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'
|
||||
|
||||
# 是否启用本地 mock(true 时拦截 /api/v1 请求)
|
||||
VITE_USE_MOCK = 'true'
|
||||
# 是否启用本地 mock(true 时拦截全部 /api/v1 请求;false 时仅命中标记 alwaysMock 的接口)
|
||||
VITE_USE_MOCK = 'false'
|
||||
|
||||
# 开发端口
|
||||
VITE_APP_PORT = '3000'
|
||||
|
||||
+57
-32
@@ -7,28 +7,55 @@ export type UserStatus = (typeof USER_STATUS)[keyof typeof USER_STATUS]
|
||||
|
||||
export interface MockUser {
|
||||
id: number
|
||||
username: string
|
||||
mini_id: number
|
||||
openid: string
|
||||
union_id: string
|
||||
nickname: string
|
||||
mobile: string
|
||||
email: string
|
||||
status: UserStatus
|
||||
deptName: string
|
||||
roleNames: string[]
|
||||
createTime: string
|
||||
avatar: string
|
||||
status: 0 | 1
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export const users: MockUser[] = Array.from({ length: 36 }, (_, index) => {
|
||||
export const users: MockUser[] = Array.from({ length: 15 }, (_, index) => {
|
||||
const id = index + 1
|
||||
return {
|
||||
id,
|
||||
username: `user${String(id).padStart(3, '0')}`,
|
||||
nickname: `测试用户${id}`,
|
||||
mobile: `13${String(100000000 + id).slice(0, 9)}`,
|
||||
email: `user${id}@example.com`,
|
||||
status: id % 5 === 0 ? USER_STATUS.DISABLED : USER_STATUS.ENABLED,
|
||||
deptName: id % 3 === 0 ? '运营部' : '技术部',
|
||||
roleNames: id % 4 === 0 ? ['管理员'] : ['普通用户'],
|
||||
createTime: new Date(Date.now() - id * 86400000).toISOString(),
|
||||
mini_id: (id % 3) + 1,
|
||||
openid: `oABC${String(100000 + id)}`,
|
||||
union_id: id % 2 === 0 ? `uXYZ${id}` : '',
|
||||
nickname: `用户${id}`,
|
||||
avatar: `https://picsum.photos/seed/${id}/80`,
|
||||
status: id % 4 === 0 ? 0 : 1,
|
||||
created_at: 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 = [
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
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 },
|
||||
{
|
||||
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 },
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
+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 {
|
||||
url: string
|
||||
@@ -11,6 +17,8 @@ export interface MockRule {
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE'
|
||||
pattern: RegExp
|
||||
handler: (ctx: MockContext) => unknown
|
||||
/** 全局 mock 关闭时仍生效(用于单独 mock 新增接口) */
|
||||
alwaysMock?: boolean
|
||||
}
|
||||
|
||||
const toNumber = (value: unknown, fallback = 0) => {
|
||||
@@ -46,7 +54,16 @@ export const mockRules: MockRule[] = [
|
||||
nickname: '超级管理员',
|
||||
avatar: '',
|
||||
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',
|
||||
pattern: /^\/api\/v1\/users\/page$/,
|
||||
pattern: /^\/api\/v1\/api\/admin\/users$/,
|
||||
handler: (ctx) => {
|
||||
const { username, status } = ctx.query
|
||||
const pageNum = toNumber(ctx.query.pageNum, 1)
|
||||
const pageSize = toNumber(ctx.query.pageSize, 10)
|
||||
const page = toNumber(ctx.query.page, 1)
|
||||
const size = toNumber(ctx.query.size, 20)
|
||||
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 matchName = !username || item.username.includes(username)
|
||||
const matchStatus = !status || String(item.status) === status
|
||||
return matchName && matchStatus
|
||||
if (miniId && item.mini_id !== miniId) return false
|
||||
if (keyword && !item.nickname.includes(keyword) && !item.openid.includes(keyword)) return false
|
||||
return true
|
||||
})
|
||||
|
||||
const start = (pageNum - 1) * pageSize
|
||||
return { list: filtered.slice(start, start + pageSize), total: filtered.length }
|
||||
const start = (page - 1) * size
|
||||
return { list: filtered.slice(start, start + size), total: filtered.length }
|
||||
},
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
pattern: /^\/api\/v1\/users$/,
|
||||
method: 'PUT',
|
||||
pattern: /^\/api\/v1\/api\/admin\/users\/(\d+)$/,
|
||||
handler: (ctx) => {
|
||||
const id = users.length + 1
|
||||
const created: MockUser = {
|
||||
const target = users.find((item) => item.id === toNumber(ctx.params[0]))
|
||||
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,
|
||||
username: pickString(ctx.body.username, `user${id}`),
|
||||
nickname: pickString(ctx.body.nickname),
|
||||
mobile: pickString(ctx.body.mobile),
|
||||
email: pickString(ctx.body.email),
|
||||
name: pickString(ctx.body.name),
|
||||
description: pickString(ctx.body.description),
|
||||
app_id: pickString(ctx.body.app_id),
|
||||
app_secret: pickString(ctx.body.app_secret),
|
||||
status: toNumber(ctx.body.status, USER_STATUS.ENABLED) === 0 ? 0 : 1,
|
||||
deptName: pickString(ctx.body.deptName, '技术部'),
|
||||
roleNames: Array.isArray(ctx.body.roleNames)
|
||||
? (ctx.body.roleNames as string[])
|
||||
: ['普通用户'],
|
||||
createTime: new Date().toISOString(),
|
||||
ext: pickString(ctx.body.ext, '{}'),
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
}
|
||||
users.unshift(created)
|
||||
miniApps.unshift(created)
|
||||
return id
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'PUT',
|
||||
pattern: /^\/api\/v1\/users\/(\d+)$/,
|
||||
pattern: /^\/api\/v1\/api\/admin\/miniapps\/(\d+)$/,
|
||||
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
|
||||
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
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'DELETE',
|
||||
pattern: /^\/api\/v1\/users\/([\d,]+)$/,
|
||||
pattern: /^\/api\/v1\/api\/admin\/miniapps\/([\d,]+)$/,
|
||||
handler: (ctx) => {
|
||||
const ids = ctx.params[0].split(',').map((id) => toNumber(id))
|
||||
ids.forEach((id) => {
|
||||
const index = users.findIndex((item) => item.id === id)
|
||||
if (index > -1) users.splice(index, 1)
|
||||
const index = miniApps.findIndex((item) => item.id === id)
|
||||
if (index > -1) miniApps.splice(index, 1)
|
||||
})
|
||||
return true
|
||||
},
|
||||
},
|
||||
{
|
||||
// 统计每个小程序的用户数(有真实用户的取真实值,其余返回稳定模拟值)
|
||||
method: 'GET',
|
||||
pattern: /^\/api\/v1\/dashboard\/summary$/,
|
||||
handler: () => dashboardSummary,
|
||||
pattern: /^\/api\/v1\/api\/admin\/dashboard\/user-count-by-miniapp$/,
|
||||
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 依赖,避免污染生产依赖树。
|
||||
*/
|
||||
export function mockPlugin(enabled = true): Plugin {
|
||||
export function mockPlugin(globalMock = false): Plugin {
|
||||
return {
|
||||
name: 'portal-mock',
|
||||
apply: 'serve',
|
||||
configureServer(server) {
|
||||
if (!enabled) return
|
||||
server.middlewares.use((req, res, next) => {
|
||||
if (!req.url?.startsWith(MOCK_PREFIX)) return next()
|
||||
|
||||
@@ -30,7 +30,8 @@ export function mockPlugin(enabled = true): Plugin {
|
||||
const rule = mockRules.find(
|
||||
(item) =>
|
||||
item.method === (req.method ?? 'GET').toUpperCase() &&
|
||||
item.pattern.test(requestUrl.pathname),
|
||||
item.pattern.test(requestUrl.pathname) &&
|
||||
(globalMock || item.alwaysMock === true),
|
||||
)
|
||||
|
||||
if (!rule) return next()
|
||||
|
||||
+11
-9
@@ -1,27 +1,29 @@
|
||||
import request from '@/utils/request'
|
||||
import type { LoginForm, LoginResult, UserInfo } from '@/types/auth'
|
||||
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 = {
|
||||
/** 登录 */
|
||||
/** 登录,返回 JWT(对应后端 POST /api/admin/login) */
|
||||
login(data: LoginForm) {
|
||||
return request<LoginResult>({ url: `${BASE_URL}/login`, method: 'post', data })
|
||||
return request<LoginResult>({ url: `${ADMIN_BASE}/login`, method: 'post', data })
|
||||
},
|
||||
|
||||
/** 登出 */
|
||||
/** 后端未提供登出接口,仅前端清理登录态 */
|
||||
logout() {
|
||||
return request<boolean>({ url: `${BASE_URL}/logout`, method: 'post' })
|
||||
return Promise.resolve(true)
|
||||
},
|
||||
|
||||
/** 获取当前登录用户信息(含角色与权限) */
|
||||
/** 当前登录管理员信息(对应后端 GET /api/admin/info) */
|
||||
getMe() {
|
||||
return request<UserInfo>({ url: `${BASE_URL}/me`, method: 'get' })
|
||||
return request<UserInfo>({ url: `${ADMIN_BASE}/info`, method: 'get' })
|
||||
},
|
||||
|
||||
/** 获取当前用户可访问的菜单路由 */
|
||||
/** 后端未提供菜单接口,返回前端静态路由(含小程序管理) */
|
||||
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 type { DashboardSummary } from '@/types/dashboard'
|
||||
import type { MiniAppUserCountVO } from '@/types/dashboard'
|
||||
|
||||
const BASE_URL = '/dashboard'
|
||||
const BASE_URL = '/api/admin/dashboard'
|
||||
|
||||
export const DashboardAPI = {
|
||||
/** 首页汇总数据 */
|
||||
summary() {
|
||||
return request<DashboardSummary>({ url: `${BASE_URL}/summary`, method: 'get' })
|
||||
/** 统计每个小程序的用户数量 */
|
||||
statUserCountByMiniApp() {
|
||||
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 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 = {
|
||||
/** 分页查询用户列表 */
|
||||
/** 分页查询小程序端用户列表(pageNum/pageSize 映射为后端 page/size) */
|
||||
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' })
|
||||
},
|
||||
|
||||
/** 新增用户 */
|
||||
create(data: UserForm) {
|
||||
return request<number>({ url: BASE_URL, method: 'post', data })
|
||||
},
|
||||
|
||||
/** 修改用户 */
|
||||
/** 编辑用户(昵称 / 头像 / 状态,按需提供) */
|
||||
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) {
|
||||
return request<boolean>({ url: `${BASE_URL}/${ids}`, method: 'delete' })
|
||||
/** 禁用 / 启用用户 */
|
||||
updateStatus(id: number, status: UserStatus) {
|
||||
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 },
|
||||
]
|
||||
+16
-8
@@ -4,6 +4,19 @@ import { usePermissionStore, useUserStore } from '@/store'
|
||||
|
||||
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[] = [
|
||||
{
|
||||
@@ -15,15 +28,10 @@ export const constantRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/layouts/index.vue'),
|
||||
redirect: '/dashboard',
|
||||
children: [
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'Dashboard',
|
||||
component: () => import('@/views/dashboard/index.vue'),
|
||||
meta: { title: '仪表盘', icon: 'Odometer' },
|
||||
redirect: () => {
|
||||
const permissionStore = usePermissionStore()
|
||||
return findFirstRoutePath(permissionStore.routes) ?? '/login'
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -3,6 +3,21 @@ import { AuthAPI } from '@/api/auth'
|
||||
import type { LoginForm, UserInfo } from '@/types/auth'
|
||||
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', () => {
|
||||
/** Token 初始从本地恢复,避免刷新后丢失登录态 */
|
||||
const token = ref(getToken())
|
||||
@@ -15,17 +30,37 @@ export const useUserStore = defineStore('user', () => {
|
||||
/** 登录 */
|
||||
async function login(data: LoginForm) {
|
||||
const result = await AuthAPI.login(data)
|
||||
token.value = result.accessToken
|
||||
setToken(result.accessToken)
|
||||
token.value = result.token
|
||||
setToken(result.token)
|
||||
}
|
||||
|
||||
/** 拉取用户信息(角色 + 权限码) */
|
||||
async function getUserInfo() {
|
||||
try {
|
||||
const data = await AuthAPI.getMe()
|
||||
userInfo.value = data
|
||||
roles.value = data.roles ?? []
|
||||
permissions.value = data.permissions ?? []
|
||||
return data
|
||||
const raw = data as UserInfo & { admin_id?: string }
|
||||
userInfo.value = {
|
||||
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
|
||||
}
|
||||
|
||||
/** 登录响应 */
|
||||
/** 登录响应(对应后端 data.token) */
|
||||
export interface LoginResult {
|
||||
accessToken: string
|
||||
refreshToken?: string
|
||||
tokenType: string
|
||||
expiresIn?: number
|
||||
token: string
|
||||
}
|
||||
|
||||
/** 当前登录用户 */
|
||||
|
||||
@@ -12,6 +12,12 @@ export interface PageQuery {
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
/** RBAC 分页参数(后端使用 page / size) */
|
||||
export interface RbacPageQuery {
|
||||
page: number
|
||||
size: number
|
||||
}
|
||||
|
||||
/** 分页响应结构 */
|
||||
export interface PageResult<T> {
|
||||
list: T[]
|
||||
|
||||
+8
-12
@@ -1,13 +1,9 @@
|
||||
/** 仪表盘汇总数据 */
|
||||
export interface DashboardSummary {
|
||||
userTotal: number
|
||||
orderTotal: number
|
||||
gmv: number
|
||||
pendingApproval: number
|
||||
trend: TrendPoint[]
|
||||
}
|
||||
|
||||
export interface TrendPoint {
|
||||
date: string
|
||||
value: number
|
||||
/** 每个小程序对应的用户数统计项 */
|
||||
export interface MiniAppUserCountVO {
|
||||
/** 小程序 ID */
|
||||
miniAppId: number
|
||||
/** 小程序名称 */
|
||||
miniAppName: string
|
||||
/** 用户数量 */
|
||||
userCount: 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'
|
||||
|
||||
/** 用户状态:1 启用 / 0 禁用 */
|
||||
/** 用户状态:1 正常 / 0 禁用 */
|
||||
export type UserStatus = 0 | 1
|
||||
|
||||
/** 用户列表查询参数 */
|
||||
/** 小程序端用户列表查询参数 */
|
||||
export interface UserQuery extends PageQuery {
|
||||
username?: string
|
||||
status?: UserStatus
|
||||
deptId?: number
|
||||
/** 按所属小程序筛选,0 或不传表示全部 */
|
||||
mini_id?: number
|
||||
/** 模糊匹配昵称 / OpenID */
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
/** 用户列表实体(后端返回) */
|
||||
/** 小程序端用户实体(后端返回) */
|
||||
export interface UserVO {
|
||||
id: number
|
||||
username: string
|
||||
mini_id: number
|
||||
openid: string
|
||||
union_id: string
|
||||
nickname: string
|
||||
mobile: string
|
||||
email: string
|
||||
avatar: string
|
||||
status: UserStatus
|
||||
deptName: string
|
||||
roleNames: string[]
|
||||
createTime: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** 新增/编辑表单 */
|
||||
/** 编辑用户表单(仅昵称 / 头像 / 状态可改) */
|
||||
export interface UserForm {
|
||||
id?: number
|
||||
username: string
|
||||
nickname: string
|
||||
mobile: string
|
||||
email: string
|
||||
nickname?: string
|
||||
avatar?: string
|
||||
status: UserStatus
|
||||
roleNames: string[]
|
||||
}
|
||||
|
||||
@@ -88,7 +88,9 @@ export default function request<T>(config: AxiosRequestConfig): Promise<T> {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
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 type { DashboardSummary } from '@/types/dashboard'
|
||||
import type { MiniAppUserCountVO } from '@/types/dashboard'
|
||||
import ServerMonitorPanel from './components/ServerMonitorPanel.vue'
|
||||
|
||||
defineOptions({ name: 'Dashboard' })
|
||||
|
||||
const loading = ref(false)
|
||||
const summary = ref<DashboardSummary>()
|
||||
const list = ref<MiniAppUserCountVO[]>([])
|
||||
|
||||
const cards = computed(() => [
|
||||
{ label: '用户总数', value: summary.value?.userTotal ?? 0, icon: 'User', color: '#409eff' },
|
||||
{ label: '订单总数', value: summary.value?.orderTotal ?? 0, icon: 'Tickets', color: '#67c23a' },
|
||||
{ label: '交易额', value: summary.value?.gmv ?? 0, icon: 'Money', color: '#e6a23c' },
|
||||
{
|
||||
label: '待审批',
|
||||
value: summary.value?.pendingApproval ?? 0,
|
||||
icon: 'Clock',
|
||||
color: '#f56c6c',
|
||||
},
|
||||
])
|
||||
|
||||
/** 趋势柱状图最大高度基准 */
|
||||
const maxTrendValue = computed(() =>
|
||||
Math.max(1, ...(summary.value?.trend ?? []).map((item) => item.value)),
|
||||
const totalUsers = computed(() => list.value.reduce((sum, item) => sum + item.userCount, 0))
|
||||
const miniAppCount = computed(() => list.value.length)
|
||||
const topMiniApp = computed(() =>
|
||||
[...list.value].sort((a, b) => b.userCount - a.userCount)[0] ?? null,
|
||||
)
|
||||
|
||||
async function loadSummary() {
|
||||
const chartData = computed(() =>
|
||||
list.value.map((item) => ({ label: item.miniAppName, value: item.userCount })),
|
||||
)
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
try {
|
||||
summary.value = await DashboardAPI.summary()
|
||||
list.value = await DashboardAPI.statUserCountByMiniApp()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadSummary)
|
||||
onMounted(fetchData)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="loading" class="app-container dashboard">
|
||||
<el-row :gutter="16">
|
||||
<el-col v-for="card in cards" :key="card.label" :xs="24" :sm="12" :lg="6">
|
||||
<el-card class="dashboard__card" shadow="never">
|
||||
<div class="dashboard__card-body">
|
||||
<el-icon :style="{ color: card.color }"><component :is="card.icon" /></el-icon>
|
||||
<div>
|
||||
<p class="dashboard__label">{{ card.label }}</p>
|
||||
<p class="dashboard__value">{{ card.value }}</p>
|
||||
<div class="app-container">
|
||||
<!-- 概览卡片 -->
|
||||
<el-row :gutter="16" class="overview">
|
||||
<el-col :span="8">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<el-icon class="stat-card__icon" :style="{ color: 'var(--el-color-primary)' }">
|
||||
<Menu />
|
||||
</el-icon>
|
||||
<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>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-card class="dashboard__trend" shadow="never">
|
||||
<template #header>近 7 日趋势</template>
|
||||
<div class="dashboard__chart">
|
||||
<div v-for="point in summary?.trend ?? []" :key="point.date" class="dashboard__bar">
|
||||
<div
|
||||
class="dashboard__bar-inner"
|
||||
:style="{ height: `${(point.value / maxTrendValue) * 100}%` }"
|
||||
/>
|
||||
<span class="dashboard__bar-label">{{ point.date }}</span>
|
||||
</div>
|
||||
<!-- 柱状图 -->
|
||||
<el-card v-loading="loading" shadow="never" class="chart-card">
|
||||
<template #header>
|
||||
<div class="chart-card__header">
|
||||
<span><el-icon><DataBoard /></el-icon> 各小程序用户数量</span>
|
||||
<el-button :icon="Refresh" text @click="fetchData">刷新</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<BarChart :data="chartData" value-unit="人" color="var(--el-color-primary)" height="360px" />
|
||||
</el-card>
|
||||
|
||||
<!-- 服务监控 -->
|
||||
<ServerMonitorPanel />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.dashboard {
|
||||
&__card {
|
||||
.overview {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
&__card-body {
|
||||
.stat-card {
|
||||
border: none;
|
||||
|
||||
:deep(.el-card__body) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.el-icon {
|
||||
font-size: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
&__label {
|
||||
margin: 0;
|
||||
.stat-card__icon {
|
||||
font-size: 40px;
|
||||
}
|
||||
|
||||
.stat-card__value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: $text-color-primary;
|
||||
}
|
||||
|
||||
.stat-card__label {
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
color: $text-color-secondary;
|
||||
}
|
||||
|
||||
&__value {
|
||||
margin: 4px 0 0;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__chart {
|
||||
.chart-card__header {
|
||||
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%;
|
||||
}
|
||||
justify-content: space-between;
|
||||
|
||||
&__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;
|
||||
span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 600;
|
||||
color: $text-color-primary;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
const loading = ref(false)
|
||||
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> = {
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
@@ -70,7 +70,7 @@
|
||||
登 录
|
||||
</el-button>
|
||||
</el-form>
|
||||
<p class="login__tip">演示账号:admin / 123456(本地 mock 不校验密码)</p>
|
||||
<p class="login__tip">演示账号:admin / admin123</p>
|
||||
</el-card>
|
||||
</div>
|
||||
</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 formData = reactive<UserFormData>({
|
||||
username: '',
|
||||
nickname: '',
|
||||
mobile: '',
|
||||
email: '',
|
||||
avatar: '',
|
||||
status: UserStatusEnum.ENABLED,
|
||||
roleNames: [],
|
||||
})
|
||||
|
||||
const title = computed(() => (formData.id ? '编辑用户' : '新增用户'))
|
||||
|
||||
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' }],
|
||||
mobile: [{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }],
|
||||
email: [{ type: 'email', message: '请输入正确的邮箱', trigger: 'blur' }],
|
||||
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
Object.assign(formData, {
|
||||
id: undefined,
|
||||
username: '',
|
||||
nickname: '',
|
||||
mobile: '',
|
||||
email: '',
|
||||
avatar: '',
|
||||
status: UserStatusEnum.ENABLED,
|
||||
roleNames: [],
|
||||
})
|
||||
void nextTick(() => formRef.value?.clearValidate())
|
||||
}
|
||||
|
||||
async function open(id?: number) {
|
||||
async function open(id: number) {
|
||||
visible.value = true
|
||||
resetForm()
|
||||
if (!id) return
|
||||
|
||||
const detail = await UserAPI.detail(id)
|
||||
Object.assign(formData, {
|
||||
id: detail.id,
|
||||
username: detail.username,
|
||||
nickname: detail.nickname,
|
||||
mobile: detail.mobile,
|
||||
email: detail.email,
|
||||
avatar: detail.avatar,
|
||||
status: detail.status,
|
||||
roleNames: detail.roleNames ?? [],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -73,8 +55,6 @@
|
||||
try {
|
||||
if (formData.id) {
|
||||
await UserAPI.update(formData.id, formData)
|
||||
} else {
|
||||
await UserAPI.create(formData)
|
||||
}
|
||||
ElMessage.success('保存成功')
|
||||
visible.value = false
|
||||
@@ -88,19 +68,16 @@
|
||||
</script>
|
||||
|
||||
<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-item label="用户名" prop="username">
|
||||
<el-input v-model="formData.username" placeholder="请输入用户名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="昵称" prop="nickname">
|
||||
<el-input v-model="formData.nickname" placeholder="请输入昵称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="手机号" prop="mobile">
|
||||
<el-input v-model="formData.mobile" placeholder="请输入手机号" />
|
||||
<el-form-item label="头像" prop="avatar">
|
||||
<el-input v-model="formData.avatar" placeholder="请输入头像 URL" />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱" prop="email">
|
||||
<el-input v-model="formData.email" placeholder="请输入邮箱" />
|
||||
<el-form-item v-if="formData.avatar" label="预览">
|
||||
<el-avatar :src="formData.avatar" :size="48" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-radio-group v-model="formData.status">
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { Delete, Plus, Refresh, Search } from '@element-plus/icons-vue'
|
||||
import { computed } from 'vue'
|
||||
import { Refresh, Search } from '@element-plus/icons-vue'
|
||||
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 UserForm from './components/UserForm.vue'
|
||||
|
||||
@@ -13,18 +12,15 @@
|
||||
const loading = ref(false)
|
||||
const total = ref(0)
|
||||
const list = ref<UserVO[]>([])
|
||||
const selectedIds = ref<number[]>([])
|
||||
|
||||
const queryParams = reactive<UserQuery>({
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
username: '',
|
||||
status: undefined,
|
||||
mini_id: undefined,
|
||||
keyword: '',
|
||||
})
|
||||
|
||||
const selectedIdStr = computed(() => selectedIds.value.join(','))
|
||||
|
||||
/** 分页参数变化后需要重新拉取数据 */
|
||||
/** 分页参数变化后重新拉取数据 */
|
||||
watch([() => queryParams.pageNum, () => queryParams.pageSize], handleQuery)
|
||||
|
||||
async function handleQuery() {
|
||||
@@ -39,34 +35,20 @@
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
Object.assign(queryParams, { pageNum: 1, pageSize: 10, username: '', status: undefined })
|
||||
Object.assign(queryParams, { pageNum: 1, pageSize: 10, mini_id: undefined, keyword: '' })
|
||||
handleQuery()
|
||||
}
|
||||
|
||||
function handleSelectionChange(selection: UserVO[]) {
|
||||
selectedIds.value = selection.map((item) => item.id)
|
||||
function openForm(row: UserVO) {
|
||||
formRef.value?.open(row.id)
|
||||
}
|
||||
|
||||
function openForm(row?: Partial<UserVO>) {
|
||||
formRef.value?.open(row?.id)
|
||||
}
|
||||
|
||||
async function handleDelete(row?: Partial<UserVO>) {
|
||||
const ids = row ? String(row.id) : selectedIdStr.value
|
||||
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('删除成功')
|
||||
async function handleToggleStatus(row: UserVO) {
|
||||
const next = row.status === UserStatusEnum.ENABLED ? UserStatusEnum.DISABLED : UserStatusEnum.ENABLED
|
||||
const action = next === UserStatusEnum.ENABLED ? '启用' : '禁用'
|
||||
await ElMessageBox.confirm(`确认${action}该用户吗?`, '提示', { type: 'warning' })
|
||||
await UserAPI.updateStatus(row.id, next)
|
||||
ElMessage.success(`${action}成功`)
|
||||
handleQuery()
|
||||
}
|
||||
|
||||
@@ -77,24 +59,23 @@
|
||||
<div class="app-container">
|
||||
<!-- 搜索栏 -->
|
||||
<el-form :model="queryParams" inline @submit.prevent="handleQuery">
|
||||
<el-form-item label="用户名">
|
||||
<el-form-item label="小程序ID">
|
||||
<el-input
|
||||
v-model="queryParams.username"
|
||||
placeholder="请输入用户名"
|
||||
v-model.number="queryParams.mini_id"
|
||||
placeholder="按小程序筛选,0 为全部"
|
||||
clearable
|
||||
style="width: 180px"
|
||||
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 USER_STATUS_OPTIONS"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
<el-form-item label="关键字">
|
||||
<el-input
|
||||
v-model="queryParams.keyword"
|
||||
placeholder="昵称 / OpenID"
|
||||
clearable
|
||||
style="width: 200px"
|
||||
@keyup.enter="handleQuery"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :icon="Search" @click="handleQuery">查询</el-button>
|
||||
@@ -102,47 +83,34 @@
|
||||
</el-form-item>
|
||||
</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-column type="selection" width="50" align="center" />
|
||||
<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="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="deptName" label="部门" min-width="120" />
|
||||
<el-table-column prop="mobile" label="手机号" min-width="130" />
|
||||
<el-table-column label="头像" width="80" align="center">
|
||||
<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">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === UserStatusEnum.ENABLED ? 'success' : 'info'">
|
||||
{{ row.status === UserStatusEnum.ENABLED ? '启用' : '禁用' }}
|
||||
{{ row.status === UserStatusEnum.ENABLED ? '正常' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</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">
|
||||
<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 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>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
Reference in New Issue
Block a user