From 4e9f3dcb134ddb4f43e660203d9cb8b5b5476588 Mon Sep 17 00:00:00 2001 From: wangrui233 Date: Sun, 25 Oct 2026 23:14:53 +0800 Subject: [PATCH] first commit --- .codebuddy/rules/00-project-overview.md | 68 ++++++++++++ .codebuddy/rules/01-code-style.md | 44 ++++++++ .codebuddy/rules/02-api-and-types.md | 77 ++++++++++++++ .codebuddy/rules/03-page-development.md | 72 +++++++++++++ .codebuddy/rules/04-ui-and-permission.md | 43 ++++++++ .codebuddy/rules/05-workflow.md | 29 +++++ .env.development | 17 +++ .env.production | 6 ++ .eslintignore | 8 ++ .eslintrc-auto-import.json | 3 + .eslintrc.cjs | 57 ++++++++++ .gitignore | 20 ++++ .husky/pre-commit | 4 + .prettierignore | 6 ++ .prettierrc.cjs | 14 +++ README.md | 3 + env.d.ts | 31 ++++++ index.html | 16 +++ mock/data.ts | 66 ++++++++++++ mock/index.ts | 128 +++++++++++++++++++++++ mock/plugin.ts | 52 +++++++++ package.json | 58 ++++++++++ tsconfig.json | 33 ++++++ tsconfig.node.json | 12 +++ vite.config.ts | 65 ++++++++++++ 25 files changed, 932 insertions(+) create mode 100644 .codebuddy/rules/00-project-overview.md create mode 100644 .codebuddy/rules/01-code-style.md create mode 100644 .codebuddy/rules/02-api-and-types.md create mode 100644 .codebuddy/rules/03-page-development.md create mode 100644 .codebuddy/rules/04-ui-and-permission.md create mode 100644 .codebuddy/rules/05-workflow.md create mode 100644 .env.development create mode 100644 .env.production create mode 100644 .eslintignore create mode 100644 .eslintrc-auto-import.json create mode 100644 .eslintrc.cjs create mode 100644 .gitignore create mode 100644 .husky/pre-commit create mode 100644 .prettierignore create mode 100644 .prettierrc.cjs create mode 100644 README.md create mode 100644 env.d.ts create mode 100644 index.html create mode 100644 mock/data.ts create mode 100644 mock/index.ts create mode 100644 mock/plugin.ts create mode 100644 package.json create mode 100644 tsconfig.json create mode 100644 tsconfig.node.json create mode 100644 vite.config.ts diff --git a/.codebuddy/rules/00-project-overview.md b/.codebuddy/rules/00-project-overview.md new file mode 100644 index 0000000..de31f07 --- /dev/null +++ b/.codebuddy/rules/00-project-overview.md @@ -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` 解决跨域,**不允许在代码里硬编码域名或端口**。 diff --git a/.codebuddy/rules/01-code-style.md b/.codebuddy/rules/01-code-style.md new file mode 100644 index 0000000..91cecb2 --- /dev/null +++ b/.codebuddy/rules/01-code-style.md @@ -0,0 +1,44 @@ +--- +description: TypeScript / Vue3 通用编码规范,适用于所有 .ts / .vue / .scss 文件 +globs: ["src/**/*.{ts,vue,js,scss,css}"] +alwaysApply: true +--- + +# 编码规范 + +## TypeScript +1. **禁止 `any`**。不确定类型用 `unknown` 后收窄,或定义显式类型;实在无法确定的第三方数据用 `Record`。 +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. 组件一律 ` + + diff --git a/mock/data.ts b/mock/data.ts new file mode 100644 index 0000000..73e6ccf --- /dev/null +++ b/mock/data.ts @@ -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 }, + ], +} diff --git a/mock/index.ts b/mock/index.ts new file mode 100644 index 0000000..96bf1b5 --- /dev/null +++ b/mock/index.ts @@ -0,0 +1,128 @@ +import { dashboardSummary, menuRoutes, USER_STATUS, users } from './data' + +export interface MockContext { + url: string + params: string[] + query: Record + body: Record +} + +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, + }, +] diff --git a/mock/plugin.ts b/mock/plugin.ts new file mode 100644 index 0000000..4a35be2 --- /dev/null +++ b/mock/plugin.ts @@ -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 { + 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' })) + })() + }) + }, + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c56ca0d --- /dev/null +++ b/package.json @@ -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" + ] + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..13256b8 --- /dev/null +++ b/tsconfig.json @@ -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" }] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..ec4ed22 --- /dev/null +++ b/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"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..d59ed3f --- /dev/null +++ b/vite.config.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'], + }, + }, + }, + }, + } +})