permission

This commit is contained in:
2026-10-26 23:17:06 +08:00
parent c654b89647
commit d2dfb4069d
42 changed files with 3113 additions and 346 deletions
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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[])
},
}
+8 -5
View File
@@ -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',
})
},
}
+39
View File
@@ -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 } })
},
}
+16
View File
@@ -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' })
},
}
+48
View File
@@ -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 })
},
}
+32
View File
@@ -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' })
},
}
+42
View File
@@ -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 })
},
}
+19 -14
View File
@@ -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 } })
},
}
+127
View File
@@ -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>
+85
View File
@@ -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 },
},
],
},
]
+14
View File
@@ -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 },
]
+26
View File
@@ -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
View File
@@ -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'
},
],
},
]
+41 -6
View File
@@ -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
View File
@@ -8,12 +8,9 @@ export interface LoginForm {
captchaCode?: string
}
/** 登录响应 */
/** 登录响应(对应后端 data.token) */
export interface LoginResult {
accessToken: string
refreshToken?: string
tokenType: string
expiresIn?: number
token: string
}
/** 当前登录用户 */
+6
View File
@@ -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
View File
@@ -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
}
+35
View File
@@ -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
}
+70
View File
@@ -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
}
+114
View File
@@ -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
View File
@@ -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[]
}
+3 -1
View File
@@ -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>
+87 -79
View File
@@ -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>
+2 -2
View File
@@ -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>
+196
View File
@@ -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>
+187
View File
@@ -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>
+156
View File
@@ -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>
+165
View File
@@ -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>
+9 -32
View File
@@ -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">
+40 -72
View File
@@ -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>