通用中后台框架第一版
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import pluginVue from 'eslint-plugin-vue'
|
||||
import vueTsEslintConfig from '@vue/eslint-config-typescript'
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ['dist/**', 'node_modules/**']
|
||||
},
|
||||
...pluginVue.configs['flat/recommended'],
|
||||
...vueTsEslintConfig(),
|
||||
{
|
||||
rules: {
|
||||
'vue/multi-word-component-names': 'off',
|
||||
'vue/max-attributes-per-line': 'off',
|
||||
'vue/html-indent': 'off',
|
||||
'vue/html-closing-bracket-newline': 'off',
|
||||
'vue/html-self-closing': 'off',
|
||||
'vue/singleline-html-element-content-newline': 'off',
|
||||
'vue/multiline-html-element-content-newline': 'off'
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Plantform 管理后台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+5154
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "platform-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --mode development",
|
||||
"build": "vue-tsc --noEmit && vite build --mode production",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext .ts,.vue",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/vue-query": "^5.76.0",
|
||||
"axios": "^1.9.0",
|
||||
"lucide-vue-next": "^0.511.0",
|
||||
"naive-ui": "^2.42.0",
|
||||
"pinia": "^2.3.1",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.15.3",
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
"@vue/eslint-config-typescript": "^14.6.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^9.26.0",
|
||||
"eslint-plugin-vue": "^9.33.0",
|
||||
"postcss": "^8.5.3",
|
||||
"prettier": "^3.5.3",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^5.4.19",
|
||||
"vitest": "^2.1.9",
|
||||
"vue-tsc": "^2.2.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<n-config-provider :theme-overrides="themeOverrides" :locale="zhCN" :date-locale="dateZhCN">
|
||||
<n-loading-bar-provider>
|
||||
<n-dialog-provider>
|
||||
<n-notification-provider>
|
||||
<n-message-provider>
|
||||
<router-view />
|
||||
</n-message-provider>
|
||||
</n-notification-provider>
|
||||
</n-dialog-provider>
|
||||
</n-loading-bar-provider>
|
||||
</n-config-provider>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { dateZhCN, zhCN, type GlobalThemeOverrides } from 'naive-ui'
|
||||
|
||||
const themeOverrides: GlobalThemeOverrides = {
|
||||
common: {
|
||||
primaryColor: '#2563eb',
|
||||
primaryColorHover: '#1d4ed8',
|
||||
primaryColorPressed: '#1e40af',
|
||||
primaryColorSuppl: '#14b8a6',
|
||||
borderRadius: '8px',
|
||||
borderRadiusSmall: '6px',
|
||||
fontFamily: 'Inter, "Avenir Next", "PingFang SC", "Microsoft YaHei", sans-serif'
|
||||
},
|
||||
Card: {
|
||||
borderRadius: '8px'
|
||||
},
|
||||
Button: {
|
||||
borderRadiusMedium: '8px'
|
||||
},
|
||||
Input: {
|
||||
borderRadius: '8px'
|
||||
},
|
||||
Select: {
|
||||
peers: {
|
||||
InternalSelection: {
|
||||
borderRadius: '8px'
|
||||
}
|
||||
}
|
||||
},
|
||||
Menu: {
|
||||
itemHeight: '40px',
|
||||
itemBorderRadius: '8px',
|
||||
itemTextColor: '#64748b',
|
||||
itemTextColorHover: '#0f172a',
|
||||
itemTextColorActive: '#111827',
|
||||
itemTextColorActiveHover: '#111827',
|
||||
itemIconColor: '#94a3b8',
|
||||
itemIconColorHover: '#2563eb',
|
||||
itemIconColorActive: '#2563eb',
|
||||
itemColorActive: '#eff6ff',
|
||||
itemColorActiveHover: '#eff6ff'
|
||||
},
|
||||
DataTable: {
|
||||
thColor: '#f8fafc',
|
||||
tdColor: '#ffffff',
|
||||
borderColor: '#e2e8f0',
|
||||
thTextColor: '#475569'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,14 @@
|
||||
import http from '@/api/http'
|
||||
import type { LoginRequest, LoginResponse, MeResponse } from '@/types/auth'
|
||||
|
||||
export function loginApi(payload: LoginRequest) {
|
||||
return http.post<never, LoginResponse>('/auth/login', payload)
|
||||
}
|
||||
|
||||
export function logoutApi() {
|
||||
return http.post<never, void>('/auth/logout')
|
||||
}
|
||||
|
||||
export function meApi() {
|
||||
return http.get<never, MeResponse>('/auth/me')
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import axios, { AxiosError } from 'axios'
|
||||
import { createDiscreteApi } from 'naive-ui'
|
||||
import type { ApiResult } from '@/types/http'
|
||||
import { BizError } from '@/types/http'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { router } from '@/router'
|
||||
import { appEnv } from '@/config/env'
|
||||
|
||||
const { message } = createDiscreteApi(['message'])
|
||||
|
||||
const http = axios.create({
|
||||
baseURL: appEnv.apiBaseUrl,
|
||||
timeout: 15000
|
||||
})
|
||||
|
||||
http.interceptors.request.use((config) => {
|
||||
const authStore = useAuthStore()
|
||||
const token = authStore.token
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
http.interceptors.response.use(
|
||||
(response) => {
|
||||
const payload = response.data as ApiResult<unknown>
|
||||
const traceId = response.headers['x-trace-id'] as string | undefined
|
||||
if (!payload || typeof payload.code !== 'string') {
|
||||
return response.data
|
||||
}
|
||||
if (payload.code !== '0') {
|
||||
throw new BizError(payload.code, payload.message || '请求失败', payload.traceId ?? traceId)
|
||||
}
|
||||
return payload.data
|
||||
},
|
||||
async (error: AxiosError<ApiResult<unknown>>) => {
|
||||
const authStore = useAuthStore()
|
||||
const status = error.response?.status
|
||||
const traceId =
|
||||
(error.response?.headers?.['x-trace-id'] as string | undefined) ??
|
||||
error.response?.data?.traceId
|
||||
const backendMessage = error.response?.data?.message
|
||||
|
||||
if (status === 401) {
|
||||
authStore.clearAuth()
|
||||
if (router.currentRoute.value.path !== '/login') {
|
||||
await router.replace({
|
||||
path: '/login',
|
||||
query: { redirect: router.currentRoute.value.fullPath }
|
||||
})
|
||||
}
|
||||
message.warning(backendMessage ?? '登录已失效,请重新登录')
|
||||
return Promise.reject(new BizError('401', backendMessage ?? '未登录', traceId))
|
||||
}
|
||||
|
||||
if (status === 403) {
|
||||
if (router.currentRoute.value.path !== '/403') {
|
||||
await router.replace('/403')
|
||||
}
|
||||
message.error(backendMessage ?? '无权限访问该资源')
|
||||
return Promise.reject(new BizError('403', backendMessage ?? '无权限', traceId))
|
||||
}
|
||||
|
||||
const messageText = backendMessage ?? error.message ?? '网络异常,请稍后重试'
|
||||
message.error(traceId ? `${messageText}(追踪ID:${traceId})` : messageText)
|
||||
return Promise.reject(new BizError(String(status ?? 'HTTP_ERROR'), messageText, traceId))
|
||||
}
|
||||
)
|
||||
|
||||
export default http
|
||||
@@ -0,0 +1,20 @@
|
||||
import http from '@/api/http'
|
||||
import type { ApiAccessLogPage, OperationLogPage } from '@/types/logs'
|
||||
|
||||
export function listOperationLogsApi(params: {
|
||||
page: number
|
||||
pageSize: number
|
||||
keyword?: string
|
||||
status?: string
|
||||
}) {
|
||||
return http.get<never, OperationLogPage>('/logs/operation', { params })
|
||||
}
|
||||
|
||||
export function listApiAccessLogsApi(params: {
|
||||
page: number
|
||||
pageSize: number
|
||||
keyword?: string
|
||||
status?: string
|
||||
}) {
|
||||
return http.get<never, ApiAccessLogPage>('/logs/api-access', { params })
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import http from '@/api/http'
|
||||
import type { DictItemPage, DictTypePage } from '@/types/system/dict'
|
||||
|
||||
export function listDictTypesApi(params: { page: number; pageSize: number; keyword?: string }) {
|
||||
return http.get<never, DictTypePage>('/system/dict-types', { params })
|
||||
}
|
||||
|
||||
export function createDictTypeApi(payload: {
|
||||
code: string
|
||||
name: string
|
||||
status: string
|
||||
remark?: string
|
||||
}) {
|
||||
return http.post<never, { id: string }>('/system/dict-types', payload)
|
||||
}
|
||||
|
||||
export function updateDictTypeApi(
|
||||
id: string,
|
||||
payload: { name: string; status: string; remark?: string }
|
||||
) {
|
||||
return http.put<never, void>(`/system/dict-types/${id}`, payload)
|
||||
}
|
||||
|
||||
export function deleteDictTypeApi(id: string) {
|
||||
return http.delete<never, void>(`/system/dict-types/${id}`)
|
||||
}
|
||||
|
||||
export function listDictItemsApi(params: { page: number; pageSize: number; typeId?: string }) {
|
||||
return http.get<never, DictItemPage>('/system/dict-items', { params })
|
||||
}
|
||||
|
||||
export function createDictItemApi(payload: {
|
||||
typeId: string
|
||||
label: string
|
||||
value: string
|
||||
color?: string
|
||||
sort: number
|
||||
status: string
|
||||
remark?: string
|
||||
}) {
|
||||
return http.post<never, { id: string }>('/system/dict-items', payload)
|
||||
}
|
||||
|
||||
export function updateDictItemApi(
|
||||
id: string,
|
||||
payload: {
|
||||
typeId: string
|
||||
label: string
|
||||
value: string
|
||||
color?: string
|
||||
sort: number
|
||||
status: string
|
||||
remark?: string
|
||||
}
|
||||
) {
|
||||
return http.put<never, void>(`/system/dict-items/${id}`, payload)
|
||||
}
|
||||
|
||||
export function deleteDictItemApi(id: string) {
|
||||
return http.delete<never, void>(`/system/dict-items/${id}`)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import http from '@/api/http'
|
||||
import type { CreateMenuRequest, MenuTreeNode, UpdateMenuRequest } from '@/types/system/menu'
|
||||
|
||||
export function listMenusApi() {
|
||||
return http.get<never, MenuTreeNode[]>('/system/menus')
|
||||
}
|
||||
|
||||
export function createMenuApi(payload: CreateMenuRequest) {
|
||||
return http.post<never, { id: string }>('/system/menus', payload)
|
||||
}
|
||||
|
||||
export function updateMenuApi(id: string, payload: UpdateMenuRequest) {
|
||||
return http.put<never, void>(`/system/menus/${id}`, payload)
|
||||
}
|
||||
|
||||
export function deleteMenuApi(id: string) {
|
||||
return http.delete<never, void>(`/system/menus/${id}`)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import http from '@/api/http'
|
||||
import type { CreateOrgRequest, OrgTreeNode, UpdateOrgRequest } from '@/types/system/org'
|
||||
|
||||
export function listOrgsApi() {
|
||||
return http.get<never, OrgTreeNode[]>('/system/orgs')
|
||||
}
|
||||
|
||||
export function createOrgApi(payload: CreateOrgRequest) {
|
||||
return http.post<never, { id: string }>('/system/orgs', payload)
|
||||
}
|
||||
|
||||
export function updateOrgApi(id: string, payload: UpdateOrgRequest) {
|
||||
return http.put<never, void>(`/system/orgs/${id}`, payload)
|
||||
}
|
||||
|
||||
export function deleteOrgApi(id: string) {
|
||||
return http.delete<never, void>(`/system/orgs/${id}`)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import http from '@/api/http'
|
||||
import type {
|
||||
CreateRoleRequest,
|
||||
RoleDetail,
|
||||
RolePage,
|
||||
RoleQuery,
|
||||
UpdateRoleRequest
|
||||
} from '@/types/system/role'
|
||||
|
||||
export function listRolesApi(params: RoleQuery) {
|
||||
return http.get<never, RolePage>('/system/roles', { params })
|
||||
}
|
||||
|
||||
export function createRoleApi(payload: CreateRoleRequest) {
|
||||
return http.post<never, { id: string }>('/system/roles', payload)
|
||||
}
|
||||
|
||||
export function getRoleDetailApi(id: string) {
|
||||
return http.get<never, RoleDetail>(`/system/roles/${id}`)
|
||||
}
|
||||
|
||||
export function updateRoleApi(id: string, payload: UpdateRoleRequest) {
|
||||
return http.put<never, void>(`/system/roles/${id}`, payload)
|
||||
}
|
||||
|
||||
export function deleteRoleApi(id: string) {
|
||||
return http.delete<never, void>(`/system/roles/${id}`)
|
||||
}
|
||||
|
||||
export function updateRoleMenusApi(id: string, menuIds: string[]) {
|
||||
return http.put<never, void>(`/system/roles/${id}/menus`, { menuIds })
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import http from '@/api/http'
|
||||
import type {
|
||||
CreateUserRequest,
|
||||
UpdateUserPasswordRequest,
|
||||
UpdateUserRequest,
|
||||
UpdateUserRolesRequest,
|
||||
UpdateUserStatusRequest,
|
||||
UserDetail,
|
||||
UserListPage,
|
||||
UserQuery
|
||||
} from '@/types/system/user'
|
||||
|
||||
export function listUsersApi(params: UserQuery) {
|
||||
return http.get<never, UserListPage>('/system/users', { params })
|
||||
}
|
||||
|
||||
export function createUserApi(payload: CreateUserRequest) {
|
||||
return http.post<never, { id: string }>('/system/users', payload)
|
||||
}
|
||||
|
||||
export function getUserDetailApi(id: string) {
|
||||
return http.get<never, UserDetail>(`/system/users/${id}`)
|
||||
}
|
||||
|
||||
export function updateUserApi(id: string, payload: UpdateUserRequest) {
|
||||
return http.put<never, void>(`/system/users/${id}`, payload)
|
||||
}
|
||||
|
||||
export function deleteUserApi(id: string) {
|
||||
return http.delete<never, void>(`/system/users/${id}`)
|
||||
}
|
||||
|
||||
export function updateUserStatusApi(id: string, payload: UpdateUserStatusRequest) {
|
||||
return http.put<never, void>(`/system/users/${id}/status`, payload)
|
||||
}
|
||||
|
||||
export function updateUserPasswordApi(id: string, payload: UpdateUserPasswordRequest) {
|
||||
return http.put<never, void>(`/system/users/${id}/password`, payload)
|
||||
}
|
||||
|
||||
export function updateUserRolesApi(id: string, payload: UpdateUserRolesRequest) {
|
||||
return http.put<never, void>(`/system/users/${id}/roles`, payload)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<n-breadcrumb>
|
||||
<n-breadcrumb-item v-for="item in crumbs" :key="item.path">
|
||||
{{ item.title }}
|
||||
</n-breadcrumb-item>
|
||||
</n-breadcrumb>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const crumbs = computed(() =>
|
||||
route.matched
|
||||
.filter((item) => item.meta?.title)
|
||||
.map((item) => ({
|
||||
path: item.path,
|
||||
title: item.meta.title as string
|
||||
}))
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<n-menu
|
||||
:collapsed="authStore.layoutCollapsed"
|
||||
:collapsed-width="72"
|
||||
:collapsed-icon-size="18"
|
||||
:options="menuOptions"
|
||||
:value="activePath"
|
||||
:indent="18"
|
||||
@update:value="handleSelect"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, type Component } from 'vue'
|
||||
import { NIcon, type MenuOption } from 'naive-ui'
|
||||
import * as LucideIcons from 'lucide-vue-next'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { MenuNode } from '@/types/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
function renderIcon(icon?: string | null) {
|
||||
if (!icon) return undefined
|
||||
const IconComp = LucideIcons[icon as keyof typeof LucideIcons] as unknown as Component | undefined
|
||||
if (!IconComp) return undefined
|
||||
return () => h(NIcon, null, { default: () => h(IconComp) })
|
||||
}
|
||||
|
||||
function toOption(menu: MenuNode): MenuOption | null {
|
||||
if (!menu.visible) return null
|
||||
if (menu.type === 'BUTTON') return null
|
||||
|
||||
const children = (menu.children ?? [])
|
||||
.map((item) => toOption(item))
|
||||
.filter((item): item is MenuOption => Boolean(item))
|
||||
|
||||
const key = menu.path || `catalog-${menu.id}`
|
||||
return {
|
||||
key,
|
||||
label: menu.title,
|
||||
icon: renderIcon(menu.icon),
|
||||
children: children.length > 0 ? children : undefined
|
||||
}
|
||||
}
|
||||
|
||||
const menuOptions = computed(() =>
|
||||
authStore.menus.map((item) => toOption(item)).filter((item): item is MenuOption => Boolean(item))
|
||||
)
|
||||
|
||||
const activePath = computed(() => route.path)
|
||||
|
||||
function handleSelect(key: string) {
|
||||
if (key.startsWith('catalog-')) return
|
||||
router.push(key)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.n-menu) {
|
||||
overflow-x: hidden;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
:deep(.n-menu-item) {
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
:deep(.n-menu-item-content) {
|
||||
color: #64748b;
|
||||
transition:
|
||||
background 0.18s ease,
|
||||
color 0.18s ease;
|
||||
}
|
||||
|
||||
:deep(.n-menu-item-content-header),
|
||||
:deep(.n-menu-item-content__arrow) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:deep(.n-menu-item-content-header) {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:deep(.n-menu-item-content--selected::before) {
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
:deep(.n-menu-item-content--selected) {
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<div class="tabs-shell">
|
||||
<div class="tabs-track">
|
||||
<button
|
||||
v-for="tab in authStore.tabs"
|
||||
:key="tab.path"
|
||||
type="button"
|
||||
class="tab-chip"
|
||||
:class="{ 'tab-chip-active': tab.path === activePath }"
|
||||
@click="router.push(tab.path)"
|
||||
>
|
||||
<span class="tab-title">{{ tab.title }}</span>
|
||||
<span v-if="tab.closable" class="tab-close" @click.stop="onClose(tab.path)">×</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const activePath = computed(() => (route.path === '/workbench' ? '/dashboard' : route.path))
|
||||
|
||||
function onClose(path: string) {
|
||||
const tab = authStore.tabs.find((item) => item.path === path)
|
||||
if (!tab || !tab.closable) return
|
||||
authStore.closeTab(path)
|
||||
if (activePath.value === path) {
|
||||
router.push(authStore.tabs[authStore.tabs.length - 1]?.path ?? '/dashboard')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tabs-shell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 36px;
|
||||
margin-top: 8px;
|
||||
border-top: 1px solid #eef2f7;
|
||||
padding-top: 7px;
|
||||
}
|
||||
|
||||
.tabs-track {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.tab-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.18s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tab-chip:hover {
|
||||
background: #f1f5f9;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.tab-chip-active {
|
||||
background: #111827;
|
||||
border-color: #111827;
|
||||
color: #ffffff;
|
||||
box-shadow: 0 8px 16px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.tab-title {
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tab-close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 999px;
|
||||
color: currentColor;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.tab-close:hover {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<slot v-if="visible" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
interface Props {
|
||||
permission: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const visible = computed(() => authStore.hasPermission(props.permission))
|
||||
</script>
|
||||
@@ -0,0 +1,9 @@
|
||||
function normalizeUrl(url: string | undefined, fallback: string) {
|
||||
const value = (url || '').trim()
|
||||
return value.length > 0 ? value : fallback
|
||||
}
|
||||
|
||||
export const appEnv = {
|
||||
appTitle: normalizeUrl(import.meta.env.VITE_APP_TITLE, '通用管理平台'),
|
||||
apiBaseUrl: normalizeUrl(import.meta.env.VITE_API_BASE_URL, '/api')
|
||||
}
|
||||
Vendored
+17
@@ -0,0 +1,17 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_APP_TITLE: string
|
||||
readonly VITE_API_BASE_URL: string
|
||||
readonly VITE_API_PROXY_TARGET?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>
|
||||
export default component
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<div class="page-shell">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-3 lg:grid-cols-4">
|
||||
<div class="soft-stat"><n-statistic label="用户总数" :value="userTotal" /></div>
|
||||
<div class="soft-stat"><n-statistic label="组织总数" :value="orgTotal" /></div>
|
||||
<div class="soft-stat"><n-statistic label="角色总数" :value="roleTotal" /></div>
|
||||
<div class="soft-stat"><n-statistic label="菜单总数" :value="menuTotal" /></div>
|
||||
<div class="soft-stat"><n-statistic label="今日操作数" :value="todayOperationCount" /></div>
|
||||
<div class="soft-stat">
|
||||
<n-statistic label="权限码数量" :value="authStore.permissions.length" />
|
||||
</div>
|
||||
<div class="soft-stat"><n-statistic label="当前状态" :value="currentStatus" /></div>
|
||||
<div class="soft-stat"><n-statistic label="已打开页签" :value="authStore.tabs.length" /></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { listUsersApi } from '@/api/system/user'
|
||||
import { listOrgsApi } from '@/api/system/org'
|
||||
import { listRolesApi } from '@/api/system/role'
|
||||
import { listMenusApi } from '@/api/system/menu'
|
||||
import { listOperationLogsApi } from '@/api/logs'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { statusLabel } from '@/utils/display'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const usersQuery = useQuery({
|
||||
queryKey: ['dashboard', 'users-count'],
|
||||
queryFn: () => listUsersApi({ page: 1, pageSize: 1 })
|
||||
})
|
||||
const orgsQuery = useQuery({
|
||||
queryKey: ['dashboard', 'orgs-count'],
|
||||
queryFn: listOrgsApi
|
||||
})
|
||||
const rolesQuery = useQuery({
|
||||
queryKey: ['dashboard', 'roles-count'],
|
||||
queryFn: () => listRolesApi({ page: 1, pageSize: 1 })
|
||||
})
|
||||
const menusQuery = useQuery({
|
||||
queryKey: ['dashboard', 'menus-count'],
|
||||
queryFn: listMenusApi
|
||||
})
|
||||
const operationLogQuery = useQuery({
|
||||
queryKey: ['dashboard', 'operation-logs-today'],
|
||||
queryFn: () => listOperationLogsApi({ page: 1, pageSize: 200 })
|
||||
})
|
||||
|
||||
const userTotal = computed(() => usersQuery.data.value?.total ?? 0)
|
||||
const orgTotal = computed(() => orgsQuery.data.value?.length ?? 0)
|
||||
const roleTotal = computed(() => rolesQuery.data.value?.total ?? 0)
|
||||
const menuTotal = computed(() => {
|
||||
const flat = (nodes: { children?: unknown[] }[]): number =>
|
||||
nodes.reduce(
|
||||
(acc, item) => acc + 1 + flat((item.children as { children?: unknown[] }[]) ?? []),
|
||||
0
|
||||
)
|
||||
return flat(menusQuery.data.value ?? [])
|
||||
})
|
||||
const todayOperationCount = computed(() => {
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
return (operationLogQuery.data.value?.items ?? []).filter((item) =>
|
||||
item.createdAt.startsWith(today)
|
||||
).length
|
||||
})
|
||||
const currentStatus = computed(() => statusLabel(authStore.user?.status))
|
||||
</script>
|
||||
@@ -0,0 +1,141 @@
|
||||
<template>
|
||||
<div class="page-shell">
|
||||
<section class="page-card table-fill">
|
||||
<div class="page-toolbar">
|
||||
<n-form inline :show-label="false" class="toolbar-form">
|
||||
<n-form-item>
|
||||
<n-input v-model:value="query.keyword" clearable placeholder="应用/路径" />
|
||||
</n-form-item>
|
||||
<n-form-item>
|
||||
<n-select
|
||||
v-model:value="query.status"
|
||||
clearable
|
||||
placeholder="状态"
|
||||
:options="statusOptions"
|
||||
style="width: 140px"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item>
|
||||
<n-space>
|
||||
<n-button type="primary" @click="refetch">查询</n-button>
|
||||
<n-button @click="reset">重置</n-button>
|
||||
</n-space>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</div>
|
||||
<div class="card-body card-body-fill table-fill">
|
||||
<n-data-table
|
||||
remote
|
||||
size="small"
|
||||
:columns="columns"
|
||||
:data="rows"
|
||||
:loading="apiAccessLogQuery.isLoading.value"
|
||||
:pagination="pagination"
|
||||
:row-key="(row: ApiAccessLogItem) => row.id"
|
||||
@update:page="onPage"
|
||||
@update:page-size="onPageSize"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, reactive } from 'vue'
|
||||
import { NTag, type DataTableColumns } from 'naive-ui'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { listApiAccessLogsApi } from '@/api/logs'
|
||||
import type { ApiAccessLogItem } from '@/types/logs'
|
||||
import { statusLabel, statusTagType } from '@/utils/display'
|
||||
import { renderPagePrefix } from '@/utils/pagination'
|
||||
|
||||
const query = reactive({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
keyword: '',
|
||||
status: null as string | null
|
||||
})
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '成功', value: 'SUCCESS' },
|
||||
{ label: '失败', value: 'FAIL' }
|
||||
]
|
||||
|
||||
const apiAccessLogQuery = useQuery({
|
||||
queryKey: computed(() => ['logs', 'api-access', { ...query }]),
|
||||
queryFn: () =>
|
||||
listApiAccessLogsApi({
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
keyword: query.keyword || undefined,
|
||||
status: query.status || undefined
|
||||
})
|
||||
})
|
||||
|
||||
const rows = computed(() => apiAccessLogQuery.data.value?.items ?? [])
|
||||
|
||||
const pagination = computed(() => ({
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
itemCount: apiAccessLogQuery.data.value?.total ?? 0,
|
||||
showSizePicker: true,
|
||||
pageSizes: [10, 20, 50],
|
||||
prefix: renderPagePrefix
|
||||
}))
|
||||
|
||||
function refetch() {
|
||||
query.page = 1
|
||||
apiAccessLogQuery.refetch()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
query.keyword = ''
|
||||
query.status = null
|
||||
query.page = 1
|
||||
apiAccessLogQuery.refetch()
|
||||
}
|
||||
|
||||
function onPage(page: number) {
|
||||
query.page = page
|
||||
}
|
||||
|
||||
function onPageSize(pageSize: number) {
|
||||
query.pageSize = pageSize
|
||||
query.page = 1
|
||||
}
|
||||
|
||||
const columns = computed<DataTableColumns<ApiAccessLogItem>>(() => [
|
||||
{ title: '时间', key: 'createdAt', width: 170 },
|
||||
{
|
||||
title: '应用',
|
||||
key: 'appName',
|
||||
minWidth: 160,
|
||||
render: (row) =>
|
||||
h('div', { class: 'leading-5' }, [
|
||||
h('div', { class: 'font-medium text-slate-900' }, row.appName || '-'),
|
||||
h('div', { class: 'text-xs text-slate-500' }, row.appKey || '未提供 AppKey')
|
||||
])
|
||||
},
|
||||
{ title: '方法', key: 'httpMethod', width: 90 },
|
||||
{ title: '路径', key: 'requestPath', minWidth: 240 },
|
||||
{ title: '响应码', key: 'responseCode', width: 100, render: (row) => row.responseCode || '-' },
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 90,
|
||||
render: (row) =>
|
||||
h(
|
||||
NTag,
|
||||
{ type: statusTagType(row.status), size: 'small' },
|
||||
{ default: () => statusLabel(row.status) }
|
||||
)
|
||||
},
|
||||
{ title: '耗时', key: 'costMs', width: 90, render: (row) => `${row.costMs} ms` },
|
||||
{
|
||||
title: '错误信息',
|
||||
key: 'errorMessage',
|
||||
minWidth: 180,
|
||||
render: (row) => row.errorMessage || '-'
|
||||
}
|
||||
])
|
||||
</script>
|
||||
@@ -0,0 +1,141 @@
|
||||
<template>
|
||||
<div class="page-shell">
|
||||
<section class="page-card table-fill">
|
||||
<div class="page-toolbar">
|
||||
<n-form inline :show-label="false" class="toolbar-form">
|
||||
<n-form-item>
|
||||
<n-input v-model:value="query.keyword" clearable placeholder="用户/路径" />
|
||||
</n-form-item>
|
||||
<n-form-item>
|
||||
<n-select
|
||||
v-model:value="query.status"
|
||||
clearable
|
||||
placeholder="状态"
|
||||
:options="statusOptions"
|
||||
style="width: 140px"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item>
|
||||
<n-space>
|
||||
<n-button type="primary" @click="refetch">查询</n-button>
|
||||
<n-button @click="reset">重置</n-button>
|
||||
</n-space>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</div>
|
||||
<div class="card-body card-body-fill table-fill">
|
||||
<n-data-table
|
||||
remote
|
||||
size="small"
|
||||
:columns="columns"
|
||||
:data="rows"
|
||||
:loading="operationLogQuery.isLoading.value"
|
||||
:pagination="pagination"
|
||||
:row-key="(row: OperationLogItem) => row.id"
|
||||
@update:page="onPage"
|
||||
@update:page-size="onPageSize"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, reactive } from 'vue'
|
||||
import { NTag, type DataTableColumns } from 'naive-ui'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { listOperationLogsApi } from '@/api/logs'
|
||||
import type { OperationLogItem } from '@/types/logs'
|
||||
import { httpMethodLabel, operationTypeLabel, statusLabel, statusTagType } from '@/utils/display'
|
||||
import { renderPagePrefix } from '@/utils/pagination'
|
||||
|
||||
const query = reactive({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
keyword: '',
|
||||
status: null as string | null
|
||||
})
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '成功', value: 'SUCCESS' },
|
||||
{ label: '失败', value: 'FAIL' }
|
||||
]
|
||||
|
||||
const operationLogQuery = useQuery({
|
||||
queryKey: computed(() => ['logs', 'operation', { ...query }]),
|
||||
queryFn: () =>
|
||||
listOperationLogsApi({
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
keyword: query.keyword || undefined,
|
||||
status: query.status || undefined
|
||||
})
|
||||
})
|
||||
|
||||
const rows = computed(() => operationLogQuery.data.value?.items ?? [])
|
||||
|
||||
const pagination = computed(() => ({
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
itemCount: operationLogQuery.data.value?.total ?? 0,
|
||||
showSizePicker: true,
|
||||
pageSizes: [10, 20, 50],
|
||||
prefix: renderPagePrefix
|
||||
}))
|
||||
|
||||
function refetch() {
|
||||
query.page = 1
|
||||
operationLogQuery.refetch()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
query.keyword = ''
|
||||
query.status = null
|
||||
query.page = 1
|
||||
operationLogQuery.refetch()
|
||||
}
|
||||
|
||||
function onPage(page: number) {
|
||||
query.page = page
|
||||
}
|
||||
|
||||
function onPageSize(pageSize: number) {
|
||||
query.pageSize = pageSize
|
||||
query.page = 1
|
||||
}
|
||||
|
||||
const columns = computed<DataTableColumns<OperationLogItem>>(() => [
|
||||
{ title: '时间', key: 'createdAt', width: 170 },
|
||||
{ title: '用户', key: 'username', minWidth: 110, render: (row) => row.username || '-' },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'operationName',
|
||||
minWidth: 180,
|
||||
render: (row) =>
|
||||
h('div', { class: 'leading-5' }, [
|
||||
h('div', { class: 'font-medium text-slate-900' }, row.operationName),
|
||||
h('div', { class: 'text-xs text-slate-500' }, operationTypeLabel(row.operationType))
|
||||
])
|
||||
},
|
||||
{ title: '方法', key: 'httpMethod', width: 90, render: (row) => httpMethodLabel(row.httpMethod) },
|
||||
{ title: '路径', key: 'requestPath', minWidth: 220 },
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 90,
|
||||
render: (row) =>
|
||||
h(
|
||||
NTag,
|
||||
{ type: statusTagType(row.status), size: 'small' },
|
||||
{ default: () => statusLabel(row.status) }
|
||||
)
|
||||
},
|
||||
{ title: '耗时', key: 'costMs', width: 90, render: (row) => `${row.costMs} ms` },
|
||||
{
|
||||
title: '错误信息',
|
||||
key: 'errorMessage',
|
||||
minWidth: 180,
|
||||
render: (row) => row.errorMessage || '-'
|
||||
}
|
||||
])
|
||||
</script>
|
||||
@@ -0,0 +1,235 @@
|
||||
<template>
|
||||
<div class="page-shell">
|
||||
<div class="grid min-h-0 flex-1 grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<section class="page-card table-fill">
|
||||
<div class="page-toolbar">
|
||||
<span class="text-sm font-semibold text-slate-700">字典类型</span>
|
||||
<n-button size="small" type="primary" @click="openTypeCreate">新增类型</n-button>
|
||||
</div>
|
||||
<div class="card-body card-body-fill table-fill">
|
||||
<n-data-table
|
||||
:columns="typeColumns"
|
||||
:data="typeRows"
|
||||
:pagination="typePagination"
|
||||
remote
|
||||
@update:page="onTypePage"
|
||||
@update:page-size="onTypePageSize"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<section class="page-card table-fill">
|
||||
<div class="page-toolbar">
|
||||
<span class="text-sm font-semibold text-slate-700">字典项</span>
|
||||
<n-button size="small" type="primary" :disabled="!selectedTypeId" @click="openItemCreate"
|
||||
>新增字典项</n-button
|
||||
>
|
||||
</div>
|
||||
<div class="card-body card-body-fill table-fill">
|
||||
<n-data-table
|
||||
:columns="itemColumns"
|
||||
:data="itemRows"
|
||||
:pagination="itemPagination"
|
||||
remote
|
||||
@update:page="onItemPage"
|
||||
@update:page-size="onItemPageSize"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, reactive, ref, watch } from 'vue'
|
||||
import { NButton, NPopconfirm, NTag, NSpace, useMessage, type DataTableColumns } from 'naive-ui'
|
||||
import { useMutation, useQuery } from '@tanstack/vue-query'
|
||||
import {
|
||||
createDictItemApi,
|
||||
createDictTypeApi,
|
||||
deleteDictItemApi,
|
||||
deleteDictTypeApi,
|
||||
listDictItemsApi,
|
||||
listDictTypesApi
|
||||
} from '@/api/system/dict'
|
||||
import type { DictItem, DictTypeItem } from '@/types/system/dict'
|
||||
import { statusLabel, statusTagType } from '@/utils/display'
|
||||
import { renderPagePrefix } from '@/utils/pagination'
|
||||
|
||||
const message = useMessage()
|
||||
const selectedTypeId = ref<string>('')
|
||||
const typeQuery = reactive({ page: 1, pageSize: 10, keyword: '' })
|
||||
const itemQuery = reactive({ page: 1, pageSize: 10 })
|
||||
const typesReq = useQuery({
|
||||
queryKey: computed(() => ['dict-types', { ...typeQuery }]),
|
||||
queryFn: () => listDictTypesApi({ ...typeQuery, keyword: typeQuery.keyword || undefined })
|
||||
})
|
||||
const itemsReq = useQuery({
|
||||
queryKey: computed(() => ['dict-items', selectedTypeId.value, { ...itemQuery }]),
|
||||
queryFn: () => listDictItemsApi({ ...itemQuery, typeId: selectedTypeId.value || undefined })
|
||||
})
|
||||
const typeRows = computed(() => typesReq.data.value?.items ?? [])
|
||||
const itemRows = computed(() => itemsReq.data.value?.items ?? [])
|
||||
const typePagination = computed(() => ({
|
||||
page: typeQuery.page,
|
||||
pageSize: typeQuery.pageSize,
|
||||
itemCount: typesReq.data.value?.total ?? 0,
|
||||
showSizePicker: true,
|
||||
pageSizes: [10, 20],
|
||||
prefix: renderPagePrefix
|
||||
}))
|
||||
const itemPagination = computed(() => ({
|
||||
page: itemQuery.page,
|
||||
pageSize: itemQuery.pageSize,
|
||||
itemCount: itemsReq.data.value?.total ?? 0,
|
||||
showSizePicker: true,
|
||||
pageSizes: [10, 20],
|
||||
prefix: renderPagePrefix
|
||||
}))
|
||||
watch(
|
||||
typeRows,
|
||||
(v) => {
|
||||
if (!selectedTypeId.value && v.length > 0) selectedTypeId.value = v[0].id
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const createTypeMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
createDictTypeApi({
|
||||
code: `TYPE_${Date.now()}`,
|
||||
name: `新类型${Date.now().toString().slice(-4)}`,
|
||||
status: 'ENABLED'
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
message.success('新增类型成功')
|
||||
await typesReq.refetch()
|
||||
}
|
||||
})
|
||||
const createItemMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
createDictItemApi({
|
||||
typeId: selectedTypeId.value,
|
||||
label: `新项${Date.now().toString().slice(-4)}`,
|
||||
value: `${Date.now()}`,
|
||||
sort: 0,
|
||||
status: 'ENABLED'
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
message.success('新增字典项成功')
|
||||
await itemsReq.refetch()
|
||||
}
|
||||
})
|
||||
const deleteTypeMutation = useMutation({
|
||||
mutationFn: deleteDictTypeApi,
|
||||
onSuccess: async () => {
|
||||
message.success('删除成功')
|
||||
selectedTypeId.value = ''
|
||||
await typesReq.refetch()
|
||||
await itemsReq.refetch()
|
||||
}
|
||||
})
|
||||
const deleteItemMutation = useMutation({
|
||||
mutationFn: deleteDictItemApi,
|
||||
onSuccess: async () => {
|
||||
message.success('删除成功')
|
||||
await itemsReq.refetch()
|
||||
}
|
||||
})
|
||||
|
||||
function openTypeCreate() {
|
||||
createTypeMutation.mutate()
|
||||
}
|
||||
function openItemCreate() {
|
||||
if (!selectedTypeId.value) return
|
||||
createItemMutation.mutate()
|
||||
}
|
||||
function onTypePage(p: number) {
|
||||
typeQuery.page = p
|
||||
}
|
||||
function onTypePageSize(s: number) {
|
||||
typeQuery.pageSize = s
|
||||
typeQuery.page = 1
|
||||
}
|
||||
function onItemPage(p: number) {
|
||||
itemQuery.page = p
|
||||
}
|
||||
function onItemPageSize(s: number) {
|
||||
itemQuery.pageSize = s
|
||||
itemQuery.page = 1
|
||||
}
|
||||
|
||||
const typeColumns = computed<DataTableColumns<DictTypeItem>>(() => [
|
||||
{ title: '编码', key: 'code' },
|
||||
{
|
||||
title: '名称',
|
||||
key: 'name',
|
||||
render: (r) => h('a', { onClick: () => (selectedTypeId.value = r.id) }, r.name)
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
render: (r) =>
|
||||
h(
|
||||
NTag,
|
||||
{ size: 'small', type: statusTagType(r.status) },
|
||||
{ default: () => statusLabel(r.status) }
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
render: (r) =>
|
||||
h(
|
||||
NPopconfirm,
|
||||
{ onPositiveClick: () => deleteTypeMutation.mutate(r.id) },
|
||||
{
|
||||
trigger: () =>
|
||||
h(NButton, { size: 'tiny', tertiary: true, type: 'error' }, { default: () => '删除' }),
|
||||
default: () => '确认删除?'
|
||||
}
|
||||
)
|
||||
}
|
||||
])
|
||||
const itemColumns = computed<DataTableColumns<DictItem>>(() => [
|
||||
{ title: '标签', key: 'label' },
|
||||
{ title: '值', key: 'value' },
|
||||
{
|
||||
title: '颜色',
|
||||
key: 'color',
|
||||
render: (r) =>
|
||||
r.color
|
||||
? h(NTag, { size: 'small', color: { color: r.color } }, { default: () => r.color })
|
||||
: '-'
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
render: (r) =>
|
||||
h(
|
||||
NTag,
|
||||
{ size: 'small', type: statusTagType(r.status) },
|
||||
{ default: () => statusLabel(r.status) }
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
render: (r) =>
|
||||
h(NSpace, { size: 6 }, () => [
|
||||
h(
|
||||
NPopconfirm,
|
||||
{ onPositiveClick: () => deleteItemMutation.mutate(r.id) },
|
||||
{
|
||||
trigger: () =>
|
||||
h(
|
||||
NButton,
|
||||
{ size: 'tiny', tertiary: true, type: 'error' },
|
||||
{ default: () => '删除' }
|
||||
),
|
||||
default: () => '确认删除?'
|
||||
}
|
||||
)
|
||||
])
|
||||
}
|
||||
])
|
||||
</script>
|
||||
@@ -0,0 +1,341 @@
|
||||
<template>
|
||||
<div class="page-shell">
|
||||
<section class="page-card table-fill">
|
||||
<div class="page-toolbar">
|
||||
<span class="text-sm font-semibold text-slate-700">菜单结构</span>
|
||||
<n-button type="primary" @click="openCreate()">新增菜单</n-button>
|
||||
</div>
|
||||
<div class="card-body card-body-fill tree-card-body">
|
||||
<n-tree
|
||||
block-line
|
||||
expand-on-click
|
||||
:data="menus"
|
||||
key-field="id"
|
||||
label-field="title"
|
||||
children-field="children"
|
||||
:render-switcher-icon="renderSwitcherIcon"
|
||||
:render-label="renderLabel"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<n-modal
|
||||
v-model:show="modal.visible"
|
||||
preset="card"
|
||||
:title="modal.mode === 'create' ? '新增菜单' : '编辑菜单'"
|
||||
class="w-[860px]"
|
||||
>
|
||||
<n-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-width="90"
|
||||
class="grid grid-cols-2 gap-x-4"
|
||||
>
|
||||
<n-form-item label="上级"
|
||||
><n-tree-select
|
||||
v-model:value="form.parentId"
|
||||
clearable
|
||||
:options="menus as any"
|
||||
key-field="id"
|
||||
label-field="title"
|
||||
children-field="children"
|
||||
/></n-form-item>
|
||||
<n-form-item label="类型"
|
||||
><n-select v-model:value="form.type" :options="typeOptions"
|
||||
/></n-form-item>
|
||||
<n-form-item label="标题" path="title"><n-input v-model:value="form.title" /></n-form-item>
|
||||
<n-form-item label="路由名"><n-input v-model:value="form.name" /></n-form-item>
|
||||
<n-form-item label="路径"><n-input v-model:value="form.path" /></n-form-item>
|
||||
<n-form-item label="组件"
|
||||
><n-input v-model:value="form.component" placeholder="system/users/index"
|
||||
/></n-form-item>
|
||||
<n-form-item label="图标">
|
||||
<n-space align="center" class="w-full">
|
||||
<n-select
|
||||
v-model:value="form.icon"
|
||||
filterable
|
||||
clearable
|
||||
:options="iconOptions"
|
||||
placeholder="请选择图标"
|
||||
/>
|
||||
<n-icon v-if="currentIconComp" size="18"><component :is="currentIconComp" /></n-icon>
|
||||
</n-space>
|
||||
</n-form-item>
|
||||
<n-form-item label="权限码"><n-input v-model:value="form.permission" /></n-form-item>
|
||||
<n-form-item label="排序"
|
||||
><n-input-number v-model:value="form.sort" :min="0"
|
||||
/></n-form-item>
|
||||
<n-form-item label="可见"><n-switch v-model:value="form.visible" /></n-form-item>
|
||||
<n-form-item label="缓存"><n-switch v-model:value="form.keepAlive" /></n-form-item>
|
||||
<n-form-item label="状态"
|
||||
><n-select v-model:value="form.status" :options="statusOptions"
|
||||
/></n-form-item>
|
||||
</n-form>
|
||||
<template #footer
|
||||
><div class="flex justify-end gap-2">
|
||||
<n-button @click="modal.visible = false">取消</n-button
|
||||
><n-button type="primary" @click="save">保存</n-button>
|
||||
</div></template
|
||||
>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, reactive, ref } from 'vue'
|
||||
import {
|
||||
NButton,
|
||||
NIcon,
|
||||
NPopconfirm,
|
||||
NSpace,
|
||||
NTag,
|
||||
useMessage,
|
||||
type FormInst,
|
||||
type FormRules
|
||||
} from 'naive-ui'
|
||||
import { ChevronRight } from 'lucide-vue-next'
|
||||
import * as LucideIcons from 'lucide-vue-next'
|
||||
import { useMutation, useQuery } from '@tanstack/vue-query'
|
||||
import { createMenuApi, deleteMenuApi, listMenusApi, updateMenuApi } from '@/api/system/menu'
|
||||
import type { MenuTreeNode } from '@/types/system/menu'
|
||||
import { menuTypeLabel, statusLabel } from '@/utils/display'
|
||||
|
||||
const message = useMessage()
|
||||
const menuQuery = useQuery({ queryKey: ['system', 'menus'], queryFn: listMenusApi })
|
||||
const menus = computed(() => menuQuery.data.value ?? [])
|
||||
const typeOptions = [
|
||||
{ label: '目录', value: 'CATALOG' },
|
||||
{ label: '菜单', value: 'MENU' },
|
||||
{ label: '按钮', value: 'BUTTON' }
|
||||
]
|
||||
const statusOptions = [
|
||||
{ label: '启用', value: 'ENABLED' },
|
||||
{ label: '禁用', value: 'DISABLED' }
|
||||
]
|
||||
const iconOptions = [
|
||||
{ label: '仪表盘', value: 'LayoutDashboard' },
|
||||
{ label: '设置', value: 'Settings' },
|
||||
{ label: '用户', value: 'Users' },
|
||||
{ label: '组织', value: 'Building2' },
|
||||
{ label: '盾牌', value: 'Shield' },
|
||||
{ label: '侧栏', value: 'PanelLeft' },
|
||||
{ label: '字典', value: 'BookType' },
|
||||
{ label: '日志', value: 'Logs' },
|
||||
{ label: '文本日志', value: 'ScrollText' },
|
||||
{ label: '接口链路', value: 'Waypoints' },
|
||||
{ label: '首页', value: 'Home' },
|
||||
{ label: '目录树', value: 'FolderTree' },
|
||||
{ label: '列表树', value: 'ListTree' },
|
||||
{ label: '清单', value: 'ClipboardList' },
|
||||
{ label: '图表', value: 'ChartColumn' },
|
||||
{ label: '文件', value: 'FileText' },
|
||||
{ label: '数据库', value: 'Database' },
|
||||
{ label: '齿轮', value: 'Cog' },
|
||||
{ label: '用户设置', value: 'UserCog' },
|
||||
{ label: '密钥', value: 'KeyRound' }
|
||||
]
|
||||
|
||||
const formRef = ref<FormInst | null>(null)
|
||||
const modal = reactive({ visible: false, mode: 'create' as 'create' | 'edit', id: '' })
|
||||
const form = reactive({
|
||||
parentId: null as string | null,
|
||||
type: 'MENU',
|
||||
title: '',
|
||||
name: '',
|
||||
path: '',
|
||||
component: '',
|
||||
icon: '',
|
||||
permission: '',
|
||||
sort: 0,
|
||||
visible: true,
|
||||
keepAlive: false,
|
||||
status: 'ENABLED'
|
||||
})
|
||||
const rules: FormRules = { title: [{ required: true, message: '请输入标题' }] }
|
||||
const currentIconComp = computed(() =>
|
||||
form.icon ? (LucideIcons[form.icon as keyof typeof LucideIcons] as unknown) : null
|
||||
)
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const payload = {
|
||||
...form,
|
||||
parentId: form.parentId ?? undefined,
|
||||
name: form.name || undefined,
|
||||
path: form.path || undefined,
|
||||
component: form.component || undefined,
|
||||
icon: form.icon || undefined,
|
||||
permission: form.permission || undefined
|
||||
}
|
||||
if (modal.mode === 'create') {
|
||||
await createMenuApi(payload)
|
||||
return
|
||||
}
|
||||
await updateMenuApi(modal.id, payload)
|
||||
},
|
||||
onSuccess: async () => {
|
||||
message.success('保存成功')
|
||||
modal.visible = false
|
||||
await menuQuery.refetch()
|
||||
}
|
||||
})
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteMenuApi,
|
||||
onSuccess: async () => {
|
||||
message.success('删除成功')
|
||||
await menuQuery.refetch()
|
||||
}
|
||||
})
|
||||
|
||||
function openCreate(parentId?: string) {
|
||||
modal.mode = 'create'
|
||||
modal.id = ''
|
||||
Object.assign(form, {
|
||||
parentId: parentId ?? null,
|
||||
type: 'MENU',
|
||||
title: '',
|
||||
name: '',
|
||||
path: '',
|
||||
component: '',
|
||||
icon: '',
|
||||
permission: '',
|
||||
sort: 0,
|
||||
visible: true,
|
||||
keepAlive: false,
|
||||
status: 'ENABLED'
|
||||
})
|
||||
modal.visible = true
|
||||
}
|
||||
function openEdit(node: MenuTreeNode) {
|
||||
modal.mode = 'edit'
|
||||
modal.id = node.id
|
||||
Object.assign(form, {
|
||||
parentId: node.parentId ?? null,
|
||||
type: node.type,
|
||||
title: node.title,
|
||||
name: node.name ?? '',
|
||||
path: node.path ?? '',
|
||||
component: node.component ?? '',
|
||||
icon: node.icon ?? '',
|
||||
permission: node.permission ?? '',
|
||||
sort: node.sort,
|
||||
visible: node.visible,
|
||||
keepAlive: node.keepAlive,
|
||||
status: node.status
|
||||
})
|
||||
modal.visible = true
|
||||
}
|
||||
async function save() {
|
||||
await formRef.value?.validate()
|
||||
await saveMutation.mutateAsync()
|
||||
}
|
||||
|
||||
function renderLabel(payload: { option: unknown }) {
|
||||
const node = payload.option as MenuTreeNode
|
||||
return h('div', { class: 'flex w-full items-center justify-between py-2' }, [
|
||||
h('div', { class: 'flex items-center gap-2' }, [
|
||||
h('span', node.title),
|
||||
h(
|
||||
NTag,
|
||||
{
|
||||
size: 'small',
|
||||
type: node.type === 'BUTTON' ? 'warning' : node.type === 'CATALOG' ? 'info' : 'success'
|
||||
},
|
||||
{ default: () => menuTypeLabel(node.type) }
|
||||
),
|
||||
h(
|
||||
NTag,
|
||||
{ size: 'small', type: node.status === 'ENABLED' ? 'success' : 'warning' },
|
||||
{ default: () => statusLabel(node.status) }
|
||||
),
|
||||
node.builtIn
|
||||
? h(NTag, { size: 'small', type: 'primary' }, { default: () => '基础内置' })
|
||||
: null
|
||||
]),
|
||||
h(NSpace, { size: 6 }, () => [
|
||||
h(
|
||||
NButton,
|
||||
{ size: 'small', tertiary: true, class: 'action-btn', onClick: () => openCreate(node.id) },
|
||||
{ default: () => '新增子级' }
|
||||
),
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
tertiary: true,
|
||||
type: 'primary',
|
||||
class: 'action-btn',
|
||||
onClick: () => openEdit(node)
|
||||
},
|
||||
{ default: () => '编辑' }
|
||||
),
|
||||
node.builtIn
|
||||
? h(
|
||||
NButton,
|
||||
{ size: 'small', tertiary: true, type: 'default', class: 'action-btn', disabled: true },
|
||||
{ default: () => '不可删除' }
|
||||
)
|
||||
: h(
|
||||
NPopconfirm,
|
||||
{ onPositiveClick: () => deleteMutation.mutate(node.id) },
|
||||
{
|
||||
trigger: () =>
|
||||
h(
|
||||
NButton,
|
||||
{ size: 'small', tertiary: true, type: 'error', class: 'action-btn' },
|
||||
{ default: () => '删除' }
|
||||
),
|
||||
default: () => '确认删除?'
|
||||
}
|
||||
)
|
||||
])
|
||||
])
|
||||
}
|
||||
|
||||
function renderSwitcherIcon(payload: { option: unknown }) {
|
||||
const node = payload.option as MenuTreeNode
|
||||
if (node.type !== 'CATALOG') {
|
||||
return h('span', { class: 'tree-switcher-empty' })
|
||||
}
|
||||
return h(ChevronRight, { size: 16, strokeWidth: 2.25, class: 'tree-switcher-icon' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tree-card-body {
|
||||
padding: 8px 10px 12px;
|
||||
}
|
||||
|
||||
:deep(.n-tree-node-switcher) {
|
||||
width: 28px;
|
||||
height: 42px;
|
||||
margin-top: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
:deep(.tree-switcher-icon) {
|
||||
display: block;
|
||||
color: #0f8f86;
|
||||
}
|
||||
|
||||
:deep(.tree-switcher-empty) {
|
||||
display: block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
:deep(.n-tree-node-content) {
|
||||
min-height: 42px;
|
||||
align-items: center;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
:deep(.n-tree-node-content__text) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,238 @@
|
||||
<template>
|
||||
<div class="page-shell">
|
||||
<section class="page-card table-fill">
|
||||
<div class="page-toolbar">
|
||||
<span class="text-sm font-semibold text-slate-700">组织架构</span>
|
||||
<n-button type="primary" @click="openCreate()">新增组织</n-button>
|
||||
</div>
|
||||
<div class="card-body card-body-fill tree-card-body">
|
||||
<n-tree
|
||||
block-line
|
||||
expand-on-click
|
||||
:data="treeData"
|
||||
key-field="id"
|
||||
label-field="name"
|
||||
children-field="children"
|
||||
:render-switcher-icon="renderSwitcherIcon"
|
||||
:render-label="renderLabel"
|
||||
:expanded-keys="expandedKeys"
|
||||
@update:expanded-keys="onExpanded"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<n-modal
|
||||
v-model:show="modal.visible"
|
||||
preset="card"
|
||||
:title="modal.mode === 'create' ? '新增组织' : '编辑组织'"
|
||||
class="w-[520px]"
|
||||
>
|
||||
<n-form ref="formRef" :model="form" :rules="rules" label-placement="left" label-width="90">
|
||||
<n-form-item label="上级组织">
|
||||
<n-tree-select
|
||||
v-model:value="form.parentId"
|
||||
clearable
|
||||
:options="treeSelectOptions"
|
||||
placeholder="可选"
|
||||
key-field="id"
|
||||
label-field="name"
|
||||
children-field="children"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="组织名称" path="name"
|
||||
><n-input v-model:value="form.name"
|
||||
/></n-form-item>
|
||||
<n-form-item label="组织编码" path="code"
|
||||
><n-input v-model:value="form.code" :disabled="modal.mode === 'edit'"
|
||||
/></n-form-item>
|
||||
<n-form-item label="排序"
|
||||
><n-input-number v-model:value="form.sort" :min="0"
|
||||
/></n-form-item>
|
||||
<n-form-item label="状态">
|
||||
<n-radio-group v-model:value="form.status">
|
||||
<n-radio-button value="ENABLED">启用</n-radio-button>
|
||||
<n-radio-button value="DISABLED">禁用</n-radio-button>
|
||||
</n-radio-group>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<n-button @click="modal.visible = false">取消</n-button>
|
||||
<n-button type="primary" :loading="saveMutation.isPending.value" @click="save"
|
||||
>保存</n-button
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, reactive, ref } from 'vue'
|
||||
import type { FormInst, FormRules, TreeOption } from 'naive-ui'
|
||||
import { NButton, NPopconfirm, NSpace, NTag, useMessage } from 'naive-ui'
|
||||
import { ChevronRight } from 'lucide-vue-next'
|
||||
import { useMutation, useQuery } from '@tanstack/vue-query'
|
||||
import { createOrgApi, deleteOrgApi, listOrgsApi, updateOrgApi } from '@/api/system/org'
|
||||
import type { OrgTreeNode } from '@/types/system/org'
|
||||
import { statusLabel, statusTagType } from '@/utils/display'
|
||||
|
||||
const message = useMessage()
|
||||
const formRef = ref<FormInst | null>(null)
|
||||
const expandedKeys = ref<string[]>([])
|
||||
|
||||
const modal = reactive({ visible: false, mode: 'create' as 'create' | 'edit', id: '' })
|
||||
const form = reactive({
|
||||
parentId: null as string | null,
|
||||
name: '',
|
||||
code: '',
|
||||
sort: 0,
|
||||
status: 'ENABLED'
|
||||
})
|
||||
const rules: FormRules = {
|
||||
name: [{ required: true, message: '请输入组织名称', trigger: ['blur', 'input'] }],
|
||||
code: [{ required: true, message: '请输入组织编码', trigger: ['blur', 'input'] }]
|
||||
}
|
||||
|
||||
const orgQuery = useQuery({
|
||||
queryKey: ['system', 'orgs'],
|
||||
queryFn: listOrgsApi
|
||||
})
|
||||
|
||||
const treeData = computed(() => orgQuery.data.value ?? [])
|
||||
const treeSelectOptions = computed(() => treeData.value as unknown as TreeOption[])
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const payload = {
|
||||
parentId: form.parentId ?? undefined,
|
||||
name: form.name.trim(),
|
||||
code: form.code.trim(),
|
||||
sort: form.sort,
|
||||
status: form.status
|
||||
}
|
||||
if (modal.mode === 'create') return createOrgApi(payload)
|
||||
return updateOrgApi(modal.id, payload)
|
||||
},
|
||||
onSuccess: async () => {
|
||||
message.success('保存成功')
|
||||
modal.visible = false
|
||||
await orgQuery.refetch()
|
||||
expandedKeys.value = collectAllKeys(treeData.value)
|
||||
}
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteOrgApi,
|
||||
onSuccess: async () => {
|
||||
message.success('删除成功')
|
||||
await orgQuery.refetch()
|
||||
}
|
||||
})
|
||||
|
||||
function openCreate(parentId?: string) {
|
||||
modal.mode = 'create'
|
||||
modal.id = ''
|
||||
form.parentId = parentId ?? null
|
||||
form.name = ''
|
||||
form.code = ''
|
||||
form.sort = 0
|
||||
form.status = 'ENABLED'
|
||||
modal.visible = true
|
||||
}
|
||||
|
||||
function openEdit(node: OrgTreeNode) {
|
||||
modal.mode = 'edit'
|
||||
modal.id = node.id
|
||||
form.parentId = node.parentId ?? null
|
||||
form.name = node.name
|
||||
form.code = node.code
|
||||
form.sort = node.sort
|
||||
form.status = node.status
|
||||
modal.visible = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
await formRef.value?.validate()
|
||||
await saveMutation.mutateAsync()
|
||||
}
|
||||
|
||||
function renderLabel(payload: { option: unknown }) {
|
||||
const node = payload.option as OrgTreeNode
|
||||
return h('div', { class: 'flex w-full items-center justify-between py-2' }, [
|
||||
h('div', { class: 'flex items-center gap-2' }, [
|
||||
h('span', node.name),
|
||||
h(
|
||||
NTag,
|
||||
{ size: 'small', type: statusTagType(node.status) },
|
||||
{ default: () => statusLabel(node.status) }
|
||||
)
|
||||
]),
|
||||
h(NSpace, { size: 6 }, () => [
|
||||
h(
|
||||
NButton,
|
||||
{ size: 'small', tertiary: true, onClick: () => openCreate(node.id) },
|
||||
{ default: () => '新增子级' }
|
||||
),
|
||||
h(
|
||||
NButton,
|
||||
{ size: 'small', tertiary: true, type: 'primary', onClick: () => openEdit(node) },
|
||||
{ default: () => '编辑' }
|
||||
),
|
||||
h(
|
||||
NPopconfirm,
|
||||
{ onPositiveClick: () => deleteMutation.mutate(node.id) },
|
||||
{
|
||||
trigger: () =>
|
||||
h(NButton, { size: 'small', tertiary: true, type: 'error' }, { default: () => '删除' }),
|
||||
default: () => '确认删除该组织吗?'
|
||||
}
|
||||
)
|
||||
])
|
||||
])
|
||||
}
|
||||
function onExpanded(keys: string[]) {
|
||||
expandedKeys.value = keys
|
||||
}
|
||||
|
||||
function collectAllKeys(nodes: OrgTreeNode[]): string[] {
|
||||
return nodes.flatMap((node) => [node.id, ...collectAllKeys(node.children ?? [])])
|
||||
}
|
||||
|
||||
function renderSwitcherIcon() {
|
||||
return h(ChevronRight, { size: 16, strokeWidth: 2.25, class: 'tree-switcher-icon' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tree-card-body {
|
||||
padding: 8px 10px 12px;
|
||||
}
|
||||
|
||||
:deep(.n-tree-node-switcher) {
|
||||
width: 28px;
|
||||
height: 42px;
|
||||
margin-top: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
:deep(.tree-switcher-icon) {
|
||||
display: block;
|
||||
color: #0f8f86;
|
||||
}
|
||||
|
||||
:deep(.n-tree-node-content) {
|
||||
min-height: 42px;
|
||||
align-items: center;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
:deep(.n-tree-node-content__text) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,297 @@
|
||||
<template>
|
||||
<div class="page-shell">
|
||||
<section class="page-card table-fill">
|
||||
<div class="page-toolbar">
|
||||
<n-form inline :show-label="false" class="toolbar-form">
|
||||
<n-form-item
|
||||
><n-input v-model:value="query.keyword" placeholder="角色名/编码" clearable
|
||||
/></n-form-item>
|
||||
<n-form-item
|
||||
><n-select
|
||||
v-model:value="query.status"
|
||||
:options="statusOptions"
|
||||
clearable
|
||||
placeholder="状态"
|
||||
style="width: 140px"
|
||||
/></n-form-item>
|
||||
<n-form-item><n-button type="primary" @click="refetch">查询</n-button></n-form-item>
|
||||
</n-form>
|
||||
<n-button type="primary" @click="openCreate">新增角色</n-button>
|
||||
</div>
|
||||
<div class="card-body card-body-fill table-fill">
|
||||
<n-data-table
|
||||
:columns="columns"
|
||||
:data="rows"
|
||||
:pagination="pagination"
|
||||
remote
|
||||
@update:page="onPage"
|
||||
@update:page-size="onPageSize"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<n-modal
|
||||
v-model:show="modal.visible"
|
||||
preset="card"
|
||||
:title="modal.mode === 'create' ? '新增角色' : '编辑角色'"
|
||||
class="w-[560px]"
|
||||
>
|
||||
<n-form ref="formRef" :model="form" :rules="rules" label-width="90">
|
||||
<n-form-item label="角色名称" path="name"
|
||||
><n-input v-model:value="form.name"
|
||||
/></n-form-item>
|
||||
<n-form-item label="角色编码" path="code"
|
||||
><n-input v-model:value="form.code" :disabled="modal.mode === 'edit'"
|
||||
/></n-form-item>
|
||||
<n-form-item label="状态"
|
||||
><n-radio-group v-model:value="form.status"
|
||||
><n-radio-button value="ENABLED">启用</n-radio-button
|
||||
><n-radio-button value="DISABLED">禁用</n-radio-button></n-radio-group
|
||||
></n-form-item
|
||||
>
|
||||
<n-form-item label="数据范围"
|
||||
><n-select v-model:value="form.dataScope" :options="dataScopeOptions"
|
||||
/></n-form-item>
|
||||
<n-form-item label="描述"
|
||||
><n-input v-model:value="form.description" type="textarea"
|
||||
/></n-form-item>
|
||||
</n-form>
|
||||
<template #footer
|
||||
><div class="flex justify-end gap-2">
|
||||
<n-button @click="modal.visible = false">取消</n-button
|
||||
><n-button type="primary" @click="save">保存</n-button>
|
||||
</div></template
|
||||
>
|
||||
</n-modal>
|
||||
|
||||
<n-modal v-model:show="menuModal.visible" preset="card" title="分配菜单" class="w-[620px]">
|
||||
<n-tree-select
|
||||
v-model:value="menuModal.menuIds"
|
||||
multiple
|
||||
clearable
|
||||
:options="menuTree as any"
|
||||
key-field="id"
|
||||
label-field="title"
|
||||
children-field="children"
|
||||
/>
|
||||
<template #footer
|
||||
><div class="flex justify-end gap-2">
|
||||
<n-button @click="menuModal.visible = false">取消</n-button
|
||||
><n-button type="primary" @click="saveMenus">保存</n-button>
|
||||
</div></template
|
||||
>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, reactive, ref } from 'vue'
|
||||
import {
|
||||
NButton,
|
||||
NPopconfirm,
|
||||
NSpace,
|
||||
NTag,
|
||||
useMessage,
|
||||
type DataTableColumns,
|
||||
type FormInst,
|
||||
type FormRules
|
||||
} from 'naive-ui'
|
||||
import { useMutation, useQuery } from '@tanstack/vue-query'
|
||||
import {
|
||||
createRoleApi,
|
||||
deleteRoleApi,
|
||||
getRoleDetailApi,
|
||||
listRolesApi,
|
||||
updateRoleApi,
|
||||
updateRoleMenusApi
|
||||
} from '@/api/system/role'
|
||||
import { listMenusApi } from '@/api/system/menu'
|
||||
import type { RoleItem } from '@/types/system/role'
|
||||
import { dataScopeLabel, statusLabel, statusTagType } from '@/utils/display'
|
||||
import { renderPagePrefix } from '@/utils/pagination'
|
||||
|
||||
const message = useMessage()
|
||||
const query = reactive({ page: 1, pageSize: 10, keyword: '', status: null as string | null })
|
||||
const statusOptions = [
|
||||
{ label: '启用', value: 'ENABLED' },
|
||||
{ label: '禁用', value: 'DISABLED' }
|
||||
]
|
||||
const dataScopeOptions = [
|
||||
{ label: '全部数据', value: 'ALL' },
|
||||
{ label: '本组织及下级', value: 'DEPT' },
|
||||
{ label: '本组织', value: 'DEPT_ONLY' },
|
||||
{ label: '仅本人', value: 'SELF' }
|
||||
]
|
||||
const roleQuery = useQuery({
|
||||
queryKey: computed(() => ['roles', { ...query }]),
|
||||
queryFn: () =>
|
||||
listRolesApi({
|
||||
...query,
|
||||
keyword: query.keyword || undefined,
|
||||
status: query.status || undefined
|
||||
})
|
||||
})
|
||||
const rows = computed(() => roleQuery.data.value?.items ?? [])
|
||||
const pagination = computed(() => ({
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
itemCount: roleQuery.data.value?.total ?? 0,
|
||||
showSizePicker: true,
|
||||
pageSizes: [10, 20, 50],
|
||||
prefix: renderPagePrefix
|
||||
}))
|
||||
const menuQuery = useQuery({ queryKey: ['menus-all'], queryFn: listMenusApi })
|
||||
const menuTree = computed(() => menuQuery.data.value ?? [])
|
||||
|
||||
const formRef = ref<FormInst | null>(null)
|
||||
const modal = reactive({ visible: false, mode: 'create' as 'create' | 'edit', id: '' })
|
||||
const form = reactive({ name: '', code: '', status: 'ENABLED', dataScope: 'SELF', description: '' })
|
||||
const rules: FormRules = {
|
||||
name: [{ required: true, message: '请输入名称' }],
|
||||
code: [{ required: true, message: '请输入编码' }]
|
||||
}
|
||||
const menuModal = reactive({ visible: false, id: '', menuIds: [] as string[] })
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (modal.mode === 'create') {
|
||||
await createRoleApi({
|
||||
name: form.name.trim(),
|
||||
code: form.code.trim(),
|
||||
status: form.status,
|
||||
dataScope: form.dataScope.trim() || 'SELF',
|
||||
description: form.description.trim() || undefined
|
||||
})
|
||||
return
|
||||
}
|
||||
await updateRoleApi(modal.id, {
|
||||
name: form.name.trim(),
|
||||
status: form.status,
|
||||
dataScope: form.dataScope.trim() || 'SELF',
|
||||
description: form.description.trim() || undefined
|
||||
})
|
||||
},
|
||||
onSuccess: async () => {
|
||||
message.success('保存成功')
|
||||
modal.visible = false
|
||||
await roleQuery.refetch()
|
||||
}
|
||||
})
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteRoleApi,
|
||||
onSuccess: async () => {
|
||||
message.success('删除成功')
|
||||
await roleQuery.refetch()
|
||||
}
|
||||
})
|
||||
const menuMutation = useMutation({
|
||||
mutationFn: () => updateRoleMenusApi(menuModal.id, menuModal.menuIds),
|
||||
onSuccess: async () => {
|
||||
message.success('分配成功')
|
||||
menuModal.visible = false
|
||||
}
|
||||
})
|
||||
|
||||
function refetch() {
|
||||
query.page = 1
|
||||
roleQuery.refetch()
|
||||
}
|
||||
function onPage(p: number) {
|
||||
query.page = p
|
||||
}
|
||||
function onPageSize(s: number) {
|
||||
query.pageSize = s
|
||||
query.page = 1
|
||||
}
|
||||
function openCreate() {
|
||||
modal.mode = 'create'
|
||||
modal.id = ''
|
||||
Object.assign(form, { name: '', code: '', status: 'ENABLED', dataScope: 'SELF', description: '' })
|
||||
modal.visible = true
|
||||
}
|
||||
async function openEdit(row: RoleItem) {
|
||||
modal.mode = 'edit'
|
||||
modal.id = row.id
|
||||
const d = await getRoleDetailApi(row.id)
|
||||
Object.assign(form, {
|
||||
name: d.name,
|
||||
code: d.code,
|
||||
status: d.status,
|
||||
dataScope: d.dataScope,
|
||||
description: d.description ?? ''
|
||||
})
|
||||
modal.visible = true
|
||||
}
|
||||
async function openAssign(row: RoleItem) {
|
||||
const d = await getRoleDetailApi(row.id)
|
||||
menuModal.id = row.id
|
||||
menuModal.menuIds = [...d.menuIds]
|
||||
menuModal.visible = true
|
||||
}
|
||||
async function save() {
|
||||
await formRef.value?.validate()
|
||||
await saveMutation.mutateAsync()
|
||||
}
|
||||
async function saveMenus() {
|
||||
await menuMutation.mutateAsync()
|
||||
}
|
||||
|
||||
const columns = computed<DataTableColumns<RoleItem>>(() => [
|
||||
{ title: '名称', key: 'name' },
|
||||
{ title: '编码', key: 'code' },
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
render: (r) =>
|
||||
h(
|
||||
NTag,
|
||||
{ size: 'small', type: statusTagType(r.status) },
|
||||
{ default: () => statusLabel(r.status) }
|
||||
)
|
||||
},
|
||||
{ title: '数据范围', key: 'dataScope', render: (r) => dataScopeLabel(r.dataScope) },
|
||||
{ title: '描述', key: 'description', render: (r) => r.description || '-' },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
render: (r) =>
|
||||
h(NSpace, { size: 6 }, () => [
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
tertiary: true,
|
||||
type: 'primary',
|
||||
class: 'action-btn',
|
||||
onClick: () => openEdit(r)
|
||||
},
|
||||
{ default: () => '编辑' }
|
||||
),
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
tertiary: true,
|
||||
type: 'info',
|
||||
class: 'action-btn',
|
||||
onClick: () => openAssign(r)
|
||||
},
|
||||
{ default: () => '分配菜单' }
|
||||
),
|
||||
h(
|
||||
NPopconfirm,
|
||||
{ onPositiveClick: () => deleteMutation.mutate(r.id) },
|
||||
{
|
||||
trigger: () =>
|
||||
h(
|
||||
NButton,
|
||||
{ size: 'small', tertiary: true, type: 'error', class: 'action-btn' },
|
||||
{ default: () => '删除' }
|
||||
),
|
||||
default: () => '确认删除?'
|
||||
}
|
||||
)
|
||||
])
|
||||
}
|
||||
])
|
||||
</script>
|
||||
@@ -0,0 +1,549 @@
|
||||
<template>
|
||||
<div class="page-shell">
|
||||
<section class="page-card table-fill">
|
||||
<div class="page-toolbar">
|
||||
<n-form inline :show-label="false" class="toolbar-form">
|
||||
<n-form-item>
|
||||
<n-input v-model:value="queryForm.username" clearable placeholder="用户名" />
|
||||
</n-form-item>
|
||||
<n-form-item>
|
||||
<n-input v-model:value="queryForm.nickname" clearable placeholder="昵称" />
|
||||
</n-form-item>
|
||||
<n-form-item>
|
||||
<n-select
|
||||
v-model:value="queryForm.status"
|
||||
clearable
|
||||
placeholder="状态"
|
||||
:options="statusOptions"
|
||||
style="width: 140px"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item>
|
||||
<n-space>
|
||||
<n-button type="primary" @click="handleSearch">查询</n-button>
|
||||
<n-button @click="handleReset">重置</n-button>
|
||||
</n-space>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<PermissionButton permission="system:user:create">
|
||||
<n-button type="primary" @click="openCreate">新增用户</n-button>
|
||||
</PermissionButton>
|
||||
</div>
|
||||
|
||||
<div class="card-body card-body-fill table-fill">
|
||||
<n-data-table
|
||||
remote
|
||||
size="small"
|
||||
:columns="columns"
|
||||
:data="tableRows"
|
||||
:loading="usersQuery.isLoading.value"
|
||||
:pagination="pagination"
|
||||
:row-key="(row: UserListItem) => row.id"
|
||||
@update:page="onPageChange"
|
||||
@update:page-size="onPageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<n-modal
|
||||
v-model:show="editModal.visible"
|
||||
preset="card"
|
||||
:title="editModal.title"
|
||||
class="w-[560px]"
|
||||
>
|
||||
<n-form
|
||||
ref="editFormRef"
|
||||
:model="editForm"
|
||||
:rules="editRules"
|
||||
label-placement="left"
|
||||
label-width="90"
|
||||
>
|
||||
<n-form-item label="用户名" path="username">
|
||||
<n-input v-model:value="editForm.username" :disabled="editModal.mode === 'edit'" />
|
||||
</n-form-item>
|
||||
<n-form-item v-if="editModal.mode === 'create'" label="密码" path="password">
|
||||
<n-input v-model:value="editForm.password" type="password" show-password-on="click" />
|
||||
</n-form-item>
|
||||
<n-form-item label="昵称" path="nickname">
|
||||
<n-input v-model:value="editForm.nickname" />
|
||||
</n-form-item>
|
||||
<n-form-item label="姓名" path="realName">
|
||||
<n-input v-model:value="editForm.realName" />
|
||||
</n-form-item>
|
||||
<n-form-item label="手机号" path="phone">
|
||||
<n-input v-model:value="editForm.phone" />
|
||||
</n-form-item>
|
||||
<n-form-item label="邮箱" path="email">
|
||||
<n-input v-model:value="editForm.email" />
|
||||
</n-form-item>
|
||||
<n-form-item v-if="editModal.mode === 'create'" label="状态" path="status">
|
||||
<n-radio-group v-model:value="editForm.status">
|
||||
<n-radio-button value="ENABLED">启用</n-radio-button>
|
||||
<n-radio-button value="DISABLED">禁用</n-radio-button>
|
||||
</n-radio-group>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<n-button @click="editModal.visible = false">取消</n-button>
|
||||
<n-button type="primary" :loading="saveMutation.isPending.value" @click="handleSave"
|
||||
>保存</n-button
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
</n-modal>
|
||||
|
||||
<n-modal v-model:show="passwordModal.visible" preset="card" title="重置密码" class="w-[460px]">
|
||||
<n-form
|
||||
ref="passwordFormRef"
|
||||
:model="passwordForm"
|
||||
:rules="passwordRules"
|
||||
label-placement="left"
|
||||
label-width="90"
|
||||
>
|
||||
<n-form-item label="新密码" path="password">
|
||||
<n-input v-model:value="passwordForm.password" type="password" show-password-on="click" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<n-button @click="passwordModal.visible = false">取消</n-button>
|
||||
<n-button
|
||||
type="primary"
|
||||
:loading="passwordMutation.isPending.value"
|
||||
@click="handleResetPassword"
|
||||
>确认</n-button
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
</n-modal>
|
||||
|
||||
<n-modal v-model:show="rolesModal.visible" preset="card" title="分配角色" class="w-[520px]">
|
||||
<n-form label-placement="left" label-width="90">
|
||||
<n-form-item label="角色">
|
||||
<n-select
|
||||
v-model:value="rolesModal.roleIds"
|
||||
multiple
|
||||
clearable
|
||||
filterable
|
||||
:options="roleOptions"
|
||||
placeholder="请选择角色"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<n-button @click="rolesModal.visible = false">取消</n-button>
|
||||
<n-button
|
||||
type="primary"
|
||||
:loading="rolesMutation.isPending.value"
|
||||
@click="handleAssignRoles"
|
||||
>确认</n-button
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, reactive, ref } from 'vue'
|
||||
import type { DataTableColumns, FormInst, FormRules } from 'naive-ui'
|
||||
import { NButton, NPopconfirm, NSpace, NSwitch, NTag, useMessage } from 'naive-ui'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import PermissionButton from '@/components/PermissionButton.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { statusLabel, statusTagType } from '@/utils/display'
|
||||
import { renderPagePrefix } from '@/utils/pagination'
|
||||
import {
|
||||
createUserApi,
|
||||
deleteUserApi,
|
||||
getUserDetailApi,
|
||||
listUsersApi,
|
||||
updateUserApi,
|
||||
updateUserPasswordApi,
|
||||
updateUserRolesApi,
|
||||
updateUserStatusApi
|
||||
} from '@/api/system/user'
|
||||
import { listRolesApi } from '@/api/system/role'
|
||||
import type { CreateUserRequest, UpdateUserRequest, UserListItem } from '@/types/system/user'
|
||||
|
||||
const message = useMessage()
|
||||
const authStore = useAuthStore()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const queryForm = reactive({
|
||||
username: '',
|
||||
nickname: '',
|
||||
status: null as string | null
|
||||
})
|
||||
|
||||
const pager = reactive({
|
||||
page: 1,
|
||||
pageSize: 10
|
||||
})
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '启用', value: 'ENABLED' },
|
||||
{ label: '禁用', value: 'DISABLED' }
|
||||
]
|
||||
|
||||
const usersQuery = useQuery({
|
||||
queryKey: computed(() => ['system', 'users', { ...queryForm, ...pager }]),
|
||||
queryFn: () =>
|
||||
listUsersApi({
|
||||
page: pager.page,
|
||||
pageSize: pager.pageSize,
|
||||
username: queryForm.username || undefined,
|
||||
nickname: queryForm.nickname || undefined,
|
||||
status: queryForm.status || undefined
|
||||
})
|
||||
})
|
||||
|
||||
const rolesQuery = useQuery({
|
||||
queryKey: ['system', 'roles', 'enabled'],
|
||||
queryFn: () => listRolesApi({ page: 1, pageSize: 200, status: 'ENABLED' })
|
||||
})
|
||||
|
||||
const roleOptions = computed(() =>
|
||||
(rolesQuery.data.value?.items ?? []).map((role) => ({
|
||||
label: `${role.name} (${role.code})`,
|
||||
value: role.id
|
||||
}))
|
||||
)
|
||||
|
||||
const tableRows = computed(() => usersQuery.data.value?.items ?? [])
|
||||
|
||||
const pagination = computed(() => ({
|
||||
page: pager.page,
|
||||
pageSize: pager.pageSize,
|
||||
itemCount: usersQuery.data.value?.total ?? 0,
|
||||
pageSizes: [10, 20, 50],
|
||||
showSizePicker: true,
|
||||
prefix: renderPagePrefix
|
||||
}))
|
||||
|
||||
const editFormRef = ref<FormInst | null>(null)
|
||||
const editModal = reactive({
|
||||
visible: false,
|
||||
mode: 'create' as 'create' | 'edit',
|
||||
title: '新增用户',
|
||||
id: ''
|
||||
})
|
||||
const editForm = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
nickname: '',
|
||||
realName: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
status: 'ENABLED'
|
||||
})
|
||||
|
||||
const editRules: FormRules = {
|
||||
username: [{ required: true, message: '请输入用户名', trigger: ['input', 'blur'] }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: ['input', 'blur'] }]
|
||||
}
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (editModal.mode === 'create') {
|
||||
const payload: CreateUserRequest = {
|
||||
username: editForm.username.trim(),
|
||||
password: editForm.password,
|
||||
nickname: editForm.nickname.trim() || undefined,
|
||||
realName: editForm.realName.trim() || undefined,
|
||||
phone: editForm.phone.trim() || undefined,
|
||||
email: editForm.email.trim() || undefined,
|
||||
status: editForm.status
|
||||
}
|
||||
return createUserApi(payload)
|
||||
}
|
||||
const payload: UpdateUserRequest = {
|
||||
nickname: editForm.nickname.trim() || undefined,
|
||||
realName: editForm.realName.trim() || undefined,
|
||||
phone: editForm.phone.trim() || undefined,
|
||||
email: editForm.email.trim() || undefined
|
||||
}
|
||||
return updateUserApi(editModal.id, payload)
|
||||
},
|
||||
onSuccess: async () => {
|
||||
message.success('保存成功')
|
||||
editModal.visible = false
|
||||
await usersQuery.refetch()
|
||||
}
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteUserApi(id),
|
||||
onSuccess: async () => {
|
||||
message.success('删除成功')
|
||||
await usersQuery.refetch()
|
||||
}
|
||||
})
|
||||
|
||||
const statusMutation = useMutation({
|
||||
mutationFn: ({ id, status }: { id: string; status: string }) =>
|
||||
updateUserStatusApi(id, { status }),
|
||||
onSuccess: async () => {
|
||||
message.success('状态更新成功')
|
||||
await usersQuery.refetch()
|
||||
}
|
||||
})
|
||||
|
||||
const passwordFormRef = ref<FormInst | null>(null)
|
||||
const passwordModal = reactive({
|
||||
visible: false,
|
||||
id: ''
|
||||
})
|
||||
const passwordForm = reactive({
|
||||
password: ''
|
||||
})
|
||||
const passwordRules: FormRules = {
|
||||
password: [{ required: true, message: '请输入新密码', trigger: ['input', 'blur'] }]
|
||||
}
|
||||
|
||||
const passwordMutation = useMutation({
|
||||
mutationFn: () => updateUserPasswordApi(passwordModal.id, { password: passwordForm.password }),
|
||||
onSuccess: async () => {
|
||||
message.success('密码重置成功')
|
||||
passwordModal.visible = false
|
||||
passwordForm.password = ''
|
||||
await queryClient.invalidateQueries({ queryKey: ['system', 'users'] })
|
||||
}
|
||||
})
|
||||
|
||||
const rolesModal = reactive({
|
||||
visible: false,
|
||||
id: '',
|
||||
roleIds: [] as string[]
|
||||
})
|
||||
|
||||
const rolesMutation = useMutation({
|
||||
mutationFn: () => updateUserRolesApi(rolesModal.id, { roleIds: rolesModal.roleIds }),
|
||||
onSuccess: async () => {
|
||||
message.success('角色分配成功')
|
||||
rolesModal.visible = false
|
||||
await usersQuery.refetch()
|
||||
}
|
||||
})
|
||||
|
||||
function onPageChange(page: number) {
|
||||
pager.page = page
|
||||
}
|
||||
|
||||
function onPageSizeChange(pageSize: number) {
|
||||
pager.pageSize = pageSize
|
||||
pager.page = 1
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pager.page = 1
|
||||
usersQuery.refetch()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
queryForm.username = ''
|
||||
queryForm.nickname = ''
|
||||
queryForm.status = null
|
||||
pager.page = 1
|
||||
usersQuery.refetch()
|
||||
}
|
||||
|
||||
function resetEditForm() {
|
||||
editForm.username = ''
|
||||
editForm.password = ''
|
||||
editForm.nickname = ''
|
||||
editForm.realName = ''
|
||||
editForm.phone = ''
|
||||
editForm.email = ''
|
||||
editForm.status = 'ENABLED'
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editModal.mode = 'create'
|
||||
editModal.title = '新增用户'
|
||||
editModal.id = ''
|
||||
resetEditForm()
|
||||
editModal.visible = true
|
||||
}
|
||||
|
||||
async function openEdit(row: UserListItem) {
|
||||
editModal.mode = 'edit'
|
||||
editModal.title = `编辑用户 - ${row.username}`
|
||||
editModal.id = row.id
|
||||
const detail = await getUserDetailApi(row.id)
|
||||
editForm.username = detail.username
|
||||
editForm.password = ''
|
||||
editForm.nickname = detail.nickname ?? ''
|
||||
editForm.realName = detail.realName ?? ''
|
||||
editForm.phone = detail.phone ?? ''
|
||||
editForm.email = detail.email ?? ''
|
||||
editForm.status = detail.status
|
||||
editModal.visible = true
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (editModal.mode === 'create') {
|
||||
await editFormRef.value?.validate()
|
||||
} else {
|
||||
await editFormRef.value?.validate(
|
||||
(errors) => {
|
||||
if (!errors) return
|
||||
throw errors
|
||||
},
|
||||
(rule) => rule?.key !== 'password'
|
||||
)
|
||||
}
|
||||
await saveMutation.mutateAsync()
|
||||
}
|
||||
|
||||
function openResetPassword(row: UserListItem) {
|
||||
passwordModal.id = row.id
|
||||
passwordForm.password = ''
|
||||
passwordModal.visible = true
|
||||
}
|
||||
|
||||
async function handleResetPassword() {
|
||||
await passwordFormRef.value?.validate()
|
||||
await passwordMutation.mutateAsync()
|
||||
}
|
||||
|
||||
async function openAssignRoles(row: UserListItem) {
|
||||
const detail = await getUserDetailApi(row.id)
|
||||
rolesModal.id = row.id
|
||||
rolesModal.roleIds = [...detail.roleIds]
|
||||
rolesModal.visible = true
|
||||
}
|
||||
|
||||
async function handleAssignRoles() {
|
||||
await rolesMutation.mutateAsync()
|
||||
}
|
||||
|
||||
function updateStatus(row: UserListItem, checked: boolean) {
|
||||
statusMutation.mutate({ id: row.id, status: checked ? 'ENABLED' : 'DISABLED' })
|
||||
}
|
||||
|
||||
const columns = computed<DataTableColumns<UserListItem>>(() => [
|
||||
{
|
||||
title: '用户信息',
|
||||
key: 'username',
|
||||
minWidth: 180,
|
||||
render: (row) =>
|
||||
h('div', { class: 'leading-5' }, [
|
||||
h(
|
||||
'div',
|
||||
{ class: 'font-medium text-slate-900' },
|
||||
row.nickname || row.realName || row.username
|
||||
),
|
||||
h('div', { class: 'text-xs text-slate-500' }, row.username)
|
||||
])
|
||||
},
|
||||
{
|
||||
title: '角色',
|
||||
key: 'roleCodes',
|
||||
minWidth: 180,
|
||||
render: (row) => (row.roleCodes.length > 0 ? row.roleCodes.join(', ') : '-')
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 120,
|
||||
render: (row) =>
|
||||
h(
|
||||
NTag,
|
||||
{ type: statusTagType(row.status), size: 'small' },
|
||||
{ default: () => statusLabel(row.status) }
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '启停',
|
||||
key: 'statusSwitch',
|
||||
width: 110,
|
||||
render: (row) =>
|
||||
authStore.hasPermission('system:user:update')
|
||||
? h(NSwitch, {
|
||||
size: 'small',
|
||||
value: row.status === 'ENABLED',
|
||||
onUpdateValue: (checked: boolean) => updateStatus(row, checked)
|
||||
})
|
||||
: '-'
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 360,
|
||||
render: (row) =>
|
||||
h(
|
||||
NSpace,
|
||||
{ size: 6 },
|
||||
{
|
||||
default: () => [
|
||||
authStore.hasPermission('system:user:update')
|
||||
? h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
tertiary: true,
|
||||
type: 'primary',
|
||||
class: 'action-btn',
|
||||
onClick: () => openEdit(row)
|
||||
},
|
||||
{ default: () => '编辑' }
|
||||
)
|
||||
: null,
|
||||
authStore.hasPermission('system:user:update')
|
||||
? h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
tertiary: true,
|
||||
type: 'warning',
|
||||
class: 'action-btn',
|
||||
onClick: () => openResetPassword(row)
|
||||
},
|
||||
{ default: () => '重置密码' }
|
||||
)
|
||||
: null,
|
||||
authStore.hasPermission('system:role:assign')
|
||||
? h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
tertiary: true,
|
||||
type: 'info',
|
||||
class: 'action-btn',
|
||||
onClick: () => openAssignRoles(row)
|
||||
},
|
||||
{ default: () => '分配角色' }
|
||||
)
|
||||
: null,
|
||||
authStore.hasPermission('system:user:delete')
|
||||
? h(
|
||||
NPopconfirm,
|
||||
{
|
||||
onPositiveClick: () => deleteMutation.mutate(row.id)
|
||||
},
|
||||
{
|
||||
trigger: () =>
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
tertiary: true,
|
||||
type: 'error',
|
||||
class: 'action-btn',
|
||||
loading: deleteMutation.isPending.value
|
||||
},
|
||||
{ default: () => '删除' }
|
||||
),
|
||||
default: () => `确认删除用户 ${row.username} 吗?`
|
||||
}
|
||||
)
|
||||
: null
|
||||
]
|
||||
}
|
||||
)
|
||||
}
|
||||
])
|
||||
</script>
|
||||
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<div class="page-shell">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div class="soft-stat"><n-statistic label="菜单数量" :value="authStore.menus.length" /></div>
|
||||
<div class="soft-stat">
|
||||
<n-statistic label="权限码数量" :value="authStore.permissions.length" />
|
||||
</div>
|
||||
<div class="soft-stat"><n-statistic label="当前状态" :value="currentStatus" /></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { statusLabel } from '@/utils/display'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const currentStatus = computed(() => statusLabel(authStore.user?.status))
|
||||
</script>
|
||||
@@ -0,0 +1,242 @@
|
||||
<template>
|
||||
<n-layout has-sider class="app-root app-backdrop">
|
||||
<n-layout-sider
|
||||
collapse-mode="width"
|
||||
:collapsed-width="72"
|
||||
:width="248"
|
||||
:collapsed="authStore.layoutCollapsed"
|
||||
class="layout-sider"
|
||||
:class="{ 'layout-sider-collapsed': authStore.layoutCollapsed }"
|
||||
show-trigger
|
||||
@collapse="authStore.layoutCollapsed = true"
|
||||
@expand="authStore.layoutCollapsed = false"
|
||||
>
|
||||
<div class="brand-mark">
|
||||
<span class="brand-dot"></span>
|
||||
<span class="brand-name" :title="appTitle">{{ appTitle }}</span>
|
||||
</div>
|
||||
<div class="menu-wrap">
|
||||
<AppMenu />
|
||||
</div>
|
||||
</n-layout-sider>
|
||||
|
||||
<n-layout class="app-main">
|
||||
<header class="app-header">
|
||||
<div class="header-main">
|
||||
<div class="title-group">
|
||||
<h1>{{ currentTitle }}</h1>
|
||||
<AppBreadcrumb />
|
||||
</div>
|
||||
<div class="summary-actions">
|
||||
<n-button
|
||||
tertiary
|
||||
circle
|
||||
size="small"
|
||||
title="刷新当前页面"
|
||||
@click="authStore.refreshTab(route.path)"
|
||||
>
|
||||
<template #icon>
|
||||
<n-icon><RefreshCw /></n-icon>
|
||||
</template>
|
||||
</n-button>
|
||||
<n-tag type="info" size="small">{{ authStore.user?.username ?? '访客' }}</n-tag>
|
||||
<n-button tertiary size="small" @click="handleLogout">退出</n-button>
|
||||
</div>
|
||||
</div>
|
||||
<AppTabs />
|
||||
</header>
|
||||
|
||||
<n-layout-content embedded class="layout-content bg-transparent">
|
||||
<div class="content-scroll">
|
||||
<router-view v-slot="{ Component, route: currentRoute }">
|
||||
<transition name="fade-slide" mode="out-in">
|
||||
<keep-alive :include="cachedRouteNames">
|
||||
<component
|
||||
:is="Component"
|
||||
:key="`${currentRoute.fullPath}-${authStore.tabRefreshMarks[currentRoute.path === '/workbench' ? '/dashboard' : currentRoute.path] ?? 0}`"
|
||||
/>
|
||||
</keep-alive>
|
||||
</transition>
|
||||
</router-view>
|
||||
</div>
|
||||
</n-layout-content>
|
||||
</n-layout>
|
||||
</n-layout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, watch } from 'vue'
|
||||
import { RefreshCw } from 'lucide-vue-next'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import AppMenu from '@/components/AppMenu.vue'
|
||||
import AppTabs from '@/components/AppTabs.vue'
|
||||
import AppBreadcrumb from '@/components/AppBreadcrumb.vue'
|
||||
import { appEnv } from '@/config/env'
|
||||
import { resetDynamicRoutes } from '@/router'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const appTitle = appEnv.appTitle
|
||||
|
||||
const currentTitle = computed(() => (route.meta.title as string | undefined) ?? '工作台')
|
||||
|
||||
const cachedRouteNames = computed(() =>
|
||||
router
|
||||
.getRoutes()
|
||||
.filter((item) => item.meta.keepAlive && typeof item.name === 'string')
|
||||
.map((item) => item.name as string)
|
||||
)
|
||||
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => {
|
||||
if (!route.meta.requiresAuth || route.path === '/403' || route.path === '/404') return
|
||||
const name = (route.name as string | undefined) ?? route.path
|
||||
authStore.addTab({
|
||||
name,
|
||||
title: (route.meta.title as string | undefined) ?? '未命名页面',
|
||||
path: route.path,
|
||||
closable: route.path !== '/dashboard' && route.path !== '/workbench'
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
async function handleLogout() {
|
||||
await authStore.logout()
|
||||
resetDynamicRoutes()
|
||||
await router.replace('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-slide-enter-active,
|
||||
.fade-slide-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-slide-enter-from,
|
||||
.fade-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
|
||||
.app-root {
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
height: 100vh;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 16px 18px 18px;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
box-shadow: 0 14px 34px rgba(15, 23, 42, 0.06);
|
||||
padding: 12px 14px 8px;
|
||||
}
|
||||
|
||||
.header-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.title-group {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.title-group h1 {
|
||||
margin: 0;
|
||||
color: #0f172a;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.summary-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.layout-content {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
.content-scroll {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-backdrop {
|
||||
background: #f5f7fb;
|
||||
}
|
||||
|
||||
.layout-sider {
|
||||
background: #ffffff;
|
||||
border-right: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
height: 64px;
|
||||
padding: 0 18px;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
}
|
||||
|
||||
.brand-dot {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, #111827, #2563eb);
|
||||
box-shadow: 0 10px 22px rgba(37, 99, 235, 0.18);
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
color: #111827;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.menu-wrap {
|
||||
padding: 12px 10px;
|
||||
}
|
||||
|
||||
.layout-sider-collapsed .brand-mark {
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.layout-sider-collapsed .brand-name {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,18 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import { VueQueryPlugin, QueryClient } from '@tanstack/vue-query'
|
||||
import naive from 'naive-ui'
|
||||
import App from '@/App.vue'
|
||||
import { router } from '@/router'
|
||||
import '@/style.css'
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
app.use(naive)
|
||||
app.use(VueQueryPlugin, { queryClient })
|
||||
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { Component } from 'vue'
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import type { MenuNode } from '@/types/auth'
|
||||
|
||||
const viewModules = import.meta.glob('../features/**/index.vue')
|
||||
|
||||
const dynamicRouteNames = new Set<string>()
|
||||
|
||||
function normalizePath(path: string) {
|
||||
if (!path.startsWith('/')) return `/${path}`
|
||||
return path
|
||||
}
|
||||
|
||||
function routeNameByMenu(menu: MenuNode) {
|
||||
if (menu.name && menu.name.trim().length > 0) {
|
||||
return menu.name.trim()
|
||||
}
|
||||
return `menu-${menu.id}`
|
||||
}
|
||||
|
||||
function resolveViewComponent(componentPath?: string | null): Component {
|
||||
if (!componentPath) {
|
||||
return () => import('@/views/errors/NotFoundView.vue')
|
||||
}
|
||||
const normalized = componentPath.endsWith('.vue') ? componentPath : `${componentPath}.vue`
|
||||
const fullPath = `../features/${normalized}`
|
||||
const component = viewModules[fullPath]
|
||||
if (!component) {
|
||||
return () => import('@/views/errors/NotFoundView.vue')
|
||||
}
|
||||
return component as unknown as Component
|
||||
}
|
||||
|
||||
function toRoute(menu: MenuNode): RouteRecordRaw | null {
|
||||
if (menu.type !== 'MENU') return null
|
||||
if (!menu.path || !menu.component || !menu.visible) return null
|
||||
|
||||
const name = routeNameByMenu(menu)
|
||||
dynamicRouteNames.add(name)
|
||||
|
||||
return {
|
||||
path: normalizePath(menu.path),
|
||||
name,
|
||||
component: resolveViewComponent(menu.component),
|
||||
meta: {
|
||||
title: menu.title,
|
||||
permission: menu.permission ?? undefined,
|
||||
keepAlive: menu.keepAlive,
|
||||
requiresAuth: true,
|
||||
dynamic: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function flattenMenus(menus: MenuNode[]): MenuNode[] {
|
||||
return menus.flatMap((item) => [item, ...flattenMenus(item.children ?? [])])
|
||||
}
|
||||
|
||||
export function buildDynamicRoutes(menus: MenuNode[]) {
|
||||
return flattenMenus(menus)
|
||||
.map(toRoute)
|
||||
.filter((item): item is RouteRecordRaw => Boolean(item))
|
||||
}
|
||||
|
||||
export function consumeDynamicRouteNames() {
|
||||
return [...dynamicRouteNames]
|
||||
}
|
||||
|
||||
export function resetDynamicRouteNames() {
|
||||
dynamicRouteNames.clear()
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import {
|
||||
buildDynamicRoutes,
|
||||
consumeDynamicRouteNames,
|
||||
resetDynamicRouteNames
|
||||
} from '@/router/dynamic-routes'
|
||||
import { staticRoutes } from '@/router/static-routes'
|
||||
import { appEnv } from '@/config/env'
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: staticRoutes
|
||||
})
|
||||
|
||||
async function ensureDynamicRoutes() {
|
||||
const authStore = useAuthStore()
|
||||
if (!authStore.isAuthed) return
|
||||
|
||||
const profile = await authStore.loadProfile()
|
||||
if (!profile) return
|
||||
|
||||
const existingNames = new Set(
|
||||
router
|
||||
.getRoutes()
|
||||
.map((route) => route.name)
|
||||
.filter(Boolean)
|
||||
)
|
||||
buildDynamicRoutes(profile.menus).forEach((route) => {
|
||||
if (route.name && !existingNames.has(route.name)) {
|
||||
router.addRoute('root', route)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function resetDynamicRoutes() {
|
||||
consumeDynamicRouteNames().forEach((name) => {
|
||||
if (router.hasRoute(name)) {
|
||||
router.removeRoute(name)
|
||||
}
|
||||
})
|
||||
resetDynamicRouteNames()
|
||||
}
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
if (to.path === '/login' && authStore.isAuthed) {
|
||||
return '/dashboard'
|
||||
}
|
||||
|
||||
if (to.meta.requiresAuth && !authStore.isAuthed) {
|
||||
return { path: '/login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
|
||||
if (authStore.isAuthed) {
|
||||
await ensureDynamicRoutes()
|
||||
|
||||
if (to.name === 'not-found' && to.path !== '/404') {
|
||||
const resolved = router.resolve(to.fullPath)
|
||||
const hasRealMatch = resolved.matched.some((route) => route.name !== 'not-found')
|
||||
if (hasRealMatch) {
|
||||
return { path: to.fullPath, replace: true }
|
||||
}
|
||||
}
|
||||
|
||||
if (to.meta.permission && !authStore.hasPermission(to.meta.permission)) {
|
||||
return '/403'
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
router.afterEach((to) => {
|
||||
if (to.meta.title) {
|
||||
document.title = `${to.meta.title} - ${appEnv.appTitle}`
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
export const staticRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('@/views/auth/LoginView.vue'),
|
||||
meta: { title: '登录' }
|
||||
},
|
||||
{
|
||||
path: '/403',
|
||||
name: 'forbidden',
|
||||
component: () => import('@/views/errors/ForbiddenView.vue'),
|
||||
meta: { title: '无权限' }
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
name: 'root',
|
||||
component: () => import('@/layouts/MainLayout.vue'),
|
||||
redirect: '/dashboard',
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: '/dashboard',
|
||||
alias: ['/workbench'],
|
||||
name: 'workbench-home',
|
||||
component: () => import('@/features/dashboard/index.vue'),
|
||||
meta: {
|
||||
title: '工作台',
|
||||
requiresAuth: true,
|
||||
keepAlive: true
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'not-found',
|
||||
component: () => import('@/views/errors/NotFoundView.vue'),
|
||||
meta: { title: '页面不存在' }
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,181 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { loginApi, logoutApi, meApi } from '@/api/auth'
|
||||
import { listDictItemsApi } from '@/api/system/dict'
|
||||
import type { CurrentUserProfile, LoginRequest, MeResponse, MenuNode } from '@/types/auth'
|
||||
import type { DictItem } from '@/types/system/dict'
|
||||
|
||||
const TOKEN_KEY = 'platform.token'
|
||||
|
||||
export interface TabItem {
|
||||
name: string
|
||||
title: string
|
||||
path: string
|
||||
closable: boolean
|
||||
}
|
||||
|
||||
function normalizeTabPath(path: string) {
|
||||
if (path === '/workbench') return '/dashboard'
|
||||
return path
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const token = ref<string>(localStorage.getItem(TOKEN_KEY) ?? '')
|
||||
const user = ref<CurrentUserProfile | null>(null)
|
||||
const menus = ref<MenuNode[]>([])
|
||||
const permissions = ref<string[]>([])
|
||||
const layoutCollapsed = ref(false)
|
||||
const dictCache = ref<Record<string, DictItem[]>>({})
|
||||
const tabs = ref<TabItem[]>([
|
||||
{ name: 'workbench-home', title: '工作台', path: '/dashboard', closable: false }
|
||||
])
|
||||
const tabRefreshMarks = ref<Record<string, number>>({})
|
||||
const loaded = ref(false)
|
||||
|
||||
const isAuthed = computed(() => Boolean(token.value))
|
||||
|
||||
function setToken(nextToken: string) {
|
||||
token.value = nextToken
|
||||
localStorage.setItem(TOKEN_KEY, nextToken)
|
||||
}
|
||||
|
||||
function clearAuth() {
|
||||
token.value = ''
|
||||
user.value = null
|
||||
menus.value = []
|
||||
permissions.value = []
|
||||
dictCache.value = {}
|
||||
loaded.value = false
|
||||
tabs.value = [{ name: 'workbench-home', title: '工作台', path: '/dashboard', closable: false }]
|
||||
tabRefreshMarks.value = {}
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
async function login(payload: LoginRequest) {
|
||||
const result = await loginApi(payload)
|
||||
setToken(result.accessToken)
|
||||
await loadProfile()
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
if (token.value) {
|
||||
await logoutApi()
|
||||
}
|
||||
} finally {
|
||||
clearAuth()
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProfile(force = false): Promise<MeResponse | null> {
|
||||
if (!token.value) return null
|
||||
if (loaded.value && !force) {
|
||||
return {
|
||||
user: user.value!,
|
||||
menus: menus.value,
|
||||
permissions: permissions.value
|
||||
}
|
||||
}
|
||||
const profile = await meApi()
|
||||
user.value = profile.user
|
||||
menus.value = profile.menus
|
||||
permissions.value = [...profile.permissions]
|
||||
loaded.value = true
|
||||
return profile
|
||||
}
|
||||
|
||||
function hasPermission(permission?: string | null) {
|
||||
if (!permission) return true
|
||||
return permissions.value.includes(permission)
|
||||
}
|
||||
|
||||
async function loadDictByTypeId(typeId: string, force = false) {
|
||||
const cached = dictCache.value[typeId]
|
||||
if (cached && !force) return cached
|
||||
const result = await listDictItemsApi({ typeId, page: 1, pageSize: 200 })
|
||||
dictCache.value[typeId] = result.items.filter((item) => item.status === 'ENABLED')
|
||||
return dictCache.value[typeId]
|
||||
}
|
||||
|
||||
function getDictLabel(typeId: string, value: string) {
|
||||
const item = dictCache.value[typeId]?.find((entry) => entry.value === value)
|
||||
return item?.label ?? value
|
||||
}
|
||||
|
||||
function getDictColor(typeId: string, value: string) {
|
||||
const item = dictCache.value[typeId]?.find((entry) => entry.value === value)
|
||||
return item?.color ?? undefined
|
||||
}
|
||||
|
||||
function addTab(tab: TabItem) {
|
||||
const normalizedPath = normalizeTabPath(tab.path)
|
||||
if (tabs.value.some((item) => item.path === normalizedPath)) return
|
||||
tabs.value.push({
|
||||
...tab,
|
||||
path: normalizedPath
|
||||
})
|
||||
}
|
||||
|
||||
function closeTab(path: string) {
|
||||
const targetPath = normalizeTabPath(path)
|
||||
const fixed = tabs.value.filter((item) => !item.closable)
|
||||
tabs.value = [
|
||||
...fixed,
|
||||
...tabs.value.filter((item) => item.closable && item.path !== targetPath)
|
||||
]
|
||||
delete tabRefreshMarks.value[targetPath]
|
||||
}
|
||||
|
||||
function closeOtherTabs(path: string) {
|
||||
const targetPath = normalizeTabPath(path)
|
||||
tabs.value = tabs.value.filter((item) => !item.closable || item.path === targetPath)
|
||||
Object.keys(tabRefreshMarks.value).forEach((tabPath) => {
|
||||
if (!tabs.value.some((item) => item.path === tabPath)) {
|
||||
delete tabRefreshMarks.value[tabPath]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function closeAllTabs() {
|
||||
const fixedTabs = tabs.value.filter((item) => !item.closable)
|
||||
const fixedPaths = new Set(fixedTabs.map((item) => item.path))
|
||||
Object.keys(tabRefreshMarks.value).forEach((path) => {
|
||||
if (!fixedPaths.has(path)) {
|
||||
delete tabRefreshMarks.value[path]
|
||||
}
|
||||
})
|
||||
tabs.value = fixedTabs
|
||||
}
|
||||
|
||||
function refreshTab(path: string) {
|
||||
const targetPath = normalizeTabPath(path)
|
||||
tabRefreshMarks.value[targetPath] = Date.now()
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
user,
|
||||
menus,
|
||||
permissions,
|
||||
layoutCollapsed,
|
||||
dictCache,
|
||||
tabs,
|
||||
tabRefreshMarks,
|
||||
loaded,
|
||||
isAuthed,
|
||||
setToken,
|
||||
clearAuth,
|
||||
login,
|
||||
logout,
|
||||
loadProfile,
|
||||
hasPermission,
|
||||
addTab,
|
||||
closeTab,
|
||||
closeOtherTabs,
|
||||
closeAllTabs,
|
||||
refreshTab,
|
||||
loadDictByTypeId,
|
||||
getDictLabel,
|
||||
getDictColor
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,252 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
color: #0f172a;
|
||||
background: #f5f7fb;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
font-family: Inter, 'Avenir Next', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
background: #f5f7fb;
|
||||
@apply text-slate-900 antialiased;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-panel {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
@apply text-lg font-semibold tracking-tight text-slate-900;
|
||||
}
|
||||
|
||||
.subtle-grid {
|
||||
background-image:
|
||||
linear-gradient(to right, rgba(148, 163, 184, 0.06) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(148, 163, 184, 0.06) 1px, transparent 1px);
|
||||
background-size: 28px 28px;
|
||||
}
|
||||
|
||||
.list-page {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page-shell {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page-hero {
|
||||
display: none;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.76);
|
||||
border-radius: 26px;
|
||||
background:
|
||||
radial-gradient(circle at 12% 12%, rgba(45, 212, 191, 0.24), transparent 28%),
|
||||
radial-gradient(circle at 92% 18%, rgba(125, 211, 252, 0.24), transparent 30%),
|
||||
linear-gradient(135deg, rgba(255, 255, 255, 0.78), rgba(240, 253, 250, 0.56));
|
||||
box-shadow:
|
||||
0 18px 36px rgba(14, 116, 144, 0.07),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.9);
|
||||
padding: 18px 22px;
|
||||
}
|
||||
|
||||
.page-hero::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: -36px;
|
||||
top: -52px;
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
border-radius: 999px;
|
||||
border: 28px solid rgba(14, 165, 233, 0.08);
|
||||
}
|
||||
|
||||
.page-hero-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-kicker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
color: #0f8f86;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.page-title-xl {
|
||||
margin: 0;
|
||||
color: #0f172a;
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.page-desc {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.page-card {
|
||||
@apply app-panel;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.page-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
background: #ffffff;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.toolbar-form {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.toolbar-form .n-form-item {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.card-body-fill {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.table-fill {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.table-fill .n-data-table,
|
||||
.table-fill .n-data-table-wrapper,
|
||||
.table-fill .n-data-table-base-table {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.table-fill .n-data-table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.table-fill .n-data-table-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.table-fill .n-data-table-base-table {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.table-fill .n-data-table-base-table-body {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.table-fill .n-data-table .n-data-table__pagination {
|
||||
flex: 0 0 auto;
|
||||
justify-content: flex-end;
|
||||
margin-top: auto;
|
||||
padding: 12px 2px 0;
|
||||
}
|
||||
|
||||
.page-card .n-data-table {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.page-card .n-data-table-wrapper {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.page-card .n-data-table-base-table {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.page-card .n-data-table-th {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-card .n-data-table-td {
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
min-width: 64px;
|
||||
}
|
||||
|
||||
.soft-stat {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.05);
|
||||
padding: 18px 18px 16px;
|
||||
}
|
||||
|
||||
.soft-stat::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 3px;
|
||||
height: 100%;
|
||||
background: linear-gradient(180deg, #2563eb, #14b8a6);
|
||||
}
|
||||
|
||||
.n-button {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.n-input,
|
||||
.n-base-selection {
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
export interface LoginRequest {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
accessToken: string
|
||||
tokenType: string
|
||||
expiresIn: number
|
||||
}
|
||||
|
||||
export interface CurrentUserProfile {
|
||||
id: string
|
||||
username: string
|
||||
nickname?: string | null
|
||||
realName?: string | null
|
||||
orgId?: string | null
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface MenuNode {
|
||||
id: string
|
||||
parentId?: string | null
|
||||
type: 'CATALOG' | 'MENU' | 'BUTTON' | string
|
||||
title: string
|
||||
name?: string | null
|
||||
path?: string | null
|
||||
component?: string | null
|
||||
icon?: string | null
|
||||
permission?: string | null
|
||||
sort: number
|
||||
visible: boolean
|
||||
keepAlive: boolean
|
||||
builtIn?: boolean
|
||||
children: MenuNode[]
|
||||
}
|
||||
|
||||
export interface MeResponse {
|
||||
user: CurrentUserProfile
|
||||
menus: MenuNode[]
|
||||
permissions: string[]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export interface ApiResult<T> {
|
||||
code: string
|
||||
message: string
|
||||
data: T
|
||||
traceId?: string
|
||||
}
|
||||
|
||||
export interface PageResult<T> {
|
||||
items: T[]
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export class BizError extends Error {
|
||||
code: string
|
||||
traceId?: string
|
||||
|
||||
constructor(code: string, message: string, traceId?: string) {
|
||||
super(message)
|
||||
this.code = code
|
||||
this.traceId = traceId
|
||||
this.name = 'BizError'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { PageResult } from '@/types/http'
|
||||
|
||||
export interface OperationLogItem {
|
||||
id: string
|
||||
traceId?: string | null
|
||||
username?: string | null
|
||||
operationType: string
|
||||
operationName: string
|
||||
httpMethod: string
|
||||
requestPath: string
|
||||
status: string
|
||||
errorMessage?: string | null
|
||||
costMs: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface ApiAccessLogItem {
|
||||
id: string
|
||||
traceId?: string | null
|
||||
appKey?: string | null
|
||||
appName?: string | null
|
||||
httpMethod: string
|
||||
requestPath: string
|
||||
responseCode?: string | null
|
||||
status: string
|
||||
errorMessage?: string | null
|
||||
costMs: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type OperationLogPage = PageResult<OperationLogItem>
|
||||
export type ApiAccessLogPage = PageResult<ApiAccessLogItem>
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
import 'vue-router'
|
||||
|
||||
declare module 'vue-router' {
|
||||
interface RouteMeta {
|
||||
title?: string
|
||||
requiresAuth?: boolean
|
||||
permission?: string
|
||||
keepAlive?: boolean
|
||||
dynamic?: boolean
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { PageResult } from '@/types/http'
|
||||
|
||||
export interface DictTypeItem {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
status: string
|
||||
statusLabel?: string
|
||||
remark?: string | null
|
||||
}
|
||||
|
||||
export interface DictItem {
|
||||
id: string
|
||||
typeId: string
|
||||
label: string
|
||||
value: string
|
||||
color?: string | null
|
||||
sort: number
|
||||
status: string
|
||||
statusLabel?: string
|
||||
remark?: string | null
|
||||
}
|
||||
|
||||
export type DictTypePage = PageResult<DictTypeItem>
|
||||
export type DictItemPage = PageResult<DictItem>
|
||||
@@ -0,0 +1,36 @@
|
||||
export interface MenuTreeNode {
|
||||
id: string
|
||||
parentId?: string | null
|
||||
type: 'CATALOG' | 'MENU' | 'BUTTON' | string
|
||||
typeLabel?: string
|
||||
title: string
|
||||
name?: string | null
|
||||
path?: string | null
|
||||
component?: string | null
|
||||
icon?: string | null
|
||||
permission?: string | null
|
||||
sort: number
|
||||
visible: boolean
|
||||
keepAlive: boolean
|
||||
builtIn?: boolean
|
||||
status: string
|
||||
statusLabel?: string
|
||||
children: MenuTreeNode[]
|
||||
}
|
||||
|
||||
export interface CreateMenuRequest {
|
||||
parentId?: string
|
||||
type: string
|
||||
title: string
|
||||
name?: string
|
||||
path?: string
|
||||
component?: string
|
||||
icon?: string
|
||||
permission?: string
|
||||
sort: number
|
||||
visible: boolean
|
||||
keepAlive: boolean
|
||||
status: string
|
||||
}
|
||||
|
||||
export type UpdateMenuRequest = CreateMenuRequest
|
||||
@@ -0,0 +1,25 @@
|
||||
export interface OrgTreeNode {
|
||||
id: string
|
||||
parentId?: string | null
|
||||
name: string
|
||||
code: string
|
||||
sort: number
|
||||
status: string
|
||||
statusLabel?: string
|
||||
children: OrgTreeNode[]
|
||||
}
|
||||
|
||||
export interface CreateOrgRequest {
|
||||
parentId?: string
|
||||
name: string
|
||||
code: string
|
||||
sort: number
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface UpdateOrgRequest {
|
||||
parentId?: string
|
||||
name: string
|
||||
sort: number
|
||||
status: string
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { PageResult } from '@/types/http'
|
||||
|
||||
export interface RoleItem {
|
||||
id: string
|
||||
name: string
|
||||
code: string
|
||||
description?: string | null
|
||||
status: string
|
||||
statusLabel?: string
|
||||
dataScope: string
|
||||
dataScopeLabel?: string
|
||||
}
|
||||
|
||||
export interface RoleDetail extends RoleItem {
|
||||
menuIds: string[]
|
||||
}
|
||||
|
||||
export type RolePage = PageResult<RoleItem>
|
||||
|
||||
export interface RoleQuery {
|
||||
page: number
|
||||
pageSize: number
|
||||
keyword?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
export interface CreateRoleRequest {
|
||||
name: string
|
||||
code: string
|
||||
description?: string
|
||||
status: string
|
||||
dataScope: string
|
||||
}
|
||||
|
||||
export interface UpdateRoleRequest {
|
||||
name: string
|
||||
description?: string
|
||||
status: string
|
||||
dataScope: string
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { PageResult } from '@/types/http'
|
||||
|
||||
export interface UserListItem {
|
||||
id: string
|
||||
username: string
|
||||
nickname?: string | null
|
||||
realName?: string | null
|
||||
orgId?: string | null
|
||||
status: string
|
||||
statusLabel?: string
|
||||
roleCodes: string[]
|
||||
}
|
||||
|
||||
export interface UserDetail {
|
||||
id: string
|
||||
username: string
|
||||
nickname?: string | null
|
||||
realName?: string | null
|
||||
phone?: string | null
|
||||
email?: string | null
|
||||
avatar?: string | null
|
||||
orgId?: string | null
|
||||
status: string
|
||||
statusLabel?: string
|
||||
roleIds: string[]
|
||||
}
|
||||
|
||||
export interface UserQuery {
|
||||
username?: string
|
||||
nickname?: string
|
||||
status?: string
|
||||
orgId?: string
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export type UserListPage = PageResult<UserListItem>
|
||||
|
||||
export interface CreateUserRequest {
|
||||
username: string
|
||||
password: string
|
||||
nickname?: string
|
||||
realName?: string
|
||||
phone?: string
|
||||
email?: string
|
||||
avatar?: string
|
||||
orgId?: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface UpdateUserRequest {
|
||||
nickname?: string
|
||||
realName?: string
|
||||
phone?: string
|
||||
email?: string
|
||||
avatar?: string
|
||||
orgId?: string
|
||||
}
|
||||
|
||||
export interface UpdateUserStatusRequest {
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface UpdateUserPasswordRequest {
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface UpdateUserRolesRequest {
|
||||
roleIds: string[]
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { TagProps } from 'naive-ui'
|
||||
|
||||
export function statusLabel(status?: string | null) {
|
||||
const map: Record<string, string> = {
|
||||
ENABLED: '启用',
|
||||
DISABLED: '禁用',
|
||||
SUCCESS: '成功',
|
||||
FAIL: '失败'
|
||||
}
|
||||
return status ? (map[status] ?? status) : '-'
|
||||
}
|
||||
|
||||
export function statusTagType(status?: string | null): TagProps['type'] {
|
||||
if (status === 'ENABLED' || status === 'SUCCESS') return 'success'
|
||||
if (status === 'DISABLED') return 'warning'
|
||||
if (status === 'FAIL') return 'error'
|
||||
return 'default'
|
||||
}
|
||||
|
||||
export function menuTypeLabel(type?: string | null) {
|
||||
const map: Record<string, string> = {
|
||||
CATALOG: '目录',
|
||||
MENU: '菜单',
|
||||
BUTTON: '按钮'
|
||||
}
|
||||
return type ? (map[type] ?? type) : '-'
|
||||
}
|
||||
|
||||
export function dataScopeLabel(scope?: string | null) {
|
||||
const map: Record<string, string> = {
|
||||
ALL: '全部数据',
|
||||
DEPT: '本组织及下级',
|
||||
DEPT_ONLY: '本组织',
|
||||
SELF: '仅本人'
|
||||
}
|
||||
return scope ? (map[scope] ?? scope) : '-'
|
||||
}
|
||||
|
||||
export function httpMethodLabel(method?: string | null) {
|
||||
const map: Record<string, string> = {
|
||||
GET: '查询',
|
||||
POST: '新增',
|
||||
PUT: '更新',
|
||||
PATCH: '局部更新',
|
||||
DELETE: '删除'
|
||||
}
|
||||
return method ? (map[method] ?? method) : '-'
|
||||
}
|
||||
|
||||
export function operationTypeLabel(type?: string | null) {
|
||||
const map: Record<string, string> = {
|
||||
CREATE: '新增',
|
||||
UPDATE: '更新',
|
||||
DELETE: '删除',
|
||||
UPDATE_STATUS: '更新状态',
|
||||
RESET_PASSWORD: '重置密码',
|
||||
ASSIGN_ROLE: '分配角色',
|
||||
ASSIGN_MENU: '分配菜单'
|
||||
}
|
||||
return type ? (map[type] ?? type) : '-'
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
type PagePrefixInfo = {
|
||||
page: number
|
||||
pageCount: number
|
||||
itemCount?: number
|
||||
}
|
||||
|
||||
export function renderPagePrefix(info: PagePrefixInfo) {
|
||||
const pageCount = Math.max(info.pageCount, 1)
|
||||
const itemCount = info.itemCount ?? 0
|
||||
return `第 ${info.page} / ${pageCount} 页,共 ${itemCount} 条`
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative flex min-h-screen items-center justify-center overflow-hidden bg-slate-100 px-4"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-[radial-gradient(circle_at_20%_10%,rgba(53,109,255,0.12),transparent_34%),radial-gradient(circle_at_80%_90%,rgba(14,165,233,0.12),transparent_32%)]"
|
||||
></div>
|
||||
<n-card class="relative z-10 w-full max-w-md rounded-2xl border border-slate-200 shadow-panel">
|
||||
<template #header>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-semibold text-slate-900">通用管理平台</div>
|
||||
<div class="mt-1 text-sm text-slate-500">中后台管理系统</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<n-form ref="formRef" :model="form" :rules="rules" size="large" @submit.prevent="onSubmit">
|
||||
<n-form-item path="username" label="用户名">
|
||||
<n-input v-model:value="form.username" placeholder="请输入用户名" />
|
||||
</n-form-item>
|
||||
<n-form-item path="password" label="密码">
|
||||
<n-input
|
||||
v-model:value="form.password"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
placeholder="请输入密码"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-button type="primary" block size="large" :loading="loading" attr-type="submit"
|
||||
>登录</n-button
|
||||
>
|
||||
</n-form>
|
||||
|
||||
<div class="mt-4 text-xs text-slate-500">默认账号:admin / Admin@123456</div>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import type { FormInst, FormRules } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const message = useMessage()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const formRef = ref<FormInst | null>(null)
|
||||
const form = reactive({
|
||||
username: 'admin',
|
||||
password: 'Admin@123456'
|
||||
})
|
||||
|
||||
const rules: FormRules = {
|
||||
username: [{ required: true, message: '请输入用户名', trigger: ['blur', 'input'] }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: ['blur', 'input'] }]
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
await formRef.value?.validate()
|
||||
loading.value = true
|
||||
try {
|
||||
await authStore.login(form)
|
||||
message.success('登录成功')
|
||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/dashboard'
|
||||
await router.replace(redirect)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<div class="flex min-h-screen items-center justify-center bg-slate-100 p-6">
|
||||
<n-result
|
||||
status="403"
|
||||
title="403 无权限访问"
|
||||
description="你当前没有访问该页面的权限,请联系管理员。"
|
||||
>
|
||||
<template #footer>
|
||||
<n-button type="primary" @click="router.push('/workbench')">返回工作台</n-button>
|
||||
</template>
|
||||
</n-result>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
</script>
|
||||
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<div class="flex min-h-screen items-center justify-center bg-slate-100 p-6">
|
||||
<n-result status="404" title="404 页面不存在" description="路由未匹配或菜单组件映射不存在。">
|
||||
<template #footer>
|
||||
<n-button type="primary" @click="router.push('/workbench')">返回工作台</n-button>
|
||||
</template>
|
||||
</n-result>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
</script>
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Config } from 'tailwindcss'
|
||||
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{vue,ts,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: {
|
||||
50: '#eef5ff',
|
||||
100: '#dce9ff',
|
||||
500: '#356dff',
|
||||
600: '#2a5af0'
|
||||
}
|
||||
},
|
||||
borderRadius: {
|
||||
panel: '14px'
|
||||
},
|
||||
boxShadow: {
|
||||
panel: '0 6px 24px rgba(15, 23, 42, 0.06)'
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: []
|
||||
} satisfies Config
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": false,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"types": ["vite/client", "node"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts", "vitest.config.ts", "tailwind.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import path from 'node:path'
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
const proxyTarget = env.VITE_API_PROXY_TARGET
|
||||
|
||||
return {
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src')
|
||||
}
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
open: false,
|
||||
proxy: proxyTarget
|
||||
? {
|
||||
'/api': {
|
||||
target: proxyTarget,
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
},
|
||||
preview: {
|
||||
host: '0.0.0.0',
|
||||
port: 4173
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['src/**/*.spec.ts']
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user