通用中后台框架第一版

This commit is contained in:
BBIT-Kai
2026-04-28 16:27:16 +08:00
commit b8d25869c6
115 changed files with 15223 additions and 0 deletions
+64
View File
@@ -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>
+14
View File
@@ -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')
}
+71
View File
@@ -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
+20
View File
@@ -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 })
}
+61
View File
@@ -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}`)
}
+18
View File
@@ -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}`)
}
+18
View File
@@ -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}`)
}
+32
View File
@@ -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 })
}
+43
View File
@@ -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)
}
+23
View File
@@ -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>
+96
View File
@@ -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>
+107
View File
@@ -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>
+17
View File
@@ -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>
+9
View File
@@ -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')
}
+17
View File
@@ -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
}
+70
View File
@@ -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>
+141
View File
@@ -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>
+141
View File
@@ -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>
+235
View File
@@ -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>
+341
View File
@@ -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>
+238
View File
@@ -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>
+297
View File
@@ -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>
+549
View File
@@ -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>
+20
View File
@@ -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>
+242
View File
@@ -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>
+18
View File
@@ -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')
+71
View File
@@ -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()
}
+79
View File
@@ -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}`
}
})
+42
View File
@@ -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: '页面不存在' }
}
]
+181
View File
@@ -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
}
})
+252
View File
@@ -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;
}
+42
View File
@@ -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[]
}
+25
View File
@@ -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'
}
}
+32
View File
@@ -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>
+11
View File
@@ -0,0 +1,11 @@
import 'vue-router'
declare module 'vue-router' {
interface RouteMeta {
title?: string
requiresAuth?: boolean
permission?: string
keepAlive?: boolean
dynamic?: boolean
}
}
+25
View File
@@ -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>
+36
View File
@@ -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
+25
View File
@@ -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
}
+40
View File
@@ -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
}
+70
View File
@@ -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[]
}
+61
View File
@@ -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) : '-'
}
+11
View File
@@ -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}`
}
+74
View File
@@ -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>
+19
View File
@@ -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>
+15
View File
@@ -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>