feat: 第一阶段基础框架搭建
后端 (Laravel 10 + Sanctum): - RBAC 四层权限系统 (users→roles→permissions→menus) - 门店隔离中间件 (BelongsToStore Trait + StoreIsolation Middleware) - 操作日志中间件 (自动记录写操作) - 权限检查中间件 (CheckPermission) - 16张数据库表迁移 (系统基础+RBAC+字典+配置) - 11个 Eloquent Model - Auth API (登录/登出/用户信息) - 系统设置模块 CRUD (门店/部门/职务/用户/角色/菜单/权限/字典/日志) - 45条 RESTful API 路由 - InitSeeder 初始数据 (超管/角色/76权限/31菜单) 前端 (Vue 3 + Element Plus + Vite): - Axios 请求封装 + Token 注入 - Pinia 状态管理 (user + permission store) - 动态路由 (服务端菜单→前端路由自动生成) - 后台布局 (侧边栏+顶栏+主内容区) - 登录页 + 仪表盘首页 - 系统设置 7 个 CRUD 页面 技术方案文档 (7卷): - 技术总览/数据库设计/API规范/RBAC设计/模块详设/小程序设计/部署方案
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,5 @@
|
||||
# Vue 3 + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2141
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"axios": "^1.13.6",
|
||||
"element-plus": "^2.13.5",
|
||||
"nprogress": "^0.2.0",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.30",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"sass": "^1.98.0",
|
||||
"vite": "^8.0.0"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
import request from '@/utils/request.js'
|
||||
|
||||
/**
|
||||
* Login
|
||||
* @param {{ username: string, password: string }} data
|
||||
*/
|
||||
export function login(data) {
|
||||
return request.post('/auth/login', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout
|
||||
*/
|
||||
export function logout() {
|
||||
return request.post('/auth/logout')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current authenticated user info
|
||||
*/
|
||||
export function getMe() {
|
||||
return request.get('/auth/me')
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import request from '@/utils/request.js'
|
||||
|
||||
// ─── Stores ────────────────────────────────────────────────────────────────
|
||||
|
||||
export const storeApi = {
|
||||
getList: (params) => request.get('/system/stores', { params }),
|
||||
getDetail: (id) => request.get(`/system/stores/${id}`),
|
||||
create: (data) => request.post('/system/stores', data),
|
||||
update: (id, data) => request.put(`/system/stores/${id}`, data),
|
||||
delete: (id) => request.delete(`/system/stores/${id}`)
|
||||
}
|
||||
|
||||
// ─── Departments ────────────────────────────────────────────────────────────
|
||||
|
||||
export const departmentApi = {
|
||||
getList: (params) => request.get('/system/departments', { params }),
|
||||
getDetail: (id) => request.get(`/system/departments/${id}`),
|
||||
create: (data) => request.post('/system/departments', data),
|
||||
update: (id, data) => request.put(`/system/departments/${id}`, data),
|
||||
delete: (id) => request.delete(`/system/departments/${id}`),
|
||||
getTree: () => request.get('/system/departments/tree')
|
||||
}
|
||||
|
||||
// ─── Positions ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const positionApi = {
|
||||
getList: (params) => request.get('/system/positions', { params }),
|
||||
getDetail: (id) => request.get(`/system/positions/${id}`),
|
||||
create: (data) => request.post('/system/positions', data),
|
||||
update: (id, data) => request.put(`/system/positions/${id}`, data),
|
||||
delete: (id) => request.delete(`/system/positions/${id}`)
|
||||
}
|
||||
|
||||
// ─── Users ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export const userApi = {
|
||||
getList: (params) => request.get('/system/users', { params }),
|
||||
getDetail: (id) => request.get(`/system/users/${id}`),
|
||||
create: (data) => request.post('/system/users', data),
|
||||
update: (id, data) => request.put(`/system/users/${id}`, data),
|
||||
delete: (id) => request.delete(`/system/users/${id}`),
|
||||
resetPassword: (id, data) => request.post(`/system/users/${id}/reset-password`, data),
|
||||
updateStatus: (id, status) => request.patch(`/system/users/${id}/status`, { status })
|
||||
}
|
||||
|
||||
// ─── Roles ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export const roleApi = {
|
||||
getList: (params) => request.get('/system/roles', { params }),
|
||||
getDetail: (id) => request.get(`/system/roles/${id}`),
|
||||
create: (data) => request.post('/system/roles', data),
|
||||
update: (id, data) => request.put(`/system/roles/${id}`, data),
|
||||
delete: (id) => request.delete(`/system/roles/${id}`),
|
||||
assignPermissions: (id, permissionIds) =>
|
||||
request.post(`/system/roles/${id}/permissions`, { permission_ids: permissionIds }),
|
||||
getPermissions: (id) => request.get(`/system/roles/${id}/permissions`)
|
||||
}
|
||||
|
||||
// ─── Menus ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export const menuApi = {
|
||||
getList: (params) => request.get('/system/menus', { params }),
|
||||
getTree: () => request.get('/system/menus/tree'),
|
||||
getDetail: (id) => request.get(`/system/menus/${id}`),
|
||||
create: (data) => request.post('/system/menus', data),
|
||||
update: (id, data) => request.put(`/system/menus/${id}`, data),
|
||||
delete: (id) => request.delete(`/system/menus/${id}`)
|
||||
}
|
||||
|
||||
// ─── Permissions ────────────────────────────────────────────────────────────
|
||||
|
||||
export const permissionApi = {
|
||||
getList: (params) => request.get('/system/permissions', { params }),
|
||||
getTree: () => request.get('/system/permissions/tree'),
|
||||
getDetail: (id) => request.get(`/system/permissions/${id}`),
|
||||
create: (data) => request.post('/system/permissions', data),
|
||||
update: (id, data) => request.put(`/system/permissions/${id}`, data),
|
||||
delete: (id) => request.delete(`/system/permissions/${id}`)
|
||||
}
|
||||
|
||||
// ─── Dictionaries ────────────────────────────────────────────────────────────
|
||||
|
||||
export const dictApi = {
|
||||
// Dictionary types
|
||||
getTypeList: (params) => request.get('/system/dict-types', { params }),
|
||||
getTypeDetail: (id) => request.get(`/system/dict-types/${id}`),
|
||||
createType: (data) => request.post('/system/dict-types', data),
|
||||
updateType: (id, data) => request.put(`/system/dict-types/${id}`, data),
|
||||
deleteType: (id) => request.delete(`/system/dict-types/${id}`),
|
||||
|
||||
// Dictionary items
|
||||
getItemList: (typeId, params) =>
|
||||
request.get(`/system/dict-types/${typeId}/items`, { params }),
|
||||
getItemDetail: (typeId, itemId) =>
|
||||
request.get(`/system/dict-types/${typeId}/items/${itemId}`),
|
||||
createItem: (typeId, data) =>
|
||||
request.post(`/system/dict-types/${typeId}/items`, data),
|
||||
updateItem: (typeId, itemId, data) =>
|
||||
request.put(`/system/dict-types/${typeId}/items/${itemId}`, data),
|
||||
deleteItem: (typeId, itemId) =>
|
||||
request.delete(`/system/dict-types/${typeId}/items/${itemId}`)
|
||||
}
|
||||
|
||||
// ─── Operation Logs ─────────────────────────────────────────────────────────
|
||||
|
||||
export const logApi = {
|
||||
getList: (params) => request.get('/system/operation-logs', { params }),
|
||||
getDetail: (id) => request.get(`/system/operation-logs/${id}`)
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
@@ -0,0 +1,93 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import viteLogo from '../assets/vite.svg'
|
||||
import heroImg from '../assets/hero.png'
|
||||
import vueLogo from '../assets/vue.svg'
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="center">
|
||||
<div class="hero">
|
||||
<img :src="heroImg" class="base" width="170" height="179" alt="" />
|
||||
<img :src="vueLogo" class="framework" alt="Vue logo" />
|
||||
<img :src="viteLogo" class="vite" alt="Vite logo" />
|
||||
</div>
|
||||
<div>
|
||||
<h1>Get started</h1>
|
||||
<p>Edit <code>src/App.vue</code> and save to test <code>HMR</code></p>
|
||||
</div>
|
||||
<button class="counter" @click="count++">Count is {{ count }}</button>
|
||||
</section>
|
||||
|
||||
<div class="ticks"></div>
|
||||
|
||||
<section id="next-steps">
|
||||
<div id="docs">
|
||||
<svg class="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#documentation-icon"></use>
|
||||
</svg>
|
||||
<h2>Documentation</h2>
|
||||
<p>Your questions, answered</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://vite.dev/" target="_blank">
|
||||
<img class="logo" :src="viteLogo" alt="" />
|
||||
Explore Vite
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://vuejs.org/" target="_blank">
|
||||
<img class="button-icon" :src="vueLogo" alt="" />
|
||||
Learn more
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="social">
|
||||
<svg class="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#social-icon"></use>
|
||||
</svg>
|
||||
<h2>Connect with us</h2>
|
||||
<p>Join the Vite community</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://github.com/vitejs/vite" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#github-icon"></use>
|
||||
</svg>
|
||||
GitHub
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://chat.vite.dev/" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#discord-icon"></use>
|
||||
</svg>
|
||||
Discord
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://x.com/vite_js" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#x-icon"></use>
|
||||
</svg>
|
||||
X.com
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://bsky.app/profile/vite.dev" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#bluesky-icon"></use>
|
||||
</svg>
|
||||
Bluesky
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="ticks"></div>
|
||||
<section id="spacer"></section>
|
||||
</template>
|
||||
@@ -0,0 +1,186 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user.js'
|
||||
import { ElMessageBox, ElMessage } from 'element-plus'
|
||||
|
||||
const props = defineProps({
|
||||
collapsed: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['toggle-sidebar'])
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const username = computed(() => userStore.userInfo?.name || userStore.userInfo?.username || '用户')
|
||||
const storeName = computed(() => userStore.userInfo?.store?.name || '')
|
||||
|
||||
// Build breadcrumbs from matched routes
|
||||
const breadcrumbs = computed(() => {
|
||||
return route.matched
|
||||
.filter((r) => r.meta?.title && !r.meta?.hidden)
|
||||
.map((r) => ({ title: r.meta.title, path: r.path }))
|
||||
})
|
||||
|
||||
async function handleLogout() {
|
||||
await ElMessageBox.confirm('确定要退出登录吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
await userStore.logout()
|
||||
router.push('/login')
|
||||
ElMessage.success('已安全退出')
|
||||
}
|
||||
|
||||
function goToProfile() {
|
||||
router.push('/system/profile')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="header-wrapper">
|
||||
<!-- Left: collapse toggle + breadcrumb -->
|
||||
<div class="header-left">
|
||||
<el-icon
|
||||
class="collapse-btn"
|
||||
:size="20"
|
||||
@click="emit('toggle-sidebar')"
|
||||
>
|
||||
<Fold v-if="!collapsed" />
|
||||
<Expand v-else />
|
||||
</el-icon>
|
||||
|
||||
<el-breadcrumb separator="/" class="breadcrumb">
|
||||
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
|
||||
<el-breadcrumb-item
|
||||
v-for="(crumb, idx) in breadcrumbs"
|
||||
:key="idx"
|
||||
:to="idx < breadcrumbs.length - 1 ? { path: crumb.path } : undefined"
|
||||
>
|
||||
{{ crumb.title }}
|
||||
</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
</div>
|
||||
|
||||
<!-- Right: store info + user dropdown -->
|
||||
<div class="header-right">
|
||||
<span v-if="storeName" class="store-name">
|
||||
<el-icon><OfficeBuilding /></el-icon>
|
||||
{{ storeName }}
|
||||
</span>
|
||||
|
||||
<el-dropdown trigger="click" placement="bottom-end">
|
||||
<div class="user-info">
|
||||
<el-avatar :size="32" class="user-avatar">
|
||||
{{ username.charAt(0).toUpperCase() }}
|
||||
</el-avatar>
|
||||
<span class="username">{{ username }}</span>
|
||||
<el-icon class="arrow"><ArrowDown /></el-icon>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click="goToProfile">
|
||||
<el-icon><User /></el-icon>
|
||||
个人信息
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item divided @click="handleLogout">
|
||||
<el-icon><SwitchButton /></el-icon>
|
||||
退出登录
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.header-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: var(--header-height);
|
||||
padding: 0 20px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.collapse-btn {
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-primary);
|
||||
background: var(--color-primary-lighter);
|
||||
}
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.store-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 4px 10px;
|
||||
background: var(--color-bg);
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
background: linear-gradient(135deg, #E8A87C, #D4875A);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,198 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user.js'
|
||||
|
||||
const props = defineProps({
|
||||
collapsed: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const activeMenu = computed(() => route.path)
|
||||
|
||||
const menus = computed(() => userStore.menus)
|
||||
|
||||
function resolveIcon(iconName) {
|
||||
return iconName || 'Menu'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sidebar-wrapper" :class="{ collapsed }">
|
||||
<!-- Logo -->
|
||||
<div class="sidebar-logo">
|
||||
<el-icon :size="28" color="#E8A87C"><House /></el-icon>
|
||||
<span v-if="!collapsed" class="logo-text">宫中有喜</span>
|
||||
</div>
|
||||
|
||||
<!-- Menu -->
|
||||
<el-scrollbar class="sidebar-scrollbar">
|
||||
<el-menu
|
||||
:default-active="activeMenu"
|
||||
:collapse="collapsed"
|
||||
:collapse-transition="false"
|
||||
router
|
||||
class="sidebar-menu"
|
||||
background-color="#2D1B0E"
|
||||
text-color="#C8B09A"
|
||||
active-text-color="#E8A87C"
|
||||
>
|
||||
<!-- Always show Dashboard first -->
|
||||
<el-menu-item index="/dashboard">
|
||||
<el-icon><Odometer /></el-icon>
|
||||
<template #title>工作台</template>
|
||||
</el-menu-item>
|
||||
|
||||
<!-- Dynamic menu items from server -->
|
||||
<template v-for="menu in menus" :key="menu.id || menu.path">
|
||||
<!-- Has children → sub-menu -->
|
||||
<el-sub-menu
|
||||
v-if="menu.children && menu.children.length > 0 && menu.visible !== 0"
|
||||
:index="menu.path"
|
||||
>
|
||||
<template #title>
|
||||
<el-icon>
|
||||
<component :is="resolveIcon(menu.icon)" />
|
||||
</el-icon>
|
||||
<span>{{ menu.title }}</span>
|
||||
</template>
|
||||
<template v-for="child in menu.children" :key="child.id || child.path">
|
||||
<el-sub-menu
|
||||
v-if="child.children && child.children.length > 0 && child.visible !== 0"
|
||||
:index="child.path"
|
||||
>
|
||||
<template #title>
|
||||
<el-icon>
|
||||
<component :is="resolveIcon(child.icon)" />
|
||||
</el-icon>
|
||||
<span>{{ child.title }}</span>
|
||||
</template>
|
||||
<el-menu-item
|
||||
v-for="grandchild in child.children"
|
||||
:key="grandchild.id || grandchild.path"
|
||||
:index="grandchild.path"
|
||||
>
|
||||
<el-icon>
|
||||
<component :is="resolveIcon(grandchild.icon)" />
|
||||
</el-icon>
|
||||
<template #title>{{ grandchild.title }}</template>
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
<el-menu-item
|
||||
v-else-if="child.visible !== 0"
|
||||
:index="child.path"
|
||||
>
|
||||
<el-icon>
|
||||
<component :is="resolveIcon(child.icon)" />
|
||||
</el-icon>
|
||||
<template #title>{{ child.title }}</template>
|
||||
</el-menu-item>
|
||||
</template>
|
||||
</el-sub-menu>
|
||||
|
||||
<!-- No children → single item -->
|
||||
<el-menu-item
|
||||
v-else-if="menu.visible !== 0"
|
||||
:index="menu.path"
|
||||
>
|
||||
<el-icon>
|
||||
<component :is="resolveIcon(menu.icon)" />
|
||||
</el-icon>
|
||||
<template #title>{{ menu.title }}</template>
|
||||
</el-menu-item>
|
||||
</template>
|
||||
</el-menu>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.sidebar-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: var(--sidebar-width);
|
||||
height: 100%;
|
||||
background-color: #2D1B0E;
|
||||
transition: width 0.25s ease;
|
||||
overflow: hidden;
|
||||
|
||||
&.collapsed {
|
||||
width: var(--sidebar-collapsed-width);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
height: var(--header-height);
|
||||
padding: 0 16px;
|
||||
background-color: #1E1108;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
|
||||
.logo-text {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #E8A87C;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-scrollbar {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sidebar-menu {
|
||||
border-right: none;
|
||||
height: 100%;
|
||||
|
||||
:deep(.el-menu-item),
|
||||
:deep(.el-sub-menu__title) {
|
||||
height: 48px;
|
||||
line-height: 48px;
|
||||
font-size: 14px;
|
||||
|
||||
&:hover {
|
||||
background-color: #4A2E1A !important;
|
||||
color: #E8A87C !important;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-menu-item.is-active) {
|
||||
background-color: #4A2E1A !important;
|
||||
color: #E8A87C !important;
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background: #E8A87C;
|
||||
border-radius: 0 2px 2px 0;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-sub-menu .el-menu) {
|
||||
background-color: #241509 !important;
|
||||
}
|
||||
|
||||
:deep(.el-menu--collapse) {
|
||||
.el-sub-menu__title span,
|
||||
.el-menu-item span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import Sidebar from './components/Sidebar.vue'
|
||||
import Header from './components/Header.vue'
|
||||
|
||||
const collapsed = ref(false)
|
||||
|
||||
function toggleSidebar() {
|
||||
collapsed.value = !collapsed.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="layout-wrapper">
|
||||
<!-- Sidebar -->
|
||||
<aside class="layout-sidebar" :class="{ collapsed }">
|
||||
<Sidebar :collapsed="collapsed" />
|
||||
</aside>
|
||||
|
||||
<!-- Main area -->
|
||||
<div class="layout-main" :class="{ 'sidebar-collapsed': collapsed }">
|
||||
<!-- Header -->
|
||||
<header class="layout-header">
|
||||
<Header :collapsed="collapsed" @toggle-sidebar="toggleSidebar" />
|
||||
</header>
|
||||
|
||||
<!-- Content -->
|
||||
<main class="layout-content">
|
||||
<router-view v-slot="{ Component, route }">
|
||||
<transition name="fade-slide" mode="out-in">
|
||||
<component :is="Component" :key="route.fullPath" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.layout-wrapper {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.layout-sidebar {
|
||||
width: var(--sidebar-width);
|
||||
flex-shrink: 0;
|
||||
transition: width 0.25s ease;
|
||||
z-index: 100;
|
||||
|
||||
&.collapsed {
|
||||
width: var(--sidebar-collapsed-width);
|
||||
}
|
||||
}
|
||||
|
||||
.layout-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
transition: margin-left 0.25s ease;
|
||||
}
|
||||
|
||||
.layout-header {
|
||||
flex-shrink: 0;
|
||||
height: var(--header-height);
|
||||
}
|
||||
|
||||
.layout-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
// Route transition
|
||||
.fade-slide-enter-active,
|
||||
.fade-slide-leave-active {
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-slide-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
.fade-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,21 @@
|
||||
import { createApp } from 'vue'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import router from './router/index.js'
|
||||
import App from './App.vue'
|
||||
import '@/styles/index.scss'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
// Register all Element Plus icons globally
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus)
|
||||
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,69 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import NProgress from 'nprogress'
|
||||
import 'nprogress/nprogress.css'
|
||||
import { constantRoutes } from '@/router/routes.js'
|
||||
|
||||
NProgress.configure({ showSpinner: false })
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: constantRoutes,
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
if (savedPosition) {
|
||||
return savedPosition
|
||||
}
|
||||
return { top: 0 }
|
||||
}
|
||||
})
|
||||
|
||||
// Whitelist paths that don't require authentication
|
||||
const WHITE_LIST = ['/login', '/404']
|
||||
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
NProgress.start()
|
||||
|
||||
// Lazily import stores to avoid circular dependency at module init time
|
||||
const { useUserStore } = await import('@/stores/user.js')
|
||||
const { usePermissionStore } = await import('@/stores/permission.js')
|
||||
const userStore = useUserStore()
|
||||
const permissionStore = usePermissionStore()
|
||||
|
||||
const token = userStore.token
|
||||
|
||||
if (token) {
|
||||
if (to.path === '/login') {
|
||||
// Already logged in — redirect to home
|
||||
next({ path: '/' })
|
||||
} else {
|
||||
// Check if user info has been fetched
|
||||
if (!userStore.userInfo) {
|
||||
try {
|
||||
await userStore.getInfo()
|
||||
const accessRoutes = permissionStore.generateRoutes(userStore.menus)
|
||||
accessRoutes.forEach((route) => router.addRoute(route))
|
||||
// Replace so the new routes are active
|
||||
next({ ...to, replace: true })
|
||||
} catch (err) {
|
||||
// Token invalid — clear and redirect
|
||||
await userStore.logout()
|
||||
next(`/login?redirect=${to.path}`)
|
||||
}
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No token
|
||||
if (WHITE_LIST.includes(to.path)) {
|
||||
next()
|
||||
} else {
|
||||
next(`/login?redirect=${to.path}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
router.afterEach(() => {
|
||||
NProgress.done()
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Static routes — always available regardless of authentication or permissions.
|
||||
*/
|
||||
export const constantRoutes = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/login/index.vue'),
|
||||
meta: { title: '登录', hidden: true }
|
||||
},
|
||||
{
|
||||
path: '/404',
|
||||
name: 'NotFound',
|
||||
component: () => import('@/views/error/404.vue'),
|
||||
meta: { title: '页面不存在', hidden: true }
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,114 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { constantRoutes } from '@/router/routes.js'
|
||||
|
||||
// Layout wrapper for authenticated pages
|
||||
const Layout = () => import('@/layout/index.vue')
|
||||
|
||||
/**
|
||||
* Convert a single server menu node to a vue-router route object.
|
||||
* Recursive — handles children at any depth.
|
||||
*/
|
||||
function menuToRoute(menu) {
|
||||
const route = {
|
||||
path: menu.path,
|
||||
name: menu.name || menu.path,
|
||||
meta: {
|
||||
title: menu.title,
|
||||
icon: menu.icon || '',
|
||||
id: menu.id,
|
||||
hidden: menu.visible === 0
|
||||
}
|
||||
}
|
||||
|
||||
if (menu.component === 'Layout') {
|
||||
route.component = Layout
|
||||
} else if (menu.component) {
|
||||
// Dynamic import from views directory
|
||||
const comp = menu.component
|
||||
route.component = () => import(`@/views/${comp}.vue`)
|
||||
}
|
||||
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
route.children = menu.children.map(menuToRoute)
|
||||
// If this is a top-level layout node with children, add redirect
|
||||
if (!route.redirect && route.children.length > 0) {
|
||||
route.redirect = route.children[0].path
|
||||
}
|
||||
}
|
||||
|
||||
return route
|
||||
}
|
||||
|
||||
export const usePermissionStore = defineStore('permission', () => {
|
||||
const routes = ref([])
|
||||
const dynamicRoutes = ref([])
|
||||
|
||||
/**
|
||||
* Generate vue-router routes from the server menu tree.
|
||||
* Returns the array of dynamic routes to be added via router.addRoute().
|
||||
*/
|
||||
function generateRoutes(menus) {
|
||||
const generated = []
|
||||
|
||||
// Wrap all menu roots under the Layout component
|
||||
const layoutRoute = {
|
||||
path: '/',
|
||||
name: 'Root',
|
||||
component: Layout,
|
||||
redirect: '/dashboard',
|
||||
children: []
|
||||
}
|
||||
|
||||
// Flatten top-level menu items into Layout's children
|
||||
for (const menu of menus) {
|
||||
if (menu.component === 'Layout' && menu.children) {
|
||||
// This menu node IS the layout wrapper — add its children directly
|
||||
for (const child of menu.children) {
|
||||
layoutRoute.children.push(menuToRoute(child))
|
||||
}
|
||||
} else {
|
||||
layoutRoute.children.push(menuToRoute(menu))
|
||||
}
|
||||
}
|
||||
|
||||
// Always ensure dashboard is accessible
|
||||
const hasDashboard = layoutRoute.children.some(
|
||||
(r) => r.path === '/dashboard' || r.path === 'dashboard'
|
||||
)
|
||||
if (!hasDashboard) {
|
||||
layoutRoute.children.unshift({
|
||||
path: 'dashboard',
|
||||
name: 'Dashboard',
|
||||
component: () => import('@/views/dashboard/index.vue'),
|
||||
meta: { title: '工作台', icon: 'Odometer' }
|
||||
})
|
||||
}
|
||||
|
||||
generated.push(layoutRoute)
|
||||
|
||||
// Catch-all 404 — must be last
|
||||
generated.push({
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: '/404',
|
||||
meta: { hidden: true }
|
||||
})
|
||||
|
||||
dynamicRoutes.value = generated
|
||||
routes.value = [...constantRoutes, ...generated]
|
||||
|
||||
return generated
|
||||
}
|
||||
|
||||
function resetRoutes() {
|
||||
routes.value = []
|
||||
dynamicRoutes.value = []
|
||||
}
|
||||
|
||||
return {
|
||||
routes,
|
||||
dynamicRoutes,
|
||||
generateRoutes,
|
||||
resetRoutes
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { login as loginApi, logout as logoutApi, getMe } from '@/api/auth.js'
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const token = ref(localStorage.getItem('token') || '')
|
||||
const userInfo = ref(null)
|
||||
const permissions = ref([])
|
||||
const menus = ref([])
|
||||
|
||||
// Getters
|
||||
const isLoggedIn = computed(() => !!token.value)
|
||||
const isSuper = computed(() => userInfo.value?.is_super === true || userInfo.value?.is_super === 1)
|
||||
|
||||
// Actions
|
||||
async function login(credentials) {
|
||||
const res = await loginApi(credentials)
|
||||
const { token: newToken, user } = res.data
|
||||
token.value = newToken
|
||||
userInfo.value = user
|
||||
localStorage.setItem('token', newToken)
|
||||
return res
|
||||
}
|
||||
|
||||
async function getInfo() {
|
||||
const res = await getMe()
|
||||
const { user, permissions: perms, menus: menuList } = res.data
|
||||
userInfo.value = user
|
||||
permissions.value = perms || []
|
||||
menus.value = menuList || []
|
||||
return res
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await logoutApi()
|
||||
} catch {
|
||||
// Ignore logout API errors — clear local state regardless
|
||||
} finally {
|
||||
token.value = ''
|
||||
userInfo.value = null
|
||||
permissions.value = []
|
||||
menus.value = []
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('userInfo')
|
||||
}
|
||||
}
|
||||
|
||||
function hasPermission(permCode) {
|
||||
if (isSuper.value) return true
|
||||
return permissions.value.includes(permCode)
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
userInfo,
|
||||
permissions,
|
||||
menus,
|
||||
isLoggedIn,
|
||||
isSuper,
|
||||
login,
|
||||
getInfo,
|
||||
logout,
|
||||
hasPermission
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,296 @@
|
||||
:root {
|
||||
--text: #6b6375;
|
||||
--text-h: #08060d;
|
||||
--bg: #fff;
|
||||
--border: #e5e4e7;
|
||||
--code-bg: #f4f3ec;
|
||||
--accent: #aa3bff;
|
||||
--accent-bg: rgba(170, 59, 255, 0.1);
|
||||
--accent-border: rgba(170, 59, 255, 0.5);
|
||||
--social-bg: rgba(244, 243, 236, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
||||
|
||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--mono: ui-monospace, Consolas, monospace;
|
||||
|
||||
font: 18px/145% var(--sans);
|
||||
letter-spacing: 0.18px;
|
||||
color-scheme: light dark;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--text: #9ca3af;
|
||||
--text-h: #f3f4f6;
|
||||
--bg: #16171d;
|
||||
--border: #2e303a;
|
||||
--code-bg: #1f2028;
|
||||
--accent: #c084fc;
|
||||
--accent-bg: rgba(192, 132, 252, 0.15);
|
||||
--accent-border: rgba(192, 132, 252, 0.5);
|
||||
--social-bg: rgba(47, 48, 58, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
||||
}
|
||||
|
||||
#social .button-icon {
|
||||
filter: invert(1) brightness(2);
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-family: var(--heading);
|
||||
font-weight: 500;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 56px;
|
||||
letter-spacing: -1.68px;
|
||||
margin: 32px 0;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 36px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
}
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
line-height: 118%;
|
||||
letter-spacing: -0.24px;
|
||||
margin: 0 0 8px;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code,
|
||||
.counter {
|
||||
font-family: var(--mono);
|
||||
display: inline-flex;
|
||||
border-radius: 4px;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 15px;
|
||||
line-height: 135%;
|
||||
padding: 4px 8px;
|
||||
background: var(--code-bg);
|
||||
}
|
||||
|
||||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 1126px;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
border-inline: 1px solid var(--border);
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
// Brand color variables
|
||||
:root {
|
||||
--color-primary: #E8A87C;
|
||||
--color-primary-light: #F5C9A0;
|
||||
--color-primary-lighter: #FBE8D5;
|
||||
--color-primary-dark: #D4875A;
|
||||
--color-primary-darker: #B86B3A;
|
||||
--color-secondary: #C97B5E;
|
||||
--color-bg: #F7F3EF;
|
||||
--color-bg-sidebar: #2D1B0E;
|
||||
--color-bg-sidebar-light: #4A2E1A;
|
||||
--color-text-primary: #2C2C2C;
|
||||
--color-text-secondary: #666666;
|
||||
--color-text-placeholder: #AAAAAA;
|
||||
--color-border: #EAEAEA;
|
||||
--color-success: #67C23A;
|
||||
--color-warning: #E6A23C;
|
||||
--color-danger: #F56C6C;
|
||||
--color-info: #909399;
|
||||
--sidebar-width: 220px;
|
||||
--sidebar-collapsed-width: 64px;
|
||||
--header-height: 56px;
|
||||
}
|
||||
|
||||
// Element Plus primary color override
|
||||
:root {
|
||||
--el-color-primary: #E8A87C;
|
||||
--el-color-primary-light-3: #F0BB97;
|
||||
--el-color-primary-light-5: #F5C9A0;
|
||||
--el-color-primary-light-7: #F8D8BB;
|
||||
--el-color-primary-light-8: #FBEACE;
|
||||
--el-color-primary-light-9: #FDF4EC;
|
||||
--el-color-primary-dark-2: #D4875A;
|
||||
}
|
||||
|
||||
// Global reset
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei',
|
||||
'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-primary);
|
||||
background-color: var(--color-bg);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
&:hover {
|
||||
color: var(--color-primary-dark);
|
||||
}
|
||||
}
|
||||
|
||||
// Custom scrollbar
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #cccccc;
|
||||
border-radius: 3px;
|
||||
&:hover {
|
||||
background: #aaaaaa;
|
||||
}
|
||||
}
|
||||
|
||||
// Utility classes
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.flex-1 {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.flex-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.flex-between {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.flex-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.h-full {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.text-primary {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.text-danger {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.text-success {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.text-secondary {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.mt-4 { margin-top: 4px; }
|
||||
.mt-8 { margin-top: 8px; }
|
||||
.mt-12 { margin-top: 12px; }
|
||||
.mt-16 { margin-top: 16px; }
|
||||
.mt-24 { margin-top: 24px; }
|
||||
.mb-4 { margin-bottom: 4px; }
|
||||
.mb-8 { margin-bottom: 8px; }
|
||||
.mb-12 { margin-bottom: 12px; }
|
||||
.mb-16 { margin-bottom: 16px; }
|
||||
.mb-24 { margin-bottom: 24px; }
|
||||
.ml-8 { margin-left: 8px; }
|
||||
.mr-8 { margin-right: 8px; }
|
||||
|
||||
.page-container {
|
||||
padding: 16px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
padding: 16px 20px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.table-container {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 16px 20px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.table-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
// NProgress bar color
|
||||
#nprogress .bar {
|
||||
background: var(--color-primary) !important;
|
||||
}
|
||||
|
||||
#nprogress .peg {
|
||||
box-shadow: 0 0 10px var(--color-primary), 0 0 5px var(--color-primary) !important;
|
||||
}
|
||||
|
||||
// Element Plus overrides
|
||||
.el-button--primary {
|
||||
background-color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: var(--color-primary-light);
|
||||
border-color: var(--color-primary-light);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--color-primary-dark);
|
||||
border-color: var(--color-primary-dark);
|
||||
}
|
||||
}
|
||||
|
||||
.el-table {
|
||||
.el-table__header th {
|
||||
background-color: #FAFAFA;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.el-pagination {
|
||||
margin-top: 16px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.el-tag {
|
||||
border-radius: 4px;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import axios from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const request = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
timeout: 15000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
// Request interceptor — inject Authorization token
|
||||
request.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// Response interceptor — handle business codes and auth errors
|
||||
request.interceptors.response.use(
|
||||
(response) => {
|
||||
const res = response.data
|
||||
|
||||
// Success: code === 0
|
||||
if (res.code === 0) {
|
||||
return res
|
||||
}
|
||||
|
||||
// Auth error: 40100–40199 → clear token and redirect to login
|
||||
if (res.code >= 40100 && res.code <= 40199) {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('userInfo')
|
||||
ElMessage.error('登录已过期,请重新登录')
|
||||
// Avoid circular import by using window.location
|
||||
window.location.href = '/login'
|
||||
return Promise.reject(new Error(res.message || '未授权'))
|
||||
}
|
||||
|
||||
// Other business error
|
||||
ElMessage.error(res.message || '请求失败')
|
||||
return Promise.reject(new Error(res.message || '请求失败'))
|
||||
},
|
||||
(error) => {
|
||||
// Network / HTTP errors
|
||||
if (error.response) {
|
||||
const status = error.response.status
|
||||
if (status === 401) {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('userInfo')
|
||||
ElMessage.error('登录已过期,请重新登录')
|
||||
window.location.href = '/login'
|
||||
return Promise.reject(error)
|
||||
}
|
||||
if (status === 403) {
|
||||
ElMessage.error('无权限访问')
|
||||
return Promise.reject(error)
|
||||
}
|
||||
if (status === 404) {
|
||||
ElMessage.error('请求的资源不存在')
|
||||
return Promise.reject(error)
|
||||
}
|
||||
if (status >= 500) {
|
||||
ElMessage.error('服务器内部错误,请稍后重试')
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
if (error.code === 'ECONNABORTED' || error.message.includes('timeout')) {
|
||||
ElMessage.error('请求超时,请检查网络连接')
|
||||
return Promise.reject(error)
|
||||
}
|
||||
ElMessage.error(error.message || '网络错误')
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default request
|
||||
@@ -0,0 +1,299 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useUserStore } from '@/stores/user.js'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const username = computed(() => userStore.userInfo?.name || userStore.userInfo?.username || '用户')
|
||||
|
||||
const today = computed(() => {
|
||||
const d = new Date()
|
||||
return `${d.getFullYear()} 年 ${d.getMonth() + 1} 月 ${d.getDate()} 日`
|
||||
})
|
||||
|
||||
const weekDay = computed(() => {
|
||||
const days = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
|
||||
return days[new Date().getDay()]
|
||||
})
|
||||
|
||||
const statCards = ref([
|
||||
{
|
||||
title: '在住宾客',
|
||||
value: '--',
|
||||
unit: '位',
|
||||
icon: 'User',
|
||||
color: '#E8A87C',
|
||||
bg: '#FBE8D5'
|
||||
},
|
||||
{
|
||||
title: '今日入住',
|
||||
value: '--',
|
||||
unit: '单',
|
||||
icon: 'House',
|
||||
color: '#67C23A',
|
||||
bg: '#E8F5E1'
|
||||
},
|
||||
{
|
||||
title: '今日退住',
|
||||
value: '--',
|
||||
unit: '单',
|
||||
icon: 'SuitcaseLine',
|
||||
color: '#409EFF',
|
||||
bg: '#E0EFFF'
|
||||
},
|
||||
{
|
||||
title: '今日收款',
|
||||
value: '--',
|
||||
unit: '元',
|
||||
icon: 'Money',
|
||||
color: '#E6A23C',
|
||||
bg: '#FDF0D6'
|
||||
},
|
||||
{
|
||||
title: '客房出租率',
|
||||
value: '--',
|
||||
unit: '%',
|
||||
icon: 'DataAnalysis',
|
||||
color: '#C97B5E',
|
||||
bg: '#F9E8E0'
|
||||
},
|
||||
{
|
||||
title: '本月新签',
|
||||
value: '--',
|
||||
unit: '单',
|
||||
icon: 'DocumentAdd',
|
||||
color: '#909399',
|
||||
bg: '#F0F1F2'
|
||||
}
|
||||
])
|
||||
|
||||
const shortcuts = [
|
||||
{ label: '新增客户', icon: 'UserFilled', path: '/crm/customers/add' },
|
||||
{ label: '办理入住', icon: 'House', path: '/room/check-in' },
|
||||
{ label: '办理退住', icon: 'DoorOpen', path: '/room/check-out' },
|
||||
{ label: '新建工单', icon: 'DocumentAdd', path: '/service/orders/add' },
|
||||
{ label: '今日月子餐', icon: 'Bowl', path: '/meal/today' },
|
||||
{ label: '护理记录', icon: 'FirstAidKit', path: '/nursing/records' }
|
||||
]
|
||||
|
||||
// Placeholder — in production would fetch from API
|
||||
onMounted(() => {
|
||||
// Future: fetchDashboardStats()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<!-- Welcome Banner -->
|
||||
<div class="welcome-banner card">
|
||||
<div class="welcome-info">
|
||||
<h2 class="welcome-title">你好,{{ username }} 👋</h2>
|
||||
<p class="welcome-sub">{{ today }} {{ weekDay }},祝您工作愉快!</p>
|
||||
</div>
|
||||
<div class="welcome-deco">
|
||||
<el-icon :size="80" color="rgba(232,168,124,0.2)"><House /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="stats-grid mt-16">
|
||||
<div
|
||||
v-for="stat in statCards"
|
||||
:key="stat.title"
|
||||
class="stat-card card"
|
||||
>
|
||||
<div class="stat-icon-wrap" :style="{ background: stat.bg }">
|
||||
<el-icon :size="28" :style="{ color: stat.color }">
|
||||
<component :is="stat.icon" />
|
||||
</el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-label">{{ stat.title }}</div>
|
||||
<div class="stat-value">
|
||||
<span class="stat-number" :style="{ color: stat.color }">{{ stat.value }}</span>
|
||||
<span class="stat-unit">{{ stat.unit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Access -->
|
||||
<div class="section-title mt-24">快捷入口</div>
|
||||
<div class="shortcuts-grid mt-12">
|
||||
<router-link
|
||||
v-for="item in shortcuts"
|
||||
:key="item.label"
|
||||
:to="item.path"
|
||||
class="shortcut-card card"
|
||||
>
|
||||
<el-icon :size="28" color="#E8A87C">
|
||||
<component :is="item.icon" />
|
||||
</el-icon>
|
||||
<span class="shortcut-label">{{ item.label }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- System Info -->
|
||||
<div class="system-info card mt-16">
|
||||
<div class="info-title">系统信息</div>
|
||||
<div class="info-grid mt-12">
|
||||
<div class="info-item">
|
||||
<span class="info-key">系统名称</span>
|
||||
<span class="info-val">宫中有喜月子会所综合管理系统</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-key">当前版本</span>
|
||||
<span class="info-val">v1.0.0</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-key">技术支持</span>
|
||||
<span class="info-val">宫中有喜运营团队</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.welcome-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: linear-gradient(135deg, #FBE8D5, #F5D5BC);
|
||||
border: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.welcome-title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--color-primary-dark);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.welcome-sub {
|
||||
font-size: 14px;
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
|
||||
.welcome-deco {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.stat-icon-wrap {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-unit {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
padding-left: 8px;
|
||||
border-left: 3px solid var(--color-primary);
|
||||
}
|
||||
|
||||
.shortcuts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.shortcut-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 24px 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 16px rgba(232, 168, 124, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.shortcut-label {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.system-info {
|
||||
.info-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.info-key {
|
||||
color: var(--color-text-secondary);
|
||||
min-width: 80px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info-val {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="error-page flex-center flex-col">
|
||||
<div class="code">404</div>
|
||||
<div class="message">抱歉,您访问的页面不存在</div>
|
||||
<el-button type="primary" @click="router.push('/')">返回首页</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.error-page {
|
||||
height: 100vh;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.code {
|
||||
font-size: 120px;
|
||||
font-weight: 700;
|
||||
color: var(--color-primary);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.message {
|
||||
font-size: 18px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,220 @@
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user.js'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const formRef = ref(null)
|
||||
const loading = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: ''
|
||||
})
|
||||
|
||||
const rules = {
|
||||
username: [
|
||||
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
||||
{ min: 2, max: 50, message: '用户名长度为 2-50 个字符', trigger: 'blur' }
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 6, message: '密码长度不少于 6 位', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
async function handleLogin() {
|
||||
if (!formRef.value) return
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
loading.value = true
|
||||
try {
|
||||
await userStore.login({ username: form.username, password: form.password })
|
||||
const redirect = route.query.redirect || '/'
|
||||
await router.push(redirect)
|
||||
ElMessage.success('登录成功,欢迎回来!')
|
||||
} catch {
|
||||
// Error already handled by request interceptor
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleKeydown(e) {
|
||||
if (e.key === 'Enter') {
|
||||
handleLogin()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-wrapper">
|
||||
<!-- Background decoration -->
|
||||
<div class="bg-decoration bg-deco-1" />
|
||||
<div class="bg-decoration bg-deco-2" />
|
||||
|
||||
<div class="login-card">
|
||||
<!-- Logo / Branding -->
|
||||
<div class="brand-area">
|
||||
<div class="brand-icon">
|
||||
<el-icon :size="48" color="#E8A87C"><House /></el-icon>
|
||||
</div>
|
||||
<h1 class="brand-title">宫中有喜</h1>
|
||||
<p class="brand-subtitle">月子会所综合管理系统</p>
|
||||
</div>
|
||||
|
||||
<!-- Login Form -->
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
class="login-form"
|
||||
@keydown="handleKeydown"
|
||||
>
|
||||
<el-form-item prop="username">
|
||||
<el-input
|
||||
v-model="form.username"
|
||||
placeholder="请输入用户名"
|
||||
size="large"
|
||||
:prefix-icon="'User'"
|
||||
autocomplete="username"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="password">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
size="large"
|
||||
:prefix-icon="'Lock'"
|
||||
autocomplete="current-password"
|
||||
show-password
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item class="mt-8">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
class="login-btn"
|
||||
:loading="loading"
|
||||
@click="handleLogin"
|
||||
>
|
||||
{{ loading ? '登录中...' : '登 录' }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<p class="login-footer">宫中有喜月子会所 © {{ new Date().getFullYear() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.login-wrapper {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #FBE8D5 0%, #F7F0E8 40%, #EDE0D4 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bg-decoration {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
opacity: 0.3;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.bg-deco-1 {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
background: radial-gradient(circle, #E8A87C, transparent);
|
||||
top: -120px;
|
||||
right: -80px;
|
||||
}
|
||||
|
||||
.bg-deco-2 {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
background: radial-gradient(circle, #C97B5E, transparent);
|
||||
bottom: -80px;
|
||||
left: -60px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 420px;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 48px 44px 36px;
|
||||
box-shadow: 0 20px 60px rgba(200, 120, 80, 0.15);
|
||||
}
|
||||
|
||||
.brand-area {
|
||||
text-align: center;
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--color-primary-dark);
|
||||
letter-spacing: 4px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
.el-input {
|
||||
--el-input-border-radius: 8px;
|
||||
}
|
||||
|
||||
:deep(.el-input__wrapper) {
|
||||
padding: 4px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
font-size: 16px;
|
||||
letter-spacing: 4px;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, #E8A87C, #D4875A);
|
||||
border: none;
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(135deg, #F0BB97, #E8A87C);
|
||||
}
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
margin-top: 24px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-placeholder);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,280 @@
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { departmentApi } from '@/api/system.js'
|
||||
|
||||
// ─── State ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const treeData = ref([])
|
||||
const loading = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('新增部门')
|
||||
const submitLoading = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const formRef = ref(null)
|
||||
|
||||
// Flat list for parent selector (avoid selecting self/descendants)
|
||||
const flatDepts = ref([])
|
||||
|
||||
// ─── Form ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const form = reactive({
|
||||
id: null,
|
||||
parent_id: null,
|
||||
name: '',
|
||||
code: '',
|
||||
leader: '',
|
||||
phone: '',
|
||||
sort: 0,
|
||||
status: 1,
|
||||
description: ''
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
name: [{ required: true, message: '请输入部门名称', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function flattenTree(nodes, result = []) {
|
||||
for (const node of nodes) {
|
||||
result.push({ id: node.id, name: node.name })
|
||||
if (node.children?.length) {
|
||||
flattenTree(node.children, result)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Get all descendant IDs to prevent circular parent selection
|
||||
function getDescendantIds(id, nodes) {
|
||||
const ids = [id]
|
||||
const node = nodes.find((n) => n.id === id)
|
||||
if (node?.children) {
|
||||
for (const child of node.children) {
|
||||
ids.push(...getDescendantIds(child.id, flatDepts.value.map((d) => ({ ...d }))))
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
const parentOptions = ref([])
|
||||
|
||||
function buildParentOptions(excludeId) {
|
||||
if (!excludeId) {
|
||||
parentOptions.value = flatDepts.value
|
||||
return
|
||||
}
|
||||
parentOptions.value = flatDepts.value.filter((d) => d.id !== excludeId)
|
||||
}
|
||||
|
||||
// ─── Methods ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchTree() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await departmentApi.getTree()
|
||||
treeData.value = res.data || []
|
||||
flatDepts.value = flattenTree(treeData.value)
|
||||
} catch {
|
||||
//
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleAdd(parentRow) {
|
||||
isEdit.value = false
|
||||
dialogTitle.value = parentRow ? `在「${parentRow.name}」下新增部门` : '新增顶级部门'
|
||||
Object.assign(form, {
|
||||
id: null,
|
||||
parent_id: parentRow?.id || null,
|
||||
name: '', code: '', leader: '', phone: '',
|
||||
sort: 0, status: 1, description: ''
|
||||
})
|
||||
buildParentOptions(null)
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleEdit(row) {
|
||||
isEdit.value = true
|
||||
dialogTitle.value = `编辑部门 - ${row.name}`
|
||||
const res = await departmentApi.getDetail(row.id)
|
||||
const dept = res.data
|
||||
Object.assign(form, {
|
||||
id: dept.id,
|
||||
parent_id: dept.parent_id,
|
||||
name: dept.name,
|
||||
code: dept.code || '',
|
||||
leader: dept.leader || '',
|
||||
phone: dept.phone || '',
|
||||
sort: dept.sort || 0,
|
||||
status: dept.status,
|
||||
description: dept.description || ''
|
||||
})
|
||||
buildParentOptions(row.id)
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!formRef.value) return
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
submitLoading.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await departmentApi.update(form.id, form)
|
||||
ElMessage.success('部门信息更新成功')
|
||||
} else {
|
||||
await departmentApi.create(form)
|
||||
ElMessage.success('部门创建成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
fetchTree()
|
||||
} catch {
|
||||
//
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleDelete(row) {
|
||||
if (row.children && row.children.length > 0) {
|
||||
ElMessage.warning('请先删除子部门')
|
||||
return
|
||||
}
|
||||
await ElMessageBox.confirm(
|
||||
`确定删除部门「${row.name}」吗?`,
|
||||
'删除确认',
|
||||
{
|
||||
confirmButtonText: '确定删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
confirmButtonClass: 'el-button--danger'
|
||||
}
|
||||
)
|
||||
await departmentApi.delete(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchTree()
|
||||
}
|
||||
|
||||
onMounted(fetchTree)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="table-container">
|
||||
<div class="table-actions">
|
||||
<el-button type="primary" :icon="'Plus'" @click="handleAdd(null)">新增顶级部门</el-button>
|
||||
<el-button :icon="'Refresh'" @click="fetchTree">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="treeData"
|
||||
border
|
||||
row-key="id"
|
||||
:tree-props="{ children: 'children', hasChildren: 'has_children' }"
|
||||
default-expand-all
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-table-column prop="name" label="部门名称" min-width="200" />
|
||||
<el-table-column prop="code" label="部门编码" width="120" />
|
||||
<el-table-column prop="leader" label="负责人" width="110" />
|
||||
<el-table-column prop="phone" label="联系电话" width="140" />
|
||||
<el-table-column prop="sort" label="排序" width="70" align="center" />
|
||||
<el-table-column label="状态" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">
|
||||
{{ row.status === 1 ? '正常' : '停用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="创建时间" min-width="160" />
|
||||
<el-table-column label="操作" width="180" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="success" link @click="handleAdd(row)">新增子部门</el-button>
|
||||
<el-button size="small" type="primary" link @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" link @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- Dialog -->
|
||||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="520px" destroy-on-close>
|
||||
<el-form ref="formRef" :model="form" :rules="formRules" label-width="90px">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="24">
|
||||
<el-form-item label="上级部门" prop="parent_id">
|
||||
<el-select
|
||||
v-model="form.parent_id"
|
||||
placeholder="不选则为顶级部门"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option label="顶级部门(无上级)" :value="null" />
|
||||
<el-option
|
||||
v-for="dept in parentOptions"
|
||||
:key="dept.id"
|
||||
:label="dept.name"
|
||||
:value="dept.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="部门名称" prop="name">
|
||||
<el-input v-model="form.name" placeholder="请输入部门名称" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="部门编码" prop="code">
|
||||
<el-input v-model="form.code" placeholder="如: HR(选填)" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="负责人" prop="leader">
|
||||
<el-input v-model="form.leader" placeholder="部门负责人" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="联系电话" prop="phone">
|
||||
<el-input v-model="form.phone" placeholder="部门联系电话" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="排序" prop="sort">
|
||||
<el-input-number v-model="form.sort" :min="0" :max="9999" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-radio-group v-model="form.status">
|
||||
<el-radio :value="1">正常</el-radio>
|
||||
<el-radio :value="0">停用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="备注" prop="description">
|
||||
<el-input
|
||||
v-model="form.description"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="备注信息(选填)"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">
|
||||
{{ isEdit ? '保存修改' : '确认创建' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,561 @@
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { dictApi } from '@/api/system.js'
|
||||
|
||||
// ─── Dictionary Types (left panel) ───────────────────────────────────────────
|
||||
|
||||
const typeList = ref([])
|
||||
const typeTotal = ref(0)
|
||||
const typeLoading = ref(false)
|
||||
const selectedType = ref(null)
|
||||
|
||||
const typeSearch = reactive({ keyword: '' })
|
||||
const typePagination = reactive({ page: 1, page_size: 20 })
|
||||
|
||||
const typeDialogVisible = ref(false)
|
||||
const typeDialogTitle = ref('新增字典类型')
|
||||
const typeSubmitLoading = ref(false)
|
||||
const typeIsEdit = ref(false)
|
||||
const typeFormRef = ref(null)
|
||||
|
||||
const typeForm = reactive({
|
||||
id: null,
|
||||
name: '',
|
||||
code: '',
|
||||
description: '',
|
||||
status: 1
|
||||
})
|
||||
|
||||
const typeFormRules = {
|
||||
name: [{ required: true, message: '请输入类型名称', trigger: 'blur' }],
|
||||
code: [
|
||||
{ required: true, message: '请输入类型编码', trigger: 'blur' },
|
||||
{ pattern: /^[a-zA-Z][a-zA-Z0-9_]*$/, message: '编码以字母开头,只允许字母、数字、下划线', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
// ─── Dictionary Items (right panel) ──────────────────────────────────────────
|
||||
|
||||
const itemList = ref([])
|
||||
const itemTotal = ref(0)
|
||||
const itemLoading = ref(false)
|
||||
|
||||
const itemPagination = reactive({ page: 1, page_size: 20 })
|
||||
|
||||
const itemDialogVisible = ref(false)
|
||||
const itemDialogTitle = ref('新增字典项')
|
||||
const itemSubmitLoading = ref(false)
|
||||
const itemIsEdit = ref(false)
|
||||
const itemFormRef = ref(null)
|
||||
|
||||
const itemForm = reactive({
|
||||
id: null,
|
||||
label: '',
|
||||
value: '',
|
||||
sort: 0,
|
||||
color: '',
|
||||
description: '',
|
||||
status: 1
|
||||
})
|
||||
|
||||
const itemFormRules = {
|
||||
label: [{ required: true, message: '请输入字典标签', trigger: 'blur' }],
|
||||
value: [{ required: true, message: '请输入字典值', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
// ─── Type Methods ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchTypeList() {
|
||||
typeLoading.value = true
|
||||
try {
|
||||
const res = await dictApi.getTypeList({
|
||||
keyword: typeSearch.keyword,
|
||||
...typePagination
|
||||
})
|
||||
typeList.value = res.data?.list || []
|
||||
typeTotal.value = res.data?.total || 0
|
||||
// Auto-select first type
|
||||
if (!selectedType.value && typeList.value.length > 0) {
|
||||
selectType(typeList.value[0])
|
||||
}
|
||||
} catch {
|
||||
//
|
||||
} finally {
|
||||
typeLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function selectType(type) {
|
||||
selectedType.value = type
|
||||
itemPagination.page = 1
|
||||
fetchItemList()
|
||||
}
|
||||
|
||||
function handleTypeSearch() {
|
||||
typePagination.page = 1
|
||||
fetchTypeList()
|
||||
}
|
||||
|
||||
function handleTypeAdd() {
|
||||
typeIsEdit.value = false
|
||||
typeDialogTitle.value = '新增字典类型'
|
||||
Object.assign(typeForm, { id: null, name: '', code: '', description: '', status: 1 })
|
||||
typeDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleTypeEdit(type) {
|
||||
typeIsEdit.value = true
|
||||
typeDialogTitle.value = `编辑字典类型 - ${type.name}`
|
||||
const res = await dictApi.getTypeDetail(type.id)
|
||||
const t = res.data
|
||||
Object.assign(typeForm, {
|
||||
id: t.id, name: t.name, code: t.code,
|
||||
description: t.description || '', status: t.status
|
||||
})
|
||||
typeDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleTypeSubmit() {
|
||||
if (!typeFormRef.value) return
|
||||
await typeFormRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
typeSubmitLoading.value = true
|
||||
try {
|
||||
if (typeIsEdit.value) {
|
||||
await dictApi.updateType(typeForm.id, typeForm)
|
||||
ElMessage.success('字典类型更新成功')
|
||||
// Update selected type name
|
||||
if (selectedType.value?.id === typeForm.id) {
|
||||
selectedType.value.name = typeForm.name
|
||||
}
|
||||
} else {
|
||||
await dictApi.createType(typeForm)
|
||||
ElMessage.success('字典类型创建成功')
|
||||
}
|
||||
typeDialogVisible.value = false
|
||||
fetchTypeList()
|
||||
} catch {
|
||||
//
|
||||
} finally {
|
||||
typeSubmitLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleTypeDelete(type) {
|
||||
await ElMessageBox.confirm(
|
||||
`确定删除字典类型「${type.name}」吗?该类型下的所有字典项将一并删除。`,
|
||||
'删除确认',
|
||||
{
|
||||
confirmButtonText: '确定删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
confirmButtonClass: 'el-button--danger'
|
||||
}
|
||||
)
|
||||
await dictApi.deleteType(type.id)
|
||||
ElMessage.success('删除成功')
|
||||
if (selectedType.value?.id === type.id) {
|
||||
selectedType.value = null
|
||||
itemList.value = []
|
||||
}
|
||||
fetchTypeList()
|
||||
}
|
||||
|
||||
// ─── Item Methods ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchItemList() {
|
||||
if (!selectedType.value) return
|
||||
itemLoading.value = true
|
||||
try {
|
||||
const res = await dictApi.getItemList(selectedType.value.id, itemPagination)
|
||||
itemList.value = res.data?.list || []
|
||||
itemTotal.value = res.data?.total || 0
|
||||
} catch {
|
||||
//
|
||||
} finally {
|
||||
itemLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleItemAdd() {
|
||||
if (!selectedType.value) {
|
||||
ElMessage.warning('请先选择左侧字典类型')
|
||||
return
|
||||
}
|
||||
itemIsEdit.value = false
|
||||
itemDialogTitle.value = `在「${selectedType.value.name}」下新增字典项`
|
||||
Object.assign(itemForm, { id: null, label: '', value: '', sort: 0, color: '', description: '', status: 1 })
|
||||
itemDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleItemEdit(item) {
|
||||
itemIsEdit.value = true
|
||||
itemDialogTitle.value = `编辑字典项 - ${item.label}`
|
||||
const res = await dictApi.getItemDetail(selectedType.value.id, item.id)
|
||||
const i = res.data
|
||||
Object.assign(itemForm, {
|
||||
id: i.id, label: i.label, value: i.value,
|
||||
sort: i.sort || 0, color: i.color || '',
|
||||
description: i.description || '', status: i.status
|
||||
})
|
||||
itemDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleItemSubmit() {
|
||||
if (!itemFormRef.value) return
|
||||
await itemFormRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
itemSubmitLoading.value = true
|
||||
try {
|
||||
if (itemIsEdit.value) {
|
||||
await dictApi.updateItem(selectedType.value.id, itemForm.id, itemForm)
|
||||
ElMessage.success('字典项更新成功')
|
||||
} else {
|
||||
await dictApi.createItem(selectedType.value.id, itemForm)
|
||||
ElMessage.success('字典项创建成功')
|
||||
}
|
||||
itemDialogVisible.value = false
|
||||
fetchItemList()
|
||||
} catch {
|
||||
//
|
||||
} finally {
|
||||
itemSubmitLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleItemDelete(item) {
|
||||
await ElMessageBox.confirm(`确定删除字典项「${item.label}」吗?`, '删除确认', {
|
||||
confirmButtonText: '确定删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
confirmButtonClass: 'el-button--danger'
|
||||
})
|
||||
await dictApi.deleteItem(selectedType.value.id, item.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchItemList()
|
||||
}
|
||||
|
||||
function handleItemPageChange(val) {
|
||||
itemPagination.page = val
|
||||
fetchItemList()
|
||||
}
|
||||
|
||||
onMounted(fetchTypeList)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container dict-page">
|
||||
<div class="dict-layout">
|
||||
<!-- Left: Type List -->
|
||||
<div class="dict-left card">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">字典类型</span>
|
||||
<el-button size="small" type="primary" :icon="'Plus'" @click="handleTypeAdd" />
|
||||
</div>
|
||||
<div class="type-search">
|
||||
<el-input
|
||||
v-model="typeSearch.keyword"
|
||||
placeholder="搜索类型"
|
||||
size="small"
|
||||
clearable
|
||||
@keydown.enter="handleTypeSearch"
|
||||
@clear="handleTypeSearch"
|
||||
>
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
</div>
|
||||
|
||||
<div v-loading="typeLoading" class="type-list">
|
||||
<div
|
||||
v-for="type in typeList"
|
||||
:key="type.id"
|
||||
class="type-item"
|
||||
:class="{ active: selectedType?.id === type.id }"
|
||||
@click="selectType(type)"
|
||||
>
|
||||
<div class="type-item-info">
|
||||
<div class="type-name">{{ type.name }}</div>
|
||||
<div class="type-code">{{ type.code }}</div>
|
||||
</div>
|
||||
<div class="type-item-actions" @click.stop>
|
||||
<el-icon class="action-icon" @click="handleTypeEdit(type)"><Edit /></el-icon>
|
||||
<el-icon class="action-icon danger" @click="handleTypeDelete(type)"><Delete /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-if="!typeLoading && typeList.length === 0" description="暂无字典类型" :image-size="60" />
|
||||
</div>
|
||||
|
||||
<el-pagination
|
||||
v-if="typeTotal > typePagination.page_size"
|
||||
v-model:current-page="typePagination.page"
|
||||
:page-size="typePagination.page_size"
|
||||
:total="typeTotal"
|
||||
layout="prev, pager, next"
|
||||
small
|
||||
@current-change="fetchTypeList"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Right: Item List -->
|
||||
<div class="dict-right card">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">
|
||||
{{ selectedType ? `字典项 — ${selectedType.name}(${selectedType.code})` : '字典项' }}
|
||||
</span>
|
||||
<el-button size="small" type="primary" :icon="'Plus'" :disabled="!selectedType" @click="handleItemAdd">
|
||||
新增字典项
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-loading="itemLoading"
|
||||
:data="itemList"
|
||||
border
|
||||
stripe
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-table-column prop="label" label="字典标签" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
v-if="row.color"
|
||||
:color="row.color"
|
||||
style="color: #fff; border-color: transparent"
|
||||
size="small"
|
||||
>
|
||||
{{ row.label }}
|
||||
</el-tag>
|
||||
<span v-else>{{ row.label }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="value" label="字典值" min-width="120" />
|
||||
<el-table-column prop="sort" label="排序" width="70" align="center" />
|
||||
<el-table-column label="状态" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">
|
||||
{{ row.status === 1 ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" label="备注" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="120" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="primary" link @click="handleItemEdit(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" link @click="handleItemDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-empty
|
||||
v-if="!itemLoading && !selectedType"
|
||||
description="请在左侧选择字典类型"
|
||||
style="margin-top: 40px"
|
||||
/>
|
||||
|
||||
<el-pagination
|
||||
v-if="itemTotal > 0"
|
||||
v-model:current-page="itemPagination.page"
|
||||
:page-size="itemPagination.page_size"
|
||||
:total="itemTotal"
|
||||
layout="total, prev, pager, next"
|
||||
background
|
||||
style="margin-top: 16px"
|
||||
@current-change="handleItemPageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Type Dialog -->
|
||||
<el-dialog v-model="typeDialogVisible" :title="typeDialogTitle" width="460px" destroy-on-close>
|
||||
<el-form ref="typeFormRef" :model="typeForm" :rules="typeFormRules" label-width="90px">
|
||||
<el-form-item label="类型名称" prop="name">
|
||||
<el-input v-model="typeForm.name" placeholder="如: 性别、状态" />
|
||||
</el-form-item>
|
||||
<el-form-item label="类型编码" prop="code">
|
||||
<el-input v-model="typeForm.code" placeholder="如: gender、order_status" :disabled="typeIsEdit" />
|
||||
<div class="form-tip">编码唯一,用于程序中取值,创建后不可修改</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="描述" prop="description">
|
||||
<el-input v-model="typeForm.description" type="textarea" :rows="2" placeholder="选填" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-radio-group v-model="typeForm.status">
|
||||
<el-radio :value="1">启用</el-radio>
|
||||
<el-radio :value="0">禁用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="typeDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="typeSubmitLoading" @click="handleTypeSubmit">
|
||||
{{ typeIsEdit ? '保存修改' : '确认创建' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Item Dialog -->
|
||||
<el-dialog v-model="itemDialogVisible" :title="itemDialogTitle" width="460px" destroy-on-close>
|
||||
<el-form ref="itemFormRef" :model="itemForm" :rules="itemFormRules" label-width="90px">
|
||||
<el-form-item label="字典标签" prop="label">
|
||||
<el-input v-model="itemForm.label" placeholder="显示文本,如: 男、启用" />
|
||||
</el-form-item>
|
||||
<el-form-item label="字典值" prop="value">
|
||||
<el-input v-model="itemForm.value" placeholder="存储值,如: male、1" />
|
||||
</el-form-item>
|
||||
<el-form-item label="标签颜色" prop="color">
|
||||
<div style="display: flex; align-items: center; gap: 10px">
|
||||
<el-color-picker v-model="itemForm.color" />
|
||||
<span class="form-tip">可选,设置后标签将显示对应颜色</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="排序" prop="sort">
|
||||
<el-input-number v-model="itemForm.sort" :min="0" :max="9999" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-radio-group v-model="itemForm.status">
|
||||
<el-radio :value="1">启用</el-radio>
|
||||
<el-radio :value="0">禁用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" prop="description">
|
||||
<el-input v-model="itemForm.description" type="textarea" :rows="2" placeholder="选填" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="itemDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="itemSubmitLoading" @click="handleItemSubmit">
|
||||
{{ itemIsEdit ? '保存修改' : '确认创建' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.dict-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dict-layout {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.dict-left {
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
height: fit-content;
|
||||
max-height: calc(100vh - 140px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dict-right {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.type-search {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.type-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.type-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-primary-lighter);
|
||||
|
||||
.type-item-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--color-primary-lighter);
|
||||
|
||||
.type-name {
|
||||
color: var(--color-primary-dark);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.type-item-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.type-name {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.type-code {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-placeholder);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.type-item-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 14px;
|
||||
padding: 2px;
|
||||
border-radius: 3px;
|
||||
transition: color 0.15s;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
&.danger:hover {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,323 @@
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { menuApi } from '@/api/system.js'
|
||||
|
||||
// ─── State ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const treeData = ref([])
|
||||
const loading = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('新增菜单')
|
||||
const submitLoading = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const formRef = ref(null)
|
||||
|
||||
// ─── Form ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const form = reactive({
|
||||
id: null,
|
||||
parent_id: null,
|
||||
title: '',
|
||||
name: '',
|
||||
icon: '',
|
||||
path: '',
|
||||
component: '',
|
||||
type: 1, // 1=目录 2=菜单 3=按钮
|
||||
sort: 0,
|
||||
visible: 1,
|
||||
status: 1,
|
||||
redirect: ''
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
title: [{ required: true, message: '请输入菜单名称', trigger: 'blur' }],
|
||||
path: [
|
||||
{
|
||||
validator: (rule, value, callback) => {
|
||||
if (form.type !== 3 && !value) {
|
||||
callback(new Error('请输入路由路径'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const menuTypeOptions = [
|
||||
{ label: '目录', value: 1 },
|
||||
{ label: '菜单', value: 2 },
|
||||
{ label: '按钮', value: 3 }
|
||||
]
|
||||
|
||||
const commonIcons = [
|
||||
'House', 'User', 'Setting', 'DataLine', 'Grid', 'Money',
|
||||
'DocumentCopy', 'List', 'Calendar', 'Bell', 'Lock', 'Tools',
|
||||
'Odometer', 'ChatDotRound', 'Collection', 'Management'
|
||||
]
|
||||
|
||||
// ─── Methods ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchTree() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await menuApi.getTree()
|
||||
treeData.value = res.data || []
|
||||
} catch {
|
||||
//
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function menuTypeTag(type) {
|
||||
const map = { 1: '', 2: 'success', 3: 'info' }
|
||||
return map[type] || ''
|
||||
}
|
||||
|
||||
function menuTypeLabel(type) {
|
||||
const map = { 1: '目录', 2: '菜单', 3: '按钮' }
|
||||
return map[type] || '-'
|
||||
}
|
||||
|
||||
function handleAdd(parentRow) {
|
||||
isEdit.value = false
|
||||
dialogTitle.value = parentRow ? `在「${parentRow.title}」下新增菜单` : '新增顶级菜单'
|
||||
Object.assign(form, {
|
||||
id: null,
|
||||
parent_id: parentRow?.id || null,
|
||||
title: '', name: '', icon: '', path: '',
|
||||
component: '', type: parentRow ? 2 : 1,
|
||||
sort: 0, visible: 1, status: 1, redirect: ''
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleEdit(row) {
|
||||
isEdit.value = true
|
||||
dialogTitle.value = `编辑菜单 - ${row.title}`
|
||||
const res = await menuApi.getDetail(row.id)
|
||||
const menu = res.data
|
||||
Object.assign(form, {
|
||||
id: menu.id,
|
||||
parent_id: menu.parent_id,
|
||||
title: menu.title,
|
||||
name: menu.name || '',
|
||||
icon: menu.icon || '',
|
||||
path: menu.path || '',
|
||||
component: menu.component || '',
|
||||
type: menu.type,
|
||||
sort: menu.sort || 0,
|
||||
visible: menu.visible,
|
||||
status: menu.status,
|
||||
redirect: menu.redirect || ''
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!formRef.value) return
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
submitLoading.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await menuApi.update(form.id, form)
|
||||
ElMessage.success('菜单更新成功')
|
||||
} else {
|
||||
await menuApi.create(form)
|
||||
ElMessage.success('菜单创建成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
fetchTree()
|
||||
} catch {
|
||||
//
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleDelete(row) {
|
||||
await ElMessageBox.confirm(
|
||||
`确定删除菜单「${row.title}」吗?若有子菜单将一并删除。`,
|
||||
'删除确认',
|
||||
{
|
||||
confirmButtonText: '确定删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
confirmButtonClass: 'el-button--danger'
|
||||
}
|
||||
)
|
||||
await menuApi.delete(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchTree()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchTree()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="table-container">
|
||||
<div class="table-actions">
|
||||
<el-button type="primary" :icon="'Plus'" @click="handleAdd(null)">新增顶级菜单</el-button>
|
||||
<el-button :icon="'Refresh'" @click="fetchTree">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="treeData"
|
||||
border
|
||||
row-key="id"
|
||||
:tree-props="{ children: 'children', hasChildren: 'has_children' }"
|
||||
default-expand-all
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-table-column prop="title" label="菜单名称" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<span style="display: flex; align-items: center; gap: 6px">
|
||||
<el-icon v-if="row.icon" size="14">
|
||||
<component :is="row.icon" />
|
||||
</el-icon>
|
||||
{{ row.title }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="类型" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="menuTypeTag(row.type)" size="small">
|
||||
{{ menuTypeLabel(row.type) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="path" label="路由路径" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="component" label="组件路径" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="sort" label="排序" width="70" align="center" />
|
||||
<el-table-column label="显示" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.visible === 1 ? 'success' : 'info'" size="small">
|
||||
{{ row.visible === 1 ? '显示' : '隐藏' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">
|
||||
{{ row.status === 1 ? '正常' : '停用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="row.type !== 3"
|
||||
size="small"
|
||||
type="success"
|
||||
link
|
||||
@click="handleAdd(row)"
|
||||
>
|
||||
新增子项
|
||||
</el-button>
|
||||
<el-button size="small" type="primary" link @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" link @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- Dialog -->
|
||||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px" destroy-on-close>
|
||||
<el-form ref="formRef" :model="form" :rules="formRules" label-width="100px">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="菜单类型" prop="type">
|
||||
<el-radio-group v-model="form.type">
|
||||
<el-radio-button
|
||||
v-for="opt in menuTypeOptions"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="菜单名称" prop="title">
|
||||
<el-input v-model="form.title" placeholder="菜单显示名称" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="图标" prop="icon">
|
||||
<el-select v-model="form.icon" placeholder="选择图标" clearable style="width: 100%">
|
||||
<el-option
|
||||
v-for="icon in commonIcons"
|
||||
:key="icon"
|
||||
:label="icon"
|
||||
:value="icon"
|
||||
>
|
||||
<span style="display: flex; align-items: center; gap: 8px">
|
||||
<el-icon><component :is="icon" /></el-icon>
|
||||
{{ icon }}
|
||||
</span>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col v-if="form.type !== 3" :span="12">
|
||||
<el-form-item label="路由路径" prop="path">
|
||||
<el-input v-model="form.path" placeholder="如: /system/users" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col v-if="form.type === 2" :span="12">
|
||||
<el-form-item label="组件路径" prop="component">
|
||||
<el-input v-model="form.component" placeholder="如: system/users/index" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col v-if="form.type === 1" :span="12">
|
||||
<el-form-item label="重定向" prop="redirect">
|
||||
<el-input v-model="form.redirect" placeholder="目录默认跳转路径(选填)" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="路由名称" prop="name">
|
||||
<el-input v-model="form.name" placeholder="唯一路由名(选填)" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="排序" prop="sort">
|
||||
<el-input-number v-model="form.sort" :min="0" :max="9999" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="是否显示" prop="visible">
|
||||
<el-radio-group v-model="form.visible">
|
||||
<el-radio :value="1">显示</el-radio>
|
||||
<el-radio :value="0">隐藏</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-radio-group v-model="form.status">
|
||||
<el-radio :value="1">正常</el-radio>
|
||||
<el-radio :value="0">停用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">
|
||||
{{ isEdit ? '保存修改' : '确认创建' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,327 @@
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, nextTick } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { roleApi, permissionApi } from '@/api/system.js'
|
||||
|
||||
// ─── List state ───────────────────────────────────────────────────────────────
|
||||
|
||||
const tableData = ref([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
|
||||
const searchForm = reactive({ keyword: '' })
|
||||
const pagination = reactive({ page: 1, page_size: 20 })
|
||||
|
||||
// ─── Role form ────────────────────────────────────────────────────────────────
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('新增角色')
|
||||
const submitLoading = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const formRef = ref(null)
|
||||
|
||||
const form = reactive({
|
||||
id: null,
|
||||
name: '',
|
||||
code: '',
|
||||
description: '',
|
||||
status: 1
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
name: [{ required: true, message: '请输入角色名称', trigger: 'blur' }],
|
||||
code: [
|
||||
{ required: true, message: '请输入角色标识', trigger: 'blur' },
|
||||
{ pattern: /^[a-zA-Z][a-zA-Z0-9_:]*$/, message: '只允许字母、数字、下划线,以字母开头', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
// ─── Permission assignment ────────────────────────────────────────────────────
|
||||
|
||||
const permDialogVisible = ref(false)
|
||||
const permLoading = ref(false)
|
||||
const permTreeRef = ref(null)
|
||||
const permTree = ref([])
|
||||
const currentRoleId = ref(null)
|
||||
const currentRoleName = ref('')
|
||||
const checkedPermIds = ref([])
|
||||
|
||||
const treeProps = {
|
||||
label: 'name',
|
||||
children: 'children'
|
||||
}
|
||||
|
||||
// ─── Fetch ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await roleApi.getList({ ...searchForm, ...pagination })
|
||||
tableData.value = res.data?.list || []
|
||||
total.value = res.data?.total || 0
|
||||
} catch {
|
||||
//
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPermTree() {
|
||||
try {
|
||||
const res = await permissionApi.getTree()
|
||||
permTree.value = res.data || []
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Handlers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function handleSearch() {
|
||||
pagination.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
searchForm.keyword = ''
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
isEdit.value = false
|
||||
dialogTitle.value = '新增角色'
|
||||
Object.assign(form, { id: null, name: '', code: '', description: '', status: 1 })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleEdit(row) {
|
||||
isEdit.value = true
|
||||
dialogTitle.value = '编辑角色'
|
||||
const res = await roleApi.getDetail(row.id)
|
||||
const role = res.data
|
||||
Object.assign(form, {
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
code: role.code,
|
||||
description: role.description || '',
|
||||
status: role.status
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!formRef.value) return
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
submitLoading.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await roleApi.update(form.id, form)
|
||||
ElMessage.success('角色更新成功')
|
||||
} else {
|
||||
await roleApi.create(form)
|
||||
ElMessage.success('角色创建成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
fetchList()
|
||||
} catch {
|
||||
//
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleDelete(row) {
|
||||
await ElMessageBox.confirm(`确定删除角色「${row.name}」吗?`, '删除确认', {
|
||||
confirmButtonText: '确定删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
confirmButtonClass: 'el-button--danger'
|
||||
})
|
||||
await roleApi.delete(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchList()
|
||||
}
|
||||
|
||||
async function openPermDialog(row) {
|
||||
currentRoleId.value = row.id
|
||||
currentRoleName.value = row.name
|
||||
permDialogVisible.value = true
|
||||
permLoading.value = true
|
||||
|
||||
try {
|
||||
// Ensure perm tree is loaded
|
||||
if (permTree.value.length === 0) {
|
||||
await fetchPermTree()
|
||||
}
|
||||
const res = await roleApi.getPermissions(row.id)
|
||||
checkedPermIds.value = res.data?.map((p) => p.id) || []
|
||||
|
||||
await nextTick()
|
||||
if (permTreeRef.value) {
|
||||
permTreeRef.value.setCheckedKeys(checkedPermIds.value, true)
|
||||
}
|
||||
} catch {
|
||||
//
|
||||
} finally {
|
||||
permLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSavePerms() {
|
||||
if (!permTreeRef.value) return
|
||||
const checked = permTreeRef.value.getCheckedKeys(false)
|
||||
const halfChecked = permTreeRef.value.getHalfCheckedKeys()
|
||||
const allIds = [...checked, ...halfChecked]
|
||||
try {
|
||||
await roleApi.assignPermissions(currentRoleId.value, allIds)
|
||||
ElMessage.success('权限分配成功')
|
||||
permDialogVisible.value = false
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
function handlePageChange(val) {
|
||||
pagination.page = val
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function handleSizeChange(val) {
|
||||
pagination.page_size = val
|
||||
pagination.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchList()
|
||||
fetchPermTree()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<!-- Search -->
|
||||
<div class="search-bar">
|
||||
<el-input
|
||||
v-model="searchForm.keyword"
|
||||
placeholder="搜索角色名称/标识"
|
||||
style="width: 240px"
|
||||
clearable
|
||||
@keydown.enter="handleSearch"
|
||||
>
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
<el-button type="primary" :icon="'Search'" @click="handleSearch">查询</el-button>
|
||||
<el-button :icon="'Refresh'" @click="handleReset">重置</el-button>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="table-container">
|
||||
<div class="table-actions">
|
||||
<el-button type="primary" :icon="'Plus'" @click="handleAdd">新增角色</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="tableData" border stripe style="width: 100%">
|
||||
<el-table-column prop="name" label="角色名称" min-width="140" />
|
||||
<el-table-column prop="code" label="角色标识" min-width="140" />
|
||||
<el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="user_count" label="用户数" width="90" align="center" />
|
||||
<el-table-column label="状态" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">
|
||||
{{ row.status === 1 ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="创建时间" min-width="160" />
|
||||
<el-table-column label="操作" width="200" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="primary" link @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button size="small" type="warning" link @click="openPermDialog(row)">分配权限</el-button>
|
||||
<el-button size="small" type="danger" link @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.page_size"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
background
|
||||
@current-change="handlePageChange"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Role Add/Edit Dialog -->
|
||||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="480px" destroy-on-close>
|
||||
<el-form ref="formRef" :model="form" :rules="formRules" label-width="90px">
|
||||
<el-form-item label="角色名称" prop="name">
|
||||
<el-input v-model="form.name" placeholder="请输入角色名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色标识" prop="code">
|
||||
<el-input v-model="form.code" placeholder="如: admin, nurse_manager" :disabled="isEdit" />
|
||||
<div class="form-tip">标识用于程序内部权限判断,创建后不可修改</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="描述" prop="description">
|
||||
<el-input
|
||||
v-model="form.description"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入角色描述(选填)"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-radio-group v-model="form.status">
|
||||
<el-radio :value="1">启用</el-radio>
|
||||
<el-radio :value="0">禁用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">
|
||||
{{ isEdit ? '保存修改' : '确认创建' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Permission Assignment Dialog -->
|
||||
<el-dialog
|
||||
v-model="permDialogVisible"
|
||||
:title="`分配权限 - ${currentRoleName}`"
|
||||
width="520px"
|
||||
destroy-on-close
|
||||
>
|
||||
<div v-loading="permLoading" style="min-height: 200px; max-height: 480px; overflow-y: auto">
|
||||
<el-tree
|
||||
ref="permTreeRef"
|
||||
:data="permTree"
|
||||
:props="treeProps"
|
||||
show-checkbox
|
||||
node-key="id"
|
||||
default-expand-all
|
||||
check-strictly
|
||||
/>
|
||||
<el-empty v-if="!permLoading && permTree.length === 0" description="暂无权限数据" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="permDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSavePerms">保存权限</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.form-tip {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
margin-top: 4px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,303 @@
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { storeApi } from '@/api/system.js'
|
||||
|
||||
// ─── State ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const tableData = ref([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('新增门店')
|
||||
const submitLoading = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const formRef = ref(null)
|
||||
|
||||
const searchForm = reactive({ keyword: '', status: '' })
|
||||
const pagination = reactive({ page: 1, page_size: 20 })
|
||||
|
||||
// ─── Form ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const form = reactive({
|
||||
id: null,
|
||||
name: '',
|
||||
code: '',
|
||||
region: '',
|
||||
address: '',
|
||||
phone: '',
|
||||
contact: '',
|
||||
capacity: '',
|
||||
description: '',
|
||||
status: 1
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
name: [{ required: true, message: '请输入门店名称', trigger: 'blur' }],
|
||||
code: [
|
||||
{ required: true, message: '请输入门店编码', trigger: 'blur' },
|
||||
{ pattern: /^[A-Z0-9_]+$/, message: '编码只允许大写字母、数字、下划线', trigger: 'blur' }
|
||||
],
|
||||
phone: [
|
||||
{ pattern: /^[\d-()()\s]+$/, message: '请输入正确的联系电话', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
// ─── Methods ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await storeApi.getList({ ...searchForm, ...pagination })
|
||||
tableData.value = res.data?.list || []
|
||||
total.value = res.data?.total || 0
|
||||
} catch {
|
||||
//
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
Object.assign(searchForm, { keyword: '', status: '' })
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
isEdit.value = false
|
||||
dialogTitle.value = '新增门店'
|
||||
Object.assign(form, {
|
||||
id: null, name: '', code: '', region: '', address: '',
|
||||
phone: '', contact: '', capacity: '', description: '', status: 1
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleEdit(row) {
|
||||
isEdit.value = true
|
||||
dialogTitle.value = `编辑门店 - ${row.name}`
|
||||
const res = await storeApi.getDetail(row.id)
|
||||
const store = res.data
|
||||
Object.assign(form, {
|
||||
id: store.id,
|
||||
name: store.name,
|
||||
code: store.code,
|
||||
region: store.region || '',
|
||||
address: store.address || '',
|
||||
phone: store.phone || '',
|
||||
contact: store.contact || '',
|
||||
capacity: store.capacity || '',
|
||||
description: store.description || '',
|
||||
status: store.status
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!formRef.value) return
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
submitLoading.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await storeApi.update(form.id, form)
|
||||
ElMessage.success('门店信息更新成功')
|
||||
} else {
|
||||
await storeApi.create(form)
|
||||
ElMessage.success('门店创建成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
fetchList()
|
||||
} catch {
|
||||
//
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleDelete(row) {
|
||||
await ElMessageBox.confirm(
|
||||
`确定删除门店「${row.name}」吗?删除后该门店下的数据将不可访问。`,
|
||||
'删除确认',
|
||||
{
|
||||
confirmButtonText: '确定删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
confirmButtonClass: 'el-button--danger'
|
||||
}
|
||||
)
|
||||
await storeApi.delete(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchList()
|
||||
}
|
||||
|
||||
async function handleStatusToggle(row) {
|
||||
const newStatus = row.status === 1 ? 0 : 1
|
||||
const label = newStatus === 1 ? '启用' : '停用'
|
||||
try {
|
||||
await storeApi.update(row.id, { ...row, status: newStatus })
|
||||
row.status = newStatus
|
||||
ElMessage.success(`门店已${label}`)
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
function handlePageChange(val) {
|
||||
pagination.page = val
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function handleSizeChange(val) {
|
||||
pagination.page_size = val
|
||||
pagination.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
onMounted(fetchList)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<!-- Search -->
|
||||
<div class="search-bar">
|
||||
<el-input
|
||||
v-model="searchForm.keyword"
|
||||
placeholder="搜索门店名称/编码/地址"
|
||||
style="width: 260px"
|
||||
clearable
|
||||
@keydown.enter="handleSearch"
|
||||
>
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
<el-select v-model="searchForm.status" placeholder="状态" clearable style="width: 120px">
|
||||
<el-option label="启用" :value="1" />
|
||||
<el-option label="停用" :value="0" />
|
||||
</el-select>
|
||||
<el-button type="primary" :icon="'Search'" @click="handleSearch">查询</el-button>
|
||||
<el-button :icon="'Refresh'" @click="handleReset">重置</el-button>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="table-container">
|
||||
<div class="table-actions">
|
||||
<el-button type="primary" :icon="'Plus'" @click="handleAdd">新增门店</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="tableData" border stripe style="width: 100%">
|
||||
<el-table-column prop="name" label="门店名称" min-width="160" />
|
||||
<el-table-column prop="code" label="门店编码" width="120" />
|
||||
<el-table-column prop="region" label="所在地区" min-width="130" />
|
||||
<el-table-column prop="address" label="详细地址" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="phone" label="联系电话" width="140" />
|
||||
<el-table-column prop="contact" label="联系人" width="100" />
|
||||
<el-table-column prop="capacity" label="床位数" width="80" align="center" />
|
||||
<el-table-column label="状态" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">
|
||||
{{ row.status === 1 ? '启用' : '停用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="primary" link @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
:type="row.status === 1 ? 'warning' : 'success'"
|
||||
link
|
||||
@click="handleStatusToggle(row)"
|
||||
>
|
||||
{{ row.status === 1 ? '停用' : '启用' }}
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" link @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.page_size"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
background
|
||||
@current-change="handlePageChange"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Dialog -->
|
||||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px" destroy-on-close>
|
||||
<el-form ref="formRef" :model="form" :rules="formRules" label-width="90px">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="门店名称" prop="name">
|
||||
<el-input v-model="form.name" placeholder="请输入门店名称" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="门店编码" prop="code">
|
||||
<el-input v-model="form.code" placeholder="如: HQ001" :disabled="isEdit" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="所在地区" prop="region">
|
||||
<el-input v-model="form.region" placeholder="如: 北京市朝阳区" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="联系电话" prop="phone">
|
||||
<el-input v-model="form.phone" placeholder="请输入联系电话" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="详细地址" prop="address">
|
||||
<el-input v-model="form.address" placeholder="请输入详细地址" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="联系人" prop="contact">
|
||||
<el-input v-model="form.contact" placeholder="负责人姓名" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="床位数量" prop="capacity">
|
||||
<el-input-number v-model="form.capacity" :min="1" :max="999" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-radio-group v-model="form.status">
|
||||
<el-radio :value="1">启用</el-radio>
|
||||
<el-radio :value="0">停用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="备注" prop="description">
|
||||
<el-input
|
||||
v-model="form.description"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="门店备注信息(选填)"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">
|
||||
{{ isEdit ? '保存修改' : '确认创建' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,486 @@
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { userApi, roleApi, departmentApi } from '@/api/system.js'
|
||||
|
||||
// ─── State ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const tableData = ref([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('新增用户')
|
||||
const submitLoading = ref(false)
|
||||
const resetPwdVisible = ref(false)
|
||||
const currentUserId = ref(null)
|
||||
|
||||
const roles = ref([])
|
||||
const departments = ref([])
|
||||
|
||||
const searchForm = reactive({
|
||||
keyword: '',
|
||||
department_id: '',
|
||||
status: ''
|
||||
})
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
page_size: 20
|
||||
})
|
||||
|
||||
// ─── Form ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const formRef = ref(null)
|
||||
const form = reactive({
|
||||
id: null,
|
||||
username: '',
|
||||
name: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
department_id: '',
|
||||
position_id: '',
|
||||
role_ids: [],
|
||||
status: 1,
|
||||
password: '',
|
||||
password_confirm: ''
|
||||
})
|
||||
|
||||
const isEdit = ref(false)
|
||||
|
||||
const formRules = {
|
||||
username: [
|
||||
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
||||
{ min: 2, max: 50, message: '用户名长度 2-50 个字符', trigger: 'blur' }
|
||||
],
|
||||
name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
|
||||
phone: [
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
|
||||
],
|
||||
password: [
|
||||
{
|
||||
validator: (rule, value, callback) => {
|
||||
if (!isEdit.value && !value) {
|
||||
callback(new Error('请输入密码'))
|
||||
} else if (value && value.length < 6) {
|
||||
callback(new Error('密码不少于 6 位'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// ─── Reset password form ──────────────────────────────────────────────────────
|
||||
|
||||
const resetPwdFormRef = ref(null)
|
||||
const resetPwdForm = reactive({ new_password: '', confirm_password: '' })
|
||||
|
||||
const resetPwdRules = {
|
||||
new_password: [
|
||||
{ required: true, message: '请输入新密码', trigger: 'blur' },
|
||||
{ min: 6, message: '密码不少于 6 位', trigger: 'blur' }
|
||||
],
|
||||
confirm_password: [
|
||||
{ required: true, message: '请确认新密码', trigger: 'blur' },
|
||||
{
|
||||
validator: (rule, value, callback) => {
|
||||
if (value !== resetPwdForm.new_password) {
|
||||
callback(new Error('两次输入的密码不一致'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// ─── Methods ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await userApi.getList({ ...searchForm, ...pagination })
|
||||
tableData.value = res.data?.list || []
|
||||
total.value = res.data?.total || 0
|
||||
} catch {
|
||||
//
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRoles() {
|
||||
try {
|
||||
const res = await roleApi.getList({ page_size: 200 })
|
||||
roles.value = res.data?.list || []
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDepartments() {
|
||||
try {
|
||||
const res = await departmentApi.getTree()
|
||||
departments.value = res.data || []
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
Object.assign(searchForm, { keyword: '', department_id: '', status: '' })
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
isEdit.value = false
|
||||
dialogTitle.value = '新增用户'
|
||||
Object.assign(form, {
|
||||
id: null, username: '', name: '', phone: '', email: '',
|
||||
department_id: '', position_id: '', role_ids: [], status: 1,
|
||||
password: '', password_confirm: ''
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleEdit(row) {
|
||||
isEdit.value = true
|
||||
dialogTitle.value = '编辑用户'
|
||||
const res = await userApi.getDetail(row.id)
|
||||
const user = res.data
|
||||
Object.assign(form, {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
name: user.name,
|
||||
phone: user.phone || '',
|
||||
email: user.email || '',
|
||||
department_id: user.department_id || '',
|
||||
position_id: user.position_id || '',
|
||||
role_ids: user.roles?.map((r) => r.id) || [],
|
||||
status: user.status,
|
||||
password: '',
|
||||
password_confirm: ''
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!formRef.value) return
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const payload = { ...form }
|
||||
if (isEdit.value) {
|
||||
delete payload.password
|
||||
delete payload.password_confirm
|
||||
await userApi.update(form.id, payload)
|
||||
ElMessage.success('用户信息已更新')
|
||||
} else {
|
||||
await userApi.create(payload)
|
||||
ElMessage.success('用户创建成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
fetchList()
|
||||
} catch {
|
||||
//
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleDelete(row) {
|
||||
await ElMessageBox.confirm(`确定删除用户「${row.name}」吗?此操作不可恢复。`, '删除确认', {
|
||||
confirmButtonText: '确定删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
confirmButtonClass: 'el-button--danger'
|
||||
})
|
||||
await userApi.delete(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchList()
|
||||
}
|
||||
|
||||
async function handleStatusChange(row) {
|
||||
const newStatus = row.status === 1 ? 0 : 1
|
||||
try {
|
||||
await userApi.updateStatus(row.id, newStatus)
|
||||
row.status = newStatus
|
||||
ElMessage.success(newStatus === 1 ? '已启用' : '已禁用')
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
function openResetPassword(row) {
|
||||
currentUserId.value = row.id
|
||||
Object.assign(resetPwdForm, { new_password: '', confirm_password: '' })
|
||||
resetPwdVisible.value = true
|
||||
}
|
||||
|
||||
async function submitResetPassword() {
|
||||
if (!resetPwdFormRef.value) return
|
||||
await resetPwdFormRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
try {
|
||||
await userApi.resetPassword(currentUserId.value, {
|
||||
new_password: resetPwdForm.new_password
|
||||
})
|
||||
ElMessage.success('密码重置成功')
|
||||
resetPwdVisible.value = false
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handlePageChange(val) {
|
||||
pagination.page = val
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function handleSizeChange(val) {
|
||||
pagination.page_size = val
|
||||
pagination.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchList()
|
||||
fetchRoles()
|
||||
fetchDepartments()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<!-- Search Bar -->
|
||||
<div class="search-bar">
|
||||
<el-input
|
||||
v-model="searchForm.keyword"
|
||||
placeholder="搜索用户名/姓名/手机号"
|
||||
style="width: 240px"
|
||||
clearable
|
||||
@keydown.enter="handleSearch"
|
||||
>
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
<el-tree-select
|
||||
v-model="searchForm.department_id"
|
||||
:data="departments"
|
||||
:props="{ label: 'name', value: 'id', children: 'children' }"
|
||||
placeholder="选择部门"
|
||||
clearable
|
||||
style="width: 180px"
|
||||
/>
|
||||
<el-select v-model="searchForm.status" placeholder="状态" clearable style="width: 120px">
|
||||
<el-option label="启用" :value="1" />
|
||||
<el-option label="禁用" :value="0" />
|
||||
</el-select>
|
||||
<el-button type="primary" :icon="'Search'" @click="handleSearch">查询</el-button>
|
||||
<el-button :icon="'Refresh'" @click="handleReset">重置</el-button>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="table-container">
|
||||
<div class="table-actions">
|
||||
<el-button type="primary" :icon="'Plus'" @click="handleAdd">新增用户</el-button>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="tableData"
|
||||
border
|
||||
stripe
|
||||
row-key="id"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-table-column prop="username" label="用户名" min-width="120" />
|
||||
<el-table-column prop="name" label="姓名" min-width="100" />
|
||||
<el-table-column prop="phone" label="手机号" min-width="130" />
|
||||
<el-table-column prop="department.name" label="部门" min-width="120" />
|
||||
<el-table-column label="角色" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
v-for="role in row.roles"
|
||||
:key="role.id"
|
||||
size="small"
|
||||
type="info"
|
||||
style="margin: 2px"
|
||||
>
|
||||
{{ role.name }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">
|
||||
{{ row.status === 1 ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="创建时间" min-width="160" />
|
||||
<el-table-column label="操作" width="220" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="primary" link @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
:type="row.status === 1 ? 'warning' : 'success'"
|
||||
link
|
||||
@click="handleStatusChange(row)"
|
||||
>
|
||||
{{ row.status === 1 ? '禁用' : '启用' }}
|
||||
</el-button>
|
||||
<el-button size="small" type="info" link @click="openResetPassword(row)">重置密码</el-button>
|
||||
<el-button size="small" type="danger" link @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.page_size"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
background
|
||||
@current-change="handlePageChange"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Dialog -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogTitle"
|
||||
width="600px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="formRules"
|
||||
label-width="90px"
|
||||
label-position="right"
|
||||
>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="用户名" prop="username">
|
||||
<el-input v-model="form.username" placeholder="请输入用户名" :disabled="isEdit" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="姓名" prop="name">
|
||||
<el-input v-model="form.name" placeholder="请输入姓名" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="手机号" prop="phone">
|
||||
<el-input v-model="form.phone" placeholder="请输入手机号" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="邮箱" prop="email">
|
||||
<el-input v-model="form.email" placeholder="请输入邮箱" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="部门" prop="department_id">
|
||||
<el-tree-select
|
||||
v-model="form.department_id"
|
||||
:data="departments"
|
||||
:props="{ label: 'name', value: 'id', children: 'children' }"
|
||||
placeholder="选择部门"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="角色" prop="role_ids">
|
||||
<el-select
|
||||
v-model="form.role_ids"
|
||||
multiple
|
||||
placeholder="选择角色"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="role in roles"
|
||||
:key="role.id"
|
||||
:label="role.name"
|
||||
:value="role.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-radio-group v-model="form.status">
|
||||
<el-radio :value="1">启用</el-radio>
|
||||
<el-radio :value="0">禁用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<template v-if="!isEdit">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="密码" prop="password">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="请输入初始密码"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</template>
|
||||
</el-row>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">
|
||||
{{ isEdit ? '保存修改' : '确认创建' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Reset Password Dialog -->
|
||||
<el-dialog v-model="resetPwdVisible" title="重置密码" width="420px" destroy-on-close>
|
||||
<el-form
|
||||
ref="resetPwdFormRef"
|
||||
:model="resetPwdForm"
|
||||
:rules="resetPwdRules"
|
||||
label-width="90px"
|
||||
>
|
||||
<el-form-item label="新密码" prop="new_password">
|
||||
<el-input
|
||||
v-model="resetPwdForm.new_password"
|
||||
type="password"
|
||||
placeholder="请输入新密码(不少于6位)"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="确认密码" prop="confirm_password">
|
||||
<el-input
|
||||
v-model="resetPwdForm.confirm_password"
|
||||
type="password"
|
||||
placeholder="请再次输入新密码"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="resetPwdVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitResetPassword">确认重置</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
}
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
secure: false
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user