Compare commits

..

8 Commits

Author SHA1 Message Date
Ironsp
f7722e19df fix skill 2026-10-14 14:19:12 +08:00
Ironsp
0b974bd36a feat 登录页面整改 2026-10-13 16:13:56 +08:00
Ironsp
a067fd8357 feat 在线客服和详细中心整改 2026-10-13 16:08:55 +08:00
Ironsp
367380e44c feat 个人中心_帮助中心整改 2026-10-13 15:56:12 +08:00
Ironsp
77dbabb1f1 feat 个人中心_地址整改 2026-10-13 15:48:57 +08:00
Ironsp
f917062d76 feat 个人中心_地址整改 2026-10-13 15:45:50 +08:00
Ironsp
093cf9acd0 feat 首页_业务服务整改 2026-10-13 15:42:08 +08:00
Ironsp
5fa940e9ba feat 案例库样式整改 2026-10-13 15:34:55 +08:00
30 changed files with 1017 additions and 854 deletions

View File

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

1
.gitignore vendored
View File

@@ -19,6 +19,7 @@ RTK.md
specs2
.shots
docs
.claude
# Editor directories and files
.vscode/*

View File

@@ -344,60 +344,61 @@ export function submitFeedback(feedback: Feedback): Promise<void> {
return Promise.resolve()
}
/** TODO(api): 后端帮助中心接口就绪后改为 request 调用;暂用设计稿假数据 */
export function getHelpFaqs(): Promise<HelpFaqGroup[]> {
return Promise.resolve([
{
title: '1. 账户与登录',
title: '账户与登录',
items: [
{
q: '如何注册账号?',
a: '点击页面右上角"注册",输入手机号并获取验证码,设置 6-20 位登录密码即可完成注册。',
q: '如何注册账号?',
a: '打开APP后点击“注册”,输入手机号获取验证码,设置登录密码即可完成注册。',
},
{
q: '忘记密码怎么办?',
a: '在登录页点击"忘记密码",通过绑定手机号验证码验证身份后即可重新设置密码。',
q: '忘记密码怎么办?',
a: '在登录页面点击“忘记密码”,输入注册手机号获取验证码,验证通过后即可重置新密码。',
},
{
q: '一个账号可以多地登录吗?',
a: '同一账号同时仅允许在 1 台设备上保持在线,超出将自动踢出最早登录的设备。',
q: '一个账号可以多端登录吗?',
a: '同一账号仅允许在 1 台设备上保持在线,超出将自动踢出最早登录的设备。',
},
{
q: '如何修改绑定手机号?',
a: '进入 个人中心 → 账号安全 → 绑定手机,验证原手机号后即可绑定新号码。',
q: '如何修改绑定手机号?',
a: '进入「我的 -> 设置 -> 账户安全 -> 更换手机号」,验证原手机号后即可绑定新号码。',
},
],
},
{
title: '2. 功能使用指南',
title: '功能使用指南',
items: [
{
q: '如何提交需求?',
a: '进入"发布需求"页面,填写需求描述、预算、周期等信息并提交,专业顾问将在一个工作日内与您联系。',
q: '如何提交报修/工单?',
a: '首页点击「报修」 -> 选择报修类型 -> 填写问题描述并上传图片 -> 提交。提交后可在「我的 -> 我的工单」中查看处理进度。',
},
{
q: '如何查看我的消息?',
a: '登录后点击右上角消息图标,或进入 个人中心 → 消息通知,即可查看系统通知与顾问消息。',
q: '如何预约会议室?',
a: '进入「服务 -> 会议室预订」,选择日期、时间段及会议室,确认后提交预约,预约成功将收到消息通知。',
},
{
q: '如何申请售后?',
a: '在"我的订单"中找到对应项目,点击"申请售后"填写问题描述并上传附件,平台将尽快为您处理。',
q: '如何查看我的消息通知?',
a: '首页右上角铃铛图标进入消息中心,支持按“全部 / 未读 / 系统通知 / 业务提醒”分类查看。',
},
],
},
{
title: '3. 系统与权限',
title: '系统与权限',
items: [
{
q: '无权限怎么办?',
a: '请确认当前账号角色是否具备该模块权限;如仍无法访问,请联系专属客服或平台管理员为您开通。',
q: 'APP提示“无权限”怎么办?',
a: '部分功能需要管理员授权后方可使用,请联系贵司系统管理员为您分配对应角色权限。',
},
{
q: '收不到通知怎么办?',
a: '请检查浏览器通知权限与消息设置是否开启;若仍未收到,请联系客服核实账号通知配置。',
q: '为什么收不到推送通知?',
a: '请检查:①手机系统设置中是否允许本APP发送通知;②APP内「我的 -> 设置 -> 消息推送」是否已开启。',
},
{
q: '支持哪些浏览器?',
a: '推荐使用 Chrome、Edge、Firefox、Safari 等主流浏览器的最新版本访问本平台。',
q: 'APP支持哪些系统版本?',
a: 'iOS 13.0 及以上版本,Android 8.0 及以上版本。建议使用最新系统版本以获得最佳体验。',
},
],
},

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

View File

@@ -32,42 +32,44 @@ function onGetCode(): void {
.sms-code {
.flex(row, stretch);
gap: @spacing-sm;
width: 100%;
&__input {
flex: 1;
min-width: 0;
height: 40px;
padding: 0 12px;
height: 44px;
padding: 0 @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-sm;
background: @white;
font-size: 13px;
color: #1f2937;
border-radius: @radius-base;
background: @bg-color-light;
font-size: @font-size-base;
color: @heading-color;
outline: none;
transition: border-color 0.2s;
transition: border-color 0.2s, background 0.2s;
box-sizing: border-box;
&:focus {
border-color: @primary-strong;
background: @white;
}
&::placeholder {
color: #1f2937;
color: @text-color-secondary;
}
}
// 设计稿 #38:「获取验证码」是带蓝色描边的独立按钮(62×16 文字居中),
// 不是纯文字链接。按键宽 88px,与输入框间距 @spacing-sm。
&__btn {
flex-shrink: 0;
width: 88px;
height: 40px;
padding: 0;
min-width: 120px;
height: 44px;
padding: 0 @spacing-base;
border: 1px solid @primary-strong;
border-radius: @radius-sm;
border-radius: @radius-base;
background: @white;
color: @primary-strong;
font-size: 12px;
font-weight: 400;
font-size: @font-size-sm;
font-weight: 500;
white-space: nowrap;
text-align: center;
cursor: pointer;
transition: all 0.2s;
@@ -78,8 +80,9 @@ function onGetCode(): void {
}
&:disabled {
border-color: @text-color-disabled;
color: @text-color-disabled;
border-color: @border-color;
background: @bg-color-light;
color: @text-color-secondary;
cursor: not-allowed;
}
}

View File

@@ -71,6 +71,7 @@ function toBusinessLine(vo: ProjectSubmissionVo): BusinessLine {
desc: subtitle,
slogan: subtitle,
icon: vo.logoUrl ?? '',
cover: vo.coverUrl ?? '',
serviceContent,
contents,
serviceTypes,
@@ -99,6 +100,7 @@ function toBusinessLineFromInfo(info: ProjectSubmissionInfo): BusinessLine {
desc: subtitle,
slogan: subtitle,
icon: ps.logoUrl ?? '',
cover: ps.coverUrl ?? '',
serviceContent,
contents,
serviceTypes,

View File

@@ -85,9 +85,8 @@
.flex(column, center, center);
flex: 1;
min-width: 0;
// 设计稿三页的标题顶分别为 306/287/268,卡片在 1080 视口中居中后
// 会比标注高 15px,用上内边距把内容整体下移补平。
padding: 80px 0 40px;
// 底部多留白,整体视觉中心上移,「欢迎登录」不再偏下
padding: 24px 0 120px;
}
// 设计稿表单区统一 693px 宽。标题/副标题按自身文字宽度相对表单居中,

View File

@@ -35,6 +35,7 @@ export interface BusinessLine {
desc: string
slogan: string
icon: string
cover: string
serviceContent: string
contents: ServiceContentBlock[]
serviceTypes: ServiceType[]

View File

@@ -18,7 +18,7 @@ const coverStyle = computed(() =>
<template>
<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>
<h1 class="case-hero__title">
{{ props.item.title }}
@@ -30,15 +30,9 @@ const coverStyle = computed(() =>
<style lang="less" scoped>
.case-hero {
position: relative;
width: auto;
max-width: @home-content;
width: 100%;
height: 180px;
margin: 0 @home-gutter;
border-radius: @radius-base;
@media (max-width: 1600px) {
margin: 0 @spacing-lg;
}
background-color: @primary-color;
background-position: center;
background-repeat: no-repeat;
@@ -58,7 +52,8 @@ const coverStyle = computed(() =>
.flex(column, flex-end, flex-start);
gap: @spacing-sm;
height: 100%;
padding-bottom: @spacing-lg;
box-sizing: border-box;
padding: 0 @spacing-lg @spacing-lg;
}
&__tag {

View File

@@ -51,19 +51,21 @@ onMounted(load)
</div>
<template v-if="item">
<CaseHero :item="item" />
<div class="container case-detail__body">
<CaseHero :item="item" />
<div class="container case-detail__main">
<CaseDetailHeader :item="item" @favorite="onFavorite" @share="onShare" />
<CaseRichText :item="item" />
<div class="case-detail__main">
<CaseDetailHeader :item="item" @favorite="onFavorite" @share="onShare" />
<CaseRichText :item="item" />
<button
class="case-detail__cta"
type="button"
@click="router.push('/demand')"
>
一键填报类似案例
</button>
<button
class="case-detail__cta"
type="button"
@click="router.push('/demand')"
>
一键填报类似案例
</button>
</div>
</div>
</template>
@@ -85,10 +87,14 @@ onMounted(load)
font-size: @font-size-sm;
}
&__body {
.flex(column, flex-start, stretch);
gap: @spacing-base;
}
&__main {
.flex(column, flex-start, stretch);
gap: @spacing-base;
padding-top: @spacing-lg;
}
&__cta {

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import type { BusinessType } from '@/types/api'
import type { BusinessLine } from '@/types/business/home'
import defaultCover from '@/assets/home/chahua.png'
interface Props {
lines: BusinessLine[]
@@ -9,6 +10,16 @@ interface Props {
defineProps<Props>()
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>
<template>
@@ -25,6 +36,11 @@ const FALLBACK_ACTIVE: BusinessType[] = ['internet']
'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" />
<h4 class="home-business__name">
{{ line.name }}
@@ -52,16 +68,16 @@ const FALLBACK_ACTIVE: BusinessType[] = ['internet']
}
&__grid {
display: flex;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: @spacing-base;
}
&__card {
position: relative;
.flex(column, flex-start, flex-start);
flex: 1 1 0;
min-width: 0;
min-height: 160px;
min-height: 168px;
box-sizing: border-box;
padding: @spacing-lg;
overflow: hidden;
@@ -80,22 +96,32 @@ const FALLBACK_ACTIVE: BusinessType[] = ['internet']
}
}
&__cover {
display: block;
width: 72px;
height: 72px;
object-fit: contain;
flex-shrink: 0;
}
&__check {
position: absolute;
top: 27px;
right: 37px;
top: @spacing-base;
right: @spacing-base;
z-index: 1;
flex-shrink: 0;
width: 31px;
height: 31px;
width: 20px;
height: 20px;
border: 1px solid @home-ring;
border-radius: @radius-round;
background: @white;
&::after {
content: '';
position: absolute;
inset: 0;
width: 21px;
height: 21px;
width: 12px;
height: 12px;
margin: auto;
border-radius: @radius-round;
background: @white;
@@ -104,7 +130,8 @@ const FALLBACK_ACTIVE: BusinessType[] = ['internet']
}
&__name {
margin: @spacing-xxl 0 0;
margin: @spacing-base 0 0;
padding-right: @spacing-xl;
font-size: @font-size-lg;
font-weight: 500;
line-height: 1.4;
@@ -113,6 +140,7 @@ const FALLBACK_ACTIVE: BusinessType[] = ['internet']
&__desc {
margin: @spacing-xs 0 0;
padding-right: @spacing-xl;
font-size: @font-size-sm;
font-weight: 400;
line-height: 1.5;

View File

@@ -33,9 +33,7 @@ const visibleCases = computed<Case[]>(() => props.cases.slice(0, 4))
<template>
<section class="home-cases">
<div class="home-cases__head">
<h3 class="home-cases__title">
案例库精选
</h3>
<h3 class="home-cases__title">案例库精选</h3>
<div class="home-cases__tabs">
<button
v-for="cat in categories"
@@ -60,7 +58,12 @@ const visibleCases = computed<Case[]>(() => props.cases.slice(0, 4))
@click="$router.push(`/cases/${item.id}`)"
>
<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>
@@ -140,13 +143,13 @@ const visibleCases = computed<Case[]>(() => props.cases.slice(0, 4))
}
&__grid {
display: flex;
gap: 24px;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: @spacing-lg;
}
&__card {
overflow: hidden;
flex: 1 1 0;
min-width: 0;
min-height: 220px;
box-sizing: border-box;
@@ -155,7 +158,10 @@ const visibleCases = computed<Case[]>(() => props.cases.slice(0, 4))
background: @white;
box-shadow: none;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s, border-color 0.2s;
transition:
transform 0.2s,
box-shadow 0.2s,
border-color 0.2s;
&:hover {
border-color: @primary-color;
@@ -185,7 +191,7 @@ const visibleCases = computed<Case[]>(() => props.cases.slice(0, 4))
}
&__body {
padding: 0 20px;
padding: 0 20px 10px 20px;
}
&__name {
@@ -201,7 +207,7 @@ const visibleCases = computed<Case[]>(() => props.cases.slice(0, 4))
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 0 @spacing-lg;
margin-top: 43px;
margin-top: 20px;
font-size: 13px;
line-height: 20px;
color: @home-body;

View File

@@ -67,70 +67,58 @@ const localAgree = computed({
</template>
<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 {
.flex(column, flex-start, stretch);
margin: 26px 0 0;
gap: @spacing-lg;
width: 100%;
margin-top: @spacing-xl;
&__field {
.flex(column, flex-start, stretch);
// 标签底 474 → 输入框顶 480,间距 6px
gap: 6px;
gap: @spacing-sm;
width: 100%;
}
// 第一个字段底(480+40=520)→ 第二个字段标签顶 585,间距 65px
&__field + &__field {
margin-top: 65px;
}
&__label {
font-size: 13px;
font-size: @font-size-sm;
font-weight: 500;
line-height: 17px;
color: #1f2937;
text-align: center;
line-height: 1.4;
color: @heading-color;
text-align: left;
}
&__input {
width: 100%;
height: 40px;
padding: 0 12px;
height: 44px;
padding: 0 @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-sm;
background: @white;
font-size: 13px;
color: #1f2937;
border-radius: @radius-base;
background: @bg-color-light;
font-size: @font-size-base;
color: @heading-color;
outline: none;
transition: border-color 0.2s;
transition: border-color 0.2s, background 0.2s;
box-sizing: border-box;
&:focus {
border-color: @primary-strong;
background: @white;
}
&::placeholder {
color: #1f2937;
color: @text-color-secondary;
}
}
// 设计稿 #36:勾选框 (1042.2,642) 7.5×14,文字 12px 起于 x=1059。
// 注:设计稿勾选标注 y=642 与密码输入框底 648 重叠,二者无法同时满足,
// 这里按视觉取勾选行落在输入框下方 8px。
&__agree {
.flex(row, flex-start, center);
gap: 8px;
margin-top: 8px;
font-size: 12px;
font-weight: 400;
line-height: 16px;
color: #6b7280;
gap: @spacing-sm;
margin: 0;
font-size: @font-size-sm;
line-height: 1.5;
color: @text-color-secondary;
cursor: pointer;
user-select: none;
input {
flex-shrink: 0;
@@ -146,17 +134,15 @@ const localAgree = computed({
}
}
// 设计稿 #36:按钮文字 (1352,688) 54×20 居中,按钮盒约 693×46
&__submit {
width: 100%;
height: 46px;
// 勾选行底 657 → 按钮顶 674
margin-top: 17px;
height: 44px;
margin: 0;
border: none;
border-radius: @radius-sm;
border-radius: @radius-base;
background: @primary-strong;
color: @white;
font-size: 15px;
font-size: @font-size-base;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;

View File

@@ -85,65 +85,58 @@ function onSubmit(): void {
</template>
<style lang="less" scoped>
// 设计稿 #38(手机验证码登录)纵向坐标:
// 手机号标签 438 → 验证码标签 520(差 82)
// 验证码输入框底 ~588 → 勾选行 622 → 登录按钮文字 669
.sms-login {
.flex(column, flex-start, stretch);
margin: 26px 0 0;
gap: @spacing-lg;
width: 100%;
margin-top: @spacing-xl;
&__field {
.flex(column, flex-start, stretch);
gap: 6px;
gap: @spacing-sm;
width: 100%;
}
// 第一个字段底(457+40=497... 按标签 438+17+6+40=501)→ 第二标签 520,间距 19px
&__field + &__field {
margin-top: 19px;
}
&__label {
font-size: 13px;
font-size: @font-size-sm;
font-weight: 500;
line-height: 17px;
color: #1f2937;
text-align: center;
line-height: 1.4;
color: @heading-color;
text-align: left;
}
&__input {
width: 100%;
height: 40px;
padding: 0 12px;
height: 44px;
padding: 0 @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-sm;
background: @white;
font-size: 13px;
color: #1f2937;
border-radius: @radius-base;
background: @bg-color-light;
font-size: @font-size-base;
color: @heading-color;
outline: none;
transition: border-color 0.2s;
transition: border-color 0.2s, background 0.2s;
box-sizing: border-box;
&:focus {
border-color: @primary-strong;
background: @white;
}
&::placeholder {
color: #1f2937;
color: @text-color-secondary;
}
}
// 设计稿 #38:勾选框 (992.2,623) 7.5×14,勾选文字 622
&__agree {
.flex(row, flex-start, center);
gap: 8px;
margin-top: 14px;
font-size: 12px;
font-weight: 400;
line-height: 16px;
color: #6b7280;
gap: @spacing-sm;
margin: 0;
font-size: @font-size-sm;
line-height: 1.5;
color: @text-color-secondary;
cursor: pointer;
user-select: none;
input {
flex-shrink: 0;
@@ -159,16 +152,15 @@ function onSubmit(): void {
}
}
// 设计稿 #38:按钮文字 (1302,669) 54×20
&__submit {
width: 100%;
height: 46px;
margin-top: 17px;
height: 44px;
margin: 0;
border: none;
border-radius: @radius-sm;
border-radius: @radius-base;
background: @primary-strong;
color: @white;
font-size: 15px;
font-size: @font-size-base;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;

View File

@@ -144,26 +144,31 @@ async function onGetCode(target: string): Promise<void> {
<style lang="less" scoped>
.auth-page {
// 设计稿 #36:两个 tab 顶 406,字号 14px,选中下划线 2px 位于 429。
// tab 文字底 425,下划线顶 429 → padding-bottom 6px。
// 上间距 26px:副标题底 380 → tab 文字顶 406。
&__tabs {
.flex(row, flex-start, center);
gap: 24px;
margin: 26px 0 0;
gap: @spacing-xl;
width: 100%;
margin-top: @spacing-xl;
border-bottom: 1px solid @border-color;
}
&__tab {
position: relative;
padding: 0 0 6px;
padding: 0 0 @spacing-sm;
margin-bottom: -1px;
border: none;
background: transparent;
font-size: 14px;
font-size: @font-size-base;
font-weight: 400;
color: #4b5563;
line-height: 1.4;
color: @text-color-secondary;
cursor: pointer;
transition: color 0.2s;
&:hover {
color: @heading-color;
}
&.is-active {
font-weight: 600;
color: @primary-strong;
@@ -175,24 +180,23 @@ async function onGetCode(target: string): Promise<void> {
bottom: 0;
left: 0;
height: 2px;
border-radius: 1px;
background: @primary-strong;
}
}
}
// 设计稿 #36:「没有账号?」(1325,774)、「立即注册」(1384.5,774) 12px。
// 「找回密码」为设计稿外的补充入口,按同样式接在分隔符后。
&__switch {
align-self: center;
margin: 53px 0 0;
font-size: 12px;
margin: @spacing-xl 0 0;
font-size: @font-size-sm;
font-weight: 400;
line-height: 16px;
color: #6b7280;
line-height: 1.5;
color: @text-color-secondary;
&-sep {
margin: 0 8px;
color: #d0d5dd;
margin: 0 @spacing-sm;
color: @border-color;
}
}
}

View File

@@ -7,12 +7,18 @@ interface 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>
@@ -22,10 +28,17 @@ const emojiMap: Record<MessageType, string> = {
:class="{ 'is-unread': !message.isRead }"
role="button"
tabindex="0"
@click="emit('view', message.id)"
@keydown.enter="emit('view', message.id)"
@click="emit('view', message)"
@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__head">
<span class="message-item__title">{{ message.title }}</span>
@@ -34,18 +47,17 @@ const emojiMap: Record<MessageType, string> = {
<p class="message-item__summary">
{{ message.summary }}
</p>
<span class="message-item__type">{{ message.type }}</span>
</div>
<span v-if="!message.isRead" class="message-item__dot" aria-label="未读" />
</div>
</template>
<style lang="less" scoped>
.message-item {
position: relative;
.flex(row, flex-start, flex-start);
gap: 16px;
min-height: 86px;
padding: 19px 29px;
gap: @spacing-base;
min-height: 88px;
padding: @spacing-base @spacing-lg;
background: @white;
cursor: pointer;
box-sizing: border-box;
@@ -55,20 +67,38 @@ const emojiMap: Record<MessageType, string> = {
background: @bg-color-light;
}
&__emoji {
&__icon {
position: relative;
flex-shrink: 0;
.flex(row, center);
width: 42px;
height: 42px;
width: 40px;
height: 40px;
border-radius: @radius-round;
background: @message-item-icon;
font-size: 20px;
background: @primary-light;
color: @primary-strong;
font-size: @font-size-sm;
font-weight: 600;
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 {
.flex(column, flex-start, stretch);
gap: 8px;
gap: @spacing-xs;
flex: 1;
min-width: 0;
}
@@ -80,33 +110,44 @@ const emojiMap: Record<MessageType, string> = {
}
&__title {
font-size: 14px;
font-weight: 600;
line-height: 20px;
color: @finance-ink;
font-size: @font-size-base;
font-weight: 500;
line-height: 1.4;
color: @heading-color;
.ellipsis();
}
&__time {
flex-shrink: 0;
font-size: 12px;
line-height: 20px;
color: @message-tabs-text;
font-size: @font-size-xs;
line-height: 1.4;
color: @text-color-secondary;
}
&__summary {
font-size: 12px;
line-height: 18px;
color: @message-tabs-text;
margin: 0;
font-size: @font-size-sm;
line-height: 1.5;
color: @text-color-secondary;
.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 {
position: absolute;
top: 21px;
left: 60px;
width: 14px;
height: 14px;
top: 0;
right: 0;
width: 8px;
height: 8px;
border: 2px solid @white;
border-radius: @radius-round;
background: @message-tabs-badge;

View File

@@ -8,7 +8,7 @@ interface Props {
defineProps<Props>()
const emit = defineEmits<{ view: [id: string] }>()
const emit = defineEmits<{ view: [message: Message] }>()
</script>
<template>
@@ -33,11 +33,12 @@ const emit = defineEmits<{ view: [id: string] }>()
gap: 0;
margin-top: 0;
padding: 0;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @white;
overflow: hidden;
.message-item + .message-item {
:deep(.message-item + .message-item) {
border-top: 1px solid @border-color;
}

View File

@@ -44,6 +44,7 @@ function countOf(value: MessageType | 'all', counts: MessageTabCounts): number {
gap: 2px;
min-height: 0;
padding: @spacing-sm 0;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @white;
box-sizing: border-box;
@@ -76,17 +77,22 @@ function countOf(value: MessageType | 'all', counts: MessageTabCounts): number {
&__count {
flex-shrink: 0;
min-width: 15px;
height: 15px;
padding: 0 4px;
min-width: 18px;
height: 18px;
padding: 0 @spacing-xs;
border-radius: @radius-round;
background: @message-tabs-badge;
color: @white;
font-size: 10px;
font-weight: 400;
line-height: 15px;
background: @bg-color-light;
color: @text-color-secondary;
font-size: @font-size-xs;
font-weight: 500;
line-height: 18px;
text-align: center;
box-sizing: border-box;
}
&__tab.is-active &__count {
background: fade(@primary-strong, 12%);
color: @primary-strong;
}
}
</style>

View File

@@ -1,7 +1,7 @@
<!-- 页面:消息详情(MessageDetail) -->
<script setup lang="ts">
import { computed, onMounted, watch } from 'vue'
import { ElButton, ElMessage } from 'element-plus'
import { ElMessage } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { PageHead } from '@/components'
import { useMessageStore } from '@/stores/modules/message'
@@ -42,7 +42,7 @@ onMounted(load)
slogan="查看站内消息完整内容与附件资料"
/>
<div class="message-detail__wrap">
<div class="container message-detail__wrap">
<nav class="message-detail__breadcrumb">
<RouterLink to="/message" class="message-detail__breadcrumb-link">
消息中心
@@ -53,12 +53,15 @@ onMounted(load)
<template v-if="item">
<article class="message-detail__card">
<h1 class="message-detail__title">
{{ item.title }}
</h1>
<p class="message-detail__time">
发布时间:{{ item.publishTime }}
</p>
<header class="message-detail__head">
<h1 class="message-detail__title">
{{ item.title }}
</h1>
<p class="message-detail__time">
发布时间:{{ item.publishTime }}
</p>
</header>
<div class="message-detail__content">
{{ item.content }}
</div>
@@ -80,32 +83,33 @@ onMounted(load)
</span>
</div>
<div class="message-detail__file-actions">
<el-button
<button
type="button"
class="message-detail__file-btn"
link
@click="onPreview"
>
预览
</el-button>
<el-button
</button>
<button
type="button"
class="message-detail__file-btn"
link
@click="onDownload"
>
下载
</el-button>
</button>
</div>
</li>
</ul>
</section>
<div class="message-detail__actions">
<el-button
<button
type="button"
class="message-detail__back"
@click="onBack"
>
返回
</el-button>
</button>
</div>
</article>
</template>
@@ -127,165 +131,176 @@ onMounted(load)
background: @bg-color;
&__wrap {
width: 100%;
max-width: @container-width;
margin: 0 auto;
padding: @spacing-lg @page-gutter 0;
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 {
.flex(row, flex-start, center);
gap: 4px;
min-height: 40px;
gap: @spacing-xs;
margin-bottom: @spacing-base;
font-size: @font-size-sm;
line-height: 1.5;
color: @project-muted;
color: @text-color-secondary;
}
&__breadcrumb-link {
color: @project-muted;
color: @text-color-secondary;
&:hover {
color: @primary-strong;
}
}
&__breadcrumb-sep {
color: @project-muted;
}
&__breadcrumb-current {
color: @message-tabs-text;
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: @finance-ink;
text-align: left;
color: @heading-color;
}
&__time {
margin-top: 14px;
font-size: 12px;
line-height: 18px;
color: @project-muted;
text-align: center;
margin: @spacing-sm 0 0;
font-size: @font-size-sm;
line-height: 1.4;
color: @text-color-secondary;
}
&__content {
margin-top: 16px;
background: transparent;
color: @message-tabs-text;
font-size: 13px;
line-height: 2.8;
text-align: center;
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: 24px;
margin-top: @spacing-xl;
}
&-title {
margin-bottom: 10px;
font-size: 12px;
font-weight: 400;
color: @project-muted;
}
&__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: 10px;
gap: @spacing-sm;
margin: 0;
padding: 0;
}
&__file {
.flex(row, space-between, center);
gap: @spacing-base;
width: 254px;
max-width: 100%;
padding: 15px 12px 15px 19px;
border-radius: @radius-sm;
background: @finance-row-bg;
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: 4px;
gap: @spacing-xs;
min-width: 0;
}
&__file-name {
font-size: 12px;
font-weight: 700;
color: @finance-ink;
font-size: @font-size-sm;
font-weight: 600;
color: @heading-color;
.ellipsis();
}
&__file-meta {
font-size: 11px;
color: @project-muted;
font-size: @font-size-xs;
color: @text-color-secondary;
.ellipsis();
}
&__file-actions {
.flex(column, center, center);
gap: 2px;
.flex(row, flex-end, center);
gap: @spacing-sm;
flex-shrink: 0;
}
&__file-btn {
padding: 0;
font-size: 12px;
font-weight: 400;
color: @message-tabs-text;
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 {
color: @primary-strong;
background: fade(@primary-strong, 8%);
}
}
&__actions {
.flex(row, flex-end);
margin-top: 40px;
margin-top: @spacing-xl;
}
&__back {
width: 94px;
height: 34px;
padding: 0;
border-color: @primary-strong;
border-radius: @radius-sm;
min-width: 88px;
height: 36px;
padding: 0 @spacing-lg;
border: none;
border-radius: @radius-round;
background: @primary-strong;
color: @white;
font-size: 13px;
font-size: @font-size-sm;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
&:hover {
border-color: @primary-strong;
background: @primary-strong;
color: @white;
background: @primary-hover;
}
}
&__empty {
padding: 80px 0;
padding: @spacing-xxl 0;
text-align: center;
font-size: 13px;
color: @project-muted;
font-size: @font-size-sm;
color: @text-color-secondary;
}
}
</style>

View File

@@ -6,7 +6,7 @@ import { useRouter } from 'vue-router'
import { ElButton, ElInput } from 'element-plus'
import { PageHead, PaginationBar } from '@/components'
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 MessageList from './cpns/MessageList.vue'
@@ -35,8 +35,13 @@ function onTabChange(type: MessageType | 'all'): void {
void messageStore.search()
}
function onView(id: string): void {
void router.push(`/message/${id}`)
function onView(message: Message): void {
// 用 message.type 区分:在线客服进会话页,其余进消息详情
if (message.type === '在线客服') {
void router.push('/service')
return
}
void router.push(`/message/${message.id}`)
}
function onMarkAllRead(): void {
@@ -128,8 +133,15 @@ const UNREAD_LABEL = computed(() => (unreadCount.value > 0 ? `全部已读 (${un
.flex(row, flex-start, flex-start);
gap: @spacing-lg;
width: 100%;
padding: @spacing-lg 0 0;
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 {
@@ -149,7 +161,7 @@ const UNREAD_LABEL = computed(() => (unreadCount.value > 0 ? `全部已读 (${un
gap: @spacing-base;
min-height: 64px;
padding: 0 @spacing-lg;
border: none;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @white;
box-sizing: border-box;

View File

@@ -78,7 +78,7 @@ onMounted(() => {
class="address-row"
>
<span v-if="address.isDefault" class="address-row__tag">默认</span>
<div class="address-row__col address-row__col--name">
<div class="address-row__col">
<p class="address-row__label">收货人姓名</p>
<EllipsisText class="address-row__value" :text="address.receiverName" />
</div>
@@ -90,7 +90,7 @@ onMounted(() => {
<p class="address-row__label">所在地区</p>
<EllipsisText class="address-row__value" :text="address.region" />
</div>
<div class="address-row__col address-row__col--detail">
<div class="address-row__col">
<p class="address-row__label">详细地址</p>
<EllipsisText class="address-row__value" :text="address.detailAddress" />
</div>
@@ -144,8 +144,9 @@ onMounted(() => {
<style lang="less" scoped>
.address-page {
.flex(column, flex-start);
gap: 20px;
gap: @spacing-lg;
width: 100%;
min-width: 0;
&__head {
.flex(row, space-between, center);
@@ -158,7 +159,7 @@ onMounted(() => {
font-size: @font-size-base;
font-weight: 600;
color: @profile-ink;
line-height: 20px;
line-height: 1.4;
&::before {
content: '';
@@ -174,7 +175,7 @@ onMounted(() => {
&__create {
height: 32px;
padding: 0 14px;
padding: 0 @spacing-base;
border: none;
border-radius: @profile-radius;
background: @primary-strong;
@@ -191,15 +192,16 @@ onMounted(() => {
&__list {
.flex(column, flex-start, stretch);
gap: 15px;
gap: @spacing-base;
width: 100%;
min-width: 0;
}
&__empty {
padding: @spacing-xxl 0;
width: 100%;
text-align: center;
font-size: 13px;
font-size: @font-size-sm;
}
:deep(.pagination) {
@@ -216,7 +218,15 @@ onMounted(() => {
.address-row {
position: relative;
.flex(row, flex-start, center);
display: grid;
grid-template-columns:
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;
@@ -227,48 +237,40 @@ onMounted(() => {
&__tag {
position: absolute;
top: -9px;
left: 28px;
left: @spacing-lg;
height: 18px;
padding: 0 10px;
border-radius: @radius-sm;
background: @primary-strong;
color: @white;
font-size: 11px;
font-size: @font-size-xs;
font-weight: 500;
line-height: 18px;
}
&__col {
flex: 0 0 405px;
min-width: 0;
padding-right: 24px;
box-sizing: border-box;
&--detail {
flex: 1 1 auto;
}
}
&__label {
margin: 0;
font-size: @font-size-sm;
color: @finance-axis-text;
line-height: 16px;
color: @text-color-secondary;
line-height: 1.4;
}
&__value {
margin-top: 6px;
margin-top: @spacing-xs;
font-size: @font-size-base;
font-weight: 600;
color: @profile-ink;
line-height: 20px;
.ellipsis();
line-height: 1.4;
}
&__actions {
.flex(row, flex-start, center);
flex-shrink: 0;
gap: 12px;
margin-left: 16px;
.flex(row, flex-end, center);
flex-wrap: wrap;
gap: @spacing-sm;
}
&__btn {
@@ -277,8 +279,9 @@ onMounted(() => {
border: 1px solid @border-color;
border-radius: @radius-round;
background: @white;
color: @finance-entry-text;
color: @text-color;
font-size: @font-size-sm;
white-space: nowrap;
cursor: pointer;
transition: all 0.2s;

View File

@@ -148,10 +148,10 @@ function onSave(): void {
&__title {
position: relative;
padding-left: 12px;
font-size: 12px;
font-weight: 700;
font-size: @font-size-base;
font-weight: 600;
color: @heading-color;
line-height: 20px;
line-height: 1.4;
&::before {
content: '';
@@ -172,7 +172,7 @@ function onSave(): void {
}
&__label {
font-size: 11px;
font-size: @font-size-sm;
font-weight: 500;
color: @heading-color;
}
@@ -180,11 +180,11 @@ function onSave(): void {
&__input,
&__textarea {
width: 100%;
padding: 0 12px;
padding: 0 @spacing-base;
border: 1px solid @profile-input-border;
border-radius: @profile-radius;
background: @white;
font-size: 11px;
font-size: @font-size-sm;
color: @text-color;
outline: none;
transition: border-color 0.2s;
@@ -195,7 +195,7 @@ function onSave(): void {
}
&::placeholder {
color: @heading-color;
color: @text-color-secondary;
}
}
@@ -218,7 +218,7 @@ function onSave(): void {
.app-button {
flex: 1;
height: 36px;
font-size: 12px;
font-size: @font-size-sm;
font-weight: 500;
border-radius: @profile-radius;
}

View File

@@ -1,36 +1,50 @@
<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 {
groups: HelpFaqGroup[]
}
defineProps<Props>()
const props = defineProps<Props>()
const questions = computed<HelpFaqItem[]>(() =>
props.groups.flatMap((group) => group.items),
)
</script>
<template>
<div class="help-faq">
<section
v-for="(group, gi) in groups"
:key="group.title"
class="help-faq__group"
>
<h3 class="help-faq__group-title">
{{ gi + 1 }}、{{ group.title }}
</h3>
<div
v-for="item in group.items"
<aside class="help-faq__aside">
<p
v-for="item in questions"
:key="item.q"
class="help-faq__item"
class="help-faq__q"
>
<p class="help-faq__q">
Q: {{ item.q }}
</p>
<p class="help-faq__a text-secondary">
{{ item.a }}
</p>
Q: {{ item.q }}
</p>
</aside>
<div class="help-faq__main">
<div class="help-faq__main-inner">
<section
v-for="(group, gi) in groups"
:key="group.title"
class="help-faq__group"
>
<h3 class="help-faq__group-title">
{{ gi + 1 }}、{{ group.title }}
</h3>
<p
v-for="item in group.items"
:key="`${group.title}-${item.q}`"
class="help-faq__a"
>
{{ item.a }}
</p>
</section>
</div>
</section>
</div>
<p v-if="groups.length === 0" class="help-faq__empty text-secondary">
暂无常见问题
@@ -40,48 +54,79 @@ defineProps<Props>()
<style lang="less" scoped>
.help-faq {
.flex(column, flex-start);
display: grid;
grid-template-columns: max-content 1fr;
column-gap: @spacing-xxl;
align-items: start;
width: 100%;
min-width: 0;
&__aside {
.flex(column, flex-start, flex-start);
gap: @spacing-xl;
text-align: left;
}
&__q {
margin: 0;
font-size: @font-size-base;
font-weight: 600;
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-xl;
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: left;
}
&__item {
margin-top: @spacing-base;
padding: @spacing-base;
border-radius: @radius-base;
background: @bg-color-light;
}
&__q {
font-size: @font-size-base;
font-weight: 600;
color: @heading-color;
line-height: 1.4;
text-align: center;
}
&__a {
margin: @spacing-sm 0 0;
margin: 0;
width: 100%;
font-size: @font-size-sm;
line-height: 1.6;
line-height: 1.8;
color: @text-color-secondary;
text-align: center;
}
&__empty {
grid-column: 1 / -1;
padding: @spacing-xxl 0;
text-align: center;
font-size: 13px;
font-size: @font-size-sm;
}
}
</style>

View File

@@ -74,7 +74,17 @@ const STATUS_CLASS: Record<QualificationStatus, string> = {
<style lang="less" scoped>
.qual-row {
position: relative;
.flex(row, flex-start, center);
display: grid;
grid-template-columns:
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;
@@ -83,37 +93,22 @@ const STATUS_CLASS: Record<QualificationStatus, string> = {
box-sizing: border-box;
&__col {
flex: 0 0 200px;
min-width: 0;
padding-right: 20px;
box-sizing: border-box;
&--name {
flex: 0 0 270px;
}
&--valid {
flex: 0 0 250px;
}
&--status {
flex: 0 0 100px;
}
}
&__label {
margin: 0;
font-size: @font-size-sm;
color: @finance-axis-text;
line-height: 16px;
color: @text-color-secondary;
line-height: 1.4;
}
&__value {
margin-top: 6px;
margin-top: @spacing-xs;
font-size: @font-size-base;
font-weight: 600;
color: @profile-ink;
line-height: 20px;
.ellipsis();
line-height: 1.4;
}
&__status {
@@ -131,10 +126,9 @@ const STATUS_CLASS: Record<QualificationStatus, string> = {
}
&__actions {
.flex(row, flex-start, center);
flex-shrink: 0;
gap: 12px;
margin-left: auto;
.flex(row, flex-end, center);
flex-wrap: wrap;
gap: @spacing-sm;
}
&__btn {
@@ -143,8 +137,9 @@ const STATUS_CLASS: Record<QualificationStatus, string> = {
border: 1px solid @border-color;
border-radius: @radius-round;
background: @white;
color: @finance-entry-text;
color: @text-color;
font-size: @font-size-sm;
white-space: nowrap;
cursor: pointer;
transition: all 0.2s;

View File

@@ -37,11 +37,11 @@ onMounted(() => {
&__title {
position: relative;
padding-left: 12px;
font-size: 14px;
font-weight: 700;
color: @heading-color;
line-height: 20px;
padding-left: 14px;
font-size: @font-size-base;
font-weight: 600;
color: @profile-ink;
line-height: 1.4;
&::before {
content: '';

View File

@@ -31,45 +31,47 @@ function extOf(name: string): string {
<style lang="less" scoped>
.chat-file-card {
.flex(row, center);
gap: 12px;
min-width: 300px;
gap: @spacing-base;
min-width: 240px;
max-width: 420px;
padding: 14px 16px;
padding: @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @white;
box-shadow: @shadow-base;
&__icon {
flex-shrink: 0;
.flex(row, center);
width: 37px;
height: 37px;
width: 40px;
height: 40px;
border-radius: @radius-sm;
background: @service-scroll-bg;
background: @primary-light;
color: @primary-strong;
font-size: 11px;
font-size: @font-size-xs;
font-weight: 700;
line-height: 1;
}
&__info {
.flex(column, flex-start, flex-start);
gap: 6px;
gap: @spacing-xs;
flex: 1;
min-width: 0;
}
&__name {
font-size: 12px;
font-weight: 700;
line-height: 16px;
color: @service-bubble-text;
font-size: @font-size-sm;
font-weight: 600;
line-height: 1.4;
color: @heading-color;
.ellipsis();
}
&__size {
font-size: 12px;
line-height: 16px;
color: @project-muted;
font-size: @font-size-xs;
line-height: 1.4;
color: @text-color-secondary;
}
}
</style>

View File

@@ -1,10 +1,11 @@
<script setup lang="ts">
import { nextTick, ref } from 'vue'
import { ElButton, ElMessage } from 'element-plus'
import { ElMessage } from 'element-plus'
const MAX_FILE_SIZE = 1024 * 1024 * 1024
const content = ref('')
const fileRef = ref<HTMLInputElement | null>(null)
const emit = defineEmits<{
send: [content: string]
@@ -26,6 +27,10 @@ function onKeydown(event: KeyboardEvent): void {
}
}
function onPickFile(): void {
fileRef.value?.click()
}
function onFileChange(event: Event): void {
const target = event.target
if (!(target instanceof HTMLInputElement)) return
@@ -45,27 +50,35 @@ function onFileChange(event: Event): void {
<textarea
v-model="content"
class="chat-input__textarea"
placeholder="输入消息,按Enter发送,Shift+Enter换行..."
placeholder="输入消息,按 Enter 发送,Shift + Enter 换行"
rows="3"
@keydown="onKeydown"
/>
<div class="chat-input__bar">
<span class="chat-input__hint">
按 Enter 发送 | 支持拖拽上传文件
</span>
<div class="chat-input__actions">
<input
type="file"
class="chat-input__file"
@change="onFileChange"
<div class="chat-input__tools">
<button
type="button"
class="chat-input__attach"
@click="onPickFile"
>
<el-button
class="chat-input__send"
@click="doSend"
>
发送
</el-button>
附件
</button>
<span class="chat-input__hint">Enter 发送</span>
</div>
<input
ref="fileRef"
type="file"
class="chat-input__file"
@change="onFileChange"
>
<button
type="button"
class="chat-input__send"
:disabled="!content.trim()"
@click="doSend"
>
发送
</button>
</div>
</div>
</template>
@@ -73,34 +86,34 @@ function onFileChange(event: Event): void {
<style lang="less" scoped>
.chat-input {
.flex(column, flex-start, stretch);
gap: 0;
padding: 16px 22px 20px;
border-top: 1px solid @service-card-line;
gap: @spacing-base;
padding: @spacing-base @spacing-lg @spacing-lg;
border-top: 1px solid @border-color;
background: @white;
box-sizing: border-box;
&__textarea {
width: 100%;
height: 72px;
padding: 12px 14px;
border: none;
border-radius: @radius-sm;
background: @service-input-bg;
min-height: 84px;
padding: @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @bg-color-light;
color: @service-bubble-text;
font-size: 13px;
font-size: @font-size-sm;
line-height: 1.6;
resize: none;
outline: none;
transition: background 0.2s;
transition: border-color 0.2s, background 0.2s;
box-sizing: border-box;
&:focus {
border: none;
background: @service-input-bg;
border-color: @primary-color;
background: @white;
}
&::placeholder {
color: @project-muted;
color: @text-color-secondary;
}
}
@@ -108,24 +121,35 @@ function onFileChange(event: Event): void {
.flex(row, space-between, center);
gap: @spacing-base;
width: 100%;
margin-top: 0;
padding-top: 14px;
background: @service-bar-bg;
}
&__hint {
font-size: 11px;
line-height: 16px;
color: @project-muted;
}
&__actions {
.flex(row, flex-end, center);
gap: @spacing-sm;
&__tools {
.flex(row, flex-start, center);
gap: @spacing-base;
min-width: 0;
}
&__attach {
display: none;
height: 32px;
padding: 0 @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-round;
background: @white;
color: @text-color;
font-size: @font-size-sm;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: @primary-strong;
color: @primary-strong;
}
}
&__hint {
font-size: @font-size-xs;
line-height: 1.4;
color: @text-color-secondary;
}
&__file {
@@ -133,20 +157,26 @@ function onFileChange(event: Event): void {
}
&__send {
width: 76px;
height: 30px;
padding: 0;
border-color: @primary-strong;
border-radius: @radius-sm;
flex-shrink: 0;
min-width: 80px;
height: 32px;
padding: 0 @spacing-lg;
border: none;
border-radius: @radius-round;
background: @primary-strong;
color: @white;
font-size: 12px;
font-size: @font-size-sm;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
&:hover {
border-color: @primary-strong;
background: @primary-strong;
color: @white;
&:hover:not(:disabled) {
background: @primary-hover;
}
&:disabled {
opacity: 0.45;
cursor: not-allowed;
}
}
}

View File

@@ -29,14 +29,14 @@ withDefaults(defineProps<Props>(), { unread: false })
<span class="chat-message-item__time">{{ message.time }}</span>
</div>
<span v-if="message.role === 'user'" class="chat-message-item__avatar chat-message-item__avatar--user">王</span>
<span v-if="message.role === 'user'" class="chat-message-item__avatar chat-message-item__avatar--user">我</span>
</div>
</template>
<style lang="less" scoped>
.chat-message-item {
.flex(row, flex-start, flex-start);
gap: 16px;
gap: @spacing-base;
&__avatar {
flex-shrink: 0;
@@ -46,55 +46,54 @@ withDefaults(defineProps<Props>(), { unread: false })
border-radius: @radius-round;
background: @primary-strong;
color: @white;
font-size: 14px;
font-weight: 400;
font-size: @font-size-sm;
font-weight: 500;
&--user {
background: @service-user-avatar;
color: @white;
}
}
&__main {
.flex(column, flex-start, flex-start);
gap: 6px;
max-width: 420px;
gap: @spacing-xs;
max-width: min(480px, 70%);
}
&__bubble {
position: relative;
padding: 12px 16px;
border-radius: @radius-base;
padding: @spacing-base;
border-radius: @radius-base @radius-base @radius-base @radius-sm;
background: @white;
border: 1px solid @border-color;
color: @service-bubble-text;
font-size: 13px;
font-size: @font-size-sm;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-word;
box-shadow: @shadow-base;
}
&__bubble:has(.chat-file-card) {
padding: 0;
border: none;
background: transparent;
box-shadow: none;
}
&__time {
font-size: 11px;
line-height: 16px;
color: @project-muted;
font-size: @font-size-xs;
line-height: 1.4;
color: @text-color-secondary;
}
&__unread {
position: absolute;
top: 100%;
top: calc(100% + @spacing-xs);
left: 0;
margin-top: 8px;
padding: 0;
background: transparent;
color: @project-muted;
font-size: 10px;
font-weight: 400;
line-height: 14px;
color: @text-color-secondary;
font-size: @font-size-xs;
line-height: 1.4;
}
&--user {
@@ -105,8 +104,11 @@ withDefaults(defineProps<Props>(), { unread: false })
}
.chat-message-item__bubble {
border: none;
border-radius: @radius-base @radius-base @radius-sm @radius-base;
background: @primary-strong;
color: @white;
box-shadow: none;
}
}
}

View File

@@ -37,14 +37,17 @@ watch(
<template>
<div class="chat-window">
<div class="chat-window__header">
<span class="chat-window__header-avatar" />
<span class="chat-window__header-name">专属客服</span>
<span class="chat-window__header-avatar" aria-hidden="true">客</span>
<div class="chat-window__header-meta">
<span class="chat-window__header-name">专属客服</span>
<span class="chat-window__header-status">在线 · 通常几分钟内回复</span>
</div>
</div>
<div ref="scrollRef" class="chat-window__scroll">
<template v-for="(item, index) in messages" :key="item.id">
<div v-if="index === 0" class="chat-window__divider">
—— 历史消息 ——
<span class="chat-window__divider-text">历史消息</span>
</div>
<ChatMessageItem
:message="item"
@@ -60,7 +63,7 @@ watch(
<style lang="less" scoped>
.chat-window {
.flex(column, flex-start, stretch);
border: none;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @white;
box-sizing: border-box;
@@ -69,8 +72,8 @@ watch(
&__header {
.flex(row, flex-start, center);
gap: @spacing-base;
height: 56px;
padding: 0 @spacing-lg;
min-height: 64px;
padding: @spacing-base @spacing-lg;
border-bottom: 1px solid @border-color;
background: @white;
box-sizing: border-box;
@@ -78,37 +81,65 @@ watch(
&__header-avatar {
flex-shrink: 0;
width: 33px;
height: 33px;
.flex(row, center);
width: 40px;
height: 40px;
border-radius: @radius-round;
background: @primary-strong;
color: @white;
font-size: @font-size-base;
font-weight: 600;
}
&__header-meta {
.flex(column, flex-start, flex-start);
gap: 2px;
min-width: 0;
}
&__header-name {
font-size: 13px;
font-weight: 500;
line-height: 18px;
color: @service-header-name;
font-size: @font-size-base;
font-weight: 600;
line-height: 1.4;
color: @heading-color;
}
&__header-status {
font-size: @font-size-xs;
line-height: 1.4;
color: @text-color-secondary;
}
&__scroll {
.flex(column, flex-start, stretch);
gap: 32px;
height: 450px;
padding: 24px 22px;
background: @service-scroll-bg;
gap: @spacing-lg;
height: 460px;
padding: @spacing-lg;
background: @bg-color-light;
overflow-y: auto;
box-sizing: border-box;
.scrollbar();
}
&__divider {
.flex(row, center, center);
flex-shrink: 0;
margin-bottom: -18px;
font-size: 12px;
line-height: 18px;
color: @project-muted;
text-align: center;
gap: @spacing-base;
&::before,
&::after {
content: '';
flex: 1;
height: 1px;
background: @border-color;
}
}
&__divider-text {
flex-shrink: 0;
font-size: @font-size-xs;
line-height: 1.4;
color: @text-color-secondary;
}
}
</style>

View File

@@ -50,6 +50,9 @@ onMounted(() => {
&__wrap {
width: 100%;
max-width: 960px;
margin-left: auto;
margin-right: auto;
padding: @spacing-lg 0 0;
box-sizing: border-box;
}