first commit
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
---
|
||||
description: 项目 overview —— portal-mini-front 后台管理系统的技术栈、目录结构与运行方式
|
||||
globs: ["**/*"]
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# 项目概述
|
||||
|
||||
## 产品定位
|
||||
`portal-mini-front` 是一个**后台管理系统前端**(Portal 管理后台),面向内部运营/管理员,核心场景是:登录鉴权 → 菜单/路由权限 → 数据列表(查询/分页/筛选)→ 增删改查表单 → 数据看板。
|
||||
|
||||
## 技术栈(固定选型,不得随意替换)
|
||||
|
||||
| 领域 | 选型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 框架 | Vue 3.4+ | 仅使用 Composition API |
|
||||
| 语言 | TypeScript 5.x | 严格模式,禁止 `any` |
|
||||
| 构建 | Vite 5+ | |
|
||||
| UI 库 | Element Plus | 主题色、组件尺寸统一在应用入口配置 |
|
||||
| 脚手架基线 | `vue3-element-admin` | 目录结构、请求封装、权限方案以该模板为基准 |
|
||||
| 状态管理 | Pinia | 使用 setup store 语法 |
|
||||
| 路由 | Vue Router 4 | 动态路由 + 权限守卫 |
|
||||
| HTTP | Axios | 统一封装于 `src/utils/request.ts` |
|
||||
| 图标 | `@element-plus/icons-vue` + 本地 svg sprite | |
|
||||
| 工具库 | VueUse(`@vueuse/core`) | 优先复用,不重复造轮子 |
|
||||
| 代码规范 | ESLint + Prettier + husky/lint-staged | 提交前自动校验 |
|
||||
|
||||
> 引入任何新依赖前必须说明理由;能用 Element Plus / VueUse / 现有 utils 解决的,不允许加新库。
|
||||
|
||||
## 目录结构约定
|
||||
|
||||
```
|
||||
src/
|
||||
├── api/ # 接口层,按业务模块划分,不得在组件内直接写请求
|
||||
│ └── {module}/
|
||||
│ └── index.ts # 导出 {Module}API 对象
|
||||
├── components/ # 跨模块复用的全局组件(自动注册)
|
||||
├── composables/ # 跨模块复用的组合式函数 useXxx
|
||||
├── directives/ # 自定义指令,如 v-hasPerm
|
||||
├── enums/ # 枚举与常量字典
|
||||
├── layouts/ # 布局骨架(侧边栏/顶栏/标签栏/内容区)
|
||||
├── router/ # 静态路由 + 动态路由 + 守卫
|
||||
├── store/modules/ # Pinia 模块
|
||||
├── styles/ # 全局样式、变量、Element Plus 覆盖
|
||||
├── types/ # 全局与模块类型声明(*.d.ts 或 *.ts)
|
||||
├── utils/ # request / storage / 通用工具
|
||||
└── views/ # 页面,按业务模块划分
|
||||
└── {module}/
|
||||
├── index.vue # 列表主页(必须)
|
||||
└── components/ # 仅该模块使用的子组件(表单弹窗等)
|
||||
```
|
||||
|
||||
命名规则:
|
||||
- 目录、文件名统一 **kebab-case**;Vue 组件文件用 **PascalCase.vue** 或目录 + `index.vue`。
|
||||
- 页面路由 path 与 `views/{module}` 目录保持一致。
|
||||
|
||||
## 常用命令
|
||||
```bash
|
||||
pnpm dev # 本地开发
|
||||
pnpm build:prod # 生产构建
|
||||
pnpm preview # 预览构建产物
|
||||
pnpm lint # ESLint 检查并修复
|
||||
pnpm type-check # vue-tsc 类型检查
|
||||
```
|
||||
|
||||
## 环境
|
||||
- 环境变量文件:`.env.development` / `.env.production`,变量必须以 `VITE_` 开头。
|
||||
- 接口地址统一走 `VITE_APP_BASE_API`,本地通过 Vite `server.proxy` 解决跨域,**不允许在代码里硬编码域名或端口**。
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
description: TypeScript / Vue3 通用编码规范,适用于所有 .ts / .vue / .scss 文件
|
||||
globs: ["src/**/*.{ts,vue,js,scss,css}"]
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# 编码规范
|
||||
|
||||
## TypeScript
|
||||
1. **禁止 `any`**。不确定类型用 `unknown` 后收窄,或定义显式类型;实在无法确定的第三方数据用 `Record<string, unknown>`。
|
||||
2. 业务数据结构必须有 **显式 interface**,统一放在 `src/types/`,命名 `XxxVO / XxxForm / XxxQuery`,不内联在组件里。
|
||||
3. 使用 `import type { Xxx } from '@/types/xxx'` 导入纯类型,避免运行时冗余引入。
|
||||
4. 枚举统一定义在 `src/enums/`,使用 `const` 对象 + `as const` 或 `enum`,**禁止散落的魔法字符串/数字**。
|
||||
5. 空值处理优先用可选链 `?.` 与空值合并 `??`,避免多层 `&&` 嵌套。
|
||||
6. 不允许使用 `// @ts-ignore`、`eslint-disable` 掩盖问题;确有必要的必须写清原因。
|
||||
|
||||
## Vue 组件
|
||||
1. 组件一律 `<script setup lang="ts">`,禁止 Options API。
|
||||
2. `defineProps` / `defineEmits` 必须带类型:
|
||||
```vue
|
||||
const props = defineProps<{ modelValue: string; disabled?: boolean }>()
|
||||
const emit = defineEmits<{ (e: 'update:modelValue', v: string): void }>()
|
||||
```
|
||||
3. Props 命名 camelCase,模板中绑定用 kebab-case;禁止在子组件内直接修改 props。
|
||||
4. 单向数据流:子组件通过 emit 或 `defineExpose` 暴露方法,父组件通过 `ref` 调用。
|
||||
5. 响应式选择:基础数据用 `ref`,对象表单用 `reactive`;**禁止对 `reactive` 整体重新赋值**,需要重置时用 `Object.assign`。
|
||||
6. 副作用统一写在 `watch` / `watchEffect` / `onMounted`,并注意在 `onUnmounted` 清理定时器与事件监听。
|
||||
7. 计算属性必须无副作用;有副作用的逻辑放到方法或 watch。
|
||||
8. `v-if` 与 `v-for` 不得同处一个元素;`v-for` 必须提供稳定 `:key`(禁止用 index)。
|
||||
9. 组件文件超过 300 行必须拆分;表单弹窗拆成独立子组件。
|
||||
|
||||
## 模板与样式
|
||||
1. 模板保持简洁,复杂判断抽成 computed,禁止在模板里写长表达式。
|
||||
2. 样式必须 `<style scoped lang="scss">`;覆盖 Element Plus 内部样式用 `:deep()`,且必须限定父级作用域,禁止全局裸写。
|
||||
3. 颜色、圆角、间距等使用 `src/styles/variables.scss` 中的变量,**禁止硬编码色值**。
|
||||
4. 布局优先使用 Element Plus 的 `el-row/el-col` 与 flex 工具类,不写魔法 `margin-top: 17px`。
|
||||
|
||||
## 注释与命名
|
||||
1. 注释只解释"为什么",不解释"做了什么"。禁止无意义注释、禁止注释掉的代码块。
|
||||
2. 函数/变量命名见名知意,布尔值用 `is/has/can` 前缀,事件处理用 `handleXxx`,接口调用用 `fetchXxx / createXxx / updateXxx / deleteXxx`。
|
||||
3. 代码中不得出现 `console.log`、调试代码与 TODO 之外的临时标记。
|
||||
|
||||
## Git 提交
|
||||
使用 Conventional Commits:`feat|fix|refactor|perf|style|docs|chore|test(scope): subject`,一次提交只做一件事。
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
description: 接口层与类型定义规范 —— 所有 src/api、src/types、src/utils/request.ts 相关代码必须遵守
|
||||
globs: ["src/api/**/*.ts", "src/types/**/*.ts", "src/utils/**/*.ts", "src/**/*.d.ts"]
|
||||
---
|
||||
|
||||
# 接口层与类型规范
|
||||
|
||||
## 铁律
|
||||
1. **组件中不得出现 axios / request 的直接调用**,必须与后端交互的逻辑全部收敛到 `src/api/{module}/index.ts`。
|
||||
2. **接口返回结构不得在组件里二次解包判断**,统一由 `request` 拦截器处理。
|
||||
3. 未在 `src/types/` 定义类型的接口不得上线。
|
||||
|
||||
## 请求封装(`src/utils/request.ts`)
|
||||
基于 Axios 单例,必须实现:
|
||||
|
||||
```ts
|
||||
// 统一响应结构
|
||||
interface ApiResponse<T> {
|
||||
code: string | number
|
||||
data: T
|
||||
msg: string
|
||||
}
|
||||
```
|
||||
|
||||
- 请求拦截器:注入 `Authorization` Token、统一处理 GET 参数序列化。
|
||||
- 响应拦截器:
|
||||
- `code` 为成功值时**直接返回 `response.data.data`**(业务数据),调用方无需 `.data.data`。
|
||||
- 业务失败:统一 `ElMessage.error(msg)`,并返回 `Promise.reject`。
|
||||
- `401`:清除用户态 → 跳转登录;`403`:提示无权限。
|
||||
- 网络错误 / 超时:统一提示,禁止调用方各自 try/catch 弹提示。
|
||||
- 导出 `request<T>(config): Promise<T>`,泛型为业务数据类型。
|
||||
|
||||
## API 模块写法
|
||||
每个业务模块一个文件,导出命名空间化的 API 对象:
|
||||
|
||||
```ts
|
||||
// src/api/system/user/index.ts
|
||||
import request from '@/utils/request'
|
||||
import type { UserVO, UserQuery, UserForm } from '@/types/system/user'
|
||||
|
||||
const BASE_URL = '/api/v1/users'
|
||||
|
||||
export const UserAPI = {
|
||||
/** 分页查询用户列表 */
|
||||
query(params: UserQuery) {
|
||||
return request<PageResult<UserVO>>({ url: `${BASE_URL}/page`, method: 'get', params })
|
||||
},
|
||||
detail(id: number) {
|
||||
return request<UserVO>({ url: `${BASE_URL}/${id}`, method: 'get' })
|
||||
},
|
||||
create(data: UserForm) {
|
||||
return request<number>({ url: BASE_URL, method: 'post', data })
|
||||
},
|
||||
update(id: number, data: UserForm) {
|
||||
return request<boolean>({ url: `${BASE_URL}/${id}`, method: 'put', data })
|
||||
},
|
||||
delete(ids: string) {
|
||||
return request<boolean>({ url: `${BASE_URL}/${ids}`, method: 'delete' })
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
约定:
|
||||
- URL 前缀集中定义为 `BASE_URL`,禁止散写完整路径。
|
||||
- 方法命名:`query / detail / create / update / delete / export / import / updateStatus` 等,语义固定。
|
||||
- 分页统一返回 `PageResult<T>`(字段 `list` 与 `total`,在 `src/types/common.d.ts` 定义)。
|
||||
|
||||
## 类型定义
|
||||
- 位置:`src/types/{module}/{entity}.ts` 或 `src/types/{module}.d.ts`。
|
||||
- 三类基础模型必须区分:
|
||||
- `XxxQuery`:列表查询参数(含 `pageNum`、`pageSize` 及筛选字段,全部可选)。
|
||||
- `XxxVO`:后端返回的完整实体(含 `id`、`createTime` 等只读字段)。
|
||||
- `XxxForm`:新增/编辑表单(不含只读字段,所有字段可选性按后端要求)。
|
||||
- 禁止用 `XxxVO` 直接充当表单类型;编辑时用 `Object.assign(form, row)` 或显式字段映射。
|
||||
|
||||
## Mock
|
||||
接口未就绪时优先与后端约定字段后先写类型与 API,再在 `mock/` 下补临时数据,**禁止为了跑通页面而放宽类型**。
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
description: 后台页面(列表/表单/详情)开发范式 —— 新增或修改 views 下页面时必读
|
||||
globs: ["src/views/**/*.vue", "src/views/**/*.ts", "src/components/**/*.vue"]
|
||||
---
|
||||
|
||||
# 页面开发范式
|
||||
|
||||
## 标准列表页结构
|
||||
一个规范的列表页自上而下固定为四段:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<!-- 1. 搜索栏:el-form inline + 查询/重置 -->
|
||||
<!-- 2. 工具栏:新增 / 批量删除 / 导入导出 -->
|
||||
<!-- 3. 表格:el-table + 操作列 -->
|
||||
<!-- 4. 分页:el-pagination -->
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
- 搜索栏支持回车查询与"重置"重置到初始查询条件。
|
||||
- 表格必须 `v-loading="loading"`、`border`、操作列 `fixed="right"`。
|
||||
- 分页器 `layout="total, sizes, prev, pager, next, jumper"`,与 `queryParams.pageNum/pageSize` 双向联动。
|
||||
- 查询条件与分页状态变更时用 `watch` 或统一 `handleQuery` 重新拉取,**禁止在模板里调用接口**。
|
||||
|
||||
## 标准数据流
|
||||
```ts
|
||||
const loading = ref(false)
|
||||
const total = ref(0)
|
||||
const list = ref<UserVO[]>([])
|
||||
const queryParams = reactive<UserQuery>({ pageNum: 1, pageSize: 10 })
|
||||
|
||||
async function handleQuery() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { list: rows, total: t } = await UserAPI.query(queryParams)
|
||||
list.value = rows
|
||||
total.value = t
|
||||
} finally {
|
||||
loading.value = false // loading 必须在 finally 里关闭
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(handleQuery)
|
||||
```
|
||||
|
||||
- 请求失败由 `request` 拦截器统一提示,页面**只负责数据与 loading**;确需额外处理时才 `catch`。
|
||||
- `loading` 必须放在 `finally` 关闭,避免异常时永久转圈。
|
||||
|
||||
## 表单弹窗
|
||||
1. 弹窗组件独立文件,父组件通过 `ref` + `defineExpose({ open })` 调用:
|
||||
```vue
|
||||
<UserForm ref="formRef" @submit="handleQuery" />
|
||||
```
|
||||
2. 弹窗内部:`el-dialog` + `el-form`,必须有 `ref`、`rules`、`:model`;关闭时 `resetFields()` 并清空隐藏主键。
|
||||
3. 提交按钮在请求期间设置 `confirmLoading`,防止重复提交。
|
||||
4. 新增与编辑复用同一弹窗,通过是否有 `id` 区分,标题用 computed 切换。
|
||||
|
||||
## 删除与危险操作
|
||||
- 删除必须走 `ElMessageBox.confirm` 二次确认,成功后 `ElMessage.success` + 刷新列表。
|
||||
- 若当前页删完且非第一页,需回退 `pageNum` 再查询。
|
||||
|
||||
## 表单校验
|
||||
- 规则写在 `rules` 常量里(`const rules = reactive<FormRules<UserForm>>({...})`),不在模板内联。
|
||||
- 提交前必须 `await formRef.value.validate()`,校验失败直接返回。
|
||||
- 后端返回字段级错误需映射到 `formRef` 对应字段。
|
||||
|
||||
## 页面内复用
|
||||
- 仅当前页面使用的子组件放 `views/{module}/components/`,**不要**放进全局 `src/components/`。
|
||||
- 跨页面复用的逻辑抽成 `useXxx` 组合式函数,放 `src/composables/`。
|
||||
- 字典/枚举下拉数据统一走 Pinia 的字典 store 缓存,不得每次打开弹窗都重新请求。
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
description: Element Plus 使用规范、权限控制与 Pinia 状态管理约定
|
||||
globs: ["src/**/*.{vue,ts}", "src/styles/**/*.scss", "src/router/**/*.ts", "src/store/**/*.ts", "src/directives/**/*.ts"]
|
||||
---
|
||||
|
||||
# UI、权限与状态管理
|
||||
|
||||
## Element Plus
|
||||
1. 组件与图标通过 `unplugin-vue-components` / `unplugin-auto-import` **按需自动导入**;必须手动补充的(如 `ElMessage`、`ElMessageBox`、`ElLoading`)在 `src/types/element-plus.d.ts` 声明,禁止全量 `import ElementPlus from 'element-plus'`。
|
||||
2. 消息提示统一使用 `ElMessage` / `ElMessageBox` / `ElNotification`,**禁止 `alert` / `confirm` 原生弹窗**。
|
||||
- 成功 `ElMessage.success`,失败由拦截器统一处理,删除确认用 `ElMessageBox.confirm`。
|
||||
- 同一操作不要堆叠多条 message。
|
||||
3. 全局默认配置(size、zIndex、中文语言包)在 `main.ts` 或 `App.vue` 的 `ElConfigProvider` 中统一设置,不在每个组件重复传 `size`。
|
||||
4. 表单统一 `label-width` 与布局:搜索栏 `inline`,弹窗表单 `label-position="right"`,标签宽度按项目约定(默认 `100px`)。
|
||||
5. 表格空数据、图片加载失败等边界状态必须处理(`empty-text`、默认头像)。
|
||||
6. 主题定制通过 `src/styles/element/index.scss` 覆盖 SCSS 变量,**禁止用 `!important` 打补丁**;确需覆盖内部样式时用 `:deep()` 且限定作用域。
|
||||
|
||||
## 权限
|
||||
1. 采用「动态路由 + 按钮级权限」双层控制:
|
||||
- 菜单/页面:后端返回路由表,前端 `router/index.ts` 中动态 `addRoute`,静态路由仅保留登录、404、首页。
|
||||
- 按钮:`v-hasPerm="['sys:user:add']"` 指令,指令实现从用户 store 读取权限集合,无权限时**移除 DOM 节点**而非仅禁用。
|
||||
2. 路由元信息 `meta` 字段固定:`title`、`icon`、`hidden`、`roles`、`keepAlive`、`breadcrumb`。
|
||||
3. 路由守卫必须处理:已登录访问登录页跳转首页、无 Token 跳登录并携带 `redirect`、路由不存在跳 404。
|
||||
4. 权限判断不得散落在业务代码里写字符串比较,统一走指令或 store 中的 `hasPermission()`。
|
||||
|
||||
## Pinia
|
||||
1. 一律使用 **setup store** 语法:
|
||||
```ts
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const token = ref('')
|
||||
const permissions = ref<string[]>([])
|
||||
function resetToken() { token.value = '' }
|
||||
return { token, permissions, resetToken }
|
||||
}, { persist: true })
|
||||
```
|
||||
2. 一个业务域一个 store,放 `src/store/modules/{module}.ts`,index 统一导出。
|
||||
3. 需要持久化的字段(token、主题、标签栏)必须显式声明 `persist` 路径,**禁止整个 store 无脑持久化**,敏感信息不落 localStorage。
|
||||
4. 组件内不得直接修改其他模块的 store 状态,需通过该 store 暴露的 action。
|
||||
5. 接口请求优先放 API 层;store 只做状态存放与跨组件共享,不承载大量业务过程。
|
||||
|
||||
## 路由与页面缓存
|
||||
- 页面组件 `name` 必须与路由 `name` 一致,否则 `<keep-alive>` 的 `include/exclude` 失效。
|
||||
- 列表页默认开启缓存;从详情页返回时保留查询条件,从菜单进入时重置。
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
description: 需求到交付的协作流程 —— 新增功能、修改代码、联调与自测时的执行顺序
|
||||
globs: ["**/*"]
|
||||
---
|
||||
|
||||
# 协作流程
|
||||
|
||||
## 接到需求后的执行顺序
|
||||
1. **先确认契约**:与后端确认接口路径、请求/响应字段、分页结构、错误码;未确认前不得凭猜测写页面。
|
||||
2. **先写类型**:在 `src/types/{module}/` 下定义 `Query / VO / Form`。
|
||||
3. **再写 API**:`src/api/{module}/index.ts`。
|
||||
4. **最后写页面**:列表 → 表单弹窗 → 详情(按需)。
|
||||
5. **自测**:`pnpm type-check` 与 `pnpm lint` 必须通过;再手动走通「空数据 / 查询 / 新增 / 编辑 / 删除 / 无权限 / 接口报错」七条路径。
|
||||
|
||||
## 代码修改原则
|
||||
- 修改前先读完整文件与相关调用方,禁止盲目整文件重写。
|
||||
- 优先用局部小改动( targeted edit )解决;确需重构时先说明影响面。
|
||||
- 不要顺手重构无关代码、不要调整无关格式、不要删除他人注释。
|
||||
- 不要新增未被要求的功能、文档文件(README/md)或测试用例。
|
||||
|
||||
## 产出要求
|
||||
- 生成的代码必须可直接运行:补齐 import、类型、路由注册、store 注册等所有必要接线。
|
||||
- 新增页面必须在对应路由/菜单中注册,并保证能真实访问。
|
||||
- 新增依赖需说明用途与体积影响,优先选择已在技术栈内的方案。
|
||||
|
||||
## 禁止事项
|
||||
- 提交前遗留 `console.log`、调试代码、TODO 未处理项。
|
||||
- 使用已废弃 API(Vue 2 写法、`$refs` 字符串、`filters`、Element UI 组件名)。
|
||||
- 为了跑通而放宽类型、加 `any`、`@ts-ignore`、关闭 ESLint 规则。
|
||||
@@ -0,0 +1,17 @@
|
||||
# 应用标题
|
||||
VITE_APP_TITLE = 'Portal 管理后台'
|
||||
|
||||
# 部署基础路径
|
||||
VITE_APP_BASE_URL = '/'
|
||||
|
||||
# 接口前缀(本地走 Vite proxy)
|
||||
VITE_APP_BASE_API = '/api/v1'
|
||||
|
||||
# 后端真实地址
|
||||
VITE_APP_API_URL = 'http://localhost:8080'
|
||||
|
||||
# 是否启用本地 mock(true 时拦截 /api/v1 请求)
|
||||
VITE_USE_MOCK = 'true'
|
||||
|
||||
# 开发端口
|
||||
VITE_APP_PORT = '3000'
|
||||
@@ -0,0 +1,6 @@
|
||||
VITE_APP_TITLE = 'Portal 管理后台'
|
||||
VITE_APP_BASE_URL = '/'
|
||||
VITE_APP_BASE_API = '/api/v1'
|
||||
VITE_APP_API_URL = 'https://api.example.com'
|
||||
VITE_USE_MOCK = 'false'
|
||||
VITE_APP_PORT = '3000'
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.md
|
||||
*.json
|
||||
*.d.ts
|
||||
.idea
|
||||
.vscode
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"globals": {}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
browser: true,
|
||||
node: true,
|
||||
es2022: true,
|
||||
},
|
||||
parser: 'vue-eslint-parser',
|
||||
parserOptions: {
|
||||
parser: '@typescript-eslint/parser',
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
},
|
||||
plugins: ['@typescript-eslint'],
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:vue/vue3-recommended',
|
||||
'prettier',
|
||||
'./.eslintrc-auto-import.json',
|
||||
],
|
||||
rules: {
|
||||
// Vue
|
||||
'vue/multi-word-component-names': 'off',
|
||||
'vue/component-api-style': ['error', ['script-setup']],
|
||||
'vue/attributes-order': 'warn',
|
||||
'vue/no-v-html': 'warn',
|
||||
'vue/block-lang': [
|
||||
'error',
|
||||
{ script: { lang: 'ts' }, style: { lang: 'scss', allowNoLang: true } },
|
||||
],
|
||||
'vue/component-name-in-template-casing': ['error', 'PascalCase'],
|
||||
|
||||
// TypeScript
|
||||
'@typescript-eslint/no-explicit-any': 'error',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
||||
],
|
||||
'@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }],
|
||||
|
||||
// 通用
|
||||
'no-console': ['warn', { allow: ['warn', 'error'] }],
|
||||
'no-debugger': 'error',
|
||||
eqeqeq: ['error', 'smart'],
|
||||
'prefer-const': 'error',
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ['mock/**/*.ts', 'vite.config.ts', '.eslintrc.cjs', '.prettierrc.cjs'],
|
||||
env: { node: true, browser: false },
|
||||
rules: {
|
||||
'no-console': 'off',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
.DS_Store
|
||||
*.local
|
||||
.idea
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
|
||||
# 日志
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# 自动生成的声明文件(dev 运行时生成)
|
||||
src/types/auto-imports.d.ts
|
||||
src/types/components.d.ts
|
||||
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
npx lint-staged
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
*.md
|
||||
@@ -0,0 +1,14 @@
|
||||
module.exports = {
|
||||
printWidth: 100,
|
||||
tabWidth: 2,
|
||||
useTabs: false,
|
||||
semi: false,
|
||||
singleQuote: true,
|
||||
quoteProps: 'as-needed',
|
||||
trailingComma: 'all',
|
||||
bracketSpacing: true,
|
||||
arrowParens: 'always',
|
||||
endOfLine: 'lf',
|
||||
vueIndentScriptAndStyle: true,
|
||||
htmlWhitespaceSensitivity: 'ignore',
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# portal-mini-front
|
||||
|
||||
Portal 后台管理系统前端(Vue 3 + TypeScript + Vite + Element Plus)。
|
||||
@@ -0,0 +1,31 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
|
||||
declare module '*.scss' {
|
||||
const css: Record<string, string>
|
||||
export default css
|
||||
}
|
||||
|
||||
interface ImportMetaEnv {
|
||||
/** 应用标题 */
|
||||
readonly VITE_APP_TITLE: string
|
||||
/** 部署基础路径 */
|
||||
readonly VITE_APP_BASE_URL: string
|
||||
/** 接口前缀,本地开发经 Vite proxy 转发 */
|
||||
readonly VITE_APP_BASE_API: string
|
||||
/** 接口真实地址(仅 mock 关闭、未走代理时使用) */
|
||||
readonly VITE_APP_API_URL: string
|
||||
/** 是否启用本地 mock */
|
||||
readonly VITE_USE_MOCK: string
|
||||
/** 端口 */
|
||||
readonly VITE_APP_PORT: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Portal 管理后台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div class="app-loading">
|
||||
<div class="app-loading__spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,66 @@
|
||||
export const USER_STATUS = {
|
||||
ENABLED: 1,
|
||||
DISABLED: 0,
|
||||
} as const
|
||||
|
||||
export type UserStatus = (typeof USER_STATUS)[keyof typeof USER_STATUS]
|
||||
|
||||
export interface MockUser {
|
||||
id: number
|
||||
username: string
|
||||
nickname: string
|
||||
mobile: string
|
||||
email: string
|
||||
status: UserStatus
|
||||
deptName: string
|
||||
roleNames: string[]
|
||||
createTime: string
|
||||
}
|
||||
|
||||
export const users: MockUser[] = Array.from({ length: 36 }, (_, index) => {
|
||||
const id = index + 1
|
||||
return {
|
||||
id,
|
||||
username: `user${String(id).padStart(3, '0')}`,
|
||||
nickname: `测试用户${id}`,
|
||||
mobile: `13${String(100000000 + id).slice(0, 9)}`,
|
||||
email: `user${id}@example.com`,
|
||||
status: id % 5 === 0 ? USER_STATUS.DISABLED : USER_STATUS.ENABLED,
|
||||
deptName: id % 3 === 0 ? '运营部' : '技术部',
|
||||
roleNames: id % 4 === 0 ? ['管理员'] : ['普通用户'],
|
||||
createTime: new Date(Date.now() - id * 86400000).toISOString(),
|
||||
}
|
||||
})
|
||||
|
||||
export const menuRoutes = [
|
||||
{
|
||||
path: '/system',
|
||||
component: 'Layout',
|
||||
redirect: '/system/user',
|
||||
meta: { title: '系统管理', icon: 'Setting', hidden: false },
|
||||
children: [
|
||||
{
|
||||
path: 'user',
|
||||
component: 'system/user/index',
|
||||
name: 'User',
|
||||
meta: { title: '用户管理', icon: 'User', hidden: false },
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export const dashboardSummary = {
|
||||
userTotal: 1024,
|
||||
orderTotal: 8672,
|
||||
gmv: 1289340.55,
|
||||
pendingApproval: 12,
|
||||
trend: [
|
||||
{ date: '10-01', value: 120 },
|
||||
{ date: '10-02', value: 200 },
|
||||
{ date: '10-03', value: 150 },
|
||||
{ date: '10-04', value: 320 },
|
||||
{ date: '10-05', value: 280 },
|
||||
{ date: '10-06', value: 410 },
|
||||
{ date: '10-07', value: 365 },
|
||||
],
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
import { dashboardSummary, menuRoutes, USER_STATUS, users } from './data'
|
||||
|
||||
export interface MockContext {
|
||||
url: string
|
||||
params: string[]
|
||||
query: Record<string, string>
|
||||
body: Record<string, any>
|
||||
}
|
||||
|
||||
export interface MockRule {
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE'
|
||||
pattern: RegExp
|
||||
handler: (ctx: MockContext) => unknown
|
||||
}
|
||||
|
||||
const toNumber = (value: unknown, fallback = 0) => {
|
||||
const n = Number(value)
|
||||
return Number.isFinite(n) ? n : fallback
|
||||
}
|
||||
|
||||
export const mockRules: MockRule[] = [
|
||||
{
|
||||
method: 'POST',
|
||||
pattern: /^\/api\/v1\/auth\/login$/,
|
||||
handler: (ctx) => {
|
||||
const { username, password } = ctx.body
|
||||
if (!username || !password) {
|
||||
return { code: 'A0120', msg: '用户名和密码不能为空' }
|
||||
}
|
||||
return {
|
||||
accessToken: 'mock-access-token',
|
||||
refreshToken: 'mock-refresh-token',
|
||||
tokenType: 'Bearer',
|
||||
expiresIn: 7200,
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
pattern: /^\/api\/v1\/auth\/logout$/,
|
||||
handler: () => true,
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
pattern: /^\/api\/v1\/auth\/me$/,
|
||||
handler: () => ({
|
||||
userId: 1,
|
||||
username: 'admin',
|
||||
nickname: '超级管理员',
|
||||
avatar: '',
|
||||
roles: ['ADMIN'],
|
||||
permissions: ['sys:user:add', 'sys:user:edit', 'sys:user:delete', 'sys:user:query'],
|
||||
}),
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
pattern: /^\/api\/v1\/auth\/routes$/,
|
||||
handler: () => menuRoutes,
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
pattern: /^\/api\/v1\/users\/page$/,
|
||||
handler: (ctx) => {
|
||||
const { username, status, pageNum = '1', pageSize = '10' } = ctx.query
|
||||
const filtered = users.filter((item) => {
|
||||
const matchName = !username || item.username.includes(username)
|
||||
const matchStatus = !status || String(item.status) === status
|
||||
return matchName && matchStatus
|
||||
})
|
||||
const start = (toNumber(pageNum, 1) - 1) * toNumber(pageSize, 10)
|
||||
return {
|
||||
list: filtered.slice(start, start + toNumber(pageSize, 10)),
|
||||
total: filtered.length,
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
pattern: /^\/api\/v1\/users\/(\d+)$/,
|
||||
handler: (ctx) => users.find((item) => item.id === toNumber(ctx.params[0])) ?? null,
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
pattern: /^\/api\/v1\/users$/,
|
||||
handler: (ctx) => {
|
||||
const id = users.length + 1
|
||||
users.unshift({
|
||||
id,
|
||||
username: ctx.body.username ?? `user${id}`,
|
||||
nickname: ctx.body.nickname ?? '',
|
||||
mobile: ctx.body.mobile ?? '',
|
||||
email: ctx.body.email ?? '',
|
||||
status: ctx.body.status ?? USER_STATUS.ENABLED,
|
||||
deptName: ctx.body.deptName ?? '技术部',
|
||||
roleNames: ctx.body.roleNames ?? ['普通用户'],
|
||||
createTime: new Date().toISOString(),
|
||||
})
|
||||
return id
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'PUT',
|
||||
pattern: /^\/api\/v1\/users\/(\d+)$/,
|
||||
handler: (ctx) => {
|
||||
const target = users.find((item) => item.id === toNumber(ctx.params[0]))
|
||||
if (!target) return false
|
||||
Object.assign(target, ctx.body)
|
||||
return true
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'DELETE',
|
||||
pattern: /^\/api\/v1\/users\/([\d,]+)$/,
|
||||
handler: (ctx) => {
|
||||
const ids = ctx.params[0].split(',').map((id) => toNumber(id))
|
||||
ids.forEach((id) => {
|
||||
const index = users.findIndex((item) => item.id === id)
|
||||
if (index > -1) users.splice(index, 1)
|
||||
})
|
||||
return true
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
pattern: /^\/api\/v1\/dashboard\/summary$/,
|
||||
handler: () => dashboardSummary,
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Plugin } from 'vite'
|
||||
import { mockRules, type MockContext } from './index'
|
||||
|
||||
const MOCK_PREFIX = '/api/v1'
|
||||
|
||||
function readBody(req: NodeJS.ReadableStream): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
let raw = ''
|
||||
req.on('data', (chunk) => (raw += chunk))
|
||||
req.on('end', () => resolve(raw))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地 mock 中间件:仅在 dev 且 VITE_USE_MOCK=true 时拦截 /api/v1 请求。
|
||||
* 不引入三方 mock 依赖,避免污染生产依赖树。
|
||||
*/
|
||||
export function mockPlugin(enabled = true): Plugin {
|
||||
return {
|
||||
name: 'portal-mock',
|
||||
apply: 'serve',
|
||||
configureServer(server) {
|
||||
if (!enabled) return
|
||||
server.middlewares.use((req, res, next) => {
|
||||
if (!req.url?.startsWith(MOCK_PREFIX)) return next()
|
||||
|
||||
const requestUrl = new URL(req.url, 'http://localhost')
|
||||
void (async () => {
|
||||
const raw = req.method === 'GET' ? '' : await readBody(req)
|
||||
const rule = mockRules.find(
|
||||
(item) =>
|
||||
item.method === (req.method ?? 'GET').toUpperCase() &&
|
||||
item.pattern.test(requestUrl.pathname),
|
||||
)
|
||||
|
||||
if (!rule) return next()
|
||||
|
||||
const matched = requestUrl.pathname.match(rule.pattern)
|
||||
const ctx: MockContext = {
|
||||
url: requestUrl.pathname,
|
||||
params: matched ? matched.slice(1) : [],
|
||||
query: Object.fromEntries(requestUrl.searchParams.entries()),
|
||||
body: raw ? JSON.parse(raw) : {},
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'application/json;charset=utf-8')
|
||||
res.end(JSON.stringify({ code: '00000', data: rule.handler(ctx) ?? null, msg: 'success' }))
|
||||
})()
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"name": "portal-mini-front",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Portal 后台管理系统前端",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build:prod": "vue-tsc --noEmit && vite build",
|
||||
"build:test": "vue-tsc --noEmit && vite build --mode test",
|
||||
"preview": "vite preview",
|
||||
"type-check": "vue-tsc --noEmit",
|
||||
"lint": "eslint . --ext .ts,.vue --fix",
|
||||
"format": "prettier --write \"src/**/*.{ts,vue,scss,json}\"",
|
||||
"prepare": "husky install || true"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"@vueuse/core": "^11.1.0",
|
||||
"axios": "^1.7.7",
|
||||
"element-plus": "^2.8.4",
|
||||
"nprogress": "^0.2.0",
|
||||
"path-browserify": "^1.0.1",
|
||||
"pinia": "^2.2.4",
|
||||
"vue": "^3.5.12",
|
||||
"vue-router": "^4.4.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.7.5",
|
||||
"@types/nprogress": "^0.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
"@vitejs/plugin-vue": "^5.1.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"eslint-plugin-vue": "^9.28.0",
|
||||
"husky": "^8.0.3",
|
||||
"lint-staged": "^15.2.10",
|
||||
"prettier": "^3.3.3",
|
||||
"sass": "^1.79.5",
|
||||
"typescript": "~5.5.4",
|
||||
"unplugin-auto-import": "^0.18.6",
|
||||
"unplugin-vue-components": "^0.27.4",
|
||||
"vite": "^5.4.9",
|
||||
"vue-eslint-parser": "^9.4.3",
|
||||
"vue-tsc": "^2.1.6"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{ts,vue}": [
|
||||
"eslint --fix"
|
||||
],
|
||||
"*.{ts,vue,scss,json,md}": [
|
||||
"prettier --write"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"jsx": "preserve",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
"types": ["vite/client", "node"]
|
||||
},
|
||||
"include": [
|
||||
"env.d.ts",
|
||||
"src/**/*.ts",
|
||||
"src/**/*.d.ts",
|
||||
"src/**/*.vue"
|
||||
],
|
||||
"exclude": ["node_modules", "dist"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["vite.config.ts", "mock/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import AutoImport from 'unplugin-auto-import/vite'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
||||
import { mockPlugin } from './mock/plugin'
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
|
||||
return {
|
||||
base: env.VITE_APP_BASE_URL || '/',
|
||||
plugins: [
|
||||
vue(),
|
||||
AutoImport({
|
||||
imports: ['vue', 'vue-router', 'pinia', '@vueuse/core'],
|
||||
resolvers: [ElementPlusResolver()],
|
||||
dts: 'src/types/auto-imports.d.ts',
|
||||
eslintrc: { enabled: true, filepath: './.eslintrc-auto-import.json' },
|
||||
}),
|
||||
Components({
|
||||
resolvers: [ElementPlusResolver()],
|
||||
dts: 'src/types/components.d.ts',
|
||||
dirs: ['src/components'],
|
||||
}),
|
||||
mockPlugin(env.VITE_USE_MOCK === 'true'),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
additionalData: `@use "@/styles/variables.scss" as *;`,
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: true,
|
||||
port: Number(env.VITE_APP_PORT) || 3000,
|
||||
open: true,
|
||||
proxy: {
|
||||
[env.VITE_APP_BASE_API]: {
|
||||
target: env.VITE_APP_API_URL,
|
||||
changeOrigin: true,
|
||||
rewrite: (path: string) => path.replace(new RegExp(`^${env.VITE_APP_BASE_API}`), ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
chunkSizeWarningLimit: 2000,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
vue: ['vue', 'vue-router', 'pinia'],
|
||||
elementPlus: ['element-plus', '@element-plus/icons-vue'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user