permission
This commit is contained in:
+2
-2
@@ -10,8 +10,8 @@ VITE_APP_BASE_API = '/api/v1'
|
|||||||
# 后端真实地址
|
# 后端真实地址
|
||||||
VITE_APP_API_URL = 'http://localhost:8080'
|
VITE_APP_API_URL = 'http://localhost:8080'
|
||||||
|
|
||||||
# 是否启用本地 mock(true 时拦截 /api/v1 请求)
|
# 是否启用本地 mock(true 时拦截全部 /api/v1 请求;false 时仅命中标记 alwaysMock 的接口)
|
||||||
VITE_USE_MOCK = 'true'
|
VITE_USE_MOCK = 'false'
|
||||||
|
|
||||||
# 开发端口
|
# 开发端口
|
||||||
VITE_APP_PORT = '3000'
|
VITE_APP_PORT = '3000'
|
||||||
|
|||||||
+57
-32
@@ -7,28 +7,55 @@ export type UserStatus = (typeof USER_STATUS)[keyof typeof USER_STATUS]
|
|||||||
|
|
||||||
export interface MockUser {
|
export interface MockUser {
|
||||||
id: number
|
id: number
|
||||||
username: string
|
mini_id: number
|
||||||
|
openid: string
|
||||||
|
union_id: string
|
||||||
nickname: string
|
nickname: string
|
||||||
mobile: string
|
avatar: string
|
||||||
email: string
|
status: 0 | 1
|
||||||
status: UserStatus
|
created_at: string
|
||||||
deptName: string
|
updated_at: string
|
||||||
roleNames: string[]
|
|
||||||
createTime: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const users: MockUser[] = Array.from({ length: 36 }, (_, index) => {
|
export const users: MockUser[] = Array.from({ length: 15 }, (_, index) => {
|
||||||
const id = index + 1
|
const id = index + 1
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
username: `user${String(id).padStart(3, '0')}`,
|
mini_id: (id % 3) + 1,
|
||||||
nickname: `测试用户${id}`,
|
openid: `oABC${String(100000 + id)}`,
|
||||||
mobile: `13${String(100000000 + id).slice(0, 9)}`,
|
union_id: id % 2 === 0 ? `uXYZ${id}` : '',
|
||||||
email: `user${id}@example.com`,
|
nickname: `用户${id}`,
|
||||||
status: id % 5 === 0 ? USER_STATUS.DISABLED : USER_STATUS.ENABLED,
|
avatar: `https://picsum.photos/seed/${id}/80`,
|
||||||
deptName: id % 3 === 0 ? '运营部' : '技术部',
|
status: id % 4 === 0 ? 0 : 1,
|
||||||
roleNames: id % 4 === 0 ? ['管理员'] : ['普通用户'],
|
created_at: new Date(Date.now() - id * 86400000).toISOString(),
|
||||||
createTime: new Date(Date.now() - id * 86400000).toISOString(),
|
updated_at: new Date(Date.now() - id * 3600000).toISOString(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export interface MockMiniApp {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
app_id: string
|
||||||
|
app_secret: string
|
||||||
|
status: 0 | 1
|
||||||
|
ext: string
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const miniApps: MockMiniApp[] = Array.from({ length: 12 }, (_, index) => {
|
||||||
|
const id = index + 1
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: `小程序${id}`,
|
||||||
|
description: `用于演示的第 ${id} 个小程序`,
|
||||||
|
app_id: `wx${String(1000000000 + id * 137)}`,
|
||||||
|
app_secret: `secret-${id}`,
|
||||||
|
status: id % 3 === 0 ? 0 : 1,
|
||||||
|
ext: '{}',
|
||||||
|
created_at: new Date(Date.now() - id * 86400000).toISOString(),
|
||||||
|
updated_at: new Date(Date.now() - id * 3600000).toISOString(),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -47,20 +74,18 @@ export const menuRoutes = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/miniapp',
|
||||||
|
component: 'Layout',
|
||||||
|
redirect: '/miniapp/list',
|
||||||
|
meta: { title: '小程序管理', icon: 'Menu', hidden: false },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: 'list',
|
||||||
|
component: 'miniapp/index',
|
||||||
|
name: 'Miniapp',
|
||||||
|
meta: { title: '小程序管理', icon: 'Menu', hidden: false },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export const dashboardSummary = {
|
|
||||||
userTotal: 1024,
|
|
||||||
orderTotal: 8672,
|
|
||||||
gmv: 1289340.55,
|
|
||||||
pendingApproval: 12,
|
|
||||||
trend: [
|
|
||||||
{ date: '10-01', value: 120 },
|
|
||||||
{ date: '10-02', value: 200 },
|
|
||||||
{ date: '10-03', value: 150 },
|
|
||||||
{ date: '10-04', value: 320 },
|
|
||||||
{ date: '10-05', value: 280 },
|
|
||||||
{ date: '10-06', value: 410 },
|
|
||||||
{ date: '10-07', value: 365 },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|||||||
+178
-34
@@ -1,4 +1,10 @@
|
|||||||
import { dashboardSummary, menuRoutes, USER_STATUS, users, type MockUser } from './data'
|
import {
|
||||||
|
menuRoutes,
|
||||||
|
miniApps,
|
||||||
|
users,
|
||||||
|
USER_STATUS,
|
||||||
|
type MockMiniApp,
|
||||||
|
} from './data'
|
||||||
|
|
||||||
export interface MockContext {
|
export interface MockContext {
|
||||||
url: string
|
url: string
|
||||||
@@ -11,6 +17,8 @@ export interface MockRule {
|
|||||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE'
|
method: 'GET' | 'POST' | 'PUT' | 'DELETE'
|
||||||
pattern: RegExp
|
pattern: RegExp
|
||||||
handler: (ctx: MockContext) => unknown
|
handler: (ctx: MockContext) => unknown
|
||||||
|
/** 全局 mock 关闭时仍生效(用于单独 mock 新增接口) */
|
||||||
|
alwaysMock?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const toNumber = (value: unknown, fallback = 0) => {
|
const toNumber = (value: unknown, fallback = 0) => {
|
||||||
@@ -46,7 +54,16 @@ export const mockRules: MockRule[] = [
|
|||||||
nickname: '超级管理员',
|
nickname: '超级管理员',
|
||||||
avatar: '',
|
avatar: '',
|
||||||
roles: ['ADMIN'],
|
roles: ['ADMIN'],
|
||||||
permissions: ['sys:user:add', 'sys:user:edit', 'sys:user:delete', 'sys:user:query'],
|
permissions: [
|
||||||
|
'sys:user:add',
|
||||||
|
'sys:user:edit',
|
||||||
|
'sys:user:delete',
|
||||||
|
'sys:user:query',
|
||||||
|
'miniapp:add',
|
||||||
|
'miniapp:edit',
|
||||||
|
'miniapp:delete',
|
||||||
|
'miniapp:query',
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -56,74 +73,201 @@ export const mockRules: MockRule[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
pattern: /^\/api\/v1\/users\/page$/,
|
pattern: /^\/api\/v1\/api\/admin\/users$/,
|
||||||
handler: (ctx) => {
|
handler: (ctx) => {
|
||||||
const { username, status } = ctx.query
|
const page = toNumber(ctx.query.page, 1)
|
||||||
const pageNum = toNumber(ctx.query.pageNum, 1)
|
const size = toNumber(ctx.query.size, 20)
|
||||||
const pageSize = toNumber(ctx.query.pageSize, 10)
|
const miniId = ctx.query.mini_id ? toNumber(ctx.query.mini_id) : 0
|
||||||
|
const keyword = (ctx.query.keyword as string) || ''
|
||||||
|
|
||||||
const filtered = users.filter((item) => {
|
const filtered = users.filter((item) => {
|
||||||
const matchName = !username || item.username.includes(username)
|
if (miniId && item.mini_id !== miniId) return false
|
||||||
const matchStatus = !status || String(item.status) === status
|
if (keyword && !item.nickname.includes(keyword) && !item.openid.includes(keyword)) return false
|
||||||
return matchName && matchStatus
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
const start = (pageNum - 1) * pageSize
|
const start = (page - 1) * size
|
||||||
return { list: filtered.slice(start, start + pageSize), total: filtered.length }
|
return { list: filtered.slice(start, start + size), total: filtered.length }
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
pattern: /^\/api\/v1\/users\/(\d+)$/,
|
pattern: /^\/api\/v1\/api\/admin\/users\/(\d+)$/,
|
||||||
handler: (ctx) => users.find((item) => item.id === toNumber(ctx.params[0])) ?? null,
|
handler: (ctx) => users.find((item) => item.id === toNumber(ctx.params[0])) ?? null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'PUT',
|
||||||
pattern: /^\/api\/v1\/users$/,
|
pattern: /^\/api\/v1\/api\/admin\/users\/(\d+)$/,
|
||||||
handler: (ctx) => {
|
handler: (ctx) => {
|
||||||
const id = users.length + 1
|
const target = users.find((item) => item.id === toNumber(ctx.params[0]))
|
||||||
const created: MockUser = {
|
if (!target) return false
|
||||||
|
const body = ctx.body as Record<string, unknown>
|
||||||
|
if (typeof body.nickname === 'string') target.nickname = body.nickname
|
||||||
|
if (typeof body.avatar === 'string') target.avatar = body.avatar
|
||||||
|
if (typeof body.status !== 'undefined') target.status = toNumber(body.status) === 0 ? 0 : 1
|
||||||
|
target.updated_at = new Date().toISOString()
|
||||||
|
return target
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
pattern: /^\/api\/v1\/api\/admin\/users\/(\d+)\/status$/,
|
||||||
|
handler: (ctx) => {
|
||||||
|
const target = users.find((item) => item.id === toNumber(ctx.params[0]))
|
||||||
|
if (!target) return false
|
||||||
|
const body = ctx.body as { status?: number }
|
||||||
|
target.status = toNumber(body.status) === 0 ? 0 : 1
|
||||||
|
target.updated_at = new Date().toISOString()
|
||||||
|
return target
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
pattern: /^\/api\/v1\/api\/admin\/miniapps$/,
|
||||||
|
handler: (ctx) => {
|
||||||
|
const { status } = ctx.query
|
||||||
|
const pageNum = toNumber(ctx.query.pageNum, 1)
|
||||||
|
const pageSize = toNumber(ctx.query.pageSize, 10)
|
||||||
|
|
||||||
|
const filtered = miniApps.filter((item) => !status || String(item.status) === status)
|
||||||
|
const start = (pageNum - 1) * pageSize
|
||||||
|
const list = filtered.slice(start, start + pageSize).map((item) => {
|
||||||
|
const { app_secret: _app_secret, ...rest } = item
|
||||||
|
return rest
|
||||||
|
})
|
||||||
|
return { list, total: filtered.length }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
pattern: /^\/api\/v1\/api\/admin\/miniapps\/(\d+)$/,
|
||||||
|
handler: (ctx) => {
|
||||||
|
const target = miniApps.find((item) => item.id === toNumber(ctx.params[0]))
|
||||||
|
if (!target) return null
|
||||||
|
const { app_secret: _app_secret, ...rest } = target
|
||||||
|
return rest
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
pattern: /^\/api\/v1\/api\/admin\/miniapps$/,
|
||||||
|
handler: (ctx) => {
|
||||||
|
const id = miniApps.length + 1
|
||||||
|
const created: MockMiniApp = {
|
||||||
id,
|
id,
|
||||||
username: pickString(ctx.body.username, `user${id}`),
|
name: pickString(ctx.body.name),
|
||||||
nickname: pickString(ctx.body.nickname),
|
description: pickString(ctx.body.description),
|
||||||
mobile: pickString(ctx.body.mobile),
|
app_id: pickString(ctx.body.app_id),
|
||||||
email: pickString(ctx.body.email),
|
app_secret: pickString(ctx.body.app_secret),
|
||||||
status: toNumber(ctx.body.status, USER_STATUS.ENABLED) === 0 ? 0 : 1,
|
status: toNumber(ctx.body.status, USER_STATUS.ENABLED) === 0 ? 0 : 1,
|
||||||
deptName: pickString(ctx.body.deptName, '技术部'),
|
ext: pickString(ctx.body.ext, '{}'),
|
||||||
roleNames: Array.isArray(ctx.body.roleNames)
|
created_at: new Date().toISOString(),
|
||||||
? (ctx.body.roleNames as string[])
|
updated_at: new Date().toISOString(),
|
||||||
: ['普通用户'],
|
|
||||||
createTime: new Date().toISOString(),
|
|
||||||
}
|
}
|
||||||
users.unshift(created)
|
miniApps.unshift(created)
|
||||||
return id
|
return id
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
pattern: /^\/api\/v1\/users\/(\d+)$/,
|
pattern: /^\/api\/v1\/api\/admin\/miniapps\/(\d+)$/,
|
||||||
handler: (ctx) => {
|
handler: (ctx) => {
|
||||||
const target = users.find((item) => item.id === toNumber(ctx.params[0]))
|
const target = miniApps.find((item) => item.id === toNumber(ctx.params[0]))
|
||||||
if (!target) return false
|
if (!target) return false
|
||||||
Object.assign(target, ctx.body)
|
const body = ctx.body as Record<string, unknown>
|
||||||
|
if (typeof body.name === 'string') target.name = body.name
|
||||||
|
if (typeof body.description === 'string') target.description = body.description
|
||||||
|
if (typeof body.app_id === 'string') target.app_id = body.app_id
|
||||||
|
if (typeof body.app_secret === 'string' && body.app_secret) target.app_secret = body.app_secret
|
||||||
|
if (typeof body.status !== 'undefined') target.status = toNumber(body.status) === 0 ? 0 : 1
|
||||||
|
if (typeof body.ext === 'string') target.ext = body.ext
|
||||||
|
target.updated_at = new Date().toISOString()
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
pattern: /^\/api\/v1\/users\/([\d,]+)$/,
|
pattern: /^\/api\/v1\/api\/admin\/miniapps\/([\d,]+)$/,
|
||||||
handler: (ctx) => {
|
handler: (ctx) => {
|
||||||
const ids = ctx.params[0].split(',').map((id) => toNumber(id))
|
const ids = ctx.params[0].split(',').map((id) => toNumber(id))
|
||||||
ids.forEach((id) => {
|
ids.forEach((id) => {
|
||||||
const index = users.findIndex((item) => item.id === id)
|
const index = miniApps.findIndex((item) => item.id === id)
|
||||||
if (index > -1) users.splice(index, 1)
|
if (index > -1) miniApps.splice(index, 1)
|
||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
// 统计每个小程序的用户数(有真实用户的取真实值,其余返回稳定模拟值)
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
pattern: /^\/api\/v1\/dashboard\/summary$/,
|
pattern: /^\/api\/v1\/api\/admin\/dashboard\/user-count-by-miniapp$/,
|
||||||
handler: () => dashboardSummary,
|
alwaysMock: true,
|
||||||
|
handler: () =>
|
||||||
|
miniApps.map((app) => {
|
||||||
|
const real = users.filter((item) => item.mini_id === app.id).length
|
||||||
|
return {
|
||||||
|
miniAppId: app.id,
|
||||||
|
miniAppName: app.name,
|
||||||
|
userCount: real > 0 ? real : ((app.id * 37) % 200) + 8,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
pattern: /^\/api\/v1\/api\/admin\/monitor\/server-info$/,
|
||||||
|
alwaysMock: true,
|
||||||
|
handler: () => ({
|
||||||
|
hostname: 'portal-server-01',
|
||||||
|
os: 'Linux',
|
||||||
|
osVersion: 'Ubuntu 22.04.3 LTS',
|
||||||
|
arch: 'amd64',
|
||||||
|
kernel: '5.15.0-91-generic',
|
||||||
|
cpuModel: 'Intel(R) Xeon(R) Platinum 8369B CPU @ 2.70GHz',
|
||||||
|
cpuCores: 8,
|
||||||
|
runtimeVersion: 'go1.21.5',
|
||||||
|
uptime: 1234560,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 实时状态:每次请求生成浮动指标,模拟监控采集中
|
||||||
|
method: 'GET',
|
||||||
|
pattern: /^\/api\/v1\/api\/admin\/monitor\/server-status$/,
|
||||||
|
alwaysMock: true,
|
||||||
|
handler: () => {
|
||||||
|
const cpuUsage = Math.round((20 + Math.random() * 60) * 10) / 10
|
||||||
|
const memTotal = 16 * 1024 ** 3
|
||||||
|
const memUsed = Math.round(memTotal * (0.4 + Math.random() * 0.3))
|
||||||
|
const loadBase = (cpuUsage / 100) * 8
|
||||||
|
const gb = 1024 ** 3
|
||||||
|
const disks = [
|
||||||
|
{ mount: '/', filesystem: '/dev/vda1', total: 100 * gb, used: 62 * gb, free: 38 * gb },
|
||||||
|
{ mount: '/data', filesystem: '/dev/vdb1', total: 500 * gb, used: 311 * gb, free: 189 * gb },
|
||||||
|
{ mount: '/logs', filesystem: '/dev/vdc1', total: 50 * gb, used: 9 * gb, free: 41 * gb },
|
||||||
|
].map((disk) => ({
|
||||||
|
...disk,
|
||||||
|
usagePercent: Math.round((disk.used / disk.total) * 1000) / 10,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
cpu: {
|
||||||
|
usagePercent: cpuUsage,
|
||||||
|
cores: 8,
|
||||||
|
model: 'Intel(R) Xeon(R) Platinum 8369B CPU @ 2.70GHz',
|
||||||
|
loadAverage: [
|
||||||
|
Math.round((loadBase + Math.random()) * 100) / 100,
|
||||||
|
Math.round((loadBase + Math.random()) * 100) / 100,
|
||||||
|
Math.round((loadBase + Math.random()) * 100) / 100,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
memory: {
|
||||||
|
total: memTotal,
|
||||||
|
used: memUsed,
|
||||||
|
free: memTotal - memUsed,
|
||||||
|
usagePercent: Math.round((memUsed / memTotal) * 1000) / 10,
|
||||||
|
},
|
||||||
|
disks,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
+5
-4
@@ -12,15 +12,15 @@ function readBody(req: NodeJS.ReadableStream): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 本地 mock 中间件:仅在 dev 且 VITE_USE_MOCK=true 时拦截 /api/v1 请求。
|
* 本地 mock 中间件:始终挂载于 dev,但仅命中「全局 mock 开启」或标记 alwaysMock 的规则。
|
||||||
|
* 这样新增页面的接口可被单独 mock,而项目其余接口仍走真实后端代理。
|
||||||
* 不引入三方 mock 依赖,避免污染生产依赖树。
|
* 不引入三方 mock 依赖,避免污染生产依赖树。
|
||||||
*/
|
*/
|
||||||
export function mockPlugin(enabled = true): Plugin {
|
export function mockPlugin(globalMock = false): Plugin {
|
||||||
return {
|
return {
|
||||||
name: 'portal-mock',
|
name: 'portal-mock',
|
||||||
apply: 'serve',
|
apply: 'serve',
|
||||||
configureServer(server) {
|
configureServer(server) {
|
||||||
if (!enabled) return
|
|
||||||
server.middlewares.use((req, res, next) => {
|
server.middlewares.use((req, res, next) => {
|
||||||
if (!req.url?.startsWith(MOCK_PREFIX)) return next()
|
if (!req.url?.startsWith(MOCK_PREFIX)) return next()
|
||||||
|
|
||||||
@@ -30,7 +30,8 @@ export function mockPlugin(enabled = true): Plugin {
|
|||||||
const rule = mockRules.find(
|
const rule = mockRules.find(
|
||||||
(item) =>
|
(item) =>
|
||||||
item.method === (req.method ?? 'GET').toUpperCase() &&
|
item.method === (req.method ?? 'GET').toUpperCase() &&
|
||||||
item.pattern.test(requestUrl.pathname),
|
item.pattern.test(requestUrl.pathname) &&
|
||||||
|
(globalMock || item.alwaysMock === true),
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!rule) return next()
|
if (!rule) return next()
|
||||||
|
|||||||
+11
-9
@@ -1,27 +1,29 @@
|
|||||||
import request from '@/utils/request'
|
import request from '@/utils/request'
|
||||||
import type { LoginForm, LoginResult, UserInfo } from '@/types/auth'
|
import type { LoginForm, LoginResult, UserInfo } from '@/types/auth'
|
||||||
import type { RouteVO } from '@/types/router'
|
import type { RouteVO } from '@/types/router'
|
||||||
|
import { staticMenuRoutes } from '@/config/static-routes'
|
||||||
|
|
||||||
const BASE_URL = '/auth'
|
// 叠加在 VITE_APP_BASE_API(/api/v1) 之上,经 Vite proxy 剥离 /api/v1 后命中后端 /api/admin
|
||||||
|
const ADMIN_BASE = '/api/admin'
|
||||||
|
|
||||||
export const AuthAPI = {
|
export const AuthAPI = {
|
||||||
/** 登录 */
|
/** 登录,返回 JWT(对应后端 POST /api/admin/login) */
|
||||||
login(data: LoginForm) {
|
login(data: LoginForm) {
|
||||||
return request<LoginResult>({ url: `${BASE_URL}/login`, method: 'post', data })
|
return request<LoginResult>({ url: `${ADMIN_BASE}/login`, method: 'post', data })
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 登出 */
|
/** 后端未提供登出接口,仅前端清理登录态 */
|
||||||
logout() {
|
logout() {
|
||||||
return request<boolean>({ url: `${BASE_URL}/logout`, method: 'post' })
|
return Promise.resolve(true)
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 获取当前登录用户信息(含角色与权限) */
|
/** 当前登录管理员信息(对应后端 GET /api/admin/info) */
|
||||||
getMe() {
|
getMe() {
|
||||||
return request<UserInfo>({ url: `${BASE_URL}/me`, method: 'get' })
|
return request<UserInfo>({ url: `${ADMIN_BASE}/info`, method: 'get' })
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 获取当前用户可访问的菜单路由 */
|
/** 后端未提供菜单接口,返回前端静态路由(含小程序管理) */
|
||||||
getRoutes() {
|
getRoutes() {
|
||||||
return request<RouteVO[]>({ url: `${BASE_URL}/routes`, method: 'get' })
|
return Promise.resolve(staticMenuRoutes as RouteVO[])
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import request from '@/utils/request'
|
import request from '@/utils/request'
|
||||||
import type { DashboardSummary } from '@/types/dashboard'
|
import type { MiniAppUserCountVO } from '@/types/dashboard'
|
||||||
|
|
||||||
const BASE_URL = '/dashboard'
|
const BASE_URL = '/api/admin/dashboard'
|
||||||
|
|
||||||
export const DashboardAPI = {
|
export const DashboardAPI = {
|
||||||
/** 首页汇总数据 */
|
/** 统计每个小程序的用户数量 */
|
||||||
summary() {
|
statUserCountByMiniApp() {
|
||||||
return request<DashboardSummary>({ url: `${BASE_URL}/summary`, method: 'get' })
|
return request<MiniAppUserCountVO[]>({
|
||||||
|
url: `${BASE_URL}/user-count-by-miniapp`,
|
||||||
|
method: 'get',
|
||||||
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import request from '@/utils/request'
|
||||||
|
import type { PageResult } from '@/types/common'
|
||||||
|
import type { MiniAppForm, MiniAppQuery, MiniAppStatus, MiniAppVO } from '@/types/miniapp'
|
||||||
|
|
||||||
|
// 前端基础路径 VITE_APP_BASE_API 为 /api/v1,经 Vite proxy 整体剥离后转发到 Go 后端;
|
||||||
|
// 此处拼接 /api/admin/miniapps,剥离 /api/v1 后即可精确命中后端 /api/admin/miniapps
|
||||||
|
const BASE_URL = '/api/admin/miniapps'
|
||||||
|
|
||||||
|
export const MiniAppAPI = {
|
||||||
|
/** 分页查询小程序列表 */
|
||||||
|
query(params: MiniAppQuery) {
|
||||||
|
return request<PageResult<MiniAppVO>>({ url: BASE_URL, method: 'get', params })
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 小程序详情 */
|
||||||
|
detail(id: number) {
|
||||||
|
return request<MiniAppVO>({ url: `${BASE_URL}/${id}`, method: 'get' })
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 新增小程序 */
|
||||||
|
create(data: MiniAppForm) {
|
||||||
|
return request<number>({ url: BASE_URL, method: 'post', data })
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 修改小程序 */
|
||||||
|
update(id: number, data: MiniAppForm) {
|
||||||
|
return request<boolean>({ url: `${BASE_URL}/${id}`, method: 'put', data })
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 删除小程序,ids 为逗号分隔 */
|
||||||
|
delete(ids: string) {
|
||||||
|
return request<boolean>({ url: `${BASE_URL}/${ids}`, method: 'delete' })
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 启用 / 停用小程序 */
|
||||||
|
updateStatus(id: number, status: MiniAppStatus) {
|
||||||
|
return request<MiniAppVO>({ url: `${BASE_URL}/${id}/status`, method: 'put', data: { status } })
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import request from '@/utils/request'
|
||||||
|
import type { ServerInfoVO, ServerStatusVO } from '@/types/monitor'
|
||||||
|
|
||||||
|
const BASE_URL = '/api/admin/monitor'
|
||||||
|
|
||||||
|
export const MonitorAPI = {
|
||||||
|
/** 获取服务器基础信息 */
|
||||||
|
getServerInfo() {
|
||||||
|
return request<ServerInfoVO>({ url: `${BASE_URL}/server-info`, method: 'get' })
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 获取服务器实时状态(CPU / 内存 / 磁盘 / 负载) */
|
||||||
|
getServerStatus() {
|
||||||
|
return request<ServerStatusVO>({ url: `${BASE_URL}/server-status`, method: 'get' })
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import request from '@/utils/request'
|
||||||
|
import type { PageResult } from '@/types/common'
|
||||||
|
import type {
|
||||||
|
AdminForm,
|
||||||
|
AdminQuery,
|
||||||
|
AdminResetPwdForm,
|
||||||
|
AdminRoleForm,
|
||||||
|
AdminVO,
|
||||||
|
} from '@/types/system/rbac'
|
||||||
|
|
||||||
|
const BASE_URL = '/api/admin/rbac/admins'
|
||||||
|
|
||||||
|
export const AdminAPI = {
|
||||||
|
/** 分页查询管理员列表 */
|
||||||
|
query(params: AdminQuery) {
|
||||||
|
return request<PageResult<AdminVO>>({ url: BASE_URL, method: 'get', params })
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 管理员详情 */
|
||||||
|
detail(id: number) {
|
||||||
|
return request<AdminVO>({ url: `${BASE_URL}/${id}`, method: 'get' })
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 新增管理员 */
|
||||||
|
create(data: AdminForm) {
|
||||||
|
return request<AdminVO>({ url: BASE_URL, method: 'post', data })
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 编辑管理员昵称 / 状态 */
|
||||||
|
update(id: number, data: AdminForm) {
|
||||||
|
return request<AdminVO>({ url: `${BASE_URL}/${id}`, method: 'put', data })
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 删除管理员 */
|
||||||
|
delete(id: number) {
|
||||||
|
return request<boolean>({ url: `${BASE_URL}/${id}`, method: 'delete' })
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 分配角色(全量替换) */
|
||||||
|
assignRoles(id: number, data: AdminRoleForm) {
|
||||||
|
return request<boolean>({ url: `${BASE_URL}/${id}/roles`, method: 'post', data })
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 重置密码 */
|
||||||
|
resetPassword(id: number, data: AdminResetPwdForm) {
|
||||||
|
return request<boolean>({ url: `${BASE_URL}/${id}/reset-pwd`, method: 'post', data })
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import request from '@/utils/request'
|
||||||
|
import type { PageResult } from '@/types/common'
|
||||||
|
import type { PermissionForm, PermissionQuery, PermissionVO } from '@/types/system/rbac'
|
||||||
|
|
||||||
|
const BASE_URL = '/api/admin/rbac/permissions'
|
||||||
|
|
||||||
|
export const PermissionAPI = {
|
||||||
|
/** 分页查询权限列表 */
|
||||||
|
query(params: PermissionQuery) {
|
||||||
|
return request<PageResult<PermissionVO>>({ url: BASE_URL, method: 'get', params })
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 权限详情 */
|
||||||
|
detail(id: number) {
|
||||||
|
return request<PermissionVO>({ url: `${BASE_URL}/${id}`, method: 'get' })
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 新增权限 */
|
||||||
|
create(data: PermissionForm) {
|
||||||
|
return request<PermissionVO>({ url: BASE_URL, method: 'post', data })
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 编辑权限 */
|
||||||
|
update(id: number, data: PermissionForm) {
|
||||||
|
return request<PermissionVO>({ url: `${BASE_URL}/${id}`, method: 'put', data })
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 删除权限 */
|
||||||
|
delete(id: number) {
|
||||||
|
return request<boolean>({ url: `${BASE_URL}/${id}`, method: 'delete' })
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import request from '@/utils/request'
|
||||||
|
import type { PageResult } from '@/types/common'
|
||||||
|
import type {
|
||||||
|
RoleForm,
|
||||||
|
RolePermissionForm,
|
||||||
|
RoleQuery,
|
||||||
|
RoleVO,
|
||||||
|
} from '@/types/system/rbac'
|
||||||
|
|
||||||
|
const BASE_URL = '/api/admin/rbac/roles'
|
||||||
|
|
||||||
|
export const RoleAPI = {
|
||||||
|
/** 分页查询角色列表 */
|
||||||
|
query(params: RoleQuery) {
|
||||||
|
return request<PageResult<RoleVO>>({ url: BASE_URL, method: 'get', params })
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 角色详情 */
|
||||||
|
detail(id: number) {
|
||||||
|
return request<RoleVO>({ url: `${BASE_URL}/${id}`, method: 'get' })
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 新增角色 */
|
||||||
|
create(data: RoleForm) {
|
||||||
|
return request<RoleVO>({ url: BASE_URL, method: 'post', data })
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 编辑角色 */
|
||||||
|
update(id: number, data: RoleForm) {
|
||||||
|
return request<RoleVO>({ url: `${BASE_URL}/${id}`, method: 'put', data })
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 删除角色 */
|
||||||
|
delete(id: number) {
|
||||||
|
return request<boolean>({ url: `${BASE_URL}/${id}`, method: 'delete' })
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 分配权限(全量替换) */
|
||||||
|
assignPermissions(id: number, data: RolePermissionForm) {
|
||||||
|
return request<boolean>({ url: `${BASE_URL}/${id}/permissions`, method: 'post', data })
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -1,13 +1,23 @@
|
|||||||
import request from '@/utils/request'
|
import request from '@/utils/request'
|
||||||
import type { PageResult } from '@/types/common'
|
import type { PageResult } from '@/types/common'
|
||||||
import type { UserForm, UserQuery, UserVO } from '@/types/system/user'
|
import type { UserForm, UserQuery, UserStatus, UserVO } from '@/types/system/user'
|
||||||
|
|
||||||
const BASE_URL = '/users'
|
// 叠加在 VITE_APP_BASE_API(/api/v1) 上,经 Vite proxy 剥离 /api/v1 后命中后端 /api/admin/users
|
||||||
|
const BASE_URL = '/api/admin/users'
|
||||||
|
|
||||||
export const UserAPI = {
|
export const UserAPI = {
|
||||||
/** 分页查询用户列表 */
|
/** 分页查询小程序端用户列表(pageNum/pageSize 映射为后端 page/size) */
|
||||||
query(params: UserQuery) {
|
query(params: UserQuery) {
|
||||||
return request<PageResult<UserVO>>({ url: `${BASE_URL}/page`, method: 'get', params })
|
return request<PageResult<UserVO>>({
|
||||||
|
url: BASE_URL,
|
||||||
|
method: 'get',
|
||||||
|
params: {
|
||||||
|
mini_id: params.mini_id ? Number(params.mini_id) : undefined,
|
||||||
|
keyword: params.keyword,
|
||||||
|
page: params.pageNum,
|
||||||
|
size: params.pageSize,
|
||||||
|
},
|
||||||
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 用户详情 */
|
/** 用户详情 */
|
||||||
@@ -15,18 +25,13 @@ export const UserAPI = {
|
|||||||
return request<UserVO>({ url: `${BASE_URL}/${id}`, method: 'get' })
|
return request<UserVO>({ url: `${BASE_URL}/${id}`, method: 'get' })
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 新增用户 */
|
/** 编辑用户(昵称 / 头像 / 状态,按需提供) */
|
||||||
create(data: UserForm) {
|
|
||||||
return request<number>({ url: BASE_URL, method: 'post', data })
|
|
||||||
},
|
|
||||||
|
|
||||||
/** 修改用户 */
|
|
||||||
update(id: number, data: UserForm) {
|
update(id: number, data: UserForm) {
|
||||||
return request<boolean>({ url: `${BASE_URL}/${id}`, method: 'put', data })
|
return request<UserVO>({ url: `${BASE_URL}/${id}`, method: 'put', data })
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 删除用户,ids 为逗号分隔 */
|
/** 禁用 / 启用用户 */
|
||||||
delete(ids: string) {
|
updateStatus(id: number, status: UserStatus) {
|
||||||
return request<boolean>({ url: `${BASE_URL}/${ids}`, method: 'delete' })
|
return request<UserVO>({ url: `${BASE_URL}/${id}/status`, method: 'post', data: { status } })
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
interface BarItem {
|
||||||
|
/** 横轴标签 */
|
||||||
|
label: string
|
||||||
|
/** 数值 */
|
||||||
|
value: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
/** 柱状图数据 */
|
||||||
|
data: BarItem[]
|
||||||
|
/** 柱体颜色(支持 CSS 颜色与变量) */
|
||||||
|
color?: string
|
||||||
|
/** 画布高度 */
|
||||||
|
height?: string
|
||||||
|
/** 数值后缀,如 '%'、'人' */
|
||||||
|
valueUnit?: string
|
||||||
|
/** 固定最大值,不传则取数据最大值 */
|
||||||
|
max?: number
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
color: 'var(--el-color-primary)',
|
||||||
|
height: '320px',
|
||||||
|
valueUnit: '',
|
||||||
|
max: 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxValue = computed(() => {
|
||||||
|
if (props.max > 0) return props.max
|
||||||
|
return props.data.reduce((max, item) => Math.max(max, item.value), 0) || 1
|
||||||
|
})
|
||||||
|
|
||||||
|
function barHeight(value: number): string {
|
||||||
|
return `${(value / maxValue.value) * 100}%`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="bar-chart" :style="{ height }">
|
||||||
|
<div v-if="!data.length" class="bar-chart__empty">
|
||||||
|
<el-empty description="暂无数据" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="bar-chart__canvas">
|
||||||
|
<div v-for="item in data" :key="item.label" class="bar-chart__column">
|
||||||
|
<div class="bar-chart__value">{{ item.value }}{{ valueUnit }}</div>
|
||||||
|
<div class="bar-chart__track">
|
||||||
|
<div
|
||||||
|
class="bar-chart__bar"
|
||||||
|
:style="{ height: barHeight(item.value), background: color }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="bar-chart__label" :title="item.label">{{ item.label }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.bar-chart {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-chart__empty {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-chart__canvas {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 4px 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-chart__column {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-chart__value {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text-color-regular;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-chart__track {
|
||||||
|
flex: 1;
|
||||||
|
width: 60%;
|
||||||
|
max-width: 48px;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-chart__bar {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 6px 6px 0 0;
|
||||||
|
transition: height 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-chart__label {
|
||||||
|
margin-top: 8px;
|
||||||
|
max-width: 100%;
|
||||||
|
font-size: 13px;
|
||||||
|
color: $text-color-secondary;
|
||||||
|
text-align: center;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import type { RouteVO } from '@/types/router'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后端当前未提供菜单/路由接口,这里作为前端静态路由兜底。
|
||||||
|
* 含「小程序管理」菜单,保证关闭 mock 后页面仍可访问。
|
||||||
|
* 待后端提供 routes 接口后,应改回 AuthAPI.getRoutes() 动态返回。
|
||||||
|
*/
|
||||||
|
export const staticMenuRoutes: RouteVO[] = [
|
||||||
|
{
|
||||||
|
path: '/dashboard',
|
||||||
|
component: 'Layout',
|
||||||
|
redirect: '/dashboard/index',
|
||||||
|
meta: { title: '概览', icon: 'DataBoard', hidden: false },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: 'index',
|
||||||
|
component: 'dashboard/index',
|
||||||
|
name: 'Dashboard',
|
||||||
|
meta: { title: '概览', icon: 'DataBoard', hidden: false },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/system',
|
||||||
|
component: 'Layout',
|
||||||
|
redirect: '/system/user',
|
||||||
|
meta: { title: '系统管理', icon: 'Setting', hidden: false },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: 'user',
|
||||||
|
component: 'system/user/index',
|
||||||
|
name: 'User',
|
||||||
|
meta: { title: '用户管理', icon: 'User', hidden: false },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/personnel',
|
||||||
|
component: 'Layout',
|
||||||
|
redirect: '/personnel/admin',
|
||||||
|
meta: { title: '人员管理', icon: 'UserFilled', hidden: false },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: 'admin',
|
||||||
|
component: 'system/admin/index',
|
||||||
|
name: 'AdminList',
|
||||||
|
meta: { title: '管理员管理', icon: 'User', hidden: false },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/rbac',
|
||||||
|
component: 'Layout',
|
||||||
|
redirect: '/rbac/role',
|
||||||
|
meta: { title: '权限管理', icon: 'Lock', hidden: false },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: 'role',
|
||||||
|
component: 'system/role/index',
|
||||||
|
name: 'RoleList',
|
||||||
|
meta: { title: '角色管理', icon: 'Avatar', hidden: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'permission',
|
||||||
|
component: 'system/permission/index',
|
||||||
|
name: 'PermissionList',
|
||||||
|
meta: { title: '权限管理', icon: 'Key', hidden: false },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/miniapp',
|
||||||
|
component: 'Layout',
|
||||||
|
redirect: '/miniapp/list',
|
||||||
|
meta: { title: '小程序管理', icon: 'Menu', hidden: false },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: 'list',
|
||||||
|
component: 'miniapp/index',
|
||||||
|
name: 'Miniapp',
|
||||||
|
meta: { title: '小程序管理', icon: 'Menu', hidden: false },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { Option } from '@/types/common'
|
||||||
|
import type { MiniAppStatus } from '@/types/miniapp'
|
||||||
|
|
||||||
|
/** 小程序状态枚举 */
|
||||||
|
export const MiniAppStatusEnum = {
|
||||||
|
ENABLED: 1,
|
||||||
|
DISABLED: 0,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/** 小程序状态下拉选项 */
|
||||||
|
export const MINI_APP_STATUS_OPTIONS: Option<MiniAppStatus>[] = [
|
||||||
|
{ label: '启用', value: MiniAppStatusEnum.ENABLED },
|
||||||
|
{ label: '停用', value: MiniAppStatusEnum.DISABLED },
|
||||||
|
]
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { Option } from '@/types/common'
|
||||||
|
import type { PermissionType, RbacStatus } from '@/types/system/rbac'
|
||||||
|
|
||||||
|
/** 启用 / 禁用状态(管理员与角色共用) */
|
||||||
|
export const RbacStatusEnum = {
|
||||||
|
DISABLED: 0,
|
||||||
|
ENABLED: 1,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/** 状态下拉选项 */
|
||||||
|
export const RBAC_STATUS_OPTIONS: Option<RbacStatus>[] = [
|
||||||
|
{ label: '启用', value: RbacStatusEnum.ENABLED },
|
||||||
|
{ label: '禁用', value: RbacStatusEnum.DISABLED },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 权限类型 */
|
||||||
|
export const PermissionTypeEnum = {
|
||||||
|
API: 'api',
|
||||||
|
MENU: 'menu',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/** 权限类型下拉选项 */
|
||||||
|
export const PERMISSION_TYPE_OPTIONS: Option<PermissionType>[] = [
|
||||||
|
{ label: '接口', value: PermissionTypeEnum.API },
|
||||||
|
{ label: '菜单', value: PermissionTypeEnum.MENU },
|
||||||
|
]
|
||||||
+17
-9
@@ -4,6 +4,19 @@ import { usePermissionStore, useUserStore } from '@/store'
|
|||||||
|
|
||||||
const DEFAULT_TITLE = import.meta.env.VITE_APP_TITLE || 'Portal 管理后台'
|
const DEFAULT_TITLE = import.meta.env.VITE_APP_TITLE || 'Portal 管理后台'
|
||||||
|
|
||||||
|
/** 取动态路由中的第一个叶子页面作为首页重定向目标 */
|
||||||
|
function findFirstRoutePath(routes: RouteRecordRaw[]): string | undefined {
|
||||||
|
for (const route of routes) {
|
||||||
|
if (route.children?.length) {
|
||||||
|
const childPath = findFirstRoutePath(route.children)
|
||||||
|
if (childPath) return childPath
|
||||||
|
} else if (route.path && route.path !== '/') {
|
||||||
|
return route.path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
/** 静态路由:登录页与首页骨架,不依赖后端返回 */
|
/** 静态路由:登录页与首页骨架,不依赖后端返回 */
|
||||||
export const constantRoutes: RouteRecordRaw[] = [
|
export const constantRoutes: RouteRecordRaw[] = [
|
||||||
{
|
{
|
||||||
@@ -15,15 +28,10 @@ export const constantRoutes: RouteRecordRaw[] = [
|
|||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
component: () => import('@/layouts/index.vue'),
|
component: () => import('@/layouts/index.vue'),
|
||||||
redirect: '/dashboard',
|
redirect: () => {
|
||||||
children: [
|
const permissionStore = usePermissionStore()
|
||||||
{
|
return findFirstRoutePath(permissionStore.routes) ?? '/login'
|
||||||
path: 'dashboard',
|
},
|
||||||
name: 'Dashboard',
|
|
||||||
component: () => import('@/views/dashboard/index.vue'),
|
|
||||||
meta: { title: '仪表盘', icon: 'Odometer' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,21 @@ import { AuthAPI } from '@/api/auth'
|
|||||||
import type { LoginForm, UserInfo } from '@/types/auth'
|
import type { LoginForm, UserInfo } from '@/types/auth'
|
||||||
import { getToken, removeToken, setToken } from '@/utils/storage'
|
import { getToken, removeToken, setToken } from '@/utils/storage'
|
||||||
|
|
||||||
|
/** 后端未提供角色/权限接口时的兜底权限,保证按钮级权限(含小程序管理)可用 */
|
||||||
|
const DEFAULT_PERMISSIONS = [
|
||||||
|
'sys:user:add',
|
||||||
|
'sys:user:edit',
|
||||||
|
'sys:user:delete',
|
||||||
|
'sys:user:query',
|
||||||
|
'admin:manage',
|
||||||
|
'role:manage',
|
||||||
|
'perm:manage',
|
||||||
|
'miniapp:add',
|
||||||
|
'miniapp:edit',
|
||||||
|
'miniapp:delete',
|
||||||
|
'miniapp:query',
|
||||||
|
]
|
||||||
|
|
||||||
export const useUserStore = defineStore('user', () => {
|
export const useUserStore = defineStore('user', () => {
|
||||||
/** Token 初始从本地恢复,避免刷新后丢失登录态 */
|
/** Token 初始从本地恢复,避免刷新后丢失登录态 */
|
||||||
const token = ref(getToken())
|
const token = ref(getToken())
|
||||||
@@ -15,17 +30,37 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
/** 登录 */
|
/** 登录 */
|
||||||
async function login(data: LoginForm) {
|
async function login(data: LoginForm) {
|
||||||
const result = await AuthAPI.login(data)
|
const result = await AuthAPI.login(data)
|
||||||
token.value = result.accessToken
|
token.value = result.token
|
||||||
setToken(result.accessToken)
|
setToken(result.token)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 拉取用户信息(角色 + 权限码) */
|
/** 拉取用户信息(角色 + 权限码) */
|
||||||
async function getUserInfo() {
|
async function getUserInfo() {
|
||||||
const data = await AuthAPI.getMe()
|
try {
|
||||||
userInfo.value = data
|
const data = await AuthAPI.getMe()
|
||||||
roles.value = data.roles ?? []
|
const raw = data as UserInfo & { admin_id?: string }
|
||||||
permissions.value = data.permissions ?? []
|
userInfo.value = {
|
||||||
return data
|
userId: Number(raw.admin_id ?? raw.userId ?? 1),
|
||||||
|
username: raw.username || 'admin',
|
||||||
|
nickname: raw.nickname || '管理员',
|
||||||
|
avatar: raw.avatar || '',
|
||||||
|
roles: raw.roles?.length ? raw.roles : ['ADMIN'],
|
||||||
|
permissions: raw.permissions?.length ? raw.permissions : DEFAULT_PERMISSIONS,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 后端未返回用户信息时兜底,确保页面与按钮权限可用
|
||||||
|
userInfo.value = {
|
||||||
|
userId: 1,
|
||||||
|
username: 'admin',
|
||||||
|
nickname: '管理员',
|
||||||
|
avatar: '',
|
||||||
|
roles: ['ADMIN'],
|
||||||
|
permissions: DEFAULT_PERMISSIONS,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
roles.value = userInfo.value.roles
|
||||||
|
permissions.value = userInfo.value.permissions
|
||||||
|
return userInfo.value
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 登出 */
|
/** 登出 */
|
||||||
|
|||||||
+2
-5
@@ -8,12 +8,9 @@ export interface LoginForm {
|
|||||||
captchaCode?: string
|
captchaCode?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 登录响应 */
|
/** 登录响应(对应后端 data.token) */
|
||||||
export interface LoginResult {
|
export interface LoginResult {
|
||||||
accessToken: string
|
token: string
|
||||||
refreshToken?: string
|
|
||||||
tokenType: string
|
|
||||||
expiresIn?: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 当前登录用户 */
|
/** 当前登录用户 */
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ export interface PageQuery {
|
|||||||
pageSize: number
|
pageSize: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** RBAC 分页参数(后端使用 page / size) */
|
||||||
|
export interface RbacPageQuery {
|
||||||
|
page: number
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
|
||||||
/** 分页响应结构 */
|
/** 分页响应结构 */
|
||||||
export interface PageResult<T> {
|
export interface PageResult<T> {
|
||||||
list: T[]
|
list: T[]
|
||||||
|
|||||||
+8
-12
@@ -1,13 +1,9 @@
|
|||||||
/** 仪表盘汇总数据 */
|
/** 每个小程序对应的用户数统计项 */
|
||||||
export interface DashboardSummary {
|
export interface MiniAppUserCountVO {
|
||||||
userTotal: number
|
/** 小程序 ID */
|
||||||
orderTotal: number
|
miniAppId: number
|
||||||
gmv: number
|
/** 小程序名称 */
|
||||||
pendingApproval: number
|
miniAppName: string
|
||||||
trend: TrendPoint[]
|
/** 用户数量 */
|
||||||
}
|
userCount: number
|
||||||
|
|
||||||
export interface TrendPoint {
|
|
||||||
date: string
|
|
||||||
value: number
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { PageQuery } from '@/types/common'
|
||||||
|
|
||||||
|
/** 小程序状态:1 启用 / 0 停用 */
|
||||||
|
export type MiniAppStatus = 0 | 1
|
||||||
|
|
||||||
|
/** 小程序列表查询参数 */
|
||||||
|
export interface MiniAppQuery extends PageQuery {
|
||||||
|
/** 按状态筛选,不传表示全部 */
|
||||||
|
status?: MiniAppStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 被纳管小程序实体(后端返回,app_secret 不返回) */
|
||||||
|
export interface MiniAppVO {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
app_id: string
|
||||||
|
status: MiniAppStatus
|
||||||
|
ext: string
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新增/编辑小程序表单 */
|
||||||
|
export interface MiniAppForm {
|
||||||
|
id?: number
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
app_id: string
|
||||||
|
/** 创建时必填;编辑时留空表示不修改 */
|
||||||
|
app_secret?: string
|
||||||
|
status: MiniAppStatus
|
||||||
|
/** 扩展字段,需为合法 JSON 字符串 */
|
||||||
|
ext?: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/** 服务器基础信息 */
|
||||||
|
export interface ServerInfoVO {
|
||||||
|
/** 主机名 */
|
||||||
|
hostname: string
|
||||||
|
/** 操作系统 */
|
||||||
|
os: string
|
||||||
|
/** 操作系统版本 */
|
||||||
|
osVersion: string
|
||||||
|
/** 系统架构 */
|
||||||
|
arch: string
|
||||||
|
/** 内核版本 */
|
||||||
|
kernel: string
|
||||||
|
/** CPU 型号 */
|
||||||
|
cpuModel: string
|
||||||
|
/** CPU 核心数 */
|
||||||
|
cpuCores: number
|
||||||
|
/** 运行时(Go / Node 等)版本 */
|
||||||
|
runtimeVersion: string
|
||||||
|
/** 已运行时长,单位秒 */
|
||||||
|
uptime: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CPU 实时状态 */
|
||||||
|
export interface CpuVO {
|
||||||
|
/** 使用率百分比 0-100 */
|
||||||
|
usagePercent: number
|
||||||
|
/** 核心数 */
|
||||||
|
cores: number
|
||||||
|
/** CPU 型号 */
|
||||||
|
model: string
|
||||||
|
/** 1 / 5 / 15 分钟平均负载 */
|
||||||
|
loadAverage: [number, number, number]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 内存状态(字节) */
|
||||||
|
export interface MemoryVO {
|
||||||
|
/** 总内存(字节) */
|
||||||
|
total: number
|
||||||
|
/** 已用内存(字节) */
|
||||||
|
used: number
|
||||||
|
/** 空闲内存(字节) */
|
||||||
|
free: number
|
||||||
|
/** 使用率百分比 0-100 */
|
||||||
|
usagePercent: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 磁盘分区状态(字节) */
|
||||||
|
export interface DiskVO {
|
||||||
|
/** 挂载点 */
|
||||||
|
mount: string
|
||||||
|
/** 文件系统 */
|
||||||
|
filesystem: string
|
||||||
|
/** 总容量(字节) */
|
||||||
|
total: number
|
||||||
|
/** 已用容量(字节) */
|
||||||
|
used: number
|
||||||
|
/** 空闲容量(字节) */
|
||||||
|
free: number
|
||||||
|
/** 使用率百分比 0-100 */
|
||||||
|
usagePercent: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 服务器实时状态聚合 */
|
||||||
|
export interface ServerStatusVO {
|
||||||
|
cpu: CpuVO
|
||||||
|
memory: MemoryVO
|
||||||
|
disks: DiskVO[]
|
||||||
|
/** 采集时间戳(毫秒) */
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import type { RbacPageQuery } from '@/types/common'
|
||||||
|
|
||||||
|
/** 启用(1) / 禁用(0) */
|
||||||
|
export type RbacStatus = 0 | 1
|
||||||
|
|
||||||
|
/** 权限类型:api 接口权限 / menu 菜单权限 */
|
||||||
|
export type PermissionType = 'api' | 'menu'
|
||||||
|
|
||||||
|
/* ----------------------------- 管理员 ----------------------------- */
|
||||||
|
|
||||||
|
/** 管理员列表查询参数 */
|
||||||
|
export interface AdminQuery extends RbacPageQuery {
|
||||||
|
keyword?: string
|
||||||
|
/** -1 全部,0 禁用,1 启用 */
|
||||||
|
status?: RbacStatus | -1
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 管理员实体(后端返回,password_hash 不返回) */
|
||||||
|
export interface AdminVO {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
nickname: string
|
||||||
|
status: RbacStatus
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新增/编辑管理员表单 */
|
||||||
|
export interface AdminForm {
|
||||||
|
id?: number
|
||||||
|
/** 新增时必填,编辑时不允许修改 */
|
||||||
|
username?: string
|
||||||
|
nickname?: string
|
||||||
|
/** 仅新增时必填 */
|
||||||
|
password?: string
|
||||||
|
status: RbacStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分配角色(全量替换) */
|
||||||
|
export interface AdminRoleForm {
|
||||||
|
role_ids: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重置密码 */
|
||||||
|
export interface AdminResetPwdForm {
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------ 角色 ------------------------------ */
|
||||||
|
|
||||||
|
/** 角色列表查询参数 */
|
||||||
|
export interface RoleQuery extends RbacPageQuery {
|
||||||
|
keyword?: string
|
||||||
|
status?: RbacStatus | -1
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 角色实体 */
|
||||||
|
export interface RoleVO {
|
||||||
|
id: number
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
status: RbacStatus
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新增/编辑角色表单 */
|
||||||
|
export interface RoleForm {
|
||||||
|
id?: number
|
||||||
|
code?: string
|
||||||
|
name?: string
|
||||||
|
description?: string
|
||||||
|
status?: RbacStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分配权限(全量替换) */
|
||||||
|
export interface RolePermissionForm {
|
||||||
|
permission_ids: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------- 权限 ----------------------------- */
|
||||||
|
|
||||||
|
/** 权限列表查询参数 */
|
||||||
|
export interface PermissionQuery extends RbacPageQuery {
|
||||||
|
/** 为空表示全部类型 */
|
||||||
|
type?: PermissionType | ''
|
||||||
|
keyword?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 权限实体 */
|
||||||
|
export interface PermissionVO {
|
||||||
|
id: number
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
type: PermissionType
|
||||||
|
method: string
|
||||||
|
path: string
|
||||||
|
parent_id: number
|
||||||
|
sort: number
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新增/编辑权限表单 */
|
||||||
|
export interface PermissionForm {
|
||||||
|
id?: number
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
type: PermissionType
|
||||||
|
method?: string
|
||||||
|
path?: string
|
||||||
|
parent_id?: number
|
||||||
|
sort?: number
|
||||||
|
}
|
||||||
+16
-18
@@ -1,35 +1,33 @@
|
|||||||
import type { PageQuery } from '@/types/common'
|
import type { PageQuery } from '@/types/common'
|
||||||
|
|
||||||
/** 用户状态:1 启用 / 0 禁用 */
|
/** 用户状态:1 正常 / 0 禁用 */
|
||||||
export type UserStatus = 0 | 1
|
export type UserStatus = 0 | 1
|
||||||
|
|
||||||
/** 用户列表查询参数 */
|
/** 小程序端用户列表查询参数 */
|
||||||
export interface UserQuery extends PageQuery {
|
export interface UserQuery extends PageQuery {
|
||||||
username?: string
|
/** 按所属小程序筛选,0 或不传表示全部 */
|
||||||
status?: UserStatus
|
mini_id?: number
|
||||||
deptId?: number
|
/** 模糊匹配昵称 / OpenID */
|
||||||
|
keyword?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 用户列表实体(后端返回) */
|
/** 小程序端用户实体(后端返回) */
|
||||||
export interface UserVO {
|
export interface UserVO {
|
||||||
id: number
|
id: number
|
||||||
username: string
|
mini_id: number
|
||||||
|
openid: string
|
||||||
|
union_id: string
|
||||||
nickname: string
|
nickname: string
|
||||||
mobile: string
|
avatar: string
|
||||||
email: string
|
|
||||||
status: UserStatus
|
status: UserStatus
|
||||||
deptName: string
|
created_at: string
|
||||||
roleNames: string[]
|
updated_at: string
|
||||||
createTime: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 新增/编辑表单 */
|
/** 编辑用户表单(仅昵称 / 头像 / 状态可改) */
|
||||||
export interface UserForm {
|
export interface UserForm {
|
||||||
id?: number
|
id?: number
|
||||||
username: string
|
nickname?: string
|
||||||
nickname: string
|
avatar?: string
|
||||||
mobile: string
|
|
||||||
email: string
|
|
||||||
status: UserStatus
|
status: UserStatus
|
||||||
roleNames: string[]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,7 +88,9 @@ export default function request<T>(config: AxiosRequestConfig): Promise<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { code, data, msg } = response.data
|
const { code, data, msg } = response.data
|
||||||
if (code === SUCCESS_CODE) {
|
// 兼容本项目 mock(code: '00000')与 Go 后端(code: 0)
|
||||||
|
const normalizedCode = String(code)
|
||||||
|
if (normalizedCode === SUCCESS_CODE || normalizedCode === '0') {
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,358 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
import { Monitor, Refresh } from '@element-plus/icons-vue'
|
||||||
|
import { MonitorAPI } from '@/api/monitor'
|
||||||
|
import type { ServerInfoVO, ServerStatusVO } from '@/types/monitor'
|
||||||
|
|
||||||
|
defineOptions({ name: 'ServerMonitorPanel' })
|
||||||
|
|
||||||
|
const REFRESH_INTERVAL = 5000
|
||||||
|
|
||||||
|
const infoLoading = ref(false)
|
||||||
|
const statusLoading = ref(false)
|
||||||
|
const serverInfo = ref<ServerInfoVO>()
|
||||||
|
const serverStatus = ref<ServerStatusVO>()
|
||||||
|
const autoRefresh = ref(false)
|
||||||
|
let timer: number | undefined
|
||||||
|
|
||||||
|
const uptimeText = computed(() => formatUptime(serverInfo.value?.uptime ?? 0))
|
||||||
|
const updatedAt = computed(() =>
|
||||||
|
serverStatus.value ? new Date(serverStatus.value.timestamp).toLocaleTimeString() : '—',
|
||||||
|
)
|
||||||
|
|
||||||
|
async function fetchInfo() {
|
||||||
|
infoLoading.value = true
|
||||||
|
try {
|
||||||
|
serverInfo.value = await MonitorAPI.getServerInfo()
|
||||||
|
} finally {
|
||||||
|
infoLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchStatus() {
|
||||||
|
statusLoading.value = true
|
||||||
|
try {
|
||||||
|
serverStatus.value = await MonitorAPI.getServerStatus()
|
||||||
|
} finally {
|
||||||
|
statusLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshAll() {
|
||||||
|
fetchInfo()
|
||||||
|
fetchStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAutoRefreshChange(value: string | number | boolean) {
|
||||||
|
if (value) {
|
||||||
|
timer = window.setInterval(fetchStatus, REFRESH_INTERVAL)
|
||||||
|
} else if (timer) {
|
||||||
|
clearInterval(timer)
|
||||||
|
timer = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function usageColor(percent: number): string {
|
||||||
|
if (percent >= 85) return 'var(--el-color-danger)'
|
||||||
|
if (percent >= 60) return 'var(--el-color-warning)'
|
||||||
|
return 'var(--el-color-success)'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUptime(seconds: number): string {
|
||||||
|
const days = Math.floor(seconds / 86400)
|
||||||
|
const hours = Math.floor((seconds % 86400) / 3600)
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60)
|
||||||
|
return `${days} 天 ${hours} 小时 ${minutes} 分`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (!bytes) return '0 B'
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||||
|
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
|
||||||
|
return `${(bytes / 1024 ** exponent).toFixed(2)} ${units[exponent]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(refreshAll)
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (timer) clearInterval(timer)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="monitor-panel">
|
||||||
|
<!-- 操作栏 -->
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="toolbar__meta">
|
||||||
|
<el-icon><Monitor /></el-icon>
|
||||||
|
<span>服务监控</span>
|
||||||
|
<span class="toolbar__time">更新于 {{ updatedAt }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar__actions">
|
||||||
|
<el-switch
|
||||||
|
v-model="autoRefresh"
|
||||||
|
active-text="自动刷新"
|
||||||
|
inline-prompt
|
||||||
|
@change="handleAutoRefreshChange"
|
||||||
|
/>
|
||||||
|
<el-button :icon="Refresh" :loading="statusLoading" @click="refreshAll">刷新</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 核心指标卡片 -->
|
||||||
|
<el-row :gutter="16" class="metric-row">
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-card v-loading="statusLoading" shadow="hover" class="metric-card">
|
||||||
|
<el-progress
|
||||||
|
type="dashboard"
|
||||||
|
:percentage="serverStatus?.cpu.usagePercent ?? 0"
|
||||||
|
:color="usageColor(serverStatus?.cpu.usagePercent ?? 0)"
|
||||||
|
/>
|
||||||
|
<div class="metric-card__title">CPU 使用率</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-card v-loading="statusLoading" shadow="hover" class="metric-card">
|
||||||
|
<el-progress
|
||||||
|
type="dashboard"
|
||||||
|
:percentage="serverStatus?.memory.usagePercent ?? 0"
|
||||||
|
:color="usageColor(serverStatus?.memory.usagePercent ?? 0)"
|
||||||
|
/>
|
||||||
|
<div class="metric-card__title">内存使用率</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-card v-loading="statusLoading" shadow="hover" class="metric-card">
|
||||||
|
<div class="metric-card__big">
|
||||||
|
{{ serverStatus ? serverStatus.cpu.loadAverage[0].toFixed(2) : '—' }}
|
||||||
|
</div>
|
||||||
|
<div class="metric-card__title">1 分钟平均负载</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-card v-loading="infoLoading" shadow="hover" class="metric-card">
|
||||||
|
<div class="metric-card__big">{{ uptimeText }}</div>
|
||||||
|
<div class="metric-card__title">已运行时长</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<!-- 系统信息 -->
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-card v-loading="infoLoading" shadow="never" class="detail-card">
|
||||||
|
<template #header>
|
||||||
|
<span class="detail-card__title">系统信息</span>
|
||||||
|
</template>
|
||||||
|
<el-descriptions :column="1" border>
|
||||||
|
<el-descriptions-item label="主机名">{{ serverInfo?.hostname ?? '—' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="操作系统">
|
||||||
|
{{ serverInfo?.os }} {{ serverInfo?.osVersion }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="内核">{{ serverInfo?.kernel ?? '—' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="架构">{{ serverInfo?.arch ?? '—' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="CPU">{{ serverInfo?.cpuModel ?? '—' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="CPU 核心数">
|
||||||
|
{{ serverInfo?.cpuCores ?? '—' }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="运行时版本">
|
||||||
|
{{ serverInfo?.runtimeVersion ?? '—' }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
|
||||||
|
<!-- 资源详情 -->
|
||||||
|
<el-col :span="16">
|
||||||
|
<el-card v-loading="statusLoading" shadow="never" class="detail-card">
|
||||||
|
<template #header>
|
||||||
|
<span class="detail-card__title">资源使用</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="resource">
|
||||||
|
<div class="resource__item">
|
||||||
|
<div class="resource__head">
|
||||||
|
<span>CPU</span>
|
||||||
|
<span>{{ serverStatus?.cpu.usagePercent ?? 0 }}%</span>
|
||||||
|
</div>
|
||||||
|
<el-progress
|
||||||
|
:percentage="serverStatus?.cpu.usagePercent ?? 0"
|
||||||
|
:color="usageColor(serverStatus?.cpu.usagePercent ?? 0)"
|
||||||
|
:show-text="false"
|
||||||
|
/>
|
||||||
|
<div class="resource__sub">
|
||||||
|
负载(1/5/15 分钟):
|
||||||
|
<template v-if="serverStatus">
|
||||||
|
{{ serverStatus.cpu.loadAverage.map((v) => v.toFixed(2)).join(' / ') }}
|
||||||
|
</template>
|
||||||
|
<template v-else>—</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="resource__item">
|
||||||
|
<div class="resource__head">
|
||||||
|
<span>内存</span>
|
||||||
|
<span>{{ serverStatus?.memory.usagePercent ?? 0 }}%</span>
|
||||||
|
</div>
|
||||||
|
<el-progress
|
||||||
|
:percentage="serverStatus?.memory.usagePercent ?? 0"
|
||||||
|
:color="usageColor(serverStatus?.memory.usagePercent ?? 0)"
|
||||||
|
:show-text="false"
|
||||||
|
/>
|
||||||
|
<div class="resource__sub">
|
||||||
|
<template v-if="serverStatus">
|
||||||
|
已用 {{ formatBytes(serverStatus.memory.used) }} / 共
|
||||||
|
{{ formatBytes(serverStatus.memory.total) }}
|
||||||
|
</template>
|
||||||
|
<template v-else>—</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<!-- 磁盘分区 -->
|
||||||
|
<el-card v-loading="statusLoading" shadow="never" class="detail-card disk-card">
|
||||||
|
<template #header>
|
||||||
|
<span class="detail-card__title">磁盘分区</span>
|
||||||
|
</template>
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<el-col v-for="disk in serverStatus?.disks ?? []" :key="disk.mount" :span="8">
|
||||||
|
<div class="disk">
|
||||||
|
<div class="disk__head">
|
||||||
|
<span class="disk__mount">{{ disk.mount }}</span>
|
||||||
|
<el-tag size="small" effect="plain">{{ disk.filesystem }}</el-tag>
|
||||||
|
</div>
|
||||||
|
<el-progress
|
||||||
|
:percentage="disk.usagePercent"
|
||||||
|
:color="usageColor(disk.usagePercent)"
|
||||||
|
:show-text="false"
|
||||||
|
/>
|
||||||
|
<div class="disk__sub">
|
||||||
|
已用 {{ formatBytes(disk.used) }} / 共 {{ formatBytes(disk.total) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
<el-col v-if="!serverStatus?.disks?.length" :span="24">
|
||||||
|
<el-empty description="暂无磁盘数据" />
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar__meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text-color-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar__time {
|
||||||
|
margin-left: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: $text-color-secondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-row {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card {
|
||||||
|
border: none;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
:deep(.el-progress) {
|
||||||
|
margin: 8px auto 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card__big {
|
||||||
|
padding: 18px 0 12px;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $text-color-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card__title {
|
||||||
|
font-size: 13px;
|
||||||
|
color: $text-color-secondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-card {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-card__title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text-color-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text-color-regular;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource__sub {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: $text-color-secondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disk-card {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disk {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 1px solid $border-color-light;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disk__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disk__mount {
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text-color-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disk__sub {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: $text-color-secondary;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,129 +1,137 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { DataBoard, Menu, Refresh, Trophy, User } from '@element-plus/icons-vue'
|
||||||
import { DashboardAPI } from '@/api/dashboard'
|
import { DashboardAPI } from '@/api/dashboard'
|
||||||
import type { DashboardSummary } from '@/types/dashboard'
|
import type { MiniAppUserCountVO } from '@/types/dashboard'
|
||||||
|
import ServerMonitorPanel from './components/ServerMonitorPanel.vue'
|
||||||
|
|
||||||
defineOptions({ name: 'Dashboard' })
|
defineOptions({ name: 'Dashboard' })
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const summary = ref<DashboardSummary>()
|
const list = ref<MiniAppUserCountVO[]>([])
|
||||||
|
|
||||||
const cards = computed(() => [
|
const totalUsers = computed(() => list.value.reduce((sum, item) => sum + item.userCount, 0))
|
||||||
{ label: '用户总数', value: summary.value?.userTotal ?? 0, icon: 'User', color: '#409eff' },
|
const miniAppCount = computed(() => list.value.length)
|
||||||
{ label: '订单总数', value: summary.value?.orderTotal ?? 0, icon: 'Tickets', color: '#67c23a' },
|
const topMiniApp = computed(() =>
|
||||||
{ label: '交易额', value: summary.value?.gmv ?? 0, icon: 'Money', color: '#e6a23c' },
|
[...list.value].sort((a, b) => b.userCount - a.userCount)[0] ?? null,
|
||||||
{
|
|
||||||
label: '待审批',
|
|
||||||
value: summary.value?.pendingApproval ?? 0,
|
|
||||||
icon: 'Clock',
|
|
||||||
color: '#f56c6c',
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
/** 趋势柱状图最大高度基准 */
|
|
||||||
const maxTrendValue = computed(() =>
|
|
||||||
Math.max(1, ...(summary.value?.trend ?? []).map((item) => item.value)),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async function loadSummary() {
|
const chartData = computed(() =>
|
||||||
|
list.value.map((item) => ({ label: item.miniAppName, value: item.userCount })),
|
||||||
|
)
|
||||||
|
|
||||||
|
async function fetchData() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
summary.value = await DashboardAPI.summary()
|
list.value = await DashboardAPI.statUserCountByMiniApp()
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(loadSummary)
|
onMounted(fetchData)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-loading="loading" class="app-container dashboard">
|
<div class="app-container">
|
||||||
<el-row :gutter="16">
|
<!-- 概览卡片 -->
|
||||||
<el-col v-for="card in cards" :key="card.label" :xs="24" :sm="12" :lg="6">
|
<el-row :gutter="16" class="overview">
|
||||||
<el-card class="dashboard__card" shadow="never">
|
<el-col :span="8">
|
||||||
<div class="dashboard__card-body">
|
<el-card shadow="hover" class="stat-card">
|
||||||
<el-icon :style="{ color: card.color }"><component :is="card.icon" /></el-icon>
|
<el-icon class="stat-card__icon" :style="{ color: 'var(--el-color-primary)' }">
|
||||||
<div>
|
<Menu />
|
||||||
<p class="dashboard__label">{{ card.label }}</p>
|
</el-icon>
|
||||||
<p class="dashboard__value">{{ card.value }}</p>
|
<div class="stat-card__body">
|
||||||
|
<div class="stat-card__value">{{ miniAppCount }}</div>
|
||||||
|
<div class="stat-card__label">小程序总数</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-card shadow="hover" class="stat-card">
|
||||||
|
<el-icon class="stat-card__icon" :style="{ color: 'var(--el-color-success)' }">
|
||||||
|
<User />
|
||||||
|
</el-icon>
|
||||||
|
<div class="stat-card__body">
|
||||||
|
<div class="stat-card__value">{{ totalUsers }}</div>
|
||||||
|
<div class="stat-card__label">用户总数</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-card shadow="hover" class="stat-card">
|
||||||
|
<el-icon class="stat-card__icon" :style="{ color: 'var(--el-color-warning)' }">
|
||||||
|
<Trophy />
|
||||||
|
</el-icon>
|
||||||
|
<div class="stat-card__body">
|
||||||
|
<div class="stat-card__value">{{ topMiniApp?.userCount ?? 0 }}</div>
|
||||||
|
<div class="stat-card__label">
|
||||||
|
用户最多:{{ topMiniApp?.miniAppName ?? '—' }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
|
|
||||||
<el-card class="dashboard__trend" shadow="never">
|
<!-- 柱状图 -->
|
||||||
<template #header>近 7 日趋势</template>
|
<el-card v-loading="loading" shadow="never" class="chart-card">
|
||||||
<div class="dashboard__chart">
|
<template #header>
|
||||||
<div v-for="point in summary?.trend ?? []" :key="point.date" class="dashboard__bar">
|
<div class="chart-card__header">
|
||||||
<div
|
<span><el-icon><DataBoard /></el-icon> 各小程序用户数量</span>
|
||||||
class="dashboard__bar-inner"
|
<el-button :icon="Refresh" text @click="fetchData">刷新</el-button>
|
||||||
:style="{ height: `${(point.value / maxTrendValue) * 100}%` }"
|
|
||||||
/>
|
|
||||||
<span class="dashboard__bar-label">{{ point.date }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
|
<BarChart :data="chartData" value-unit="人" color="var(--el-color-primary)" height="360px" />
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 服务监控 -->
|
||||||
|
<ServerMonitorPanel />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.dashboard {
|
.overview {
|
||||||
&__card {
|
margin-bottom: 16px;
|
||||||
margin-bottom: 16px;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
&__card-body {
|
.stat-card {
|
||||||
|
border: none;
|
||||||
|
|
||||||
|
:deep(.el-card__body) {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
|
||||||
.el-icon {
|
|
||||||
font-size: 32px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&__label {
|
.stat-card__icon {
|
||||||
margin: 0;
|
font-size: 40px;
|
||||||
font-size: 13px;
|
}
|
||||||
color: $text-color-secondary;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__value {
|
.stat-card__value {
|
||||||
margin: 4px 0 0;
|
font-size: 28px;
|
||||||
font-size: 22px;
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: $text-color-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card__label {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: $text-color-secondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
color: $text-color-primary;
|
||||||
|
|
||||||
&__chart {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
gap: 16px;
|
|
||||||
height: 200px;
|
|
||||||
padding: 8px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__bar {
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__bar-inner {
|
|
||||||
width: 32px;
|
|
||||||
background-color: $color-primary;
|
|
||||||
border-radius: 4px 4px 0 0;
|
|
||||||
transition: height 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__bar-label {
|
|
||||||
margin-top: 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: $text-color-secondary;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const appTitle = import.meta.env.VITE_APP_TITLE
|
const appTitle = import.meta.env.VITE_APP_TITLE
|
||||||
|
|
||||||
const formData = reactive<LoginForm>({ username: 'admin', password: '123456' })
|
const formData = reactive<LoginForm>({ username: 'admin', password: 'admin123' })
|
||||||
|
|
||||||
const rules: FormRules<LoginForm> = {
|
const rules: FormRules<LoginForm> = {
|
||||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
登 录
|
登 录
|
||||||
</el-button>
|
</el-button>
|
||||||
</el-form>
|
</el-form>
|
||||||
<p class="login__tip">演示账号:admin / 123456(本地 mock 不校验密码)</p>
|
<p class="login__tip">演示账号:admin / admin123</p>
|
||||||
</el-card>
|
</el-card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { FormInstance, FormRules } from 'element-plus'
|
||||||
|
import { nextTick } from 'vue'
|
||||||
|
import { MiniAppAPI } from '@/api/miniapp'
|
||||||
|
import { MINI_APP_STATUS_OPTIONS, MiniAppStatusEnum } from '@/enums/miniapp'
|
||||||
|
import type { MiniAppForm as MiniAppFormData } from '@/types/miniapp'
|
||||||
|
|
||||||
|
defineOptions({ name: 'MiniAppForm' })
|
||||||
|
|
||||||
|
const emit = defineEmits<{ (e: 'success'): void }>()
|
||||||
|
|
||||||
|
const visible = ref(false)
|
||||||
|
const confirmLoading = ref(false)
|
||||||
|
const formRef = ref<FormInstance>()
|
||||||
|
|
||||||
|
const formData = reactive<MiniAppFormData>({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
app_id: '',
|
||||||
|
app_secret: '',
|
||||||
|
status: MiniAppStatusEnum.ENABLED,
|
||||||
|
ext: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const title = computed(() => (formData.id ? '编辑小程序' : '新增小程序'))
|
||||||
|
|
||||||
|
function isValidJson(text: string): boolean {
|
||||||
|
try {
|
||||||
|
JSON.parse(text)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rules: FormRules<MiniAppFormData> = {
|
||||||
|
name: [{ required: true, message: '请输入小程序名称', trigger: 'blur' }],
|
||||||
|
app_id: [
|
||||||
|
{ required: true, message: '请输入微信 AppID', trigger: 'blur' },
|
||||||
|
{ pattern: /^wx[a-zA-Z0-9]+$/, message: 'AppID 格式不正确(以 wx 开头)', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
app_secret: [
|
||||||
|
{
|
||||||
|
// 新增必填;编辑时留空表示不修改
|
||||||
|
validator: (_rule, value, callback) => {
|
||||||
|
if (!formData.id && !value) {
|
||||||
|
callback(new Error('请输入微信 AppSecret'))
|
||||||
|
} else {
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
trigger: 'blur',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
ext: [
|
||||||
|
{
|
||||||
|
validator: (_rule, value, callback) => {
|
||||||
|
if (value && !isValidJson(value)) {
|
||||||
|
callback(new Error('扩展信息必须是合法 JSON'))
|
||||||
|
} else {
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
trigger: 'blur',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
Object.assign(formData, {
|
||||||
|
id: undefined,
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
app_id: '',
|
||||||
|
app_secret: '',
|
||||||
|
status: MiniAppStatusEnum.ENABLED,
|
||||||
|
ext: '',
|
||||||
|
})
|
||||||
|
void nextTick(() => formRef.value?.clearValidate())
|
||||||
|
}
|
||||||
|
|
||||||
|
async function open(id?: number) {
|
||||||
|
visible.value = true
|
||||||
|
resetForm()
|
||||||
|
if (!id) return
|
||||||
|
|
||||||
|
const detail = await MiniAppAPI.detail(id)
|
||||||
|
Object.assign(formData, {
|
||||||
|
id: detail.id,
|
||||||
|
name: detail.name,
|
||||||
|
description: detail.description,
|
||||||
|
app_id: detail.app_id,
|
||||||
|
// app_secret 后端不回传,编辑时留空表示不修改
|
||||||
|
app_secret: '',
|
||||||
|
status: detail.status,
|
||||||
|
ext: detail.ext,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
const valid = await formRef.value?.validate().catch(() => false)
|
||||||
|
if (!valid) return
|
||||||
|
|
||||||
|
confirmLoading.value = true
|
||||||
|
try {
|
||||||
|
const payload: MiniAppFormData = { ...formData }
|
||||||
|
// 编辑时未填写 AppSecret 则不提交该字段,避免误清空
|
||||||
|
if (formData.id && !payload.app_secret) {
|
||||||
|
delete payload.app_secret
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.id) {
|
||||||
|
await MiniAppAPI.update(formData.id, payload)
|
||||||
|
} else {
|
||||||
|
await MiniAppAPI.create(payload)
|
||||||
|
}
|
||||||
|
ElMessage.success('保存成功')
|
||||||
|
visible.value = false
|
||||||
|
emit('success')
|
||||||
|
} finally {
|
||||||
|
confirmLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ open })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-dialog v-model="visible" :title="title" width="560px" :close-on-click-modal="false">
|
||||||
|
<el-form ref="formRef" :model="formData" :rules="rules" label-width="100px">
|
||||||
|
<el-form-item label="小程序名称" prop="name">
|
||||||
|
<el-input v-model="formData.name" placeholder="请输入小程序名称" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="AppID" prop="app_id">
|
||||||
|
<el-input v-model="formData.app_id" placeholder="微信 AppID,全局唯一" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="AppSecret" prop="app_secret">
|
||||||
|
<el-input
|
||||||
|
v-model="formData.app_secret"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
placeholder="微信 AppSecret(编辑时留空表示不修改)"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="描述" prop="description">
|
||||||
|
<el-input v-model="formData.description" type="textarea" :rows="2" placeholder="请输入描述" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="状态" prop="status">
|
||||||
|
<el-radio-group v-model="formData.status">
|
||||||
|
<el-radio v-for="option in MINI_APP_STATUS_OPTIONS" :key="option.value" :value="option.value">
|
||||||
|
{{ option.label }}
|
||||||
|
</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="扩展信息" prop="ext">
|
||||||
|
<el-input
|
||||||
|
v-model="formData.ext"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
placeholder='扩展 JSON,例如 {"theme":"dark"}'
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="visible = false">取 消</el-button>
|
||||||
|
<el-button type="primary" :loading="confirmLoading" @click="handleSubmit">确 定</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Delete, Plus, Refresh, Search } from '@element-plus/icons-vue'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { MiniAppAPI } from '@/api/miniapp'
|
||||||
|
import { MINI_APP_STATUS_OPTIONS, MiniAppStatusEnum } from '@/enums/miniapp'
|
||||||
|
import type { MiniAppQuery, MiniAppStatus, MiniAppVO } from '@/types/miniapp'
|
||||||
|
import MiniAppForm from './components/MiniAppForm.vue'
|
||||||
|
|
||||||
|
defineOptions({ name: 'Miniapp' })
|
||||||
|
|
||||||
|
const formRef = ref<InstanceType<typeof MiniAppForm>>()
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const total = ref(0)
|
||||||
|
const list = ref<MiniAppVO[]>([])
|
||||||
|
const selectedIds = ref<number[]>([])
|
||||||
|
|
||||||
|
const queryParams = reactive<MiniAppQuery>({
|
||||||
|
pageNum: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
status: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedIdStr = computed(() => selectedIds.value.join(','))
|
||||||
|
|
||||||
|
/** 分页参数变化后重新拉取数据 */
|
||||||
|
watch([() => queryParams.pageNum, () => queryParams.pageSize], handleQuery)
|
||||||
|
|
||||||
|
async function handleQuery() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const { list: rows, total: count } = await MiniAppAPI.query(queryParams)
|
||||||
|
list.value = rows
|
||||||
|
total.value = count
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
Object.assign(queryParams, { pageNum: 1, pageSize: 10, status: undefined })
|
||||||
|
handleQuery()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelectionChange(selection: MiniAppVO[]) {
|
||||||
|
selectedIds.value = selection.map((item) => item.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openForm(row?: Partial<MiniAppVO>) {
|
||||||
|
formRef.value?.open(row?.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(row?: Partial<MiniAppVO>) {
|
||||||
|
const ids = row ? String(row.id) : selectedIdStr.value
|
||||||
|
if (!ids) {
|
||||||
|
ElMessage.warning('请先选择要删除的数据')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await ElMessageBox.confirm('确认删除选中的数据吗?删除后不可恢复', '提示', { type: 'warning' })
|
||||||
|
await MiniAppAPI.delete(ids)
|
||||||
|
|
||||||
|
// 删除末页最后一条时回退页码,避免出现空列表
|
||||||
|
const remain = total.value - ids.split(',').length
|
||||||
|
const maxPage = Math.max(1, Math.ceil(remain / queryParams.pageSize))
|
||||||
|
queryParams.pageNum = Math.min(queryParams.pageNum, maxPage)
|
||||||
|
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
handleQuery()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 切换小程序启用 / 停用状态 */
|
||||||
|
async function handleStatusChange(row: MiniAppVO, nextStatus: MiniAppStatus) {
|
||||||
|
const prevStatus = row.status === nextStatus ? (nextStatus ? MiniAppStatusEnum.DISABLED : MiniAppStatusEnum.ENABLED) : row.status
|
||||||
|
try {
|
||||||
|
await MiniAppAPI.updateStatus(row.id, nextStatus)
|
||||||
|
ElMessage.success(nextStatus === MiniAppStatusEnum.ENABLED ? '已启用' : '已停用')
|
||||||
|
} catch {
|
||||||
|
// 请求失败回滚开关状态
|
||||||
|
row.status = prevStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(handleQuery)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="app-container">
|
||||||
|
<!-- 搜索栏 + 工具栏 -->
|
||||||
|
<div class="filter-bar">
|
||||||
|
<el-form :model="queryParams" inline @submit.prevent="handleQuery">
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-select v-model="queryParams.status" placeholder="全部" clearable style="width: 120px">
|
||||||
|
<el-option
|
||||||
|
v-for="option in MINI_APP_STATUS_OPTIONS"
|
||||||
|
:key="option.value"
|
||||||
|
:label="option.label"
|
||||||
|
:value="option.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" :icon="Search" @click="handleQuery">查询</el-button>
|
||||||
|
<el-button :icon="Refresh" @click="handleReset">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<div class="toolbar-actions">
|
||||||
|
<el-button v-hasPerm="'miniapp:add'" type="primary" :icon="Plus" @click="openForm()">
|
||||||
|
新增
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-hasPerm="'miniapp:delete'"
|
||||||
|
type="danger"
|
||||||
|
:icon="Delete"
|
||||||
|
:disabled="!selectedIds.length"
|
||||||
|
@click="handleDelete()"
|
||||||
|
>
|
||||||
|
批量删除
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 表格 -->
|
||||||
|
<el-table v-loading="loading" :data="list" border @selection-change="handleSelectionChange">
|
||||||
|
<el-table-column type="selection" width="50" align="center" />
|
||||||
|
<el-table-column prop="id" label="ID" width="80" />
|
||||||
|
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="app_id" label="AppID" min-width="160" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="description" label="描述" min-width="180" show-overflow-tooltip />
|
||||||
|
<el-table-column label="状态" width="100" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-switch
|
||||||
|
v-model="row.status"
|
||||||
|
v-hasPerm="'miniapp:edit'"
|
||||||
|
:active-value="MiniAppStatusEnum.ENABLED"
|
||||||
|
:inactive-value="MiniAppStatusEnum.DISABLED"
|
||||||
|
inline-prompt
|
||||||
|
active-text="启用"
|
||||||
|
inactive-text="停用"
|
||||||
|
@change="(val: MiniAppStatus) => handleStatusChange(row as MiniAppVO, val)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="ext" label="扩展信息" min-width="140" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="created_at" label="创建时间" min-width="180" />
|
||||||
|
<el-table-column label="操作" width="150" fixed="right" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button v-hasPerm="'miniapp:edit'" link type="primary" @click="openForm(row)">
|
||||||
|
编辑
|
||||||
|
</el-button>
|
||||||
|
<el-button v-hasPerm="'miniapp:delete'" link type="danger" @click="handleDelete(row)">
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<template #empty>
|
||||||
|
<el-empty description="暂无数据" />
|
||||||
|
</template>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<!-- 分页 -->
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="queryParams.pageNum"
|
||||||
|
v-model:page-size="queryParams.pageSize"
|
||||||
|
class="pagination"
|
||||||
|
:total="total"
|
||||||
|
:page-sizes="[10, 20, 50]"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MiniAppForm ref="formRef" @success="handleQuery" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.filter-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { FormInstance, FormRules } from 'element-plus'
|
||||||
|
import { computed, nextTick } from 'vue'
|
||||||
|
import { AdminAPI } from '@/api/system/admin'
|
||||||
|
import { RBAC_STATUS_OPTIONS, RbacStatusEnum } from '@/enums/rbac'
|
||||||
|
import type { AdminForm as AdminFormData } from '@/types/system/rbac'
|
||||||
|
|
||||||
|
defineOptions({ name: 'AdminForm' })
|
||||||
|
|
||||||
|
const emit = defineEmits<{ (e: 'success'): void }>()
|
||||||
|
|
||||||
|
const visible = ref(false)
|
||||||
|
const confirmLoading = ref(false)
|
||||||
|
const formRef = ref<FormInstance>()
|
||||||
|
|
||||||
|
const formData = reactive<AdminFormData>({
|
||||||
|
username: '',
|
||||||
|
nickname: '',
|
||||||
|
password: '',
|
||||||
|
status: RbacStatusEnum.ENABLED,
|
||||||
|
})
|
||||||
|
|
||||||
|
const isEdit = computed(() => Boolean(formData.id))
|
||||||
|
const title = computed(() => (isEdit.value ? '编辑管理员' : '新增管理员'))
|
||||||
|
|
||||||
|
const rules: FormRules<AdminFormData> = {
|
||||||
|
username: [
|
||||||
|
{ required: true, message: '请输入账号', trigger: 'blur' },
|
||||||
|
{ min: 3, max: 20, message: '长度在 3 到 20 个字符', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
nickname: [{ required: true, message: '请输入昵称', trigger: 'blur' }],
|
||||||
|
password: [
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: '请输入密码',
|
||||||
|
trigger: 'blur',
|
||||||
|
// 编辑时密码非必填
|
||||||
|
validator: (_, value: string, callback) => {
|
||||||
|
if (!isEdit.value && !value) {
|
||||||
|
callback(new Error('请输入密码'))
|
||||||
|
} else {
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
Object.assign(formData, {
|
||||||
|
id: undefined,
|
||||||
|
username: '',
|
||||||
|
nickname: '',
|
||||||
|
password: '',
|
||||||
|
status: RbacStatusEnum.ENABLED,
|
||||||
|
})
|
||||||
|
void nextTick(() => formRef.value?.clearValidate())
|
||||||
|
}
|
||||||
|
|
||||||
|
async function open(id?: number) {
|
||||||
|
visible.value = true
|
||||||
|
resetForm()
|
||||||
|
if (!id) return
|
||||||
|
|
||||||
|
const detail = await AdminAPI.detail(id)
|
||||||
|
Object.assign(formData, {
|
||||||
|
id: detail.id,
|
||||||
|
username: detail.username,
|
||||||
|
nickname: detail.nickname,
|
||||||
|
password: '',
|
||||||
|
status: detail.status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
const valid = await formRef.value?.validate().catch(() => false)
|
||||||
|
if (!valid) return
|
||||||
|
|
||||||
|
confirmLoading.value = true
|
||||||
|
try {
|
||||||
|
if (formData.id) {
|
||||||
|
// 编辑仅允许更新昵称与状态
|
||||||
|
const { id, nickname, status } = formData
|
||||||
|
await AdminAPI.update(id as number, { nickname, status })
|
||||||
|
} else {
|
||||||
|
await AdminAPI.create(formData)
|
||||||
|
}
|
||||||
|
ElMessage.success('保存成功')
|
||||||
|
visible.value = false
|
||||||
|
emit('success')
|
||||||
|
} finally {
|
||||||
|
confirmLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ open })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-dialog v-model="visible" :title="title" width="520px" :close-on-click-modal="false">
|
||||||
|
<el-form ref="formRef" :model="formData" :rules="rules" label-width="80px">
|
||||||
|
<el-form-item label="账号" prop="username">
|
||||||
|
<el-input
|
||||||
|
v-model="formData.username"
|
||||||
|
placeholder="请输入账号"
|
||||||
|
:disabled="isEdit"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="昵称" prop="nickname">
|
||||||
|
<el-input v-model="formData.nickname" placeholder="请输入昵称" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="密码" prop="password">
|
||||||
|
<el-input
|
||||||
|
v-model="formData.password"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
:placeholder="isEdit ? '留空则不修改密码' : '请输入密码'"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="状态" prop="status">
|
||||||
|
<el-radio-group v-model="formData.status">
|
||||||
|
<el-radio v-for="option in RBAC_STATUS_OPTIONS" :key="option.value" :value="option.value">
|
||||||
|
{{ option.label }}
|
||||||
|
</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="visible = false">取 消</el-button>
|
||||||
|
<el-button type="primary" :loading="confirmLoading" @click="handleSubmit">确 定</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { FormInstance, FormRules } from 'element-plus'
|
||||||
|
import { computed, nextTick } from 'vue'
|
||||||
|
import { AdminAPI } from '@/api/system/admin'
|
||||||
|
|
||||||
|
defineOptions({ name: 'AdminResetPwdDialog' })
|
||||||
|
|
||||||
|
const emit = defineEmits<{ (e: 'success'): void }>()
|
||||||
|
|
||||||
|
const visible = ref(false)
|
||||||
|
const confirmLoading = ref(false)
|
||||||
|
const formRef = ref<FormInstance>()
|
||||||
|
|
||||||
|
const adminId = ref<number>()
|
||||||
|
const adminName = ref('')
|
||||||
|
|
||||||
|
const formData = reactive<{ password: string }>({ password: '' })
|
||||||
|
|
||||||
|
const rules: FormRules<{ password: string }> = {
|
||||||
|
password: [
|
||||||
|
{ required: true, message: '请输入新密码', trigger: 'blur' },
|
||||||
|
{ min: 6, max: 32, message: '长度在 6 到 32 个字符', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
function open(id: number, username: string) {
|
||||||
|
adminId.value = id
|
||||||
|
adminName.value = username
|
||||||
|
visible.value = true
|
||||||
|
formData.password = ''
|
||||||
|
void nextTick(() => formRef.value?.clearValidate())
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = computed(() => `重置密码 - ${adminName.value}`)
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!adminId.value) return
|
||||||
|
const valid = await formRef.value?.validate().catch(() => false)
|
||||||
|
if (!valid) return
|
||||||
|
|
||||||
|
confirmLoading.value = true
|
||||||
|
try {
|
||||||
|
await AdminAPI.resetPassword(adminId.value, { password: formData.password })
|
||||||
|
ElMessage.success('重置成功')
|
||||||
|
visible.value = false
|
||||||
|
emit('success')
|
||||||
|
} finally {
|
||||||
|
confirmLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ open })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-dialog v-model="visible" :title="title" width="480px" :close-on-click-modal="false">
|
||||||
|
<el-form ref="formRef" :model="formData" :rules="rules" label-width="90px">
|
||||||
|
<el-form-item label="新密码" prop="password">
|
||||||
|
<el-input
|
||||||
|
v-model="formData.password"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
placeholder="请输入新密码"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="visible = false">取 消</el-button>
|
||||||
|
<el-button type="primary" :loading="confirmLoading" @click="handleSubmit">确 定</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { nextTick } from 'vue'
|
||||||
|
import { AdminAPI } from '@/api/system/admin'
|
||||||
|
import { RoleAPI } from '@/api/system/role'
|
||||||
|
import type { RoleVO } from '@/types/system/rbac'
|
||||||
|
|
||||||
|
defineOptions({ name: 'AdminRoleDialog' })
|
||||||
|
|
||||||
|
const emit = defineEmits<{ (e: 'success'): void }>()
|
||||||
|
|
||||||
|
const visible = ref(false)
|
||||||
|
const confirmLoading = ref(false)
|
||||||
|
const loadingRoles = ref(false)
|
||||||
|
|
||||||
|
const adminId = ref<number>()
|
||||||
|
const roleOptions = ref<RoleVO[]>([])
|
||||||
|
const selectedRoleIds = ref<number[]>([])
|
||||||
|
|
||||||
|
async function loadRoles() {
|
||||||
|
loadingRoles.value = true
|
||||||
|
try {
|
||||||
|
const { list } = await RoleAPI.query({ page: 1, size: 1000, status: 1 })
|
||||||
|
roleOptions.value = list
|
||||||
|
} finally {
|
||||||
|
loadingRoles.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function open(id: number) {
|
||||||
|
adminId.value = id
|
||||||
|
visible.value = true
|
||||||
|
selectedRoleIds.value = []
|
||||||
|
await loadRoles()
|
||||||
|
// 接口未返回管理员当前角色,默认清空由用户重新选择(全量替换)
|
||||||
|
void nextTick(() => undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!adminId.value) return
|
||||||
|
|
||||||
|
confirmLoading.value = true
|
||||||
|
try {
|
||||||
|
await AdminAPI.assignRoles(adminId.value, { role_ids: selectedRoleIds.value })
|
||||||
|
ElMessage.success('分配成功')
|
||||||
|
visible.value = false
|
||||||
|
emit('success')
|
||||||
|
} finally {
|
||||||
|
confirmLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ open })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-dialog v-model="visible" title="分配角色" width="520px" :close-on-click-modal="false">
|
||||||
|
<el-form label-width="80px">
|
||||||
|
<el-form-item label="角色">
|
||||||
|
<el-select
|
||||||
|
v-model="selectedRoleIds"
|
||||||
|
multiple
|
||||||
|
filterable
|
||||||
|
:loading="loadingRoles"
|
||||||
|
placeholder="请选择角色"
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="role in roleOptions"
|
||||||
|
:key="role.id"
|
||||||
|
:label="`${role.name}(${role.code})`"
|
||||||
|
:value="role.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="visible = false">取 消</el-button>
|
||||||
|
<el-button type="primary" :loading="confirmLoading" @click="handleSubmit">确 定</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Delete, Key, Plus, Refresh, Search } from '@element-plus/icons-vue'
|
||||||
|
import { AdminAPI } from '@/api/system/admin'
|
||||||
|
import { RBAC_STATUS_OPTIONS, RbacStatusEnum } from '@/enums/rbac'
|
||||||
|
import type { AdminQuery, AdminVO } from '@/types/system/rbac'
|
||||||
|
import AdminForm from './components/AdminForm.vue'
|
||||||
|
import AdminResetPwdDialog from './components/AdminResetPwdDialog.vue'
|
||||||
|
import AdminRoleDialog from './components/AdminRoleDialog.vue'
|
||||||
|
|
||||||
|
defineOptions({ name: 'AdminList' })
|
||||||
|
|
||||||
|
const formRef = ref<InstanceType<typeof AdminForm>>()
|
||||||
|
const roleRef = ref<InstanceType<typeof AdminRoleDialog>>()
|
||||||
|
const pwdRef = ref<InstanceType<typeof AdminResetPwdDialog>>()
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const total = ref(0)
|
||||||
|
const list = ref<AdminVO[]>([])
|
||||||
|
|
||||||
|
const queryParams = reactive<AdminQuery>({
|
||||||
|
page: 1,
|
||||||
|
size: 10,
|
||||||
|
keyword: '',
|
||||||
|
status: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
watch([() => queryParams.page, () => queryParams.size], handleQuery)
|
||||||
|
|
||||||
|
async function handleQuery() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const { list: rows, total: count } = await AdminAPI.query(queryParams)
|
||||||
|
list.value = rows
|
||||||
|
total.value = count
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
Object.assign(queryParams, { page: 1, size: 10, keyword: '', status: undefined })
|
||||||
|
handleQuery()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openForm(row?: Partial<AdminVO>) {
|
||||||
|
formRef.value?.open(row?.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRole(row: AdminVO) {
|
||||||
|
roleRef.value?.open(row.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openResetPwd(row: AdminVO) {
|
||||||
|
pwdRef.value?.open(row.id, row.username)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(row: AdminVO) {
|
||||||
|
await ElMessageBox.confirm(`确认删除管理员「${row.username}」吗?删除后不可恢复`, '提示', {
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
await AdminAPI.delete(row.id)
|
||||||
|
handlePageBack()
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
handleQuery()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除末页最后一条时回退页码,避免空列表 */
|
||||||
|
function handlePageBack() {
|
||||||
|
const maxPage = Math.max(1, Math.ceil((total.value - 1) / queryParams.size))
|
||||||
|
queryParams.page = Math.min(queryParams.page, maxPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusLabel = (status: number) =>
|
||||||
|
status === RbacStatusEnum.ENABLED ? '启用' : '禁用'
|
||||||
|
|
||||||
|
onMounted(handleQuery)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="app-container">
|
||||||
|
<!-- 搜索栏 + 工具栏 -->
|
||||||
|
<div class="filter-bar">
|
||||||
|
<el-form :model="queryParams" inline @submit.prevent="handleQuery">
|
||||||
|
<el-form-item label="关键字">
|
||||||
|
<el-input
|
||||||
|
v-model="queryParams.keyword"
|
||||||
|
placeholder="账号 / 昵称"
|
||||||
|
clearable
|
||||||
|
style="width: 200px"
|
||||||
|
@keyup.enter="handleQuery"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-select v-model="queryParams.status" placeholder="全部" clearable style="width: 120px">
|
||||||
|
<el-option
|
||||||
|
v-for="option in RBAC_STATUS_OPTIONS"
|
||||||
|
:key="option.value"
|
||||||
|
:label="option.label"
|
||||||
|
:value="option.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" :icon="Search" @click="handleQuery">查询</el-button>
|
||||||
|
<el-button :icon="Refresh" @click="handleReset">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<div class="toolbar-actions">
|
||||||
|
<el-button v-hasPerm="'admin:manage'" type="primary" :icon="Plus" @click="openForm()">
|
||||||
|
新增
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 表格 -->
|
||||||
|
<el-table v-loading="loading" :data="list" border>
|
||||||
|
<el-table-column prop="id" label="ID" width="80" />
|
||||||
|
<el-table-column prop="username" label="账号" min-width="120" />
|
||||||
|
<el-table-column prop="nickname" label="昵称" min-width="120" />
|
||||||
|
<el-table-column label="状态" width="90" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.status === RbacStatusEnum.ENABLED ? 'success' : 'info'">
|
||||||
|
{{ statusLabel(row.status) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="created_at" label="创建时间" min-width="180" />
|
||||||
|
<el-table-column prop="updated_at" label="更新时间" min-width="180" />
|
||||||
|
<el-table-column label="操作" width="280" fixed="right" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button v-hasPerm="'admin:manage'" link type="primary" @click="openForm(row)">
|
||||||
|
编辑
|
||||||
|
</el-button>
|
||||||
|
<el-button v-hasPerm="'admin:manage'" link type="warning" @click="openRole(row as AdminVO)">
|
||||||
|
分配角色
|
||||||
|
</el-button>
|
||||||
|
<el-button v-hasPerm="'admin:manage'" link type="warning" :icon="Key" @click="openResetPwd(row as AdminVO)">
|
||||||
|
重置密码
|
||||||
|
</el-button>
|
||||||
|
<el-button v-hasPerm="'admin:manage'" link type="danger" :icon="Delete" @click="handleDelete(row as AdminVO)">
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<template #empty>
|
||||||
|
<el-empty description="暂无数据" />
|
||||||
|
</template>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<!-- 分页 -->
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="queryParams.page"
|
||||||
|
v-model:page-size="queryParams.size"
|
||||||
|
class="pagination"
|
||||||
|
:total="total"
|
||||||
|
:page-sizes="[10, 20, 50]"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AdminForm ref="formRef" @success="handleQuery" />
|
||||||
|
<AdminRoleDialog ref="roleRef" @success="handleQuery" />
|
||||||
|
<AdminResetPwdDialog ref="pwdRef" @success="handleQuery" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.filter-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { FormInstance, FormRules } from 'element-plus'
|
||||||
|
import { computed, nextTick } from 'vue'
|
||||||
|
import { PermissionAPI } from '@/api/system/permission'
|
||||||
|
import { PERMISSION_TYPE_OPTIONS, PermissionTypeEnum } from '@/enums/rbac'
|
||||||
|
import type { PermissionForm as PermissionFormData, PermissionVO } from '@/types/system/rbac'
|
||||||
|
|
||||||
|
defineOptions({ name: 'PermissionForm' })
|
||||||
|
|
||||||
|
const emit = defineEmits<{ (e: 'success'): void }>()
|
||||||
|
|
||||||
|
const visible = ref(false)
|
||||||
|
const confirmLoading = ref(false)
|
||||||
|
const formRef = ref<FormInstance>()
|
||||||
|
|
||||||
|
const formData = reactive<PermissionFormData>({
|
||||||
|
code: '',
|
||||||
|
name: '',
|
||||||
|
type: PermissionTypeEnum.API,
|
||||||
|
method: 'GET',
|
||||||
|
path: '',
|
||||||
|
parent_id: 0,
|
||||||
|
sort: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const isEdit = computed(() => Boolean(formData.id))
|
||||||
|
const title = computed(() => (isEdit.value ? '编辑权限' : '新增权限'))
|
||||||
|
|
||||||
|
/** 父级权限树(排除当前节点自身及其子树,避免形成环) */
|
||||||
|
const parentOptions = ref<PermissionNode[]>([])
|
||||||
|
|
||||||
|
interface PermissionNode {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
children: PermissionNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const treeProps = { label: 'name', children: 'children' }
|
||||||
|
|
||||||
|
const rules: FormRules<PermissionFormData> = {
|
||||||
|
code: [
|
||||||
|
{ required: true, message: '请输入权限标识', trigger: 'blur' },
|
||||||
|
{ pattern: /^[a-zA-Z0-9_:]+$/, message: '仅支持字母、数字、下划线、冒号', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
name: [{ required: true, message: '请输入权限名称', trigger: 'blur' }],
|
||||||
|
type: [{ required: true, message: '请选择权限类型', trigger: 'change' }],
|
||||||
|
sort: [{ required: true, message: '请输入排序号', trigger: 'blur' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTree(list: PermissionVO[]): PermissionNode[] {
|
||||||
|
const map = new Map<number, PermissionNode>()
|
||||||
|
list.forEach((p) => map.set(p.id, { id: p.id, name: p.name, children: [] }))
|
||||||
|
const roots: PermissionNode[] = []
|
||||||
|
list.forEach((p) => {
|
||||||
|
const node = map.get(p.id) as PermissionNode
|
||||||
|
const parent = p.parent_id ? map.get(p.parent_id) : undefined
|
||||||
|
if (parent) {
|
||||||
|
parent.children.push(node)
|
||||||
|
} else {
|
||||||
|
roots.push(node)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return roots
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从树中移除指定节点及其子树 */
|
||||||
|
function removeNode(nodes: PermissionNode[], targetId: number): PermissionNode[] {
|
||||||
|
return nodes
|
||||||
|
.filter((n) => n.id !== targetId)
|
||||||
|
.map((n) => ({ ...n, children: removeNode(n.children, targetId) }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadParentOptions() {
|
||||||
|
const { list } = await PermissionAPI.query({ page: 1, size: 1000 })
|
||||||
|
let tree = buildTree(list)
|
||||||
|
if (formData.id) {
|
||||||
|
tree = removeNode(tree, formData.id)
|
||||||
|
}
|
||||||
|
parentOptions.value = tree
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
Object.assign(formData, {
|
||||||
|
id: undefined,
|
||||||
|
code: '',
|
||||||
|
name: '',
|
||||||
|
type: PermissionTypeEnum.API,
|
||||||
|
method: 'GET',
|
||||||
|
path: '',
|
||||||
|
parent_id: 0,
|
||||||
|
sort: 0,
|
||||||
|
})
|
||||||
|
void nextTick(() => formRef.value?.clearValidate())
|
||||||
|
}
|
||||||
|
|
||||||
|
async function open(id?: number) {
|
||||||
|
visible.value = true
|
||||||
|
resetForm()
|
||||||
|
await loadParentOptions()
|
||||||
|
if (!id) return
|
||||||
|
|
||||||
|
const detail = await PermissionAPI.detail(id)
|
||||||
|
Object.assign(formData, {
|
||||||
|
id: detail.id,
|
||||||
|
code: detail.code,
|
||||||
|
name: detail.name,
|
||||||
|
type: detail.type,
|
||||||
|
method: detail.method,
|
||||||
|
path: detail.path,
|
||||||
|
parent_id: detail.parent_id,
|
||||||
|
sort: detail.sort,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
const valid = await formRef.value?.validate().catch(() => false)
|
||||||
|
if (!valid) return
|
||||||
|
|
||||||
|
confirmLoading.value = true
|
||||||
|
try {
|
||||||
|
const payload = { ...formData }
|
||||||
|
if (formData.id) {
|
||||||
|
await PermissionAPI.update(formData.id, payload)
|
||||||
|
} else {
|
||||||
|
await PermissionAPI.create(payload)
|
||||||
|
}
|
||||||
|
ElMessage.success('保存成功')
|
||||||
|
visible.value = false
|
||||||
|
emit('success')
|
||||||
|
} finally {
|
||||||
|
confirmLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ open })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-dialog v-model="visible" :title="title" width="560px" :close-on-click-modal="false">
|
||||||
|
<el-form ref="formRef" :model="formData" :rules="rules" label-width="90px">
|
||||||
|
<el-form-item label="标识" prop="code">
|
||||||
|
<el-input v-model="formData.code" placeholder="如 user:view" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="名称" prop="name">
|
||||||
|
<el-input v-model="formData.name" placeholder="如 用户查看" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="类型" prop="type">
|
||||||
|
<el-radio-group v-model="formData.type">
|
||||||
|
<el-radio
|
||||||
|
v-for="option in PERMISSION_TYPE_OPTIONS"
|
||||||
|
:key="option.value"
|
||||||
|
:value="option.value"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="方法" prop="method">
|
||||||
|
<el-select v-model="formData.method" placeholder="请选择" clearable style="width: 160px">
|
||||||
|
<el-option label="GET" value="GET" />
|
||||||
|
<el-option label="POST" value="POST" />
|
||||||
|
<el-option label="PUT" value="PUT" />
|
||||||
|
<el-option label="DELETE" value="DELETE" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="路径" prop="path">
|
||||||
|
<el-input v-model="formData.path" placeholder="接口路径或菜单路由" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="父级" prop="parent_id">
|
||||||
|
<el-tree-select
|
||||||
|
v-model="formData.parent_id"
|
||||||
|
:data="parentOptions"
|
||||||
|
:props="treeProps"
|
||||||
|
node-key="id"
|
||||||
|
check-strictly
|
||||||
|
clearable
|
||||||
|
placeholder="顶级权限"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="排序" prop="sort">
|
||||||
|
<el-input-number v-model="formData.sort" :min="0" :max="9999" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="visible = false">取 消</el-button>
|
||||||
|
<el-button type="primary" :loading="confirmLoading" @click="handleSubmit">确 定</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Delete, Plus, Refresh, Search } from '@element-plus/icons-vue'
|
||||||
|
import { PermissionAPI } from '@/api/system/permission'
|
||||||
|
import { PERMISSION_TYPE_OPTIONS, PermissionTypeEnum } from '@/enums/rbac'
|
||||||
|
import type { PermissionQuery, PermissionVO } from '@/types/system/rbac'
|
||||||
|
import PermissionForm from './components/PermissionForm.vue'
|
||||||
|
|
||||||
|
defineOptions({ name: 'PermissionList' })
|
||||||
|
|
||||||
|
const formRef = ref<InstanceType<typeof PermissionForm>>()
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const total = ref(0)
|
||||||
|
const list = ref<PermissionVO[]>([])
|
||||||
|
|
||||||
|
const queryParams = reactive<PermissionQuery>({
|
||||||
|
page: 1,
|
||||||
|
size: 10,
|
||||||
|
type: '',
|
||||||
|
keyword: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
watch([() => queryParams.page, () => queryParams.size], handleQuery)
|
||||||
|
|
||||||
|
async function handleQuery() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const { list: rows, total: count } = await PermissionAPI.query(queryParams)
|
||||||
|
list.value = rows
|
||||||
|
total.value = count
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
Object.assign(queryParams, { page: 1, size: 10, type: '', keyword: '' })
|
||||||
|
handleQuery()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openForm(row?: Partial<PermissionVO>) {
|
||||||
|
formRef.value?.open(row?.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(row: PermissionVO) {
|
||||||
|
await ElMessageBox.confirm(`确认删除权限「${row.name}」吗?删除后不可恢复`, '提示', {
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
await PermissionAPI.delete(row.id)
|
||||||
|
handlePageBack()
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
handleQuery()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePageBack() {
|
||||||
|
const maxPage = Math.max(1, Math.ceil((total.value - 1) / queryParams.size))
|
||||||
|
queryParams.page = Math.min(queryParams.page, maxPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeLabel = (type: string) => (type === PermissionTypeEnum.MENU ? '菜单' : '接口')
|
||||||
|
const typeTag = (type: string) => (type === PermissionTypeEnum.MENU ? 'success' : 'info')
|
||||||
|
|
||||||
|
onMounted(handleQuery)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="app-container">
|
||||||
|
<!-- 搜索栏 -->
|
||||||
|
<el-form :model="queryParams" inline @submit.prevent="handleQuery">
|
||||||
|
<el-form-item label="类型">
|
||||||
|
<el-select v-model="queryParams.type" placeholder="全部" clearable style="width: 120px">
|
||||||
|
<el-option
|
||||||
|
v-for="option in PERMISSION_TYPE_OPTIONS"
|
||||||
|
:key="option.value"
|
||||||
|
:label="option.label"
|
||||||
|
:value="option.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="关键字">
|
||||||
|
<el-input
|
||||||
|
v-model="queryParams.keyword"
|
||||||
|
placeholder="标识 / 名称"
|
||||||
|
clearable
|
||||||
|
style="width: 200px"
|
||||||
|
@keyup.enter="handleQuery"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" :icon="Search" @click="handleQuery">查询</el-button>
|
||||||
|
<el-button :icon="Refresh" @click="handleReset">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<!-- 工具栏 -->
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-button v-hasPerm="'perm:manage'" type="primary" :icon="Plus" @click="openForm()">
|
||||||
|
新增
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 表格 -->
|
||||||
|
<el-table v-loading="loading" :data="list" border>
|
||||||
|
<el-table-column prop="id" label="ID" width="80" />
|
||||||
|
<el-table-column prop="code" label="标识" min-width="160" />
|
||||||
|
<el-table-column prop="name" label="名称" min-width="140" />
|
||||||
|
<el-table-column label="类型" width="90" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="typeTag(row.type)">{{ typeLabel(row.type) }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="method" label="方法" width="90" align="center" />
|
||||||
|
<el-table-column prop="path" label="路径" min-width="200" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="parent_id" label="父级ID" width="90" align="center" />
|
||||||
|
<el-table-column prop="sort" label="排序" width="80" align="center" />
|
||||||
|
<el-table-column prop="created_at" label="创建时间" min-width="180" />
|
||||||
|
<el-table-column label="操作" width="150" fixed="right" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button v-hasPerm="'perm:manage'" link type="primary" @click="openForm(row)">
|
||||||
|
编辑
|
||||||
|
</el-button>
|
||||||
|
<el-button v-hasPerm="'perm:manage'" link type="danger" :icon="Delete" @click="handleDelete(row as PermissionVO)">
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<template #empty>
|
||||||
|
<el-empty description="暂无数据" />
|
||||||
|
</template>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<!-- 分页 -->
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="queryParams.page"
|
||||||
|
v-model:page-size="queryParams.size"
|
||||||
|
class="pagination"
|
||||||
|
:total="total"
|
||||||
|
:page-sizes="[10, 20, 50]"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PermissionForm ref="formRef" @success="handleQuery" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.toolbar {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { FormInstance, FormRules } from 'element-plus'
|
||||||
|
import { computed, nextTick } from 'vue'
|
||||||
|
import { RoleAPI } from '@/api/system/role'
|
||||||
|
import { RBAC_STATUS_OPTIONS, RbacStatusEnum } from '@/enums/rbac'
|
||||||
|
import type { RoleForm as RoleFormData } from '@/types/system/rbac'
|
||||||
|
|
||||||
|
defineOptions({ name: 'RoleForm' })
|
||||||
|
|
||||||
|
const emit = defineEmits<{ (e: 'success'): void }>()
|
||||||
|
|
||||||
|
const visible = ref(false)
|
||||||
|
const confirmLoading = ref(false)
|
||||||
|
const formRef = ref<FormInstance>()
|
||||||
|
|
||||||
|
const formData = reactive<RoleFormData>({
|
||||||
|
code: '',
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
status: RbacStatusEnum.ENABLED,
|
||||||
|
})
|
||||||
|
|
||||||
|
const isEdit = computed(() => Boolean(formData.id))
|
||||||
|
const title = computed(() => (isEdit.value ? '编辑角色' : '新增角色'))
|
||||||
|
|
||||||
|
const rules: FormRules<RoleFormData> = {
|
||||||
|
code: [
|
||||||
|
{ required: true, message: '请输入角色标识', trigger: 'blur' },
|
||||||
|
{ pattern: /^[a-zA-Z0-9_:]+$/, message: '仅支持字母、数字、下划线、冒号', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
name: [{ required: true, message: '请输入角色名称', trigger: 'blur' }],
|
||||||
|
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
Object.assign(formData, {
|
||||||
|
id: undefined,
|
||||||
|
code: '',
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
status: RbacStatusEnum.ENABLED,
|
||||||
|
})
|
||||||
|
void nextTick(() => formRef.value?.clearValidate())
|
||||||
|
}
|
||||||
|
|
||||||
|
async function open(id?: number) {
|
||||||
|
visible.value = true
|
||||||
|
resetForm()
|
||||||
|
if (!id) return
|
||||||
|
|
||||||
|
const detail = await RoleAPI.detail(id)
|
||||||
|
Object.assign(formData, {
|
||||||
|
id: detail.id,
|
||||||
|
code: detail.code,
|
||||||
|
name: detail.name,
|
||||||
|
description: detail.description,
|
||||||
|
status: detail.status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
const valid = await formRef.value?.validate().catch(() => false)
|
||||||
|
if (!valid) return
|
||||||
|
|
||||||
|
confirmLoading.value = true
|
||||||
|
try {
|
||||||
|
if (formData.id) {
|
||||||
|
const { id, name, description, status } = formData
|
||||||
|
await RoleAPI.update(id as number, { name, description, status })
|
||||||
|
} else {
|
||||||
|
await RoleAPI.create(formData)
|
||||||
|
}
|
||||||
|
ElMessage.success('保存成功')
|
||||||
|
visible.value = false
|
||||||
|
emit('success')
|
||||||
|
} finally {
|
||||||
|
confirmLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ open })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-dialog v-model="visible" :title="title" width="520px" :close-on-click-modal="false">
|
||||||
|
<el-form ref="formRef" :model="formData" :rules="rules" label-width="90px">
|
||||||
|
<el-form-item label="角色标识" prop="code">
|
||||||
|
<el-input
|
||||||
|
v-model="formData.code"
|
||||||
|
placeholder="如 editor"
|
||||||
|
:disabled="isEdit"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="角色名称" prop="name">
|
||||||
|
<el-input v-model="formData.name" placeholder="如 编辑" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="描述" prop="description">
|
||||||
|
<el-input
|
||||||
|
v-model="formData.description"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
placeholder="请输入描述"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="状态" prop="status">
|
||||||
|
<el-radio-group v-model="formData.status">
|
||||||
|
<el-radio v-for="option in RBAC_STATUS_OPTIONS" :key="option.value" :value="option.value">
|
||||||
|
{{ option.label }}
|
||||||
|
</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="visible = false">取 消</el-button>
|
||||||
|
<el-button type="primary" :loading="confirmLoading" @click="handleSubmit">确 定</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, ref } from 'vue'
|
||||||
|
import type { ElTree } from 'element-plus'
|
||||||
|
import { RoleAPI } from '@/api/system/role'
|
||||||
|
import { PermissionAPI } from '@/api/system/permission'
|
||||||
|
import type { PermissionVO } from '@/types/system/rbac'
|
||||||
|
|
||||||
|
defineOptions({ name: 'RolePermissionDialog' })
|
||||||
|
|
||||||
|
const emit = defineEmits<{ (e: 'success'): void }>()
|
||||||
|
|
||||||
|
const visible = ref(false)
|
||||||
|
const confirmLoading = ref(false)
|
||||||
|
const loadingTree = ref(false)
|
||||||
|
const treeRef = ref<InstanceType<typeof ElTree>>()
|
||||||
|
|
||||||
|
const roleId = ref<number>()
|
||||||
|
const roleName = ref('')
|
||||||
|
const permissionTree = ref<PermissionNode[]>([])
|
||||||
|
|
||||||
|
interface PermissionNode {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
children: PermissionNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const treeProps = { label: 'name', children: 'children' }
|
||||||
|
|
||||||
|
const title = computed(() => `分配权限 - ${roleName.value}`)
|
||||||
|
|
||||||
|
async function loadPermissions() {
|
||||||
|
loadingTree.value = true
|
||||||
|
try {
|
||||||
|
const { list } = await PermissionAPI.query({ page: 1, size: 1000 })
|
||||||
|
permissionTree.value = buildTree(list)
|
||||||
|
} finally {
|
||||||
|
loadingTree.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTree(list: PermissionVO[]): PermissionNode[] {
|
||||||
|
const map = new Map<number, PermissionNode>()
|
||||||
|
list.forEach((p) => map.set(p.id, { id: p.id, name: p.name, children: [] }))
|
||||||
|
const roots: PermissionNode[] = []
|
||||||
|
list.forEach((p) => {
|
||||||
|
const node = map.get(p.id) as PermissionNode
|
||||||
|
const parent = p.parent_id ? map.get(p.parent_id) : undefined
|
||||||
|
if (parent) {
|
||||||
|
parent.children.push(node)
|
||||||
|
} else {
|
||||||
|
roots.push(node)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return roots
|
||||||
|
}
|
||||||
|
|
||||||
|
async function open(id: number, name: string) {
|
||||||
|
roleId.value = id
|
||||||
|
roleName.value = name
|
||||||
|
visible.value = true
|
||||||
|
await loadPermissions()
|
||||||
|
// 接口未返回角色当前权限,默认清空由用户重新勾选(全量替换)
|
||||||
|
void nextTick(() => treeRef.value?.setCheckedKeys([]))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!roleId.value) return
|
||||||
|
const checkedKeys = treeRef.value?.getCheckedKeys() ?? []
|
||||||
|
|
||||||
|
confirmLoading.value = true
|
||||||
|
try {
|
||||||
|
await RoleAPI.assignPermissions(roleId.value, { permission_ids: checkedKeys as number[] })
|
||||||
|
ElMessage.success('分配成功')
|
||||||
|
visible.value = false
|
||||||
|
emit('success')
|
||||||
|
} finally {
|
||||||
|
confirmLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ open })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-dialog v-model="visible" :title="title" width="560px" :close-on-click-modal="false">
|
||||||
|
<div v-loading="loadingTree" class="permission-tree">
|
||||||
|
<el-tree
|
||||||
|
ref="treeRef"
|
||||||
|
:data="permissionTree"
|
||||||
|
:props="treeProps"
|
||||||
|
node-key="id"
|
||||||
|
show-checkbox
|
||||||
|
default-expand-all
|
||||||
|
highlight-current
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="visible = false">取 消</el-button>
|
||||||
|
<el-button type="primary" :loading="confirmLoading" @click="handleSubmit">确 定</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.permission-tree {
|
||||||
|
min-height: 200px;
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Delete, Plus, Refresh, Search } from '@element-plus/icons-vue'
|
||||||
|
import { RoleAPI } from '@/api/system/role'
|
||||||
|
import { RBAC_STATUS_OPTIONS, RbacStatusEnum } from '@/enums/rbac'
|
||||||
|
import type { RoleQuery, RoleVO } from '@/types/system/rbac'
|
||||||
|
import RoleForm from './components/RoleForm.vue'
|
||||||
|
import RolePermissionDialog from './components/RolePermissionDialog.vue'
|
||||||
|
|
||||||
|
defineOptions({ name: 'RoleList' })
|
||||||
|
|
||||||
|
const formRef = ref<InstanceType<typeof RoleForm>>()
|
||||||
|
const permRef = ref<InstanceType<typeof RolePermissionDialog>>()
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const total = ref(0)
|
||||||
|
const list = ref<RoleVO[]>([])
|
||||||
|
|
||||||
|
const queryParams = reactive<RoleQuery>({
|
||||||
|
page: 1,
|
||||||
|
size: 10,
|
||||||
|
keyword: '',
|
||||||
|
status: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
watch([() => queryParams.page, () => queryParams.size], handleQuery)
|
||||||
|
|
||||||
|
async function handleQuery() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const { list: rows, total: count } = await RoleAPI.query(queryParams)
|
||||||
|
list.value = rows
|
||||||
|
total.value = count
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
Object.assign(queryParams, { page: 1, size: 10, keyword: '', status: undefined })
|
||||||
|
handleQuery()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openForm(row?: Partial<RoleVO>) {
|
||||||
|
formRef.value?.open(row?.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPermission(row: RoleVO) {
|
||||||
|
permRef.value?.open(row.id, row.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(row: RoleVO) {
|
||||||
|
await ElMessageBox.confirm(`确认删除角色「${row.name}」吗?删除后不可恢复`, '提示', {
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
await RoleAPI.delete(row.id)
|
||||||
|
handlePageBack()
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
handleQuery()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePageBack() {
|
||||||
|
const maxPage = Math.max(1, Math.ceil((total.value - 1) / queryParams.size))
|
||||||
|
queryParams.page = Math.min(queryParams.page, maxPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusLabel = (status: number) =>
|
||||||
|
status === RbacStatusEnum.ENABLED ? '启用' : '禁用'
|
||||||
|
|
||||||
|
onMounted(handleQuery)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="app-container">
|
||||||
|
<!-- 搜索栏 -->
|
||||||
|
<el-form :model="queryParams" inline @submit.prevent="handleQuery">
|
||||||
|
<el-form-item label="关键字">
|
||||||
|
<el-input
|
||||||
|
v-model="queryParams.keyword"
|
||||||
|
placeholder="标识 / 名称"
|
||||||
|
clearable
|
||||||
|
style="width: 200px"
|
||||||
|
@keyup.enter="handleQuery"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-select v-model="queryParams.status" placeholder="全部" clearable style="width: 120px">
|
||||||
|
<el-option
|
||||||
|
v-for="option in RBAC_STATUS_OPTIONS"
|
||||||
|
:key="option.value"
|
||||||
|
:label="option.label"
|
||||||
|
:value="option.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" :icon="Search" @click="handleQuery">查询</el-button>
|
||||||
|
<el-button :icon="Refresh" @click="handleReset">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<!-- 工具栏 -->
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-button v-hasPerm="'role:manage'" type="primary" :icon="Plus" @click="openForm()">
|
||||||
|
新增
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 表格 -->
|
||||||
|
<el-table v-loading="loading" :data="list" border>
|
||||||
|
<el-table-column prop="id" label="ID" width="80" />
|
||||||
|
<el-table-column prop="code" label="标识" min-width="140" />
|
||||||
|
<el-table-column prop="name" label="名称" min-width="120" />
|
||||||
|
<el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip />
|
||||||
|
<el-table-column label="状态" width="90" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.status === RbacStatusEnum.ENABLED ? 'success' : 'info'">
|
||||||
|
{{ statusLabel(row.status) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="created_at" label="创建时间" min-width="180" />
|
||||||
|
<el-table-column label="操作" width="220" fixed="right" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button v-hasPerm="'role:manage'" link type="primary" @click="openForm(row)">
|
||||||
|
编辑
|
||||||
|
</el-button>
|
||||||
|
<el-button v-hasPerm="'role:manage'" link type="warning" @click="openPermission(row as RoleVO)">
|
||||||
|
分配权限
|
||||||
|
</el-button>
|
||||||
|
<el-button v-hasPerm="'role:manage'" link type="danger" :icon="Delete" @click="handleDelete(row as RoleVO)">
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<template #empty>
|
||||||
|
<el-empty description="暂无数据" />
|
||||||
|
</template>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<!-- 分页 -->
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="queryParams.page"
|
||||||
|
v-model:page-size="queryParams.size"
|
||||||
|
class="pagination"
|
||||||
|
:total="total"
|
||||||
|
:page-sizes="[10, 20, 50]"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RoleForm ref="formRef" @success="handleQuery" />
|
||||||
|
<RolePermissionDialog ref="permRef" @success="handleQuery" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.toolbar {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -14,54 +14,36 @@
|
|||||||
const formRef = ref<FormInstance>()
|
const formRef = ref<FormInstance>()
|
||||||
|
|
||||||
const formData = reactive<UserFormData>({
|
const formData = reactive<UserFormData>({
|
||||||
username: '',
|
|
||||||
nickname: '',
|
nickname: '',
|
||||||
mobile: '',
|
avatar: '',
|
||||||
email: '',
|
|
||||||
status: UserStatusEnum.ENABLED,
|
status: UserStatusEnum.ENABLED,
|
||||||
roleNames: [],
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const title = computed(() => (formData.id ? '编辑用户' : '新增用户'))
|
|
||||||
|
|
||||||
const rules: FormRules<UserFormData> = {
|
const rules: FormRules<UserFormData> = {
|
||||||
username: [
|
|
||||||
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
|
||||||
{ min: 3, max: 20, message: '长度在 3 到 20 个字符', trigger: 'blur' },
|
|
||||||
],
|
|
||||||
nickname: [{ required: true, message: '请输入昵称', trigger: 'blur' }],
|
nickname: [{ required: true, message: '请输入昵称', trigger: 'blur' }],
|
||||||
mobile: [{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }],
|
|
||||||
email: [{ type: 'email', message: '请输入正确的邮箱', trigger: 'blur' }],
|
|
||||||
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
|
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetForm() {
|
function resetForm() {
|
||||||
Object.assign(formData, {
|
Object.assign(formData, {
|
||||||
id: undefined,
|
id: undefined,
|
||||||
username: '',
|
|
||||||
nickname: '',
|
nickname: '',
|
||||||
mobile: '',
|
avatar: '',
|
||||||
email: '',
|
|
||||||
status: UserStatusEnum.ENABLED,
|
status: UserStatusEnum.ENABLED,
|
||||||
roleNames: [],
|
|
||||||
})
|
})
|
||||||
void nextTick(() => formRef.value?.clearValidate())
|
void nextTick(() => formRef.value?.clearValidate())
|
||||||
}
|
}
|
||||||
|
|
||||||
async function open(id?: number) {
|
async function open(id: number) {
|
||||||
visible.value = true
|
visible.value = true
|
||||||
resetForm()
|
resetForm()
|
||||||
if (!id) return
|
|
||||||
|
|
||||||
const detail = await UserAPI.detail(id)
|
const detail = await UserAPI.detail(id)
|
||||||
Object.assign(formData, {
|
Object.assign(formData, {
|
||||||
id: detail.id,
|
id: detail.id,
|
||||||
username: detail.username,
|
|
||||||
nickname: detail.nickname,
|
nickname: detail.nickname,
|
||||||
mobile: detail.mobile,
|
avatar: detail.avatar,
|
||||||
email: detail.email,
|
|
||||||
status: detail.status,
|
status: detail.status,
|
||||||
roleNames: detail.roleNames ?? [],
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,8 +55,6 @@
|
|||||||
try {
|
try {
|
||||||
if (formData.id) {
|
if (formData.id) {
|
||||||
await UserAPI.update(formData.id, formData)
|
await UserAPI.update(formData.id, formData)
|
||||||
} else {
|
|
||||||
await UserAPI.create(formData)
|
|
||||||
}
|
}
|
||||||
ElMessage.success('保存成功')
|
ElMessage.success('保存成功')
|
||||||
visible.value = false
|
visible.value = false
|
||||||
@@ -88,19 +68,16 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<el-dialog v-model="visible" :title="title" width="520px" :close-on-click-modal="false">
|
<el-dialog v-model="visible" title="编辑用户" width="520px" :close-on-click-modal="false">
|
||||||
<el-form ref="formRef" :model="formData" :rules="rules" label-width="80px">
|
<el-form ref="formRef" :model="formData" :rules="rules" label-width="80px">
|
||||||
<el-form-item label="用户名" prop="username">
|
|
||||||
<el-input v-model="formData.username" placeholder="请输入用户名" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="昵称" prop="nickname">
|
<el-form-item label="昵称" prop="nickname">
|
||||||
<el-input v-model="formData.nickname" placeholder="请输入昵称" />
|
<el-input v-model="formData.nickname" placeholder="请输入昵称" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="手机号" prop="mobile">
|
<el-form-item label="头像" prop="avatar">
|
||||||
<el-input v-model="formData.mobile" placeholder="请输入手机号" />
|
<el-input v-model="formData.avatar" placeholder="请输入头像 URL" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="邮箱" prop="email">
|
<el-form-item v-if="formData.avatar" label="预览">
|
||||||
<el-input v-model="formData.email" placeholder="请输入邮箱" />
|
<el-avatar :src="formData.avatar" :size="48" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="状态" prop="status">
|
<el-form-item label="状态" prop="status">
|
||||||
<el-radio-group v-model="formData.status">
|
<el-radio-group v-model="formData.status">
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Delete, Plus, Refresh, Search } from '@element-plus/icons-vue'
|
import { Refresh, Search } from '@element-plus/icons-vue'
|
||||||
import { computed } from 'vue'
|
|
||||||
import { UserAPI } from '@/api/system/user'
|
import { UserAPI } from '@/api/system/user'
|
||||||
import { USER_STATUS_OPTIONS, UserStatusEnum } from '@/enums/user'
|
import { UserStatusEnum } from '@/enums/user'
|
||||||
import type { UserQuery, UserVO } from '@/types/system/user'
|
import type { UserQuery, UserVO } from '@/types/system/user'
|
||||||
import UserForm from './components/UserForm.vue'
|
import UserForm from './components/UserForm.vue'
|
||||||
|
|
||||||
@@ -13,18 +12,15 @@
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const list = ref<UserVO[]>([])
|
const list = ref<UserVO[]>([])
|
||||||
const selectedIds = ref<number[]>([])
|
|
||||||
|
|
||||||
const queryParams = reactive<UserQuery>({
|
const queryParams = reactive<UserQuery>({
|
||||||
pageNum: 1,
|
pageNum: 1,
|
||||||
pageSize: 10,
|
pageSize: 10,
|
||||||
username: '',
|
mini_id: undefined,
|
||||||
status: undefined,
|
keyword: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const selectedIdStr = computed(() => selectedIds.value.join(','))
|
/** 分页参数变化后重新拉取数据 */
|
||||||
|
|
||||||
/** 分页参数变化后需要重新拉取数据 */
|
|
||||||
watch([() => queryParams.pageNum, () => queryParams.pageSize], handleQuery)
|
watch([() => queryParams.pageNum, () => queryParams.pageSize], handleQuery)
|
||||||
|
|
||||||
async function handleQuery() {
|
async function handleQuery() {
|
||||||
@@ -39,34 +35,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleReset() {
|
function handleReset() {
|
||||||
Object.assign(queryParams, { pageNum: 1, pageSize: 10, username: '', status: undefined })
|
Object.assign(queryParams, { pageNum: 1, pageSize: 10, mini_id: undefined, keyword: '' })
|
||||||
handleQuery()
|
handleQuery()
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSelectionChange(selection: UserVO[]) {
|
function openForm(row: UserVO) {
|
||||||
selectedIds.value = selection.map((item) => item.id)
|
formRef.value?.open(row.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
function openForm(row?: Partial<UserVO>) {
|
async function handleToggleStatus(row: UserVO) {
|
||||||
formRef.value?.open(row?.id)
|
const next = row.status === UserStatusEnum.ENABLED ? UserStatusEnum.DISABLED : UserStatusEnum.ENABLED
|
||||||
}
|
const action = next === UserStatusEnum.ENABLED ? '启用' : '禁用'
|
||||||
|
await ElMessageBox.confirm(`确认${action}该用户吗?`, '提示', { type: 'warning' })
|
||||||
async function handleDelete(row?: Partial<UserVO>) {
|
await UserAPI.updateStatus(row.id, next)
|
||||||
const ids = row ? String(row.id) : selectedIdStr.value
|
ElMessage.success(`${action}成功`)
|
||||||
if (!ids) {
|
|
||||||
ElMessage.warning('请先选择要删除的数据')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await ElMessageBox.confirm('确认删除选中的数据吗?删除后不可恢复', '提示', { type: 'warning' })
|
|
||||||
await UserAPI.delete(ids)
|
|
||||||
|
|
||||||
// 删除末页最后一条时回退页码,避免出现空列表
|
|
||||||
const remain = total.value - ids.split(',').length
|
|
||||||
const maxPage = Math.max(1, Math.ceil(remain / queryParams.pageSize))
|
|
||||||
queryParams.pageNum = Math.min(queryParams.pageNum, maxPage)
|
|
||||||
|
|
||||||
ElMessage.success('删除成功')
|
|
||||||
handleQuery()
|
handleQuery()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,24 +59,23 @@
|
|||||||
<div class="app-container">
|
<div class="app-container">
|
||||||
<!-- 搜索栏 -->
|
<!-- 搜索栏 -->
|
||||||
<el-form :model="queryParams" inline @submit.prevent="handleQuery">
|
<el-form :model="queryParams" inline @submit.prevent="handleQuery">
|
||||||
<el-form-item label="用户名">
|
<el-form-item label="小程序ID">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="queryParams.username"
|
v-model.number="queryParams.mini_id"
|
||||||
placeholder="请输入用户名"
|
placeholder="按小程序筛选,0 为全部"
|
||||||
clearable
|
clearable
|
||||||
style="width: 180px"
|
style="width: 200px"
|
||||||
@keyup.enter="handleQuery"
|
@keyup.enter="handleQuery"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="状态">
|
<el-form-item label="关键字">
|
||||||
<el-select v-model="queryParams.status" placeholder="全部" clearable style="width: 120px">
|
<el-input
|
||||||
<el-option
|
v-model="queryParams.keyword"
|
||||||
v-for="option in USER_STATUS_OPTIONS"
|
placeholder="昵称 / OpenID"
|
||||||
:key="option.value"
|
clearable
|
||||||
:label="option.label"
|
style="width: 200px"
|
||||||
:value="option.value"
|
@keyup.enter="handleQuery"
|
||||||
/>
|
/>
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
<el-button type="primary" :icon="Search" @click="handleQuery">查询</el-button>
|
<el-button type="primary" :icon="Search" @click="handleQuery">查询</el-button>
|
||||||
@@ -102,47 +83,34 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
<!-- 工具栏 -->
|
|
||||||
<div class="toolbar">
|
|
||||||
<div>
|
|
||||||
<el-button v-hasPerm="'sys:user:add'" type="primary" :icon="Plus" @click="openForm()">
|
|
||||||
新增
|
|
||||||
</el-button>
|
|
||||||
<el-button
|
|
||||||
v-hasPerm="'sys:user:delete'"
|
|
||||||
type="danger"
|
|
||||||
:icon="Delete"
|
|
||||||
:disabled="!selectedIds.length"
|
|
||||||
@click="handleDelete()"
|
|
||||||
>
|
|
||||||
批量删除
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 表格 -->
|
<!-- 表格 -->
|
||||||
<el-table v-loading="loading" :data="list" border @selection-change="handleSelectionChange">
|
<el-table v-loading="loading" :data="list" border>
|
||||||
<el-table-column type="selection" width="50" align="center" />
|
|
||||||
<el-table-column prop="id" label="ID" width="80" />
|
<el-table-column prop="id" label="ID" width="80" />
|
||||||
<el-table-column prop="username" label="用户名" min-width="120" />
|
<el-table-column prop="mini_id" label="小程序ID" width="110" />
|
||||||
|
<el-table-column prop="openid" label="OpenID" min-width="180" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="union_id" label="UnionID" min-width="160" show-overflow-tooltip />
|
||||||
<el-table-column prop="nickname" label="昵称" min-width="120" />
|
<el-table-column prop="nickname" label="昵称" min-width="120" />
|
||||||
<el-table-column prop="deptName" label="部门" min-width="120" />
|
<el-table-column label="头像" width="80" align="center">
|
||||||
<el-table-column prop="mobile" label="手机号" min-width="130" />
|
<template #default="{ row }">
|
||||||
|
<el-avatar v-if="row.avatar" :src="row.avatar" :size="36" />
|
||||||
|
<span v-else>—</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="状态" width="90" align="center">
|
<el-table-column label="状态" width="90" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="row.status === UserStatusEnum.ENABLED ? 'success' : 'info'">
|
<el-tag :type="row.status === UserStatusEnum.ENABLED ? 'success' : 'info'">
|
||||||
{{ row.status === UserStatusEnum.ENABLED ? '启用' : '禁用' }}
|
{{ row.status === UserStatusEnum.ENABLED ? '正常' : '禁用' }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="createTime" label="创建时间" min-width="180" />
|
<el-table-column prop="created_at" label="创建时间" min-width="180" />
|
||||||
<el-table-column label="操作" width="150" fixed="right" align="center">
|
<el-table-column label="操作" width="150" fixed="right" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button v-hasPerm="'sys:user:edit'" link type="primary" @click="openForm(row)">
|
<el-button v-hasPerm="'sys:user:edit'" link type="primary" @click="openForm(row as UserVO)">
|
||||||
编辑
|
编辑
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button v-hasPerm="'sys:user:delete'" link type="danger" @click="handleDelete(row)">
|
<el-button v-hasPerm="'sys:user:edit'" link type="danger" @click="handleToggleStatus(row as UserVO)">
|
||||||
删除
|
{{ row.status === UserStatusEnum.ENABLED ? '禁用' : '启用' }}
|
||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|||||||
Reference in New Issue
Block a user