Compare commits
25 Commits
wzp
...
50636d0e06
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50636d0e06 | ||
|
|
e6e37d3b0e | ||
|
|
1187f20fb1 | ||
|
|
1bbbe50bd9 | ||
|
|
bb897ccc94 | ||
|
|
f73103a2a6 | ||
|
|
aa440d6bb5 | ||
|
|
a3b04485dd | ||
|
|
9f4a5c7cda | ||
|
|
2b59f68fd7 | ||
|
|
0d2389e49a | ||
|
|
b99b2a0a88 | ||
|
|
eda0bc2200 | ||
|
|
863fdafcc5 | ||
|
|
d856fb623a | ||
|
|
1601bdd82c | ||
|
|
9ab6253c81 | ||
|
|
ce8210fb3f | ||
|
|
6b88c3805f | ||
|
|
b014fffacf | ||
|
|
43c2dc0d8d | ||
|
|
f1e64ef864 | ||
|
|
4e1bb0f82a | ||
|
|
7a2efa3fea | ||
|
|
dd9f6b77a4 |
240
.claude/skills/auto-dev/SKILL.md
Normal 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 配置,降级为手动提供设计参数
|
||||
77
.claude/skills/karpathy-guidelines/SKILL.md
Normal 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.
|
||||
382
.claude/skills/vue-project-structure/SKILL.md
Normal 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
@@ -11,13 +11,14 @@ node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
docs
|
||||
.claude
|
||||
read_prototype.js
|
||||
RTK.md
|
||||
.playwright-mcp
|
||||
.omo
|
||||
.opencode
|
||||
.workbuddy
|
||||
specs2
|
||||
.shots
|
||||
docs
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
|
||||
31
.mock/afterSales.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "ok",
|
||||
"data": {
|
||||
"id": "1",
|
||||
"projectNo": "20260813ZK2Q5f45",
|
||||
"projectName": "社区康养智能服务系统",
|
||||
"businessType": "互联网软件开发",
|
||||
"status": "IN_PROGRESS",
|
||||
"createTime": "2026-8-17 14:00",
|
||||
"submitUserName": "张三",
|
||||
"afterSale": {
|
||||
"id": "1",
|
||||
"requirementId": "1",
|
||||
"type": "BUG问题",
|
||||
"reason": "具体请看清单",
|
||||
"remark": null,
|
||||
"status": "IN_PROGRESS",
|
||||
"requestUserName": "张三",
|
||||
"requestTime": "2026-8-17 17:00",
|
||||
"processUserName": null,
|
||||
"processNote": null,
|
||||
"processTime": null,
|
||||
"withdrawReason": null
|
||||
},
|
||||
"afterSaleAttachments": [
|
||||
{ "id": "1", "requirementId": "1", "phase": "AFTER_SALE", "ossId": "11", "originalName": "项目需求规格说明书.pdf", "url": "https://example.com/a.pdf", "fileSize": 2516582, "sortOrder": 1, "createTime": "2026-8-17 17:00" },
|
||||
{ "id": "2", "requirementId": "1", "phase": "AFTER_SALE", "ossId": "12", "originalName": "项目需求说明书.word", "url": "https://example.com/b.docx", "fileSize": 2516582, "sortOrder": 2, "createTime": "2026-8-17 17:00" }
|
||||
]
|
||||
}
|
||||
}
|
||||
102
.mock/bills.json
Normal file
@@ -0,0 +1,102 @@
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "ok",
|
||||
"data": [
|
||||
{
|
||||
"id": "1001",
|
||||
"billNo": "20260813Z2Z5f245",
|
||||
"projectId": "1",
|
||||
"projectName": "某大型银行数字化转型方案",
|
||||
"userId": "1",
|
||||
"amount": 20000,
|
||||
"billType": "ADVANCE",
|
||||
"periodName": "第一期 · 付款 比例33%",
|
||||
"periodRatio": 33,
|
||||
"status": "PAID",
|
||||
"payChannel": "WECHAT",
|
||||
"transactionNo": "T2026081915303578K2M001",
|
||||
"payTime": "2026-8-17 17:00",
|
||||
"createTime": "2026-8-17 14:00"
|
||||
},
|
||||
{
|
||||
"id": "1002",
|
||||
"billNo": "20260813Z2Z5f245",
|
||||
"projectId": "2",
|
||||
"projectName": "智慧城市大数据平台建设项目",
|
||||
"userId": "1",
|
||||
"amount": 20000,
|
||||
"billType": "PROGRESS",
|
||||
"periodName": "第二期 · 付款 比例33%",
|
||||
"periodRatio": 33,
|
||||
"status": "PENDING",
|
||||
"payChannel": null,
|
||||
"transactionNo": null,
|
||||
"payTime": null,
|
||||
"createTime": "2026-8-17 14:00"
|
||||
},
|
||||
{
|
||||
"id": "1003",
|
||||
"billNo": "20260813Z2Z5f245",
|
||||
"projectId": "3",
|
||||
"projectName": "三甲医院暗审医疗信息化系统",
|
||||
"userId": "1",
|
||||
"amount": 20000,
|
||||
"billType": "FINAL",
|
||||
"periodName": "第三期 · 尾款 比例33%",
|
||||
"periodRatio": 33,
|
||||
"status": "UNSTARTED",
|
||||
"payChannel": null,
|
||||
"transactionNo": null,
|
||||
"payTime": null,
|
||||
"createTime": "2026-8-17 14:00"
|
||||
},
|
||||
{
|
||||
"id": "1004",
|
||||
"billNo": "20260813Z2Z5f246",
|
||||
"projectId": "4",
|
||||
"projectName": "社区康养智能服务系统",
|
||||
"userId": "1",
|
||||
"amount": 20000,
|
||||
"billType": "ADVANCE",
|
||||
"periodName": "第一期 · 预付款 比例30%",
|
||||
"periodRatio": 30,
|
||||
"status": "PAID",
|
||||
"payChannel": "ALIPAY",
|
||||
"transactionNo": "T2026081915303578K2M002",
|
||||
"payTime": "2026-8-18 10:20",
|
||||
"createTime": "2026-8-17 14:00"
|
||||
},
|
||||
{
|
||||
"id": "1005",
|
||||
"billNo": "20260813Z2Z5f247",
|
||||
"projectId": "5",
|
||||
"projectName": "智慧园区物联网平台",
|
||||
"userId": "1",
|
||||
"amount": 20000,
|
||||
"billType": "PROGRESS",
|
||||
"periodName": "第二期 · 进度款 比例35%",
|
||||
"periodRatio": 35,
|
||||
"status": "PENDING",
|
||||
"payChannel": null,
|
||||
"transactionNo": null,
|
||||
"payTime": null,
|
||||
"createTime": "2026-8-17 14:00"
|
||||
},
|
||||
{
|
||||
"id": "1006",
|
||||
"billNo": "20260813Z2Z5f248",
|
||||
"projectId": "6",
|
||||
"projectName": "某大型银行数字化转型方案",
|
||||
"userId": "1",
|
||||
"amount": 20000,
|
||||
"billType": "ADVANCE",
|
||||
"periodName": "第一期 · 预付款 比例33%",
|
||||
"periodRatio": 33,
|
||||
"status": "UNSTARTED",
|
||||
"payChannel": null,
|
||||
"transactionNo": null,
|
||||
"payTime": null,
|
||||
"createTime": "2026-8-17 14:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
19
.mock/files.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "ok",
|
||||
"data": {
|
||||
"id": "1",
|
||||
"projectNo": "20260813ZK2Q5f45",
|
||||
"projectName": "社区康养智能服务系统",
|
||||
"businessType": "互联网软件开发",
|
||||
"status": "IN_PROGRESS",
|
||||
"createTime": "2026-8-17 14:00",
|
||||
"submitUserName": "张三",
|
||||
"requirementAttachments": [
|
||||
{ "id": "1", "requirementId": "1", "phase": "REQUIREMENT", "ossId": "1", "originalName": "项目需求规格说明书.pdf", "url": "https://example.com/a.pdf", "fileSize": 2516582, "sortOrder": 1, "createTime": "2026-8-17 17:00" },
|
||||
{ "id": "2", "requirementId": "1", "phase": "REQUIREMENT", "ossId": "2", "originalName": "项目需求说明书.word", "url": "https://example.com/b.docx", "fileSize": 2516582, "sortOrder": 2, "createTime": "2026-8-17 17:00" },
|
||||
{ "id": "3", "requirementId": "1", "phase": "REQUIREMENT", "ossId": "3", "originalName": "项目需求规格说明书.pdf", "url": "https://example.com/c.pdf", "fileSize": 2516582, "sortOrder": 3, "createTime": "2026-8-17 17:00" },
|
||||
{ "id": "4", "requirementId": "1", "phase": "REQUIREMENT", "ossId": "4", "originalName": "项目需求说明书.word", "url": "https://example.com/d.docx", "fileSize": 2516582, "sortOrder": 4, "createTime": "2026-8-17 17:00" }
|
||||
]
|
||||
}
|
||||
}
|
||||
27
.mock/finance.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "ok",
|
||||
"data": {
|
||||
"id": "1",
|
||||
"projectNo": "20260813ZK2Q5f45",
|
||||
"projectName": "社区康养智能服务系统",
|
||||
"businessType": "互联网软件开发",
|
||||
"status": "IN_PROGRESS",
|
||||
"createTime": "2026-8-17 14:00",
|
||||
"submitUserName": "张三",
|
||||
"bills": [
|
||||
{ "id": "1001", "billNo": "2026083222P345", "projectId": "1", "projectNo": "20260813ZK2Q5f45", "projectName": "社区康养智能服务系统", "userId": "1", "amount": "20100.00", "contractId": null, "billType": "ADVANCE", "periodName": "第一期", "periodRatio": "33.00", "status": "PAID", "payChannel": "WECHAT", "transactionNo": "123D06891B30030/8K2M001", "tradeName": null, "payTime": "2026-8-17 17:00", "dueDate": null },
|
||||
{ "id": "1002", "billNo": "2026083222P345", "projectId": "1", "projectNo": "20260813ZK2Q5f45", "projectName": "社区康养智能服务系统", "userId": "1", "amount": "20100.00", "contractId": null, "billType": "PROGRESS", "periodName": "第二期", "periodRatio": "33.00", "status": "PENDING", "payChannel": null, "transactionNo": null, "tradeName": null, "payTime": null, "dueDate": null },
|
||||
{ "id": "1003", "billNo": "2026083222P345", "projectId": "1", "projectNo": "20260813ZK2Q5f45", "projectName": "社区康养智能服务系统", "userId": "1", "amount": "20100.00", "contractId": null, "billType": "FINAL", "periodName": "第三期", "periodRatio": "33.00", "status": "UNSTARTED", "payChannel": null, "transactionNo": null, "tradeName": null, "payTime": null, "dueDate": null }
|
||||
],
|
||||
"billPlans": [],
|
||||
"invoices": [
|
||||
{ "id": "1", "billId": "1001", "invoiceTitle": "测试", "taxNo": null, "invoiceType": "电子普通发票", "amount": "20100.00", "status": "ISSUED", "invoiceNo": null, "invoiceFile": "https://example.com/inv.pdf", "applyTime": "2026-8-17 17:00", "invoiceDate": null, "createTime": "2026-8-17 17:00" },
|
||||
{ "id": "2", "billId": "1001", "invoiceTitle": "测试", "taxNo": null, "invoiceType": "电子普通发票", "amount": "20100.00", "status": "ISSUED", "invoiceNo": null, "invoiceFile": "https://example.com/inv2.pdf", "applyTime": "2026-8-17 17:00", "invoiceDate": null, "createTime": "2026-8-17 17:00" }
|
||||
],
|
||||
"contracts": [
|
||||
{ "id": "1", "contractNo": "HT20260813001", "projectId": "1", "title": "项目需求规格说明书.pdf", "amount": "60300.00", "signStatus": "SIGNED", "status": "SIGNED", "signDate": "2026-8-17", "file": "https://example.com/contract.pdf", "createTime": "2026-8-17 17:00" },
|
||||
{ "id": "2", "contractNo": "HT20260813002", "projectId": "1", "title": "项目需求说明书.word", "amount": "60300.00", "signStatus": "SIGNED", "status": "SIGNED", "signDate": "2026-8-17", "file": "https://example.com/contract2.docx", "createTime": "2026-8-17 17:00" }
|
||||
]
|
||||
}
|
||||
}
|
||||
42
.mock/progress.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "ok",
|
||||
"data": {
|
||||
"id": "1",
|
||||
"projectNo": "20260813ZK2Q5f45",
|
||||
"projectName": "社区康养智能服务系统",
|
||||
"businessType": "互联网软件开发",
|
||||
"status": "IN_PROGRESS",
|
||||
"createTime": "2026-8-17 14:00",
|
||||
"submitUserName": "张三",
|
||||
"projectStage": {
|
||||
"id": "1",
|
||||
"projectId": "1",
|
||||
"requirementId": "1",
|
||||
"stageName": "开发实现",
|
||||
"status": "IN_PROGRESS",
|
||||
"startTime": "2026-8-17",
|
||||
"planTime": "2026-8-17",
|
||||
"finishTime": null,
|
||||
"developmentProgress": 65,
|
||||
"sort": 3
|
||||
},
|
||||
"developmentProgress": 65,
|
||||
"projectMilestones": [
|
||||
{ "id": "1", "projectId": "1", "milestoneName": "需求认证", "sortOrder": 1, "status": "COMPLETED", "startTime": "2026-8-13 11:04:11", "endTime": "2026-8-13 11:04:11", "parallelFlag": false },
|
||||
{ "id": "2", "projectId": "1", "milestoneName": "原型设计", "sortOrder": 2, "status": "COMPLETED", "startTime": "2026-8-13 11:04:11", "endTime": "2026-8-13 11:04:11", "parallelFlag": false },
|
||||
{ "id": "3", "projectId": "1", "milestoneName": "开发实现", "sortOrder": 3, "status": "IN_PROGRESS", "startTime": null, "endTime": null, "parallelFlag": false },
|
||||
{ "id": "4", "projectId": "1", "milestoneName": "需求验收", "sortOrder": 4, "status": "UNSTARTED", "startTime": null, "endTime": null, "parallelFlag": false },
|
||||
{ "id": "5", "projectId": "1", "milestoneName": "项目交付", "sortOrder": 5, "status": "UNSTARTED", "startTime": null, "endTime": null, "parallelFlag": false }
|
||||
],
|
||||
"members": [
|
||||
{ "id": "1", "projectId": "1", "userId": "1", "userName": "张三", "roleKey": "pm", "roleName": "项目经理", "isOwner": true },
|
||||
{ "id": "2", "projectId": "1", "userId": "2", "userName": "张三", "roleKey": "pm", "roleName": "项目经理", "isOwner": false },
|
||||
{ "id": "3", "projectId": "1", "userId": "3", "userName": "张三", "roleKey": "pm", "roleName": "项目经理", "isOwner": false },
|
||||
{ "id": "4", "projectId": "1", "userId": "4", "userName": "张三", "roleKey": "pm", "roleName": "项目经理", "isOwner": false },
|
||||
{ "id": "5", "projectId": "1", "userId": "5", "userName": "张三", "roleKey": "pm", "roleName": "项目经理", "isOwner": false },
|
||||
{ "id": "6", "projectId": "1", "userId": "6", "userName": "张三", "roleKey": "pm", "roleName": "项目经理", "isOwner": false },
|
||||
{ "id": "7", "projectId": "1", "userId": "7", "userName": "张三", "roleKey": "pm", "roleName": "项目经理", "isOwner": false }
|
||||
]
|
||||
}
|
||||
}
|
||||
65
AGENTS.md
Normal 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 |
|
||||
@@ -17,6 +17,7 @@
|
||||
"axios": "^1.20.0",
|
||||
"element-plus": "^2.14.5",
|
||||
"pinia": "^4.0.3",
|
||||
"qrcode-generator": "^2.0.4",
|
||||
"vue": "^3.5.42",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
|
||||
8
pnpm-lock.yaml
generated
@@ -17,6 +17,9 @@ importers:
|
||||
pinia:
|
||||
specifier: ^4.0.3
|
||||
version: 4.0.3(@vue/devtools-api@6.6.4)(typescript@6.0.3)(vue@3.5.42(typescript@6.0.3))
|
||||
qrcode-generator:
|
||||
specifier: ^2.0.4
|
||||
version: 2.0.4
|
||||
vue:
|
||||
specifier: ^3.5.42
|
||||
version: 3.5.42(typescript@6.0.3)
|
||||
@@ -642,6 +645,9 @@ packages:
|
||||
prr@1.0.1:
|
||||
resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==}
|
||||
|
||||
qrcode-generator@2.0.4:
|
||||
resolution: {integrity: sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==}
|
||||
|
||||
rolldown@1.2.8:
|
||||
resolution: {integrity: sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -1282,6 +1288,8 @@ snapshots:
|
||||
prr@1.0.1:
|
||||
optional: true
|
||||
|
||||
qrcode-generator@2.0.4: {}
|
||||
|
||||
rolldown@1.2.8:
|
||||
dependencies:
|
||||
'@oxc-project/types': 0.149.0
|
||||
|
||||
@@ -1,44 +1,51 @@
|
||||
import { request, CLIENT_ID } from '@/utils/request'
|
||||
import type { LoginResult as AuthLoginResult, RegisterResult } from '@/types/business/auth'
|
||||
import type {
|
||||
LoginByAccountParams,
|
||||
LoginBySmsParams,
|
||||
LoginResult,
|
||||
RegisterParams,
|
||||
ResetPasswordParams,
|
||||
User,
|
||||
} from '@/types/business/user'
|
||||
import { isPhone, isValidPassword } from '@/utils/validate'
|
||||
|
||||
// TODO(api): 待接口文档到位后,将以下 mock 替换为 request<T> 调用(页面 → store → api 数据流不变)
|
||||
export const PC_CLIENT_ID = CLIENT_ID
|
||||
|
||||
export function loginByAccountApi(data: { username: string; password: string }): Promise<AuthLoginResult> {
|
||||
return request<AuthLoginResult>({
|
||||
url: '/auth/login',
|
||||
method: 'post',
|
||||
data: {
|
||||
username: data.username,
|
||||
password: data.password,
|
||||
clientId: PC_CLIENT_ID,
|
||||
grantType: 'password',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function registerByAccountApi(data: { username: string; password: string }): Promise<RegisterResult> {
|
||||
return request<RegisterResult>({
|
||||
url: '/auth/register',
|
||||
method: 'post',
|
||||
data: {
|
||||
username: data.username,
|
||||
password: data.password,
|
||||
userType: 'sys_user',
|
||||
clientId: PC_CLIENT_ID,
|
||||
grantType: 'password',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// TODO(api): 以下为原 mock 逻辑(保留,待后端对应接口就绪后替换)
|
||||
|
||||
const SIX_DIGIT_CODE = /^\d{6}$/
|
||||
|
||||
/** 发送短信验证码 */
|
||||
export function sendSmsCode(phone: string): Promise<void> {
|
||||
if (!isPhone(phone)) return Promise.reject(new Error('手机号格式不正确'))
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
/** 账号密码登录(mock:demo 账号可登录,其余提示先注册) */
|
||||
export function loginByAccount(params: LoginByAccountParams): Promise<LoginResult> {
|
||||
const { account, password } = params
|
||||
if (!account.trim() || !password) {
|
||||
return Promise.reject(new Error('请输入账号和密码'))
|
||||
}
|
||||
if (account === 'demo') {
|
||||
const user: User = {
|
||||
id: 'u1',
|
||||
nickname: '王永志',
|
||||
avatar: '',
|
||||
phone: '186****618',
|
||||
email: 'yongzh******4@163.com',
|
||||
passwordModifiedAt: '',
|
||||
}
|
||||
return Promise.resolve({ token: `mock-token-${Date.now()}`, user })
|
||||
}
|
||||
return Promise.reject(new Error('登录失败,请先注册账号'))
|
||||
}
|
||||
|
||||
/** 手机验证码登录(mock:验证码须为 6 位数字) */
|
||||
export function loginBySms(params: LoginBySmsParams): Promise<LoginResult> {
|
||||
const { phone, code } = params
|
||||
if (!isPhone(phone)) return Promise.reject(new Error('手机号格式不正确'))
|
||||
@@ -69,20 +76,6 @@ function validateAuthForm(
|
||||
return null
|
||||
}
|
||||
|
||||
/** 注册 */
|
||||
export function register(params: RegisterParams): Promise<void> {
|
||||
const message = validateAuthForm(
|
||||
params.phone,
|
||||
params.code,
|
||||
params.password,
|
||||
params.confirmPassword,
|
||||
params.agree,
|
||||
)
|
||||
if (message) return Promise.reject(new Error(message))
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
/** 重置密码 */
|
||||
export function resetPassword(params: ResetPasswordParams): Promise<void> {
|
||||
const message = validateAuthForm(
|
||||
params.phone,
|
||||
|
||||
252
src/api/cases.ts
@@ -1,227 +1,51 @@
|
||||
import type { BusinessType, PageResult } from '@/types/api'
|
||||
import type { Case, CaseQuery, ScaleRange } from '@/types/business/cases'
|
||||
//
|
||||
import { request, serializeQuery } from '@/utils/request'
|
||||
import type {
|
||||
CaseSelectionModel,
|
||||
ProjectCaseAndCaseCountVo,
|
||||
ProjectCaseUserBo,
|
||||
ProjectCaseVo,
|
||||
} from '@/types/business/cases'
|
||||
|
||||
// TODO(api): 待接口文档到位后,将以下 mock 替换为 request<T> 调用
|
||||
|
||||
/** 案例 mock(可变,收藏状态在前端内存中翻转演示) */
|
||||
const MOCK_CASES: Case[] = [
|
||||
{
|
||||
id: 'case-001',
|
||||
title: '自动化生产线升级改造项目',
|
||||
category: 'manufacturing',
|
||||
cover: '',
|
||||
finishDate: '2026/8/10',
|
||||
teamSize: 50,
|
||||
scale: '300万',
|
||||
industry: '自动化',
|
||||
duration: '150个工作日',
|
||||
clientType: '企业',
|
||||
background: '传统产线效率低、人工依赖高,客户需要通过自动化升级提升产能与品控水平。',
|
||||
painPoints: '1、产线依赖人工巡检,异常响应慢;2、设备数据孤岛,缺乏统一监控;3、工艺参数无法追溯。',
|
||||
requirements: '建设产线数据采集与监控平台,完成关键工位自动化改造,打通 MES 系统接口。',
|
||||
deliverables: '1、产线自动化改造方案;2、PLC 控制程序与上位机软件;3、数据看板与运维手册;4、培训与质保服务。',
|
||||
video: '',
|
||||
isFavorite: false,
|
||||
},
|
||||
{
|
||||
id: 'case-002',
|
||||
title: '智能工厂物联网整体解决方案',
|
||||
category: 'iot',
|
||||
cover: '',
|
||||
finishDate: '2026/8/10',
|
||||
teamSize: 30,
|
||||
scale: '500万',
|
||||
industry: '工厂服务',
|
||||
duration: '200个工作日',
|
||||
clientType: '企业',
|
||||
background: '园区工厂多系统独立运行,能耗与设备管理粗放,需要整体数字化升级。',
|
||||
painPoints: '1、门禁/监控/能耗子系统数据不互通;2、能源浪费缺乏精细监测;3、决策缺少实时数据支撑。',
|
||||
requirements: '构建 IoT 平台接入设备数据,建设综合驾驶舱,实现能耗监测与智能告警。',
|
||||
deliverables: '1、IoT 接入平台;2、综合驾驶舱大屏;3、能耗分析系统;4、集成联调与培训服务。',
|
||||
video: '',
|
||||
isFavorite: false,
|
||||
},
|
||||
{
|
||||
id: 'case-003',
|
||||
title: '智能穿戴设备PCB设计与量产',
|
||||
category: 'hardware',
|
||||
cover: '',
|
||||
finishDate: '2026/8/10',
|
||||
teamSize: 50,
|
||||
scale: '10-50万',
|
||||
industry: '智能设备',
|
||||
duration: '30个工作日',
|
||||
clientType: '企业',
|
||||
background: '客户推出新一代智能穿戴产品,需要高集成度低功耗 PCB 设计并快速量产。',
|
||||
painPoints: '1、板载空间受限,器件布局密集;2、功耗要求严格;3、量产良率需爬坡。',
|
||||
requirements: '完成原理图与 PCB 设计、打样验证、SMT 贴片与整机装配量产。',
|
||||
deliverables: '1、原理图与 PCB 设计文件;2、样板与测试报告;3、量产良率报告;4、BOM 与工艺文件。',
|
||||
video: '',
|
||||
isFavorite: false,
|
||||
},
|
||||
{
|
||||
id: 'case-004',
|
||||
title: '智能园区综合管理平台开发',
|
||||
category: 'internet',
|
||||
cover: '',
|
||||
finishDate: '2026/8/10',
|
||||
teamSize: 20,
|
||||
scale: '200万',
|
||||
industry: '园区服务',
|
||||
duration: '200个工作日',
|
||||
clientType: '企业',
|
||||
background: '传统园区管理普遍"数据孤岛",安防、能耗、物业、招商等系统独立运行,缺乏联动,管理手段依赖人工巡检和纸质流程。',
|
||||
painPoints: '1、运营效率低下:安保依赖人工巡逻;2、系统孤岛严重:门禁、监控、停车、能耗等子系统独立建设;3、能源浪费突出;4、服务体验欠佳;5、决策缺乏依据;6、安全隐患犹存。',
|
||||
requirements: '打造集"感知、预警、分析、决策、服务"于一体的智能园区综合管理平台,打通园区各子系统壁垒,实现全场景数字化覆盖。',
|
||||
deliverables: '1、Web 端管理平台(驾驶舱/安防/能源/物业);2、移动端服务门户;3、IoT 平台与数据大屏;4、全套技术文档;5、培训与不少于1年免费质保。',
|
||||
video: '',
|
||||
isFavorite: false,
|
||||
},
|
||||
{
|
||||
id: 'case-005',
|
||||
title: '电商中台系统定制开发',
|
||||
category: 'internet',
|
||||
cover: '',
|
||||
finishDate: '2026/7/20',
|
||||
teamSize: 15,
|
||||
scale: '150万',
|
||||
industry: '电商零售',
|
||||
duration: '120个工作日',
|
||||
clientType: '企业',
|
||||
background: '品牌方多渠道订单分散,库存与会员数据不通,需要统一电商中台。',
|
||||
painPoints: '1、多平台库存不同步;2、会员体系割裂;3、营销活动上线周期长。',
|
||||
requirements: '建设商品/库存/订单/会员中台,开放 API 对接主流电商平台。',
|
||||
deliverables: '1、中台系统与管理后台;2、开放 API 与对接文档;3、数据迁移方案;4、上线陪跑服务。',
|
||||
video: '',
|
||||
isFavorite: false,
|
||||
},
|
||||
{
|
||||
id: 'case-006',
|
||||
title: '教培机构在线学习平台',
|
||||
category: 'internet',
|
||||
cover: '',
|
||||
finishDate: '2026/6/30',
|
||||
teamSize: 12,
|
||||
scale: '80万',
|
||||
industry: '教育培训',
|
||||
duration: '90个工作日',
|
||||
clientType: '企业',
|
||||
background: '教培机构需要线上授课、作业批改与学员管理一体化能力。',
|
||||
painPoints: '1、授课依赖第三方工具,数据不沉淀;2、续费转化缺少数据支撑。',
|
||||
requirements: '交付直播授课、题库作业、班级管理、数据分析完整平台(Web + 小程序)。',
|
||||
deliverables: '1、Web 学习平台;2、学员端小程序;3、题库与直播模块;4、运维部署手册。',
|
||||
video: '',
|
||||
isFavorite: false,
|
||||
},
|
||||
{
|
||||
id: 'case-007',
|
||||
title: '医院智能随访系统开发',
|
||||
category: 'internet',
|
||||
cover: '',
|
||||
finishDate: '2026/5/18',
|
||||
teamSize: 18,
|
||||
scale: '250万',
|
||||
industry: '医疗健康',
|
||||
duration: '180个工作日',
|
||||
clientType: '企业',
|
||||
background: '三甲医院需要对出院患者进行结构化随访,提升复诊率与科研数据质量。',
|
||||
painPoints: '1、电话随访效率低;2、随访数据非结构化,科研难复用。',
|
||||
requirements: '建设随访计划引擎、患者端触达与随访数据仓,对接 HIS 系统。',
|
||||
deliverables: '1、随访管理平台;2、患者端小程序;3、HIS 对接中间件;4、随访报表与数据字典。',
|
||||
video: '',
|
||||
isFavorite: false,
|
||||
},
|
||||
{
|
||||
id: 'case-008',
|
||||
title: '园区光伏能耗管理系统',
|
||||
category: 'iot',
|
||||
cover: '',
|
||||
finishDate: '2026/4/25',
|
||||
teamSize: 10,
|
||||
scale: '120万',
|
||||
industry: '能源环保',
|
||||
duration: '60个工作日',
|
||||
clientType: '企业',
|
||||
background: '园区部署屋顶光伏后需要实时监控发电与用电,实现削峰填谷。',
|
||||
painPoints: '1、发电数据分散在多品牌逆变器;2、能耗缺少分项计量。',
|
||||
requirements: '接入逆变器/电表数据,建设发电与能耗看板,输出节能策略。',
|
||||
deliverables: '1、能耗采集网关适配;2、监控看板系统;3、告警与报表;4、验收测试报告。',
|
||||
video: '',
|
||||
isFavorite: false,
|
||||
},
|
||||
]
|
||||
|
||||
const SCALE_BOUNDS: Record<ScaleRange, [number, number]> = {
|
||||
lt50w: [0, 50],
|
||||
'50to100w': [50, 100],
|
||||
'100to300w': [100, 300],
|
||||
'300to500w': [300, 500],
|
||||
gt500w: [500, Number.POSITIVE_INFINITY],
|
||||
}
|
||||
|
||||
/** 从展示值解析规模数值(万),如 '300万' → 300,'10-50万' → 10 */
|
||||
function parseScaleValue(scale: string): number {
|
||||
const value = Number.parseFloat(scale)
|
||||
return Number.isNaN(value) ? 0 : value
|
||||
}
|
||||
|
||||
function matchScale(item: Case, selected: ScaleRange[]): boolean {
|
||||
if (selected.length === 0) return true
|
||||
const value = parseScaleValue(item.scale)
|
||||
return selected.some((key) => {
|
||||
const [min, max] = SCALE_BOUNDS[key]
|
||||
return value >= min && value < max
|
||||
export function getCaseList(
|
||||
bo: ProjectCaseUserBo,
|
||||
pageNum: number,
|
||||
pageSize: number,
|
||||
): Promise<ProjectCaseAndCaseCountVo> {
|
||||
return request<ProjectCaseAndCaseCountVo>({
|
||||
url: '/web/projectCase/list',
|
||||
method: 'get',
|
||||
params: { ...bo, pageNum, pageSize },
|
||||
paramsSerializer: { serialize: serializeQuery },
|
||||
})
|
||||
}
|
||||
|
||||
function matchDate(item: Case, range: [string, string] | null): boolean {
|
||||
if (!range) return true
|
||||
const time = new Date(item.finishDate).getTime()
|
||||
const start = new Date(range[0]).getTime()
|
||||
const end = new Date(range[1]).getTime()
|
||||
return time >= start && time <= end
|
||||
}
|
||||
|
||||
function applyFilters(query: CaseQuery): Case[] {
|
||||
return MOCK_CASES.filter((item) => {
|
||||
if (query.category !== 'all' && item.category !== query.category) return false
|
||||
if (query.industries.length > 0 && !query.industries.includes(item.industry)) return false
|
||||
if (!matchScale(item, query.scales)) return false
|
||||
if (!matchDate(item, query.dateRange)) return false
|
||||
if (query.keyword && !item.title.includes(query.keyword)) return false
|
||||
return true
|
||||
export function getCaseDetail(id: string): Promise<ProjectCaseVo | null> {
|
||||
return request<ProjectCaseVo | null>({
|
||||
url: `/web/projectCase/${id}`,
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
/** 案例列表(筛选 + 分页) */
|
||||
export function getCaseList(query: CaseQuery): Promise<PageResult<Case>> {
|
||||
const filtered = applyFilters(query)
|
||||
const start = (query.page - 1) * query.pageSize
|
||||
return Promise.resolve({
|
||||
list: filtered.slice(start, start + query.pageSize),
|
||||
total: filtered.length,
|
||||
export function collectCase(id: string): Promise<void> {
|
||||
return request<void>({
|
||||
url: '/web/projectCase/collect',
|
||||
method: 'post',
|
||||
data: { id },
|
||||
})
|
||||
}
|
||||
|
||||
/** 案例详情(不存在返回 null) */
|
||||
export function getCaseDetail(id: string): Promise<Case | null> {
|
||||
const found = MOCK_CASES.find((item) => item.id === id) ?? null
|
||||
return Promise.resolve(found)
|
||||
export function getCaseCategories(): Promise<CaseSelectionModel> {
|
||||
return request<CaseSelectionModel>({
|
||||
url: '/web/projectCase/categories',
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
/** 收藏/取消收藏,返回最新案例数据 */
|
||||
export function toggleFavorite(id: string): Promise<Case> {
|
||||
const found = MOCK_CASES.find((item) => item.id === id)
|
||||
if (!found) return Promise.reject(new Error('案例不存在'))
|
||||
found.isFavorite = !found.isFavorite
|
||||
return Promise.resolve({ ...found })
|
||||
}
|
||||
|
||||
/** 业务类型计数(筛选面板角标) */
|
||||
export function getCaseCounts(): Promise<Partial<Record<BusinessType, number>>> {
|
||||
const counts: Partial<Record<BusinessType, number>> = {}
|
||||
for (const item of MOCK_CASES) {
|
||||
counts[item.category] = (counts[item.category] ?? 0) + 1
|
||||
}
|
||||
return Promise.resolve(counts)
|
||||
export function getFeaturedCases(type?: string): Promise<ProjectCaseVo[]> {
|
||||
return request<ProjectCaseVo[]>({
|
||||
url: '/web/projectCase/mostViewed',
|
||||
method: 'get',
|
||||
params: type ? { type } : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,15 +3,12 @@ import type { Contract, ContractQuery } from '@/types/business/contract'
|
||||
|
||||
// TODO(api): 待接口文档到位后,将以下 mock 替换为 request<T> 调用
|
||||
|
||||
/** 构造 mock 文件信息 */
|
||||
function mockFile(name: string, size: number, uploadTime: string): FileInfo {
|
||||
return { name, size, uploadTime, url: '' }
|
||||
}
|
||||
|
||||
/** 详情 mock 所属合同 id */
|
||||
const DETAIL_ID = 'contract-001'
|
||||
|
||||
/** 合同列表 mock(3 条,覆盖全部签署状态) */
|
||||
const MOCK_CONTRACTS: Contract[] = [
|
||||
{
|
||||
id: 'contract-001',
|
||||
@@ -56,7 +53,6 @@ const MOCK_CONTRACTS: Contract[] = [
|
||||
},
|
||||
]
|
||||
|
||||
/** 合同详情 mock */
|
||||
const CONTRACT_DETAIL: Contract = {
|
||||
id: DETAIL_ID,
|
||||
contractNo: 'C8K2M202408001',
|
||||
@@ -72,7 +68,6 @@ const CONTRACT_DETAIL: Contract = {
|
||||
files: [mockFile('项目需求规格说明书.pdf', 2516582, '2024-10-15')],
|
||||
}
|
||||
|
||||
/** 合同列表(keyword 筛选 + 分页) */
|
||||
export function getContractList(query: ContractQuery): Promise<PageResult<Contract>> {
|
||||
const filtered = MOCK_CONTRACTS.filter((item) => {
|
||||
if (
|
||||
@@ -92,19 +87,16 @@ export function getContractList(query: ContractQuery): Promise<PageResult<Contra
|
||||
})
|
||||
}
|
||||
|
||||
/** 合同详情(不存在返回 null) */
|
||||
export function getContractDetail(id: string): Promise<Contract | null> {
|
||||
if (id === DETAIL_ID) return Promise.resolve({ ...CONTRACT_DETAIL, files: [...CONTRACT_DETAIL.files] })
|
||||
const found = MOCK_CONTRACTS.find((item) => item.id === id) ?? null
|
||||
return Promise.resolve(found ? { ...found, files: [...found.files] } : null)
|
||||
}
|
||||
|
||||
/** 获取签署二维码(返回有效期) */
|
||||
export function getSignQr(_id: string): Promise<{ expiresInSeconds: number }> {
|
||||
return Promise.resolve({ expiresInSeconds: 272 })
|
||||
}
|
||||
|
||||
/** 刷新签署二维码(返回有效期) */
|
||||
export function refreshSignQr(_id: string): Promise<{ expiresInSeconds: number }> {
|
||||
return Promise.resolve({ expiresInSeconds: 272 })
|
||||
}
|
||||
|
||||
@@ -1,25 +1,101 @@
|
||||
import type { Demand } from '@/types/business/demand'
|
||||
import { storage } from '@/utils/storage'
|
||||
//
|
||||
import service, { request } from '@/utils/request'
|
||||
import type { ApiResponse } from '@/types/api'
|
||||
import type {
|
||||
Demand,
|
||||
DemandDraftVo,
|
||||
DictOption,
|
||||
OssUploadVo,
|
||||
OssUrlVo,
|
||||
SubmissionBizTypeVo,
|
||||
} from '@/types/business/demand'
|
||||
|
||||
// TODO(api): 待接口文档到位后,将以下 mock 替换为 request<T> 调用(页面 → store → api 数据流不变)
|
||||
export const BIZ_TYPE_DICT = 'vm_biz_type'
|
||||
export const BUDGET_RANGE_DICT = 'vm_budget_range'
|
||||
|
||||
const DRAFT_KEY = 'demand-draft'
|
||||
export function getSubmissionBizTypes(): Promise<SubmissionBizTypeVo[]> {
|
||||
return request<SubmissionBizTypeVo[]>({
|
||||
url: '/web/projectSubmission/list',
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
/** 提交需求(mock:返回提报单 id) */
|
||||
export function submitDemand(demand: Demand): Promise<{ id: string }> {
|
||||
if (!demand.businessType || !demand.projectName || !demand.coreDescription) {
|
||||
return Promise.reject(new Error('需求信息不完整,无法提交'))
|
||||
export function getOssUrls(ids: (string | number)[]): Promise<OssUrlVo[]> {
|
||||
return request<OssUrlVo[]>({
|
||||
url: `/web/resource/oss/listByIds/${ids.join(',')}`,
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
export function getBizTypeDict(): Promise<DictOption[]> {
|
||||
return request<DictOption[]>({
|
||||
url: `/web/system/dict/data/type/${BIZ_TYPE_DICT}`,
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
export function getBudgetRangeDict(): Promise<DictOption[]> {
|
||||
return request<DictOption[]>({
|
||||
url: `/web/system/dict/data/type/${BUDGET_RANGE_DICT}`,
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
export async function getDraftDemand(): Promise<DemandDraftVo | null> {
|
||||
const resp = await service.request<ApiResponse<DemandDraftVo>>({
|
||||
url: '/web/workOrderDraft/getDraft',
|
||||
method: 'get',
|
||||
})
|
||||
if (resp.data?.code !== 200) return null
|
||||
return resp.data.data ?? null
|
||||
}
|
||||
|
||||
function toBoPayload(demand: Demand): Record<string, unknown> {
|
||||
return {
|
||||
id: demand.id,
|
||||
userName: demand.userName,
|
||||
userPhone: demand.userPhone,
|
||||
bizType: demand.bizType,
|
||||
budgetRange: demand.budgetRange,
|
||||
developmentCycle:
|
||||
demand.developmentCycle === '' ? null : demand.developmentCycle,
|
||||
supplementaryRemarks: demand.supplementaryRemarks,
|
||||
projectName: demand.projectName,
|
||||
requirementDesc: demand.requirementDesc,
|
||||
attachmentInfo: demand.attachmentInfo,
|
||||
}
|
||||
return Promise.resolve({ id: `demand-${Date.now()}` })
|
||||
}
|
||||
|
||||
/** 读取本地草稿(无草稿返回 null) */
|
||||
export function getDraftDemand(): Promise<Demand | null> {
|
||||
return Promise.resolve(storage.sessionGet<Demand>(DRAFT_KEY))
|
||||
}
|
||||
|
||||
/** 保存草稿到本地 */
|
||||
export function saveDraft(demand: Demand): Promise<void> {
|
||||
storage.sessionSet(DRAFT_KEY, { ...demand, status: 'draft' })
|
||||
return Promise.resolve()
|
||||
return request<void>({
|
||||
url: '/web/workOrderDraft/add',
|
||||
method: 'post',
|
||||
data: toBoPayload(demand),
|
||||
})
|
||||
}
|
||||
|
||||
export function resetDraft(): Promise<void> {
|
||||
return request<void>({
|
||||
url: '/web/workOrderDraft/reset',
|
||||
method: 'post',
|
||||
})
|
||||
}
|
||||
|
||||
export function uploadDemandFile(file: File): Promise<OssUploadVo> {
|
||||
const data = new FormData()
|
||||
data.append('file', file)
|
||||
return request<OssUploadVo>({
|
||||
url: '/web/workOrderDraft/upload',
|
||||
method: 'post',
|
||||
data,
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
}
|
||||
|
||||
export function submitDemand(demand: Demand): Promise<void> {
|
||||
return request<void>({
|
||||
url: '/web/requirementPool/create',
|
||||
method: 'post',
|
||||
data: toBoPayload(demand),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,19 +2,21 @@ import type { PageResult } from '@/types/api'
|
||||
import type {
|
||||
Bill,
|
||||
BillQuery,
|
||||
BillVo,
|
||||
FinanceStats,
|
||||
Invoice,
|
||||
InvoiceQuery,
|
||||
InvoiceTitle,
|
||||
InvoiceTitlePayload,
|
||||
PayMethod,
|
||||
PayOrderResponse,
|
||||
Payment,
|
||||
PaymentResult,
|
||||
} from '@/types/business/finance'
|
||||
import { toBills, toPlanBills, type PlanLike } from '@/utils/billVo'
|
||||
import { toPayMethod } from '@/utils/payMeta'
|
||||
import { request } from '@/utils/request'
|
||||
|
||||
// TODO(api): 待接口文档到位后,将以下 mock 替换为 request<T> 调用
|
||||
|
||||
/** 账单 mock(可变:支付后状态在前端内存中翻转演示) */
|
||||
const MOCK_BILLS: Bill[] = [
|
||||
{
|
||||
id: 'bill-001',
|
||||
@@ -74,7 +76,6 @@ const MOCK_BILLS: Bill[] = [
|
||||
},
|
||||
]
|
||||
|
||||
/** 发票 mock(可变:申请开票后状态在前端内存中翻转演示) */
|
||||
const MOCK_INVOICES: Invoice[] = [
|
||||
{
|
||||
id: 'invoice-001',
|
||||
@@ -107,7 +108,6 @@ const MOCK_INVOICES: Invoice[] = [
|
||||
},
|
||||
]
|
||||
|
||||
/** 发票抬头 mock(可变,CRUD 在前端内存中演示) */
|
||||
const MOCK_TITLES: InvoiceTitle[] = [
|
||||
{
|
||||
id: 'title-001',
|
||||
@@ -125,10 +125,8 @@ const MOCK_TITLES: InvoiceTitle[] = [
|
||||
},
|
||||
]
|
||||
|
||||
/** 支付进行单(billId → 当前二维码) */
|
||||
const MOCK_PAYMENTS = new Map<string, Payment>()
|
||||
|
||||
/** 已产生支付结果的账单(billId → 结果),预置已支付账单 */
|
||||
const MOCK_RESULTS = new Map<string, PaymentResult>()
|
||||
MOCK_RESULTS.set('bill-001', {
|
||||
method: 'wechat',
|
||||
@@ -157,12 +155,20 @@ const DEFAULT_STATS: FinanceStats = {
|
||||
],
|
||||
}
|
||||
|
||||
/** 财务统计(period 为展示周期参数,mock 恒返回原型数据) */
|
||||
export function getFinanceStats(period?: string): Promise<FinanceStats> {
|
||||
void period
|
||||
return Promise.resolve(structuredClone(DEFAULT_STATS))
|
||||
}
|
||||
|
||||
function isRealBillId(id: string): boolean {
|
||||
return /^\d+$/.test(id)
|
||||
}
|
||||
|
||||
async function fetchOwnBills(): Promise<Bill[]> {
|
||||
const rows = await request<BillVo[]>({ url: '/web/bill/list', method: 'get' })
|
||||
return toBills(rows)
|
||||
}
|
||||
|
||||
function matchBillKeyword(item: Bill, keyword: string): boolean {
|
||||
if (!keyword) return true
|
||||
return (
|
||||
@@ -173,26 +179,25 @@ function matchBillKeyword(item: Bill, keyword: string): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
/** 账单列表(筛选 + 分页) */
|
||||
export function getBillList(query: BillQuery): Promise<PageResult<Bill>> {
|
||||
const filtered = MOCK_BILLS.filter((item) => {
|
||||
export async function getBillList(query: BillQuery): Promise<PageResult<Bill>> {
|
||||
const all = await fetchOwnBills()
|
||||
const filtered = all.filter((item) => {
|
||||
if (query.status !== 'all' && item.status !== query.status) return false
|
||||
return matchBillKeyword(item, query.keyword)
|
||||
})
|
||||
const start = (query.page - 1) * query.pageSize
|
||||
return Promise.resolve({
|
||||
list: filtered.slice(start, start + query.pageSize).map((item) => ({ ...item })),
|
||||
total: filtered.length,
|
||||
})
|
||||
return { list: filtered.slice(start, start + query.pageSize), total: filtered.length }
|
||||
}
|
||||
|
||||
/** 账单详情(不存在返回 null) */
|
||||
export function getBillDetail(id: string): Promise<Bill | null> {
|
||||
const found = MOCK_BILLS.find((item) => item.id === id) ?? null
|
||||
return Promise.resolve(found ? { ...found } : null)
|
||||
export async function getBillDetail(id: string): Promise<Bill | null> {
|
||||
const rows = await request<BillVo[]>({ url: '/web/bill/list', method: 'get', params: { ids: id } })
|
||||
return toBills(rows)[0] ?? null
|
||||
}
|
||||
|
||||
/** 创建支付单(生成二维码支付信息) */
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// ---------------------------------------------------------------------
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
export function createPayment(billId: string, method: PayMethod): Promise<Payment> {
|
||||
const bill = MOCK_BILLS.find((item) => item.id === billId)
|
||||
if (!bill) return Promise.reject(new Error('账单不存在'))
|
||||
@@ -206,13 +211,26 @@ export function createPayment(billId: string, method: PayMethod): Promise<Paymen
|
||||
return Promise.resolve({ ...payment })
|
||||
}
|
||||
|
||||
/** 切换支付方式(刷新二维码) */
|
||||
export function switchPaymentMethod(billId: string, method: PayMethod): Promise<Payment> {
|
||||
return createPayment(billId, method)
|
||||
}
|
||||
|
||||
/** 查询支付结果(无支付记录且未支付返回 null;演示数据 payTime/tradeNo 固定) */
|
||||
export function getPayResult(billId: string): Promise<PaymentResult | null> {
|
||||
export async function getPayResult(billId: string): Promise<PaymentResult | null> {
|
||||
if (isRealBillId(billId)) {
|
||||
const bill = await getBillDetail(billId)
|
||||
if (!bill || bill.status !== '已支付') return null
|
||||
return {
|
||||
method: toPayMethod(bill.payChannel),
|
||||
amount: bill.amount,
|
||||
payTime: bill.payTime ?? '—',
|
||||
tradeNo: bill.tradeNo ?? '—',
|
||||
bills: [bill],
|
||||
}
|
||||
}
|
||||
return getLegacyPayResult(billId)
|
||||
}
|
||||
|
||||
function getLegacyPayResult(billId: string): Promise<PaymentResult | null> {
|
||||
const existing = MOCK_RESULTS.get(billId)
|
||||
if (existing) return Promise.resolve(structuredClone(existing))
|
||||
|
||||
@@ -220,7 +238,6 @@ export function getPayResult(billId: string): Promise<PaymentResult | null> {
|
||||
const bill = MOCK_BILLS.find((item) => item.id === billId)
|
||||
if (!payment || !bill) return Promise.resolve(null)
|
||||
|
||||
// 演示:存在支付单即视为完成支付(createPayment 由「完成支付」触发)
|
||||
const tradeNo = bill.tradeNo ?? 'T20260819153035678K2M001'
|
||||
if (bill.status !== '已支付') {
|
||||
bill.status = '已支付'
|
||||
@@ -247,7 +264,6 @@ function matchInvoiceKeyword(item: Invoice, keyword: string): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
/** 发票列表(筛选 + 分页) */
|
||||
export function getInvoiceList(query: InvoiceQuery): Promise<PageResult<Invoice>> {
|
||||
const filtered = MOCK_INVOICES.filter((item) => {
|
||||
if (query.status !== 'all' && item.status !== query.status) return false
|
||||
@@ -260,7 +276,6 @@ export function getInvoiceList(query: InvoiceQuery): Promise<PageResult<Invoice>
|
||||
})
|
||||
}
|
||||
|
||||
/** 申请开票(演示:状态置为开票中),返回最新发票 */
|
||||
export function applyInvoice(id: string): Promise<Invoice> {
|
||||
const found = MOCK_INVOICES.find((item) => item.id === id)
|
||||
if (!found) return Promise.reject(new Error('发票记录不存在'))
|
||||
@@ -268,12 +283,10 @@ export function applyInvoice(id: string): Promise<Invoice> {
|
||||
return Promise.resolve({ ...found })
|
||||
}
|
||||
|
||||
/** 发票抬头列表 */
|
||||
export function getInvoiceTitles(): Promise<InvoiceTitle[]> {
|
||||
return Promise.resolve(MOCK_TITLES.map((item) => ({ ...item })))
|
||||
}
|
||||
|
||||
/** 新增发票抬头 */
|
||||
export function createInvoiceTitle(payload: InvoiceTitlePayload): Promise<InvoiceTitle> {
|
||||
const created: InvoiceTitle = {
|
||||
id: `title-${Date.now()}`,
|
||||
@@ -284,7 +297,6 @@ export function createInvoiceTitle(payload: InvoiceTitlePayload): Promise<Invoic
|
||||
return Promise.resolve({ ...created })
|
||||
}
|
||||
|
||||
/** 编辑发票抬头 */
|
||||
export function updateInvoiceTitle(id: string, payload: InvoiceTitlePayload): Promise<InvoiceTitle> {
|
||||
const found = MOCK_TITLES.find((item) => item.id === id)
|
||||
if (!found) return Promise.reject(new Error('抬头不存在'))
|
||||
@@ -294,7 +306,6 @@ export function updateInvoiceTitle(id: string, payload: InvoiceTitlePayload): Pr
|
||||
return Promise.resolve({ ...found })
|
||||
}
|
||||
|
||||
/** 删除发票抬头 */
|
||||
export function deleteInvoiceTitle(id: string): Promise<void> {
|
||||
const index = MOCK_TITLES.findIndex((item) => item.id === id)
|
||||
if (index < 0) return Promise.reject(new Error('抬头不存在'))
|
||||
@@ -302,7 +313,6 @@ export function deleteInvoiceTitle(id: string): Promise<void> {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
/** 设为默认抬头(唯一默认) */
|
||||
export function setDefaultTitle(id: string): Promise<InvoiceTitle[]> {
|
||||
for (const item of MOCK_TITLES) {
|
||||
item.isDefault = item.id === id
|
||||
@@ -310,7 +320,58 @@ export function setDefaultTitle(id: string): Promise<InvoiceTitle[]> {
|
||||
return Promise.resolve(MOCK_TITLES.map((item) => ({ ...item })))
|
||||
}
|
||||
|
||||
/** 导出报表(演示:稍后由页面提示成功) */
|
||||
export function exportReport(): Promise<void> {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
export async function getCheckoutBills(requirementId: string, ids: string[] = []): Promise<Bill[]> {
|
||||
if (!/^\d+$/.test(requirementId)) return []
|
||||
const vo = await request<{ billPlans?: PlanLike[] }>({
|
||||
url: `/web/requirementPool/get/finance/${requirementId}`,
|
||||
method: 'get',
|
||||
})
|
||||
const all = toPlanBills(vo?.billPlans)
|
||||
return ids.length > 0
|
||||
? all.filter((bill) => ids.includes(bill.id))
|
||||
: all.filter((bill) => bill.status === '待支付')
|
||||
}
|
||||
|
||||
export async function getPlanPayResult(requirementId: string, planId: string): Promise<PaymentResult | null> {
|
||||
const bills = await getCheckoutBills(requirementId, [planId])
|
||||
const bill = bills[0]
|
||||
if (!bill || bill.status !== '已支付') return null
|
||||
return {
|
||||
method: toPayMethod(bill.payChannel),
|
||||
amount: bill.amount,
|
||||
payTime: bill.payTime ?? '—',
|
||||
tradeNo: bill.tradeNo ?? '—',
|
||||
bills: [bill],
|
||||
}
|
||||
}
|
||||
|
||||
export function createWechatNativeOrder(
|
||||
orderNo: string,
|
||||
amount: number,
|
||||
billIds: string[] = [],
|
||||
): Promise<PayOrderResponse> {
|
||||
return request<PayOrderResponse>({
|
||||
url: '/pay/web/wechat/native',
|
||||
method: 'post',
|
||||
data: { orderNo, amount, billIds },
|
||||
})
|
||||
}
|
||||
|
||||
export function createAlipayPageOrder(
|
||||
outTradeNo: string,
|
||||
amount: number,
|
||||
billIds: string[] = [],
|
||||
): Promise<PayOrderResponse> {
|
||||
return request<PayOrderResponse>({
|
||||
url: '/pay/web/alipay/page',
|
||||
method: 'post',
|
||||
data: { outTradeNo, amount, billIds },
|
||||
})
|
||||
}
|
||||
|
||||
237
src/api/home.ts
@@ -1,180 +1,73 @@
|
||||
import type { BusinessType } from '@/types/api'
|
||||
import type { Case } from '@/types/business/cases'
|
||||
import type { BusinessLine, HomeStats, ServiceType } from '@/types/business/home'
|
||||
//
|
||||
import { request } from '@/utils/request'
|
||||
import type {
|
||||
AgreementFormVo,
|
||||
BannerVo,
|
||||
PortalEntryGroup,
|
||||
PortalEntryVo,
|
||||
ProjectStatusCount,
|
||||
ProjectSubmissionInfo,
|
||||
ProjectSubmissionVo,
|
||||
} from '@/types/business/home'
|
||||
|
||||
// TODO(api): 待接口文档到位后,将以下 mock 返回替换为 request<T> 调用(页面 → store → api 数据流不变)
|
||||
|
||||
const SERVICE_TYPES: ServiceType[] = [
|
||||
{ label: 'Web应用', desc: '响应式 Web 与后台管理系统定制开发' },
|
||||
{ label: '小程序', desc: '微信/支付宝小程序开发与上架' },
|
||||
{ label: 'APP', desc: 'iOS/Android 移动应用定制开发' },
|
||||
{ label: '二次开发', desc: '基于现有系统的功能扩展与重构' },
|
||||
{ label: '桌面工具', desc: '跨平台桌面端工具与自动化软件' },
|
||||
]
|
||||
|
||||
const SERVICE_CONTENT = `一、产品咨询与规划服务
|
||||
1、业务诊断与梳理:深入调研客户现有业务流程,识别数字化痛点与改进机会。
|
||||
2、竞品分析与市场调研:分析行业标杆产品,提炼差异化竞争优势。
|
||||
3、产品定位与MVP规划:明确核心用户群体,规划最小可行性产品(MVP)功能集,制定迭代路线图。
|
||||
4、技术可行性评估:评估所选技术栈的成熟度、扩展性及与现有系统的兼容性。
|
||||
二、UI/UX 设计服务
|
||||
1、用户研究与体验地图:构建用户画像,梳理用户旅程,优化关键触点体验。
|
||||
2、交互原型设计:制作高保真可交互原型,直观展示产品逻辑与操作流程,支持早期验证。
|
||||
3、视觉UI设计:遵循最新设计趋势与品牌规范,输出图标、界面、动效等视觉元素。
|
||||
4、设计规范与组件库:建立统一的设计系统(Design System),确保多端体验一致性,提升后续迭代效率。
|
||||
三、技术开发服务
|
||||
5、前端开发:基于 React/Vue/Flutter 等框架,实现响应式Web、H5、小程序及跨平台移动端应用。
|
||||
6、后端开发:采用 Spring Cloud、Node.js、Go 等微服务架构,构建高并发、高可用的业务接口与逻辑。
|
||||
7、数据库设计:根据业务特性设计关系型(MySQL)与非关系型(Redis、MongoDB)数据库集群方案。
|
||||
8、DevOps 与云服务部署:搭建 CI/CD 自动化流水线,基于 Docker/K8s 进行容器化部署,保障系统弹性伸缩。
|
||||
四、质量保障(QA)服务
|
||||
1、功能测试:覆盖单元测试、集成测试、系统测试及UAT(用户验收测试)支持。
|
||||
2、性能测试:进行压力测试、负载测试,定位系统瓶颈,优化响应速度。
|
||||
3、安全测试:实施渗透测试、漏洞扫描,保障数据安全与隐私合规。
|
||||
4、兼容性测试:覆盖主流浏览器、操作系统及移动设备型号。
|
||||
五、运维与持续运营服务
|
||||
1、7x24小时监控:实时监控系统健康度、流量及异常日志。
|
||||
2、数据运营分析:接入埋点系统,输出用户行为分析报告,指导产品优化。
|
||||
3、迭代升级:基于用户反馈与业务增长需求,提供持续的功能迭代与版本更新。`
|
||||
|
||||
const BUSINESS_LINES: BusinessLine[] = [
|
||||
{
|
||||
id: 'internet',
|
||||
name: '互联网软件',
|
||||
desc: 'Web/小程序/APP/二次开发/桌面工具等',
|
||||
slogan: '专业的软件定制开发服务,从需求到上线全流程保障',
|
||||
icon: '',
|
||||
serviceContent: SERVICE_CONTENT,
|
||||
serviceTypes: SERVICE_TYPES,
|
||||
video: '',
|
||||
},
|
||||
{
|
||||
id: 'hardware',
|
||||
name: '硬件电路',
|
||||
desc: '原理图设计/PCB设计/电气效验等',
|
||||
slogan: '专业的硬件电路设计服务,从方案到量产全流程保障',
|
||||
icon: '',
|
||||
serviceContent: SERVICE_CONTENT,
|
||||
serviceTypes: SERVICE_TYPES,
|
||||
video: '',
|
||||
},
|
||||
{
|
||||
id: 'iot',
|
||||
name: '物联网全案',
|
||||
desc: '软硬件开发+云端一体化/定制化开发等',
|
||||
slogan: '专业的物联网软硬一体化服务,从感知到云端全流程保障',
|
||||
icon: '',
|
||||
serviceContent: SERVICE_CONTENT,
|
||||
serviceTypes: SERVICE_TYPES,
|
||||
video: '',
|
||||
},
|
||||
{
|
||||
id: 'manufacturing',
|
||||
name: '生产制造',
|
||||
desc: 'PCB制版/SMT/整机装配/定制生产等',
|
||||
slogan: '专业的研发智造生产服务,从制版到交付全流程保障',
|
||||
icon: '',
|
||||
serviceContent: SERVICE_CONTENT,
|
||||
serviceTypes: SERVICE_TYPES,
|
||||
video: '',
|
||||
},
|
||||
]
|
||||
|
||||
const FEATURED_CASES: Case[] = [
|
||||
{
|
||||
id: 'case-001',
|
||||
title: '自动化生产线升级改造项目',
|
||||
category: 'manufacturing',
|
||||
cover: '',
|
||||
finishDate: '2026/8/10',
|
||||
teamSize: 50,
|
||||
scale: '300万',
|
||||
industry: '自动化',
|
||||
duration: '150个工作日',
|
||||
clientType: '企业',
|
||||
background: '传统产线效率低、人工依赖高,客户需要通过自动化升级提升产能与品控水平。',
|
||||
painPoints: '1、产线依赖人工巡检,异常响应慢;2、设备数据孤岛,缺乏统一监控;3、工艺参数无法追溯。',
|
||||
requirements: '建设产线数据采集与监控平台,完成关键工位自动化改造,打通 MES 系统接口。',
|
||||
deliverables: '1、产线自动化改造方案;2、PLC 控制程序与上位机软件;3、数据看板与运维手册;4、培训与质保服务。',
|
||||
video: '',
|
||||
isFavorite: false,
|
||||
},
|
||||
{
|
||||
id: 'case-002',
|
||||
title: '智能工厂物联网整体解决方案',
|
||||
category: 'iot',
|
||||
cover: '',
|
||||
finishDate: '2026/8/10',
|
||||
teamSize: 30,
|
||||
scale: '500万',
|
||||
industry: '工厂服务',
|
||||
duration: '200个工作日',
|
||||
clientType: '企业',
|
||||
background: '园区工厂多系统独立运行,能耗与设备管理粗放,需要整体数字化升级。',
|
||||
painPoints: '1、门禁/监控/能耗子系统数据不互通;2、能源浪费缺乏精细监测;3、决策缺少实时数据支撑。',
|
||||
requirements: '构建 IoT 平台接入设备数据,建设综合驾驶舱,实现能耗监测与智能告警。',
|
||||
deliverables: '1、IoT 接入平台;2、综合驾驶舱大屏;3、能耗分析系统;4、集成联调与培训服务。',
|
||||
video: '',
|
||||
isFavorite: false,
|
||||
},
|
||||
{
|
||||
id: 'case-003',
|
||||
title: '智能穿戴设备PCB设计与量产',
|
||||
category: 'hardware',
|
||||
cover: '',
|
||||
finishDate: '2026/8/10',
|
||||
teamSize: 50,
|
||||
scale: '10-50万',
|
||||
industry: '智能设备',
|
||||
duration: '30个工作日',
|
||||
clientType: '企业',
|
||||
background: '客户推出新一代智能穿戴产品,需要高集成度低功耗 PCB 设计并快速量产。',
|
||||
painPoints: '1、板载空间受限,器件布局密集;2、功耗要求严格;3、量产良率需爬坡。',
|
||||
requirements: '完成原理图与 PCB 设计、打样验证、SMT 贴片与整机装配量产。',
|
||||
deliverables: '1、原理图与 PCB 设计文件;2、样板与测试报告;3、量产良率报告;4、BOM 与工艺文件。',
|
||||
video: '',
|
||||
isFavorite: false,
|
||||
},
|
||||
{
|
||||
id: 'case-004',
|
||||
title: '智能园区综合管理平台开发',
|
||||
category: 'internet',
|
||||
cover: '',
|
||||
finishDate: '2026/8/10',
|
||||
teamSize: 20,
|
||||
scale: '200万',
|
||||
industry: '园区服务',
|
||||
duration: '200个工作日',
|
||||
clientType: '企业',
|
||||
background: '随着新型城镇化建设的加速和"数字中国"战略的深入推进,传统园区管理模式正面临严峻挑战,普遍存在"数据孤岛"现象。',
|
||||
painPoints: '1、运营效率低下:安保依赖人工巡逻;2、系统孤岛严重:门禁、监控、停车等子系统独立建设;3、能源浪费突出;4、服务体验欠佳;5、决策缺乏依据;6、安全隐患犹存。',
|
||||
requirements: '打造集"感知、预警、分析、决策、服务"于一体的智能园区综合管理平台,打通园区各子系统壁垒。',
|
||||
deliverables: '1、Web 端管理平台(驾驶舱/安防/能源/物业);2、移动端服务门户;3、IoT 平台与数据大屏;4、全套技术文档;5、培训与不少于1年免费质保。',
|
||||
video: '',
|
||||
isFavorite: false,
|
||||
},
|
||||
]
|
||||
|
||||
/** 首页数据看板 */
|
||||
export function getHomeStats(): Promise<HomeStats> {
|
||||
return Promise.resolve({ pending: 3, inProgress: 3, total: 6 })
|
||||
export function getProjectStatusCount(): Promise<ProjectStatusCount> {
|
||||
return request<ProjectStatusCount>({
|
||||
url: '/web/home-page/project-status-count',
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
/** 业务线列表(首页 4 卡片) */
|
||||
export function getBusinessLines(): Promise<BusinessLine[]> {
|
||||
return Promise.resolve(BUSINESS_LINES)
|
||||
export function getServiceTypeList(): Promise<ProjectSubmissionVo[]> {
|
||||
return request<ProjectSubmissionVo[]>({
|
||||
url: '/web/home-page/service-type-list',
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
/** 业务详情(业务详情页) */
|
||||
export function getBusinessDetail(type: BusinessType): Promise<BusinessLine> {
|
||||
const found = BUSINESS_LINES.find((line) => line.id === type)
|
||||
return Promise.resolve(found ?? BUSINESS_LINES[0]!)
|
||||
export function getServiceTypeDetail(
|
||||
id: number | string,
|
||||
): Promise<ProjectSubmissionInfo> {
|
||||
return request<ProjectSubmissionInfo>({
|
||||
url: `/web/home-page/service-type-detail/${id}`,
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
/** 首页案例精选(category=all 时返回全部) */
|
||||
export function getFeaturedCases(category: BusinessType | 'all' = 'all'): Promise<Case[]> {
|
||||
const list = category === 'all'
|
||||
? FEATURED_CASES
|
||||
: FEATURED_CASES.filter((item) => item.category === category)
|
||||
return Promise.resolve(list)
|
||||
export function getCarouselList(): Promise<BannerVo[]> {
|
||||
return request<BannerVo[]>({
|
||||
url: '/web/home-page/carousel-list',
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
export function getPlatformIntro(): Promise<AgreementFormVo> {
|
||||
return request<AgreementFormVo>({
|
||||
url: '/web/home-page/platform-intro',
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
export function getContactInfo(): Promise<AgreementFormVo> {
|
||||
return request<AgreementFormVo>({
|
||||
url: '/web/home-page/contact-info',
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
export function getPortalEntryList(): Promise<PortalEntryVo[]> {
|
||||
return request<PortalEntryVo[]>({
|
||||
url: '/web/vermins/portalEntryUser/list',
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
export function switchPortalEntryGroup(
|
||||
id: number,
|
||||
type: PortalEntryGroup,
|
||||
): Promise<void> {
|
||||
return request<void>({
|
||||
url: '/web/vermins/portalEntryUser/add',
|
||||
method: 'post',
|
||||
params: { id, type },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { Message, MessageQuery } from '@/types/business/message'
|
||||
|
||||
// TODO(api): 待接口文档到位后,将以下 mock 替换为 request<T> 调用
|
||||
|
||||
/** 消息 mock(可变,已读状态在前端内存中翻转演示) */
|
||||
const MOCK_MESSAGES: Message[] = [
|
||||
{
|
||||
id: 'msg-001',
|
||||
@@ -107,7 +106,6 @@ function applyFilters(query: MessageQuery): Message[] {
|
||||
})
|
||||
}
|
||||
|
||||
/** 消息列表(筛选 + 分页) */
|
||||
export function getMessageList(query: MessageQuery): Promise<PageResult<Message>> {
|
||||
const filtered = applyFilters(query)
|
||||
const start = (query.page - 1) * query.pageSize
|
||||
@@ -117,13 +115,11 @@ export function getMessageList(query: MessageQuery): Promise<PageResult<Message>
|
||||
})
|
||||
}
|
||||
|
||||
/** 消息详情(不存在返回 null) */
|
||||
export function getMessageDetail(id: string): Promise<Message | null> {
|
||||
const found = MOCK_MESSAGES.find((item) => item.id === id) ?? null
|
||||
return Promise.resolve(found ? { ...found } : null)
|
||||
}
|
||||
|
||||
/** 全部标记已读 */
|
||||
export function markAllRead(): Promise<void> {
|
||||
for (const item of MOCK_MESSAGES) {
|
||||
item.isRead = true
|
||||
@@ -131,14 +127,12 @@ export function markAllRead(): Promise<void> {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
/** 单条标记已读 */
|
||||
export function markRead(id: string): Promise<void> {
|
||||
const found = MOCK_MESSAGES.find((item) => item.id === id)
|
||||
if (found) found.isRead = true
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
/** 各分类消息总数(tab 角标) */
|
||||
export function getTabCounts(): Promise<{ all: number; 在线客服: number; 状态通知: number; 系统通知: number }> {
|
||||
const counts = { all: MOCK_MESSAGES.length, 在线客服: 0, 状态通知: 0, 系统通知: 0 }
|
||||
for (const item of MOCK_MESSAGES) {
|
||||
@@ -147,7 +141,6 @@ export function getTabCounts(): Promise<{ all: number; 在线客服: number; 状
|
||||
return Promise.resolve(counts)
|
||||
}
|
||||
|
||||
/** 未读消息总数(头部角标) */
|
||||
export function getUnreadCount(): Promise<number> {
|
||||
return Promise.resolve(MOCK_MESSAGES.filter((item) => !item.isRead).length)
|
||||
}
|
||||
|
||||
8
src/api/oss.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import service from '@/utils/request'
|
||||
|
||||
export async function fetchOssPreviewBlob(ossId: string | number): Promise<Blob> {
|
||||
const resp = await service.get(`/web/workOrderDraft/preview/${ossId}`, {
|
||||
responseType: 'blob',
|
||||
})
|
||||
return resp.data as Blob
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import type {
|
||||
|
||||
// TODO(api): 待接口文档到位后,将以下 mock 替换为 request<T> 调用(页面 → store → api 数据流不变)
|
||||
|
||||
/** 账号资料 mock(可变,昵称/绑定等操作在前端内存中演示) */
|
||||
const MOCK_PROFILE: Profile = {
|
||||
nickname: '王永志',
|
||||
phone: '186****618',
|
||||
@@ -22,57 +21,48 @@ const MOCK_PROFILE: Profile = {
|
||||
passwordModifiedAt: '15天前',
|
||||
}
|
||||
|
||||
/** 获取账号资料 */
|
||||
export function getProfile(): Promise<Profile> {
|
||||
return Promise.resolve({ ...MOCK_PROFILE })
|
||||
}
|
||||
|
||||
/** 修改昵称 */
|
||||
export function updateNickname(name: string): Promise<Profile> {
|
||||
MOCK_PROFILE.nickname = name
|
||||
return Promise.resolve({ ...MOCK_PROFILE })
|
||||
}
|
||||
|
||||
/** 修改密码(验证码 + 新密码) */
|
||||
export function changePassword(params: ChangePasswordParams): Promise<void> {
|
||||
MOCK_PROFILE.passwordModifiedAt = '刚刚'
|
||||
void params
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
/** 绑定/换绑手机号 */
|
||||
export function bindPhone(phone: string, code: string): Promise<Profile> {
|
||||
void code
|
||||
MOCK_PROFILE.phone = phone
|
||||
return Promise.resolve({ ...MOCK_PROFILE })
|
||||
}
|
||||
|
||||
/** 绑定/换绑邮箱 */
|
||||
export function bindEmail(email: string, code: string): Promise<Profile> {
|
||||
void code
|
||||
MOCK_PROFILE.email = email
|
||||
return Promise.resolve({ ...MOCK_PROFILE })
|
||||
}
|
||||
|
||||
/** 取消绑定邮箱 */
|
||||
export function unbindEmail(): Promise<Profile> {
|
||||
MOCK_PROFILE.email = ''
|
||||
return Promise.resolve({ ...MOCK_PROFILE })
|
||||
}
|
||||
|
||||
/** 注销前安全身份验证 */
|
||||
export function verifyIdentity(phone: string, code: string): Promise<void> {
|
||||
void phone
|
||||
void code
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
/** 注销账号 */
|
||||
export function deleteAccount(): Promise<void> {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
/** 收货地址 mock(isDefault 混合) */
|
||||
const MOCK_ADDRESSES: Address[] = [
|
||||
{
|
||||
id: 'addr-1',
|
||||
@@ -116,12 +106,10 @@ function paginate<T>(list: T[], query: ListQuery): PageResult<T> {
|
||||
}
|
||||
}
|
||||
|
||||
/** 收货地址列表(分页) */
|
||||
export function getAddresses(query: ListQuery): Promise<PageResult<Address>> {
|
||||
return Promise.resolve(paginate(MOCK_ADDRESSES, query))
|
||||
}
|
||||
|
||||
/** 新增收货地址 */
|
||||
export function createAddress(input: Omit<Address, 'id' | 'isDefault'>): Promise<Address> {
|
||||
const created: Address = {
|
||||
...input,
|
||||
@@ -132,7 +120,6 @@ export function createAddress(input: Omit<Address, 'id' | 'isDefault'>): Promise
|
||||
return Promise.resolve({ ...created })
|
||||
}
|
||||
|
||||
/** 编辑收货地址 */
|
||||
export function updateAddress(id: string, input: Omit<Address, 'id' | 'isDefault'>): Promise<Address> {
|
||||
const found = MOCK_ADDRESSES.find((item) => item.id === id)
|
||||
if (!found) return Promise.reject(new Error('地址不存在'))
|
||||
@@ -140,14 +127,12 @@ export function updateAddress(id: string, input: Omit<Address, 'id' | 'isDefault
|
||||
return Promise.resolve({ ...found })
|
||||
}
|
||||
|
||||
/** 删除收货地址 */
|
||||
export function deleteAddress(id: string): Promise<void> {
|
||||
const index = MOCK_ADDRESSES.findIndex((item) => item.id === id)
|
||||
if (index >= 0) MOCK_ADDRESSES.splice(index, 1)
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
/** 设为默认地址 */
|
||||
export function setDefaultAddress(id: string): Promise<Address[]> {
|
||||
for (const item of MOCK_ADDRESSES) {
|
||||
item.isDefault = item.id === id
|
||||
@@ -155,7 +140,6 @@ export function setDefaultAddress(id: string): Promise<Address[]> {
|
||||
return Promise.resolve(MOCK_ADDRESSES.map((item) => ({ ...item })))
|
||||
}
|
||||
|
||||
/** 我的收藏 mock(4 条已收藏案例,字段与案例库原型一致) */
|
||||
const MOCK_FAVORITES: Case[] = [
|
||||
{
|
||||
id: 'fav-1',
|
||||
@@ -168,12 +152,15 @@ const MOCK_FAVORITES: Case[] = [
|
||||
industry: '自动化',
|
||||
duration: '150个工作日',
|
||||
clientType: '企业',
|
||||
background: '传统产线效率低、人工依赖高,客户需要通过自动化升级提升产能与品控水平。',
|
||||
painPoints: '1、产线依赖人工巡检,异常响应慢;2、设备数据孤岛,缺乏统一监控;3、工艺参数无法追溯。',
|
||||
requirements: '建设产线数据采集与监控平台,完成关键工位自动化改造,打通 MES 系统接口。',
|
||||
deliverables: '1、产线自动化改造方案;2、PLC 控制程序与上位机软件;3、数据看板与运维手册;4、培训与质保服务。',
|
||||
sections: [
|
||||
{ title: '项目背景', content: '<p>传统产线效率低、人工依赖高,客户需要通过自动化升级提升产能与品控水平。</p>' },
|
||||
{ title: '客户痛点', content: '<p>1、产线依赖人工巡检,异常响应慢;2、设备数据孤岛,缺乏统一监控;3、工艺参数无法追溯。</p>' },
|
||||
{ title: '需求描述', content: '<p>建设产线数据采集与监控平台,完成关键工位自动化改造,打通 MES 系统接口。</p>' },
|
||||
{ title: '交付物', content: '<p>1、产线自动化改造方案;2、PLC 控制程序与上位机软件;3、数据看板与运维手册;4、培训与质保服务。</p>' },
|
||||
],
|
||||
video: '',
|
||||
isFavorite: true,
|
||||
favoriteCount: 0,
|
||||
},
|
||||
{
|
||||
id: 'fav-2',
|
||||
@@ -186,12 +173,15 @@ const MOCK_FAVORITES: Case[] = [
|
||||
industry: '工厂服务',
|
||||
duration: '200个工作日',
|
||||
clientType: '企业',
|
||||
background: '园区工厂多系统独立运行,能耗与设备管理粗放,需要整体数字化升级。',
|
||||
painPoints: '1、门禁/监控/能耗子系统数据不互通;2、能源浪费缺乏精细监测;3、决策缺少实时数据支撑。',
|
||||
requirements: '构建 IoT 平台接入设备数据,建设综合驾驶舱,实现能耗监测与智能告警。',
|
||||
deliverables: '1、IoT 接入平台;2、综合驾驶舱大屏;3、能耗分析系统;4、集成联调与培训服务。',
|
||||
sections: [
|
||||
{ title: '项目背景', content: '<p>园区工厂多系统独立运行,能耗与设备管理粗放,需要整体数字化升级。</p>' },
|
||||
{ title: '客户痛点', content: '<p>1、门禁/监控/能耗子系统数据不互通;2、能源浪费缺乏精细监测;3、决策缺少实时数据支撑。</p>' },
|
||||
{ title: '需求描述', content: '<p>构建 IoT 平台接入设备数据,建设综合驾驶舱,实现能耗监测与智能告警。</p>' },
|
||||
{ title: '交付物', content: '<p>1、IoT 接入平台;2、综合驾驶舱大屏;3、能耗分析系统;4、集成联调与培训服务。</p>' },
|
||||
],
|
||||
video: '',
|
||||
isFavorite: true,
|
||||
favoriteCount: 0,
|
||||
},
|
||||
{
|
||||
id: 'fav-3',
|
||||
@@ -204,12 +194,15 @@ const MOCK_FAVORITES: Case[] = [
|
||||
industry: '智能设备',
|
||||
duration: '30个工作日',
|
||||
clientType: '企业',
|
||||
background: '客户推出新一代智能穿戴产品,需要高集成度低功耗 PCB 设计并快速量产。',
|
||||
painPoints: '1、板载空间受限,器件布局密集;2、功耗要求严格;3、量产良率需爬坡。',
|
||||
requirements: '完成原理图与 PCB 设计、打样验证、SMT 贴片与整机装配量产。',
|
||||
deliverables: '1、原理图与 PCB 设计文件;2、样板与测试报告;3、量产良率报告;4、BOM 与工艺文件。',
|
||||
sections: [
|
||||
{ title: '项目背景', content: '<p>客户推出新一代智能穿戴产品,需要高集成度低功耗 PCB 设计并快速量产。</p>' },
|
||||
{ title: '客户痛点', content: '<p>1、板载空间受限,器件布局密集;2、功耗要求严格;3、量产良率需爬坡。</p>' },
|
||||
{ title: '需求描述', content: '<p>完成原理图与 PCB 设计、打样验证、SMT 贴片与整机装配量产。</p>' },
|
||||
{ title: '交付物', content: '<p>1、原理图与 PCB 设计文件;2、样板与测试报告;3、量产良率报告;4、BOM 与工艺文件。</p>' },
|
||||
],
|
||||
video: '',
|
||||
isFavorite: true,
|
||||
favoriteCount: 0,
|
||||
},
|
||||
{
|
||||
id: 'fav-4',
|
||||
@@ -222,16 +215,18 @@ const MOCK_FAVORITES: Case[] = [
|
||||
industry: '园区服务',
|
||||
duration: '200个工作日',
|
||||
clientType: '企业',
|
||||
background: '传统园区管理普遍"数据孤岛",安防、能耗、物业、招商等系统独立运行,缺乏联动,管理手段依赖人工巡检和纸质流程。',
|
||||
painPoints: '1、运营效率低下:安保依赖人工巡逻;2、系统孤岛严重:门禁、监控、停车、能耗等子系统独立建设;3、能源浪费突出;4、服务体验欠佳;5、决策缺乏依据;6、安全隐患犹存。',
|
||||
requirements: '打造集"感知、预警、分析、决策、服务"于一体的智能园区综合管理平台,打通园区各子系统壁垒,实现全场景数字化覆盖。',
|
||||
deliverables: '1、Web 端管理平台(驾驶舱/安防/能源/物业);2、移动端服务门户;3、IoT 平台与数据大屏;4、全套技术文档;5、培训与不少于1年免费质保。',
|
||||
sections: [
|
||||
{ title: '项目背景', content: '<p>传统园区管理普遍"数据孤岛",安防、能耗、物业、招商等系统独立运行,缺乏联动,管理手段依赖人工巡检和纸质流程。</p>' },
|
||||
{ title: '客户痛点', content: '<p>1、运营效率低下:安保依赖人工巡逻;2、系统孤岛严重:门禁、监控、停车、能耗等子系统独立建设;3、能源浪费突出;4、服务体验欠佳;5、决策缺乏依据;6、安全隐患犹存。</p>' },
|
||||
{ title: '需求描述', content: '<p>打造集"感知、预警、分析、决策、服务"于一体的智能园区综合管理平台,打通园区各子系统壁垒,实现全场景数字化覆盖。</p>' },
|
||||
{ title: '交付物', content: '<p>1、Web 端管理平台(驾驶舱/安防/能源/物业);2、移动端服务门户;3、IoT 平台与数据大屏;4、全套技术文档;5、培训与不少于1年免费质保。</p>' },
|
||||
],
|
||||
video: '',
|
||||
isFavorite: true,
|
||||
favoriteCount: 0,
|
||||
},
|
||||
]
|
||||
|
||||
/** 我的收藏(关键字/分类筛选 + 分页) */
|
||||
export function getFavorites(query: FavoritesQuery): Promise<PageResult<Case>> {
|
||||
const filtered = MOCK_FAVORITES.filter((item) => {
|
||||
if (query.category !== 'all' && item.category !== query.category) return false
|
||||
@@ -245,7 +240,6 @@ export function getFavorites(query: FavoritesQuery): Promise<PageResult<Case>> {
|
||||
})
|
||||
}
|
||||
|
||||
/** 收藏分类计数(分类快捷按钮角标) */
|
||||
export function getCaseCounts(): Promise<Partial<Record<BusinessType, number>>> {
|
||||
const counts: Partial<Record<BusinessType, number>> = {
|
||||
internet: 1,
|
||||
@@ -256,7 +250,6 @@ export function getCaseCounts(): Promise<Partial<Record<BusinessType, number>>>
|
||||
return Promise.resolve(counts)
|
||||
}
|
||||
|
||||
/** 资质 mock(状态混合:已认证/审核中/未通过) */
|
||||
const MOCK_QUALIFICATIONS: Qualification[] = [
|
||||
{
|
||||
id: 'qual-1',
|
||||
@@ -312,12 +305,10 @@ const MOCK_QUALIFICATIONS: Qualification[] = [
|
||||
},
|
||||
]
|
||||
|
||||
/** 资质列表(分页) */
|
||||
export function getQualifications(query: ListQuery): Promise<PageResult<Qualification>> {
|
||||
return Promise.resolve(paginate(MOCK_QUALIFICATIONS, query))
|
||||
}
|
||||
|
||||
/** 新增资质(提交后进入审核中) */
|
||||
export function createQualification(input: QualificationInput): Promise<Qualification> {
|
||||
const created: Qualification = {
|
||||
...input,
|
||||
@@ -328,7 +319,6 @@ export function createQualification(input: QualificationInput): Promise<Qualific
|
||||
return Promise.resolve({ ...created })
|
||||
}
|
||||
|
||||
/** 编辑资质(未通过重新提交后回到审核中) */
|
||||
export function updateQualification(id: string, input: QualificationInput): Promise<Qualification> {
|
||||
const found = MOCK_QUALIFICATIONS.find((item) => item.id === id)
|
||||
if (!found) return Promise.reject(new Error('资质不存在'))
|
||||
@@ -343,20 +333,17 @@ export function updateQualification(id: string, input: QualificationInput): Prom
|
||||
return Promise.resolve({ ...found })
|
||||
}
|
||||
|
||||
/** 删除资质 */
|
||||
export function deleteQualification(id: string): Promise<void> {
|
||||
const index = MOCK_QUALIFICATIONS.findIndex((item) => item.id === id)
|
||||
if (index >= 0) MOCK_QUALIFICATIONS.splice(index, 1)
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
/** 提交意见反馈 */
|
||||
export function submitFeedback(feedback: Feedback): Promise<void> {
|
||||
void feedback
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
/** 帮助中心 FAQ */
|
||||
export function getHelpFaqs(): Promise<HelpFaqGroup[]> {
|
||||
return Promise.resolve([
|
||||
{
|
||||
|
||||
@@ -1,270 +1,177 @@
|
||||
import type { FileInfo, PageResult } from '@/types/api'
|
||||
import type {
|
||||
AfterSales,
|
||||
AfterSalesAttachment,
|
||||
AfterSalesPayload,
|
||||
Project,
|
||||
AfterSalesUploadVo,
|
||||
ProjectListResult,
|
||||
ProjectQuery,
|
||||
ProjectStatistics,
|
||||
RdProjectVo,
|
||||
RequirementDetailBundle,
|
||||
} from '@/types/business/project'
|
||||
import { request, serializeQuery } from '@/utils/request'
|
||||
import { formatDate } from '@/utils/format'
|
||||
|
||||
// TODO(api): 待接口文档到位后,将以下 mock 替换为 request<T> 调用
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// GET /web/requirementPool/list → 控制器 ProjectRequirementUserController#list
|
||||
// → 服务层 ProjectRequirementServiceImpl#queryUserPageList
|
||||
// 返回 { total, rows: RdProjectVo[] }
|
||||
// key → 模糊匹配 projectName / projectNo / requirementDesc
|
||||
// pageNum/pageSize → 分页参数 PageQuery
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 构造 mock 文件信息 */
|
||||
function mockFile(name: string, size: number, uploadTime: string): FileInfo {
|
||||
return { name, size, uploadTime, url: '' }
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// GET /web/requirementPool/getFilterConditions
|
||||
// → 控制器 ProjectRequirementUserController#getFilterConditions
|
||||
// → 服务层 ProjectRequirementServiceImpl#getFilterConditions
|
||||
// 业务类型统计 businessTypeCounts: [{ businessTypeId, businessTypeLabel, count }]
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function pad(value: number): string {
|
||||
return String(value).padStart(2, '0')
|
||||
}
|
||||
|
||||
/** 详情 mock 所属项目 id */
|
||||
const DETAIL_ID = 'project-004'
|
||||
|
||||
/** 项目列表 mock(6 条,覆盖全部状态,internet/iot 混合) */
|
||||
const MOCK_PROJECTS: Project[] = [
|
||||
{
|
||||
id: 'project-001',
|
||||
projectNo: 'P8K2M202408001',
|
||||
projectName: '某大型银行数字化转型方案',
|
||||
businessType: 'internet',
|
||||
progress: 64,
|
||||
status: '待立项',
|
||||
submitTime: '2026-8-17 14:00',
|
||||
milestones: [],
|
||||
team: [],
|
||||
files: [],
|
||||
paymentPlans: [],
|
||||
invoiceRecords: [],
|
||||
},
|
||||
{
|
||||
id: 'project-002',
|
||||
projectNo: 'P8K2M202408002',
|
||||
projectName: '智慧城市大数据平台建设项目',
|
||||
businessType: 'iot',
|
||||
progress: 64,
|
||||
status: '待验收',
|
||||
submitTime: '2026-8-17 14:00',
|
||||
milestones: [],
|
||||
team: [],
|
||||
files: [],
|
||||
paymentPlans: [],
|
||||
invoiceRecords: [],
|
||||
},
|
||||
{
|
||||
id: 'project-003',
|
||||
projectNo: 'P8K2M202408003',
|
||||
projectName: '三甲医院指挥医疗信息化系统',
|
||||
businessType: 'internet',
|
||||
progress: 64,
|
||||
status: '已完成',
|
||||
submitTime: '2026-8-17 14:00',
|
||||
milestones: [],
|
||||
team: [],
|
||||
files: [],
|
||||
paymentPlans: [],
|
||||
invoiceRecords: [],
|
||||
},
|
||||
{
|
||||
id: DETAIL_ID,
|
||||
projectNo: '20260813RJ251545',
|
||||
projectName: '社区康养智能服务系统',
|
||||
businessType: 'internet',
|
||||
progress: 64,
|
||||
status: '进行中',
|
||||
submitTime: '2026-8-17 14:00',
|
||||
milestones: [],
|
||||
team: [],
|
||||
files: [],
|
||||
paymentPlans: [],
|
||||
invoiceRecords: [],
|
||||
},
|
||||
{
|
||||
id: 'project-005',
|
||||
projectNo: 'P8K2M202408005',
|
||||
projectName: '智能仓储物流分拣系统',
|
||||
businessType: 'iot',
|
||||
progress: 64,
|
||||
status: '售后中',
|
||||
submitTime: '2026-8-17 14:00',
|
||||
milestones: [],
|
||||
team: [],
|
||||
files: [],
|
||||
paymentPlans: [],
|
||||
invoiceRecords: [],
|
||||
afterSales: {
|
||||
status: '处理中',
|
||||
type: 'BUG问题',
|
||||
description: '分拣识别偶发误判,需要排查',
|
||||
remark: '',
|
||||
attachments: [],
|
||||
createTime: '2026-8-19 17:00',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'project-006',
|
||||
projectNo: 'P8K2M202408006',
|
||||
projectName: '新能源电站运维监控平台',
|
||||
businessType: 'internet',
|
||||
progress: 64,
|
||||
status: '已取消',
|
||||
submitTime: '2026-8-17 14:00',
|
||||
milestones: [],
|
||||
team: [],
|
||||
files: [],
|
||||
paymentPlans: [],
|
||||
invoiceRecords: [],
|
||||
},
|
||||
]
|
||||
|
||||
/** 项目详情 mock(社区康养智能服务系统) */
|
||||
const PROJECT_DETAIL: Project = {
|
||||
...MOCK_PROJECTS[3],
|
||||
progress: 65,
|
||||
startTime: '2026-8-13 11:04:12',
|
||||
endTime: '2026-10-30 18:00:00',
|
||||
milestones: [
|
||||
{ name: '需求确认', status: '已完成', time: '2026-8-13 11:04:12' },
|
||||
{ name: '原型设计', status: '已完成', time: '2026-8-13 11:04:12' },
|
||||
{ name: '开发实现', status: '进行中' },
|
||||
{ name: '测试验收', status: '待开始' },
|
||||
{ name: '项目交付', status: '待开始' },
|
||||
],
|
||||
team: [
|
||||
{ role: '项目经理', name: '张三', avatarChar: '张' },
|
||||
{ role: '项目经理', name: '张三', avatarChar: '张' },
|
||||
],
|
||||
files: [
|
||||
mockFile('项目需求规格说明书.pdf', 2516582, '2026-08-19 17:00'),
|
||||
mockFile('项目需求说明书.word', 1887437, '2026-08-19 17:00'),
|
||||
],
|
||||
paymentPlans: [
|
||||
{
|
||||
id: 'bill-001',
|
||||
planName: '第一期·预付款(比例33%)',
|
||||
billNo: '20260813ZZ251245',
|
||||
amount: 20100,
|
||||
status: '已支付',
|
||||
tradeNo: 'T20260819153035678K2M001',
|
||||
projectName: '社区康养智能服务系统',
|
||||
},
|
||||
{
|
||||
id: 'bill-002',
|
||||
planName: '第二期·付款(比例33%)',
|
||||
billNo: '20260813ZZ251245',
|
||||
amount: 20100,
|
||||
status: '待支付',
|
||||
projectName: '社区康养智能服务系统',
|
||||
},
|
||||
{
|
||||
id: 'bill-003',
|
||||
planName: '第三期·尾款(比例33%)',
|
||||
billNo: '20260813ZZ251245',
|
||||
amount: 20100,
|
||||
status: '未到期',
|
||||
projectName: '社区康养智能服务系统',
|
||||
},
|
||||
],
|
||||
invoiceRecords: [
|
||||
mockFile('增值税专用发票.pdf', 325120, '2026-08-19 17:00'),
|
||||
mockFile('电子回单.jpg', 186368, '2026-08-19 17:00'),
|
||||
],
|
||||
contractFile: mockFile('社区康养智能服务系统开发合同.pdf', 1039360, '2026-08-19 17:00'),
|
||||
afterSales: {
|
||||
status: '处理中',
|
||||
type: 'BUG问题',
|
||||
description: '具体请看清单',
|
||||
remark: '',
|
||||
attachments: [],
|
||||
createTime: '2026-8-19 17:00',
|
||||
},
|
||||
function toDateTime(date: Date, endOfDay: boolean): string {
|
||||
const ymd = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||
return `${ymd} ${endOfDay ? '23:59:59' : '00:00:00'}`
|
||||
}
|
||||
|
||||
/** 提交时间快捷区间匹配 */
|
||||
function matchTimeShortcut(submitTime: string, shortcut: ProjectQuery['timeShortcut']): boolean {
|
||||
if (shortcut === 'all') return true
|
||||
// mock 时间为 '2026-8-17 14:00',替换为 / 保证各环境可解析
|
||||
const time = new Date(submitTime.replaceAll('-', '/')).getTime()
|
||||
function toTimeRange(query: ProjectQuery): { beginTime?: string; endTime?: string } {
|
||||
if (query.startDate || query.endDate) {
|
||||
return {
|
||||
beginTime: query.startDate ? `${query.startDate} 00:00:00` : undefined,
|
||||
endTime: query.endDate ? `${query.endDate} 23:59:59` : undefined,
|
||||
}
|
||||
}
|
||||
if (query.timeShortcut === 'all') return {}
|
||||
|
||||
const start = new Date()
|
||||
if (shortcut === '3m') start.setMonth(start.getMonth() - 3)
|
||||
if (shortcut === '6m') start.setMonth(start.getMonth() - 6)
|
||||
if (shortcut === '1y') start.setFullYear(start.getFullYear() - 1)
|
||||
return time >= start.getTime()
|
||||
if (query.timeShortcut === '3m') start.setMonth(start.getMonth() - 3)
|
||||
else if (query.timeShortcut === '6m') start.setMonth(start.getMonth() - 6)
|
||||
else start.setFullYear(start.getFullYear() - 1)
|
||||
return { beginTime: toDateTime(start, false), endTime: toDateTime(new Date(), true) }
|
||||
}
|
||||
|
||||
/** 项目列表(筛选 + 分页,pageSize 固定 8) */
|
||||
export function getProjectList(query: ProjectQuery): Promise<PageResult<Project>> {
|
||||
const filtered = MOCK_PROJECTS.filter((item) => {
|
||||
if (
|
||||
query.keyword
|
||||
&& !item.projectName.includes(query.keyword)
|
||||
&& !item.projectNo.includes(query.keyword)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (query.businessType !== 'all' && item.businessType !== query.businessType) return false
|
||||
if (query.status !== 'all' && item.status !== query.status) return false
|
||||
if (!matchTimeShortcut(item.submitTime, query.timeShortcut)) return false
|
||||
return true
|
||||
})
|
||||
const pageSize = query.pageSize > 0 ? query.pageSize : 8
|
||||
const start = (query.page - 1) * pageSize
|
||||
return Promise.resolve({
|
||||
list: filtered.slice(start, start + pageSize),
|
||||
total: filtered.length,
|
||||
export function getProjectList(query: ProjectQuery): Promise<ProjectListResult> {
|
||||
return request<ProjectListResult>({
|
||||
url: '/web/requirementPool/list',
|
||||
method: 'get',
|
||||
paramsSerializer: { serialize: serializeQuery },
|
||||
params: {
|
||||
pageNum: query.pageNum,
|
||||
pageSize: query.pageSize,
|
||||
key: query.keyword || undefined,
|
||||
businessTypes: query.businessTypes.length > 0 ? [...query.businessTypes] : undefined,
|
||||
status: query.status || undefined,
|
||||
...toTimeRange(query),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 项目详情(不存在返回 null;仅详情项目返回完整数据) */
|
||||
export function getProjectDetail(id: string): Promise<Project | null> {
|
||||
if (id === DETAIL_ID) return Promise.resolve({ ...PROJECT_DETAIL })
|
||||
const found = MOCK_PROJECTS.find((item) => item.id === id) ?? null
|
||||
return Promise.resolve(found ? { ...found } : null)
|
||||
export function getProjectFilterConditions(): Promise<ProjectStatistics> {
|
||||
return request<ProjectStatistics>({
|
||||
url: '/web/requirementPool/getFilterConditions',
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
/** 上传项目文件(追加进详情文件列表) */
|
||||
export function uploadProjectFile(file: File): Promise<FileInfo> {
|
||||
const info: FileInfo = {
|
||||
name: file.name,
|
||||
export function getCompletedProjects(): Promise<RdProjectVo[]> {
|
||||
return request<ProjectListResult>({
|
||||
url: '/web/requirementPool/list',
|
||||
method: 'get',
|
||||
params: {
|
||||
pageNum: 1,
|
||||
pageSize: 200,
|
||||
status: 'COMPLETED',
|
||||
},
|
||||
}).then((result) => result?.rows ?? [])
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// GET /web/requirementPool/get/progress/{id} → 进度详情 ProjectRequirementProgressVo
|
||||
// GET /web/requirementPool/get/files/{id} → 文件资料 ProjectRequirementFilesVo
|
||||
// GET /web/requirementPool/get/finance/{id} → 财务信息 ProjectRequirementFinanceVo
|
||||
// GET /web/requirementPool/get/afterSales/{id} → 售后信息 ProjectRequirementAfterSalesVo
|
||||
// → 控制器 ProjectRequirementUserController#getProgress / #getFiles / #getFinance / #getAfterSales
|
||||
// → 服务层 ProjectRequirementServiceImpl#queryProgress / queryFiles / queryFinance / queryAfterSales
|
||||
//
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type ProjectDetailTab = 'progress' | 'files' | 'finance' | 'afterSales'
|
||||
|
||||
const DETAIL_TAB_URL: Record<ProjectDetailTab, string> = {
|
||||
progress: '/web/requirementPool/get/progress',
|
||||
files: '/web/requirementPool/get/files',
|
||||
finance: '/web/requirementPool/get/finance',
|
||||
afterSales: '/web/requirementPool/get/afterSales',
|
||||
}
|
||||
|
||||
export function getProjectDetailTab<T extends ProjectDetailTab>(
|
||||
id: string,
|
||||
tab: T,
|
||||
): Promise<RequirementDetailBundle[T] | null> {
|
||||
return request<RequirementDetailBundle[T] | null>({
|
||||
url: `${DETAIL_TAB_URL[tab]}/${id}`,
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
export function getProjectDetailBundle(id: string): Promise<RequirementDetailBundle | null> {
|
||||
return Promise.all([
|
||||
getProjectDetailTab(id, 'progress'),
|
||||
getProjectDetailTab(id, 'files'),
|
||||
getProjectDetailTab(id, 'finance'),
|
||||
getProjectDetailTab(id, 'afterSales'),
|
||||
]).then(([progress, files, finance, afterSales]) => {
|
||||
if (!progress || !files || !finance || !afterSales) return null
|
||||
return { progress, files, finance, afterSales }
|
||||
})
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// → ProjectRequirementUserController#requestAfterSale / #withdrawAfterSale
|
||||
// → ProjectRequirementServiceImpl#requestAfterSale / #withdrawAfterSale
|
||||
//
|
||||
//
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function uploadAfterSalesFile(file: File): Promise<AfterSalesAttachment> {
|
||||
const data = new FormData()
|
||||
data.append('file', file)
|
||||
return request<AfterSalesUploadVo>({
|
||||
url: '/web/workOrderDraft/upload',
|
||||
method: 'post',
|
||||
data,
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
}).then((vo) => ({
|
||||
ossId: vo.ossId,
|
||||
name: vo.originalName || file.name,
|
||||
size: file.size,
|
||||
url: vo.url,
|
||||
uploadTime: formatDate(new Date()),
|
||||
url: '',
|
||||
}
|
||||
PROJECT_DETAIL.files.push(info)
|
||||
return Promise.resolve({ ...info })
|
||||
}))
|
||||
}
|
||||
|
||||
/** 删除项目文件(按索引从详情文件列表移除) */
|
||||
export function deleteProjectFile(index: number): Promise<void> {
|
||||
if (index >= 0 && index < PROJECT_DETAIL.files.length) {
|
||||
PROJECT_DETAIL.files.splice(index, 1)
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
/** 提交售后工单 */
|
||||
export function submitAfterSales(
|
||||
projectId: string,
|
||||
id: string,
|
||||
payload: AfterSalesPayload,
|
||||
): Promise<AfterSales> {
|
||||
const result: AfterSales = {
|
||||
status: '处理中',
|
||||
): Promise<void> {
|
||||
return request<void>({
|
||||
url: '/web/requirementPool/afterSales/request',
|
||||
method: 'post',
|
||||
data: {
|
||||
id,
|
||||
type: payload.type,
|
||||
description: payload.description,
|
||||
remark: payload.remark,
|
||||
attachments: [...payload.attachments],
|
||||
createTime: formatDate(new Date()),
|
||||
}
|
||||
if (projectId === DETAIL_ID) PROJECT_DETAIL.afterSales = result
|
||||
return Promise.resolve({ ...result, attachments: [...result.attachments] })
|
||||
reason: payload.reason,
|
||||
note: payload.note,
|
||||
attachmentOssIds: payload.attachmentOssIds,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 取消售后工单 */
|
||||
export function cancelAfterSales(projectId: string): Promise<AfterSales> {
|
||||
const result: AfterSales = {
|
||||
status: '无',
|
||||
type: '',
|
||||
description: '',
|
||||
remark: '',
|
||||
attachments: [],
|
||||
createTime: '',
|
||||
}
|
||||
if (projectId === DETAIL_ID) PROJECT_DETAIL.afterSales = result
|
||||
return Promise.resolve({ ...result })
|
||||
export function cancelAfterSales(id: string, reason = ''): Promise<void> {
|
||||
return request<void>({
|
||||
url: '/web/requirementPool/afterSales/withdraw',
|
||||
method: 'post',
|
||||
data: { id, reason },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ function nowTime(): string {
|
||||
return `今天 ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
}
|
||||
|
||||
/** 历史会话(欢迎语 + 用户提问 + 文件资料) */
|
||||
export function getChatHistory(): Promise<ChatMessage[]> {
|
||||
return Promise.resolve([
|
||||
{
|
||||
@@ -40,7 +39,6 @@ export function getChatHistory(): Promise<ChatMessage[]> {
|
||||
])
|
||||
}
|
||||
|
||||
/** 发送文本消息(echo 用户消息,携带当前时间) */
|
||||
export function sendChatMessage(content: string): Promise<ChatMessage> {
|
||||
return Promise.resolve({
|
||||
id: nextId('chat'),
|
||||
@@ -50,7 +48,6 @@ export function sendChatMessage(content: string): Promise<ChatMessage> {
|
||||
})
|
||||
}
|
||||
|
||||
/** 上传聊天文件(生成文件卡片消息) */
|
||||
export function uploadChatFile(file: File): Promise<ChatMessage> {
|
||||
return Promise.resolve({
|
||||
id: nextId('chat'),
|
||||
@@ -61,7 +58,6 @@ export function uploadChatFile(file: File): Promise<ChatMessage> {
|
||||
})
|
||||
}
|
||||
|
||||
/** 客服自动回复 */
|
||||
export function mockAutoReply(): Promise<ChatMessage> {
|
||||
return Promise.resolve({
|
||||
id: nextId('chat'),
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
// 用户资料接口(真实后端绑定)
|
||||
// 后端:org.dromara.system.controller.system.SysUserController#profile → /system/user/profile
|
||||
// 鉴权:需登录态
|
||||
import { request } from '@/utils/request'
|
||||
import type { UserProfileVo } from '@/types/business/user'
|
||||
|
||||
/**
|
||||
* 当前登录用户资料(需求提报页预填联系人姓名 / 电话)。
|
||||
*
|
||||
* <p>⚠️ 后端返回的是 `R<Map>`,用户对象嵌在 **`data.user`** 里(同级还有 roles / posts),
|
||||
* 不是平铺在 data 上——这里在 api 层解包,调用方拿到的是用户对象本身。</p>
|
||||
*/
|
||||
export async function getUserProfile(): Promise<UserProfileVo> {
|
||||
const data = await request<{ user: UserProfileVo }>({
|
||||
url: '/system/user/profile',
|
||||
url: '/web/system/user/profile',
|
||||
method: 'get',
|
||||
})
|
||||
return data.user
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
BIN
src/assets/home/banner-illustration.png
Normal file
|
After Width: | Height: | Size: 287 KiB |
BIN
src/assets/home/business-hardware.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
src/assets/home/business-internet.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
src/assets/home/business-iot.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
src/assets/home/business-manufacturing.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
src/assets/home/entry-cases.png
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
BIN
src/assets/home/entry-contract.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
src/assets/home/entry-demand.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
src/assets/home/entry-finance.png
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
BIN
src/assets/home/entry-service.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src/assets/icons/online-service.png
Normal file
|
After Width: | Height: | Size: 422 B |
BIN
src/assets/icons/quick-report.png
Normal file
|
After Width: | Height: | Size: 407 B |
@@ -22,28 +22,28 @@ const coreBusiness = [
|
||||
|
||||
<template>
|
||||
<footer class="app-footer">
|
||||
<div class="app-footer__inner container">
|
||||
<div class="app-footer__inner">
|
||||
<div class="app-footer__about">
|
||||
<div class="app-footer__brand">
|
||||
<!-- TODO(蓝湖): 替换 logo 切图 -->
|
||||
<img src="@/assets/vue.svg" class="app-footer__logo" alt="虫子智造 logo">
|
||||
<span class="app-footer__name">虫子智造</span>
|
||||
</div>
|
||||
<p class="app-footer__desc">
|
||||
研发智造一体化自营平台,打通研发‑仿真‑设计‑PCB‑SMT‑整机装配‑售后迭代全链路,为科技企业、硬件厂商、创客团队提供软硬一体化定制与量产落地服务。平台自营不开放第三方服务商入驻,对项目质量全权兜底。
|
||||
</p>
|
||||
<p class="app-footer__legal">
|
||||
©2026 虫子智造 研发智造一体化平台 版权所有|自营研发生产平台,所有项目源码、图纸受平台知识产权保护,禁止盗用、复制、二次分发
|
||||
</p>
|
||||
<p class="app-footer__legal">
|
||||
{{ siteStore.siteInfo.icp }} | {{ siteStore.siteInfo.police }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="app-footer__col">
|
||||
<h4 class="app-footer__title">
|
||||
快速导航
|
||||
</h4>
|
||||
<div class="app-footer__col app-footer__col--core">
|
||||
<h4 class="app-footer__title">核心业务</h4>
|
||||
<span
|
||||
v-for="item in coreBusiness"
|
||||
:key="item"
|
||||
class="app-footer__link"
|
||||
>{{ item }}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="app-footer__col app-footer__col--nav">
|
||||
<h4 class="app-footer__title">快速导航</h4>
|
||||
<RouterLink
|
||||
v-for="link in quickLinks"
|
||||
:key="link.label"
|
||||
@@ -54,93 +54,155 @@ const coreBusiness = [
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="app-footer__col">
|
||||
<h4 class="app-footer__title">
|
||||
核心业务
|
||||
</h4>
|
||||
<span
|
||||
v-for="item in coreBusiness"
|
||||
:key="item"
|
||||
class="app-footer__link"
|
||||
>{{ item }}</span>
|
||||
<div class="app-footer__col app-footer__col--contact">
|
||||
<h4 class="app-footer__title">联系我们</h4>
|
||||
<span class="app-footer__contact"
|
||||
>商务合作:{{ siteStore.siteInfo.businessEmail }}</span
|
||||
>
|
||||
<span class="app-footer__contact"
|
||||
>技术支持:{{ siteStore.siteInfo.supportEmail }}</span
|
||||
>
|
||||
<span class="app-footer__contact"
|
||||
>客服热线:{{ siteStore.siteInfo.hotline }}</span
|
||||
>
|
||||
<span class="app-footer__contact"
|
||||
>工作时间:{{ siteStore.siteInfo.workTime }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="app-footer__col">
|
||||
<h4 class="app-footer__title">
|
||||
联系我们
|
||||
</h4>
|
||||
<span class="app-footer__contact">商务合作:{{ siteStore.siteInfo.businessEmail }}</span>
|
||||
<span class="app-footer__contact">技术支持:{{ siteStore.siteInfo.supportEmail }}</span>
|
||||
<span class="app-footer__contact">客服热线:{{ siteStore.siteInfo.hotline }}</span>
|
||||
<span class="app-footer__contact">工作时间:{{ siteStore.siteInfo.workTime }}</span>
|
||||
</div>
|
||||
<div class="app-footer__bottom">
|
||||
<p class="app-footer__legal">
|
||||
©2026 虫子智造 研发智造一体化平台
|
||||
版权所有|自营研发生产平台,所有项目源码、图纸受平台知识产权保护,禁止盗用、复制、二次分发
|
||||
</p>
|
||||
<p class="app-footer__legal app-footer__legal--icp">
|
||||
{{ siteStore.siteInfo.icp }} | {{ siteStore.siteInfo.police }}
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.app-footer {
|
||||
background: @heading-color;
|
||||
color: @text-color-secondary;
|
||||
// 设计稿:footer 顶 y=1942,总高 481px。各行绝对坐标换算为相对 footer 顶:
|
||||
// 列标题 54 / 内容首行 115 / legal[0] 332 / legal[1] 371。
|
||||
min-height: @footer-height;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: @footer-bg;
|
||||
color: @white;
|
||||
|
||||
&__inner {
|
||||
.flex(row, space-between, flex-start);
|
||||
gap: @spacing-xl;
|
||||
padding-top: @spacing-xxl;
|
||||
padding-bottom: @spacing-xxl;
|
||||
.flex(row, flex-start, flex-start);
|
||||
width: 100%;
|
||||
max-width: 1920px;
|
||||
margin: 0 auto;
|
||||
height: 250px;
|
||||
box-sizing: border-box;
|
||||
padding-top: 54px;
|
||||
padding-left: clamp(
|
||||
@spacing-base,
|
||||
calc((100% - 1609px) / 2),
|
||||
155px
|
||||
);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__about {
|
||||
max-width: 480px;
|
||||
flex: 0 1 625px; // x155 → 780
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__brand {
|
||||
.flex(row, flex-start);
|
||||
gap: @spacing-sm;
|
||||
margin-bottom: @spacing-base;
|
||||
}
|
||||
|
||||
&__logo {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin-bottom: 20px;
|
||||
margin-left: 45px;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: @font-size-lg;
|
||||
font-weight: 600;
|
||||
font-size: 28px;
|
||||
font-weight: 500;
|
||||
line-height: 41px;
|
||||
color: @white;
|
||||
}
|
||||
|
||||
&__desc,
|
||||
&__legal {
|
||||
margin-top: @spacing-sm;
|
||||
font-size: @font-size-sm;
|
||||
line-height: 1.6;
|
||||
&__desc {
|
||||
margin-top: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
line-height: 23px;
|
||||
}
|
||||
|
||||
&__col {
|
||||
.flex(column, flex-start);
|
||||
gap: @spacing-sm;
|
||||
.flex(column, flex-start, flex-start);
|
||||
gap: 0;
|
||||
min-width: 0;
|
||||
|
||||
&--core {
|
||||
flex: 0 0 193px;
|
||||
margin-left: 142px;
|
||||
}
|
||||
&--nav {
|
||||
flex: 0 0 80px;
|
||||
margin-left: 142px;
|
||||
}
|
||||
&--contact {
|
||||
flex: 0 0 253px;
|
||||
margin-left: 174px;
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin-bottom: @spacing-sm;
|
||||
font-size: @font-size-base;
|
||||
margin-bottom: 20px;
|
||||
font-size: 28px;
|
||||
font-weight: 500;
|
||||
line-height: 41px;
|
||||
white-space: nowrap;
|
||||
color: @white;
|
||||
}
|
||||
|
||||
&__link {
|
||||
font-size: @font-size-sm;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
line-height: 23px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: @primary-color;
|
||||
color: @primary-light;
|
||||
}
|
||||
}
|
||||
|
||||
&__contact {
|
||||
font-size: @font-size-sm;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
line-height: 23px;
|
||||
}
|
||||
|
||||
&__bottom {
|
||||
width: 867px;
|
||||
max-width: calc(100% - 32px);
|
||||
margin: 0 auto;
|
||||
padding-bottom: 20px;
|
||||
text-align: left;
|
||||
// legal[0] 需落在相对 footer 顶 332px 处
|
||||
margin-top: 62px;
|
||||
}
|
||||
|
||||
&__legal {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 23px;
|
||||
white-space: nowrap;
|
||||
color: @white;
|
||||
|
||||
&--icp {
|
||||
margin-top: 16px;
|
||||
margin-left: 315px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -20,19 +20,12 @@ const navItems: NavItem[] = [
|
||||
|
||||
const messageStore = useMessageStore()
|
||||
const { unreadCount } = storeToRefs(messageStore)
|
||||
|
||||
onMounted(() => void messageStore.fetchUnreadCount())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="app-header">
|
||||
<div class="app-header__inner container">
|
||||
<RouterLink to="/" class="app-header__logo">
|
||||
<!-- TODO(蓝湖): 替换 logo 切图 -->
|
||||
<img src="@/assets/vue.svg" class="app-header__logo-icon" alt="虫子智造 logo">
|
||||
<span class="app-header__logo-text">虫子智造</span>
|
||||
</RouterLink>
|
||||
|
||||
<div class="app-header__inner">
|
||||
<nav class="app-header__nav">
|
||||
<RouterLink
|
||||
v-for="item in navItems"
|
||||
@@ -45,9 +38,31 @@ onMounted(() => void messageStore.fetchUnreadCount())
|
||||
</nav>
|
||||
|
||||
<div class="app-header__actions">
|
||||
<RouterLink to="/message" class="app-header__bell" aria-label="消息中心">
|
||||
<RouterLink to="/demand" class="app-header__action">
|
||||
<img
|
||||
src="@/assets/icons/quick-report.png"
|
||||
class="app-header__action-icon"
|
||||
alt=""
|
||||
/>
|
||||
快捷提报
|
||||
</RouterLink>
|
||||
<RouterLink to="/service" class="app-header__action">
|
||||
<img
|
||||
src="@/assets/icons/online-service.png"
|
||||
class="app-header__action-icon"
|
||||
alt=""
|
||||
/>
|
||||
在线客服
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/message"
|
||||
class="app-header__bell"
|
||||
aria-label="消息中心"
|
||||
>
|
||||
<span class="app-header__bell-icon">🔔</span>
|
||||
<span v-if="unreadCount > 0" class="app-header__badge">{{ unreadCount }}</span>
|
||||
<span v-if="unreadCount > 0" class="app-header__badge">{{
|
||||
unreadCount > 99 ? '99+' : unreadCount
|
||||
}}</span>
|
||||
</RouterLink>
|
||||
<RouterLink to="/login" class="app-header__login link">
|
||||
登录
|
||||
@@ -63,76 +78,112 @@ onMounted(() => void messageStore.fetchUnreadCount())
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
height: @header-height;
|
||||
background: @white;
|
||||
box-shadow: @shadow-base;
|
||||
background: @header-bg;
|
||||
border-bottom: 1px solid @header-border;
|
||||
|
||||
&__inner {
|
||||
.flex(row, space-between);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&__logo {
|
||||
.flex(row, flex-start);
|
||||
gap: @spacing-sm;
|
||||
|
||||
&-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
&-text {
|
||||
font-size: @font-size-xl;
|
||||
font-weight: 600;
|
||||
color: @heading-color;
|
||||
}
|
||||
height: 100%;
|
||||
// 设计稿 1920 基准:导航首项左 275px,操作区右边界 1800px(右侧留白 120px)。
|
||||
// 视口窄于 1920 时按留白收缩,避免两端顶死。
|
||||
padding-left: clamp(@spacing-base, calc((100% - 1370px) / 2), 275px);
|
||||
padding-right: clamp(@spacing-base, calc((100% - 1680px) / 2), 120px);
|
||||
}
|
||||
|
||||
&__nav {
|
||||
.flex(row, center);
|
||||
gap: @spacing-lg;
|
||||
gap: @header-nav-gap;
|
||||
height: 100%;
|
||||
|
||||
&-item {
|
||||
font-size: @font-size-base;
|
||||
color: @text-color;
|
||||
position: relative;
|
||||
.flex(row, center);
|
||||
height: 100%;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: @black;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover,
|
||||
&.router-link-active {
|
||||
color: @primary-color;
|
||||
color: @home-tab-active;
|
||||
}
|
||||
|
||||
&.router-link-active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 11px;
|
||||
width: 29px;
|
||||
height: 2px;
|
||||
border-radius: 1px;
|
||||
background: @home-tab-active;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__actions {
|
||||
.flex(row, flex-end);
|
||||
gap: @spacing-base;
|
||||
.flex(row, flex-end, center);
|
||||
gap: 29px;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__action {
|
||||
.flex(row, center);
|
||||
gap: 5px;
|
||||
width: 106px;
|
||||
height: 36px;
|
||||
border-radius: @radius-sm;
|
||||
background: @primary-color;
|
||||
color: @white;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
&-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
&__bell {
|
||||
position: relative;
|
||||
.flex(row, center, center);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&-icon {
|
||||
font-size: @font-size-lg;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&__badge {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -8px;
|
||||
top: -2px;
|
||||
right: -2px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: @radius-round;
|
||||
background: @danger-color;
|
||||
color: @white;
|
||||
font-size: @font-size-sm;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
&__login {
|
||||
font-size: @font-size-base;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { formatBusinessType } from '@/utils/format'
|
||||
import type { Case } from '@/types/business/cases'
|
||||
|
||||
@@ -6,36 +7,58 @@ interface Props {
|
||||
item: Case
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{ view: [id: string] }>()
|
||||
const emit = defineEmits<{
|
||||
view: [id: string]
|
||||
favorite: [id: string]
|
||||
}>()
|
||||
|
||||
function onView(id: string): void {
|
||||
emit('view', id)
|
||||
}
|
||||
const meta = computed<Array<{ label: string; value: string }>>(() => [
|
||||
{ label: '客户类型', value: props.item.clientType || '--' },
|
||||
{ label: '所属行业', value: props.item.industry || '--' },
|
||||
{ label: '规模', value: props.item.scale || '--' },
|
||||
{ label: '项目周期', value: props.item.duration || '--' },
|
||||
{ label: '服务团队', value: props.item.teamSize ? `${props.item.teamSize}人` : '--' },
|
||||
{ label: '完成时间', value: props.item.finishDate ? props.item.finishDate.replace(/-/g, '/') : '--' },
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="case-card" @click="onView(item.id)">
|
||||
<article class="case-card" @click="emit('view', item.id)">
|
||||
<div class="case-card__cover">
|
||||
<!-- TODO(蓝湖): 替换案例封面切图,接入 v-lazy -->
|
||||
<!-- TODO(蓝湖): 替换案例封面切图 -->
|
||||
<img v-if="item.cover" v-lazy="item.cover" class="case-card__img" :alt="item.title">
|
||||
<div v-else class="case-card__img case-card__img--placeholder" />
|
||||
<span class="case-card__tag">{{ formatBusinessType(item.category) }}</span>
|
||||
<span class="case-card__more">查看详情 ></span>
|
||||
|
||||
<span class="case-card__tag" :class="`case-card__tag--${item.category}`">
|
||||
{{ formatBusinessType(item.category) }}
|
||||
</span>
|
||||
|
||||
<button
|
||||
class="case-card__fav"
|
||||
:class="{ 'is-active': item.isFavorite }"
|
||||
:aria-label="item.isFavorite ? '取消收藏' : '收藏案例'"
|
||||
@click.stop="emit('favorite', item.id)"
|
||||
>
|
||||
<svg class="case-card__fav-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 20.5s-7.2-4.6-8.4-9A5.1 5.1 0 0 1 12 5.6a5.1 5.1 0 0 1 8.4 5.9c-1.2 4.4-8.4 9-8.4 9Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="case-card__body">
|
||||
<h3 class="case-card__title">
|
||||
{{ item.title }}
|
||||
</h3>
|
||||
|
||||
<ul class="case-card__meta">
|
||||
<li>完成时间:{{ item.finishDate }}</li>
|
||||
<li>服务团队:{{ item.teamSize }}人</li>
|
||||
<li>规模:{{ item.scale }}</li>
|
||||
<li>所属行业:{{ item.industry }}</li>
|
||||
<li>项目周期:{{ item.duration }}</li>
|
||||
<li>客户类型:{{ item.clientType }}</li>
|
||||
<li v-for="row in meta" :key="row.label">
|
||||
<span class="case-card__label">{{ row.label }}:</span>{{ row.value }}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<span class="case-card__more">查看详情 ></span>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
@@ -43,24 +66,22 @@ function onView(id: string): void {
|
||||
<style lang="less" scoped>
|
||||
.case-card {
|
||||
overflow: hidden;
|
||||
border-radius: @radius-lg;
|
||||
height: 267px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid @cases-card-border;
|
||||
border-radius: @radius-base;
|
||||
background: @white;
|
||||
box-shadow: @shadow-base;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.2s, transform 0.2s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: @shadow-lg;
|
||||
transform: translateY(-2px);
|
||||
|
||||
.case-card__more {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&__cover {
|
||||
position: relative;
|
||||
height: 160px;
|
||||
height: 137px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -70,56 +91,115 @@ function onView(id: string): void {
|
||||
object-fit: cover;
|
||||
|
||||
&--placeholder {
|
||||
background: linear-gradient(135deg, @primary-light, @bg-color);
|
||||
background: @primary-strong;
|
||||
}
|
||||
}
|
||||
|
||||
&__tag {
|
||||
position: absolute;
|
||||
top: @spacing-sm;
|
||||
left: @spacing-sm;
|
||||
padding: 2px @spacing-sm;
|
||||
border-radius: @radius-round;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
top: 87px;
|
||||
left: 22px;
|
||||
height: 44px;
|
||||
padding: 0 14px;
|
||||
border-radius: @radius-sm;
|
||||
background: @primary-strong;
|
||||
color: @white;
|
||||
font-size: @font-size-sm;
|
||||
font-size: 28px;
|
||||
font-weight: 500;
|
||||
line-height: 44px;
|
||||
|
||||
&--hardware {
|
||||
background: @tag-hardware;
|
||||
}
|
||||
|
||||
&__more {
|
||||
&--iot {
|
||||
background: @tag-iot;
|
||||
}
|
||||
|
||||
&--manufacturing {
|
||||
background: @tag-manufacturing;
|
||||
}
|
||||
|
||||
&--other {
|
||||
background: @tag-other;
|
||||
}
|
||||
}
|
||||
|
||||
&__fav {
|
||||
position: absolute;
|
||||
right: @spacing-sm;
|
||||
bottom: @spacing-sm;
|
||||
padding: 2px @spacing-sm;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
.flex(row, center);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: @radius-round;
|
||||
background: @primary-color;
|
||||
color: @white;
|
||||
font-size: @font-size-sm;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: @white;
|
||||
}
|
||||
|
||||
&-icon {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
fill: none;
|
||||
stroke: @text-color-secondary;
|
||||
stroke-width: 1.8;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
background: @white;
|
||||
|
||||
.case-card__fav-icon {
|
||||
fill: @danger-color;
|
||||
stroke: @danger-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__body {
|
||||
padding: @spacing-base;
|
||||
position: relative;
|
||||
padding: 18px 20px 0;
|
||||
height: 129px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin-bottom: @spacing-sm;
|
||||
font-size: @font-size-lg;
|
||||
font-weight: 600;
|
||||
color: @heading-color;
|
||||
.multi-ellipsis(1);
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
line-height: 21.5px;
|
||||
color: @cases-text;
|
||||
.ellipsis();
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4px @spacing-sm;
|
||||
font-size: @font-size-sm;
|
||||
color: @text-color-secondary;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 21.5px 12px;
|
||||
margin-top: 10px;
|
||||
font-size: 10px;
|
||||
line-height: 14px;
|
||||
color: @cases-text;
|
||||
|
||||
li {
|
||||
.ellipsis();
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
|
||||
&__label {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
&__more {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
bottom: 14px;
|
||||
color: @primary-strong;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -63,67 +63,82 @@ function onCancel(): void {
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
.flex(row, center);
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
background: transparent;
|
||||
|
||||
&__panel {
|
||||
width: 400px;
|
||||
width: 660px;
|
||||
max-width: calc(100vw - 32px);
|
||||
padding: @spacing-lg;
|
||||
padding: @spacing-base;
|
||||
border-radius: @radius-lg;
|
||||
background: @white;
|
||||
box-shadow: @shadow-lg;
|
||||
}
|
||||
|
||||
&__title {
|
||||
position: relative;
|
||||
margin-bottom: @spacing-base;
|
||||
font-size: @font-size-lg;
|
||||
font-weight: 600;
|
||||
padding-left: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: @heading-color;
|
||||
line-height: 20px;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 3px;
|
||||
width: 3px;
|
||||
height: 14px;
|
||||
border-radius: 2px;
|
||||
background: @primary-strong;
|
||||
}
|
||||
}
|
||||
|
||||
&__body {
|
||||
margin-bottom: @spacing-lg;
|
||||
font-size: @font-size-base;
|
||||
font-size: 11px;
|
||||
color: @text-color;
|
||||
line-height: 1.6;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
.flex(row, flex-end);
|
||||
.flex(row, flex-start);
|
||||
gap: @spacing-sm;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
height: 34px;
|
||||
flex: 1;
|
||||
height: 36px;
|
||||
padding: 0 @spacing-base;
|
||||
border: 1px solid @border-color-dark;
|
||||
border-radius: @radius-base;
|
||||
background: @white;
|
||||
border: none;
|
||||
border-radius: @profile-radius;
|
||||
background: @profile-quiet-btn;
|
||||
color: @text-color;
|
||||
font-size: @font-size-base;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: @primary-color;
|
||||
color: @primary-color;
|
||||
border: none;
|
||||
background: darken(@profile-quiet-btn, 4%);
|
||||
color: @text-color;
|
||||
}
|
||||
|
||||
&--confirm {
|
||||
border-color: @primary-color;
|
||||
background: @primary-color;
|
||||
background: @primary-strong;
|
||||
color: @white;
|
||||
|
||||
&:hover {
|
||||
background: @primary-hover;
|
||||
background: darken(@primary-strong, 6%);
|
||||
color: @white;
|
||||
}
|
||||
|
||||
&.is-danger {
|
||||
border-color: @danger-color;
|
||||
background: @danger-color;
|
||||
|
||||
&:hover {
|
||||
background: fade(@danger-color, 85%);
|
||||
background: darken(@danger-color, 6%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import type { FileInfo } from '@/types/api'
|
||||
import { fileLabelOf, fileToneOf } from '@/utils/fileMeta'
|
||||
import { formatFileSize } from '@/utils/format'
|
||||
|
||||
interface Props {
|
||||
files: FileInfo[]
|
||||
showActions?: boolean
|
||||
/** 左侧文件类型徽标(设计稿 #28 附件行有,#29/#27 同款) */
|
||||
showBadge?: boolean
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), { showActions: true })
|
||||
withDefaults(defineProps<Props>(), { showActions: true, showBadge: false })
|
||||
|
||||
const emit = defineEmits<{
|
||||
preview: [file: FileInfo]
|
||||
@@ -19,6 +22,13 @@ const emit = defineEmits<{
|
||||
<template>
|
||||
<ul v-if="files.length > 0" class="file-list">
|
||||
<li v-for="(file, index) in files" :key="`${file.name}-${index}`" class="file-list__item">
|
||||
<span
|
||||
v-if="showBadge"
|
||||
class="file-list__badge"
|
||||
:style="{ color: fileToneOf(file), backgroundColor: `${fileToneOf(file)}1a` }"
|
||||
>
|
||||
{{ fileLabelOf(file) }}
|
||||
</span>
|
||||
<div class="file-list__info">
|
||||
<span class="file-list__name" :title="file.name">{{ file.name }}</span>
|
||||
<span class="file-list__meta text-secondary">
|
||||
@@ -48,10 +58,22 @@ const emit = defineEmits<{
|
||||
&__item {
|
||||
.flex(row, space-between);
|
||||
gap: @spacing-base;
|
||||
padding: @spacing-sm @spacing-base;
|
||||
border: 1px solid @border-color;
|
||||
border-radius: @radius-base;
|
||||
background: @bg-color-light;
|
||||
padding: @spacing-base;
|
||||
border: none;
|
||||
border-radius: @radius-sm;
|
||||
background: @demand-upload-bg;
|
||||
}
|
||||
|
||||
// 设计稿 #29/#27/#28 附件行:徽标 33.5×33.5。
|
||||
&__badge {
|
||||
.flex(row, center, center);
|
||||
flex-shrink: 0;
|
||||
width: 33.5px;
|
||||
height: 33.5px;
|
||||
border-radius: @radius-sm;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
&__info {
|
||||
|
||||
239
src/components/FilePreviewDialog.vue
Normal file
@@ -0,0 +1,239 @@
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { AppButton } from '@/components'
|
||||
import { useOssStore } from '@/stores/modules/oss'
|
||||
import { PREVIEW_TEXT_MAX_BYTES, formatBytes } from '@/utils/filePreview'
|
||||
|
||||
const ossStore = useOssStore()
|
||||
const { visible, file, kind, objectUrl, textContent, loading, error, truncated, totalBytes } = storeToRefs(ossStore)
|
||||
|
||||
function onClose(): void {
|
||||
ossStore.closePreview()
|
||||
}
|
||||
|
||||
function onDownload(): void {
|
||||
const target = file.value
|
||||
if (!target?.url) return
|
||||
const link = document.createElement('a')
|
||||
link.href = target.url
|
||||
link.download = target.name
|
||||
link.target = '_blank'
|
||||
link.rel = 'noopener'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="visible" class="preview-mask" @click.self="onClose">
|
||||
<div class="preview-dialog">
|
||||
<header class="preview-dialog__head">
|
||||
<span class="preview-dialog__name" :title="file?.name">{{ file?.name }}</span>
|
||||
<button class="preview-dialog__close" type="button" aria-label="关闭" @click="onClose">
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="preview-dialog__body">
|
||||
<div v-if="loading" class="preview-dialog__center">
|
||||
预览加载中…
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="preview-dialog__center">
|
||||
<p class="preview-dialog__msg">{{ error }}</p>
|
||||
<AppButton v-if="file?.url" type="primary" @click="onDownload">
|
||||
下载文件
|
||||
</AppButton>
|
||||
</div>
|
||||
|
||||
<div v-else-if="kind === 'unsupported'" class="preview-dialog__center">
|
||||
<p class="preview-dialog__msg">
|
||||
当前格式({{ (file?.name ?? '').split('.').pop() }})不支持在线预览,请下载后查看。
|
||||
</p>
|
||||
<AppButton v-if="file?.url" type="primary" @click="onDownload">
|
||||
下载文件
|
||||
</AppButton>
|
||||
</div>
|
||||
|
||||
<img
|
||||
v-else-if="kind === 'image' && objectUrl"
|
||||
:src="objectUrl"
|
||||
class="preview-dialog__image"
|
||||
alt="预览"
|
||||
>
|
||||
|
||||
<iframe
|
||||
v-else-if="kind === 'pdf' && objectUrl"
|
||||
:src="objectUrl"
|
||||
class="preview-dialog__frame"
|
||||
title="预览"
|
||||
/>
|
||||
|
||||
<video
|
||||
v-else-if="kind === 'video' && objectUrl"
|
||||
:src="objectUrl"
|
||||
class="preview-dialog__media"
|
||||
controls
|
||||
/>
|
||||
|
||||
<audio
|
||||
v-else-if="kind === 'audio' && objectUrl"
|
||||
:src="objectUrl"
|
||||
class="preview-dialog__media"
|
||||
controls
|
||||
/>
|
||||
|
||||
<div v-else-if="kind === 'text'" class="preview-dialog__text-wrap">
|
||||
<p v-if="truncated" class="preview-dialog__hint">
|
||||
<span>文件较大({{ formatBytes(totalBytes ?? 0) }}),仅预览前 {{ formatBytes(PREVIEW_TEXT_MAX_BYTES) }},完整内容请下载。</span>
|
||||
<AppButton v-if="file?.url" type="primary" @click="onDownload">下载文件</AppButton>
|
||||
</p>
|
||||
<pre class="preview-dialog__text">{{ textContent }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.preview-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2000;
|
||||
.flex(row, center);
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
padding: @spacing-lg;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.preview-dialog {
|
||||
width: min(960px, 100%);
|
||||
max-height: calc(100vh - @spacing-xxl);
|
||||
.flex(column, flex-start, stretch);
|
||||
border-radius: @radius-lg;
|
||||
background: @white;
|
||||
box-shadow: @shadow-lg;
|
||||
overflow: hidden;
|
||||
|
||||
&__head {
|
||||
.flex(row, space-between, center);
|
||||
flex-shrink: 0;
|
||||
gap: @spacing-sm;
|
||||
padding: @spacing-base @spacing-lg;
|
||||
border-bottom: 1px solid @border-color;
|
||||
}
|
||||
|
||||
&__name {
|
||||
min-width: 0;
|
||||
font-size: @font-size-base;
|
||||
font-weight: 600;
|
||||
color: @heading-color;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&__close {
|
||||
flex-shrink: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: @radius-base;
|
||||
background: transparent;
|
||||
color: @text-color-secondary;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: @bg-color-light;
|
||||
color: @text-color;
|
||||
}
|
||||
}
|
||||
|
||||
&__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
.flex(column, flex-start, stretch);
|
||||
padding: @spacing-base;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
&__center {
|
||||
.flex(column, center, center);
|
||||
gap: @spacing-base;
|
||||
width: 100%;
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
&__msg {
|
||||
font-size: @font-size-base;
|
||||
color: @text-color-secondary;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__image {
|
||||
max-width: 100%;
|
||||
max-height: 70vh;
|
||||
margin: auto;
|
||||
object-fit: contain;
|
||||
border-radius: @radius-base;
|
||||
}
|
||||
|
||||
&__frame {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
min-height: 60vh;
|
||||
border: none;
|
||||
}
|
||||
|
||||
&__media {
|
||||
width: 100%;
|
||||
max-height: 70vh;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
&__text-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
&__hint {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: @spacing-sm;
|
||||
flex-shrink: 0;
|
||||
margin: 0 0 @spacing-sm;
|
||||
padding: @spacing-sm @spacing-base;
|
||||
border-radius: @radius-base;
|
||||
background: #fff7e6;
|
||||
border: 1px solid #ffd591;
|
||||
color: #ad6800;
|
||||
font-size: @font-size-sm;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
&__text {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: @spacing-base;
|
||||
border: 1px solid @border-color;
|
||||
border-radius: @radius-base;
|
||||
background: @bg-color-light;
|
||||
color: @text-color;
|
||||
font-size: @font-size-sm;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 70vh;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -71,50 +71,54 @@ const emit = defineEmits<{
|
||||
|
||||
<style lang="less" scoped>
|
||||
.filter-panel {
|
||||
padding: @spacing-base @spacing-lg;
|
||||
border-radius: @radius-lg;
|
||||
padding: 24px 36px;
|
||||
border-radius: @radius-base;
|
||||
background: @white;
|
||||
box-shadow: @shadow-base;
|
||||
|
||||
&__bar {
|
||||
.flex(row, flex-start);
|
||||
gap: @spacing-sm;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
&__input {
|
||||
width: 240px;
|
||||
height: 34px;
|
||||
flex: 1;
|
||||
max-width: 456px;
|
||||
margin-right: 28px;
|
||||
height: 38px;
|
||||
padding: 0 @spacing-base;
|
||||
border: 1px solid @border-color;
|
||||
border-radius: @radius-base;
|
||||
background: @bg-color-light;
|
||||
font-size: @font-size-base;
|
||||
border: 1px solid @cases-input-border;
|
||||
border-radius: @radius-sm;
|
||||
background: @white;
|
||||
color: @cases-text;
|
||||
font-size: 15px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&::placeholder {
|
||||
color: @cases-input-border;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: @primary-color;
|
||||
border-color: @cases-accent;
|
||||
}
|
||||
}
|
||||
|
||||
&__btn {
|
||||
height: 34px;
|
||||
min-width: 77px;
|
||||
height: 38px;
|
||||
padding: 0 @spacing-base;
|
||||
border: 1px solid @border-color-dark;
|
||||
border-radius: @radius-base;
|
||||
background: @white;
|
||||
color: @text-color;
|
||||
font-size: @font-size-base;
|
||||
border-radius: @radius-sm;
|
||||
background: @cases-accent;
|
||||
color: @white;
|
||||
font-size: 15px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: @primary-color;
|
||||
color: @primary-color;
|
||||
background: @primary-hover;
|
||||
}
|
||||
|
||||
&--primary {
|
||||
border-color: @primary-color;
|
||||
background: @primary-color;
|
||||
background: @cases-accent;
|
||||
color: @white;
|
||||
|
||||
&:hover {
|
||||
@@ -127,29 +131,36 @@ const emit = defineEmits<{
|
||||
&__chips {
|
||||
.flex(row, flex-start, center);
|
||||
flex-wrap: wrap;
|
||||
gap: @spacing-sm;
|
||||
gap: 11px 10px;
|
||||
margin-top: @spacing-base;
|
||||
font-size: @font-size-sm;
|
||||
padding: 12px 18px 16px;
|
||||
border-radius: @radius-base;
|
||||
background: @chips-panel-bg;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
&__chips-label {
|
||||
color: @cases-text;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__chip {
|
||||
height: 26px;
|
||||
height: 25px;
|
||||
padding: 0 @spacing-sm;
|
||||
border: 1px solid @primary-light;
|
||||
border-radius: @radius-round;
|
||||
background: @primary-light;
|
||||
color: @primary-color;
|
||||
font-size: @font-size-sm;
|
||||
background: @white;
|
||||
color: @cases-accent;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: @primary-color;
|
||||
color: @primary-hover;
|
||||
}
|
||||
|
||||
&--clear {
|
||||
border-color: @border-color;
|
||||
background: @white;
|
||||
color: @text-color-secondary;
|
||||
background: transparent;
|
||||
color: @primary-strong;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
45
src/components/PageHead.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<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-bottom: 72px;
|
||||
|
||||
&__title {
|
||||
padding-top: 52px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
line-height: 29px;
|
||||
color: @finance-ink;
|
||||
}
|
||||
|
||||
&__slogan {
|
||||
padding-bottom: 0;
|
||||
margin-top: 9px;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 17px;
|
||||
color: @finance-muted-text;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -13,7 +13,6 @@ const emit = defineEmits<{ pageChange: [page: number] }>()
|
||||
|
||||
const pageCount = computed(() => Math.max(1, Math.ceil(props.total / props.pageSize)))
|
||||
|
||||
/** 页码序列:≤7 全部展示,否则首尾 + 当前页窗口(… 省略) */
|
||||
const pages = computed<Array<number | '...'>>(() => {
|
||||
const n = pageCount.value
|
||||
const { page } = props
|
||||
@@ -68,29 +67,29 @@ function go(page: number): void {
|
||||
.pagination {
|
||||
.flex(row, center);
|
||||
gap: @spacing-xs;
|
||||
padding: @spacing-base 0;
|
||||
padding: 12px 0;
|
||||
|
||||
&__btn {
|
||||
.flex(row, center);
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
padding: 0 @spacing-xs;
|
||||
min-width: 20px;
|
||||
height: 25px;
|
||||
padding: 0 3px;
|
||||
border: 1px solid @border-color;
|
||||
border-radius: @radius-sm;
|
||||
background: @white;
|
||||
color: @text-color;
|
||||
font-size: @font-size-base;
|
||||
color: @text-color-secondary;
|
||||
font-size: 11px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover:not(:disabled):not(.is-ellipsis) {
|
||||
border-color: @primary-color;
|
||||
color: @primary-color;
|
||||
border-color: @primary-strong;
|
||||
color: @primary-strong;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
border-color: @primary-color;
|
||||
background: @primary-color;
|
||||
color: @white;
|
||||
border-color: @cases-page-active-bg;
|
||||
background: @cases-page-active-bg;
|
||||
color: @primary-strong;
|
||||
}
|
||||
|
||||
&.is-ellipsis {
|
||||
|
||||
@@ -19,17 +19,17 @@ const levelIndex = computed(() => (level.value === 'strong' ? 3 : level.value ==
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="password" class="pwd-strength">
|
||||
<div class="pwd-strength">
|
||||
<div class="pwd-strength__bars">
|
||||
<span
|
||||
v-for="i in 3"
|
||||
:key="i"
|
||||
class="pwd-strength__bar"
|
||||
:class="{ 'is-on': i <= levelIndex, [`is-${level}`]: i <= levelIndex }"
|
||||
:class="{ 'is-on': password && i <= levelIndex, [`is-${level}`]: password && i <= levelIndex }"
|
||||
/>
|
||||
</div>
|
||||
<span class="pwd-strength__label" :class="`is-${level}`">密码强度:{{ levelText }}</span>
|
||||
<p v-if="level === 'weak'" class="pwd-strength__tip">
|
||||
<span class="pwd-strength__label" :class="`is-${level}`">{{ password ? `密码强度:${levelText}` : '' }}</span>
|
||||
<p v-if="password && level === 'weak'" class="pwd-strength__tip">
|
||||
密码等级过低,请重新输入(建议字母+数字+符号组合)
|
||||
</p>
|
||||
</div>
|
||||
@@ -37,19 +37,25 @@ const levelIndex = computed(() => (level.value === 'strong' ? 3 : level.value ==
|
||||
|
||||
<style lang="less" scoped>
|
||||
.pwd-strength {
|
||||
.flex(column, flex-start);
|
||||
gap: @spacing-xs;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
grid-template-rows: 15px auto;
|
||||
column-gap: @spacing-sm;
|
||||
row-gap: 3px;
|
||||
align-items: center;
|
||||
min-height: 33px;
|
||||
box-sizing: border-box;
|
||||
|
||||
&__bars {
|
||||
.flex(row, center);
|
||||
gap: 4px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__bar {
|
||||
width: 56px;
|
||||
height: 4px;
|
||||
border-radius: @radius-round;
|
||||
background: @border-color;
|
||||
flex: 1;
|
||||
height: 5px;
|
||||
border-radius: 2px;
|
||||
background: @auth-track;
|
||||
|
||||
&.is-on.is-weak {
|
||||
background: @danger-color;
|
||||
@@ -65,23 +71,17 @@ const levelIndex = computed(() => (level.value === 'strong' ? 3 : level.value ==
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: @font-size-sm;
|
||||
|
||||
&.is-weak {
|
||||
color: @danger-color;
|
||||
}
|
||||
|
||||
&.is-medium {
|
||||
color: @warning-color;
|
||||
}
|
||||
|
||||
&.is-strong {
|
||||
color: @success-color;
|
||||
}
|
||||
justify-self: end;
|
||||
font-size: 11px;
|
||||
line-height: 15px;
|
||||
color: @text-color-disabled;
|
||||
}
|
||||
|
||||
&__tip {
|
||||
font-size: @font-size-sm;
|
||||
grid-column: 1 / -1;
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
line-height: 15px;
|
||||
color: @danger-color;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { computed, onMounted, ref } from 'vue'
|
||||
import { useCountDown } from '@/composables/useCountDown'
|
||||
|
||||
interface Props {
|
||||
/** 二维码有效期(秒),默认 4:32 */
|
||||
expiresIn?: number
|
||||
size?: number
|
||||
}
|
||||
@@ -57,7 +56,7 @@ onMounted(() => {
|
||||
</div>
|
||||
<p class="qr-panel__status text-secondary">
|
||||
<template v-if="!expired">
|
||||
二维码将在 <strong class="qr-panel__count">{{ countdownText }}</strong> 后失效(过期可点击二维码刷新)
|
||||
<span class="qr-panel__countdown">二维码将在 <strong class="qr-panel__count">{{ countdownText }}</strong> 后失效</span>(过期可点击二维码刷新)
|
||||
</template>
|
||||
<template v-else>
|
||||
二维码已过期,请点击刷新
|
||||
@@ -69,23 +68,19 @@ onMounted(() => {
|
||||
<style lang="less" scoped>
|
||||
.qr-panel {
|
||||
.flex(column, center);
|
||||
gap: @spacing-sm;
|
||||
gap: 17px;
|
||||
|
||||
&__box {
|
||||
.flex(column, center);
|
||||
gap: @spacing-xs;
|
||||
border: 1px solid @border-color;
|
||||
border-radius: @radius-base;
|
||||
background: @white;
|
||||
border: none;
|
||||
border-radius: @radius-sm;
|
||||
background: @contract-qr-bg;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: @primary-color;
|
||||
}
|
||||
transition: background 0.2s;
|
||||
|
||||
&.is-expired {
|
||||
background: @bg-color;
|
||||
background: @bg-color-light;
|
||||
color: @text-color-secondary;
|
||||
}
|
||||
}
|
||||
@@ -98,12 +93,27 @@ onMounted(() => {
|
||||
|
||||
&__tip {
|
||||
font-size: @font-size-sm;
|
||||
color: @text-color-secondary;
|
||||
color: @text-color;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__status {
|
||||
font-size: @font-size-sm;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: @spacing-sm;
|
||||
font-size: 11px;
|
||||
color: @text-color-disabled;
|
||||
}
|
||||
|
||||
&__countdown {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
border-radius: @radius-round;
|
||||
background: fade(@danger-color, 8%);
|
||||
color: @danger-color;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
&__count {
|
||||
|
||||
@@ -21,12 +21,8 @@ function onGetCode(): void {
|
||||
class="sms-code__input"
|
||||
type="text"
|
||||
placeholder="请输入验证码"
|
||||
>
|
||||
<button
|
||||
class="sms-code__btn"
|
||||
:disabled="isCounting"
|
||||
@click="onGetCode"
|
||||
>
|
||||
/>
|
||||
<button class="sms-code__btn" :disabled="isCounting" @click="onGetCode">
|
||||
{{ isCounting ? `${seconds}秒后重新获取` : '获取验证码' }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -41,36 +37,48 @@ function onGetCode(): void {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 40px;
|
||||
padding: 0 @spacing-base;
|
||||
border: 1px solid @border-color;
|
||||
border-radius: @radius-base;
|
||||
background: @bg-color-light;
|
||||
font-size: @font-size-base;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #dde1e7;
|
||||
border-radius: @radius-sm;
|
||||
background: @white;
|
||||
font-size: 13px;
|
||||
color: #1f2937;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&:focus {
|
||||
border-color: @primary-color;
|
||||
border-color: @primary-strong;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: #1f2937;
|
||||
}
|
||||
}
|
||||
|
||||
// 设计稿 #38:「获取验证码」是带蓝色描边的独立按钮(62×16 文字居中),
|
||||
// 不是纯文字链接。按键宽 88px,与输入框间距 @spacing-sm。
|
||||
&__btn {
|
||||
flex-shrink: 0;
|
||||
width: 132px;
|
||||
width: 88px;
|
||||
height: 40px;
|
||||
border: 1px solid @primary-color;
|
||||
border-radius: @radius-base;
|
||||
padding: 0;
|
||||
border: 1px solid @primary-strong;
|
||||
border-radius: @radius-sm;
|
||||
background: @white;
|
||||
color: @primary-color;
|
||||
font-size: @font-size-sm;
|
||||
color: @primary-strong;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: @primary-light;
|
||||
background: @primary-strong;
|
||||
color: @white;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
border-color: @border-color-dark;
|
||||
border-color: @text-color-disabled;
|
||||
color: @text-color-disabled;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@@ -16,10 +16,10 @@ withDefaults(defineProps<Props>(), { variant: 'info' })
|
||||
<style lang="less" scoped>
|
||||
.status-tag {
|
||||
display: inline-block;
|
||||
padding: 2px @spacing-sm;
|
||||
padding: 3px 10px;
|
||||
border-radius: @radius-sm;
|
||||
font-size: @font-size-sm;
|
||||
line-height: 1.5;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
white-space: nowrap;
|
||||
|
||||
&--primary {
|
||||
@@ -28,13 +28,13 @@ withDefaults(defineProps<Props>(), { variant: 'info' })
|
||||
}
|
||||
|
||||
&--success {
|
||||
background: fade(@success-color, 12%);
|
||||
background: @tag-success-bg;
|
||||
color: @success-color;
|
||||
}
|
||||
|
||||
&--warning {
|
||||
background: fade(@warning-color, 12%);
|
||||
color: @warning-color;
|
||||
background: @tag-warning-bg;
|
||||
color: @tag-warning-text;
|
||||
}
|
||||
|
||||
&--danger {
|
||||
@@ -43,7 +43,7 @@ withDefaults(defineProps<Props>(), { variant: 'info' })
|
||||
}
|
||||
|
||||
&--info {
|
||||
background: @bg-color;
|
||||
background: @tag-info-bg;
|
||||
color: @text-color-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,57 +30,48 @@ defineProps<Props>()
|
||||
<style lang="less" scoped>
|
||||
.step-form {
|
||||
padding: @spacing-lg @spacing-xl;
|
||||
border-radius: @radius-lg;
|
||||
border-radius: @radius-base;
|
||||
background: @white;
|
||||
box-shadow: @shadow-base;
|
||||
box-shadow: none;
|
||||
|
||||
&__header {
|
||||
.flex(row, center);
|
||||
gap: @spacing-sm;
|
||||
.flex(column, flex-start, baseline);
|
||||
gap: @spacing-base;
|
||||
margin-bottom: @spacing-lg;
|
||||
padding-bottom: @spacing-base;
|
||||
border-bottom: 1px solid @border-color;
|
||||
padding-bottom: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&__step {
|
||||
.flex(row, center);
|
||||
.flex(row, baseline);
|
||||
gap: @spacing-xs;
|
||||
|
||||
&.is-active .step-form__dot {
|
||||
background: @primary-color;
|
||||
color: @white;
|
||||
}
|
||||
|
||||
&.is-done .step-form__dot {
|
||||
border-color: @primary-color;
|
||||
color: @primary-color;
|
||||
}
|
||||
|
||||
&.is-active .step-form__label {
|
||||
color: @primary-color;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
&__dot {
|
||||
.flex(row, center);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 1px solid @border-color-dark;
|
||||
border-radius: @radius-round;
|
||||
font-size: @font-size-sm;
|
||||
color: @text-color-secondary;
|
||||
flex-shrink: 0;
|
||||
width: 46px;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
font-size: 37px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
color: @demand-accent;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: @font-size-base;
|
||||
color: @text-color;
|
||||
font-size: @font-size-title;
|
||||
font-weight: 500;
|
||||
color: @demand-heading;
|
||||
}
|
||||
|
||||
&__line {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: @border-color;
|
||||
display: none;
|
||||
}
|
||||
|
||||
&__body {
|
||||
margin-left: 46px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -66,17 +66,17 @@ function onDrop(event: DragEvent): void {
|
||||
.upload-area {
|
||||
.flex(column, center);
|
||||
gap: @spacing-xs;
|
||||
padding: @spacing-lg;
|
||||
border: 1px dashed @border-color-dark;
|
||||
border-radius: @radius-base;
|
||||
background: @bg-color-light;
|
||||
min-height: 69px;
|
||||
padding: @spacing-base;
|
||||
border: none;
|
||||
border-radius: @radius-sm;
|
||||
background: @demand-upload-bg;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover,
|
||||
&.is-dragging {
|
||||
border-color: @primary-color;
|
||||
background: @primary-light;
|
||||
background: darken(@demand-upload-bg, 5%);
|
||||
}
|
||||
|
||||
&.is-disabled {
|
||||
@@ -85,8 +85,8 @@ function onDrop(event: DragEvent): void {
|
||||
}
|
||||
|
||||
&__icon {
|
||||
font-size: @font-size-xl;
|
||||
color: @primary-color;
|
||||
font-size: @font-size-lg;
|
||||
color: @text-color-disabled;
|
||||
}
|
||||
|
||||
&__hint {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export { default as AppHeader } from './AppHeader.vue'
|
||||
export { default as AppFooter } from './AppFooter.vue'
|
||||
export { default as AppButton } from './AppButton.vue'
|
||||
export { default as PageHead } from './PageHead.vue'
|
||||
export { default as CaseCard } from './CaseCard.vue'
|
||||
export { default as FilterPanel } from './FilterPanel.vue'
|
||||
export type { FilterChip } from './FilterPanel.vue'
|
||||
@@ -8,6 +9,7 @@ export { default as PaginationBar } from './PaginationBar.vue'
|
||||
export { default as StatusTag } from './StatusTag.vue'
|
||||
export { default as FileList } from './FileList.vue'
|
||||
export { default as UploadArea } from './UploadArea.vue'
|
||||
export { default as FilePreviewDialog } from './FilePreviewDialog.vue'
|
||||
export { default as StepForm } from './StepForm.vue'
|
||||
export { default as SmsCodeInput } from './SmsCodeInput.vue'
|
||||
export { default as PasswordStrength } from './PasswordStrength.vue'
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { computed, onScopeDispose, ref } from 'vue'
|
||||
|
||||
/** 倒计时(验证码 60s、二维码失效 4:32 等通用) */
|
||||
export function useCountDown(defaultSeconds = 60) {
|
||||
const seconds = ref(0)
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { onMounted, onUnmounted, ref, type Ref } from 'vue'
|
||||
|
||||
/** 元素可见性(配合图片懒加载/曝光统计) */
|
||||
export function useIntersection(
|
||||
target: Ref<Element | null | undefined>,
|
||||
options?: IntersectionObserverInit,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ref, type Ref } from 'vue'
|
||||
|
||||
/** 请求 loading 状态管理 */
|
||||
export function useLoading(initial = false): {
|
||||
loading: Ref<boolean>
|
||||
withLoading: <T>(fn: () => Promise<T>) => Promise<T>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { computed, onMounted, onUnmounted, ref, type ComputedRef, type Ref } from 'vue'
|
||||
|
||||
/** 滚动监听/吸顶/回到顶部 */
|
||||
export function useScroll(threshold = 200): {
|
||||
scrollY: Ref<number>
|
||||
isAtTop: ComputedRef<boolean>
|
||||
|
||||
@@ -13,7 +13,6 @@ function normalize(value: DebounceValue): DebounceBinding {
|
||||
return typeof value === 'function' ? { handler: value } : value
|
||||
}
|
||||
|
||||
/** v-debounce 防抖点击:v-debounce="fn" 或 v-debounce="{ handler: fn, delay: 500 }" */
|
||||
const debounce: Directive<Element, DebounceValue> = {
|
||||
mounted(el, binding) {
|
||||
const { handler, delay = 300 } = normalize(binding.value)
|
||||
|
||||
@@ -3,7 +3,6 @@ import lazy from './lazy'
|
||||
import debounce from './debounce'
|
||||
import throttle from './throttle'
|
||||
|
||||
/** main.ts 调用:统一注册自定义指令 */
|
||||
export function setupDirectives(app: App): void {
|
||||
app
|
||||
.directive('lazy', lazy)
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { Directive } from 'vue'
|
||||
|
||||
const observerMap = new WeakMap<Element, IntersectionObserver>()
|
||||
|
||||
/** v-lazy 图片懒加载:v-lazy="图片地址" */
|
||||
const lazy: Directive<Element, string> = {
|
||||
mounted(el, binding) {
|
||||
if (!(el instanceof HTMLImageElement)) return
|
||||
|
||||
@@ -13,7 +13,6 @@ function normalize(value: ThrottleValue): ThrottleBinding {
|
||||
return typeof value === 'function' ? { handler: value } : value
|
||||
}
|
||||
|
||||
/** v-throttle 节流点击:v-throttle="fn" 或 v-throttle="{ handler: fn, interval: 500 }" */
|
||||
const throttle: Directive<Element, ThrottleValue> = {
|
||||
mounted(el, binding) {
|
||||
const { handler, interval = 500 } = normalize(binding.value)
|
||||
|
||||
1
src/env.d.ts
vendored
@@ -1,7 +1,6 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
/** API 基础地址 */
|
||||
readonly VITE_API_BASE_URL: string
|
||||
}
|
||||
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
// 全局浮动入口:快捷提报 / 在线客服(PRD 1.2)
|
||||
const entries = [
|
||||
{ label: '快捷提报', icon: '📝', path: '/demand' },
|
||||
{ label: '在线客服', icon: '💬', path: '/service' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="floating-actions">
|
||||
<RouterLink
|
||||
v-for="entry in entries"
|
||||
:key="entry.path"
|
||||
:to="entry.path"
|
||||
class="floating-actions__item"
|
||||
:title="entry.label"
|
||||
>
|
||||
<span class="floating-actions__icon">{{ entry.icon }}</span>
|
||||
<span class="floating-actions__label">{{ entry.label }}</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.floating-actions {
|
||||
position: fixed;
|
||||
right: 24px;
|
||||
bottom: 96px;
|
||||
z-index: 90;
|
||||
.flex(column, center);
|
||||
gap: @spacing-sm;
|
||||
|
||||
&__item {
|
||||
.flex(column, center);
|
||||
gap: 2px;
|
||||
width: 56px;
|
||||
padding: @spacing-sm 0;
|
||||
border-radius: @radius-lg;
|
||||
background: @primary-color;
|
||||
color: @white;
|
||||
box-shadow: @shadow-lg;
|
||||
transition: background 0.2s, transform 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: @primary-hover;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
&__icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { AppFooter, AppHeader } from '@/components'
|
||||
import FloatingActions from './components/FloatingActions.vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { computed } from 'vue'
|
||||
import { AppFooter, AppHeader, FilePreviewDialog } from '@/components'
|
||||
|
||||
const route = useRoute()
|
||||
const showFooter = computed(() => !route.meta.noFooter)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -9,15 +13,18 @@ import FloatingActions from './components/FloatingActions.vue'
|
||||
<main class="layout__main">
|
||||
<router-view />
|
||||
</main>
|
||||
<AppFooter />
|
||||
<FloatingActions />
|
||||
<AppFooter v-if="showFooter" />
|
||||
<FilePreviewDialog />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.layout {
|
||||
.layout__main {
|
||||
min-height: calc(100vh - @header-height - @footer-height);
|
||||
.flex(column, flex-start, stretch);
|
||||
min-height: 100vh;
|
||||
|
||||
&__main {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import type { Router } from 'vue-router'
|
||||
import { storage } from '@/utils/storage'
|
||||
|
||||
/** 路由守卫:登录态校验 + document.title */
|
||||
export function setupGuard(router: Router): void {
|
||||
router.beforeEach((to) => {
|
||||
if (
|
||||
import.meta.env.DEV
|
||||
&& new URLSearchParams(window.location.search).has('visual')
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (to.meta.requiresAuth && !storage.get<string>('token')) {
|
||||
return { path: '/login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
|
||||
@@ -1,40 +1,33 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
/**
|
||||
* 公共布局(AppHeader + router-view + AppFooter + FloatingActions)下的路由
|
||||
* 完整规划见 docs/page-architecture.md 第 2 节
|
||||
* M3–M10 视图由子代理并行实现中,交付后本表即为终态
|
||||
*/
|
||||
const layoutRoutes: RouteRecordRaw = {
|
||||
path: '/',
|
||||
component: () => import('@/layout/index.vue'),
|
||||
children: [
|
||||
// ── 公开 ──
|
||||
{ path: '', name: 'Home', component: () => import('@/views/home'), meta: { title: '首页' } },
|
||||
{ path: '', name: 'Home', component: () => import('@/views/home'), meta: { title: '首页', footer: true } },
|
||||
{ path: 'business/:type', name: 'BusinessDetail', component: () => import('@/views/home/detail.vue'), meta: { title: '业务详情' } },
|
||||
{ path: 'cases', name: 'Cases', component: () => import('@/views/cases'), meta: { title: '成功案例库' } },
|
||||
{ path: 'cases/:id', name: 'CaseDetail', component: () => import('@/views/cases/detail.vue'), meta: { title: '案例详情' } },
|
||||
{ path: 'demand', name: 'Demand', component: () => import('@/views/demand'), meta: { title: '需求提报' } },
|
||||
{ path: 'demand', name: 'Demand', component: () => import('@/views/demand'), meta: { title: '需求提报', requiresAuth: true, noFooter: true } },
|
||||
|
||||
// ── 登录区 ──
|
||||
{ path: 'project', name: 'Project', component: () => import('@/views/project'), meta: { title: '项目中心', requiresAuth: true } },
|
||||
{ path: 'project/:id', name: 'ProjectDetail', component: () => import('@/views/project/detail.vue'), meta: { title: '项目详情', requiresAuth: true } },
|
||||
{ path: 'project/:id/after-sales', name: 'AfterSales', component: () => import('@/views/project/after-sales.vue'), meta: { title: '申请售后', requiresAuth: true } },
|
||||
{ path: 'contract', name: 'Contract', component: () => import('@/views/contract'), meta: { title: '合同中心', requiresAuth: true } },
|
||||
{ path: 'contract', name: 'Contract', component: () => import('@/views/contract'), meta: { title: '合同中心', requiresAuth: true, noFooter: true } },
|
||||
{ path: 'contract/:id', name: 'ContractDetail', component: () => import('@/views/contract/detail.vue'), meta: { title: '合同详情', requiresAuth: true } },
|
||||
{ path: 'contract/:id/sign', name: 'ContractSign', component: () => import('@/views/contract/sign.vue'), meta: { title: '签署合同', requiresAuth: true } },
|
||||
{ path: 'finance', name: 'Finance', component: () => import('@/views/finance'), meta: { title: '财务中心', requiresAuth: true } },
|
||||
{ path: 'finance/bills', name: 'Bills', component: () => import('@/views/finance/bills.vue'), meta: { title: '账单明细', requiresAuth: true } },
|
||||
{ path: 'finance/bills/:id', name: 'BillDetail', component: () => import('@/views/finance/bill-detail.vue'), meta: { title: '账单详情', requiresAuth: true } },
|
||||
{ path: 'finance/bills/:id/pay', name: 'BillPay', component: () => import('@/views/finance/pay.vue'), meta: { title: '扫码支付', requiresAuth: true } },
|
||||
{ path: 'finance/bills/:id/pay/result', name: 'PayResult', component: () => import('@/views/finance/pay-result.vue'), meta: { title: '支付结果', requiresAuth: true } },
|
||||
{ path: 'finance/bills/:id/pay/result', name: 'PayResult', component: () => import('@/views/finance/pay-result.vue'), meta: { title: '支付成功', requiresAuth: true } },
|
||||
{ path: 'finance/checkout', name: 'Checkout', component: () => import('@/views/finance/checkout.vue'), meta: { title: '确认支付', requiresAuth: true } },
|
||||
{ path: 'finance/invoices', name: 'Invoices', component: () => import('@/views/finance/invoices.vue'), meta: { title: '发票申请', requiresAuth: true } },
|
||||
{ path: 'finance/invoice-titles', name: 'InvoiceTitles', component: () => import('@/views/finance/invoice-titles.vue'), meta: { title: '发票抬头', requiresAuth: true } },
|
||||
{ path: 'message', name: 'Message', component: () => import('@/views/message'), meta: { title: '消息中心', requiresAuth: true } },
|
||||
{ path: 'message/:id', name: 'MessageDetail', component: () => import('@/views/message/detail.vue'), meta: { title: '通知详情', requiresAuth: true } },
|
||||
{ path: 'service', name: 'Service', component: () => import('@/views/service'), meta: { title: '在线客服', requiresAuth: true } },
|
||||
|
||||
// ── 个人中心(左菜单嵌套)──
|
||||
{
|
||||
path: 'profile',
|
||||
component: () => import('@/views/profile'),
|
||||
@@ -54,7 +47,6 @@ const layoutRoutes: RouteRecordRaw = {
|
||||
],
|
||||
}
|
||||
|
||||
/** 无全局布局的路由(登录注册独立品牌页 + 404) */
|
||||
const plainRoutes: RouteRecordRaw[] = [
|
||||
{ path: '/login', name: 'Login', component: () => import('@/views/login'), meta: { title: '登录' } },
|
||||
{ path: '/register', name: 'Register', component: () => import('@/views/login/register.vue'), meta: { title: '注册' } },
|
||||
|
||||
@@ -1,50 +1,147 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import type { BusinessType, PageResult } from '@/types/api'
|
||||
import type { Case, CaseQuery } from '@/types/business/cases'
|
||||
import { getCaseCounts, getCaseDetail, getCaseList, toggleFavorite as toggleFavoriteApi } from '@/api/cases'
|
||||
import type {
|
||||
BusinessIndustryOption,
|
||||
Case,
|
||||
CaseQuery,
|
||||
CaseTypeOption,
|
||||
ProjectCaseStatisticsVo,
|
||||
ProjectCaseUserBo,
|
||||
ScaleLimit,
|
||||
} from '@/types/business/cases'
|
||||
import { collectCase, getCaseCategories, getCaseDetail, getCaseList } from '@/api/cases'
|
||||
import { toCase } from '@/utils/caseVo'
|
||||
import type { CaseTypeNameMap } from '@/utils/caseVo'
|
||||
|
||||
const PAGE_SIZE = 9
|
||||
|
||||
const PROBE_DELAY = 300
|
||||
|
||||
function createDefaultQuery(): CaseQuery {
|
||||
return {
|
||||
category: 'all',
|
||||
categories: [],
|
||||
industries: [],
|
||||
scales: [],
|
||||
dateRange: null,
|
||||
favoriteOnly: false,
|
||||
keyword: '',
|
||||
page: 1,
|
||||
pageSize: 8,
|
||||
pageSize: PAGE_SIZE,
|
||||
}
|
||||
}
|
||||
|
||||
/** 案例库数据(列表 / 筛选 / 分页 / 收藏) */
|
||||
function withTime(date: string, time: string): string {
|
||||
return date.length === 10 ? `${date} ${time}` : date
|
||||
}
|
||||
|
||||
function trimmed(raw: string | null | undefined): string {
|
||||
return (raw ?? '').trim()
|
||||
}
|
||||
|
||||
function toTypeOptions(stats: ProjectCaseStatisticsVo | null): CaseTypeOption[] {
|
||||
return (stats?.projectCaseCounts ?? [])
|
||||
.map((item) => ({
|
||||
value: String(item.id ?? '').trim(),
|
||||
label: trimmed(item.businessType),
|
||||
count: item.caseCount ?? 0,
|
||||
}))
|
||||
.filter((item) => item.value.length > 0 && item.label.length > 0)
|
||||
}
|
||||
|
||||
function toTypeNameMap(stats: ProjectCaseStatisticsVo | null): CaseTypeNameMap {
|
||||
const map: CaseTypeNameMap = {}
|
||||
for (const item of stats?.projectCaseCounts ?? []) {
|
||||
const id = String(item.id ?? '').trim()
|
||||
const name = trimmed(item.businessType)
|
||||
if (id.length > 0 && name.length > 0) map[id] = name
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
export const useCasesStore = defineStore('cases', () => {
|
||||
const query = ref<CaseQuery>(createDefaultQuery())
|
||||
const list = ref<Case[]>([])
|
||||
const total = ref(0)
|
||||
const counts = ref<Partial<Record<BusinessType, number>>>({})
|
||||
const previewTotal = ref(0)
|
||||
|
||||
const typeOptions = ref<CaseTypeOption[]>([])
|
||||
const typeNameById = ref<CaseTypeNameMap>({})
|
||||
const industryOptions = ref<BusinessIndustryOption[]>([])
|
||||
const scaleOptions = ref<ScaleLimit[]>([])
|
||||
const caseTotal = ref(0)
|
||||
|
||||
const currentCase = ref<Case | null>(null)
|
||||
|
||||
async function fetchCases(): Promise<void> {
|
||||
const result: PageResult<Case> = await getCaseList(query.value)
|
||||
list.value = result.list
|
||||
total.value = result.total
|
||||
let probeTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function buildQuery(input: CaseQuery): {
|
||||
bo: ProjectCaseUserBo
|
||||
pageNum: number
|
||||
pageSize: number
|
||||
} {
|
||||
const bo: ProjectCaseUserBo = {}
|
||||
if (input.categories.length > 0) {
|
||||
bo.businessTypes = [...input.categories]
|
||||
}
|
||||
if (input.industries.length > 0) {
|
||||
bo.businessIndustries = [...input.industries]
|
||||
}
|
||||
if (input.scales.length > 0) {
|
||||
bo.scales = input.scales.map((item) => ({ ...item }))
|
||||
}
|
||||
if (input.dateRange) {
|
||||
bo.beginTime = withTime(input.dateRange[0], '00:00:00')
|
||||
bo.endTime = withTime(input.dateRange[1], '23:59:59')
|
||||
}
|
||||
if (input.favoriteOnly) bo.isFavorite = 1
|
||||
const keyword = input.keyword.trim()
|
||||
if (keyword) bo.key = keyword
|
||||
return { bo, pageNum: input.page, pageSize: input.pageSize }
|
||||
}
|
||||
|
||||
async function fetchCounts(): Promise<void> {
|
||||
counts.value = await getCaseCounts()
|
||||
async function fetchCategories(): Promise<void> {
|
||||
const result = await getCaseCategories()
|
||||
const stats = result.projectCaseStatisticsVo
|
||||
typeOptions.value = toTypeOptions(stats)
|
||||
typeNameById.value = toTypeNameMap(stats)
|
||||
caseTotal.value = stats?.total ?? 0
|
||||
industryOptions.value = (result.businessIndustry ?? []).filter((item) => trimmed(item.name).length > 0)
|
||||
scaleOptions.value = result.upperAndLowerLimits ?? []
|
||||
}
|
||||
|
||||
async function ensureCategories(): Promise<void> {
|
||||
if (typeOptions.value.length === 0) await fetchCategories()
|
||||
}
|
||||
|
||||
async function fetchCases(): Promise<void> {
|
||||
const { bo, pageNum, pageSize } = buildQuery(query.value)
|
||||
const result = await getCaseList(bo, pageNum, pageSize)
|
||||
list.value = (result.projectCasePage?.rows ?? []).map((vo) => toCase(vo, typeNameById.value))
|
||||
total.value = result.projectCasePage?.total ?? 0
|
||||
previewTotal.value = total.value
|
||||
}
|
||||
|
||||
async function probeTotal(): Promise<void> {
|
||||
const { bo } = buildQuery(query.value)
|
||||
const result = await getCaseList(bo, 1, 1)
|
||||
previewTotal.value = result.projectCasePage?.total ?? 0
|
||||
}
|
||||
|
||||
function scheduleProbe(): void {
|
||||
if (probeTimer) clearTimeout(probeTimer)
|
||||
probeTimer = setTimeout(() => void probeTotal(), PROBE_DELAY)
|
||||
}
|
||||
|
||||
function setQuery(next: CaseQuery): void {
|
||||
query.value = next
|
||||
scheduleProbe()
|
||||
}
|
||||
|
||||
/** 确定筛选(重置页码后取数) */
|
||||
async function search(): Promise<void> {
|
||||
query.value = { ...query.value, page: 1 }
|
||||
await fetchCases()
|
||||
}
|
||||
|
||||
/** 重置全部条件 */
|
||||
async function resetQuery(): Promise<void> {
|
||||
query.value = createDefaultQuery()
|
||||
await fetchCases()
|
||||
@@ -55,29 +152,53 @@ export const useCasesStore = defineStore('cases', () => {
|
||||
await fetchCases()
|
||||
}
|
||||
|
||||
async function toggleFavoriteOnly(): Promise<void> {
|
||||
query.value = { ...query.value, favoriteOnly: !query.value.favoriteOnly, page: 1 }
|
||||
await fetchCases()
|
||||
}
|
||||
|
||||
async function fetchCaseDetail(id: string): Promise<void> {
|
||||
currentCase.value = await getCaseDetail(id)
|
||||
const vo = await getCaseDetail(id)
|
||||
currentCase.value = vo ? toCase(vo, typeNameById.value) : null
|
||||
}
|
||||
|
||||
async function toggleFavorite(id: string): Promise<void> {
|
||||
const updated = await toggleFavoriteApi(id)
|
||||
await collectCase(id)
|
||||
const index = list.value.findIndex((item) => item.id === id)
|
||||
if (index >= 0) list.value[index] = updated
|
||||
if (currentCase.value?.id === id) currentCase.value = updated
|
||||
if (index >= 0) {
|
||||
const target = list.value[index]
|
||||
const delta = target.isFavorite ? -1 : 1
|
||||
list.value[index] = { ...target, isFavorite: !target.isFavorite, favoriteCount: target.favoriteCount + delta }
|
||||
}
|
||||
if (currentCase.value?.id === id) {
|
||||
const delta = currentCase.value.isFavorite ? -1 : 1
|
||||
currentCase.value = {
|
||||
...currentCase.value,
|
||||
isFavorite: !currentCase.value.isFavorite,
|
||||
favoriteCount: currentCase.value.favoriteCount + delta,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
query,
|
||||
list,
|
||||
total,
|
||||
counts,
|
||||
previewTotal,
|
||||
typeOptions,
|
||||
typeNameById,
|
||||
industryOptions,
|
||||
scaleOptions,
|
||||
caseTotal,
|
||||
currentCase,
|
||||
fetchCases,
|
||||
fetchCounts,
|
||||
fetchCategories,
|
||||
ensureCategories,
|
||||
setQuery,
|
||||
search,
|
||||
resetQuery,
|
||||
changePage,
|
||||
toggleFavoriteOnly,
|
||||
fetchCaseDetail,
|
||||
toggleFavorite,
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ function createDefaultQuery(): ContractQuery {
|
||||
}
|
||||
}
|
||||
|
||||
/** 合同中心数据(列表 / 筛选 / 分页 / 详情 / 签署二维码) */
|
||||
export const useContractStore = defineStore('contract', () => {
|
||||
const query = ref<ContractQuery>(createDefaultQuery())
|
||||
const list = ref<Contract[]>([])
|
||||
@@ -39,13 +38,11 @@ export const useContractStore = defineStore('contract', () => {
|
||||
query.value = next
|
||||
}
|
||||
|
||||
/** 确定筛选(重置页码后取数) */
|
||||
async function search(): Promise<void> {
|
||||
query.value = { ...query.value, page: 1 }
|
||||
await fetchList()
|
||||
}
|
||||
|
||||
/** 重置全部条件 */
|
||||
async function resetQuery(): Promise<void> {
|
||||
query.value = createDefaultQuery()
|
||||
await fetchList()
|
||||
@@ -56,7 +53,6 @@ export const useContractStore = defineStore('contract', () => {
|
||||
await fetchList()
|
||||
}
|
||||
|
||||
/** 获取/刷新签署二维码(refresh=true 时走刷新接口) */
|
||||
async function fetchSignQr(id: string, refresh = false): Promise<void> {
|
||||
const result = refresh ? await refreshSignQr(id) : await getSignQr(id)
|
||||
signExpires.value = result.expiresInSeconds
|
||||
|
||||
@@ -1,60 +1,211 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { Demand } from '@/types/business/demand'
|
||||
import type { FileInfo } from '@/types/api'
|
||||
import type {
|
||||
BizTypeOption,
|
||||
Demand,
|
||||
DemandAttachment,
|
||||
DemandDraftVo,
|
||||
DictOption,
|
||||
} from '@/types/business/demand'
|
||||
import {
|
||||
getBizTypeDict,
|
||||
getBudgetRangeDict,
|
||||
getDraftDemand,
|
||||
getOssUrls,
|
||||
getSubmissionBizTypes,
|
||||
resetDraft as resetDraftApi,
|
||||
saveDraft as saveDraftApi,
|
||||
submitDemand,
|
||||
uploadDemandFile,
|
||||
} from '@/api/demand'
|
||||
import { getUserProfile } from '@/api/user'
|
||||
import { toAssetUrl } from '@/utils/asset'
|
||||
import { toBizTypeOption, toBizTypeOptionFromDict } from '@/utils/bizTypeMeta'
|
||||
import { isManualBudget, resolveDraftBudget } from '@/utils/budgetMeta'
|
||||
import { formatDate } from '@/utils/format'
|
||||
import { getDraftDemand, saveDraft as saveDraftApi, submitDemand } from '@/api/demand'
|
||||
|
||||
function createDefaultForm(): Demand {
|
||||
return {
|
||||
businessType: 'internet',
|
||||
id: null,
|
||||
bizType: '',
|
||||
projectName: '',
|
||||
attachments: [],
|
||||
coreDescription: '',
|
||||
expectedDuration: '',
|
||||
requirementDesc: '',
|
||||
budgetRange: '',
|
||||
userName: '王永志',
|
||||
userPhone: '1866****9618',
|
||||
remark: '',
|
||||
status: 'draft',
|
||||
budgetManual: false,
|
||||
developmentCycle: '',
|
||||
supplementaryRemarks: '',
|
||||
userName: '',
|
||||
userPhone: '',
|
||||
attachmentInfo: [],
|
||||
}
|
||||
}
|
||||
|
||||
function sortDict(list: DictOption[] | null): DictOption[] {
|
||||
return [...(list ?? [])].sort((a, b) => (a.dictSort ?? 0) - (b.dictSort ?? 0))
|
||||
}
|
||||
|
||||
function mergeDraft(base: Demand, draft: DemandDraftVo, budgetDict: DictOption[]): Demand {
|
||||
const budget = resolveDraftBudget(draft.budgetRange, budgetDict)
|
||||
return {
|
||||
id: draft.id ?? null,
|
||||
bizType: draft.bizType || base.bizType,
|
||||
projectName: draft.projectName || base.projectName,
|
||||
requirementDesc: draft.requirementDesc || base.requirementDesc,
|
||||
budgetRange: budget.value,
|
||||
budgetManual: budget.manual,
|
||||
developmentCycle: draft.developmentCycle ?? base.developmentCycle,
|
||||
supplementaryRemarks: draft.supplementaryRemarks || base.supplementaryRemarks,
|
||||
userName: draft.userName || base.userName,
|
||||
userPhone: draft.userPhone || base.userPhone,
|
||||
attachmentInfo: draft.attachmentInfo ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOssUrlMap(raws: (string | null)[]): Promise<Record<string, string>> {
|
||||
const ids = [...new Set(
|
||||
raws.map((item) => (item ?? '').trim()).filter((item) => item && !toAssetUrl(item)),
|
||||
)]
|
||||
if (ids.length === 0) return {}
|
||||
try {
|
||||
const list = await getOssUrls(ids)
|
||||
return Object.fromEntries(list.map((item) => [String(item.ossId), item.url]))
|
||||
}
|
||||
catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
/** 需求提报数据(M3:表单 / 步骤 / 提交与草稿) */
|
||||
export const useDemandStore = defineStore('demand', () => {
|
||||
const form = ref<Demand>(createDefaultForm())
|
||||
const step = ref(1)
|
||||
const bizTypeOptions = ref<BizTypeOption[]>([])
|
||||
const budgetDict = ref<DictOption[]>([])
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const submitting = ref(false)
|
||||
const uploading = ref(false)
|
||||
|
||||
function setStep(next: number): void {
|
||||
step.value = next
|
||||
const budgetOptions = computed<DictOption[]>(() => {
|
||||
const list = budgetDict.value
|
||||
return [
|
||||
...list.filter((item) => !isManualBudget(item.dictLabel)),
|
||||
...list.filter((item) => isManualBudget(item.dictLabel)),
|
||||
]
|
||||
})
|
||||
|
||||
async function loadBizTypeOptions(): Promise<BizTypeOption[]> {
|
||||
try {
|
||||
const submissions = await getSubmissionBizTypes()
|
||||
if (submissions.length > 0) {
|
||||
const ossUrlById = await loadOssUrlMap(submissions.map((item) => item.logoUrl))
|
||||
const options = submissions
|
||||
.map((item) => toBizTypeOption(item, ossUrlById))
|
||||
.filter((item): item is BizTypeOption => item !== null)
|
||||
if (options.length > 0) return options
|
||||
}
|
||||
}
|
||||
catch {
|
||||
}
|
||||
const dict = sortDict(await getBizTypeDict())
|
||||
return dict.map((item) => toBizTypeOptionFromDict(item.dictValue, item.dictLabel))
|
||||
}
|
||||
|
||||
function patchForm(partial: Partial<Demand>): void {
|
||||
form.value = { ...form.value, ...partial }
|
||||
}
|
||||
|
||||
/** File → FileInfo(mock:url 为空,uploadTime 取当前时间) */
|
||||
function addAttachment(file: File): void {
|
||||
const info: FileInfo = {
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
uploadTime: formatDate(new Date()),
|
||||
url: '',
|
||||
async function init(): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
const [bizTypes, budgetDictRes, profile, draft] = await Promise.all([
|
||||
loadBizTypeOptions(),
|
||||
getBudgetRangeDict(),
|
||||
getUserProfile().catch(() => null),
|
||||
getDraftDemand(),
|
||||
])
|
||||
bizTypeOptions.value = bizTypes
|
||||
budgetDict.value = sortDict(budgetDictRes)
|
||||
|
||||
const base = createDefaultForm()
|
||||
if (profile) {
|
||||
base.userName = profile.nickName || profile.userName || ''
|
||||
base.userPhone = profile.phoneNumber || ''
|
||||
}
|
||||
form.value = draft ? mergeDraft(base, draft, budgetDict.value) : base
|
||||
}
|
||||
catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '需求提报数据加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
form.value.attachments = [...form.value.attachments, info]
|
||||
}
|
||||
|
||||
function removeAttachment(target: FileInfo): void {
|
||||
form.value.attachments = form.value.attachments.filter(
|
||||
(item) => !(item.name === target.name && item.uploadTime === target.uploadTime),
|
||||
async function addAttachment(file: File): Promise<void> {
|
||||
if (uploading.value) return
|
||||
uploading.value = true
|
||||
try {
|
||||
const oss = await uploadDemandFile(file)
|
||||
const suffix = (oss.fileSuffix || file.name.split('.').pop() || '').replace(/^\./, '')
|
||||
const item: DemandAttachment = {
|
||||
ossId: oss.ossId,
|
||||
originalName: oss.originalName || file.name,
|
||||
url: oss.url,
|
||||
fileSize: file.size,
|
||||
type: suffix,
|
||||
createTime: formatDate(new Date()),
|
||||
}
|
||||
form.value.attachmentInfo = [...form.value.attachmentInfo, item]
|
||||
ElMessage.success(`「${item.originalName}」上传成功`)
|
||||
}
|
||||
catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '附件上传失败')
|
||||
}
|
||||
finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function removeAttachment(target: DemandAttachment): void {
|
||||
form.value.attachmentInfo = form.value.attachmentInfo.filter(
|
||||
(item) => !(item.ossId === target.ossId && item.id === target.id),
|
||||
)
|
||||
}
|
||||
|
||||
/** 提交需求:必填校验通过后调 api,成功返回 true(页面可据此跳转) */
|
||||
async function saveDraft(): Promise<void> {
|
||||
saving.value = true
|
||||
try {
|
||||
await saveDraftApi(form.value)
|
||||
ElMessage.success('草稿已保存')
|
||||
}
|
||||
catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '草稿保存失败')
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function reset(): Promise<void> {
|
||||
saving.value = true
|
||||
try {
|
||||
await resetDraftApi()
|
||||
const base = createDefaultForm()
|
||||
base.userName = form.value.userName
|
||||
base.userPhone = form.value.userPhone
|
||||
form.value = base
|
||||
ElMessage.success('已重置')
|
||||
}
|
||||
catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '重置失败')
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(): Promise<boolean> {
|
||||
if (!form.value.businessType) {
|
||||
if (!form.value.bizType) {
|
||||
ElMessage.warning('请选择业务类型')
|
||||
return false
|
||||
}
|
||||
@@ -62,15 +213,22 @@ export const useDemandStore = defineStore('demand', () => {
|
||||
ElMessage.warning('请填写项目名称')
|
||||
return false
|
||||
}
|
||||
if (!form.value.coreDescription.trim()) {
|
||||
if (!form.value.requirementDesc.trim()) {
|
||||
ElMessage.warning('请填写核心需求描述')
|
||||
return false
|
||||
}
|
||||
loading.value = true
|
||||
if (form.value.budgetManual && !form.value.budgetRange.trim()) {
|
||||
ElMessage.warning('请填写预算金额,或改选一个预设档位')
|
||||
return false
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await submitDemand(form.value)
|
||||
form.value.status = 'submitted'
|
||||
ElMessage.success('提交后我们的专业顾问将在一个工作日内与您联系')
|
||||
const base = createDefaultForm()
|
||||
base.userName = form.value.userName
|
||||
base.userPhone = form.value.userPhone
|
||||
form.value = base
|
||||
ElMessage.success('提交成功,专业顾问将在一个工作日内与您联系')
|
||||
return true
|
||||
}
|
||||
catch (error) {
|
||||
@@ -78,49 +236,24 @@ export const useDemandStore = defineStore('demand', () => {
|
||||
return false
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存草稿到 sessionStorage */
|
||||
async function saveDraft(): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
await saveDraftApi(form.value)
|
||||
form.value.status = 'draft'
|
||||
ElMessage.success('草稿已保存')
|
||||
}
|
||||
catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '草稿保存失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 恢复本地草稿(无草稿则保持默认表单) */
|
||||
async function loadDraft(): Promise<void> {
|
||||
const draft = await getDraftDemand()
|
||||
if (draft) form.value = { ...createDefaultForm(), ...draft }
|
||||
}
|
||||
|
||||
/** 重置表单与步骤 */
|
||||
function reset(): void {
|
||||
form.value = createDefaultForm()
|
||||
step.value = 1
|
||||
}
|
||||
|
||||
return {
|
||||
form,
|
||||
step,
|
||||
bizTypeOptions,
|
||||
budgetOptions,
|
||||
loading,
|
||||
setStep,
|
||||
saving,
|
||||
submitting,
|
||||
uploading,
|
||||
init,
|
||||
patchForm,
|
||||
addAttachment,
|
||||
removeAttachment,
|
||||
submit,
|
||||
saveDraft,
|
||||
loadDraft,
|
||||
reset,
|
||||
submit,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -10,20 +10,25 @@ import type {
|
||||
InvoiceTitle,
|
||||
InvoiceTitlePayload,
|
||||
PayMethod,
|
||||
PayOrderResponse,
|
||||
Payment,
|
||||
PaymentResult,
|
||||
} from '@/types/business/finance'
|
||||
import {
|
||||
applyInvoice as applyInvoiceApi,
|
||||
createAlipayPageOrder as createAlipayPageOrderApi,
|
||||
createInvoiceTitle,
|
||||
createPayment as createPaymentApi,
|
||||
createWechatNativeOrder as createWechatNativeOrderApi,
|
||||
deleteInvoiceTitle,
|
||||
exportReport as exportReportApi,
|
||||
getBillDetail,
|
||||
getBillList,
|
||||
getCheckoutBills,
|
||||
getFinanceStats,
|
||||
getInvoiceList,
|
||||
getInvoiceTitles,
|
||||
getPlanPayResult,
|
||||
getPayResult,
|
||||
setDefaultTitle as setDefaultTitleApi,
|
||||
switchPaymentMethod as switchPaymentMethodApi,
|
||||
@@ -38,35 +43,31 @@ function createDefaultInvoiceQuery(): InvoiceQuery {
|
||||
return { keyword: '', status: 'all', page: 1, pageSize: 6 }
|
||||
}
|
||||
|
||||
/** 财务中心数据(统计/账单/支付/发票/抬头) */
|
||||
export const useFinanceStore = defineStore('finance', () => {
|
||||
// —— 统计
|
||||
const stats = ref<FinanceStats | null>(null)
|
||||
|
||||
// —— 账单列表
|
||||
const list = ref<Bill[]>([])
|
||||
const total = ref(0)
|
||||
const query = ref<BillQuery>(createDefaultBillQuery())
|
||||
const currentBill = ref<Bill | null>(null)
|
||||
|
||||
// —— 支付
|
||||
const payment = ref<Payment | null>(null)
|
||||
const result = ref<PaymentResult | null>(null)
|
||||
|
||||
// —— 发票
|
||||
const checkoutBills = ref<Bill[]>([])
|
||||
const checkoutLoading = ref(false)
|
||||
const payOrder = ref<PayOrderResponse | null>(null)
|
||||
|
||||
const invoices = ref<Invoice[]>([])
|
||||
const invoiceTotal = ref(0)
|
||||
const invoiceQuery = ref<InvoiceQuery>(createDefaultInvoiceQuery())
|
||||
|
||||
// —— 发票抬头
|
||||
const titles = ref<InvoiceTitle[]>([])
|
||||
|
||||
// —— 统计
|
||||
async function fetchStats(period?: string): Promise<void> {
|
||||
stats.value = await getFinanceStats(period)
|
||||
}
|
||||
|
||||
// —— 账单
|
||||
async function fetchList(): Promise<void> {
|
||||
const pageData: PageResult<Bill> = await getBillList(query.value)
|
||||
list.value = pageData.list
|
||||
@@ -77,13 +78,11 @@ export const useFinanceStore = defineStore('finance', () => {
|
||||
query.value = next
|
||||
}
|
||||
|
||||
/** 确定筛选(重置页码后取数) */
|
||||
async function search(): Promise<void> {
|
||||
query.value = { ...query.value, page: 1 }
|
||||
await fetchList()
|
||||
}
|
||||
|
||||
/** 重置全部条件 */
|
||||
async function resetQuery(): Promise<void> {
|
||||
query.value = createDefaultBillQuery()
|
||||
await fetchList()
|
||||
@@ -98,21 +97,41 @@ export const useFinanceStore = defineStore('finance', () => {
|
||||
currentBill.value = await getBillDetail(id)
|
||||
}
|
||||
|
||||
// —— 支付
|
||||
async function createPayment(billId: string, method: PayMethod): Promise<void> {
|
||||
payment.value = await createPaymentApi(billId, method)
|
||||
}
|
||||
|
||||
/** 切换支付方式(刷新二维码) */
|
||||
async function switchPaymentMethod(billId: string, method: PayMethod): Promise<void> {
|
||||
payment.value = await switchPaymentMethodApi(billId, method)
|
||||
}
|
||||
|
||||
async function fetchResult(billId: string): Promise<void> {
|
||||
result.value = await getPayResult(billId)
|
||||
async function fetchResult(billId: string, requirementId = ''): Promise<void> {
|
||||
result.value = requirementId
|
||||
? await getPlanPayResult(requirementId, billId)
|
||||
: await getPayResult(billId)
|
||||
}
|
||||
|
||||
async function loadCheckout(requirementId: string, ids: string[] = []): Promise<void> {
|
||||
checkoutLoading.value = true
|
||||
try {
|
||||
checkoutBills.value = await getCheckoutBills(requirementId, ids)
|
||||
} finally {
|
||||
checkoutLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function payByWechatNative(orderNo: string, amount: number, billIds: string[]): Promise<void> {
|
||||
payOrder.value = await createWechatNativeOrderApi(orderNo, amount, billIds)
|
||||
}
|
||||
|
||||
async function payByAlipayPage(orderNo: string, amount: number, billIds: string[]): Promise<void> {
|
||||
payOrder.value = await createAlipayPageOrderApi(orderNo, amount, billIds)
|
||||
}
|
||||
|
||||
function clearPayOrder(): void {
|
||||
payOrder.value = null
|
||||
}
|
||||
|
||||
// —— 发票
|
||||
async function fetchInvoices(): Promise<void> {
|
||||
const pageData: PageResult<Invoice> = await getInvoiceList(invoiceQuery.value)
|
||||
invoices.value = pageData.list
|
||||
@@ -123,7 +142,6 @@ export const useFinanceStore = defineStore('finance', () => {
|
||||
invoiceQuery.value = next
|
||||
}
|
||||
|
||||
/** 发票确定筛选(重置页码后取数) */
|
||||
async function searchInvoices(): Promise<void> {
|
||||
invoiceQuery.value = { ...invoiceQuery.value, page: 1 }
|
||||
await fetchInvoices()
|
||||
@@ -134,14 +152,12 @@ export const useFinanceStore = defineStore('finance', () => {
|
||||
await fetchInvoices()
|
||||
}
|
||||
|
||||
/** 申请开票(状态 → 开票中) */
|
||||
async function applyInvoice(id: string): Promise<void> {
|
||||
const updated = await applyInvoiceApi(id)
|
||||
const index = invoices.value.findIndex((item) => item.id === id)
|
||||
if (index >= 0) invoices.value[index] = updated
|
||||
}
|
||||
|
||||
// —— 发票抬头
|
||||
async function fetchTitles(): Promise<void> {
|
||||
titles.value = await getInvoiceTitles()
|
||||
}
|
||||
@@ -165,7 +181,6 @@ export const useFinanceStore = defineStore('finance', () => {
|
||||
titles.value = await setDefaultTitleApi(id)
|
||||
}
|
||||
|
||||
// —— 报表导出
|
||||
async function exportReport(): Promise<void> {
|
||||
await exportReportApi()
|
||||
}
|
||||
@@ -178,6 +193,9 @@ export const useFinanceStore = defineStore('finance', () => {
|
||||
currentBill,
|
||||
payment,
|
||||
result,
|
||||
checkoutBills,
|
||||
checkoutLoading,
|
||||
payOrder,
|
||||
invoices,
|
||||
invoiceTotal,
|
||||
invoiceQuery,
|
||||
@@ -192,6 +210,10 @@ export const useFinanceStore = defineStore('finance', () => {
|
||||
createPayment,
|
||||
switchPaymentMethod,
|
||||
fetchResult,
|
||||
loadCheckout,
|
||||
payByWechatNative,
|
||||
payByAlipayPage,
|
||||
clearPayOrder,
|
||||
fetchInvoices,
|
||||
setInvoiceQuery,
|
||||
searchInvoices,
|
||||
|
||||
@@ -1,46 +1,222 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import type { BusinessType } from '@/types/api'
|
||||
import type { Case } from '@/types/business/cases'
|
||||
import type { BusinessLine, HomeStats } from '@/types/business/home'
|
||||
import { getBusinessDetail, getBusinessLines, getFeaturedCases, getHomeStats } from '@/api/home'
|
||||
import type {
|
||||
Banner,
|
||||
BannerVo,
|
||||
BusinessLine,
|
||||
HomeStats,
|
||||
ProjectStatusCount,
|
||||
ProjectSubmissionFlat,
|
||||
ProjectSubmissionInfo,
|
||||
ProjectSubmissionVo,
|
||||
ServiceContentBlock,
|
||||
ServiceType,
|
||||
} from '@/types/business/home'
|
||||
import type { PortalEntryGroup, PortalEntryVo } from '@/types/business/home'
|
||||
import {
|
||||
getCarouselList,
|
||||
getPortalEntryList,
|
||||
getProjectStatusCount,
|
||||
getServiceTypeDetail,
|
||||
getServiceTypeList,
|
||||
switchPortalEntryGroup as switchPortalEntryGroupApi,
|
||||
} from '@/api/home'
|
||||
import { getFeaturedCases as getFeaturedCasesApi } from '@/api/cases'
|
||||
import { toCase } from '@/utils/caseVo'
|
||||
import { formatBusinessType, toBusinessType } from '@/utils/format'
|
||||
import { useCasesStore } from '@/stores/modules/cases'
|
||||
|
||||
function stripHtml(html: string): string {
|
||||
return html
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/<\/(p|div|li|h[1-6]|tr)>/gi, '\n')
|
||||
.replace(/<[^>]*>/g, '')
|
||||
.replace(/ /gi, ' ')
|
||||
.replace(/&/gi, '&')
|
||||
.replace(/</gi, '<')
|
||||
.replace(/>/gi, '>')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function toBanners(list: BannerVo[]): Banner[] {
|
||||
return list
|
||||
.filter((item) => item.enabled !== false && Boolean(item.imageUrl))
|
||||
.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0))
|
||||
.map((item) => ({
|
||||
id: item.id ?? 0,
|
||||
imageUrl: item.imageUrl ?? '',
|
||||
}))
|
||||
}
|
||||
|
||||
function toBusinessLine(vo: ProjectSubmissionVo): BusinessLine {
|
||||
const serviceTypes: ServiceType[] = (vo.serviceTypes ?? []).map((item) => ({
|
||||
label: item.serviceName ?? '',
|
||||
desc: '',
|
||||
icon: item.iconUrl ?? '',
|
||||
}))
|
||||
const serviceContent = (vo.backgrounds ?? [])
|
||||
.map((item) => stripHtml(item.content ?? ''))
|
||||
.filter((text) => text.length > 0)
|
||||
.join('\n\n')
|
||||
const contents: ServiceContentBlock[] = (vo.backgrounds ?? [])
|
||||
.map((item) => ({ title: '', content: item.content ?? '' }))
|
||||
.filter((block) => block.content.trim().length > 0)
|
||||
const subtitle = vo.subtitle ?? ''
|
||||
return {
|
||||
id: toBusinessType(vo.businessType),
|
||||
name: vo.title ?? '',
|
||||
desc: subtitle,
|
||||
slogan: subtitle,
|
||||
icon: vo.logoUrl ?? '',
|
||||
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 ?? '',
|
||||
serviceContent,
|
||||
contents,
|
||||
serviceTypes,
|
||||
video: '',
|
||||
}
|
||||
}
|
||||
|
||||
const EMPTY_STATUS: ProjectStatusCount = {
|
||||
projectCount: 0,
|
||||
ongoingProjectCount: 0,
|
||||
pendingProjectCount: 0,
|
||||
}
|
||||
|
||||
/** 首页数据(数据层:取数 + 状态,页面只调本 store) */
|
||||
export const useHomeStore = defineStore('home', () => {
|
||||
const casesStore = useCasesStore()
|
||||
|
||||
const stats = ref<HomeStats>({ pending: 0, inProgress: 0, total: 0 })
|
||||
const banners = ref<Banner[]>([])
|
||||
const businessLines = ref<BusinessLine[]>([])
|
||||
const featuredCases = ref<Case[]>([])
|
||||
const currentBusiness = ref<BusinessLine | null>(null)
|
||||
|
||||
/** 首页初始化:看板 + 业务线 + 案例精选 */
|
||||
const submissions = ref<ProjectSubmissionVo[]>([])
|
||||
|
||||
const portalEntries = ref<PortalEntryVo[]>([])
|
||||
|
||||
const portalEntriesLoaded = ref(false)
|
||||
|
||||
const quickEntries = computed(() =>
|
||||
portalEntries.value.filter((item) => item.groupType === '1'),
|
||||
)
|
||||
|
||||
const otherEntries = computed(() =>
|
||||
portalEntries.value.filter((item) => item.groupType !== '1'),
|
||||
)
|
||||
|
||||
async function fetchPortalEntries(): Promise<void> {
|
||||
try {
|
||||
const list = await getPortalEntryList()
|
||||
portalEntries.value = list ?? []
|
||||
portalEntriesLoaded.value = true
|
||||
} catch {
|
||||
portalEntries.value = []
|
||||
portalEntriesLoaded.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function switchEntryGroup(id: number, type: PortalEntryGroup): Promise<void> {
|
||||
await switchPortalEntryGroupApi(id, type)
|
||||
await fetchPortalEntries()
|
||||
}
|
||||
|
||||
async function ensureSubmissions(): Promise<void> {
|
||||
if (submissions.value.length === 0) {
|
||||
submissions.value = await getServiceTypeList()
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchHomeData(): Promise<void> {
|
||||
const [homeStats, lines, cases] = await Promise.all([
|
||||
getHomeStats(),
|
||||
getBusinessLines(),
|
||||
getFeaturedCases(),
|
||||
const [statusCount, submissionList, cases, carouselList] = await Promise.all([
|
||||
getProjectStatusCount(),
|
||||
getServiceTypeList(),
|
||||
getFeaturedCasesApi(),
|
||||
getCarouselList().catch(() => [] as BannerVo[]),
|
||||
casesStore.ensureCategories(),
|
||||
])
|
||||
stats.value = homeStats
|
||||
businessLines.value = lines
|
||||
featuredCases.value = cases
|
||||
const status = statusCount ?? EMPTY_STATUS
|
||||
stats.value = {
|
||||
pending: status.pendingProjectCount ?? 0,
|
||||
inProgress: status.ongoingProjectCount ?? 0,
|
||||
total: status.projectCount ?? 0,
|
||||
}
|
||||
submissions.value = submissionList
|
||||
businessLines.value = submissionList.map(toBusinessLine)
|
||||
featuredCases.value = cases.map((vo) => toCase(vo, casesStore.typeNameById))
|
||||
banners.value = toBanners(carouselList ?? [])
|
||||
await fetchPortalEntries()
|
||||
}
|
||||
|
||||
/** 按业务类型切换案例精选 */
|
||||
async function fetchFeaturedCases(category: BusinessType | 'all' = 'all'): Promise<void> {
|
||||
featuredCases.value = await getFeaturedCases(category)
|
||||
if (category === 'all') {
|
||||
featuredCases.value = (await getFeaturedCasesApi()).map((vo) => toCase(vo, casesStore.typeNameById))
|
||||
return
|
||||
}
|
||||
await casesStore.ensureCategories()
|
||||
const name = formatBusinessType(category)
|
||||
const typeId = Object.entries(casesStore.typeNameById).find(([, value]) => value === name)?.[0]
|
||||
if (!typeId) {
|
||||
featuredCases.value = []
|
||||
return
|
||||
}
|
||||
featuredCases.value = (await getFeaturedCasesApi(typeId)).map((vo) => toCase(vo, casesStore.typeNameById))
|
||||
}
|
||||
|
||||
/** 业务详情 */
|
||||
async function fetchBusinessDetail(type: BusinessType): Promise<void> {
|
||||
currentBusiness.value = await getBusinessDetail(type)
|
||||
await ensureSubmissions()
|
||||
const target = submissions.value.find((item) => toBusinessType(item.businessType) === type)
|
||||
if (!target || target.id == null) {
|
||||
currentBusiness.value = null
|
||||
return
|
||||
}
|
||||
currentBusiness.value = toBusinessLineFromInfo(await getServiceTypeDetail(target.id))
|
||||
}
|
||||
|
||||
return {
|
||||
stats,
|
||||
banners,
|
||||
businessLines,
|
||||
featuredCases,
|
||||
currentBusiness,
|
||||
portalEntries,
|
||||
portalEntriesLoaded,
|
||||
quickEntries,
|
||||
otherEntries,
|
||||
fetchHomeData,
|
||||
fetchFeaturedCases,
|
||||
fetchBusinessDetail,
|
||||
fetchPortalEntries,
|
||||
switchEntryGroup,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
markRead as markReadApi,
|
||||
} from '@/api/message'
|
||||
|
||||
/** tab 计数结构 */
|
||||
export interface MessageTabCounts {
|
||||
all: number
|
||||
在线客服: number
|
||||
@@ -29,7 +28,6 @@ function createDefaultQuery(): MessageQuery {
|
||||
}
|
||||
}
|
||||
|
||||
/** 消息中心数据(列表 / 筛选 / 分页 / 已读) */
|
||||
export const useMessageStore = defineStore('message', () => {
|
||||
const query = ref<MessageQuery>(createDefaultQuery())
|
||||
const list = ref<Message[]>([])
|
||||
@@ -59,13 +57,11 @@ export const useMessageStore = defineStore('message', () => {
|
||||
query.value = next
|
||||
}
|
||||
|
||||
/** 确定搜索(重置页码后取数) */
|
||||
async function search(): Promise<void> {
|
||||
query.value = { ...query.value, page: 1 }
|
||||
await fetchList()
|
||||
}
|
||||
|
||||
/** 重置全部条件 */
|
||||
async function resetQuery(): Promise<void> {
|
||||
query.value = createDefaultQuery()
|
||||
await fetchList()
|
||||
@@ -76,7 +72,6 @@ export const useMessageStore = defineStore('message', () => {
|
||||
await fetchList()
|
||||
}
|
||||
|
||||
/** 全部标记已读 */
|
||||
async function markAllRead(): Promise<void> {
|
||||
await markAllReadApi()
|
||||
ElMessage.success('已全部标记为已读')
|
||||
|
||||
100
src/stores/modules/oss.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { fetchOssPreviewBlob } from '@/api/oss'
|
||||
import { resolvePreviewKind, PREVIEW_TEXT_MAX_BYTES, type PreviewKind } from '@/utils/filePreview'
|
||||
import type { FileInfo } from '@/types/api'
|
||||
|
||||
export const useOssStore = defineStore('oss', () => {
|
||||
const visible = ref(false)
|
||||
const file = ref<FileInfo | null>(null)
|
||||
const kind = ref<PreviewKind>('unsupported')
|
||||
const objectUrl = ref<string | null>(null)
|
||||
const textContent = ref<string | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const truncated = ref(false)
|
||||
const totalBytes = ref(0)
|
||||
|
||||
function revokeObjectUrl(): void {
|
||||
if (objectUrl.value && objectUrl.value.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(objectUrl.value)
|
||||
}
|
||||
objectUrl.value = null
|
||||
}
|
||||
|
||||
async function openPreview(target: FileInfo): Promise<void> {
|
||||
closePreview()
|
||||
file.value = target
|
||||
visible.value = true
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
textContent.value = null
|
||||
truncated.value = false
|
||||
totalBytes.value = 0
|
||||
|
||||
const previewKind = resolvePreviewKind(target.name)
|
||||
kind.value = previewKind
|
||||
|
||||
if (previewKind === 'unsupported') {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (target.ossId === undefined || target.ossId === null) {
|
||||
if (previewKind === 'image' && target.url) {
|
||||
objectUrl.value = target.url
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
error.value = '该文件暂不支持在线预览,请使用下载'
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const blob = await fetchOssPreviewBlob(target.ossId)
|
||||
if (previewKind === 'text') {
|
||||
const total = blob.size
|
||||
if (total > PREVIEW_TEXT_MAX_BYTES) {
|
||||
textContent.value = await blob.slice(0, PREVIEW_TEXT_MAX_BYTES).text()
|
||||
truncated.value = true
|
||||
totalBytes.value = total
|
||||
} else {
|
||||
textContent.value = await blob.text()
|
||||
}
|
||||
} else {
|
||||
objectUrl.value = URL.createObjectURL(blob)
|
||||
}
|
||||
loading.value = false
|
||||
} catch {
|
||||
error.value = '预览加载失败,请使用下载'
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function closePreview(): void {
|
||||
revokeObjectUrl()
|
||||
visible.value = false
|
||||
file.value = null
|
||||
kind.value = 'unsupported'
|
||||
textContent.value = null
|
||||
truncated.value = false
|
||||
totalBytes.value = 0
|
||||
loading.value = false
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
return {
|
||||
visible,
|
||||
file,
|
||||
kind,
|
||||
objectUrl,
|
||||
textContent,
|
||||
loading,
|
||||
error,
|
||||
truncated,
|
||||
totalBytes,
|
||||
openPreview,
|
||||
closePreview,
|
||||
}
|
||||
})
|
||||
@@ -51,9 +51,7 @@ function createDefaultFavoritesQuery(): FavoritesQuery {
|
||||
return { page: 1, pageSize: 8, keyword: '', category: 'all' }
|
||||
}
|
||||
|
||||
/** 个人中心数据(资料/安全/地址/收藏/资质/反馈/帮助) */
|
||||
export const useProfileStore = defineStore('profile', () => {
|
||||
// ── 账号资料 ──
|
||||
const profile = ref<Profile | null>(null)
|
||||
|
||||
async function fetchProfile(): Promise<void> {
|
||||
@@ -65,7 +63,6 @@ export const useProfileStore = defineStore('profile', () => {
|
||||
ElMessage.success('昵称修改成功')
|
||||
}
|
||||
|
||||
// ── 账号安全 ──
|
||||
async function changePasswordAction(params: ChangePasswordParams): Promise<void> {
|
||||
await changePassword(params)
|
||||
profile.value = await getProfile()
|
||||
@@ -97,7 +94,6 @@ export const useProfileStore = defineStore('profile', () => {
|
||||
ElMessage.success('注销成功')
|
||||
}
|
||||
|
||||
// ── 收货地址 ──
|
||||
const addresses = ref<Address[]>([])
|
||||
const addressTotal = ref(0)
|
||||
const addressQuery = ref<ListQuery>(createDefaultAddressQuery())
|
||||
@@ -138,7 +134,6 @@ export const useProfileStore = defineStore('profile', () => {
|
||||
await fetchAddresses()
|
||||
}
|
||||
|
||||
// ── 我的收藏 ──
|
||||
const favorites = ref<Case[]>([])
|
||||
const favoritesTotal = ref(0)
|
||||
const favoritesCounts = ref<Partial<Record<BusinessType, number>>>({})
|
||||
@@ -169,7 +164,6 @@ export const useProfileStore = defineStore('profile', () => {
|
||||
await fetchFavorites()
|
||||
}
|
||||
|
||||
// ── 资质管理 ──
|
||||
const qualifications = ref<Qualification[]>([])
|
||||
const qualificationTotal = ref(0)
|
||||
const qualificationQuery = ref<ListQuery>(createDefaultQualificationQuery())
|
||||
@@ -204,7 +198,6 @@ export const useProfileStore = defineStore('profile', () => {
|
||||
await fetchQualifications()
|
||||
}
|
||||
|
||||
// ── 意见反馈 / 帮助中心 ──
|
||||
async function submitFeedbackAction(feedback: Feedback): Promise<void> {
|
||||
await submitFeedbackApi(feedback)
|
||||
}
|
||||
|
||||
@@ -1,90 +1,202 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { PageResult } from '@/types/api'
|
||||
import type { AfterSalesPayload, Project, ProjectQuery } from '@/types/business/project'
|
||||
import type {
|
||||
AfterSalesAttachment,
|
||||
AfterSalesPayload,
|
||||
Project,
|
||||
ProjectQuery,
|
||||
ProjectStatistics,
|
||||
ProjectTypeCount,
|
||||
RdProjectVo,
|
||||
RequirementDetailBundle,
|
||||
} from '@/types/business/project'
|
||||
import {
|
||||
cancelAfterSales as cancelAfterSalesApi,
|
||||
deleteProjectFile,
|
||||
getProjectDetail,
|
||||
getCompletedProjects,
|
||||
getProjectDetailBundle,
|
||||
getProjectDetailTab,
|
||||
getProjectFilterConditions,
|
||||
getProjectList,
|
||||
submitAfterSales as submitAfterSalesApi,
|
||||
uploadProjectFile,
|
||||
uploadAfterSalesFile as uploadAfterSalesFileApi,
|
||||
} from '@/api/project'
|
||||
import type { ProjectDetailTab } from '@/api/project'
|
||||
import { toProjectDetail } from '@/utils/projectVo'
|
||||
|
||||
function createDefaultQuery(): ProjectQuery {
|
||||
return {
|
||||
keyword: '',
|
||||
businessType: 'all',
|
||||
status: 'all',
|
||||
businessTypes: [],
|
||||
status: '',
|
||||
timeShortcut: 'all',
|
||||
page: 1,
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
pageNum: 1,
|
||||
pageSize: 8,
|
||||
}
|
||||
}
|
||||
|
||||
/** 项目中心数据(列表 / 筛选 / 分页 / 详情 / 文件 / 售后) */
|
||||
function buildTypeCounts(
|
||||
statistics: ProjectStatistics | null | undefined,
|
||||
total: number,
|
||||
): ProjectTypeCount[] {
|
||||
const counts = statistics?.businessTypeCounts ?? []
|
||||
return [
|
||||
{ value: 'all', label: '全部', count: statistics?.total ?? total },
|
||||
...counts.map((item) => ({
|
||||
value: String(item.businessTypeId),
|
||||
label: item.businessTypeLabel,
|
||||
count: item.count,
|
||||
})),
|
||||
]
|
||||
}
|
||||
|
||||
export const useProjectStore = defineStore('project', () => {
|
||||
const query = ref<ProjectQuery>(createDefaultQuery())
|
||||
const list = ref<Project[]>([])
|
||||
const list = ref<RdProjectVo[]>([])
|
||||
const total = ref(0)
|
||||
const current = ref<Project | null>(null)
|
||||
const typeCounts = ref<ProjectTypeCount[]>([{ value: 'all', label: '全部', count: 0 }])
|
||||
let filterConditionsLoaded = false
|
||||
|
||||
const completedProjects = ref<RdProjectVo[]>([])
|
||||
|
||||
const detailBundle = ref<RequirementDetailBundle | null>(null)
|
||||
let detailSeq = 0
|
||||
const tabSeq: Record<ProjectDetailTab, number> = {
|
||||
progress: 0,
|
||||
files: 0,
|
||||
finance: 0,
|
||||
afterSales: 0,
|
||||
}
|
||||
const loadingTab = ref<ProjectDetailTab | ''>('')
|
||||
|
||||
async function ensureFilterConditions(): Promise<void> {
|
||||
if (filterConditionsLoaded) return
|
||||
try {
|
||||
typeCounts.value = buildTypeCounts(await getProjectFilterConditions(), total.value)
|
||||
filterConditionsLoaded = true
|
||||
}
|
||||
catch (error) {
|
||||
console.warn('[project] 业务类型筛选条件加载失败', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchList(): Promise<void> {
|
||||
const result: PageResult<Project> = await getProjectList(query.value)
|
||||
list.value = result.list
|
||||
total.value = result.total
|
||||
const result = await getProjectList(query.value)
|
||||
list.value = result?.rows ?? []
|
||||
total.value = result?.total ?? 0
|
||||
}
|
||||
|
||||
async function fetchCompletedProjects(): Promise<void> {
|
||||
try {
|
||||
completedProjects.value = await getCompletedProjects()
|
||||
}
|
||||
catch (error) {
|
||||
console.warn('[project] 已完成项目列表加载失败', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDetail(id: string): Promise<void> {
|
||||
current.value = await getProjectDetail(id)
|
||||
const seq = ++detailSeq
|
||||
loadingTab.value = ''
|
||||
try {
|
||||
const bundle = await getProjectDetailBundle(id)
|
||||
if (seq !== detailSeq) return
|
||||
if (!bundle) {
|
||||
detailBundle.value = null
|
||||
current.value = null
|
||||
return
|
||||
}
|
||||
detailBundle.value = bundle
|
||||
current.value = toProjectDetail(bundle)
|
||||
}
|
||||
catch (error) {
|
||||
if (seq !== detailSeq) return
|
||||
detailBundle.value = null
|
||||
current.value = null
|
||||
console.warn('[project] 项目详情加载失败', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshTab(tab: ProjectDetailTab): Promise<void> {
|
||||
const requestId = current.value?.id
|
||||
if (!requestId) return
|
||||
const seq = ++tabSeq[tab]
|
||||
loadingTab.value = tab
|
||||
try {
|
||||
const vo = await getProjectDetailTab(requestId, tab)
|
||||
if (seq !== tabSeq[tab] || current.value?.id !== requestId) return
|
||||
if (!vo || !detailBundle.value) return
|
||||
const next: RequirementDetailBundle = { ...detailBundle.value }
|
||||
switch (tab) {
|
||||
case 'progress':
|
||||
next.progress = vo as RequirementDetailBundle['progress']
|
||||
break
|
||||
case 'files':
|
||||
next.files = vo as RequirementDetailBundle['files']
|
||||
break
|
||||
case 'finance':
|
||||
next.finance = vo as RequirementDetailBundle['finance']
|
||||
break
|
||||
case 'afterSales':
|
||||
next.afterSales = vo as RequirementDetailBundle['afterSales']
|
||||
break
|
||||
}
|
||||
detailBundle.value = next
|
||||
current.value = toProjectDetail(next)
|
||||
}
|
||||
catch (error) {
|
||||
console.warn(`[project] 页签「${tab}」数据刷新失败`, error)
|
||||
}
|
||||
finally {
|
||||
if (seq === tabSeq[tab]) loadingTab.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function setQuery(next: ProjectQuery): void {
|
||||
query.value = next
|
||||
}
|
||||
|
||||
/** 确定筛选(重置页码后取数) */
|
||||
async function search(): Promise<void> {
|
||||
query.value = { ...query.value, page: 1 }
|
||||
async function load(): Promise<void> {
|
||||
try {
|
||||
await fetchList()
|
||||
}
|
||||
catch (error) {
|
||||
console.warn('[project] 项目列表加载失败', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function search(): Promise<void> {
|
||||
query.value = { ...query.value, pageNum: 1 }
|
||||
await load()
|
||||
}
|
||||
|
||||
/** 重置全部条件 */
|
||||
async function resetQuery(): Promise<void> {
|
||||
query.value = createDefaultQuery()
|
||||
await fetchList()
|
||||
await load()
|
||||
}
|
||||
|
||||
async function changePage(page: number): Promise<void> {
|
||||
query.value = { ...query.value, page }
|
||||
await fetchList()
|
||||
async function changePage(pageNum: number): Promise<void> {
|
||||
query.value = { ...query.value, pageNum }
|
||||
await load()
|
||||
}
|
||||
|
||||
/** 上传项目文件(追加到当前详情) */
|
||||
async function uploadFile(file: File): Promise<void> {
|
||||
const info = await uploadProjectFile(file)
|
||||
current.value?.files.push(info)
|
||||
async function uploadAfterSalesFile(file: File): Promise<AfterSalesAttachment> {
|
||||
return uploadAfterSalesFileApi(file)
|
||||
}
|
||||
|
||||
/** 删除当前详情中的项目文件 */
|
||||
async function deleteFile(index: number): Promise<void> {
|
||||
await deleteProjectFile(index)
|
||||
current.value?.files.splice(index, 1)
|
||||
async function submitAfterSales(id: string, payload: AfterSalesPayload): Promise<void> {
|
||||
await submitAfterSalesApi(id, payload)
|
||||
ElMessage.success('售后申请已提交')
|
||||
if (current.value?.id === id) await refreshTab('afterSales')
|
||||
}
|
||||
|
||||
/** 提交售后工单 */
|
||||
async function submitAfterSales(payload: AfterSalesPayload): Promise<void> {
|
||||
if (!current.value) return
|
||||
current.value.afterSales = await submitAfterSalesApi(current.value.id, payload)
|
||||
ElMessage.success('已提交售后工单')
|
||||
}
|
||||
|
||||
/** 取消售后工单 */
|
||||
async function cancelAfterSales(): Promise<void> {
|
||||
if (!current.value) return
|
||||
current.value.afterSales = await cancelAfterSalesApi(current.value.id)
|
||||
ElMessage.success('已取消售后工单')
|
||||
async function cancelAfterSales(id: string): Promise<void> {
|
||||
await cancelAfterSalesApi(id)
|
||||
ElMessage.success('售后申请已撤回')
|
||||
if (current.value?.id === id) await refreshTab('afterSales')
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -92,14 +204,20 @@ export const useProjectStore = defineStore('project', () => {
|
||||
list,
|
||||
total,
|
||||
current,
|
||||
typeCounts,
|
||||
ensureFilterConditions,
|
||||
fetchList,
|
||||
completedProjects,
|
||||
fetchCompletedProjects,
|
||||
fetchDetail,
|
||||
refreshTab,
|
||||
loadingTab,
|
||||
setQuery,
|
||||
load,
|
||||
search,
|
||||
resetQuery,
|
||||
changePage,
|
||||
uploadFile,
|
||||
deleteFile,
|
||||
uploadAfterSalesFile,
|
||||
submitAfterSales,
|
||||
cancelAfterSales,
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
uploadChatFile,
|
||||
} from '@/api/service'
|
||||
|
||||
/** 在线客服会话数据(消息列表 / 发送状态) */
|
||||
export const useServiceStore = defineStore('service', () => {
|
||||
const messages = ref<ChatMessage[]>([])
|
||||
const sending = ref(false)
|
||||
@@ -17,7 +16,6 @@ export const useServiceStore = defineStore('service', () => {
|
||||
messages.value = await getChatHistory()
|
||||
}
|
||||
|
||||
/** 发送文本:先推入用户消息,600ms 后推入客服自动回复 */
|
||||
async function send(content: string): Promise<void> {
|
||||
const trimmed = content.trim()
|
||||
if (!trimmed || sending.value) return
|
||||
@@ -37,7 +35,6 @@ export const useServiceStore = defineStore('service', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** 发送文件卡片消息 */
|
||||
async function sendFile(file: File): Promise<void> {
|
||||
if (sending.value) return
|
||||
sending.value = true
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
/** 站点公共数据(站点配置、全局 loading),非页面级 */
|
||||
export const useSiteStore = defineStore('site', () => {
|
||||
const globalLoading = ref(false)
|
||||
|
||||
|
||||
@@ -4,21 +4,20 @@ import { ElMessage } from 'element-plus'
|
||||
import type {
|
||||
LoginByAccountParams,
|
||||
LoginBySmsParams,
|
||||
LoginResult,
|
||||
RegisterParams,
|
||||
ResetPasswordParams,
|
||||
User,
|
||||
} from '@/types/business/user'
|
||||
import {
|
||||
loginByAccount as loginByAccountApi,
|
||||
loginBySms as loginBySmsApi,
|
||||
register as registerApi,
|
||||
resetPassword as resetPasswordApi,
|
||||
sendSmsCode as sendSmsCodeApi,
|
||||
} from '@/api/auth'
|
||||
import {
|
||||
loginByAccountApi,
|
||||
registerByAccountApi,
|
||||
} from '@/api/auth'
|
||||
import { storage } from '@/utils/storage'
|
||||
|
||||
/** 用户登录态(M4:token 持久化 key 'token',路由守卫读取同一 key) */
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const token = ref<string | null>(storage.get<string>('token'))
|
||||
const user = ref<User | null>(null)
|
||||
@@ -26,7 +25,6 @@ export const useUserStore = defineStore('user', () => {
|
||||
|
||||
const isLoggedIn = computed(() => Boolean(token.value))
|
||||
|
||||
/** 发送短信验证码 */
|
||||
async function sendSmsCode(phone: string): Promise<void> {
|
||||
try {
|
||||
await sendSmsCodeApi(phone)
|
||||
@@ -38,19 +36,17 @@ export const useUserStore = defineStore('user', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** 登录成功统一落库(token 持久化) */
|
||||
function applyLogin(result: LoginResult): void {
|
||||
token.value = result.token
|
||||
user.value = result.user
|
||||
storage.set('token', result.token)
|
||||
function applyLogin(payload: { token: string; user: User | null }): void {
|
||||
token.value = payload.token
|
||||
user.value = payload.user
|
||||
storage.set('token', payload.token)
|
||||
}
|
||||
|
||||
/** 账号密码登录 */
|
||||
async function loginByAccount(params: LoginByAccountParams): Promise<boolean> {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await loginByAccountApi(params)
|
||||
applyLogin(result)
|
||||
const result = await loginByAccountApi({ username: params.account, password: params.password })
|
||||
applyLogin({ token: result.access_token, user: null })
|
||||
ElMessage.success('登录成功')
|
||||
return true
|
||||
}
|
||||
@@ -63,12 +59,11 @@ export const useUserStore = defineStore('user', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** 手机验证码登录 */
|
||||
async function loginBySms(params: LoginBySmsParams): Promise<boolean> {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await loginBySmsApi(params)
|
||||
applyLogin(result)
|
||||
applyLogin({ token: result.token, user: result.user })
|
||||
ElMessage.success('登录成功')
|
||||
return true
|
||||
}
|
||||
@@ -81,11 +76,10 @@ export const useUserStore = defineStore('user', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** 注册 */
|
||||
async function register(params: RegisterParams): Promise<boolean> {
|
||||
async function register(params: { username: string; password: string; confirmPassword: string }): Promise<boolean> {
|
||||
loading.value = true
|
||||
try {
|
||||
await registerApi(params)
|
||||
await registerByAccountApi({ username: params.username, password: params.password })
|
||||
ElMessage.success('注册成功,请登录')
|
||||
return true
|
||||
}
|
||||
@@ -98,7 +92,6 @@ export const useUserStore = defineStore('user', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** 重置密码 */
|
||||
async function resetPassword(params: ResetPasswordParams): Promise<boolean> {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -115,7 +108,6 @@ export const useUserStore = defineStore('user', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** 退出登录(清除持久化 token) */
|
||||
function logout(): void {
|
||||
token.value = null
|
||||
user.value = null
|
||||
|
||||
@@ -1,10 +1,134 @@
|
||||
// 通用工具类
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: @container-width;
|
||||
margin: 0 auto;
|
||||
padding: 0 @spacing-base;
|
||||
padding: 0
|
||||
clamp(@spacing-base, calc((100% - @page-content) / 2), @page-gutter);
|
||||
}
|
||||
|
||||
.container--home {
|
||||
padding: 0 @home-gutter;
|
||||
|
||||
// 1:1 还原基准为 1920。仅当视口明显窄于 1920 时才收窄留白。
|
||||
@media (max-width: 1600px) {
|
||||
padding: 0 @spacing-lg;
|
||||
}
|
||||
}
|
||||
|
||||
.auth-page {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
background: @white;
|
||||
|
||||
// 设计稿 #36/#38/#39 的左侧品牌区为固定 784px 渐变块。
|
||||
&__brand {
|
||||
.flex(column, flex-start, flex-start);
|
||||
flex: 0 0 @login-brand-width;
|
||||
padding: 304px 0 0 257px;
|
||||
background: linear-gradient(
|
||||
160deg,
|
||||
@auth-gradient-from 0%,
|
||||
@auth-gradient-to 100%
|
||||
);
|
||||
color: @white;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
&__logo {
|
||||
margin: 0;
|
||||
font-size: 40px;
|
||||
font-weight: 700;
|
||||
line-height: 58px;
|
||||
color: @white;
|
||||
}
|
||||
|
||||
&__key {
|
||||
margin: 26px 0 0;
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.5px;
|
||||
color: @white;
|
||||
}
|
||||
|
||||
&__slogan {
|
||||
// logo(116) + key 上距 26 + key(20) = 162;设计稿 slogan 顶 715,
|
||||
// 故与 key 底(446+20=466) 间距 249px。
|
||||
margin: 249px 0 0;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
line-height: 29px;
|
||||
letter-spacing: 8px;
|
||||
color: @white;
|
||||
}
|
||||
|
||||
// 设计稿 #39:右上角「游客模式」12px,标注框 (1840,24) 64.2×16
|
||||
// (64.2 含 Sketch 文本框内边距,实际文字 48px),右留白 16px。
|
||||
&__guest {
|
||||
position: absolute;
|
||||
top: 24px;
|
||||
right: 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 16px;
|
||||
color: #1f2937;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: @primary-strong;
|
||||
}
|
||||
}
|
||||
|
||||
&__main {
|
||||
.flex(column, center, center);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
// 设计稿三页的标题顶分别为 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 {
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// 样式入口(main.ts 引入;variables/mixins 由 vite additionalData 自动注入)
|
||||
@import './reset.less';
|
||||
@import './common.less';
|
||||
|
||||
// 视觉基准:蓝湖设计稿 1920px,1:1 还原,不做整体缩放。
|
||||
// 如需适配窄屏,请在各页面内部用媒体查询处理,不要在根元素缩放。
|
||||
html {
|
||||
zoom: 1;
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
// 混合宏(flex、文本省略、滚动条)
|
||||
|
||||
.flex(@direction: row; @justify: flex-start; @align: center) {
|
||||
display: flex;
|
||||
@@ -35,3 +34,46 @@
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.section-title() {
|
||||
position: relative;
|
||||
padding-left: 15px;
|
||||
font-size: @font-size-lg;
|
||||
font-weight: 600;
|
||||
line-height: 20px;
|
||||
color: @heading-color;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
width: 5px;
|
||||
height: 20px;
|
||||
border-radius: @radius-round;
|
||||
background: @project-accent;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
.text-control() {
|
||||
width: 100%;
|
||||
padding: 0 @spacing-base;
|
||||
border: 1px solid @border-color;
|
||||
border-radius: @radius-sm;
|
||||
background: @white;
|
||||
font-size: @font-size-base;
|
||||
font-family: inherit;
|
||||
color: @heading-color;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&:focus {
|
||||
border-color: @primary-color;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: @text-color-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// 样式重置
|
||||
|
||||
*,
|
||||
*::before,
|
||||
@@ -14,8 +13,8 @@ body {
|
||||
|
||||
body {
|
||||
color: @text-color;
|
||||
font-family: 'PingFang SC', 'Microsoft YaHei', -apple-system, 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: @font-size-base;
|
||||
font-family: 'PingFang SC', 'Source Han Sans SC', 'Microsoft YaHei', -apple-system, 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
background-color: @bg-color;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
|
||||
@@ -1,56 +1,277 @@
|
||||
// ★ less 变量唯一来源(颜色/间距/字号/圆角)
|
||||
// 注:蓝湖设计稿到位前为原型近似值,到位后按标注精确校准
|
||||
|
||||
// 品牌色
|
||||
@primary-color: #3370ff;
|
||||
@primary-color: #1f67ef;
|
||||
@primary-strong: #024fff;
|
||||
@primary-hover: #598bff;
|
||||
@primary-light: #eaf1ff;
|
||||
|
||||
// 功能色
|
||||
@success-color: #34c724;
|
||||
@success-color: #16a34a;
|
||||
@warning-color: #ff8f1f;
|
||||
@danger-color: #f54a45;
|
||||
@danger-color: #ef4444;
|
||||
@info-color: #8a94a6;
|
||||
|
||||
// 文字色
|
||||
@heading-color: #1f2733;
|
||||
@text-color: #525e71;
|
||||
@text-color-secondary: #8a94a6;
|
||||
@text-color-disabled: #b8c0cc;
|
||||
@heading-color: #121212;
|
||||
@text-color: #333333;
|
||||
@text-color-secondary: #666666;
|
||||
@text-color-disabled: #999999;
|
||||
|
||||
// 背景 / 边框
|
||||
@bg-color: #f5f6f8;
|
||||
@bg-color-light: #fafbfc;
|
||||
@border-color: #e4e7ec;
|
||||
@border-color-dark: #cdd3dd;
|
||||
@border-color-btn: #d8dbdf;
|
||||
@white: #ffffff;
|
||||
@black: #000000;
|
||||
@header-bg: #f4f5f9;
|
||||
@header-border: #d8d8d8;
|
||||
@footer-bg: #0c1321;
|
||||
|
||||
// 圆角
|
||||
@radius-sm: 4px;
|
||||
@radius-base: 8px;
|
||||
@radius-lg: 12px;
|
||||
@radius-round: 999px;
|
||||
|
||||
// 间距
|
||||
@spacing-xs: 4px;
|
||||
@spacing-sm: 8px;
|
||||
@spacing-base: 16px;
|
||||
@spacing-lg: 24px;
|
||||
@spacing-xl: 32px;
|
||||
@spacing-xxl: 48px;
|
||||
/** 内容区底边到页脚的最小留白(老大指定 200px,不在 4/8 梯度内) */
|
||||
@spacing-page-bottom: 200px;
|
||||
|
||||
// 字号
|
||||
@font-size-xs: 10px;
|
||||
@font-size-sm: 12px;
|
||||
@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-xxl: 28px;
|
||||
@font-size-title: 28px;
|
||||
@font-size-xxl: 36px;
|
||||
@font-size-hero: 48px;
|
||||
@font-size-hero-lg: 60px;
|
||||
|
||||
// 布局
|
||||
@container-width: 1200px;
|
||||
@header-height: 64px;
|
||||
@footer-height: 240px;
|
||||
@banner-title-color: #12171a;
|
||||
@btn-gradient-from: #a061fe;
|
||||
@btn-gradient-to: #045dff;
|
||||
@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: 481px;
|
||||
@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-lg: 0 4px 16px rgba(31, 39, 51, 0.12);
|
||||
|
||||
@filter-selected-bg: #e7eeff;
|
||||
@pill-bg: #ededed;
|
||||
@pill-selected-bg: #075cff;
|
||||
@chips-panel-bg: #eaf1ff;
|
||||
@input-bg: #ededed;
|
||||
@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: #999999;
|
||||
@cases-icon: #797979;
|
||||
@cases-reset-bg: #f0f0f0;
|
||||
@cases-card-border: #e1dfdf;
|
||||
@cases-card-shadow: 0 4px 10px rgba(0, 0, 0, 0.08);
|
||||
@cases-page-active-bg: #e0e9fa;
|
||||
|
||||
@demand-selected-bg: #024fff;
|
||||
@demand-accent: #024fff;
|
||||
@demand-secondary-bg: #075cff;
|
||||
|
||||
@project-table-head-bg: #e8e8e8;
|
||||
@project-table-line: #bbbbbb;
|
||||
|
||||
@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: #999999;
|
||||
@demand-upload-bg: #ededed;
|
||||
@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: #dde1e7;
|
||||
@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: #222222;
|
||||
@contract-cell-text: #333333;
|
||||
@contract-line: #999999;
|
||||
@contract-table-head-bg: #ededed;
|
||||
@contract-placeholder: #b3b9c4;
|
||||
@contract-reset-bg: #eef0f3;
|
||||
@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: #f1f3f5;
|
||||
@finance-field-bg: #f3f4f6;
|
||||
@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: #dde1e7;
|
||||
@profile-radius: 6px;
|
||||
@profile-page-bg: #f4f6f8;
|
||||
@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;
|
||||
|
||||
@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;
|
||||
|
||||
7
src/types/api.d.ts
vendored
@@ -1,31 +1,26 @@
|
||||
// 接口通用结构(PRD 4.8)
|
||||
|
||||
/** 业务类型(跨模块共享) */
|
||||
export type BusinessType = 'internet' | 'hardware' | 'iot' | 'manufacturing' | 'other'
|
||||
|
||||
/** 统一响应结构 */
|
||||
export interface ApiResponse<T> {
|
||||
code: number
|
||||
msg: string
|
||||
data: T
|
||||
}
|
||||
|
||||
/** 分页结果 */
|
||||
export interface PageResult<T> {
|
||||
list: T[]
|
||||
total: number
|
||||
}
|
||||
|
||||
/** 通用分页查询参数 */
|
||||
export interface PageQuery {
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
/** 附件/文件信息 */
|
||||
export interface FileInfo {
|
||||
name: string
|
||||
size: number
|
||||
uploadTime: string
|
||||
url: string
|
||||
ossId?: string | number
|
||||
}
|
||||
|
||||
11
src/types/business/auth.d.ts
vendored
@@ -1,23 +1,12 @@
|
||||
// 账号密码登录/注册真实接口类型(对接 vermis-maker /auth/*)
|
||||
// 归位于 types/business/auth.d.ts,与 api/auth.ts 对应
|
||||
|
||||
/** 登录成功返回,对应后端 LoginVo(字段名以 @JsonProperty 为准) */
|
||||
export interface LoginResult {
|
||||
/** 授权令牌(后端字段 access_token) */
|
||||
access_token: string
|
||||
/** 刷新令牌 */
|
||||
refresh_token?: string
|
||||
/** access_token 有效期(秒) */
|
||||
expire_in?: number
|
||||
/** refresh_token 有效期(秒) */
|
||||
refresh_expire_in?: number
|
||||
/** 应用 id */
|
||||
client_id?: string
|
||||
/** 令牌权限 */
|
||||
scope?: string
|
||||
/** 用户 openid */
|
||||
openid?: string
|
||||
}
|
||||
|
||||
/** 注册返回:后端 R<Void>,无 data */
|
||||
export type RegisterResult = void
|
||||
|
||||
135
src/types/business/cases.d.ts
vendored
@@ -1,11 +1,24 @@
|
||||
// 案例实体(PRD 4.1,home/cases/profile.favorites 共享)
|
||||
//
|
||||
|
||||
import type { BusinessType } from '@/types/api'
|
||||
|
||||
/** 项目规模区间 */
|
||||
export type ScaleRange = 'lt50w' | '50to100w' | '100to300w' | '300to500w' | 'gt500w'
|
||||
export interface CaseTypeOption {
|
||||
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 {
|
||||
id: string
|
||||
title: string
|
||||
@@ -13,26 +26,124 @@ export interface Case {
|
||||
cover: string
|
||||
finishDate: string
|
||||
teamSize: number
|
||||
/** 规模展示值(如 '300万'、'10-50万'),筛选区间见 CaseQuery.scales */
|
||||
scale: string
|
||||
industry: string
|
||||
duration: string
|
||||
clientType: string
|
||||
background: string
|
||||
painPoints: string
|
||||
requirements: string
|
||||
deliverables: string
|
||||
sections: CaseSection[]
|
||||
attachments?: CaseAttachment[]
|
||||
video: string
|
||||
isFavorite: boolean
|
||||
favoriteCount: number
|
||||
}
|
||||
|
||||
/** 案例筛选条件 */
|
||||
export interface CaseQuery {
|
||||
category: BusinessType | 'all'
|
||||
categories: string[]
|
||||
industries: string[]
|
||||
scales: ScaleRange[]
|
||||
scales: ScaleLimit[]
|
||||
dateRange: [string, string] | null
|
||||
favoriteOnly: boolean
|
||||
keyword: string
|
||||
page: 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
|
||||
}
|
||||
|
||||
4
src/types/business/contract.d.ts
vendored
@@ -1,11 +1,8 @@
|
||||
// 合同中心实体(PRD M6)
|
||||
|
||||
import type { FileInfo } from '@/types/api'
|
||||
|
||||
/** 签署状态 */
|
||||
export type SignStatus = '履行中' | '已过期' | '待签署'
|
||||
|
||||
/** 合同 */
|
||||
export interface Contract {
|
||||
id: string
|
||||
contractNo: string
|
||||
@@ -21,7 +18,6 @@ export interface Contract {
|
||||
files: FileInfo[]
|
||||
}
|
||||
|
||||
/** 合同列表查询条件 */
|
||||
export interface ContractQuery {
|
||||
keyword: string
|
||||
page: number
|
||||
|
||||
90
src/types/business/demand.d.ts
vendored
@@ -1,20 +1,86 @@
|
||||
// 需求提报实体(M3,page-architecture)
|
||||
|
||||
import type { BusinessType, FileInfo } from '@/types/api'
|
||||
export type BizIconKey = 'code' | 'chip' | 'iot' | 'factory' | 'other'
|
||||
|
||||
/** 预算范围 */
|
||||
export type BudgetRange = 'lt1w' | '1to5w' | '5to10w' | 'gt10w' | ''
|
||||
export interface DictOption {
|
||||
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 {
|
||||
businessType: BusinessType
|
||||
id?: string | number | null
|
||||
bizType: string
|
||||
projectName: string
|
||||
attachments: FileInfo[]
|
||||
coreDescription: string
|
||||
expectedDuration: number | ''
|
||||
budgetRange: 'lt1w' | '1to5w' | '5to10w' | 'gt10w' | ''
|
||||
requirementDesc: string
|
||||
budgetRange: string
|
||||
budgetManual: boolean
|
||||
developmentCycle: number | ''
|
||||
supplementaryRemarks: string
|
||||
userName: string
|
||||
userPhone: string
|
||||
remark: string
|
||||
status: 'draft' | 'submitted'
|
||||
attachmentInfo: DemandAttachment[]
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
71
src/types/business/finance.d.ts
vendored
@@ -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 PayMethod = 'wechat' | 'alipay'
|
||||
|
||||
/** 账单 */
|
||||
export interface PayOrderResponse {
|
||||
channel: string
|
||||
scene: string
|
||||
codeUrl?: string
|
||||
formBody?: string
|
||||
orderString?: string
|
||||
h5Url?: string
|
||||
}
|
||||
|
||||
export interface Bill {
|
||||
id: string
|
||||
/** 分期计划名,如 '第一期·预付款 比例33%' */
|
||||
planName: string
|
||||
billNo: string
|
||||
tradeNo?: string
|
||||
@@ -20,9 +67,10 @@ export interface Bill {
|
||||
amount: number
|
||||
status: BillStatus
|
||||
createTime: string
|
||||
payChannel?: string
|
||||
payTime?: string
|
||||
}
|
||||
|
||||
/** 账单查询条件 */
|
||||
export interface BillQuery {
|
||||
keyword: string
|
||||
status: BillStatus | 'all'
|
||||
@@ -30,7 +78,6 @@ export interface BillQuery {
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
/** 支付单(二维码) */
|
||||
export interface Payment {
|
||||
billId: string
|
||||
method: PayMethod
|
||||
@@ -38,7 +85,6 @@ export interface Payment {
|
||||
qrExpiresInSeconds: number
|
||||
}
|
||||
|
||||
/** 支付结果 */
|
||||
export interface PaymentResult {
|
||||
method: PayMethod
|
||||
amount: number
|
||||
@@ -47,7 +93,6 @@ export interface PaymentResult {
|
||||
bills: Bill[]
|
||||
}
|
||||
|
||||
/** 发票 */
|
||||
export interface Invoice {
|
||||
id: string
|
||||
planName: string
|
||||
@@ -59,7 +104,6 @@ export interface Invoice {
|
||||
applyTime: string
|
||||
}
|
||||
|
||||
/** 发票查询条件 */
|
||||
export interface InvoiceQuery {
|
||||
keyword: string
|
||||
status: InvoiceStatus | 'all'
|
||||
@@ -67,7 +111,6 @@ export interface InvoiceQuery {
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
/** 发票抬头 */
|
||||
export interface InvoiceTitle {
|
||||
id: string
|
||||
type: '企业单位' | '非企业单位'
|
||||
@@ -76,14 +119,12 @@ export interface InvoiceTitle {
|
||||
isDefault: boolean
|
||||
}
|
||||
|
||||
/** 新增/编辑发票抬头入参 */
|
||||
export interface InvoiceTitlePayload {
|
||||
type: InvoiceTitle['type']
|
||||
title: string
|
||||
taxNo: string
|
||||
}
|
||||
|
||||
/** 财务统计 */
|
||||
export interface FinanceStats {
|
||||
totalExpense: number
|
||||
month: string
|
||||
|
||||
134
src/types/business/home.d.ts
vendored
@@ -1,27 +1,34 @@
|
||||
// 首页实体(PRD 3.1/3.2,page-architecture M1)
|
||||
//
|
||||
|
||||
import type { BusinessType } from '@/types/api'
|
||||
|
||||
/** 数据看板 */
|
||||
export interface HomeStats {
|
||||
pending: number
|
||||
inProgress: number
|
||||
total: number
|
||||
}
|
||||
|
||||
/** 快捷入口 */
|
||||
export interface Banner {
|
||||
id: number
|
||||
imageUrl: string
|
||||
}
|
||||
|
||||
export interface QuickLink {
|
||||
label: string
|
||||
path: string
|
||||
}
|
||||
|
||||
/** 服务类型子项 */
|
||||
export interface ServiceType {
|
||||
label: string
|
||||
desc: string
|
||||
icon?: string
|
||||
}
|
||||
|
||||
export interface ServiceContentBlock {
|
||||
title: string
|
||||
content: string
|
||||
}
|
||||
|
||||
/** 业务线(首页业务卡片 + 业务详情页共用) */
|
||||
export interface BusinessLine {
|
||||
id: BusinessType
|
||||
name: string
|
||||
@@ -29,6 +36,123 @@ export interface BusinessLine {
|
||||
slogan: string
|
||||
icon: string
|
||||
serviceContent: string
|
||||
contents: ServiceContentBlock[]
|
||||
serviceTypes: ServiceType[]
|
||||
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'
|
||||
|
||||
5
src/types/business/message.d.ts
vendored
@@ -1,24 +1,19 @@
|
||||
// 消息实体(PRD M8 消息中心)
|
||||
|
||||
import type { FileInfo } from '@/types/api'
|
||||
|
||||
/** 消息类型 */
|
||||
export type MessageType = '在线客服' | '状态通知' | '系统通知'
|
||||
|
||||
/** 站内消息 */
|
||||
export interface Message {
|
||||
id: string
|
||||
type: MessageType
|
||||
title: string
|
||||
summary: string
|
||||
/** 正文纯文本,详情页 pre-wrap 渲染 */
|
||||
content: string
|
||||
publishTime: string
|
||||
isRead: boolean
|
||||
attachments: FileInfo[]
|
||||
}
|
||||
|
||||
/** 消息查询条件 */
|
||||
export interface MessageQuery {
|
||||
keyword: string
|
||||
type: MessageType | 'all'
|
||||
|
||||
14
src/types/business/profile.d.ts
vendored
@@ -1,23 +1,18 @@
|
||||
// 个人中心实体(M10:security/address/favorites/qualifications/feedback/help/about/hotline 共享)
|
||||
|
||||
import type { BusinessType, FileInfo } from '@/types/api'
|
||||
|
||||
/** 账号资料 */
|
||||
export interface Profile {
|
||||
nickname: string
|
||||
phone: string
|
||||
email: string
|
||||
/** 密码上次修改时间(展示值,如 '15天前') */
|
||||
passwordModifiedAt: string
|
||||
}
|
||||
|
||||
/** 修改密码入参 */
|
||||
export interface ChangePasswordParams {
|
||||
code: string
|
||||
password: string
|
||||
}
|
||||
|
||||
/** 收货地址 */
|
||||
export interface Address {
|
||||
id: string
|
||||
receiverName: string
|
||||
@@ -27,7 +22,6 @@ export interface Address {
|
||||
isDefault: boolean
|
||||
}
|
||||
|
||||
/** 资质类型 */
|
||||
export type QualificationType =
|
||||
| '营业执照'
|
||||
| '生产许可证'
|
||||
@@ -36,10 +30,8 @@ export type QualificationType =
|
||||
| '授权书'
|
||||
| '其他资质'
|
||||
|
||||
/** 资质审核状态 */
|
||||
export type QualificationStatus = '已认证' | '审核中' | '未通过'
|
||||
|
||||
/** 资质 */
|
||||
export interface Qualification {
|
||||
id: string
|
||||
type: QualificationType
|
||||
@@ -52,35 +44,29 @@ export interface Qualification {
|
||||
attachments: FileInfo[]
|
||||
}
|
||||
|
||||
/** 资质新增/编辑提交载荷(id 存在为编辑) */
|
||||
export type QualificationInput = Omit<Qualification, 'id' | 'status'> & { id?: string }
|
||||
|
||||
/** 意见反馈 */
|
||||
export interface Feedback {
|
||||
title: string
|
||||
description: string
|
||||
attachments: FileInfo[]
|
||||
}
|
||||
|
||||
/** 列表分页查询 */
|
||||
export interface ListQuery {
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
/** 我的收藏查询(关键字 + 分类 + 分页) */
|
||||
export interface FavoritesQuery extends ListQuery {
|
||||
keyword: string
|
||||
category: BusinessType | 'all'
|
||||
}
|
||||
|
||||
/** 帮助中心 FAQ 条目 */
|
||||
export interface HelpFaqItem {
|
||||
q: string
|
||||
a: string
|
||||
}
|
||||
|
||||
/** 帮助中心 FAQ 分组 */
|
||||
export interface HelpFaqGroup {
|
||||
title: string
|
||||
items: HelpFaqItem[]
|
||||
|
||||
289
src/types/business/project.d.ts
vendored
@@ -1,28 +1,85 @@
|
||||
// 项目中心实体(PRD M5)
|
||||
|
||||
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 {
|
||||
name: string
|
||||
status: '待开始' | '进行中' | '已完成'
|
||||
time?: string
|
||||
}
|
||||
|
||||
/** 团队成员 */
|
||||
export interface TeamMember {
|
||||
role: string
|
||||
name: string
|
||||
avatarChar: string
|
||||
}
|
||||
|
||||
/** 项目账单(付款计划) */
|
||||
export interface ProjectBill {
|
||||
id: string
|
||||
source: 'bill' | 'plan'
|
||||
planName: string
|
||||
periodRatio?: string
|
||||
billNo: string
|
||||
amount: number
|
||||
status: '待支付' | '已支付' | '未到期'
|
||||
@@ -30,7 +87,6 @@ export interface ProjectBill {
|
||||
projectName: string
|
||||
}
|
||||
|
||||
/** 售后工单 */
|
||||
export interface AfterSales {
|
||||
status: '无' | '处理中'
|
||||
type: string
|
||||
@@ -40,20 +96,30 @@ export interface AfterSales {
|
||||
createTime: string
|
||||
}
|
||||
|
||||
/** 售后提交入参 */
|
||||
export interface AfterSalesPayload {
|
||||
type: string
|
||||
description: string
|
||||
remark: string
|
||||
attachments: FileInfo[]
|
||||
export interface AfterSalesAttachment extends FileInfo {
|
||||
ossId: string | number
|
||||
}
|
||||
|
||||
export interface AfterSalesUploadVo {
|
||||
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 {
|
||||
id: string
|
||||
projectNo: string
|
||||
projectName: string
|
||||
businessType: BusinessType
|
||||
businessTypeName: string
|
||||
progress: number
|
||||
status: ProjectStatus
|
||||
submitTime: string
|
||||
@@ -68,12 +134,199 @@ export interface Project {
|
||||
afterSales?: AfterSales
|
||||
}
|
||||
|
||||
/** 项目列表查询条件 */
|
||||
export interface ProjectTypeCount {
|
||||
value: string
|
||||
label: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface ProjectQuery {
|
||||
keyword: string
|
||||
businessType: BusinessType | 'all'
|
||||
status: ProjectStatus | 'all'
|
||||
businessTypes: string[]
|
||||
status: string
|
||||
timeShortcut: 'all' | '3m' | '6m' | '1y'
|
||||
page: number
|
||||
startDate: string
|
||||
endDate: string
|
||||
pageNum: 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
|
||||
}
|
||||
|
||||
4
src/types/business/service.d.ts
vendored
@@ -1,14 +1,10 @@
|
||||
// 在线客服会话实体(PRD M9 在线客服)
|
||||
|
||||
/** 会话角色 */
|
||||
export type ChatRole = 'user' | 'assistant'
|
||||
|
||||
/** 会话消息 */
|
||||
export interface ChatMessage {
|
||||
id: string
|
||||
role: ChatRole
|
||||
content: string
|
||||
/** 文件消息卡片(有值时按文件渲染) */
|
||||
file?: {
|
||||
name: string
|
||||
size: number
|
||||
|
||||
17
src/types/business/user.d.ts
vendored
@@ -1,6 +1,4 @@
|
||||
// 用户/认证实体(M4,page-architecture)
|
||||
|
||||
/** 用户 */
|
||||
export interface User {
|
||||
id: string
|
||||
nickname: string
|
||||
@@ -10,25 +8,31 @@ export interface User {
|
||||
passwordModifiedAt: string
|
||||
}
|
||||
|
||||
/** 账号密码登录参数 */
|
||||
export interface UserProfileVo {
|
||||
userId: string
|
||||
userName: string
|
||||
nickName: string
|
||||
phoneNumber: string
|
||||
email: string
|
||||
avatarUrl: string
|
||||
deptName: string
|
||||
}
|
||||
|
||||
export interface LoginByAccountParams {
|
||||
account: string
|
||||
password: string
|
||||
}
|
||||
|
||||
/** 短信验证码登录参数 */
|
||||
export interface LoginBySmsParams {
|
||||
phone: string
|
||||
code: string
|
||||
}
|
||||
|
||||
/** 登录结果 */
|
||||
export interface LoginResult {
|
||||
token: string
|
||||
user: User
|
||||
}
|
||||
|
||||
/** 注册参数 */
|
||||
export interface RegisterParams {
|
||||
phone: string
|
||||
code: string
|
||||
@@ -37,7 +41,6 @@ export interface RegisterParams {
|
||||
agree: boolean
|
||||
}
|
||||
|
||||
/** 找回/重置密码参数 */
|
||||
export interface ResetPasswordParams {
|
||||
phone: string
|
||||
code: string
|
||||
|
||||
2
src/types/global.d.ts
vendored
@@ -1,8 +1,6 @@
|
||||
// 全局声明(Window 扩展等,按需添加)
|
||||
export {}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
// 全局挂载扩展位
|
||||
}
|
||||
}
|
||||
|
||||
4
src/types/router.d.ts
vendored
@@ -1,11 +1,9 @@
|
||||
// RouteMeta 扩展
|
||||
export {}
|
||||
|
||||
declare module 'vue-router' {
|
||||
interface RouteMeta {
|
||||
/** 页面标题(document.title) */
|
||||
title?: string
|
||||
/** 是否需要登录 */
|
||||
requiresAuth?: boolean
|
||||
footer?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
65
src/types/web/home.d.ts
vendored
@@ -1,65 +0,0 @@
|
||||
// 后端「用户web端首页」接口真实返回类型
|
||||
// 对应 vermis-maker: com.ruoyi.vermins.controller.pc.web.UserSideWebHomePageController
|
||||
// 统一前缀: /web/home-page (公开接口,无需登录)
|
||||
// 字段与后端 VO 严格对齐(驼峰、可空用 | null)。
|
||||
|
||||
/** GET /web/home-page/project-status-count —— 项目状态统计 */
|
||||
export interface ProjectStatusCount {
|
||||
/** 项目数量 */
|
||||
projectCount: number
|
||||
/** 进行中的项目数目 */
|
||||
ongoingProjectCount: number
|
||||
/** 待处理数目 */
|
||||
pendingProjectCount: number
|
||||
}
|
||||
|
||||
/** 服务类型子项(service-type-list / service-type-detail 内含) */
|
||||
export interface ProjectServiceTypeVo {
|
||||
id: number | null
|
||||
projectId: number | null
|
||||
serviceName: string | null
|
||||
iconUrl: string | null
|
||||
sortOrder: number | null
|
||||
}
|
||||
|
||||
/** 项目背景(富文本) */
|
||||
export interface ProjectBackgroundVo {
|
||||
id: number | null
|
||||
projectId: number | null
|
||||
content: string | null
|
||||
sortOrder: number | null
|
||||
}
|
||||
|
||||
/** 项目提报视图对象:service-type-list / service-type-detail/{id} 的返回 */
|
||||
export interface ProjectSubmissionVo {
|
||||
id: number
|
||||
businessType: string | null
|
||||
title: string | null
|
||||
subtitle: string | null
|
||||
/** 是否启用:1 启用,0 停用 */
|
||||
enabled: number | null
|
||||
coverUrl: string | null
|
||||
logoUrl: string | null
|
||||
serviceTypes: ProjectServiceTypeVo[]
|
||||
backgrounds: ProjectBackgroundVo[]
|
||||
/** yyyy-MM-dd HH:mm:ss */
|
||||
updateTime: string | null
|
||||
}
|
||||
|
||||
/** GET /web/home-page/carousel-list —— 轮播图 */
|
||||
export interface BannerVo {
|
||||
id: number | null
|
||||
imageUrl: string | null
|
||||
sort: number | null
|
||||
/** 是否启用(true 启用,false 停用) */
|
||||
enabled: boolean | null
|
||||
}
|
||||
|
||||
/** GET /web/home-page/platform-intro、contact-info —— 协议/静态文章富文本 */
|
||||
export interface AgreementFormVo {
|
||||
id: number | null
|
||||
/** 协议类型(目前只有「关于我们」一条) */
|
||||
type: string | null
|
||||
/** 文章内容(富文本 HTML) */
|
||||
content: string | null
|
||||
}
|
||||
@@ -1,15 +1,5 @@
|
||||
// 资源地址工具(后端多个接口会下发「OSS 文件标识」而不是完整地址)
|
||||
//
|
||||
// 实测:案例 ProjectCaseVo.coverUrl、项目提报 ProjectSubmissionVo.coverUrl/logoUrl
|
||||
// 都可能是 OSS 主键(如 2099753406859292674)。直接当 <img src> 用会请求到当前站点的
|
||||
// 错误路径上(表现为 404 / 空白),故统一在这里收口判断。
|
||||
|
||||
/**
|
||||
* 仅当值本身是可直接访问的地址时才返回它,否则返回空串。
|
||||
*
|
||||
* <p>可接受:`http(s)://…`、协议相对 `//…`、站内绝对路径 `/…`。
|
||||
* OSS 文件标识(纯数字)、相对路径(`a/b.png`)一律判为不可直接使用。</p>
|
||||
*/
|
||||
export function toAssetUrl(raw: string | null | undefined): string {
|
||||
const value = (raw ?? '').trim()
|
||||
if (!value) return ''
|
||||
|
||||
91
src/utils/billVo.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import type { Bill, BillStatus, BillVo } from '@/types/business/finance'
|
||||
|
||||
const STATUS_TEXT: Record<string, BillStatus> = {
|
||||
UNSTARTED: '未到期',
|
||||
PENDING: '待支付',
|
||||
PAID: '已支付',
|
||||
CANCELLED: '已取消',
|
||||
}
|
||||
|
||||
export function toBillStatus(code?: string): BillStatus {
|
||||
if (!code) return '未到期'
|
||||
return STATUS_TEXT[code] ?? '未到期'
|
||||
}
|
||||
|
||||
export function toBill(vo: BillVo): Bill {
|
||||
return {
|
||||
id: String(vo.id),
|
||||
planName: vo.periodName ?? '',
|
||||
billNo: vo.billNo ?? '',
|
||||
tradeNo: vo.transactionNo,
|
||||
projectName: vo.projectName ?? '',
|
||||
amount: Number(vo.amount ?? 0),
|
||||
status: toBillStatus(vo.status),
|
||||
createTime: vo.createTime ?? '',
|
||||
payChannel: vo.payChannel,
|
||||
payTime: vo.payTime,
|
||||
}
|
||||
}
|
||||
|
||||
export function toBills(list: BillVo[] | null | undefined): Bill[] {
|
||||
if (!list || list.length === 0) return []
|
||||
return list.filter((item) => item != null && item.id != null).map(toBill)
|
||||
}
|
||||
|
||||
export function toPlanStatus(code?: string | null): BillStatus {
|
||||
const value = (code ?? '').toUpperCase()
|
||||
if (value === 'DONE' || value === 'PAID' || value === 'COMPLETED') return '已支付'
|
||||
if (value === 'ACTIVE' || value === 'PENDING') return '待支付'
|
||||
if (value === 'CLOSED' || value === 'CANCELLED') return '已取消'
|
||||
return '未到期'
|
||||
}
|
||||
|
||||
export interface PlanLike {
|
||||
id?: number | string | null
|
||||
projectName?: string | null
|
||||
totalAmount?: number | string | null
|
||||
periods?: number | null
|
||||
currentPeriod?: number | null
|
||||
periodRule?: string | null
|
||||
status?: string | null
|
||||
orderNo?: string | null
|
||||
transactionNo?: string | null
|
||||
payChannel?: string | null
|
||||
payStatus?: string | null
|
||||
payTime?: string | null
|
||||
}
|
||||
|
||||
function periodLabel(period: number, periods: number): string {
|
||||
if (period <= 1) return '预付款'
|
||||
if (period >= periods) return '尾款'
|
||||
return '进度款'
|
||||
}
|
||||
|
||||
export function toPlanBill(vo: PlanLike): Bill {
|
||||
const period = vo.currentPeriod ?? 0
|
||||
const periods = vo.periods ?? 0
|
||||
const rule = (vo.periodRule ?? '').split(',').map((part) => Number(part.trim()))
|
||||
const segment = rule[period - 1]
|
||||
const ratio = Number.isFinite(segment) ? segment : periods > 0 ? 100 / periods : 0
|
||||
const total = Number(vo.totalAmount ?? 0)
|
||||
const paid = (vo.payStatus ?? '').toUpperCase() === 'SUCCESS'
|
||||
return {
|
||||
id: String(vo.id ?? ''),
|
||||
planName: period > 0 && periods > 0
|
||||
? `第${period}期·${periodLabel(period, periods)}`
|
||||
: (vo.periodRule ?? '付款计划'),
|
||||
billNo: vo.orderNo ?? '',
|
||||
tradeNo: vo.transactionNo ?? undefined,
|
||||
projectName: vo.projectName ?? '',
|
||||
amount: Math.round(total * ratio) / 100,
|
||||
status: paid ? '已支付' : toPlanStatus(vo.status),
|
||||
createTime: '',
|
||||
payChannel: vo.payChannel ?? undefined,
|
||||
payTime: vo.payTime ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function toPlanBills(list: readonly PlanLike[] | null | undefined): Bill[] {
|
||||
if (!list || list.length === 0) return []
|
||||
return list.filter((item) => item != null && item.id != null).map(toPlanBill)
|
||||
}
|
||||