refactor(pc): 从 Gi Admin Pro + Arco 重搭为纯 Vue 3 + Naive UI
旧模板的 193 个文件整体删除,换成纯 Vue 3.5 + Naive UI 2.42 的树。
- 去掉 auto-import(组件与 API 全显式 import)、v-hasPerm(改 user.has('码'),全库 8 处)、
Stylelint;n-tag 的 type 统一过 utils/tag.ts 转换(Arco 色词直接透传会静默变灰)
- 图标改为构建期抽取:scripts/extract-pc-icons.py -> icons/icon-data.ts(37 个),
内网离线部署不会白屏
- 重搭后修掉 6 类"编译 / lint / typecheck 全绿、界面上就是坏的"故障:
① NGrid 的 cols 写成像素值(不是断点名 -> 永远只 1 列,工作台因此错乱)
② n-tab-pane 用 tab 属性(只渲染空注释节点,整排 tab 一个字都不显示)
③ 漏 import 被当未知自定义元素(样式整块丢) ④ .n-card__content 类名不存在
(高度链第一环失效,表格行在 DOM 里但屏幕上没有) ⑤ 填报单状态映射错位一格
⑥ 有标题的卡片没有上内边距(naive 的 padding-top 只给"内容区是长子"的卡片)
- 卡片式版面提成全局基线:styles/index.scss 的 xt-* 令牌 + .page-card / .toolbar /
.section-title,页面只引用、不再各写一套
- 品牌:改名「深学在线」,文字标换成几何标(方 = 卷宗 / 环 = 在线,不用文字字形);
index.html 的页签图标从 404 的 /favicon.ico 改指真实存在的 /favicon.svg
This commit is contained in:
@@ -1,36 +1,112 @@
|
||||
---
|
||||
name: code-css
|
||||
description: Vue SFC 样式块、BEM 命名、Stylelint 与 scoped 约定。编写或修改组件样式(.vue style、scss、less、css)时加载本技能。
|
||||
description: SCSS 写法、样式作用域与全局样式的边界。编写或修改 .vue 的 style 块、.scss 文件时加载本技能。
|
||||
---
|
||||
|
||||
# CSS 规范(Vue 3)
|
||||
# 样式规范(SCSS + Naive UI)
|
||||
|
||||
## SFC 样式块
|
||||
> ⚠️ **Stylelint 已随 2026-10 重搭移除**(`pnpm lint:style` 不存在了)。样式只受 ESLint 与人工约定约束。
|
||||
|
||||
- 位于 `<script>` 之后;默认 **scoped**;全局样式须在注释中说明原因。
|
||||
- 预处理器按项目约定,同一组件内保持一致。
|
||||
## 放哪里
|
||||
|
||||
## 命名(BEM)
|
||||
| 场景 | 位置 |
|
||||
|---|---|
|
||||
| 组件的具体样式 | 该 SFC 的 `<style scoped lang="scss">` |
|
||||
| **跨页面**的版面基线(背景、卡片外观、卡片间距、滚动、分页器容器、区块标题) | `src/styles/index.scss`(**只放跨页面的东西**) |
|
||||
| 组件外观的主题变量(Card / Button / DataTable 的描边、圆角、阴影) | `src/App.vue` 的 `themeOverrides` |
|
||||
|
||||
- 自定义 class:**kebab-case** + **BEM**(`block`、`block__element`、`block--modifier`)。
|
||||
- 一个 SFC 通常一个 Block;元素用 `__`,修饰符用 `--`;**禁止** `block__a__b` 链式元素。
|
||||
- 第三方 UI 类名、全局工具类不在 BEM 约束内。
|
||||
## 设计基线(2026-10 卡片式改造后:**页面只引用,不再各写一套**)
|
||||
|
||||
```scss
|
||||
.user-card {
|
||||
&__avatar { width: 48px; }
|
||||
&__badge--vip { color: var(--color-warning); }
|
||||
}
|
||||
```
|
||||
`src/styles/index.scss` 的 `:root` 里定义,页面直接 `var()`:
|
||||
|
||||
## 编写原则
|
||||
| 变量 | 值 | 用途 |
|
||||
|---|---|---|
|
||||
| `--xt-header-h` | `56px` | 顶栏高度(`.app-shell__header` 与 `.page-card` 高度算式**共用**) |
|
||||
| `--xt-content-pad` | `16px` | 内容区内边距(同上,两处共用,别写死数值) |
|
||||
| `--xt-gap` | `16px` | 卡片/区域之间的间距 |
|
||||
| `--xt-bg` | `#f2f4f7` | 页面底色(**卡片要靠它才浮得起来**) |
|
||||
| `--xt-card-border` / `--xt-card-border-weak` | `#e5e8ee` / `#eef1f5` | 卡片描边 / 嵌套卡片描边 |
|
||||
| `--xt-card-radius` | `10px` | 卡片圆角 |
|
||||
| `--xt-card-pad-x` / `--xt-card-pad-y` | `18px` / `16px` | 卡片内容区左右 / 上下内边距(**别在页面里写死**,见下) |
|
||||
| `--xt-band-bg` | `#f7f8fa` | 浅灰色带:卡片头 / 表头 / 卡片内的子格子 |
|
||||
| `--xt-text` / `--xt-text-2` / `--xt-text-3` | `#1f2229` / `#667085` / `#98a2b3` | 主 / 次 / 弱文字 |
|
||||
|
||||
- 颜色、间距、字号优先复用项目 CSS 变量或设计令牌。
|
||||
- 避免内联 `style`(动态样式除外)和滥用 `!important`。
|
||||
- 遵循项目 Stylelint;改动后视情况运行 `lint:style`。
|
||||
### ⚠️ 卡片内边距:**不要让页面自己写**
|
||||
|
||||
## 禁止
|
||||
Naive 的规则是 `cB("card-content", "padding: 0 var(--n-padding-left) var(--n-padding-bottom) …")`
|
||||
外加 `c("&:first-child", "padding-top: var(--n-padding-top)")` ——
|
||||
**`padding-top` 只给"内容区是长子"(也就是没有卡片头)的卡片**。
|
||||
所以有标题的卡片,内容区 `padding-top` 是 **0**,第一行内容直接贴在标题下面那条灰条上。
|
||||
|
||||
- ❌ 自定义 class 用 camelCase 或非语义命名
|
||||
- ❌ BEM 元素链式嵌套(`block__a__b`)
|
||||
- ❌ 大段内联 style 或过多 `:deep()` 堆叠
|
||||
全局 `.n-card.n-card > .n-card-content { padding: var(--xt-card-pad-y) var(--xt-card-pad-x) }`
|
||||
已经统一处理掉了。**但它是普通样式,会被内联样式盖掉** ——
|
||||
页面里写 `:content-style="{ padding: '12px' }"` 会让那张卡片比邻居紧一圈
|
||||
(而且那个写法只解决左右,不管上面这件事)。要调内边距就改 `--xt-card-pad-x/y`。
|
||||
|
||||
> 📌 排查手法:`getComputedStyle(card.querySelector(':scope > .n-card-content')).paddingTop`
|
||||
> 应当是 `16px`;是 `0` 说明没匹配上,是别的值说明有内联样式或更具体的选择器在盖。
|
||||
|
||||
现成的全局类(**优先复用,不要另起名字**):
|
||||
|
||||
| 类 | 用途 |
|
||||
|---|---|
|
||||
| `.page-plain` | 普通(非列表)页根节点:flex 列 + `--xt-gap` 间距 |
|
||||
| `.page-card` + `.page-card__body` | **列表页专用**高度链:卡片撑满内容区 → 表体自滚 → 分页器钉底 |
|
||||
| `.toolbar` / `.toolbar--plain` | 筛选与动作行(自带下分隔线 / 去掉分隔线) |
|
||||
| `.toolbar__hint` | 工具条里的灰色小字提示 |
|
||||
| `.section-title` + `.section-title__note` | 卡片**内部**次级区块标题(左侧蓝色小竖条 + 右侧灰色说明) |
|
||||
|
||||
## 卡片式的三条规矩
|
||||
|
||||
1. **卡片外观是全局给的**:`.n-card.n-card` 统一白底 + 描边 + 轻阴影 + 卡片头浅灰色带
|
||||
(权重写成 `.n-card.n-card` 是因为 naive 的样式由 css-render **运行时注入**,先后顺序不保证)。
|
||||
页面**不要再**自己给卡片写边框/阴影。
|
||||
2. **卡里不要套卡**:卡片内部要分区时,用浅灰底(`--xt-band-bg`)+ 弱描边(`--xt-card-border-weak`)
|
||||
的小格子,或 `.section-title` + `1px` 分隔线。嵌套的 `NCard` 已被全局降级为无阴影 + 无色带,
|
||||
但布局上仍然容易糊成一片。
|
||||
3. **区域间隔靠"色带 + 描边",不是靠加大 margin**:卡片之间 `--xt-gap`,卡片内区块 12~20px。
|
||||
上一版的教训是把卡片写成 `:bordered="false"` + 内容区白底 → 白卡片浮在白底上,整页没有任何边界。
|
||||
|
||||
## 按钮的可见性
|
||||
|
||||
- 页面**主操作**(新建 / 导入 / 批量通过…)用 `type="primary"`,**默认尺寸**(不要 `size="small"`)。
|
||||
- **同一行内控件尺寸必须一致**:一行里混用 `small`(28px) 与默认(34px) 会参差不齐。
|
||||
工具条统一默认尺寸;表格行内动作保持 `text` + `size="small"`。
|
||||
- 默认按钮的描边已在 `App.vue` 里加深(`#d0d5dd`)—— 原来用的 `common.borderColor`(`#e0e0e6`)
|
||||
在白色卡片上几乎看不见,这是"按钮不明显"的主因。
|
||||
- ⚠️ naive 的按钮**边框画在子元素 `.n-button__border` 上**,不是根节点;
|
||||
用 `getComputedStyle(btn).borderTopWidth` 检查会得到 `0px`,别据此判断"边框没生效"。
|
||||
|
||||
## 覆盖 naive 内部类之前:**先确认真实类名**
|
||||
|
||||
⚠️ naive 的类名**不是一套统一规则**,同一个组件里两种命名混着用:
|
||||
|
||||
| 真实类名 | 由什么生成 |
|
||||
|---|---|
|
||||
| `.n-card-header` / `.n-card-content` | `cB("card-header")` / `cB("card-content")` —— **单短横** |
|
||||
| `.n-button__border` / `.n-button__content` | `cE("border")` / `cE("content")` —— **双下划线** |
|
||||
|
||||
所以"想当然按 BEM 写"会**静默失效**:`:deep(.n-card__content)` 匹配不到任何元素,
|
||||
不报错、不影响编译,只是那条样式**什么也没做**。
|
||||
|
||||
> 2026-10 实际踩到:`.page-card` 的高度链第一环(卡片内容区要是能撑满的 flex 列)
|
||||
> 就写在 `:deep(.n-card__content)` 里 —— 结果「操作日志」的表格体高度算成 **0**,
|
||||
> 行**在 DOM 里、数据也查得到,屏幕上一行都看不见**。
|
||||
>
|
||||
> **正确做法**:在 DevTools 里选中那个元素,看它实际的 class;或者去
|
||||
> `node_modules/naive-ui/es/<组件>/src/styles/index.cssr.mjs` 里搜一眼。
|
||||
> 顺带:`.n-card-content` 与 `.n-card-header` 这两个名字已经被 `styles/index.scss` 用到,
|
||||
> 改卡片相关样式时以那里为准。
|
||||
|
||||
## 约定
|
||||
|
||||
- class 用 **kebab-case**;同一块的子元素用 `块__元素` 风格(`task__tabs`、`join__hint`),
|
||||
与现有页面保持一致即可,**不要引入新的方法论或工具链**。
|
||||
- 颜色优先用 Naive UI 的 CSS 变量 / 主题变量,**不要**写死 Arco 时代的 `--arco-*` 变量(已不存在)。
|
||||
- 选择器保持扁平(≤2 层),避免 `!important`;确实要覆盖 naive 内部类时**加注释说明原因**。
|
||||
|
||||
## 踩过的坑(照抄现有写法即可)
|
||||
|
||||
1. **列表页卡片要能自己滚**:分页器被裁出可视区是本项目反复踩的问题 ——
|
||||
列表页统一用 `styles/index.scss` 里的 `.page-card` 容器(卡片撑满内容区 → 表体自己滚 → 分页器钉在卡片底部)。
|
||||
2. 页面根节点用 `NPagination`/`NDataTable` 时别自己写 `overflow: hidden`,会把分页器裁掉。
|
||||
|
||||
@@ -1,38 +1,41 @@
|
||||
---
|
||||
name: code-ts
|
||||
description: Vue 3 + TypeScript 命名、导入顺序、目录组织与 Lint 约定。编写或修改 .ts/.js 代码、新增 composable 或工具函数时加载本技能。
|
||||
description: Vue 3 + TypeScript 命名、导入顺序、显式 import 与目录组织。编写或修改 .ts/.js 代码、新增 composable 或工具函数时加载本技能。
|
||||
---
|
||||
|
||||
# TypeScript 规范(Vue 3)
|
||||
# TypeScript 规范(Vue 3 + Naive UI)
|
||||
|
||||
Vue SFC 见 [code-vue](../code-vue/SKILL.md),样式见 [code-css](../code-css/SKILL.md)。
|
||||
|
||||
## 命名
|
||||
|
||||
- 组件 / 文件名:**PascalCase**;变量 / 函数:**camelCase**(事件处理推荐 `handle` 前缀)。
|
||||
- 常量:需要时 **UPPER_SNAKE_CASE**;Composables:`use` 前缀。
|
||||
- Props camelCase;模板事件名与 emits 定义 **kebab-case**。
|
||||
- 样式 class:**kebab-case** + BEM(见 [code-css](../code-css/SKILL.md));全局组件前缀遵循项目统一约定。
|
||||
- 组件文件:**PascalCase**(`AppIcon.vue`、`AuditDetailDrawer.vue`);工具/接口模块:**kebab-case 或小写**(`export-task.ts`、`acting-tenant.ts`)。
|
||||
- 变量 / 函数:**camelCase**;事件处理用 `handle` 前缀;Composables 用 `use` 前缀。
|
||||
- 常量:`UPPER_SNAKE_CASE`(如 `TENANT_STATUS`);展示映射放在 `src/apis/xt/<域>.ts` 里与接口同处。
|
||||
- 样式 class:kebab-case(见 [code-css](../code-css/SKILL.md))。
|
||||
|
||||
## TypeScript
|
||||
|
||||
- `import type` 与值导入分离;对象结构优先 `interface`,联合/工具类型用 `type`。
|
||||
- `import type { ... }` 与值导入**分开写**(ESLint `@antfu` 会强制)。
|
||||
- 对象结构优先 `interface`;联合/工具类型用 `type`。
|
||||
- Props 默认值:`withDefaults(defineProps<Type>(), { ... })`;避免 `any`。
|
||||
- 公共类型集中管理;领域类型靠近业务模块。
|
||||
- **接口出入参类型直接复用 `src/apis/xt/` 里的契约类型**,不要手写一份字段。
|
||||
|
||||
## 导入顺序
|
||||
## 导入顺序(ESLint 会排)
|
||||
|
||||
1. 类型导入 → 2. 第三方 → 3. Vue/官方库 → 4. 项目内部(composables、组件、utils、样式)
|
||||
1. 类型导入(`import type`) → 2. 第三方(`vue` / `naive-ui` / `axios`) → 3. 项目内部(`@/apis`、`@/stores`、`@/utils`、`@/icons`、`@/composables`)
|
||||
|
||||
不要重复 import 已自动注册的符号(类型除外)。
|
||||
⚠️ **本仓库没有 auto-import**:naive-ui 组件、Vue API(`ref`/`computed`/`h`…)、工具函数**必须显式 import**。
|
||||
不要写"为已自动导入的 API 写冗余 import"这类旧规范 —— 那是上一版(Gi 模板)的约定,现在写了才不报错。
|
||||
|
||||
## 组织
|
||||
## 常用内部模块
|
||||
|
||||
- 遵循项目现有目录划分;单文件建议不超过 400 行,超出应拆分。
|
||||
- 可复用 UI 放组件目录;跨页面逻辑抽 composables;纯函数抽 utils。
|
||||
- 路径别名遵循项目配置,避免 `../../../` 穿越。
|
||||
|
||||
## Lint
|
||||
|
||||
- 遵循项目 ESLint、Stylelint、tsconfig,不擅自关闭规则。
|
||||
- 必要的 `eslint-disable` / `@ts-expect-error` 须注释说明原因。
|
||||
| 模块 | 用途 |
|
||||
|---|---|
|
||||
| `@/utils/http.ts` | axios 实例:成功直接 resolve `data`、失败弹提示、带 `Authorization` 与 `X-Tenant-Id` |
|
||||
| `@/utils/naive.ts` | 全局 `message`(在组件外用,如 http 拦截器) |
|
||||
| `@/utils/tag.ts` | `tagOf()`:状态色词 → `n-tag` 的 `type`(**必须过这一层**) |
|
||||
| `@/utils/format.ts` | `fmtDate` 等格式化 |
|
||||
| `@/composables/pagination.ts` | `listPagination()`:列表页分页器统一参数 |
|
||||
| `@/stores/user.ts` | `useUserStore()`:`has('码')` / `hasAny(...)` / `menus` / `isPlatform` |
|
||||
| `@/stores/acting-tenant.ts` | 平台运营的「当前学校」(请求头 `X-Tenant-Id`) |
|
||||
|
||||
@@ -1,67 +1,92 @@
|
||||
---
|
||||
name: code-vue
|
||||
description: Vue 3 Composition API、SFC 块顺序、defineEmits、useTemplateRef、响应式约定。编写或修改 .vue 文件时加载本技能。
|
||||
description: Vue 3 Composition API、SFC 块顺序、Naive UI 组件显式导入、props/emits 与模板约定。编写或修改 .vue 文件时加载本技能。
|
||||
---
|
||||
|
||||
# Vue 规范(Vue 3)
|
||||
# Vue 规范(Vue 3 + Naive UI)
|
||||
|
||||
适用于 Composition API + `<script setup lang="ts">`。样式见 [code-css](../code-css/SKILL.md)。
|
||||
|
||||
## 基础
|
||||
|
||||
- 新组件一律 **Composition API** + `<script setup>`,禁止 Options API。
|
||||
- 需要组件名时用 `defineOptions({ name: 'ComponentName' })`;路由页 `name` 与路由配置一致。
|
||||
- 块顺序:`<template>` → `<script setup>` → `<style>`(遵循项目 ESLint block-order)。
|
||||
- 新组件一律 **Composition API** + `<script setup lang="ts">`,禁止 Options API。
|
||||
- 块顺序:`<template>` → `<script setup>` → `<style scoped>`。
|
||||
- 需要组件名时用 `defineOptions({ name: 'XxxYyy' })`。
|
||||
|
||||
## Script 内顺序
|
||||
|
||||
类型导入 → 第三方 → Vue API → 项目模块 → `defineOptions` → `defineModel` → `defineProps` + `withDefaults` → `defineEmits` → `defineSlots` → 常量/函数 → `ref`/`reactive` → `computed` → 方法 → 生命周期/`watch` → `defineExpose`
|
||||
类型导入 → 第三方 → Vue API → 项目模块 → `defineOptions` → `defineProps` + `withDefaults` → `defineEmits` → 常量/函数 → `ref`/`reactive` → `computed` → 方法 → 生命周期/`watch`。
|
||||
|
||||
## 自动导入
|
||||
## ⚠️ 显式 import(本仓库没有 auto-import)
|
||||
|
||||
已自动注册的 API / 组件不要重复 import;仅需类型时用 `import type`。以 `vite.config` 与现有代码为准。
|
||||
```ts
|
||||
import type { DataTableColumns, FormInst } from 'naive-ui'
|
||||
import { NButton, NCard, NDataTable, NForm, NFormItem, NInput, NModal, NTag, useMessage } from 'naive-ui'
|
||||
import { computed, h, onMounted, reactive, ref } from 'vue'
|
||||
import * as tenantApi from '@/apis/xt/tenant'
|
||||
```
|
||||
|
||||
- 组件用 **PascalCase** 标签:`<NDataTable :columns="columns" :data="list" />`(**不是** `<a-table>`)。
|
||||
- 弹窗/抽屉、表格、表单全部来自 **naive-ui**;主题与全局配置在 `utils/naive.ts` / `App.vue` 一侧。
|
||||
|
||||
### 漏 import 是**静默故障**(2026-10 实测踩到 4 个文件 6 处)
|
||||
|
||||
写小写标签(`<n-button>`)而忘了 import 时,Vue 把它当**未知自定义元素**渲染:
|
||||
**不报错、不崩、`pnpm typecheck` 与 `pnpm lint` 全绿**,只是样式整块丢掉。
|
||||
实测表现:`<n-button>` 变成无边框透明块、`<n-avatar>` 变成 18×29 的裸元素、`<n-form>` 的表单排版全丢 ——
|
||||
用户看到的就是"这个页面样式坏了"。
|
||||
|
||||
> **统一写 PascalCase 标签**(`<NButton>`),漏 import 时 Vue 会给出
|
||||
> `Failed to resolve component` 警告,比小写标签好排查。
|
||||
> 另外 `python scripts/check-wiring.py` 的**第 ⑧ 项**会静态核对
|
||||
> 「模板里用到的 `<n-*>` ↔ 本文件的 import」,改完 PC 端跑一下它。
|
||||
|
||||
## ⚠️ `n-tab-pane` 的标签必须用 `#tab` **插槽**
|
||||
|
||||
naive-ui 2.45.3 里,`tab="文字"` **属性**写法渲染不出任何文字(只留一个空注释节点),
|
||||
表现是**整排 tab 一个字都不显示、整行高度只剩 12px** —— 筛选功能等于消失,且不报错。
|
||||
|
||||
```vue
|
||||
<!-- ✅ 正确 -->
|
||||
<NTabs v-model:value="node">
|
||||
<NTabPane :name="1">
|
||||
<template #tab>
|
||||
初审(班主任)
|
||||
</template>
|
||||
</NTabPane>
|
||||
</NTabs>
|
||||
|
||||
<!-- ❌ 不要:标签会静默消失 -->
|
||||
<NTabPane :name="1" tab="初审(班主任)" />
|
||||
```
|
||||
|
||||
原因:属性值走 `normalizeVNode(() => render(mergedTab ?? name))`,而这个 thunk 返回
|
||||
Text VNode,被当成块 `createBlock(vnode)` 处理;插槽那条分支返回数组,所以正常。
|
||||
|
||||
## 权限与状态色
|
||||
|
||||
```ts
|
||||
const user = useUserStore()
|
||||
// 没有 v-hasPerm 指令,权限判断就是一次函数调用
|
||||
const canManage = computed(() => user.has('org:manage'))
|
||||
```
|
||||
|
||||
```vue
|
||||
<NTag
|
||||
:type="tagOf(row.status)"
|
||||
size="small"
|
||||
>
|
||||
{{ STATUS_NAMES[row.status] }}
|
||||
</NTag>
|
||||
```
|
||||
|
||||
⚠️ **不要**把 `apis/xt/*.ts` 里的 `color`(Arco 色词 `green`/`arcoblue`/`gold`)直接传给 `n-tag` 的 `type` ——
|
||||
会**静默变成默认灰色**且不报错;必须过 `@/utils/tag.ts` 的 `tagOf()`。
|
||||
|
||||
## 模板
|
||||
|
||||
- 事件名、插槽名 **kebab-case**;组件 **PascalCase**。
|
||||
- 受控表单优先 `v-model`;复杂逻辑抽到 `computed` 或方法,不要写在模板里。
|
||||
- 子组件通过 props/emits 通信,避免随意访问父组件实例。
|
||||
|
||||
## defineEmits
|
||||
|
||||
使用**基于类型的函数调用签名**,禁止数组 / tuple 形式:
|
||||
|
||||
```typescript
|
||||
// ✅
|
||||
const emit = defineEmits<{
|
||||
(e: 'submit'): void
|
||||
(e: 'change', value: string): void
|
||||
}>()
|
||||
|
||||
// ❌ defineEmits<{ submit: [] }>() 或 tuple 形式
|
||||
```
|
||||
|
||||
## 模板 Ref
|
||||
|
||||
优先 **`useTemplateRef`**(Vue 3.5+);模板 `ref` 属性值与参数字符串一致。
|
||||
|
||||
```typescript
|
||||
const formRef = useTemplateRef<FormInstance>('formRef')
|
||||
// template: <form ref="formRef">
|
||||
```
|
||||
|
||||
## 响应式
|
||||
|
||||
- 原始值 `ref`,对象 `reactive`,派生 `computed`。
|
||||
- 解构 reactive 注意响应性丢失,必要时 `toRefs`。
|
||||
- 深拷贝优先 `structuredClone` 或项目工具,禁止 `JSON.parse(JSON.stringify())`。
|
||||
|
||||
## defineExpose
|
||||
|
||||
仅暴露父组件确实需要调用的方法或属性(如弹窗 `open`/`close`/`reset`)。
|
||||
|
||||
## 禁止
|
||||
|
||||
- ❌ Options API、重复自动导入、defineEmits tuple 形式
|
||||
- ❌ 无关 `ref()` 绑定模板 ref(优先 `useTemplateRef`)
|
||||
- ❌ 模板中写复杂业务逻辑
|
||||
- 表格列用 `h()` 渲染(`NButton` / `NTag` / `AppIcon`),逻辑别堆在模板里。
|
||||
- 受控表单优先 `v-model`;复杂逻辑抽到 `computed` 或方法。
|
||||
- 图标统一用 `@/icons/AppIcon.vue`(缺图标是**空占位、不报错**)。
|
||||
- 子组件通过 props/emits 通信,避免访问父组件实例。
|
||||
|
||||
@@ -1,162 +1,58 @@
|
||||
---
|
||||
name: project-api
|
||||
description: 在本模板项目中新增或修改 API 接口与类型定义(本仓库已不用 Mock,见开头说明)。涉及 http、baseAPI、分页响应格式、响应体适配时使用。
|
||||
description: 接口调用约定(axios 封装、apis/xt 模块、平台代管头、错误处理)。新增或修改接口调用时加载本技能。
|
||||
---
|
||||
|
||||
# API 与 Mock
|
||||
# 接口调用
|
||||
|
||||
> ⚠️ **本仓库不用 Mock(2026-09-30 核实)**:`mock/` 目录与 `vite-plugin-mock` 都已删除,
|
||||
> `.env.development` 里 `VITE_API_PREFIX = '/api/v1'`、`VITE_API_BASE_URL = 'http://localhost:8080'`,
|
||||
> `pnpm dev` 把 `/api` 代理到真实后端 —— **后端不起着就没有数据**(这不是故障)。
|
||||
>
|
||||
> 所以本文档里所有关于 Mock 的部分(`mock/**`、`defineMock`、`getBaseApi` 生成 mock、
|
||||
> Mock 目录约定)在本仓库**不适用**,保留它们只是因为 Gi 模板升级时要做差异比对。
|
||||
> 新增接口照 `src/apis/xt/*.ts` 的写法:用 `@/utils/http`,响应体 `{ code, msg, data }`
|
||||
> (适配层会补 `records`)已在 `@/utils/http` 统一处理,业务模块不要再自己解一层。
|
||||
> ⚠️ **本仓库没有 Mock**:`mock/` 与 `vite-plugin-mock` 都已删除。
|
||||
> `pnpm dev` 把 `/api` 代理到 `http://localhost:8080` → **后端不起着就没有数据**。
|
||||
> (上一版这份 skill 以 Mock 为主线,那是 Gi 模板的写法,**已不适用**。)
|
||||
|
||||
## 接口层结构
|
||||
## 契约
|
||||
|
||||
每个业务模块一个文件,例如 `src/apis/system/user.ts`:
|
||||
- 响应恒为 `{ code, msg, data }`;**业务错误也用 HTTP 200**,
|
||||
只有 `401` / `403` / `429` / `5xx` 用**真实状态码**(别在业务码里找 `40301`)。
|
||||
- `code === 0` 是成功。
|
||||
|
||||
## 封装(`src/utils/http.ts`)
|
||||
|
||||
它替业务代码做掉三件事,所以页面里几乎不出现 `code`:
|
||||
|
||||
1. 成功时**直接 resolve `data`** → `const vo = await getTask(id)` 拿到的就是数据;
|
||||
2. 失败时**弹一次提示**并 reject 一个带 `bizCode` 的错误(要按码分支也能拿到);
|
||||
3. 自动带 `Authorization: Bearer <token>` 与平台代管头 `X-Tenant-Id`。
|
||||
|
||||
## 写一个接口模块(`src/apis/xt/<域>.ts`)
|
||||
|
||||
```ts
|
||||
import { getBaseApi } from '@/apis/base'
|
||||
import type { PageResult } from '@/apis/types'
|
||||
import http from '@/utils/http'
|
||||
|
||||
export interface ListItem {
|
||||
id: string
|
||||
name: string
|
||||
status: Status
|
||||
createTime: string
|
||||
// ...
|
||||
export interface XxxVO { id: number, name: string, status: number }
|
||||
|
||||
/** 状态映射与接口同处一个文件(别处也要用,改动只有一处) */
|
||||
export const XXX_STATUS: Record<number, { text: string, color: string }> = {
|
||||
0: { text: '停用', color: 'gray' },
|
||||
1: { text: '正常', color: 'green' }
|
||||
}
|
||||
|
||||
/** 标准 CRUD */
|
||||
export const baseAPI = getBaseApi<ListItem>({ baseUrl: '/system/user' })
|
||||
|
||||
/** 非标准接口单独声明 */
|
||||
export function getDictDataList(params: { code: string } & Pagination) {
|
||||
return http.get<PageRes<DictDataItem[]>>('/system/dict/getDictDataList', params)
|
||||
export function list(params: { page: number, pageSize: number, keyword?: string }) {
|
||||
return http.get<PageResult<XxxVO>>('/xxx', { params })
|
||||
}
|
||||
```
|
||||
|
||||
## getBaseApi 约定路径
|
||||
- 新域就**新建一个文件**,并在 `src/apis/xt/index.ts` 里按需导出;
|
||||
- 通用类型(`PageResult`、`BizError` 等)在 `src/apis/types.ts`;
|
||||
- ⚠️ 展示映射里的 `color` 是**上一版 Arco 的色词**,给 `n-tag` 用之前必须过 `@/utils/tag.ts` 的 `tagOf()`。
|
||||
|
||||
| 方法 | HTTP | 路径 |
|
||||
|------|------|------|
|
||||
| getList | GET | `{baseUrl}/getList` |
|
||||
| getDetail | GET | `{baseUrl}/getDetail` |
|
||||
| add | POST | `{baseUrl}/add` |
|
||||
| update | POST | `{baseUrl}/update` |
|
||||
| delete | POST | `{baseUrl}/delete` |
|
||||
## 平台代管(`X-Tenant-Id`)
|
||||
|
||||
列表参数:`{ page, size, ...filters }`
|
||||
删除参数:`{ ids: string[] }`
|
||||
平台运营的请求要带"当前学校":值存在 `src/stores/acting-tenant.ts`,
|
||||
由 `http.ts` 统一注入。**页面不要自己拼这个头**;不选学校时**不要发请求**(发了会把各租户数据混在一起)。
|
||||
|
||||
## 响应格式
|
||||
## 其它
|
||||
|
||||
```ts
|
||||
// 单条/布尔
|
||||
interface ApiRes<T> {
|
||||
code: number
|
||||
data: T
|
||||
message: string
|
||||
success: boolean
|
||||
}
|
||||
|
||||
// 分页列表 data 结构
|
||||
interface PageRes<T> {
|
||||
records: T
|
||||
total: number
|
||||
}
|
||||
```
|
||||
|
||||
`useTable` 同时兼容 `data` 为数组或 `PageRes` 两种形态。
|
||||
|
||||
## http 封装
|
||||
|
||||
- 实例:`src/utils/http.ts`,`baseURL` = `import.meta.env.VITE_API_PREFIX`
|
||||
- 开发环境 `.env.development`:`VITE_API_PREFIX = '/mock'`
|
||||
- Token:请求头 `token` 字段(`getToken()`)
|
||||
- 业务失败:`success === false` 时 Message/Notification 提示并 reject
|
||||
- 401:跳转 `/login`
|
||||
|
||||
```ts
|
||||
import http from '@/utils/http'
|
||||
|
||||
http.get<PageRes<Item[]>>('/path', { page: 1, size: 10 })
|
||||
http.post<boolean>('/path', { ids: ['1'] })
|
||||
```
|
||||
|
||||
## Mock 开发流程
|
||||
|
||||
### 1. 静态数据
|
||||
|
||||
在 `mock/_data/` 下维护 JSON/TS 数据,如 `system_dict.ts`。
|
||||
|
||||
### 2. 路由 Mock
|
||||
|
||||
在 `mock/system/`(或对应模块)新建文件:
|
||||
|
||||
```ts
|
||||
import { defineMock } from '../_base'
|
||||
import { getBaseApi, getDelayTime, resultError, resultSuccess } from '../_utils'
|
||||
|
||||
export default defineMock([
|
||||
...getBaseApi({
|
||||
baseUrl: '/system/xxx',
|
||||
getListData: () => resultSuccess({ total: list.length, records: list }),
|
||||
getDetailData: (query) => {
|
||||
const obj = list.find((i) => i.id === query.id)
|
||||
return obj ? resultSuccess(obj) : resultError(null, '未找到', 400)
|
||||
}
|
||||
}),
|
||||
{
|
||||
url: '/system/xxx/customAction',
|
||||
method: 'get',
|
||||
timeout: getDelayTime(),
|
||||
response: ({ query }) => {
|
||||
// 筛选、分页后返回
|
||||
return resultSuccess({ total, records })
|
||||
}
|
||||
}
|
||||
])
|
||||
```
|
||||
|
||||
### 3. 工具函数
|
||||
|
||||
| 函数 | 用途 |
|
||||
|------|------|
|
||||
| `resultSuccess(data)` | `{ code:200, success:true, data, message }` |
|
||||
| `resultError(data, message, code?)` | 失败响应 |
|
||||
| `getDelayTime()` | 50–300ms 随机延迟 |
|
||||
| `getBaseApi({ baseUrl, getListData, ... })` | 生成标准 CRUD mock |
|
||||
|
||||
`defineMock` 会自动给 url 加前缀 `/mock`,与 `VITE_API_PREFIX` 对应。
|
||||
|
||||
### 4. 注册
|
||||
|
||||
确保 mock 文件被 `mock/index` 或同级聚合引入(遵循现有 `mock/system/*.ts` 模式)。
|
||||
|
||||
## 新增模块检查清单
|
||||
|
||||
- [ ] `src/apis/{module}/{name}.ts`:类型 + `baseAPI` 或自定义方法
|
||||
- [ ] `mock/{module}/{name}.ts`:与接口路径一致
|
||||
- [ ] 列表 Mock 返回 `{ total, records }`
|
||||
- [ ] 字段类型与 `Status`、`Gender` 等全局类型对齐
|
||||
- [ ] 在页面通过 `useTable({ listAPI, deleteAPI })` 接入
|
||||
|
||||
## 自定义 listAPI 示例
|
||||
|
||||
```ts
|
||||
useTable({
|
||||
listAPI: (page) => getDictDataList({
|
||||
...page,
|
||||
code: dictCode.value,
|
||||
name: query.name || undefined,
|
||||
status: query.status === '' ? undefined : query.status
|
||||
}),
|
||||
immediate: false
|
||||
})
|
||||
```
|
||||
|
||||
`immediate: false` 用于依赖左侧选中项后再 `search()` 的场景。
|
||||
- 下载类接口(导出包、整理结果)必须走 `fetch`/axios **取 blob**:接口要鉴权,
|
||||
直接 `window.open` 或 `<a href>` 会 401,用户看到的是"下载没反应"。
|
||||
- 签名 URL(预览图片等)**每次现取**,不要缓存进本地状态或存库(有有效期)。
|
||||
|
||||
@@ -1,214 +1,121 @@
|
||||
---
|
||||
name: project-crud
|
||||
description: 在本模板项目中创建或修改 CRUD 列表页、表格列、搜索区、表单弹窗。使用 GiPageLayout、useTable、GiButton、a-table、GiForm、useDict 时加载本技能。
|
||||
description: 列表页 + 新增/编辑弹窗的标准写法(Naive UI + 本项目约定)。新增或修改 CRUD 页面时加载本技能。
|
||||
---
|
||||
|
||||
# CRUD 列表页开发
|
||||
# CRUD 列表 / 表单(标准写法)
|
||||
|
||||
> 写/改 `.vue` 前先读 [code-vue](../code-vue/SKILL.md);写样式前读 [code-css](../code-css/SKILL.md)。
|
||||
先找一个同类页面照抄结构(`src/views/tenant/index.vue`、`src/views/user/index.vue`、
|
||||
`src/views/dict/index.vue` 是最典型的三份)。
|
||||
|
||||
## 标准页面结构
|
||||
## 列表页骨架
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<GiPageLayout margin collapsed>
|
||||
<template #left><!-- 可选:树/分类 --></template>
|
||||
<script setup lang="ts">
|
||||
import type { DataTableColumns } from 'naive-ui'
|
||||
import { NButton, NCard, NDataTable, NSpace, NTag, useMessage } from 'naive-ui'
|
||||
import { h, onMounted, reactive, ref } from 'vue'
|
||||
import * as xxxApi from '@/apis/xt/xxx'
|
||||
import { listPagination } from '@/composables/pagination'
|
||||
import { fmtDate } from '@/utils/format'
|
||||
import { tagOf } from '@/utils/tag'
|
||||
|
||||
<a-row justify="space-between" class="g-row-tool">
|
||||
<a-space wrap>
|
||||
<GiButton type="add" @click="onAdd" />
|
||||
<GiButton type="delete" @click="onBatchDelete" />
|
||||
</a-space>
|
||||
<a-space wrap>
|
||||
<!-- 搜索表单项 -->
|
||||
<GiButton type="search" @click="search" />
|
||||
<GiButton type="reset" @click="reset" />
|
||||
</a-space>
|
||||
</a-row>
|
||||
const message = useMessage()
|
||||
const loading = ref(false)
|
||||
const list = ref<XxxVO[]>([])
|
||||
const query = reactive({ page: 1, pageSize: 20, keyword: '' })
|
||||
const total = ref(0)
|
||||
|
||||
<a-table
|
||||
class="g-table"
|
||||
row-key="id"
|
||||
:loading="loading"
|
||||
:data="tableData"
|
||||
:columns="tableColumns"
|
||||
:bordered="{ cell: true }"
|
||||
:scroll="{ x: '100%', y: '100%', minWidth: 1200 }"
|
||||
:pagination="pagination"
|
||||
:row-selection="{ type: 'checkbox', showCheckedAll: true }"
|
||||
:selected-keys="selectedKeys"
|
||||
@select="select"
|
||||
@select-all="selectAll"
|
||||
/>
|
||||
|
||||
<XxxFormModal ref="XxxFormModalRef" @save-success="search" />
|
||||
</GiPageLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="tsx">
|
||||
defineOptions({ name: 'ModuleName' })
|
||||
</script>
|
||||
```
|
||||
|
||||
## useTable 用法
|
||||
|
||||
```ts
|
||||
import type * as T from '@/apis/system/user'
|
||||
import { baseAPI } from '@/apis/system/user'
|
||||
import { useDict, useTable } from '@/hooks'
|
||||
|
||||
const { dictData } = useDict(['STATUS'] as const)
|
||||
|
||||
const queryParams = reactive({ status: '', username: '' })
|
||||
|
||||
const {
|
||||
loading,
|
||||
tableData,
|
||||
pagination,
|
||||
selectedKeys,
|
||||
search,
|
||||
select,
|
||||
selectAll,
|
||||
fixed, // 操作列 fixed: 'right'(小屏自动取消)
|
||||
onDelete,
|
||||
onBatchDelete,
|
||||
refresh
|
||||
} = useTable({
|
||||
listAPI: (page) => baseAPI.getList({ ...page, ...queryParams }),
|
||||
deleteAPI: (ids) => baseAPI.delete({ ids }),
|
||||
immediate: true,
|
||||
rowKey: 'id', // 默认 id
|
||||
crossPageSelect: false // 跨页多选时设为 true
|
||||
})
|
||||
```
|
||||
|
||||
要点:
|
||||
|
||||
- `search()` 会重置到第 1 页并清空选中
|
||||
- `refresh()` 保持当前页刷新
|
||||
- `listAPI` 参数为 `{ page, size }`,需展开到接口
|
||||
- 空字符串查询条件建议转 `undefined` 再传 API
|
||||
|
||||
## GiButton 预设类型
|
||||
|
||||
`add` | `edit` | `delete` | `search` | `reset` | `upload` | `import` | `export`
|
||||
|
||||
列表工具栏与操作列统一使用 `GiButton`,操作列常用 `size="mini"`。
|
||||
|
||||
## 表格列(TSX)
|
||||
|
||||
使用 `lang="tsx"`,从 `@arco-design/web-vue` 引入 `TableColumnData`:
|
||||
|
||||
```tsx
|
||||
const tableColumns: TableColumnData[] = [
|
||||
{
|
||||
title: '序号',
|
||||
width: 68,
|
||||
align: 'center',
|
||||
render: ({ rowIndex }) => <span>{rowIndex + 1}</span>
|
||||
},
|
||||
{ title: '名称', dataIndex: 'name', ellipsis: true, tooltip: true },
|
||||
{
|
||||
title: '状态',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
render: ({ record }) => (
|
||||
<GiCellStatus status={record.status} />
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 160,
|
||||
align: 'center',
|
||||
fixed: 'right', // 或绑定 useTable 的 fixed
|
||||
render: ({ record }) => (
|
||||
<Space>
|
||||
<GiButton type="edit" size="mini" onClick={() => onEdit(record as T.ListItem)} />
|
||||
<Popconfirm content="确定删除?" onBeforeOk={() => onDelete(record as T.ListItem)}>
|
||||
<GiButton type="delete" size="mini" />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await xxxApi.list({ ...query })
|
||||
list.value = res.list
|
||||
total.value = res.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const columns: DataTableColumns<XxxVO> = [
|
||||
{ title: '名称', key: 'name' },
|
||||
{ title: '状态', key: 'status', render: (row) => h(NTag, { type: tagOf(row.status), size: 'small' }, { default: () => STATUS_NAMES[row.status] }) },
|
||||
{ title: '操作', key: 'actions', render: (row) => h(NSpace, null, { default: () => [
|
||||
h(NButton, { size: 'small', onClick: () => openEdit(row) }, { default: () => '编辑' })
|
||||
] }) }
|
||||
]
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!--
|
||||
高度链必须完整:.page-card(撑满内容区)→ .page-card__body → n-data-table[flex-height],
|
||||
每一层都要 min-height: 0(前两层由 styles/index.scss 给)。少一层 → 分页器被顶出可视区。
|
||||
-->
|
||||
<NCard title="xxx" :bordered="false" class="page-card">
|
||||
<template #header-extra>
|
||||
<span class="toolbar__hint">共 {{ total }} 条</span>
|
||||
</template>
|
||||
|
||||
<!-- 工具条:筛选在左/右,主操作 type="primary" + **默认尺寸**;
|
||||
同一行控件尺寸要一致(别混 size="small",否则 28px 与 34px 参差不齐) -->
|
||||
<div class="toolbar">
|
||||
<NSpace :size="8">
|
||||
<NButton v-if="canManage" type="primary" @click="openAdd">
|
||||
新建 xxx
|
||||
</NButton>
|
||||
</NSpace>
|
||||
<NSpace :size="8">
|
||||
<NInput v-model:value="query.keyword" placeholder="搜索名称" clearable @keyup.enter="search" />
|
||||
<NButton type="primary" ghost @click="search">
|
||||
搜索
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</div>
|
||||
|
||||
<div class="page-card__body">
|
||||
<NDataTable
|
||||
remote
|
||||
flex-height
|
||||
:loading="loading"
|
||||
:columns="columns"
|
||||
:data="list"
|
||||
:pagination="listPagination({ page: query.page, pageSize: query.pageSize, itemCount: total })"
|
||||
@update:page="(p) => { query.page = p; load() }"
|
||||
@update:page-size="(s) => { query.page = 1; query.pageSize = s; load() }"
|
||||
/>
|
||||
</div>
|
||||
</NCard>
|
||||
</template>
|
||||
```
|
||||
|
||||
常用单元格组件:`GiCellAvatar`、`GiCellGender`、`GiCellStatus`(从 `@/components/GiCell` 引入或在 TSX 中直接使用)。
|
||||
### 三个必须记住的点
|
||||
|
||||
## GiPageLayout
|
||||
1. **远程分页要处理两个事件**:`@update:page` **和** `@update:page-size`,
|
||||
且都要**重新取数**。naive 的表格在 `pagination` 非 false 时会自己再切一次数据
|
||||
(内部 `flattenData`)—— 只改本地每页条数不重新取数,会出现"分页器说 50 条/页、表里还是 20 条"。
|
||||
2. **分页器容器用 `.page-card` + `.page-card__body`**(`styles/index.scss`):
|
||||
否则卡片变高会把分页器顶出可视区、怎么滚都看不到(本项目反复踩过)。
|
||||
3. **`listPagination()` 而不是手写对象**:手写时 `prefix` 的参数少一个 `?` 就报 TS2322。
|
||||
|
||||
| 属性/插槽 | 说明 |
|
||||
|-----------|------|
|
||||
| `#left` | 左侧栏(部门树、字典类型列表等) |
|
||||
| `#header` | 顶栏(少用) |
|
||||
| `margin` | 外边距模式 |
|
||||
| `collapsed` | 显示折叠按钮 |
|
||||
| `size` | 左侧宽度,如 `"280px"` |
|
||||
### 版面(2026-10 卡片式基线)
|
||||
|
||||
左栏选中后通过事件同步右侧列表,参考 `dict/index.vue` 的 `onDictTypeRefresh`。
|
||||
- **卡片外观是全局给的**(白底 + 描边 + 轻阴影 + 卡片头浅灰色带),页面**不要**再自己写边框/阴影;
|
||||
卡片之所以"浮"得起来,靠的是 `layout` 那一层的 `--xt-bg` 灰底。
|
||||
- 卡片**内部分区**用 `.section-title`(左侧蓝色小竖条)+ 浅灰小格子,**不要卡里套卡**。
|
||||
- 工具条用 `.toolbar`(自带下分隔线,把"筛选区"和"表格区"切开)。
|
||||
- 详细规则见 [code-css](../code-css/SKILL.md) 的「设计基线」「卡片式的三条规矩」「按钮的可见性」。
|
||||
|
||||
## 表单弹窗(Modal + GiForm)
|
||||
## 表单弹窗
|
||||
|
||||
```vue
|
||||
<a-modal v-model:visible="visible" :title="title" :mask-closable="false"
|
||||
@before-ok="save" @close="close">
|
||||
<GiForm ref="GiFormRef" :model-value="form" :columns="formColumns"
|
||||
@update:model-value="Object.assign(form, $event)" />
|
||||
</a-modal>
|
||||
```
|
||||
- `NModal` + `NForm` + `NFormItem`,`ref<FormInst | null>` 拿实例,提交前 `await formRef.value?.validate()`。
|
||||
- 校验规则用 `FormRules`,与后端约束对齐(**但服务端才是权威**,前端只减少无效提交)。
|
||||
- 成功后 `message.success(...)` + 关弹窗 + `load()`。
|
||||
- 危险动作(删除/停用/重置密码)用 `useDialog()` 二次确认,文案写明后果。
|
||||
|
||||
```ts
|
||||
import type { FormColumnItem } from '@/components/index'
|
||||
import { useResetReactive } from '@/hooks'
|
||||
## 权限与展示
|
||||
|
||||
const [form, resetForm] = useResetReactive({ id: '', name: '', status: '1' as Status })
|
||||
const GiFormRef = useTemplateRef<InstanceType<typeof GiForm>>('GiFormRef')
|
||||
|
||||
const add = () => {
|
||||
detailId.value = ''
|
||||
visible.value = true
|
||||
}
|
||||
const edit = async (id: string) => {
|
||||
visible.value = true
|
||||
detailId.value = id
|
||||
Object.assign(form, (await baseAPI.getDetail({ id })).data)
|
||||
}
|
||||
const close = () => {
|
||||
GiFormRef.value?.formRef?.resetFields()
|
||||
resetForm()
|
||||
}
|
||||
const save = async () => {
|
||||
const valid = await GiFormRef.value?.formRef?.validate()
|
||||
if (valid) return false
|
||||
// await baseAPI.add(form) 或 baseAPI.update(form)
|
||||
emit('save-success')
|
||||
return true
|
||||
}
|
||||
|
||||
defineExpose({ add, edit })
|
||||
```
|
||||
|
||||
`formColumns` 常用 `type`:`input`、`select`、`tree-select`、`radio-group`、`switch`、`textarea`。校验可用 `required`、`rules`;正则见 `@/utils/regexp`。
|
||||
|
||||
## 字典下拉
|
||||
|
||||
```ts
|
||||
const { dictData } = useDict(['STATUS'] as const)
|
||||
// 模板:<a-select :options="dictData.STATUS" />
|
||||
```
|
||||
|
||||
## 表格增强方案选型
|
||||
|
||||
| 需求 | 方案 |
|
||||
|------|------|
|
||||
| 内置斑马纹/全屏/列设置 | `GiTable` |
|
||||
| 继续用 `a-table` + 工具栏/列设置 | `GiTableSetting` 包裹(见 `views/table/custom3`) |
|
||||
| 简单列表 | `a-table` + `useTable`(系统模块主流) |
|
||||
|
||||
## 检查清单
|
||||
|
||||
- [ ] `defineOptions({ name })` 已设置
|
||||
- [ ] `row-key="id"` 与 `useTable` 的 `rowKey` 一致
|
||||
- [ ] 删除走 `onDelete` / `onBatchDelete` 或自定义 `Popconfirm` + API
|
||||
- [ ] 弹窗 `defineExpose({ add, edit })`,父组件 `useTemplateRef` 调用
|
||||
- [ ] 保存成功后 `emit('save-success')` 并触发 `search`
|
||||
- 按钮显隐:`computed(() => user.has('xxx:manage'))`;**不要**用 `v-hasPerm`。
|
||||
- 状态色:`tagOf(row.status)`;状态文案映射放 `apis/xt/<域>.ts` 里与接口同处。
|
||||
- 敏感字段(手机号/证件号)后端只回**脱敏值** → 编辑时**无法回填原文**,要改就得重填(照抄账号页的写法)。
|
||||
|
||||
@@ -1,171 +1,72 @@
|
||||
---
|
||||
name: project-guide
|
||||
description: 后台管理模板的项目总览与前端强制规范。涵盖 Vue 3 + Arco Design + Gi 组件库、目录结构、自动导入、CRUD/API 约定与代码风格。在当前仓库内开发、新增页面、查阅架构或不确定项目规范时使用。
|
||||
description: PC 管理端的技术栈、目录结构、菜单与权限模型,以及七条最容易写错的约定。开始任何 PC 端改动前加载本技能。
|
||||
---
|
||||
|
||||
# 项目开发指南
|
||||
|
||||
> **适用范围**:本仓库 `src/**`、`mock/**`
|
||||
> **规范来源**:`AGENTS.md` 常驻行为准则 + 本 skill(项目细节);编码规范按需读 `code-vue` / `code-css` / `code-ts` skill
|
||||
|
||||
## 本项目 Agent 约定
|
||||
- 优先复用 `Gi*`、`hooks`、`getBaseApi`、现有 mock 工具;新增页面/接口对齐参考页与 `getBaseApi` 约定。
|
||||
- API / Mock 以 `src/apis`、`mock` 与类型定义为准,不要猜测路径。
|
||||
- 涉及 CRUD、API、Mock 时按需读取对应 skill;写 `.vue` / 样式 / TS 前读取 [code-vue](../code-vue/SKILL.md)、[code-css](../code-css/SKILL.md)、[code-ts](../code-ts/SKILL.md)。
|
||||
- 改动后视情况运行 `pnpm typecheck`、`pnpm lint`、`pnpm lint:style`。
|
||||
# 项目总览(PC 管理端)
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 类别 | 选型 |
|
||||
|------|------|
|
||||
| 框架 | Vue 3.5 + TypeScript + `<script setup lang="ts">` |
|
||||
| 构建 | Vite 7 |
|
||||
| UI | Arco Design Vue(按需自动导入)+ 本地 `Gi*` 组件 |
|
||||
| 状态 | Pinia + pinia-plugin-persistedstate |
|
||||
| 路由 | Vue Router 4(Hash 模式) |
|
||||
| HTTP | `@/utils/http`,统一响应类型 `ApiRes<T>`、`PageRes<T>` |
|
||||
| 工具 | @vueuse/core、lodash-es、dayjs、xe-utils |
|
||||
| Mock | vite-plugin-mock + mockjs(开发默认启用) |
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 框架 | **Vue 3.5 + TypeScript + Vite 7** |
|
||||
| UI | **Naive UI 2.42**(2026-10 从 Gi Admin Pro + Arco 重搭,**已无 Arco/Gi 依赖**) |
|
||||
| 状态 | Pinia(`pinia-plugin-persistedstate`) |
|
||||
| 请求 | axios(`src/utils/http.ts` 统一封装) |
|
||||
| 样式 | SCSS(无 Stylelint) |
|
||||
| 检查 | `pnpm typecheck`(vue-tsc,**当前 0 错**)、`pnpm lint`(@antfu/eslint-config) |
|
||||
|
||||
每个 SFC 必须 `defineOptions({ name: 'XxxYyy' })`;通用 Vue 规范见 [code-vue](../code-vue/SKILL.md),本项目补充见下文「本项目 Vue 约定」。
|
||||
|
||||
## 路径别名
|
||||
|
||||
- `@` → `src/`
|
||||
- `~` → 项目根目录
|
||||
|
||||
## 目录约定
|
||||
## 目录
|
||||
|
||||
```
|
||||
src/
|
||||
├── apis/ # 接口(按业务模块分文件,标准 CRUD 用 getBaseApi)
|
||||
├── components/ # Gi* 业务组件 + 部分通用组件
|
||||
├── hooks/ # 组合式函数(useTable、useDict 等)
|
||||
├── hooks/app/ # 业务域 hooks(useDept、useRole)
|
||||
├── stores/ # Pinia
|
||||
├── views/ # 页面(按模块分子目录)
|
||||
├── router/ # 路由与守卫
|
||||
├── utils/ # http、auth、regexp、deepClone 等
|
||||
├── types/ # 全局类型(global.d.ts、api.d.ts)
|
||||
mock/ # vite-plugin-mock 模拟数据
|
||||
├── apis/xt/ 接口模块(按业务域分文件)+ types.ts(通用类型)
|
||||
├── composables/ pagination.ts(列表页分页器统一参数)
|
||||
├── config/ xt-routes.ts(**业务菜单表:菜单 + 路由 + 分组**)
|
||||
├── icons/ AppIcon.vue + icon-data.ts(由 scripts/extract-pc-icons.py 生成)
|
||||
├── layout/ AppSider / AppHeader / index.vue
|
||||
├── router/ index.ts(本地菜单表按后端 menuKeys 过滤后注册)
|
||||
├── stores/ user.ts(登录态/权限/菜单)、acting-tenant.ts(平台代管学校)、app.ts
|
||||
├── styles/ index.scss(只放跨页面样式)
|
||||
├── utils/ http.ts / naive.ts / tag.ts / format.ts / auth.ts / rsa.ts
|
||||
└── views/ 页面(每个菜单一个目录,行内抽屉与弹窗同目录)
|
||||
```
|
||||
|
||||
页面文件命名:
|
||||
## 菜单与权限模型(**后端决定可见性**)
|
||||
|
||||
```
|
||||
src/views/{module}/index.vue # 列表页
|
||||
src/views/{module}/XxxFormModal.vue # 新增/编辑弹窗(有表单时必建)
|
||||
src/apis/{module}/{name}.ts # 类型定义 + CRUD API(或 apis/system/user.ts)
|
||||
mock/{module}/{name}.ts # 与 API 路径一致的 Mock
|
||||
- 前端 `config/xt-routes.ts` 是**唯一**菜单表:`key`(菜单键)↔ 路由 ↔ 视图 ↔ 分组。
|
||||
- **能不能看由后端决定**:`/auth/me` 返回 `menus: string[]`,前端只按它过滤;
|
||||
绕过前端也只会拿到 `40301`。
|
||||
- **能不能做**看权限码:`useUserStore().has('码')`,字符串必须与后端 `PermissionDef` **逐字一致**
|
||||
(`progress:viewAll` 是驼峰,**不是** `progress:view:all` —— 写错只会恒为 false,不报错)。
|
||||
- 平台运营要**先选学校**(`stores/acting-tenant.ts` → 请求头 `X-Tenant-Id`);
|
||||
不选就不发请求,否则会把各租户数据混在一起且看起来正常。
|
||||
|
||||
## 七条最容易写错的约定
|
||||
|
||||
1. **没有 auto-import**:组件与 API 全显式 import,标签写 **PascalCase**。
|
||||
漏 import 是**静默故障**(未知自定义元素 → 样式整块丢掉,typecheck/lint 全绿);
|
||||
`python scripts/check-wiring.py` 第 ⑧ 项会核对。
|
||||
2. **没有 `v-hasPerm`**:权限判断写 `user.has('码')`。
|
||||
3. **`n-tag` 的 `type` 必须过 `@/utils/tag.ts`**(Arco 色词直接透传会静默变灰)。
|
||||
4. **图标要重跑脚本**:`scripts/extract-pc-icons.py` → `src/icons/icon-data.ts`(37 个);
|
||||
缺图标是空占位、不报错 → 图标集升级后必须重跑。
|
||||
5. **`n-tab-pane` 的标签用 `#tab` 插槽**,不要用 `tab="文字"` 属性 ——
|
||||
naive-ui 2.45.3 的属性写法只渲染出空注释节点,整排 tab 一个字都不显示(不报错)。
|
||||
6. **卡片式版面**:白卡片靠全局 `--xt-bg` 灰底浮起来;卡片外观由 `styles/index.scss` 统一给,
|
||||
页面不要再自己写;卡片内部分区用 `.section-title` + 浅灰小格子,不要卡里套卡。
|
||||
详见 [code-css](../code-css/SKILL.md) 的「设计基线」一节。
|
||||
7. **品牌标是几何标**:`src/assets/images/logo.svg`(方 = 卷宗,环 = 在线)。
|
||||
**不要换回文字字形标**;页签用 `public/favicon.svg`(加粗版),改一个要同步另一个。
|
||||
⚠️ SVG 注释里**不能出现连续两个减号**(XML 规范禁止)→ 整个文件 not well-formed,
|
||||
图片裂开而控制台一句话都不报;`python scripts/check-wiring.py` 第 ⑨ 项会核对。
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev # → http://localhost:5173,/api 代理到 8080(后端不起着就没有数据)
|
||||
pnpm typecheck && pnpm lint
|
||||
```
|
||||
|
||||
## 全局类型(`src/types/`)
|
||||
|
||||
- `Status`:`'0' | '1'`(禁用/启用,与字典 `STATUS` 一致)
|
||||
- `Gender`:`'1' | '2' | '3'`
|
||||
- `Pagination`:`{ page, size }`
|
||||
- `ApiRes<T>`、`PageRes<T>`:统一接口响应(分页 `data` 为 `{ records, total }`)
|
||||
|
||||
## CRUD 列表页(强制遵循)
|
||||
|
||||
新建/修改表格页时,**必须**参考并遵循:
|
||||
|
||||
- 参考实现:`src/views/system/user/index.vue`、`UserFormModal.vue`
|
||||
- Hook:`@/hooks/useTable`
|
||||
- API 模板:`src/apis/system/user.ts` + `getBaseApi`
|
||||
- 详细步骤:读取并遵循 [project-crud](../project-crud/SKILL.md)
|
||||
|
||||
核心结构:
|
||||
|
||||
1. `GiPageLayout` 布局(可选 `#left` 树/分类)
|
||||
2. `queryParams`(reactive)+ 搜索区 + `GiButton`(search/reset)
|
||||
3. `tableColumns`(`TableColumnData[]`,复杂列用 `lang="tsx"` + `render`)
|
||||
4. `useTable` 绑定 `listAPI` 与 `deleteAPI`
|
||||
5. `XxxFormModal` ref,`defineExpose({ add, edit })`,`@save-success="search"`
|
||||
|
||||
分页类型:`PageRes<T>`(`records` + `total`)。Mock 与接口详见 [project-api](../project-api/SKILL.md)。
|
||||
|
||||
## API 层模板
|
||||
|
||||
```typescript
|
||||
import { getBaseApi } from '@/apis/base'
|
||||
import http from '@/utils/http'
|
||||
|
||||
export interface ListItem {
|
||||
id: string
|
||||
name: string
|
||||
status: Status
|
||||
}
|
||||
|
||||
/** 标准 CRUD */
|
||||
export const baseAPI = getBaseApi<ListItem>({ baseUrl: '/system/xxx' })
|
||||
|
||||
/** 非标准接口 */
|
||||
export function customAction(params: Pagination) {
|
||||
return http.get<PageRes<Item[]>>('/system/xxx/customAction', params)
|
||||
}
|
||||
```
|
||||
|
||||
- 开发环境 `VITE_API_PREFIX = '/mock'`(`.env.development`)
|
||||
- 禁止绕过 `@/utils/http` 裸用 axios
|
||||
|
||||
## 代码风格
|
||||
|
||||
- 遵循 `@antfu/eslint-config`(`eslint.config.js`):箭头参数括号、1TBS、无尾随逗号;Vue block 顺序 `[['script', 'template'], 'style']`;宏顺序 `defineOptions/defineModel/defineProps/defineEmits/defineSlots`,`defineExpose` 最后。
|
||||
- 布局放 `src/layout/`,Gi 组件放 `src/components/Gi*`,业务 Hook 放 `hooks/` 或 `hooks/app/`。
|
||||
- 空字符串查询条件传 API 前建议转为 `undefined`。
|
||||
- 通用编码规范见 [code-ts](../code-ts/SKILL.md)、[code-vue](../code-vue/SKILL.md)、[code-css](../code-css/SKILL.md);本项目补充见下文。
|
||||
|
||||
## 本项目编码约定
|
||||
|
||||
- 路径别名:`@` → `src/`,`~` → 项目根;禁止 `../../../` 穿越。
|
||||
- 组件全局前缀 **`Gi`** 仅用于 `src/components/Gi*` 全局组件。
|
||||
- 目录:`src/views` 页面、`src/components` 组件、`src/apis/<domain>/` + `type.ts` 接口类型、`src/types` 公共类型、`src/hooks` composables。
|
||||
- API 统一 `@/utils/http`;状态 Pinia + `pinia-plugin-persistedstate`。
|
||||
- 图标 `@iconify/vue`;样式从 scss/less 导入,避免内联。
|
||||
- 业务注释中文,标识符英文。
|
||||
- 检查:`pnpm lint`、`pnpm lint:style`、`pnpm typecheck`。
|
||||
|
||||
## 本项目 Vue 约定
|
||||
|
||||
- 每个 SFC 必须 `defineOptions({ name: 'XxxYyy' })`,名称与路由/模块对应,并与路由表 `name` 一致以支持缓存。
|
||||
- **自动导入**(`vite.config.ts`,勿重复 import):
|
||||
- Vue API:`ref`、`reactive`、`computed`、`watch`、`useTemplateRef` 等
|
||||
- Gi 组件:`src/components/Gi*`(如 `GiPageLayout`、`GiButton`、`GiForm`)
|
||||
- Arco 组件:`a-table`、`a-modal`、`a-select` 等
|
||||
- 图标:`icon-*`(Iconify `icon-park-outline`)
|
||||
- Gi 组件模板用 **PascalCase**;Arco 用 `a-*` 前缀;禁止手动 import Gi/Arco(类型除外)。
|
||||
- 表格列复杂时用 `lang="tsx"` + `TableColumnData[]` 的 `render`;字典字段用 `useDict(['STATUS'])` 等。
|
||||
- `dayjs` 默认导入变量名必须为 **`Dayjs`**(大写),禁止小写 `dayjs`。
|
||||
- 深拷贝使用 `@/utils` 的 `deepClone`,禁止 `JSON.parse(JSON.stringify())`。
|
||||
- Vue 参考页:`src/views/system/user/index.vue`、`UserFormModal.vue`、`src/views/table/custom3/index.vue`
|
||||
|
||||
## 本项目样式
|
||||
|
||||
- 全局样式:`src/styles/index.scss`(变量 `var.scss`、混入 `mixin.scss`、Arco 主题 `arco-ui/`)
|
||||
- Stylelint:`pnpm lint:style`
|
||||
- 列表页常用工具类:`g-row-tool`(工具栏行)、`g-table`(表格)、`g-full-column`(左栏撑满)、`g-mb`(下边距)
|
||||
- BEM 示例:`src/views/login/LoginLeft.vue`
|
||||
|
||||
## 子技能(按需阅读)
|
||||
|
||||
| 场景 | 技能 |
|
||||
|------|------|
|
||||
| Vue SFC、Emits、Ref | [code-vue](../code-vue/SKILL.md) |
|
||||
| 样式、BEM | [code-css](../code-css/SKILL.md) |
|
||||
| TS 命名、导入 | [code-ts](../code-ts/SKILL.md) |
|
||||
| 新建 CRUD 列表页、表格、弹窗表单 | [project-crud](../project-crud/SKILL.md) |
|
||||
| 新增接口、Mock、baseAPI | [project-api](../project-api/SKILL.md) |
|
||||
|
||||
## 参考页面
|
||||
|
||||
- 标准 CRUD + 左树:`src/views/system/user/index.vue`
|
||||
- 左右分栏 + 字典:`src/views/system/dict/index.vue`
|
||||
- GiTableSetting + a-table:`src/views/table/custom3/index.vue`
|
||||
- 表单弹窗:`src/views/system/user/UserFormModal.vue`
|
||||
|
||||
## 禁止事项
|
||||
|
||||
- ❌ 绕过 `@/utils/http` 直接裸用 axios
|
||||
- ❌ 表格页自行实现分页/删除逻辑,而不使用 `useTable`
|
||||
- ❌ 新建列表页不参考 `system/user` 与 [project-crud](../project-crud/SKILL.md) 约定
|
||||
演示账号:`admin / Admin@123` + 租户码 `demo`(**不是** Gi README 里的 `admin/123456`)。
|
||||
|
||||
Reference in New Issue
Block a user