feat: 对接后端登录和菜单

This commit is contained in:
chansee97 2025-08-26 22:53:09 +08:00
parent f2e82e725f
commit d54810ab02
55 changed files with 1952 additions and 1177 deletions

View File

@ -7,6 +7,7 @@ import { FileSystemIconLoader } from 'unplugin-icons/loaders'
import IconsResolver from 'unplugin-icons/resolver'
import Icons from 'unplugin-icons/vite'
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers'
import { ProNaiveUIResolver } from 'pro-naive-ui-resolver'
import Components from 'unplugin-vue-components/vite'
import viteCompression from 'vite-plugin-compression'
import VueDevTools from 'vite-plugin-vue-devtools'
@ -65,6 +66,7 @@ export function createVitePlugins(env: ImportMetaEnv) {
],
}),
NaiveUiResolver(),
ProNaiveUIResolver(),
],
}),

View File

@ -70,7 +70,6 @@
"signInTitle": "Login",
"accountRuleTip": "Please enter account",
"passwordRuleTip": "Please enter password",
"or": "Or",
"rememberMe": "Remember me",
"forgotPassword": "Forget the password?",
"signIn": "Sign in",

View File

@ -101,7 +101,6 @@
"passwordPlaceholder": "输入密码",
"accountRuleTip": "请输入账户",
"passwordRuleTip": "请输入密码",
"or": "其他",
"signIn": "登录",
"rememberMe": "记住我",
"forgotPassword": "忘记密码?",

View File

