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:
li
2026-03-13 19:52:09 +08:00
commit 11c177a432
144 changed files with 25209 additions and 0 deletions
+24
View File
@@ -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?
+5
View File
@@ -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).
+13
View File
@@ -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>
+2141
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -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

+24
View File
@@ -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

+3
View File
@@ -0,0 +1,3 @@
<template>
<router-view />
</template>
+23
View File
@@ -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')
}
+109
View File
@@ -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

+1
View File
@@ -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

+93
View File
@@ -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>
+186
View File
@@ -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>
+198
View File
@@ -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>
+91
View File
@@ -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>
+21
View File
@@ -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')
+69
View File
@@ -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
+17
View File
@@ -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 }
}
]
+114
View File
@@ -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
}
})
+66
View File
@@ -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
}
})
+296
View File
@@ -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);
}
}
+239
View File
@@ -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;
}
+83
View File
@@ -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: 4010040199 → 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
+299
View File
@@ -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 }} &nbsp; {{ 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>
+32
View File
@@ -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>
+220
View File
@@ -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">宫中有喜月子会所 &copy; {{ 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>
+323
View File
@@ -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>
+327
View File
@@ -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>
+303
View File
@@ -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>
+486
View File
@@ -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>
+21
View File
@@ -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
}
}
}
})