Files
cz-web/.claude/skills/vue-project-structure/SKILL.md
2026-10-14 14:19:12 +08:00

336 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
name: vue-project-structure
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
---
# Vue 3项目目录结构规范(通用 · 先探测后套用)
> 适用范围:**Vue 3**(含 uni-app 的 Vue 3 版本)。不涉及 Vue 2。
> 本 skill 与具体仓库无关:执行顺序永远是 **① 探测现状 → ② 选定骨架变体 → ③ 按规范落位**。项目内已有显式文档(`AGENTS.md`/`CLAUDE.md`、`docs/` 下的结构说明)优先级高于本 skill。
---
## 第零步:探测项目形态(每次加载必做)
### 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` |
### 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/news/index.vue 或 pages/news/index.vue)
│ ① 只读 store state / 调 store action
▼
状态层 (stores/modules/news.ts) —— 数据层:取数 + 状态 + 缓存 + 跨页共享
│ ② action 内部调用 api 函数 → await → 写 state
▼
API (api/news.ts) —— 接口层:只发请求、只返回带类型的数据
│ ③ request.ts(axios / uni.request 封装)
▼
后端
```
铁律:
- 页面**禁止** import api;取数必须走 store。
- api 是**纯接口层**:不发业务判断、不缓存、不 toast。
- store 承担取数、状态、缓存、跨页共享;本地缓存用 `storage.ts`,不直接写 `localStorage` / `uni.setStorageSync`。
- 依赖方向单向:`页面 → store → api`,禁止反向与跨层直连。
---
## 各层职责速查
| 层 | 目录 | 一句话职责 | 禁止 |
| ------------ | --------------------------------------------------------- | ---------------------------------------- | ------------------------------ |
| 页面 | `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 注入]
```
### 命名一致性
同一业务域 `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` |
---
## 规范速查(形态无关的硬性项)
### 组件规范
- 统一 `<script setup lang="ts">`。
- props:`defineProps<Props>()` + `withDefaults`;emits:`defineEmits<{ change: [val: number] }>()`,事件名 camelCase。
- 模板内不写复杂表达式(超过一次三元/逻辑运算即抽方法或 computed)。
- 单文件 > 400 行 → 拆到同页面 `cpns/`。
- uni-app 组件模板用 `view/text/image`。
### 样式规范
- **跟随项目既有方案**(less 或 scss),不混用两套预处理器。
- 变量单一来源:Vue 项目 `src/styles/variables.*`;uni-app 项目 `uni.scss`。
- 组件内禁止魔法色值/魔法间距。
- Vue 项目:`<style scoped lang="less|scss">`;uni-app 页面样式**不加 `scoped`**(小程序不支持),靠类名隔离。
### 类型规范
- 实体类型 → `types/business/<domain>.d.ts`;接口通用结构 → `types/api.d.ts`。
- **禁止** `as any` / `@ts-ignore` / `@ts-expect-error`。
### 导入规范
- 有别名(`@/`)的项目一律用别名;禁止多级跨层 `../../../`。
- uni-app 需在 `vite.config` 的 `resolve.alias` 或 `tsconfig` 的 `paths` 配置 `@/`。
### 请求规范
- 全项目**唯一**请求实例/封装:`utils/request.ts`;页面与组件禁止直接 `axios` / `uni.request` / `fetch`。
- baseURL 走环境变量(`import.meta.env.VITE_*`)。
- 错误统一由拦截器兜底,业务侧仅在需要特殊处理时 `catch`。
---
## 工作流
### 场景速查:想做什么 → 改哪些文件
| 想做的事 | 动作 |
| ---------------------- | ------------------------------------------------------------------------------------------ |
| 给已有页面加一块新数据 | 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/、页面在 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`
- [ ] 构建/类型检查零错误
> 发现不符合规范的历史代码:**指出问题并给出改法**,除非本次需求正好涉及,否则不擅自批量重构。