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

|
||||
|
||||
## 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)
|
||||
@@ -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>
|
||||
|
||||
[](https://github.com/un-pany/v3-admin-vite/releases)
|
||||
[](https://github.com/un-pany/v3-admin-vite/stargazers)
|
||||
[](https://gitee.com/un-pany/v3-admin-vite/stargazers)
|
||||
[](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 将不再维护
|
||||
|
||||
> [!TIP]
|
||||
> 正式推出付费服务,如果不想自己动手,但想移除 TS 或其他模块?试试懒人套餐
|
||||
|
||||
> [!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 引擎
|
||||
|
||||
## 项目预览图
|
||||
|
||||

|
||||
|
||||
## 贡献者
|
||||
|
||||
在此感谢所有的贡献者!
|
||||
|
||||
<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)
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>"
|
||||
}
|
||||
|
After Width: | Height: | Size: 8.4 KiB |
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}>
|
||||
@@ -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"
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type CurrentUserResponseData = ApiResponseData<{
|
||||
username: string
|
||||
roles: string[]
|
||||
permissions?: string[]
|
||||
}>
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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" />`
|
||||
@@ -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 |
|
After Width: | Height: | Size: 492 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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>
|
||||
@@ -0,0 +1,8 @@
|
||||
export interface NotifyItem {
|
||||
avatar?: string
|
||||
title: string
|
||||
datetime?: string
|
||||
description?: string
|
||||
status?: "primary" | "success" | "info" | "warning" | "danger"
|
||||
extra?: string
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||