Template
first commit
This commit is contained in:
@@ -0,0 +1,361 @@
|
||||
---
|
||||
name: v3-create-crud
|
||||
description: 创建增删改查(CRUD)页面,基于 Element Plus 组件库,包含表格、搜索、分页、新增/编辑弹窗、删除确认等功能。当用户提到以下任何场景时都应触发:创建管理页面、创建列表页、创建表格页。即使用户没有明确说 CRUD,只要意图是创建带表格和表单操作的后台页面就应该使用此 Skill。使用时需提供模块名称和字段信息。
|
||||
metadata:
|
||||
author: Glittering Ma & pany
|
||||
version: "2026.06.13"
|
||||
---
|
||||
|
||||
# 创建 CRUD 页面
|
||||
|
||||
根据用户提供的模块名称和字段信息,生成增删改查页面。
|
||||
|
||||
本 Skill 定义的是默认 CRUD 模式,当用户的实际需求与本 Skill 约定冲突时,以用户需求为准,灵活调整。
|
||||
|
||||
## 输入要求
|
||||
|
||||
用户需提供:
|
||||
1. **模块名称**(如 `product`、`order`)— 用于目录名和命名
|
||||
2. **字段列表** — 每个字段需明确:
|
||||
- 字段名(英文,camelCase)
|
||||
- 中文标签
|
||||
- 类型(string / number / boolean / enum / date 等)
|
||||
- 是否必填
|
||||
- 是否作为搜索条件
|
||||
- 是否需要自定义渲染(如 tag 状态展示)
|
||||
|
||||
如果用户信息不完整,主动询问补全后再生成。
|
||||
|
||||
## 生成文件
|
||||
|
||||
在 `src/pages/<模块路径>/` 下生成:
|
||||
|
||||
1. `index.vue` — 页面主文件
|
||||
2. `apis/index.ts` — 接口文件
|
||||
3. `apis/type.ts` — 类型定义
|
||||
|
||||
## 设计决策指引
|
||||
|
||||
生成代码时需要做出的判断(不要机械套用,根据具体场景决定):
|
||||
|
||||
**弹窗宽度** — 默认用 `30%`,有复杂布局用 `50%` 或更大的比例。
|
||||
|
||||
**工具栏按钮** — 按需组合,不必全部包含:
|
||||
- "新增" 几乎总是需要的
|
||||
- "批量删除" 仅在有批量操作需求时添加(对应表格 selection 列)
|
||||
- "下载/导出" 仅在有导出需求时添加
|
||||
- "刷新当前页" 推荐保留,方便调试和手动刷新
|
||||
|
||||
**删除确认文案** — 选择对用户最有辨识度的字段作为确认提示(如用户名、订单编号、商品名称),而不是 id。
|
||||
|
||||
**搜索参数传递** — 保持简单,避免类型体操:
|
||||
- 字段少(2-3 个)时可以逐个列出
|
||||
- 字段多时用 `...searchData` 展开更简洁
|
||||
- 核心原则:不要出现 `as any`,除非明确要求
|
||||
|
||||
**表单字段 vs 表格列** — 不是所有字段都同时出现在两处:
|
||||
- 仅创建时需要的字段(如密码):表单中有,表格中无
|
||||
- 系统生成的字段(如创建时间):表格中有,表单中无
|
||||
- 用 `v-if="formData.id === undefined"` 控制仅新增时显示的字段
|
||||
|
||||
## 页面结构规范
|
||||
|
||||
页面整体包裹在 `<div class="app-container">` 中,由搜索区域、表格区域、弹窗三部分组成。
|
||||
|
||||
### 1. 搜索区域
|
||||
|
||||
- `el-card`:`v-loading="loading"` + `shadow="never"` + `class="search-wrapper"`
|
||||
- 内含 `el-form`:`ref="searchFormRef"` + `:inline="true"` + `:model="searchData"`
|
||||
- 每个搜索字段设置 `prop`(`resetFields` 依赖它)和 `label`
|
||||
- 末尾放查询按钮(`:icon="Search"` + `type="primary"`)和重置按钮(`:icon="Refresh"`)
|
||||
|
||||
### 2. 表格区域
|
||||
|
||||
- `el-card`:`v-loading="loading"` + `shadow="never"`
|
||||
- 工具栏(`div.toolbar-wrapper`)分左右两侧:
|
||||
- 左侧:文字按钮(新增、批量删除等)
|
||||
- 右侧:圆形图标按钮 + `el-tooltip`(下载、刷新当前页等)
|
||||
- 表格(`div.table-wrapper` > `el-table :data="tableData"`):
|
||||
- 需要批量操作时加 `type="selection"` 首列
|
||||
- 数据列:`prop` + `label` + `align="center"`
|
||||
- 操作列:`fixed="right"` + 适当 `width` + `align="center"`,内含修改/删除按钮(`text bg size="small"`)
|
||||
- 操作列按钮将 `scope.row` 传给强类型 handler 时,使用 `scope.row as XxxData` 明确行类型,避免 Element Plus 默认 `DefaultRow` 导致 `vue-tsc` 报错,不要使用 `as any`
|
||||
- 分页(`div.pager-wrapper` > `el-pagination`)
|
||||
|
||||
### 3. 弹窗
|
||||
|
||||
- `el-dialog`:`v-model="dialogVisible"` + 通过 `formData.id === undefined` 判断新增/修改标题 + `@closed="resetForm"`
|
||||
- 使用 `@closed` 而非 `@close`(确保关闭动画结束后再重置,避免用户看到表单内容闪烁)
|
||||
- 表单:`ref="formRef"` + `:model="formData"` + `:rules="formRules"` + `label-width="auto"`
|
||||
- footer:取消按钮 + 确认按钮(`type="primary"` + `:loading="loading"`)
|
||||
|
||||
## 代码组织规范
|
||||
|
||||
使用 `<script lang="ts" setup>` + `defineOptions({ name: "PascalCase 模块名" })`。
|
||||
|
||||
顶部声明共享的 `loading` `ref` 和 `usePagination` 解构,分页请求统一通过 `callback`、`resetCurrentPage`、`watchPagination` 组织。
|
||||
|
||||
逻辑按增删改查分区,用 `// #region` 和 `// #endregion` 标记:
|
||||
|
||||
### 增(Create)
|
||||
|
||||
```ts
|
||||
const DEFAULT_FORM_DATA: CreateOrUpdateXxxRequestData = {
|
||||
id: undefined,
|
||||
// ... 所有表单字段的初始值
|
||||
}
|
||||
const dialogVisible = ref<boolean>(false)
|
||||
const formRef = useTemplateRef("formRef")
|
||||
const formData = ref<CreateOrUpdateXxxRequestData>(cloneDeep(DEFAULT_FORM_DATA))
|
||||
const formRules: FormRules<CreateOrUpdateXxxRequestData> = { /* 验证规则 */ }
|
||||
|
||||
function handleCreateOrUpdate() {
|
||||
formRef.value?.validate((valid) => {
|
||||
if (!valid) { ElMessage.error("表单校验不通过"); return }
|
||||
loading.value = true
|
||||
const api = formData.value.id === undefined ? createXxxApi : updateXxxApi
|
||||
api(formData.value).then(() => {
|
||||
ElMessage.success("操作成功")
|
||||
dialogVisible.value = false
|
||||
}).finally(() => {
|
||||
loading.value = false
|
||||
getTableData()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
formRef.value?.clearValidate()
|
||||
formData.value = cloneDeep(DEFAULT_FORM_DATA)
|
||||
}
|
||||
```
|
||||
|
||||
**为什么新增和编辑共用一个弹窗**:减少重复代码,通过 `id` 是否存在区分状态,共享验证规则和提交逻辑。
|
||||
|
||||
### 删(Delete)
|
||||
|
||||
```ts
|
||||
function handleDelete(row: XxxData) {
|
||||
ElMessageBox.confirm(`正在删除${模块中文名}:${row.辨识字段},确认删除?`, "提示", {
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning"
|
||||
}).then(() => {
|
||||
loading.value = true
|
||||
deleteXxxApi(row.id).then(() => {
|
||||
ElMessage.success("删除成功")
|
||||
}).finally(() => {
|
||||
loading.value = false
|
||||
getTableData()
|
||||
})
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 改(Update)
|
||||
|
||||
```ts
|
||||
function handleUpdate(row: XxxData) {
|
||||
dialogVisible.value = true
|
||||
formData.value = cloneDeep(row)
|
||||
}
|
||||
```
|
||||
|
||||
**为什么用 `cloneDeep`**:避免编辑时直接修改表格行数据(引用类型),用户取消编辑时表格数据不会被污染。
|
||||
|
||||
### 查(Read)
|
||||
|
||||
```ts
|
||||
const tableData = ref<XxxData[]>([])
|
||||
const searchFormRef = useTemplateRef("searchFormRef")
|
||||
const searchData = reactive({ /* 搜索字段,初始值为空字符串 */ })
|
||||
|
||||
function getTableData() {
|
||||
loading.value = true
|
||||
getXxxApi({
|
||||
currentPage: paginationData.currentPage,
|
||||
size: paginationData.pageSize,
|
||||
...searchData
|
||||
}).then(({ data }) => {
|
||||
paginationData.total = data.total
|
||||
tableData.value = data.list
|
||||
}).catch(() => {
|
||||
tableData.value = []
|
||||
}).finally(() => { loading.value = false })
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
resetCurrentPage()
|
||||
}
|
||||
|
||||
function resetSearch() {
|
||||
searchFormRef.value?.resetFields()
|
||||
handleSearch()
|
||||
}
|
||||
```
|
||||
|
||||
**`handleSearch` 的逻辑**:直接调用 `resetCurrentPage`。`resetCurrentPage` 会在当前已是第 1 页时直接请求,否则重置页码并由分页监听触发请求,避免重复调用。
|
||||
|
||||
### 分页监听
|
||||
|
||||
```ts
|
||||
watchPagination()
|
||||
```
|
||||
|
||||
**为什么用 `watchPagination` 而不是 `onMounted`**:让分页变化和初始加载共享同一个入口,数据获取逻辑只写一处。
|
||||
|
||||
## usePagination 用法
|
||||
|
||||
```ts
|
||||
import { usePagination } from "@@/composables/usePagination"
|
||||
|
||||
const { paginationData, resetCurrentPage, watchPagination } = usePagination({
|
||||
callback: getTableData
|
||||
})
|
||||
```
|
||||
|
||||
`paginationData` 是 `reactive` 对象,包含:`total`、`currentPage`、`pageSizes`、`pageSize`、`layout`。模板中使用 `v-model` 双向绑定页码和每页条数:
|
||||
|
||||
```html
|
||||
<el-pagination
|
||||
v-model:current-page="paginationData.currentPage"
|
||||
v-model:page-size="paginationData.pageSize"
|
||||
:page-sizes="paginationData.pageSizes"
|
||||
:total="paginationData.total"
|
||||
:layout="paginationData.layout"
|
||||
background
|
||||
/>
|
||||
```
|
||||
|
||||
## 表格列的自定义渲染
|
||||
|
||||
对于需要视觉区分的字段(状态、角色、类型等),使用 `el-tag` 渲染。思路是:突出重要/异常值,其余用温和颜色兜底。
|
||||
|
||||
```html
|
||||
<!-- boolean:二元对立,可以用 success / danger 对比 -->
|
||||
<el-table-column prop="status" label="状态" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag v-if="scope.row.status" type="success" effect="plain" disable-transitions>启用</el-tag>
|
||||
<el-tag v-else type="danger" effect="plain" disable-transitions>禁用</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<!-- enum:值高亮 -->
|
||||
<el-table-column prop="roles" label="角色" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag v-if="scope.row.roles === 'admin'" type="primary" effect="plain" disable-transitions>admin</el-tag>
|
||||
<el-tag v-else type="warning" effect="plain" disable-transitions>{{ scope.row.roles }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
```
|
||||
|
||||
当 enum 值需要明确区分时,逐个用 `v-if / v-else-if` 列出,最后用 `v-else` 兜底。
|
||||
|
||||
## 表单字段组件选择
|
||||
|
||||
根据字段语义选择组件:
|
||||
|
||||
| 字段类型 | 组件 | 备注 |
|
||||
|---------|------|------|
|
||||
| string(短文本)| `el-input` | |
|
||||
| string(长文本)| `el-input type="textarea"` | 描述、备注等 |
|
||||
| string(密码)| `el-input type="password"` | |
|
||||
| number | `el-input-number` | `:min` / `:max` 根据业务约束设定 |
|
||||
| boolean | `el-switch` | |
|
||||
| enum | `el-select` + `el-option` | 搜索区域加 `clearable` |
|
||||
| date | `el-date-picker type="date" value-format="YYYY-MM-DD"` | |
|
||||
| datetime | `el-date-picker type="datetime" value-format="YYYY-MM-DD HH:mm:ss"` | |
|
||||
|
||||
验证规则的 `trigger`:输入型组件(input、textarea)用 `"blur"`,选择型组件(select、date-picker、switch)用 `"change"`。
|
||||
|
||||
## 接口规范
|
||||
|
||||
接口文件使用命名空间导入类型:`import type * as Xxx from "./type"`
|
||||
|
||||
导出四个函数(注释用"增删改查"标记):
|
||||
|
||||
- `createXxxApi` — POST
|
||||
- `deleteXxxApi` — DELETE,参数为 id
|
||||
- `updateXxxApi` — PUT
|
||||
- `getXxxApi` — GET,参数为分页 + 搜索条件
|
||||
|
||||
使用 `import { request } from "@/http/axios"` 发起请求。查询接口需指定泛型:`request<Xxx.XxxResponseData>({ ... })`。
|
||||
|
||||
## 类型规范
|
||||
|
||||
类型文件导出(`ApiResponseData` 是全局类型,无需导入):
|
||||
|
||||
- `CreateOrUpdateXxxRequestData` — 表单提交数据(`id?: number` + 各表单字段)
|
||||
- `XxxRequestData` — 列表查询参数(`currentPage: number` + `size: number` + 搜索字段用 `?:` 可选标记)
|
||||
- `XxxData` — 表格行数据(`id: number` + 各展示字段)
|
||||
- `XxxResponseData` — `ApiResponseData<{ list: XxxData[]; total: number }>`
|
||||
|
||||
搜索字段在 `XxxRequestData` 中默认用 `string` 类型(除非明确指定类型),因为搜索框传递的默认是字符串值(除非明确指定类型),需直接保持类型正常,不要随意使用类型断言。
|
||||
|
||||
页面中使用具名导入:`import type { CreateOrUpdateXxxRequestData, XxxData } from "./apis/type"`
|
||||
|
||||
## 导入规范
|
||||
|
||||
```ts
|
||||
// 类型导入
|
||||
import type { CreateOrUpdateXxxRequestData, XxxData } from "./apis/type"
|
||||
import type { FormRules } from "element-plus"
|
||||
|
||||
// API 导入
|
||||
import { createXxxApi, deleteXxxApi, getXxxApi, updateXxxApi } from "./apis"
|
||||
|
||||
// Composable 导入
|
||||
import { usePagination } from "@@/composables/usePagination"
|
||||
|
||||
// 图标导入(按需,只导入实际使用的)
|
||||
import { CirclePlus, Delete, Download, Refresh, RefreshRight, Search } from "@element-plus/icons-vue"
|
||||
|
||||
// 工具导入
|
||||
import { cloneDeep } from "lodash-es"
|
||||
```
|
||||
|
||||
以下为自动导入,无需手动引入:`ElMessage`、`ElMessageBox`、`ref`、`reactive`、`useTemplateRef`。
|
||||
|
||||
## 样式规范
|
||||
|
||||
使用 `<style lang="scss" scoped>`,以下为推荐的基础样式:
|
||||
|
||||
```scss
|
||||
.search-wrapper {
|
||||
margin-bottom: 20px;
|
||||
:deep(.el-card__body) {
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar-wrapper {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.table-wrapper {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.pager-wrapper {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
```
|
||||
|
||||
## 命名约定
|
||||
|
||||
模块名为 `product` 时:
|
||||
|
||||
| 位置 | 命名 |
|
||||
|------|------|
|
||||
| 目录 | `src/pages/.../product/` |
|
||||
| 组件 name | `Product` |
|
||||
| API 函数 | `createProductApi` / `deleteProductApi` / `updateProductApi` / `getProductApi` |
|
||||
| 类型 | `CreateOrUpdateProductRequestData` / `ProductRequestData` / `ProductData` / `ProductResponseData` |
|
||||
| 命名空间 | `import type * as Product from "./type"` |
|
||||
|
||||
## 路由提示
|
||||
|
||||
生成代码后,提醒用户在路由配置中添加对应路由。
|
||||
@@ -0,0 +1,298 @@
|
||||
---
|
||||
name: v3-upsert-route
|
||||
description: 创建或更新路由表(Router)。当用户提到以下任何场景时都应触发:新建页面路由、新增菜单项、修改路由权限、调整路由结构。即使用户没有明确说路由,只要意图是导航菜单和访问控制就应该使用此 Skill。使用时需提供路由路径、名称和类型等必要信息。
|
||||
metadata:
|
||||
author: Defined & pany
|
||||
version: "2026.06.02"
|
||||
---
|
||||
|
||||
# 创建或更新路由
|
||||
|
||||
根据用户提供的路由信息,在 `src/router/index.ts` 中生成或更新路由配置。
|
||||
|
||||
本 Skill 定义的是项目默认路由模式,当用户的实际需求与本 Skill 约定冲突时,以用户需求为准。
|
||||
|
||||
## 输入要求
|
||||
|
||||
用户需提供:
|
||||
1. **路由路径**(如 `/school`、`/user`)
|
||||
2. **路由标题**(中文,如 `学校管理`、`用户管理`)
|
||||
3. **路由类型**:
|
||||
- **常驻路由**(constantRoutes):所有登录用户可访问
|
||||
- **动态路由**(dynamicRoutes):需要角色或权限控制
|
||||
4. **权限配置**(仅动态路由):角色如 `["admin"]`、`["admin", "editor"]`,权限标识字符如 `["permission:page-level"]`
|
||||
5. **图标**:Element Plus 图标名(elIcon)或 SVG 图标名(svgIcon)
|
||||
6. **子路由信息**:路径、标题、权限配置、图标等
|
||||
|
||||
如果用户信息不完整,主动询问补全后再生成。
|
||||
|
||||
## 路由文件结构
|
||||
|
||||
路由配置在 `src/router/index.ts` 中。
|
||||
|
||||
其他路由文件(通常无需修改):
|
||||
- `src/router/config.ts` — 路由模式、动态路由开关、三级路由缓存配置、路径常量(`DASHBOARD_PATH`、`REDIRECT_PATH`)
|
||||
- `src/router/guard.ts` — 导航守卫(登录验证、权限加载)
|
||||
- `src/router/helper.ts` — 路由降级(三级路由转二级)
|
||||
- `src/router/whitelist.ts` — 免登录白名单
|
||||
|
||||
## 侧边栏显示规则
|
||||
|
||||
理解这套规则是正确配置路由的关键:
|
||||
|
||||
- **1 个非隐藏叶子子路由** → 子路由直接显示在侧边栏(扁平化,不显示父级折叠)
|
||||
- **多个非隐藏子路由** → 自动嵌套显示(父级作为折叠组)
|
||||
- **`alwaysShow: true`** → 强制始终显示父级折叠,即使只有 1 个子路由
|
||||
|
||||
因此:
|
||||
- 单页面模块不需要设置 `alwaysShow`,侧边栏会直接显示子路由
|
||||
- 多页面模块不需要设置 `alwaysShow`,自动嵌套
|
||||
- 只有 "单子路由但仍想显示为折叠组" 的特殊需求才用 `alwaysShow: true`
|
||||
- 动态路由的特殊情况:如果角色或权限过滤可能导致只剩 1 个可见子路由,建议加 `alwaysShow: true` 防止意外扁平化
|
||||
|
||||
## 图标放置规则
|
||||
|
||||
| 场景 | 图标位置 | 原因 |
|
||||
|------|---------|------|
|
||||
| 单子路由(扁平显示) | `children[0].meta` | 子路由直接作为菜单项展示 |
|
||||
| 多子路由(嵌套显示) | 父级 `meta` | 父级作为折叠组的标题 |
|
||||
|
||||
## 路由配置示例
|
||||
|
||||
### 单页面模块
|
||||
|
||||
侧边栏中显示为一个菜单项(不显示父级折叠),图标放在子路由:
|
||||
|
||||
```typescript
|
||||
{
|
||||
path: "/school",
|
||||
component: Layouts,
|
||||
redirect: "/school/list",
|
||||
children: [
|
||||
{
|
||||
path: "list",
|
||||
component: () => import("@/pages/school/index.vue"),
|
||||
name: "SchoolList",
|
||||
meta: {
|
||||
title: "学校管理",
|
||||
elIcon: "School"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 多页面模块
|
||||
|
||||
侧边栏中显示为折叠组,图标放在父级:
|
||||
|
||||
```typescript
|
||||
{
|
||||
path: "/school",
|
||||
component: Layouts,
|
||||
redirect: "/school/list",
|
||||
name: "School",
|
||||
meta: {
|
||||
title: "学校管理",
|
||||
elIcon: "School"
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: "list",
|
||||
component: () => import("@/pages/school/list/index.vue"),
|
||||
name: "SchoolList",
|
||||
meta: { title: "学校列表" }
|
||||
},
|
||||
{
|
||||
path: "grade",
|
||||
component: () => import("@/pages/school/grade/index.vue"),
|
||||
name: "SchoolGrade",
|
||||
meta: { title: "年级管理" }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 三级路由
|
||||
|
||||
支持嵌套子路由,中间层有两种写法:
|
||||
|
||||
**有自身页面的中间层**(渲染自身内容 + 子路由 `<router-view>`):
|
||||
|
||||
```typescript
|
||||
{
|
||||
path: "category",
|
||||
component: () => import("@/pages/school/category/index.vue"),
|
||||
redirect: "/school/category/primary",
|
||||
name: "SchoolCategory",
|
||||
meta: {
|
||||
title: "分类管理",
|
||||
alwaysShow: true
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: "primary",
|
||||
component: () => import("@/pages/school/category/primary/index.vue"),
|
||||
name: "SchoolCategoryPrimary",
|
||||
meta: { title: "小学" }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**纯逻辑分组的中间层**(无自身页面,子路由直接渲染在上层 `Layouts` 中):
|
||||
|
||||
```typescript
|
||||
{
|
||||
path: "category",
|
||||
redirect: "/school/category/primary",
|
||||
name: "SchoolCategory",
|
||||
meta: {
|
||||
title: "分类管理"
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: "primary",
|
||||
component: () => import("@/pages/school/category/primary/index.vue"),
|
||||
name: "SchoolCategoryPrimary",
|
||||
meta: { title: "小学" }
|
||||
},
|
||||
{
|
||||
path: "secondary",
|
||||
component: () => import("@/pages/school/category/secondary/index.vue"),
|
||||
name: "SchoolCategorySecondary",
|
||||
meta: { title: "中学" }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
注意:如果 `src/router/config.ts` 中 `thirdLevelRouteCache: true`,三级路由会被降级为二级,内嵌子路由结构将失效。
|
||||
|
||||
### 外链路由
|
||||
|
||||
```typescript
|
||||
{
|
||||
path: "/link",
|
||||
meta: {
|
||||
title: "外部链接",
|
||||
elIcon: "Link"
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: "https://example.com",
|
||||
component: () => {},
|
||||
name: "ExternalLink",
|
||||
meta: { title: "示例网站" }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 系统路由(通常无需修改)
|
||||
|
||||
以下是项目已内置的基础设施路由,日常开发不需要创建,仅供参考:
|
||||
|
||||
**独立页面**(无 `Layouts`、无 `children`,用于脱离后台布局的页面):
|
||||
|
||||
```typescript
|
||||
// 错误页面、登录页面等
|
||||
{
|
||||
path: "/403",
|
||||
component: () => import("@/pages/error/403.vue"),
|
||||
meta: { hidden: true }
|
||||
}
|
||||
|
||||
// 404 使用 alias 实现全局未匹配路由兜底
|
||||
{
|
||||
path: "/404",
|
||||
component: () => import("@/pages/error/404.vue"),
|
||||
meta: { hidden: true },
|
||||
alias: "/:pathMatch(.*)*"
|
||||
}
|
||||
|
||||
// 登录页
|
||||
{
|
||||
path: "/login",
|
||||
component: () => import("@/pages/login/index.vue"),
|
||||
meta: { hidden: true }
|
||||
}
|
||||
```
|
||||
|
||||
**重定向中转路由**(使用动态路径参数):
|
||||
|
||||
```typescript
|
||||
{
|
||||
path: "/redirect",
|
||||
component: Layouts,
|
||||
meta: { hidden: true },
|
||||
children: [
|
||||
{
|
||||
path: ":path(.*)",
|
||||
component: () => import("@/pages/redirect/index.vue")
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Meta 字段完整说明
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `title` | `string` | 菜单标题(中文) |
|
||||
| `hidden` | `boolean` | 默认 false,设为 true 时不在侧边栏显示 |
|
||||
| `elIcon` | `ElementPlusIconsName` | Element Plus 图标名 |
|
||||
| `svgIcon` | `SvgName` | SVG 图标名(`src/common/assets/icons/` 下) |
|
||||
| `roles` | `string[]` | 可访问的角色列表(仅动态路由) |
|
||||
| `permissions` | `string[]` | 可访问的权限标识字符列表(仅动态路由) |
|
||||
| `keepAlive` | `boolean` | 是否缓存页面(需页面 `name` 与路由 `name` 一致) |
|
||||
| `affix` | `boolean` | 默认 false,设为 true 时固定在标签页不可关闭 |
|
||||
| `alwaysShow` | `boolean` | 强制显示父级折叠(仅在单子路由时有实际效果) |
|
||||
| `breadcrumb` | `boolean` | 默认 true,设为 false 时不在面包屑中显示 |
|
||||
| `activeMenu` | `string` | 如 `/xxx/xxx`,进入该路由时高亮指定的侧边栏菜单项,适合 `hidden` 路由 |
|
||||
|
||||
`svgIcon` 和 `elIcon` 同时设置时,`svgIcon` 优先生效。
|
||||
|
||||
当路由设置 `redirect: "noRedirect"` 时,该路由在面包屑中显示为纯文本(不可点击),适用于有子路由但自身不应被直接访问的父路由。
|
||||
|
||||
## 关键规则
|
||||
|
||||
1. **`name` 唯一性**:动态路由(`dynamicRoutes`)必须有唯一 `name`(`resetRouter` 依赖 `name` 移除路由);常驻路由建议设置 `name`(隐藏页面如 redirect/403/404 可省略)
|
||||
2. **懒加载**:使用 `() => import()` 实现路由懒加载
|
||||
3. **子路由相对路径**:`children` 的 `path` 不带 `/`
|
||||
4. **`Layouts` 组件**:顶级页面路由的父级使用 `component: Layouts`(外链路由除外,外链不需要 `Layouts`)
|
||||
5. **权限继承**:子路由未设置 `roles` / `permissions` 时,会受父路由权限过滤结果影响
|
||||
6. **权限关系**:同一路由同时设置 `roles` 和 `permissions` 时,满足其一即可访问(OR 关系)
|
||||
7. **`redirect`**:有子路由的页面模块应设置 `redirect` 指向默认子页面
|
||||
|
||||
## 命名约定
|
||||
|
||||
模块名为 `school` 时:
|
||||
|
||||
| 位置 | 命名 |
|
||||
|------|------|
|
||||
| 目录 | `src/pages/school/` |
|
||||
| 页面 name | `SchoolList`、`SchoolGrade`(PascalCase) |
|
||||
| 父路由 name | `School` |
|
||||
| 子路由 name | `SchoolList`、`SchoolGrade` |
|
||||
| 父路由 path | `/school` |
|
||||
| 子路由 path | `list`、`grade`(相对路径) |
|
||||
|
||||
## 设计决策指引
|
||||
|
||||
**常驻路由 vs 动态路由** — 大多数业务页面应使用动态路由(dynamicRoutes)。只有登录、404、首页等基础页面使用常驻路由(constantRoutes)。
|
||||
|
||||
**单页面模块 vs 多页面模块** — 功能独立的页面用单页面结构;有关联的多个子功能用多页面结构。
|
||||
|
||||
**图标选择** — 优先使用 Element Plus 图标(elIcon),项目自定义图标使用 svgIcon。
|
||||
|
||||
## 验证清单
|
||||
|
||||
完成路由配置后检查:
|
||||
- [ ] 路由 `name` 全局唯一
|
||||
- [ ] `roles` / `permissions` 是数组格式或 `undefined`
|
||||
- [ ] `children` 的 `path` 是相对路径(不带 `/`)
|
||||
- [ ] 有子路由时父路由设置了 `redirect`,且 `redirect` 指向的子路由路径实际存在
|
||||
- [ ] 对应页面已存在时,页面 `name` 与路由 `name` 一致
|
||||
- [ ] 顶级业务路由使用了 `component: Layouts`
|
||||
- [ ] 动态路由中角色或权限过滤可能只剩 1 个可见子路由时,父路由加了 `alwaysShow: true`
|
||||
@@ -0,0 +1,250 @@
|
||||
---
|
||||
name: v3-upsert-store
|
||||
description: 创建或更新全局状态(Store)。当用户提到以下任何场景时都应触发:新建状态管理、新增 Pinia Store、给 Store 加字段。即使用户没有明确说 Store,只要意图是和状态管理器有关就应该使用此 Skill。使用时需提供 Store 名称、State 字段和 Actions 描述。
|
||||
metadata:
|
||||
author: pany
|
||||
version: "2026.06.01"
|
||||
---
|
||||
|
||||
# 创建或更新 Pinia Store
|
||||
|
||||
根据用户提供的 Store 名称、State 字段和 Actions 描述,生成或更新 Pinia Store 文件。
|
||||
|
||||
本 Skill 定义的是项目默认 Store 模式,当用户的实际需求与本 Skill 约定冲突时,以用户需求为准。
|
||||
|
||||
## 输入要求
|
||||
|
||||
用户需提供:
|
||||
1. **Store 名称**(如 `counter`、`notification`)— 用于文件名和命名
|
||||
2. **State 字段列表** — 每个字段需明确:
|
||||
- 字段名(英文,camelCase)
|
||||
- 类型(string / number / boolean / array / object / 自定义类型等)
|
||||
- 初始值
|
||||
- 是否需要持久化到 localStorage
|
||||
3. **Actions 描述** — 需要哪些操作(如 reset、toggle、increment、fetch 等)
|
||||
|
||||
如果用户信息不完整,主动询问补全后再生成。
|
||||
|
||||
## 操作模式
|
||||
|
||||
### 创建新 Store
|
||||
|
||||
当 `src/pinia/stores/<store名>.ts` 不存在时,生成完整的 Store 文件。
|
||||
|
||||
### 更新已有 Store
|
||||
|
||||
当文件已存在时,在已有代码中追加新的 State 字段和 Actions,保留原有代码不动。追加时:
|
||||
- 新 State 声明插入到已有 State 声明之后
|
||||
- 新 Actions 插入到已有 Actions 之后(如有 `// #region` 分组则放在对应区域)
|
||||
- 更新 `return` 语句,追加新导出的字段和方法
|
||||
|
||||
## 生成文件
|
||||
|
||||
### Store 文件
|
||||
|
||||
路径:`src/pinia/stores/<store名>.ts`
|
||||
|
||||
### 持久化相关(仅在用户指定持久化字段时生成)
|
||||
|
||||
- 在 `src/common/constants/cache-key.ts` 的 `CacheKey` 类中追加新的 Key
|
||||
- 在 `src/common/utils/local-storage.ts` 中追加对应的 get/set 函数
|
||||
|
||||
## 代码规范
|
||||
|
||||
### Store 结构
|
||||
|
||||
完整的 Store 文件结构如下(以一个带持久化和辅助函数的 Store 为例):
|
||||
|
||||
```ts
|
||||
import type { XxxType } from "@@/types/xxx"
|
||||
import { getXxx, setXxx } from "@@/utils/local-storage"
|
||||
import { pinia } from "@/pinia"
|
||||
|
||||
interface Sidebar {
|
||||
opened: boolean
|
||||
withoutAnimation: boolean
|
||||
}
|
||||
|
||||
/** 辅助函数的用途描述 */
|
||||
function helperFunction(param: string) {
|
||||
// ...
|
||||
}
|
||||
|
||||
export const useXxxStore = defineStore("xxx", () => {
|
||||
// Token
|
||||
const token = ref<string>(getToken() || "")
|
||||
|
||||
// 侧边栏状态
|
||||
const sidebar: Sidebar = reactive({
|
||||
opened: true,
|
||||
withoutAnimation: false
|
||||
})
|
||||
|
||||
// 设置 Token
|
||||
const setToken = (value: string) => {
|
||||
token.value = value
|
||||
}
|
||||
|
||||
// 切换侧边栏
|
||||
const toggleSidebar = (withoutAnimation: boolean) => {
|
||||
sidebar.opened = !sidebar.opened
|
||||
sidebar.withoutAnimation = withoutAnimation
|
||||
}
|
||||
|
||||
return { token, sidebar, setToken, toggleSidebar }
|
||||
})
|
||||
|
||||
/**
|
||||
* @description 在 SPA 应用中可用于在 pinia 实例被激活前使用 store
|
||||
* @description 在 SSR 应用中可用于在 setup 外使用 store
|
||||
*/
|
||||
export function useXxxStoreOutside() {
|
||||
return useXxxStore(pinia)
|
||||
}
|
||||
```
|
||||
|
||||
### 关键规则
|
||||
|
||||
1. **必须使用 Setup Store 语法**(`defineStore("id", () => { ... })`),不使用 Options API
|
||||
2. **必须导出 `useXxxStoreOutside`** 函数,且 JSDoc 注释原样保留(见上方模板)
|
||||
3. **`defineStore` 第一个参数**(Store ID):单词用小写(如 `"user"`),多词用 kebab-case(如 `"tags-view"`)
|
||||
4. **State 类型标注**:
|
||||
- `ref` 用泛型:`ref<string>("")`、`ref<number>(0)`、`ref<string[]>([])`
|
||||
- `reactive` 在变量上标注类型:`const sidebar: Sidebar = reactive({...})`
|
||||
5. **Actions 统一使用箭头函数**:`const xxx = (param: Type) => { ... }`
|
||||
6. **注释风格**:
|
||||
- Store 内部的 State 和 Actions 用 `//` 单行注释,描述具体用途(如 `// 切换侧边栏`、`// 设置 Token`)
|
||||
- 不要使用 `/** */` JSDoc 注释 Store 内部成员
|
||||
- 不要使用泛化的分区标题(如 `// state`、`// actions`),每条注释直接描述对应内容
|
||||
- 模块级辅助函数(Store 外部)使用 `/** */` JSDoc 注释
|
||||
7. **`// #region` / `// #endregion`** 仅在同一类操作有多组变体时使用(如 tags-view 的 add/del/delOthers/delAll),普通 Store 不需要
|
||||
8. **自动导入**:`defineStore`、`ref`、`reactive`、`watch`、`watchEffect`、`computed` 无需手动 import
|
||||
9. **Vue 类型导入是允许的**:如 `import type { Ref } from "vue"`
|
||||
|
||||
### 文件结构顺序
|
||||
|
||||
```
|
||||
1. import type 语句(类型导入)
|
||||
2. import 语句(运行时导入,Pinia 实例必须导入)
|
||||
3. interface / type 声明(Store 需要的本地类型)
|
||||
4. 模块级辅助函数(不需要响应式访问的纯函数,用 /** */ 注释)
|
||||
5. export const useXxxStore = defineStore(...)
|
||||
6. export function useXxxStoreOutside()
|
||||
```
|
||||
|
||||
### 持久化模式
|
||||
|
||||
当字段需要持久化时,遵循项目已有模式:
|
||||
|
||||
**1. CacheKey 常量**(`src/common/constants/cache-key.ts`):
|
||||
|
||||
```ts
|
||||
static readonly XXX_DATA = `${SYSTEM_NAME}-xxx-data-key`
|
||||
```
|
||||
|
||||
命名规则:大写下划线 + `-key` 后缀。
|
||||
|
||||
**2. localStorage 工具函数**(`src/common/utils/local-storage.ts`):
|
||||
|
||||
简单字符串值:
|
||||
```ts
|
||||
// #region Xxx 描述
|
||||
export function getXxx() {
|
||||
return localStorage.getItem(CacheKey.XXX_DATA)
|
||||
}
|
||||
|
||||
export function setXxx(value: string) {
|
||||
localStorage.setItem(CacheKey.XXX_DATA, value)
|
||||
}
|
||||
// #endregion
|
||||
```
|
||||
|
||||
对象/数组值:
|
||||
```ts
|
||||
// #region Xxx 描述
|
||||
export function getXxx() {
|
||||
const json = localStorage.getItem(CacheKey.XXX_DATA)
|
||||
return json ? (JSON.parse(json) as XxxType) : null
|
||||
}
|
||||
|
||||
export function setXxx(data: XxxType) {
|
||||
localStorage.setItem(CacheKey.XXX_DATA, JSON.stringify(data))
|
||||
}
|
||||
// #endregion
|
||||
```
|
||||
|
||||
**3. Store 中使用**:
|
||||
|
||||
```ts
|
||||
import { getXxx, setXxx } from "@@/utils/local-storage"
|
||||
|
||||
// Xxx 数据
|
||||
const data = ref<XxxType>(getXxx() ?? defaultValue)
|
||||
|
||||
// 监听变化并持久化
|
||||
watch(data, (newVal) => {
|
||||
setXxx(newVal)
|
||||
})
|
||||
```
|
||||
|
||||
当需要同时监听多个持久化字段时,用 `watchEffect` 代替多个 `watch`:
|
||||
|
||||
```ts
|
||||
watchEffect(() => {
|
||||
setVisitedViews(visitedViews.value)
|
||||
setCachedViews(cachedViews.value)
|
||||
})
|
||||
```
|
||||
|
||||
### 类型定义
|
||||
|
||||
- 简单类型直接在 Store 文件中用 `interface` 或 `type` 声明(定义在 Store 函数之前)
|
||||
- 需要被外部引用的类型加 `export`(如 `export type TagView = Partial<RouteLocationNormalizedGeneric>`)
|
||||
- 复杂、共享类型抽离到 `types` 目录或就近放置
|
||||
|
||||
## 命名约定
|
||||
|
||||
Store 名为 `notification` 时:
|
||||
|
||||
| 位置 | 命名 |
|
||||
|------|------|
|
||||
| 文件 | `src/pinia/stores/notification.ts` |
|
||||
| Store ID | `"notification"` |
|
||||
| Composable | `useNotificationStore` |
|
||||
| Outside | `useNotificationStoreOutside` |
|
||||
| CacheKey(如需) | `NOTIFICATION_DATA` |
|
||||
| localStorage 函数(如需) | `getNotificationData` / `setNotificationData` |
|
||||
|
||||
多词 Store 名使用 kebab-case 文件名和 Store ID(如 `user-preference` → `"user-preference"` → `useUserPreferenceStore`)。
|
||||
|
||||
## 导入规范
|
||||
|
||||
```ts
|
||||
// 类型导入
|
||||
import type { Ref } from "vue"
|
||||
import type { RouteRecordRaw } from "vue-router"
|
||||
|
||||
// 运行时导入:常量和工具函数
|
||||
import { SIDEBAR_CLOSED, SIDEBAR_OPENED } from "@@/constants/app-key"
|
||||
import { getSidebarStatus, setSidebarStatus } from "@@/utils/local-storage"
|
||||
|
||||
// 运行时导入:Pinia 实例(必需)
|
||||
import { pinia } from "@/pinia"
|
||||
|
||||
// 运行时导入:其他 Store(当需要跨 Store 交互时)
|
||||
import { useSettingsStore } from "./settings"
|
||||
```
|
||||
|
||||
注意导入顺序:类型导入在前,运行时导入在后;`@@` 路径在前,`@` 路径在后。
|
||||
|
||||
## 设计决策指引
|
||||
|
||||
**ref vs reactive** — 当 State 是一个有结构的对象且字段间关联紧密时用 `reactive`(如 sidebar 的 opened + withoutAnimation),否则每个独立状态用 `ref`。大多数情况下用 `ref`。
|
||||
|
||||
**watch vs watchEffect** — 监听单个字段变化时用 `watch`;监听多个字段且都需要持久化时用 `watchEffect`(自动追踪依赖,代码更简洁)。
|
||||
|
||||
**辅助函数放在 Store 内还是外** — 不依赖响应式状态的纯逻辑函数放在 Store 外部(如权限过滤、格式化),需要访问 Store 内 `ref` / `reactive` 的函数放在内部作为 Action。
|
||||
|
||||
**是否需要 Reset Action** — 如果 Store 管理的是临时状态(如表单数据、UI 状态),通常需要一个 Reset 方法恢复初始值。如果是用户身份等持久数据,通常不需要通用 Reset。
|
||||
|
||||
**是否需要 Outside 函数** — 始终生成。这是项目约定,确保在路由守卫、Axios 拦截器等 setup 外场景可用。
|
||||
@@ -0,0 +1,283 @@
|
||||
---
|
||||
name: v3-use-composables
|
||||
description: 项目内置组合式函数使用教程,涵盖设备检测、异步下拉、全屏加载、分页、路由监听、主题切换、动态标题、水印等组合式函数。当用户提到以下任何场景时都应触发:使用组合式函数、调用 Composables、判断设备类型、异步加载下拉选项、全屏 Loading、分页逻辑、监听路由变化、切换主题、设置页面标题、添加水印。即使用户没有明确说 Composables,只要意图是使用项目内置的组合式函数就应该使用此 Skill。
|
||||
metadata:
|
||||
author: pany
|
||||
version: "2026.06.13"
|
||||
---
|
||||
|
||||
# 内置组合式函数使用教程
|
||||
|
||||
项目在 `src/common/composables` 目录下提供了一组通用组合式函数,通过路径别名 `@@/composables/` 导入。
|
||||
|
||||
本 Skill 定义的是项目内置的组合式函数,当这些函数不存在时,以用户需求为准,新增对应函数。
|
||||
|
||||
## 设备检测 `useDevice`
|
||||
|
||||
判断当前设备类型(移动端 / 桌面端),基于 `appStore.device` 的响应式计算属性。
|
||||
|
||||
```ts
|
||||
import { useDevice } from "@@/composables/useDevice"
|
||||
|
||||
const { isMobile, isDesktop } = useDevice()
|
||||
|
||||
// 在模板或逻辑中使用
|
||||
if (isMobile.value) {
|
||||
// 移动端逻辑
|
||||
}
|
||||
```
|
||||
|
||||
## 异步下拉选择器 `useFetchSelect`
|
||||
|
||||
封装下拉选择器的异步数据加载逻辑,组件挂载时自动调用接口获取选项。
|
||||
|
||||
```ts
|
||||
import { useFetchSelect } from "@@/composables/useFetchSelect"
|
||||
import { getSelectDataApi } from "./apis/xxx"
|
||||
|
||||
const { loading, options, value } = useFetchSelect({
|
||||
api: getSelectDataApi // 直接传函数引用,返回 ApiResponseData<SelectOption[]>
|
||||
})
|
||||
```
|
||||
|
||||
在模板中配合 Element Plus 使用:
|
||||
|
||||
```vue
|
||||
<el-card v-loading="loading">
|
||||
<el-select v-model="value" filterable>
|
||||
<el-option v-for="item in options" v-bind="item" :key="item.value" placeholder="请选择" />
|
||||
</el-select>
|
||||
</el-card>
|
||||
```
|
||||
|
||||
接口返回的数据格式:
|
||||
|
||||
```ts
|
||||
interface SelectOption {
|
||||
value: string | number
|
||||
label: string
|
||||
disabled?: boolean
|
||||
}
|
||||
```
|
||||
|
||||
## 全屏加载 `useFullscreenLoading`
|
||||
|
||||
包装一个函数,在其执行期间自动显示全屏 Loading,函数执行完毕后自动关闭。
|
||||
|
||||
```ts
|
||||
import { useFullscreenLoading } from "@@/composables/useFullscreenLoading"
|
||||
|
||||
// 方式一:行内调用(推荐简单场景)
|
||||
const res = await useFullscreenLoading(getSuccessApi)([1, 2, 3])
|
||||
|
||||
// 方式二:自定义 Loading 配置
|
||||
try {
|
||||
await useFullscreenLoading(getErrorApi, { text: "删除中...", background: "#F56C6C20" })()
|
||||
} catch (error) {
|
||||
ElMessage.error((error as Error).message)
|
||||
}
|
||||
|
||||
// 方式三:先赋值再调用(适合多次复用)
|
||||
const submitWithLoading = useFullscreenLoading(submitApi)
|
||||
await submitWithLoading(formData)
|
||||
```
|
||||
|
||||
## 分页 `usePagination`
|
||||
|
||||
封装分页状态与操作,适配 Element Plus 的 `el-pagination` 组件。
|
||||
|
||||
```ts
|
||||
import { usePagination } from "@@/composables/usePagination"
|
||||
|
||||
function getTableData() {
|
||||
// 请求表格数据,并更新 paginationData.total
|
||||
}
|
||||
|
||||
const { paginationData, resetCurrentPage, watchPagination } = usePagination({
|
||||
callback: getTableData,
|
||||
pageSize: 20,
|
||||
pageSizes: [10, 20, 50, 100]
|
||||
})
|
||||
|
||||
// 初始化加载,并在 currentPage / pageSize 变化时重新请求
|
||||
watchPagination()
|
||||
|
||||
function handleSearch() {
|
||||
resetCurrentPage()
|
||||
}
|
||||
```
|
||||
|
||||
在模板中使用:
|
||||
|
||||
```html
|
||||
<el-pagination
|
||||
v-model:current-page="paginationData.currentPage"
|
||||
v-model:page-size="paginationData.pageSize"
|
||||
:page-sizes="paginationData.pageSizes"
|
||||
:total="paginationData.total"
|
||||
:layout="paginationData.layout"
|
||||
background
|
||||
/>
|
||||
```
|
||||
|
||||
`callback` 会在 `watchPagination` 监听到页码或每页条数变化时执行;`resetCurrentPage` 用于搜索场景,当前已在第 1 页时直接执行回调,否则重置到第 1 页并交给监听触发,避免重复请求。
|
||||
|
||||
默认分页参数:
|
||||
|
||||
| 参数 | 默认值 |
|
||||
|------|--------|
|
||||
| `total` | `0` |
|
||||
| `currentPage` | `1` |
|
||||
| `pageSizes` | `[10, 20, 50]` |
|
||||
| `pageSize` | `10` |
|
||||
| `layout` | `"total, sizes, prev, pager, next, jumper"` |
|
||||
|
||||
## 路由监听 `useRouteListener`
|
||||
|
||||
基于发布订阅模式监听路由变化,比直接 `watch` 路由性能更好,组件卸载时自动移除监听。
|
||||
|
||||
```ts
|
||||
import { useRouteListener } from "@@/composables/useRouteListener"
|
||||
|
||||
const { listenerRouteChange, removeRouteListener } = useRouteListener()
|
||||
|
||||
// 监听路由变化
|
||||
listenerRouteChange((route) => {
|
||||
console.log("路由变化了:", route.path)
|
||||
})
|
||||
|
||||
// 立即执行一次(获取当前路由信息)
|
||||
listenerRouteChange((route) => {
|
||||
activeMenu.value = route.path
|
||||
}, true) // 第二个参数 immediate = true
|
||||
```
|
||||
|
||||
## 主题切换 `useTheme`
|
||||
|
||||
管理主题切换,支持 View Transition 动画效果。
|
||||
|
||||
```ts
|
||||
import { useTheme } from "@@/composables/useTheme"
|
||||
|
||||
const { themeList, activeThemeName, initTheme, setTheme } = useTheme()
|
||||
|
||||
// 初始化主题(应用启动时调用一次)
|
||||
initTheme()
|
||||
|
||||
// 切换主题(需要传入鼠标事件以实现过渡动画)
|
||||
function handleThemeChange(event: MouseEvent, themeName: ThemeName) {
|
||||
setTheme(event, themeName)
|
||||
}
|
||||
```
|
||||
|
||||
可用主题:
|
||||
|
||||
| name | title |
|
||||
|------|-------|
|
||||
| `"normal"` | 默认 |
|
||||
| `"dark"` | 黑暗 |
|
||||
| `"dark-blue"` | 深蓝 |
|
||||
|
||||
## 动态标题 `useTitle`
|
||||
|
||||
动态设置浏览器标签页标题,格式为 `项目名 | 页面名`。
|
||||
|
||||
```ts
|
||||
import { useTitle } from "@@/composables/useTitle"
|
||||
|
||||
const { setTitle } = useTitle()
|
||||
|
||||
// 设置标题为 "V3 Admin Vite | 用户管理"
|
||||
setTitle("用户管理")
|
||||
|
||||
// 重置为项目默认标题
|
||||
setTitle()
|
||||
```
|
||||
|
||||
## 水印 `useWatermark`
|
||||
|
||||
为页面或指定容器添加水印,支持防御(防止用户通过控制台删除或隐藏水印),组件卸载时自动清除。
|
||||
|
||||
```ts
|
||||
import { useWatermark } from "@@/composables/useWatermark"
|
||||
|
||||
// 默认挂载到 body
|
||||
const { setWatermark, clearWatermark } = useWatermark()
|
||||
|
||||
// 设置水印
|
||||
setWatermark("机密文件")
|
||||
|
||||
// 自定义配置
|
||||
setWatermark("内部使用", {
|
||||
defense: true, // 开启防御(默认 true)
|
||||
color: "#c0c4cc", // 文本颜色
|
||||
opacity: 0.5, // 透明度
|
||||
size: 16, // 字体大小
|
||||
angle: -20, // 倾斜角度
|
||||
width: 300, // 单个水印宽度(越大密度越低)
|
||||
height: 200 // 单个水印高度(越大密度越低)
|
||||
})
|
||||
|
||||
// 清除水印
|
||||
clearWatermark()
|
||||
```
|
||||
|
||||
挂载到指定容器:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { useWatermark } from "@@/composables/useWatermark"
|
||||
|
||||
const localRef = useTemplateRef("localRef")
|
||||
const { setWatermark, clearWatermark } = useWatermark(localRef)
|
||||
|
||||
onMounted(() => {
|
||||
setWatermark("仅限内部")
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="localRef">
|
||||
<!-- 内容 -->
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
## 灰色模式与色弱模式 `useGreyAndColorWeakness`
|
||||
|
||||
初始化灰色模式和色弱模式,基于 `settingsStore` 的配置自动切换 HTML 根元素的 CSS 类名。
|
||||
|
||||
```ts
|
||||
import { useGreyAndColorWeakness } from "@@/composables/useGreyAndColorWeakness"
|
||||
|
||||
const { initGreyAndColorWeakness } = useGreyAndColorWeakness()
|
||||
|
||||
// 应用启动时调用一次即可,后续会自动响应 Store 变化
|
||||
initGreyAndColorWeakness()
|
||||
```
|
||||
|
||||
## 布局模式 `useLayoutMode`
|
||||
|
||||
管理系统布局模式(左侧菜单 / 顶部菜单 / 左侧 + 顶部混合菜单)。
|
||||
|
||||
```ts
|
||||
import { useLayoutMode } from "@@/composables/useLayoutMode"
|
||||
import { LayoutModeEnum } from "@@/constants/app-key"
|
||||
|
||||
const { isLeft, isTop, isLeftTop, setLayoutMode } = useLayoutMode()
|
||||
|
||||
// 判断当前布局
|
||||
if (isLeft.value) {
|
||||
// 左侧菜单布局
|
||||
}
|
||||
|
||||
// 切换布局
|
||||
setLayoutMode(LayoutModeEnum.Top)
|
||||
```
|
||||
|
||||
## 使用原则
|
||||
|
||||
1. 优先使用这些内置组合式函数,不要重复造轮子
|
||||
2. 组合式函数内部已处理生命周期(如 `onBeforeUnmount` 自动清理),无需手动管理
|
||||
3. 需要新增通用组合式函数时,在 `src/common/composables` 目录下创建,命名以 `use` 开头
|
||||
4. 页面私有的组合式函数应放在对应页面目录的 `composables` 子目录下,而非 `src/common/composables`
|
||||
@@ -0,0 +1,117 @@
|
||||
---
|
||||
name: v3-use-utils
|
||||
description: 项目内置工具函数使用教程,涵盖验证、日期格式化、CSS 变量、权限判断、本地存储等工具。当用户提到以下任何场景时都应触发:使用工具函数、调用 utils、格式化日期、判断权限、操作 localStorage、获取/设置 CSS 变量、验证数据类型、判断外链、获取/存储 Token。即使用户没有明确说 Utils,只要意图是使用项目内置的通用工具函数就应该使用此 Skill。
|
||||
metadata:
|
||||
author: pany
|
||||
version: "2026.06.03"
|
||||
---
|
||||
|
||||
# 内置工具函数使用教程
|
||||
|
||||
项目在 `src/common/utils` 目录下提供了一组通用工具函数,通过路径别名 `@@/utils/` 导入。
|
||||
|
||||
本 Skill 定义的是项目内置的工具函数,当这些函数不存在时,以用户需求为准,新增对应函数。
|
||||
|
||||
## 验证工具 `@@/utils/validate`
|
||||
|
||||
```ts
|
||||
import { isArray, isString, isExternal } from "@@/utils/validate"
|
||||
|
||||
// 判断是否为数组
|
||||
isArray([1, 2, 3]) // true
|
||||
|
||||
// 判断是否为字符串
|
||||
isString("hello") // true
|
||||
|
||||
// 判断是否为外链(以 http(s)://、mailto:、tel: 开头)
|
||||
isExternal("https://example.com") // true
|
||||
isExternal("/dashboard") // false
|
||||
```
|
||||
|
||||
## 日期格式化 `@@/utils/datetime`
|
||||
|
||||
基于 dayjs 封装,无效日期返回 `"N/A"`。
|
||||
|
||||
```ts
|
||||
import { formatDateTime } from "@@/utils/datetime"
|
||||
|
||||
// 默认格式 YYYY-MM-DD HH:mm:ss
|
||||
formatDateTime("2026-01-15T10:30:00") // "2026-01-15 10:30:00"
|
||||
|
||||
// 自定义格式
|
||||
formatDateTime("2026-01-15", "YYYY/MM/DD") // "2026/01/15"
|
||||
|
||||
// 支持时间戳
|
||||
formatDateTime(1737000000000) // 格式化后的日期时间
|
||||
|
||||
// 无效输入
|
||||
formatDateTime("invalid") // "N/A"
|
||||
```
|
||||
|
||||
## CSS 变量 `@@/utils/css`
|
||||
|
||||
读取和设置 CSS 变量(变量名必须以 `--` 开头)。`getCssVar` 未找到变量时返回空串 `""`,不是 `undefined`。
|
||||
|
||||
```ts
|
||||
import { getCssVar, setCssVar } from "@@/utils/css"
|
||||
|
||||
// 获取全局 CSS 变量
|
||||
const color = getCssVar("--el-color-primary")
|
||||
|
||||
// 设置全局 CSS 变量
|
||||
setCssVar("--el-color-primary", "#409eff")
|
||||
|
||||
// 操作指定元素上的 CSS 变量
|
||||
const el = document.querySelector(".container") as HTMLElement
|
||||
setCssVar("--bg-color", "#fff", el)
|
||||
```
|
||||
|
||||
## 权限判断 `@@/utils/permission`
|
||||
|
||||
基于当前用户角色或权限判断是否拥有指定权限,内部读取 `useUserStore().roles` 和 `useUserStore().permissions`。
|
||||
|
||||
```ts
|
||||
import { checkPermission } from "@@/utils/permission"
|
||||
|
||||
// 判断当前用户是否拥有 admin 或 editor 角色
|
||||
if (checkPermission(["admin", "editor"])) {
|
||||
// 有权限
|
||||
}
|
||||
|
||||
// 判断当前用户是否拥有指定权限
|
||||
if (checkPermission(["permission:button-level"])) {
|
||||
// 有权限
|
||||
}
|
||||
```
|
||||
|
||||
适用于模板中 `v-permission` 指令无法覆盖的场景(如在 TS 逻辑中做条件判断)。
|
||||
|
||||
## 本地存储 `@@/utils/local-storage`
|
||||
|
||||
对 localStorage 的类型安全封装,所有 key 统一由 `@@/constants/cache-key` 中的 `CacheKey` 管理。
|
||||
|
||||
```ts
|
||||
import { getToken, setToken, removeToken } from "@@/utils/local-storage"
|
||||
|
||||
// Token 操作
|
||||
setToken("eyJhbGciOiJIUzI1NiJ9...")
|
||||
const token = getToken()
|
||||
removeToken()
|
||||
```
|
||||
|
||||
可用函数一览:
|
||||
|
||||
| 分组 | 函数 | 说明 |
|
||||
|------|------|------|
|
||||
| Token | `getToken` / `setToken` / `removeToken` | 用户认证令牌 |
|
||||
| 布局配置 | `getLayoutsConfig` / `setLayoutsConfig` / `removeLayoutsConfig` | 系统布局设置 |
|
||||
| 侧边栏 | `getSidebarStatus` / `setSidebarStatus` | 侧边栏展开/收起状态 |
|
||||
| 主题 | `getActiveThemeName` / `setActiveThemeName` | 当前主题名称 |
|
||||
| 标签栏 | `getVisitedViews` / `setVisitedViews` / `getCachedViews` / `setCachedViews` | 标签页缓存 |
|
||||
|
||||
## 使用原则
|
||||
|
||||
1. 优先使用这些内置工具,不要重复造轮子
|
||||
2. 需要新增 localStorage 存储项时,先在 `CacheKey` 中定义 key,再在 `local-storage.ts` 中添加对应的 get / set 函数
|
||||
3. 需要新增通用工具时,根据功能归类到对应文件,或创建新的工具文件
|
||||
4. 工具文件之间可以互相引用(如 `permission.ts` 引用了 `@@/utils/validate`),新建工具时也可复用已有工具
|
||||
Reference in New Issue
Block a user