first commit

This commit is contained in:
2026-10-26 18:54:09 +08:00
commit 20a594d834
180 changed files with 18496 additions and 0 deletions
+361
View File
@@ -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"` |
## 路由提示
生成代码后,提醒用户在路由配置中添加对应路由。
+298
View File
@@ -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`
+250
View File
@@ -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 外场景可用。
+283
View File
@@ -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`
+117
View File
@@ -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`),新建工具时也可复用已有工具
+24
View File
@@ -0,0 +1,24 @@
# 配置项文档:https://editorconfig.org(修改配置后重启编辑器)
## 告知 EditorConfig 插件,当前即是根文件
root = true
## 适用全部文件
[*]
### 设置字符集
charset = utf-8
### 缩进风格 space | tab,建议 space
indent_style = space
### 缩进的空格数
indent_size = 2
### 换行符类型 lf | cr | crlf,一般都是设置为 lf
end_of_line = lf
### 是否在文件末尾插入空白行
insert_final_newline = true
### 是否删除一行中的前后空格
trim_trailing_whitespace = true
## 适用 .md 文件
[*.md]
insert_final_newline = false
trim_trailing_whitespace = false
+7
View File
@@ -0,0 +1,7 @@
# 所有环境的环境变量(命名必须以 VITE_ 开头)
## 项目标题
VITE_APP_TITLE = V3 Admin Vite
## 路由模式 hash 或 html5
VITE_ROUTER_HISTORY = hash
+7
View File
@@ -0,0 +1,7 @@
# 开发环境的环境变量(命名必须以 VITE_ 开头)
## 后端接口地址(如果解决跨域问题采用反向代理就只需写相对路径)
VITE_BASE_URL = /api/v1
## 开发环境域名和静态资源公共路径(一般 / 或 ./ 都可以)
VITE_PUBLIC_PATH = /
+7
View File
@@ -0,0 +1,7 @@
# 生产环境的环境变量(命名必须以 VITE_ 开头)
## 后端接口地址(如果解决跨域问题采用 CORS 就需要写绝对路径)
VITE_BASE_URL = https://apifoxmock.com/m1/2930465-2145633-default/api/v1
## 打包构建静态资源公共路径(例如部署到 https://un-pany.github.io/v3-admin-vite/ 域名下就需要填写 /v3-admin-vite/)
VITE_PUBLIC_PATH = /v3-admin-vite/
+7
View File
@@ -0,0 +1,7 @@
# 预发布环境的环境变量(命名必须以 VITE_ 开头)
## 后端接口地址(如果解决跨域问题采用 CORS 就需要写绝对路径)
VITE_BASE_URL = https://apifoxmock.com/m1/2930465-2145633-default/api/v1
## 打包构建静态资源公共路径(例如部署到 https://un-pany.github.io/ 域名下就需要填写 /)
VITE_PUBLIC_PATH = /
+24
View File
@@ -0,0 +1,24 @@
# Common
dist
node_modules
.eslintcache
vite.config.*.timestamp*
# MacOS
.DS_Store
# Local env files
*.local
*.local.json
# Logs
*.log
# Use the pnpm
package-lock.json
yarn.lock
# Agent data
.omc
.agents/skills/*
!.agents/skills/v3-*/
+5
View File
@@ -0,0 +1,5 @@
# China mirror of npm
registry = https://registry.npmmirror.com
# 安装依赖时锁定版本号
save-exact = true
+10
View File
@@ -0,0 +1,10 @@
{
"recommendations": [
"vue.volar",
"editorconfig.editorconfig",
"dbaeumer.vscode-eslint",
"antfu.unocss",
"vitest.explorer",
"wiensss.region-highlighter"
]
}
+15
View File
@@ -0,0 +1,15 @@
{
"Vue3 Composable 代码结构一键生成": {
"prefix": "Vue3 Composable",
"body": [
"const refName1 = ref<string>(\"这是一个响应式变量\")\n",
"export function useName() {",
"\tconst refName2 = ref<string>(\"这是一个响应式变量\")\n",
"\tconst fnName = () => {}\n",
"\treturn { refName1, refName2, fnName }",
"}",
"$1"
],
"description": "Vue3 Composable"
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"servers": {
"vite": {
"type": "sse",
"url": "http://localhost:3333/__mcp/sse"
}
}
}
+56
View File
@@ -0,0 +1,56 @@
{
// Use workspace TypeScript version
"typescript.tsdk": "node_modules/typescript/lib",
// Show inlay hints for destructured props
"vue.inlayHints.destructuredProps": true,
// Disable the default formatter, use eslint instead
"prettier.enable": false,
"editor.formatOnSave": false,
// Auto fix
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.organizeImports": "never"
},
// Silent the stylistic rules in you IDE, but still auto fix them
"eslint.rules.customizations": [
{ "rule": "style/*", "severity": "off", "fixable": true },
{ "rule": "format/*", "severity": "off", "fixable": true },
{ "rule": "*-indent", "severity": "off", "fixable": true },
{ "rule": "*-spacing", "severity": "off", "fixable": true },
{ "rule": "*-spaces", "severity": "off", "fixable": true },
{ "rule": "*-order", "severity": "off", "fixable": true },
{ "rule": "*-dangle", "severity": "off", "fixable": true },
{ "rule": "*-newline", "severity": "off", "fixable": true },
{ "rule": "*quotes", "severity": "off", "fixable": true },
{ "rule": "*semi", "severity": "off", "fixable": true }
],
// Enable eslint for all supported languages
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact",
"vue",
"html",
"markdown",
"json",
"jsonc",
"yaml",
"toml",
"xml",
"gql",
"graphql",
"astro",
"svelte",
"css",
"less",
"scss",
"pcss",
"postcss"
]
}
+16
View File
@@ -0,0 +1,16 @@
{
"Vue3 SFC 代码结构一键生成": {
"prefix": "Vue3 SFC",
"body": [
"<script lang=\"ts\" setup></script>\n",
"<template>",
"\t<div class=\"app-container\">",
"\t\t...",
"\t</div>",
"</template>\n",
"<style lang=\"scss\" scoped></style>",
"$1"
],
"description": "Vue3 SFC"
}
}
+143
View File
@@ -0,0 +1,143 @@
## 原则
### 1. 编码前思考
**不要假设,不要隐藏困惑,呈现权衡**
在实现之前:
- 明确陈述你的假设,如果不确定,请提问
- 如果存在多种解读,把它们都列出来,不要默默选择其一
- 如果有更简单的方案,就指出来,在必要时提出反对意见
- 如果有不清楚的地方,停下来,说出哪里让你困惑,然后提问
### 2. 简洁优先
**用最少的代码解决问题,不要过度推测**
- 不要添加要求之外的功能
- 不要为一次性代码创建抽象
- 不要添加未要求的 "灵活性" 或 "可配置性"
- 不要为不可能发生的场景做错误处理
- 如果 200 行代码可以写成 50 行,重写它
**检验标准:** 资深工程师会觉得这过于复杂吗?如果是,简化
### 3. 精准修改
**只碰必须碰的,只清理自己造成的混乱**
编辑现有代码时:
- 不要 "改进" 相邻的代码、注释或格式
- 不要重构没坏的东西
- 匹配现有风格,即使你更倾向于不同的写法
- 如果注意到无关的死代码,提一下,不要删除它
当你的改动产生孤儿代码时:
- 删除因你的改动而变得无用的导入、变量或函数
- 不要删除预先存在的死代码,除非被要求
**检验标准:** 每一行修改都应该能直接追溯到用户的请求
### 4. 目标驱动执行
**定义成功标准,循环验证直到达成**
将指令式任务转化为可验证的目标:
"添加验证" → "为无效输入编写测试,然后让它们通过"
"修复 bug" → "编写重现 bug 的测试,然后让它通过"
"重构 X" → "确保重构前后测试都能通过"
对于多步骤任务,说明一个简短的计划:
```
1. [步骤] → 验证: [检查]
2. [步骤] → 验证: [检查]
3. [步骤] → 验证: [检查]
```
明确有力的成功标准能让你独立循环推进,模糊的标准("让它工作")只会不断需要澄清
## 命令
```bash
pnpm i # 安装依赖
pnpm dev # 启动服务
pnpm build:staging # 打包构建预发布环境
pnpm build # 打包构建生产环境
pnpm preview # 先执行打包构建命令生成 dist 目录后再执行以下预览命令
pnpm lint # 代码校验与格式化
pnpm test # 单元测试
```
## 架构
### 技术栈
- 框架: Vue 3.5
- 打包构建工具: Vite 7
- 路由管理: Vue Router 5
- 状态管理: Pinia 3
- UI 组件库: Element Plus
- CSS 预处理器: Scss
- 代码校验与格式化: ESLint
- 开发语言: TypeScript
- 包管理工具: pnpm
- 网络请求: Axios
### 目录结构
```sh
v3-admin-vite
├─ .husky # commit 时进行代码校验和格式化
├─ .vscode # vscode 配置和插件
├─ public
│ ├─ favicon.ico # 网站头像
│ ├─ app-loading.css # 首屏 loading 动画
│ └─ detect-ie.js # 检测 ie
├─ src
│ ├─ common # 通用目录
│ │ ├─ apis # 通用目录 - 接口
│ │ ├─ assets # 通用目录 - 静态资源
│ │ ├─ components # 通用目录 - 组件
│ │ ├─ composables # 通用目录 - 组合式函数
│ │ ├─ constants # 通用目录 - 常量
│ │ └─ utils # 通用目录 - 工具函数
│ ├─ http # 网络请求
│ ├─ layouts # 布局
│ ├─ pages # 页面
│ │ └─ login # 登录模块
│ │ ├─ apis # 登录模块 - 私有接口
│ │ ├─ components # 登录模块 - 私有组件
│ │ ├─ composables # 登录模块 - 私有组合式函数
│ │ ├─ images # 登录模块 - 私有图片
│ │ └─ index.vue # 登录模块 - 页面
│ ├─ pinia # 状态管理
│ ├─ plugins # 插件(全局组件、自定义指令等)
│ ├─ router # 路由
│ ├─ App.vue # 入口页面
│ └─ main.ts # 入口文件
├─ tests # 单元测试
├─ types # 类型声明
│ └─ auto # 自动生成的类型(禁止手动修改)
├─ .editorconfig # 编辑器配置
├─ .env # 所有环境
├─ .env.development # 开发环境
├─ .env.production # 生产环境
├─ .env.staging # 预发布环境
├─ eslint.config.js # eslint 配置
├─ tsconfig.json # ts 配置
├─ uno.config.ts # unocss 配置
└─ vite.config.ts # vite 配置
```
- 保持目录结构清晰,遵循现有目录规范
- 同一个业务逻辑的代码和资源应当被收拢到了一起,避免在不同的目录间来回跳跃 (例如登录模块的接口应该放在 `src/pages/login/apis` 而不是 `src/common/apis`)
## 约定
- 按需参考 `.cursor/rules` 目录下规则
- 任何时候都禁止主动删除文件,必须经过同意
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022-present pany <https://github.com/pany-ang>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+238
View File
@@ -0,0 +1,238 @@
<div align="center">
<img alt="logo" width="120" height="120" src="./src/common/assets/images/layouts/logo.png">
<h1>V3 Admin Vite - AI Friendly</h1>
</div>
[![github release](https://img.shields.io/github/v/release/un-pany/v3-admin-vite?style=flat)](https://github.com/un-pany/v3-admin-vite/releases)
[![github stars](https://img.shields.io/github/stars/un-pany/v3-admin-vite?style=flat)](https://github.com/un-pany/v3-admin-vite/stargazers)
[![gitee stars](https://gitee.com/un-pany/v3-admin-vite/badge/star.svg)](https://gitee.com/un-pany/v3-admin-vite/stargazers)
[![atomgit stars](https://atomgit.com/un-pany/v3-admin-vite/star/badge.svg)](https://atomgit.com/un-pany/v3-admin-vite/stargazers)
<b>English | <a href="./README.zh-CN.md">中文</a></b>
## Introduction <a href="https://juejin.cn/post/7660780385584316454"><img src="https://img.shields.io/badge/AI-Coding-black" alt="AI Coding"></a>
V3 Admin Vite is an AI-friendly admin dashboard template built on mainstream technologies such as Vue3, Vite, TypeScript, and Element Plus, offering an excellent "Vibe Coding" experience!
_Supports mainstream AI coding tools such as Claude Code, CodeX, Cursor, Trae, and Antigravity_
## Notifications
> [!IMPORTANT]
> Welcome to the all-new version 5.2, delivering a unique AI coding experience! It also introduces the long-awaited backend-controlled dynamic routing permission system
> [!WARNING]
> Version 4.x will no longer be maintained unless there are critical bugs! [Click to switch to the 4.x branch](https://github.com/un-pany/v3-admin-vite/tree/4.x)
> [!TIP]
> Paid services are officially launched! If you don’t want to do it yourself but want to remove TS or other modules, try the lazy package! [Click to check it out](https://github.com/un-pany/v3-admin-vite/issues/225)
> [!NOTE]
> If you have mobile web app requirements, give the new open-source template [MobVue](https://github.com/un-pany/mobvue) a try
## Usage
<details>
<summary>Recommended Environment</summary>
<br>
- Latest version of `Visual Studio Code` or AI IDE `Cursor` and `Trae`
- Install the recommended plugins in the `.vscode/extensions.json` file
- `node` 20.19+ or 22.12+
- `pnpm` 10+
</details>
<details>
<summary>Local Development</summary>
<br>
```bash
# Clone the project
git clone https://github.com/un-pany/v3-admin-vite.git
# Enter the project directory
cd v3-admin-vite
# Install dependencies
pnpm i
# Start the development server
pnpm dev
```
</details>
<details>
<summary>Build</summary>
<br>
```bash
# Build for the staging environment
pnpm build:staging
# Build for the production environment
pnpm build
```
</details>
<details>
<summary>Local Preview</summary>
<br>
```bash
# Execute the build command first to generate the dist directory, then run the preview command
pnpm preview
```
</details>
<details>
<summary>Code Check</summary>
<br>
```bash
# Code linting and formatting
pnpm lint
# Unit tests
pnpm test
```
</details>
<details>
<summary>Commit Guidelines</summary>
<br>
`feat` New feature
`fix` Bug fix
`perf` Performance improvement
`refactor` Code refactoring
`docs` Documentation and comments
`types` Type-related changes
`test` Unit tests related
`ci` Continuous integration, workflows
`revert` Revert changes
`chore` Chores (update dependencies, modify configurations, etc)
</details>
<details>
<summary>Install recommended skills</summary>
<br>
```bash
npx skills experimental_install
```
</details>
## Links
**Online Preview**: [github-pages](https://un-pany.github.io/v3-admin-vite)
**Chinese Documentation**: [link](https://juejin.cn/post/7445151895121543209)
**Zero to Hero Tutorial**: [link](https://juejin.cn/column/7207659644487139387)
**Mobile Web App**: [mobvue](https://github.com/un-pany/mobvue)
**Electron Desktop Version**: [v3-electron-vite](https://github.com/un-pany/v3-electron-vite)
**Chinese Repository**: [gitee](https://gitee.com/un-pany/v3-admin-vite), [atomgit](https://atomgit.com/un-pany/v3-admin-vite)
**Optional Group**: [check how to join](https://github.com/un-pany/v3-admin-vite/issues/191)
**Donations**: [buy a coffee for the author](https://github.com/un-pany/v3-admin-vite/issues/69)
**Releases & Changelog**: [releases](https://github.com/un-pany/v3-admin-vite/releases)
## Features
**Simplified structure**: No complex encapsulation, no complicated type gymnastics, just enough to meet the needs
**Detailed comments**: Every configuration item comes with as detailed comments as possible
**Latest dependencies**: Keeps all third-party dependencies up to date
**Consistency**: Unified code style, naming conventions, and comment style
## Built-in Features
**User Management**: Login, logout demonstration
**Permission Management**: Page-level permissions (dynamic routing controlled by backend APIs), button-level permissions (permission directives and functions), and route guards
**Multiple Environments**: Development, staging, and production environments
**Multiple Themes**: Normal, dark, and deep blue themes
**Multiple Layouts**: Left-side, top, and hybrid layouts
**Homepage**: Different dashboard pages for different users
**Error Pages**: 403, 404
**Mobile Compatibility**: Layouts compatible with mobile screen resolutions
**Others**: SVG sprite sheet, dynamic sidebar, dynamic breadcrumbs, tab navigation, content zoom and fullscreen, composable functions
## Tech Stack
**Vue3**: Vue3 + script setup with the latest Vue3 Composition API
**Element Plus**: The Vue3 version of Element UI
**Pinia**: The legendary Vuex5
**Vite**: Really fast
**Vue Router**: The routing system
**TypeScript**: A superset of JavaScript
**pnpm**: A faster, disk-space-saving package manager
**Scss**: Consistent with Element Plus
**CSS Variables**: Primarily controls layout and color in the project
**ESLint**: Code linting and formatting
**Axios**: Sends network requests
**UnoCSS**: A high-performance, flexible atomic CSS engine
## Project Preview Image
![preview](./src/common/assets/images/docs/preview.png)
## Contributors
A big thank you to all the contributors!
<a href="https://github.com/un-pany/v3-admin-vite/graphs/contributors">
<img src="https://contrib.rocks/image?repo=un-pany/v3-admin-vite" alt="contributors">
</a>
## License
[MIT](./LICENSE) License © 2022-PRESENT [pany](https://github.com/pany-ang)
+238
View File
@@ -0,0 +1,238 @@
<div align="center">
<img alt="logo" width="120" height="120" src="./src/common/assets/images/layouts/logo.png">
<h1>V3 Admin Vite - AI Friendly</h1>
</div>
[![github release](https://img.shields.io/github/v/release/un-pany/v3-admin-vite?style=flat)](https://github.com/un-pany/v3-admin-vite/releases)
[![github stars](https://img.shields.io/github/stars/un-pany/v3-admin-vite?style=flat)](https://github.com/un-pany/v3-admin-vite/stargazers)
[![gitee stars](https://gitee.com/un-pany/v3-admin-vite/badge/star.svg)](https://gitee.com/un-pany/v3-admin-vite/stargazers)
[![atomgit stars](https://atomgit.com/un-pany/v3-admin-vite/star/badge.svg)](https://atomgit.com/un-pany/v3-admin-vite/stargazers)
<b><a href="./README.md">English</a> | 中文</b>
## 简介 <a href="https://juejin.cn/post/7660780385584316454"><img src="https://img.shields.io/badge/AI-Coding-black" alt="AI Coding"></a>
V3 Admin Vite 是一个 AI 友好的后台管理系统模板,基于 Vue3、Vite、TypeScript、Element Plus 等主流技术,并且具备优秀的 Vibe Coding 体验!
_支持 Claude Code、CodeX、Cursor、Trae、Antigravity 等主流 AI Coding 工具_
## 通知
> [!IMPORTANT]
> 欢迎体验全新的 5.2 版本,它将带来不一样的 AI Coding 体验!同时还支持了大家期待已久的后端控制动态路由权限方案~
> [!WARNING]
> 4.x 版本如果没有严重的 BUG 将不再维护![点击切换到 4.x 分支](https://github.com/un-pany/v3-admin-vite/tree/4.x)
> [!TIP]
> 正式推出付费服务,如果不想自己动手,但想移除 TS 或其他模块?试试懒人套餐![点击看看](https://github.com/un-pany/v3-admin-vite/issues/225)
> [!NOTE]
> 如果你有移动端 H5 需求,试试新的开源模板 [MobVue](https://github.com/un-pany/mobvue)
## 使用
<details>
<summary>推荐环境</summary>
<br>
- 新版 `Visual Studio Code` 或者 AI IDE `Cursor` 与 `Trae`
- 安装 `.vscode/extensions.json` 文件中推荐的插件
- `node` 20.19+ 或 22.12+
- `pnpm` 10+
</details>
<details>
<summary>本地开发</summary>
<br>
```bash
# 克隆项目
git clone https://github.com/un-pany/v3-admin-vite.git
# 进入项目目录
cd v3-admin-vite
# 安装依赖
pnpm i
# 启动服务
pnpm dev
```
</details>
<details>
<summary>打包构建</summary>
<br>
```bash
# 打包构建预发布环境
pnpm build:staging
# 打包构建生产环境
pnpm build
```
</details>
<details>
<summary>本地预览</summary>
<br>
```bash
# 先执行打包构建命令生成 dist 目录后再执行以下预览命令
pnpm preview
```
</details>
<details>
<summary>代码检查</summary>
<br>
```bash
# 代码校验与格式化
pnpm lint
# 单元测试
pnpm test
```
</details>
<details>
<summary>代码提交规范</summary>
<br>
`feat` 新功能
`fix` 修复错误
`perf` 性能优化
`refactor` 重构代码
`docs` 文档和注释
`types` 类型相关
`test` 单测相关
`ci` 持续集成、工作流
`revert` 撤销更改
`chore` 琐事(更新依赖、修改配置等)
</details>
<details>
<summary>安装推荐的 Skills</summary>
<br>
```bash
npx skills experimental_install
```
</details>
## 链接
**在线预览**:[github-pages](https://un-pany.github.io/v3-admin-vite)
**中文文档**:[链接](https://juejin.cn/post/7445151895121543209)
**零基础教程**:[链接](https://juejin.cn/column/7207659644487139387)
**移动端 H5**:[mobvue](https://github.com/un-pany/mobvue)
**Electron 桌面版**:[v3-electron-vite](https://github.com/un-pany/v3-electron-vite)
**国内仓库**:[gitee](https://gitee.com/un-pany/v3-admin-vite)、[atomgit](https://atomgit.com/un-pany/v3-admin-vite)
**可有可无的群**:[查看进群方式](https://github.com/un-pany/v3-admin-vite/issues/191)
**捐赠**:[请作者喝咖啡](https://github.com/un-pany/v3-admin-vite/issues/69)
**发行版 & 更新日志**:[releases](https://github.com/un-pany/v3-admin-vite/releases)
## 特性
**结构精简**:没有复杂的封装,没有复杂的类型体操,刚好够用
**详细的注释**:各个配置项都写有尽可能详细的注释
**最新的依赖**:及时更新所有三方依赖至最新版
**有一点规范**:代码风格统一、命名风格统一、注释风格统一
## 内置功能
**用户管理**:登录、登出演示
**权限管理**:页面级权限(后端接口控制动态路由)、按钮级权限(权限指令、权限函数)、路由守卫
**多环境**:开发环境(development)、预发布环境(staging)、生产环境(production)
**多主题**:普通、黑暗、深蓝, 三种主题模式
**多布局**:左侧、顶部、混合, 三种布局模式
**首页**:根据不同用户显示不同的 Dashboard 页面
**错误页**:403、404
**兼容移动端**:布局兼容移动端页面分辨率
**其他**:SVG 雪碧图、动态侧边栏、动态面包屑、标签页快捷导航、内容区放大与全屏、组合式函数
## 技术栈
**Vue3**:采用 Vue3 + script setup 最新的 Vue3 组合式 API
**Element Plus**:Element UI 的 Vue3 版本
**Pinia**:传说中的 Vuex5
**Vite**:真的很快
**Vue Router**:路由路由
**TypeScript**:JavaScript 语言的超集
**pnpm**:更快速的,节省磁盘空间的包管理工具
**Scss**:和 Element Plus 保持一致
**CSS 变量**:主要控制项目的布局和颜色
**ESLint**:代码校验与格式化
**Axios**:发送网络请求(已封装好)
**UnoCSS**:具有高性能且极具灵活性的即时原子化 CSS 引擎
## 项目预览图
![preview](./src/common/assets/images/docs/preview.png)
## 贡献者
在此感谢所有的贡献者!
<a href="https://github.com/un-pany/v3-admin-vite/graphs/contributors">
<img src="https://contrib.rocks/image?repo=un-pany/v3-admin-vite" alt="contributors">
</a>
## License
[MIT](./LICENSE) License © 2022-PRESENT [pany](https://github.com/pany-ang)
+43
View File
@@ -0,0 +1,43 @@
import antfu from "@antfu/eslint-config"
// 更多自定义配置可查阅仓库:https://github.com/antfu/eslint-config
export default antfu(
{
// 使用外部格式化程序格式化 css、html、markdown 等文件
formatters: true,
// 启用样式规则
stylistic: {
// 缩进级别
indent: 2,
// 引号风格 'single' | 'double'
quotes: "double",
// 是否启用分号
semi: false
},
// 忽略文件
ignores: ["*/skills"]
},
{
// 对所有文件都生效的规则
rules: {
// vue
"vue/block-order": ["error", { order: ["script", "template", "style"] }],
"vue/attributes-order": "off",
// ts
"ts/no-use-before-define": "off",
// node
"node/prefer-global/process": "off",
// style
"style/comma-dangle": ["error", "never"],
"style/brace-style": ["error", "1tbs"],
// regexp
"regexp/no-unused-capturing-group": "off",
// other
"no-console": "off",
"no-debugger": "off",
"symbol-description": "off",
"antfu/if-newline": "off",
"unicorn/no-instanceof-builtins": "off"
}
}
)
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/favicon.ico" />
<link rel="stylesheet" href="/app-loading.css" />
<title>%VITE_APP_TITLE%</title>
<script src="/detect-ie.js" defer></script>
</head>
<body>
<div id="app">
<div id="app-loading"></div>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+62
View File
@@ -0,0 +1,62 @@
{
"name": "v3-admin-vite",
"type": "module",
"version": "5.2.0",
"description": "AI-friendly admin template, built with Vue3, Vite, TypeScript, Element Plus, and more",
"author": "pany <939630029@qq.com> (https://github.com/pany-ang)",
"repository": "https://github.com/un-pany/v3-admin-vite",
"scripts": {
"dev": "vite",
"build:staging": "vue-tsc && vite build --mode staging",
"build": "vue-tsc && vite build",
"preview": "vite preview",
"lint": "eslint --fix",
"prepare": "husky",
"test": "vitest"
},
"dependencies": {
"@element-plus/icons-vue": "2.3.2",
"axios": "1.18.1",
"dayjs": "1.11.21",
"element-plus": "2.14.2",
"lodash-es": "4.18.1",
"mitt": "3.0.1",
"normalize.css": "8.0.1",
"nprogress": "0.2.0",
"path-browserify": "1.0.1",
"path-to-regexp": "8.4.2",
"pinia": "3.0.4",
"screenfull": "6.0.2",
"vue": "3.5.39",
"vue-router": "5.1.0",
"vxe-table": "4.6.25"
},
"devDependencies": {
"@antfu/eslint-config": "9.1.0",
"@types/lodash-es": "4.17.12",
"@types/node": "26.1.1",
"@types/nprogress": "0.2.3",
"@types/path-browserify": "1.0.3",
"@vitejs/plugin-vue": "6.0.7",
"@vue/test-utils": "2.4.11",
"eslint": "10.6.0",
"eslint-plugin-format": "2.0.1",
"happy-dom": "20.10.6",
"husky": "9.1.7",
"lint-staged": "17.0.8",
"sass-embedded": "1.78.0",
"typescript": "5.9.3",
"unocss": "66.7.5",
"unplugin-auto-import": "21.0.0",
"unplugin-svg-component": "0.12.6",
"unplugin-vue-components": "32.1.0",
"vite": "7.3.6",
"vite-plugin-mcp": "0.3.2",
"vite-svg-loader": "5.1.1",
"vitest": "4.1.10",
"vue-tsc": "3.3.7"
},
"lint-staged": {
"*": "eslint --fix"
}
}
+8706
View File
File diff suppressed because it is too large Load Diff
+45
View File
@@ -0,0 +1,45 @@
/* 白屏阶段会执行的 CSS 加载动画 */
#app-loading {
position: relative;
top: 45vh;
margin: 0 auto;
color: #409eff;
font-size: 12px;
}
#app-loading,
#app-loading::before,
#app-loading::after {
width: 2em;
height: 2em;
border-radius: 50%;
animation: 2s ease-in-out infinite app-loading-animation;
}
#app-loading::before,
#app-loading::after {
content: "";
position: absolute;
}
#app-loading::before {
left: -4em;
animation-delay: -0.2s;
}
#app-loading::after {
left: 4em;
animation-delay: 0.2s;
}
@keyframes app-loading-animation {
0%,
80%,
100% {
box-shadow: 0 2em 0 -2em;
}
40% {
box-shadow: 0 2em 0 0;
}
}
+4
View File
@@ -0,0 +1,4 @@
// Tip: Simple judgments may not fully cover
if (/MSIE\s|Trident\//.test(navigator.userAgent)) {
document.body.innerHTML = "<strong>Sorry, this browser is currently not supported. We recommend using the latest version of a modern browser. For example, Chrome/Firefox/Edge.</strong>"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

+59
View File
@@ -0,0 +1,59 @@
{
"version": 1,
"skills": {
"pinia": {
"source": "antfu/skills",
"sourceType": "github",
"skillPath": "skills/pinia/SKILL.md",
"computedHash": "897b6b0982956f41f5cc85128ca638b625e1b3c8bc619e303cd1e7661c95b7de"
},
"pnpm": {
"source": "antfu/skills",
"sourceType": "github",
"skillPath": "skills/pnpm/SKILL.md",
"computedHash": "2ec08c851c6f901d4ed72c21b826416ed074a487db65662b36249418afd728bf"
},
"unocss": {
"source": "antfu/skills",
"sourceType": "github",
"skillPath": "skills/unocss/SKILL.md",
"computedHash": "4b7a9f7b89994d5aef70b9d55cb7f65d6e80cea437d5786cd33bb5cf7231c7c4"
},
"vite": {
"source": "antfu/skills",
"sourceType": "github",
"skillPath": "skills/vite/SKILL.md",
"computedHash": "867ff66238f3152e9c494339ad08dc432dd0df5bcf5cc7a00b61a72b580eb908"
},
"vitest": {
"source": "antfu/skills",
"sourceType": "github",
"skillPath": "skills/vitest/SKILL.md",
"computedHash": "943d436114c677802426741980146c07560bf375297666b03062444f4b98a785"
},
"vue": {
"source": "antfu/skills",
"sourceType": "github",
"skillPath": "skills/vue/SKILL.md",
"computedHash": "9c241141e07e836e4f0537c63e8f929fba3767c2997250be38e699f19b75e3f2"
},
"vue-best-practices": {
"source": "antfu/skills",
"sourceType": "github",
"skillPath": "skills/vue-best-practices/SKILL.md",
"computedHash": "d7d22c8cb343583c3904692c4d1d7b50382945e433e4f6e053f4aabb9846cbc3"
},
"vue-router-best-practices": {
"source": "antfu/skills",
"sourceType": "github",
"skillPath": "skills/vue-router-best-practices/SKILL.md",
"computedHash": "e27384f4e6c8c70a612e76b74e4387efb8e291a6a1e3aa14a69102a4ce4b4654"
},
"vue-testing-best-practices": {
"source": "antfu/skills",
"sourceType": "github",
"skillPath": "skills/vue-testing-best-practices/SKILL.md",
"computedHash": "18c7d8f42f350f927e37de055e34c97b8cfb9f79c12cf942f7f3d2a0821057b5"
}
}
}
+32
View File
@@ -0,0 +1,32 @@
<script lang="ts" setup>
import { useGreyAndColorWeakness } from "@@/composables/useGreyAndColorWeakness"
import { usePany } from "@@/composables/usePany"
import { useTheme } from "@@/composables/useTheme"
import zhCn from "element-plus/es/locale/lang/zh-cn" // Element Plus 中文包
const { initTheme } = useTheme()
const { initGreyAndColorWeakness } = useGreyAndColorWeakness()
const { initStarNotification, initStoreNotification, initMobileNotification } = usePany()
// 初始化主题
initTheme()
// 初始化灰色模式和色弱模式
initGreyAndColorWeakness()
// #region 初始化通知
initStarNotification()
initStoreNotification()
initMobileNotification()
// #endregion
</script>
<template>
<el-config-provider :locale="zhCn">
<router-view />
</el-config-provider>
</template>
+37
View File
@@ -0,0 +1,37 @@
import type * as Tables from "./type"
import { request } from "@/http/axios"
/** 增 */
export function createTableDataApi(data: Tables.CreateOrUpdateTableRequestData) {
return request({
url: "tables",
method: "post",
data
})
}
/** 删 */
export function deleteTableDataApi(id: number) {
return request({
url: `tables/${id}`,
method: "delete"
})
}
/** 改 */
export function updateTableDataApi(data: Tables.CreateOrUpdateTableRequestData) {
return request({
url: "tables",
method: "put",
data
})
}
/** 查 */
export function getTableDataApi(params: Tables.TableRequestData) {
return request<Tables.TableResponseData>({
url: "tables",
method: "get",
params
})
}
+31
View File
@@ -0,0 +1,31 @@
export interface CreateOrUpdateTableRequestData {
id?: number
username: string
password?: string
}
export interface TableRequestData {
/** 当前页码 */
currentPage: number
/** 查询条数 */
size: number
/** 查询参数:用户名 */
username?: string
/** 查询参数:手机号 */
phone?: string
}
export interface TableData {
createTime: string
email: string
id: number
phone: string
roles: string
status: boolean
username: string
}
export type TableResponseData = ApiResponseData<{
list: TableData[]
total: number
}>
+10
View File
@@ -0,0 +1,10 @@
import type * as Users from "./type"
import { request } from "@/http/axios"
/** 获取当前登录用户详情 */
export function getCurrentUserApi() {
return request<Users.CurrentUserResponseData>({
url: "users/me",
method: "get"
})
}
+5
View File
@@ -0,0 +1,5 @@
export type CurrentUserResponseData = ApiResponseData<{
username: string
roles: string[]
permissions?: string[]
}>
+1
View File
@@ -0,0 +1 @@
<svg t="1651118937898" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8601" width="200" height="200"><path d="M924.8 385.6c-22.6-53.4-54.9-101.3-96-142.4-41.1-41.1-89-73.4-142.4-96C631.1 123.8 572.5 112 512 112s-119.1 11.8-174.4 35.2c-53.4 22.6-101.3 54.9-142.4 96-41.1 41.1-73.4 89-96 142.4C75.8 440.9 64 499.5 64 560c0 132.7 58.3 257.7 159.9 343.1l1.7 1.4c5.8 4.8 13.1 7.5 20.6 7.5h531.7c7.5 0 14.8-2.7 20.6-7.5l1.7-1.4C901.7 817.7 960 692.7 960 560c0-60.5-11.9-119.1-35.2-174.4zM761.4 836H262.6C184.5 765.5 140 665.6 140 560c0-99.4 38.7-192.8 109-263 70.3-70.3 163.7-109 263-109 99.4 0 192.8 38.7 263 109 70.3 70.3 109 163.7 109 263 0 105.6-44.5 205.5-122.6 276z" p-id="8602"></path><path d="M623.5 421.5c-3.1-3.1-8.2-3.1-11.3 0L527.7 506c-18.7-5-39.4-0.2-54.1 14.5-21.9 21.9-21.9 57.3 0 79.2 21.9 21.9 57.3 21.9 79.2 0 14.7-14.7 19.5-35.4 14.5-54.1l84.5-84.5c3.1-3.1 3.1-8.2 0-11.3l-28.3-28.3zM490 320h44c4.4 0 8-3.6 8-8v-80c0-4.4-3.6-8-8-8h-44c-4.4 0-8 3.6-8 8v80c0 4.4 3.6 8 8 8zM750 538v44c0 4.4 3.6 8 8 8h80c4.4 0 8-3.6 8-8v-44c0-4.4-3.6-8-8-8h-80c-4.4 0-8 3.6-8 8zM762.7 340.8l-31.1-31.1c-3.1-3.1-8.2-3.1-11.3 0l-56.6 56.6c-3.1 3.1-3.1 8.2 0 11.3l31.1 31.1c3.1 3.1 8.2 3.1 11.3 0l56.6-56.6c3.1-3.1 3.1-8.2 0-11.3zM304.1 309.7c-3.1-3.1-8.2-3.1-11.3 0l-31.1 31.1c-3.1 3.1-3.1 8.2 0 11.3l56.6 56.6c3.1 3.1 8.2 3.1 11.3 0l31.1-31.1c3.1-3.1 3.1-8.2 0-11.3l-56.6-56.6zM262 530h-80c-4.4 0-8 3.6-8 8v44c0 4.4 3.6 8 8 8h80c4.4 0 8-3.6 8-8v-44c0-4.4-3.6-8-8-8z" p-id="8603"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1 @@
<svg t="1661153147729" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3352" width="200" height="200"><path d="M704 864v-96c0-54.4 41.6-96 96-96h96c19.2 0 32-12.8 32-32s-12.8-32-32-32h-96c-89.6 0-160 70.4-160 160v96c0 19.2 12.8 32 32 32s32-12.8 32-32z m-64-704v96c0 89.6 70.4 160 160 160h96c19.2 0 32-12.8 32-32s-12.8-32-32-32h-96c-54.4 0-96-41.6-96-96v-96c0-19.2-12.8-32-32-32s-32 12.8-32 32z m-256 704v-96c0-89.6-70.4-160-160-160h-96c-19.2 0-32 12.8-32 32s12.8 32 32 32h96c54.4 0 96 41.6 96 96v96c0 19.2 12.8 32 32 32s32-12.8 32-32z m-64-704v96c0 54.4-41.6 96-96 96h-96c-19.2 0-32 12.8-32 32s12.8 32 32 32h96c89.6 0 160-70.4 160-160v-96c0-19.2-12.8-32-32-32s-32 12.8-32 32z" p-id="3353"></path></svg>

After

Width:  |  Height:  |  Size: 747 B

+1
View File
@@ -0,0 +1 @@
<svg t="1661151768669" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3212" width="200" height="200"><path d="M192 384v-96c0-54.4 41.6-96 96-96h96c19.2 0 32-12.8 32-32s-12.8-32-32-32h-96c-89.6 0-160 70.4-160 160v96c0 19.2 12.8 32 32 32s32-12.8 32-32z m-64 256v96c0 89.6 70.4 160 160 160h96c19.2 0 32-12.8 32-32s-12.8-32-32-32h-96c-54.4 0-96-41.6-96-96v-96c0-19.2-12.8-32-32-32s-32 12.8-32 32z m768-256v-96c0-89.6-70.4-160-160-160h-96c-19.2 0-32 12.8-32 32s12.8 32 32 32h96c54.4 0 96 41.6 96 96v96c0 19.2 12.8 32 32 32s32-12.8 32-32z m-64 256v96c0 54.4-41.6 96-96 96h-96c-19.2 0-32 12.8-32 32s12.8 32 32 32h96c89.6 0 160-70.4 160-160v-96c0-19.2-12.8-32-32-32s-32 12.8-32 32z" p-id="3213"></path></svg>

After

Width:  |  Height:  |  Size: 746 B

@@ -0,0 +1 @@
<svg width="15" height="15" aria-label="Arrow down" role="img"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2"><path d="M7.5 3.5v8M10.5 8.5l-3 3-3-3"></path></g></svg>

After

Width:  |  Height:  |  Size: 223 B

@@ -0,0 +1 @@
<svg width="15" height="15" aria-label="Enter key" role="img"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2"><path d="M12 3.53088v3c0 1-1 2-2 2H4M7 11.53088l-3-3 3-3"></path></g></svg>

After

Width:  |  Height:  |  Size: 241 B

+1
View File
@@ -0,0 +1 @@
<svg width="15" height="15" aria-label="Escape key" role="img"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2"><path d="M13.6167 8.936c-.1065.3583-.6883.962-1.4875.962-.7993 0-1.653-.9165-1.653-2.1258v-.5678c0-1.2548.7896-2.1016 1.653-2.1016.8634 0 1.3601.4778 1.4875 1.0724M9 6c-.1352-.4735-.7506-.9219-1.46-.8972-.7092.0246-1.344.57-1.344 1.2166s.4198.8812 1.3445.9805C8.465 7.3992 8.968 7.9337 9 8.5c.032.5663-.454 1.398-1.4595 1.398C6.6593 9.898 6 9 5.963 8.4851m-1.4748.5368c-.2635.5941-.8099.876-1.5443.876s-1.7073-.6248-1.7073-2.204v-.4603c0-1.0416.721-2.131 1.7073-2.131.9864 0 1.6425 1.031 1.5443 2.2492h-2.956"></path></g></svg>

After

Width:  |  Height:  |  Size: 694 B

+1
View File
@@ -0,0 +1 @@
<svg width="15" height="15" aria-label="Arrow up" role="img"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2"><path d="M7.5 11.5v-8M10.5 6.5l-3-3-3 3"></path></g></svg>

After

Width:  |  Height:  |  Size: 223 B

@@ -0,0 +1,11 @@
## 目录说明
- `common/assets/icons/preserve-color` 目录下存放带颜色的 svg icon
- `common/assets/icons` 目录存放的 svg icon 会被插件重写 `fill` 和 `stroke` 属性,使得图片自带的颜色丢失,从而继承父元素的颜色
## 使用说明
`common/assets/icons/preserve-color` 目录下需要添加 `preserve-color/` 前缀,像这样: `<SvgIcon name="preserve-color/name" />`
`common/assets/icons` 目录下则不需要,像这样: `<SvgIcon name="name" />`
+1
View File
@@ -0,0 +1 @@
<svg t="1691398959507" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2431" width="200" height="200"><path d="M862.609 816.955L726.44 680.785l-0.059-0.056a358.907 358.907 0 0 0 56.43-91.927c18.824-44.507 28.369-91.767 28.369-140.467 0-48.701-9.545-95.96-28.369-140.467-18.176-42.973-44.19-81.56-77.319-114.689-33.13-33.129-71.717-59.144-114.69-77.32-44.507-18.825-91.767-28.37-140.467-28.37-48.701 0-95.96 9.545-140.467 28.37-42.973 18.176-81.56 44.19-114.689 77.32-33.13 33.129-59.144 71.717-77.32 114.689-18.825 44.507-28.37 91.767-28.37 140.467 0 48.7 9.545 95.96 28.37 140.467 18.176 42.974 44.19 81.561 77.32 114.69 33.129 33.129 71.717 59.144 114.689 77.319 44.507 18.824 91.767 28.369 140.467 28.369 48.7 0 95.96-9.545 140.467-28.369 32.78-13.864 62.997-32.303 90.197-54.968 0.063 0.064 0.122 0.132 0.186 0.195l136.169 136.17c6.25 6.25 14.438 9.373 22.628 9.373 8.188 0 16.38-3.125 22.627-9.372 12.496-12.496 12.496-32.758 0-45.254z m-412.274-69.466c-79.907 0-155.031-31.118-211.534-87.62-56.503-56.503-87.62-131.627-87.62-211.534s31.117-155.031 87.62-211.534c56.502-56.503 131.626-87.62 211.534-87.62s155.031 31.117 211.534 87.62c56.502 56.502 87.62 131.626 87.62 211.534s-31.118 155.031-87.62 211.534c-56.503 56.502-131.627 87.62-211.534 87.62z" p-id="2432"></path></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 492 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

+90
View File
@@ -0,0 +1,90 @@
/**
* @description dark-blue 主题模式下的 Element Plus CSS 变量
* @description 在此查阅所有可自定义的变量:https://github.com/element-plus/element-plus/blob/dev/packages/theme-chalk/src/common/var.scss
* @description 也可以打开浏览器控制台选择元素,查看要覆盖的变量名
*/
/* 基础颜色 */
html.dark-blue {
/* color-primary */
--el-color-primary: #00bb99;
--el-color-primary-light-3: #00bb99b3;
--el-color-primary-light-5: #00bb9980;
--el-color-primary-light-7: #00bb994d;
--el-color-primary-light-8: #00bb9933;
--el-color-primary-light-9: #00bb991a;
--el-color-primary-dark-2: #00bb99;
/* color-success */
--el-color-success: #67c23a;
--el-color-success-light-3: #67c23ab3;
--el-color-success-light-5: #67c23a80;
--el-color-success-light-7: #67c23a4d;
--el-color-success-light-8: #67c23a33;
--el-color-success-light-9: #67c23a1a;
--el-color-success-dark-2: #67c23a;
/* color-warning */
--el-color-warning: #e6a23c;
--el-color-warning-light-3: #e6a23cb3;
--el-color-warning-light-5: #e6a23c80;
--el-color-warning-light-7: #e6a23c4d;
--el-color-warning-light-8: #e6a23c33;
--el-color-warning-light-9: #e6a23c1a;
--el-color-warning-dark-2: #e6a23c;
/* color-danger */
--el-color-danger: #f56c6c;
--el-color-danger-light-3: #f56c6cb3;
--el-color-danger-light-5: #f56c6c80;
--el-color-danger-light-7: #f56c6c4d;
--el-color-danger-light-8: #f56c6c33;
--el-color-danger-light-9: #f56c6c1a;
--el-color-danger-dark-2: #f56c6c;
/* color-error */
--el-color-error: #f56c6c;
--el-color-error-light-3: #f56c6cb3;
--el-color-error-light-5: #f56c6c80;
--el-color-error-light-7: #f56c6c4d;
--el-color-error-light-8: #f56c6c33;
--el-color-error-light-9: #f56c6c1a;
--el-color-error-dark-2: #f56c6c;
/* color-info */
--el-color-info: #909399;
--el-color-info-light-3: #909399b3;
--el-color-info-light-5: #90939980;
--el-color-info-light-7: #9093994d;
--el-color-info-light-8: #90939933;
--el-color-info-light-9: #9093991a;
--el-color-info-dark-2: #909399;
/* text-color */
--el-text-color-primary: #e5eaf3;
--el-text-color-regular: #cfd3dc;
--el-text-color-secondary: #a3a6ad;
--el-text-color-placeholder: #8d9095;
--el-text-color-disabled: #6c6e72;
/* border-color */
--el-border-color-darker: #003380;
--el-border-color-dark: #003380;
--el-border-color: #003380;
--el-border-color-light: #003380;
--el-border-color-lighter: #003380;
--el-border-color-extra-light: #003380;
/* fill-color */
--el-fill-color-darker: #002b6b;
--el-fill-color-dark: #002b6b;
--el-fill-color: #002b6b;
--el-fill-color-light: #002359;
--el-fill-color-lighter: #002359;
--el-fill-color-blank: #001b44;
--el-fill-color-extra-light: #001b44;
/* bg-color */
--el-bg-color-page: #001535;
--el-bg-color: #001b44;
--el-bg-color-overlay: #002359;
/* mask-color */
--el-mask-color: rgba(0, 0, 0, 0.5);
--el-mask-color-extra-light: rgba(0, 0, 0, 0.3);
}
/* button */
html.dark-blue .el-button {
--el-button-disabled-text-color: rgba(255, 255, 255, 0.5);
}
@@ -0,0 +1,15 @@
// 自定义 Element Plus 样式
// 分页
.el-pagination {
// 参考 Bootstrap 的响应式设计 WIDTH = 768
@media screen and (max-width: 768px) {
.el-pagination__total,
.el-pagination__sizes,
.el-pagination__jump,
.btn-prev,
.btn-next {
display: none;
}
}
}
+67
View File
@@ -0,0 +1,67 @@
// 全局 CSS 变量
@import "./variables.css";
// Transition
@import "./transition.scss";
// Element Plus
@import "./element-plus.css";
@import "./element-plus.scss";
// Vxe Table
@import "./vxe-table.css";
@import "./vxe-table.scss";
// 注册多主题
@import "./theme/register.scss";
// Mixins
@import "./mixins.scss";
// View Transition
@import "./view-transition.scss";
// 业务页面几乎都应该在根元素上挂载 class="app-container",以保持页面美观
.app-container {
padding: 20px;
}
html {
height: 100%;
// 灰色模式
&.grey-mode {
filter: grayscale(1);
}
// 色弱模式
&.color-weakness {
filter: invert(0.8);
}
}
body {
height: 100%;
color: var(--v3-body-text-color);
background-color: var(--v3-body-bg-color);
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
font-family:
Inter, "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "微软雅黑", Arial,
sans-serif;
@extend %scrollbar;
}
#app {
height: 100%;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
a,
a:focus,
a:hover {
color: inherit;
outline: none;
text-decoration: none;
}
div:focus {
outline: none;
}
+42
View File
@@ -0,0 +1,42 @@
// 清除浮动
%clearfix {
&::after {
content: "";
display: table;
clear: both;
}
}
// 美化原生滚动条
%scrollbar {
// 整个滚动条
&::-webkit-scrollbar {
width: 8px;
height: 8px;
}
// 滚动条上的滚动滑块
&::-webkit-scrollbar-thumb {
border-radius: 4px;
background-color: #90939955;
}
&::-webkit-scrollbar-thumb:hover {
background-color: #90939977;
}
&::-webkit-scrollbar-thumb:active {
background-color: #90939999;
}
// 当同时有垂直滚动条和水平滚动条时交汇的部分
&::-webkit-scrollbar-corner {
background-color: transparent;
}
}
// 文本溢出时显示省略号
%ellipsis {
// 隐藏溢出的文本
overflow: hidden;
// 防止文本换行
white-space: nowrap;
// 文本内容溢出容器时,文本末尾显示省略号
text-overflow: ellipsis;
}
@@ -0,0 +1,29 @@
// Element Plus 相关
// 侧边栏的 item 的 popper
.el-popper {
.el-menu {
background-color: var(--el-bg-color);
.el-menu-item {
background-color: var(--el-bg-color);
&.is-active,
&:hover {
background-color: var(--el-bg-color-overlay);
color: #ffffff;
}
}
.el-sub-menu__title {
background-color: var(--el-bg-color);
}
.el-sub-menu {
&.is-active {
> .el-sub-menu__title {
color: #ffffff;
}
}
}
}
.el-menu--horizontal {
border: none;
}
}
@@ -0,0 +1,4 @@
.#{$theme-name} {
@import "./layouts.scss";
@import "./element-plus.scss";
}
@@ -0,0 +1,34 @@
// Layout 相关
.app-wrapper {
// 侧边栏
.sidebar-container {
background-color: var(--el-bg-color);
.el-menu {
background-color: var(--el-bg-color);
.el-menu-item {
background-color: var(--el-bg-color);
&.is-active,
&:hover {
background-color: var(--el-bg-color-overlay);
color: #ffffff;
}
}
}
.el-sub-menu__title {
background-color: var(--el-bg-color);
}
.el-sub-menu {
&.is-active {
> .el-sub-menu__title {
color: #ffffff;
}
}
}
}
}
// 右侧设置面板
.handle-button {
background-color: lighten($theme-bg-color, 20%) !important;
}
@@ -0,0 +1,2 @@
@import "./variables.scss";
@import "../core/index.scss";
@@ -0,0 +1,6 @@
// dark-blue 主题下的变量
// 主题名称
$theme-name: "dark-blue";
// 主题背景颜色
$theme-bg-color: #001b44;
@@ -0,0 +1,2 @@
@import "./variables.scss";
@import "../core/index.scss";
@@ -0,0 +1,6 @@
// dark 主题下的变量
// 主题名称
$theme-name: "dark";
// 主题背景颜色
$theme-bg-color: #141414;
@@ -0,0 +1,3 @@
// 注册多主题
@import "./dark/index.scss";
@import "./dark-blue/index.scss";
+25
View File
@@ -0,0 +1,25 @@
// https://cn.vuejs.org/guide/built-ins/transition
// fade-transform
.fade-transform-leave-active,
.fade-transform-enter-active {
transition: all 0.5s;
}
.fade-transform-enter {
opacity: 0;
transform: translateX(-30px);
}
.fade-transform-leave-to {
opacity: 0;
transform: translateX(30px);
}
// layout-logo-fade
.layout-logo-fade-enter-active,
.layout-logo-fade-leave-active {
transition: opacity 1.5s;
}
.layout-logo-fade-enter-from,
.layout-logo-fade-leave-to {
opacity: 0;
}
+72
View File
@@ -0,0 +1,72 @@
/* 全局 CSS 变量,这种变量不仅可以在 CSS 和 SCSS 中使用,还可以导入到 JS 中使用 */
:root {
/* Body */
--v3-body-text-color: var(--el-text-color-primary);
--v3-body-bg-color: var(--el-bg-color-page);
/* Header 区域 = NavigationBar 组件 + TagsView 组件 */
--v3-header-height: calc(
var(--v3-navigationbar-height) + var(--v3-tagsview-height) + var(--v3-header-border-bottom-width)
);
--v3-header-bg-color: var(--el-bg-color);
--v3-header-box-shadow: var(--el-box-shadow-lighter);
--v3-header-border-bottom-width: 1px;
--v3-header-border-bottom: var(--v3-header-border-bottom-width) solid var(--el-fill-color);
/* NavigationBar 组件 */
--v3-navigationbar-height: 50px;
--v3-navigationbar-text-color: var(--el-text-color-regular);
/* Sidebar 组件(左侧模式全部生效、顶部模式全部不生效、混合模式非颜色部分生效) */
--v3-sidebar-width: 220px;
--v3-sidebar-hide-width: 58px;
--v3-sidebar-border-right: 1px solid var(--el-fill-color);
--v3-sidebar-menu-item-height: 60px;
--v3-sidebar-menu-tip-line-bg-color: var(--el-color-primary);
--v3-sidebar-menu-bg-color: #001428;
--v3-sidebar-menu-hover-bg-color: #409eff10;
--v3-sidebar-menu-text-color: #cfd3dc;
--v3-sidebar-menu-active-text-color: #ffffff;
/* TagsView 组件 */
--v3-tagsview-height: 34px;
--v3-tagsview-text-color: var(--el-text-color-regular);
--v3-tagsview-tag-active-text-color: #ffffff;
--v3-tagsview-tag-bg-color: var(--el-bg-color);
--v3-tagsview-tag-active-bg-color: var(--el-color-primary);
--v3-tagsview-tag-border-radius: 2px;
--v3-tagsview-tag-border-color: var(--el-border-color-lighter);
--v3-tagsview-tag-active-border-color: var(--el-color-primary);
--v3-tagsview-tag-icon-hover-bg-color: #00000030;
--v3-tagsview-tag-icon-hover-color: #ffffff;
--v3-tagsview-contextmenu-text-color: var(--el-text-color-regular);
--v3-tagsview-contextmenu-hover-text-color: var(--el-text-color-primary);
--v3-tagsview-contextmenu-bg-color: var(--el-bg-color-overlay);
--v3-tagsview-contextmenu-hover-bg-color: var(--el-fill-color);
--v3-tagsview-contextmenu-box-shadow: var(--el-box-shadow);
/* Hamburger 组件 */
--v3-hamburger-text-color: var(--el-text-color-primary);
/* RightPanel 组件 */
--v3-rightpanel-button-bg-color: #001428;
}
/* 内容区放大时,将不需要的组件隐藏 */
body.content-large {
/* Header 区域 = TagsView 组件 */
--v3-header-height: var(--v3-tagsview-height);
/* NavigationBar 组件 */
--v3-navigationbar-height: 0px;
/* Sidebar 组件 */
--v3-sidebar-width: 0px;
--v3-sidebar-hide-width: 0px;
}
/* 内容区全屏时,将不需要的组件隐藏 */
body.content-full {
/* Header 区域 */
--v3-header-height: 0px;
/* NavigationBar 组件 */
--v3-navigationbar-height: 0px;
/* Sidebar 组件 */
--v3-sidebar-width: 0px;
--v3-sidebar-hide-width: 0px;
/* TagsView 组件 */
--v3-tagsview-height: 0px;
}
@@ -0,0 +1,20 @@
// 控制切换主题时的动画效果(只在较新的浏览器上生效,例如 Chrome 111+)
::view-transition-old(root) {
animation: none;
mix-blend-mode: normal;
}
::view-transition-new(root) {
animation: 0.5s ease-in clip-animation;
mix-blend-mode: normal;
}
@keyframes clip-animation {
from {
clip-path: circle(0px at var(--v3-theme-x) var(--v3-theme-y));
}
to {
clip-path: circle(var(--v3-theme-r) at var(--v3-theme-x) var(--v3-theme-y));
}
}
+97
View File
@@ -0,0 +1,97 @@
/**
* @description 所有主题模式下的 Vxe Table CSS 变量
* @description 用 Element Plus 的 CSS 变量来覆写 Vxe Table 的 CSS 变量,目的是使 Vxe Table 支持多主题模式且样式统一
* @description 在此查阅所有可自定义的变量:https://github.com/x-extends/vxe-table/blob/master/styles/css-variable.scss
*/
:root {
/* color */
--vxe-font-color: var(--el-text-color-regular);
--vxe-primary-color: var(--el-color-primary);
--vxe-success-color: var(--el-color-success);
--vxe-info-color: var(--el-color-info);
--vxe-warning-color: var(--el-color-warning);
--vxe-danger-color: var(--el-color-danger);
--vxe-font-lighten-color: var(--el-text-color-primary);
--vxe-primary-lighten-color: var(--el-color-primary-light-3);
--vxe-success-lighten-color: var(--el-color-success-light-3);
--vxe-info-lighten-color: var(--el-color-info-light-3);
--vxe-warning-lighten-color: var(--el-color-warning-light-3);
--vxe-danger-lighten-color: var(--el-color-danger-light-3);
--vxe-font-darken-color: var(--el-text-color-secondary);
--vxe-primary-darken-color: var(--el-color-primary-dark-2);
--vxe-success-darken-color: var(--el-color-success-dark-2);
--vxe-info-darken-color: var(--el-color-info-dark-2);
--vxe-warning-darken-color: var(--el-color-warning-dark-2);
--vxe-danger-darken-color: var(--el-color-danger-dark-2);
--vxe-font-disabled-color: var(--el-text-color-disabled);
--vxe-primary-disabled-color: var(--el-color-primary-light-5);
--vxe-success-disabled-color: var(--el-color-success-light-5);
--vxe-info-disabled-color: var(--el-color-info-light-5);
--vxe-warning-disabled-color: var(--el-color-warning-light-5);
--vxe-danger-disabled-color: var(--el-color-danger-light-5);
/* input/radio/checkbox */
--vxe-input-border-color: var(--el-border-color);
--vxe-input-disabled-color: var(--el-text-color-disabled);
--vxe-input-disabled-background-color: var(--el-fill-color-light);
--vxe-input-placeholder-color: var(--el-text-color-placeholder);
/* popup */
--vxe-table-popup-border-color: var(--el-border-color);
/* table */
--vxe-table-header-font-color: var(--el-text-color-regular);
--vxe-table-footer-font-color: var(--el-text-color-regular);
--vxe-table-border-color: var(--el-border-color-lighter);
--vxe-table-header-background-color: var(--el-bg-color);
--vxe-table-body-background-color: var(--el-bg-color);
--vxe-table-footer-background-color: var(--el-bg-color);
--vxe-table-row-hover-background-color: var(--el-fill-color-light);
--vxe-table-row-current-background-color: var(--el-fill-color-light);
--vxe-table-row-hover-current-background-color: var(--el-fill-color-light);
--vxe-table-checkbox-range-background-color: var(--el-fill-color-light);
/* menu */
--vxe-table-menu-background-color: var(--el-bg-color-overlay);
/* loading */
--vxe-loading-color: var(--el-color-primary);
--vxe-loading-background-color: var(--el-mask-color);
/* validate */
--vxe-table-validate-error-color: var(--el-color-danger);
/* toolbar */
--vxe-toolbar-background-color: var(--el-bg-color);
--vxe-toolbar-custom-active-background-color: var(--el-bg-color-overlay);
--vxe-toolbar-panel-background-color: var(--el-bg-color-overlay);
/* pager */
--vxe-pager-background-color: var(--el-bg-color);
/* modal */
--vxe-modal-header-background-color: var(--el-bg-color);
--vxe-modal-body-background-color: var(--el-bg-color);
--vxe-modal-border-color: var(--el-border-color);
/* button */
--vxe-button-default-background-color: var(--el-bg-color-overlay);
/* input */
--vxe-input-background-color: var(--el-fill-color-blank);
--vxe-input-panel-background-color: var(--el-fill-color-blank);
/* form */
--vxe-form-background-color: var(--el-bg-color);
--vxe-form-validate-error-color: var(--el-color-danger);
/* select */
--vxe-select-option-hover-background-color: var(--el-bg-color-overlay);
--vxe-select-panel-background-color: var(--el-bg-color);
}
+38
View File
@@ -0,0 +1,38 @@
// 自定义 Vxe Table 样式
.vxe-grid {
// 表单
&--form-wrapper {
.vxe-form {
padding: 10px 20px;
margin-bottom: 20px;
}
}
// 工具栏
&--toolbar-wrapper {
.vxe-toolbar {
padding: 20px;
}
}
// 分页
&--pager-wrapper {
.vxe-pager {
height: 70px;
padding: 0 20px;
&--wrapper {
// 参考 Bootstrap 的响应式设计 WIDTH = 768
@media screen and (max-width: 768px) {
.vxe-pager--total,
.vxe-pager--sizes,
.vxe-pager--jump,
.vxe-pager--jump-prev,
.vxe-pager--jump-next {
display: none;
}
}
}
}
}
}
+60
View File
@@ -0,0 +1,60 @@
<script lang="ts" setup>
import type { NotifyItem } from "./type"
interface Props {
data: NotifyItem[]
}
const props = defineProps<Props>()
</script>
<template>
<el-empty v-if="props.data.length === 0" />
<el-card v-else v-for="(item, index) in props.data" :key="index" shadow="never" class="card-container">
<template #header>
<div class="card-header">
<div>
<span>
<span class="card-title">{{ item.title }}</span>
<el-tag v-if="item.extra" :type="item.status" effect="plain" size="small">{{ item.extra }}</el-tag>
</span>
<div class="card-time">
{{ item.datetime }}
</div>
</div>
<div v-if="item.avatar" class="card-avatar">
<img :src="item.avatar" width="34">
</div>
</div>
</template>
<div class="card-body">
{{ item.description ?? "No Data" }}
</div>
</el-card>
</template>
<style lang="scss" scoped>
.card-container {
margin-bottom: 10px;
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
.card-title {
font-weight: bold;
margin-right: 10px;
}
.card-time {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.card-avatar {
display: flex;
align-items: center;
}
}
.card-body {
font-size: 12px;
}
}
</style>
+58
View File
@@ -0,0 +1,58 @@
import type { NotifyItem } from "./type"
export const notifyData: NotifyItem[] = [
{
avatar: "https://gw.alipayobjects.com/zos/rmsportal/OKJXDXrmkNshAMvwtvhu.png",
title: "V3 Admin Vite 上线啦",
datetime: "四年前",
description: "一个 AI 友好的后台管理系统模板,基于 Vue3、Vite、TypeScript、Element Plus 等主流技术"
},
{
avatar: "https://gw.alipayobjects.com/zos/rmsportal/OKJXDXrmkNshAMvwtvhu.png",
title: "V3 Admin 上线啦",
datetime: "五年前",
description: "一个中后台管理系统基础解决方案,基于 Vue3、TypeScript、Element Plus、Pinia 和 Vue CLI 5.x"
}
]
export const messageData: NotifyItem[] = [
{
avatar: "https://gw.alipayobjects.com/zos/rmsportal/ThXAXghbEsBCCSDihZxY.png",
title: "来自楚门的世界",
description: "如果再也不能见到你,祝你早安、午安和晚安",
datetime: "1998-06-05"
},
{
avatar: "https://gw.alipayobjects.com/zos/rmsportal/ThXAXghbEsBCCSDihZxY.png",
title: "来自大话西游",
description: "如果非要在这份爱上加上一个期限,我希望是一万年",
datetime: "1995-02-04"
},
{
avatar: "https://gw.alipayobjects.com/zos/rmsportal/ThXAXghbEsBCCSDihZxY.png",
title: "来自龙猫",
description: "心存善意,定能途遇天使",
datetime: "1988-04-16"
}
]
export const todoData: NotifyItem[] = [
{
title: "任务名称",
description: "这家伙很懒,什么都没留下",
extra: "未开始",
status: "info"
},
{
title: "任务名称",
description: "这家伙很懒,什么都没留下",
extra: "进行中",
status: "primary"
},
{
title: "任务名称",
description: "这家伙很懒,什么都没留下",
extra: "已超时",
status: "danger"
}
]
+94
View File
@@ -0,0 +1,94 @@
<script lang="ts" setup>
import type { NotifyItem } from "./type"
import { Bell } from "@element-plus/icons-vue"
import { messageData, notifyData, todoData } from "./data"
import List from "./List.vue"
type TabName = "通知" | "消息" | "待办"
interface DataItem {
name: TabName
type: "primary" | "success" | "warning" | "danger" | "info"
list: NotifyItem[]
}
/** 角标当前值 */
const badgeValue = computed(() => data.value.reduce((sum, item) => sum + item.list.length, 0))
/** 角标最大值 */
const badgeMax = 99
/** 面板宽度 */
const popoverWidth = 350
/** 当前 Tab */
const activeName = ref<TabName>("通知")
/** 所有数据 */
const data = ref<DataItem[]>([
// 通知数据
{
name: "通知",
type: "primary",
list: notifyData
},
// 消息数据
{
name: "消息",
type: "danger",
list: messageData
},
// 待办数据
{
name: "待办",
type: "warning",
list: todoData
}
])
function handleHistory() {
ElMessage.success(`跳转到${activeName.value}历史页面`)
}
</script>
<template>
<div class="notify">
<el-popover placement="bottom" :width="popoverWidth" trigger="click">
<template #reference>
<el-badge :value="badgeValue" :max="badgeMax" :hidden="badgeValue === 0">
<el-tooltip effect="dark" content="消息通知" placement="bottom">
<el-icon :size="20">
<Bell />
</el-icon>
</el-tooltip>
</el-badge>
</template>
<template #default>
<el-tabs v-model="activeName" class="demo-tabs" stretch>
<el-tab-pane v-for="(item, index) in data" :key="index" :name="item.name">
<template #label>
{{ item.name }}
<el-badge :value="item.list.length" :max="badgeMax" :type="item.type" />
</template>
<el-scrollbar height="400px">
<List :data="item.list" />
</el-scrollbar>
</el-tab-pane>
</el-tabs>
<div class="notify-history">
<el-button link @click="handleHistory">
查看{{ activeName }}历史
</el-button>
</div>
</template>
</el-popover>
</div>
</template>
<style lang="scss" scoped>
.notify-history {
text-align: center;
padding-top: 12px;
border-top: 1px solid var(--el-border-color);
}
</style>
+8
View File
@@ -0,0 +1,8 @@
export interface NotifyItem {
avatar?: string
title: string
datetime?: string
description?: string
status?: "primary" | "success" | "info" | "warning" | "danger"
extra?: string
}
+111
View File
@@ -0,0 +1,111 @@
<script lang="ts" setup>
import screenfull from "screenfull"
interface Props {
/** 全屏的元素,默认是 html */
element?: string
/** 打开全屏提示语 */
openTips?: string
/** 关闭全屏提示语 */
exitTips?: string
/** 是否只针对内容区 */
content?: boolean
}
const { element = "html", openTips = "全屏", exitTips = "退出全屏", content = false } = defineProps<Props>()
const CONTENT_LARGE = "content-large"
const CONTENT_FULL = "content-full"
const classList = document.body.classList
// #region 全屏
const isEnabled = screenfull.isEnabled
const isFullscreen = ref<boolean>(false)
const fullscreenTips = computed(() => (isFullscreen.value ? exitTips : openTips))
const fullscreenSvgName = computed(() => (isFullscreen.value ? "fullscreen-exit" : "fullscreen"))
function handleFullscreenClick() {
const dom = document.querySelector(element) || undefined
isEnabled ? screenfull.toggle(dom) : ElMessage.warning("您的浏览器无法工作")
}
function handleFullscreenChange() {
isFullscreen.value = screenfull.isFullscreen
// 退出全屏时清除相关的 class
isFullscreen.value || classList.remove(CONTENT_LARGE, CONTENT_FULL)
}
watchEffect(() => {
if (isEnabled) {
// 挂载组件时自动执行
screenfull.on("change", handleFullscreenChange)
// 卸载组件时自动执行
onWatcherCleanup(() => {
screenfull.off("change", handleFullscreenChange)
})
}
})
// #endregion
// #region 内容区
const isContentLarge = ref<boolean>(false)
const contentLargeTips = computed(() => (isContentLarge.value ? "内容区复原" : "内容区放大"))
const contentLargeSvgName = computed(() => (isContentLarge.value ? "fullscreen-exit" : "fullscreen"))
function handleContentLargeClick() {
isContentLarge.value = !isContentLarge.value
// 内容区放大时,将不需要的组件隐藏
classList.toggle(CONTENT_LARGE, isContentLarge.value)
}
function handleContentFullClick() {
// 取消内容区放大
isContentLarge.value && handleContentLargeClick()
// 内容区全屏时,将不需要的组件隐藏
classList.add(CONTENT_FULL)
// 开启全屏
handleFullscreenClick()
}
// #endregion
</script>
<template>
<div>
<!-- 全屏 -->
<el-tooltip v-if="!content" effect="dark" :content="fullscreenTips" placement="bottom">
<SvgIcon :name="fullscreenSvgName" @click="handleFullscreenClick" class="svg-icon" />
</el-tooltip>
<!-- 内容区 -->
<el-dropdown v-else :disabled="isFullscreen">
<SvgIcon :name="contentLargeSvgName" class="svg-icon" />
<template #dropdown>
<el-dropdown-menu>
<!-- 内容区放大 -->
<el-dropdown-item @click="handleContentLargeClick">
{{ contentLargeTips }}
</el-dropdown-item>
<!-- 内容区全屏 -->
<el-dropdown-item @click="handleContentFullClick">
内容区全屏
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
<style lang="scss" scoped>
.svg-icon {
font-size: 20px;
&:focus {
outline: none;
}
}
</style>
@@ -0,0 +1,54 @@
<script lang="ts" setup>
import { useDevice } from "@@/composables/useDevice"
interface Props {
total: number
}
const props = defineProps<Props>()
const { isMobile } = useDevice()
</script>
<template>
<div class="search-footer">
<template v-if="!isMobile">
<span class="search-footer-item">
<SvgIcon name="keyboard-enter" class="svg-icon" />
<span>确认</span>
</span>
<span class="search-footer-item">
<SvgIcon name="keyboard-up" class="svg-icon" />
<SvgIcon name="keyboard-down" class="svg-icon" />
<span>切换</span>
</span>
<span class="search-footer-item">
<SvgIcon name="keyboard-esc" class="svg-icon" />
<span>关闭</span>
</span>
</template>
<span class="search-footer-total">共 {{ props.total }} 项</span>
</div>
</template>
<style lang="scss" scoped>
.search-footer {
display: flex;
color: var(--el-text-color-secondary);
font-size: 14px;
&-item {
display: flex;
align-items: center;
margin-right: 12px;
.svg-icon {
margin-right: 5px;
padding: 2px;
font-size: 20px;
background-color: var(--el-fill-color);
}
}
&-total {
margin: 0 0 0 auto;
}
}
</style>
+199
View File
@@ -0,0 +1,199 @@
<script lang="ts" setup>
import type { RouteRecordNameGeneric, RouteRecordRaw } from "vue-router"
import { useDevice } from "@@/composables/useDevice"
import { isExternal } from "@@/utils/validate"
import { cloneDeep, debounce } from "lodash-es"
import { usePermissionStore } from "@/pinia/stores/permission"
import Footer from "./Footer.vue"
import Result from "./Result.vue"
/** 控制 modal 显隐 */
const modelValue = defineModel<boolean>({ required: true })
const router = useRouter()
const { isMobile } = useDevice()
const inputRef = useTemplateRef("inputRef")
const scrollbarRef = useTemplateRef("scrollbarRef")
const resultRef = useTemplateRef("resultRef")
const keyword = ref<string>("")
const result = shallowRef<RouteRecordRaw[]>([])
const activeRouteName = ref<RouteRecordNameGeneric | undefined>(undefined)
/** 是否按下了上键或下键(用于解决和 mouseenter 事件的冲突) */
const isPressUpOrDown = ref<boolean>(false)
/** 控制搜索对话框宽度 */
const modalWidth = computed(() => (isMobile.value ? "80vw" : "40vw"))
/** 树形菜单 */
const menus = computed(() => cloneDeep(usePermissionStore().routes))
/** 搜索(防抖) */
const handleSearch = debounce(() => {
const flatMenus = flatTree(menus.value)
const _keywords = keyword.value.toLocaleLowerCase().trim()
result.value = flatMenus.filter(menu => keyword.value ? menu.meta?.title?.toLocaleLowerCase().includes(_keywords) : false)
// 默认选中搜索结果的第一项
const length = result.value?.length
activeRouteName.value = length > 0 ? result.value[0].name : undefined
}, 500)
/** 将树形菜单扁平化为一维数组,用于菜单搜索 */
function flatTree(arr: RouteRecordRaw[], result: RouteRecordRaw[] = []) {
arr.forEach((item) => {
result.push(item)
item.children && flatTree(item.children, result)
})
return result
}
/** 关闭搜索对话框 */
function handleClose() {
modelValue.value = false
// 延时处理防止用户看到重置数据的操作
setTimeout(() => {
keyword.value = ""
result.value = []
}, 200)
}
/** 根据下标位置进行滚动 */
function scrollTo(index: number) {
if (!resultRef.value) return
const scrollTop = resultRef.value.getScrollTop(index)
// 手动控制 el-scrollbar 滚动条滚动,设置滚动条到顶部的距离
scrollbarRef.value?.setScrollTop(scrollTop)
}
/** 键盘上键 */
function handleUp() {
isPressUpOrDown.value = true
const { length } = result.value
if (length === 0) return
// 获取该 name 在菜单中第一次出现的位置
const index = result.value.findIndex(item => item.name === activeRouteName.value)
// 如果已处在顶部
if (index === 0) {
const bottomName = result.value[length - 1].name
// 如果顶部和底部的 bottomName 相同,且长度大于 1,就再跳一个位置(可解决遇到首尾两个相同 name 导致的上键不能生效的问题)
if (activeRouteName.value === bottomName && length > 1) {
activeRouteName.value = result.value[length - 2].name
scrollTo(length - 2)
} else {
// 跳转到底部
activeRouteName.value = bottomName
scrollTo(length - 1)
}
} else {
activeRouteName.value = result.value[index - 1].name
scrollTo(index - 1)
}
}
/** 键盘下键 */
function handleDown() {
isPressUpOrDown.value = true
const { length } = result.value
if (length === 0) return
// 获取该 name 在菜单中最后一次出现的位置(可解决遇到连续两个相同 name 导致的下键不能生效的问题)
const index = result.value.map(item => item.name).lastIndexOf(activeRouteName.value)
// 如果已处在底部
if (index === length - 1) {
const topName = result.value[0].name
// 如果底部和顶部的 topName 相同,且长度大于 1,就再跳一个位置(可解决遇到首尾两个相同 name 导致的下键不能生效的问题)
if (activeRouteName.value === topName && length > 1) {
activeRouteName.value = result.value[1].name
scrollTo(1)
} else {
// 跳转到顶部
activeRouteName.value = topName
scrollTo(0)
}
} else {
activeRouteName.value = result.value[index + 1].name
scrollTo(index + 1)
}
}
/** 键盘回车键 */
function handleEnter() {
const { length } = result.value
if (length === 0) return
const name = activeRouteName.value
const path = result.value.find(item => item.name === name)?.path
if (path && isExternal(path)) return window.open(path, "_blank", "noopener, noreferrer")
if (!name) return ElMessage.warning("无法通过搜索进入该菜单,请为对应的路由设置唯一的 Name")
try {
router.push({ name })
} catch {
return ElMessage.warning("该菜单有必填的动态参数,无法通过搜索进入")
}
handleClose()
}
/** 释放上键或下键 */
function handleReleaseUpOrDown() {
isPressUpOrDown.value = false
}
</script>
<template>
<el-dialog
v-model="modelValue"
:before-close="handleClose"
:width="modalWidth"
top="5vh"
class="search-modal-private"
append-to-body
@opened="inputRef?.focus()"
@closed="inputRef?.blur()"
@keydown.up="handleUp"
@keydown.down="handleDown"
@keydown.enter="handleEnter"
@keyup.up.down="handleReleaseUpOrDown"
>
<el-input ref="inputRef" v-model="keyword" placeholder="搜索菜单" size="large" clearable @input="handleSearch">
<template #prefix>
<SvgIcon name="search" class="svg-icon" />
</template>
</el-input>
<el-empty v-if="result.length === 0" description="暂无搜索结果" :image-size="100" />
<template v-else>
<p>搜索结果</p>
<el-scrollbar ref="scrollbarRef" max-height="40vh" always>
<Result
ref="resultRef"
v-model="activeRouteName"
:data="result"
:is-press-up-or-down="isPressUpOrDown"
@click="handleEnter"
/>
</el-scrollbar>
</template>
<template #footer>
<Footer :total="result.length" />
</template>
</el-dialog>
</template>
<style lang="scss">
.search-modal-private {
.svg-icon {
font-size: 18px;
}
.el-dialog__header {
display: none;
}
.el-dialog__footer {
border-top: 1px solid var(--el-border-color);
padding-top: var(--el-dialog-padding-primary);
}
}
</style>
+114
View File
@@ -0,0 +1,114 @@
<script lang="ts" setup>
import type { RouteRecordNameGeneric, RouteRecordRaw } from "vue-router"
interface Props {
data: RouteRecordRaw[]
isPressUpOrDown: boolean
}
const props = defineProps<Props>()
/** 选中的菜单 */
const modelValue = defineModel<RouteRecordNameGeneric | undefined>({ required: true })
const resultItemRefs = useTemplateRef<HTMLDivElement[]>("resultItemRefs")
const scrollbarHeight = ref<number>(0)
/** 菜单的样式 */
function itemStyle(item: RouteRecordRaw) {
const flag = item.name === modelValue.value
return {
background: flag ? "var(--el-color-primary)" : "",
color: flag ? "#ffffff" : ""
}
}
/** 鼠标移入 */
function handleMouseenter(item: RouteRecordRaw) {
// 如果上键或下键与 mouseenter 事件同时生效,则以上下键为准,不执行该函数的赋值逻辑
if (props.isPressUpOrDown) return
modelValue.value = item.name
}
/** 计算滚动可视区高度 */
function getScrollbarHeight() {
// el-scrollbar max-height="40vh"
scrollbarHeight.value = Number((window.innerHeight * 0.4).toFixed(1))
}
/** 根据下标计算到顶部的距离 */
function getScrollTop(index: number) {
const currentRef = resultItemRefs.value?.[index]
if (!currentRef) return 0
// 128 = 两个 result-item (56 + 56 = 112)高度与上下 margin(8 + 8 = 16)大小之和
const scrollTop = currentRef.offsetTop + 128
return scrollTop > scrollbarHeight.value ? scrollTop - scrollbarHeight.value : 0
}
// 在组件挂载前添加窗口大小变化事件监听器
onBeforeMount(() => {
window.addEventListener("resize", getScrollbarHeight)
})
// 在组件挂载时立即计算滚动可视区高度
onMounted(() => {
getScrollbarHeight()
})
// 在组件卸载前移除窗口大小变化事件监听器
onBeforeUnmount(() => {
window.removeEventListener("resize", getScrollbarHeight)
})
defineExpose({ getScrollTop })
</script>
<template>
<!-- 外层 div 不能删除,是用来接收父组件 click 事件的 -->
<div>
<div
v-for="(item, index) in props.data"
:key="index"
ref="resultItemRefs"
class="result-item"
:style="itemStyle(item)"
@mouseenter="handleMouseenter(item)"
>
<SvgIcon v-if="item.meta?.svgIcon" :name="item.meta.svgIcon" class="svg-icon" />
<component v-else-if="item.meta?.elIcon" :is="item.meta.elIcon" class="el-icon" />
<span class="result-item-title">
{{ item.meta?.title }}
</span>
<SvgIcon v-if="modelValue && modelValue === item.name" name="keyboard-enter" class="svg-icon" />
</div>
</div>
</template>
<style lang="scss" scoped>
@import "@@/assets/styles/mixins.scss";
.result-item {
display: flex;
align-items: center;
height: 56px;
padding: 0 15px;
margin-bottom: 8px;
border: 1px solid var(--el-border-color);
border-radius: 4px;
cursor: pointer;
.svg-icon {
min-width: 1em;
font-size: 18px;
}
.el-icon {
width: 1em;
font-size: 18px;
}
&-title {
flex: 1;
margin-left: 12px;
@extend %ellipsis;
}
}
</style>
@@ -0,0 +1,29 @@
<script lang="ts" setup>
import Modal from "./Modal.vue"
/** 控制 modal 显隐 */
const visible = ref<boolean>(false)
/** 打开 modal */
function handleOpen() {
visible.value = true
}
</script>
<template>
<div>
<el-tooltip effect="dark" content="搜索菜单" placement="bottom">
<SvgIcon name="search" @click="handleOpen" class="svg-icon" />
</el-tooltip>
<Modal v-model="visible" />
</div>
</template>
<style lang="scss" scoped>
.svg-icon {
font-size: 20px;
&:focus {
outline: none;
}
}
</style>
@@ -0,0 +1,30 @@
<script lang="ts" setup>
import { useTheme } from "@@/composables/useTheme"
import { MagicStick } from "@element-plus/icons-vue"
const { themeList, activeThemeName, setTheme } = useTheme()
</script>
<template>
<el-dropdown trigger="click">
<div>
<el-tooltip effect="dark" content="主题模式" placement="bottom">
<el-icon :size="20">
<MagicStick />
</el-icon>
</el-tooltip>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="(theme, index) in themeList"
:key="index"
:disabled="activeThemeName === theme.name"
@click="(e: MouseEvent) => setTheme(e, theme.name)"
>
<span>{{ theme.title }}</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
+13
View File
@@ -0,0 +1,13 @@
import { DeviceEnum } from "@@/constants/app-key"
import { useAppStore } from "@/pinia/stores/app"
const appStore = useAppStore()
const isMobile = computed(() => appStore.device === DeviceEnum.Mobile)
const isDesktop = computed(() => appStore.device === DeviceEnum.Desktop)
/** 设备类型 Composable */
export function useDevice() {
return { isMobile, isDesktop }
}
+44
View File
@@ -0,0 +1,44 @@
type OptionValue = string | number
/** Select 需要的数据格式 */
interface SelectOption {
value: OptionValue
label: string
disabled?: boolean
}
/** 接口响应格式 */
type ApiData = ApiResponseData<SelectOption[]>
/** 入参格式,暂时只需要传递 api 函数即可 */
interface FetchSelectProps {
api: () => Promise<ApiData>
}
/** 下拉选择器 Composable */
export function useFetchSelect(props: FetchSelectProps) {
const { api } = props
const loading = ref<boolean>(false)
const options = ref<SelectOption[]>([])
const value = ref<OptionValue>("")
// 调用接口获取数据
const loadData = () => {
loading.value = true
options.value = []
api().then((res) => {
options.value = res.data
}).finally(() => {
loading.value = false
})
}
onMounted(() => {
loadData()
})
return { loading, options, value }
}
@@ -0,0 +1,36 @@
import type { LoadingOptions } from "element-plus"
interface UseFullscreenLoading {
<T extends (...args: Parameters<T>) => ReturnType<T>>(
fn: T,
options?: LoadingOptions
): (...args: Parameters<T>) => Promise<ReturnType<T>>
}
interface LoadingInstance {
close: () => void
}
const DEFAULT_OPTIONS = {
lock: true,
text: "加载中..."
}
/**
* @name 全屏加载 Composable
* @description 传入一个函数 fn,在它执行周期内,加上「全屏」Loading
* @param fn 要执行的函数
* @param options LoadingOptions
* @returns 返回一个新的函数,该函数返回一个 Promise
*/
export const useFullscreenLoading: UseFullscreenLoading = (fn, options = {}) => {
let loadingInstance: LoadingInstance
return async (...args) => {
try {
loadingInstance = ElLoading.service({ ...DEFAULT_OPTIONS, ...options })
return await fn(...args)
} finally {
loadingInstance.close()
}
}
}
@@ -0,0 +1,21 @@
import { useSettingsStore } from "@/pinia/stores/settings"
const GREY_MODE = "grey-mode"
const COLOR_WEAKNESS = "color-weakness"
const classList = document.documentElement.classList
/** 初始化 */
function initGreyAndColorWeakness() {
const settingsStore = useSettingsStore()
watchEffect(() => {
classList.toggle(GREY_MODE, settingsStore.showGreyMode)
classList.toggle(COLOR_WEAKNESS, settingsStore.showColorWeakness)
})
}
/** 灰色模式和色弱模式 Composable */
export function useGreyAndColorWeakness() {
return { initGreyAndColorWeakness }
}
+19
View File
@@ -0,0 +1,19 @@
import { LayoutModeEnum } from "@@/constants/app-key"
import { useSettingsStore } from "@/pinia/stores/settings"
const settingsStore = useSettingsStore()
const isLeft = computed(() => settingsStore.layoutMode === LayoutModeEnum.Left)
const isTop = computed(() => settingsStore.layoutMode === LayoutModeEnum.Top)
const isLeftTop = computed(() => settingsStore.layoutMode === LayoutModeEnum.LeftTop)
function setLayoutMode(mode: LayoutModeEnum) {
settingsStore.layoutMode = mode
}
/** 布局模式 Composable */
export function useLayoutMode() {
return { isLeft, isTop, isLeftTop, setLayoutMode }
}
+62
View File
@@ -0,0 +1,62 @@
import type { WatchOptions } from "vue"
interface PaginationData {
total?: number
currentPage?: number
pageSizes?: number[]
pageSize?: number
layout?: string
}
interface UsePaginationOptions extends PaginationData {
callback?: () => void
}
/** 默认的分页参数 */
const DEFAULT_PAGINATION_DATA = {
currentPage: 1,
pageSize: 10,
pageSizes: [10, 20, 50],
total: 0,
layout: "total, sizes, prev, pager, next, jumper"
}
/** 分页 Composable */
export function usePagination(options: UsePaginationOptions = {}) {
const { callback, ...initPaginationData } = options
// 合并分页参数
const paginationData = reactive({ ...DEFAULT_PAGINATION_DATA, ...initPaginationData })
// 改变当前页码
const handleCurrentChange = (value: number) => {
paginationData.currentPage = value
}
// 改变每页显示条数
const handleSizeChange = (value: number) => {
paginationData.pageSize = value
}
// 重置当前页码,已在第一页时直接执行回调
const resetCurrentPage = () => {
paginationData.currentPage === 1 ? callback?.() : (paginationData.currentPage = 1)
}
// 监听分页参数变化,并执行回调
const watchPagination = (options: WatchOptions = { immediate: true }) => {
watch(
[() => paginationData.currentPage, () => paginationData.pageSize],
() => callback?.(),
options
)
}
return {
paginationData,
handleCurrentChange,
handleSizeChange,
resetCurrentPage,
watchPagination
}
}
+61
View File
@@ -0,0 +1,61 @@
function initStarNotification() {
setTimeout(() => {
ElNotification({
title: "为爱发电!",
type: "success",
message: h(
"div",
null,
[
h("div", null, "所有源码均免费开源,如果对你有帮助,欢迎点个 Star 支持一下!"),
h("a", { style: "color: teal", target: "_blank", href: "https://github.com/un-pany/v3-admin-vite" }, "点击传送")
]
),
duration: 0,
position: "bottom-right"
})
}, 0)
}
function initStoreNotification() {
setTimeout(() => {
ElNotification({
title: "懒人服务?",
type: "warning",
message: h(
"div",
null,
[
h("div", null, "不想自己动手,但想移除 TS 或其他模块?也有懒人套餐!"),
h("a", { style: "color: teal", target: "_blank", href: "https://github.com/un-pany/v3-admin-vite/issues/225" }, "点击获取")
]
),
duration: 0,
position: "bottom-right"
})
}, 500)
}
function initMobileNotification() {
setTimeout(() => {
ElNotification({
title: "移动端",
type: "primary",
message: h(
"div",
null,
[
h("div", null, "如果你有移动端 H5 需求,试试新的开源模板 MobVue"),
h("a", { style: "color: teal", target: "_blank", href: "https://github.com/un-pany/mobvue" }, "点击查看")
]
),
duration: 0,
position: "bottom-right"
})
}, 1000)
}
/** 作者的小心思 */
export function usePany() {
return { initStarNotification, initStoreNotification, initMobileNotification }
}
@@ -0,0 +1,52 @@
import type { Handler } from "mitt"
import type { RouteLocationNormalizedGeneric } from "vue-router"
import mitt from "mitt"
/** 回调函数的类型 */
type Callback = (route: RouteLocationNormalizedGeneric) => void
const emitter = mitt()
const key = Symbol("ROUTE_CHANGE")
let latestRoute: RouteLocationNormalizedGeneric
/** 设置最新的路由信息,触发路由变化事件 */
export function setRouteChange(to: RouteLocationNormalizedGeneric) {
// 触发事件
emitter.emit(key, to)
// 缓存最新的路由信息
latestRoute = to
}
/**
* @name 订阅路由变化 Composable
* @description 1. 单独用 watch 监听路由会浪费渲染性能
* @description 2. 可优先选择使用该发布订阅模式去进行分发管理
*/
export function useRouteListener() {
// 回调函数集合
const callbackList: Callback[] = []
// 监听路由变化(可以选择立即执行)
const listenerRouteChange = (callback: Callback, immediate = false) => {
// 缓存回调函数
callbackList.push(callback)
// 监听事件
emitter.on(key, callback as Handler)
// 可以选择立即执行一次回调函数
immediate && latestRoute && callback(latestRoute)
}
// 移除路由变化事件监听器
const removeRouteListener = (callback: Callback) => {
emitter.off(key, callback as Handler)
}
// 组件销毁前移除监听器
onBeforeUnmount(() => {
callbackList.forEach(removeRouteListener)
})
return { listenerRouteChange, removeRouteListener }
}
+75
View File
@@ -0,0 +1,75 @@
import { setCssVar } from "@@/utils/css"
import { getActiveThemeName, setActiveThemeName } from "@@/utils/local-storage"
const DEFAULT_THEME_NAME = "normal"
type DefaultThemeName = typeof DEFAULT_THEME_NAME
/** 注册的主题名称, 其中 DefaultThemeName 是必填的 */
export type ThemeName = DefaultThemeName | "dark" | "dark-blue"
interface ThemeList {
title: string
name: ThemeName
}
/** 主题列表 */
const themeList: ThemeList[] = [
{
title: "默认",
name: DEFAULT_THEME_NAME
},
{
title: "黑暗",
name: "dark"
},
{
title: "深蓝",
name: "dark-blue"
}
]
/** 正在应用的主题名称 */
const activeThemeName = ref<ThemeName>(getActiveThemeName() || DEFAULT_THEME_NAME)
/** 设置主题 */
function setTheme({ clientX, clientY }: MouseEvent, value: ThemeName) {
const maxRadius = Math.hypot(
Math.max(clientX, window.innerWidth - clientX),
Math.max(clientY, window.innerHeight - clientY)
)
setCssVar("--v3-theme-x", `${clientX}px`)
setCssVar("--v3-theme-y", `${clientY}px`)
setCssVar("--v3-theme-r", `${maxRadius}px`)
const handler = () => {
activeThemeName.value = value
}
document.startViewTransition ? document.startViewTransition(handler) : handler()
}
/** 在 html 根元素上挂载 class */
function addHtmlClass(value: ThemeName) {
document.documentElement.classList.add(value)
}
/** 在 html 根元素上移除其他主题 class */
function removeHtmlClass(value: ThemeName) {
const otherThemeNameList = themeList.map(item => item.name).filter(name => name !== value)
document.documentElement.classList.remove(...otherThemeNameList)
}
/** 初始化 */
function initTheme() {
// watchEffect 来收集副作用
watchEffect(() => {
const value = activeThemeName.value
removeHtmlClass(value)
addHtmlClass(value)
setActiveThemeName(value)
})
}
/** 主题 Composable */
export function useTheme() {
return { themeList, activeThemeName, initTheme, setTheme }
}
+22
View File
@@ -0,0 +1,22 @@
/** 项目标题 */
const VITE_APP_TITLE = import.meta.env.VITE_APP_TITLE ?? "V3 Admin Vite"
/** 动态标题 */
const dynamicTitle = ref<string>("")
/** 设置标题 */
function setTitle(title?: string) {
dynamicTitle.value = title ? `${VITE_APP_TITLE} | ${title}` : VITE_APP_TITLE
}
// 监听标题变化
watch(dynamicTitle, (value, oldValue) => {
if (document && value !== oldValue) {
document.title = value
}
})
/** 标题 Composable */
export function useTitle() {
return { setTitle }
}
+236
View File
@@ -0,0 +1,236 @@
import type { Ref } from "vue"
import { debounce } from "lodash-es"
/** 默认配置 */
const DEFAULT_CONFIG = {
/** 防御(默认开启,能防御水印被删除或隐藏,但可能会有性能损耗) */
defense: true,
/** 文本颜色 */
color: "#c0c4cc",
/** 文本透明度 */
opacity: 0.5,
/** 文本字体大小 */
size: 16,
/** 文本字体 */
family: "serif",
/** 文本倾斜角度 */
angle: -20,
/** 一处水印所占宽度(数值越大水印密度越低) */
width: 300,
/** 一处水印所占高度(数值越大水印密度越低) */
height: 200
}
type DefaultConfig = typeof DEFAULT_CONFIG
interface Observer {
watermarkElMutationObserver?: MutationObserver
parentElMutationObserver?: MutationObserver
parentElResizeObserver?: ResizeObserver
}
/** body 元素 */
const bodyEl = ref<HTMLElement>(document.body)
/**
* @name 水印 Composable
* @description 1. 可以选择传入挂载水印的容器元素,默认是 body
* @description 2. 做了水印防御,能有效防御别人打开控制台删除或隐藏水印
*/
export function useWatermark(parentEl: Ref<HTMLElement | null> = bodyEl) {
// 备份文本
let backupText: string
// 最终配置
let mergeConfig: DefaultConfig
// 水印元素
let watermarkEl: HTMLElement | null = null
// 观察器
const observer: Observer = {
watermarkElMutationObserver: undefined,
parentElMutationObserver: undefined,
parentElResizeObserver: undefined
}
// 设置水印
const setWatermark = (text: string, config: Partial<DefaultConfig> = {}) => {
if (!parentEl.value) return console.warn("请在 DOM 挂载完成后再调用 setWatermark 方法设置水印")
// 备份文本
backupText = text
// 合并配置
mergeConfig = { ...DEFAULT_CONFIG, ...config }
// 创建或更新水印元素
watermarkEl ? updateWatermarkEl() : createWatermarkEl()
// 监听水印元素和容器元素的变化
addElListener(parentEl.value)
}
// 创建水印元素
const createWatermarkEl = () => {
const isBody = parentEl.value!.tagName.toLowerCase() === bodyEl.value.tagName.toLowerCase()
const watermarkElPosition = isBody ? "fixed" : "absolute"
const parentElPosition = isBody ? "" : "relative"
watermarkEl = document.createElement("div")
watermarkEl.style.pointerEvents = "none"
watermarkEl.style.top = "0"
watermarkEl.style.left = "0"
watermarkEl.style.position = watermarkElPosition
watermarkEl.style.zIndex = "99999"
const { clientWidth, clientHeight } = parentEl.value!
updateWatermarkEl({ width: clientWidth, height: clientHeight })
// 设置水印容器为相对定位
parentEl.value!.style.position = parentElPosition
// 将水印元素添加到水印容器中
parentEl.value!.appendChild(watermarkEl)
}
// 更新水印元素
const updateWatermarkEl = (
options: Partial<{
width: number
height: number
}> = {}
) => {
if (!watermarkEl) return
backupText && (watermarkEl.style.background = `url(${createBase64()}) left top repeat`)
options.width && (watermarkEl.style.width = `${options.width}px`)
options.height && (watermarkEl.style.height = `${options.height}px`)
}
// 创建 base64 图片
const createBase64 = () => {
const { color, opacity, size, family, angle, width, height } = mergeConfig
const canvasEl = document.createElement("canvas")
canvasEl.width = width
canvasEl.height = height
const ctx = canvasEl.getContext("2d")
if (ctx) {
ctx.fillStyle = color
ctx.globalAlpha = opacity
ctx.font = `${size}px ${family}`
ctx.rotate((Math.PI / 180) * angle)
ctx.fillText(backupText, 0, height / 2)
}
return canvasEl.toDataURL()
}
// 清除水印
const clearWatermark = () => {
if (!parentEl.value || !watermarkEl) return
// 移除对水印元素和容器元素的监听
removeListener()
// 移除水印元素
try {
parentEl.value.removeChild(watermarkEl)
} catch {
// 比如在无防御情况下,用户打开控制台删除了这个元素
console.warn("水印元素已不存在,请重新创建")
} finally {
watermarkEl = null
}
}
// 刷新水印(防御时调用)
const updateWatermark = debounce(() => {
clearWatermark()
createWatermarkEl()
addElListener(parentEl.value!)
}, 100)
// 监听水印元素和容器元素的变化(DOM 变化 & DOM 大小变化)
const addElListener = (targetNode: HTMLElement) => {
// 判断是否开启防御
if (mergeConfig.defense) {
// 防止重复添加监听
if (!observer.watermarkElMutationObserver && !observer.parentElMutationObserver) {
// 监听 DOM 变化
addMutationListener(targetNode)
}
} else {
// 无防御时不需要 mutation 监听
removeListener("mutation")
}
// 防止重复添加监听
if (!observer.parentElResizeObserver) {
// 监听 DOM 大小变化
addResizeListener(targetNode)
}
}
// 移除对水印元素和容器元素的监听,传参可指定要移除哪个监听,不传默认移除全部监听
const removeListener = (kind: "mutation" | "resize" | "all" = "all") => {
// 移除 mutation 监听
if (kind === "mutation" || kind === "all") {
observer.watermarkElMutationObserver?.disconnect()
observer.watermarkElMutationObserver = undefined
observer.parentElMutationObserver?.disconnect()
observer.parentElMutationObserver = undefined
}
// 移除 resize 监听
if (kind === "resize" || kind === "all") {
observer.parentElResizeObserver?.disconnect()
observer.parentElResizeObserver = undefined
}
}
// 监听 DOM 变化
const addMutationListener = (targetNode: HTMLElement) => {
// 当观察到变动时执行的回调
const mutationCallback = debounce((mutationList: MutationRecord[]) => {
// 水印的防御(防止用户手动删除水印元素或通过 CSS 隐藏水印)
mutationList.forEach(
debounce((mutation: MutationRecord) => {
switch (mutation.type) {
case "attributes":
mutation.target === watermarkEl && updateWatermark()
break
case "childList":
mutation.removedNodes.forEach((item) => {
item === watermarkEl && targetNode.appendChild(watermarkEl)
})
break
}
}, 100)
)
}, 100)
// 创建观察器实例并传入回调
observer.watermarkElMutationObserver = new MutationObserver(mutationCallback)
observer.parentElMutationObserver = new MutationObserver(mutationCallback)
// 以上述配置开始观察目标节点
observer.watermarkElMutationObserver.observe(watermarkEl!, {
// 观察目标节点属性是否变动,默认为 true
attributes: true,
// 观察目标子节点是否有添加或者删除,默认为 false
childList: false,
// 是否拓展到观察所有后代节点,默认为 false
subtree: false
})
observer.parentElMutationObserver.observe(targetNode, {
attributes: false,
childList: true,
subtree: false
})
}
// 监听 DOM 大小变化
const addResizeListener = (targetNode: HTMLElement) => {
// 当 targetNode 元素大小变化时去更新整个水印的大小
const resizeCallback = debounce(() => {
const { clientWidth, clientHeight } = targetNode
updateWatermarkEl({ width: clientWidth, height: clientHeight })
}, 500)
// 创建一个观察器实例并传入回调
observer.parentElResizeObserver = new ResizeObserver(resizeCallback)
// 开始观察目标节点
observer.parentElResizeObserver.observe(targetNode)
}
// 在组件卸载前移除水印以及各种监听
onBeforeUnmount(() => {
clearWatermark()
})
return { setWatermark, clearWatermark }
}
+22
View File
@@ -0,0 +1,22 @@
/** 设备类型 */
export enum DeviceEnum {
Mobile,
Desktop
}
/** 布局模式 */
export enum LayoutModeEnum {
Left = "left",
Top = "top",
LeftTop = "left-top"
}
/** 侧边栏打开状态常量 */
export const SIDEBAR_OPENED = "opened"
/** 侧边栏关闭状态常量 */
export const SIDEBAR_CLOSED = "closed"
export type SidebarOpened = typeof SIDEBAR_OPENED
export type SidebarClosed = typeof SIDEBAR_CLOSED
+11
View File
@@ -0,0 +1,11 @@
const SYSTEM_NAME = "v3-admin-vite"
/** 缓存数据时用到的 Key */
export class CacheKey {
static readonly TOKEN = `${SYSTEM_NAME}-token-key`
static readonly CONFIG_LAYOUT = `${SYSTEM_NAME}-config-layout-key`
static readonly SIDEBAR_STATUS = `${SYSTEM_NAME}-sidebar-status-key`
static readonly ACTIVE_THEME_NAME = `${SYSTEM_NAME}-active-theme-name-key`
static readonly VISITED_VIEWS = `${SYSTEM_NAME}-visited-views-key`
static readonly CACHED_VIEWS = `${SYSTEM_NAME}-cached-views-key`
}
+18
View File
@@ -0,0 +1,18 @@
/** 获取指定元素(默认全局)上的 CSS 变量的值 */
export function getCssVar(varName: string, element: HTMLElement = document.documentElement) {
if (!varName?.startsWith("--")) {
console.error("CSS 变量名应以 '--' 开头")
return ""
}
// 没有拿到值时,会返回空串
return getComputedStyle(element).getPropertyValue(varName)
}
/** 设置指定元素(默认全局)上的 CSS 变量的值 */
export function setCssVar(varName: string, value: string, element: HTMLElement = document.documentElement) {
if (!varName?.startsWith("--")) {
console.error("CSS 变量名应以 '--' 开头")
return
}
element.style.setProperty(varName, value)
}
+9
View File
@@ -0,0 +1,9 @@
import dayjs from "dayjs"
const INVALID_DATE = "N/A"
/** 格式化日期时间 */
export function formatDateTime(datetime: string | number | Date = "", template: string = "YYYY-MM-DD HH:mm:ss") {
const day = dayjs(datetime)
return day.isValid() ? day.format(template) : INVALID_DATE
}
+81
View File
@@ -0,0 +1,81 @@
// 统一处理 localStorage
import type { ThemeName } from "@@/composables/useTheme"
import type { SidebarClosed, SidebarOpened } from "@@/constants/app-key"
import type { LayoutsConfig } from "@/layouts/config"
import type { TagView } from "@/pinia/stores/tags-view"
import { CacheKey } from "@@/constants/cache-key"
// #region Token
export function getToken() {
return localStorage.getItem(CacheKey.TOKEN)
}
export function setToken(token: string) {
localStorage.setItem(CacheKey.TOKEN, token)
}
export function removeToken() {
localStorage.removeItem(CacheKey.TOKEN)
}
// #endregion
// #region 系统布局配置
export function getLayoutsConfig() {
const json = localStorage.getItem(CacheKey.CONFIG_LAYOUT)
return json ? (JSON.parse(json) as LayoutsConfig) : null
}
export function setLayoutsConfig(settings: LayoutsConfig) {
localStorage.setItem(CacheKey.CONFIG_LAYOUT, JSON.stringify(settings))
}
export function removeLayoutsConfig() {
localStorage.removeItem(CacheKey.CONFIG_LAYOUT)
}
// #endregion
// #region 侧边栏状态
export function getSidebarStatus() {
return localStorage.getItem(CacheKey.SIDEBAR_STATUS)
}
export function setSidebarStatus(sidebarStatus: SidebarOpened | SidebarClosed) {
localStorage.setItem(CacheKey.SIDEBAR_STATUS, sidebarStatus)
}
// #endregion
// #region 正在应用的主题名称
export function getActiveThemeName() {
return localStorage.getItem(CacheKey.ACTIVE_THEME_NAME) as ThemeName | null
}
export function setActiveThemeName(themeName: ThemeName) {
localStorage.setItem(CacheKey.ACTIVE_THEME_NAME, themeName)
}
// #endregion
// #region 标签栏
export function getVisitedViews() {
const json = localStorage.getItem(CacheKey.VISITED_VIEWS)
return JSON.parse(json ?? "[]") as TagView[]
}
export function setVisitedViews(views: TagView[]) {
views.forEach((view) => {
// 删除不必要的属性,防止 JSON.stringify 处理到循环引用
delete view.matched
delete view.redirectedFrom
})
localStorage.setItem(CacheKey.VISITED_VIEWS, JSON.stringify(views))
}
export function getCachedViews() {
const json = localStorage.getItem(CacheKey.CACHED_VIEWS)
return JSON.parse(json ?? "[]") as string[]
}
export function setCachedViews(views: string[]) {
localStorage.setItem(CacheKey.CACHED_VIEWS, JSON.stringify(views))
}
// #endregion
+13
View File
@@ -0,0 +1,13 @@
import { isArray } from "@@/utils/validate"
import { useUserStore } from "@/pinia/stores/user"
/** 全局权限判断函数,和权限指令 v-permission 功能类似 */
export function checkPermission(rolesOrPermissions: string[]): boolean {
if (isArray(rolesOrPermissions) && rolesOrPermissions.length > 0) {
const { roles, permissions } = useUserStore()
return roles.some(role => rolesOrPermissions.includes(role)) || permissions.some(permission => rolesOrPermissions.includes(permission))
} else {
console.error("参数必须是一个数组且长度大于 0,参考:checkPermission(['admin', 'permission:button-level'])")
return false
}
}
+15
View File
@@ -0,0 +1,15 @@
/** 判断是否为数组 */
export function isArray<T>(arg: T) {
return Array.isArray ? Array.isArray(arg) : Object.prototype.toString.call(arg) === "[object Array]"
}
/** 判断是否为字符串 */
export function isString(str: unknown) {
return typeof str === "string" || str instanceof String
}
/** 判断是否为外链 */
export function isExternal(path: string) {
const reg = /^(https?:|mailto:|tel:)/
return reg.test(path)
}
+125
View File
@@ -0,0 +1,125 @@
import type { AxiosInstance, AxiosRequestConfig } from "axios"
import { getToken } from "@@/utils/local-storage"
import axios from "axios"
import { get, merge } from "lodash-es"
import { useUserStore } from "@/pinia/stores/user"
/** 创建请求实例 */
function createInstance() {
// 创建一个 axios 实例命名为 instance
const instance = axios.create()
// 请求拦截器
instance.interceptors.request.use(
// 发送之前
config => config,
// 发送失败
error => Promise.reject(error)
)
// 响应拦截器(可根据具体业务作出相应的调整)
instance.interceptors.response.use(
(response) => {
// apiData 是 api 返回的数据
const apiData = response.data
// 二进制数据则直接返回
const responseType = response.config.responseType
if (responseType === "blob" || responseType === "arraybuffer") return apiData
// 这个 code 是和后端约定的业务 code
const code = apiData.code
// 如果没有 code, 代表这不是项目后端开发的 api
if (code === undefined) {
ElMessage.error("非本系统的接口")
return Promise.reject(new Error("非本系统的接口"))
}
switch (code) {
case 0:
// 本系统采用 code === 0 来表示没有业务错误
return apiData
case 401:
// Token 过期时
return useUserStore().logout()
default:
// 不是正确的 code
ElMessage.error(apiData.message || "Error")
return Promise.reject(apiData)
}
},
(error) => {
// status 是 HTTP 状态码
const status = get(error, "response.status")
const message = get(error, "response.data.message")
switch (status) {
case 400:
error.message = "请求错误"
break
case 401:
// Token 过期时
error.message = message || "未授权"
useUserStore().logout()
break
case 403:
error.message = message || "拒绝访问"
break
case 404:
error.message = "请求地址出错"
break
case 408:
error.message = "请求超时"
break
case 500:
error.message = "服务器内部错误"
break
case 501:
error.message = "服务未实现"
break
case 502:
error.message = "网关错误"
break
case 503:
error.message = "服务不可用"
break
case 504:
error.message = "网关超时"
break
case 505:
error.message = "HTTP 版本不受支持"
break
}
ElMessage.error(error.message)
return Promise.reject(error)
}
)
return instance
}
/** 创建请求方法 */
function createRequest(instance: AxiosInstance) {
return <T>(config: AxiosRequestConfig): Promise<T> => {
const token = getToken()
// 默认配置
const defaultConfig: AxiosRequestConfig = {
// 接口地址
baseURL: import.meta.env.VITE_BASE_URL,
// 请求头
headers: {
// 携带 Token
"Authorization": token ? `Bearer ${token}` : undefined,
"Content-Type": "application/json"
},
// 请求体
data: {},
// 请求超时
timeout: 5000,
// 跨域请求时是否携带 Cookies
withCredentials: false
}
// 将默认配置 defaultConfig 和传入的自定义配置 config 进行合并成为 mergeConfig
const mergeConfig = merge(defaultConfig, config)
return instance(mergeConfig)
}
}
/** 用于请求的实例 */
const instance = createInstance()
/** 用于请求的方法 */
export const request = createRequest(instance)
+50
View File
@@ -0,0 +1,50 @@
<script lang="ts" setup>
import { useSettingsStore } from "@/pinia/stores/settings"
import { useTagsViewStore } from "@/pinia/stores/tags-view"
import { Footer } from "../index"
const tagsViewStore = useTagsViewStore()
const settingsStore = useSettingsStore()
</script>
<template>
<section class="app-main">
<div class="app-scrollbar">
<!-- key 采用 route.path 和 route.fullPath 有着不同的效果,大多数时候 path 更通用 -->
<router-view v-slot="{ Component, route }">
<transition name="el-fade-in" mode="out-in">
<keep-alive :include="tagsViewStore.cachedViews">
<component :is="Component" :key="route.path" class="app-container-grow" />
</keep-alive>
</transition>
</router-view>
<!-- 页脚 -->
<Footer v-if="settingsStore.showFooter" />
</div>
<!-- 返回顶部 -->
<el-backtop />
<!-- 返回顶部(固定 Header 情况下) -->
<el-backtop target=".app-scrollbar" />
</section>
</template>
<style lang="scss" scoped>
@import "@@/assets/styles/mixins.scss";
.app-main {
width: 100%;
display: flex;
}
.app-scrollbar {
flex-grow: 1;
overflow: auto;
@extend %scrollbar;
display: flex;
flex-direction: column;
.app-container-grow {
flex-grow: 1;
}
}
</style>
@@ -0,0 +1,65 @@
<script lang="ts" setup>
import type { RouteLocationMatched } from "vue-router"
import { useRouteListener } from "@@/composables/useRouteListener"
import { compile } from "path-to-regexp"
import { REDIRECT_PATH } from "@/router/config"
const route = useRoute()
const router = useRouter()
const { listenerRouteChange } = useRouteListener()
/** 定义响应式数据 breadcrumbs,用于存储面包屑导航信息 */
const breadcrumbs = ref<RouteLocationMatched[]>([])
/** 获取面包屑导航信息 */
function getBreadcrumb() {
breadcrumbs.value = route.matched.filter(item => item.meta?.title && item.meta?.breadcrumb !== false)
}
/** 编译路由路径 */
function pathCompile(path: string) {
const toPath = compile(path)
return toPath(route.params)
}
/** 处理面包屑导航点击事件 */
function handleLink(item: RouteLocationMatched) {
const { redirect, path } = item
if (redirect) return router.push(redirect as string)
router.push(pathCompile(path))
}
// 监听路由变化,更新面包屑导航信息
listenerRouteChange((route) => {
if (route.path.startsWith(`${REDIRECT_PATH}/`)) return
getBreadcrumb()
}, true)
</script>
<template>
<el-breadcrumb>
<el-breadcrumb-item v-for="(item, index) in breadcrumbs" :key="item.path">
<span v-if="item.redirect === 'noRedirect' || index === breadcrumbs.length - 1" class="no-redirect">
{{ item.meta.title }}
</span>
<a v-else @click.prevent="handleLink(item)">
{{ item.meta.title }}
</a>
</el-breadcrumb-item>
</el-breadcrumb>
</template>
<style lang="scss" scoped>
.el-breadcrumb {
display: flex;
align-items: center;
.no-redirect {
color: var(--el-text-color-placeholder);
}
a {
font-weight: normal;
}
}
</style>
+20
View File
@@ -0,0 +1,20 @@
<script lang="ts" setup>
const VITE_APP_TITLE = import.meta.env.VITE_APP_TITLE
</script>
<template>
<footer class="layout-footer">
MIT © 2021-PRESENT {{ VITE_APP_TITLE }}
</footer>
</template>
<style lang="scss" scoped>
.layout-footer {
width: 100%;
min-height: 50px;
display: flex;
align-items: center;
justify-content: center;
color: var(--el-text-color-placeholder);
}
</style>
@@ -0,0 +1,32 @@
<script lang="ts" setup>
import { Expand, Fold } from "@element-plus/icons-vue"
interface Props {
isActive?: boolean
}
const { isActive = false } = defineProps<Props>()
const emit = defineEmits<{
toggleClick: []
}>()
function toggleClick() {
emit("toggleClick")
}
</script>
<template>
<div @click="toggleClick">
<el-icon :size="20" class="icon">
<Fold v-if="isActive" />
<Expand v-else />
</el-icon>
</div>
</template>
<style lang="scss" scoped>
.icon {
color: var(--v3-hamburger-text-color);
}
</style>
+65
View File
@@ -0,0 +1,65 @@
<script lang="ts" setup>
import logoText1 from "@@/assets/images/layouts/logo-text-1.png?url"
import logoText2 from "@@/assets/images/layouts/logo-text-2.png?url"
import logo from "@@/assets/images/layouts/logo.png?url"
import { useLayoutMode } from "@@/composables/useLayoutMode"
interface Props {
collapse?: boolean
}
const { collapse = true } = defineProps<Props>()
const { isLeft, isTop } = useLayoutMode()
</script>
<template>
<div class="layout-logo-container" :class="{ 'collapse': collapse, 'layout-mode-top': isTop }">
<transition name="layout-logo-fade">
<router-link v-if="collapse" key="collapse" to="/">
<img :src="logo" class="layout-logo">
</router-link>
<router-link v-else key="expand" to="/">
<img :src="!isLeft ? logoText2 : logoText1" class="layout-logo-text">
</router-link>
</transition>
</div>
</template>
<style lang="scss" scoped>
.layout-logo-container {
position: relative;
width: 100%;
height: var(--v3-header-height);
display: flex;
justify-content: center;
overflow: hidden;
a {
display: flex;
align-items: center;
.layout-logo {
display: none;
}
.layout-logo-text {
height: 100%;
}
}
}
.layout-mode-top {
height: var(--v3-navigationbar-height);
}
.collapse {
a {
.layout-logo {
width: 32px;
height: 32px;
display: inline-block;
}
.layout-logo-text {
display: none;
}
}
}
</style>
@@ -0,0 +1,134 @@
<script lang="ts" setup>
import Notify from "@@/components/Notify/index.vue"
import Screenfull from "@@/components/Screenfull/index.vue"
import SearchMenu from "@@/components/SearchMenu/index.vue"
import ThemeSwitch from "@@/components/ThemeSwitch/index.vue"
import { useDevice } from "@@/composables/useDevice"
import { useLayoutMode } from "@@/composables/useLayoutMode"
import { UserFilled } from "@element-plus/icons-vue"
import { useAppStore } from "@/pinia/stores/app"
import { useSettingsStore } from "@/pinia/stores/settings"
import { useUserStore } from "@/pinia/stores/user"
import { Breadcrumb, Hamburger, Sidebar } from "../index"
const { isMobile } = useDevice()
const { isTop } = useLayoutMode()
const appStore = useAppStore()
const userStore = useUserStore()
const settingsStore = useSettingsStore()
const { showNotify, showThemeSwitch, showScreenfull, showSearchMenu } = storeToRefs(settingsStore)
/** 切换侧边栏 */
function toggleSidebar() {
appStore.toggleSidebar(false)
}
</script>
<template>
<div class="navigation-bar">
<Hamburger
v-if="!isTop || isMobile"
:is-active="appStore.sidebar.opened"
class="hamburger"
@toggle-click="toggleSidebar"
/>
<Breadcrumb v-if="!isTop || isMobile" class="breadcrumb" />
<Sidebar v-if="isTop && !isMobile" class="sidebar" />
<div class="right-menu">
<SearchMenu v-if="showSearchMenu" class="right-menu-item" />
<Screenfull v-if="showScreenfull" class="right-menu-item" />
<ThemeSwitch v-if="showThemeSwitch" class="right-menu-item" />
<Notify v-if="showNotify" class="right-menu-item" />
<el-dropdown>
<div class="right-menu-item user">
<el-avatar :icon="UserFilled" :size="30" />
<span>{{ userStore.username }}</span>
</div>
<template #dropdown>
<el-dropdown-menu>
<a target="_blank" href="https://github.com/un-pany/v3-admin-vite">
<el-dropdown-item>GitHub</el-dropdown-item>
</a>
<a target="_blank" href="https://gitee.com/un-pany/v3-admin-vite">
<el-dropdown-item>Gitee</el-dropdown-item>
</a>
<a target="_blank" href="https://atomgit.com/un-pany/v3-admin-vite">
<el-dropdown-item>AtomGit</el-dropdown-item>
</a>
<el-dropdown-item divided @click="userStore.logout">
退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</template>
<style lang="scss" scoped>
.navigation-bar {
height: var(--v3-navigationbar-height);
overflow: hidden;
color: var(--v3-navigationbar-text-color);
display: flex;
justify-content: space-between;
.hamburger {
display: flex;
align-items: center;
height: 100%;
padding: 0 15px;
cursor: pointer;
}
.breadcrumb {
flex: 1;
// 参考 Bootstrap 的响应式设计将宽度设置为 576
@media screen and (max-width: 576px) {
display: none;
}
}
.sidebar {
flex: 1;
// 设置 min-width 是为了让 Sidebar 里的 el-menu 宽度自适应
min-width: 0px;
:deep(.el-menu) {
background-color: transparent;
}
:deep(.el-sub-menu) {
&.is-active {
.el-sub-menu__title {
color: var(--el-color-primary);
}
}
}
}
.right-menu {
margin-right: 10px;
height: 100%;
display: flex;
align-items: center;
&-item {
height: 20px;
margin: 0 10px;
cursor: pointer;
&:last-child {
margin-left: 20px;
}
}
.user {
display: flex;
align-items: center;
.el-avatar {
margin-right: 10px;
}
span {
font-size: 16px;
}
}
}
}
</style>

Some files were not shown because too many files have changed in this diff Show More