Compare commits
18 Commits
fd1eca95a6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7722e19df | ||
|
|
0b974bd36a | ||
|
|
a067fd8357 | ||
|
|
367380e44c | ||
|
|
77dbabb1f1 | ||
|
|
f917062d76 | ||
|
|
093cf9acd0 | ||
|
|
5fa940e9ba | ||
|
|
f552dd370c | ||
|
|
fe9492ab69 | ||
|
|
d0779fe37c | ||
|
|
b0e9fe6450 | ||
|
|
631d54f269 | ||
|
|
0f038e7818 | ||
|
|
53f23d4e69 | ||
|
|
6ecc649a1c | ||
|
|
6af8fd7b09 | ||
|
|
123d259f92 |
@@ -1,382 +1,335 @@
|
|||||||
---
|
---
|
||||||
name: vue-project-structure
|
name: vue-project-structure
|
||||||
description: cz-web 项目(Vue 3 + TypeScript + Vite 网页端站点)的目录结构与编码规范强制 skill。当在本项目中新增、创建、移动、重构或修改任何 src/ 下的代码文件(.vue/.ts/.less)、路由、store、API、组件、页面时,必须加载本 skill,先读取项目目录、再按本规范确定文件归属与写法。本项目是**网页端站点(非中后台)**:页面按名称组织(home/news/product…),接口层在 api/、数据层在 Pinia、页面只经 store 取数,样式使用**less**。凡是涉及"新文件放哪"、"页面数据怎么拿"、"页面/公共组件归属"、"命名"、"功能对应哪些文件"的任务也必须使用本 skill。
|
description: Vue 3 与 uni-app(Vue3) 项目的目录结构与编码规范通用 skill。当在任意 Vue 3 或 uni-app(Vue3) 项目中新增、创建、移动、重构、修改 src/ 下的代码文件(.vue/.ts/.less/.scss)、路由、Pinia store、API、组件、页面、分包时,必须先加载本 skill。核心内容:先探测项目形态(Vue3 网页端/中后台 或 uni-app 小程序),再套用对应目录骨架;页面统一为「index.vue + cpns/」两件套(不使用 index.ts barrel);数据流固定为 页面 → store → api 单向;含 pages.json 注册与分包规范。凡是涉及"新文件放哪""页面数据怎么拿""公共组件 vs 页面私有组件归属""命名""分包""功能对应哪些文件"的任务也必须使用本 skill。本 skill 与具体仓库无关,可在任何 Vue 3 / uni-app 项目中直接复用。
|
||||||
version: 2.0.0
|
version: 4.0.0
|
||||||
---
|
---
|
||||||
|
|
||||||
# cz-web 项目目录结构规范(强制 · 网页端)
|
# Vue 3项目目录结构规范(通用 · 先探测后套用)
|
||||||
|
|
||||||
> 完整版调试文档见 `docs/vue-project-guide.md`(其中中后台相关部分已废弃),**本 skill 为唯一现行规范,以其为准**;未覆盖场景参照 Vue 3 官方目录约定。
|
> 适用范围:**Vue 3**(含 uni-app 的 Vue 3 版本)。不涉及 Vue 2。
|
||||||
|
> 本 skill 与具体仓库无关:执行顺序永远是 **① 探测现状 → ② 选定骨架变体 → ③ 按规范落位**。项目内已有显式文档(`AGENTS.md`/`CLAUDE.md`、`docs/` 下的结构说明)优先级高于本 skill。
|
||||||
|
|
||||||
## 第一步:读取项目目录(每次加载必做)
|
---
|
||||||
|
|
||||||
动手写任何代码前,先用工具映射当前项目结构,确认正在操作的正确层级:
|
## 第零步:探测项目形态(每次加载必做)
|
||||||
|
|
||||||
1. 用 `codegraph_files` 或 `glob` 列出 `src/` 下的现行目录结构(如某目录尚不存在,应在对应职责目录创建,而不是自创新顶层目录)。
|
### 0.1 读取证据
|
||||||
2. 读取目标文件及其同级文件,确认现有命名风格与组织方式(页面名、store 名、api 名必须一致)。
|
|
||||||
3. 只有在目录树确认完毕后,才允许创建/修改文件。
|
|
||||||
|
|
||||||
## 核心原则
|
| 要看什么 | 怎么看 |
|
||||||
|
| ---------- | --------------------------------------------------------------------------- |
|
||||||
|
| 依赖清单 | `package.json`:是否有 `@dcloudio/*`、`pinia`、`vue-router` |
|
||||||
|
| 是否小程序 | 存在 `pages.json`、`manifest.json`;`App.vue` 含 `onLaunch` |
|
||||||
|
| 构建工具 | `vite.config.*`(Vue 3 必配);`vue.config.*` 为 Vue CLI |
|
||||||
|
| 目录现状 | 列出 `src/`(uni-app 通常无 src 包裹,直接根目录)的实际结构 |
|
||||||
|
| 样式方案 | 现有 `.less` → less;`.scss` → scss |
|
||||||
|
| 语言 | 有 `tsconfig.json` → TypeScript |
|
||||||
|
| 目标平台 | `manifest.json` 的 `mp-weixin`/`h5`/`app` 等;`pages.json` 的 `subPackages` |
|
||||||
|
|
||||||
1. **三层数据流(页面 → store → api)**:页面组件只通过 **Pinia store** 获取数据;store 是数据层,负责调用 **api 层**接口取数并维护状态;api 层只发请求。**页面禁止直接 import api**。
|
### 0.2 判定形态
|
||||||
2. **按页面/页面名组织(一页一套)**:`views/<page>/`、`api/<page>.ts`、`stores/modules/<page>.ts`、`types/business/<page>.d.ts` 四者同名对应,方便分层查看。
|
|
||||||
3. **组件两级归属**:`components/` 只放**跨页面复用的公共组件**;`views/<page>/cpns/` 放**该页面独有的组件**。
|
|
||||||
4. **网页端(非中后台)**:无侧边栏菜单/权限路由/TabsBar 体系;页面以业务(首页、新闻、产品、关于我们…)划分。
|
|
||||||
5. **样式使用 less**:全局样式为 `.less` 文件,组件内 `<style lang="less" scoped>`。
|
|
||||||
|
|
||||||
## 数据流架构(强约束)
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
A[读 package.json + 目录] --> B{含 @dcloudio/* ?}
|
||||||
|
B -->|是| U[形态 B: uni-app Vue3]
|
||||||
|
B -->|否| D{有 layout 壳 + 权限路由?}
|
||||||
|
D -->|是| M[形态 A2: 中后台]
|
||||||
|
D -->|否| W[形态 A1: 网页端站点]
|
||||||
|
U --> U1{pages.json 有 subPackages?}
|
||||||
|
U1 -->|是| U2[大应用: 必须分包]
|
||||||
|
U1 -->|否| U3[小应用: 主包即可]
|
||||||
|
```
|
||||||
|
|
||||||
|
| 形态 | 判据 | 骨架 |
|
||||||
|
| ---------------------- | ---------------------------------------------- | ----------------------------------- |
|
||||||
|
| **A1 Vue3 网页端站点** | 无权限路由,业务按页面名划分 | 骨架 A |
|
||||||
|
| **A2 Vue3 中后台** | 有 `layout/` 壳、`permission.ts`、表格表单密集 | 骨架 A + 中后台补充 |
|
||||||
|
| **B uni-app(Vue3)** | 依赖含 `@dcloudio/*`,存在 `pages.json` | 骨架 B(**页面注册用 pages.json**) |
|
||||||
|
|
||||||
|
### 0.3 探测结论必须先复述
|
||||||
|
|
||||||
|
动手改代码前,先输出:
|
||||||
|
|
||||||
|
```
|
||||||
|
探测结论:形态=B uni-app(Vue3 + Vite + TS + Pinia + scss,含 subPackages 3 个)
|
||||||
|
页面结构:index.vue + cpns/(无 index.ts)
|
||||||
|
样式方案:scss(uni.scss 已存在)
|
||||||
|
```
|
||||||
|
|
||||||
|
探测不明确时**先问用户**,不要猜。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第一步:读取目标区域(探测后必做)
|
||||||
|
|
||||||
|
1. 列出将要操作目录的现行结构(目录不存在则新建,**不要自创新顶层目录**)。
|
||||||
|
2. 读取目标文件 + 同级 2 个文件,确认既有命名/导出/样式风格。
|
||||||
|
3. 确认该业务域"页面 / api / store / 类型"在本项目中的实际路径。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 核心原则(形态无关)
|
||||||
|
|
||||||
|
1. **三层数据流:页面 → store → api**。页面只通过 store 取数;store 调用 api 并维护状态;api 只发请求。**页面禁止直接 import api**。
|
||||||
|
2. **业务同名单点**:同一业务域的 `页面目录` / `api 文件` / `store 文件` / `类型文件` 同名对应。
|
||||||
|
3. **页面两件套(无 barrel)**:页面目录 = `index.vue` + `cpns/`。**不建 `index.ts`**,路由直接指向 `@/views/<page>/index.vue`。
|
||||||
|
4. **组件两级归属**:跨 ≥2 个页面/分包复用 → 全局 `components/`;单页面独有 → 该页面的 `cpns/`。
|
||||||
|
5. **技术选型跟随现状**:用 Pinia 就不引 Vuex;用 scss 就不写 less;用 TS 就不落 `.js`。**不擅自更换技术栈**。
|
||||||
|
6. **最小改动**:只改与本次需求相关的行;无关死代码只报告、不删除。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 骨架 A:Vue 3 网页端 / 中后台
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── api/<domain>.ts # 接口层:按业务命名,一域一文件
|
||||||
|
├── assets/ # 参与构建的静态资源(images/ icons/ fonts/)
|
||||||
|
├── components/ # 跨页面复用的公共组件(禁含具体业务)
|
||||||
|
│ └── index.ts # barrel 统一导出(仅公共组件目录需要)
|
||||||
|
├── composables/ # 通用组合式逻辑(useLoading / useScroll / useCountDown)
|
||||||
|
├── directives/ # 自定义指令(lazy / debounce / throttle)+ index.ts 注册
|
||||||
|
├── layout/ # 全站骨架(网页端:Header+router-view+Footer)
|
||||||
|
├── router/ # index.ts(createRouter)、routes.ts、guard.ts
|
||||||
|
├── stores/
|
||||||
|
│ ├── index.ts # pinia 实例
|
||||||
|
│ └── modules/<domain>.ts
|
||||||
|
├── styles/ # variables / mixins / reset / common + 入口
|
||||||
|
├── types/ # api.d.ts / router.d.ts / business/<domain>.d.ts
|
||||||
|
├── utils/ # request.ts(唯一 axios 实例)/ storage / validate / format
|
||||||
|
└── views/
|
||||||
|
└── <page>/ # ★ 页面 = index.vue + cpns/
|
||||||
|
├── index.vue # 页面主组件(路由直接指向它)
|
||||||
|
├── detail.vue # 同页面模块的次级页面(如列表/详情)
|
||||||
|
└── cpns/ # 仅本页使用的组件
|
||||||
|
├── Head.vue
|
||||||
|
└── Banner.vue
|
||||||
|
```
|
||||||
|
|
||||||
|
路由写法(**不经过 index.ts**):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
path: '/news',
|
||||||
|
name: 'News',
|
||||||
|
component: () => import('@/views/news/index.vue'), // 直接指向 .vue
|
||||||
|
meta: { title: '新闻列表' },
|
||||||
|
}
|
||||||
|
{
|
||||||
|
path: '/news/:id',
|
||||||
|
name: 'NewsDetail',
|
||||||
|
component: () => import('@/views/news/detail.vue'),
|
||||||
|
meta: { title: '新闻详情' },
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### A2 中后台补充(在骨架 A 上增加)
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── layout/
|
||||||
|
│ ├── index.vue # 后台外壳:侧边栏 + 顶栏 + tab 缓存 + <router-view/>
|
||||||
|
│ └── components/ # Sider / Header / Breadcrumb / TagsView
|
||||||
|
├── router/
|
||||||
|
│ ├── routes.ts # constantRoutes(无权限)
|
||||||
|
│ └── modules.ts # asyncRoutes(按角色过滤)
|
||||||
|
├── permission.ts # 守卫:登录态 → 白名单 → 动态路由注入
|
||||||
|
├── stores/modules/ # user(token/角色/权限)、permission(动态路由)、app(侧栏/主题)
|
||||||
|
└── views/<module>/<page>/ # 按模块再分页面,如 views/system/user/index.vue
|
||||||
|
```
|
||||||
|
|
||||||
|
- 权限:`meta: { title, icon, roles: [], perms: [] }`,由 `permission.ts` 统一处理,页面用 `hasPermi('sys:user:add')` 判断。
|
||||||
|
- 表格页统一结构:`搜索区 + 工具栏 + 表格 + 分页`,分页用公共 `Pagination` 组件。
|
||||||
|
- 动态路由必须 `router.addRoute` 注入后 `next({ ...to, replace: true })`,避免空白页。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 骨架 B:uni-app(Vue 3)
|
||||||
|
|
||||||
|
```
|
||||||
|
├── pages.json # ★ 唯一页面注册入口(路径、导航栏、分包)
|
||||||
|
├── manifest.json # 应用配置(appid、平台配置、原生插件)
|
||||||
|
├── uni.scss # ★ 全局 scss 变量(uni-app 约定入口)
|
||||||
|
├── App.vue # 全局生命周期 onLaunch/onShow/onHide
|
||||||
|
├── main.ts # createApp、注册 pinia、引入全局样式
|
||||||
|
├── api/<domain>.ts # 接口层(uni.request 封装为函数)
|
||||||
|
├── stores/modules/<domain>.ts # 状态层
|
||||||
|
├── utils/
|
||||||
|
│ ├── request.ts # 封装 uni.request 的唯一请求层
|
||||||
|
│ └── storage.ts # uni.getStorageSync 封装
|
||||||
|
├── components/ # 跨页面/跨分包复用的自定义组件
|
||||||
|
├── static/ # 不参与编译的静态资源(不要用 assets)
|
||||||
|
├── composables/
|
||||||
|
├── types/
|
||||||
|
├── pages/ # ★ 主包页面
|
||||||
|
│ ├── index/
|
||||||
|
│ │ ├── index.vue
|
||||||
|
│ │ └── cpns/
|
||||||
|
│ └── login/
|
||||||
|
│ ├── index.vue
|
||||||
|
│ └── cpns/
|
||||||
|
└── subpackages/ # 分包页面(仅分包项目)
|
||||||
|
└── news/
|
||||||
|
├── detail.vue
|
||||||
|
└── cpns/
|
||||||
|
```
|
||||||
|
|
||||||
|
uni-app 硬性约定:
|
||||||
|
|
||||||
|
- 页面**必须**在 `pages.json` 的 `pages` / `subPackages[].pages` 注册,路径不带扩展名、不以 `/` 开头。
|
||||||
|
- 页面目录同样遵循 **`index.vue` + `cpns/`**,不建 `index.ts`。
|
||||||
|
- 主包体积敏感:**非首屏必需**页面一律放 `subPackages`;静态大图放 `static/`。
|
||||||
|
- 跨分包随意 import 受限;公共依赖优先放主包或做分包异步化。
|
||||||
|
- 平台差异(`#ifdef MP-WEIXIN / H5 / APP-PLUS`)集中封装并注释,禁止散落全项目。
|
||||||
|
- 用 `uni.request` / `uni.chooseImage` / `uni.navigateTo`,不混用 `wx.*`;tabBar 页用 `switchTab`(不能带参数)。
|
||||||
|
- 组件模板优先 `view/text/image`,不用 `div/span/img`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 数据流架构(强约束,两形态通用)
|
||||||
|
|
||||||
```text
|
```text
|
||||||
页面组件 (views/home/index.vue)
|
页面组件 (views/news/index.vue 或 pages/news/index.vue)
|
||||||
│ ① 页面只调用 store 的 action / 读取 store 的 state(模板渲染数据)
|
│ ① 只读 store state / 调 store action
|
||||||
▼
|
▼
|
||||||
Pinia store (stores/modules/home.ts) —— 数据层
|
状态层 (stores/modules/news.ts) —— 数据层:取数 + 状态 + 缓存 + 跨页共享
|
||||||
│ ② store 的 action 内部调用 api 接口函数 → await → 写入 state / 返回结果
|
│ ② action 内部调用 api 函数 → await → 写 state
|
||||||
▼
|
▼
|
||||||
API (api/home.ts) —— 接口层
|
API (api/news.ts) —— 接口层:只发请求、只返回带类型的数据
|
||||||
│ ③ 经 utils/request.ts 发 HTTP 请求
|
│ ③ request.ts(axios / uni.request 封装)
|
||||||
▼
|
▼
|
||||||
后端服务器
|
后端
|
||||||
```
|
```
|
||||||
|
|
||||||
铁律:
|
铁律:
|
||||||
|
|
||||||
- **页面禁止 `import` api 层文件**;获取数据必须调对应页面 store 的方法。
|
- 页面**禁止** import api;取数必须走 store。
|
||||||
- **api 层是纯接口层**:只发请求、返回数据(带类型),不做业务状态处理、不缓存。
|
- api 是**纯接口层**:不发业务判断、不缓存、不 toast。
|
||||||
- **store 承担取数 + 数据状态 + 页面间共享 + 缓存**;与页面同名的 store 责任边界即该页面。
|
- store 承担取数、状态、缓存、跨页共享;本地缓存用 `storage.ts`,不直接写 `localStorage` / `uni.setStorageSync`。
|
||||||
- 调用链必须是 `页面 → store → api` 单向,禁止跳过 store 或反向依赖。
|
- 依赖方向单向:`页面 → store → api`,禁止反向与跨层直连。
|
||||||
|
|
||||||
## src/ 目录结构(强制划分)
|
---
|
||||||
|
|
||||||
```
|
## 各层职责速查
|
||||||
src/
|
|
||||||
├── api/ # ★ 接口层:按页面命名,一页一文件(home.ts、news.ts、product.ts、about.ts)
|
| 层 | 目录 | 一句话职责 | 禁止 |
|
||||||
├── assets/ # 参与构建的静态资源(images/、icons/、fonts/)
|
| ------------ | --------------------------------------------------------- | ---------------------------------------- | ------------------------------ |
|
||||||
├── components/ # ★ 公共组件:仅跨页面复用的全站通用组件(禁含具体页面业务)
|
| 页面 | `views/<page>/index.vue`(uni:`pages/<page>/index.vue`) | 渲染 + 交互 + 编排 | 直接调 api、直接 axios/request |
|
||||||
│ ├── AppHeader.vue # 全站头部(导航)
|
| 页面私有组件 | `views/<page>/cpns/`(uni 同名) | 仅本页用 | 被其他页 import |
|
||||||
│ ├── AppFooter.vue # 全站页脚
|
| 公共组件 | `components/` | ≥2 处复用 | 含单页业务逻辑 |
|
||||||
│ ├── AppButton.vue # 通用按钮
|
| 状态层 | `stores/modules/<domain>.ts` | 取数 + 状态 + 缓存 | 拼 URL、写 UI 逻辑 |
|
||||||
│ ├── AppSwiper.vue # 通用轮播
|
| 接口层 | `api/<domain>.ts` | 请求分发 + 类型 | 状态、弹窗提示 |
|
||||||
│ └── index.ts # 统一导出
|
| 请求层 | `utils/request.ts` | 唯一实例/封装:token、统一解包、错误提示 | 被页面直接 import axios |
|
||||||
├── composables/ # 通用组合式逻辑(useScroll、useLoading、useCountDown、useIntersection)
|
| 工具 | `utils/`(纯函数) / `composables/`(响应式) | — | 互相混放 |
|
||||||
├── directives/ # 自定义指令(lazy 图片懒加载、debounce、throttle + index.ts 统一注册)
|
|
||||||
├── layout/ # 全站布局骨架:index.vue = <AppHeader/> + <router-view/> + <AppFooter/>
|
> **页面私有组件目录名统一用 `cpns/`**(不再用 `components/`,避免与全局 `components/` 混淆)。
|
||||||
├── router/ # 路由:index.ts(入口)、routes.ts(静态路由表)、guard.ts(守卫,可选)
|
|
||||||
├── stores/ # ★ 数据层(Pinia):按页面命名,一页一 store
|
### 组件归属判定(唯一判据)
|
||||||
│ ├── index.ts # pinia 实例(main.ts 中 app.use(pinia))
|
|
||||||
│ └── modules/
|
```mermaid
|
||||||
│ ├── home.ts # useHomeStore:首页数据(banner、推荐、公告)
|
graph TD
|
||||||
│ ├── news.ts # useNewsStore:新闻列表/分页/详情
|
A[新组件] --> B{被几个页面/分包使用?}
|
||||||
│ ├── product.ts # useProductStore:产品列表/详情
|
B -->|1 个| C[放该页面 cpns/]
|
||||||
│ └── site.ts # useSiteStore:站点公共数据(站点配置、全局 loading),非页面级
|
B -->|≥2 个| D{业务无关还是业务相关?}
|
||||||
├── styles/ # ★ 全局样式(less)
|
D -->|业务无关<br/>按钮/弹窗/上传| E[全局 components/]
|
||||||
│ ├── index.less # 样式入口(main.ts 引入),组织以下文件
|
D -->|业务相关| F[全局 components/<domain>/]
|
||||||
│ ├── variables.less # ★ less 变量唯一来源(颜色/间距/字号/圆角),禁止组件内魔法色值
|
F --> G[差异用 props 注入]
|
||||||
│ ├── mixins.less # 混合宏(flex、文本省略、滚动条)
|
|
||||||
│ ├── reset.less # 样式重置
|
|
||||||
│ └── common.less # 通用工具类(容器宽度、间距类)
|
|
||||||
├── types/ # 类型定义
|
|
||||||
│ ├── global.d.ts # 全局声明(Window 扩展、import.meta.env 兜底)
|
|
||||||
│ ├── api.d.ts # 接口通用结构:ApiResponse<T>、PageResult<T>
|
|
||||||
│ ├── router.d.ts # RouteMeta 扩展
|
|
||||||
│ └── business/ # 按页面与 api 对应:home.d.ts / news.d.ts / product.d.ts
|
|
||||||
├── utils/ # 纯函数工具(无 Vue 依赖):request.ts、storage.ts、validate.ts、format.ts
|
|
||||||
└── views/ # ★ 页面:一页一目录
|
|
||||||
├── home/
|
|
||||||
│ ├── index.vue # 页面主组件(路由通过 index.ts 加载)
|
|
||||||
│ ├── index.ts # 页面出口(barrel):export default 页面 + 导出本页独有组件
|
|
||||||
│ └── cpns/ # 页面独有组件(head、banner、news-list 等,仅本页面使用)
|
|
||||||
│ ├── Head.vue
|
|
||||||
│ └── Banner.vue
|
|
||||||
├── news/
|
|
||||||
│ ├── index.vue # 列表页
|
|
||||||
│ ├── detail.vue # 详情页
|
|
||||||
│ ├── index.ts
|
|
||||||
│ └── cpns/
|
|
||||||
├── product/ # 同上结构
|
|
||||||
├── about/ # 同上结构
|
|
||||||
└── login/ # 登录(仅存在会员体系时)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**命名一致性(可分层互查)**:页面 `home` → `api/home.ts` + `stores/modules/home.ts`(`useHomeStore`) + `types/business/home.d.ts` + `views/home/`,四者同名。
|
### 命名一致性
|
||||||
|
|
||||||
## src/ 每个文件的作用(新建/修改文件前先对照本节定位)
|
同一业务域 `news` 的四层落位:
|
||||||
|
|
||||||
### 入口与根文件
|
| 层 | Vue 形态 | uni-app 形态 |
|
||||||
|
| -------- | ----------------------------------------- | ------------------------------------------ |
|
||||||
|
| 页面 | `views/news/index.vue` | `pages/news/index.vue` + `pages.json` 注册 |
|
||||||
|
| 私有组件 | `views/news/cpns/` | `pages/news/cpns/` |
|
||||||
|
| 接口 | `api/news.ts` | `api/news.ts` |
|
||||||
|
| 状态 | `stores/modules/news.ts` → `useNewsStore` | 同左 |
|
||||||
|
| 类型 | `types/business/news.d.ts` | `types/business/news.d.ts` |
|
||||||
|
|
||||||
| 文件 | 作用 |
|
---
|
||||||
| -------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `src/main.ts` | 应用入口:`createApp(App)`,注册 `pinia`、`router`、自定义指令(`setupDirectives`)、引入全局 `styles/index.less`。不写业务逻辑。 |
|
|
||||||
| `src/App.vue` | 根组件,承载 `<router-view/>` 与全局过渡;不含业务代码。 |
|
|
||||||
| `src/env.d.ts` | Vite 环境变量类型声明(`ImportMetaEnv` / `ImportMeta`)。 |
|
|
||||||
|
|
||||||
### `api/` —— 接口层(一页一文件,与页面同名)
|
## 规范速查(形态无关的硬性项)
|
||||||
|
|
||||||
| 文件 | 作用 |
|
|
||||||
| ---------------- | ------------------------------------------------------ |
|
|
||||||
| `api/home.ts` | 首页接口:`getHomeBanner`、`getRecommend`、`getNotice` |
|
|
||||||
| `api/news.ts` | 新闻接口:`getNewsList`、`getNewsDetail` |
|
|
||||||
| `api/product.ts` | 产品接口:`getProductList`、`getProductDetail` |
|
|
||||||
| `api/about.ts` | 关于我们/公司介绍接口 |
|
|
||||||
| `api/auth.ts` | 登录/注册/用户信息(仅会员体系需要时) |
|
|
||||||
|
|
||||||
> **纯接口层**:函数只负责 `request<T>({ url, method, params/data })`,不做数据加工、不维护状态。新增页面 → 新建 `api/<page>.ts`。
|
|
||||||
|
|
||||||
### `components/` —— 公共组件(仅跨页面复用)
|
|
||||||
|
|
||||||
| 文件 | 作用 |
|
|
||||||
| -------------------------- | ------------------------------------------------------------ |
|
|
||||||
| `components/AppHeader.vue` | 全站头部:logo、导航菜单(路由跳转) |
|
|
||||||
| `components/AppFooter.vue` | 全站页脚:版权、友链 |
|
|
||||||
| `components/AppButton.vue` | 通用按钮(disabled/loading/类型统一) |
|
|
||||||
| `components/AppSwiper.vue` | 通用轮播(首页 banner、产品图) |
|
|
||||||
| `components/index.ts` | 统一导出(barrel),页面与页面组件统一从 `@/components` 导入 |
|
|
||||||
|
|
||||||
> 判定:被 ≥2 个页面使用 → 放这里;仅 1 个页面使用 → 放 `views/<page>/cpns/`。
|
|
||||||
|
|
||||||
### `composables/` —— 通用组合式逻辑(与 UI 解耦)
|
|
||||||
|
|
||||||
| 文件 | 作用 |
|
|
||||||
| -------------------------------- | ------------------------ |
|
|
||||||
| `composables/useScroll.ts` | 滚动监听/吸顶/回到顶部 |
|
|
||||||
| `composables/useLoading.ts` | 请求 loading 状态管理 |
|
|
||||||
| `composables/useCountDown.ts` | 倒计时(验证码等) |
|
|
||||||
| `composables/useIntersection.ts` | 元素可见性(配合懒加载) |
|
|
||||||
|
|
||||||
### `directives/` —— 自定义指令
|
|
||||||
|
|
||||||
| 文件 | 作用 |
|
|
||||||
| ------------------------ | --------------------------------------------- |
|
|
||||||
| `directives/lazy.ts` | `v-lazy` 图片懒加载(网页端常用) |
|
|
||||||
| `directives/debounce.ts` | `v-debounce` 防抖 |
|
|
||||||
| `directives/throttle.ts` | `v-throttle` 节流 |
|
|
||||||
| `directives/index.ts` | `setupDirectives(app)` 统一注册,main.ts 调用 |
|
|
||||||
|
|
||||||
### `layout/` —— 全站布局骨架
|
|
||||||
|
|
||||||
| 文件 | 作用 |
|
|
||||||
| -------------------- | -------------------------------------------------------------------------- |
|
|
||||||
| `layout/index.vue` | 全站公共布局:`<AppHeader/>` + `<router-view/>` + `<AppFooter/>`,仅此一层 |
|
|
||||||
| `layout/components/` | 布局内部组件(如返回顶部按钮);需要全站复用的上移 `components/` |
|
|
||||||
|
|
||||||
### `router/` —— 路由系统(网页端:静态路由为主)
|
|
||||||
|
|
||||||
| 文件 | 作用 |
|
|
||||||
| ------------------ | ------------------------------------------------------------------------- |
|
|
||||||
| `router/index.ts` | 创建 `createRouter`(createWebHistory)+ 注册静态路由 + 挂守卫 |
|
|
||||||
| `router/routes.ts` | 静态路由表:`/home`、`/news`、`/news/:id`、`/product`、`/about`、404 兜底 |
|
|
||||||
| `router/guard.ts` | 路由守卫(可选):登录态校验(会员页)、滚动位置恢复、document.title |
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// 路由加载页面(指向 views/home/index.ts —— 页面出口)
|
|
||||||
{
|
|
||||||
path: '/home',
|
|
||||||
name: 'Home',
|
|
||||||
component: () => import('@/views/home'), // 解析到 index.ts 的 default(index.vue)
|
|
||||||
meta: { title: '首页' },
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `stores/modules/` —— 数据层(Pinia,一页一 store,与页面同名)
|
|
||||||
|
|
||||||
| 文件 | 作用 |
|
|
||||||
| --------------------------- | ------------------------------------------------------------------------------------------- |
|
|
||||||
| `stores/modules/home.ts` | `useHomeStore`:state(banner/推荐/公告)+ actions(`fetchHomeData`,内部调 `api/home.ts`) |
|
|
||||||
| `stores/modules/news.ts` | `useNewsStore`:列表/分页/详情 state + `fetchNewsList`、`fetchNewsDetail` |
|
|
||||||
| `stores/modules/product.ts` | `useProductStore`:产品列表/详情/分类筛选 |
|
|
||||||
| `stores/modules/site.ts` | `useSiteStore`:站点配置、全局 loading、主题(非页面级公共数据) |
|
|
||||||
| `stores/modules/user.ts` | `useUserStore`:登录态/token(仅会员体系需要时) |
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// stores/modules/home.ts —— 数据层写法示例(页面 → store → api)
|
|
||||||
import { defineStore } from 'pinia'
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { getHomeBanner, getRecommend } from '@/api/home'
|
|
||||||
import type { Banner, RecommendItem } from '@/types/business/home'
|
|
||||||
|
|
||||||
export const useHomeStore = defineStore('home', () => {
|
|
||||||
const banners = ref<Banner[]>([])
|
|
||||||
const recommends = ref<RecommendItem[]>([])
|
|
||||||
|
|
||||||
async function fetchHomeData() {
|
|
||||||
// ① store 调 api 层拿数据
|
|
||||||
const [bannerData, recommendData] = await Promise.all([
|
|
||||||
getHomeBanner(),
|
|
||||||
getRecommend(),
|
|
||||||
])
|
|
||||||
// ② 写入 state——完成页面所需数据
|
|
||||||
banners.value = bannerData
|
|
||||||
recommends.value = recommendData
|
|
||||||
}
|
|
||||||
|
|
||||||
return { banners, recommends, fetchHomeData }
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### `styles/` —— 全局样式(less)
|
|
||||||
|
|
||||||
| 文件 | 作用 |
|
|
||||||
| ----------------------- | ------------------------------------------------------------------------------------ |
|
|
||||||
| `styles/index.less` | 入口,main.ts `import '@/styles/index.less'` |
|
|
||||||
| `styles/variables.less` | ★ 变量唯一来源:`@primary-color`、`@text-color`、`@font-size-base`、`@spacing-base`… |
|
|
||||||
| `styles/mixins.less` | 混合宏(flex、文本省略) |
|
|
||||||
| `styles/reset.less` | 重置 |
|
|
||||||
| `styles/common.less` | 通用工具类 |
|
|
||||||
|
|
||||||
组件内样式:`<style lang="less" scoped>`,变量经 vite `css.preprocessorOptions.less.additionalData` 自动注入 `@import '@/styles/variables.less';`,组件内直接使用 `@primary-color`。
|
|
||||||
|
|
||||||
### `types/` —— 类型定义
|
|
||||||
|
|
||||||
| 文件 | 作用 |
|
|
||||||
| -------------------------- | --------------------------------------------------------------------- |
|
|
||||||
| `types/api.d.ts` | `ApiResponse<T> { code, msg, data }`、`PageResult<T> { list, total }` |
|
|
||||||
| `types/router.d.ts` | `declare module 'vue-router'` 扩展 `RouteMeta`(title 等) |
|
|
||||||
| `types/business/home.d.ts` | 首页实体类型(Banner/RecommendItem),与 `api/home.ts` 对应 |
|
|
||||||
| `types/business/news.d.ts` | 新闻实体(NewsItem/NewsQuery),与 `api/news.ts` 对应 |
|
|
||||||
|
|
||||||
> 新页面 → 新建 `types/business/<page>.d.ts`;禁止在组件内散落实体类型。
|
|
||||||
|
|
||||||
### `utils/` —— 纯函数工具
|
|
||||||
|
|
||||||
| 文件 | 作用 |
|
|
||||||
| ------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `utils/request.ts` | ★ 唯一 axios 实例:拦截器注入 token/统一解包 `ApiResponse`/统一错误提示,导出 `request<T>`。组件与页面禁止再 import axios |
|
|
||||||
| `utils/storage.ts` | localStorage/sessionStorage 泛型封装 |
|
|
||||||
| `utils/validate.ts` | 校验规则(手机号/邮箱/URL) |
|
|
||||||
| `utils/format.ts` | 格式化(金额/日期/千分位) |
|
|
||||||
|
|
||||||
### `views/` —— 页面(一页一目录,三件套:index.vue + index.ts + cpns/)
|
|
||||||
|
|
||||||
| 文件/目录 | 作用 |
|
|
||||||
| ---------------------------- | --------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `views/home/index.vue` | 首页主组件:组合 `cpns/` 的独有组件,数据由 `useHomeStore` 提供 |
|
|
||||||
| `views/home/index.ts` | **页面出口(barrel)**:`export { default } from './index.vue'` + 导出 `cpns/` 组件;路由与外部引用统一走它"展示"页面 |
|
|
||||||
| `views/home/cpns/Head.vue` | 首页独有头部区块(banner 下导航条等),仅本页使用 |
|
|
||||||
| `views/home/cpns/Banner.vue` | 首页独有轮播区块(渲染 store 的 banners) |
|
|
||||||
| `views/news/index.vue` | 新闻列表页 |
|
|
||||||
| `views/news/detail.vue` | 新闻详情页(与 index.vue 同目录,同页面模块) |
|
|
||||||
| `views/news/index.ts` | 出口 barrel(同上) |
|
|
||||||
| `views/news/cpns/` | 新闻页独有组件(筛选栏、列表项卡片) |
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// views/home/index.ts —— 页面出口(barrel)写法
|
|
||||||
export { default } from './index.vue' // 页面主组件(default)
|
|
||||||
export { default as HomeHead } from './cpns/Head.vue'
|
|
||||||
export { default as HomeBanner } from './cpns/Banner.vue'
|
|
||||||
```
|
|
||||||
|
|
||||||
> 页面独有组件的归属判定:**只属于某一个页面 → 进该页面 `cpns/`;被 ≥2 个页面共用 → 上移 `components/`**。
|
|
||||||
|
|
||||||
## 项目功能模块图(功能 → 涉及文件)
|
|
||||||
|
|
||||||
| 功能 | 涉及文件 | 说明 |
|
|
||||||
| --------------------- | --------------------------------------------------------------------------------------- | ------------------------------ |
|
|
||||||
| 全站布局(头部/页脚) | `layout/index.vue` + `components/AppHeader.vue` + `components/AppFooter.vue` | 路由首层挂在 layout 下 |
|
|
||||||
| 首页 | `api/home.ts` + `stores/modules/home.ts` + `views/home/`(index.vue + index.ts + cpns) | 页面 → store → api 取数 |
|
|
||||||
| 新闻中心 | `api/news.ts` + `stores/modules/news.ts` + `views/news/` | 列表 + 详情 |
|
|
||||||
| 产品展示 | `api/product.ts` + `stores/modules/product.ts` + `views/product/` | 列表 + 详情 + 分类 |
|
|
||||||
| 关于我们 | `api/about.ts` + `stores/modules/about.ts` + `views/about/` | 公司介绍内容 |
|
|
||||||
| 会员登录(可选) | `api/auth.ts` + `stores/modules/user.ts` + `views/login/` + `router/guard.ts` | 登录态/token |
|
|
||||||
| 轮播/动效 | `components/AppSwiper.vue` + `composables/useScroll.ts` + `directives/lazy.ts` | 首页 banner、图片懒加载 |
|
|
||||||
| 站点公共数据 | `stores/modules/site.ts` + `styles/variables.less` | 站点配置、全局 loading、主题色 |
|
|
||||||
|
|
||||||
## 统一规范速查
|
|
||||||
|
|
||||||
### 路由规范
|
|
||||||
|
|
||||||
- 路由 `component` 指向页面出口:`() => import('@/views/<page>')`(解析到 `index.ts` 的 default = `index.vue`)
|
|
||||||
- 每个路由必须声明 `meta.title`;`RouteMeta` 扩展在 `src/types/router.d.ts`
|
|
||||||
- 网页端以静态路由为主,无需动态权限路由;嵌套页面(news 列表/详情)在同一页面目录下
|
|
||||||
- 公共页面挂载在 `layout/index.vue` 下,页面自身不重复写 header/footer
|
|
||||||
|
|
||||||
### 状态管理(Pinia)规范 —— 数据层
|
|
||||||
|
|
||||||
- store 文件:`src/stores/modules/<page>.ts`,命名 `use<Page>Store`(PascalCase),与 api/views 同名
|
|
||||||
- **统一组合式(setup)写法**:state 用 `ref`,getters 用 `computed`,actions 用普通函数,最后 return 全部
|
|
||||||
- **store 的 action 负责调用对应页面 api 层的接口取数**,页面只调 action
|
|
||||||
- 组件内:state/getters 用 `storeToRefs` 解构;actions 直接解构调用
|
|
||||||
- 简单展示型数据也走 store(保持数据层统一),页面禁止直接 import api
|
|
||||||
|
|
||||||
### 接口 API 规范 —— 接口层
|
|
||||||
|
|
||||||
- **全项目唯一 axios 实例**在 `src/utils/request.ts`,禁止页面/组件 import axios
|
|
||||||
- 统一响应结构 `ApiResponse<T> { code, msg, data }`(见 `types/api.d.ts`)
|
|
||||||
- 接口函数名 = 动词 + 资源:`getHomeBanner` / `getNewsList` / `getNewsDetail` / `getProductList`
|
|
||||||
- `api/<page>.ts` 只做请求分发,不做数据处理;处理与状态归 store
|
|
||||||
- baseURL 用环境变量 `import.meta.env.VITE_API_BASE_URL` 区分环境(.env / .env.development / .env.production)
|
|
||||||
- 错误统一由拦截器兜底,store/页面仅在需特殊处理时 catch
|
|
||||||
|
|
||||||
### 组件规范
|
### 组件规范
|
||||||
|
|
||||||
- 公共组件 `components/`:文件 PascalCase(AppHeader.vue);被 ≥2 页面使用才放这里
|
- 统一 `<script setup lang="ts">`。
|
||||||
- 页面独有组件 `views/<page>/cpns/`:**严禁被其他页面 import**;语义化命名(Head.vue、Banner.vue)
|
- props:`defineProps<Props>()` + `withDefaults`;emits:`defineEmits<{ change: [val: number] }>()`,事件名 camelCase。
|
||||||
- 页面结构三件套:`index.vue`(页面) + `index.ts`(出口 barrel) + `cpns/`(独有组件)
|
- 模板内不写复杂表达式(超过一次三元/逻辑运算即抽方法或 computed)。
|
||||||
- 组件统一 `<script setup lang="ts">`;props 用 `defineProps<Props>()` + `withDefaults`;emits 用 `defineEmits<{ 事件名: [参数类型] }>()`
|
- 单文件 > 400 行 → 拆到同页面 `cpns/`。
|
||||||
- 组件事件 camelCase 命名;模板内不放复杂表达式;样式默认 `<style lang="less" scoped>`
|
- uni-app 组件模板用 `view/text/image`。
|
||||||
- 单文件超 400 行即拆分到 `cpns/`
|
|
||||||
|
|
||||||
### 样式规范(less)
|
### 样式规范
|
||||||
|
|
||||||
- 全项目使用 **less**,禁止新增 `.scss/.css` 业务样式
|
- **跟随项目既有方案**(less 或 scss),不混用两套预处理器。
|
||||||
- 全局变量统一在 `styles/variables.less`;组件内禁止魔法色值
|
- 变量单一来源:Vue 项目 `src/styles/variables.*`;uni-app 项目 `uni.scss`。
|
||||||
- vite `css.preprocessorOptions.less` 配置 `additionalData` 自动注入 variables
|
- 组件内禁止魔法色值/魔法间距。
|
||||||
- 依赖:`pnpm add -D less`(vite 内置 less 编译支持)
|
- Vue 项目:`<style scoped lang="less|scss">`;uni-app 页面样式**不加 `scoped`**(小程序不支持),靠类名隔离。
|
||||||
|
|
||||||
### 工程化硬性约定
|
### 类型规范
|
||||||
|
|
||||||
- **导入一律 `@/` 别名**;禁止跨目录相对路径(`../` 超过一层即违规)
|
- 实体类型 → `types/business/<domain>.d.ts`;接口通用结构 → `types/api.d.ts`。
|
||||||
- utils 与 composables 判据:**需要响应式状态或生命周期 → composables;否则 → utils**
|
- **禁止** `as any` / `@ts-ignore` / `@ts-expect-error`。
|
||||||
- 类型归位:实体类型 → `types/business/<page>.d.ts`(与 api 同名);接口结构 → `types/api.d.ts`
|
|
||||||
- **禁止 `as any` / `@ts-ignore` / `@ts-expect-error`**
|
|
||||||
|
|
||||||
## 后续使用方式(工作流)
|
### 导入规范
|
||||||
|
|
||||||
### 场景速查:想做什么 → 去找哪个文件
|
- 有别名(`@/`)的项目一律用别名;禁止多级跨层 `../../../`。
|
||||||
|
- uni-app 需在 `vite.config` 的 `resolve.alias` 或 `tsconfig` 的 `paths` 配置 `@/`。
|
||||||
|
|
||||||
| 想做的事 | 动哪个文件 | 注意 |
|
### 请求规范
|
||||||
| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- |
|
|
||||||
| 给首页加一块新数据 | `api/home.ts` 加接口函数 → `stores/modules/home.ts` 加 action/state → `views/home/index.vue` 调 store action,结构放 `views/home/cpns/` 新组件 | 遵循数据流:页面 → store → api |
|
|
||||||
| 新增一个页面模块(如 news) | `types/business/news.d.ts` → `api/news.ts` → `stores/modules/news.ts` → `views/news/`(index.vue+index.ts+cpns) → `router/routes.ts` 注册 | 四者同名,走七步流程 |
|
|
||||||
| 页面要取数据 | 调对应 `use<Page>Store` 的 action(`storeToRefs` 取 state) | **禁止直接 import api** |
|
|
||||||
| 一个组件多个页面要用 | 放 `components/`(公共组件) | 从 `@/components` 导入 |
|
|
||||||
| 一个组件只有本页面用 | 放 `views/<page>/cpns/` | 禁止其他页面引用 |
|
|
||||||
| 改全站颜色/字体/间距 | `styles/variables.less` | 禁止组件内魔法值 |
|
|
||||||
| 图片懒加载 | 模板 `v-lazy`(`directives/lazy.ts`) | 勿手写滚动判断 |
|
|
||||||
| 通用逻辑(响应式) | `composables/` | 纯函数则 `utils/` |
|
|
||||||
| 弹窗/表单等小块 | 页面内 `cpns/` | 复用时上移 components/ |
|
|
||||||
| 路由新增页面 | `router/routes.ts` 注册 | component 指向 `@/views/<page>` |
|
|
||||||
|
|
||||||
### 新增页面模块七步流程(以"新闻 news"为例)
|
- 全项目**唯一**请求实例/封装:`utils/request.ts`;页面与组件禁止直接 `axios` / `uni.request` / `fetch`。
|
||||||
|
- baseURL 走环境变量(`import.meta.env.VITE_*`)。
|
||||||
|
- 错误统一由拦截器兜底,业务侧仅在需要特殊处理时 `catch`。
|
||||||
|
|
||||||
1. **类型**:`types/business/news.d.ts` 定义 `NewsItem / NewsQuery / PageResult`
|
---
|
||||||
2. **接口层**:`api/news.ts`(`getNewsList`、`getNewsDetail`,走 `request<T>`)
|
|
||||||
3. **数据层**:`stores/modules/news.ts`(`useNewsStore`,action 内调用 api 函数取数)
|
|
||||||
4. **页面**:`views/news/index.vue`(列表)+ `views/news/detail.vue`(详情)+ `views/news/index.ts`(出口 barrel)+ `views/news/cpns/`(独有组件)
|
|
||||||
5. **路由**:`router/routes.ts` 注册(`component: () => import('@/views/news')`,配 `meta.title`)
|
|
||||||
6. **复用检查**:被 ≥2 页面使用的组件上移 `components/`
|
|
||||||
7. **验证**:`pnpm build`(含 vue-tsc 类型检查)零错误
|
|
||||||
|
|
||||||
### 日常开发姿势
|
## 工作流
|
||||||
|
|
||||||
- **每次动手前**:按"第一步"读取目录,确认目标文件存在与现状,再决定新建还是修改。
|
### 场景速查:想做什么 → 改哪些文件
|
||||||
- **新建文件前**:对照"src/ 每个文件的作用"确认归属(api/stores/views 四者同名)。
|
|
||||||
- **改数据相关代码**:先确认数据流方向——页面只碰 store,store 只碰 api,api 只发请求。
|
| 想做的事 | 动作 |
|
||||||
- **组件归属**:先自问"有几个页面用它"——1 个 → cpns/;≥2 个 → components/。
|
| ---------------------- | ------------------------------------------------------------------------------------------ |
|
||||||
- **规格冲突时**:本 skill 为唯一现行规范;未覆盖场景 → 询问用户或按最贴近的既有模式执行后复述假设。
|
| 给已有页面加一块新数据 | api 加函数 → store 加 action/state → 页面调 action,UI 放该页 `cpns/` 新组件 |
|
||||||
|
| 新增业务模块 | 类型 → api → store → 页面目录(index.vue + cpns/) → 路由(Vue)或 `pages.json`(uni-app) |
|
||||||
|
| 页面要取数据 | 调 `use<Domain>Store`;**禁止**直接 import api |
|
||||||
|
| 组件多页要用 | 上移 `components/` |
|
||||||
|
| 组件仅本页用 | 留在该页面 `cpns/` |
|
||||||
|
| 改全局颜色/间距 | 样式变量文件(`variables.*` / `uni.scss`) |
|
||||||
|
| 小程序主包体积超标 | 把非首屏页面迁入 `subPackages` |
|
||||||
|
|
||||||
|
### 新增业务模块流程(以 `news` 为例)
|
||||||
|
|
||||||
|
1. **类型** `types/business/news.d.ts`
|
||||||
|
2. **接口** `api/news.ts`
|
||||||
|
3. **状态** `stores/modules/news.ts`(action 内调 api)
|
||||||
|
4. **页面** `views/news/index.vue`(或 `pages/news/index.vue`)+ `cpns/`
|
||||||
|
5. **注册** `router/routes.ts`(`component: () => import('@/views/news/index.vue')`)或 `pages.json`
|
||||||
|
6. **复用检查** ≥2 处复用的上移 `components/`
|
||||||
|
7. **验证** 类型检查 + 构建零错误(`pnpm build` / `npm run build` / `vue-tsc --noEmit`)
|
||||||
|
|
||||||
|
### 最小改动纪律
|
||||||
|
|
||||||
|
- 每次动手前先读现状,确认是"新建"还是"修改"。
|
||||||
|
- 只碰与需求相关的行;不顺手重构、不顺手换技术栈。
|
||||||
|
- 规范冲突时:项目内显式文档 > 本 skill;本 skill 未覆盖时,先说明假设再执行。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 修改代码时的强制检查清单
|
## 修改代码时的强制检查清单
|
||||||
|
|
||||||
修改任何已存在文件时,逐条核对(不满足即视为违反规范,必须先修正再继续):
|
修改任何已存在文件时逐条核对,不满足必须先修正:
|
||||||
|
|
||||||
- [ ] 文件是否位于其职责对应的目录(接口在 api/、数据在 stores/modules/、页面在 views/<page>/ 等)
|
- [ ] 文件位于其职责对应目录(接口在 api/、状态在 stores/、页面在 views|pages/)
|
||||||
- [ ] **页面是否直接 import 了 api 层?(违规——数据必须经 store)**
|
- [ ] **页面是否直接 import 了 api?**(违规)
|
||||||
- [ ] **store 名 / api 文件名 / 页面目录名 / 类型文件名 是否与页面一致(可分层互查)**
|
- [ ] api / store / 页面 / 类型 是否同业务域命名一致
|
||||||
- [ ] 导入是否使用 `@/` 别名
|
- [ ] 页面目录是否只有 `index.vue`(+ 同模块次级页面)+ `cpns/`,**没有多余 `index.ts`**
|
||||||
- [ ] 组件归属是否正确:≥2 页面共用 → components/;单页面独有 → views/<page>/cpns/
|
- [ ] 私有组件是否在 `cpns/` 且无其他页面 import
|
||||||
- [ ] 不在组件里直接 new axios 实例 / 直接调用原生 fetch
|
- [ ] 导入使用 `@/` 别名,无多级跨层相对路径
|
||||||
- [ ] 样式是否使用 **less**(`<style lang="less" scoped>` / \*.less),颜色是否取自 variables.less
|
- [ ] 未在组件内直接 `axios` / `uni.request` / `fetch`
|
||||||
- [ ] 类型是否齐全(无 `as any`、无 `@ts-ignore`)
|
- [ ] 样式沿用项目既有预处理器,色值取自变量文件
|
||||||
- [ ] 新页面是否有完整三件套(index.vue + index.ts + cpns/)并在 router 注册
|
- [ ] uni-app:新页面已在 `pages.json` 注册;未把非首屏页放进主包;页面样式未加 `scoped`
|
||||||
- [ ] 运行时 `pnpm build`(含 vue-tsc 类型检查)零错误
|
- [ ] 无 `as any` / `@ts-ignore`
|
||||||
|
- [ ] 构建/类型检查零错误
|
||||||
|
|
||||||
> 本规范为项目强约束:**新增与修改的代码必须严格遵循**。发现不符合结构约定的代码,应指出问题并要求按规范调整,而不是放任通过。
|
> 发现不符合规范的历史代码:**指出问题并给出改法**,除非本次需求正好涉及,否则不擅自批量重构。
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -19,6 +19,7 @@ RTK.md
|
|||||||
specs2
|
specs2
|
||||||
.shots
|
.shots
|
||||||
docs
|
docs
|
||||||
|
.claude
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
|
|||||||
@@ -344,60 +344,61 @@ export function submitFeedback(feedback: Feedback): Promise<void> {
|
|||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** TODO(api): 后端帮助中心接口就绪后改为 request 调用;暂用设计稿假数据 */
|
||||||
export function getHelpFaqs(): Promise<HelpFaqGroup[]> {
|
export function getHelpFaqs(): Promise<HelpFaqGroup[]> {
|
||||||
return Promise.resolve([
|
return Promise.resolve([
|
||||||
{
|
{
|
||||||
title: '1. 账户与登录',
|
title: '账户与登录',
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
q: '如何注册账号?',
|
q: '如何注册账号?',
|
||||||
a: '点击页面右上角"注册",输入手机号并获取验证码,设置 6-20 位登录密码即可完成注册。',
|
a: '打开APP后点击“注册”,输入手机号获取验证码,设置登录密码即可完成注册。',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
q: '忘记密码怎么办?',
|
q: '忘记密码怎么办?',
|
||||||
a: '在登录页点击"忘记密码",通过绑定手机号验证码验证身份后即可重新设置密码。',
|
a: '在登录页面点击“忘记密码”,输入注册手机号获取验证码,验证通过后即可重置新密码。',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
q: '一个账号可以多地登录吗?',
|
q: '一个账号可以多端登录吗?',
|
||||||
a: '同一账号同时仅允许在 1 台设备上保持在线,超出将自动踢出最早登录的设备。',
|
a: '同一账号仅允许在 1 台设备上保持在线,超出将自动踢出最早登录的设备。',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
q: '如何修改绑定手机号?',
|
q: '如何修改绑定手机号?',
|
||||||
a: '进入 个人中心 → 账号安全 → 绑定手机,验证原手机号后即可绑定新号码。',
|
a: '进入「我的 -> 设置 -> 账户安全 -> 更换手机号」,验证原手机号后即可绑定新号码。',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '2. 功能使用指南',
|
title: '功能使用指南',
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
q: '如何提交需求?',
|
q: '如何提交报修/工单?',
|
||||||
a: '进入"发布需求"页面,填写需求描述、预算、周期等信息并提交,专业顾问将在一个工作日内与您联系。',
|
a: '首页点击「报修」 -> 选择报修类型 -> 填写问题描述并上传图片 -> 提交。提交后可在「我的 -> 我的工单」中查看处理进度。',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
q: '如何查看我的消息?',
|
q: '如何预约会议室?',
|
||||||
a: '登录后点击右上角消息图标,或进入 个人中心 → 消息通知,即可查看系统通知与顾问消息。',
|
a: '进入「服务 -> 会议室预订」,选择日期、时间段及会议室,确认后提交预约,预约成功将收到消息通知。',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
q: '如何申请售后?',
|
q: '如何查看我的消息通知?',
|
||||||
a: '在"我的订单"中找到对应项目,点击"申请售后"填写问题描述并上传附件,平台将尽快为您处理。',
|
a: '首页右上角铃铛图标进入消息中心,支持按“全部 / 未读 / 系统通知 / 业务提醒”分类查看。',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '3. 系统与权限',
|
title: '系统与权限',
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
q: '无权限怎么办?',
|
q: 'APP提示“无权限”怎么办?',
|
||||||
a: '请确认当前账号角色是否具备该模块权限;如仍无法访问,请联系专属客服或平台管理员为您开通。',
|
a: '部分功能需要管理员授权后方可使用,请联系贵司系统管理员为您分配对应角色权限。',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
q: '收不到通知怎么办?',
|
q: '为什么收不到推送通知?',
|
||||||
a: '请检查浏览器通知权限与消息设置是否开启;若仍未收到,请联系客服核实账号通知配置。',
|
a: '请检查:①手机系统设置中是否允许本APP发送通知;②APP内「我的 -> 设置 -> 消息推送」是否已开启。',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
q: '支持哪些浏览器?',
|
q: 'APP支持哪些系统版本?',
|
||||||
a: '推荐使用 Chrome、Edge、Firefox、Safari 等主流浏览器的最新版本访问本平台。',
|
a: 'iOS 13.0 及以上版本,Android 8.0 及以上版本。建议使用最新系统版本以获得最佳体验。',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
BIN
src/assets/home/chahua.png
Normal file
BIN
src/assets/home/chahua.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.0 KiB |
@@ -85,90 +85,71 @@ const coreBusiness = [
|
|||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.app-footer {
|
.app-footer {
|
||||||
// 设计稿: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;
|
background: @footer-bg;
|
||||||
color: @white;
|
color: @white;
|
||||||
|
padding: @spacing-xl 0 @spacing-base;
|
||||||
|
|
||||||
&__inner {
|
&__inner {
|
||||||
.flex(row, flex-start, flex-start);
|
.flex(row, space-between, flex-start);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: @spacing-xl;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 1920px;
|
max-width: @page-content;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
height: 250px;
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding-top: 54px;
|
padding: 0 @page-gutter;
|
||||||
padding-left: clamp(
|
|
||||||
@spacing-base,
|
|
||||||
calc((100% - 1609px) / 2),
|
|
||||||
155px
|
|
||||||
);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__about {
|
&__about {
|
||||||
flex: 0 1 625px; // x155 → 780
|
flex: 0 1 360px;
|
||||||
|
max-width: 420px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__brand {
|
&__brand {
|
||||||
.flex(row, flex-start);
|
.flex(row, flex-start);
|
||||||
margin-bottom: 20px;
|
margin-bottom: @spacing-sm;
|
||||||
margin-left: 45px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__name {
|
&__name {
|
||||||
font-size: 28px;
|
font-size: @font-size-lg;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 41px;
|
line-height: 1.4;
|
||||||
color: @white;
|
color: @white;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__desc {
|
&__desc {
|
||||||
margin-top: 0;
|
margin: 0;
|
||||||
font-size: 16px;
|
font-size: @font-size-sm;
|
||||||
font-weight: 500;
|
font-weight: 400;
|
||||||
line-height: 23px;
|
line-height: 1.6;
|
||||||
|
color: fade(@white, 72%);
|
||||||
}
|
}
|
||||||
|
|
||||||
&__col {
|
&__col {
|
||||||
.flex(column, flex-start, flex-start);
|
.flex(column, flex-start, flex-start);
|
||||||
gap: 0;
|
gap: 2px;
|
||||||
|
flex: 0 0 auto;
|
||||||
min-width: 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 {
|
&__title {
|
||||||
margin-bottom: 20px;
|
margin: 0 0 @spacing-sm;
|
||||||
font-size: 28px;
|
font-size: @font-size-base;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 41px;
|
line-height: 1.4;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
color: @white;
|
color: @white;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__link {
|
&__link {
|
||||||
font-size: 16px;
|
font-size: @font-size-sm;
|
||||||
font-weight: 500;
|
font-weight: 400;
|
||||||
line-height: 23px;
|
line-height: 1.7;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: color 0.2s;
|
transition: color 0.2s;
|
||||||
|
color: fade(@white, 72%);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: @primary-light;
|
color: @primary-light;
|
||||||
@@ -176,32 +157,31 @@ const coreBusiness = [
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__contact {
|
&__contact {
|
||||||
font-size: 16px;
|
font-size: @font-size-sm;
|
||||||
font-weight: 500;
|
font-weight: 400;
|
||||||
line-height: 23px;
|
line-height: 1.7;
|
||||||
|
color: fade(@white, 72%);
|
||||||
}
|
}
|
||||||
|
|
||||||
&__bottom {
|
&__bottom {
|
||||||
width: 867px;
|
width: 100%;
|
||||||
max-width: calc(100% - 32px);
|
max-width: @page-content;
|
||||||
margin: 0 auto;
|
margin: @spacing-lg auto 0;
|
||||||
padding-bottom: 20px;
|
padding: @spacing-base @page-gutter 0;
|
||||||
text-align: left;
|
border-top: 1px solid fade(@white, 10%);
|
||||||
// legal[0] 需落在相对 footer 顶 332px 处
|
text-align: center;
|
||||||
margin-top: 62px;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__legal {
|
&__legal {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 14px;
|
font-size: @font-size-xs;
|
||||||
font-weight: 500;
|
font-weight: 400;
|
||||||
line-height: 23px;
|
line-height: 1.6;
|
||||||
white-space: nowrap;
|
color: fade(@white, 55%);
|
||||||
color: @white;
|
|
||||||
|
|
||||||
&--icp {
|
&--icp {
|
||||||
margin-top: 16px;
|
margin-top: @spacing-xs;
|
||||||
margin-left: 315px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted } from 'vue'
|
import { computed, onMounted } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { useMessageStore } from '@/stores/modules/message'
|
import { useMessageStore } from '@/stores/modules/message'
|
||||||
|
|
||||||
@@ -18,6 +19,18 @@ const navItems: NavItem[] = [
|
|||||||
{ label: '个人中心', path: '/profile' },
|
{ label: '个人中心', path: '/profile' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// 首页 path 为 '/',是其它所有路径的前缀;用 Vue Router 默认的
|
||||||
|
// router-link-active(包含匹配)会导致首页在所有页面都被点亮。
|
||||||
|
// 这里自算激活态:首页仅严格等于 '/' 时高亮,其它菜单等于其路径或以 '/path/' 开头
|
||||||
|
// (覆盖子路由,如 /project/123 点亮「项目中心」)。
|
||||||
|
const route = useRoute()
|
||||||
|
const activePath = computed(() => route.path)
|
||||||
|
function isActive(item: NavItem): boolean {
|
||||||
|
const current = activePath.value
|
||||||
|
if (item.path === '/') return current === '/'
|
||||||
|
return current === item.path || current.startsWith(item.path + '/')
|
||||||
|
}
|
||||||
|
|
||||||
const messageStore = useMessageStore()
|
const messageStore = useMessageStore()
|
||||||
const { unreadCount } = storeToRefs(messageStore)
|
const { unreadCount } = storeToRefs(messageStore)
|
||||||
onMounted(() => void messageStore.fetchUnreadCount())
|
onMounted(() => void messageStore.fetchUnreadCount())
|
||||||
@@ -32,6 +45,7 @@ onMounted(() => void messageStore.fetchUnreadCount())
|
|||||||
:key="item.path"
|
:key="item.path"
|
||||||
:to="item.path"
|
:to="item.path"
|
||||||
class="app-header__nav-item"
|
class="app-header__nav-item"
|
||||||
|
:class="{ 'app-header__nav-item--active': isActive(item) }"
|
||||||
>
|
>
|
||||||
{{ item.label }}
|
{{ item.label }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
@@ -92,29 +106,29 @@ onMounted(() => void messageStore.fetchUnreadCount())
|
|||||||
|
|
||||||
&__nav {
|
&__nav {
|
||||||
.flex(row, center);
|
.flex(row, center);
|
||||||
gap: @header-nav-gap;
|
gap: clamp(@spacing-lg, 4vw, 56px);
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
||||||
&-item {
|
&-item {
|
||||||
position: relative;
|
position: relative;
|
||||||
.flex(row, center);
|
.flex(row, center);
|
||||||
height: 100%;
|
height: 100%;
|
||||||
font-size: 18px;
|
font-size: @font-size-md;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: @black;
|
color: @heading-color;
|
||||||
transition: color 0.2s;
|
transition: color 0.2s;
|
||||||
|
|
||||||
&:hover,
|
&:hover,
|
||||||
&.router-link-active {
|
&--active {
|
||||||
color: @home-tab-active;
|
color: @home-tab-active;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.router-link-active::after {
|
&--active::after {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
bottom: 11px;
|
bottom: 12px;
|
||||||
width: 29px;
|
width: 24px;
|
||||||
height: 2px;
|
height: 2px;
|
||||||
border-radius: 1px;
|
border-radius: 1px;
|
||||||
background: @home-tab-active;
|
background: @home-tab-active;
|
||||||
@@ -125,20 +139,21 @@ onMounted(() => void messageStore.fetchUnreadCount())
|
|||||||
|
|
||||||
&__actions {
|
&__actions {
|
||||||
.flex(row, flex-end, center);
|
.flex(row, flex-end, center);
|
||||||
gap: 29px;
|
gap: @spacing-base;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__action {
|
&__action {
|
||||||
.flex(row, center);
|
.flex(row, center);
|
||||||
gap: 5px;
|
gap: @spacing-xs;
|
||||||
width: 106px;
|
min-width: 96px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
border-radius: @radius-sm;
|
padding: 0 @spacing-base;
|
||||||
|
border-radius: @radius-round;
|
||||||
background: @primary-color;
|
background: @primary-color;
|
||||||
color: @white;
|
color: @white;
|
||||||
font-size: 16px;
|
font-size: @font-size-sm;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
transition: opacity 0.2s;
|
transition: opacity 0.2s;
|
||||||
|
|
||||||
@@ -147,8 +162,8 @@ onMounted(() => void messageStore.fetchUnreadCount())
|
|||||||
}
|
}
|
||||||
|
|
||||||
&-icon {
|
&-icon {
|
||||||
width: 18px;
|
width: 16px;
|
||||||
height: 18px;
|
height: 16px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,7 +197,7 @@ onMounted(() => void messageStore.fetchUnreadCount())
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__login {
|
&__login {
|
||||||
font-size: 16px;
|
font-size: @font-size-base;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,16 +97,16 @@ const meta = computed<Array<{ label: string; value: string }>>(() => [
|
|||||||
|
|
||||||
&__tag {
|
&__tag {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 87px;
|
top: 12px;
|
||||||
left: 22px;
|
left: 12px;
|
||||||
height: 44px;
|
height: 28px;
|
||||||
padding: 0 14px;
|
padding: 0 @spacing-sm;
|
||||||
border-radius: @radius-sm;
|
border-radius: @radius-sm;
|
||||||
background: @primary-strong;
|
background: @primary-strong;
|
||||||
color: @white;
|
color: @white;
|
||||||
font-size: 28px;
|
font-size: @font-size-sm;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 44px;
|
line-height: 28px;
|
||||||
|
|
||||||
&--hardware {
|
&--hardware {
|
||||||
background: @tag-hardware;
|
background: @tag-hardware;
|
||||||
|
|||||||
@@ -292,15 +292,15 @@ function clearChips(): void {
|
|||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.case-filter {
|
.case-filter {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 516px;
|
width: @cases-filter-width;
|
||||||
padding: 15px 0 40px;
|
padding: @spacing-lg 0 @spacing-xl;
|
||||||
border-radius: @radius-base;
|
border-radius: @radius-base;
|
||||||
background: @white;
|
background: @white;
|
||||||
|
|
||||||
&__head,
|
&__head,
|
||||||
&__group,
|
&__group,
|
||||||
&__actions {
|
&__actions {
|
||||||
padding: 0 50px 0 35px;
|
padding: 0 @spacing-lg;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__head {
|
&__head {
|
||||||
@@ -308,15 +308,15 @@ function clearChips(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__title {
|
&__title {
|
||||||
font-size: 28px;
|
font-size: @font-size-xl;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
line-height: 41px;
|
line-height: 1.4;
|
||||||
color: @cases-filter-title;
|
color: @cases-filter-title;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__link {
|
&__link {
|
||||||
color: @primary-strong;
|
color: @primary-strong;
|
||||||
font-size: 16px;
|
font-size: @font-size-sm;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: @primary-hover;
|
color: @primary-hover;
|
||||||
@@ -324,32 +324,36 @@ function clearChips(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__group {
|
&__group {
|
||||||
margin-top: 24px;
|
margin-top: @spacing-lg;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__head + &__group {
|
&__head + &__group {
|
||||||
margin-top: 11px;
|
margin-top: @spacing-base;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__group-title {
|
&__group-title {
|
||||||
font-size: 24px;
|
font-size: @font-size-md;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 35px;
|
line-height: 1.4;
|
||||||
color: @cases-text;
|
color: @cases-text;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__rows {
|
&__rows {
|
||||||
margin-top: 19px;
|
margin-top: @spacing-sm;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__row {
|
&__row {
|
||||||
.flex(row, flex-start, center);
|
.flex(row, flex-start, center);
|
||||||
gap: 12px;
|
gap: @spacing-sm;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 50px;
|
height: 40px;
|
||||||
padding: 0;
|
padding: 0 @spacing-sm;
|
||||||
|
border-radius: @radius-sm;
|
||||||
color: @cases-text;
|
color: @cases-text;
|
||||||
font-size: 20px;
|
font-size: @font-size-base;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
transition: background 0.2s;
|
transition: background 0.2s;
|
||||||
|
|
||||||
@@ -367,8 +371,8 @@ function clearChips(): void {
|
|||||||
&__box {
|
&__box {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 16px;
|
width: 14px;
|
||||||
height: 16px;
|
height: 14px;
|
||||||
border: 1px solid @cases-input-border;
|
border: 1px solid @cases-input-border;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
|
|
||||||
@@ -380,9 +384,9 @@ function clearChips(): void {
|
|||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 1px;
|
top: 1px;
|
||||||
left: 4px;
|
left: 3px;
|
||||||
width: 5px;
|
width: 4px;
|
||||||
height: 8px;
|
height: 7px;
|
||||||
border: solid @white;
|
border: solid @white;
|
||||||
border-width: 0 2px 2px 0;
|
border-width: 0 2px 2px 0;
|
||||||
transform: rotate(45deg);
|
transform: rotate(45deg);
|
||||||
@@ -397,7 +401,7 @@ function clearChips(): void {
|
|||||||
|
|
||||||
&__row-count {
|
&__row-count {
|
||||||
color: @cases-muted;
|
color: @cases-muted;
|
||||||
font-size: 16px;
|
font-size: @font-size-sm;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -409,17 +413,17 @@ function clearChips(): void {
|
|||||||
&__pills {
|
&__pills {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 24px 33px;
|
gap: @spacing-sm;
|
||||||
margin-top: 21px;
|
margin-top: @spacing-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__pill {
|
&__pill {
|
||||||
height: 53px;
|
height: 36px;
|
||||||
padding: 0 @spacing-sm;
|
padding: 0 @spacing-sm;
|
||||||
border-radius: @radius-base;
|
border-radius: @radius-base;
|
||||||
background: @pill-bg;
|
background: @pill-bg;
|
||||||
color: @cases-text;
|
color: @cases-text;
|
||||||
font-size: 20px;
|
font-size: @font-size-sm;
|
||||||
.ellipsis();
|
.ellipsis();
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
|
|
||||||
@@ -429,15 +433,14 @@ function clearChips(): void {
|
|||||||
|
|
||||||
&.is-active,
|
&.is-active,
|
||||||
&.is-light {
|
&.is-light {
|
||||||
background: @pill-selected-bg;
|
background: @filter-selected-bg;
|
||||||
color: @white;
|
color: @primary-strong;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ElDatePicker 有两层元素:外层 div.case-filter__date 与内部 .el-range-editor,
|
// ElDatePicker 有两层元素:外层 div.case-filter__date 与内部 .el-range-editor,
|
||||||
// 真正控制盒子尺寸的是内层。所以尺寸/背景/圆角都要用 :deep() 打到内层,
|
// 真正控制盒子尺寸的是内层。所以尺寸/背景/圆角都要用 :deep() 打到内层。
|
||||||
// 且不能写在 &__date 上——那样只命中外层,视觉上看不到变化。
|
|
||||||
&__date-wrap {
|
&__date-wrap {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -448,7 +451,7 @@ function clearChips(): void {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
height: 53px;
|
height: 36px;
|
||||||
padding: 0 @spacing-sm;
|
padding: 0 @spacing-sm;
|
||||||
border-radius: @radius-base;
|
border-radius: @radius-base;
|
||||||
background: @input-bg;
|
background: @input-bg;
|
||||||
@@ -461,7 +464,7 @@ function clearChips(): void {
|
|||||||
&__date {
|
&__date {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
height: 53px;
|
height: 36px;
|
||||||
padding: 0 @spacing-sm;
|
padding: 0 @spacing-sm;
|
||||||
border-radius: @radius-base;
|
border-radius: @radius-base;
|
||||||
background: @input-bg;
|
background: @input-bg;
|
||||||
@@ -481,32 +484,33 @@ function clearChips(): void {
|
|||||||
|
|
||||||
:deep(.el-range-input) {
|
:deep(.el-range-input) {
|
||||||
flex: 0 1 auto;
|
flex: 0 1 auto;
|
||||||
width: 92px;
|
width: 88px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: @cases-text;
|
color: @cases-text;
|
||||||
font-size: 20px;
|
font-size: @font-size-sm;
|
||||||
|
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
color: @cases-text;
|
color: @cases-muted;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.el-range-separator) {
|
:deep(.el-range-separator) {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
margin: 0 @spacing-xs;
|
margin: 0 @spacing-xs;
|
||||||
color: @cases-text;
|
color: @cases-muted;
|
||||||
font-size: 20px;
|
font-size: @font-size-sm;
|
||||||
line-height: 53px;
|
line-height: 36px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.is-focus {
|
&.is-focus {
|
||||||
background: @input-bg;
|
background: @white;
|
||||||
|
box-shadow: 0 0 0 1px @cases-accent inset;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__selected {
|
&__selected {
|
||||||
margin: 23px 50px 0 35px;
|
margin: @spacing-lg @spacing-lg 0;
|
||||||
padding: 16px 24px 21px;
|
padding: @spacing-base;
|
||||||
border-radius: @radius-base;
|
border-radius: @radius-base;
|
||||||
background: @chips-panel-bg;
|
background: @chips-panel-bg;
|
||||||
}
|
}
|
||||||
@@ -517,30 +521,30 @@ function clearChips(): void {
|
|||||||
|
|
||||||
&__selected-title {
|
&__selected-title {
|
||||||
color: @cases-text;
|
color: @cases-text;
|
||||||
font-size: 20px;
|
font-size: @font-size-base;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__selected-empty {
|
&__selected-empty {
|
||||||
margin-top: @spacing-sm;
|
margin-top: @spacing-sm;
|
||||||
color: @cases-muted;
|
color: @cases-muted;
|
||||||
font-size: 14px;
|
font-size: @font-size-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__chips {
|
&__chips {
|
||||||
.flex(row, flex-start, center);
|
.flex(row, flex-start, center);
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 14px 13px;
|
gap: @spacing-sm;
|
||||||
margin-top: 16px;
|
margin-top: @spacing-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__chip {
|
&__chip {
|
||||||
height: 32px;
|
height: 28px;
|
||||||
padding: 0 @spacing-sm;
|
padding: 0 @spacing-sm;
|
||||||
border-radius: @radius-round;
|
border-radius: @radius-round;
|
||||||
background: @white;
|
background: @white;
|
||||||
color: @cases-accent;
|
color: @cases-accent;
|
||||||
font-size: 14px;
|
font-size: @font-size-sm;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
transition: color 0.2s;
|
transition: color 0.2s;
|
||||||
|
|
||||||
@@ -551,32 +555,32 @@ function clearChips(): void {
|
|||||||
|
|
||||||
&__actions {
|
&__actions {
|
||||||
.flex(row, flex-start, center);
|
.flex(row, flex-start, center);
|
||||||
gap: 13px;
|
gap: @spacing-sm;
|
||||||
margin-top: 30px;
|
margin-top: @spacing-lg;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__reset {
|
&__reset {
|
||||||
width: 135px;
|
flex: 0 0 88px;
|
||||||
height: 30px;
|
height: 32px;
|
||||||
border-radius: @radius-round;
|
border-radius: @radius-round;
|
||||||
background: @cases-reset-bg;
|
background: @cases-reset-bg;
|
||||||
color: @black;
|
color: @text-color;
|
||||||
font-size: 14px;
|
font-size: @font-size-sm;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
transition: filter 0.2s;
|
transition: color 0.2s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
filter: brightness(0.96);
|
color: @primary-color;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__confirm {
|
&__confirm {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 30px;
|
height: 32px;
|
||||||
border-radius: @radius-round;
|
border-radius: @radius-round;
|
||||||
background: @cases-confirm;
|
background: @cases-confirm;
|
||||||
color: @white;
|
color: @white;
|
||||||
font-size: 14px;
|
font-size: @font-size-sm;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
transition: background 0.2s;
|
transition: background 0.2s;
|
||||||
|
|
||||||
@@ -4,9 +4,11 @@ import { computed } from 'vue'
|
|||||||
interface Props {
|
interface Props {
|
||||||
keyword: string
|
keyword: string
|
||||||
favoriteOnly: boolean
|
favoriteOnly: boolean
|
||||||
|
/** 是否显示「收藏列表」切换按钮(案例库显示,收藏页本身不显示) */
|
||||||
|
showFavoriteToggle?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = withDefaults(defineProps<Props>(), { showFavoriteToggle: true })
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:keyword': [keyword: string]
|
'update:keyword': [keyword: string]
|
||||||
@@ -44,6 +46,7 @@ const keyword = computed<string>({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
v-if="showFavoriteToggle"
|
||||||
class="case-search__fav"
|
class="case-search__fav"
|
||||||
:class="{ 'is-active': favoriteOnly }"
|
:class="{ 'is-active': favoriteOnly }"
|
||||||
@click="emit('toggleFavoriteOnly')"
|
@click="emit('toggleFavoriteOnly')"
|
||||||
@@ -59,29 +62,27 @@ const keyword = computed<string>({
|
|||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.case-search {
|
.case-search {
|
||||||
.flex(row, flex-start, center);
|
.flex(row, flex-start, center);
|
||||||
gap: 24px;
|
gap: @spacing-base;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 128px;
|
min-height: 64px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
// 设计稿 #31:输入框 x=653(栏左 606 + 47),重置 1305,搜索 1429
|
padding: 0 @spacing-lg;
|
||||||
padding: 0 47px;
|
|
||||||
border-radius: @radius-base;
|
border-radius: @radius-base;
|
||||||
background: @white;
|
background: @white;
|
||||||
|
|
||||||
&__field {
|
&__field {
|
||||||
position: relative;
|
position: relative;
|
||||||
// 输入框右边界 1245 → 重置按钮 1305,设计稿间距 60
|
flex: 0 1 360px;
|
||||||
margin-right: 36px;
|
min-width: 0;
|
||||||
flex: 0 0 592px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__icon {
|
&__icon {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
right: 16px;
|
right: @spacing-base;
|
||||||
left: auto;
|
left: auto;
|
||||||
width: 27px;
|
width: 18px;
|
||||||
height: 27px;
|
height: 18px;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
fill: none;
|
fill: none;
|
||||||
stroke: @cases-icon;
|
stroke: @cases-icon;
|
||||||
@@ -90,15 +91,15 @@ const keyword = computed<string>({
|
|||||||
|
|
||||||
&__input {
|
&__input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 49px;
|
height: 36px;
|
||||||
padding: 0 52px 0 20px;
|
padding: 0 40px 0 @spacing-base;
|
||||||
border: 1px solid @cases-input-border;
|
border: 1px solid @cases-input-border;
|
||||||
border-radius: @radius-sm;
|
border-radius: @radius-round;
|
||||||
background: @white;
|
background: @input-bg;
|
||||||
color: @cases-text;
|
color: @cases-text;
|
||||||
font-size: 20px;
|
font-size: @font-size-base;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color 0.2s;
|
transition: border-color 0.2s, background-color 0.2s;
|
||||||
|
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
color: @text-color-disabled;
|
color: @text-color-disabled;
|
||||||
@@ -106,22 +107,24 @@ const keyword = computed<string>({
|
|||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
border-color: @cases-accent;
|
border-color: @cases-accent;
|
||||||
|
background: @white;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__btn {
|
&__btn {
|
||||||
min-width: 100px;
|
flex-shrink: 0;
|
||||||
height: 49px;
|
min-width: 72px;
|
||||||
padding: 0 @spacing-base;
|
height: 36px;
|
||||||
border-radius: @radius-sm;
|
padding: 0 @spacing-lg;
|
||||||
background: @cases-accent;
|
border-radius: @radius-round;
|
||||||
color: @white;
|
background: @pill-bg;
|
||||||
font-size: 20px;
|
color: @text-color;
|
||||||
|
font-size: @font-size-base;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: @primary-hover;
|
color: @primary-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
&--primary {
|
&--primary {
|
||||||
@@ -140,7 +143,7 @@ const keyword = computed<string>({
|
|||||||
gap: @spacing-xs;
|
gap: @spacing-xs;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
color: @heading-color;
|
color: @heading-color;
|
||||||
font-size: 16px;
|
font-size: @font-size-base;
|
||||||
transition: color 0.2s;
|
transition: color 0.2s;
|
||||||
|
|
||||||
&:hover,
|
&:hover,
|
||||||
69
src/components/EllipsisText.vue
Normal file
69
src/components/EllipsisText.vue
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
import { ElTooltip } from 'element-plus'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
text: string
|
||||||
|
placement?: 'top' | 'bottom' | 'left' | 'right'
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
placement: 'top',
|
||||||
|
})
|
||||||
|
|
||||||
|
const root = ref<HTMLElement | null>(null)
|
||||||
|
const overflow = ref(false)
|
||||||
|
|
||||||
|
function check(): void {
|
||||||
|
const el = root.value
|
||||||
|
if (!el) return
|
||||||
|
overflow.value = el.scrollWidth > el.clientWidth + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
let ro: ResizeObserver | null = null
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
check()
|
||||||
|
if (root.value && typeof ResizeObserver !== 'undefined') {
|
||||||
|
ro = new ResizeObserver(() => check())
|
||||||
|
ro.observe(root.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
ro?.disconnect()
|
||||||
|
ro = null
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.text,
|
||||||
|
() => check(),
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span ref="root" class="ellipsis-text">
|
||||||
|
<ElTooltip :content="text" :disabled="!overflow" :placement="placement" effect="dark">
|
||||||
|
<span class="ellipsis-text__inner">{{ text }}</span>
|
||||||
|
</ElTooltip>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.ellipsis-text {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: @font-size-base;
|
||||||
|
font-weight: 600;
|
||||||
|
color: @profile-ink;
|
||||||
|
line-height: 20px;
|
||||||
|
.ellipsis();
|
||||||
|
|
||||||
|
&__inner {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 100%;
|
||||||
|
vertical-align: bottom;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -71,54 +71,55 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.filter-panel {
|
.filter-panel {
|
||||||
padding: 24px 36px;
|
padding: @spacing-lg;
|
||||||
border-radius: @radius-base;
|
border-radius: @radius-base;
|
||||||
background: @white;
|
background: @white;
|
||||||
|
|
||||||
&__bar {
|
&__bar {
|
||||||
.flex(row, flex-start);
|
.flex(row, flex-start, center);
|
||||||
gap: 18px;
|
gap: @spacing-base;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__input {
|
&__input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
max-width: 456px;
|
max-width: 360px;
|
||||||
margin-right: 28px;
|
margin-right: 0;
|
||||||
height: 38px;
|
height: 36px;
|
||||||
padding: 0 @spacing-base;
|
padding: 0 @spacing-base;
|
||||||
border: 1px solid @cases-input-border;
|
border: 1px solid @border-color;
|
||||||
border-radius: @radius-sm;
|
border-radius: @radius-round;
|
||||||
background: @white;
|
background: @input-bg;
|
||||||
color: @cases-text;
|
color: @text-color;
|
||||||
font-size: 15px;
|
font-size: @font-size-base;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color 0.2s;
|
transition: border-color 0.2s, background-color 0.2s;
|
||||||
|
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
color: @cases-input-border;
|
color: @text-color-disabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
border-color: @cases-accent;
|
border-color: @primary-color;
|
||||||
|
background: @white;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__btn {
|
&__btn {
|
||||||
min-width: 77px;
|
min-width: 72px;
|
||||||
height: 38px;
|
height: 36px;
|
||||||
padding: 0 @spacing-base;
|
padding: 0 @spacing-lg;
|
||||||
border-radius: @radius-sm;
|
border-radius: @radius-round;
|
||||||
background: @cases-accent;
|
background: @pill-bg;
|
||||||
color: @white;
|
color: @text-color;
|
||||||
font-size: 15px;
|
font-size: @font-size-base;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: @primary-hover;
|
color: @primary-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
&--primary {
|
&--primary {
|
||||||
background: @cases-accent;
|
background: @primary-color;
|
||||||
color: @white;
|
color: @white;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
@@ -131,26 +132,26 @@ const emit = defineEmits<{
|
|||||||
&__chips {
|
&__chips {
|
||||||
.flex(row, flex-start, center);
|
.flex(row, flex-start, center);
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 11px 10px;
|
gap: @spacing-sm;
|
||||||
margin-top: @spacing-base;
|
margin-top: @spacing-base;
|
||||||
padding: 12px 18px 16px;
|
padding: @spacing-base;
|
||||||
border-radius: @radius-base;
|
border-radius: @radius-base;
|
||||||
background: @chips-panel-bg;
|
background: @chips-panel-bg;
|
||||||
font-size: 15px;
|
font-size: @font-size-base;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__chips-label {
|
&__chips-label {
|
||||||
color: @cases-text;
|
color: @text-color;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__chip {
|
&__chip {
|
||||||
height: 25px;
|
height: 28px;
|
||||||
padding: 0 @spacing-sm;
|
padding: 0 @spacing-sm;
|
||||||
border-radius: @radius-round;
|
border-radius: @radius-round;
|
||||||
background: @white;
|
background: @white;
|
||||||
color: @cases-accent;
|
color: @primary-color;
|
||||||
font-size: 11px;
|
font-size: @font-size-sm;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
|||||||
@@ -23,23 +23,25 @@ defineProps<Props>()
|
|||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.page-head {
|
.page-head {
|
||||||
background: @white;
|
background: @white;
|
||||||
padding-bottom: 72px;
|
padding: @spacing-lg 0;
|
||||||
|
border-bottom: 1px solid @border-color;
|
||||||
|
|
||||||
&__title {
|
&__title {
|
||||||
padding-top: 52px;
|
padding-top: 0;
|
||||||
font-size: 22px;
|
font-size: @font-size-title;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
line-height: 29px;
|
line-height: 1.3;
|
||||||
color: @finance-ink;
|
color: @heading-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__slogan {
|
&__slogan {
|
||||||
|
text-align: left;
|
||||||
padding-bottom: 0;
|
padding-bottom: 0;
|
||||||
margin-top: 9px;
|
margin-top: @spacing-sm;
|
||||||
font-size: 13px;
|
font-size: @font-size-base;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
line-height: 17px;
|
line-height: 1.5;
|
||||||
color: @finance-muted-text;
|
color: @text-color-secondary;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -67,18 +67,18 @@ function go(page: number): void {
|
|||||||
.pagination {
|
.pagination {
|
||||||
.flex(row, center);
|
.flex(row, center);
|
||||||
gap: @spacing-xs;
|
gap: @spacing-xs;
|
||||||
padding: 12px 0;
|
padding: @spacing-base 0;
|
||||||
|
|
||||||
&__btn {
|
&__btn {
|
||||||
.flex(row, center);
|
.flex(row, center);
|
||||||
min-width: 20px;
|
min-width: 28px;
|
||||||
height: 25px;
|
height: 28px;
|
||||||
padding: 0 3px;
|
padding: 0 6px;
|
||||||
border: 1px solid @border-color;
|
border: 1px solid @border-color;
|
||||||
border-radius: @radius-sm;
|
border-radius: @radius-sm;
|
||||||
background: @white;
|
background: @white;
|
||||||
color: @text-color-secondary;
|
color: @text-color;
|
||||||
font-size: 11px;
|
font-size: @font-size-sm;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
|
|
||||||
&:hover:not(:disabled):not(.is-ellipsis) {
|
&:hover:not(:disabled):not(.is-ellipsis) {
|
||||||
@@ -87,9 +87,10 @@ function go(page: number): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.is-active {
|
&.is-active {
|
||||||
border-color: @cases-page-active-bg;
|
border-color: @primary-strong;
|
||||||
background: @cases-page-active-bg;
|
background: @primary-light;
|
||||||
color: @primary-strong;
|
color: @primary-strong;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.is-ellipsis {
|
&.is-ellipsis {
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import { getPasswordLevel } from '@/utils/validate'
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
password: string
|
password: string
|
||||||
|
/** 是否显示右侧「密码强度:X」文字(登录/注册页显示,个人中心设计稿不显示) */
|
||||||
|
showLabel?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = withDefaults(defineProps<Props>(), { showLabel: true })
|
||||||
|
|
||||||
const level = computed(() => getPasswordLevel(props.password))
|
const level = computed(() => getPasswordLevel(props.password))
|
||||||
|
|
||||||
@@ -28,7 +30,7 @@ const levelIndex = computed(() => (level.value === 'strong' ? 3 : level.value ==
|
|||||||
:class="{ 'is-on': password && i <= levelIndex, [`is-${level}`]: password && i <= levelIndex }"
|
:class="{ 'is-on': password && i <= levelIndex, [`is-${level}`]: password && i <= levelIndex }"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span class="pwd-strength__label" :class="`is-${level}`">{{ password ? `密码强度:${levelText}` : '' }}</span>
|
<span v-if="showLabel" class="pwd-strength__label" :class="`is-${level}`">{{ password ? `密码强度:${levelText}` : '' }}</span>
|
||||||
<p v-if="password && level === 'weak'" class="pwd-strength__tip">
|
<p v-if="password && level === 'weak'" class="pwd-strength__tip">
|
||||||
密码等级过低,请重新输入(建议字母+数字+符号组合)
|
密码等级过低,请重新输入(建议字母+数字+符号组合)
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -32,42 +32,44 @@ function onGetCode(): void {
|
|||||||
.sms-code {
|
.sms-code {
|
||||||
.flex(row, stretch);
|
.flex(row, stretch);
|
||||||
gap: @spacing-sm;
|
gap: @spacing-sm;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
&__input {
|
&__input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
height: 40px;
|
height: 44px;
|
||||||
padding: 0 12px;
|
padding: 0 @spacing-base;
|
||||||
border: 1px solid #dde1e7;
|
border: 1px solid @border-color;
|
||||||
border-radius: @radius-sm;
|
border-radius: @radius-base;
|
||||||
background: @white;
|
background: @bg-color-light;
|
||||||
font-size: 13px;
|
font-size: @font-size-base;
|
||||||
color: #1f2937;
|
color: @heading-color;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color 0.2s;
|
transition: border-color 0.2s, background 0.2s;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
border-color: @primary-strong;
|
border-color: @primary-strong;
|
||||||
|
background: @white;
|
||||||
}
|
}
|
||||||
|
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
color: #1f2937;
|
color: @text-color-secondary;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设计稿 #38:「获取验证码」是带蓝色描边的独立按钮(62×16 文字居中),
|
|
||||||
// 不是纯文字链接。按键宽 88px,与输入框间距 @spacing-sm。
|
|
||||||
&__btn {
|
&__btn {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 88px;
|
min-width: 120px;
|
||||||
height: 40px;
|
height: 44px;
|
||||||
padding: 0;
|
padding: 0 @spacing-base;
|
||||||
border: 1px solid @primary-strong;
|
border: 1px solid @primary-strong;
|
||||||
border-radius: @radius-sm;
|
border-radius: @radius-base;
|
||||||
background: @white;
|
background: @white;
|
||||||
color: @primary-strong;
|
color: @primary-strong;
|
||||||
font-size: 12px;
|
font-size: @font-size-sm;
|
||||||
font-weight: 400;
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
@@ -78,8 +80,9 @@ function onGetCode(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
border-color: @text-color-disabled;
|
border-color: @border-color;
|
||||||
color: @text-color-disabled;
|
background: @bg-color-light;
|
||||||
|
color: @text-color-secondary;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,3 +15,7 @@ export { default as SmsCodeInput } from './SmsCodeInput.vue'
|
|||||||
export { default as PasswordStrength } from './PasswordStrength.vue'
|
export { default as PasswordStrength } from './PasswordStrength.vue'
|
||||||
export { default as ConfirmDialog } from './ConfirmDialog.vue'
|
export { default as ConfirmDialog } from './ConfirmDialog.vue'
|
||||||
export { default as QrCodePanel } from './QrCodePanel.vue'
|
export { default as QrCodePanel } from './QrCodePanel.vue'
|
||||||
|
export { default as CasesFilter } from './CasesFilter.vue'
|
||||||
|
export { default as CasesSearchBar } from './CasesSearchBar.vue'
|
||||||
|
export { default as CasesGrid } from './CasesGrid.vue'
|
||||||
|
export { default as EllipsisText } from './EllipsisText.vue'
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const layoutRoutes: RouteRecordRaw = {
|
|||||||
{ path: 'business/:type', name: 'BusinessDetail', component: () => import('@/views/home/detail.vue'), meta: { title: '业务详情' } },
|
{ path: 'business/:type', name: 'BusinessDetail', component: () => import('@/views/home/detail.vue'), meta: { title: '业务详情' } },
|
||||||
{ path: 'cases', name: 'Cases', component: () => import('@/views/cases'), meta: { title: '成功案例库' } },
|
{ path: 'cases', name: 'Cases', component: () => import('@/views/cases'), meta: { title: '成功案例库' } },
|
||||||
{ path: 'cases/:id', name: 'CaseDetail', component: () => import('@/views/cases/detail.vue'), meta: { title: '案例详情' } },
|
{ path: 'cases/:id', name: 'CaseDetail', component: () => import('@/views/cases/detail.vue'), meta: { title: '案例详情' } },
|
||||||
|
{ path: 'favorites', name: 'Favorites', component: () => import('@/views/profile/favorites.vue'), meta: { title: '收藏列表', requiresAuth: true } },
|
||||||
{ path: 'demand', name: 'Demand', component: () => import('@/views/demand'), meta: { title: '需求提报', requiresAuth: true, noFooter: true } },
|
{ path: 'demand', name: 'Demand', component: () => import('@/views/demand'), meta: { title: '需求提报', requiresAuth: true, noFooter: true } },
|
||||||
|
|
||||||
{ path: 'project', name: 'Project', component: () => import('@/views/project'), meta: { title: '项目中心', requiresAuth: true } },
|
{ path: 'project', name: 'Project', component: () => import('@/views/project'), meta: { title: '项目中心', requiresAuth: true } },
|
||||||
@@ -35,8 +36,11 @@ const layoutRoutes: RouteRecordRaw = {
|
|||||||
children: [
|
children: [
|
||||||
{ path: '', redirect: '/profile/security' },
|
{ path: '', redirect: '/profile/security' },
|
||||||
{ path: 'security', name: 'ProfileSecurity', component: () => import('@/views/profile/security.vue'), meta: { title: '账号安全', requiresAuth: true } },
|
{ path: 'security', name: 'ProfileSecurity', component: () => import('@/views/profile/security.vue'), meta: { title: '账号安全', requiresAuth: true } },
|
||||||
|
{ path: 'security/change-password', name: 'ProfileChangePassword', component: () => import('@/views/profile/change-password.vue'), meta: { title: '修改密码', requiresAuth: true } },
|
||||||
|
{ path: 'security/bind-phone', name: 'ProfileBindPhone', component: () => import('@/views/profile/bind-phone.vue'), meta: { title: '绑定手机号', requiresAuth: true } },
|
||||||
|
{ path: 'security/bind-email', name: 'ProfileBindEmail', component: () => import('@/views/profile/bind-email.vue'), meta: { title: '绑定邮箱', requiresAuth: true } },
|
||||||
|
{ path: 'security/delete-account', name: 'ProfileDeleteAccount', component: () => import('@/views/profile/delete-account.vue'), meta: { title: '注销账号', requiresAuth: true } },
|
||||||
{ path: 'address', name: 'ProfileAddress', component: () => import('@/views/profile/address.vue'), meta: { title: '地址管理', requiresAuth: true } },
|
{ path: 'address', name: 'ProfileAddress', component: () => import('@/views/profile/address.vue'), meta: { title: '地址管理', requiresAuth: true } },
|
||||||
{ path: 'favorites', name: 'ProfileFavorites', component: () => import('@/views/profile/favorites.vue'), meta: { title: '收藏列表', requiresAuth: true } },
|
|
||||||
{ path: 'qualifications', name: 'ProfileQualifications', component: () => import('@/views/profile/qualifications.vue'), meta: { title: '资质列表', requiresAuth: true } },
|
{ path: 'qualifications', name: 'ProfileQualifications', component: () => import('@/views/profile/qualifications.vue'), meta: { title: '资质列表', requiresAuth: true } },
|
||||||
{ path: 'feedback', name: 'ProfileFeedback', component: () => import('@/views/profile/feedback.vue'), meta: { title: '意见反馈', requiresAuth: true } },
|
{ path: 'feedback', name: 'ProfileFeedback', component: () => import('@/views/profile/feedback.vue'), meta: { title: '意见反馈', requiresAuth: true } },
|
||||||
{ path: 'help', name: 'ProfileHelp', component: () => import('@/views/profile/help.vue'), meta: { title: '帮助中心', requiresAuth: true } },
|
{ path: 'help', name: 'ProfileHelp', component: () => import('@/views/profile/help.vue'), meta: { title: '帮助中心', requiresAuth: true } },
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ function toBusinessLine(vo: ProjectSubmissionVo): BusinessLine {
|
|||||||
desc: subtitle,
|
desc: subtitle,
|
||||||
slogan: subtitle,
|
slogan: subtitle,
|
||||||
icon: vo.logoUrl ?? '',
|
icon: vo.logoUrl ?? '',
|
||||||
|
cover: vo.coverUrl ?? '',
|
||||||
serviceContent,
|
serviceContent,
|
||||||
contents,
|
contents,
|
||||||
serviceTypes,
|
serviceTypes,
|
||||||
@@ -99,6 +100,7 @@ function toBusinessLineFromInfo(info: ProjectSubmissionInfo): BusinessLine {
|
|||||||
desc: subtitle,
|
desc: subtitle,
|
||||||
slogan: subtitle,
|
slogan: subtitle,
|
||||||
icon: ps.logoUrl ?? '',
|
icon: ps.logoUrl ?? '',
|
||||||
|
cover: ps.coverUrl ?? '',
|
||||||
serviceContent,
|
serviceContent,
|
||||||
contents,
|
contents,
|
||||||
serviceTypes,
|
serviceTypes,
|
||||||
|
|||||||
@@ -85,9 +85,8 @@
|
|||||||
.flex(column, center, center);
|
.flex(column, center, center);
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
// 设计稿三页的标题顶分别为 306/287/268,卡片在 1080 视口中居中后
|
// 底部多留白,整体视觉中心上移,「欢迎登录」不再偏下
|
||||||
// 会比标注高 15px,用上内边距把内容整体下移补平。
|
padding: 24px 0 120px;
|
||||||
padding: 80px 0 40px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设计稿表单区统一 693px 宽。标题/副标题按自身文字宽度相对表单居中,
|
// 设计稿表单区统一 693px 宽。标题/副标题按自身文字宽度相对表单居中,
|
||||||
|
|||||||
@@ -77,7 +77,7 @@
|
|||||||
|
|
||||||
@header-height: 72px;
|
@header-height: 72px;
|
||||||
@header-nav-gap: 94px;
|
@header-nav-gap: 94px;
|
||||||
@footer-height: 481px;
|
@footer-height: 280px;
|
||||||
@footer-inset: 70px;
|
@footer-inset: 70px;
|
||||||
|
|
||||||
// 登录/注册/找回密码三页共用的品牌区宽度。设计稿 #36/#38/#39 的左侧渐变
|
// 登录/注册/找回密码三页共用的品牌区宽度。设计稿 #36/#38/#39 的左侧渐变
|
||||||
@@ -90,10 +90,10 @@
|
|||||||
@shadow-lg: 0 4px 16px rgba(31, 39, 51, 0.12);
|
@shadow-lg: 0 4px 16px rgba(31, 39, 51, 0.12);
|
||||||
|
|
||||||
@filter-selected-bg: #e7eeff;
|
@filter-selected-bg: #e7eeff;
|
||||||
@pill-bg: #ededed;
|
@pill-bg: #f5f6f8;
|
||||||
@pill-selected-bg: #075cff;
|
@pill-selected-bg: #075cff;
|
||||||
@chips-panel-bg: #eaf1ff;
|
@chips-panel-bg: #eaf1ff;
|
||||||
@input-bg: #ededed;
|
@input-bg: #f5f6f8;
|
||||||
@tag-hardware: #4caf50;
|
@tag-hardware: #4caf50;
|
||||||
@tag-iot: #5720ba;
|
@tag-iot: #5720ba;
|
||||||
@tag-manufacturing: #ff8f1f;
|
@tag-manufacturing: #ff8f1f;
|
||||||
@@ -105,19 +105,23 @@
|
|||||||
@cases-filter-title: #222222;
|
@cases-filter-title: #222222;
|
||||||
@cases-text: #333333;
|
@cases-text: #333333;
|
||||||
@cases-muted: #666666;
|
@cases-muted: #666666;
|
||||||
@cases-input-border: #999999;
|
@cases-input-border: #cdd3dd;
|
||||||
@cases-icon: #797979;
|
@cases-icon: #797979;
|
||||||
@cases-reset-bg: #f0f0f0;
|
@cases-reset-bg: #f5f6f8;
|
||||||
@cases-card-border: #e1dfdf;
|
@cases-card-border: #e4e7ec;
|
||||||
@cases-card-shadow: 0 4px 10px rgba(0, 0, 0, 0.08);
|
@cases-card-shadow: 0 2px 8px rgba(31, 39, 51, 0.06);
|
||||||
@cases-page-active-bg: #e0e9fa;
|
@cases-page-active-bg: #e0e9fa;
|
||||||
|
@cases-filter-width: 320px;
|
||||||
|
|
||||||
@demand-selected-bg: #024fff;
|
@demand-selected-bg: #024fff;
|
||||||
@demand-accent: #024fff;
|
@demand-accent: #024fff;
|
||||||
@demand-secondary-bg: #075cff;
|
@demand-secondary-bg: #075cff;
|
||||||
|
|
||||||
@project-table-head-bg: #e8e8e8;
|
@project-table-head-bg: #f7f8fa;
|
||||||
@project-table-line: #bbbbbb;
|
@project-table-line: #e4e7ec;
|
||||||
|
@project-filter-width: 320px;
|
||||||
|
@project-pill-bg: #f5f6f8;
|
||||||
|
@project-input-bg: #f5f6f8;
|
||||||
|
|
||||||
@project-accent: #024fff;
|
@project-accent: #024fff;
|
||||||
@project-track: #edeff2;
|
@project-track: #edeff2;
|
||||||
@@ -181,8 +185,8 @@
|
|||||||
@demand-ink: #121212;
|
@demand-ink: #121212;
|
||||||
@demand-heading: #333333;
|
@demand-heading: #333333;
|
||||||
@demand-subtitle: #666666;
|
@demand-subtitle: #666666;
|
||||||
@demand-line: #999999;
|
@demand-line: #e4e7ec;
|
||||||
@demand-upload-bg: #ededed;
|
@demand-upload-bg: #f5f6f8;
|
||||||
@demand-radio-idle: #c7c2c2;
|
@demand-radio-idle: #c7c2c2;
|
||||||
@auth-gradient-from: #505be8;
|
@auth-gradient-from: #505be8;
|
||||||
@auth-gradient-to: #8b3fe8;
|
@auth-gradient-to: #8b3fe8;
|
||||||
@@ -208,7 +212,7 @@
|
|||||||
@project-cell: #4b5563;
|
@project-cell: #4b5563;
|
||||||
@project-card: #ffffff;
|
@project-card: #ffffff;
|
||||||
@project-field-bg: #f3f4f6;
|
@project-field-bg: #f3f4f6;
|
||||||
@project-input-border: #dde1e7;
|
@project-input-border: #e4e7ec;
|
||||||
@project-head-bg: #f7f8fa;
|
@project-head-bg: #f7f8fa;
|
||||||
@project-upload-tag: #ef4444;
|
@project-upload-tag: #ef4444;
|
||||||
@project-banner-soft: #fdeeee;
|
@project-banner-soft: #fdeeee;
|
||||||
@@ -223,12 +227,12 @@
|
|||||||
|
|
||||||
@contract-accent: #075cff;
|
@contract-accent: #075cff;
|
||||||
@contract-muted: #666666;
|
@contract-muted: #666666;
|
||||||
@contract-head-text: #222222;
|
@contract-head-text: #666666;
|
||||||
@contract-cell-text: #333333;
|
@contract-cell-text: #333333;
|
||||||
@contract-line: #999999;
|
@contract-line: #e4e7ec;
|
||||||
@contract-table-head-bg: #ededed;
|
@contract-table-head-bg: #f7f8fa;
|
||||||
@contract-placeholder: #b3b9c4;
|
@contract-placeholder: #b3b9c4;
|
||||||
@contract-reset-bg: #eef0f3;
|
@contract-reset-bg: #f5f6f8;
|
||||||
@contract-qr-bg: #d6d6d6;
|
@contract-qr-bg: #d6d6d6;
|
||||||
|
|
||||||
@finance-hero-gradient: linear-gradient(
|
@finance-hero-gradient: linear-gradient(
|
||||||
@@ -239,8 +243,8 @@
|
|||||||
);
|
);
|
||||||
@finance-chart-bar: #4a90c4;
|
@finance-chart-bar: #4a90c4;
|
||||||
@finance-chart-grid: #edeef2;
|
@finance-chart-grid: #edeef2;
|
||||||
@finance-table-head-bg: #f1f3f5;
|
@finance-table-head-bg: #f7f8fa;
|
||||||
@finance-field-bg: #f3f4f6;
|
@finance-field-bg: #f5f6f8;
|
||||||
@finance-page-active: #d5e2fe;
|
@finance-page-active: #d5e2fe;
|
||||||
@finance-summary-cta: #ffb754;
|
@finance-summary-cta: #ffb754;
|
||||||
@finance-result-circle: #7ed321;
|
@finance-result-circle: #7ed321;
|
||||||
@@ -252,9 +256,9 @@
|
|||||||
@profile-nav-active-bg: #e7eeff;
|
@profile-nav-active-bg: #e7eeff;
|
||||||
@profile-card-border: #f0f1f3;
|
@profile-card-border: #f0f1f3;
|
||||||
@profile-line: #e5e7eb;
|
@profile-line: #e5e7eb;
|
||||||
@profile-input-border: #dde1e7;
|
@profile-input-border: #e4e7ec;
|
||||||
@profile-radius: 6px;
|
@profile-radius: 6px;
|
||||||
@profile-page-bg: #f4f6f8;
|
@profile-page-bg: #f4f5f9;
|
||||||
@profile-ink: #1f2937;
|
@profile-ink: #1f2937;
|
||||||
@profile-hotline-bg: #d6e4fb;
|
@profile-hotline-bg: #d6e4fb;
|
||||||
@profile-icon-pwd: #c7d2fe;
|
@profile-icon-pwd: #c7d2fe;
|
||||||
@@ -265,6 +269,9 @@
|
|||||||
@profile-user-avatar: #8976ed;
|
@profile-user-avatar: #8976ed;
|
||||||
@profile-page-active-bg: #d1e0fe;
|
@profile-page-active-bg: #d1e0fe;
|
||||||
@profile-quiet-btn: #eef0f3;
|
@profile-quiet-btn: #eef0f3;
|
||||||
|
@profile-tag-ok-bg: #e0f0e0;
|
||||||
|
@profile-input-disabled-bg: #f3f4f6;
|
||||||
|
@profile-verify-banner-bg: #edf3fe;
|
||||||
|
|
||||||
@contract-ink: #121212;
|
@contract-ink: #121212;
|
||||||
@contract-page-bg: #f4f6f8;
|
@contract-page-bg: #f4f6f8;
|
||||||
@@ -275,3 +282,31 @@
|
|||||||
@finance-entry-text: #4b5563;
|
@finance-entry-text: #4b5563;
|
||||||
@finance-axis-text: #9ca3af;
|
@finance-axis-text: #9ca3af;
|
||||||
@finance-ink: #1f2937;
|
@finance-ink: #1f2937;
|
||||||
|
// 财务中心首页快捷入口图标(设计稿 #20,蓝湖 fallback 无切图,色值取自封面采样)
|
||||||
|
@finance-entry-bill-bg: #e9f1fe;
|
||||||
|
@finance-entry-bill-icon: #1f67ef;
|
||||||
|
@finance-entry-pending-bg: #fff4e5;
|
||||||
|
@finance-entry-invoice-bg: #f1ecfe;
|
||||||
|
@finance-entry-invoice-icon: #7c5ce0;
|
||||||
|
@finance-entry-export-bg: #e6f6ec;
|
||||||
|
@finance-entry-badge-bg: #f53f3f;
|
||||||
|
@finance-chart-switch-active: #024fff;
|
||||||
|
@finance-switch-active-bg: #e8f0ff;
|
||||||
|
@finance-switch-idle-bg: #f2f4f7;
|
||||||
|
|
||||||
|
// 消息中心(设计稿 #22)
|
||||||
|
@message-page-bg: #f4f6f8;
|
||||||
|
@message-tabs-text: #4b5563;
|
||||||
|
@message-tabs-badge: #f53f3f;
|
||||||
|
@message-toolbar-btn-border: #e5e7eb;
|
||||||
|
@message-pager-active-bg: #edf3fe;
|
||||||
|
@message-item-icon: #3b82f6;
|
||||||
|
|
||||||
|
// 在线客服(设计稿 #42)
|
||||||
|
@service-card-line: #f0f1f3;
|
||||||
|
@service-scroll-bg: #fafbfc;
|
||||||
|
@service-input-bg: #f7f8fa;
|
||||||
|
@service-bar-bg: #f8f8f9;
|
||||||
|
@service-header-name: #1f2937;
|
||||||
|
@service-avatar-text: #8d99ae;
|
||||||
|
@service-bubble-text: #1f2937;
|
||||||
|
|||||||
1
src/types/business/home.d.ts
vendored
1
src/types/business/home.d.ts
vendored
@@ -35,6 +35,7 @@ export interface BusinessLine {
|
|||||||
desc: string
|
desc: string
|
||||||
slogan: string
|
slogan: string
|
||||||
icon: string
|
icon: string
|
||||||
|
cover: string
|
||||||
serviceContent: string
|
serviceContent: string
|
||||||
contents: ServiceContentBlock[]
|
contents: ServiceContentBlock[]
|
||||||
serviceTypes: ServiceType[]
|
serviceTypes: ServiceType[]
|
||||||
|
|||||||
@@ -6,30 +6,79 @@ function goHome(): void {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="not-found">
|
<section class="not-found">
|
||||||
<h1>404</h1>
|
<div class="not-found__card">
|
||||||
<p class="text-secondary">
|
<p class="not-found__code">
|
||||||
页面不存在或已下线
|
404
|
||||||
</p>
|
</p>
|
||||||
<button class="not-found__back link" @click="goHome">
|
<h1 class="not-found__title">
|
||||||
返回首页
|
页面不存在
|
||||||
</button>
|
</h1>
|
||||||
|
<p class="not-found__desc text-secondary">
|
||||||
|
页面不存在或已下线,请返回首页继续浏览
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
class="not-found__back"
|
||||||
|
type="button"
|
||||||
|
@click="goHome"
|
||||||
|
>
|
||||||
|
返回首页
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.not-found {
|
.not-found {
|
||||||
.flex(column, center);
|
.flex(column, center, center);
|
||||||
gap: @spacing-base;
|
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
padding: @spacing-lg;
|
||||||
|
background: @bg-color;
|
||||||
|
|
||||||
h1 {
|
&__card {
|
||||||
font-size: 64px;
|
.flex(column, center, center);
|
||||||
|
gap: @spacing-sm;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
padding: @spacing-xxl @spacing-xl;
|
||||||
|
border-radius: @radius-base;
|
||||||
|
background: @white;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__code {
|
||||||
|
margin: 0;
|
||||||
|
font-size: @font-size-hero;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
color: @primary-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: @font-size-xl;
|
||||||
|
font-weight: 600;
|
||||||
color: @heading-color;
|
color: @heading-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__back {
|
&__desc {
|
||||||
color: @primary-color;
|
margin: 0 0 @spacing-base;
|
||||||
font-size: @font-size-base;
|
font-size: @font-size-base;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__back {
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 @spacing-xl;
|
||||||
|
border: none;
|
||||||
|
border-radius: @radius-round;
|
||||||
|
background: @primary-color;
|
||||||
|
color: @white;
|
||||||
|
font-size: @font-size-base;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: @primary-hover;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const coverStyle = computed(() =>
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="case-hero" :style="coverStyle">
|
<section class="case-hero" :style="coverStyle">
|
||||||
<div class="container case-hero__inner">
|
<div class="case-hero__inner">
|
||||||
<span v-if="tag" class="case-hero__tag">{{ tag }}</span>
|
<span v-if="tag" class="case-hero__tag">{{ tag }}</span>
|
||||||
<h1 class="case-hero__title">
|
<h1 class="case-hero__title">
|
||||||
{{ props.item.title }}
|
{{ props.item.title }}
|
||||||
@@ -31,17 +31,19 @@ const coverStyle = computed(() =>
|
|||||||
.case-hero {
|
.case-hero {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 260px;
|
height: 180px;
|
||||||
|
border-radius: @radius-base;
|
||||||
background-color: @primary-color;
|
background-color: @primary-color;
|
||||||
background-position: center;
|
background-position: center;
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
background-size: cover;
|
background-size: cover;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: linear-gradient(180deg, rgba(15, 23, 42, 0) 42%, rgba(15, 23, 42, 0.55));
|
background: linear-gradient(180deg, fade(@black, 0%) 40%, fade(@black, 45%));
|
||||||
}
|
}
|
||||||
|
|
||||||
&__inner {
|
&__inner {
|
||||||
@@ -50,7 +52,8 @@ const coverStyle = computed(() =>
|
|||||||
.flex(column, flex-end, flex-start);
|
.flex(column, flex-end, flex-start);
|
||||||
gap: @spacing-sm;
|
gap: @spacing-sm;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding-bottom: @spacing-lg;
|
box-sizing: border-box;
|
||||||
|
padding: 0 @spacing-lg @spacing-lg;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__tag {
|
&__tag {
|
||||||
@@ -64,9 +67,9 @@ const coverStyle = computed(() =>
|
|||||||
|
|
||||||
&__title {
|
&__title {
|
||||||
color: @white;
|
color: @white;
|
||||||
font-size: @font-size-xxl;
|
font-size: @font-size-title;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-shadow: 0 1px 8px rgba(0, 0, 0, 0.35);
|
text-shadow: 0 1px 8px fade(@black, 35%);
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
|
|||||||
@@ -51,19 +51,21 @@ onMounted(load)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-if="item">
|
<template v-if="item">
|
||||||
<CaseHero :item="item" />
|
<div class="container case-detail__body">
|
||||||
|
<CaseHero :item="item" />
|
||||||
|
|
||||||
<div class="container case-detail__main">
|
<div class="case-detail__main">
|
||||||
<CaseDetailHeader :item="item" @favorite="onFavorite" @share="onShare" />
|
<CaseDetailHeader :item="item" @favorite="onFavorite" @share="onShare" />
|
||||||
<CaseRichText :item="item" />
|
<CaseRichText :item="item" />
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="case-detail__cta"
|
class="case-detail__cta"
|
||||||
type="button"
|
type="button"
|
||||||
@click="router.push('/demand')"
|
@click="router.push('/demand')"
|
||||||
>
|
>
|
||||||
一键填报类似案例
|
一键填报类似案例
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -75,27 +77,33 @@ onMounted(load)
|
|||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.case-detail {
|
.case-detail {
|
||||||
padding-bottom: @spacing-xxl;
|
min-height: 100%;
|
||||||
|
padding-bottom: @spacing-page-bottom;
|
||||||
|
background: @bg-color;
|
||||||
|
|
||||||
&__crumb {
|
&__crumb {
|
||||||
padding-top: 6px;
|
padding-top: @spacing-base;
|
||||||
padding-bottom: 6px;
|
padding-bottom: @spacing-base;
|
||||||
font-size: @font-size-base;
|
font-size: @font-size-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__body {
|
||||||
|
.flex(column, flex-start, stretch);
|
||||||
|
gap: @spacing-base;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__main {
|
&__main {
|
||||||
.flex(column, flex-start, stretch);
|
.flex(column, flex-start, stretch);
|
||||||
gap: @spacing-base;
|
gap: @spacing-base;
|
||||||
padding-top: @spacing-lg;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__cta {
|
&__cta {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 44px;
|
height: 40px;
|
||||||
border-radius: @radius-base;
|
border-radius: @radius-round;
|
||||||
background: @primary-color;
|
background: @primary-color;
|
||||||
color: @white;
|
color: @white;
|
||||||
font-size: @font-size-lg;
|
font-size: @font-size-base;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.2s;
|
transition: background 0.2s;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
export { default } from './index.vue'
|
export { default } from './index.vue'
|
||||||
export { default as CasesFilter } from './cpns/CasesFilter.vue'
|
export { CasesFilter, CasesSearchBar, CasesGrid } from '@/components'
|
||||||
export { default as CasesSearchBar } from './cpns/CasesSearchBar.vue'
|
|
||||||
export { default as CasesGrid } from './cpns/CasesGrid.vue'
|
|
||||||
export { default as CaseHero } from './cpns/CaseHero.vue'
|
export { default as CaseHero } from './cpns/CaseHero.vue'
|
||||||
export { default as CaseDetailHeader } from './cpns/CaseDetailHeader.vue'
|
export { default as CaseDetailHeader } from './cpns/CaseDetailHeader.vue'
|
||||||
export { default as CaseRichText } from './cpns/CaseRichText.vue'
|
export { default as CaseRichText } from './cpns/CaseRichText.vue'
|
||||||
|
|||||||
@@ -3,12 +3,9 @@
|
|||||||
import { onMounted } from 'vue'
|
import { onMounted } from 'vue'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { PaginationBar } from '@/components'
|
import { CasesFilter, CasesGrid, CasesSearchBar, PaginationBar } from '@/components'
|
||||||
import { useCasesStore } from '@/stores/modules/cases'
|
import { useCasesStore } from '@/stores/modules/cases'
|
||||||
import type { CaseQuery } from '@/types/business/cases'
|
import type { CaseQuery } from '@/types/business/cases'
|
||||||
import CasesFilter from './cpns/CasesFilter.vue'
|
|
||||||
import CasesSearchBar from './cpns/CasesSearchBar.vue'
|
|
||||||
import CasesGrid from './cpns/CasesGrid.vue'
|
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const casesStore = useCasesStore()
|
const casesStore = useCasesStore()
|
||||||
@@ -48,6 +45,8 @@ function onViewDetail(id: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
// 收藏页复用本 store 并会置 favoriteOnly=true,进入案例库时重置,避免状态残留
|
||||||
|
casesStore.resetQuery()
|
||||||
void Promise.all([casesStore.fetchCategories(), casesStore.fetchCases()])
|
void Promise.all([casesStore.fetchCategories(), casesStore.fetchCases()])
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -110,53 +109,49 @@ onMounted(() => {
|
|||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.cases {
|
.cases {
|
||||||
padding-bottom: 78px;
|
min-height: 100%;
|
||||||
|
padding-bottom: @spacing-page-bottom;
|
||||||
|
background: @bg-color;
|
||||||
|
|
||||||
&__hero {
|
&__hero {
|
||||||
height: 179px;
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding-top: 32px;
|
padding: @spacing-lg 0;
|
||||||
background: @white;
|
background: @white;
|
||||||
|
border-bottom: 1px solid @border-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设计稿 #31:hero 标题 x=112、副标题 x=112;
|
|
||||||
// 主体筛选栏 x=70、右侧操作区右边界 1850。两套留白并存,
|
|
||||||
// 所以 hero 内容单独内缩,不动全局 .container 的 70px 留白。
|
|
||||||
&__hero-inner {
|
&__hero-inner {
|
||||||
padding-left: 112px;
|
padding-left: @spacing-xl;
|
||||||
padding-right: 112px;
|
padding-right: @spacing-xl;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__title {
|
&__title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 36px;
|
font-size: @font-size-title;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
line-height: 52px;
|
line-height: 1.3;
|
||||||
color: @cases-hero-title;
|
color: @cases-hero-title;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__slogan {
|
&__slogan {
|
||||||
margin: 15px 0 0;
|
margin: @spacing-sm 0 0;
|
||||||
font-size: 24px;
|
font-size: @font-size-base;
|
||||||
font-weight: 500;
|
font-weight: 400;
|
||||||
line-height: 37px;
|
line-height: 1.5;
|
||||||
color: @cases-muted;
|
color: @cases-muted;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__body {
|
&__body {
|
||||||
.flex(row, flex-start, flex-start);
|
.flex(row, flex-start, flex-start);
|
||||||
gap: 20px;
|
gap: @spacing-lg;
|
||||||
padding-top: 20px;
|
padding-top: @spacing-lg;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__main {
|
&__main {
|
||||||
// 设计稿:右侧操作区 x=606 w=1244(右边界 1850,与筛选栏区留白 70 对称)。
|
|
||||||
// mixin 签名是 .flex(direction; justify; align),第三参才是 align-items。
|
|
||||||
// 纵向排列时 justify 无意义,align 用 stretch 让子项撑满 1244。
|
|
||||||
.flex(column, flex-start, stretch);
|
.flex(column, flex-start, stretch);
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
gap: 24px;
|
gap: @spacing-lg;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__pager {
|
&__pager {
|
||||||
|
|||||||
@@ -25,165 +25,172 @@ const SIGN_VARIANTS: Record<SignStatus, 'primary' | 'success' | 'warning' | 'dan
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="contract-info">
|
<div class="contract-info">
|
||||||
<h3 class="contract-info__section contract-info__section--1">
|
<section class="contract-info__section">
|
||||||
<span class="contract-info__step">01</span>
|
<h3 class="contract-info__heading">
|
||||||
<span class="contract-info__section-title">合同信息</span>
|
<span class="contract-info__step">01</span>
|
||||||
</h3>
|
<span>合同信息</span>
|
||||||
<div class="contract-info__field contract-info__field--1 contract-info__field--l">
|
</h3>
|
||||||
<span class="contract-info__label">项目名称</span>
|
<div class="contract-info__grid">
|
||||||
<span class="contract-info__value">{{ contract.contractName }}</span>
|
<div class="contract-info__field">
|
||||||
</div>
|
<span class="contract-info__label">项目名称</span>
|
||||||
<div class="contract-info__field contract-info__field--1 contract-info__field--r">
|
<span class="contract-info__value">{{ contract.contractName }}</span>
|
||||||
<span class="contract-info__label">合同类型</span>
|
</div>
|
||||||
<span class="contract-info__value">{{ contract.contractType }}</span>
|
<div class="contract-info__field">
|
||||||
</div>
|
<span class="contract-info__label">合同类型</span>
|
||||||
<div class="contract-info__field contract-info__field--2 contract-info__field--l">
|
<span class="contract-info__value">{{ contract.contractType }}</span>
|
||||||
<span class="contract-info__label">合同编号</span>
|
</div>
|
||||||
<span class="contract-info__value">{{ contract.contractNo }}</span>
|
<div class="contract-info__field">
|
||||||
</div>
|
<span class="contract-info__label">合同编号</span>
|
||||||
<div class="contract-info__field contract-info__field--2 contract-info__field--r">
|
<span class="contract-info__value">{{ contract.contractNo }}</span>
|
||||||
<span class="contract-info__label">签订日期</span>
|
</div>
|
||||||
<span class="contract-info__value">{{ contract.signDate || '—' }}</span>
|
<div class="contract-info__field">
|
||||||
</div>
|
<span class="contract-info__label">签订日期</span>
|
||||||
<div class="contract-info__field contract-info__field--3 contract-info__field--l">
|
<span class="contract-info__value">{{ contract.signDate || '—' }}</span>
|
||||||
<span class="contract-info__label">生效日期</span>
|
</div>
|
||||||
<span class="contract-info__value">{{ contract.effectiveDate }}</span>
|
<div class="contract-info__field">
|
||||||
</div>
|
<span class="contract-info__label">生效日期</span>
|
||||||
<div class="contract-info__field contract-info__field--3 contract-info__field--r">
|
<span class="contract-info__value">{{ contract.effectiveDate }}</span>
|
||||||
<span class="contract-info__label">到期日期</span>
|
</div>
|
||||||
<span class="contract-info__value">{{ contract.expiryDate }}</span>
|
<div class="contract-info__field">
|
||||||
</div>
|
<span class="contract-info__label">到期日期</span>
|
||||||
<div class="contract-info__field contract-info__field--4 contract-info__field--l">
|
<span class="contract-info__value">{{ contract.expiryDate }}</span>
|
||||||
<span class="contract-info__label">合同状态</span>
|
</div>
|
||||||
<StatusTag :label="contract.signStatus" :variant="SIGN_VARIANTS[contract.signStatus]" />
|
<div class="contract-info__field">
|
||||||
</div>
|
<span class="contract-info__label">合同状态</span>
|
||||||
|
<StatusTag :label="contract.signStatus" :variant="SIGN_VARIANTS[contract.signStatus]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<h3 class="contract-info__section contract-info__section--2">
|
<section class="contract-info__section">
|
||||||
<span class="contract-info__step">02</span>
|
<h3 class="contract-info__heading">
|
||||||
<span class="contract-info__section-title">关联项目</span>
|
<span class="contract-info__step">02</span>
|
||||||
</h3>
|
<span>关联项目</span>
|
||||||
<div class="contract-info__field contract-info__field--5 contract-info__field--l">
|
</h3>
|
||||||
<span class="contract-info__label">
|
<div class="contract-info__grid">
|
||||||
项目名称
|
<div class="contract-info__field">
|
||||||
<button class="contract-info__link" @click="emit('viewProject')">查看详情</button>
|
<span class="contract-info__label">
|
||||||
</span>
|
项目名称
|
||||||
<span class="contract-info__value">{{ contract.relatedProjectName }}</span>
|
<button
|
||||||
</div>
|
type="button"
|
||||||
<div class="contract-info__field contract-info__field--5 contract-info__field--r">
|
class="contract-info__link"
|
||||||
<span class="contract-info__label">项目编号</span>
|
@click="emit('viewProject')"
|
||||||
<span class="contract-info__value">{{ contract.projectNo }}</span>
|
>
|
||||||
</div>
|
查看详情
|
||||||
|
</button>
|
||||||
<h3 class="contract-info__section contract-info__section--3">
|
|
||||||
<span class="contract-info__step">03</span>
|
|
||||||
<span class="contract-info__section-title">合同文件</span>
|
|
||||||
</h3>
|
|
||||||
<ul class="contract-info__files">
|
|
||||||
<li
|
|
||||||
v-for="(file, index) in contract.files"
|
|
||||||
:key="`${file.name}-${index}`"
|
|
||||||
class="contract-info__file"
|
|
||||||
>
|
|
||||||
<div class="contract-info__file-info">
|
|
||||||
<span class="contract-info__file-name" :title="file.name">{{ file.name }}</span>
|
|
||||||
<span class="contract-info__file-meta">
|
|
||||||
{{ formatFileSize(file.size) }} · {{ file.uploadTime }}
|
|
||||||
</span>
|
</span>
|
||||||
|
<span class="contract-info__value">{{ contract.relatedProjectName }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="contract-info__file-actions">
|
<div class="contract-info__field">
|
||||||
<button class="contract-info__link" @click="emit('preview', file)">
|
<span class="contract-info__label">项目编号</span>
|
||||||
预览
|
<span class="contract-info__value">{{ contract.projectNo }}</span>
|
||||||
</button>
|
|
||||||
<button class="contract-info__link" @click="emit('download', file)">
|
|
||||||
下载
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</div>
|
||||||
<li v-if="contract.files.length === 0" class="contract-info__empty">
|
</section>
|
||||||
暂无合同文件
|
|
||||||
</li>
|
<section class="contract-info__section">
|
||||||
</ul>
|
<h3 class="contract-info__heading">
|
||||||
|
<span class="contract-info__step">03</span>
|
||||||
|
<span>合同文件</span>
|
||||||
|
</h3>
|
||||||
|
<ul class="contract-info__files">
|
||||||
|
<li
|
||||||
|
v-for="(file, index) in contract.files"
|
||||||
|
:key="`${file.name}-${index}`"
|
||||||
|
class="contract-info__file"
|
||||||
|
>
|
||||||
|
<div class="contract-info__file-info">
|
||||||
|
<span class="contract-info__file-name" :title="file.name">{{ file.name }}</span>
|
||||||
|
<span class="contract-info__file-meta">
|
||||||
|
{{ formatFileSize(file.size) }} · {{ file.uploadTime }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="contract-info__file-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="contract-info__link"
|
||||||
|
@click="emit('preview', file)"
|
||||||
|
>
|
||||||
|
预览
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="contract-info__link"
|
||||||
|
@click="emit('download', file)"
|
||||||
|
>
|
||||||
|
下载
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li v-if="contract.files.length === 0" class="contract-info__empty">
|
||||||
|
暂无合同文件
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.contract-info {
|
.contract-info {
|
||||||
position: relative;
|
width: 100%;
|
||||||
width: 1780px;
|
margin-top: @spacing-base;
|
||||||
height: 1152px;
|
padding: @spacing-xl;
|
||||||
margin-top: 21px;
|
border-radius: @radius-base;
|
||||||
border-radius: @radius-lg;
|
|
||||||
background: @white;
|
background: @white;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
||||||
&__section {
|
&__section + &__section {
|
||||||
position: absolute;
|
margin-top: @spacing-xxl;
|
||||||
left: 43px;
|
|
||||||
top: 0;
|
|
||||||
height: 0;
|
|
||||||
|
|
||||||
&--1 { top: 36px; }
|
|
||||||
&--2 { top: 649px; }
|
|
||||||
&--3 { top: 901px; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__section-title {
|
&__heading {
|
||||||
position: absolute;
|
.flex(row, flex-start, center);
|
||||||
left: 60px;
|
gap: @spacing-sm;
|
||||||
margin: 0;
|
margin: 0 0 @spacing-lg;
|
||||||
font-size: 28px;
|
font-size: @font-size-lg;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 41px;
|
line-height: 1.4;
|
||||||
color: @contract-cell-text;
|
color: @contract-cell-text;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__step {
|
&__step {
|
||||||
position: absolute;
|
font-size: @font-size-title;
|
||||||
left: 0;
|
font-weight: 600;
|
||||||
top: 50%;
|
line-height: 1.2;
|
||||||
font-size: 48px;
|
|
||||||
font-weight: 500;
|
|
||||||
line-height: 70px;
|
|
||||||
color: @project-accent;
|
color: @project-accent;
|
||||||
white-space: nowrap;
|
}
|
||||||
transform: translateY(-50%);
|
|
||||||
|
&__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: @spacing-lg;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__field {
|
&__field {
|
||||||
.flex(column, flex-start, stretch);
|
.flex(column, flex-start, stretch);
|
||||||
position: absolute;
|
gap: @spacing-sm;
|
||||||
width: 764px;
|
min-width: 0;
|
||||||
gap: 14px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__field--1 { top: 174px; }
|
|
||||||
&__field--2 { top: 296px; }
|
|
||||||
&__field--3 { top: 418px; }
|
|
||||||
&__field--4 { top: 540px; }
|
|
||||||
&__field--5 { top: 770px; }
|
|
||||||
|
|
||||||
&__field--5:has(&__label) { top: 749px; }
|
|
||||||
|
|
||||||
&__field--l { left: 64px; }
|
|
||||||
&__field--r { left: 940px; }
|
|
||||||
|
|
||||||
&__label {
|
&__label {
|
||||||
font-size: 20px;
|
.flex(row, flex-start, center);
|
||||||
|
gap: @spacing-sm;
|
||||||
|
font-size: @font-size-base;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 29px;
|
line-height: 1.4;
|
||||||
color: @contract-cell-text;
|
color: @contract-cell-text;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__value {
|
&__value {
|
||||||
.flex(row, flex-start, center);
|
.flex(row, flex-start, center);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 49px;
|
height: 40px;
|
||||||
padding: 0 18px;
|
padding: 0 @spacing-base;
|
||||||
border: 1px solid @contract-line;
|
border: 1px solid @border-color;
|
||||||
border-radius: 8px;
|
border-radius: @radius-sm;
|
||||||
background: @contract-page-bg;
|
background: @bg-color-light;
|
||||||
font-size: 20px;
|
font-size: @font-size-base;
|
||||||
line-height: 29px;
|
line-height: 1.4;
|
||||||
color: @contract-cell-text;
|
color: @contract-cell-text;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
.ellipsis();
|
.ellipsis();
|
||||||
@@ -192,19 +199,19 @@ const SIGN_VARIANTS: Record<SignStatus, 'primary' | 'success' | 'warning' | 'dan
|
|||||||
&__field :deep(.status-tag) {
|
&__field :deep(.status-tag) {
|
||||||
.flex(row, flex-start, center);
|
.flex(row, flex-start, center);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 49px;
|
height: 40px;
|
||||||
padding: 0 18px;
|
padding: 0 @spacing-base;
|
||||||
border: 1px solid @contract-line;
|
border: 1px solid @border-color;
|
||||||
border-radius: 8px;
|
border-radius: @radius-sm;
|
||||||
background: @contract-page-bg;
|
background: @bg-color-light;
|
||||||
font-size: 20px;
|
font-size: @font-size-base;
|
||||||
line-height: 29px;
|
line-height: 1.4;
|
||||||
color: @contract-cell-text;
|
color: @contract-cell-text;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__link {
|
&__link {
|
||||||
font-size: 18px;
|
font-size: @font-size-sm;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: @contract-accent;
|
color: @contract-accent;
|
||||||
transition: opacity 0.2s;
|
transition: opacity 0.2s;
|
||||||
@@ -214,18 +221,9 @@ const SIGN_VARIANTS: Record<SignStatus, 'primary' | 'success' | 'warning' | 'dan
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__label &__link {
|
|
||||||
margin-left: 10px;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__files {
|
&__files {
|
||||||
.flex(column, flex-start, stretch);
|
.flex(column, flex-start, stretch);
|
||||||
position: absolute;
|
gap: @spacing-sm;
|
||||||
top: 1001px; // 1153 − 152
|
|
||||||
left: 54px; // 124 − 70
|
|
||||||
width: 1640px;
|
|
||||||
gap: 0;
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
@@ -234,28 +232,29 @@ const SIGN_VARIANTS: Record<SignStatus, 'primary' | 'success' | 'warning' | 'dan
|
|||||||
&__file {
|
&__file {
|
||||||
.flex(row, space-between, center);
|
.flex(row, space-between, center);
|
||||||
gap: @spacing-base;
|
gap: @spacing-base;
|
||||||
height: 90px;
|
min-height: 64px;
|
||||||
padding: 0 30px;
|
padding: 0 @spacing-base;
|
||||||
background: @contract-table-head-bg;
|
border-radius: @radius-base;
|
||||||
|
background: @bg-color-light;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__file-info {
|
&__file-info {
|
||||||
.flex(column, flex-start, stretch);
|
.flex(column, flex-start, stretch);
|
||||||
gap: 4px;
|
gap: 2px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__file-name {
|
&__file-name {
|
||||||
font-size: 20px;
|
font-size: @font-size-base;
|
||||||
line-height: 29px;
|
line-height: 1.4;
|
||||||
color: @contract-cell-text;
|
color: @contract-cell-text;
|
||||||
.ellipsis();
|
.ellipsis();
|
||||||
}
|
}
|
||||||
|
|
||||||
&__file-meta {
|
&__file-meta {
|
||||||
font-size: 14px;
|
font-size: @font-size-sm;
|
||||||
line-height: 20px;
|
line-height: 1.4;
|
||||||
color: @contract-muted;
|
color: @contract-muted;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,9 +266,11 @@ const SIGN_VARIANTS: Record<SignStatus, 'primary' | 'success' | 'warning' | 'dan
|
|||||||
|
|
||||||
&__empty {
|
&__empty {
|
||||||
.flex(row, flex-start, center);
|
.flex(row, flex-start, center);
|
||||||
height: 90px;
|
min-height: 64px;
|
||||||
padding: 0 30px;
|
padding: 0 @spacing-base;
|
||||||
font-size: 18px;
|
border-radius: @radius-base;
|
||||||
|
background: @bg-color-light;
|
||||||
|
font-size: @font-size-base;
|
||||||
color: @contract-muted;
|
color: @contract-muted;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,9 +25,9 @@ const emit = defineEmits<{ refresh: [] }>()
|
|||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.sign-panel {
|
.sign-panel {
|
||||||
.flex(row, flex-start, flex-start);
|
.flex(row, flex-start, flex-start);
|
||||||
gap: 48px;
|
gap: @spacing-xl;
|
||||||
padding: 28px 32px;
|
padding: @spacing-xl;
|
||||||
border-radius: @radius-lg;
|
border-radius: @radius-base;
|
||||||
background: @white;
|
background: @white;
|
||||||
|
|
||||||
&__left {
|
&__left {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ElTable, ElTableColumn } from 'element-plus'
|
||||||
import { StatusTag } from '@/components'
|
import { StatusTag } from '@/components'
|
||||||
import type { Contract, SignStatus } from '@/types/business/contract'
|
import type { Contract, SignStatus } from '@/types/business/contract'
|
||||||
import { formatMoney } from '@/utils/format'
|
import { formatMoney } from '@/utils/format'
|
||||||
@@ -23,135 +24,115 @@ const SIGN_VARIANTS: Record<SignStatus, 'primary' | 'success' | 'warning' | 'dan
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="contract-table">
|
<div class="contract-table">
|
||||||
<table class="contract-table__grid">
|
<el-table
|
||||||
<thead>
|
:data="contracts"
|
||||||
<tr>
|
style="width: 100%"
|
||||||
<th>合同编号</th>
|
>
|
||||||
<th>合同名称</th>
|
<el-table-column prop="contractNo" label="合同编号" show-overflow-tooltip />
|
||||||
<th>合同类型</th>
|
<el-table-column prop="contractName" label="合同名称" show-overflow-tooltip />
|
||||||
<th>合同金额</th>
|
<el-table-column prop="contractType" label="合同类型" show-overflow-tooltip />
|
||||||
<th>关联项目</th>
|
<el-table-column label="合同金额" show-overflow-tooltip>
|
||||||
<th>项目编号</th>
|
<template #default="{ row }">
|
||||||
<th>签署状态</th>
|
¥{{ formatMoney((row as Contract).contractAmount) }}
|
||||||
<th>生效时间</th>
|
</template>
|
||||||
<th>到期时间</th>
|
</el-table-column>
|
||||||
<th>操作</th>
|
<el-table-column prop="relatedProjectName" label="关联项目" show-overflow-tooltip />
|
||||||
</tr>
|
<el-table-column prop="projectNo" label="项目编号" show-overflow-tooltip />
|
||||||
</thead>
|
<el-table-column label="签署状态" width="140">
|
||||||
<tbody>
|
<template #default="{ row }">
|
||||||
<tr v-for="item in contracts" :key="item.id">
|
<StatusTag :label="(row as Contract).signStatus" :variant="SIGN_VARIANTS[(row as Contract).signStatus]" />
|
||||||
<td>{{ item.contractNo }}</td>
|
</template>
|
||||||
<td class="contract-table__name" :title="item.contractName">
|
</el-table-column>
|
||||||
{{ item.contractName }}
|
<el-table-column prop="effectiveDate" label="生效时间" show-overflow-tooltip />
|
||||||
</td>
|
<el-table-column prop="expiryDate" label="到期时间" show-overflow-tooltip />
|
||||||
<td>{{ item.contractType }}</td>
|
<el-table-column label="操作" width="180">
|
||||||
<td>¥{{ formatMoney(item.contractAmount) }}</td>
|
<template #default="{ row }">
|
||||||
<td class="contract-table__name" :title="item.relatedProjectName">
|
<div class="contract-table__actions">
|
||||||
{{ item.relatedProjectName }}
|
|
||||||
</td>
|
|
||||||
<td>{{ item.projectNo }}</td>
|
|
||||||
<td>
|
|
||||||
<StatusTag :label="item.signStatus" :variant="SIGN_VARIANTS[item.signStatus]" />
|
|
||||||
</td>
|
|
||||||
<td>{{ item.effectiveDate }}</td>
|
|
||||||
<td>{{ item.expiryDate }}</td>
|
|
||||||
<td class="contract-table__actions">
|
|
||||||
<button
|
<button
|
||||||
v-if="item.signStatus === '待签署'"
|
v-if="(row as Contract).signStatus === '待签署'"
|
||||||
|
type="button"
|
||||||
class="contract-table__btn contract-table__btn--primary"
|
class="contract-table__btn contract-table__btn--primary"
|
||||||
@click="emit('sign', item.id)"
|
@click="emit('sign', (row as Contract).id)"
|
||||||
>
|
>
|
||||||
去签署
|
去签署
|
||||||
</button>
|
</button>
|
||||||
<button class="contract-table__btn" @click="emit('view', item.id)">
|
<button
|
||||||
|
type="button"
|
||||||
|
class="contract-table__btn"
|
||||||
|
@click="emit('view', (row as Contract).id)"
|
||||||
|
>
|
||||||
详情
|
详情
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
</template>
|
||||||
<tr v-if="contracts.length === 0">
|
</el-table-column>
|
||||||
<td colspan="10" class="contract-table__empty text-secondary">
|
<template #empty>
|
||||||
暂无符合条件的合同
|
<span class="contract-table__empty">暂无符合条件的合同</span>
|
||||||
</td>
|
</template>
|
||||||
</tr>
|
</el-table>
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.contract-table {
|
.contract-table {
|
||||||
overflow-x: auto;
|
:deep(.el-table) {
|
||||||
.scrollbar();
|
font-size: @font-size-sm;
|
||||||
|
--el-table-border-color: @contract-line;
|
||||||
|
--el-table-header-bg-color: @contract-table-head-bg;
|
||||||
|
--el-table-header-text-color: @contract-head-text;
|
||||||
|
--el-table-text-color: @contract-cell-text;
|
||||||
|
--el-table-row-hover-bg-color: @bg-color-light;
|
||||||
|
background: @white;
|
||||||
|
|
||||||
&__grid {
|
&::before {
|
||||||
width: 100%;
|
display: none;
|
||||||
border: 1px solid @contract-line;
|
|
||||||
border-collapse: collapse;
|
|
||||||
table-layout: fixed;
|
|
||||||
font-size: 20px;
|
|
||||||
|
|
||||||
th,
|
|
||||||
td {
|
|
||||||
padding: 0 10px;
|
|
||||||
border-bottom: 1px solid @contract-line;
|
|
||||||
vertical-align: middle;
|
|
||||||
white-space: nowrap;
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
td.contract-table__name {
|
.el-table__cell {
|
||||||
white-space: normal;
|
padding: 0;
|
||||||
word-break: break-all;
|
border-right: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
th {
|
.cell {
|
||||||
height: 64px;
|
padding: 0 @spacing-sm;
|
||||||
padding-top: 0;
|
font-size: @font-size-sm;
|
||||||
padding-bottom: 0;
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
th.el-table__cell {
|
||||||
|
height: 44px;
|
||||||
|
font-weight: 500;
|
||||||
background: @contract-table-head-bg;
|
background: @contract-table-head-bg;
|
||||||
color: @contract-head-text;
|
border-bottom: 1px solid @contract-line;
|
||||||
font-weight: 400;
|
|
||||||
text-align: center;
|
.cell {
|
||||||
|
color: @contract-head-text;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
td {
|
td.el-table__cell {
|
||||||
height: 96px;
|
height: 64px;
|
||||||
color: @contract-cell-text;
|
color: @contract-cell-text;
|
||||||
line-height: 29px;
|
border-bottom: 1px solid @contract-line;
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody tr:last-child td {
|
.el-table__row:hover > td.el-table__cell {
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
tbody tr:hover td {
|
|
||||||
background: @bg-color-light;
|
background: @bg-color-light;
|
||||||
}
|
}
|
||||||
|
|
||||||
th:nth-child(1) { width: 11%; }
|
|
||||||
th:nth-child(2) { width: 9%; }
|
|
||||||
th:nth-child(3) { width: 10%; }
|
|
||||||
th:nth-child(4) { width: 9%; }
|
|
||||||
th:nth-child(5) { width: 10%; }
|
|
||||||
th:nth-child(6) { width: 11%; }
|
|
||||||
th:nth-child(7) { width: 7%; }
|
|
||||||
th:nth-child(8) { width: 11%; }
|
|
||||||
th:nth-child(9) { width: 11%; }
|
|
||||||
th:nth-child(10) { width: 11%; }
|
|
||||||
}
|
|
||||||
|
|
||||||
&__name {
|
|
||||||
color: @contract-cell-text;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__actions {
|
&__actions {
|
||||||
.flex(row, center);
|
.flex(row, center);
|
||||||
gap: @spacing-sm;
|
gap: @spacing-base;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__btn {
|
&__btn {
|
||||||
font-size: 13px;
|
font-size: @font-size-sm;
|
||||||
color: @contract-accent;
|
color: @contract-accent;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
transition: opacity 0.2s;
|
transition: opacity 0.2s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
@@ -161,7 +142,6 @@ const SIGN_VARIANTS: Record<SignStatus, 'primary' | 'success' | 'warning' | 'dan
|
|||||||
&--primary {
|
&--primary {
|
||||||
height: 28px;
|
height: 28px;
|
||||||
padding: 0 @spacing-sm;
|
padding: 0 @spacing-sm;
|
||||||
border: none;
|
|
||||||
border-radius: @radius-sm;
|
border-radius: @radius-sm;
|
||||||
background: @contract-accent;
|
background: @contract-accent;
|
||||||
color: @white;
|
color: @white;
|
||||||
@@ -173,6 +153,12 @@ const SIGN_VARIANTS: Record<SignStatus, 'primary' | 'success' | 'warning' | 'dan
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__empty {
|
||||||
|
display: block;
|
||||||
|
padding: @spacing-xl 0;
|
||||||
|
color: @text-color-secondary;
|
||||||
|
}
|
||||||
|
|
||||||
:deep(.status-tag) {
|
:deep(.status-tag) {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
@@ -180,10 +166,5 @@ const SIGN_VARIANTS: Record<SignStatus, 'primary' | 'success' | 'warning' | 'dan
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__empty {
|
|
||||||
padding: @spacing-xl 0;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, watch } from 'vue'
|
import { computed, onMounted, watch } from 'vue'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { AppButton } from '@/components'
|
|
||||||
import type { FileInfo } from '@/types/api'
|
import type { FileInfo } from '@/types/api'
|
||||||
import { useContractStore } from '@/stores/modules/contract'
|
import { useContractStore } from '@/stores/modules/contract'
|
||||||
import ContractInfoCard from './cpns/ContractInfoCard.vue'
|
import ContractInfoCard from './cpns/ContractInfoCard.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
|
||||||
const contractStore = useContractStore()
|
const contractStore = useContractStore()
|
||||||
const { current } = storeToRefs(contractStore)
|
const { current } = storeToRefs(contractStore)
|
||||||
|
|
||||||
@@ -19,10 +17,6 @@ function load(): void {
|
|||||||
void contractStore.fetchDetail(contractId.value)
|
void contractStore.fetchDetail(contractId.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onBack(): void {
|
|
||||||
void router.push('/contract')
|
|
||||||
}
|
|
||||||
|
|
||||||
function onViewProject(): void {
|
function onViewProject(): void {
|
||||||
// TODO: 关联项目 id 由接口返回后跳转项目详情
|
// TODO: 关联项目 id 由接口返回后跳转项目详情
|
||||||
ElMessage.info('项目详情跳转待接口接入')
|
ElMessage.info('项目详情跳转待接口接入')
|
||||||
@@ -65,54 +59,29 @@ onMounted(load)
|
|||||||
<p v-else class="contract-detail__empty text-secondary">
|
<p v-else class="contract-detail__empty text-secondary">
|
||||||
合同不存在或已下线
|
合同不存在或已下线
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="contract-detail__footer">
|
|
||||||
<AppButton @click="onBack">
|
|
||||||
返回
|
|
||||||
</AppButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.contract-detail {
|
.contract-detail {
|
||||||
padding-bottom: @spacing-xxl;
|
min-height: 100%;
|
||||||
|
padding-bottom: @spacing-page-bottom;
|
||||||
|
background: @bg-color;
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
.flex(column, flex-start, stretch);
|
.flex(column, flex-start, stretch);
|
||||||
gap: 0;
|
gap: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__breadcrumb {
|
&__breadcrumb {
|
||||||
padding-top: 32px;
|
padding-top: @spacing-lg;
|
||||||
font-size: 24px;
|
font-size: @font-size-sm;
|
||||||
font-weight: 500;
|
font-weight: 400;
|
||||||
line-height: 27px;
|
line-height: 1.5;
|
||||||
color: @contract-muted;
|
color: @contract-muted;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__footer {
|
|
||||||
.flex(row, flex-end);
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 30px;
|
|
||||||
|
|
||||||
:deep(.app-button) {
|
|
||||||
height: 40px;
|
|
||||||
padding: 0 32px;
|
|
||||||
border: none;
|
|
||||||
border-radius: @radius-base;
|
|
||||||
background: @primary-strong;
|
|
||||||
color: @white;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.app-button:hover) {
|
|
||||||
border-color: transparent;
|
|
||||||
background: @primary-color;
|
|
||||||
color: @white;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__empty {
|
&__empty {
|
||||||
padding: @spacing-xxl 0;
|
padding: @spacing-xxl 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -82,41 +82,40 @@ onMounted(() => {
|
|||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.contract {
|
.contract {
|
||||||
padding-bottom: @spacing-xxl;
|
min-height: 100%;
|
||||||
|
padding-bottom: @spacing-page-bottom;
|
||||||
|
background: @bg-color;
|
||||||
|
|
||||||
&__head {
|
&__head {
|
||||||
padding: 24px 0 52px;
|
padding: @spacing-lg 0;
|
||||||
background: @white;
|
background: @white;
|
||||||
|
border-bottom: 1px solid @border-color;
|
||||||
.container {
|
|
||||||
padding-left: 42px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__title {
|
&__title {
|
||||||
font-size: 36px;
|
font-size: @font-size-title;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
line-height: 52px;
|
line-height: 1.3;
|
||||||
color: @contract-ink;
|
color: @contract-ink;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__slogan {
|
&__slogan {
|
||||||
margin-top: 13.5px;
|
margin-top: @spacing-sm;
|
||||||
font-size: 24px;
|
font-size: @font-size-base;
|
||||||
font-weight: 500;
|
font-weight: 400;
|
||||||
line-height: 37px;
|
line-height: 1.5;
|
||||||
color: @contract-muted;
|
color: @contract-muted;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.contract .contract__card {
|
.contract .contract__card {
|
||||||
margin-top: 20px;
|
margin-top: @spacing-lg;
|
||||||
padding: 51px 64px 59px;
|
padding: @spacing-lg;
|
||||||
border-radius: @radius-lg;
|
border-radius: @radius-base;
|
||||||
background: @white;
|
background: @white;
|
||||||
|
|
||||||
:deep(.filter-panel) {
|
:deep(.filter-panel) {
|
||||||
margin-bottom: 39px;
|
margin-bottom: @spacing-lg;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -128,26 +127,19 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
:deep(.filter-panel__bar) {
|
:deep(.filter-panel__bar) {
|
||||||
gap: 8px;
|
gap: @spacing-base;
|
||||||
|
}
|
||||||
.filter-panel__btn {
|
|
||||||
margin-left: 78px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-panel__btn--primary {
|
|
||||||
margin-left: 40px;
|
|
||||||
} }
|
|
||||||
|
|
||||||
:deep(.filter-panel__input) {
|
:deep(.filter-panel__input) {
|
||||||
flex: 0 0 592px;
|
flex: 0 1 360px;
|
||||||
width: 592px;
|
width: auto;
|
||||||
max-width: none;
|
max-width: 360px;
|
||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
height: 49px;
|
height: 36px;
|
||||||
border-color: transparent;
|
border: 1px solid @border-color;
|
||||||
border-radius: 0;
|
border-radius: @radius-round;
|
||||||
background: @input-bg;
|
background: @input-bg;
|
||||||
font-size: 20px;
|
font-size: @font-size-base;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,53 +149,50 @@ onMounted(() => {
|
|||||||
|
|
||||||
:deep(.filter-panel__input:focus) {
|
:deep(.filter-panel__input:focus) {
|
||||||
border-color: @primary-strong;
|
border-color: @primary-strong;
|
||||||
|
background: @white;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.filter-panel__btn) {
|
:deep(.filter-panel__btn) {
|
||||||
order: 1;
|
min-width: 72px;
|
||||||
min-width: 0;
|
height: 36px;
|
||||||
height: 29px;
|
padding: 0 @spacing-lg;
|
||||||
padding: 0 12px;
|
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 0;
|
border-radius: @radius-round;
|
||||||
background: transparent;
|
background: @pill-bg;
|
||||||
color: @contract-placeholder;
|
color: @text-color;
|
||||||
font-size: 20px;
|
font-size: @font-size-base;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.filter-panel__btn:hover) {
|
:deep(.filter-panel__btn:hover) {
|
||||||
border-color: transparent;
|
color: @primary-color;
|
||||||
color: @text-color;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.filter-panel__btn--primary) {
|
:deep(.filter-panel__btn--primary) {
|
||||||
order: 2;
|
background: @contract-accent;
|
||||||
border-radius: 0;
|
color: @white;
|
||||||
background: transparent;
|
|
||||||
color: @contract-accent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.filter-panel__btn--primary:hover) {
|
:deep(.filter-panel__btn--primary:hover) {
|
||||||
background: transparent;
|
background: @primary-color;
|
||||||
color: @primary-color;
|
color: @white;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.pagination) {
|
:deep(.pagination) {
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 10px;
|
gap: @spacing-xs;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin-top: 28px;
|
margin-top: @spacing-lg;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.pagination__btn) {
|
:deep(.pagination__btn) {
|
||||||
min-width: 40px;
|
min-width: 28px;
|
||||||
height: 26px;
|
height: 28px;
|
||||||
padding: 0 4px;
|
padding: 0 6px;
|
||||||
border: 1px solid @contract-line;
|
border: 1px solid @border-color;
|
||||||
border-radius: 0;
|
border-radius: @radius-sm;
|
||||||
background: @white;
|
background: @white;
|
||||||
color: @contract-line;
|
color: @text-color;
|
||||||
font-size: 14px;
|
font-size: @font-size-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.pagination__btn:hover:not(:disabled):not(.is-ellipsis)) {
|
:deep(.pagination__btn:hover:not(:disabled):not(.is-ellipsis)) {
|
||||||
@@ -213,8 +202,9 @@ onMounted(() => {
|
|||||||
|
|
||||||
:deep(.pagination__btn.is-active) {
|
:deep(.pagination__btn.is-active) {
|
||||||
border-color: @contract-accent;
|
border-color: @contract-accent;
|
||||||
background: @contract-accent;
|
background: @primary-light;
|
||||||
color: @white;
|
color: @contract-accent;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.pagination__btn.is-ellipsis) {
|
:deep(.pagination__btn.is-ellipsis) {
|
||||||
@@ -222,20 +212,5 @@ onMounted(() => {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
color: @contract-muted;
|
color: @contract-muted;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.pagination__btn:first-child),
|
|
||||||
:deep(.pagination__btn:last-child) {
|
|
||||||
min-width: auto;
|
|
||||||
padding: 0 2px;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
color: @contract-muted;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.pagination__btn:first-child:disabled),
|
|
||||||
:deep(.pagination__btn:last-child:disabled) {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -63,16 +63,18 @@ onMounted(load)
|
|||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.contract-sign {
|
.contract-sign {
|
||||||
|
min-height: 100%;
|
||||||
padding-top: @spacing-lg;
|
padding-top: @spacing-lg;
|
||||||
padding-bottom: @spacing-xxl;
|
padding-bottom: @spacing-page-bottom;
|
||||||
|
background: @bg-color;
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
.flex(column, flex-start, stretch);
|
.flex(column, flex-start, stretch);
|
||||||
gap: 12px;
|
gap: @spacing-base;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__breadcrumb {
|
&__breadcrumb {
|
||||||
font-size: 11px;
|
font-size: @font-size-sm;
|
||||||
color: @text-color-disabled;
|
color: @text-color-disabled;
|
||||||
|
|
||||||
> span:last-child {
|
> span:last-child {
|
||||||
@@ -84,16 +86,16 @@ onMounted(load)
|
|||||||
&__footer {
|
&__footer {
|
||||||
.flex(row, flex-end);
|
.flex(row, flex-end);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 19px;
|
margin-top: @spacing-base;
|
||||||
|
|
||||||
:deep(.app-button) {
|
:deep(.app-button) {
|
||||||
height: 30px;
|
height: 36px;
|
||||||
padding: 0 24px;
|
padding: 0 @spacing-lg;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: @radius-sm;
|
border-radius: @radius-round;
|
||||||
background: @primary-strong;
|
background: @primary-strong;
|
||||||
color: @white;
|
color: @white;
|
||||||
font-size: 11px;
|
font-size: @font-size-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.app-button:hover) {
|
:deep(.app-button:hover) {
|
||||||
|
|||||||
@@ -49,35 +49,32 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.demand-bar {
|
.demand-bar {
|
||||||
// 设计稿里操作栏不是 fixed 底栏,而是卡片内的一行:
|
|
||||||
// el66 提示文字 x=240 y=2297,按钮 el36/34/32 x=1009/1288/1567 y=2280 w=232 h=63。
|
|
||||||
// 内容左边界 194,提示文字比它右移 46px;按钮右边界 1799 与输入框右边界齐平。
|
|
||||||
.flex(row, space-between, center);
|
.flex(row, space-between, center);
|
||||||
margin-top: 66px;
|
margin-top: @spacing-xxl;
|
||||||
|
gap: @spacing-lg;
|
||||||
|
|
||||||
&__tip {
|
&__tip {
|
||||||
// 提示文字左边界 240 = 内容左边界 194 + 46。
|
margin: 0;
|
||||||
margin: 0 0 0 114px;
|
font-size: @font-size-base;
|
||||||
font-size: 20px;
|
font-weight: 400;
|
||||||
font-weight: 500;
|
line-height: 1.5;
|
||||||
line-height: 29px;
|
color: @text-color-secondary;
|
||||||
color: @demand-heading;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__actions {
|
&__actions {
|
||||||
.flex(row, flex-end, center);
|
.flex(row, flex-end, center);
|
||||||
gap: 47px;
|
gap: @spacing-base;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__btn {
|
&__btn {
|
||||||
width: 232px;
|
min-width: 120px;
|
||||||
height: 63px;
|
height: 40px;
|
||||||
padding: 0;
|
padding: 0 @spacing-lg;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: @radius-base;
|
border-radius: @radius-round;
|
||||||
background: @demand-secondary-bg;
|
background: @demand-secondary-bg;
|
||||||
font-size: 20px;
|
font-size: @font-size-base;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: @white;
|
color: @white;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|||||||
@@ -202,11 +202,10 @@ function onDownload(file: DemandAttachment): void {
|
|||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.demand-attach {
|
.demand-attach {
|
||||||
.flex(column, flex-start, stretch);
|
.flex(column, flex-start, stretch);
|
||||||
// el59标签底 1405 → el54 顶 1419 = 14px;el54 底 1509 → el53 顶 1529 = 20px。
|
gap: @spacing-base;
|
||||||
gap: 14px;
|
|
||||||
|
|
||||||
&__list {
|
&__list {
|
||||||
gap: 20px;
|
gap: @spacing-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__head {
|
&__head {
|
||||||
@@ -215,38 +214,38 @@ function onDownload(file: DemandAttachment): void {
|
|||||||
|
|
||||||
&__label {
|
&__label {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
font-size: 20px;
|
font-size: @font-size-base;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 29px;
|
line-height: 1.4;
|
||||||
color: @demand-heading;
|
color: @demand-heading;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__hint {
|
&__hint {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
margin-left: 20px;
|
margin-left: @spacing-base;
|
||||||
font-size: 16px;
|
font-size: @font-size-sm;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
line-height: 23px;
|
line-height: 1.5;
|
||||||
color: @demand-line;
|
color: @text-color-secondary;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设计稿 el54:x=194 y=1419 w=1605 h=90,整块浅灰上传区。
|
|
||||||
&__drop {
|
&__drop {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 90px;
|
height: 72px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: none;
|
border: 1px dashed @border-color;
|
||||||
border-radius: @radius-sm;
|
border-radius: @radius-base;
|
||||||
background: @demand-upload-bg;
|
background: @bg-color-light;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 16px;
|
font-size: @font-size-base;
|
||||||
color: @demand-line;
|
color: @text-color-secondary;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.2s, color 0.2s;
|
transition: background 0.2s, color 0.2s, border-color 0.2s;
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
&:hover:not(:disabled) {
|
||||||
background: darken(@demand-upload-bg, 4%);
|
border-color: @demand-accent;
|
||||||
|
background: @filter-selected-bg;
|
||||||
color: @demand-accent;
|
color: @demand-accent;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,35 +261,34 @@ function onDownload(file: DemandAttachment): void {
|
|||||||
|
|
||||||
&__list {
|
&__list {
|
||||||
.flex(column, flex-start, stretch);
|
.flex(column, flex-start, stretch);
|
||||||
gap: 20px;
|
gap: @spacing-sm;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
// el53:x=194 y=1529 w=1605 h=90,空态占位块;有附件时列表项本身就是这个高度。
|
|
||||||
&__list:empty {
|
&__list:empty {
|
||||||
height: 90px;
|
height: 72px;
|
||||||
border-radius: @radius-sm;
|
border-radius: @radius-base;
|
||||||
background: @demand-upload-bg;
|
background: @bg-color-light;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__item {
|
&__item {
|
||||||
.flex(row, flex-start, center);
|
.flex(row, flex-start, center);
|
||||||
gap: @spacing-base;
|
gap: @spacing-base;
|
||||||
min-height: 90px;
|
min-height: 64px;
|
||||||
padding: 0 @spacing-lg;
|
padding: 0 @spacing-base;
|
||||||
border-radius: @radius-sm;
|
border-radius: @radius-base;
|
||||||
background: @demand-upload-bg;
|
background: @bg-color-light;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__badge {
|
&__badge {
|
||||||
.flex(row, center);
|
.flex(row, center);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 44px;
|
width: 36px;
|
||||||
height: 44px;
|
height: 36px;
|
||||||
border-radius: @radius-sm;
|
border-radius: @radius-sm;
|
||||||
font-size: 12px;
|
font-size: @font-size-xs;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.2px;
|
letter-spacing: 0.2px;
|
||||||
}
|
}
|
||||||
@@ -298,18 +296,18 @@ function onDownload(file: DemandAttachment): void {
|
|||||||
&__meta {
|
&__meta {
|
||||||
.flex(column, flex-start, stretch);
|
.flex(column, flex-start, stretch);
|
||||||
flex: 1;
|
flex: 1;
|
||||||
gap: 4px;
|
gap: 2px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__name {
|
&__name {
|
||||||
font-size: 20px;
|
font-size: @font-size-base;
|
||||||
color: @demand-ink;
|
color: @demand-ink;
|
||||||
.ellipsis();
|
.ellipsis();
|
||||||
}
|
}
|
||||||
|
|
||||||
&__sub {
|
&__sub {
|
||||||
font-size: 16px;
|
font-size: @font-size-sm;
|
||||||
color: @text-color-secondary;
|
color: @text-color-secondary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
|||||||
v-else
|
v-else
|
||||||
class="biz-types__icon"
|
class="biz-types__icon"
|
||||||
:name="option.icon"
|
:name="option.icon"
|
||||||
:size="68"
|
:size="48"
|
||||||
/>
|
/>
|
||||||
<span class="biz-types__name">{{ option.label }}</span>
|
<span class="biz-types__name">{{ option.label }}</span>
|
||||||
<span v-if="option.desc" class="biz-types__desc">{{ option.desc }}</span>
|
<span v-if="option.desc" class="biz-types__desc">{{ option.desc }}</span>
|
||||||
@@ -63,20 +63,17 @@ const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
|||||||
&__grid {
|
&__grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(5, 1fr);
|
grid-template-columns: repeat(5, 1fr);
|
||||||
gap: 17px;
|
gap: @spacing-base;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__card {
|
&__card {
|
||||||
position: relative;
|
position: relative;
|
||||||
.flex(column, flex-start, flex-start);
|
.flex(column, flex-start, flex-start);
|
||||||
gap: 0;
|
gap: 0;
|
||||||
height: 206px;
|
min-height: 160px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
// 设计稿 el21 "互联网软件" y=545 → 卡片 y=439 距顶 106px。
|
padding: @spacing-lg;
|
||||||
// 插画 el4 y=444 h=110 的框底 554 已越过 name 顶545,说明插画切片含
|
border: 1px solid @border-color;
|
||||||
// 透明边距;按文字层定位为准,插画框给68px 见方、左对齐即可。
|
|
||||||
padding: 4px 16px 0;
|
|
||||||
border: 1px solid @demand-line;
|
|
||||||
border-radius: @radius-base;
|
border-radius: @radius-base;
|
||||||
background: @white;
|
background: @white;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@@ -88,18 +85,17 @@ const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.is-active {
|
&.is-active {
|
||||||
border-color: @demand-selected-bg;
|
border-color: @demand-accent;
|
||||||
background: @demand-selected-bg;
|
background: @filter-selected-bg;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// el8 "圆形51" x=442 y=467 w=30 h=30:卡片 x=194 w=307,右内缩 29px、上移 22px。
|
|
||||||
&__check {
|
&__check {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 22px;
|
top: @spacing-base;
|
||||||
right: 19px;
|
right: @spacing-base;
|
||||||
width: 30px;
|
width: 20px;
|
||||||
height: 30px;
|
height: 20px;
|
||||||
border: 1px solid @demand-radio-idle;
|
border: 1px solid @demand-radio-idle;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: @white;
|
background: @white;
|
||||||
@@ -107,41 +103,42 @@ const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__card.is-active &__check {
|
&__card.is-active &__check {
|
||||||
border-color: @white;
|
border-color: @demand-accent;
|
||||||
background: @white;
|
background: @white;
|
||||||
box-shadow: inset 0 0 0 3px @demand-selected-bg;
|
box-shadow: inset 0 0 0 5px @demand-accent;
|
||||||
}
|
}
|
||||||
|
|
||||||
// el4 插画 x=233 / el3 x=542 / el1 x=875 / el2 x=1190:
|
|
||||||
// 四张卡的插画都在卡片左边线右侧 27~39px 处,不是水平居中,按左对齐处理。
|
|
||||||
&__icon {
|
&__icon {
|
||||||
margin-bottom: 33px;
|
margin-bottom: @spacing-base;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__logo {
|
&__logo {
|
||||||
width: 68px;
|
width: 48px;
|
||||||
height: 68px;
|
height: 48px;
|
||||||
margin-bottom: 33px;
|
margin-bottom: @spacing-base;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__name {
|
&__name {
|
||||||
font-size: 28px;
|
font-size: @font-size-lg;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 45px;
|
line-height: 1.4;
|
||||||
color: @demand-ink;
|
color: @demand-ink;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__desc {
|
&__desc {
|
||||||
font-size: 16px;
|
font-size: @font-size-sm;
|
||||||
font-weight: 500;
|
font-weight: 400;
|
||||||
line-height: 23px;
|
line-height: 1.5;
|
||||||
color: @demand-ink;
|
color: @text-color-secondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__card.is-active &__name {
|
||||||
|
color: @demand-accent;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__card.is-active &__name,
|
|
||||||
&__card.is-active &__desc {
|
&__card.is-active &__desc {
|
||||||
color: @white;
|
color: @text-color-secondary;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -99,24 +99,24 @@ function onRemark(event: Event): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__label {
|
&__label {
|
||||||
font-size: 20px;
|
font-size: @font-size-base;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 29px;
|
line-height: 1.4;
|
||||||
color: @demand-heading;
|
color: @demand-heading;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__input {
|
&__input {
|
||||||
.text-control();
|
.text-control();
|
||||||
height: 49px;
|
height: 40px;
|
||||||
padding: 0 18px;
|
padding: 0 @spacing-base;
|
||||||
border-color: @demand-line;
|
border-color: @border-color;
|
||||||
border-radius: @radius-sm;
|
border-radius: @radius-sm;
|
||||||
font-size: 20px;
|
font-size: @font-size-base;
|
||||||
color: @demand-ink;
|
color: @demand-ink;
|
||||||
|
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: @demand-line;
|
color: @text-color-disabled;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,27 +127,27 @@ function onRemark(event: Event): void {
|
|||||||
|
|
||||||
&__textarea {
|
&__textarea {
|
||||||
.text-control();
|
.text-control();
|
||||||
height: 270px;
|
height: 160px;
|
||||||
min-height: 270px;
|
min-height: 160px;
|
||||||
padding: 10px 18px 24px;
|
padding: @spacing-base;
|
||||||
border-color: @demand-line;
|
border-color: @border-color;
|
||||||
border-radius: @radius-sm;
|
border-radius: @radius-sm;
|
||||||
font-size: 20px;
|
font-size: @font-size-base;
|
||||||
color: @demand-ink;
|
color: @demand-ink;
|
||||||
line-height: 29px;
|
line-height: 1.5;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
|
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: @demand-line;
|
color: @text-color-disabled;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__count {
|
&__count {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: @spacing-lg;
|
right: @spacing-base;
|
||||||
bottom: @spacing-sm;
|
bottom: @spacing-sm;
|
||||||
font-size: 16px;
|
font-size: @font-size-sm;
|
||||||
color: @text-color-disabled;
|
color: @text-color-disabled;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -201,9 +201,9 @@ function onBudgetManualBlur(event: Event): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__label {
|
&__label {
|
||||||
font-size: 20px;
|
font-size: @font-size-base;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 29px;
|
line-height: 1.4;
|
||||||
color: @demand-heading;
|
color: @demand-heading;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,16 +214,16 @@ function onBudgetManualBlur(event: Event): void {
|
|||||||
|
|
||||||
&__input {
|
&__input {
|
||||||
.text-control();
|
.text-control();
|
||||||
height: 49px;
|
height: 40px;
|
||||||
padding: 0 18px;
|
padding: 0 @spacing-base;
|
||||||
border-color: @demand-line;
|
border-color: @border-color;
|
||||||
border-radius: @radius-sm;
|
border-radius: @radius-sm;
|
||||||
font-size: 20px;
|
font-size: @font-size-base;
|
||||||
color: @demand-ink;
|
color: @demand-ink;
|
||||||
|
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: @demand-line;
|
color: @text-color-disabled;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,47 +234,47 @@ function onBudgetManualBlur(event: Event): void {
|
|||||||
|
|
||||||
&__textarea {
|
&__textarea {
|
||||||
.text-control();
|
.text-control();
|
||||||
height: 270px;
|
height: 160px;
|
||||||
min-height: 270px;
|
min-height: 160px;
|
||||||
padding: 10px 18px 24px;
|
padding: @spacing-base;
|
||||||
border-color: @demand-line;
|
border-color: @border-color;
|
||||||
border-radius: @radius-sm;
|
border-radius: @radius-sm;
|
||||||
font-size: 20px;
|
font-size: @font-size-base;
|
||||||
color: @demand-ink;
|
color: @demand-ink;
|
||||||
line-height: 29px;
|
line-height: 1.5;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
|
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: @demand-line;
|
color: @text-color-disabled;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__count {
|
&__count {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: @spacing-lg;
|
right: @spacing-base;
|
||||||
bottom: @spacing-sm;
|
bottom: @spacing-sm;
|
||||||
font-size: 16px;
|
font-size: @font-size-sm;
|
||||||
color: @text-color-disabled;
|
color: @text-color-disabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__budgets {
|
&__budgets {
|
||||||
.flex(row, flex-start, stretch);
|
.flex(row, flex-start, stretch);
|
||||||
gap: 17px;
|
gap: @spacing-base;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__budget {
|
&__budget {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
height: 49px;
|
height: 40px;
|
||||||
padding: 0 @spacing-sm;
|
padding: 0 @spacing-sm;
|
||||||
border: 1px solid @demand-line;
|
border: 1px solid @border-color;
|
||||||
border-radius: @radius-sm;
|
border-radius: @radius-base;
|
||||||
background: @white;
|
background: @white;
|
||||||
font-size: 20px;
|
font-size: @font-size-base;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
color: @demand-line;
|
color: @text-color;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
@@ -287,8 +287,8 @@ function onBudgetManualBlur(event: Event): void {
|
|||||||
|
|
||||||
&.is-active {
|
&.is-active {
|
||||||
border-color: @demand-accent;
|
border-color: @demand-accent;
|
||||||
background: @demand-selected-bg;
|
background: @filter-selected-bg;
|
||||||
color: @white;
|
color: @demand-accent;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -298,7 +298,7 @@ function onBudgetManualBlur(event: Event): void {
|
|||||||
|
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: @demand-line;
|
color: @text-color-disabled;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,7 +120,9 @@ onMounted(() => {
|
|||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.demand-page {
|
.demand-page {
|
||||||
// 操作栏已收进卡片内(设计稿 el36 y=2280),不需要为fixed 底栏预留空白。
|
min-height: 100%;
|
||||||
|
padding-bottom: @spacing-page-bottom;
|
||||||
|
background: @bg-color;
|
||||||
|
|
||||||
&.is-loading .demand {
|
&.is-loading .demand {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
@@ -128,65 +130,60 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__hero {
|
&__hero {
|
||||||
height: 179px;
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding-top: 32px;
|
padding: @spacing-lg 0;
|
||||||
background: @white;
|
background: @white;
|
||||||
|
border-bottom: 1px solid @border-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__hero-inner {
|
&__hero-inner {
|
||||||
// 设计稿 el82:标题左边界 112px(hero 白底 1920 宽,标题左起 112)。
|
padding-left: @spacing-xl;
|
||||||
padding-left: 112px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__title {
|
&__title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 36px;
|
font-size: @font-size-title;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
line-height: 52px;
|
line-height: 1.3;
|
||||||
color: @demand-ink;
|
color: @demand-ink;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__subtitle {
|
&__subtitle {
|
||||||
margin: 15px 0 0;
|
margin: @spacing-sm 0 0;
|
||||||
font-size: 24px;
|
font-size: @font-size-base;
|
||||||
font-weight: 500;
|
font-weight: 400;
|
||||||
line-height: 35px;
|
line-height: 1.5;
|
||||||
color: @demand-subtitle;
|
color: @demand-subtitle;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.demand {
|
.demand {
|
||||||
margin-top: 20px; // 271 − (72+179) = 20
|
margin-top: @spacing-lg;
|
||||||
// 设计稿 el83 卡片 x=70 w=1780;内容左边界 194(el68 输入框 x=194 w=1605),
|
padding: @spacing-xl;
|
||||||
// 右边界 1799。故左内边距 56、右内边距 51(不对称是设计稿本身如此)。
|
border-radius: @radius-base;
|
||||||
padding: 48px 51px 114px 56px;
|
|
||||||
background: @white;
|
background: @white;
|
||||||
|
|
||||||
&__heading {
|
&__heading {
|
||||||
// 设计稿三个 heading 的标签都比序号低 23px(el80 "01" y=319 →
|
.flex(row, flex-start, center);
|
||||||
// el79 "选择业务类型*" y=342),不是垂直居中,故用flex-start + margin-top。
|
gap: @spacing-sm;
|
||||||
.flex(row, flex-start, flex-start);
|
margin: 0 0 @spacing-lg;
|
||||||
gap: 4px;
|
padding-left: 0;
|
||||||
margin: 0 0 50px;
|
|
||||||
padding-left: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__index {
|
&__index {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
font-size: 48px;
|
font-size: @font-size-title;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
line-height: 70px;
|
line-height: 1.2;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
color: @demand-accent;
|
color: @demand-accent;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__label {
|
&__label {
|
||||||
// 与序号的顶部落差 23px(el80 y=319 → el79 y=342)。
|
margin-top: 0;
|
||||||
margin-top: 23px;
|
font-size: @font-size-lg;
|
||||||
font-size: 28px;
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 41px;
|
line-height: 1.4;
|
||||||
color: @demand-heading;
|
color: @demand-heading;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,28 +192,24 @@ onMounted(() => {
|
|||||||
color: @danger-color;
|
color: @danger-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 内容左边界 194:卡片 x=70 + padding-left 56 + 内容缩进 68。
|
|
||||||
&__heading + * {
|
&__heading + * {
|
||||||
margin-left: 68px;
|
margin-left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// el29业务类型卡片底 645 → el78 "02" 顶 705 = 60px。
|
|
||||||
&__section + &__section {
|
&__section + &__section {
|
||||||
margin-top: 60px;
|
margin-top: @spacing-xxl;
|
||||||
}
|
}
|
||||||
|
|
||||||
// el53 附件灰块底 1619 → el76 "03" 顶 1689 = 70px。
|
|
||||||
&__section + &__section:last-of-type {
|
&__section + &__section:last-of-type {
|
||||||
margin-top: 70px;
|
margin-top: @spacing-xxl;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__section + &__section &__heading {
|
&__section + &__section &__heading {
|
||||||
margin-bottom: 0;
|
margin-bottom: @spacing-lg;
|
||||||
}
|
}
|
||||||
|
|
||||||
// el78 "02" 底 775 → el70 "项目名称*" 顶 789 = 14px。
|
|
||||||
&__section + &__section &__heading + * {
|
&__section + &__section &__heading + * {
|
||||||
margin-top: 14px;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed, onMounted, watch } from 'vue'
|
import { computed, onMounted, watch } from 'vue'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { ElButton } from 'element-plus'
|
||||||
import { useFinanceStore } from '@/stores/modules/finance'
|
import { useFinanceStore } from '@/stores/modules/finance'
|
||||||
import type { Bill, BillStatus } from '@/types/business/finance'
|
import type { Bill, BillStatus } from '@/types/business/finance'
|
||||||
import { formatMoney } from '@/utils/format'
|
import { formatMoney } from '@/utils/format'
|
||||||
@@ -122,20 +123,20 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<el-button
|
||||||
v-if="card.status === '待支付'"
|
v-if="card.status === '待支付'"
|
||||||
class="bill-detail__cta"
|
class="bill-detail__cta"
|
||||||
@click="onPay(currentBill.id)"
|
@click="onPay(currentBill.id)"
|
||||||
>
|
>
|
||||||
立即支付
|
立即支付
|
||||||
</button>
|
</el-button>
|
||||||
<button
|
<el-button
|
||||||
v-else
|
v-else
|
||||||
class="bill-detail__cta"
|
class="bill-detail__cta"
|
||||||
@click="router.push('/finance/bills')"
|
@click="router.push('/finance/bills')"
|
||||||
>
|
>
|
||||||
返回
|
返回
|
||||||
</button>
|
</el-button>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -149,16 +150,17 @@ onMounted(() => {
|
|||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.bill-detail {
|
.bill-detail {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding-bottom: 325px;
|
min-height: 100%;
|
||||||
background: @contract-page-bg;
|
padding-bottom: @spacing-page-bottom;
|
||||||
|
background: @bg-color;
|
||||||
|
|
||||||
&__breadcrumb {
|
&__breadcrumb {
|
||||||
.flex(row, flex-start, center);
|
.flex(row, flex-start, center);
|
||||||
height: 86px;
|
min-height: 48px;
|
||||||
padding-top: 34px;
|
padding-top: @spacing-lg;
|
||||||
font-size: 13px;
|
font-size: @font-size-sm;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
line-height: 17px;
|
line-height: 1.5;
|
||||||
color: @project-muted;
|
color: @project-muted;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
@@ -350,12 +352,12 @@ onMounted(() => {
|
|||||||
width: 90px;
|
width: 90px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
border: none;
|
border: none;
|
||||||
|
padding: 0;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: @primary-strong;
|
background: @primary-strong;
|
||||||
color: @white;
|
color: @white;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
padding-bottom: 6px;
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
transition: background 0.2s;
|
transition: background 0.2s;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { computed, onMounted, ref } from 'vue'
|
|||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { PaginationBar } from '@/components'
|
import { PaginationBar } from '@/components'
|
||||||
|
import { ElRadioGroup, ElRadioButton, ElInput, ElButton } from 'element-plus'
|
||||||
import { useFinanceStore } from '@/stores/modules/finance'
|
import { useFinanceStore } from '@/stores/modules/finance'
|
||||||
import type { BillStatus } from '@/types/business/finance'
|
import type { BillStatus } from '@/types/business/finance'
|
||||||
import BillSummaryBar from './cpns/BillSummaryBar.vue'
|
import BillSummaryBar from './cpns/BillSummaryBar.vue'
|
||||||
@@ -35,6 +36,10 @@ function onTab(value: BillStatus | 'all'): void {
|
|||||||
void financeStore.search()
|
void financeStore.search()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onTabChange(val: string | number | boolean | undefined): void {
|
||||||
|
onTab(val as BillStatus | 'all')
|
||||||
|
}
|
||||||
|
|
||||||
function onSearch(): void {
|
function onSearch(): void {
|
||||||
financeStore.setQuery({ ...query.value, keyword: keyword.value })
|
financeStore.setQuery({ ...query.value, keyword: keyword.value })
|
||||||
void financeStore.search()
|
void financeStore.search()
|
||||||
@@ -87,31 +92,34 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bills__toolbar">
|
<div class="bills__toolbar">
|
||||||
<div class="bills__tabs">
|
<el-radio-group
|
||||||
<button
|
class="bills__tabs"
|
||||||
|
:model-value="query.status"
|
||||||
|
@change="onTabChange"
|
||||||
|
>
|
||||||
|
<el-radio-button
|
||||||
v-for="tab in STATUS_TABS"
|
v-for="tab in STATUS_TABS"
|
||||||
:key="tab.value"
|
:key="tab.value"
|
||||||
class="bills__tab"
|
:value="tab.value"
|
||||||
:class="{ 'is-active': query.status === tab.value }"
|
|
||||||
@click="onTab(tab.value)"
|
|
||||||
>
|
>
|
||||||
{{ tab.label }}
|
{{ tab.label }}
|
||||||
</button>
|
</el-radio-button>
|
||||||
</div>
|
</el-radio-group>
|
||||||
|
|
||||||
<input
|
<el-input
|
||||||
v-model="keyword"
|
v-model="keyword"
|
||||||
class="bills__search"
|
class="bills__search"
|
||||||
placeholder="关键字搜索"
|
placeholder="关键字搜索"
|
||||||
|
clearable
|
||||||
@keyup.enter="onSearch"
|
@keyup.enter="onSearch"
|
||||||
>
|
/>
|
||||||
|
|
||||||
<button class="bills__btn bills__btn--reset" @click="onReset">
|
<el-button class="bills__btn bills__btn--reset" @click="onReset">
|
||||||
重置
|
重置
|
||||||
</button>
|
</el-button>
|
||||||
<button class="bills__btn bills__btn--primary" @click="onSearch">
|
<el-button class="bills__btn bills__btn--primary" @click="onSearch">
|
||||||
搜索
|
搜索
|
||||||
</button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bills__board">
|
<div class="bills__board">
|
||||||
@@ -146,8 +154,9 @@ onMounted(() => {
|
|||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding-bottom: 14px;
|
min-height: 100%;
|
||||||
background: @contract-page-bg;
|
padding-bottom: @spacing-page-bottom;
|
||||||
|
background: @bg-color;
|
||||||
|
|
||||||
&__breadcrumb {
|
&__breadcrumb {
|
||||||
.flex(row, flex-start, center);
|
.flex(row, flex-start, center);
|
||||||
@@ -177,9 +186,9 @@ onMounted(() => {
|
|||||||
&__stats {
|
&__stats {
|
||||||
.flex(row, flex-start, flex-start);
|
.flex(row, flex-start, flex-start);
|
||||||
gap: 0;
|
gap: 0;
|
||||||
height: 106px;
|
min-height: 96px;
|
||||||
margin-top: 11px;
|
margin-top: @spacing-base;
|
||||||
border: 1px solid @finance-card-line;
|
border: none;
|
||||||
border-radius: @radius-base;
|
border-radius: @radius-base;
|
||||||
background: @white;
|
background: @white;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -201,82 +210,78 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__stat-value {
|
&__stat-value {
|
||||||
font-size: 26px;
|
font-size: @font-size-xl;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
line-height: 35px;
|
line-height: 1.3;
|
||||||
color: @finance-ink;
|
color: @finance-ink;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__toolbar {
|
&__toolbar {
|
||||||
.flex(row, flex-start, center);
|
.flex(row, flex-start, center);
|
||||||
gap: 0;
|
gap: @spacing-base;
|
||||||
height: 71px;
|
min-height: 64px;
|
||||||
margin-top: 14px;
|
margin-top: @spacing-base;
|
||||||
padding: 0 0 0 22px;
|
padding: 0 @spacing-lg;
|
||||||
border: 1px solid @finance-card-line;
|
border: none;
|
||||||
border-radius: @radius-base;
|
border-radius: @radius-base;
|
||||||
background: @white;
|
background: @white;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__tabs {
|
&__tabs {
|
||||||
.flex(row, flex-start, center);
|
|
||||||
gap: 0;
|
|
||||||
width: 279px;
|
|
||||||
height: 34px;
|
|
||||||
border: 1px solid @border-color;
|
|
||||||
border-radius: 4px;
|
|
||||||
overflow: hidden;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
box-sizing: border-box;
|
|
||||||
padding: 0 11.3px 0 9.2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__tab {
|
:deep(.el-radio-button__inner) {
|
||||||
flex: 1 1 0;
|
height: 34px;
|
||||||
height: 32px;
|
padding: 0 11.3px;
|
||||||
border: none;
|
border: 1px solid @border-color;
|
||||||
border-right: 1px solid @border-color;
|
border-radius: 4px;
|
||||||
border-radius: 0;
|
background: @white;
|
||||||
background: @white;
|
color: @finance-entry-text;
|
||||||
color: @finance-entry-text;
|
font-size: 12px;
|
||||||
font-size: 12px;
|
font-weight: 400;
|
||||||
font-weight: 400;
|
line-height: 32px;
|
||||||
transition: all 0.2s;
|
box-shadow: none;
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
border-right: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
:deep(.el-radio-button + .el-radio-button) {
|
||||||
|
margin-left: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-radio-button.is-active .el-radio-button__inner) {
|
||||||
|
border-color: @primary-strong;
|
||||||
|
background: @filter-selected-bg;
|
||||||
color: @primary-strong;
|
color: @primary-strong;
|
||||||
}
|
|
||||||
|
|
||||||
&.is-active {
|
|
||||||
border-right-color: @primary-strong;
|
|
||||||
background: @primary-strong;
|
|
||||||
color: @white;
|
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__search {
|
&__search {
|
||||||
width: 1300px;
|
flex: 0 1 360px;
|
||||||
height: 37px;
|
width: auto;
|
||||||
margin-left: 33px;
|
height: 36px;
|
||||||
padding: 0 16px;
|
margin-left: 0;
|
||||||
border: none;
|
flex-shrink: 1;
|
||||||
border-radius: 0;
|
min-width: 0;
|
||||||
background: @finance-field-bg;
|
|
||||||
color: @finance-entry-text;
|
|
||||||
font-size: 13px;
|
|
||||||
outline: none;
|
|
||||||
box-sizing: border-box;
|
|
||||||
flex-shrink: 0;
|
|
||||||
|
|
||||||
&::placeholder {
|
:deep(.el-input__wrapper) {
|
||||||
color: @contract-placeholder;
|
width: 100%;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 @spacing-base;
|
||||||
|
background: @finance-field-bg;
|
||||||
|
box-shadow: 0 0 0 1px @border-color inset;
|
||||||
|
border-radius: @radius-round;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-input__inner) {
|
||||||
|
color: @finance-entry-text;
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: @contract-placeholder;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,7 +289,6 @@ onMounted(() => {
|
|||||||
height: 32px;
|
height: 32px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
background: transparent;
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -293,9 +297,11 @@ onMounted(() => {
|
|||||||
width: 62.2px;
|
width: 62.2px;
|
||||||
margin-left: 15.4px;
|
margin-left: 15.4px;
|
||||||
color: @finance-entry-text;
|
color: @finance-entry-text;
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: @primary-strong;
|
color: @primary-strong;
|
||||||
|
background: transparent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,36 +317,41 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__board {
|
&__board {
|
||||||
margin-top: 14px;
|
margin-top: @spacing-base;
|
||||||
border: 1px solid @finance-card-line;
|
border: none;
|
||||||
border-radius: @radius-base;
|
border-radius: @radius-base;
|
||||||
background: @white;
|
background: @white;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
padding: @spacing-base @spacing-lg @spacing-lg;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.pagination) {
|
:deep(.pagination) {
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 3.7px;
|
gap: @spacing-xs;
|
||||||
padding: 8px 26px 30px 0;
|
padding: @spacing-base 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.pagination__btn) {
|
:deep(.pagination__btn) {
|
||||||
min-width: 0;
|
min-width: 28px;
|
||||||
height: 16px;
|
height: 28px;
|
||||||
padding: 0 3px;
|
padding: 0 6px;
|
||||||
border: none;
|
border: 1px solid @border-color;
|
||||||
border-radius: 0;
|
border-radius: @radius-sm;
|
||||||
background: transparent;
|
background: @white;
|
||||||
color: @finance-muted-text;
|
color: @text-color;
|
||||||
font-size: 12px;
|
font-size: @font-size-sm;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
|
||||||
&.is-active {
|
&.is-active {
|
||||||
min-width: 21.45px;
|
min-width: 28px;
|
||||||
|
border-color: @primary-strong;
|
||||||
|
background: @primary-light;
|
||||||
|
color: @primary-strong;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover:not(:disabled):not(.is-ellipsis) {
|
&:hover:not(:disabled):not(.is-ellipsis) {
|
||||||
border: none;
|
border-color: @primary-strong;
|
||||||
color: @primary-strong;
|
color: @primary-strong;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage, ElButton } from 'element-plus'
|
||||||
import { useFinanceStore } from '@/stores/modules/finance'
|
import { useFinanceStore } from '@/stores/modules/finance'
|
||||||
import type { PayMethod } from '@/types/business/finance'
|
import type { PayMethod } from '@/types/business/finance'
|
||||||
import { createMerchantOrderNo, PAY_METHODS } from '@/utils/payMeta'
|
import { createMerchantOrderNo, PAY_METHODS } from '@/utils/payMeta'
|
||||||
@@ -172,13 +172,13 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<el-button
|
||||||
class="checkout__cta"
|
class="checkout__cta"
|
||||||
:disabled="!canPay"
|
:disabled="!canPay"
|
||||||
@click="onConfirm"
|
@click="onConfirm"
|
||||||
>
|
>
|
||||||
{{ submitting ? '处理中…' : '确认支付' }}
|
{{ submitting ? '处理中…' : '确认支付' }}
|
||||||
</button>
|
</el-button>
|
||||||
|
|
||||||
<p class="checkout__summary-tip">
|
<p class="checkout__summary-tip">
|
||||||
支付完成后订单状态将自动更新
|
支付完成后订单状态将自动更新
|
||||||
@@ -191,9 +191,9 @@ onBeforeUnmount(() => {
|
|||||||
<p class="checkout__empty-text">
|
<p class="checkout__empty-text">
|
||||||
未找到待支付账单,请返回账单明细重新选择
|
未找到待支付账单,请返回账单明细重新选择
|
||||||
</p>
|
</p>
|
||||||
<button class="checkout__empty-btn" @click="goBack">
|
<el-button class="checkout__empty-btn" @click="goBack">
|
||||||
返回账单明细
|
返回账单明细
|
||||||
</button>
|
</el-button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<WechatQrDialog
|
<WechatQrDialog
|
||||||
@@ -210,11 +210,13 @@ onBeforeUnmount(() => {
|
|||||||
.checkout {
|
.checkout {
|
||||||
.flex(column, flex-start, stretch);
|
.flex(column, flex-start, stretch);
|
||||||
gap: @spacing-base;
|
gap: @spacing-base;
|
||||||
|
min-height: 100%;
|
||||||
padding-top: @spacing-lg;
|
padding-top: @spacing-lg;
|
||||||
padding-bottom: @spacing-xxl;
|
padding-bottom: @spacing-page-bottom;
|
||||||
|
background: @bg-color;
|
||||||
|
|
||||||
&__breadcrumb {
|
&__breadcrumb {
|
||||||
font-size: @font-size-base;
|
font-size: @font-size-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__layout {
|
&__layout {
|
||||||
@@ -283,6 +285,8 @@ onBeforeUnmount(() => {
|
|||||||
&__cta {
|
&__cta {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
border-radius: @radius-round;
|
border-radius: @radius-round;
|
||||||
background: @checkout-cta;
|
background: @checkout-cta;
|
||||||
color: @white;
|
color: @white;
|
||||||
@@ -329,6 +333,12 @@ onBeforeUnmount(() => {
|
|||||||
font-size: @font-size-base;
|
font-size: @font-size-base;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:deep(.checkout__empty-btn.el-button) {
|
||||||
|
border: 1px solid @checkout-cta;
|
||||||
|
background: @checkout-card;
|
||||||
|
color: @checkout-cta;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 992px) {
|
@media (max-width: 992px) {
|
||||||
&__layout {
|
&__layout {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import { ElCheckbox, ElButton } from 'element-plus'
|
||||||
import type { Bill } from '@/types/business/finance'
|
import type { Bill } from '@/types/business/finance'
|
||||||
import { formatMoney } from '@/utils/format'
|
import { formatMoney } from '@/utils/format'
|
||||||
|
|
||||||
@@ -30,12 +31,11 @@ function onBatchPay(): void {
|
|||||||
<div class="bill-summary">
|
<div class="bill-summary">
|
||||||
<div class="bill-summary__inner">
|
<div class="bill-summary__inner">
|
||||||
<div class="bill-summary__left">
|
<div class="bill-summary__left">
|
||||||
<input
|
<el-checkbox
|
||||||
class="bill-summary__check"
|
class="bill-summary__check"
|
||||||
type="checkbox"
|
:model-value="canPay"
|
||||||
:checked="canPay"
|
|
||||||
@change="onBatchPay"
|
@change="onBatchPay"
|
||||||
>
|
/>
|
||||||
<span class="bill-summary__text">已选择</span>
|
<span class="bill-summary__text">已选择</span>
|
||||||
<span class="bill-summary__count">{{ selectedBills.length }}</span>
|
<span class="bill-summary__count">{{ selectedBills.length }}</span>
|
||||||
<span class="bill-summary__text">笔订单</span>
|
<span class="bill-summary__text">笔订单</span>
|
||||||
@@ -44,9 +44,9 @@ function onBatchPay(): void {
|
|||||||
<div class="bill-summary__right">
|
<div class="bill-summary__right">
|
||||||
<span class="bill-summary__text">合计:</span>
|
<span class="bill-summary__text">合计:</span>
|
||||||
<span class="bill-summary__amount">¥ {{ formatMoney(selectedAmount, 0) }}</span>
|
<span class="bill-summary__amount">¥ {{ formatMoney(selectedAmount, 0) }}</span>
|
||||||
<button class="bill-summary__cta" :disabled="!canPay" @click="onBatchPay">
|
<el-button class="bill-summary__cta" :disabled="!canPay" @click="onBatchPay">
|
||||||
立即支付
|
立即支付
|
||||||
</button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -77,11 +77,15 @@ function onBatchPay(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__check {
|
&__check {
|
||||||
width: 15px;
|
|
||||||
height: 15px;
|
height: 15px;
|
||||||
margin: 0 12.25px 0 0;
|
margin: 0 12.25px 0 0;
|
||||||
accent-color: @primary-strong;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
:deep(.el-checkbox__inner) {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
border-color: @border-color;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__text {
|
&__text {
|
||||||
@@ -118,6 +122,7 @@ function onBatchPay(): void {
|
|||||||
width: 84px;
|
width: 84px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
border: none;
|
border: none;
|
||||||
|
padding: 0;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: @finance-summary-cta;
|
background: @finance-summary-cta;
|
||||||
color: @white;
|
color: @white;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
|
import { ElTable, ElTableColumn } from 'element-plus'
|
||||||
|
import type { TableInstance } from 'element-plus'
|
||||||
import type { Bill } from '@/types/business/finance'
|
import type { Bill } from '@/types/business/finance'
|
||||||
import { formatMoney } from '@/utils/format'
|
import { formatMoney } from '@/utils/format'
|
||||||
|
|
||||||
@@ -16,223 +18,162 @@ const emit = defineEmits<{
|
|||||||
'update:selection': [ids: string[]]
|
'update:selection': [ids: string[]]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const selection = ref<Set<string>>(new Set(props.selectedIds))
|
const tableRef = ref<TableInstance>()
|
||||||
|
|
||||||
|
function onSelectionChange(rows: Bill[]): void {
|
||||||
|
emit('update:selection', rows.map((row) => row.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 外部清空选中(如重置查询)时同步清空 el-table 选择态
|
||||||
watch(
|
watch(
|
||||||
() => props.selectedIds,
|
() => props.selectedIds,
|
||||||
(ids) => {
|
(ids) => {
|
||||||
selection.value = new Set(ids)
|
if (ids.length === 0 && tableRef.value) tableRef.value.clearSelection()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
const allSelected = computed(
|
|
||||||
() => props.bills.length > 0 && props.bills.every((bill) => selection.value.has(bill.id)),
|
|
||||||
)
|
|
||||||
|
|
||||||
const someSelected = computed(
|
|
||||||
() => props.bills.some((bill) => selection.value.has(bill.id)) && !allSelected.value,
|
|
||||||
)
|
|
||||||
|
|
||||||
function toggle(id: string): void {
|
|
||||||
if (selection.value.has(id)) selection.value.delete(id)
|
|
||||||
else selection.value.add(id)
|
|
||||||
emit('update:selection', [...selection.value])
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleAll(): void {
|
|
||||||
if (allSelected.value) {
|
|
||||||
for (const bill of props.bills) selection.value.delete(bill.id)
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
for (const bill of props.bills) selection.value.add(bill.id)
|
|
||||||
}
|
|
||||||
emit('update:selection', [...selection.value])
|
|
||||||
}
|
|
||||||
|
|
||||||
const COLUMNS = [
|
|
||||||
{ key: 'index', label: '序号', className: 'bill-table__col--index' },
|
|
||||||
{ key: 'planName', label: '账单名称', className: 'bill-table__col--name' },
|
|
||||||
{ key: 'billNo', label: '账单号', className: 'bill-table__col--no' },
|
|
||||||
{ key: 'tradeNo', label: '交易流水号', className: 'bill-table__col--trade' },
|
|
||||||
{ key: 'projectName', label: '项目名称', className: 'bill-table__col--project' },
|
|
||||||
{ key: 'amount', label: '订单金额', className: 'bill-table__col--amount' },
|
|
||||||
{ key: 'status', label: '状态', className: 'bill-table__col--status' },
|
|
||||||
{ key: 'ops', label: '操作', className: 'bill-table__col--ops' },
|
|
||||||
] as const
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<table class="bill-table">
|
<el-table
|
||||||
<colgroup>
|
ref="tableRef"
|
||||||
<col class="bill-table__col--check">
|
class="bill-table"
|
||||||
<col
|
:data="bills"
|
||||||
v-for="column in COLUMNS"
|
row-key="id"
|
||||||
:key="column.key"
|
@selection-change="onSelectionChange"
|
||||||
:class="column.className"
|
>
|
||||||
>
|
<el-table-column
|
||||||
</colgroup>
|
type="selection"
|
||||||
<thead>
|
width="51"
|
||||||
<tr>
|
/>
|
||||||
<th class="bill-table__cell bill-table__cell--check">
|
<el-table-column
|
||||||
<input
|
type="index"
|
||||||
type="checkbox"
|
label="序号"
|
||||||
:checked="allSelected"
|
width="235"
|
||||||
:indeterminate="someSelected"
|
/>
|
||||||
@change="toggleAll"
|
<el-table-column
|
||||||
|
prop="planName"
|
||||||
|
label="账单名称"
|
||||||
|
width="310"
|
||||||
|
show-overflow-tooltip
|
||||||
|
/>
|
||||||
|
<el-table-column
|
||||||
|
prop="billNo"
|
||||||
|
label="账单号"
|
||||||
|
width="148"
|
||||||
|
show-overflow-tooltip
|
||||||
|
/>
|
||||||
|
<el-table-column
|
||||||
|
prop="tradeNo"
|
||||||
|
label="交易流水号"
|
||||||
|
width="394"
|
||||||
|
show-overflow-tooltip
|
||||||
|
/>
|
||||||
|
<el-table-column
|
||||||
|
prop="projectName"
|
||||||
|
label="项目名称"
|
||||||
|
width="339"
|
||||||
|
show-overflow-tooltip
|
||||||
|
/>
|
||||||
|
<el-table-column
|
||||||
|
label="订单金额"
|
||||||
|
width="97"
|
||||||
|
>
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatMoney((row as Bill).amount, 0) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column
|
||||||
|
label="状态"
|
||||||
|
width="95"
|
||||||
|
>
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ (row as Bill).status }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column
|
||||||
|
label="操作"
|
||||||
|
width="96"
|
||||||
|
fixed="right"
|
||||||
|
>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="bill-table__ops">
|
||||||
|
<el-button
|
||||||
|
v-if="(row as Bill).status === '待支付'"
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
class="bill-table__link"
|
||||||
|
@click="emit('pay', (row as Bill).id)"
|
||||||
>
|
>
|
||||||
</th>
|
去支付
|
||||||
<th
|
</el-button>
|
||||||
v-for="column in COLUMNS"
|
<el-button
|
||||||
:key="column.key"
|
link
|
||||||
class="bill-table__cell"
|
type="primary"
|
||||||
>
|
class="bill-table__link"
|
||||||
{{ column.label }}
|
@click="emit('view', (row as Bill).id)"
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="(bill, index) in bills" :key="bill.id">
|
|
||||||
<td class="bill-table__cell bill-table__cell--check">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
:checked="selection.has(bill.id)"
|
|
||||||
@change="toggle(bill.id)"
|
|
||||||
>
|
>
|
||||||
</td>
|
详情
|
||||||
<td class="bill-table__cell">
|
</el-button>
|
||||||
{{ index + 1 }}
|
</span>
|
||||||
</td>
|
</template>
|
||||||
<td class="bill-table__cell">
|
</el-table-column>
|
||||||
{{ bill.planName }}
|
|
||||||
</td>
|
<template #empty>
|
||||||
<td class="bill-table__cell">
|
<span class="bill-table__empty">暂无账单数据</span>
|
||||||
{{ bill.billNo }}
|
</template>
|
||||||
</td>
|
</el-table>
|
||||||
<td class="bill-table__cell">
|
|
||||||
{{ bill.tradeNo ?? '—' }}
|
|
||||||
</td>
|
|
||||||
<td class="bill-table__cell">
|
|
||||||
{{ bill.projectName }}
|
|
||||||
</td>
|
|
||||||
<td class="bill-table__cell">
|
|
||||||
{{ formatMoney(bill.amount, 0) }}
|
|
||||||
</td>
|
|
||||||
<td class="bill-table__cell">
|
|
||||||
{{ bill.status }}
|
|
||||||
</td>
|
|
||||||
<td class="bill-table__cell bill-table__cell--ops">
|
|
||||||
<span class="bill-table__ops">
|
|
||||||
<button
|
|
||||||
v-if="bill.status === '待支付'"
|
|
||||||
class="bill-table__link"
|
|
||||||
@click="emit('pay', bill.id)"
|
|
||||||
>
|
|
||||||
去支付
|
|
||||||
</button>
|
|
||||||
<button class="bill-table__link" @click="emit('view', bill.id)">
|
|
||||||
详情
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr v-if="bills.length === 0">
|
|
||||||
<td colspan="9" class="bill-table__empty">
|
|
||||||
暂无账单数据
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.bill-table {
|
.bill-table {
|
||||||
width: 1765px;
|
width: 100%;
|
||||||
margin: 6.5px 0 0 14.5px;
|
margin: @spacing-sm 0 0;
|
||||||
border-collapse: collapse;
|
font-size: @font-size-sm;
|
||||||
table-layout: fixed;
|
|
||||||
font-size: 12px;
|
|
||||||
color: @finance-entry-text;
|
color: @finance-entry-text;
|
||||||
|
|
||||||
&__col--check { width: 51px; }
|
:deep(.el-table) {
|
||||||
&__col--index { width: 235.4px; }
|
--el-table-border-color: @border-color;
|
||||||
&__col--name { width: 310.2px; }
|
|
||||||
&__col--no { width: 148px; }
|
|
||||||
&__col--trade { width: 394.1px; }
|
|
||||||
&__col--project { width: 339.4px; }
|
|
||||||
&__col--amount { width: 97px; }
|
|
||||||
&__col--status { width: 95px; }
|
|
||||||
&__col--ops { width: 95.5px; }
|
|
||||||
|
|
||||||
&__cell {
|
&::before {
|
||||||
height: 43.77px;
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table__header-wrapper th.el-table__cell) {
|
||||||
|
height: 44px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border-bottom: 1px solid @project-divider;
|
|
||||||
text-align: left;
|
|
||||||
vertical-align: middle;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
box-sizing: border-box;
|
|
||||||
|
|
||||||
&:not(&--check):not(&--ops) {
|
|
||||||
padding-left: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--check {
|
|
||||||
padding-left: 43.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--ops {
|
|
||||||
padding-right: 22px;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@col-pad-1: 43.5px;
|
|
||||||
@col-pad-2: 149.9px;
|
|
||||||
@col-pad-3: 8px;
|
|
||||||
@col-pad-4: 17px;
|
|
||||||
@col-pad-5: 5px;
|
|
||||||
@col-pad-6: 14.6px;
|
|
||||||
@col-pad-7: 4.6px;
|
|
||||||
@col-pad-8: 6px;
|
|
||||||
|
|
||||||
thead &__cell,
|
|
||||||
tbody &__cell {
|
|
||||||
&:nth-child(1) { padding-left: @col-pad-1; }
|
|
||||||
&:nth-child(2) { padding-left: @col-pad-2; }
|
|
||||||
&:nth-child(3) { padding-left: @col-pad-3; }
|
|
||||||
&:nth-child(4) { padding-left: @col-pad-4; }
|
|
||||||
&:nth-child(5) { padding-left: @col-pad-5; }
|
|
||||||
&:nth-child(6) { padding-left: @col-pad-6; }
|
|
||||||
&:nth-child(7) { padding-left: @col-pad-7; }
|
|
||||||
&:nth-child(8) { padding-left: @col-pad-8; }
|
|
||||||
&:nth-child(9) { padding-left: 0; padding-right: 10px; }
|
|
||||||
}
|
|
||||||
|
|
||||||
thead &__cell {
|
|
||||||
height: 41.77px;
|
|
||||||
background: @finance-table-head-bg;
|
background: @finance-table-head-bg;
|
||||||
border-right: 1px solid @project-divider;
|
border-right: none;
|
||||||
color: @finance-entry-text;
|
border-bottom: 1px solid @border-color;
|
||||||
font-size: 12px;
|
color: @text-color-secondary;
|
||||||
font-weight: 600;
|
font-size: @font-size-sm;
|
||||||
|
font-weight: 500;
|
||||||
&:first-child {
|
text-align: left;
|
||||||
border-left: 1px solid @project-divider;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody tr:hover {
|
:deep(.el-table__body-wrapper td.el-table__cell) {
|
||||||
|
height: 56px;
|
||||||
|
padding: 0;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid @border-color;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table__body tr:hover > td.el-table__cell) {
|
||||||
background: @bg-color-light;
|
background: @bg-color-light;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__cell--check input {
|
:deep(.el-table .cell) {
|
||||||
width: 14px;
|
padding-left: @spacing-sm;
|
||||||
height: 14px;
|
padding-right: @spacing-sm;
|
||||||
margin: 0;
|
line-height: normal;
|
||||||
accent-color: @primary-strong;
|
white-space: nowrap;
|
||||||
vertical-align: middle;
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table-column--selection .cell) {
|
||||||
|
padding-left: @spacing-base;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__ops {
|
&__ops {
|
||||||
@@ -240,31 +181,18 @@ const COLUMNS = [
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
gap: @spacing-base;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__link {
|
&__link {
|
||||||
padding: 0;
|
font-size: @font-size-sm;
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
color: @primary-strong;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
transition: color 0.2s;
|
padding: 0;
|
||||||
|
|
||||||
+ .bill-table__link {
|
|
||||||
margin-left: 18.36px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: @primary-color;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__empty {
|
&__empty {
|
||||||
height: 43.77px;
|
|
||||||
color: @project-muted;
|
color: @project-muted;
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
import { ElButton } from 'element-plus'
|
||||||
|
|
||||||
interface DistributionItem {
|
interface DistributionItem {
|
||||||
name: string
|
name: string
|
||||||
@@ -26,13 +27,32 @@ const gradient = computed(() => {
|
|||||||
return `conic-gradient(${stops.join(', ')})`
|
return `conic-gradient(${stops.join(', ')})`
|
||||||
})
|
})
|
||||||
|
|
||||||
function percentOf(value: number): number {
|
const AXIS = [250, 200, 150, 100, 50]
|
||||||
if (total.value <= 0) return 0
|
const AXIS_MAX = AXIS[0]
|
||||||
return Math.round((value / total.value) * 100)
|
const MONTHS = ['1月', '2月', '3月', '4月', '5月']
|
||||||
|
|
||||||
|
interface PeriodOption {
|
||||||
|
label: string
|
||||||
|
value: 'month' | 'half'
|
||||||
}
|
}
|
||||||
|
|
||||||
const AXIS = [250, 200, 150, 100, 50]
|
// 设计稿 #20 右上角切换:月度账单(激活)/ 上半
|
||||||
const MONTHS = ['1月', '2月', '3月', '4月', '5月']
|
const PERIODS: PeriodOption[] = [
|
||||||
|
{ label: '月度账单', value: 'month' },
|
||||||
|
{ label: '上半', value: 'half' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const period = ref<'month' | 'half'>('month')
|
||||||
|
|
||||||
|
function onPeriodChange(value: 'month' | 'half'): void {
|
||||||
|
period.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
// 柱高按 Y 轴量程绝对值换算(设计稿 #20:Y 轴 250 为满量程),非占比
|
||||||
|
function heightPercentOf(value: number): number {
|
||||||
|
if (value <= 0) return 0
|
||||||
|
return Math.min((value / AXIS_MAX) * 100, 100)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -41,6 +61,18 @@ const MONTHS = ['1月', '2月', '3月', '4月', '5月']
|
|||||||
业务类型分布
|
业务类型分布
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
|
<div class="finance-chart__switch">
|
||||||
|
<el-button
|
||||||
|
v-for="item in PERIODS"
|
||||||
|
:key="item.value"
|
||||||
|
class="finance-chart__switch-btn"
|
||||||
|
:class="{ 'is-active': period === item.value }"
|
||||||
|
@click="onPeriodChange(item.value)"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="finance-chart__body">
|
<div class="finance-chart__body">
|
||||||
<ul class="finance-chart__axis" aria-hidden="true">
|
<ul class="finance-chart__axis" aria-hidden="true">
|
||||||
<li v-for="v in AXIS" :key="v" class="finance-chart__axis-item">
|
<li v-for="v in AXIS" :key="v" class="finance-chart__axis-item">
|
||||||
@@ -55,10 +87,10 @@ const MONTHS = ['1月', '2月', '3月', '4月', '5月']
|
|||||||
|
|
||||||
<div class="finance-chart__bars">
|
<div class="finance-chart__bars">
|
||||||
<span
|
<span
|
||||||
v-for="item in distribution"
|
v-for="(m, i) in MONTHS"
|
||||||
:key="item.name"
|
:key="m"
|
||||||
class="finance-chart__bar"
|
class="finance-chart__bar"
|
||||||
:style="{ height: `${Math.max(percentOf(item.value), 6)}%` }"
|
:style="{ height: `${heightPercentOf(distribution[i]?.value ?? 0)}%` }"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -78,6 +110,10 @@ const MONTHS = ['1月', '2月', '3月', '4月', '5月']
|
|||||||
:key="item.name"
|
:key="item.name"
|
||||||
class="finance-chart__legend-item"
|
class="finance-chart__legend-item"
|
||||||
>
|
>
|
||||||
|
<i
|
||||||
|
class="finance-chart__dot"
|
||||||
|
:style="{ background: item.color }"
|
||||||
|
/>
|
||||||
<span class="finance-chart__name">{{ item.name }}</span>
|
<span class="finance-chart__name">{{ item.name }}</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -116,6 +152,42 @@ const MONTHS = ['1月', '2月', '3月', '4月', '5月']
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__switch {
|
||||||
|
.flex(row, flex-end, center);
|
||||||
|
gap: 8px;
|
||||||
|
position: absolute;
|
||||||
|
top: 25px;
|
||||||
|
right: 23px;
|
||||||
|
|
||||||
|
:deep(.el-button + .el-button) {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__switch-btn {
|
||||||
|
height: 24px;
|
||||||
|
margin-left: 0;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: @radius-round;
|
||||||
|
background: @finance-switch-idle-bg;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 24px;
|
||||||
|
color: @finance-muted-text;
|
||||||
|
transition: color 0.2s, background 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: @finance-switch-idle-bg;
|
||||||
|
color: @finance-chart-switch-active;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-active {
|
||||||
|
background: @finance-switch-active-bg;
|
||||||
|
color: @finance-chart-switch-active;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&__body {
|
&__body {
|
||||||
.flex(row, flex-start, flex-start);
|
.flex(row, flex-start, flex-start);
|
||||||
gap: 0;
|
gap: 0;
|
||||||
@@ -126,7 +198,8 @@ const MONTHS = ['1月', '2月', '3月', '4月', '5月']
|
|||||||
.flex(column, space-between, flex-start);
|
.flex(column, space-between, flex-start);
|
||||||
width: 34px;
|
width: 34px;
|
||||||
height: 234px;
|
height: 234px;
|
||||||
margin: 0;
|
// 设计稿 #20:Y 轴刻度文字左缘 x=103(卡左 70 + padding 23 + 10)
|
||||||
|
margin: 0 0 0 10px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -164,7 +237,7 @@ const MONTHS = ['1月', '2月', '3月', '4月', '5月']
|
|||||||
.flex(row, space-around, flex-end);
|
.flex(row, space-around, flex-end);
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
padding: 0 40px;
|
// 无横向 padding:柱子与下方月份标签同为 space-around,中心才能对齐(设计稿 #20 柱月对齐)
|
||||||
}
|
}
|
||||||
|
|
||||||
&__bar {
|
&__bar {
|
||||||
@@ -200,7 +273,8 @@ const MONTHS = ['1月', '2月', '3月', '4月', '5月']
|
|||||||
.flex(row, flex-start, center);
|
.flex(row, flex-start, center);
|
||||||
gap: 27px;
|
gap: 27px;
|
||||||
margin: 41px 0 0;
|
margin: 41px 0 0;
|
||||||
padding: 0 0 0 662px;
|
// 设计稿 #20:图例文字左缘 x=809(卡左 70 + padding 23 + 704 + 圆点 6 + gap 6)
|
||||||
|
padding: 0 0 0 704px;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,6 +286,13 @@ const MONTHS = ['1月', '2月', '3月', '4月', '5月']
|
|||||||
color: @finance-muted-text;
|
color: @finance-muted-text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__dot {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
&__name {
|
&__name {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ElButton } from 'element-plus'
|
||||||
import type { InvoiceTitle } from '@/types/business/finance'
|
import type { InvoiceTitle } from '@/types/business/finance'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -20,18 +21,30 @@ const emit = defineEmits<{
|
|||||||
<span v-if="invoiceTitle.isDefault" class="invoice-title-card__badge">默认抬头</span>
|
<span v-if="invoiceTitle.isDefault" class="invoice-title-card__badge">默认抬头</span>
|
||||||
<span v-else class="invoice-title-card__badge-placeholder" />
|
<span v-else class="invoice-title-card__badge-placeholder" />
|
||||||
<div class="invoice-title-card__actions">
|
<div class="invoice-title-card__actions">
|
||||||
<button
|
<el-button
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
class="invoice-title-card__btn"
|
class="invoice-title-card__btn"
|
||||||
@click="emit('setDefault', invoiceTitle.id)"
|
@click="emit('setDefault', invoiceTitle.id)"
|
||||||
>
|
>
|
||||||
{{ invoiceTitle.isDefault ? '取消默认' : '设为默认' }}
|
{{ invoiceTitle.isDefault ? '取消默认' : '设为默认' }}
|
||||||
</button>
|
</el-button>
|
||||||
<button class="invoice-title-card__btn" @click="emit('delete', invoiceTitle.id)">
|
<el-button
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
class="invoice-title-card__btn"
|
||||||
|
@click="emit('delete', invoiceTitle.id)"
|
||||||
|
>
|
||||||
删除
|
删除
|
||||||
</button>
|
</el-button>
|
||||||
<button class="invoice-title-card__btn" @click="emit('edit', invoiceTitle)">
|
<el-button
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
class="invoice-title-card__btn"
|
||||||
|
@click="emit('edit', invoiceTitle)"
|
||||||
|
>
|
||||||
编辑
|
编辑
|
||||||
</button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -104,6 +117,7 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: @primary-strong;
|
color: @primary-strong;
|
||||||
|
background: transparent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, watch } from 'vue'
|
import { reactive, watch } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage, ElInput, ElRadio, ElButton } from 'element-plus'
|
||||||
import type { InvoiceTitle, InvoiceTitlePayload } from '@/types/business/finance'
|
import type { InvoiceTitle, InvoiceTitlePayload } from '@/types/business/finance'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -50,41 +50,39 @@ function onCancel(): void {
|
|||||||
|
|
||||||
<div class="title-dialog__field">
|
<div class="title-dialog__field">
|
||||||
<span class="title-dialog__label">抬头类型</span>
|
<span class="title-dialog__label">抬头类型</span>
|
||||||
<label class="title-dialog__radio">
|
<el-radio v-model="form.type" value="企业单位">
|
||||||
<input v-model="form.type" type="radio" value="企业单位">
|
|
||||||
企业单位
|
企业单位
|
||||||
</label>
|
</el-radio>
|
||||||
<label class="title-dialog__radio">
|
<el-radio v-model="form.type" value="非企业单位">
|
||||||
<input v-model="form.type" type="radio" value="非企业单位">
|
|
||||||
非企业单位
|
非企业单位
|
||||||
</label>
|
</el-radio>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="title-dialog__field">
|
<div class="title-dialog__field">
|
||||||
<span class="title-dialog__label">发票抬头</span>
|
<span class="title-dialog__label">发票抬头</span>
|
||||||
<input
|
<el-input
|
||||||
v-model="form.title"
|
v-model="form.title"
|
||||||
class="title-dialog__input"
|
class="title-dialog__input"
|
||||||
placeholder="请输入发票抬头"
|
placeholder="请输入发票抬头"
|
||||||
>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="title-dialog__field">
|
<div class="title-dialog__field">
|
||||||
<span class="title-dialog__label">税号</span>
|
<span class="title-dialog__label">税号</span>
|
||||||
<input
|
<el-input
|
||||||
v-model="form.taxNo"
|
v-model="form.taxNo"
|
||||||
class="title-dialog__input"
|
class="title-dialog__input"
|
||||||
placeholder="请输入税号"
|
placeholder="请输入税号"
|
||||||
>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="title-dialog__footer">
|
<div class="title-dialog__footer">
|
||||||
<button class="title-dialog__btn" @click="onCancel">
|
<el-button class="title-dialog__btn" @click="onCancel">
|
||||||
取消
|
取消
|
||||||
</button>
|
</el-button>
|
||||||
<button class="title-dialog__btn title-dialog__btn--confirm" @click="onSave">
|
<el-button class="title-dialog__btn title-dialog__btn--confirm" @click="onSave">
|
||||||
保存
|
保存
|
||||||
</button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -119,6 +117,11 @@ function onCancel(): void {
|
|||||||
.flex(row, flex-start, center);
|
.flex(row, flex-start, center);
|
||||||
gap: @spacing-sm;
|
gap: @spacing-sm;
|
||||||
margin-bottom: @spacing-base;
|
margin-bottom: @spacing-base;
|
||||||
|
|
||||||
|
:deep(.el-radio__label) {
|
||||||
|
color: @text-color;
|
||||||
|
font-size: @font-size-base;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__label {
|
&__label {
|
||||||
@@ -128,31 +131,19 @@ function onCancel(): void {
|
|||||||
color: @text-color;
|
color: @text-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__radio {
|
|
||||||
.flex(row, flex-start, center);
|
|
||||||
gap: @spacing-xs;
|
|
||||||
font-size: @font-size-base;
|
|
||||||
color: @text-color;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
input {
|
|
||||||
accent-color: @primary-color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__input {
|
&__input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 34px;
|
|
||||||
padding: 0 @spacing-sm;
|
|
||||||
border: 1px solid @border-color;
|
|
||||||
border-radius: @radius-base;
|
|
||||||
background: @bg-color-light;
|
|
||||||
font-size: @font-size-base;
|
|
||||||
outline: none;
|
|
||||||
transition: border-color 0.2s;
|
|
||||||
|
|
||||||
&:focus {
|
:deep(.el-input__wrapper) {
|
||||||
border-color: @primary-color;
|
height: 34px;
|
||||||
|
background: @bg-color-light;
|
||||||
|
box-shadow: none;
|
||||||
|
border-radius: @radius-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-input__inner) {
|
||||||
|
color: @text-color;
|
||||||
|
font-size: @font-size-base;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
|
import { ElRadio, ElButton } from 'element-plus'
|
||||||
import type { PayMethod } from '@/types/business/finance'
|
import type { PayMethod } from '@/types/business/finance'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -47,13 +48,13 @@ function onCancel(): void {
|
|||||||
class="pay-method-dialog__option"
|
class="pay-method-dialog__option"
|
||||||
:class="{ 'is-active': selected === 'wechat' }"
|
:class="{ 'is-active': selected === 'wechat' }"
|
||||||
>
|
>
|
||||||
<input
|
<el-radio
|
||||||
v-model="selected"
|
v-model="selected"
|
||||||
type="radio"
|
|
||||||
value="wechat"
|
value="wechat"
|
||||||
class="pay-method-dialog__radio"
|
class="pay-method-dialog__radio"
|
||||||
>
|
>
|
||||||
<span class="pay-method-dialog__name">微信支付</span>
|
微信支付
|
||||||
|
</el-radio>
|
||||||
<span class="pay-method-dialog__tip text-secondary">推荐使用</span>
|
<span class="pay-method-dialog__tip text-secondary">推荐使用</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -61,23 +62,23 @@ function onCancel(): void {
|
|||||||
class="pay-method-dialog__option"
|
class="pay-method-dialog__option"
|
||||||
:class="{ 'is-active': selected === 'alipay' }"
|
:class="{ 'is-active': selected === 'alipay' }"
|
||||||
>
|
>
|
||||||
<input
|
<el-radio
|
||||||
v-model="selected"
|
v-model="selected"
|
||||||
type="radio"
|
|
||||||
value="alipay"
|
value="alipay"
|
||||||
class="pay-method-dialog__radio"
|
class="pay-method-dialog__radio"
|
||||||
>
|
>
|
||||||
<span class="pay-method-dialog__name">支付宝支付</span>
|
支付宝支付
|
||||||
|
</el-radio>
|
||||||
<span class="pay-method-dialog__tip text-secondary">其他用户选择</span>
|
<span class="pay-method-dialog__tip text-secondary">其他用户选择</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="pay-method-dialog__footer">
|
<div class="pay-method-dialog__footer">
|
||||||
<button class="pay-method-dialog__btn" @click="onCancel">
|
<el-button class="pay-method-dialog__btn" @click="onCancel">
|
||||||
取消
|
取消
|
||||||
</button>
|
</el-button>
|
||||||
<button class="pay-method-dialog__btn pay-method-dialog__btn--confirm" @click="onConfirm">
|
<el-button class="pay-method-dialog__btn pay-method-dialog__btn--confirm" @click="onConfirm">
|
||||||
确认
|
确认
|
||||||
</button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -129,13 +130,11 @@ function onCancel(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__radio {
|
&__radio {
|
||||||
accent-color: @primary-color;
|
.el-radio__label {
|
||||||
}
|
color: @heading-color;
|
||||||
|
font-size: @font-size-base;
|
||||||
&__name {
|
font-weight: 500;
|
||||||
font-size: @font-size-base;
|
}
|
||||||
color: @heading-color;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__tip {
|
&__tip {
|
||||||
@@ -158,7 +157,6 @@ function onCancel(): void {
|
|||||||
color: @text-color;
|
color: @text-color;
|
||||||
font-size: @font-size-base;
|
font-size: @font-size-base;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border-color: @primary-color;
|
border-color: @primary-color;
|
||||||
color: @primary-color;
|
color: @primary-color;
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ const financeStore = useFinanceStore()
|
|||||||
const { stats } = storeToRefs(financeStore)
|
const { stats } = storeToRefs(financeStore)
|
||||||
|
|
||||||
const ENTRIES = [
|
const ENTRIES = [
|
||||||
{ label: '账单明细', to: '/finance/bills' },
|
{ key: 'bill', label: '账单明细', to: '/finance/bills' },
|
||||||
{ label: '待支付', to: '/finance/bills' },
|
{ key: 'pending', label: '待支付', to: '/finance/bills' },
|
||||||
{ label: '发票申请', to: '/finance/invoices' },
|
{ key: 'invoice', label: '发票申请', to: '/finance/invoices' },
|
||||||
{ label: '报表导出', to: '' },
|
{ key: 'export', label: '报表导出', to: '' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
function onPeriodChange(period: string): void {
|
function onPeriodChange(period: string): void {
|
||||||
@@ -49,20 +49,108 @@ onMounted(() => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="finance__entries">
|
<div class="finance__entries">
|
||||||
<template v-for="entry in ENTRIES" :key="entry.label">
|
<template v-for="entry in ENTRIES" :key="entry.key">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
v-if="entry.to"
|
v-if="entry.to"
|
||||||
:to="entry.to"
|
:to="entry.to"
|
||||||
class="finance__entry"
|
class="finance__entry"
|
||||||
>
|
>
|
||||||
{{ entry.label }}
|
<span
|
||||||
|
class="finance__entry-icon"
|
||||||
|
:class="`finance__entry-icon--${entry.key}`"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
v-if="entry.key === 'bill'"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
x="4"
|
||||||
|
y="2.5"
|
||||||
|
width="12"
|
||||||
|
height="15"
|
||||||
|
rx="1.5"
|
||||||
|
/>
|
||||||
|
<path d="M7 7h6M7 10h6M7 13h4" />
|
||||||
|
</svg>
|
||||||
|
<svg
|
||||||
|
v-else-if="entry.key === 'pending'"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
x="2.5"
|
||||||
|
y="4.5"
|
||||||
|
width="15"
|
||||||
|
height="11"
|
||||||
|
rx="1.5"
|
||||||
|
/>
|
||||||
|
<path d="M2.5 8.5h15" />
|
||||||
|
</svg>
|
||||||
|
<svg
|
||||||
|
v-else-if="entry.key === 'invoice'"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
x="4"
|
||||||
|
y="2.5"
|
||||||
|
width="12"
|
||||||
|
height="15"
|
||||||
|
rx="1.5"
|
||||||
|
/>
|
||||||
|
<path d="M7.5 7h5M7.5 10.5h5M7.5 14h2.5" />
|
||||||
|
</svg>
|
||||||
|
<svg
|
||||||
|
v-else
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M10 13V4M6.5 7.5 10 4l3.5 3.5M4 15.5h12" />
|
||||||
|
</svg>
|
||||||
|
<i
|
||||||
|
v-if="entry.key === 'pending' && (stats?.pendingCount ?? 0) > 0"
|
||||||
|
class="finance__entry-badge"
|
||||||
|
>
|
||||||
|
{{ stats?.pendingCount }}
|
||||||
|
</i>
|
||||||
|
</span>
|
||||||
|
<span class="finance__entry-label">{{ entry.label }}</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<button
|
<button
|
||||||
v-else
|
v-else
|
||||||
class="finance__entry"
|
class="finance__entry"
|
||||||
@click="onExport"
|
@click="onExport"
|
||||||
>
|
>
|
||||||
{{ entry.label }}
|
<span class="finance__entry-icon finance__entry-icon--export">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M10 13V4M6.5 7.5 10 4l3.5 3.5M4 15.5h12" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="finance__entry-label">{{ entry.label }}</span>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -76,27 +164,32 @@ onMounted(() => {
|
|||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.finance {
|
.finance {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
padding-bottom: @spacing-page-bottom;
|
||||||
|
background: @bg-color;
|
||||||
|
|
||||||
&__top {
|
&__top {
|
||||||
.flex(row, flex-start, stretch);
|
.flex(row, flex-start, stretch);
|
||||||
gap: 14px;
|
gap: @spacing-base;
|
||||||
margin-top: -56px;
|
margin-top: @spacing-lg;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__entries {
|
&__entries {
|
||||||
.flex(row, space-between, center);
|
.flex(row, space-between, center);
|
||||||
flex: 0 0 672px;
|
flex: 0 0 672px;
|
||||||
height: 135px;
|
height: 135px;
|
||||||
padding: 0 34px;
|
padding: 0 66px;
|
||||||
border: 1px solid @finance-card-line;
|
border: none;
|
||||||
border-radius: @radius-base;
|
border-radius: @radius-base;
|
||||||
background: @white;
|
background: @white;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__entry {
|
&__entry {
|
||||||
.flex(row, center, center);
|
.flex(column, center, center);
|
||||||
height: 100%;
|
gap: 8px;
|
||||||
|
// 等宽保证 space-between 下四入口中心等距 161.5px(设计稿 #20)
|
||||||
|
width: 56px;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: @finance-entry-text;
|
color: @finance-entry-text;
|
||||||
@@ -110,5 +203,56 @@ onMounted(() => {
|
|||||||
color: @primary-strong;
|
color: @primary-strong;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__entry-icon {
|
||||||
|
position: relative;
|
||||||
|
.flex(row, center, center);
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 10px;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--bill {
|
||||||
|
background: @finance-entry-bill-bg;
|
||||||
|
color: @finance-entry-bill-icon;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--pending {
|
||||||
|
background: @finance-entry-pending-bg;
|
||||||
|
color: @finance-pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--invoice {
|
||||||
|
background: @finance-entry-invoice-bg;
|
||||||
|
color: @finance-entry-invoice-icon;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--export {
|
||||||
|
background: @finance-entry-export-bg;
|
||||||
|
color: @finance-paid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__entry-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -7px;
|
||||||
|
// 设计稿 #20:badge 叠在图标右上内侧(右缘距图标右约 7px)
|
||||||
|
right: 7px;
|
||||||
|
min-width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 0 4px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: @finance-entry-badge-bg;
|
||||||
|
color: @white;
|
||||||
|
font-size: 9px;
|
||||||
|
font-style: normal;
|
||||||
|
line-height: 16px;
|
||||||
|
text-align: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage, ElButton, ElPagination } from 'element-plus'
|
||||||
import { ConfirmDialog } from '@/components'
|
import { ConfirmDialog } from '@/components'
|
||||||
import { useFinanceStore } from '@/stores/modules/finance'
|
import { useFinanceStore } from '@/stores/modules/finance'
|
||||||
import type { InvoiceTitle, InvoiceTitlePayload } from '@/types/business/finance'
|
import type { InvoiceTitle, InvoiceTitlePayload } from '@/types/business/finance'
|
||||||
@@ -24,7 +24,6 @@ const pageCount = computed(() => Math.max(1, Math.ceil(titles.value.length / PAG
|
|||||||
const pagedTitles = computed(() =>
|
const pagedTitles = computed(() =>
|
||||||
titles.value.slice((page.value - 1) * PAGE_SIZE, page.value * PAGE_SIZE),
|
titles.value.slice((page.value - 1) * PAGE_SIZE, page.value * PAGE_SIZE),
|
||||||
)
|
)
|
||||||
const pages = computed(() => Array.from({ length: Math.max(5, pageCount.value) }, (_, i) => i + 1))
|
|
||||||
|
|
||||||
function go(next: number): void {
|
function go(next: number): void {
|
||||||
if (next < 1 || next > pageCount.value) return
|
if (next < 1 || next > pageCount.value) return
|
||||||
@@ -89,9 +88,9 @@ onMounted(() => {
|
|||||||
<h2 class="invoice-titles__title">
|
<h2 class="invoice-titles__title">
|
||||||
发票管理
|
发票管理
|
||||||
</h2>
|
</h2>
|
||||||
<button class="invoice-titles__create" @click="onCreate">
|
<el-button class="invoice-titles__create" @click="onCreate">
|
||||||
+ 新增抬头
|
+ 新增抬头
|
||||||
</button>
|
</el-button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="invoice-titles__list">
|
<div class="invoice-titles__list">
|
||||||
@@ -108,23 +107,14 @@ onMounted(() => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="invoice-titles__pager" aria-label="分页">
|
<el-pagination
|
||||||
<button class="invoice-titles__page" :disabled="page <= 1" @click="go(page - 1)">
|
class="invoice-titles__pager"
|
||||||
‹
|
layout="prev, pager, next"
|
||||||
</button>
|
:page-size="PAGE_SIZE"
|
||||||
<button
|
:total="titles.length"
|
||||||
v-for="item in pages"
|
:current-page="page"
|
||||||
:key="item"
|
@current-change="go"
|
||||||
class="invoice-titles__page"
|
/>
|
||||||
:class="{ 'is-active': item === page }"
|
|
||||||
@click="go(item)"
|
|
||||||
>
|
|
||||||
{{ item }}
|
|
||||||
</button>
|
|
||||||
<button class="invoice-titles__page" :disabled="page >= pageCount" @click="go(page + 1)">
|
|
||||||
›
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<InvoiceTitleDialog
|
<InvoiceTitleDialog
|
||||||
@@ -147,16 +137,17 @@ onMounted(() => {
|
|||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.invoice-titles {
|
.invoice-titles {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding-bottom: 459px;
|
min-height: 100%;
|
||||||
background: @contract-page-bg;
|
padding-bottom: @spacing-page-bottom;
|
||||||
|
background: @bg-color;
|
||||||
|
|
||||||
&__breadcrumb {
|
&__breadcrumb {
|
||||||
.flex(row, flex-start, center);
|
.flex(row, flex-start, center);
|
||||||
height: 81px;
|
min-height: 48px;
|
||||||
padding-top: 36px;
|
padding-top: @spacing-lg;
|
||||||
font-size: 13px;
|
font-size: @font-size-sm;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
line-height: 17px;
|
line-height: 1.5;
|
||||||
color: @project-muted;
|
color: @project-muted;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
@@ -177,9 +168,9 @@ onMounted(() => {
|
|||||||
|
|
||||||
&__head {
|
&__head {
|
||||||
.flex(row, space-between, center);
|
.flex(row, space-between, center);
|
||||||
height: 62px;
|
min-height: 56px;
|
||||||
padding: 0 33.56px 0 36px;
|
padding: 0 @spacing-lg;
|
||||||
border: 1px solid @finance-card-line;
|
border: none;
|
||||||
border-radius: @radius-base;
|
border-radius: @radius-base;
|
||||||
background: @white;
|
background: @white;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -197,6 +188,7 @@ onMounted(() => {
|
|||||||
height: 34px;
|
height: 34px;
|
||||||
margin-right: -5px;
|
margin-right: -5px;
|
||||||
border: none;
|
border: none;
|
||||||
|
padding: 0;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: @primary-strong;
|
background: @primary-strong;
|
||||||
color: @white;
|
color: @white;
|
||||||
@@ -223,33 +215,34 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__pager {
|
&__pager {
|
||||||
.flex(row, flex-end, center);
|
|
||||||
gap: 3.7px;
|
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
padding-right: 2.4px;
|
padding-right: 2.4px;
|
||||||
}
|
justify-content: flex-end;
|
||||||
|
|
||||||
&__page {
|
:deep(.el-pagination button),
|
||||||
min-width: 21.45px;
|
:deep(.el-pagination .el-pager li) {
|
||||||
height: 16px;
|
min-width: 21.45px;
|
||||||
padding: 0 3px;
|
height: 16px;
|
||||||
border: none;
|
padding: 0 3px;
|
||||||
border-radius: 0;
|
border: none;
|
||||||
background: transparent;
|
border-radius: 0;
|
||||||
color: @finance-muted-text;
|
background: transparent;
|
||||||
font-size: 12px;
|
color: @finance-muted-text;
|
||||||
font-weight: 400;
|
font-size: 12px;
|
||||||
transition: color 0.2s;
|
font-weight: 400;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
:deep(.el-pagination .el-pager li.is-active) {
|
||||||
|
color: @primary-strong;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-pagination button:hover:not(:disabled)) {
|
||||||
color: @primary-strong;
|
color: @primary-strong;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.is-active {
|
:deep(.el-pagination button:disabled) {
|
||||||
color: @primary-strong;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage, ElButton, ElTable, ElTableColumn } from 'element-plus'
|
||||||
import { FilterPanel, PaginationBar, StatusTag } from '@/components'
|
import { FilterPanel, PaginationBar, StatusTag } from '@/components'
|
||||||
import { useFinanceStore } from '@/stores/modules/finance'
|
import { useFinanceStore } from '@/stores/modules/finance'
|
||||||
import type { Invoice, InvoiceStatus } from '@/types/business/finance'
|
import type { Invoice, InvoiceStatus } from '@/types/business/finance'
|
||||||
@@ -69,13 +69,8 @@ function onSendEmail(): void {
|
|||||||
ElMessage.success('发票已发送至绑定邮箱(演示)')
|
ElMessage.success('发票已发送至绑定邮箱(演示)')
|
||||||
}
|
}
|
||||||
|
|
||||||
function onToggleSelect(id: string): void {
|
function onSelectionChange(rows: Invoice[]): void {
|
||||||
if (selectedIds.value.includes(id)) {
|
selectedIds.value = rows.map((row) => row.id)
|
||||||
selectedIds.value = selectedIds.value.filter((item) => item !== id)
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
selectedIds.value = [...selectedIds.value, id]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -114,7 +109,7 @@ onMounted(() => {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="invoices__tabs">
|
<div class="invoices__tabs">
|
||||||
<button
|
<el-button
|
||||||
v-for="tab in STATUS_TABS"
|
v-for="tab in STATUS_TABS"
|
||||||
:key="tab.value"
|
:key="tab.value"
|
||||||
class="invoices__tab"
|
class="invoices__tab"
|
||||||
@@ -122,7 +117,7 @@ onMounted(() => {
|
|||||||
@click="onTab(tab.value)"
|
@click="onTab(tab.value)"
|
||||||
>
|
>
|
||||||
{{ tab.label }}
|
{{ tab.label }}
|
||||||
</button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FilterPanel
|
<FilterPanel
|
||||||
@@ -133,67 +128,121 @@ onMounted(() => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="invoices__table card">
|
<div class="invoices__table card">
|
||||||
<table class="invoices__table-el">
|
<el-table
|
||||||
<thead>
|
class="invoices__table-el"
|
||||||
<tr>
|
:data="invoices"
|
||||||
<th class="invoices__check">
|
row-key="id"
|
||||||
多选
|
@selection-change="onSelectionChange"
|
||||||
</th>
|
>
|
||||||
<th>序号</th>
|
<el-table-column
|
||||||
<th>账单名称</th>
|
type="selection"
|
||||||
<th>账单号</th>
|
width="48"
|
||||||
<th>交易流水号</th>
|
/>
|
||||||
<th>项目名称</th>
|
<el-table-column
|
||||||
<th>订单金额</th>
|
type="index"
|
||||||
<th>状态</th>
|
label="序号"
|
||||||
<th>申请时间</th>
|
width="60"
|
||||||
<th>操作</th>
|
/>
|
||||||
</tr>
|
<el-table-column
|
||||||
</thead>
|
prop="planName"
|
||||||
<tbody>
|
label="账单名称"
|
||||||
<tr v-for="(invoice, index) in invoices" :key="invoice.id">
|
min-width="180"
|
||||||
<td class="invoices__check">
|
show-overflow-tooltip
|
||||||
<input
|
/>
|
||||||
type="checkbox"
|
<el-table-column
|
||||||
:checked="selectedIds.includes(invoice.id)"
|
prop="billNo"
|
||||||
@change="onToggleSelect(invoice.id)"
|
label="账单号"
|
||||||
>
|
width="150"
|
||||||
</td>
|
show-overflow-tooltip
|
||||||
<td>{{ index + 1 }}</td>
|
/>
|
||||||
<td>{{ invoice.planName }}</td>
|
<el-table-column
|
||||||
<td>{{ invoice.billNo }}</td>
|
prop="tradeNo"
|
||||||
<td>{{ invoice.tradeNo ?? '—' }}</td>
|
label="交易流水号"
|
||||||
<td>{{ invoice.projectName }}</td>
|
min-width="180"
|
||||||
<td class="invoices__amount">¥{{ formatMoney(invoice.amount) }}</td>
|
show-overflow-tooltip
|
||||||
<td>
|
/>
|
||||||
<StatusTag :label="invoice.status" :variant="STATUS_VARIANTS[invoice.status]" />
|
<el-table-column
|
||||||
</td>
|
prop="projectName"
|
||||||
<td>{{ invoice.applyTime }}</td>
|
label="项目名称"
|
||||||
<td class="invoices__ops">
|
min-width="160"
|
||||||
<template v-if="invoice.status === '已开票'">
|
show-overflow-tooltip
|
||||||
<button class="invoices__link" @click="onPreview">
|
/>
|
||||||
|
<el-table-column
|
||||||
|
label="订单金额"
|
||||||
|
width="110"
|
||||||
|
>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="invoices__amount">¥{{ formatMoney((row as Invoice).amount) }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column
|
||||||
|
label="状态"
|
||||||
|
width="90"
|
||||||
|
>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<StatusTag
|
||||||
|
:label="(row as Invoice).status"
|
||||||
|
:variant="STATUS_VARIANTS[(row as Invoice).status]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column
|
||||||
|
prop="applyTime"
|
||||||
|
label="申请时间"
|
||||||
|
width="140"
|
||||||
|
show-overflow-tooltip
|
||||||
|
/>
|
||||||
|
<el-table-column
|
||||||
|
label="操作"
|
||||||
|
width="180"
|
||||||
|
fixed="right"
|
||||||
|
>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="invoices__ops">
|
||||||
|
<template v-if="(row as Invoice).status === '已开票'">
|
||||||
|
<el-button
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
class="invoices__link"
|
||||||
|
@click="onPreview"
|
||||||
|
>
|
||||||
预览
|
预览
|
||||||
</button>
|
</el-button>
|
||||||
<button class="invoices__link" @click="onDownload">
|
<el-button
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
class="invoices__link"
|
||||||
|
@click="onDownload"
|
||||||
|
>
|
||||||
下载
|
下载
|
||||||
</button>
|
</el-button>
|
||||||
<button class="invoices__link" @click="onSendEmail">
|
<el-button
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
class="invoices__link"
|
||||||
|
@click="onSendEmail"
|
||||||
|
>
|
||||||
发送邮箱
|
发送邮箱
|
||||||
</button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
<span v-else-if="invoice.status === '开票中'" class="invoices__dash">—</span>
|
<span v-else-if="(row as Invoice).status === '开票中'" class="invoices__dash">—</span>
|
||||||
<button v-else class="invoices__link" @click="onApply(invoice)">
|
<el-button
|
||||||
|
v-else
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
class="invoices__link"
|
||||||
|
@click="onApply(row as Invoice)"
|
||||||
|
>
|
||||||
申请开票
|
申请开票
|
||||||
</button>
|
</el-button>
|
||||||
</td>
|
</span>
|
||||||
</tr>
|
</template>
|
||||||
<tr v-if="invoices.length === 0">
|
</el-table-column>
|
||||||
<td colspan="10" class="invoices__empty text-secondary">
|
|
||||||
暂无发票记录
|
<template #empty>
|
||||||
</td>
|
<span class="invoices__empty text-secondary">暂无发票记录</span>
|
||||||
</tr>
|
</template>
|
||||||
</tbody>
|
</el-table>
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PaginationBar
|
<PaginationBar
|
||||||
@@ -216,8 +265,10 @@ onMounted(() => {
|
|||||||
gap: @spacing-base;
|
gap: @spacing-base;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: @container-width;
|
max-width: @container-width;
|
||||||
|
min-height: 100%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: @spacing-lg @spacing-base @spacing-xxl;
|
padding: @spacing-lg @spacing-base @spacing-page-bottom;
|
||||||
|
background: @bg-color;
|
||||||
|
|
||||||
&__breadcrumb {
|
&__breadcrumb {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -271,29 +322,41 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__tabs {
|
&__tabs {
|
||||||
.flex(row, flex-start);
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: flex-start;
|
||||||
gap: @spacing-sm;
|
gap: @spacing-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__tab {
|
&__tab {
|
||||||
height: 34px;
|
height: 34px;
|
||||||
|
margin: 0;
|
||||||
padding: 0 @spacing-base;
|
padding: 0 @spacing-base;
|
||||||
border: 1px solid @border-color;
|
border: 1px solid @border-color;
|
||||||
border-radius: @radius-base;
|
border-radius: @radius-base;
|
||||||
background: @white;
|
background: @white;
|
||||||
color: @text-color;
|
color: @text-color;
|
||||||
font-size: @font-size-base;
|
font-size: @font-size-base;
|
||||||
|
font-weight: 400;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border-color: @primary-color;
|
border-color: @primary-color;
|
||||||
|
background: @white;
|
||||||
color: @primary-color;
|
color: @primary-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.is-active {
|
&.is-active {
|
||||||
border-color: @primary-color;
|
border-color: @primary-color;
|
||||||
background: @primary-color;
|
background: @filter-selected-bg;
|
||||||
color: @white;
|
color: @primary-color;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: @primary-color;
|
||||||
|
background: @filter-selected-bg;
|
||||||
|
color: @primary-color;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,32 +370,35 @@ onMounted(() => {
|
|||||||
|
|
||||||
&__table-el {
|
&__table-el {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: @font-size-base;
|
font-size: @font-size-base;
|
||||||
color: @text-color;
|
color: @text-color;
|
||||||
|
|
||||||
th,
|
:deep(th.el-table__cell) {
|
||||||
td {
|
background: @bg-color-light;
|
||||||
padding: @spacing-sm;
|
|
||||||
text-align: left;
|
|
||||||
white-space: nowrap;
|
|
||||||
border-bottom: 1px solid @border-color;
|
border-bottom: 1px solid @border-color;
|
||||||
}
|
|
||||||
|
|
||||||
th {
|
|
||||||
background: @bg-color-light;
|
|
||||||
color: @text-color-secondary;
|
color: @text-color-secondary;
|
||||||
|
font-size: @font-size-base;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody tr:hover {
|
:deep(td.el-table__cell) {
|
||||||
|
padding: @spacing-sm 0;
|
||||||
|
border-bottom: 1px solid @border-color;
|
||||||
|
font-size: @font-size-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table__body tr:hover > td.el-table__cell) {
|
||||||
background: @bg-color-light;
|
background: @bg-color-light;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
&__check {
|
:deep(.el-table .cell) {
|
||||||
width: 48px;
|
padding-left: @spacing-sm;
|
||||||
text-align: center;
|
padding-right: @spacing-sm;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__amount {
|
&__amount {
|
||||||
@@ -341,17 +407,23 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__ops {
|
&__ops {
|
||||||
.flex(row, flex-start);
|
display: inline-flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
gap: @spacing-sm;
|
gap: @spacing-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__link {
|
&__link {
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
color: @primary-color;
|
color: @primary-color;
|
||||||
font-size: @font-size-base;
|
font-size: @font-size-base;
|
||||||
transition: color 0.2s;
|
transition: color 0.2s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: @primary-hover;
|
color: @primary-hover;
|
||||||
|
background: transparent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed, onMounted } from 'vue'
|
import { computed, onMounted } from 'vue'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { ElButton } from 'element-plus'
|
||||||
import { useFinanceStore } from '@/stores/modules/finance'
|
import { useFinanceStore } from '@/stores/modules/finance'
|
||||||
import { formatMoney } from '@/utils/format'
|
import { formatMoney } from '@/utils/format'
|
||||||
|
|
||||||
@@ -110,13 +111,12 @@ onMounted(() => {
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="pay-result__footer">
|
<div class="pay-result__footer">
|
||||||
<button
|
<el-button
|
||||||
class="pay-result__back"
|
class="pay-result__back"
|
||||||
type="button"
|
@click="router.push('/finance/bills')"
|
||||||
@click="router.push('/finance/bills')"
|
>
|
||||||
>
|
返回
|
||||||
返回
|
</el-button>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
@@ -131,15 +131,16 @@ onMounted(() => {
|
|||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.pay-result {
|
.pay-result {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding-bottom: 344px;
|
min-height: 100%;
|
||||||
background: @contract-page-bg;
|
padding-bottom: @spacing-page-bottom;
|
||||||
|
background: @bg-color;
|
||||||
|
|
||||||
&__breadcrumb {
|
&__breadcrumb {
|
||||||
.flex(row, flex-start, center);
|
.flex(row, flex-start, center);
|
||||||
padding-top: 22px;
|
padding-top: @spacing-lg;
|
||||||
font-size: 13px;
|
font-size: @font-size-sm;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
line-height: 17px;
|
line-height: 1.5;
|
||||||
color: @project-muted;
|
color: @project-muted;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,13 +165,13 @@ onMounted(() => {
|
|||||||
|
|
||||||
&__check {
|
&__check {
|
||||||
.flex(row, center, center);
|
.flex(row, center, center);
|
||||||
width: 120px;
|
width: 72px;
|
||||||
height: 120px;
|
height: 72px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: @finance-result-circle;
|
background: @finance-result-circle;
|
||||||
color: @white;
|
color: @white;
|
||||||
font-size: 56px;
|
font-size: @font-size-hero;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
@@ -311,6 +312,7 @@ onMounted(() => {
|
|||||||
width: 84px;
|
width: 84px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
border: none;
|
border: none;
|
||||||
|
padding: 0;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: @primary-strong;
|
background: @primary-strong;
|
||||||
color: @white;
|
color: @white;
|
||||||
|
|||||||
@@ -161,8 +161,10 @@ onMounted(() => {
|
|||||||
.pay {
|
.pay {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: @container-width;
|
max-width: @container-width;
|
||||||
|
min-height: 100%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: @spacing-lg @spacing-base @spacing-xxl;
|
padding: @spacing-lg @spacing-base @spacing-page-bottom;
|
||||||
|
background: @bg-color;
|
||||||
|
|
||||||
&__breadcrumb {
|
&__breadcrumb {
|
||||||
margin-bottom: @spacing-base;
|
margin-bottom: @spacing-base;
|
||||||
@@ -235,8 +237,8 @@ onMounted(() => {
|
|||||||
|
|
||||||
&__amount {
|
&__amount {
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
font-size: 26px;
|
font-size: @font-size-xl;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
color: @danger-color;
|
color: @danger-color;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,59 +61,92 @@ onBeforeUnmount(stopAutoplay)
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="home-banner">
|
<section class="home-banner">
|
||||||
<div class="home-banner__carousel" aria-hidden="true">
|
<div class="home-banner__stage">
|
||||||
<div
|
<div class="home-banner__carousel" aria-hidden="true">
|
||||||
v-for="(src, index) in slides"
|
<div
|
||||||
:key="`${src}-${index}`"
|
v-for="(src, index) in slides"
|
||||||
class="home-banner__slide"
|
:key="`${src}-${index}`"
|
||||||
:class="{ 'home-banner__slide--active': index === activeIndex }"
|
class="home-banner__slide"
|
||||||
>
|
:class="{ 'home-banner__slide--active': index === activeIndex }"
|
||||||
<img
|
|
||||||
class="home-banner__image"
|
|
||||||
:class="{ 'home-banner__image--inline': !hasBanners }"
|
|
||||||
:src="src"
|
|
||||||
alt=""
|
|
||||||
>
|
>
|
||||||
|
<img
|
||||||
|
class="home-banner__image"
|
||||||
|
:class="{ 'home-banner__image--inline': !hasBanners }"
|
||||||
|
:src="src"
|
||||||
|
alt=""
|
||||||
|
>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="home-banner__inner">
|
<div class="home-banner__inner">
|
||||||
<h1 class="home-banner__title">虫子智造研发一体化平台</h1>
|
<h1 class="home-banner__title">
|
||||||
<p class="home-banner__subtitle">一站式智能软硬件研发与量产综合服务商</p>
|
虫子智造研发一体化平台
|
||||||
|
</h1>
|
||||||
|
<p class="home-banner__subtitle">
|
||||||
|
一站式智能软硬件研发与量产综合服务商
|
||||||
|
</p>
|
||||||
|
|
||||||
<div class="home-banner__actions">
|
<div class="home-banner__actions">
|
||||||
<button class="home-banner__btn home-banner__btn--primary" type="button" @click="goDemand">
|
<button
|
||||||
需求提报
|
class="home-banner__btn home-banner__btn--primary"
|
||||||
</button>
|
type="button"
|
||||||
<button class="home-banner__btn home-banner__btn--ghost" type="button" @click="goCases">
|
@click="goDemand"
|
||||||
案例中心
|
>
|
||||||
</button>
|
需求提报
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="home-banner__btn home-banner__btn--ghost"
|
||||||
|
type="button"
|
||||||
|
@click="goCases"
|
||||||
|
>
|
||||||
|
案例中心
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="slides.length > 1" class="home-banner__dots" role="tablist" aria-label="头图轮播">
|
<div
|
||||||
<button
|
v-if="slides.length > 1"
|
||||||
v-for="(_, index) in slides"
|
class="home-banner__dots"
|
||||||
:key="index"
|
role="tablist"
|
||||||
class="home-banner__dot"
|
aria-label="头图轮播"
|
||||||
:class="{ 'home-banner__dot--active': index === activeIndex }"
|
>
|
||||||
type="button"
|
<button
|
||||||
role="tab"
|
v-for="(_, index) in slides"
|
||||||
:aria-selected="index === activeIndex"
|
:key="index"
|
||||||
@click="goTo(index)"
|
class="home-banner__dot"
|
||||||
/>
|
:class="{ 'home-banner__dot--active': index === activeIndex }"
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
:aria-selected="index === activeIndex"
|
||||||
|
@click="goTo(index)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.home-banner {
|
.home-banner {
|
||||||
position: relative;
|
|
||||||
height: 661px;
|
|
||||||
overflow: hidden;
|
|
||||||
background: @home-banner-bg;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
padding: @spacing-lg @home-gutter 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
|
@media (max-width: 1600px) {
|
||||||
|
padding: @spacing-lg @spacing-lg 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stage {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: @home-content;
|
||||||
|
height: 420px;
|
||||||
|
margin: 0 auto;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: @radius-base;
|
||||||
|
background: @home-banner-bg;
|
||||||
|
}
|
||||||
|
|
||||||
&__carousel {
|
&__carousel {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -138,15 +171,14 @@ onBeforeUnmount(stopAutoplay)
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
|
|
||||||
// 内置插图:按设计稿定位与尺寸原样显示,不铺满
|
|
||||||
&--inline {
|
&--inline {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 31px;
|
top: 24px;
|
||||||
right: 269px;
|
right: 48px;
|
||||||
left: auto;
|
left: auto;
|
||||||
bottom: auto;
|
bottom: auto;
|
||||||
width: 723px;
|
width: 420px;
|
||||||
height: 598px;
|
height: 348px;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
@@ -156,50 +188,48 @@ onBeforeUnmount(stopAutoplay)
|
|||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: @container-width;
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
margin: 0 auto;
|
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__title {
|
&__title {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 184px;
|
top: 96px;
|
||||||
left: 150px;
|
left: 64px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: @banner-title-color;
|
color: @banner-title-color;
|
||||||
font-size: @font-size-hero-lg;
|
font-size: @font-size-hero;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 87px;
|
line-height: 1.25;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__subtitle {
|
&__subtitle {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 287px;
|
top: 168px;
|
||||||
left: 150px;
|
left: 64px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: @banner-title-color;
|
color: @banner-title-color;
|
||||||
font-size: 24px;
|
font-size: @font-size-lg;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 35px;
|
line-height: 1.4;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__actions {
|
&__actions {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 392px;
|
top: 232px;
|
||||||
left: 150px;
|
left: 64px;
|
||||||
.flex(row, flex-start, center);
|
.flex(row, flex-start, center);
|
||||||
gap: 20px;
|
gap: @spacing-base;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__btn {
|
&__btn {
|
||||||
width: 183px;
|
width: 140px;
|
||||||
height: 55px;
|
height: 44px;
|
||||||
border-radius: 8px;
|
border-radius: @radius-base;
|
||||||
font-size: 18px;
|
font-size: @font-size-base;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -220,7 +250,7 @@ onBeforeUnmount(stopAutoplay)
|
|||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
border-radius: 8px;
|
border-radius: @radius-base;
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
background: linear-gradient(135deg, @btn-gradient-from 0%, @btn-gradient-to 100%);
|
background: linear-gradient(135deg, @btn-gradient-from 0%, @btn-gradient-to 100%);
|
||||||
-webkit-mask:
|
-webkit-mask:
|
||||||
@@ -238,16 +268,17 @@ onBeforeUnmount(stopAutoplay)
|
|||||||
|
|
||||||
&__dots {
|
&__dots {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 929px;
|
left: 50%;
|
||||||
top: 594px;
|
bottom: @spacing-base;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
.flex(row, flex-start, center);
|
.flex(row, center, center);
|
||||||
gap: 13px;
|
gap: @spacing-sm;
|
||||||
|
transform: translateX(-50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
&__dot {
|
&__dot {
|
||||||
width: 5px;
|
width: 6px;
|
||||||
height: 5px;
|
height: 6px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
@@ -255,7 +286,7 @@ onBeforeUnmount(stopAutoplay)
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
&--active {
|
&--active {
|
||||||
width: 44px;
|
width: 28px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
background: @home-banner-dot;
|
background: @home-banner-dot;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { BusinessType } from '@/types/api'
|
import type { BusinessType } from '@/types/api'
|
||||||
import type { BusinessLine } from '@/types/business/home'
|
import type { BusinessLine } from '@/types/business/home'
|
||||||
|
import defaultCover from '@/assets/home/chahua.png'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
lines: BusinessLine[]
|
lines: BusinessLine[]
|
||||||
@@ -9,6 +10,16 @@ interface Props {
|
|||||||
defineProps<Props>()
|
defineProps<Props>()
|
||||||
|
|
||||||
const FALLBACK_ACTIVE: BusinessType[] = ['internet']
|
const FALLBACK_ACTIVE: BusinessType[] = ['internet']
|
||||||
|
|
||||||
|
function isImageUrl(value: string): boolean {
|
||||||
|
return /^(https?:)?\/\//i.test(value) || value.startsWith('/') || value.startsWith('data:')
|
||||||
|
}
|
||||||
|
|
||||||
|
function coverSrc(line: BusinessLine): string {
|
||||||
|
const remote = (line.cover || line.icon).trim()
|
||||||
|
if (remote && isImageUrl(remote)) return remote
|
||||||
|
return defaultCover
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -25,6 +36,11 @@ const FALLBACK_ACTIVE: BusinessType[] = ['internet']
|
|||||||
'home-business__card--active': FALLBACK_ACTIVE.includes(line.id),
|
'home-business__card--active': FALLBACK_ACTIVE.includes(line.id),
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
|
<img
|
||||||
|
class="home-business__cover"
|
||||||
|
:src="coverSrc(line)"
|
||||||
|
:alt="line.name"
|
||||||
|
>
|
||||||
<span class="home-business__check" aria-hidden="true" />
|
<span class="home-business__check" aria-hidden="true" />
|
||||||
<h4 class="home-business__name">
|
<h4 class="home-business__name">
|
||||||
{{ line.name }}
|
{{ line.name }}
|
||||||
@@ -39,61 +55,73 @@ const FALLBACK_ACTIVE: BusinessType[] = ['internet']
|
|||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.home-business {
|
.home-business {
|
||||||
padding: 44px 70px 54px;
|
padding: @spacing-xl;
|
||||||
border: 1px solid @home-section-border;
|
border: none;
|
||||||
border-radius: @radius-lg;
|
border-radius: @radius-base;
|
||||||
background: @white;
|
background: @white;
|
||||||
|
|
||||||
&__title {
|
&__title {
|
||||||
margin: 0 0 47px;
|
margin: 0 0 @spacing-lg;
|
||||||
font-size: @font-size-xxl;
|
font-size: @font-size-xl;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
color: @home-ink;
|
color: @home-ink;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__grid {
|
&__grid {
|
||||||
display: flex;
|
display: grid;
|
||||||
gap: 22px;
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: @spacing-base;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__card {
|
&__card {
|
||||||
position: relative;
|
position: relative;
|
||||||
.flex(column, flex-start, flex-start);
|
.flex(column, flex-start, flex-start);
|
||||||
flex: 0 1 368px;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
height: 206px;
|
min-height: 168px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 0 20px;
|
padding: @spacing-lg;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid @home-business-border;
|
border: 1px solid @border-color;
|
||||||
border-radius: @radius-base;
|
border-radius: @radius-base;
|
||||||
background: @white;
|
background: @white;
|
||||||
transition:
|
transition:
|
||||||
transform 0.2s,
|
transform 0.2s,
|
||||||
box-shadow 0.2s;
|
box-shadow 0.2s,
|
||||||
|
border-color 0.2s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
border-color: @primary-color;
|
||||||
box-shadow: @shadow-base;
|
box-shadow: @shadow-base;
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__cover {
|
||||||
|
display: block;
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
object-fit: contain;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
&__check {
|
&__check {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 27px;
|
top: @spacing-base;
|
||||||
right: 37px;
|
right: @spacing-base;
|
||||||
|
z-index: 1;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 31px;
|
width: 20px;
|
||||||
height: 31px;
|
height: 20px;
|
||||||
border: 1px solid @home-ring;
|
border: 1px solid @home-ring;
|
||||||
border-radius: @radius-round;
|
border-radius: @radius-round;
|
||||||
|
background: @white;
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
width: 21px;
|
width: 12px;
|
||||||
height: 21px;
|
height: 12px;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
border-radius: @radius-round;
|
border-radius: @radius-round;
|
||||||
background: @white;
|
background: @white;
|
||||||
@@ -102,37 +130,43 @@ const FALLBACK_ACTIVE: BusinessType[] = ['internet']
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__name {
|
&__name {
|
||||||
margin: 104px 0 0;
|
margin: @spacing-base 0 0;
|
||||||
font-size: @font-size-title;
|
padding-right: @spacing-xl;
|
||||||
|
font-size: @font-size-lg;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 41px;
|
line-height: 1.4;
|
||||||
color: @home-ink;
|
color: @home-ink;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__desc {
|
&__desc {
|
||||||
margin: 4px 0 0;
|
margin: @spacing-xs 0 0;
|
||||||
font-size: @font-size-md;
|
padding-right: @spacing-xl;
|
||||||
font-weight: 500;
|
font-size: @font-size-sm;
|
||||||
line-height: 23px;
|
font-weight: 400;
|
||||||
color: @home-ink;
|
line-height: 1.5;
|
||||||
.multi-ellipsis(1);
|
color: @text-color-secondary;
|
||||||
|
.multi-ellipsis(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
&__card--active {
|
&__card--active {
|
||||||
border-color: @primary-strong;
|
border-color: @primary-color;
|
||||||
background: @primary-strong;
|
background: @filter-selected-bg;
|
||||||
|
|
||||||
.home-business__check {
|
.home-business__check {
|
||||||
border-color: @white;
|
border-color: @primary-color;
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
background: @primary-color;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-business__name,
|
.home-business__name {
|
||||||
|
color: @primary-strong;
|
||||||
|
}
|
||||||
|
|
||||||
.home-business__desc {
|
.home-business__desc {
|
||||||
color: @white;
|
color: @text-color-secondary;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,9 +33,7 @@ const visibleCases = computed<Case[]>(() => props.cases.slice(0, 4))
|
|||||||
<template>
|
<template>
|
||||||
<section class="home-cases">
|
<section class="home-cases">
|
||||||
<div class="home-cases__head">
|
<div class="home-cases__head">
|
||||||
<h3 class="home-cases__title">
|
<h3 class="home-cases__title">案例库精选</h3>
|
||||||
案例库精选
|
|
||||||
</h3>
|
|
||||||
<div class="home-cases__tabs">
|
<div class="home-cases__tabs">
|
||||||
<button
|
<button
|
||||||
v-for="cat in categories"
|
v-for="cat in categories"
|
||||||
@@ -60,7 +58,12 @@ const visibleCases = computed<Case[]>(() => props.cases.slice(0, 4))
|
|||||||
@click="$router.push(`/cases/${item.id}`)"
|
@click="$router.push(`/cases/${item.id}`)"
|
||||||
>
|
>
|
||||||
<div class="home-cases__cover">
|
<div class="home-cases__cover">
|
||||||
<img v-if="item.cover" v-lazy="item.cover" class="home-cases__img" :alt="item.title">
|
<img
|
||||||
|
v-if="item.cover"
|
||||||
|
v-lazy="item.cover"
|
||||||
|
class="home-cases__img"
|
||||||
|
:alt="item.title"
|
||||||
|
/>
|
||||||
<div v-else class="home-cases__img home-cases__img--placeholder" />
|
<div v-else class="home-cases__img home-cases__img--placeholder" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -85,26 +88,26 @@ const visibleCases = computed<Case[]>(() => props.cases.slice(0, 4))
|
|||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.home-cases {
|
.home-cases {
|
||||||
padding: 40px 66px 60px;
|
padding: @spacing-xl;
|
||||||
border: 1px solid @home-section-border;
|
border: none;
|
||||||
border-radius: @radius-lg;
|
border-radius: @radius-base;
|
||||||
background: @white;
|
background: @white;
|
||||||
|
|
||||||
&__head {
|
&__head {
|
||||||
.flex(row, flex-start);
|
.flex(row, flex-start, center);
|
||||||
gap: 24px;
|
gap: @spacing-lg;
|
||||||
margin-bottom: 47px;
|
margin-bottom: @spacing-lg;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__title {
|
&__title {
|
||||||
font-size: @font-size-xxl;
|
font-size: @font-size-xl;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
color: @home-ink;
|
color: @home-ink;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__tabs {
|
&__tabs {
|
||||||
.flex(row, center);
|
.flex(row, center);
|
||||||
gap: 36px;
|
gap: @spacing-lg;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__tab {
|
&__tab {
|
||||||
@@ -140,25 +143,29 @@ const visibleCases = computed<Case[]>(() => props.cases.slice(0, 4))
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__grid {
|
&__grid {
|
||||||
display: flex;
|
display: grid;
|
||||||
gap: 24px;
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: @spacing-lg;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__card {
|
&__card {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
flex: 0 1 368px;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
height: 248px;
|
min-height: 220px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
border: 1px solid @home-case-border;
|
border: 1px solid @border-color;
|
||||||
border-radius: @radius-base;
|
border-radius: @radius-base;
|
||||||
background: @white;
|
background: @white;
|
||||||
box-shadow: 0 4px 10px fade(@black, 8%);
|
box-shadow: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: transform 0.2s, box-shadow 0.2s;
|
transition:
|
||||||
|
transform 0.2s,
|
||||||
|
box-shadow 0.2s,
|
||||||
|
border-color 0.2s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
box-shadow: 0 6px 14px fade(@black, 12%);
|
border-color: @primary-color;
|
||||||
|
box-shadow: @shadow-base;
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
|
|
||||||
.home-cases__view {
|
.home-cases__view {
|
||||||
@@ -184,7 +191,7 @@ const visibleCases = computed<Case[]>(() => props.cases.slice(0, 4))
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__body {
|
&__body {
|
||||||
padding: 0 20px;
|
padding: 0 20px 10px 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__name {
|
&__name {
|
||||||
@@ -200,7 +207,7 @@ const visibleCases = computed<Case[]>(() => props.cases.slice(0, 4))
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr 1fr;
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
gap: 0 @spacing-lg;
|
gap: 0 @spacing-lg;
|
||||||
margin-top: 43px;
|
margin-top: 20px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
color: @home-body;
|
color: @home-body;
|
||||||
|
|||||||
@@ -173,15 +173,15 @@ function onMore(): void {
|
|||||||
position: relative;
|
position: relative;
|
||||||
.flex(column, flex-start, stretch);
|
.flex(column, flex-start, stretch);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
flex: 0 0 529px;
|
flex: 0 0 420px;
|
||||||
width: 529px;
|
width: 420px;
|
||||||
height: 245px;
|
min-height: 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
padding: 18px 40px 28px;
|
padding: @spacing-lg;
|
||||||
border: 1px solid @home-card-border;
|
border: none;
|
||||||
border-radius: @radius-lg;
|
border-radius: @radius-base;
|
||||||
background: @white;
|
background: @white;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
@@ -190,22 +190,22 @@ function onMore(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__title {
|
&__title {
|
||||||
font-size: @font-size-xxl;
|
font-size: @font-size-xl;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
line-height: 52px;
|
line-height: 1.4;
|
||||||
color: @home-ink;
|
color: @home-ink;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__list {
|
&__list {
|
||||||
.flex(row, flex-start, flex-start);
|
.flex(row, flex-start, flex-start);
|
||||||
gap: 30px;
|
gap: @spacing-base;
|
||||||
margin-top: 17px;
|
margin-top: @spacing-base;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__item {
|
&__item {
|
||||||
.flex(column, flex-start, center);
|
.flex(column, flex-start, center);
|
||||||
gap: 8px;
|
gap: @spacing-sm;
|
||||||
flex: none;
|
flex: none;
|
||||||
transition: transform 0.2s;
|
transition: transform 0.2s;
|
||||||
|
|
||||||
@@ -216,14 +216,14 @@ function onMore(): void {
|
|||||||
|
|
||||||
&__tile {
|
&__tile {
|
||||||
display: block;
|
display: block;
|
||||||
width: 97px;
|
width: 72px;
|
||||||
height: 97px;
|
height: 72px;
|
||||||
border-radius: @radius-base;
|
border-radius: @radius-base;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__label {
|
&__label {
|
||||||
font-size: @font-size-lg;
|
font-size: @font-size-sm;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: @home-ink;
|
color: @home-ink;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -231,15 +231,16 @@ function onMore(): void {
|
|||||||
|
|
||||||
&__more {
|
&__more {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 120px;
|
top: 50%;
|
||||||
right: 25px;
|
right: @spacing-base;
|
||||||
width: 32px;
|
width: 28px;
|
||||||
height: 32px;
|
height: 28px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #c9cdd4;
|
color: @text-color-disabled;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transform: translateY(-50%);
|
||||||
transition: color 0.2s;
|
transition: color 0.2s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
|||||||
@@ -27,10 +27,10 @@ defineProps<Props>()
|
|||||||
.home-contents {
|
.home-contents {
|
||||||
&__block {
|
&__block {
|
||||||
margin-bottom: @spacing-base;
|
margin-bottom: @spacing-base;
|
||||||
padding: 40px 45px 0;
|
padding: @spacing-xl;
|
||||||
border-radius: @radius-lg;
|
border-radius: @radius-base;
|
||||||
background: @white;
|
background: @white;
|
||||||
min-height: 593px;
|
min-height: 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
||||||
&:last-child {
|
&:last-child {
|
||||||
@@ -39,10 +39,10 @@ defineProps<Props>()
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__title {
|
&__title {
|
||||||
margin: 0 0 301px;
|
margin: 0 0 @spacing-lg;
|
||||||
font-size: 36px;
|
font-size: @font-size-xl;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
line-height: 52px;
|
line-height: 1.4;
|
||||||
color: @home-ink;
|
color: @home-ink;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,40 +43,41 @@ function isImageIcon(icon?: string): boolean {
|
|||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.home-service-types {
|
.home-service-types {
|
||||||
margin-bottom: 20px;
|
margin-bottom: @spacing-base;
|
||||||
padding: 24px 42px 0;
|
padding: @spacing-lg @spacing-xl;
|
||||||
border-radius: @radius-lg;
|
border-radius: @radius-base;
|
||||||
background: @white;
|
background: @white;
|
||||||
height: 287px;
|
min-height: 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
||||||
&__title {
|
&__title {
|
||||||
margin: 0 0 132px;
|
margin: 0 0 @spacing-lg;
|
||||||
font-size: 36px;
|
font-size: @font-size-xl;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
line-height: 52px;
|
line-height: 1.4;
|
||||||
color: @home-ink;
|
color: @home-ink;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__list {
|
&__list {
|
||||||
.flex(row, flex-start);
|
.flex(row, flex-start);
|
||||||
gap: 0;
|
gap: @spacing-base;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__item {
|
&__item {
|
||||||
.flex(column, center);
|
.flex(column, center);
|
||||||
width: 187px;
|
width: 120px;
|
||||||
gap: 47px;
|
gap: @spacing-base;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__icon {
|
&__icon {
|
||||||
.flex(row, center);
|
.flex(row, center);
|
||||||
width: 68px;
|
width: 48px;
|
||||||
height: 68px;
|
height: 48px;
|
||||||
border-radius: @radius-round;
|
border-radius: @radius-round;
|
||||||
background: @primary-light;
|
background: @primary-light;
|
||||||
color: @white;
|
color: @white;
|
||||||
font-size: 24px;
|
font-size: @font-size-lg;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
|
|||||||
@@ -30,30 +30,30 @@ const items = [
|
|||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.home-stats {
|
.home-stats {
|
||||||
.flex(row, flex-end);
|
.flex(row, flex-end);
|
||||||
gap: 30px;
|
gap: @spacing-base;
|
||||||
|
|
||||||
&__item {
|
&__item {
|
||||||
.flex(column, center, center);
|
.flex(column, center, center);
|
||||||
gap: 0;
|
gap: 2px;
|
||||||
width: 112px;
|
width: 88px;
|
||||||
height: 112px;
|
height: 88px;
|
||||||
min-width: 112px;
|
min-width: 88px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border-radius: @radius-sm;
|
border-radius: @radius-base;
|
||||||
background: @primary-strong;
|
background: @filter-selected-bg;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__value {
|
&__value {
|
||||||
font-size: 48px;
|
font-size: @font-size-title;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
color: @white;
|
color: @primary-strong;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__label {
|
&__label {
|
||||||
font-size: @font-size-base;
|
font-size: @font-size-sm;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: @white;
|
color: @text-color-secondary;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -37,24 +37,24 @@ const greeting = computed(() => {
|
|||||||
&__greeting {
|
&__greeting {
|
||||||
.flex(row, flex-start);
|
.flex(row, flex-start);
|
||||||
gap: @spacing-sm;
|
gap: @spacing-sm;
|
||||||
font-size: 52px;
|
font-size: @font-size-title;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
color: @home-welcome;
|
color: @home-welcome;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__wave {
|
&__wave {
|
||||||
.flex(row, center);
|
.flex(row, center);
|
||||||
width: 44px;
|
width: 32px;
|
||||||
height: 44px;
|
height: 32px;
|
||||||
border-radius: @radius-round;
|
border-radius: @radius-round;
|
||||||
background: fade(@black, 4%);
|
background: fade(@black, 4%);
|
||||||
font-size: @font-size-xl;
|
font-size: @font-size-lg;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__summary {
|
&__summary {
|
||||||
font-size: 24px;
|
font-size: @font-size-base;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: @home-welcome;
|
color: @text-color-secondary;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -58,14 +58,16 @@ onMounted(load)
|
|||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.business-detail {
|
.business-detail {
|
||||||
padding-top: 32px;
|
min-height: 100%;
|
||||||
padding-bottom: @spacing-xxl;
|
padding-top: @spacing-lg;
|
||||||
|
padding-bottom: @spacing-page-bottom;
|
||||||
|
background: @bg-color;
|
||||||
|
|
||||||
&__breadcrumb {
|
&__breadcrumb {
|
||||||
margin-bottom: 49px;
|
margin-bottom: @spacing-lg;
|
||||||
font-size: 24px;
|
font-size: @font-size-base;
|
||||||
font-weight: 500;
|
font-weight: 400;
|
||||||
line-height: 37px;
|
line-height: 1.5;
|
||||||
color: @home-breadcrumb;
|
color: @home-breadcrumb;
|
||||||
|
|
||||||
.link {
|
.link {
|
||||||
@@ -74,18 +76,19 @@ onMounted(load)
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__hero {
|
&__hero {
|
||||||
.flex(column, flex-start);
|
.flex(column, flex-start, flex-start);
|
||||||
|
justify-content: center;
|
||||||
margin-bottom: @spacing-base;
|
margin-bottom: @spacing-base;
|
||||||
padding: 129px 29px 0;
|
padding: @spacing-xl;
|
||||||
border-radius: @radius-lg;
|
border-radius: @radius-base;
|
||||||
background: @home-detail-hero-bg;
|
background: @home-detail-hero-bg;
|
||||||
height: 219px;
|
min-height: 140px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__loading {
|
&__loading {
|
||||||
padding: 80px 0;
|
padding: @spacing-xxl 0;
|
||||||
font-size: 16px;
|
font-size: @font-size-base;
|
||||||
color: @text-color-secondary;
|
color: @text-color-secondary;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,23 +98,23 @@ onMounted(load)
|
|||||||
|
|
||||||
&__intro {
|
&__intro {
|
||||||
.flex(column, flex-start);
|
.flex(column, flex-start);
|
||||||
gap: 8px;
|
gap: @spacing-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__name {
|
&__name {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 26px;
|
font-size: @font-size-xl;
|
||||||
font-weight: 400;
|
font-weight: 600;
|
||||||
line-height: 33px;
|
line-height: 1.3;
|
||||||
color: @home-body;
|
color: @home-body;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__slogan {
|
&__slogan {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 14px;
|
font-size: @font-size-base;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
line-height: 18px;
|
line-height: 1.5;
|
||||||
color: @home-body;
|
color: @text-color-secondary;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__cta {
|
&__cta {
|
||||||
|
|||||||
@@ -53,25 +53,25 @@ onMounted(() => {
|
|||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.home {
|
.home {
|
||||||
.flex(column, flex-start);
|
.flex(column, flex-start);
|
||||||
gap: 20px;
|
gap: @spacing-lg;
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
padding-bottom: @spacing-xxl;
|
padding-bottom: @spacing-page-bottom;
|
||||||
|
background: @bg-color;
|
||||||
|
|
||||||
&__dashboard {
|
&__dashboard {
|
||||||
.flex(row, space-between, stretch);
|
.flex(row, space-between, stretch);
|
||||||
gap: 60px;
|
gap: @spacing-lg;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__dashboard-main {
|
&__dashboard-main {
|
||||||
.flex(row, space-between, center);
|
.flex(row, space-between, center);
|
||||||
flex: 0 0 1091px;
|
flex: 1 1 auto;
|
||||||
width: 1091px;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
gap: @spacing-lg;
|
gap: @spacing-lg;
|
||||||
padding: 0 59px 0 105px;
|
padding: @spacing-lg @spacing-xl;
|
||||||
border: 1px solid @home-card-border;
|
border: none;
|
||||||
border-radius: @radius-lg;
|
border-radius: @radius-base;
|
||||||
background: @white;
|
background: @white;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,70 +67,58 @@ const localAgree = computed({
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
// 设计稿 #36(账号密码登录)纵向坐标:
|
|
||||||
// 账号标签 457 → 密码标签 585(差 128)
|
|
||||||
// 密码输入框底 ~648 → 勾选行 641 区间 → 登录按钮文字 688
|
|
||||||
// 表单左边界与 title 内缩对齐:1032.5 - 982.5(title 层中心对齐后) ≈ 0,
|
|
||||||
// 实测 title padding-left 17px 抵消 693/714 的差,故表单不再额外内缩。
|
|
||||||
.login-form {
|
.login-form {
|
||||||
.flex(column, flex-start, stretch);
|
.flex(column, flex-start, stretch);
|
||||||
margin: 26px 0 0;
|
gap: @spacing-lg;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
margin-top: @spacing-xl;
|
||||||
|
|
||||||
&__field {
|
&__field {
|
||||||
.flex(column, flex-start, stretch);
|
.flex(column, flex-start, stretch);
|
||||||
// 标签底 474 → 输入框顶 480,间距 6px
|
gap: @spacing-sm;
|
||||||
gap: 6px;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 第一个字段底(480+40=520)→ 第二个字段标签顶 585,间距 65px
|
|
||||||
&__field + &__field {
|
|
||||||
margin-top: 65px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__label {
|
&__label {
|
||||||
font-size: 13px;
|
font-size: @font-size-sm;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 17px;
|
line-height: 1.4;
|
||||||
color: #1f2937;
|
color: @heading-color;
|
||||||
text-align: center;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__input {
|
&__input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 40px;
|
height: 44px;
|
||||||
padding: 0 12px;
|
padding: 0 @spacing-base;
|
||||||
border: 1px solid #dde1e7;
|
border: 1px solid @border-color;
|
||||||
border-radius: @radius-sm;
|
border-radius: @radius-base;
|
||||||
background: @white;
|
background: @bg-color-light;
|
||||||
font-size: 13px;
|
font-size: @font-size-base;
|
||||||
color: #1f2937;
|
color: @heading-color;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color 0.2s;
|
transition: border-color 0.2s, background 0.2s;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
border-color: @primary-strong;
|
border-color: @primary-strong;
|
||||||
|
background: @white;
|
||||||
}
|
}
|
||||||
|
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
color: #1f2937;
|
color: @text-color-secondary;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设计稿 #36:勾选框 (1042.2,642) 7.5×14,文字 12px 起于 x=1059。
|
|
||||||
// 注:设计稿勾选标注 y=642 与密码输入框底 648 重叠,二者无法同时满足,
|
|
||||||
// 这里按视觉取勾选行落在输入框下方 8px。
|
|
||||||
&__agree {
|
&__agree {
|
||||||
.flex(row, flex-start, center);
|
.flex(row, flex-start, center);
|
||||||
gap: 8px;
|
gap: @spacing-sm;
|
||||||
margin-top: 8px;
|
margin: 0;
|
||||||
font-size: 12px;
|
font-size: @font-size-sm;
|
||||||
font-weight: 400;
|
line-height: 1.5;
|
||||||
line-height: 16px;
|
color: @text-color-secondary;
|
||||||
color: #6b7280;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
input {
|
input {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -146,17 +134,15 @@ const localAgree = computed({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设计稿 #36:按钮文字 (1352,688) 54×20 居中,按钮盒约 693×46
|
|
||||||
&__submit {
|
&__submit {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 46px;
|
height: 44px;
|
||||||
// 勾选行底 657 → 按钮顶 674
|
margin: 0;
|
||||||
margin-top: 17px;
|
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: @radius-sm;
|
border-radius: @radius-base;
|
||||||
background: @primary-strong;
|
background: @primary-strong;
|
||||||
color: @white;
|
color: @white;
|
||||||
font-size: 15px;
|
font-size: @font-size-base;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.2s;
|
transition: background 0.2s;
|
||||||
|
|||||||
@@ -85,65 +85,58 @@ function onSubmit(): void {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
// 设计稿 #38(手机验证码登录)纵向坐标:
|
|
||||||
// 手机号标签 438 → 验证码标签 520(差 82)
|
|
||||||
// 验证码输入框底 ~588 → 勾选行 622 → 登录按钮文字 669
|
|
||||||
.sms-login {
|
.sms-login {
|
||||||
.flex(column, flex-start, stretch);
|
.flex(column, flex-start, stretch);
|
||||||
margin: 26px 0 0;
|
gap: @spacing-lg;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
margin-top: @spacing-xl;
|
||||||
|
|
||||||
&__field {
|
&__field {
|
||||||
.flex(column, flex-start, stretch);
|
.flex(column, flex-start, stretch);
|
||||||
gap: 6px;
|
gap: @spacing-sm;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 第一个字段底(457+40=497... 按标签 438+17+6+40=501)→ 第二标签 520,间距 19px
|
|
||||||
&__field + &__field {
|
|
||||||
margin-top: 19px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__label {
|
&__label {
|
||||||
font-size: 13px;
|
font-size: @font-size-sm;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 17px;
|
line-height: 1.4;
|
||||||
color: #1f2937;
|
color: @heading-color;
|
||||||
text-align: center;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__input {
|
&__input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 40px;
|
height: 44px;
|
||||||
padding: 0 12px;
|
padding: 0 @spacing-base;
|
||||||
border: 1px solid #dde1e7;
|
border: 1px solid @border-color;
|
||||||
border-radius: @radius-sm;
|
border-radius: @radius-base;
|
||||||
background: @white;
|
background: @bg-color-light;
|
||||||
font-size: 13px;
|
font-size: @font-size-base;
|
||||||
color: #1f2937;
|
color: @heading-color;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color 0.2s;
|
transition: border-color 0.2s, background 0.2s;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
border-color: @primary-strong;
|
border-color: @primary-strong;
|
||||||
|
background: @white;
|
||||||
}
|
}
|
||||||
|
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
color: #1f2937;
|
color: @text-color-secondary;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设计稿 #38:勾选框 (992.2,623) 7.5×14,勾选文字 622
|
|
||||||
&__agree {
|
&__agree {
|
||||||
.flex(row, flex-start, center);
|
.flex(row, flex-start, center);
|
||||||
gap: 8px;
|
gap: @spacing-sm;
|
||||||
margin-top: 14px;
|
margin: 0;
|
||||||
font-size: 12px;
|
font-size: @font-size-sm;
|
||||||
font-weight: 400;
|
line-height: 1.5;
|
||||||
line-height: 16px;
|
color: @text-color-secondary;
|
||||||
color: #6b7280;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
input {
|
input {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -159,16 +152,15 @@ function onSubmit(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设计稿 #38:按钮文字 (1302,669) 54×20
|
|
||||||
&__submit {
|
&__submit {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 46px;
|
height: 44px;
|
||||||
margin-top: 17px;
|
margin: 0;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: @radius-sm;
|
border-radius: @radius-base;
|
||||||
background: @primary-strong;
|
background: @primary-strong;
|
||||||
color: @white;
|
color: @white;
|
||||||
font-size: 15px;
|
font-size: @font-size-base;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.2s;
|
transition: background 0.2s;
|
||||||
|
|||||||
@@ -144,26 +144,31 @@ async function onGetCode(target: string): Promise<void> {
|
|||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.auth-page {
|
.auth-page {
|
||||||
// 设计稿 #36:两个 tab 顶 406,字号 14px,选中下划线 2px 位于 429。
|
|
||||||
// tab 文字底 425,下划线顶 429 → padding-bottom 6px。
|
|
||||||
// 上间距 26px:副标题底 380 → tab 文字顶 406。
|
|
||||||
&__tabs {
|
&__tabs {
|
||||||
.flex(row, flex-start, center);
|
.flex(row, flex-start, center);
|
||||||
gap: 24px;
|
gap: @spacing-xl;
|
||||||
margin: 26px 0 0;
|
width: 100%;
|
||||||
|
margin-top: @spacing-xl;
|
||||||
|
border-bottom: 1px solid @border-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__tab {
|
&__tab {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: 0 0 6px;
|
padding: 0 0 @spacing-sm;
|
||||||
|
margin-bottom: -1px;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
font-size: 14px;
|
font-size: @font-size-base;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: #4b5563;
|
line-height: 1.4;
|
||||||
|
color: @text-color-secondary;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: color 0.2s;
|
transition: color 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: @heading-color;
|
||||||
|
}
|
||||||
|
|
||||||
&.is-active {
|
&.is-active {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: @primary-strong;
|
color: @primary-strong;
|
||||||
@@ -175,24 +180,23 @@ async function onGetCode(target: string): Promise<void> {
|
|||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
height: 2px;
|
height: 2px;
|
||||||
|
border-radius: 1px;
|
||||||
background: @primary-strong;
|
background: @primary-strong;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设计稿 #36:「没有账号?」(1325,774)、「立即注册」(1384.5,774) 12px。
|
|
||||||
// 「找回密码」为设计稿外的补充入口,按同样式接在分隔符后。
|
|
||||||
&__switch {
|
&__switch {
|
||||||
align-self: center;
|
align-self: center;
|
||||||
margin: 53px 0 0;
|
margin: @spacing-xl 0 0;
|
||||||
font-size: 12px;
|
font-size: @font-size-sm;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
line-height: 16px;
|
line-height: 1.5;
|
||||||
color: #6b7280;
|
color: @text-color-secondary;
|
||||||
|
|
||||||
&-sep {
|
&-sep {
|
||||||
margin: 0 8px;
|
margin: 0 @spacing-sm;
|
||||||
color: #d0d5dd;
|
color: @border-color;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ onBeforeUnmount(() => {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
border: 1px solid #dde1e7;
|
border: 1px solid @border-color;
|
||||||
border-radius: @radius-sm;
|
border-radius: @radius-sm;
|
||||||
background: @white;
|
background: @white;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ onBeforeUnmount(() => {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
border: 1px solid #dde1e7;
|
border: 1px solid @border-color;
|
||||||
border-radius: @radius-sm;
|
border-radius: @radius-sm;
|
||||||
background: @white;
|
background: @white;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|||||||
@@ -7,12 +7,18 @@ interface Props {
|
|||||||
|
|
||||||
defineProps<Props>()
|
defineProps<Props>()
|
||||||
|
|
||||||
const emit = defineEmits<{ view: [id: string] }>()
|
const emit = defineEmits<{ view: [message: Message] }>()
|
||||||
|
|
||||||
const emojiMap: Record<MessageType, string> = {
|
const LABEL_MAP: Record<MessageType, string> = {
|
||||||
在线客服: '💬',
|
在线客服: '客',
|
||||||
状态通知: '📢',
|
状态通知: '态',
|
||||||
系统通知: '🔔',
|
系统通知: '系',
|
||||||
|
}
|
||||||
|
|
||||||
|
const TONE_MAP: Record<MessageType, string> = {
|
||||||
|
在线客服: 'is-service',
|
||||||
|
状态通知: 'is-status',
|
||||||
|
系统通知: 'is-system',
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -22,94 +28,134 @@ const emojiMap: Record<MessageType, string> = {
|
|||||||
:class="{ 'is-unread': !message.isRead }"
|
:class="{ 'is-unread': !message.isRead }"
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
@click="emit('view', message.id)"
|
@click="emit('view', message)"
|
||||||
@keydown.enter="emit('view', message.id)"
|
@keydown.enter="emit('view', message)"
|
||||||
>
|
>
|
||||||
<span class="message-item__emoji">{{ emojiMap[message.type] }}</span>
|
<span
|
||||||
|
class="message-item__icon"
|
||||||
|
:class="TONE_MAP[message.type]"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{{ LABEL_MAP[message.type] }}
|
||||||
|
<span v-if="!message.isRead" class="message-item__dot" aria-label="未读" />
|
||||||
|
</span>
|
||||||
<div class="message-item__body">
|
<div class="message-item__body">
|
||||||
<div class="message-item__head">
|
<div class="message-item__head">
|
||||||
<span class="message-item__title">{{ message.title }}</span>
|
<span class="message-item__title">{{ message.title }}</span>
|
||||||
<span class="message-item__time text-secondary">{{ message.publishTime }}</span>
|
<span class="message-item__time">{{ message.publishTime }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="message-item__summary text-secondary">
|
<p class="message-item__summary">
|
||||||
{{ message.summary }}
|
{{ message.summary }}
|
||||||
</p>
|
</p>
|
||||||
|
<span class="message-item__type">{{ message.type }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span v-if="!message.isRead" class="message-item__dot" aria-label="未读" />
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.message-item {
|
.message-item {
|
||||||
position: relative;
|
.flex(row, flex-start, flex-start);
|
||||||
.flex(row, flex-start);
|
|
||||||
gap: @spacing-base;
|
gap: @spacing-base;
|
||||||
padding: 14px 17px;
|
min-height: 88px;
|
||||||
|
padding: @spacing-base @spacing-lg;
|
||||||
background: @white;
|
background: @white;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
box-sizing: border-box;
|
||||||
transition: background 0.2s;
|
transition: background 0.2s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: @bg-color-light;
|
background: @bg-color-light;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__emoji {
|
&__icon {
|
||||||
|
position: relative;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
.flex(row, center);
|
.flex(row, center);
|
||||||
width: 31px;
|
width: 40px;
|
||||||
height: 31px;
|
height: 40px;
|
||||||
border-radius: @radius-round;
|
border-radius: @radius-round;
|
||||||
background: @message-icon-bg;
|
background: @primary-light;
|
||||||
font-size: 15px;
|
color: @primary-strong;
|
||||||
|
font-size: @font-size-sm;
|
||||||
|
font-weight: 600;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
|
||||||
|
&.is-service {
|
||||||
|
background: @primary-light;
|
||||||
|
color: @primary-strong;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-status {
|
||||||
|
background: fade(@success-color, 12%);
|
||||||
|
color: @success-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-system {
|
||||||
|
background: fade(@warning-color, 14%);
|
||||||
|
color: @warning-color;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__body {
|
&__body {
|
||||||
.flex(column, flex-start, stretch);
|
.flex(column, flex-start, stretch);
|
||||||
gap: 5px;
|
gap: @spacing-xs;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
margin-top: 6px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__head {
|
&__head {
|
||||||
.flex(row, space-between);
|
.flex(row, space-between, flex-start);
|
||||||
gap: @spacing-base;
|
gap: @spacing-base;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__title {
|
&__title {
|
||||||
font-size: 11px;
|
font-size: @font-size-base;
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
|
line-height: 1.4;
|
||||||
color: @heading-color;
|
color: @heading-color;
|
||||||
.ellipsis();
|
.ellipsis();
|
||||||
}
|
}
|
||||||
|
|
||||||
&__time {
|
&__time {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
font-size: 11px;
|
font-size: @font-size-xs;
|
||||||
color: @text-color-disabled;
|
line-height: 1.4;
|
||||||
|
color: @text-color-secondary;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__summary {
|
&__summary {
|
||||||
font-size: 11px;
|
margin: 0;
|
||||||
|
font-size: @font-size-sm;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: @text-color-secondary;
|
||||||
.ellipsis();
|
.ellipsis();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__type {
|
||||||
|
width: fit-content;
|
||||||
|
padding: 0 @spacing-sm;
|
||||||
|
border-radius: @radius-round;
|
||||||
|
background: @bg-color-light;
|
||||||
|
color: @text-color-secondary;
|
||||||
|
font-size: @font-size-xs;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
&__dot {
|
&__dot {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 12px;
|
top: 0;
|
||||||
left: 42px;
|
right: 0;
|
||||||
width: 15px;
|
width: 8px;
|
||||||
height: 15px;
|
height: 8px;
|
||||||
|
border: 2px solid @white;
|
||||||
border-radius: @radius-round;
|
border-radius: @radius-round;
|
||||||
background: @danger-color;
|
background: @message-tabs-badge;
|
||||||
|
box-sizing: content-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.is-unread {
|
&.is-unread .message-item__title {
|
||||||
.message-item__title {
|
font-weight: 600;
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ interface Props {
|
|||||||
|
|
||||||
defineProps<Props>()
|
defineProps<Props>()
|
||||||
|
|
||||||
const emit = defineEmits<{ view: [id: string] }>()
|
const emit = defineEmits<{ view: [message: Message] }>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -31,19 +31,21 @@ const emit = defineEmits<{ view: [id: string] }>()
|
|||||||
.message-list {
|
.message-list {
|
||||||
.flex(column, flex-start, stretch);
|
.flex(column, flex-start, stretch);
|
||||||
gap: 0;
|
gap: 0;
|
||||||
|
margin-top: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
border: 1px solid @border-color;
|
||||||
border-radius: @radius-base;
|
border-radius: @radius-base;
|
||||||
background: @white;
|
background: @white;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
.message-item + .message-item {
|
:deep(.message-item + .message-item) {
|
||||||
border-top: 1px solid @project-divider;
|
border-top: 1px solid @border-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__empty {
|
&__empty {
|
||||||
padding: 40px 0;
|
padding: @spacing-xxl 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 11px;
|
font-size: @font-size-sm;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,16 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { MessageTabCounts } from '@/stores/modules/message'
|
||||||
import type { MessageType } from '@/types/business/message'
|
import type { MessageType } from '@/types/business/message'
|
||||||
|
|
||||||
interface Counts {
|
|
||||||
all: number
|
|
||||||
在线客服: number
|
|
||||||
状态通知: number
|
|
||||||
系统通知: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
active: MessageType | 'all'
|
active: MessageType | 'all'
|
||||||
counts: Counts
|
counts: MessageTabCounts
|
||||||
}
|
}
|
||||||
|
|
||||||
defineProps<Props>()
|
defineProps<Props>()
|
||||||
@@ -24,7 +18,7 @@ const tabs: Array<{ label: string; value: MessageType | 'all' }> = [
|
|||||||
{ label: '系统通知', value: '系统通知' },
|
{ label: '系统通知', value: '系统通知' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function countOf(value: MessageType | 'all', counts: Counts): number {
|
function countOf(value: MessageType | 'all', counts: MessageTabCounts): number {
|
||||||
return counts[value]
|
return counts[value]
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -47,51 +41,58 @@ function countOf(value: MessageType | 'all', counts: Counts): number {
|
|||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.message-tabs {
|
.message-tabs {
|
||||||
.flex(column, flex-start, stretch);
|
.flex(column, flex-start, stretch);
|
||||||
padding: 18px 0;
|
gap: 2px;
|
||||||
min-height: 338px;
|
min-height: 0;
|
||||||
|
padding: @spacing-sm 0;
|
||||||
|
border: 1px solid @border-color;
|
||||||
border-radius: @radius-base;
|
border-radius: @radius-base;
|
||||||
background: @white;
|
background: @white;
|
||||||
box-shadow: none;
|
box-sizing: border-box;
|
||||||
|
|
||||||
&__tab {
|
&__tab {
|
||||||
.flex(row, space-between);
|
.flex(row, space-between, center);
|
||||||
gap: @spacing-sm;
|
gap: @spacing-sm;
|
||||||
height: 32px;
|
height: 40px;
|
||||||
padding: 0 18px 0 25px;
|
margin: 0 @spacing-sm;
|
||||||
border-radius: 0;
|
padding: 0 @spacing-base;
|
||||||
|
border-radius: @radius-sm;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: @text-color;
|
color: @message-tabs-text;
|
||||||
font-size: 11px;
|
font-size: @font-size-sm;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
transition: all 0.2s;
|
line-height: 1.4;
|
||||||
|
transition: color 0.2s, background-color 0.2s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: @primary-strong;
|
color: @primary-strong;
|
||||||
|
background: @bg-color-light;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.is-active {
|
&.is-active {
|
||||||
background: transparent;
|
background: @filter-selected-bg;
|
||||||
color: @primary-strong;
|
color: @primary-strong;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__count {
|
&__count {
|
||||||
min-width: 15px;
|
flex-shrink: 0;
|
||||||
height: 15px;
|
min-width: 18px;
|
||||||
padding: 0 4px;
|
height: 18px;
|
||||||
|
padding: 0 @spacing-xs;
|
||||||
border-radius: @radius-round;
|
border-radius: @radius-round;
|
||||||
background: @danger-color;
|
background: @bg-color-light;
|
||||||
color: @white;
|
color: @text-color-secondary;
|
||||||
font-size: 11px;
|
font-size: @font-size-xs;
|
||||||
line-height: 15px;
|
font-weight: 500;
|
||||||
|
line-height: 18px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.is-active &__count {
|
&__tab.is-active &__count {
|
||||||
background: @danger-color;
|
background: fade(@primary-strong, 12%);
|
||||||
color: @white;
|
color: @primary-strong;
|
||||||
font-weight: 600;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
<!-- 页面:消息详情(MessageDetail) -->
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, watch } from 'vue'
|
import { computed, onMounted, watch } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { AppButton } from '@/components'
|
import { PageHead } from '@/components'
|
||||||
import { useMessageStore } from '@/stores/modules/message'
|
import { useMessageStore } from '@/stores/modules/message'
|
||||||
import { formatFileSize } from '@/utils/format'
|
import { formatFileSize } from '@/utils/format'
|
||||||
|
|
||||||
@@ -36,211 +37,270 @@ onMounted(load)
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="message-detail">
|
<div class="message-detail">
|
||||||
<nav class="message-detail__breadcrumb text-secondary">
|
<PageHead
|
||||||
<RouterLink to="/message" class="link">
|
title="消息详情"
|
||||||
消息中心
|
slogan="查看站内消息完整内容与附件资料"
|
||||||
</RouterLink>
|
/>
|
||||||
<span> > </span>
|
|
||||||
<span>{{ item ? `${item.type}详情` : '消息详情' }}</span>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<template v-if="item">
|
<div class="container message-detail__wrap">
|
||||||
<article class="message-detail__card">
|
<nav class="message-detail__breadcrumb">
|
||||||
<h1 class="message-detail__title">
|
<RouterLink to="/message" class="message-detail__breadcrumb-link">
|
||||||
{{ item.title }}
|
消息中心
|
||||||
</h1>
|
</RouterLink>
|
||||||
<p class="message-detail__time text-secondary">
|
<span class="message-detail__breadcrumb-sep">></span>
|
||||||
发布时间:{{ item.publishTime }}
|
<span class="message-detail__breadcrumb-current">{{ item ? `${item.type}详情` : '消息详情' }}</span>
|
||||||
</p>
|
</nav>
|
||||||
<div class="message-detail__content">
|
|
||||||
{{ item.content }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section v-if="item.attachments.length > 0" class="message-detail__attachments">
|
<template v-if="item">
|
||||||
<h2 class="message-detail__attachments-title">
|
<article class="message-detail__card">
|
||||||
附件({{ item.attachments.length }})
|
<header class="message-detail__head">
|
||||||
</h2>
|
<h1 class="message-detail__title">
|
||||||
<ul class="message-detail__file-list">
|
{{ item.title }}
|
||||||
<li
|
</h1>
|
||||||
v-for="(file, index) in item.attachments"
|
<p class="message-detail__time">
|
||||||
:key="`${file.name}-${index}`"
|
发布时间:{{ item.publishTime }}
|
||||||
class="message-detail__file"
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="message-detail__content">
|
||||||
|
{{ item.content }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section v-if="item.attachments.length > 0" class="message-detail__attachments">
|
||||||
|
<h2 class="message-detail__attachments-title">
|
||||||
|
附件({{ item.attachments.length }})
|
||||||
|
</h2>
|
||||||
|
<ul class="message-detail__file-list">
|
||||||
|
<li
|
||||||
|
v-for="(file, index) in item.attachments"
|
||||||
|
:key="`${file.name}-${index}`"
|
||||||
|
class="message-detail__file"
|
||||||
|
>
|
||||||
|
<div class="message-detail__file-info">
|
||||||
|
<span class="message-detail__file-name" :title="file.name">{{ file.name }}</span>
|
||||||
|
<span class="message-detail__file-meta">
|
||||||
|
{{ formatFileSize(file.size) }} · 上传于{{ file.uploadTime }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="message-detail__file-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="message-detail__file-btn"
|
||||||
|
@click="onPreview"
|
||||||
|
>
|
||||||
|
预览
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="message-detail__file-btn"
|
||||||
|
@click="onDownload"
|
||||||
|
>
|
||||||
|
下载
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="message-detail__actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="message-detail__back"
|
||||||
|
@click="onBack"
|
||||||
>
|
>
|
||||||
<div class="message-detail__file-info">
|
返回
|
||||||
<span class="message-detail__file-name" :title="file.name">{{ file.name }}</span>
|
</button>
|
||||||
<span class="message-detail__file-meta text-secondary">
|
</div>
|
||||||
{{ formatFileSize(file.size) }} · 上传于{{ file.uploadTime }}
|
</article>
|
||||||
</span>
|
</template>
|
||||||
</div>
|
|
||||||
<div class="message-detail__file-actions">
|
|
||||||
<button class="message-detail__file-btn" @click="onPreview">
|
|
||||||
预览
|
|
||||||
</button>
|
|
||||||
<button class="message-detail__file-btn" @click="onDownload">
|
|
||||||
下载
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div class="message-detail__actions">
|
<p v-else class="message-detail__empty">
|
||||||
<AppButton type="primary" @click="onBack">
|
消息不存在
|
||||||
返回
|
</p>
|
||||||
</AppButton>
|
</div>
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<p v-else class="message-detail__empty text-secondary">
|
|
||||||
消息不存在
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.message-detail {
|
.message-detail {
|
||||||
max-width: @container-width;
|
.flex(column, flex-start, stretch);
|
||||||
margin: 0 auto;
|
gap: 0;
|
||||||
padding: @spacing-lg @spacing-base @spacing-xxl;
|
width: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
padding-bottom: @spacing-page-bottom;
|
||||||
|
background: @bg-color;
|
||||||
|
|
||||||
|
&__wrap {
|
||||||
|
padding-top: @spacing-lg;
|
||||||
|
padding-right: @home-gutter;
|
||||||
|
padding-left: @home-gutter;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
@media (max-width: 1600px) {
|
||||||
|
padding-right: @spacing-lg;
|
||||||
|
padding-left: @spacing-lg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&__breadcrumb {
|
&__breadcrumb {
|
||||||
display: flex;
|
.flex(row, flex-start, center);
|
||||||
align-items: center;
|
gap: @spacing-xs;
|
||||||
gap: 4px;
|
margin-bottom: @spacing-base;
|
||||||
margin-bottom: 12px;
|
font-size: @font-size-sm;
|
||||||
font-size: 11px;
|
line-height: 1.5;
|
||||||
color: @text-color-disabled;
|
color: @text-color-secondary;
|
||||||
|
}
|
||||||
.link {
|
|
||||||
color: @text-color-disabled;
|
&__breadcrumb-link {
|
||||||
}
|
|
||||||
|
|
||||||
span:last-child {
|
|
||||||
color: @text-color;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__card {
|
|
||||||
padding: 32px 36px 34px;
|
|
||||||
border-radius: @radius-lg;
|
|
||||||
background: @white;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__title {
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: @heading-color;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__time {
|
|
||||||
margin-top: 11px; // 14 × 0.77
|
|
||||||
font-size: 11px;
|
|
||||||
color: @text-color-disabled;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__content {
|
|
||||||
margin-top: 24px;
|
|
||||||
background: transparent;
|
|
||||||
color: @text-color;
|
|
||||||
font-size: 11px;
|
|
||||||
line-height: 1.9;
|
|
||||||
text-align: center;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__attachments {
|
|
||||||
margin-top: 20px;
|
|
||||||
|
|
||||||
&-title {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 400;
|
|
||||||
color: @text-color-disabled;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__file-list {
|
|
||||||
.flex(column, flex-start, stretch);
|
|
||||||
gap: @spacing-sm;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__file {
|
|
||||||
.flex(row, space-between);
|
|
||||||
gap: @spacing-base;
|
|
||||||
align-items: center;
|
|
||||||
width: fit-content;
|
|
||||||
max-width: 100%;
|
|
||||||
padding: 10px 14px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: @finance-row-bg;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__file-info {
|
|
||||||
.flex(column, flex-start, flex-start);
|
|
||||||
gap: 3px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__file-name {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: @heading-color;
|
|
||||||
.ellipsis();
|
|
||||||
}
|
|
||||||
|
|
||||||
&__file-meta {
|
|
||||||
font-size: 11px;
|
|
||||||
color: @text-color-disabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__file-actions {
|
|
||||||
.flex(row, center);
|
|
||||||
gap: @spacing-sm;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__file-btn {
|
|
||||||
font-size: 11px;
|
|
||||||
color: @text-color-secondary;
|
color: @text-color-secondary;
|
||||||
transition: color 0.2s;
|
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: @primary-strong;
|
color: @primary-strong;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__breadcrumb-current {
|
||||||
|
color: @heading-color;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__card {
|
||||||
|
padding: @spacing-xl;
|
||||||
|
border: 1px solid @border-color;
|
||||||
|
border-radius: @radius-base;
|
||||||
|
background: @white;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__head {
|
||||||
|
padding-bottom: @spacing-base;
|
||||||
|
border-bottom: 1px solid @border-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: @font-size-xl;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: @heading-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__time {
|
||||||
|
margin: @spacing-sm 0 0;
|
||||||
|
font-size: @font-size-sm;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: @text-color-secondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
margin-top: @spacing-lg;
|
||||||
|
color: @text-color;
|
||||||
|
font-size: @font-size-base;
|
||||||
|
line-height: 1.8;
|
||||||
|
text-align: left;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__attachments {
|
||||||
|
margin-top: @spacing-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__attachments-title {
|
||||||
|
margin: 0 0 @spacing-base;
|
||||||
|
font-size: @font-size-sm;
|
||||||
|
font-weight: 600;
|
||||||
|
color: @heading-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__file-list {
|
||||||
|
.flex(column, flex-start, stretch);
|
||||||
|
gap: @spacing-sm;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__file {
|
||||||
|
.flex(row, space-between, center);
|
||||||
|
gap: @spacing-base;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 520px;
|
||||||
|
padding: @spacing-base;
|
||||||
|
border: 1px solid @border-color;
|
||||||
|
border-radius: @radius-base;
|
||||||
|
background: @bg-color-light;
|
||||||
|
list-style: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__file-info {
|
||||||
|
.flex(column, flex-start, flex-start);
|
||||||
|
gap: @spacing-xs;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__file-name {
|
||||||
|
font-size: @font-size-sm;
|
||||||
|
font-weight: 600;
|
||||||
|
color: @heading-color;
|
||||||
|
.ellipsis();
|
||||||
|
}
|
||||||
|
|
||||||
|
&__file-meta {
|
||||||
|
font-size: @font-size-xs;
|
||||||
|
color: @text-color-secondary;
|
||||||
|
.ellipsis();
|
||||||
|
}
|
||||||
|
|
||||||
|
&__file-actions {
|
||||||
|
.flex(row, flex-end, center);
|
||||||
|
gap: @spacing-sm;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__file-btn {
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 @spacing-sm;
|
||||||
|
border: none;
|
||||||
|
border-radius: @radius-round;
|
||||||
|
background: transparent;
|
||||||
|
color: @primary-strong;
|
||||||
|
font-size: @font-size-sm;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: fade(@primary-strong, 8%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&__actions {
|
&__actions {
|
||||||
.flex(row, flex-end);
|
.flex(row, flex-end);
|
||||||
margin-top: 32px;
|
margin-top: @spacing-xl;
|
||||||
|
}
|
||||||
|
|
||||||
.app-button {
|
&__back {
|
||||||
height: 24px; // 31 × 0.77
|
min-width: 88px;
|
||||||
padding: 0 16px;
|
height: 36px;
|
||||||
border-radius: 6px;
|
padding: 0 @spacing-lg;
|
||||||
background: @primary-strong;
|
border: none;
|
||||||
color: @white;
|
border-radius: @radius-round;
|
||||||
font-size: 11px;
|
background: @primary-strong;
|
||||||
font-weight: 500;
|
color: @white;
|
||||||
|
font-size: @font-size-sm;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: @primary-strong;
|
background: @primary-hover;
|
||||||
color: @white;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__empty {
|
&__empty {
|
||||||
padding: @spacing-xxl 0;
|
padding: @spacing-xxl 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 11px;
|
font-size: @font-size-sm;
|
||||||
|
color: @text-color-secondary;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
<!-- 页面:消息中心(Message) -->
|
<!-- 页面:消息中心(Message) -->
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { AppButton, FilterPanel, PaginationBar } from '@/components'
|
import { ElButton, ElInput } from 'element-plus'
|
||||||
|
import { PageHead, PaginationBar } from '@/components'
|
||||||
import { useMessageStore } from '@/stores/modules/message'
|
import { useMessageStore } from '@/stores/modules/message'
|
||||||
import type { MessageType } from '@/types/business/message'
|
import type { Message, MessageType } from '@/types/business/message'
|
||||||
import MessageTabs from './cpns/MessageTabs.vue'
|
import MessageTabs from './cpns/MessageTabs.vue'
|
||||||
import MessageList from './cpns/MessageList.vue'
|
import MessageList from './cpns/MessageList.vue'
|
||||||
|
|
||||||
@@ -13,16 +14,15 @@ const router = useRouter()
|
|||||||
const messageStore = useMessageStore()
|
const messageStore = useMessageStore()
|
||||||
const { query, list, total, unreadCount, tabCounts } = storeToRefs(messageStore)
|
const { query, list, total, unreadCount, tabCounts } = storeToRefs(messageStore)
|
||||||
|
|
||||||
const keyword = computed({
|
const keyword = ref(query.value.keyword)
|
||||||
get: () => query.value.keyword,
|
|
||||||
set: (value: string) => messageStore.setQuery({ ...query.value, keyword: value }),
|
|
||||||
})
|
|
||||||
|
|
||||||
function onSearch(): void {
|
function onSearch(): void {
|
||||||
|
messageStore.setQuery({ ...query.value, keyword: keyword.value })
|
||||||
void messageStore.search()
|
void messageStore.search()
|
||||||
}
|
}
|
||||||
|
|
||||||
function onReset(): void {
|
function onReset(): void {
|
||||||
|
keyword.value = ''
|
||||||
void messageStore.resetQuery()
|
void messageStore.resetQuery()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,8 +35,13 @@ function onTabChange(type: MessageType | 'all'): void {
|
|||||||
void messageStore.search()
|
void messageStore.search()
|
||||||
}
|
}
|
||||||
|
|
||||||
function onView(id: string): void {
|
function onView(message: Message): void {
|
||||||
void router.push(`/message/${id}`)
|
// 用 message.type 区分:在线客服进会话页,其余进消息详情
|
||||||
|
if (message.type === '在线客服') {
|
||||||
|
void router.push('/service')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void router.push(`/message/${message.id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onMarkAllRead(): void {
|
function onMarkAllRead(): void {
|
||||||
@@ -50,20 +55,18 @@ onMounted(() => {
|
|||||||
messageStore.fetchTabCounts(),
|
messageStore.fetchTabCounts(),
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const UNREAD_LABEL = computed(() => (unreadCount.value > 0 ? `全部已读 (${unreadCount.value})` : '全部已读'))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="message">
|
<div class="message">
|
||||||
<header class="message__head">
|
<PageHead
|
||||||
<h1 class="message__title">
|
title="消息中心"
|
||||||
消息中心
|
slogan="统一接收项目进度、售后工单、账单待办等站内消息,重要业务动态实时提醒"
|
||||||
</h1>
|
/>
|
||||||
</header>
|
|
||||||
<p class="message__subtitle text-secondary">
|
|
||||||
统一接收项目进度、售后工单、账单待办等站内消息,重要业务动态实时提醒
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="message__body">
|
<div class="container message__body">
|
||||||
<MessageTabs
|
<MessageTabs
|
||||||
class="message__tabs"
|
class="message__tabs"
|
||||||
:active="query.type"
|
:active="query.type"
|
||||||
@@ -73,21 +76,34 @@ onMounted(() => {
|
|||||||
|
|
||||||
<div class="message__main">
|
<div class="message__main">
|
||||||
<div class="message__toolbar">
|
<div class="message__toolbar">
|
||||||
<FilterPanel
|
<el-input
|
||||||
class="message__filter"
|
v-model="keyword"
|
||||||
v-model:keyword="keyword"
|
class="message__search"
|
||||||
keyword-placeholder="搜索消息标题、内容"
|
placeholder="搜索消息标题、内容"
|
||||||
@search="onSearch"
|
clearable
|
||||||
@reset="onReset"
|
@keyup.enter="onSearch"
|
||||||
/>
|
/>
|
||||||
<AppButton
|
|
||||||
class="message__mark-read"
|
<el-button
|
||||||
type="default"
|
class="message__btn"
|
||||||
|
@click="onReset"
|
||||||
|
>
|
||||||
|
重置
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
class="message__btn message__btn--primary"
|
||||||
|
@click="onSearch"
|
||||||
|
>
|
||||||
|
搜索
|
||||||
|
</el-button>
|
||||||
|
|
||||||
|
<el-button
|
||||||
|
class="message__btn message__btn--read"
|
||||||
:disabled="unreadCount === 0"
|
:disabled="unreadCount === 0"
|
||||||
@click="onMarkAllRead"
|
@click="onMarkAllRead"
|
||||||
>
|
>
|
||||||
全部已读
|
{{ UNREAD_LABEL }}
|
||||||
</AppButton>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MessageList :messages="list" @view="onView" />
|
<MessageList :messages="list" @view="onView" />
|
||||||
@@ -107,144 +123,133 @@ onMounted(() => {
|
|||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.message {
|
.message {
|
||||||
.flex(column, flex-start, stretch);
|
.flex(column, flex-start, stretch);
|
||||||
gap: @spacing-base;
|
gap: 0;
|
||||||
max-width: @container-width;
|
width: 100%;
|
||||||
margin: 0 auto;
|
min-height: 100%;
|
||||||
padding: @spacing-xl @spacing-base @spacing-xxl;
|
padding-bottom: @spacing-page-bottom;
|
||||||
|
background: @bg-color;
|
||||||
&__head {
|
|
||||||
.flex(row, flex-start);
|
|
||||||
margin-bottom: -10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__title {
|
|
||||||
font-size: 17px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: @heading-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__subtitle {
|
|
||||||
text-align: center;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__body {
|
&__body {
|
||||||
.flex(row, flex-start);
|
.flex(row, flex-start, flex-start);
|
||||||
gap: 26px;
|
gap: @spacing-lg;
|
||||||
align-items: flex-start;
|
width: 100%;
|
||||||
|
padding-top: @spacing-lg;
|
||||||
|
padding-right: @home-gutter;
|
||||||
|
padding-left: @home-gutter;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
@media (max-width: 1600px) {
|
||||||
|
padding-right: @spacing-lg;
|
||||||
|
padding-left: @spacing-lg;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__tabs {
|
&__tabs {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 156px;
|
width: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__main {
|
&__main {
|
||||||
.flex(column, flex-start, stretch);
|
.flex(column, flex-start, stretch);
|
||||||
gap: 15px;
|
gap: @spacing-base;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__toolbar {
|
&__toolbar {
|
||||||
.flex(row, flex-start);
|
.flex(row, flex-start, center);
|
||||||
align-items: center;
|
gap: @spacing-base;
|
||||||
gap: 11px;
|
min-height: 64px;
|
||||||
padding: 10px 17px;
|
padding: 0 @spacing-lg;
|
||||||
|
border: 1px solid @border-color;
|
||||||
border-radius: @radius-base;
|
border-radius: @radius-base;
|
||||||
background: @white;
|
background: @white;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
& .message__filter {
|
&__search {
|
||||||
flex: 1;
|
flex: 0 1 360px;
|
||||||
min-width: 0;
|
height: 36px;
|
||||||
padding: 0;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
& .message__mark-read {
|
:deep(.el-input__wrapper) {
|
||||||
flex-shrink: 0;
|
height: 36px;
|
||||||
height: 26px; // 34 × 0.77
|
padding: 0 @spacing-base;
|
||||||
padding: 0 16px;
|
border-radius: @radius-round;
|
||||||
border: 1px solid @border-color;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: @white;
|
|
||||||
color: @text-color;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 500;
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
border-color: @primary-color;
|
|
||||||
background: @white;
|
|
||||||
color: @primary-color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
& .message__pagination {
|
|
||||||
justify-content: flex-end;
|
|
||||||
align-self: stretch;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message__filter {
|
|
||||||
:deep(.filter-panel__bar) {
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.filter-panel__body) {
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.filter-panel__input) {
|
|
||||||
order: 0;
|
|
||||||
flex: 0 1 349px;
|
|
||||||
max-width: 349px;
|
|
||||||
height: 29px; // 38 × 0.77
|
|
||||||
margin-right: 2px;
|
|
||||||
padding: 0 10px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: @finance-field-bg;
|
background: @finance-field-bg;
|
||||||
color: @text-color;
|
box-shadow: 0 0 0 1px @border-color inset;
|
||||||
font-size: 11px;
|
}
|
||||||
|
|
||||||
|
:deep(.el-input__inner) {
|
||||||
|
color: @finance-ink;
|
||||||
|
font-size: @font-size-base;
|
||||||
|
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
color: @contract-placeholder;
|
color: @contract-placeholder;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&:focus {
|
&__btn {
|
||||||
border: none;
|
height: 36px;
|
||||||
}
|
margin-left: 0;
|
||||||
|
padding: 0 @spacing-lg;
|
||||||
|
border-radius: @radius-round;
|
||||||
|
font-size: @font-size-sm;
|
||||||
|
font-weight: 400;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: @primary-strong;
|
||||||
|
color: @primary-strong;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.filter-panel__btn) {
|
&--primary {
|
||||||
order: 1;
|
border-color: @primary-strong;
|
||||||
min-width: 46px;
|
|
||||||
height: 29px;
|
|
||||||
padding: 0 12px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: @contract-reset-bg;
|
|
||||||
color: @text-color;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 500;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: @contract-reset-bg;
|
|
||||||
color: @text-color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.filter-panel__btn--primary) {
|
|
||||||
order: 2;
|
|
||||||
background: @primary-strong;
|
background: @primary-strong;
|
||||||
color: @white;
|
color: @white;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: @primary-strong;
|
border-color: @primary-hover;
|
||||||
|
background: @primary-hover;
|
||||||
color: @white;
|
color: @white;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--read {
|
||||||
|
margin-left: auto;
|
||||||
|
border-color: @border-color;
|
||||||
|
background: @white;
|
||||||
|
color: @finance-entry-text;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
border-color: @primary-strong;
|
||||||
|
background: @white;
|
||||||
|
color: @primary-strong;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__pagination {
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-self: stretch;
|
||||||
|
margin-top: @spacing-base;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
:deep(.pagination__btn) {
|
||||||
|
min-width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: 1px solid @border-color;
|
||||||
|
border-radius: @radius-sm;
|
||||||
|
background: @white;
|
||||||
|
color: @text-color;
|
||||||
|
font-size: @font-size-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.pagination__btn.is-active) {
|
||||||
|
border-color: @primary-strong;
|
||||||
|
background: @primary-light;
|
||||||
|
color: @primary-strong;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import AboutPanel from './cpns/AboutPanel.vue'
|
|||||||
&__title {
|
&__title {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding-left: 12px;
|
padding-left: 12px;
|
||||||
font-size: 12px;
|
font-size: 14px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: @heading-color;
|
color: @heading-color;
|
||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
@@ -38,7 +38,7 @@ import AboutPanel from './cpns/AboutPanel.vue'
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
top: 3px;
|
top: 3px;
|
||||||
width: 3px;
|
width: 4px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
background: @primary-strong;
|
background: @primary-strong;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { AppButton, ConfirmDialog, PaginationBar, StatusTag } from '@/components'
|
import { ConfirmDialog, PaginationBar, EllipsisText } from '@/components'
|
||||||
import { useProfileStore } from '@/stores/modules/profile'
|
import { useProfileStore } from '@/stores/modules/profile'
|
||||||
import type { Address } from '@/types/business/profile'
|
import type { Address } from '@/types/business/profile'
|
||||||
import AddressDialog from './cpns/AddressDialog.vue'
|
import AddressDialog from './cpns/AddressDialog.vue'
|
||||||
@@ -64,51 +64,50 @@ onMounted(() => {
|
|||||||
<div class="address-page">
|
<div class="address-page">
|
||||||
<header class="address-page__head">
|
<header class="address-page__head">
|
||||||
<h2 class="address-page__title">
|
<h2 class="address-page__title">
|
||||||
收货地址
|
地址管理
|
||||||
</h2>
|
</h2>
|
||||||
<AppButton type="primary" @click="onCreate">
|
<button type="button" class="address-page__create" @click="onCreate">
|
||||||
+新增地址
|
+ 新增地址
|
||||||
</AppButton>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="address-page__grid">
|
<div class="address-page__list">
|
||||||
<article
|
<article
|
||||||
v-for="address in addresses"
|
v-for="address in addresses"
|
||||||
:key="address.id"
|
:key="address.id"
|
||||||
class="address-card"
|
class="address-row"
|
||||||
>
|
>
|
||||||
<div class="address-card__head">
|
<span v-if="address.isDefault" class="address-row__tag">默认</span>
|
||||||
<h3 class="address-card__name">
|
<div class="address-row__col">
|
||||||
{{ address.receiverName }}
|
<p class="address-row__label">收货人姓名</p>
|
||||||
</h3>
|
<EllipsisText class="address-row__value" :text="address.receiverName" />
|
||||||
<StatusTag v-if="address.isDefault" label="默认" variant="success" />
|
|
||||||
</div>
|
</div>
|
||||||
<ul class="address-card__meta">
|
<div class="address-row__col">
|
||||||
<li><span>联系方式:</span>{{ address.phone }}</li>
|
<p class="address-row__label">联系方式</p>
|
||||||
<li><span>所在地区:</span>{{ address.region }}</li>
|
<EllipsisText class="address-row__value" :text="address.phone" />
|
||||||
<li><span>详细地址:</span>{{ address.detailAddress }}</li>
|
</div>
|
||||||
</ul>
|
<div class="address-row__col">
|
||||||
<div class="address-card__actions">
|
<p class="address-row__label">所在地区</p>
|
||||||
|
<EllipsisText class="address-row__value" :text="address.region" />
|
||||||
|
</div>
|
||||||
|
<div class="address-row__col">
|
||||||
|
<p class="address-row__label">详细地址</p>
|
||||||
|
<EllipsisText class="address-row__value" :text="address.detailAddress" />
|
||||||
|
</div>
|
||||||
|
<div class="address-row__actions">
|
||||||
<button
|
<button
|
||||||
v-if="!address.isDefault"
|
type="button"
|
||||||
class="address-card__btn"
|
class="address-row__btn"
|
||||||
@click="onToggleDefault(address)"
|
@click="onToggleDefault(address)"
|
||||||
>
|
>
|
||||||
设为默认
|
{{ address.isDefault ? '取消默认' : '设为默认' }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button type="button" class="address-row__btn" @click="onDelete(address)">
|
||||||
v-else
|
|
||||||
class="address-card__btn address-card__btn--muted"
|
|
||||||
disabled
|
|
||||||
>
|
|
||||||
取消默认
|
|
||||||
</button>
|
|
||||||
<button class="address-card__btn" @click="onEdit(address)">
|
|
||||||
编辑
|
|
||||||
</button>
|
|
||||||
<button class="address-card__btn address-card__btn--danger" @click="onDelete(address)">
|
|
||||||
删除
|
删除
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" class="address-row__btn" @click="onEdit(address)">
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
@@ -145,47 +144,64 @@ onMounted(() => {
|
|||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.address-page {
|
.address-page {
|
||||||
.flex(column, flex-start);
|
.flex(column, flex-start);
|
||||||
gap: @spacing-base;
|
gap: @spacing-lg;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
&__head {
|
&__head {
|
||||||
.flex(row, space-between, center);
|
.flex(row, space-between, center);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__title {
|
&__title {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding-left: 12px;
|
padding-left: 14px;
|
||||||
font-size: 12px;
|
font-size: @font-size-base;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
color: @heading-color;
|
color: @profile-ink;
|
||||||
line-height: 20px;
|
line-height: 1.4;
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
top: 3px;
|
top: 3px;
|
||||||
width: 3px;
|
width: 4px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
background: @primary-strong;
|
background: @primary-strong;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__grid {
|
&__create {
|
||||||
display: grid;
|
height: 32px;
|
||||||
grid-template-columns: 1fr;
|
padding: 0 @spacing-base;
|
||||||
|
border: none;
|
||||||
|
border-radius: @profile-radius;
|
||||||
|
background: @primary-strong;
|
||||||
|
color: @white;
|
||||||
|
font-size: @font-size-sm;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: darken(@primary-strong, 6%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__list {
|
||||||
|
.flex(column, flex-start, stretch);
|
||||||
gap: @spacing-base;
|
gap: @spacing-base;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__empty {
|
&__empty {
|
||||||
grid-column: 1 / -1;
|
|
||||||
padding: @spacing-xxl 0;
|
padding: @spacing-xxl 0;
|
||||||
|
width: 100%;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 11px;
|
font-size: @font-size-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.pagination) {
|
:deep(.pagination) {
|
||||||
@@ -198,115 +214,81 @@ onMounted(() => {
|
|||||||
background: @profile-page-active-bg;
|
background: @profile-page-active-bg;
|
||||||
color: @primary-strong;
|
color: @primary-strong;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-button--primary {
|
|
||||||
height: 24px;
|
|
||||||
padding: 0 10px;
|
|
||||||
border: none;
|
|
||||||
border-radius: @profile-radius;
|
|
||||||
background: @primary-strong;
|
|
||||||
color: @white;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 500;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border: none;
|
|
||||||
background: darken(@primary-strong, 6%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.address-card {
|
.address-row {
|
||||||
.flex(row, flex-start, center);
|
position: relative;
|
||||||
gap: @spacing-lg;
|
display: grid;
|
||||||
padding: @spacing-base;
|
grid-template-columns:
|
||||||
border: 1px solid @profile-card-border;
|
minmax(88px, 0.9fr)
|
||||||
|
minmax(110px, 1fr)
|
||||||
|
minmax(120px, 1.2fr)
|
||||||
|
minmax(160px, 1.8fr)
|
||||||
|
auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: @spacing-base;
|
||||||
|
min-height: 72px;
|
||||||
|
padding: @spacing-base @spacing-lg;
|
||||||
|
border: 1px solid @border-color;
|
||||||
border-radius: @radius-base;
|
border-radius: @radius-base;
|
||||||
background: @white;
|
background: @bg-color-light;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
&__head {
|
&__tag {
|
||||||
.flex(row, flex-start, center);
|
position: absolute;
|
||||||
gap: @spacing-xs;
|
top: -9px;
|
||||||
flex-shrink: 0;
|
left: @spacing-lg;
|
||||||
flex-direction: column-reverse;
|
height: 18px;
|
||||||
align-items: flex-start;
|
padding: 0 10px;
|
||||||
|
border-radius: @radius-sm;
|
||||||
|
background: @primary-strong;
|
||||||
|
color: @white;
|
||||||
|
font-size: @font-size-xs;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__name {
|
&__col {
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: @heading-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__meta {
|
|
||||||
.flex(row, flex-start);
|
|
||||||
gap: @spacing-xl;
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
list-style: none;
|
}
|
||||||
font-size: 11px;
|
|
||||||
|
|
||||||
li {
|
&__label {
|
||||||
flex: 1;
|
margin: 0;
|
||||||
min-width: 0;
|
font-size: @font-size-sm;
|
||||||
color: @heading-color;
|
color: @text-color-secondary;
|
||||||
font-weight: 600;
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
.ellipsis();
|
&__value {
|
||||||
}
|
margin-top: @spacing-xs;
|
||||||
|
font-size: @font-size-base;
|
||||||
span {
|
font-weight: 600;
|
||||||
display: block;
|
color: @profile-ink;
|
||||||
margin-bottom: @spacing-xs;
|
line-height: 1.4;
|
||||||
color: @text-color-secondary;
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__actions {
|
&__actions {
|
||||||
.flex(row, center);
|
.flex(row, flex-end, center);
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: @spacing-sm;
|
gap: @spacing-sm;
|
||||||
flex-shrink: 0;
|
|
||||||
width: auto;
|
|
||||||
margin-top: 0;
|
|
||||||
padding-top: 0;
|
|
||||||
border-top: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__btn {
|
&__btn {
|
||||||
height: 24px;
|
height: 28px;
|
||||||
padding: 0 10px;
|
padding: 0 @spacing-sm;
|
||||||
border: 1px solid @profile-line;
|
border: 1px solid @border-color;
|
||||||
border-radius: @profile-radius;
|
border-radius: @radius-round;
|
||||||
background: @white;
|
background: @white;
|
||||||
color: @text-color;
|
color: @text-color;
|
||||||
font-size: 11px;
|
font-size: @font-size-sm;
|
||||||
font-weight: 500;
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
&:hover {
|
||||||
border-color: @primary-strong;
|
border-color: @primary-strong;
|
||||||
color: @primary-strong;
|
color: @primary-strong;
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--muted {
|
|
||||||
color: @text-color;
|
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--danger {
|
|
||||||
color: @text-color;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.address-card .status-tag {
|
|
||||||
padding: 2px 8px;
|
|
||||||
background: @primary-strong;
|
|
||||||
color: @white;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
257
src/views/profile/bind-email.vue
Normal file
257
src/views/profile/bind-email.vue
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { useProfileStore } from '@/stores/modules/profile'
|
||||||
|
import { useCountDown } from '@/composables/useCountDown'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const profileStore = useProfileStore()
|
||||||
|
const { profile } = storeToRefs(profileStore)
|
||||||
|
|
||||||
|
const hasEmail = computed(() => Boolean(profile.value?.email))
|
||||||
|
|
||||||
|
const email = ref('')
|
||||||
|
const code = ref('')
|
||||||
|
|
||||||
|
const { seconds, isCounting, start } = useCountDown(60)
|
||||||
|
|
||||||
|
function onGetCode(): void {
|
||||||
|
if (!hasEmail.value && !email.value) {
|
||||||
|
ElMessage.warning('请输入新邮箱')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
start(60)
|
||||||
|
ElMessage.success('验证码已发送')
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBind(): void {
|
||||||
|
if (!hasEmail.value && !email.value) {
|
||||||
|
ElMessage.warning('请输入新邮箱')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!code.value) {
|
||||||
|
ElMessage.warning('请输入验证码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 换绑态设计稿无新邮箱输入框(#43),按现状提交当前邮箱 + 验证码
|
||||||
|
const target = hasEmail.value ? profile.value?.email ?? '' : email.value
|
||||||
|
void profileStore.bindEmail(target, code.value).then(() => {
|
||||||
|
void router.push('/profile/security')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUnbind(): void {
|
||||||
|
void profileStore.unbindEmail().then(() => {
|
||||||
|
void router.push('/profile/security')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="bind-email-page">
|
||||||
|
<h2 class="bind-email-page__title">
|
||||||
|
绑定邮箱
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<form class="bind-email-page__form" @submit.prevent="onBind">
|
||||||
|
<div class="bind-email-page__field">
|
||||||
|
<label class="bind-email-page__label">{{ hasEmail ? '邮箱' : '新邮箱' }}</label>
|
||||||
|
<input
|
||||||
|
v-if="hasEmail"
|
||||||
|
class="bind-email-page__input bind-email-page__input--disabled"
|
||||||
|
type="text"
|
||||||
|
:value="profile?.email ?? ''"
|
||||||
|
readonly
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-else
|
||||||
|
v-model="email"
|
||||||
|
class="bind-email-page__input"
|
||||||
|
type="text"
|
||||||
|
placeholder="请输入新邮箱"
|
||||||
|
maxlength="50"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bind-email-page__field">
|
||||||
|
<label class="bind-email-page__label">邮箱验证码</label>
|
||||||
|
<div class="bind-email-page__code">
|
||||||
|
<input
|
||||||
|
v-model="code"
|
||||||
|
class="bind-email-page__input"
|
||||||
|
type="text"
|
||||||
|
placeholder="请输入验证码"
|
||||||
|
maxlength="6"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="bind-email-page__get-code"
|
||||||
|
:disabled="isCounting"
|
||||||
|
@click="onGetCode"
|
||||||
|
>
|
||||||
|
{{ isCounting ? `${seconds}秒后重获` : '获取验证码' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="hasEmail" class="bind-email-page__actions">
|
||||||
|
<button type="button" class="bind-email-page__quit" @click="onUnbind">
|
||||||
|
取消绑定
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="bind-email-page__submit">
|
||||||
|
更换绑定邮箱
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button v-else type="submit" class="bind-email-page__submit">
|
||||||
|
验证并绑定
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.bind-email-page {
|
||||||
|
.flex(column, flex-start, stretch);
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 14px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
font-size: @font-size-base;
|
||||||
|
font-weight: 600;
|
||||||
|
color: @profile-ink;
|
||||||
|
line-height: 20px;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 3px;
|
||||||
|
width: 4px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: @primary-strong;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__form {
|
||||||
|
width: 860px;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__field {
|
||||||
|
.flex(column, flex-start);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: @font-size-sm;
|
||||||
|
font-weight: 500;
|
||||||
|
color: @profile-ink;
|
||||||
|
line-height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__input {
|
||||||
|
width: 100%;
|
||||||
|
height: 38px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border: 1px solid @profile-input-border;
|
||||||
|
border-radius: @profile-radius;
|
||||||
|
background: @white;
|
||||||
|
font-size: 13px;
|
||||||
|
color: @profile-ink;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: @primary-strong;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: @finance-axis-text;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--disabled {
|
||||||
|
border-color: transparent;
|
||||||
|
background: @profile-input-disabled-bg;
|
||||||
|
color: @finance-muted-text;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__code {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.bind-email-page__input {
|
||||||
|
padding-right: 124px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__get-code {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 107px;
|
||||||
|
height: 38px;
|
||||||
|
border: 1px solid @primary-strong;
|
||||||
|
border-radius: @profile-radius;
|
||||||
|
background: @white;
|
||||||
|
color: @primary-strong;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: @primary-strong;
|
||||||
|
color: @white;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
border-color: @text-color-disabled;
|
||||||
|
color: @text-color-disabled;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 28px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__quit,
|
||||||
|
&__submit {
|
||||||
|
height: 46px;
|
||||||
|
border: none;
|
||||||
|
border-radius: @profile-radius;
|
||||||
|
font-size: @font-size-base;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__quit {
|
||||||
|
background: @profile-quiet-btn;
|
||||||
|
color: @finance-muted-text;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: darken(@profile-quiet-btn, 4%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__submit {
|
||||||
|
background: @primary-strong;
|
||||||
|
color: @white;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: darken(@primary-strong, 6%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
235
src/views/profile/bind-phone.vue
Normal file
235
src/views/profile/bind-phone.vue
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { useProfileStore } from '@/stores/modules/profile'
|
||||||
|
import { useCountDown } from '@/composables/useCountDown'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const profileStore = useProfileStore()
|
||||||
|
const { profile } = storeToRefs(profileStore)
|
||||||
|
|
||||||
|
const hasPhone = computed(() => Boolean(profile.value?.phone))
|
||||||
|
|
||||||
|
const phone = ref('')
|
||||||
|
const code = ref('')
|
||||||
|
|
||||||
|
const { seconds, isCounting, start } = useCountDown(60)
|
||||||
|
|
||||||
|
function onGetCode(): void {
|
||||||
|
const target = hasPhone.value ? profile.value?.phone : phone.value
|
||||||
|
if (!target) {
|
||||||
|
ElMessage.warning('请输入手机号')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
start(60)
|
||||||
|
ElMessage.success('验证码已发送')
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSubmit(): void {
|
||||||
|
const target = hasPhone.value ? profile.value?.phone : phone.value
|
||||||
|
if (!target) {
|
||||||
|
ElMessage.warning('请输入手机号')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!code.value) {
|
||||||
|
ElMessage.warning('请输入验证码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void profileStore.bindPhone(target, code.value).then(() => {
|
||||||
|
void router.push('/profile/security')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="bind-phone-page">
|
||||||
|
<h2 class="bind-phone-page__title">
|
||||||
|
绑定手机号
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<form class="bind-phone-page__form" @submit.prevent="onSubmit">
|
||||||
|
<div class="bind-phone-page__field">
|
||||||
|
<label class="bind-phone-page__label">手机号</label>
|
||||||
|
<input
|
||||||
|
v-if="hasPhone"
|
||||||
|
class="bind-phone-page__input bind-phone-page__input--disabled"
|
||||||
|
type="text"
|
||||||
|
:value="profile?.phone ?? ''"
|
||||||
|
readonly
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-else
|
||||||
|
v-model="phone"
|
||||||
|
class="bind-phone-page__input"
|
||||||
|
type="text"
|
||||||
|
placeholder="请输入手机号"
|
||||||
|
maxlength="20"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bind-phone-page__field">
|
||||||
|
<label class="bind-phone-page__label">验证码</label>
|
||||||
|
<div class="bind-phone-page__code">
|
||||||
|
<input
|
||||||
|
v-model="code"
|
||||||
|
class="bind-phone-page__input"
|
||||||
|
type="text"
|
||||||
|
placeholder="请输入验证码"
|
||||||
|
maxlength="6"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="bind-phone-page__get-code"
|
||||||
|
:disabled="isCounting"
|
||||||
|
@click="onGetCode"
|
||||||
|
>
|
||||||
|
{{ isCounting ? `${seconds}秒后重获` : '获取验证码' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="bind-phone-page__hint">
|
||||||
|
注:更换新手机号之后,可以用新的手机号充当当前账号登录
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="bind-phone-page__submit">
|
||||||
|
{{ hasPhone ? '验证并换绑' : '验证并绑定' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.bind-phone-page {
|
||||||
|
.flex(column, flex-start, stretch);
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 14px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
font-size: @font-size-base;
|
||||||
|
font-weight: 600;
|
||||||
|
color: @profile-ink;
|
||||||
|
line-height: 20px;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 3px;
|
||||||
|
width: 4px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: @primary-strong;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__form {
|
||||||
|
width: 860px;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__field {
|
||||||
|
.flex(column, flex-start);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: @font-size-sm;
|
||||||
|
font-weight: 500;
|
||||||
|
color: @profile-ink;
|
||||||
|
line-height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__input {
|
||||||
|
width: 100%;
|
||||||
|
height: 38px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border: 1px solid @profile-input-border;
|
||||||
|
border-radius: @profile-radius;
|
||||||
|
background: @white;
|
||||||
|
font-size: 13px;
|
||||||
|
color: @profile-ink;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: @primary-strong;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: @finance-axis-text;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--disabled {
|
||||||
|
border-color: transparent;
|
||||||
|
background: @profile-input-disabled-bg;
|
||||||
|
color: @finance-muted-text;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__code {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.bind-phone-page__input {
|
||||||
|
padding-right: 124px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__get-code {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 107px;
|
||||||
|
height: 38px;
|
||||||
|
border: 1px solid @primary-strong;
|
||||||
|
border-radius: @profile-radius;
|
||||||
|
background: @white;
|
||||||
|
color: @primary-strong;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: @primary-strong;
|
||||||
|
color: @white;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
border-color: @text-color-disabled;
|
||||||
|
color: @text-color-disabled;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__hint {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: @font-size-sm;
|
||||||
|
color: @finance-axis-text;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__submit {
|
||||||
|
width: 100%;
|
||||||
|
height: 46px;
|
||||||
|
margin-top: 2px;
|
||||||
|
border: none;
|
||||||
|
border-radius: @profile-radius;
|
||||||
|
background: @primary-strong;
|
||||||
|
color: @white;
|
||||||
|
font-size: @font-size-base;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: darken(@primary-strong, 6%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
283
src/views/profile/change-password.vue
Normal file
283
src/views/profile/change-password.vue
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { PasswordStrength } from '@/components'
|
||||||
|
import { isValidPassword, getPasswordLevel } from '@/utils/validate'
|
||||||
|
import { useProfileStore } from '@/stores/modules/profile'
|
||||||
|
import { useCountDown } from '@/composables/useCountDown'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const profileStore = useProfileStore()
|
||||||
|
const { profile } = storeToRefs(profileStore)
|
||||||
|
|
||||||
|
const phone = ref('')
|
||||||
|
const code = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const confirm = ref('')
|
||||||
|
const agree = ref(false)
|
||||||
|
|
||||||
|
const { seconds, isCounting, start } = useCountDown(60)
|
||||||
|
|
||||||
|
function onGetCode(): void {
|
||||||
|
if (!phone.value) {
|
||||||
|
ElMessage.warning('请输入手机号')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
start(60)
|
||||||
|
ElMessage.success('验证码已发送')
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSubmit(): void {
|
||||||
|
if (!phone.value) {
|
||||||
|
ElMessage.warning('请输入手机号')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!code.value) {
|
||||||
|
ElMessage.warning('请输入验证码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!password.value) {
|
||||||
|
ElMessage.warning('请输入新密码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (password.value !== confirm.value) {
|
||||||
|
ElMessage.warning('两次密码不一致')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!isValidPassword(password.value) || getPasswordLevel(password.value) === 'weak') {
|
||||||
|
ElMessage.warning('密码等级过低,请重新输入')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!agree.value) {
|
||||||
|
ElMessage.warning('请先阅读并同意《用户协议》和《隐私政策》')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void profileStore.changePassword({ code: code.value, password: password.value }).then(() => {
|
||||||
|
void router.push('/profile/security')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="change-pwd-page">
|
||||||
|
<h2 class="change-pwd-page__title">
|
||||||
|
修改密码
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<form class="change-pwd-page__form" @submit.prevent="onSubmit">
|
||||||
|
<div class="change-pwd-page__field">
|
||||||
|
<label class="change-pwd-page__label">手机号</label>
|
||||||
|
<input
|
||||||
|
v-model="phone"
|
||||||
|
class="change-pwd-page__input"
|
||||||
|
type="text"
|
||||||
|
:placeholder="profile?.phone ?? '请输入手机号'"
|
||||||
|
maxlength="20"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="change-pwd-page__field">
|
||||||
|
<label class="change-pwd-page__label">验证码</label>
|
||||||
|
<div class="change-pwd-page__code">
|
||||||
|
<input
|
||||||
|
v-model="code"
|
||||||
|
class="change-pwd-page__input"
|
||||||
|
type="text"
|
||||||
|
placeholder="请输入验证码"
|
||||||
|
maxlength="6"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="change-pwd-page__get-code"
|
||||||
|
:disabled="isCounting"
|
||||||
|
@click="onGetCode"
|
||||||
|
>
|
||||||
|
{{ isCounting ? `${seconds}秒后重获` : '获取验证码' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="change-pwd-page__field">
|
||||||
|
<label class="change-pwd-page__label">设置密码</label>
|
||||||
|
<input
|
||||||
|
v-model="password"
|
||||||
|
class="change-pwd-page__input"
|
||||||
|
type="password"
|
||||||
|
placeholder="请设置8-20位密码"
|
||||||
|
maxlength="20"
|
||||||
|
>
|
||||||
|
<PasswordStrength :password="password" :show-label="false" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="change-pwd-page__field change-pwd-page__field--last">
|
||||||
|
<label class="change-pwd-page__label">确认密码</label>
|
||||||
|
<input
|
||||||
|
v-model="confirm"
|
||||||
|
class="change-pwd-page__input"
|
||||||
|
type="password"
|
||||||
|
placeholder="请设置8-20位密码"
|
||||||
|
maxlength="20"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="change-pwd-page__agree">
|
||||||
|
<input v-model="agree" type="checkbox">
|
||||||
|
<span>已阅读并同意<em>《用户协议》</em>和<em>《隐私政策》</em></span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button type="submit" class="change-pwd-page__submit">
|
||||||
|
确认修改
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.change-pwd-page {
|
||||||
|
.flex(column, flex-start, stretch);
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 14px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
font-size: @font-size-base;
|
||||||
|
font-weight: 600;
|
||||||
|
color: @profile-ink;
|
||||||
|
line-height: 20px;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 3px;
|
||||||
|
width: 4px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: @primary-strong;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__form {
|
||||||
|
width: 860px;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__field {
|
||||||
|
.flex(column, flex-start);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
&--last {
|
||||||
|
margin-top: 30px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: @font-size-sm;
|
||||||
|
font-weight: 500;
|
||||||
|
color: @profile-ink;
|
||||||
|
line-height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__input {
|
||||||
|
width: 100%;
|
||||||
|
height: 38px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border: 1px solid @profile-input-border;
|
||||||
|
border-radius: @profile-radius;
|
||||||
|
background: @white;
|
||||||
|
font-size: 13px;
|
||||||
|
color: @profile-ink;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: @primary-strong;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: @finance-axis-text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__code {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.change-pwd-page__input {
|
||||||
|
padding-right: 124px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__get-code {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 107px;
|
||||||
|
height: 38px;
|
||||||
|
border: 1px solid @primary-strong;
|
||||||
|
border-radius: @profile-radius;
|
||||||
|
background: @white;
|
||||||
|
color: @primary-strong;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: @primary-strong;
|
||||||
|
color: @white;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
border-color: @text-color-disabled;
|
||||||
|
color: @text-color-disabled;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.pwd-strength) {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__agree {
|
||||||
|
.flex(row, flex-start, center);
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: @font-size-sm;
|
||||||
|
color: @finance-muted-text;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
accent-color: @primary-strong;
|
||||||
|
}
|
||||||
|
|
||||||
|
em {
|
||||||
|
font-style: normal;
|
||||||
|
color: @primary-strong;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__submit {
|
||||||
|
width: 100%;
|
||||||
|
height: 48px;
|
||||||
|
margin-top: 18px;
|
||||||
|
border: none;
|
||||||
|
border-radius: @profile-radius;
|
||||||
|
background: @primary-strong;
|
||||||
|
color: @white;
|
||||||
|
font-size: @font-size-base;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: darken(@primary-strong, 6%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,153 +1,138 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
const intro = '虫子智造是一站式研发智造一体化服务平台,聚焦零部件加工、模具开发、样品试制与批量生产需求,打通需求发布、方案对接、项目管控、合同结算全流程,为企业提供高效透明的智能制造外协解决方案。'
|
||||||
|
|
||||||
|
const mission = '连接需求方与制造资源,简化外协生产流程,降低企业研发与制造成本,助力中小制造企业数字化转型。'
|
||||||
|
|
||||||
const advantages = [
|
const advantages = [
|
||||||
{ title: '全流程线上化', desc: '需求发布、方案对接、进度跟踪、合同结算全程线上完成,状态实时可见。' },
|
{ title: '全流程线上化', desc: '需求提报、项目跟进、合同管理、财务对账全部线上完成,进度实时可见。' },
|
||||||
{ title: '优质制造资源', desc: '汇聚软硬件研发与量产优质服务商,严格资质审核,匹配更精准。' },
|
{ title: '优质制造资源', desc: '汇聚各类加工厂商,覆盖 3D 打印、机加工、注塑、钣金等多种工艺。' },
|
||||||
{ title: '规范化项目管控', desc: '标准化交付节点与验收流程,里程碑透明管控,项目风险早发现早处理。' },
|
{ title: '规范化项目管理', desc: '统一项目流程、质量标准、售后保障,降低合作风险。' },
|
||||||
{ title: '一站式服务', desc: '从零部件加工到模具开发、样品试制与批量生产,一个平台完成全部外协。' },
|
{ title: '一站式服务', desc: '从样品打样到批量生产,提供完整的智造交付支撑。' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const contacts = [
|
||||||
|
{ label: '客服热线:', value: '400-XXXX-XXXX' },
|
||||||
|
{ label: '工作时间:', value: '周一至周五 09:00 - 18:00' },
|
||||||
|
{ label: '平台官网:', value: 'www.chongzizhizao.com', link: true },
|
||||||
|
{ label: '公司地址:', value: '山东省日照市' },
|
||||||
]
|
]
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="about-panel">
|
<div class="about-panel">
|
||||||
<section class="about-panel__section">
|
<section class="about-panel__section">
|
||||||
<h3 class="about-panel__title">
|
<h3 class="about-panel__section-title">
|
||||||
平台简介
|
平台简介
|
||||||
</h3>
|
</h3>
|
||||||
<p class="about-panel__text">
|
<p class="about-panel__center-text">
|
||||||
虫子智造是一站式研发智造一体化服务平台,聚焦零部件加工、模具开发、样品试制与批量生产需求,打通需求发布、方案对接、项目管控、合同结算全流程,为企业提供高效透明的智能制造外协解决方案。
|
{{ intro }}
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="about-panel__section">
|
<section class="about-panel__section">
|
||||||
<h3 class="about-panel__title">
|
<h3 class="about-panel__section-title">
|
||||||
我们的使命
|
我们的使命
|
||||||
</h3>
|
</h3>
|
||||||
<p class="about-panel__text">
|
<p class="about-panel__center-text">
|
||||||
连接需求方与制造资源,简化外协生产流程,降低企业研发与制造成本,助力中小制造企业数字化转型。
|
{{ mission }}
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="about-panel__section">
|
<ol class="about-panel__advantages">
|
||||||
<h3 class="about-panel__title">
|
<li
|
||||||
核心优势
|
v-for="item in advantages"
|
||||||
</h3>
|
:key="item.title"
|
||||||
<ul class="about-panel__advantages">
|
class="about-panel__advantage"
|
||||||
<li
|
>
|
||||||
v-for="item in advantages"
|
<span class="about-panel__advantage-title">{{ item.title }}:</span>
|
||||||
:key="item.title"
|
<span class="about-panel__advantage-desc">{{ item.desc }}</span>
|
||||||
class="about-panel__advantage"
|
</li>
|
||||||
>
|
</ol>
|
||||||
<h4 class="about-panel__advantage-title">
|
|
||||||
{{ item.title }}
|
|
||||||
</h4>
|
|
||||||
<p class="about-panel__advantage-desc text-secondary">
|
|
||||||
{{ item.desc }}
|
|
||||||
</p>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="about-panel__section">
|
<ul class="about-panel__contacts">
|
||||||
<h3 class="about-panel__title">
|
<li v-for="item in contacts" :key="item.label" class="about-panel__contact">
|
||||||
联系信息
|
{{ item.label }}<a
|
||||||
</h3>
|
v-if="item.link"
|
||||||
<ul class="about-panel__contact">
|
href="https://www.chongzizhizao.com"
|
||||||
<li><span>客服热线:</span>400-XXXX-XXXX</li>
|
target="_blank"
|
||||||
<li><span>工作时间:</span>周一至周五 09:00 - 18:00</li>
|
rel="noopener noreferrer"
|
||||||
<li>
|
class="about-panel__link"
|
||||||
<span>平台官网:</span>
|
>{{ item.value }}</a>
|
||||||
<a
|
<template v-else>{{ item.value }}</template>
|
||||||
href="https://www.chongzizhizao.com"
|
</li>
|
||||||
target="_blank"
|
</ul>
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="about-panel__link"
|
|
||||||
>
|
|
||||||
https://www.chongzizhizao.com
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li><span>公司地址:</span>山东省日照市</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="about-panel__section">
|
|
||||||
<h3 class="about-panel__title">
|
|
||||||
版本信息
|
|
||||||
</h3>
|
|
||||||
<p class="about-panel__text">
|
|
||||||
当前系统版本:V1.0.0<br>
|
|
||||||
更新时间:2026 年 09 月
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.about-panel {
|
.about-panel {
|
||||||
.flex(column, flex-start);
|
.flex(column, flex-start);
|
||||||
gap: @spacing-lg;
|
width: 100%;
|
||||||
|
|
||||||
&__section {
|
&__section {
|
||||||
.flex(column, flex-start);
|
& + & {
|
||||||
gap: @spacing-sm;
|
margin-top: @spacing-lg;
|
||||||
padding: 0;
|
}
|
||||||
background: transparent;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__title {
|
&__section-title {
|
||||||
font-size: 11px;
|
font-size: @font-size-base;
|
||||||
|
font-weight: 600;
|
||||||
|
color: @heading-color;
|
||||||
|
line-height: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__center-text {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: @text-color;
|
||||||
|
line-height: 1.9;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__advantages {
|
||||||
|
margin: 56px 0 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
counter-reset: advantage;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__advantage {
|
||||||
|
counter-increment: advantage;
|
||||||
|
font-size: 13px;
|
||||||
|
color: @text-color;
|
||||||
|
line-height: 32px;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: counter(advantage) '. ';
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
& + & {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__advantage-title {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: @heading-color;
|
color: @heading-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__text {
|
&__advantage-desc {
|
||||||
font-size: 11px;
|
|
||||||
color: @text-color;
|
color: @text-color;
|
||||||
line-height: 1.9;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__advantages {
|
&__contacts {
|
||||||
display: grid;
|
margin: 46px 0 0;
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: @spacing-sm;
|
|
||||||
width: 100%;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
padding: 0;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__advantage {
|
|
||||||
.flex(row, flex-start);
|
|
||||||
gap: @spacing-sm;
|
|
||||||
padding: 0;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__advantage-title {
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: @text-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__advantage-desc {
|
|
||||||
font-size: 11px;
|
|
||||||
color: @text-color;
|
|
||||||
line-height: 1.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__contact {
|
&__contact {
|
||||||
.flex(column, flex-start);
|
font-size: 13px;
|
||||||
gap: @spacing-sm;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
list-style: none;
|
|
||||||
font-size: 11px;
|
|
||||||
color: @text-color;
|
color: @text-color;
|
||||||
|
line-height: 30px;
|
||||||
span {
|
|
||||||
color: @text-color;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__link {
|
&__link {
|
||||||
|
|||||||
@@ -148,10 +148,10 @@ function onSave(): void {
|
|||||||
&__title {
|
&__title {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding-left: 12px;
|
padding-left: 12px;
|
||||||
font-size: 12px;
|
font-size: @font-size-base;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
color: @heading-color;
|
color: @heading-color;
|
||||||
line-height: 20px;
|
line-height: 1.4;
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
content: '';
|
content: '';
|
||||||
@@ -172,7 +172,7 @@ function onSave(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__label {
|
&__label {
|
||||||
font-size: 11px;
|
font-size: @font-size-sm;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: @heading-color;
|
color: @heading-color;
|
||||||
}
|
}
|
||||||
@@ -180,11 +180,11 @@ function onSave(): void {
|
|||||||
&__input,
|
&__input,
|
||||||
&__textarea {
|
&__textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0 12px;
|
padding: 0 @spacing-base;
|
||||||
border: 1px solid @profile-input-border;
|
border: 1px solid @profile-input-border;
|
||||||
border-radius: @profile-radius;
|
border-radius: @profile-radius;
|
||||||
background: @white;
|
background: @white;
|
||||||
font-size: 11px;
|
font-size: @font-size-sm;
|
||||||
color: @text-color;
|
color: @text-color;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color 0.2s;
|
transition: border-color 0.2s;
|
||||||
@@ -195,7 +195,7 @@ function onSave(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
color: @heading-color;
|
color: @text-color-secondary;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,7 +218,7 @@ function onSave(): void {
|
|||||||
.app-button {
|
.app-button {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
font-size: 12px;
|
font-size: @font-size-sm;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
border-radius: @profile-radius;
|
border-radius: @profile-radius;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,299 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, ref, watch } from 'vue'
|
|
||||||
import { ElMessage } from 'element-plus'
|
|
||||||
import { AppButton, SmsCodeInput } from '@/components'
|
|
||||||
|
|
||||||
export type BindContactMode =
|
|
||||||
| 'phone-bind'
|
|
||||||
| 'phone-rebind'
|
|
||||||
| 'email-bind'
|
|
||||||
| 'email-rebind'
|
|
||||||
| 'identity'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
mode: BindContactMode
|
|
||||||
hintPhone?: string
|
|
||||||
hintEmail?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
hintPhone: '',
|
|
||||||
hintEmail: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
const visible = defineModel<boolean>('visible', { default: false })
|
|
||||||
|
|
||||||
const emit = defineEmits<{ submit: [payload: { contact: string; code: string }] }>()
|
|
||||||
|
|
||||||
const contact = ref('')
|
|
||||||
const code = ref('')
|
|
||||||
const agree = ref(false)
|
|
||||||
|
|
||||||
const isEmailMode = computed(() => props.mode === 'email-bind' || props.mode === 'email-rebind')
|
|
||||||
const isIdentity = computed(() => props.mode === 'identity')
|
|
||||||
|
|
||||||
const title = computed<string>(() => {
|
|
||||||
const map: Record<BindContactMode, string> = {
|
|
||||||
'phone-bind': '绑定手机号',
|
|
||||||
'phone-rebind': '验证并换绑',
|
|
||||||
'email-bind': '绑定邮箱',
|
|
||||||
'email-rebind': '更换绑定邮箱',
|
|
||||||
identity: '安全身份验证',
|
|
||||||
}
|
|
||||||
return map[props.mode]
|
|
||||||
})
|
|
||||||
|
|
||||||
const fieldLabel = computed<string>(() => {
|
|
||||||
const map: Record<BindContactMode, string> = {
|
|
||||||
'phone-bind': '手机号',
|
|
||||||
'phone-rebind': '新手机号',
|
|
||||||
'email-bind': '邮箱',
|
|
||||||
'email-rebind': '新邮箱',
|
|
||||||
identity: '手机号',
|
|
||||||
}
|
|
||||||
return map[props.mode]
|
|
||||||
})
|
|
||||||
|
|
||||||
const submitText = computed<string>(() => {
|
|
||||||
const map: Record<BindContactMode, string> = {
|
|
||||||
'phone-bind': '验证并绑定',
|
|
||||||
'phone-rebind': '验证并换绑',
|
|
||||||
'email-bind': '确认修改',
|
|
||||||
'email-rebind': '确认修改',
|
|
||||||
identity: '验证',
|
|
||||||
}
|
|
||||||
return map[props.mode]
|
|
||||||
})
|
|
||||||
|
|
||||||
const contactPlaceholder = computed<string>(() => {
|
|
||||||
if (isEmailMode.value) return props.hintEmail || '请输入邮箱'
|
|
||||||
return props.mode === 'phone-rebind' ? '请输入新手机号' : '请输入手机号'
|
|
||||||
})
|
|
||||||
|
|
||||||
const identityDesc = computed(() =>
|
|
||||||
isIdentity.value
|
|
||||||
? `安全身份验证:为确保账号安全,请完成身份验证后再进行账号操作,发送验证码至 ${props.hintPhone}`
|
|
||||||
: '',
|
|
||||||
)
|
|
||||||
|
|
||||||
watch(visible, (open) => {
|
|
||||||
if (!open) return
|
|
||||||
contact.value = isIdentity.value ? props.hintPhone : ''
|
|
||||||
code.value = ''
|
|
||||||
agree.value = false
|
|
||||||
})
|
|
||||||
|
|
||||||
function onGetCode(): void {
|
|
||||||
ElMessage.success('验证码已发送')
|
|
||||||
}
|
|
||||||
|
|
||||||
function onSubmit(): void {
|
|
||||||
if (!contact.value.trim()) {
|
|
||||||
ElMessage.warning(isEmailMode.value ? '请输入邮箱' : '请输入手机号')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!code.value) {
|
|
||||||
ElMessage.warning('请输入验证码')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!isIdentity.value && !agree.value) {
|
|
||||||
ElMessage.warning('请先阅读并同意《用户协议》和《隐私政策》')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
emit('submit', { contact: contact.value.trim(), code: code.value })
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Teleport to="body">
|
|
||||||
<div v-if="visible" class="bind-dialog" @click.self="visible = false">
|
|
||||||
<div class="bind-dialog__panel">
|
|
||||||
<h3 class="bind-dialog__title">
|
|
||||||
{{ title }}
|
|
||||||
</h3>
|
|
||||||
<p v-if="identityDesc" class="bind-dialog__desc text-secondary">
|
|
||||||
{{ identityDesc }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="bind-dialog__field">
|
|
||||||
<label class="bind-dialog__label">{{ fieldLabel }}</label>
|
|
||||||
<input
|
|
||||||
v-model="contact"
|
|
||||||
class="bind-dialog__input"
|
|
||||||
type="text"
|
|
||||||
:placeholder="contactPlaceholder"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bind-dialog__field">
|
|
||||||
<label class="bind-dialog__label">验证码</label>
|
|
||||||
<SmsCodeInput v-model:code="code" @get-code="onGetCode" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label v-if="!isIdentity" class="bind-dialog__agree">
|
|
||||||
<input v-model="agree" type="checkbox">
|
|
||||||
<span>我已阅读并同意<em>《用户协议》</em>和<em>《隐私政策》</em></span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div class="bind-dialog__footer">
|
|
||||||
<AppButton type="default" @click="visible = false">
|
|
||||||
取消
|
|
||||||
</AppButton>
|
|
||||||
<AppButton type="primary" @click="onSubmit">
|
|
||||||
{{ submitText }}
|
|
||||||
</AppButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style lang="less" scoped>
|
|
||||||
.bind-dialog {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 1000;
|
|
||||||
.flex(row, center);
|
|
||||||
background: transparent;
|
|
||||||
overflow-y: auto;
|
|
||||||
|
|
||||||
&__panel {
|
|
||||||
width: 700px;
|
|
||||||
max-width: calc(100vw - 32px);
|
|
||||||
max-height: calc(100vh - 48px);
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: @spacing-base;
|
|
||||||
border-radius: @radius-lg;
|
|
||||||
background: @white;
|
|
||||||
box-shadow: @shadow-lg;
|
|
||||||
.flex(column, flex-start);
|
|
||||||
gap: @spacing-base;
|
|
||||||
.scrollbar();
|
|
||||||
}
|
|
||||||
|
|
||||||
&__title {
|
|
||||||
position: relative;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__title:has(+ .bind-dialog__desc) {
|
|
||||||
padding-left: 0;
|
|
||||||
font-size: 11px;
|
|
||||||
color: @primary-strong;
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__desc {
|
|
||||||
padding: 0;
|
|
||||||
background: transparent;
|
|
||||||
color: @text-color-secondary;
|
|
||||||
font-size: 11px;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__field {
|
|
||||||
.flex(column, flex-start);
|
|
||||||
gap: @spacing-xs;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__label {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: @heading-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__input {
|
|
||||||
width: 100%;
|
|
||||||
height: 32px;
|
|
||||||
padding: 0 12px;
|
|
||||||
border: 1px solid @profile-input-border;
|
|
||||||
border-radius: @profile-radius;
|
|
||||||
background: @white;
|
|
||||||
font-size: 11px;
|
|
||||||
color: @text-color;
|
|
||||||
outline: none;
|
|
||||||
transition: border-color 0.2s;
|
|
||||||
box-sizing: border-box;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
border-color: @primary-strong;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::placeholder {
|
|
||||||
color: @heading-color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__agree {
|
|
||||||
.flex(row, flex-start, center);
|
|
||||||
gap: @spacing-sm;
|
|
||||||
font-size: 11px;
|
|
||||||
color: @text-color-secondary;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
input {
|
|
||||||
accent-color: @primary-strong;
|
|
||||||
}
|
|
||||||
|
|
||||||
em {
|
|
||||||
font-style: normal;
|
|
||||||
color: @primary-strong;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__footer {
|
|
||||||
.flex(row, flex-start);
|
|
||||||
gap: @spacing-sm;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-button {
|
|
||||||
flex: 1;
|
|
||||||
height: 36px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
border-radius: @profile-radius;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-button--primary {
|
|
||||||
border-color: @primary-strong;
|
|
||||||
background: @primary-strong;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: darken(@primary-strong, 6%);
|
|
||||||
background: darken(@primary-strong, 6%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-button--default {
|
|
||||||
border: none;
|
|
||||||
background: @profile-quiet-btn;
|
|
||||||
color: @text-color;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border: none;
|
|
||||||
background: darken(@profile-quiet-btn, 4%);
|
|
||||||
color: @text-color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,256 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref, watch } from 'vue'
|
|
||||||
import { ElMessage } from 'element-plus'
|
|
||||||
import { AppButton, PasswordStrength, SmsCodeInput } from '@/components'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
phone: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
|
||||||
|
|
||||||
const visible = defineModel<boolean>('visible', { default: false })
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
submit: [payload: { code: string; password: string; confirm: string }]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const code = ref('')
|
|
||||||
const password = ref('')
|
|
||||||
const confirm = ref('')
|
|
||||||
const agree = ref(false)
|
|
||||||
|
|
||||||
watch(visible, (open) => {
|
|
||||||
if (!open) return
|
|
||||||
code.value = ''
|
|
||||||
password.value = ''
|
|
||||||
confirm.value = ''
|
|
||||||
agree.value = false
|
|
||||||
})
|
|
||||||
|
|
||||||
function onGetCode(): void {
|
|
||||||
ElMessage.success('验证码已发送')
|
|
||||||
}
|
|
||||||
|
|
||||||
function onSubmit(): void {
|
|
||||||
if (!code.value) {
|
|
||||||
ElMessage.warning('请输入验证码')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!password.value) {
|
|
||||||
ElMessage.warning('请输入新密码')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!agree.value) {
|
|
||||||
ElMessage.warning('请先阅读并同意《用户协议》和《隐私政策》')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
emit('submit', {
|
|
||||||
code: code.value,
|
|
||||||
password: password.value,
|
|
||||||
confirm: confirm.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Teleport to="body">
|
|
||||||
<div v-if="visible" class="change-pwd-dialog" @click.self="visible = false">
|
|
||||||
<div class="change-pwd-dialog__panel">
|
|
||||||
<h3 class="change-pwd-dialog__title">
|
|
||||||
修改密码
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div class="change-pwd-dialog__field">
|
|
||||||
<label class="change-pwd-dialog__label">手机号</label>
|
|
||||||
<input
|
|
||||||
class="change-pwd-dialog__input"
|
|
||||||
type="text"
|
|
||||||
:value="props.phone"
|
|
||||||
readonly
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="change-pwd-dialog__field">
|
|
||||||
<label class="change-pwd-dialog__label">验证码</label>
|
|
||||||
<SmsCodeInput v-model:code="code" @get-code="onGetCode" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="change-pwd-dialog__field">
|
|
||||||
<label class="change-pwd-dialog__label">设置密码</label>
|
|
||||||
<input
|
|
||||||
v-model="password"
|
|
||||||
class="change-pwd-dialog__input"
|
|
||||||
type="password"
|
|
||||||
placeholder="请输入密码(6-20位)"
|
|
||||||
maxlength="20"
|
|
||||||
>
|
|
||||||
<PasswordStrength :password="password" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="change-pwd-dialog__field">
|
|
||||||
<label class="change-pwd-dialog__label">确认密码</label>
|
|
||||||
<input
|
|
||||||
v-model="confirm"
|
|
||||||
class="change-pwd-dialog__input"
|
|
||||||
type="password"
|
|
||||||
placeholder="请再次输入密码"
|
|
||||||
maxlength="20"
|
|
||||||
@keyup.enter="onSubmit"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label class="change-pwd-dialog__agree">
|
|
||||||
<input v-model="agree" type="checkbox">
|
|
||||||
<span>我已阅读并同意<em>《用户协议》</em>和<em>《隐私政策》</em></span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div class="change-pwd-dialog__footer">
|
|
||||||
<AppButton type="default" @click="visible = false">
|
|
||||||
取消
|
|
||||||
</AppButton>
|
|
||||||
<AppButton type="primary" @click="onSubmit">
|
|
||||||
确认修改
|
|
||||||
</AppButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style lang="less" scoped>
|
|
||||||
.change-pwd-dialog {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 1000;
|
|
||||||
.flex(row, center);
|
|
||||||
background: transparent;
|
|
||||||
overflow-y: auto;
|
|
||||||
|
|
||||||
&__panel {
|
|
||||||
width: 700px;
|
|
||||||
max-width: calc(100vw - 32px);
|
|
||||||
max-height: calc(100vh - 48px);
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: @spacing-base;
|
|
||||||
border-radius: @radius-lg;
|
|
||||||
background: @white;
|
|
||||||
box-shadow: @shadow-lg;
|
|
||||||
.flex(column, flex-start);
|
|
||||||
gap: @spacing-base;
|
|
||||||
.scrollbar();
|
|
||||||
}
|
|
||||||
|
|
||||||
&__title {
|
|
||||||
position: relative;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__field {
|
|
||||||
.flex(column, flex-start);
|
|
||||||
gap: @spacing-xs;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__label {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: @heading-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__input {
|
|
||||||
width: 100%;
|
|
||||||
height: 32px;
|
|
||||||
padding: 0 12px;
|
|
||||||
border: 1px solid @profile-input-border;
|
|
||||||
border-radius: @profile-radius;
|
|
||||||
background: @white;
|
|
||||||
font-size: 11px;
|
|
||||||
color: @text-color;
|
|
||||||
outline: none;
|
|
||||||
transition: border-color 0.2s;
|
|
||||||
box-sizing: border-box;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
border-color: @primary-strong;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::placeholder {
|
|
||||||
color: @heading-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:read-only {
|
|
||||||
color: @text-color;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__agree {
|
|
||||||
.flex(row, flex-start, center);
|
|
||||||
gap: @spacing-sm;
|
|
||||||
font-size: 11px;
|
|
||||||
color: @text-color-secondary;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
input {
|
|
||||||
accent-color: @primary-strong;
|
|
||||||
}
|
|
||||||
|
|
||||||
em {
|
|
||||||
font-style: normal;
|
|
||||||
color: @primary-strong;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__footer {
|
|
||||||
.flex(row, flex-start);
|
|
||||||
gap: @spacing-sm;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-button {
|
|
||||||
flex: 1;
|
|
||||||
height: 36px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
border-radius: @profile-radius;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-button--primary {
|
|
||||||
border-color: @primary-strong;
|
|
||||||
background: @primary-strong;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: darken(@primary-strong, 6%);
|
|
||||||
background: darken(@primary-strong, 6%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-button--default {
|
|
||||||
border: none;
|
|
||||||
background: @profile-quiet-btn;
|
|
||||||
color: @text-color;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border: none;
|
|
||||||
background: darken(@profile-quiet-btn, 4%);
|
|
||||||
color: @text-color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { CaseCard } from '@/components'
|
|
||||||
import type { Case } from '@/types/business/cases'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
cases: Case[]
|
|
||||||
}
|
|
||||||
|
|
||||||
defineProps<Props>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{ view: [id: string] }>()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="favorites-grid">
|
|
||||||
<template v-if="cases.length > 0">
|
|
||||||
<CaseCard
|
|
||||||
v-for="item in cases"
|
|
||||||
:key="item.id"
|
|
||||||
:item="item"
|
|
||||||
@view="emit('view', $event)"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<p v-else class="favorites-grid__empty text-secondary">
|
|
||||||
暂无收藏案例,快去案例库挑选感兴趣的内容吧
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style lang="less" scoped>
|
|
||||||
.favorites-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: @spacing-base;
|
|
||||||
|
|
||||||
&__empty {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
padding: @spacing-xxl 0;
|
|
||||||
text-align: center;
|
|
||||||
font-size: @font-size-base;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, watch } from 'vue'
|
import { reactive, ref, watch } from 'vue'
|
||||||
import { AppButton, FileList, UploadArea } from '@/components'
|
import { ElMessage } from 'element-plus'
|
||||||
import type { FileInfo } from '@/types/api'
|
import type { FileInfo } from '@/types/api'
|
||||||
import type { Feedback } from '@/types/business/profile'
|
import type { Feedback } from '@/types/business/profile'
|
||||||
|
|
||||||
@@ -12,7 +12,6 @@ const props = withDefaults(defineProps<Props>(), { resetSignal: 0 })
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
submit: [feedback: Feedback]
|
submit: [feedback: Feedback]
|
||||||
reset: []
|
|
||||||
preview: [file: FileInfo]
|
preview: [file: FileInfo]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@@ -22,6 +21,7 @@ const form = reactive({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const attachments = reactive<FileInfo[]>([])
|
const attachments = reactive<FileInfo[]>([])
|
||||||
|
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
function clear(): void {
|
function clear(): void {
|
||||||
form.title = ''
|
form.title = ''
|
||||||
@@ -34,13 +34,34 @@ watch(
|
|||||||
() => clear(),
|
() => clear(),
|
||||||
)
|
)
|
||||||
|
|
||||||
function onUpload(file: File): void {
|
function openFilePicker(): void {
|
||||||
attachments.push({
|
fileInputRef.value?.click()
|
||||||
name: file.name,
|
}
|
||||||
size: file.size,
|
|
||||||
uploadTime: new Date().toISOString().slice(0, 10),
|
function onFileChange(event: Event): void {
|
||||||
url: '',
|
const input = event.target as HTMLInputElement
|
||||||
})
|
const files = input.files
|
||||||
|
if (!files) return
|
||||||
|
for (const file of Array.from(files)) {
|
||||||
|
attachments.push({
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
uploadTime: new Date().toISOString().slice(0, 10),
|
||||||
|
url: '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
input.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function extOf(name: string): string {
|
||||||
|
const index = name.lastIndexOf('.')
|
||||||
|
return index >= 0 ? name.slice(index + 1, index + 4).toUpperCase() : 'FILE'
|
||||||
|
}
|
||||||
|
|
||||||
|
function sizeText(size: number): string {
|
||||||
|
if (size >= 1024 * 1024) return `${(size / 1024 / 1024).toFixed(1)}MB`
|
||||||
|
if (size >= 1024) return `${(size / 1024).toFixed(0)}KB`
|
||||||
|
return `${size}B`
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDeleteFile(file: FileInfo): void {
|
function onDeleteFile(file: FileInfo): void {
|
||||||
@@ -48,17 +69,25 @@ function onDeleteFile(file: FileInfo): void {
|
|||||||
if (index >= 0) attachments.splice(index, 1)
|
if (index >= 0) attachments.splice(index, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onPreview(file: FileInfo): void {
|
||||||
|
emit('preview', file)
|
||||||
|
}
|
||||||
|
|
||||||
function onSubmit(): void {
|
function onSubmit(): void {
|
||||||
|
if (!form.title.trim()) {
|
||||||
|
ElMessage.warning('请填写反馈标题')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!form.description.trim()) {
|
||||||
|
ElMessage.warning('请填写详细描述')
|
||||||
|
return
|
||||||
|
}
|
||||||
emit('submit', {
|
emit('submit', {
|
||||||
title: form.title,
|
title: form.title,
|
||||||
description: form.description,
|
description: form.description,
|
||||||
attachments: [...attachments],
|
attachments: [...attachments],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function onReset(): void {
|
|
||||||
emit('reset')
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -69,7 +98,7 @@ function onReset(): void {
|
|||||||
v-model="form.title"
|
v-model="form.title"
|
||||||
class="feedback-form__input"
|
class="feedback-form__input"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="请简要描述您的反馈标题"
|
placeholder="请输入反馈标题"
|
||||||
maxlength="50"
|
maxlength="50"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -79,34 +108,57 @@ function onReset(): void {
|
|||||||
<textarea
|
<textarea
|
||||||
v-model="form.description"
|
v-model="form.description"
|
||||||
class="feedback-form__textarea"
|
class="feedback-form__textarea"
|
||||||
placeholder="请详细描述您遇到的问题或建议,便于我们更快为您处理"
|
placeholder="请详细描述您的问题..."
|
||||||
maxlength="500"
|
maxlength="500"
|
||||||
rows="6"
|
|
||||||
/>
|
/>
|
||||||
<span class="feedback-form__counter text-secondary">{{ form.description.length }}/500</span>
|
<span class="feedback-form__counter">{{ form.description.length }}/500</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="feedback-form__tip">
|
<div class="feedback-form__attach-head">
|
||||||
提交后我们的专业顾问将在一个工作日内与您联系,为您提供一对一服务
|
<h3 class="feedback-form__attach-title">
|
||||||
</p>
|
附件上传
|
||||||
|
</h3>
|
||||||
<div class="feedback-form__field">
|
<span class="feedback-form__attach-hint">支持图片/文档/压缩包,大小不超过1G</span>
|
||||||
<label class="feedback-form__label">附件</label>
|
<button class="feedback-form__upload-btn" @click="openFilePicker">
|
||||||
<UploadArea @upload="onUpload" />
|
上传
|
||||||
<FileList
|
</button>
|
||||||
:files="attachments"
|
<input
|
||||||
@preview="emit('preview', $event)"
|
ref="fileInputRef"
|
||||||
@delete="onDeleteFile"
|
class="feedback-form__file-input"
|
||||||
/>
|
type="file"
|
||||||
|
multiple
|
||||||
|
@change="onFileChange"
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ul class="feedback-form__files">
|
||||||
|
<li v-for="file in attachments" :key="file.name" class="feedback-form__file">
|
||||||
|
<span class="feedback-form__file-icon" :class="`is-${extOf(file.name).toLowerCase()}`">
|
||||||
|
{{ extOf(file.name) }}
|
||||||
|
</span>
|
||||||
|
<div class="feedback-form__file-info">
|
||||||
|
<p class="feedback-form__file-name">
|
||||||
|
{{ file.name }}
|
||||||
|
</p>
|
||||||
|
<p class="feedback-form__file-meta">
|
||||||
|
{{ sizeText(file.size) }} · 上传于 {{ file.uploadTime }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="feedback-form__file-actions">
|
||||||
|
<button class="feedback-form__file-link" @click="onPreview(file)">
|
||||||
|
预览
|
||||||
|
</button>
|
||||||
|
<button class="feedback-form__file-link is-danger" @click="onDeleteFile(file)">
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
<div class="feedback-form__footer">
|
<div class="feedback-form__footer">
|
||||||
<AppButton type="primary" @click="onSubmit">
|
<button class="feedback-form__submit" @click="onSubmit">
|
||||||
提交反馈
|
提交反馈
|
||||||
</AppButton>
|
</button>
|
||||||
<AppButton type="default" @click="onReset">
|
|
||||||
重置
|
|
||||||
</AppButton>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -114,21 +166,20 @@ function onReset(): void {
|
|||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.feedback-form {
|
.feedback-form {
|
||||||
.flex(column, flex-start);
|
.flex(column, flex-start);
|
||||||
gap: @spacing-base;
|
gap: 20px;
|
||||||
padding: 0;
|
width: 100%;
|
||||||
background: transparent;
|
|
||||||
box-shadow: none;
|
|
||||||
|
|
||||||
&__field {
|
&__field {
|
||||||
.flex(column, flex-start);
|
.flex(column, flex-start);
|
||||||
gap: @spacing-xs;
|
gap: 10px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__label {
|
&__label {
|
||||||
font-size: 11px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: @heading-color;
|
color: @heading-color;
|
||||||
|
line-height: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__required {
|
&__required {
|
||||||
@@ -139,11 +190,11 @@ function onReset(): void {
|
|||||||
&__input,
|
&__input,
|
||||||
&__textarea {
|
&__textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0 12px;
|
padding: 0 14px;
|
||||||
border: 1px solid @profile-input-border;
|
border: 1px solid @profile-input-border;
|
||||||
border-radius: @profile-radius;
|
border-radius: @radius-base;
|
||||||
background: @white;
|
background: @white;
|
||||||
font-size: 11px;
|
font-size: 13px;
|
||||||
color: @text-color;
|
color: @text-color;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color 0.2s;
|
transition: border-color 0.2s;
|
||||||
@@ -154,71 +205,175 @@ function onReset(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
color: @heading-color;
|
color: @text-color-disabled;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__input {
|
&__input {
|
||||||
height: 32px;
|
height: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__textarea {
|
&__textarea {
|
||||||
padding: 8px 12px;
|
height: 150px;
|
||||||
line-height: 1.6;
|
padding: 12px 14px;
|
||||||
|
line-height: 1.7;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__counter {
|
&__counter {
|
||||||
align-self: flex-end;
|
align-self: flex-end;
|
||||||
color: @text-color-disabled;
|
color: @text-color-disabled;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
|
line-height: 17px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__tip {
|
&__attach-head {
|
||||||
padding: @spacing-sm @spacing-base;
|
.flex(row, flex-start, center);
|
||||||
border-radius: @profile-radius;
|
gap: 14px;
|
||||||
background: @primary-light;
|
width: 100%;
|
||||||
color: @primary-strong;
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__attach-title {
|
||||||
|
font-size: @font-size-base;
|
||||||
|
font-weight: 600;
|
||||||
|
color: @heading-color;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__attach-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: @text-color-disabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__upload-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 18px;
|
||||||
|
border: 1px solid @profile-input-border;
|
||||||
|
border-radius: @radius-base;
|
||||||
|
background: @white;
|
||||||
|
color: @text-color;
|
||||||
|
font-size: 13px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: @primary-strong;
|
||||||
|
color: @primary-strong;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__file-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__files {
|
||||||
|
.flex(column, flex-start);
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__file {
|
||||||
|
.flex(row, flex-start, center);
|
||||||
|
gap: 14px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 13px 16px;
|
||||||
|
border-radius: @radius-base;
|
||||||
|
background: @service-input-bg;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__file-icon {
|
||||||
|
.flex(row, center);
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: @radius-base;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
line-height: 1.6;
|
font-weight: 700;
|
||||||
|
|
||||||
|
&.is-pdf {
|
||||||
|
background: @profile-icon-phone;
|
||||||
|
color: @danger-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-doc,
|
||||||
|
&.is-docx {
|
||||||
|
background: @profile-page-active-bg;
|
||||||
|
color: @primary-strong;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-xls,
|
||||||
|
&.is-xlsx {
|
||||||
|
background: @profile-tag-ok-bg;
|
||||||
|
color: @success-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__file-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__file-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: @heading-color;
|
||||||
|
line-height: 20px;
|
||||||
|
.ellipsis();
|
||||||
|
}
|
||||||
|
|
||||||
|
&__file-meta {
|
||||||
|
margin-top: 3px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: @text-color-disabled;
|
||||||
|
line-height: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__file-actions {
|
||||||
|
.flex(row, flex-end, center);
|
||||||
|
gap: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__file-link {
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: @primary-strong;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: @primary-hover;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-danger {
|
||||||
|
color: @danger-color;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__footer {
|
&__footer {
|
||||||
.flex(row, flex-start);
|
.flex(row, flex-start);
|
||||||
gap: @spacing-sm;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.upload-area) {
|
&__submit {
|
||||||
min-height: 56px;
|
height: 40px;
|
||||||
border: 1px solid @profile-input-border;
|
padding: 0 32px;
|
||||||
background: @white;
|
border: none;
|
||||||
|
border-radius: @radius-base;
|
||||||
&:hover,
|
|
||||||
&.is-dragging {
|
|
||||||
background: @bg-color-light;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.upload-area__hint) {
|
|
||||||
color: @text-color-disabled;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-button {
|
|
||||||
height: 32px;
|
|
||||||
padding: 0 16px;
|
|
||||||
border-radius: @profile-radius;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-button--primary {
|
|
||||||
border-color: @primary-strong;
|
|
||||||
background: @primary-strong;
|
background: @primary-strong;
|
||||||
|
color: @white;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background 0.2s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border-color: darken(@primary-strong, 6%);
|
background: @primary-hover;
|
||||||
background: darken(@primary-strong, 6%);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +1,50 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { HelpFaqGroup } from '@/types/business/profile'
|
import { computed } from 'vue'
|
||||||
|
import type { HelpFaqGroup, HelpFaqItem } from '@/types/business/profile'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
groups: HelpFaqGroup[]
|
groups: HelpFaqGroup[]
|
||||||
}
|
}
|
||||||
|
|
||||||
defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const questions = computed<HelpFaqItem[]>(() =>
|
||||||
|
props.groups.flatMap((group) => group.items),
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="help-faq">
|
<div class="help-faq">
|
||||||
<details
|
<aside class="help-faq__aside">
|
||||||
v-for="group in groups"
|
<p
|
||||||
:key="group.title"
|
v-for="item in questions"
|
||||||
class="help-faq__group"
|
:key="item.q"
|
||||||
open
|
class="help-faq__q"
|
||||||
>
|
>
|
||||||
<summary class="help-faq__summary">
|
Q: {{ item.q }}
|
||||||
{{ group.title }}
|
</p>
|
||||||
</summary>
|
</aside>
|
||||||
<ul class="help-faq__list">
|
|
||||||
<li
|
<div class="help-faq__main">
|
||||||
v-for="item in group.items"
|
<div class="help-faq__main-inner">
|
||||||
:key="item.q"
|
<section
|
||||||
class="help-faq__item"
|
v-for="(group, gi) in groups"
|
||||||
|
:key="group.title"
|
||||||
|
class="help-faq__group"
|
||||||
>
|
>
|
||||||
<p class="help-faq__q">
|
<h3 class="help-faq__group-title">
|
||||||
Q:{{ item.q }}
|
{{ gi + 1 }}、{{ group.title }}
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
v-for="item in group.items"
|
||||||
|
:key="`${group.title}-${item.q}`"
|
||||||
|
class="help-faq__a"
|
||||||
|
>
|
||||||
|
{{ item.a }}
|
||||||
</p>
|
</p>
|
||||||
<p class="help-faq__a text-secondary">
|
</section>
|
||||||
A:{{ item.a }}
|
</div>
|
||||||
</p>
|
</div>
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<p v-if="groups.length === 0" class="help-faq__empty text-secondary">
|
<p v-if="groups.length === 0" class="help-faq__empty text-secondary">
|
||||||
暂无常见问题
|
暂无常见问题
|
||||||
@@ -43,69 +54,79 @@ defineProps<Props>()
|
|||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.help-faq {
|
.help-faq {
|
||||||
.flex(column, flex-start);
|
display: grid;
|
||||||
gap: @spacing-base;
|
grid-template-columns: max-content 1fr;
|
||||||
|
column-gap: @spacing-xxl;
|
||||||
|
align-items: start;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
&__group {
|
&__aside {
|
||||||
background: transparent;
|
.flex(column, flex-start, flex-start);
|
||||||
}
|
gap: @spacing-xl;
|
||||||
|
text-align: left;
|
||||||
&__summary {
|
|
||||||
.flex(row, center, center);
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: @heading-color;
|
|
||||||
cursor: pointer;
|
|
||||||
list-style: none;
|
|
||||||
user-select: none;
|
|
||||||
|
|
||||||
&::-webkit-details-marker {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::after {
|
|
||||||
content: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: transparent;
|
|
||||||
color: @primary-strong;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__list {
|
|
||||||
.flex(column, flex-start);
|
|
||||||
gap: 6px;
|
|
||||||
margin: 0;
|
|
||||||
padding: @spacing-sm 0 0;
|
|
||||||
list-style: none;
|
|
||||||
border-top: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__item {
|
|
||||||
.flex(column, flex-start);
|
|
||||||
gap: @spacing-xs;
|
|
||||||
padding-top: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__q {
|
&__q {
|
||||||
font-size: 11px;
|
margin: 0;
|
||||||
font-weight: 700;
|
font-size: @font-size-base;
|
||||||
|
font-weight: 600;
|
||||||
color: @heading-color;
|
color: @heading-color;
|
||||||
|
line-height: 1.6;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__main {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-start;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__main-inner {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 0 auto;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__group {
|
||||||
|
.flex(column, flex-start, center);
|
||||||
|
gap: @spacing-base;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
& + & {
|
||||||
|
margin-top: @spacing-xxl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__group-title {
|
||||||
|
margin: 0;
|
||||||
|
width: 100%;
|
||||||
|
font-size: @font-size-base;
|
||||||
|
font-weight: 600;
|
||||||
|
color: @heading-color;
|
||||||
|
line-height: 1.4;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__a {
|
&__a {
|
||||||
font-size: 11px;
|
margin: 0;
|
||||||
color: @text-color;
|
width: 100%;
|
||||||
line-height: 1.7;
|
font-size: @font-size-sm;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: @text-color-secondary;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__empty {
|
&__empty {
|
||||||
|
grid-column: 1 / -1;
|
||||||
padding: @spacing-xxl 0;
|
padding: @spacing-xxl 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 11px;
|
font-size: @font-size-sm;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -6,77 +6,69 @@ const WORK_TIME = '工作日 8:00-12:00 14:00-18:00'
|
|||||||
<template>
|
<template>
|
||||||
<div class="hotline-panel">
|
<div class="hotline-panel">
|
||||||
<div class="hotline-panel__card">
|
<div class="hotline-panel__card">
|
||||||
<span class="hotline-panel__icon" aria-hidden="true">📞</span>
|
<span class="hotline-panel__icon" aria-hidden="true" />
|
||||||
<h3 class="hotline-panel__title">
|
<div class="hotline-panel__info">
|
||||||
专属客服热线
|
<h3 class="hotline-panel__title">
|
||||||
</h3>
|
专属客服热线
|
||||||
<p class="hotline-panel__number">
|
</h3>
|
||||||
{{ HOTLINE }}
|
<p class="hotline-panel__number">
|
||||||
</p>
|
{{ HOTLINE }}
|
||||||
<p class="hotline-panel__time text-secondary">
|
</p>
|
||||||
{{ WORK_TIME }}
|
<p class="hotline-panel__time">
|
||||||
</p>
|
{{ WORK_TIME }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.hotline-panel {
|
.hotline-panel {
|
||||||
.flex(row, flex-start);
|
width: 100%;
|
||||||
padding: 0;
|
|
||||||
|
|
||||||
|
// 设计稿 #01:浅蓝横幅卡,蓝圆 + 标题/电话/时间三行左对齐
|
||||||
&__card {
|
&__card {
|
||||||
display: grid;
|
.flex(row, flex-start, center);
|
||||||
grid-template-columns: auto 1fr;
|
gap: @spacing-lg;
|
||||||
grid-template-areas:
|
|
||||||
'icon title'
|
|
||||||
'icon number'
|
|
||||||
'icon time';
|
|
||||||
column-gap: @spacing-base;
|
|
||||||
align-content: center;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100%;
|
|
||||||
padding: @spacing-lg;
|
padding: @spacing-lg;
|
||||||
border-radius: @radius-base;
|
border-radius: @radius-base;
|
||||||
background: @profile-hotline-bg;
|
background: @filter-selected-bg;
|
||||||
text-align: left;
|
box-sizing: border-box;
|
||||||
align-items: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__icon {
|
&__icon {
|
||||||
.flex(row, center);
|
|
||||||
grid-area: icon;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 49px;
|
width: 40px;
|
||||||
height: 49px;
|
height: 40px;
|
||||||
margin: 0;
|
|
||||||
border-radius: @radius-round;
|
border-radius: @radius-round;
|
||||||
background: @primary-strong;
|
background: @primary-strong;
|
||||||
font-size: @font-size-lg;
|
}
|
||||||
line-height: 1;
|
|
||||||
|
&__info {
|
||||||
|
.flex(column, flex-start);
|
||||||
}
|
}
|
||||||
|
|
||||||
&__title {
|
&__title {
|
||||||
grid-area: title;
|
font-size: @font-size-base;
|
||||||
align-self: end;
|
font-weight: 600;
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: @heading-color;
|
color: @heading-color;
|
||||||
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__number {
|
&__number {
|
||||||
grid-area: number;
|
margin-top: @spacing-sm;
|
||||||
font-size: 18px;
|
font-size: @font-size-xl;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
color: @primary-strong;
|
color: @primary-strong;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__time {
|
&__time {
|
||||||
grid-area: time;
|
margin-top: @spacing-xs;
|
||||||
align-self: start;
|
font-size: @font-size-sm;
|
||||||
color: @text-color-secondary;
|
color: @text-color-secondary;
|
||||||
font-size: 11px;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ interface MenuItem {
|
|||||||
const menus: MenuItem[] = [
|
const menus: MenuItem[] = [
|
||||||
{ to: '/profile/security', label: '账号安全' },
|
{ to: '/profile/security', label: '账号安全' },
|
||||||
{ to: '/profile/address', label: '地址管理' },
|
{ to: '/profile/address', label: '地址管理' },
|
||||||
{ to: '/profile/favorites', label: '收藏列表' },
|
{ to: '/favorites', label: '收藏列表' },
|
||||||
{ to: '/profile/qualifications', label: '资质列表' },
|
{ to: '/profile/qualifications', label: '资质列表' },
|
||||||
{ to: '/profile/feedback', label: '意见反馈' },
|
{ to: '/profile/feedback', label: '意见反馈' },
|
||||||
{ to: '/profile/help', label: '帮助中心' },
|
{ to: '/profile/help', label: '帮助中心' },
|
||||||
@@ -32,29 +32,31 @@ const menus: MenuItem[] = [
|
|||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.profile-sider {
|
.profile-sider {
|
||||||
.flex(column, flex-start);
|
.flex(column, flex-start);
|
||||||
gap: 0;
|
gap: 2px;
|
||||||
padding: 4px 0;
|
padding: @spacing-sm 0;
|
||||||
border-radius: @radius-lg;
|
border-radius: @radius-base;
|
||||||
background: @white;
|
background: @white;
|
||||||
|
|
||||||
&__item {
|
&__item {
|
||||||
.flex(row, flex-start, center);
|
.flex(row, flex-start, center);
|
||||||
height: 36px;
|
height: 40px;
|
||||||
padding: 0 24px;
|
margin: 0 @spacing-sm;
|
||||||
border-radius: @profile-radius;
|
padding: 0 @spacing-base;
|
||||||
|
border-radius: @radius-sm;
|
||||||
color: @text-color;
|
color: @text-color;
|
||||||
font-size: @font-size-base;
|
font-size: @font-size-sm;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: @primary-strong;
|
color: @primary-strong;
|
||||||
|
background: @bg-color-light;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.router-link-active {
|
&.router-link-active {
|
||||||
background: @profile-nav-active-bg;
|
background: @filter-selected-bg;
|
||||||
color: @primary-strong;
|
color: @primary-strong;
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ function save(): void {
|
|||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.profile-user-card {
|
.profile-user-card {
|
||||||
.flex(column, center, center);
|
.flex(column, center, center);
|
||||||
height: 110px;
|
height: 146px;
|
||||||
border-radius: @radius-lg;
|
border-radius: @radius-lg;
|
||||||
background: linear-gradient(180deg, @profile-user-from 0%, @profile-user-to 100%);
|
background: linear-gradient(180deg, @profile-user-from 0%, @profile-user-to 100%);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -69,13 +69,13 @@ function save(): void {
|
|||||||
|
|
||||||
&__name {
|
&__name {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 26px;
|
height: 22px;
|
||||||
margin-top: 6px;
|
margin-top: 8px;
|
||||||
padding: 0 @spacing-xs;
|
padding: 0 @spacing-xs;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: @profile-radius;
|
border-radius: @profile-radius;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
font-size: @font-size-lg;
|
font-size: @font-size-base;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: @white;
|
color: @white;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -92,14 +92,14 @@ function save(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
font-size: @font-size-lg;
|
font-size: @font-size-base;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: fade(@white, 75%);
|
color: fade(@white, 75%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__phone {
|
&__phone {
|
||||||
margin-top: 6px;
|
margin-top: 4px;
|
||||||
font-size: @font-size-sm;
|
font-size: @font-size-sm;
|
||||||
color: @white;
|
color: @white;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { StatusTag } from '@/components'
|
import { EllipsisText } from '@/components'
|
||||||
import type { Qualification, QualificationStatus } from '@/types/business/profile'
|
import type { Qualification, QualificationStatus } from '@/types/business/profile'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -14,144 +14,139 @@ const emit = defineEmits<{
|
|||||||
reupload: [qualification: Qualification]
|
reupload: [qualification: Qualification]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const STATUS_VARIANTS: Record<QualificationStatus, 'success' | 'warning' | 'danger'> = {
|
const STATUS_CLASS: Record<QualificationStatus, string> = {
|
||||||
已认证: 'success',
|
已认证: 'is-certified',
|
||||||
审核中: 'warning',
|
审核中: 'is-reviewing',
|
||||||
未通过: 'danger',
|
未通过: 'is-rejected',
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<article class="qual-card">
|
<article class="qual-row">
|
||||||
<div class="qual-card__head">
|
<div class="qual-row__col">
|
||||||
<h3 class="qual-card__name">
|
<p class="qual-row__label">资质类型</p>
|
||||||
{{ qualification.certName }}
|
<EllipsisText class="qual-row__value" :text="qualification.type" />
|
||||||
</h3>
|
</div>
|
||||||
<StatusTag
|
<div class="qual-row__col qual-row__col--name">
|
||||||
:label="qualification.status"
|
<p class="qual-row__label">证书名称</p>
|
||||||
:variant="STATUS_VARIANTS[qualification.status]"
|
<EllipsisText class="qual-row__value" :text="qualification.certName" />
|
||||||
|
</div>
|
||||||
|
<div class="qual-row__col">
|
||||||
|
<p class="qual-row__label">证书编号</p>
|
||||||
|
<EllipsisText class="qual-row__value" :text="qualification.certNo" />
|
||||||
|
</div>
|
||||||
|
<div class="qual-row__col">
|
||||||
|
<p class="qual-row__label">发证单位</p>
|
||||||
|
<EllipsisText class="qual-row__value" :text="qualification.issuer" />
|
||||||
|
</div>
|
||||||
|
<div class="qual-row__col qual-row__col--valid">
|
||||||
|
<p class="qual-row__label">有效期</p>
|
||||||
|
<EllipsisText
|
||||||
|
class="qual-row__value"
|
||||||
|
:text="qualification.validFrom + ' 至 ' + qualification.validTo"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="qual-row__col qual-row__col--status">
|
||||||
<ul class="qual-card__meta">
|
<p class="qual-row__label">状态</p>
|
||||||
<li><span>资质类型:</span>{{ qualification.type }}</li>
|
<p class="qual-row__value qual-row__status" :class="STATUS_CLASS[qualification.status]">
|
||||||
<li><span>证书编号:</span>{{ qualification.certNo }}</li>
|
{{ qualification.status }}
|
||||||
<li><span>发证单位:</span>{{ qualification.issuer }}</li>
|
</p>
|
||||||
<li><span>有效期:</span>{{ qualification.validFrom }} 至 {{ qualification.validTo }}</li>
|
</div>
|
||||||
</ul>
|
<div class="qual-row__actions">
|
||||||
|
<button type="button" class="qual-row__btn" @click="emit('delete', qualification)">
|
||||||
<div class="qual-card__actions">
|
|
||||||
<button class="qual-card__btn" @click="emit('edit', qualification)">
|
|
||||||
编辑
|
|
||||||
</button>
|
|
||||||
<button class="qual-card__btn qual-card__btn--danger" @click="emit('delete', qualification)">
|
|
||||||
删除
|
删除
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="qualification.status === '未通过'"
|
v-if="qualification.status === '未通过'"
|
||||||
class="qual-card__btn qual-card__btn--warning"
|
type="button"
|
||||||
|
class="qual-row__btn"
|
||||||
@click="emit('reupload', qualification)"
|
@click="emit('reupload', qualification)"
|
||||||
>
|
>
|
||||||
重新上传
|
重新上传
|
||||||
</button>
|
</button>
|
||||||
|
<button v-else type="button" class="qual-row__btn" @click="emit('edit', qualification)">
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.qual-card {
|
.qual-row {
|
||||||
.flex(column, flex-start);
|
position: relative;
|
||||||
gap: @spacing-sm;
|
display: grid;
|
||||||
padding: @spacing-base;
|
grid-template-columns:
|
||||||
border: 1px solid @profile-card-border;
|
minmax(72px, 0.9fr)
|
||||||
|
minmax(100px, 1.2fr)
|
||||||
|
minmax(100px, 1.1fr)
|
||||||
|
minmax(100px, 1.1fr)
|
||||||
|
minmax(140px, 1.4fr)
|
||||||
|
minmax(64px, 0.7fr)
|
||||||
|
auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: @spacing-base;
|
||||||
|
min-height: 72px;
|
||||||
|
padding: @spacing-base @spacing-lg;
|
||||||
|
border: 1px solid @border-color;
|
||||||
border-radius: @radius-base;
|
border-radius: @radius-base;
|
||||||
background: @white;
|
background: @bg-color-light;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
&__head {
|
&__col {
|
||||||
.flex(row, space-between, flex-start);
|
min-width: 0;
|
||||||
gap: @spacing-sm;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__name {
|
&__label {
|
||||||
font-size: 11px;
|
margin: 0;
|
||||||
|
font-size: @font-size-sm;
|
||||||
|
color: @text-color-secondary;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__value {
|
||||||
|
margin-top: @spacing-xs;
|
||||||
|
font-size: @font-size-base;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: @heading-color;
|
color: @profile-ink;
|
||||||
.multi-ellipsis(2);
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__meta {
|
&__status {
|
||||||
.flex(column, flex-start);
|
&.is-reviewing {
|
||||||
gap: @spacing-xs;
|
color: @primary-strong;
|
||||||
list-style: none;
|
|
||||||
font-size: 11px;
|
|
||||||
color: @heading-color;
|
|
||||||
|
|
||||||
li {
|
|
||||||
.ellipsis();
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
span {
|
&.is-certified {
|
||||||
color: @text-color-secondary;
|
color: @success-color;
|
||||||
font-weight: 400;
|
}
|
||||||
|
|
||||||
|
&.is-rejected {
|
||||||
|
color: @danger-color;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__actions {
|
&__actions {
|
||||||
.flex(row, center);
|
.flex(row, flex-end, center);
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: @spacing-sm;
|
gap: @spacing-sm;
|
||||||
margin-top: auto;
|
|
||||||
padding-top: @spacing-sm;
|
|
||||||
border-top: 1px solid @profile-card-border;
|
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__btn {
|
&__btn {
|
||||||
height: 24px;
|
height: 28px;
|
||||||
padding: 0 10px;
|
padding: 0 @spacing-sm;
|
||||||
border: 1px solid @profile-line;
|
border: 1px solid @border-color;
|
||||||
border-radius: @profile-radius;
|
border-radius: @radius-round;
|
||||||
background: @white;
|
background: @white;
|
||||||
color: @text-color;
|
color: @text-color;
|
||||||
font-size: 11px;
|
font-size: @font-size-sm;
|
||||||
font-weight: 500;
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border-color: @primary-strong;
|
border-color: @primary-strong;
|
||||||
color: @primary-strong;
|
color: @primary-strong;
|
||||||
opacity: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&--danger {
|
|
||||||
color: @text-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--warning {
|
|
||||||
color: @text-color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.qual-card .status-tag {
|
|
||||||
padding: 0;
|
|
||||||
background: transparent;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
|
|
||||||
&--success {
|
|
||||||
background: transparent;
|
|
||||||
color: @heading-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--warning {
|
|
||||||
background: transparent;
|
|
||||||
color: @heading-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--danger {
|
|
||||||
background: transparent;
|
|
||||||
color: @danger-color;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import type { Profile } from '@/types/business/profile'
|
import type { Profile } from '@/types/business/profile'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -8,57 +9,89 @@ interface Props {
|
|||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const router = useRouter()
|
||||||
changePassword: []
|
|
||||||
bindPhone: []
|
|
||||||
bindEmail: [mode: 'email-bind' | 'email-rebind']
|
|
||||||
unbindEmail: []
|
|
||||||
deleteAccount: []
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const hasEmail = computed(() => Boolean(props.profile?.email))
|
const hasEmail = computed(() => Boolean(props.profile?.email))
|
||||||
|
|
||||||
|
function goChangePassword(): void {
|
||||||
|
void router.push('/profile/security/change-password')
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBindPhone(): void {
|
||||||
|
void router.push('/profile/security/bind-phone')
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBindEmail(): void {
|
||||||
|
void router.push('/profile/security/bind-email')
|
||||||
|
}
|
||||||
|
|
||||||
|
function goDeleteAccount(): void {
|
||||||
|
void router.push('/profile/security/delete-account')
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="security-panel">
|
<div class="security-panel">
|
||||||
|
<h2 class="security-panel__title">
|
||||||
|
账号安全
|
||||||
|
</h2>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
class="security-panel__row"
|
class="security-panel__row"
|
||||||
@click="emit('changePassword')"
|
@click="goChangePassword"
|
||||||
>
|
>
|
||||||
|
<span class="security-panel__icon security-panel__icon--pwd" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 16 16" width="18" height="18">
|
||||||
|
<path d="M5 7V5a3 3 0 0 1 6 0v2" fill="none" stroke="#fff" stroke-width="1.5" />
|
||||||
|
<rect x="3.5" y="7" width="9" height="6.5" rx="1.5" fill="#fff" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
<div class="security-panel__main">
|
<div class="security-panel__main">
|
||||||
<h3 class="security-panel__title">
|
<h3 class="security-panel__name">
|
||||||
修改密码
|
修改密码
|
||||||
</h3>
|
</h3>
|
||||||
<p class="security-panel__sub">
|
<p class="security-panel__sub">
|
||||||
上次修改:{{ profile?.passwordModifiedAt }}
|
上次修改:{{ profile?.passwordModifiedAt }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="security-panel__state security-panel__state--ok">已设置</span>
|
<span class="security-panel__state">已设置</span>
|
||||||
<span class="security-panel__arrow" aria-hidden="true">›</span>
|
<span class="security-panel__arrow" aria-hidden="true">›</span>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
class="security-panel__row"
|
class="security-panel__row"
|
||||||
@click="emit('bindPhone')"
|
@click="goBindPhone"
|
||||||
>
|
>
|
||||||
|
<span class="security-panel__icon security-panel__icon--phone" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 16 16" width="18" height="18">
|
||||||
|
<rect x="4.5" y="1.5" width="7" height="13" rx="1.5" fill="none" stroke="#fff" stroke-width="1.5" />
|
||||||
|
<line x1="7" y1="12.2" x2="9" y2="12.2" stroke="#fff" stroke-width="1.2" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
<div class="security-panel__main">
|
<div class="security-panel__main">
|
||||||
<h3 class="security-panel__title">
|
<h3 class="security-panel__name">
|
||||||
绑定手机
|
绑定手机
|
||||||
</h3>
|
</h3>
|
||||||
<p class="security-panel__sub">
|
<p class="security-panel__sub">
|
||||||
{{ profile?.phone ?? '' }}
|
{{ profile?.phone ?? '' }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="security-panel__state security-panel__state--ok">已绑定</span>
|
<span class="security-panel__state">已绑定</span>
|
||||||
<span class="security-panel__arrow" aria-hidden="true">›</span>
|
<span class="security-panel__arrow" aria-hidden="true">›</span>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
class="security-panel__row"
|
class="security-panel__row"
|
||||||
@click="emit('bindEmail', hasEmail ? 'email-rebind' : 'email-bind')"
|
@click="goBindEmail"
|
||||||
>
|
>
|
||||||
|
<span class="security-panel__icon security-panel__icon--email" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 16 16" width="18" height="18">
|
||||||
|
<rect x="1.5" y="3.5" width="13" height="9" rx="1.5" fill="none" stroke="#fff" stroke-width="1.5" />
|
||||||
|
<path d="M2 4.5 8 9l6-4.5" fill="none" stroke="#fff" stroke-width="1.5" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
<div class="security-panel__main">
|
<div class="security-panel__main">
|
||||||
<h3 class="security-panel__title">
|
<h3 class="security-panel__name">
|
||||||
绑定邮箱
|
绑定邮箱
|
||||||
</h3>
|
</h3>
|
||||||
<p class="security-panel__sub">
|
<p class="security-panel__sub">
|
||||||
@@ -67,7 +100,7 @@ const hasEmail = computed(() => Boolean(props.profile?.email))
|
|||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
class="security-panel__state"
|
class="security-panel__state"
|
||||||
:class="{ 'security-panel__state--ok': hasEmail }"
|
:class="{ 'security-panel__state--idle': !hasEmail }"
|
||||||
>{{ hasEmail ? '已绑定' : '未绑定' }}</span>
|
>{{ hasEmail ? '已绑定' : '未绑定' }}</span>
|
||||||
<span class="security-panel__arrow" aria-hidden="true">›</span>
|
<span class="security-panel__arrow" aria-hidden="true">›</span>
|
||||||
</section>
|
</section>
|
||||||
@@ -75,7 +108,7 @@ const hasEmail = computed(() => Boolean(props.profile?.email))
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="security-panel__delete"
|
class="security-panel__delete"
|
||||||
@click="emit('deleteAccount')"
|
@click="goDeleteAccount"
|
||||||
>
|
>
|
||||||
注销账号
|
注销账号
|
||||||
</button>
|
</button>
|
||||||
@@ -85,21 +118,67 @@ const hasEmail = computed(() => Boolean(props.profile?.email))
|
|||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.security-panel {
|
.security-panel {
|
||||||
.flex(column, flex-start, stretch);
|
.flex(column, flex-start, stretch);
|
||||||
gap: @spacing-base;
|
|
||||||
|
&__title {
|
||||||
|
position: relative;
|
||||||
|
padding-left: @spacing-base;
|
||||||
|
margin-bottom: @spacing-base;
|
||||||
|
font-size: @font-size-base;
|
||||||
|
font-weight: 600;
|
||||||
|
color: @profile-ink;
|
||||||
|
line-height: 1.4;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 3px;
|
||||||
|
width: 4px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: @primary-strong;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&__row {
|
&__row {
|
||||||
.flex(row, space-between, center);
|
.flex(row, flex-start, center);
|
||||||
height: 86px;
|
min-height: 72px;
|
||||||
padding: 0 24px;
|
padding: 0 @spacing-lg;
|
||||||
border: 1px solid @profile-card-border;
|
border: 1px solid @border-color;
|
||||||
border-radius: @radius-base;
|
border-radius: @radius-base;
|
||||||
background: @white;
|
background: @bg-color-light;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 0.2s;
|
transition: border-color 0.2s, background-color 0.2s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border-color: @profile-line;
|
border-color: @primary-color;
|
||||||
|
background: @filter-selected-bg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__row + &__row {
|
||||||
|
margin-top: @spacing-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
.flex(row, center);
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
margin-right: 12px;
|
||||||
|
border-radius: @radius-base;
|
||||||
|
|
||||||
|
&--pwd {
|
||||||
|
background: @profile-icon-pwd;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--phone {
|
||||||
|
background: @profile-icon-phone;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--email {
|
||||||
|
background: @profile-icon-email;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,41 +186,52 @@ const hasEmail = computed(() => Boolean(props.profile?.email))
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__title {
|
&__name {
|
||||||
font-size: @font-size-base;
|
font-size: @font-size-base;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: @profile-ink;
|
color: @profile-ink;
|
||||||
|
line-height: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__sub {
|
&__sub {
|
||||||
margin-top: 6px;
|
margin-top: 4px;
|
||||||
font-size: @font-size-sm;
|
font-size: @font-size-sm;
|
||||||
color: @finance-axis-text; // #9CA3AF
|
color: @finance-axis-text;
|
||||||
|
line-height: 16px;
|
||||||
|
.ellipsis();
|
||||||
}
|
}
|
||||||
|
|
||||||
&__state {
|
&__state {
|
||||||
|
flex-shrink: 0;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: @radius-round;
|
||||||
|
background: @profile-tag-ok-bg;
|
||||||
|
color: @success-color;
|
||||||
font-size: @font-size-sm;
|
font-size: @font-size-sm;
|
||||||
font-weight: 500;
|
line-height: 22px;
|
||||||
color: @finance-muted-text;
|
|
||||||
|
|
||||||
&--ok {
|
&--idle {
|
||||||
color: @success-color;
|
background: @profile-quiet-btn;
|
||||||
|
color: @finance-axis-text;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__arrow {
|
&__arrow {
|
||||||
|
flex-shrink: 0;
|
||||||
margin-left: 20px;
|
margin-left: 20px;
|
||||||
font-size: 21px;
|
font-size: 18px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
color: @finance-axis-text;
|
color: @finance-axis-text;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__delete {
|
&__delete {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 48px;
|
height: 40px;
|
||||||
border: 1px solid @profile-line;
|
margin-top: @spacing-xxl;
|
||||||
border-radius: @radius-base;
|
border: 1px solid @border-color;
|
||||||
|
border-radius: @radius-round;
|
||||||
background: @white;
|
background: @white;
|
||||||
color: @finance-axis-text;
|
color: @finance-axis-text;
|
||||||
font-size: @font-size-base;
|
font-size: @font-size-base;
|
||||||
|
|||||||
590
src/views/profile/delete-account.vue
Normal file
590
src/views/profile/delete-account.vue
Normal file
@@ -0,0 +1,590 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { useProfileStore } from '@/stores/modules/profile'
|
||||||
|
import { useCountDown } from '@/composables/useCountDown'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const profileStore = useProfileStore()
|
||||||
|
const { profile } = storeToRefs(profileStore)
|
||||||
|
|
||||||
|
type DeleteStep = 'confirm' | 'verify'
|
||||||
|
const step = ref<DeleteStep>('confirm')
|
||||||
|
|
||||||
|
const agree = ref(false)
|
||||||
|
|
||||||
|
const conditions = [
|
||||||
|
{ label: '账号无未完成的订单', ok: true },
|
||||||
|
{ label: '账号无未结清的款项', ok: false },
|
||||||
|
{ label: '账号无进行中的合同', ok: true },
|
||||||
|
{ label: '账号无处理中的工单', ok: true },
|
||||||
|
]
|
||||||
|
|
||||||
|
const risks = [
|
||||||
|
'账号注销后,您将无法登录本应用,所有个人信息将被永久删除。',
|
||||||
|
'您名下的订单、合同、工单等业务数据将一并清除,无法找回。',
|
||||||
|
'注销后,同一手机号/邮箱将无法再次注册本应用。',
|
||||||
|
'如有未完成的订单或服务,请先处理完毕后再进行注销。',
|
||||||
|
]
|
||||||
|
|
||||||
|
function onConfirm(): void {
|
||||||
|
if (!agree.value) {
|
||||||
|
ElMessage.warning('请先阅读并同意《账号注销协议》')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
step.value = 'verify'
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCancel(): void {
|
||||||
|
void router.push('/profile/security')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第二步:身份验证
|
||||||
|
const code = ref('')
|
||||||
|
const codeInputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
const codeDigits = computed<string[]>(() => {
|
||||||
|
const chars = code.value.split('')
|
||||||
|
return Array.from({ length: 6 }, (_, i) => chars[i] ?? '')
|
||||||
|
})
|
||||||
|
|
||||||
|
const maskedPhone = computed(() => {
|
||||||
|
const phone = profile.value?.phone ?? ''
|
||||||
|
if (phone.length < 7) return phone
|
||||||
|
return `${phone.slice(0, 3)}****${phone.slice(-4)}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const { seconds, isCounting, start } = useCountDown(60)
|
||||||
|
|
||||||
|
function onGetCode(): void {
|
||||||
|
if (isCounting.value) return
|
||||||
|
start(60)
|
||||||
|
ElMessage.success('验证码已发送')
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusCode(): void {
|
||||||
|
codeInputRef.value?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCodeInput(event: Event): void {
|
||||||
|
const value = (event.target as HTMLInputElement).value.replace(/\D/g, '').slice(0, 6)
|
||||||
|
code.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDeleteAccount(): void {
|
||||||
|
if (code.value.length !== 6) {
|
||||||
|
ElMessage.warning('请输入 6 位验证码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void (async () => {
|
||||||
|
await profileStore.verifyIdentity(profile.value?.phone ?? '', code.value)
|
||||||
|
await profileStore.deleteAccount()
|
||||||
|
void router.push('/login')
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="delete-page">
|
||||||
|
<h2 class="delete-page__title">
|
||||||
|
注销账号
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<!-- 第一步:注销确认(设计稿 #08) -->
|
||||||
|
<div v-if="step === 'confirm'" class="delete-page__confirm">
|
||||||
|
<span class="delete-page__icon" aria-hidden="true">i</span>
|
||||||
|
<h3 class="delete-page__question">确定要注销账号吗?</h3>
|
||||||
|
|
||||||
|
<h4 class="delete-page__section-title delete-page__section-title--risk">
|
||||||
|
⚠ 注销风险提示
|
||||||
|
</h4>
|
||||||
|
<ul class="delete-page__risks">
|
||||||
|
<li v-for="(item, index) in risks" :key="item">
|
||||||
|
{{ index + 1 }}、{{ item }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4 class="delete-page__section-title delete-page__section-title--ok">
|
||||||
|
✓ 注销条件
|
||||||
|
</h4>
|
||||||
|
<ul class="delete-page__conditions">
|
||||||
|
<li v-for="item in conditions" :key="item.label" class="delete-page__condition">
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
<span
|
||||||
|
class="delete-page__mark"
|
||||||
|
:class="item.ok ? 'is-ok' : 'is-fail'"
|
||||||
|
>{{ item.ok ? '✓' : '✕' }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<label class="delete-page__agree">
|
||||||
|
<input v-model="agree" type="checkbox">
|
||||||
|
<span>已阅读并同意<em>《账号注销协议》</em></span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="delete-page__buttons">
|
||||||
|
<button type="button" class="delete-page__btn delete-page__btn--quiet" @click="onConfirm">
|
||||||
|
注销账号
|
||||||
|
</button>
|
||||||
|
<button type="button" class="delete-page__btn delete-page__btn--danger" @click="onCancel">
|
||||||
|
暂不注销
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 第二步:安全身份验证(设计稿 #41) -->
|
||||||
|
<div v-else class="delete-page__verify">
|
||||||
|
<div class="delete-page__banner">
|
||||||
|
<span class="delete-page__banner-icon" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 16 16" width="16" height="16">
|
||||||
|
<rect x="4.5" y="1.5" width="7" height="13" rx="1.5" fill="none" stroke="#fff" stroke-width="1.5" />
|
||||||
|
<line x1="7" y1="12.2" x2="9" y2="12.2" stroke="#fff" stroke-width="1.2" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h3 class="delete-page__banner-title">安全身份验证</h3>
|
||||||
|
<p class="delete-page__banner-desc">为保障账号安全,需先完成身份验证后再进行注销</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="delete-page__verify-card">
|
||||||
|
<div class="delete-page__verify-head">
|
||||||
|
<span class="delete-page__verify-avatar" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 16 16" width="16" height="16">
|
||||||
|
<circle cx="8" cy="5.5" r="2.8" fill="#fff" />
|
||||||
|
<path d="M2.5 14a5.5 5.5 0 0 1 11 0Z" fill="#fff" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<div class="delete-page__verify-info">
|
||||||
|
<h3 class="delete-page__verify-name">手机验证码</h3>
|
||||||
|
<p class="delete-page__verify-sub">发送验证码至{{ maskedPhone }}</p>
|
||||||
|
</div>
|
||||||
|
<span class="delete-page__verify-tag">默认</span>
|
||||||
|
<span class="delete-page__radio" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="delete-page__code-row">
|
||||||
|
<input
|
||||||
|
class="delete-page__code-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="请输入验证码"
|
||||||
|
maxlength="6"
|
||||||
|
@input="onCodeInput"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="delete-page__get-code"
|
||||||
|
:disabled="isCounting"
|
||||||
|
@click="onGetCode"
|
||||||
|
>
|
||||||
|
{{ isCounting ? `${seconds}秒后重获` : '获取验证码' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="delete-page__boxes"
|
||||||
|
@click="focusCode"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-for="(digit, index) in codeDigits"
|
||||||
|
:key="index"
|
||||||
|
class="delete-page__box"
|
||||||
|
:class="{ 'is-filled': Boolean(digit) }"
|
||||||
|
>{{ digit }}</span>
|
||||||
|
<input
|
||||||
|
ref="codeInputRef"
|
||||||
|
class="delete-page__ghost-input"
|
||||||
|
type="text"
|
||||||
|
inputmode="numeric"
|
||||||
|
maxlength="6"
|
||||||
|
:value="code"
|
||||||
|
@input="onCodeInput"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" class="delete-page__submit" @click="onDeleteAccount">
|
||||||
|
注销账号
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.delete-page {
|
||||||
|
.flex(column, flex-start, stretch);
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 14px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
font-size: @font-size-base;
|
||||||
|
font-weight: 600;
|
||||||
|
color: @profile-ink;
|
||||||
|
line-height: 20px;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 3px;
|
||||||
|
width: 4px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: @primary-strong;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 第一步:确认 ----------
|
||||||
|
&__confirm {
|
||||||
|
width: 760px;
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
.flex(column, flex-start, stretch);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
.flex(row, center);
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
margin: 52px auto 0;
|
||||||
|
border: 2px solid @danger-color;
|
||||||
|
border-radius: @radius-round;
|
||||||
|
color: @danger-color;
|
||||||
|
font-size: @font-size-xl;
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: italic;
|
||||||
|
line-height: 1;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__question {
|
||||||
|
margin-top: 36px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: @profile-ink;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__section-title {
|
||||||
|
margin-top: 18px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 16px;
|
||||||
|
|
||||||
|
&--risk {
|
||||||
|
margin-top: 18px;
|
||||||
|
color: @danger-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--ok {
|
||||||
|
margin-top: 27px;
|
||||||
|
color: @success-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__risks {
|
||||||
|
margin: 9px 0 0;
|
||||||
|
padding: 0;
|
||||||
|
.flex(column, flex-start);
|
||||||
|
gap: 8px;
|
||||||
|
list-style: none;
|
||||||
|
font-size: 13px;
|
||||||
|
color: @finance-entry-text;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__conditions {
|
||||||
|
margin: 18px 0 0;
|
||||||
|
padding: 0;
|
||||||
|
.flex(column, flex-start);
|
||||||
|
gap: 8px;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__condition {
|
||||||
|
.flex(row, space-between, center);
|
||||||
|
height: 24px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: @finance-entry-text;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__mark {
|
||||||
|
.flex(row, center);
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: @radius-round;
|
||||||
|
color: @white;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1;
|
||||||
|
|
||||||
|
&.is-ok {
|
||||||
|
background: @success-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-fail {
|
||||||
|
background: @danger-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__agree {
|
||||||
|
.flex(row, flex-start, center);
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 30px;
|
||||||
|
font-size: @font-size-sm;
|
||||||
|
color: @finance-muted-text;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
accent-color: @primary-strong;
|
||||||
|
}
|
||||||
|
|
||||||
|
em {
|
||||||
|
font-style: normal;
|
||||||
|
color: @primary-strong;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__buttons {
|
||||||
|
.flex(column, flex-start, stretch);
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 19px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__btn {
|
||||||
|
height: 46px;
|
||||||
|
border: none;
|
||||||
|
border-radius: @profile-radius;
|
||||||
|
font-size: @font-size-base;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&--quiet {
|
||||||
|
background: @profile-quiet-btn;
|
||||||
|
color: @finance-muted-text;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: darken(@profile-quiet-btn, 4%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--danger {
|
||||||
|
background: @danger-color;
|
||||||
|
color: @white;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: darken(@danger-color, 6%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 第二步:身份验证 ----------
|
||||||
|
&__verify {
|
||||||
|
width: 860px;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__banner {
|
||||||
|
.flex(row, flex-start, center);
|
||||||
|
gap: 14px;
|
||||||
|
height: 75px;
|
||||||
|
margin-top: 24px;
|
||||||
|
padding: 0 22px;
|
||||||
|
border-radius: @radius-base;
|
||||||
|
background: @profile-verify-banner-bg;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__banner-icon {
|
||||||
|
.flex(row, center);
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: @radius-round;
|
||||||
|
background: @primary-strong;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__banner-title {
|
||||||
|
font-size: @font-size-base;
|
||||||
|
font-weight: 600;
|
||||||
|
color: @profile-ink;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__banner-desc {
|
||||||
|
margin-top: 3px;
|
||||||
|
font-size: @font-size-sm;
|
||||||
|
color: @finance-muted-text;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__verify-card {
|
||||||
|
position: relative;
|
||||||
|
margin-top: 19px;
|
||||||
|
padding: 20px 22px 22px;
|
||||||
|
border: 1px solid @primary-strong;
|
||||||
|
border-radius: @radius-base;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__verify-head {
|
||||||
|
.flex(row, flex-start, center);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__verify-avatar {
|
||||||
|
.flex(row, center);
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: @radius-round;
|
||||||
|
background: @profile-nav-active-bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__verify-name {
|
||||||
|
font-size: @font-size-base;
|
||||||
|
font-weight: 600;
|
||||||
|
color: @profile-ink;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__verify-sub {
|
||||||
|
margin-top: 3px;
|
||||||
|
font-size: @font-size-sm;
|
||||||
|
color: @finance-axis-text;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__verify-tag {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: @font-size-sm;
|
||||||
|
color: @finance-muted-text;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__radio {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin-left: 10px;
|
||||||
|
border: 4px solid @primary-strong;
|
||||||
|
border-radius: @radius-round;
|
||||||
|
background: @white;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__code-row {
|
||||||
|
position: relative;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__code-input {
|
||||||
|
width: 100%;
|
||||||
|
height: 38px;
|
||||||
|
padding: 0 124px 0 14px;
|
||||||
|
border: 1px solid @profile-input-border;
|
||||||
|
border-radius: @profile-radius;
|
||||||
|
background: @white;
|
||||||
|
font-size: 13px;
|
||||||
|
color: @profile-ink;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: @primary-strong;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: @finance-axis-text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__get-code {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 4px;
|
||||||
|
width: 107px;
|
||||||
|
height: 30px;
|
||||||
|
border: 1px solid @primary-strong;
|
||||||
|
border-radius: @profile-radius;
|
||||||
|
background: @white;
|
||||||
|
color: @primary-strong;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: @primary-strong;
|
||||||
|
color: @white;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
border-color: @text-color-disabled;
|
||||||
|
color: @text-color-disabled;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__boxes {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
gap: 13px;
|
||||||
|
margin-top: 16px;
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__box {
|
||||||
|
.flex(row, center);
|
||||||
|
width: 46px;
|
||||||
|
height: 47px;
|
||||||
|
border: 1px solid @profile-input-border;
|
||||||
|
border-radius: @radius-base;
|
||||||
|
background: @white;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: @primary-strong;
|
||||||
|
|
||||||
|
&.is-filled {
|
||||||
|
border-color: @primary-strong;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__ghost-input {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: transparent;
|
||||||
|
caret-color: @primary-strong;
|
||||||
|
font-size: 18px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__submit {
|
||||||
|
width: 100%;
|
||||||
|
height: 46px;
|
||||||
|
margin-top: 21px;
|
||||||
|
border: none;
|
||||||
|
border-radius: @profile-radius;
|
||||||
|
background: @primary-strong;
|
||||||
|
color: @white;
|
||||||
|
font-size: @font-size-base;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: darken(@primary-strong, 6%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,54 +1,40 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue'
|
import { onMounted } from 'vue'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { FilterPanel, PaginationBar } from '@/components'
|
import { CasesFilter, CasesGrid, CasesSearchBar, PaginationBar } from '@/components'
|
||||||
import type { BusinessType } from '@/types/api'
|
import { useCasesStore } from '@/stores/modules/cases'
|
||||||
import { useProfileStore } from '@/stores/modules/profile'
|
import type { CaseQuery } from '@/types/business/cases'
|
||||||
import FavoritesGrid from './cpns/FavoritesGrid.vue'
|
|
||||||
|
|
||||||
interface CategoryOption {
|
|
||||||
key: BusinessType | 'all'
|
|
||||||
label: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const CATEGORY_OPTIONS: CategoryOption[] = [
|
|
||||||
{ key: 'all', label: '全部' },
|
|
||||||
{ key: 'internet', label: '互联网软件' },
|
|
||||||
{ key: 'hardware', label: '硬件电路' },
|
|
||||||
{ key: 'iot', label: '物联网全案' },
|
|
||||||
{ key: 'manufacturing', label: '生产制造' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const profileStore = useProfileStore()
|
const casesStore = useCasesStore()
|
||||||
const { favorites, favoritesTotal, favoritesCounts, favoritesQuery } = storeToRefs(profileStore)
|
const { query, list, total, previewTotal, typeOptions, industryOptions, scaleOptions, caseTotal }
|
||||||
|
= storeToRefs(casesStore)
|
||||||
|
|
||||||
const keyword = ref('')
|
function onUpdateQuery(next: CaseQuery): void {
|
||||||
const category = ref<BusinessType | 'all'>('all')
|
casesStore.setQuery(next)
|
||||||
|
}
|
||||||
|
|
||||||
function countOf(key: BusinessType | 'all'): number {
|
function onUpdateKeyword(keyword: string): void {
|
||||||
if (key === 'all') return favoritesTotal.value
|
casesStore.setQuery({ ...query.value, keyword })
|
||||||
return favoritesCounts.value[key] ?? 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSearch(): void {
|
function onSearch(): void {
|
||||||
void profileStore.searchFavorites({ keyword: keyword.value, category: category.value })
|
void casesStore.search()
|
||||||
}
|
}
|
||||||
|
|
||||||
function onReset(): void {
|
function onReset(): void {
|
||||||
keyword.value = ''
|
void casesStore.resetQuery()
|
||||||
category.value = 'all'
|
|
||||||
void profileStore.resetFavorites()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSelectCategory(key: BusinessType | 'all'): void {
|
async function onFavorite(id: string): Promise<void> {
|
||||||
category.value = key
|
await casesStore.toggleFavorite(id)
|
||||||
void profileStore.searchFavorites({ keyword: keyword.value, category: key })
|
// 收藏页取消收藏后,立即按 favoriteOnly 重新拉取,让卡片消失
|
||||||
|
await casesStore.fetchCases()
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPageChange(page: number): void {
|
function onPageChange(page: number): void {
|
||||||
void profileStore.changeFavoritesPage(page)
|
void casesStore.changePage(page)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onViewDetail(id: string): void {
|
function onViewDetail(id: string): void {
|
||||||
@@ -56,90 +42,78 @@ function onViewDetail(id: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
keyword.value = favoritesQuery.value.keyword
|
// 复用案例库 store,强制只看收藏;case 库进入时会 resetQuery 清除该态,互不污染
|
||||||
category.value = favoritesQuery.value.category
|
casesStore.setQuery({ ...casesStore.query, favoriteOnly: true, page: 1 })
|
||||||
void Promise.all([profileStore.fetchFavorites(), profileStore.fetchFavoritesCounts()])
|
void Promise.all([casesStore.fetchCategories(), casesStore.fetchCases()])
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="favorites-page">
|
<div class="favorites">
|
||||||
<FilterPanel
|
<div class="container favorites__body">
|
||||||
v-model:keyword="keyword"
|
<CasesFilter
|
||||||
keyword-placeholder="搜索收藏案例标题"
|
:query="query"
|
||||||
@search="onSearch"
|
:type-options="typeOptions"
|
||||||
@reset="onReset"
|
:industry-options="industryOptions"
|
||||||
/>
|
:scale-options="scaleOptions"
|
||||||
|
:case-total="caseTotal"
|
||||||
|
:preview-total="previewTotal"
|
||||||
|
@update:query="onUpdateQuery"
|
||||||
|
@search="onSearch"
|
||||||
|
@reset="onReset"
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="favorites-page__categories">
|
<div class="favorites__main">
|
||||||
<button
|
<CasesSearchBar
|
||||||
v-for="item in CATEGORY_OPTIONS"
|
:keyword="query.keyword"
|
||||||
:key="item.key"
|
:favorite-only="query.favoriteOnly"
|
||||||
class="favorites-page__cat"
|
:show-favorite-toggle="false"
|
||||||
:class="{ 'is-active': category === item.key }"
|
@update:keyword="onUpdateKeyword"
|
||||||
@click="onSelectCategory(item.key)"
|
@search="onSearch"
|
||||||
>
|
@reset="onReset"
|
||||||
{{ item.label }}({{ countOf(item.key) }})
|
/>
|
||||||
</button>
|
|
||||||
|
<CasesGrid
|
||||||
|
:cases="list"
|
||||||
|
:total="total"
|
||||||
|
@view="onViewDetail"
|
||||||
|
@favorite="onFavorite"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="favorites__pager">
|
||||||
|
<PaginationBar
|
||||||
|
:page="query.page"
|
||||||
|
:page-size="query.pageSize"
|
||||||
|
:total="total"
|
||||||
|
@page-change="onPageChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FavoritesGrid :cases="favorites" @view="onViewDetail" />
|
|
||||||
|
|
||||||
<PaginationBar
|
|
||||||
:page="favoritesQuery.page"
|
|
||||||
:page-size="favoritesQuery.pageSize"
|
|
||||||
:total="favoritesTotal"
|
|
||||||
@page-change="onPageChange"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.favorites-page {
|
.favorites {
|
||||||
.flex(column, flex-start);
|
min-height: 100%;
|
||||||
gap: @spacing-base;
|
padding-bottom: @spacing-page-bottom;
|
||||||
width: 100%;
|
background: @bg-color;
|
||||||
|
|
||||||
&__categories {
|
&__body {
|
||||||
.flex(row, flex-start);
|
.flex(row, flex-start, flex-start);
|
||||||
flex-wrap: wrap;
|
gap: @spacing-lg;
|
||||||
gap: @spacing-sm;
|
padding-top: @spacing-lg;
|
||||||
width: 100%;
|
|
||||||
padding: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__cat {
|
&__main {
|
||||||
height: 28px;
|
.flex(column, flex-start, stretch);
|
||||||
padding: 0 @spacing-base;
|
flex: 1;
|
||||||
border: 1px solid @profile-line;
|
min-width: 0;
|
||||||
border-radius: @radius-round;
|
gap: @spacing-lg;
|
||||||
background: @white;
|
|
||||||
color: @text-color;
|
|
||||||
font-size: 11px;
|
|
||||||
transition: all 0.2s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: @primary-strong;
|
|
||||||
color: @primary-strong;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.is-active {
|
|
||||||
border-color: @primary-strong;
|
|
||||||
background: @profile-nav-active-bg;
|
|
||||||
color: @primary-strong;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.pagination) {
|
&__pager {
|
||||||
justify-content: flex-end;
|
.flex(row, flex-end);
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.pagination__btn.is-active) {
|
|
||||||
border-color: @profile-page-active-bg;
|
|
||||||
background: @profile-page-active-bg;
|
|
||||||
color: @primary-strong;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -13,24 +13,12 @@ const ossStore = useOssStore()
|
|||||||
const resetSignal = ref(0)
|
const resetSignal = ref(0)
|
||||||
|
|
||||||
function onSubmit(feedback: Feedback): void {
|
function onSubmit(feedback: Feedback): void {
|
||||||
if (!feedback.title.trim()) {
|
|
||||||
ElMessage.warning('请填写反馈标题')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!feedback.description.trim()) {
|
|
||||||
ElMessage.warning('请填写详细描述')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
void profileStore.submitFeedback(feedback).then(() => {
|
void profileStore.submitFeedback(feedback).then(() => {
|
||||||
ElMessage.success('提交成功')
|
ElMessage.success('提交成功')
|
||||||
resetSignal.value += 1
|
resetSignal.value += 1
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function onReset(): void {
|
|
||||||
resetSignal.value += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
function onPreview(file: FileInfo): void {
|
function onPreview(file: FileInfo): void {
|
||||||
ossStore.openPreview(file)
|
ossStore.openPreview(file)
|
||||||
}
|
}
|
||||||
@@ -42,15 +30,11 @@ function onPreview(file: FileInfo): void {
|
|||||||
<h2 class="feedback-page__title">
|
<h2 class="feedback-page__title">
|
||||||
意见反馈
|
意见反馈
|
||||||
</h2>
|
</h2>
|
||||||
<p class="feedback-page__sub text-secondary">
|
|
||||||
您的每一条建议都是我们前进的动力
|
|
||||||
</p>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<FeedbackForm
|
<FeedbackForm
|
||||||
:reset-signal="resetSignal"
|
:reset-signal="resetSignal"
|
||||||
@submit="onSubmit"
|
@submit="onSubmit"
|
||||||
@reset="onReset"
|
|
||||||
@preview="onPreview"
|
@preview="onPreview"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -63,8 +47,6 @@ function onPreview(file: FileInfo): void {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
&__head {
|
&__head {
|
||||||
.flex(column, flex-start);
|
|
||||||
gap: @spacing-xs;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
@@ -72,7 +54,7 @@ function onPreview(file: FileInfo): void {
|
|||||||
&__title {
|
&__title {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding-left: 12px;
|
padding-left: 12px;
|
||||||
font-size: 12px;
|
font-size: 14px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: @heading-color;
|
color: @heading-color;
|
||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
@@ -82,15 +64,11 @@ function onPreview(file: FileInfo): void {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
top: 3px;
|
top: 3px;
|
||||||
width: 3px;
|
width: 4px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
background: @primary-strong;
|
background: @primary-strong;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__sub {
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -18,9 +18,6 @@ onMounted(() => {
|
|||||||
<h2 class="help-page__title">
|
<h2 class="help-page__title">
|
||||||
帮助中心
|
帮助中心
|
||||||
</h2>
|
</h2>
|
||||||
<p class="help-page__sub text-secondary">
|
|
||||||
常见问题与使用指南,帮您快速上手平台
|
|
||||||
</p>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<HelpFaq :groups="helpFaqs" />
|
<HelpFaq :groups="helpFaqs" />
|
||||||
@@ -34,34 +31,28 @@ onMounted(() => {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
&__head {
|
&__head {
|
||||||
.flex(column, flex-start);
|
|
||||||
gap: @spacing-xs;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__title {
|
&__title {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding-left: 12px;
|
padding-left: 14px;
|
||||||
font-size: 12px;
|
font-size: @font-size-base;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
color: @heading-color;
|
color: @profile-ink;
|
||||||
line-height: 20px;
|
line-height: 1.4;
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
top: 3px;
|
top: 3px;
|
||||||
width: 3px;
|
width: 4px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
background: @primary-strong;
|
background: @primary-strong;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__sub {
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -4,12 +4,45 @@ import HotlinePanel from './cpns/HotlinePanel.vue'
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="hotline-page">
|
<div class="hotline-page">
|
||||||
|
<header class="hotline-page__head">
|
||||||
|
<h2 class="hotline-page__title">
|
||||||
|
专属客服热线
|
||||||
|
</h2>
|
||||||
|
</header>
|
||||||
|
|
||||||
<HotlinePanel />
|
<HotlinePanel />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.hotline-page {
|
.hotline-page {
|
||||||
|
.flex(column, flex-start);
|
||||||
|
gap: @spacing-base;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
|
&__head {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: @heading-color;
|
||||||
|
line-height: 20px;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 3px;
|
||||||
|
width: 4px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: @primary-strong;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,10 +2,7 @@ export { default } from './index.vue'
|
|||||||
export { default as ProfileSider } from './cpns/ProfileSider.vue'
|
export { default as ProfileSider } from './cpns/ProfileSider.vue'
|
||||||
export { default as ProfileUserCard } from './cpns/ProfileUserCard.vue'
|
export { default as ProfileUserCard } from './cpns/ProfileUserCard.vue'
|
||||||
export { default as SecurityPanel } from './cpns/SecurityPanel.vue'
|
export { default as SecurityPanel } from './cpns/SecurityPanel.vue'
|
||||||
export { default as ChangePasswordDialog } from './cpns/ChangePasswordDialog.vue'
|
|
||||||
export { default as BindContactDialog } from './cpns/BindContactDialog.vue'
|
|
||||||
export { default as AddressDialog } from './cpns/AddressDialog.vue'
|
export { default as AddressDialog } from './cpns/AddressDialog.vue'
|
||||||
export { default as FavoritesGrid } from './cpns/FavoritesGrid.vue'
|
|
||||||
export { default as QualificationCard } from './cpns/QualificationCard.vue'
|
export { default as QualificationCard } from './cpns/QualificationCard.vue'
|
||||||
export { default as QualificationDialog } from './cpns/QualificationDialog.vue'
|
export { default as QualificationDialog } from './cpns/QualificationDialog.vue'
|
||||||
export { default as FeedbackForm } from './cpns/FeedbackForm.vue'
|
export { default as FeedbackForm } from './cpns/FeedbackForm.vue'
|
||||||
|
|||||||
@@ -36,36 +36,34 @@ onMounted(() => {
|
|||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.profile {
|
.profile {
|
||||||
min-height: 737px;
|
min-height: 100%;
|
||||||
background: @profile-page-bg;
|
padding-bottom: @spacing-page-bottom;
|
||||||
|
background: @bg-color;
|
||||||
|
|
||||||
&__container {
|
&__container {
|
||||||
padding-top: 26px;
|
padding-top: @spacing-lg;
|
||||||
padding-left: clamp(@spacing-base, calc((100% - @profile-content) / 2), @profile-gutter);
|
|
||||||
padding-right: clamp(@spacing-base, calc((100% - @profile-content) / 2), @profile-gutter);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__body {
|
&__body {
|
||||||
.flex(row, flex-start, stretch);
|
.flex(row, flex-start, stretch);
|
||||||
|
gap: @spacing-lg;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__aside {
|
&__aside {
|
||||||
.flex(column, flex-start, stretch);
|
.flex(column, flex-start, stretch);
|
||||||
gap: 6px;
|
gap: @spacing-base;
|
||||||
flex: 0 0 200px;
|
flex: 0 0 200px;
|
||||||
width: 200px;
|
width: 200px;
|
||||||
margin-right: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__main {
|
&__main {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 425px;
|
min-height: 480px;
|
||||||
margin-left: 40px;
|
margin-left: 0;
|
||||||
padding: 24px 24px 0;
|
padding: @spacing-lg @spacing-xl;
|
||||||
border: 1px solid @profile-card-border;
|
border-radius: @radius-base;
|
||||||
border-radius: @radius-lg;
|
|
||||||
background: @white;
|
background: @white;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { AppButton, ConfirmDialog, PaginationBar } from '@/components'
|
import { ConfirmDialog, PaginationBar } from '@/components'
|
||||||
import { useProfileStore } from '@/stores/modules/profile'
|
import { useProfileStore } from '@/stores/modules/profile'
|
||||||
import type { Qualification, QualificationInput } from '@/types/business/profile'
|
import type { Qualification, QualificationInput } from '@/types/business/profile'
|
||||||
import QualificationCard from './cpns/QualificationCard.vue'
|
import QualificationCard from './cpns/QualificationCard.vue'
|
||||||
@@ -64,11 +64,11 @@ onMounted(() => {
|
|||||||
<div class="qual-page">
|
<div class="qual-page">
|
||||||
<header class="qual-page__head">
|
<header class="qual-page__head">
|
||||||
<h2 class="qual-page__title">
|
<h2 class="qual-page__title">
|
||||||
资质管理
|
资质列表
|
||||||
</h2>
|
</h2>
|
||||||
<AppButton type="primary" @click="onCreate">
|
<button type="button" class="qual-page__create" @click="onCreate">
|
||||||
+新增资质
|
+ 新增资质
|
||||||
</AppButton>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="qual-page__grid">
|
<div class="qual-page__grid">
|
||||||
@@ -114,21 +114,20 @@ onMounted(() => {
|
|||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.qual-page {
|
.qual-page {
|
||||||
.flex(column, flex-start);
|
.flex(column, flex-start);
|
||||||
gap: @spacing-base;
|
gap: 20px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
&__head {
|
&__head {
|
||||||
.flex(row, space-between, center);
|
.flex(row, space-between, center);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__title {
|
&__title {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding-left: 12px;
|
padding-left: 14px;
|
||||||
font-size: 12px;
|
font-size: @font-size-base;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
color: @heading-color;
|
color: @profile-ink;
|
||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
@@ -136,25 +135,41 @@ onMounted(() => {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
top: 3px;
|
top: 3px;
|
||||||
width: 3px;
|
width: 4px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
background: @primary-strong;
|
background: @primary-strong;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__create {
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border: none;
|
||||||
|
border-radius: @profile-radius;
|
||||||
|
background: @primary-strong;
|
||||||
|
color: @white;
|
||||||
|
font-size: @font-size-sm;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: darken(@primary-strong, 6%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&__grid {
|
&__grid {
|
||||||
display: grid;
|
.flex(column, flex-start, stretch);
|
||||||
grid-template-columns: 1fr;
|
gap: 15px;
|
||||||
gap: @spacing-base;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__empty {
|
&__empty {
|
||||||
grid-column: 1 / -1;
|
|
||||||
padding: @spacing-xxl 0;
|
padding: @spacing-xxl 0;
|
||||||
|
width: 100%;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 11px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.pagination) {
|
:deep(.pagination) {
|
||||||
@@ -167,21 +182,5 @@ onMounted(() => {
|
|||||||
background: @profile-page-active-bg;
|
background: @profile-page-active-bg;
|
||||||
color: @primary-strong;
|
color: @primary-strong;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-button--primary {
|
|
||||||
height: 24px;
|
|
||||||
padding: 0 10px;
|
|
||||||
border: none;
|
|
||||||
border-radius: @profile-radius;
|
|
||||||
background: @primary-strong;
|
|
||||||
color: @white;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 500;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border: none;
|
|
||||||
background: darken(@primary-strong, 6%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,314 +1,20 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { ElMessage } from 'element-plus'
|
|
||||||
import { ConfirmDialog } from '@/components'
|
|
||||||
import { getPasswordLevel, isValidPassword } from '@/utils/validate'
|
|
||||||
import { useProfileStore } from '@/stores/modules/profile'
|
import { useProfileStore } from '@/stores/modules/profile'
|
||||||
import SecurityPanel from './cpns/SecurityPanel.vue'
|
import SecurityPanel from './cpns/SecurityPanel.vue'
|
||||||
import ChangePasswordDialog from './cpns/ChangePasswordDialog.vue'
|
|
||||||
import BindContactDialog from './cpns/BindContactDialog.vue'
|
|
||||||
import type { BindContactMode } from './cpns/BindContactDialog.vue'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const profileStore = useProfileStore()
|
const profileStore = useProfileStore()
|
||||||
const { profile } = storeToRefs(profileStore)
|
const { profile } = storeToRefs(profileStore)
|
||||||
|
|
||||||
const deleteVisible = ref(false)
|
|
||||||
const agree = ref(false)
|
|
||||||
const identityVisible = ref(false)
|
|
||||||
|
|
||||||
const conditions = [
|
|
||||||
{ label: '账号无未完成的订单', ok: true },
|
|
||||||
{ label: '账号无未结清的款项', ok: false },
|
|
||||||
{ label: '账号无进行中的合同', ok: true },
|
|
||||||
{ label: '账号无处理中的工单', ok: true },
|
|
||||||
]
|
|
||||||
|
|
||||||
const risks = [
|
|
||||||
'账号注销后,您将无法登录本应用,所有个人信息将被永久删除。',
|
|
||||||
'您名下的订单、合同、工单等业务数据将一并清除,无法找回。',
|
|
||||||
'注销后,同一手机号/邮箱将无法再次注册本应用。',
|
|
||||||
'如有未完成的订单或服务,请先处理完毕后再进行注销。',
|
|
||||||
]
|
|
||||||
|
|
||||||
function onDeleteConfirm(): void {
|
|
||||||
if (!agree.value) {
|
|
||||||
ElMessage.warning('请先阅读并同意《账号注销协议》')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
identityVisible.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const pwdVisible = ref(false)
|
|
||||||
|
|
||||||
function onChangePasswordSubmit(payload: { code: string; password: string; confirm: string }): void {
|
|
||||||
if (payload.password !== payload.confirm) {
|
|
||||||
ElMessage.warning('两次密码不一致')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!isValidPassword(payload.password) || getPasswordLevel(payload.password) === 'weak') {
|
|
||||||
ElMessage.warning('密码等级过低,请重新输入')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
void profileStore
|
|
||||||
.changePassword({ code: payload.code, password: payload.password })
|
|
||||||
.then(() => {
|
|
||||||
pwdVisible.value = false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const bindVisible = ref(false)
|
|
||||||
const bindMode = ref<BindContactMode>('phone-rebind')
|
|
||||||
|
|
||||||
function onBindPhone(): void {
|
|
||||||
bindMode.value = 'phone-rebind'
|
|
||||||
bindVisible.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function onBindEmail(mode: 'email-bind' | 'email-rebind'): void {
|
|
||||||
bindMode.value = mode
|
|
||||||
bindVisible.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function onBindSubmit(payload: { contact: string; code: string }): void {
|
|
||||||
const action
|
|
||||||
= bindMode.value === 'phone-rebind' || bindMode.value === 'phone-bind'
|
|
||||||
? profileStore.bindPhone(payload.contact, payload.code)
|
|
||||||
: profileStore.bindEmail(payload.contact, payload.code)
|
|
||||||
void action.then(() => {
|
|
||||||
bindVisible.value = false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const unbindVisible = ref(false)
|
|
||||||
|
|
||||||
function onUnbindConfirm(): void {
|
|
||||||
void profileStore.unbindEmail()
|
|
||||||
}
|
|
||||||
|
|
||||||
function onIdentitySubmit(payload: { contact: string; code: string }): void {
|
|
||||||
void (async () => {
|
|
||||||
await profileStore.verifyIdentity(payload.contact, payload.code)
|
|
||||||
await profileStore.deleteAccount()
|
|
||||||
setTimeout(() => {
|
|
||||||
void router.push('/login')
|
|
||||||
}, 3000)
|
|
||||||
})()
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="security-page">
|
<div class="security-page">
|
||||||
<SecurityPanel
|
<SecurityPanel :profile="profile" />
|
||||||
:profile="profile"
|
|
||||||
@change-password="pwdVisible = true"
|
|
||||||
@bind-phone="onBindPhone"
|
|
||||||
@bind-email="onBindEmail"
|
|
||||||
@unbind-email="unbindVisible = true"
|
|
||||||
@delete-account="deleteVisible = true"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
v-model:visible="deleteVisible"
|
|
||||||
title="注销账号"
|
|
||||||
confirm-text="注销账号"
|
|
||||||
cancel-text="暂不注销"
|
|
||||||
danger
|
|
||||||
@cancel="agree = false"
|
|
||||||
@confirm="onDeleteConfirm"
|
|
||||||
>
|
|
||||||
<div class="delete-confirm">
|
|
||||||
<span class="delete-confirm__icon" aria-hidden="true">i</span>
|
|
||||||
<h3 class="delete-confirm__title">
|
|
||||||
确定要注销账号吗?
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div class="delete-confirm__block">
|
|
||||||
<h4 class="delete-confirm__block-title delete-confirm__block-title--risk">
|
|
||||||
⚠ 注销风险提示
|
|
||||||
</h4>
|
|
||||||
<ul class="delete-confirm__risks">
|
|
||||||
<li v-for="(item, index) in risks" :key="item">
|
|
||||||
{{ index + 1 }}、{{ item }}
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="delete-confirm__block">
|
|
||||||
<h4 class="delete-confirm__block-title delete-confirm__block-title--ok">
|
|
||||||
✓ 注销条件
|
|
||||||
</h4>
|
|
||||||
<ul class="delete-confirm__conditions">
|
|
||||||
<li
|
|
||||||
v-for="item in conditions"
|
|
||||||
:key="item.label"
|
|
||||||
class="delete-confirm__condition"
|
|
||||||
>
|
|
||||||
<span>{{ item.label }}</span>
|
|
||||||
<span
|
|
||||||
class="delete-confirm__mark"
|
|
||||||
:class="item.ok ? 'is-ok' : 'is-fail'"
|
|
||||||
>{{ item.ok ? '✓' : '✕' }}</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label class="delete-confirm__agree">
|
|
||||||
<input v-model="agree" type="checkbox">
|
|
||||||
<span>已阅读并同意<em>《账号注销协议》</em></span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</ConfirmDialog>
|
|
||||||
|
|
||||||
<ChangePasswordDialog
|
|
||||||
v-model:visible="pwdVisible"
|
|
||||||
:phone="profile?.phone ?? ''"
|
|
||||||
@submit="onChangePasswordSubmit"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<BindContactDialog
|
|
||||||
v-model:visible="bindVisible"
|
|
||||||
:mode="bindMode"
|
|
||||||
:hint-phone="profile?.phone ?? ''"
|
|
||||||
:hint-email="profile?.email ?? ''"
|
|
||||||
@submit="onBindSubmit"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<BindContactDialog
|
|
||||||
v-model:visible="identityVisible"
|
|
||||||
mode="identity"
|
|
||||||
:hint-phone="profile?.phone ?? ''"
|
|
||||||
@submit="onIdentitySubmit"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
v-model:visible="unbindVisible"
|
|
||||||
title="取消绑定"
|
|
||||||
message="确定要取消绑定该邮箱吗?取消后将无法通过邮箱接收通知。"
|
|
||||||
confirm-text="取消绑定"
|
|
||||||
cancel-text="暂不取消"
|
|
||||||
danger
|
|
||||||
@confirm="onUnbindConfirm"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.security-page {
|
.security-page {
|
||||||
.flex(column, flex-start, stretch);
|
.flex(column, flex-start, stretch);
|
||||||
gap: @spacing-base;
|
|
||||||
}
|
|
||||||
|
|
||||||
.delete-confirm {
|
|
||||||
.flex(column, flex-start);
|
|
||||||
align-items: center;
|
|
||||||
padding: 8px 0;
|
|
||||||
|
|
||||||
&__icon {
|
|
||||||
.flex(row, center);
|
|
||||||
width: 34px;
|
|
||||||
height: 34px;
|
|
||||||
border-radius: @radius-round;
|
|
||||||
background: fade(@danger-color, 12%);
|
|
||||||
color: @danger-color;
|
|
||||||
font-size: 22px;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__title {
|
|
||||||
margin-top: 10px;
|
|
||||||
font-size: 19px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: @profile-ink;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__block {
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__block-title {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
|
|
||||||
&--risk {
|
|
||||||
color: @danger-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--ok {
|
|
||||||
color: @success-color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__risks {
|
|
||||||
margin-top: 12px;
|
|
||||||
.flex(column, flex-start);
|
|
||||||
gap: 9px;
|
|
||||||
list-style: none;
|
|
||||||
font-size: 13px;
|
|
||||||
color: @finance-entry-text;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__conditions {
|
|
||||||
margin-top: 12px;
|
|
||||||
.flex(column, flex-start);
|
|
||||||
gap: 10px;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__condition {
|
|
||||||
.flex(row, space-between, center);
|
|
||||||
width: 100%;
|
|
||||||
height: 32px;
|
|
||||||
padding: 0 16px;
|
|
||||||
border: 1px solid @border-color;
|
|
||||||
border-radius: @radius-sm;
|
|
||||||
background: @white;
|
|
||||||
font-size: 13px;
|
|
||||||
color: @finance-entry-text;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__mark {
|
|
||||||
.flex(row, center);
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
border-radius: @radius-round;
|
|
||||||
color: @white;
|
|
||||||
font-size: 10px;
|
|
||||||
line-height: 1;
|
|
||||||
|
|
||||||
&.is-ok {
|
|
||||||
background: @success-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.is-fail {
|
|
||||||
background: @danger-color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__agree {
|
|
||||||
.flex(row, flex-start, center);
|
|
||||||
gap: 8px;
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 22px;
|
|
||||||
font-size: @font-size-sm;
|
|
||||||
color: @finance-muted-text;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
input {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
accent-color: @primary-strong;
|
|
||||||
}
|
|
||||||
|
|
||||||
em {
|
|
||||||
font-style: normal;
|
|
||||||
color: @primary-strong;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -153,13 +153,18 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="container after-sales">
|
<div class="after-sales">
|
||||||
|
<div class="container after-sales__inner">
|
||||||
<nav class="after-sales__breadcrumb">
|
<nav class="after-sales__breadcrumb">
|
||||||
<RouterLink to="/project" class="link">
|
<RouterLink to="/project" class="link">
|
||||||
项目中心
|
项目中心
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<span> > </span>
|
<span> > </span>
|
||||||
<span>项目详情</span>
|
<RouterLink :to="`/project/${projectId}`" class="link">
|
||||||
|
项目详情
|
||||||
|
</RouterLink>
|
||||||
|
<span> > </span>
|
||||||
|
<span>申请售后</span>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<header class="after-sales__header">
|
<header class="after-sales__header">
|
||||||
@@ -300,51 +305,55 @@ onMounted(() => {
|
|||||||
</AppButton>
|
</AppButton>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.after-sales {
|
.after-sales {
|
||||||
.flex(column, flex-start, stretch);
|
min-height: 100%;
|
||||||
padding-bottom: @spacing-page-bottom;
|
padding-bottom: @spacing-page-bottom;
|
||||||
background: @white;
|
background: @bg-color;
|
||||||
|
|
||||||
|
&__inner {
|
||||||
|
.flex(column, flex-start, stretch);
|
||||||
|
}
|
||||||
|
|
||||||
// 设计稿 el40 面包屑 y=116,header 底 72 → padding-top 44px。
|
|
||||||
&__breadcrumb {
|
&__breadcrumb {
|
||||||
padding-top: 44px;
|
padding-top: @spacing-lg;
|
||||||
font-size: 24px;
|
font-size: @font-size-sm;
|
||||||
font-weight: 500;
|
font-weight: 400;
|
||||||
line-height: 27px;
|
line-height: 1.5;
|
||||||
color: @project-breadcrumb;
|
color: @project-breadcrumb;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__header {
|
&__header {
|
||||||
.flex(column, flex-start, stretch);
|
.flex(column, flex-start, stretch);
|
||||||
padding-top: 52px;
|
padding-top: @spacing-base;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__title {
|
&__title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 22px;
|
font-size: @font-size-title;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
line-height: 29px;
|
line-height: 1.3;
|
||||||
color: @project-ink;
|
color: @project-ink;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设计稿 #26:副标题文字 x=728~1185,中心 ≈ 957 ≈ 内容区中心 960(居中)。
|
|
||||||
&__subtitle {
|
&__subtitle {
|
||||||
margin-top: 8px;
|
margin-top: @spacing-sm;
|
||||||
font-size: 13px;
|
font-size: @font-size-base;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
line-height: 17px;
|
line-height: 1.5;
|
||||||
color: @project-label;
|
color: @project-label;
|
||||||
text-align: center;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__card {
|
&__card {
|
||||||
.flex(column, flex-start, stretch);
|
.flex(column, flex-start, stretch);
|
||||||
margin-top: 30px;
|
margin-top: @spacing-base;
|
||||||
padding: 22px 0 40px;
|
padding: @spacing-lg;
|
||||||
|
border-radius: @radius-base;
|
||||||
background: @white;
|
background: @white;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user