@ -37,28 +37,28 @@
"UnoCSS"
],
"scripts": {
"dev": "vite --mode dev --port 9980",
"dev": "vite --mode dev",
"dev:prod": "vite --mode production",
"build": "vite build",
"build:dev": "vite build --mode dev",
"preview": "vite preview --port 9981",
"preview": "vite preview",
"lint": "eslint . && vue-tsc --noEmit",
"lint:fix": "eslint . --fix",
"lint:check": "npx @eslint/config-inspector",
"sizecheck": "npx vite-bundle-visualizer"
},
"dependencies": {
"@vueuse/core": "^13.6.0",
"@vueuse/core": "^13.7.0",
"alova": "^3.3.4",
"array-to-tree": "^3.3.2",
"colord": "^2.9.3",
"echarts": "^5.6.0",
"echarts": "^6.0.0",
"md-editor-v3": "^5.6.1",
"pinia": "^3.0.3",
"pinia-plugin-persistedstate": "^4.4.1",
"pro-naive-ui": "^2.4.3",
"pinia-plugin-persistedstate": "^4.5.0",
"pro-naive-ui": "^3.0.3",
"quill": "^2.0.3",
"radash": "^12.1.1",
"vue": "^3.5.18",
"vue": "^3.5.20",
"vue-draggable-plus": "^0.6.0",
"vue-i18n": "^11.1.11",
"vue-router": "^4.5.1"
@ -73,6 +73,7 @@
"eslint": "^9.29.0",
"lint-staged": "^16.1.2",
"naive-ui": "^2.42.0",
"pro-naive-ui-resolver": "^1.0.2",
"sass": "^1.89.2",
"simple-git-hooks": "^2.13.1",
"typescript": "^5.8.3",

View File

@ -38,11 +38,20 @@ const appStore = useAppStore()
const naiveLocale = computed(() => {
return naiveI18nOptions[appStore.lang] ? naiveI18nOptions[appStore.lang] : naiveI18nOptions.enUS
})
const propOverrides = {
ProModalForm: {
labelWidth: 120,
labelPlacement: 'left',
preset: 'card',
},
}
</script>
<template>
<n-config-provider
class="wh-full"
<pro-config-provider
:prop-overrides="propOverrides"
abstract
inline-theme-disabled
:theme="appStore.colorMode === 'dark' ? darkTheme : null"
:locale="naiveLocale.locale"
@ -53,5 +62,5 @@ const naiveLocale = computed(() => {
<router-view />
<Watermark :show-watermark="appStore.showWatermark" />
</naive-provider>
</n-config-provider>
</pro-config-provider>
</template>

View File

@ -20,9 +20,96 @@ const value = defineModel('value', { type: String })
// https://icon-sets.iconify.design/
const nameList = ['icon-park-outline', 'carbon', 'ant-design']
interface CacheItem {
data: IconList
}
//
async function fetchIconList(name: string): Promise<IconList> {
return await fetch(`https://api.iconify.design/collection?prefix=${name}`).then(res => res.json())
const cacheKey = `icon_list_${name}`
try {
// sessionStorage
const cachedData = sessionStorage.getItem(cacheKey)
if (cachedData) {
const cache: CacheItem = JSON.parse(cachedData)
//
if (cache.data) {
return cache.data
}
}
}
catch (error) {
console.warn(`读取图标库缓存失败: ${name}`, error)
}
try {
// API
const data = await fetch(`https://api.iconify.design/collection?prefix=${name}`)
.then(res => res.json())
// sessionStorage
try {
const cacheItem: CacheItem = {
data,
}
sessionStorage.setItem(cacheKey, JSON.stringify(cacheItem))
}
catch (cacheError) {
console.warn(`缓存图标库数据失败: ${name}`, cacheError)
}
return data
}
catch (error) {
console.error(`获取图标库数据失败: ${name}`, error)
// API使
try {
const cachedData = sessionStorage.getItem(cacheKey)
if (cachedData) {
const cache: CacheItem = JSON.parse(cachedData)
if (cache.data) {
return cache.data
}
}
}
catch (fallbackError) {
console.warn(`读取降级缓存失败: ${name}`, fallbackError)
}
//
return {
prefix: name,
icons: [],
title: name,
total: 0,
categories: {},
uncategorized: [],
}
}
}
//
function clearIconCache(name?: string) {
try {
if (name) {
//
const cacheKey = `icon_list_${name}`
sessionStorage.removeItem(cacheKey)
}
else {
//
nameList.forEach((iconName) => {
const cacheKey = `icon_list_${iconName}`
sessionStorage.removeItem(cacheKey)
})
}
}
catch (error) {
console.warn('清除图标库缓存失败:', error)
}
}
//
@ -62,6 +149,12 @@ onMounted(async () => {
iconList.value = await fetchIconAllList(nameList)
})
// 便
if (import.meta.env.DEV) {
// @ts-expect-error window
window.clearIconCache = clearIconCache
}
// tab
const currentTab = shallowRef(0)
// tag

View File

@ -13,18 +13,18 @@ export function usePermission() {
if (!authStore.userInfo)
return false
const { role } = authStore.userInfo
const { roles } = authStore.userInfo
// 角色为super可直接通过
let has = role.includes('super')
let has = roles.includes('super')
if (!has) {
if (isArray(permission))
// 角色为数组, 判断是否有交集
has = permission.some(i => role.includes(i))
has = permission.some(i => roles.includes(i))
if (isString(permission))
// 角色为字符串, 判断是否包含
has = role.includes(permission)
has = roles.includes(permission)
}
return has
}

View File

@ -83,7 +83,7 @@ function handleSelect(key: string | number) {
<n-avatar
round
class="cursor-pointer"
:src="userInfo?.avatar"
:src="userInfo?.avatar || `https://api.dicebear.com/9.x/adventurer-neutral/svg?seed=${userInfo!.username}`"
>
<template #fallback>
<div class="wh-full flex-center">

View File

@ -8,7 +8,7 @@ export const staticRoutes: AppRoute.RowRoute[] = [
menuType: 'dir',
componentPath: null,
id: 1,
pid: null,
parentId: null,
},
{
name: 'workbench',
@ -20,7 +20,7 @@ export const staticRoutes: AppRoute.RowRoute[] = [
menuType: 'page',
componentPath: '/dashboard/workbench/index.vue',
id: 101,
pid: 1,
parentId: 1,
},
{
name: 'monitor',
@ -31,7 +31,7 @@ export const staticRoutes: AppRoute.RowRoute[] = [
menuType: 'page',
componentPath: '/dashboard/monitor/index.vue',
id: 102,
pid: 1,
parentId: 1,
},
{
name: 'multi',
@ -42,7 +42,7 @@ export const staticRoutes: AppRoute.RowRoute[] = [
menuType: 'dir',
componentPath: null,
id: 2,
pid: null,
parentId: null,
},
{
name: 'multi2',
@ -53,7 +53,7 @@ export const staticRoutes: AppRoute.RowRoute[] = [
menuType: 'page',
componentPath: '/demo/multi/multi-2/index.vue',
id: 201,
pid: 2,
parentId: 2,
},
{
name: 'multi2-detail',
@ -66,7 +66,7 @@ export const staticRoutes: AppRoute.RowRoute[] = [
menuType: 'page',
componentPath: '/demo/multi/multi-2/detail/index.vue',
id: 20101,
pid: 2,
parentId: 2,
},
{
name: 'multi3',
@ -77,7 +77,7 @@ export const staticRoutes: AppRoute.RowRoute[] = [
menuType: 'dir',
componentPath: null,
id: 202,
pid: 2,
parentId: 2,
},
{
name: 'multi4',
@ -87,7 +87,7 @@ export const staticRoutes: AppRoute.RowRoute[] = [
icon: 'icon-park-outline:list',
componentPath: '/demo/multi/multi-3/multi-4/index.vue',
id: 20201,
pid: 202,
parentId: 202,
},
{
name: 'list',
@ -98,7 +98,7 @@ export const staticRoutes: AppRoute.RowRoute[] = [
menuType: 'dir',
componentPath: null,
id: 3,
pid: null,
parentId: null,
},
{
name: 'commonList',
@ -108,7 +108,7 @@ export const staticRoutes: AppRoute.RowRoute[] = [
icon: 'icon-park-outline:list-view',
componentPath: '/demo/list/common-list/index.vue',
id: 301,
pid: 3,
parentId: 3,
},
{
name: 'cardList',
@ -118,7 +118,7 @@ export const staticRoutes: AppRoute.RowRoute[] = [
icon: 'icon-park-outline:view-grid-list',
componentPath: '/demo/list/card-list/index.vue',
id: 302,
pid: 3,
parentId: 3,
},
{
name: 'draggableList',
@ -128,7 +128,7 @@ export const staticRoutes: AppRoute.RowRoute[] = [
icon: 'icon-park-outline:menu-fold',
componentPath: '/demo/list/draggable-list/index.vue',
id: 303,
pid: 3,
parentId: 3,
},
{
name: 'demo',
@ -139,7 +139,7 @@ export const staticRoutes: AppRoute.RowRoute[] = [
menuType: 'dir',
componentPath: null,
id: 4,
pid: null,
parentId: null,
},
{
name: 'fetch',
@ -149,7 +149,7 @@ export const staticRoutes: AppRoute.RowRoute[] = [
icon: 'icon-park-outline:international',
componentPath: '/demo/fetch/index.vue',
id: 401,
pid: 4,
parentId: 4,
},
{
name: 'echarts',
@ -159,7 +159,7 @@ export const staticRoutes: AppRoute.RowRoute[] = [
icon: 'icon-park-outline:chart-proportion',
componentPath: '/demo/echarts/index.vue',
id: 402,
pid: 4,
parentId: 4,
},
{
name: 'map',
@ -170,7 +170,7 @@ export const staticRoutes: AppRoute.RowRoute[] = [
keepAlive: true,
componentPath: '/demo/map/index.vue',
id: 403,
pid: 4,
parentId: 4,
},
{
name: 'editor',
@ -181,7 +181,7 @@ export const staticRoutes: AppRoute.RowRoute[] = [
menuType: 'dir',
componentPath: null,
id: 404,
pid: 4,
parentId: 4,
},
{
name: 'editorMd',
@ -191,7 +191,7 @@ export const staticRoutes: AppRoute.RowRoute[] = [
icon: 'ri:markdown-line',
componentPath: '/demo/editor/md/index.vue',
id: 40401,
pid: 404,
parentId: 404,
},
{
name: 'editorRich',
@ -201,7 +201,7 @@ export const staticRoutes: AppRoute.RowRoute[] = [
icon: 'icon-park-outline:edit-one',
componentPath: '/demo/editor/rich/index.vue',
id: 40402,
pid: 404,
parentId: 404,
},
{
name: 'clipboard',
@ -211,7 +211,7 @@ export const staticRoutes: AppRoute.RowRoute[] = [
icon: 'icon-park-outline:clipboard',
componentPath: '/demo/clipboard/index.vue',
id: 405,
pid: 4,
parentId: 4,
},
{
name: 'icons',
@ -221,7 +221,7 @@ export const staticRoutes: AppRoute.RowRoute[] = [
icon: 'local:cool',
componentPath: '/demo/icons/index.vue',
id: 406,
pid: 4,
parentId: 4,
},
{
name: 'QRCode',
@ -231,7 +231,7 @@ export const staticRoutes: AppRoute.RowRoute[] = [
icon: 'icon-park-outline:two-dimensional-code',
componentPath: '/demo/qr-code/index.vue',
id: 407,
pid: 4,
parentId: 4,
},
{
name: 'cascader',
@ -241,7 +241,7 @@ export const staticRoutes: AppRoute.RowRoute[] = [
icon: 'icon-park-outline:add-subset',
componentPath: '/demo/cascader/index.vue',
id: 408,
pid: 4,
parentId: 4,
},
{
name: 'dict',
@ -251,7 +251,7 @@ export const staticRoutes: AppRoute.RowRoute[] = [
icon: 'icon-park-outline:book-one',
componentPath: '/demo/dict/index.vue',
id: 409,
pid: 4,
parentId: 4,
},
{
name: 'documents',
@ -262,7 +262,7 @@ export const staticRoutes: AppRoute.RowRoute[] = [
menuType: 'dir',
componentPath: null,
id: 5,
pid: null,
parentId: null,
},
{
name: 'documentsVue',
@ -272,7 +272,7 @@ export const staticRoutes: AppRoute.RowRoute[] = [
icon: 'logos:vue',
componentPath: '/demo/documents/vue/index.vue',
id: 501,
pid: 5,
parentId: 5,
},
{
name: 'documentsVite',
@ -282,7 +282,7 @@ export const staticRoutes: AppRoute.RowRoute[] = [
icon: 'logos:vitejs',
componentPath: '/demo/documents/vite/index.vue',
id: 502,
pid: 5,
parentId: 5,
},
{
name: 'documentsVueuse',
@ -293,7 +293,7 @@ export const staticRoutes: AppRoute.RowRoute[] = [
href: 'https://vueuse.org/guide/',
componentPath: 'null',
id: 503,
pid: 5,
parentId: 5,
},
{
@ -305,7 +305,7 @@ export const staticRoutes: AppRoute.RowRoute[] = [
href: 'https://nova-admin-docs.netlify.app/',
componentPath: '2333333',
id: 504,
pid: 5,
parentId: 5,
},
{
name: 'documentsPublic',
@ -316,7 +316,7 @@ export const staticRoutes: AppRoute.RowRoute[] = [
href: '/public',
componentPath: 'null',
id: 505,
pid: 5,
parentId: 5,
},
{
name: 'permission',
@ -327,7 +327,7 @@ export const staticRoutes: AppRoute.RowRoute[] = [
menuType: 'dir',
componentPath: null,
id: 6,
pid: null,
parentId: null,
},
{
name: 'permissionDemo',
@ -337,7 +337,7 @@ export const staticRoutes: AppRoute.RowRoute[] = [
icon: 'icon-park-outline:right-user',
componentPath: '/demo/permission/permission/index.vue',
id: 601,
pid: 6,
parentId: 6,
},
{
name: 'justSuper',
@ -350,7 +350,7 @@ export const staticRoutes: AppRoute.RowRoute[] = [
icon: 'icon-park-outline:wrong-user',
componentPath: '/demo/permission/just-super/index.vue',
id: 602,
pid: 6,
parentId: 6,
},
{
name: 'setting',
@ -361,7 +361,7 @@ export const staticRoutes: AppRoute.RowRoute[] = [
menuType: 'dir',
componentPath: null,
id: 7,
pid: null,
parentId: null,
},
{
name: 'accountSetting',
@ -371,7 +371,7 @@ export const staticRoutes: AppRoute.RowRoute[] = [
icon: 'icon-park-outline:every-user',
componentPath: '/setting/account/index.vue',
id: 701,
pid: 7,
parentId: 7,
},
{
name: 'dictionarySetting',
@ -381,7 +381,7 @@ export const staticRoutes: AppRoute.RowRoute[] = [
icon: 'icon-park-outline:book-one',
componentPath: '/setting/dictionary/index.vue',
id: 702,
pid: 7,
parentId: 7,
},
{
name: 'menuSetting',
@ -391,7 +391,7 @@ export const staticRoutes: AppRoute.RowRoute[] = [
icon: 'icon-park-outline:application-menu',
componentPath: '/setting/menu/index.vue',
id: 703,
pid: 7,
parentId: 7,
},
{
name: 'about',
@ -401,7 +401,7 @@ export const staticRoutes: AppRoute.RowRoute[] = [
icon: 'icon-park-outline:info',
componentPath: '/demo/about/index.vue',
id: 8,
pid: null,
parentId: null,
},
{
name: 'userCenter',
@ -412,7 +412,7 @@ export const staticRoutes: AppRoute.RowRoute[] = [
icon: 'carbon:user-avatar-filled-alt',
componentPath: '/build-in/user-center/index.vue',
id: 999,
pid: null,
parentId: null,
},
]

View File

@ -1,17 +1,22 @@
import { request } from '../http'
/* 示例列表接口 */
export function fetchUserList() {
return request.Get('/userList')
}
// 获取所有路由信息
export function fetchAllRoutes() {
return request.Get<Service.ResponseResult<AppRoute.RowRoute[]>>('/getUserRoutes')
return request.Get<Api.Response<AppRoute.RowRoute[]>>('/getUserRoutes')
}
// 获取所有用户信息
export function fetchUserPage() {
return request.Get<Service.ResponseResult<Entity.User[]>>('/userPage')
return request.Get<Api.Response<Entity.User[]>>('/userPage')
}
// 获取所有角色列表
export function fetchRoleList() {
return request.Get<Service.ResponseResult<Entity.Role[]>>('/role/list')
return request.Get<Api.Response<Entity.Role[]>>('/role/list')
}
/**
@ -22,5 +27,5 @@ export function fetchRoleList() {
*/
export function fetchDictList(code?: string) {
const params = { code }
return request.Get<Service.ResponseResult<Entity.Dict[]>>('/dict/list', { params })
return request.Get<Api.Response<Entity.Dict[]>>('/dict/list', { params })
}

View File

@ -1,5 +0,0 @@
import { request } from '../http'
export function fetchUserList() {
return request.Get('/userList')
}

View File

@ -1,25 +1,50 @@
import { request } from '../http'
interface Ilogin {
interface LoginParams {
userName: string
password: string
captchaId?: string
captcha?: string
}
export function fetchLogin(data: Ilogin) {
const methodInstance = request.Post<Service.ResponseResult<Api.Login.Info>>('/login', data)
methodInstance.meta = {
authRole: null,
}
return methodInstance
interface ResponseLogin {
accessToken: string
refreshToken?: string
}
export function fetchUpdateToken(data: any) {
const method = request.Post<Service.ResponseResult<Api.Login.Info>>('/updateToken', data)
method.meta = {
authRole: 'refreshToken',
}
return method
interface ResponseRefreshToken {
accessToken: string
refreshToken: string
}
interface ResponseCaptchaImage {
captchaId: string
captchaImage: string
enabled: boolean
}
export function fetchLogin(data: LoginParams) {
return request.Post<Api.Response<ResponseLogin>>('/login', data, {
meta: { authRole: null },
})
}
export function fetchCaptchaImage() {
return request.Get<Api.Response<ResponseCaptchaImage>>('/captcha', {
meta: { authRole: null },
})
}
export function fetchUserInfo() {
return request.Get<Api.Response<Entity.User>>('/userInfo')
}
export function fetchRefreshToken(data: any) {
return request.Post<Api.Response<ResponseRefreshToken>>('/refreshToken', data, {
meta: { authRole: 'refreshToken' },
})
}
export function fetchUserRoutes(params: { id: number }) {
return request.Get<Service.ResponseResult<AppRoute.RowRoute[]>>('/getUserRoutes', { params })
return request.Get<Api.Response<AppRoute.RowRoute[]>>('/getUserRoutes', { params })
}

View File

@ -0,0 +1,119 @@
import { request } from '../../http'
// 字典类型查询参数接口
interface DictTypeQueryParams {
pageNum?: number
pageSize?: number
dictName?: string
dictType?: string
status?: number
}
// 字典数据查询参数接口
interface DictDataQueryParams {
pageNum?: number
pageSize?: number
dictType?: string
dictLabel?: string
status?: number
}
/**
* ==================== ====================
*/
/**
* -
* POST /dict/types
*/
export function createDictType(data: Partial<Entity.DictType>) {
return request.Post<Api.Response<Entity.DictType>>('/dict/types', data)
}
/**
* -
* GET /dict/types
*/
export function getDictTypeList(params?: DictTypeQueryParams) {
return request.Get<Api.ListResponse<Entity.DictType>>('/dict/types', { params })
}
/**
* -
* GET /dict/types/{id}
*/
export function getDictTypeById(id: number) {
return request.Get<Api.Response<Entity.DictType>>(`/dict/types/${id}`)
}
/**
* -
* PUT /dict/types/{id}
*/
export function updateDictType(id: number, data: Partial<Entity.DictType>) {
return request.Put<Api.Response<Entity.DictType>>(`/dict/types/${id}`, data)
}
/**
* -
* DELETE /dict/types/{id}
*/
export function deleteDictType(id: number) {
return request.Delete<Api.Response<boolean>>(`/dict/types/${id}`)
}
/**
* ==================== ====================
*/
/**
* -
* POST /dict/data
*/
export function createDictData(data: Partial<Entity.DictData>) {
return request.Post<Api.Response<Entity.DictData>>('/dict/data', data)
}
/**
* -
* GET /dict/data
*/
export function getDictDataList(params?: DictDataQueryParams) {
return request.Get<Api.ListResponse<Entity.DictData>>('/dict/data', { params })
}
/**
* -
* GET /dict/data/{id}
*/
export function getDictDataById(id: number) {
return request.Get<Api.Response<Entity.DictData>>(`/dict/data/${id}`)
}
/**
* -
* PUT /dict/data/{id}
*/
export function updateDictData(id: number, data: Partial<Entity.DictData>) {
return request.Put<Api.Response<Entity.DictData>>(`/dict/data/${id}`, data)
}
/**
* -
* DELETE /dict/data/{id}
*/
export function deleteDictData(id: number) {
return request.Delete<Api.Response<boolean>>(`/dict/data/${id}`)
}
/**
* ==================== ====================
*/
/**
* -
* GET /dict/data/type/{dictType}
*/
export function getDictDataByType(dictType: string) {
return request.Get<Api.Response<Entity.DictData[]>>(`/dict/data/type/${dictType}`)
}

View File

@ -0,0 +1,49 @@
import { request } from '../../http'
/**
*
* POST /menu
*/
export function createMenu(data: Partial<Entity.Menu>) {
return request.Post<Api.Response<Entity.Menu>>('/menu', data)
}
/**
*
* GET /menu
*/
export function getMenuList() {
return request.Get<Api.Response<Entity.Menu[]>>('/menu')
}
/**
*
* GET /menu/{id}
*/
export function getMenuById(id: number) {
return request.Get<Api.Response<Entity.Menu>>(`/menu/${id}`)
}
/**
*
* PUT /menu/{id}
*/
export function updateMenu(id: number, data: Partial<Entity.Menu>) {
return request.Put<Api.Response<Entity.Menu>>(`/menu/${id}`, data)
}
/**
*
* DELETE /menu/{id}
*/
export function deleteMenu(id: number) {
return request.Delete<Api.Response<any>>(`/menu/${id}`)
}
/**
*
* GET /menu/selectTree
*/
export function selectMenuTree() {
return request.Get<Api.Response<Entity.TreeNode[]>>('/menu/selectTree')
}

View File

@ -0,0 +1,50 @@
import { request } from '../../http'
// 角色查询参数接口
interface RoleQueryParams {
pageNum?: number
pageSize?: number
roleName?: string
roleKey?: string
status?: number
}
/**
*
* POST /role
*/
export function createRole(data: Partial<Entity.Role>) {
return request.Post<Api.Response<Entity.Role>>('/role', data)
}
/**
*
* GET /role
*/
export function getRoleList(params?: RoleQueryParams) {
return request.Get<Api.ListResponse<Entity.Role>>('/role', { params })
}
/**
*
* GET /role/{id}
*/
export function getRoleById(id: number) {
return request.Get<Api.Response<Entity.Role>>(`/role/${id}`)
}
/**
*
* PUT /role/{id}
*/
export function updateRole(id: number, data: Partial<Entity.Role>) {
return request.Put<Api.Response<Entity.Role>>(`/role/${id}`, data)
}
/**
*
* DELETE /role/{id}
*/
export function deleteRole(id: number) {
return request.Delete<Api.Response<boolean>>(`/role/${id}`)
}

View File

@ -0,0 +1,51 @@
import { request } from '../../http'
// 用户查询参数接口
interface UserQueryParams {
pageNum?: number
pageSize?: number
username?: string
gender?: 'male' | 'female' | 'unknown'
userStatus?: number
deptId?: number
}
/**
*
* POST /user
*/
export function createUser(data: Partial<Entity.User>) {
return request.Post<Api.Response<Entity.User>>('/user', data)
}
/**
*
* GET /user
*/
export function getUserList(params?: UserQueryParams) {
return request.Get<Api.ListResponse<Entity.User>>('/user', { params })
}
/**
* ID获取单个用户信息
* GET /user/{id}
*/
export function getUserById(id: number) {
return request.Get<Api.Response<Entity.User>>(`/user/${id}`)
}
/**
*
* PUT /user/{id}
*/
export function updateUser(id: number, data: Partial<Entity.User>) {
return request.Put<Api.Response<Entity.User>>(`/user/${id}`, data)
}
/**
*
* DELETE /user/{id}
*/
export function deleteUser(id: number) {
return request.Delete<Api.Response<boolean>>(`/user/${id}`)
}

View File

@ -7,14 +7,15 @@ import type { VueHookType } from 'alova/vue'
import {
DEFAULT_ALOVA_OPTIONS,
DEFAULT_BACKEND_OPTIONS,
ERROR_STATUS,
} from './config'
import {
handleBusinessError,
handleRefreshToken,
handleResponseError,
handleServiceResult,
} from './handle'
import type { AlovaConfig, BackendConfig, ErrorStatus } from './type'
const { onAuthRequired, onResponseRefreshToken } = createServerTokenAuthentication<VueHookType>({
// 服务端判定token过期
refreshTokenOnSuccess: {
@ -45,8 +46,8 @@ const { onAuthRequired, onResponseRefreshToken } = createServerTokenAuthenticati
// docs path of alova.js https://alova.js.org/
export function createAlovaInstance(
alovaConfig: Service.AlovaConfig,
backendConfig?: Service.BackendConfig,
alovaConfig: AlovaConfig,
backendConfig?: BackendConfig,
) {
const _backendConfig = { ...DEFAULT_BACKEND_OPTIONS, ...backendConfig }
const _alovaConfig = { ...DEFAULT_ALOVA_OPTIONS, ...alovaConfig }
@ -63,12 +64,13 @@ export function createAlovaInstance(
method.config.headers['Content-Type'] = 'application/x-www-form-urlencoded'
method.data = new URLSearchParams(method.data as URLSearchParams).toString()
}
alovaConfig.beforeRequest?.(method)
alovaConfig.beforeRequest?.(method as any)
}),
responded: onResponseRefreshToken({
// 请求成功的拦截器
onSuccess: async (response, method) => {
const { status } = response
let errorMessage = ''
if (status === 200) {
// 返回blob数据
@ -79,19 +81,23 @@ export function createAlovaInstance(
const apiData = await response.json()
// 请求成功
if (apiData[_backendConfig.codeKey] === _backendConfig.successCode)
return handleServiceResult(apiData)
return apiData
// 业务请求失败
const errorResult = handleBusinessError(apiData, _backendConfig)
return handleServiceResult(errorResult, false)
errorMessage = apiData[_backendConfig.msgKey]
}
else {
// 接口请求失败
const errorResult = handleResponseError(response)
return handleServiceResult(errorResult, false)
const errorCode = response.status as ErrorStatus
errorMessage = ERROR_STATUS[errorCode] || ERROR_STATUS.default
}
window.$message?.error(errorMessage)
throw new Error(errorMessage)
},
onError: (error, method) => {
const tip = `[${method.type}] - [${method.url}] - ${error.message}`
window.$message?.warning(tip)
throw new Error(tip)
},
onComplete: async (_method) => {

View File

@ -30,6 +30,3 @@ export const ERROR_STATUS = {
504: $t('http.504'),
505: $t('http.505'),
}
/** 没有错误提示的code */
export const ERROR_NO_TIP_STATUS = [10000]

View File

@ -1,68 +1,6 @@
import { fetchUpdateToken } from '@/service'
import { fetchRefreshToken } from '@/service'
import { useAuthStore } from '@/store'
import { local } from '@/utils'
import {
ERROR_NO_TIP_STATUS,
ERROR_STATUS,
} from './config'
type ErrorStatus = keyof typeof ERROR_STATUS
/**
* @description:
* @param {Response} response
* @return {*}
*/
export function handleResponseError(response: Response) {
const error: Service.RequestError = {
errorType: 'Response Error',
code: 0,
message: ERROR_STATUS.default,
data: null,
}
const errorCode: ErrorStatus = response.status as ErrorStatus
const message = ERROR_STATUS[errorCode] || ERROR_STATUS.default
Object.assign(error, { code: errorCode, message })
showError(error)
return error
}
/**
* @description:
* @param {Record} data
* @param {Service} config
* @return {*}
*/
export function handleBusinessError(data: Record<string, any>, config: Required<Service.BackendConfig>) {
const { codeKey, msgKey } = config
const error: Service.RequestError = {
errorType: 'Business Error',
code: data[codeKey],
message: data[msgKey],
data: data.data,
}
showError(error)
return error
}
/**
* @description:
* @param {any} data
* @param {boolean} isSuccess
* @return {*} result
*/
export function handleServiceResult(data: any, isSuccess: boolean = true) {
const result = {
isSuccess,
errorType: null,
...data,
}
return result
}
/**
* @description: token刷新
@ -77,7 +15,7 @@ export async function handleRefreshToken() {
}
// 刷新token
const { data } = await fetchUpdateToken({ refreshToken: local.get('refreshToken') })
const { data } = await fetchRefreshToken({ refreshToken: local.get('refreshToken') })
if (data) {
local.set('accessToken', data.accessToken)
local.set('refreshToken', data.refreshToken)
@ -87,12 +25,3 @@ export async function handleRefreshToken() {
await authStore.logout()
}
}
export function showError(error: Service.RequestError) {
// 如果error不需要提示,则跳过
const code = Number(error.code)
if (ERROR_NO_TIP_STATUS.includes(code))
return
window.$message.error(error.message)
}

24
src/service/http/type.d.ts vendored Normal file
View File

@ -0,0 +1,24 @@
import type { Method } from 'alova'
import type {
ERROR_STATUS,
} from './config'
export type ErrorStatus = keyof typeof ERROR_STATUS
export interface AlovaConfig {
baseURL: string
timeout?: number
beforeRequest?: (method: Method<globalThis.Ref<unknown>>) => void
}
/** 后端接口返回的数据结构配置 */
export interface BackendConfig {
/** 表示后端请求状态码的属性字段 */
codeKey?: string
/** 表示后端请求数据的属性字段 */
dataKey?: string
/** 表示后端消息的属性字段 */
msgKey?: string
/** 后端业务上定义的成功请求的状态 */
successCode?: number | string
}

View File

@ -1,4 +1,7 @@
export * from './api/list'
export * from './api/login'
export * from './api/system'
export * from './api/demo'
export * from './api/test'
export * from './api/system/user'
export * from './api/system/menu'
export * from './api/system/role'
export * from './api/system/dict'

View File

@ -1,24 +1,22 @@
import { router } from '@/router'
import { fetchLogin } from '@/service'
import { fetchLogin, fetchUserInfo } from '@/service'
import { local } from '@/utils'
import { useRouteStore } from './router'
import { useTabStore } from './tab'
interface AuthStatus {
userInfo: Api.Login.Info | null
token: string
userInfo: Entity.User | null
}
export const useAuthStore = defineStore('auth-store', {
state: (): AuthStatus => {
return {
userInfo: local.get('userInfo'),
token: local.get('accessToken') || '',
userInfo: null,
}
},
getters: {
/** 是否登录 */
isLogin(state) {
return Boolean(state.token)
return Boolean(state.userInfo)
},
},
actions: {
@ -52,28 +50,32 @@ export const useAuthStore = defineStore('auth-store', {
},
/* 用户登录 */
async login(userName: string, password: string) {
try {
const { isSuccess, data } = await fetchLogin({ userName, password })
if (!isSuccess)
return
async login(userName: string, password: string, captchaId?: string, captcha?: string) {
const loginData: any = { userName, password }
// 如果提供了验证码相关参数,则添加到登录数据中
if (captchaId && captcha) {
loginData.captchaId = captchaId
loginData.captcha = captcha
}
const { data } = await fetchLogin(loginData)
// 处理登录信息
await this.handleLoginInfo(data)
}
catch (e) {
console.warn('[Login Error]:', e)
}
},
/* 处理登录返回的数据 */
async handleLoginInfo(data: Api.Login.Info) {
// 将token和userInfo保存下来
local.set('userInfo', data)
async handleLoginInfo(data: any) {
// 将token保存下来
local.set('accessToken', data.accessToken)
if (data.refreshToken) {
local.set('refreshToken', data.refreshToken)
this.token = data.accessToken
this.userInfo = data
}
const res = await fetchUserInfo()
this.userInfo = res.data
// 添加路由和菜单
const routeStore = useRouteStore()
@ -87,4 +89,7 @@ export const useAuthStore = defineStore('auth-store', {
})
},
},
persist: {
storage: localStorage,
},
})

View File

@ -36,16 +36,11 @@ export const useDictStore = defineStore('dict-store', {
},
async getDictByNet(code: string) {
const { data, isSuccess } = await fetchDictList(code)
if (isSuccess) {
const { data } = await fetchDictList(code)
Reflect.set(this.dictMap, code, data)
// 同步至session
session.set('dict', this.dictMap)
return data
}
else {
throw new Error(`Failed to get ${code} dictionary from network, check ${code} field or network`)
}
},
initDict() {
const dict = session.get('dict')
@ -55,4 +50,7 @@ export const useDictStore = defineStore('dict-store', {
this.isInitDict = true
},
},
persist: {
storage: sessionStorage,
},
})

View File

@ -2,9 +2,10 @@ import type { MenuOption } from 'naive-ui'
import type { RouteRecordRaw } from 'vue-router'
import { usePermission } from '@/hooks'
import Layout from '@/layouts/index.vue'
import { $t, arrayToTree, renderIcon } from '@/utils'
import { $t, renderIcon } from '@/utils'
import { clone, min, omit, pick } from 'radash'
import { RouterLink } from 'vue-router'
import arrayToTree from 'array-to-tree'
const metaFields: AppRoute.MetaKeys[]
= ['title', 'icon', 'requiresAuth', 'roles', 'keepAlive', 'hide', 'order', 'href', 'activeMenu', 'withoutTab', 'pinTab', 'menuType']
@ -36,7 +37,10 @@ export function createRoutes(routes: AppRoute.RowRoute[]) {
})
// Generate route tree
resultRouter = arrayToTree(resultRouter) as AppRoute.Route[]
resultRouter = arrayToTree(resultRouter, {
parentProperty: 'parentId',
customID: 'id',
}) as AppRoute.Route[]
const appRootRoute: RouteRecordRaw = {
path: '/appRoot',
@ -98,7 +102,10 @@ export function createMenus(userRoutes: AppRoute.RowRoute[]) {
const visibleMenus = resultMenus.filter(route => !route.meta.hide)
// generate side menu
return arrayToTree(transformAuthRoutesToMenus(visibleMenus))
return arrayToTree(transformAuthRoutesToMenus(visibleMenus), {
parentProperty: 'parentId',
customID: 'id',
})
}
// render the returned routing table as a sidebar
@ -121,7 +128,7 @@ function transformAuthRoutesToMenus(userRoutes: AppRoute.Route[]) {
.map((item) => {
const target: MenuOption = {
id: item.id,
pid: item.pid,
parentId: item.parentId,
label:
(!item.meta.menuType || item.meta.menuType === 'page')
? () =>

View File

@ -2,8 +2,7 @@ import type { MenuOption } from 'naive-ui'
import { router } from '@/router'
import { staticRoutes } from '@/router/routes.static'
import { fetchUserRoutes } from '@/service'
import { useAuthStore } from '@/store/auth'
import { $t, local } from '@/utils'
import { $t } from '@/utils'
import { createMenus, createRoutes, generateCacheRoutes } from './helper'
interface RoutesStatus {
@ -41,15 +40,14 @@ export const useRouteStore = defineStore('route-store', {
if (import.meta.env.VITE_ROUTE_LOAD_MODE === 'dynamic') {
try {
// Get user's route
const result = await fetchUserRoutes({
const { data } = await fetchUserRoutes({
id: 1,
})
if (!result.isSuccess || !result.data) {
if (!data) {
throw new Error('Failed to fetch user routes')
}
return result.data
return data
}
catch (error) {
console.error('Failed to initialize route info:', error)

View File

@ -1,17 +0,0 @@
/// <reference path="../global.d.ts"/>
namespace Api {
namespace Login {
/* 登录返回的用户字段, 该数据是根据用户表扩展而来, 部分字段可能需要覆盖例如id */
interface Info extends Entity.User {
/** 用户id */
id: number
/** 用户角色类型 */
role: Entity.RoleType[]
/** 访问token */
accessToken: string
/** 访问token */
refreshToken: string
}
}
}

View File

@ -1,13 +1,62 @@
/// <reference path="../global.d.ts"/>
/* 字典数据库表字段 */
/** 数据库表字段 */
namespace Entity {
interface DictType {
/**
*
*/
dictName: string
/**
*
*/
dictType: string
/**
*
*/
remark?: string
/**
*
*/
status?: number
}
interface Dict {
id?: number
isRoot?: 0 | 1
code: string
label: string
value?: number
interface DictData {
/**
*
*/
cssClass?: string
/**
*
*/
dictLabel: string
/**
*
*/
dictSort?: number
/**
*
*/
dictType: string
/**
*
*/
dictValue: string
/**
*
*/
isDefault?: number
/**
*
*/
listClass?: string
/**
*
*/
remark?: string
/**
*
*/
status?: number
}
}

78
src/typings/entities/menu.d.ts vendored Normal file
View File

@ -0,0 +1,78 @@
/// <reference path="../global.d.ts"/>
/** 数据库表字段 */
namespace Entity {
type MenuType = 'directory' | 'page' | 'permission'
interface Menu {
menuId: number
/**
*
*/
component: string
/**
*
*/
icon: string
/**
*
*/
keepAlive: boolean
/**
*
*/
isLink: boolean
/**
*
*/
title: string
/**
* Key
*/
i18nKey: string
/**
*
*/
menuType: MenuType
/**
* ID
*/
parentId: number
/**
*
*/
path: string
/**
*
*/
activePath: string
/**
*
*/
perms: string
/**
*
*/
remark: string
/**
*
*/
sort: number
/**
*
*/
status: number
/**
*
*/
menuVisible: boolean
/**
*
*/
tabVisible: boolean
/**
*
*/
children?: Menu[]
}
}

View File

@ -1,6 +1,6 @@
/// <reference path="../global.d.ts"/>
/* 角色数据库表字段 */
/* 数据库表字段 */
namespace Entity {
interface Message {
id: number

View File

@ -1,13 +1,34 @@
/// <reference path="../global.d.ts"/>
/* 角色数据库表字段 */
/* 数据库表字段 */
namespace Entity {
type RoleType = 'super' | 'admin' | 'user'
type RoleType = string
interface Role {
/** 用户id */
id?: number
/** 用户名 */
role?: RoleType
/**
* ID数组
*/
menuIds?: number[]
/**
*
*/
remark?: string
/**
*
*/
roleKey: string
/**
*
*/
roleName: string
/**
*
*/
roleStatus?: number
/**
*
*/
sort: number
[property: string]: any
}
}

View File

@ -1,28 +1,39 @@
/// <reference path="../global.d.ts"/>
/** 用户数据库表字段 */
/** 数据库表字段 */
namespace Entity {
interface User {
/** 用户id */
id?: number
userId: number
/** 部门id */
deptId?: any
/** 用户名 */
userName?: string
/* 用户头像 */
avatar?: string
/* 用户性别 */
gender?: 0 | 1
/* 用户邮箱 */
username: string
/** 密码 */
password: string
/** 昵称 */
nickName?: string
/** 邮箱 */
email?: string
/* 用户昵称 */
nickname?: string
/* 用户电话 */
tel?: string
/** 用户角色类型 */
role?: Entity.RoleType[]
/** 手机号 */
phone?: string
/** 性别 */
gender: 'male' | 'female' | 'unknown'
/** 头像 */
avatar?: string
/** 用户状态 */
status?: 0 | 1
userStatus: number
/** 创建人 */
createBy?: string
/** 创建时间 */
createTime: string
/** 更新人 */
updateBy?: string
/** 更新时间 */
updateTime: string
/** 备注 */
remark?: string
/** 用户角色类型 */
roles: Entity.Role[]
}
}

View File

@ -1,10 +1,30 @@
/* 存放数据库实体表类型, 具体内容在 ./entities */
declare namespace Entity {
interface TreeNode {
id: number
label: string
children: TreeNode[]
}
}
/* 各类接口返回的数据类型, 具体内容在 ./api */
declare namespace Api {
interface Response<T> {
/** 业务状态码 */
code: number
/** 业务信息 */
message: string
/** 业务数据 */
data: T
}
interface ListResponse<T> extends Response {
/** 业务数据 */
data: {
list: T[]
total: number
}
}
}
interface Window {
@ -35,7 +55,7 @@ declare namespace Storage {
interface Local {
/* 存储用户信息 */
userInfo: Api.Login.Info
userInfo: Entity.User
/* 存储访问token */
accessToken: string
/* 存储刷新token */

View File

@ -43,7 +43,7 @@ declare namespace AppRoute {
/* 路由id */
id: number
/* 父级路由id顶级页面为null */
pid: number | null
parentId: number | null
}
/** 单个路由的类型结构(动态路由模式:后端返回此类型结构的路由) */

View File

@ -1,49 +0,0 @@
/** 请求的相关类型 */
declare namespace Service {
import type { Method } from 'alova'
interface AlovaConfig {
baseURL: string
timeout?: number
beforeRequest?: (method: Method<globalThis.Ref<unknown>>) => void
}
/** 后端接口返回的数据结构配置 */
interface BackendConfig {
/** 表示后端请求状态码的属性字段 */
codeKey?: string
/** 表示后端请求数据的属性字段 */
dataKey?: string
/** 表示后端消息的属性字段 */
msgKey?: string
/** 后端业务上定义的成功请求的状态 */
successCode?: number | string
}
type RequestErrorType = 'Response Error' | 'Business Error' | null
type RequestCode = string | number
interface RequestError {
/** 请求服务的错误类型 */
errorType: RequestErrorType
/** 错误码 */
code: RequestCode
/** 错误信息 */
message: string
/** 返回的数据 */
data?: any
}
interface ResponseResult<T> extends RequestError {
/** 请求服务是否成功 */
isSuccess: boolean
/** 请求服务的错误类型 */
errorType: RequestErrorType
/** 错误码 */
code: RequestCode
/** 错误信息 */
message: string
/** 返回的数据 */
data: T
}
}

View File

@ -1,37 +0,0 @@
/**
*
* @param arr - id和pid属性pid表示父级id
* @returns
*/
export function arrayToTree(arr: any[]) {
// 初始化结果数组
const res: any = []
// 使用Map存储数组元素以id为键元素本身为值
const map = new Map()
// 遍历数组将每个元素以id为键存储到Map中
arr.forEach((item) => {
map.set(item.id, item)
})
// 再次遍历数组根据pid将元素组织成树形结构
arr.forEach((item) => {
// 获取当前元素的父级元素
const parent = item.pid && map.get(item.pid)
// 如果有父级元素
if (parent) {
// 如果父级元素已有子元素,则将当前元素追加到子元素数组中
if (parent?.children)
parent.children.push(item)
// 如果父级元素没有子元素,则创建子元素数组,并将当前元素作为第一个元素
else
parent.children = [item]
}
// 如果没有父级元素,则将当前元素直接添加到结果数组中
else {
res.push(item)
}
})
// 返回组织好的树形结构数组
return res
}

View File

@ -1,6 +1,7 @@
import type { NDateLocale, NLocale } from 'naive-ui'
import { i18n } from '@/modules/i18n'
import { dateZhCN, zhCN } from 'naive-ui'
import { dateZhCN } from 'naive-ui'
import { zhCN } from 'pro-naive-ui'
export function setLocale(locale: App.lang) {
i18n.global.locale.value = locale

View File

@ -1,5 +1,4 @@
export * from './storage'
export * from './array'
export * from './i18n'
export * from './icon'
export * from './normalize'

View File

@ -1,10 +1,12 @@
<script setup lang="ts">
import type { FormInst } from 'naive-ui'
import { useAuthStore } from '@/store'
import { fetchCaptchaImage } from '@/service'
import { local } from '@/utils'
import { useBoolean } from '@/hooks'
const emit = defineEmits(['update:modelValue'])
const { bool: loading, setTrue: startLoading, setFalse: endLoading } = useBoolean(false)
const authStore = useAuthStore()
function toOtherForm(type: any) {
@ -12,8 +14,16 @@ function toOtherForm(type: any) {
}
const { t } = useI18n()
//
const captchaData = ref({
captchaId: '',
captchaImage: '',
enabled: false,
})
const rules = computed(() => {
return {
const baseRules = {
account: {
required: true,
trigger: 'blur',
@ -25,13 +35,45 @@ const rules = computed(() => {
message: t('login.passwordRuleTip'),
},
}
//
if (captchaData.value.enabled) {
(baseRules as any).captcha = {
required: true,
trigger: 'blur',
message: '请输入验证码',
}
}
return baseRules
})
const formValue = ref({
account: 'super',
pwd: '123456',
account: 'admin',
pwd: '12345',
captcha: '',
})
const isRemember = ref(false)
const isLoading = ref(false)
//
async function getCaptcha() {
try {
const { data } = await fetchCaptchaImage()
captchaData.value = {
captchaId: data.captchaId,
captchaImage: data.captchaImage,
enabled: data.enabled,
}
}
catch (error) {
console.error('获取验证码失败:', error)
}
}
//
function refreshCaptcha() {
formValue.value.captcha = ''
getCaptcha()
}
const formRef = ref<FormInst | null>(null)
function handleLogin() {
@ -39,19 +81,29 @@ function handleLogin() {
if (errors)
return
isLoading.value = true
const { account, pwd } = formValue.value
startLoading()
const { account, pwd, captcha } = formValue.value
if (isRemember.value)
if (isRemember.value) {
local.set('loginAccount', { account, pwd })
else local.remove('loginAccount')
}
else {
local.remove('loginAccount')
}
await authStore.login(account, pwd)
isLoading.value = false
//
try {
await authStore.login(account, pwd, captchaData.value.captchaId, captcha)
}
catch {
refreshCaptcha()
}
endLoading()
})
}
onMounted(() => {
checkUserAccount()
getCaptcha()
})
function checkUserAccount() {
const loginAccount = local.get('loginAccount')
@ -82,6 +134,25 @@ function checkUserAccount() {
</template>
</n-input>
</n-form-item>
<n-form-item v-if="captchaData.enabled" path="captcha">
<div class="flex w-full gap-2">
<n-input
v-model:value="formValue.captcha"
placeholder="请输入验证码"
clearable
class="flex-1"
/>
<div
class="flex items-center justify-center w-24 h-10 border border-gray-300 rounded cursor-pointer hover:border-primary-500 transition-colors"
@click="refreshCaptcha"
>
<div
class="w-full h-full flex items-center justify-center"
v-html="captchaData.captchaImage"
/>
</div>
</div>
</n-form-item>
<n-space vertical :size="20">
<div class="flex-y-center justify-between">
<n-checkbox v-model:checked="isRemember">
@ -91,7 +162,7 @@ function checkUserAccount() {
{{ $t('login.forgotPassword') }}
</n-button>
</div>
<n-button block type="primary" size="large" :loading="isLoading" :disabled="isLoading" @click="handleLogin">
<n-button block type="primary" size="large" :loading="loading" :disabled="loading" @click="handleLogin">
{{ $t('login.signIn') }}
</n-button>
<n-flex>
@ -102,26 +173,6 @@ function checkUserAccount() {
</n-flex>
</n-space>
</n-form>
<n-divider>
<span op-80>{{ $t('login.or') }}</span>
</n-divider>
<n-space justify="center">
<n-button circle>
<template #icon>
<n-icon><icon-park-outline-wechat /></n-icon>
</template>
</n-button>
<n-button circle>
<template #icon>
<n-icon><icon-park-outline-tencent-qq /></n-icon>
</template>
</n-button>
<n-button circle>
<template #icon>
<n-icon><icon-park-outline-github-one /></n-icon>
</template>
</n-button>
</n-space>
</div>
</template>

View File

@ -2,88 +2,88 @@
import { useAuthStore } from '@/store'
const authStore = useAuthStore()
const { userInfo } = authStore
const formRef = ref()
const formValue = ref({
user: {
name: '',
age: '',
},
phone: '',
})
const rules = {
user: {
name: {
required: true,
message: '请输入姓名',
trigger: 'blur',
},
age: {
required: true,
message: '请输入年龄',
trigger: ['input', 'blur'],
},
},
phone: {
required: true,
message: '请输入电话号码',
trigger: ['input'],
},
//
const genderMap = {
male: '男',
female: '女',
unknown: '未知',
}
function handleValidateClick() {
formRef.value?.validate((errors: any) => {
if (!errors)
window.$message.success('验证通过')
else window.$message.error('验证不通过')
})
//
function getGreeting() {
const hour = new Date().getHours()
if (hour < 6)
return '夜深了'
if (hour < 9)
return '早上好'
if (hour < 12)
return '上午好'
if (hour < 14)
return '中午好'
if (hour < 18)
return '下午好'
if (hour < 22)
return '晚上好'
return '夜深了'
}
</script>
<template>
<n-space vertical>
<n-card title="个人信息">
<n-space size="large">
<n-avatar round :size="128" :src="userInfo?.avatar" />
<!-- 基本信息卡片 -->
<n-card :title="`${getGreeting()}${userInfo?.nickName || userInfo?.username || '用户'},欢迎使用个人中心`">
<n-flex gap="16px" align="center">
<n-avatar
round
:size="128"
:src="userInfo?.avatar || `https://api.dicebear.com/9.x/adventurer-neutral/svg?seed=${userInfo!.username}`"
class="m-x-6"
/>
<n-descriptions label-placement="left" :column="2" :title="`傍晚好,${userInfo?.nickname},这里是简单的个人中心模板`">
<n-descriptions-item label="id">
{{ userInfo?.id }}
</n-descriptions-item>
<n-descriptions
label-placement="left"
class="flex-1"
:column="2"
>
<n-descriptions-item label="用户名">
{{ userInfo?.userName }}
{{ userInfo?.username || '-' }}
</n-descriptions-item>
<n-descriptions-item label="真实名称">
{{ userInfo?.nickname }}
<n-descriptions-item label="称">
{{ userInfo?.nickName || '-' }}
</n-descriptions-item>
<n-descriptions-item label="角色">
{{ userInfo?.role }}
<n-descriptions-item label="性别">
<n-tag size="small">
{{ genderMap[userInfo?.gender || 'unknown'] }}
</n-tag>
</n-descriptions-item>
<n-descriptions-item label="邮箱">
{{ userInfo?.email || '-' }}
</n-descriptions-item>
<n-descriptions-item label="手机号">
{{ userInfo?.phone || '-' }}
</n-descriptions-item>
<n-descriptions-item label="部门ID">
{{ userInfo?.deptId || '-' }}
</n-descriptions-item>
<n-descriptions-item label="拥有角色" :span="2">
<n-space>
<n-tag
v-for="(role, index) in userInfo?.roles"
:key="index"
type="primary"
size="small"
>
{{ role?.roleName || role?.name || `角色${index + 1}` }}
</n-tag>
<n-tag v-if="!userInfo?.roles || userInfo?.roles.length === 0" type="default" size="small">
暂无角色
</n-tag>
</n-space>
</n-descriptions-item>
</n-descriptions>
</n-space>
</n-card>
<n-card title="信息修改">
<n-space justify="center">
<n-form ref="formRef" class="w-500px" :label-width="80" :model="formValue" :rules="rules">
<n-form-item label="姓名" path="user.name">
<n-input v-model:value="formValue.user.name" placeholder="输入姓名" />
</n-form-item>
<n-form-item label="年龄" path="user.age">
<n-input v-model:value="formValue.user.age" placeholder="输入年龄" />
</n-form-item>
<n-form-item label="电话号码" path="phone">
<n-input v-model:value="formValue.phone" placeholder="电话号码" />
</n-form-item>
<n-form-item>
<n-button type="primary" attr-type="button" block @click="handleValidateClick">
验证
</n-button>
</n-form-item>
</n-form>
</n-space>
</n-flex>
</n-card>
</n-space>
</template>
<style scoped></style>

View File

@ -10,15 +10,13 @@ const subOptions = ref()
const currentDict = ref()
async function getAlldict() {
const { data, isSuccess } = await fetchDictList()
if (isSuccess) {
const { data } = await fetchDictList()
options.value = data.map((item) => {
return {
label: item.label,
value: item.code,
}
})
}
}
function changeSelect(v: string) {
dict(v).then((data) => {

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import {
fetchUpdateToken,
fetchRefreshToken,
} from '@/service'
const emit = defineEmits<{
@ -8,7 +8,7 @@ const emit = defineEmits<{
}>()
async function updataToken() {
const res = await fetchUpdateToken({ token: 'test token' })
const res = await fetchRefreshToken({ token: 'test token' })
emit('update', res)
}
</script>

View File

@ -14,7 +14,7 @@ function toggleUserRole(role: Entity.RoleType) {
<template>
<n-card title="权限示例">
<n-h1> 当前权限{{ authStore.userInfo!.role }}</n-h1>
<n-h1> 当前权限{{ authStore.userInfo!.roles.map(r => r.roleName) }}</n-h1>
<n-button-group>
<n-button v-for="item in roleList" :key="item" type="default" @click="toggleUserRole(item)">
{{ item }}

View File

@ -0,0 +1,110 @@
import type { DataTableColumns } from 'naive-ui'
import { NButton, NPopconfirm, NSpace, NSwitch, NTag } from 'naive-ui'
import CopyText from '@/components/custom/CopyText.vue'
// 用户管理columns配置函数
interface UserColumnActions {
onEdit: (row: Entity.User) => void
onDelete: (id: number) => void
onStatusChange: (value: 0 | 1, id: number) => void
}
export function createUserColumns(actions: UserColumnActions): DataTableColumns<Entity.User> {
return [
{
title: 'ID',
align: 'center',
key: 'userId',
width: 80,
},
{
title: '用户名',
align: 'center',
key: 'username',
},
{
title: '昵称',
align: 'center',
key: 'nickName',
},
{
title: '性别',
align: 'center',
key: 'gender',
render: (row) => {
const genderMap = {
male: '男',
female: '女',
unknown: '未知',
}
return (
<NTag size="small">
{genderMap[row.gender || 'unknown']}
</NTag>
)
},
},
{
title: '邮箱',
align: 'center',
key: 'email',
},
{
title: '手机号',
align: 'center',
key: 'phone',
render: (row) => {
return row.phone ? <CopyText value={row.phone} /> : '-'
},
},
{
title: '状态',
align: 'center',
key: 'userStatus',
render: (row) => {
return (
<NSwitch
value={row.userStatus}
checked-value={1}
unchecked-value={0}
onUpdateValue={(value: 0 | 1) =>
actions.onStatusChange(value, row.userId)}
>
{{ checked: () => '启用', unchecked: () => '禁用' }}
</NSwitch>
)
},
},
{
title: '创建时间',
align: 'center',
key: 'createTime',
render: (row) => {
return row.createTime ? new Date(row.createTime).toLocaleDateString() : '-'
},
},
{
title: '操作',
align: 'center',
key: 'actions',
render: (row) => {
return (
<NSpace justify="center">
<NButton
size="small"
onClick={() => actions.onEdit(row)}
>
</NButton>
<NPopconfirm onPositiveClick={() => actions.onDelete(row.userId)}>
{{
default: () => '确认删除',
trigger: () => <NButton size="small" type="error"></NButton>,
}}
</NPopconfirm>
</NSpace>
)
},
},
]
}

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { useBoolean } from '@/hooks'
import { fetchRoleList } from '@/service'
import { createUser, fetchRoleList, updateUser } from '@/service'
interface Props {
modalName?: string
@ -13,20 +13,26 @@ const {
const emit = defineEmits<{
open: []
close: []
success: []
}>()
const { bool: modalVisible, setTrue: showModal, setFalse: hiddenModal } = useBoolean(false)
const { bool: submitLoading, setTrue: startLoading, setFalse: endLoading } = useBoolean(false)
const formDefault: Entity.User = {
userName: '',
const formDefault = {
userId: 0,
username: '',
nickName: '',
email: '',
tel: '',
role: [],
status: 1,
phone: '',
gender: 'unknown' as 'male' | 'female' | 'unknown',
deptId: undefined,
userStatus: 1,
roles: [] as any[],
remark: '',
}
const formModel = ref<Entity.User>({ ...formDefault })
const formModel = ref<Partial<Entity.User>>({ ...formDefault })
type ModalType = 'add' | 'view' | 'edit'
const modalType = shallowRef<ModalType>('add')
@ -76,20 +82,45 @@ const formRef = ref()
async function submitModal() {
const handlers = {
async add() {
return new Promise((resolve) => {
setTimeout(() => {
window.$message.success('模拟新增成功')
resolve(true)
}, 2000)
try {
await createUser({
username: formModel.value.username!,
password: '123456', //
nickName: formModel.value.nickName,
email: formModel.value.email,
phone: formModel.value.phone,
gender: formModel.value.gender,
deptId: formModel.value.deptId,
remark: formModel.value.remark,
})
window.$message.success('用户创建成功')
emit('success')
return true
}
catch {
window.$message.error('创建用户失败')
return false
}
},
async edit() {
return new Promise((resolve) => {
setTimeout(() => {
window.$message.success('模拟编辑成功')
resolve(true)
}, 2000)
try {
await updateUser(formModel.value.userId!, {
nickName: formModel.value.nickName,
email: formModel.value.email,
phone: formModel.value.phone,
gender: formModel.value.gender,
userStatus: formModel.value.userStatus,
deptId: formModel.value.deptId,
remark: formModel.value.remark,
})
window.$message.success('用户更新成功')
emit('success')
return true
}
catch {
window.$message.error('更新用户失败')
return false
}
},
async view() {
return true
@ -97,15 +128,31 @@ async function submitModal() {
}
await formRef.value?.validate()
startLoading()
await handlers[modalType.value]() && closeModal()
const success = await handlers[modalType.value]()
endLoading()
if (success) {
closeModal()
}
}
const rules = {
userName: {
username: {
required: true,
message: '请输入用户名',
trigger: 'blur',
},
email: {
required: false,
pattern: /^[^\s@]+@[^\s@][^\s.@]*\.[^\s@]+$/,
message: '请输入正确的邮箱格式',
trigger: 'blur',
},
phone: {
required: false,
pattern: /^1[3-9]\d{9}$/,
message: '请输入正确的手机号格式',
trigger: 'blur',
},
}
const options = ref()
@ -129,41 +176,42 @@ async function getRoleList() {
>
<n-form ref="formRef" :rules="rules" label-placement="left" :model="formModel" :label-width="100" :disabled="modalType === 'view'">
<n-grid :cols="2" :x-gap="18">
<n-form-item-grid-item :span="1" label="用户名" path="userName">
<n-input v-model:value="formModel.userName" />
<n-form-item-grid-item :span="1" label="用户名" path="username">
<n-input v-model:value="formModel.username" :disabled="modalType === 'edit'" />
</n-form-item-grid-item>
<n-form-item-grid-item :span="1" label="昵称" path="nickName">
<n-input v-model:value="formModel.nickName" />
</n-form-item-grid-item>
<n-form-item-grid-item :span="1" label="性别" path="gender">
<n-radio-group v-model:value="formModel.gender">
<n-space>
<n-radio :value="1">
<n-radio value="male">
</n-radio>
<n-radio :value="0">
<n-radio value="female">
</n-radio>
<n-radio value="unknown">
未知
</n-radio>
</n-space>
</n-radio-group>
</n-form-item-grid-item>
<n-form-item-grid-item :span="1" label="部门ID" path="deptId">
<n-input-number v-model:value="formModel.deptId" class="w-full" />
</n-form-item-grid-item>
<n-form-item-grid-item :span="1" label="邮箱" path="email">
<n-input v-model:value="formModel.email" />
</n-form-item-grid-item>
<n-form-item-grid-item :span="1" label="联系方式" path="tel">
<n-input v-model:value="formModel.tel" />
</n-form-item-grid-item>
<n-form-item-grid-item :span="2" label="角色" path="role">
<n-select
v-model:value="formModel.role" multiple filterable
label-field="role"
value-field="id"
:options="options"
/>
<n-form-item-grid-item :span="1" label="手机号" path="phone">
<n-input v-model:value="formModel.phone" />
</n-form-item-grid-item>
<n-form-item-grid-item :span="2" label="备注" path="remark">
<n-input v-model:value="formModel.remark" type="textarea" />
</n-form-item-grid-item>
<n-form-item-grid-item :span="1" label="用户状态" path="status">
<n-form-item-grid-item :span="1" label="用户状态" path="userStatus">
<n-switch
v-model:value="formModel.status"
v-model:value="formModel.userStatus"
:checked-value="1" :unchecked-value="0"
>
<template #checked>

View File

@ -1,10 +1,8 @@
<script setup lang="tsx">
import type { DataTableColumns, FormInst } from 'naive-ui'
import CopyText from '@/components/custom/CopyText.vue'
import { Gender } from '@/constants'
import type { FormInst } from 'naive-ui'
import { useBoolean } from '@/hooks'
import { fetchUserPage } from '@/service'
import { NButton, NPopconfirm, NSpace, NSwitch, NTag } from 'naive-ui'
import { deleteUser, getUserList, updateUser } from '@/service'
import { createUserColumns } from './columns'
import TableModal from './components/TableModal.vue'
const { bool: loading, setTrue: startLoading, setFalse: endLoading } = useBoolean(false)
@ -21,111 +19,58 @@ function handleResetSearch() {
const formRef = ref<FormInst | null>()
const modalRef = ref()
function delteteUser(id: number) {
window.$message.success(`删除用户id:${id}`)
async function delteteUser(id: number) {
try {
await deleteUser(id)
window.$message.success('用户删除成功')
getUserPageList() //
}
catch {
}
}
const columns: DataTableColumns<Entity.User> = [
{
title: '姓名',
align: 'center',
key: 'userName',
},
{
title: '性别',
align: 'center',
key: 'gender',
render: (row) => {
const tagType = {
0: 'primary',
1: 'success',
} as const
if (row.gender) {
return (
<NTag type={tagType[row.gender]}>
{Gender[row.gender]}
</NTag>
)
}
},
},
{
title: '邮箱',
align: 'center',
key: 'email',
},
{
title: '联系方式',
align: 'center',
key: 'tel',
render: (row) => {
return (
<CopyText value={row.tel} />
)
},
},
{
title: '状态',
align: 'center',
key: 'status',
render: (row) => {
return (
<NSwitch
value={row.status}
checked-value={1}
unchecked-value={0}
onUpdateValue={(value: 0 | 1) =>
handleUpdateDisabled(value, row.id!)}
>
{{ checked: () => '启用', unchecked: () => '禁用' }}
</NSwitch>
)
},
},
{
title: '操作',
align: 'center',
key: 'actions',
render: (row) => {
return (
<NSpace justify="center">
<NButton
size="small"
onClick={() => modalRef.value.openModal('edit', row)}
>
编辑
</NButton>
<NPopconfirm onPositiveClick={() => delteteUser(row.id!)}>
{{
default: () => '确认删除',
trigger: () => <NButton size="small" type="error">删除</NButton>,
}}
</NPopconfirm>
</NSpace>
)
},
},
]
// columns
const columns = createUserColumns({
onEdit: row => modalRef.value?.openModal('edit', row),
onDelete: delteteUser,
onStatusChange: handleUpdateDisabled,
})
const count = ref(0)
const listData = ref<Entity.User[]>([])
function handleUpdateDisabled(value: 0 | 1, id: number) {
const index = listData.value.findIndex(item => item.id === id)
if (index > -1)
listData.value[index].status = value
async function handleUpdateDisabled(value: 0 | 1, id: number) {
try {
// 使updateUser
await updateUser(id, { userStatus: value })
const index = listData.value.findIndex(item => item.userId === id)
if (index > -1) {
listData.value[index].userStatus = value
}
window.$message.success('用户状态更新成功')
}
catch {
}
}
async function getUserList() {
async function getUserPageList() {
startLoading()
await fetchUserPage().then((res: any) => {
listData.value = res.data.list
count.value = res.data.count
endLoading()
try {
const res = await getUserList({
pageNum: 1,
pageSize: 20,
username: model.value.condition_1,
gender: model.value.condition_2 as any,
})
listData.value = res.data.list
count.value = res.data.total
}
finally {
endLoading()
}
}
onMounted(() => {
getUserList()
getUserPageList()
})
function changePage(page: number, size: number) {
@ -181,7 +126,7 @@ const treeData = ref([
<n-input v-model:value="model.condition_2" placeholder="请输入" />
</n-form-item>
<n-flex class="ml-auto">
<NButton type="primary" @click="getUserList">
<NButton type="primary" @click="getUserPageList">
<template #icon>
<icon-park-outline-search />
</template>
@ -212,7 +157,7 @@ const treeData = ref([
<Pagination :count="count" @change="changePage" />
</NSpace>
<TableModal ref="modalRef" modal-name="用户" />
<TableModal ref="modalRef" modal-name="用户" @success="getUserPageList" />
</n-card>
</NSpace>
</n-flex>

View File

@ -0,0 +1,144 @@
import type { DataTableColumns } from 'naive-ui'
import { NButton, NFlex, NPopconfirm } from 'naive-ui'
import CopyText from '@/components/custom/CopyText.vue'
// 字典类型columns配置函数
interface DictTypeColumnActions {
onView: (code: string) => void
onEdit: (row: Entity.DictType) => void
onDelete: (id: number) => void
}
export function createDictTypeColumns(actions: DictTypeColumnActions): DataTableColumns<Entity.DictType> {
return [
{
title: '字典项',
key: 'dictName',
},
{
title: '字典码',
key: 'dictType',
render: (row) => {
return (
<CopyText value={row.dictType} />
)
},
},
{
title: '状态',
key: 'status',
align: 'center',
render: (row) => {
return (
<span>{row.status === 1 ? '正常' : '停用'}</span>
)
},
},
{
title: '操作',
key: 'actions',
align: 'center',
render: (row) => {
return (
<NFlex justify="center">
<NButton
size="small"
onClick={() => actions.onView(row.dictType)}
>
</NButton>
<NButton
size="small"
onClick={() => actions.onEdit(row)}
>
</NButton>
<NPopconfirm onPositiveClick={() => actions.onDelete(row.id!)}>
{{
default: () => (
<span>
<b>{row.dictName}</b>
{' '}
</span>
),
trigger: () => <NButton size="small" type="error"></NButton>,
}}
</NPopconfirm>
</NFlex>
)
},
},
]
}
// 字典数据columns配置函数
interface DictDataColumnActions {
onEdit: (row: Entity.DictData) => void
onDelete: (id: number) => void
}
export function createDictDataColumns(actions: DictDataColumnActions): DataTableColumns<Entity.DictData> {
return [
{
title: '字典名称',
key: 'dictLabel',
},
{
title: '字典码',
key: 'dictType',
},
{
title: '字典值',
key: 'dictValue',
},
{
title: '排序',
key: 'dictSort',
align: 'center',
width: '80px',
},
{
title: '状态',
key: 'status',
align: 'center',
render: (row) => {
return (
<span>{row.status === 1 ? '正常' : '停用'}</span>
)
},
},
{
title: '操作',
key: 'actions',
align: 'center',
width: '15em',
render: (row) => {
return (
<NFlex justify="center">
<NButton
size="small"
onClick={() => actions.onEdit(row)}
>
</NButton>
<NPopconfirm onPositiveClick={() => actions.onDelete(row.id!)}>
{{
default: () => (
<span>
<b>{row.dictLabel}</b>
{' '}
</span>
),
trigger: () => <NButton size="small" type="error"></NButton>,
}}
</NPopconfirm>
</NFlex>
)
},
},
]
}

View File

@ -1,6 +1,7 @@
<script setup lang="ts">
import type { FormRules } from 'naive-ui'
import { useBoolean } from '@/hooks'
import { createDictData, createDictType, updateDictData, updateDictType } from '@/service'
interface Props {
modalName?: string
@ -17,17 +18,18 @@ const {
const emit = defineEmits<{
open: []
close: []
success: []
}>()
const { bool: modalVisible, setTrue: showModal, setFalse: hiddenModal } = useBoolean(false)
const { bool: submitLoading, setTrue: startLoading, setFalse: endLoading } = useBoolean(false)
const formDefault: Entity.Dict = {
const formDefault: any = {
label: '',
code: '',
}
const formModel = ref<Entity.Dict>({ ...formDefault })
const formModel = ref<any>({ ...formDefault })
type ModalType = 'add' | 'view' | 'edit'
const modalType = shallowRef<ModalType>('add')
@ -81,20 +83,54 @@ const formRef = ref()
async function submitModal() {
const handlers = {
async add() {
return new Promise((resolve) => {
setTimeout(() => {
window.$message.success('模拟新增成功')
resolve(true)
}, 2000)
try {
if (isRoot) {
//
await createDictType({
dictType: formModel.value.label,
dictName: formModel.value.code,
})
window.$message.success('字典类型创建成功')
}
else {
//
await createDictData({
dictType: formModel.value.label,
dictValue: formModel.value.value,
})
window.$message.success('字典数据创建成功')
}
emit('success')
return true
}
catch {
return false
}
},
async edit() {
return new Promise((resolve) => {
setTimeout(() => {
window.$message.success('模拟编辑成功')
resolve(true)
}, 2000)
try {
if (isRoot) {
//
await updateDictType(formModel.value.id!, {
dictType: formModel.value.label as any,
dictName: formModel.value.code,
})
window.$message.success('字典类型更新成功')
}
else {
//
await updateDictData(formModel.value.id!, {
dictType: formModel.value.label,
dictValue: formModel.value.value,
})
window.$message.success('字典数据更新成功')
}
emit('success')
return true
}
catch {
return false
}
},
async view() {
return true
@ -102,7 +138,11 @@ async function submitModal() {
}
await formRef.value?.validate()
startLoading()
await handlers[modalType.value]() && closeModal()
const success = await handlers[modalType.value]()
endLoading()
if (success) {
closeModal()
}
}
const rules: FormRules = {

View File

@ -1,17 +1,12 @@
<script setup lang="tsx">
import type { DataTableColumns } from 'naive-ui'
import CopyText from '@/components/custom/CopyText.vue'
import { useBoolean } from '@/hooks'
import { fetchDictList } from '@/service'
import { useDictStore } from '@/store'
import { NButton, NFlex, NPopconfirm } from 'naive-ui'
import { deleteDictData, deleteDictType, getDictDataByType, getDictTypeList } from '@/service'
import { createDictDataColumns, createDictTypeColumns } from './columns'
import DictModal from './components/DictModal.vue'
const { bool: dictLoading, setTrue: startDictLoading, setFalse: endDictLoading } = useBoolean(false)
const { bool: contentLoading, setTrue: startContentLoading, setFalse: endContentLoading } = useBoolean(false)
const { getDictByNet } = useDictStore()
const dictRef = ref<InstanceType<typeof DictModal>>()
const dictContentRef = ref<InstanceType<typeof DictModal>>()
@ -19,126 +14,70 @@ onMounted(() => {
getDictList()
})
const dictData = ref<Entity.Dict[]>([])
const dictContentData = ref<Entity.Dict[]>([])
const dictData = ref<Entity.DictType[]>([])
const dictContentData = ref<Entity.DictData[]>([])
async function getDictList() {
startDictLoading()
const { data, isSuccess } = await fetchDictList()
if (isSuccess) {
dictData.value = data
try {
const { data } = await getDictTypeList()
dictData.value = data.list
}
catch (error) {
console.error('获取字典类型列表失败', error)
}
finally {
endDictLoading()
}
}
const lastDictCode = ref('')
async function getDictContent(code: string) {
startContentLoading()
dictContentData.value = await getDictByNet(code)
try {
const { data } = await getDictDataByType(code)
dictContentData.value = data
lastDictCode.value = code
}
catch (error) {
console.error('获取字典数据失败', error)
}
finally {
endContentLoading()
}
}
const dictColumns: DataTableColumns<Entity.Dict> = [
{
title: '字典项',
key: 'label',
},
{
title: '字典码',
key: 'code',
render: (row) => {
return (
<CopyText value={row.code} />
)
},
},
{
title: '操作',
key: 'actions',
align: 'center',
render: (row) => {
return (
<NFlex justify="center">
<NButton
size="small"
onClick={() => getDictContent(row.code)}
>
查看字典
</NButton>
<NButton
size="small"
onClick={() => dictRef.value!.openModal('edit', row)}
>
编辑
</NButton>
<NPopconfirm onPositiveClick={() => deleteDict(row.id!)}>
{{
default: () => (
<span>
确认删除字典
<b>{row.label}</b>
{' '}
</span>
),
trigger: () => <NButton size="small" type="error">删除</NButton>,
}}
</NPopconfirm>
</NFlex>
)
},
},
]
// columns
const dictColumns = createDictTypeColumns({
onView: getDictContent,
onEdit: row => dictRef.value!.openModal('edit', row),
onDelete: id => deleteDict(id, true),
})
const contentColumns: DataTableColumns<Entity.Dict> = [
{
title: '字典名称',
key: 'label',
},
{
title: '字典码',
key: 'code',
},
{
title: '字典值',
key: 'value',
},
{
title: '操作',
key: 'actions',
align: 'center',
width: '15em',
render: (row) => {
return (
<NFlex justify="center">
<NButton
size="small"
onClick={() => dictContentRef.value!.openModal('edit', row)}
>
编辑
</NButton>
<NPopconfirm onPositiveClick={() => deleteDict(row.id!)}>
{{
default: () => (
<span>
确认删除字典值
<b>{row.label}</b>
{' '}
</span>
),
trigger: () => <NButton size="small" type="error">删除</NButton>,
}}
</NPopconfirm>
</NFlex>
)
},
},
]
// columns
const contentColumns = createDictDataColumns({
onEdit: row => dictContentRef.value!.openModal('edit', row),
onDelete: id => deleteDict(id, false),
})
function deleteDict(id: number) {
window.$message.error(`删除字典${id}`)
async function deleteDict(id: number, isType: boolean = false) {
try {
if (isType) {
await deleteDictType(id)
window.$message.success('删除字典类型成功')
getDictList() //
}
else {
await deleteDictData(id)
window.$message.success('删除字典数据成功')
if (lastDictCode.value) {
getDictContent(lastDictCode.value) //
}
}
}
catch (error) {
console.error(`删除${isType ? '字典类型' : '字典数据'}失败`, error)
}
}
</script>
@ -197,8 +136,8 @@ function deleteDict(id: number) {
</n-card>
</div>
<DictModal ref="dictRef" modal-name="字典项" is-root />
<DictModal ref="dictContentRef" modal-name="字典值" :dict-code="lastDictCode" />
<DictModal ref="dictRef" modal-name="字典项" is-root @success="getDictList" />
<DictModal ref="dictContentRef" modal-name="字典值" :dict-code="lastDictCode" @success="() => getDictContent(lastDictCode)" />
</NFlex>
</template>

View File

@ -0,0 +1,109 @@
import type { DataTableColumns } from 'naive-ui'
import { NButton, NPopconfirm, NSpace, NTag } from 'naive-ui'
import { createIcon } from '@/utils'
import { renderProCopyableText } from 'pro-naive-ui'
// 菜单管理columns配置函数
interface MenuColumnActions {
onEdit: (row: Entity.Menu) => void
onDelete: (id: number) => void
onAdd: (row: Entity.Menu) => void
}
export function createMenuColumns(actions: MenuColumnActions): DataTableColumns<Entity.Menu> {
const { onEdit, onDelete, onAdd } = actions
return [
{
title: '菜单名称',
key: 'title',
width: 200,
},
{
title: '图标',
align: 'center',
key: 'icon',
width: '6em',
render: (row) => {
return row.icon && createIcon(row.icon, { size: 20 })
},
},
{
title: '路径',
key: 'path',
render: row => renderProCopyableText(row.path),
},
{
title: '权限标识',
key: 'perms',
width: '18em',
align: 'center',
render: row => renderProCopyableText(row.perms),
},
{
title: '排序值',
key: 'sort',
align: 'center',
width: '6em',
},
{
title: '菜单类型',
align: 'center',
key: 'menuType',
width: '8em',
render: (row) => {
const menuTypeMap = {
directory: { label: '目录', type: 'primary' },
page: { label: '菜单', type: 'warning' },
permission: { label: '按钮', type: 'success' },
} as const
const menuInfo = menuTypeMap[row.menuType]
return <NTag type={menuInfo.type} bordered={false}>{menuInfo.label}</NTag>
},
},
{
title: '状态',
align: 'center',
key: 'status',
width: '6em',
render: (row) => {
return (
<NTag type={row.status === 0 ? 'success' : 'error'} bordered={false}>
{row.status === 0 ? '正常' : '停用'}
</NTag>
)
},
},
{
title: '操作',
align: 'center',
key: 'actions',
width: '16em',
render: (row) => {
return (
<NSpace justify="center">
<NButton
text
type="primary"
onClick={() => onAdd(row)}
>
</NButton>
<NButton
text
onClick={() => onEdit(row)}
>
</NButton>
<NPopconfirm onPositiveClick={() => onDelete(row.menuId)}>
{{
default: () => '确认删除',
trigger: () => <NButton text type="error"></NButton>,
}}
</NPopconfirm>
</NSpace>
)
},
},
]
}

View File

@ -0,0 +1,43 @@
<template>
<pro-input
title="国际化标识Key"
path="i18nKey"
placeholder="Eg: system.user"
/>
<pro-field
title="菜单图标"
path="icon"
>
<template #input="{ inputProps }">
<icon-select
:value="inputProps.value"
@update:value="inputProps.onUpdateValue"
/>
</template>
</pro-field>
<pro-input
required
title="路由路径"
tooltip="目录类型的路由路径,如:/system"
path="path"
class="col-span-2"
placeholder="Eg: /system"
/>
<pro-digit
title="排序"
tooltip="数字越小,同级中越靠前"
path="sort"
/>
<pro-switch
title="启用"
path="status"
:field-props="{
checkedValue: 0,
uncheckedValue: 1,
}"
/>
<pro-switch
title="菜单可见"
path="menuVisible"
/>
</template>

View File

@ -0,0 +1,171 @@
<script setup lang="ts">
import { useBoolean } from '@/hooks'
import { createMenu, getMenuById, selectMenuTree, updateMenu } from '@/service'
import { createProModalForm } from 'pro-naive-ui'
import DirectoryForm from './DirectoryForm.vue'
import PageForm from './PageForm.vue'
import PermissionForm from './PermissionForm.vue'
interface Props {
modalName?: string
}
const {
modalName: _modalName = '',
} = defineProps<Props>()
const emit = defineEmits<{
success: []
}>()
const { bool: submitLoading, setTrue: startLoading, setFalse: endLoading } = useBoolean(false)
const modalForm = createProModalForm<Partial<Entity.Menu>>({
omitEmptyString: false,
initialValues: {
menuType: 'directory',
menuVisible: true,
tabVisible: true,
status: 0,
},
onSubmit: submitModal,
})
type ModalType = 'add' | 'edit'
const modalType = shallowRef<ModalType>('add')
const modalTitle = computed(() => {
const titleMap: Record<ModalType, string> = {
add: '添加',
edit: '编辑',
}
const menuTypeMap = {
directory: '目录',
page: '页面',
permission: '权限',
}
const currentMenuType = modalForm.values.value?.menuType || 'directory'
return `${titleMap[modalType.value]}${menuTypeMap[currentMenuType]}`
})
const treeData = ref<Entity.TreeNode[]>([])
//
const formComponents = {
directory: DirectoryForm,
page: PageForm,
permission: PermissionForm,
}
// 使
const currentFormComponent = computed(() => {
const menuType = modalForm.values.value?.menuType || 'directory'
return formComponents[menuType as keyof typeof formComponents]
})
async function openModal(type: ModalType = 'add', data?: Partial<Entity.Menu>) {
selectMenuTree().then((res) => {
treeData.value = res.data
})
modalType.value = type
modalForm.open()
const handlers = {
async add() {
// menuId
if (data?.menuId) {
modalForm.values.value.parentId = data.menuId
}
},
async edit() {
if (!data?.menuId)
return
const response = await getMenuById(data.menuId)
modalForm.values.value = response.data
},
}
await handlers[type]()
}
async function submitModal(filedValues: Partial<Entity.Menu>) {
const handlers = {
async add() {
try {
await createMenu(filedValues)
window.$message.success('菜单创建成功')
}
catch (error) {
console.error('创建菜单失败', error)
}
},
async edit() {
try {
await updateMenu(modalForm.values.value.menuId!, filedValues)
window.$message.success('菜单更新成功')
}
catch (error) {
console.error('更新菜单失败', error)
}
},
}
startLoading()
await handlers[modalType.value]()
emit('success')
modalForm.close()
endLoading()
}
defineExpose({
openModal,
})
</script>
<template>
<pro-modal-form
:title="modalTitle"
:form="modalForm"
:loading="submitLoading"
width="700px"
>
<pro-field
path="menuType"
>
<template #input="{ inputProps }">
<n-tabs
type="segment" animated :value="inputProps.value"
@update:value="inputProps.onUpdateValue"
>
<n-tab name="directory" tab="目录" />
<n-tab name="page" tab="页面" />
<n-tab name="permission" tab="权限" />
</n-tabs>
</template>
</pro-field>
<div class="grid grid-cols-2">
<pro-tree-select
tooltip="不填写则为顶层菜单"
title="父级目录"
path="parentId"
:field-props="{
options: treeData,
keyField: 'value',
}"
/>
<pro-input
required
title="标题"
path="title"
/>
<component
:is="currentFormComponent"
:tree-data="treeData"
/>
<pro-textarea
title="备注"
path="remark"
class="col-span-2"
/>
</div>
</pro-modal-form>
</template>

View File

@ -0,0 +1,82 @@
<template>
<pro-input
title="国际化标识Key"
path="i18nKey"
placeholder="Eg: system.user"
/>
<pro-field
title="菜单图标"
path="icon"
>
<template #input="{ inputProps }">
<icon-select
:value="inputProps.value"
@update:value="inputProps.onUpdateValue"
/>
</template>
</pro-field>
<pro-input
required
title="路由路径"
tooltip="页面路由路径,与组件路径对应"
path="path"
class="col-span-2"
placeholder="Eg: /system/user"
/>
<pro-input
title="高亮菜单路径"
tooltip="当前路由不在侧边菜单显示,但需要高亮为某个菜单"
path="activePath"
class="col-span-2"
placeholder="Eg: /system/user"
/>
<pro-input
required
title="组件路径"
tooltip="页面组件的文件路径"
path="component"
class="col-span-2"
placeholder="Eg: /system/user/index.vue"
/>
<pro-digit
title="排序"
tooltip="数字越小,同级中越靠前"
path="sort"
/>
<pro-input
title="权限标识"
tooltip="页面访问权限标识"
path="perms"
placeholder="Eg: system:user:list"
/>
<pro-switch
title="启用"
path="status"
:field-props="{
checkedValue: 0,
uncheckedValue: 1,
}"
/>
<pro-switch
title="菜单可见"
path="menuVisible"
/>
<pro-switch
title="标签栏可见"
path="tabVisible"
/>
<pro-switch
title="页面缓存"
tooltip="开启配置后,切换页面数据不会清空"
path="keepAlive"
/>
<pro-switch
title="常驻标签栏"
path="pinTab"
/>
<pro-switch
title="外链菜单"
tooltip="开启配置后,点击菜单会跳转到外链地址"
path="isLink"
/>
</template>

View File

@ -0,0 +1,22 @@
<template>
<pro-input
required
title="权限标识"
tooltip="按钮权限唯一标识符"
path="perms"
placeholder="Eg: system:user:add"
/>
<pro-digit
title="排序"
tooltip="数字越小,同级中越靠前"
path="sort"
/>
<pro-switch
title="启用"
path="status"
:field-props="{
checkedValue: 0,
uncheckedValue: 1,
}"
/>
</template>

View File

@ -1,296 +0,0 @@
<script setup lang="ts">
import type {
FormItemRule,
} from 'naive-ui'
import HelpInfo from '@/components/common/HelpInfo.vue'
import { Regex } from '@/constants'
import { useBoolean } from '@/hooks'
import { fetchRoleList } from '@/service'
interface Props {
modalName?: string
allRoutes: AppRoute.RowRoute[]
}
const {
modalName = '',
allRoutes,
} = defineProps<Props>()
const emit = defineEmits<{
open: []
close: []
}>()
const { bool: modalVisible, setTrue: showModal, setFalse: hiddenModal } = useBoolean(false)
const { bool: submitLoading, setTrue: startLoading, setFalse: endLoading } = useBoolean(false)
const formDefault: AppRoute.RowRoute = {
name: '',
path: '',
id: -1,
pid: null,
title: '',
requiresAuth: true,
keepAlive: false,
hide: false,
withoutTab: true,
pinTab: false,
menuType: 'page',
}
const formModel = ref<AppRoute.RowRoute>({ ...formDefault })
type ModalType = 'add' | 'view' | 'edit'
const modalType = shallowRef<ModalType>('add')
const modalTitle = computed(() => {
const titleMap: Record<ModalType, string> = {
add: '添加',
view: '查看',
edit: '编辑',
}
return `${titleMap[modalType.value]}${modalName}`
})
async function openModal(type: ModalType = 'add', data: AppRoute.RowRoute) {
emit('open')
modalType.value = type
getRoleList()
showModal()
const handlers = {
async add() {
formModel.value = { ...formDefault }
},
async view() {
if (!data)
return
formModel.value = { ...data }
},
async edit() {
if (!data)
return
formModel.value = { ...data }
},
}
await handlers[type]()
}
function closeModal() {
hiddenModal()
endLoading()
emit('close')
}
defineExpose({
openModal,
})
const formRef = ref()
async function submitModal() {
const handlers = {
async add() {
return new Promise((resolve) => {
setTimeout(() => {
window.$message.success('模拟新增成功')
resolve(true)
}, 2000)
})
},
async edit() {
return new Promise((resolve) => {
setTimeout(() => {
window.$message.success('模拟编辑成功')
resolve(true)
}, 2000)
})
},
async view() {
return true
},
}
await formRef.value?.validate()
startLoading()
await handlers[modalType.value]() && closeModal()
}
const dirTreeOptions = computed(() => {
return filterDirectory(JSON.parse(JSON.stringify(allRoutes)))
})
function filterDirectory(node: any[]) {
return node.filter((item) => {
if (item.children) {
const childDir = filterDirectory(item.children)
if (childDir.length > 0)
item.children = childDir
else
Reflect.deleteProperty(item, 'children')
}
return (item.menuType === 'dir')
})
}
const rules = {
name: {
required: true,
// message: '',
validator(rule: FormItemRule, value: string) {
if (!value)
return new Error('请输入菜单名称')
if (!new RegExp(Regex.RouteName).test(value))
return new Error('菜单只能包含英文数字_!@#$%^&*~-')
return true
},
trigger: 'blur',
},
path: {
required: true,
message: '请输入菜单路径',
trigger: 'blur',
},
componentPath: {
required: true,
message: '请输入组件路径',
trigger: 'blur',
},
title: {
required: true,
message: '请输入菜单标题',
trigger: 'blur',
},
}
const options = ref()
async function getRoleList() {
const { data } = await fetchRoleList()
options.value = data
}
</script>
<template>
<n-modal
v-model:show="modalVisible" :mask-closable="false" preset="card" :title="modalTitle" class="w-700px"
:segmented="{
content: true,
action: true,
}"
>
<n-form
ref="formRef"
:rules="rules" label-placement="left" :label-width="100"
:model="formModel"
:disabled="modalType === 'view'"
>
<n-grid :cols="2" :x-gap="18">
<n-form-item-grid-item :span="2" path="pid">
<template #label>
父级目录
<HelpInfo message="不填写则为顶层菜单" />
</template>
<n-tree-select
v-model:value="formModel.pid" filterable clearable :options="dirTreeOptions" key-field="id"
label-field="title" children-field="children" placeholder="请选择父级目录"
/>
</n-form-item-grid-item>
<n-form-item-grid-item :span="1" label="菜单名称" path="name">
<n-input v-model:value="formModel.name" placeholder="Eg: system" />
</n-form-item-grid-item>
<n-form-item-grid-item :span="1" label="标题" path="title">
<n-input v-model:value="formModel.title" placeholder="Eg: My-System" />
</n-form-item-grid-item>
<n-form-item-grid-item :span="2" label="路由路径" path="path">
<n-input v-model:value="formModel.path" placeholder="Eg: /system/user" />
</n-form-item-grid-item>
<n-form-item-grid-item :span="1" label="菜单类型" path="menuType">
<n-radio-group v-model:value="formModel.menuType" name="radiogroup">
<n-space>
<n-radio value="dir">
目录
</n-radio>
<n-radio value="page">
页面
</n-radio>
</n-space>
</n-radio-group>
</n-form-item-grid-item>
<n-form-item-grid-item :span="1" label="图标" path="icon">
<icon-select v-model:value="formModel.icon" :disabled="modalType === 'view'" />
</n-form-item-grid-item>
<n-form-item-grid-item v-if="formModel.menuType === 'page'" :span="2" label="组件路径" path="componentPath">
<n-input v-model:value="formModel.componentPath" placeholder="Eg: /system/user/index.vue" />
</n-form-item-grid-item>
<n-form-item-grid-item :span="1" path="order">
<template #label>
菜单排序
<HelpInfo message="数字越小,同级中越靠前" />
</template>
<n-input-number v-model:value="formModel.order" />
</n-form-item-grid-item>
<n-form-item-grid-item v-if="formModel.menuType === 'page'" :span="1" path="href">
<template #label>
外链页面
<HelpInfo message="填写后,点击菜单将跳转到该地址,组件路径任意填写" />
</template>
<n-input v-model:value="formModel.href" placeholder="Eg: https://example.com" />
</n-form-item-grid-item>
<n-form-item-grid-item :span="1" label="登录访问" path="requiresAuth">
<n-switch v-model:value="formModel.requiresAuth" />
</n-form-item-grid-item>
<n-form-item-grid-item
v-if="formModel.menuType === 'page'" :span="1" label="页面缓存"
path="keepAlive"
>
<n-switch v-model:value="formModel.keepAlive" />
</n-form-item-grid-item>
<n-form-item-grid-item
v-if="formModel.menuType === 'page'" :span="1" label="标签栏可见"
path="withoutTab"
>
<n-switch v-model:value="formModel.withoutTab" />
</n-form-item-grid-item>
<n-form-item-grid-item v-if="formModel.menuType === 'page'" :span="1" label="常驻标签栏" path="pinTab">
<n-switch v-model:value="formModel.pinTab" />
</n-form-item-grid-item>
<n-form-item-grid-item :span="1" label="侧边菜单隐藏" path="hide">
<n-switch v-model:value="formModel.hide" />
</n-form-item-grid-item>
<n-form-item-grid-item
v-if="formModel.menuType === 'page' && formModel.hide" :span="2"
path="activeMenu"
>
<template #label>
高亮菜单
<HelpInfo message="当前路由不在左侧菜单显示,但需要高亮某个菜单" />
</template>
<n-input v-model:value="formModel.activeMenu" />
</n-form-item-grid-item>
<n-form-item-grid-item :span="2" path="roles">
<template #label>
访问角色
<HelpInfo message="不填写则表示所有角色都可以访问" />
</template>
<n-select
v-model:value="formModel.roles" multiple filterable
label-field="role"
value-field="id"
:options="options"
/>
</n-form-item-grid-item>
</n-grid>
</n-form>
<template v-if="modalType !== 'view'" #action>
<n-space justify="center">
<n-button @click="closeModal">
取消
</n-button>
<n-button type="primary" :loading="submitLoading" @click="submitModal">
提交
</n-button>
</n-space>
</template>
</n-modal>
</template>
<style scoped></style>

View File

@ -1,173 +1,102 @@
<script setup lang="tsx">
import type { DataTableColumns } from 'naive-ui'
<script setup lang="ts">
import { useBoolean } from '@/hooks'
import { fetchAllRoutes } from '@/service'
import { arrayToTree, createIcon } from '@/utils'
import { NButton, NPopconfirm, NSpace, NTag } from 'naive-ui'
import { renderProCopyableText } from 'pro-naive-ui'
import TableModal from './components/TableModal.vue'
import { deleteMenu, getMenuList } from '@/service'
import { createMenuColumns } from './columns'
import MenuModal from './components/MenuModal.vue'
import arrayToTree from 'array-to-tree'
const { bool: loading, setTrue: startLoading, setFalse: endLoading } = useBoolean(false)
function deleteData(id: number) {
window.$message.success(`删除菜单id:${id}`)
async function deleteData(id: number) {
try {
await deleteMenu(id)
window.$message.success('删除菜单成功')
getAllRoutes() //
}
catch (error) {
console.error('删除菜单失败', error)
}
}
const tableModalRef = ref()
const menuModalRef = ref()
const columns: DataTableColumns<AppRoute.RowRoute> = [
{
type: 'selection',
width: 30,
},
{
title: '名称',
key: 'name',
width: 200,
},
{
title: '图标',
align: 'center',
key: 'icon',
width: '6em',
render: (row) => {
return row.icon && createIcon(row.icon, { size: 20 })
},
},
{
title: '标题',
align: 'center',
key: 'title',
ellipsis: {
tooltip: true,
},
},
{
title: '路径',
key: 'path',
render: row => renderProCopyableText(row.path),
},
{
title: '组件路径',
key: 'componentPath',
ellipsis: {
tooltip: true,
},
render: (row) => {
return row.componentPath || '-'
},
},
{
title: '排序值',
key: 'order',
align: 'center',
width: '6em',
},
{
title: '菜单类型',
align: 'center',
key: 'menuType',
width: '6em',
render: (row) => {
const menuType = row.menuType || 'page'
const menuTagType: Record<AppRoute.MenuType, NaiveUI.ThemeColor> = {
dir: 'primary',
page: 'warning',
// columns
const columns = createMenuColumns({
onEdit: row => menuModalRef.value.openModal('edit', row),
onDelete: deleteData,
onAdd: row => menuModalRef.value.openModal('add', row),
})
const tableData = ref<Entity.Menu[]>([])
//
function sortMenuTree(menus: Entity.Menu[]): Entity.Menu[] {
// sort
const sortedMenus = menus.sort((a, b) => {
const sortA = a.sort || 0
const sortB = b.sort || 0
return sortA - sortB
})
//
return sortedMenus.map(menu => ({
...menu,
children: menu.children ? sortMenuTree(menu.children) : undefined,
}))
}
async function getAllRoutes() {
startLoading()
try {
const { data } = await getMenuList()
const treeData = arrayToTree(data, {
parentProperty: 'parentId',
customID: 'menuId',
})
// sort
tableData.value = sortMenuTree(treeData)
}
return <NTag type={menuTagType[menuType]}>{menuType}</NTag>
},
},
{
title: '操作',
align: 'center',
key: 'actions',
width: '15em',
render: (row) => {
return (
<NSpace justify="center">
<NButton
size="small"
onClick={() => tableModalRef.value.openModal('view', row)}
>
查看
</NButton>
<NButton
size="small"
onClick={() => tableModalRef.value.openModal('edit', row)}
>
编辑
</NButton>
<NPopconfirm onPositiveClick={() => deleteData(row.id)}>
{{
default: () => '确认删除',
trigger: () => <NButton size="small" type="error">删除</NButton>,
}}
</NPopconfirm>
</NSpace>
)
},
},
]
const tableData = ref<AppRoute.RowRoute[]>([])
catch {
window.$message.error('获取菜单列表失败')
}
finally {
endLoading()
}
}
onMounted(() => {
getAllRoutes()
})
async function getAllRoutes() {
startLoading()
const { data } = await fetchAllRoutes()
tableData.value = arrayToTree(data)
endLoading()
}
const checkedRowKeys = ref<number[]>([])
async function handlePositiveClick() {
window.$message.success(`批量删除id:${checkedRowKeys.value.join(',')}`)
}
</script>
<template>
<n-card>
<template #header>
<NButton type="primary" @click="tableModalRef.openModal('add')">
<div>
<pro-data-table
row-key="menuId"
:columns="columns"
:data="tableData"
:loading="loading"
>
<template #title>
<n-button type="primary" @click="menuModalRef.openModal('add')">
<template #icon>
<icon-park-outline-add-one />
</template>
新建
</NButton>
</n-button>
</template>
<template #header-extra>
<n-flex>
<NButton type="primary" secondary @click="getAllRoutes">
<template #toolbar>
<n-button type="primary" secondary @click="getAllRoutes">
<template #icon>
<icon-park-outline-refresh />
</template>
刷新
</NButton>
<NPopconfirm
@positive-click="handlePositiveClick"
>
<template #trigger>
<NButton type="error" secondary>
<template #icon>
<icon-park-outline-delete-five />
</n-button>
</template>
批量删除
</NButton>
</template>
确认删除所有选中菜单
</NPopconfirm>
</n-flex>
</template>
<n-data-table
v-model:checked-row-keys="checkedRowKeys"
:row-key="(row:AppRoute.RowRoute) => row.id" :columns="columns" :data="tableData"
:loading="loading"
size="small"
:scroll-x="1200"
/>
<TableModal ref="tableModalRef" :all-routes="tableData" modal-name="菜单" />
</n-card>
</pro-data-table>
<MenuModal ref="menuModalRef" modal-name="菜单" @success="getAllRoutes" />
</div>
</template>