refactor: 系统权限模块重构,迁移至PostgreSQL并对齐新设计

- 切换数据库为PostgreSQL,更新依赖与配置
- 重构权限体系,使用sys_user/sys_role/sys_menu等新实体替代旧模型
- 移除旧的用户角色/权限/系统设置相关代码与实体
- 新增字典、菜单管理模块与基础CRUD接口
- 优化鉴权逻辑,基于菜单权限标识进行接口校验
- 前端同步更新API、路由与权限控制逻辑
- 删除冗余的v-permission指令与相关代码
This commit is contained in:
andyesfly committed 2026-08-19 14:11:34 +08:00
1 parent 98c22f6afb
commit 0a8103f33d
67 files changed
+3263 -1924

No files matched your search

+77
View File
@@ -0,0 +1,77 @@
<template>
<div class="placeholder-container">
<div class="placeholder-content">
<div class="placeholder-icon">
<ToolOutlined />
</div>
<h2 class="placeholder-title">功能开发中</h2>
<p class="placeholder-desc">该模块正在开发中,敬请期待</p>
<a-button type="primary" @click="goBack">
<ArrowLeftOutlined />
返回
</a-button>
</div>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { ToolOutlined, ArrowLeftOutlined } from '@ant-design/icons-vue'
const router = useRouter()
function goBack() {
if (window.history.length > 1) {
router.back()
} else {
router.push('/dashboard')
}
}
</script>
<style scoped lang="less">
.placeholder-container {
min-height: calc(100vh - 200px);
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
}
.placeholder-content {
text-align: center;
padding: 60px 40px;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.placeholder-icon {
width: 120px;
height: 120px;
margin: 0 auto 24px;
background: #e6f7ff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
:deep(.anticon) {
font-size: 64px;
color: #1890ff;
}
}
.placeholder-title {
font-size: 24px;
font-weight: 600;
color: #1f1f1f;
margin: 0 0 12px 0;
}
.placeholder-desc {
font-size: 14px;
color: #999;
margin: 0 0 32px 0;
}
</style>
@@ -4,19 +4,28 @@
<a-form layout="inline" :model="queryParams">
<a-form-item label="操作人">
<a-input
v-model:value="queryParams.operator"
placeholder="请输入操作人"
v-model:value="queryParams.userId"
placeholder="请输入用户ID"
allow-clear
style="width: 140px"
@pressEnter="handleSearch"
/>
</a-form-item>
<a-form-item label="操作模块">
<a-input
v-model:value="queryParams.module"
placeholder="请输入操作模块"
allow-clear
style="width: 160px"
@pressEnter="handleSearch"
/>
</a-form-item>
<a-form-item label="操作类型">
<a-form-item label="操作动作">
<a-select
v-model:value="queryParams.actionType"
placeholder="请选择操作类型"
v-model:value="queryParams.action"
placeholder="请选择操作动作"
allow-clear
style="width: 160px"
style="width: 140px"
>
<a-select-option value="登录">登录</a-select-option>
<a-select-option value="创建">创建</a-select-option>
@@ -63,24 +72,14 @@
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'statusCode'">
<a-tag :color="getStatusCodeColor(record.statusCode)">
{{ record.statusCode }}
</a-tag>
</template>
<template v-else-if="column.key === 'duration'">
<span :class="{ 'duration-warning': record.duration > 1000 }">
{{ record.duration }}ms
</span>
</template>
<template v-else-if="column.key === 'createdAt'">
{{ formatTime(record.createdAt) }}
</template>
<template v-else-if="column.key === 'action'">
<a-tooltip :title="record.action">
<span class="action-text">{{ record.action }}</span>
<template v-if="column.key === 'params'">
<a-tooltip :title="record.params || ''">
<span class="params-text">{{ record.params || '-' }}</span>
</a-tooltip>
</template>
<template v-else-if="column.key === 'created_at'">
{{ formatTime(record.created_at) }}
</template>
</template>
</a-table>
</a-card>
@@ -92,19 +91,20 @@ import { ref, reactive, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import { SearchOutlined, ReloadOutlined, DownloadOutlined } from '@ant-design/icons-vue'
import dayjs from 'dayjs'
import { logApi, type LogItem } from '@/api/log'
import { logApi } from '@/api/log'
const tableLoading = ref(false)
const exportLoading = ref(false)
const logList = ref<LogItem[]>([])
const logList = ref<any[]>([])
const total = ref(0)
const timeRange = ref<any[]>([])
const queryParams = reactive({
page: 1,
pageSize: 10,
operator: '',
actionType: undefined as string | undefined,
userId: '' as string | number,
module: '',
action: undefined as string | undefined,
startDate: '',
endDate: '',
})
@@ -118,23 +118,23 @@ const columns = [
},
{
title: '操作人',
dataIndex: 'username',
key: 'username',
width: 120,
ellipsis: true,
},
{
title: '操作类型',
dataIndex: 'action',
key: 'action',
width: 140,
dataIndex: 'userId',
key: 'userId',
width: 100,
ellipsis: true,
},
{
title: '操作模块',
dataIndex: 'module',
key: 'module',
width: 110,
width: 130,
ellipsis: true,
},
{
title: '操作动作',
dataIndex: 'action',
key: 'action',
width: 120,
ellipsis: true,
},
{
@@ -145,29 +145,22 @@ const columns = [
},
{
title: 'IP地址',
dataIndex: 'ipAddress',
key: 'ipAddress',
dataIndex: 'ip',
key: 'ip',
width: 140,
ellipsis: true,
},
{
title: '状态码',
dataIndex: 'statusCode',
key: 'statusCode',
width: 90,
align: 'center' as const,
},
{
title: '耗时(ms)',
dataIndex: 'duration',
key: 'duration',
width: 100,
align: 'center' as const,
title: '请求参数',
dataIndex: 'params',
key: 'params',
width: 180,
ellipsis: true,
},
{
title: '操作时间',
dataIndex: 'createdAt',
key: 'createdAt',
dataIndex: 'created_at',
key: 'created_at',
width: 180,
},
]
@@ -182,13 +175,6 @@ const paginationConfig = reactive({
showTotal: (t: number) => `共 ${t} 条记录`,
})
function getStatusCodeColor(code: number): string {
if (code >= 200 && code < 300) return 'success'
if (code >= 400 && code < 500) return 'warning'
if (code >= 500) return 'error'
return 'default'
}
function formatTime(time: string): string {
return dayjs(time).format('YYYY-MM-DD HH:mm:ss')
}
@@ -204,7 +190,14 @@ async function fetchLogList() {
queryParams.endDate = ''
}
const res: any = await logApi.getLogList(queryParams)
const requestParams: any = { ...queryParams }
if (requestParams.userId !== '') {
requestParams.userId = Number(requestParams.userId)
} else {
delete requestParams.userId
}
const res: any = await logApi.getLogList(requestParams)
if (res.code === 200) {
logList.value = res.data.list
total.value = res.data.total
@@ -224,8 +217,9 @@ function handleSearch() {
}
function handleReset() {
queryParams.operator = ''
queryParams.actionType = undefined
queryParams.userId = ''
queryParams.module = ''
queryParams.action = undefined
queryParams.startDate = ''
queryParams.endDate = ''
timeRange.value = []
@@ -244,8 +238,9 @@ async function handleExport() {
exportLoading.value = true
try {
const params: any = {}
if (queryParams.operator) params.operator = queryParams.operator
if (queryParams.actionType) params.actionType = queryParams.actionType
if (queryParams.userId !== '') params.userId = Number(queryParams.userId)
if (queryParams.module) params.module = queryParams.module
if (queryParams.action) params.action = queryParams.action
if (timeRange.value && timeRange.value.length === 2) {
params.startDate = timeRange.value[0].format('YYYY-MM-DD HH:mm:ss')
params.endDate = timeRange.value[1].format('YYYY-MM-DD HH:mm:ss')
@@ -292,12 +287,7 @@ onMounted(() => {
}
}
.duration-warning {
color: #cf1322;
font-weight: 500;
}
.action-text {
.params-text {
max-width: 150px;
display: inline-block;
overflow: hidden;
+9 -28
View File
@@ -68,7 +68,6 @@
import { reactive, ref, onMounted, nextTick } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { message } from 'ant-design-vue'
import axios from 'axios'
import {
UserOutlined,
LockOutlined,
@@ -121,36 +120,18 @@ async function handleLogin() {
try {
loading.value = true
const response = await axios.post('/api/auth/login', {
username: formState.username,
password: formState.password
})
await permissionStore.login(formState.username, formState.password)
const res = response.data
if (res.code === 200) {
const { accessToken, refreshToken, userInfo } = res.data
localStorage.setItem('access_token', accessToken)
localStorage.setItem('refresh_token', refreshToken)
permissionStore.setToken(accessToken)
permissionStore.setUserInfo(userInfo)
permissionStore.setPermissions(userInfo.permissions || [])
if (formState.rememberMe) {
localStorage.setItem('remembered_username', formState.username)
} else {
localStorage.removeItem('remembered_username')
}
message.success('登录成功!')
const redirect = (route.query.redirect as string) || '/'
router.push(redirect)
if (formState.rememberMe) {
localStorage.setItem('remembered_username', formState.username)
} else {
throw new Error(res.message || '登录失败')
localStorage.removeItem('remembered_username')
}
message.success('登录成功!')
const redirect = (route.query.redirect as string) || '/'
router.push(redirect)
} catch (error: any) {
console.error('登录失败:', error)
message.error(error.message || '登录失败,请检查用户名和密码')
-175
View File
@@ -1,175 +0,0 @@
<template>
<div class="basic-settings">
<a-card title="基本设置" :loading="loading">
<a-form
:model="formState"
:rules="rules"
ref="formRef"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 14 }"
>
<a-form-item label="站点名称" name="siteName">
<a-input
v-model:value="formState.siteName"
placeholder="请输入站点名称"
:maxlength="50"
show-count
/>
</a-form-item>
<a-form-item label="Logo" name="logo">
<a-upload
name="file"
list-type="picture-card"
class="logo-uploader"
:show-upload-list="false"
:before-upload="beforeUpload"
:custom-request="handleUpload"
>
<img v-if="formState.logo" :src="formState.logo" alt="logo" style="width: 100%" />
<div v-else>
<plus-outlined />
<div class="ant-upload-text">上传</div>
</div>
</a-upload>
<div class="form-tip">支持 JPG、PNG 格式,建议尺寸 200x200px</div>
</a-form-item>
<a-form-item label="会话超时时间" name="sessionTimeout">
<a-input-number
v-model:value="formState.sessionTimeout"
:min="5"
:max="1440"
style="width: 200px"
/>
<span class="form-tip">单位:分钟,范围 5-1440</span>
</a-form-item>
<a-form-item :wrapper-col="{ offset: 6, span: 14 }">
<a-button type="primary" @click="handleSave" :loading="saving">
保存设置
</a-button>
</a-form-item>
</a-form>
</a-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import { message } from 'ant-design-vue';
import { PlusOutlined } from '@ant-design/icons-vue';
import { systemApi, type BasicSettings } from '@/api/system';
const formRef = ref();
const loading = ref(false);
const saving = ref(false);
const formState = reactive<BasicSettings>({
siteName: '',
logo: '',
sessionTimeout: 120,
});
const rules = {
siteName: [
{ required: true, message: '请输入站点名称', trigger: 'blur' },
{ max: 50, message: '站点名称不能超过50个字符', trigger: 'blur' },
],
sessionTimeout: [
{ required: true, message: '请输入会话超时时间', trigger: 'blur' },
{ type: 'number', min: 5, max: 1440, message: '超时时间范围为5-1440分钟', trigger: 'blur' },
],
};
onMounted(async () => {
await loadSettings();
});
async function loadSettings() {
loading.value = true;
try {
const res: any = await systemApi.getSettings();
if (res.code === 200 && res.data?.basic) {
Object.assign(formState, res.data.basic);
}
} catch (error) {
console.error('加载设置失败:', error);
message.error('加载设置失败');
} finally {
loading.value = false;
}
}
function beforeUpload(file: File) {
const isJpgOrPng = file.type === 'image/jpeg' || file.type === 'image/png';
if (!isJpgOrPng) {
message.error('只能上传 JPG/PNG 格式的图片!');
return false;
}
const isLt2M = file.size / 1024 / 1024 < 2;
if (!isLt2M) {
message.error('图片大小不能超过 2MB!');
return false;
}
return true;
}
function handleUpload(options: any) {
const reader = new FileReader();
reader.onload = () => {
formState.logo = reader.result as string;
};
reader.readAsDataURL(options.file);
options.onSuccess();
}
async function handleSave() {
try {
await formRef.value.validateFields();
} catch {
return;
}
saving.value = true;
try {
const res: any = await systemApi.updateBasicSettings({
siteName: formState.siteName,
logo: formState.logo,
sessionTimeout: formState.sessionTimeout,
});
if (res.code === 200) {
message.success('基本设置保存成功');
}
} catch (error) {
console.error('保存失败:', error);
message.error('保存失败,请重试');
} finally {
saving.value = false;
}
}
</script>
<style scoped lang="less">
.basic-settings {
padding: 20px;
.form-tip {
margin-left: 10px;
color: #999;
font-size: 12px;
}
.logo-uploader {
:deep(.ant-upload) {
width: 128px;
height: 128px;
}
}
.ant-upload-text {
margin-top: 8px;
color: #999;
}
}
</style>
-151
View File
@@ -1,151 +0,0 @@
<template>
<div class="security-settings">
<a-card title="安全设置" :loading="loading">
<a-form
:model="formState"
:rules="rules"
ref="formRef"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 14 }"
>
<a-form-item label="密码最小长度" name="passwordMinLength">
<a-input-number
v-model:value="formState.passwordMinLength"
:min="6"
:max="20"
style="width: 200px"
/>
<span class="form-tip">范围 6-20 位</span>
</a-form-item>
<a-form-item label="最大登录尝试次数" name="maxLoginAttempts">
<a-input-number
v-model:value="formState.maxLoginAttempts"
:min="3"
:max="10"
style="width: 200px"
/>
<span class="form-tip">超过此次数将锁定账户</span>
</a-form-item>
<a-form-item label="账户锁定时长" name="lockoutDuration">
<a-input-number
v-model:value="formState.lockoutDuration"
:min="5"
:max="60"
style="width: 200px"
/>
<span class="form-tip">单位:分钟,范围 5-60</span>
</a-form-item>
<a-form-item label="IP白名单" name="ipWhitelist">
<a-select
v-model:value="formState.ipWhitelist"
mode="tags"
placeholder="输入IP地址后按回车添加"
style="width: 100%"
:token-separators="[',']"
/>
<div class="form-tip">
留空表示不限制IP访问,多个IP用回车或逗号分隔(如:192.168.1.1)
</div>
</a-form-item>
<a-form-item :wrapper-col="{ offset: 6, span: 14 }">
<a-button type="primary" @click="handleSave" :saving="saving">
保存设置
</a-button>
</a-form-item>
</a-form>
</a-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import { message } from 'ant-design-vue';
import { systemApi, type SecuritySettings } from '@/api/system';
const formRef = ref();
const loading = ref(false);
const saving = ref(false);
const formState = reactive<SecuritySettings>({
passwordMinLength: 6,
maxLoginAttempts: 5,
lockoutDuration: 30,
ipWhitelist: [],
});
const rules = {
passwordMinLength: [
{ required: true, message: '请输入密码最小长度', trigger: 'blur' },
{ type: 'number', min: 6, max: 20, message: '长度范围为6-20', trigger: 'blur' },
],
maxLoginAttempts: [
{ required: true, message: '请输入最大登录尝试次数', trigger: 'blur' },
{ type: 'number', min: 3, max: 10, message: '范围为3-10次', trigger: 'blur' },
],
lockoutDuration: [
{ required: true, message: '请输入账户锁定时长', trigger: 'blur' },
{ type: 'number', min: 5, max: 60, message: '范围为5-60分钟', trigger: 'blur' },
],
};
onMounted(async () => {
await loadSettings();
});
async function loadSettings() {
loading.value = true;
try {
const res: any = await systemApi.getSettings();
if (res.code === 200 && res.data?.security) {
Object.assign(formState, res.data.security);
}
} catch (error) {
console.error('加载设置失败:', error);
message.error('加载设置失败');
} finally {
loading.value = false;
}
}
async function handleSave() {
try {
await formRef.value.validateFields();
} catch {
return;
}
saving.value = true;
try {
const res: any = await systemApi.updateSecuritySettings({
passwordMinLength: formState.passwordMinLength,
maxLoginAttempts: formState.maxLoginAttempts,
lockoutDuration: formState.lockoutDuration,
ipWhitelist: formState.ipWhitelist,
});
if (res.code === 200) {
message.success('安全设置保存成功');
}
} catch (error) {
console.error('保存失败:', error);
message.error('保存失败,请重试');
} finally {
saving.value = false;
}
}
</script>
<style scoped lang="less">
.security-settings {
padding: 20px;
.form-tip {
margin-left: 10px;
color: #999;
font-size: 12px;
}
}
</style>
+368
View File
@@ -0,0 +1,368 @@
<template>
<div class="dict-management">
<a-card :bordered="false">
<a-form layout="inline" :model="queryParams" class="search-form">
<a-form-item label="字典类型">
<a-select
v-model:value="queryParams.dictType"
placeholder="请选择字典类型"
allow-clear
style="width: 200px"
>
<a-select-option v-for="type in dictTypeOptions" :key="type" :value="type">{{ type }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="关键词">
<a-input v-model:value="queryParams.keyword" placeholder="字典标签/值" allow-clear style="width: 200px" @pressEnter="handleSearch" />
</a-form-item>
<a-form-item label="状态">
<a-select v-model:value="queryParams.status" placeholder="请选择状态" allow-clear style="width: 120px">
<a-select-option :value="1">启用</a-select-option>
<a-select-option :value="0">禁用</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<a-space>
<a-button type="primary" @click="handleSearch">
<template #icon><SearchOutlined /></template>
搜索
</a-button>
<a-button @click="handleReset">
<template #icon><ReloadOutlined /></template>
重置
</a-button>
</a-space>
</a-form-item>
</a-form>
</a-card>
<div class="table-operations">
<a-button type="primary" @click="handleAdd">
<template #icon><PlusOutlined /></template>
新增字典
</a-button>
</div>
<a-card :bordered="false">
<a-table
:columns="columns"
:data-source="dataSource"
:loading="loading"
:pagination="pagination"
row-key="id"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="record.status === 1 ? 'success' : 'default'">
{{ record.status === 1 ? '启用' : '禁用' }}
</a-tag>
</template>
<template v-else-if="column.key === 'created_at'">
{{ formatTime(record.created_at) }}
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleEdit(record)">
<EditOutlined />编辑
</a-button>
<a-popconfirm
title="确定删除该字典?"
ok-text="确定"
cancel-text="取消"
@confirm="handleDelete(record)"
>
<a-button type="link" size="small" danger>
<DeleteOutlined />删除
</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-card>
<a-modal
v-model:open="modalVisible"
:title="modalTitle"
:confirm-loading="modalLoading"
width="560px"
@ok="handleSubmit"
@cancel="handleCancel"
>
<a-form
ref="formRef"
:model="formData"
:rules="formRules"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 16 }"
>
<a-form-item label="字典类型" name="dictType">
<a-input v-model:value="formData.dictType" placeholder="如 sys_user_status" :disabled="isEdit" />
</a-form-item>
<a-form-item label="字典标签" name="dictLabel">
<a-input v-model:value="formData.dictLabel" placeholder="请输入字典标签" />
</a-form-item>
<a-form-item label="字典值" name="dictValue">
<a-input v-model:value="formData.dictValue" placeholder="请输入字典值" />
</a-form-item>
<a-form-item label="排序" name="sortOrder">
<a-input-number v-model:value="formData.sortOrder" :min="0" :max="999" style="width: 100%" />
</a-form-item>
<a-form-item label="状态" name="status">
<a-radio-group v-model:value="formData.status">
<a-radio :value="1">启用</a-radio>
<a-radio :value="0">禁用</a-radio>
</a-radio-group>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue';
import { message } from 'ant-design-vue';
import {
SearchOutlined,
ReloadOutlined,
PlusOutlined,
EditOutlined,
DeleteOutlined,
} from '@ant-design/icons-vue';
import { dictApi } from '@/api/dict';
import dayjs from 'dayjs';
interface DictItem {
id: number;
dictType: string;
dictLabel: string;
dictValue: string;
sortOrder: number;
status: number;
created_at: string;
[key: string]: any;
}
const loading = ref(false);
const dataSource = ref<DictItem[]>([]);
const total = ref(0);
const dictTypeOptions = ref<string[]>([]);
const queryParams = reactive({
page: 1,
pageSize: 10,
dictType: undefined as string | undefined,
keyword: '',
status: undefined as number | undefined,
});
const columns = [
{ title: '字典类型', dataIndex: 'dictType', key: 'dictType', width: 160 },
{ title: '字典标签', dataIndex: 'dictLabel', key: 'dictLabel', width: 160 },
{ title: '字典值', dataIndex: 'dictValue', key: 'dictValue', width: 160 },
{ title: '排序', dataIndex: 'sortOrder', key: 'sortOrder', width: 80, align: 'center' as const },
{ title: '状态', key: 'status', width: 90, align: 'center' as const },
{ title: '创建时间', key: 'created_at', width: 180 },
{ title: '操作', key: 'action', width: 160, fixed: 'right' as const },
];
const pagination = computed(() => ({
current: queryParams.page,
pageSize: queryParams.pageSize,
total: total.value,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (t: number) => `共 ${t} 条数据`,
}));
onMounted(() => {
fetchDictList();
fetchDictTypes();
});
async function fetchDictTypes() {
try {
const res: any = await dictApi.getDictTypes();
if (res.code === 200) {
const data = res.data || [];
dictTypeOptions.value = data.map((item: any) =>
typeof item === 'string' ? item : item.dictType || item.type || item.label
);
}
} catch (error) {
console.error('获取字典类型失败:', error);
}
}
async function fetchDictList() {
loading.value = true;
try {
const res: any = await dictApi.getDictList(queryParams);
if (res.code === 200) {
dataSource.value = res.data.list;
total.value = res.data.total;
}
} catch (error) {
message.error('获取字典列表失败');
} finally {
loading.value = false;
}
}
function handleSearch() {
queryParams.page = 1;
fetchDictList();
}
function handleReset() {
queryParams.dictType = undefined;
queryParams.keyword = '';
queryParams.status = undefined;
queryParams.page = 1;
fetchDictList();
}
function handleTableChange(pag: any) {
queryParams.page = pag.current;
queryParams.pageSize = pag.pageSize;
fetchDictList();
}
function formatTime(dateStr: string) {
if (!dateStr) return '-';
return dayjs(dateStr).format('YYYY-MM-DD HH:mm:ss');
}
const modalVisible = ref(false);
const modalLoading = ref(false);
const modalTitle = ref('');
const formRef = ref();
const isEdit = ref(false);
const editId = ref<number | null>(null);
const formData = reactive({
dictType: '',
dictLabel: '',
dictValue: '',
sortOrder: 0,
status: 1,
});
const formRules = {
dictType: [{ required: true, message: '请输入字典类型', trigger: 'blur' }],
dictLabel: [{ required: true, message: '请输入字典标签', trigger: 'blur' }],
dictValue: [{ required: true, message: '请输入字典值', trigger: 'blur' }],
};
function handleAdd() {
isEdit.value = false;
editId.value = null;
modalTitle.value = '新增字典';
resetForm();
modalVisible.value = true;
}
function handleEdit(record: DictItem) {
isEdit.value = true;
editId.value = record.id;
modalTitle.value = '编辑字典';
formData.dictType = record.dictType;
formData.dictLabel = record.dictLabel;
formData.dictValue = record.dictValue;
formData.sortOrder = record.sortOrder ?? 0;
formData.status = record.status;
modalVisible.value = true;
}
function resetForm() {
formData.dictType = '';
formData.dictLabel = '';
formData.dictValue = '';
formData.sortOrder = 0;
formData.status = 1;
formRef.value?.resetFields();
}
async function handleSubmit() {
try {
await formRef.value?.validate();
} catch {
return;
}
modalLoading.value = true;
try {
const payload: any = {
dictType: formData.dictType,
dictLabel: formData.dictLabel,
dictValue: formData.dictValue,
sortOrder: formData.sortOrder,
status: formData.status,
};
let res: any;
if (isEdit.value && editId.value) {
res = await dictApi.updateDict(editId.value, payload);
} else {
res = await dictApi.createDict(payload);
}
if (res.code === 200) {
message.success(isEdit.value ? '更新成功' : '创建成功');
modalVisible.value = false;
fetchDictList();
fetchDictTypes();
} else {
message.error(res.message || '操作失败');
}
} catch (error) {
message.error('操作失败');
} finally {
modalLoading.value = false;
}
}
function handleCancel() {
modalVisible.value = false;
resetForm();
}
async function handleDelete(record: DictItem) {
try {
const res: any = await dictApi.deleteDict(record.id);
if (res.code === 200) {
message.success('删除成功');
fetchDictList();
fetchDictTypes();
} else {
message.error(res.message || '删除失败');
}
} catch (error) {
message.error('删除失败');
}
}
</script>
<style scoped lang="less">
.dict-management {
padding: 24px;
.search-form {
margin-bottom: 16px;
}
.table-operations {
margin-bottom: 16px;
display: flex;
justify-content: space-between;
align-items: center;
}
:deep(.ant-btn-link) {
padding: 0 4px;
}
}
</style>
+335
View File
@@ -0,0 +1,335 @@
<template>
<div class="menu-management">
<a-card title="菜单管理">
<template #extra>
<a-button type="primary" @click="handleAdd()">
<template #icon><PlusOutlined /></template>
新增菜单
</a-button>
</template>
<a-table
:columns="columns"
:data-source="menuList"
:loading="loading"
:pagination="false"
:default-expand-all-rows="true"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'menuType'">
<a-tag :color="record.menuType === 1 ? 'blue' : 'green'">
{{ record.menuType === 1 ? '目录' : '菜单' }}
</a-tag>
</template>
<template v-else-if="column.dataIndex === 'visible'">
<a-tag :color="record.visible === 1 ? 'success' : 'default'">
{{ record.visible === 1 ? '显示' : '隐藏' }}
</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleAdd(record)">
<PlusOutlined />新增子菜单
</a-button>
<a-button type="link" size="small" @click="handleEdit(record)">
<EditOutlined />编辑
</a-button>
<a-popconfirm
title="确定删除该菜单?"
ok-text="确定"
cancel-text="取消"
@confirm="handleDelete(record)"
>
<a-button type="link" size="small" danger>
<DeleteOutlined />删除
</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-card>
<a-modal
v-model:open="modalVisible"
:title="modalTitle"
:confirm-loading="modalLoading"
width="640px"
@ok="handleSubmit"
@cancel="handleCancel"
>
<a-form
ref="formRef"
:model="formData"
:rules="formRules"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 16 }"
>
<a-form-item label="上级菜单" name="parentId">
<a-tree-select
v-model:value="formData.parentId"
:tree-data="parentOptions"
:field-names="{ label: 'menuName', value: 'id', children: 'children' }"
allow-clear
tree-default-expand-all
placeholder="请选择上级菜单"
/>
</a-form-item>
<a-form-item label="菜单类型" name="menuType">
<a-radio-group v-model:value="formData.menuType">
<a-radio :value="1">目录</a-radio>
<a-radio :value="2">菜单</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="菜单名称" name="menuName">
<a-input v-model:value="formData.menuName" placeholder="请输入菜单名称" />
</a-form-item>
<a-form-item label="路由路径" name="menuPath">
<a-input v-model:value="formData.menuPath" placeholder="如 /system/user" />
</a-form-item>
<a-form-item label="组件" name="component">
<a-input v-model:value="formData.component" placeholder="如 system/user/index" />
</a-form-item>
<a-form-item label="图标" name="icon">
<a-input v-model:value="formData.icon" placeholder="如 UserOutlined" />
</a-form-item>
<a-form-item label="排序" name="sortOrder">
<a-input-number v-model:value="formData.sortOrder" :min="0" :max="999" style="width: 100%" />
</a-form-item>
<a-form-item label="权限标识" name="permission">
<a-input v-model:value="formData.permission" placeholder="如 user:list" />
</a-form-item>
<a-form-item label="可见性" name="visible">
<a-switch
:checked="formData.visible === 1"
checked-children="显示"
un-checked-children="隐藏"
@change="(checked: boolean) => (formData.visible = checked ? 1 : 0)"
/>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue';
import { message } from 'ant-design-vue';
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons-vue';
import { menuApi } from '@/api/menu';
interface MenuItem {
id: number;
parentId?: number | null;
menuName: string;
menuPath?: string;
component?: string;
icon?: string;
menuType: number;
sortOrder: number;
permission?: string;
visible: number;
children?: MenuItem[];
[key: string]: any;
}
const loading = ref(false);
const menuList = ref<MenuItem[]>([]);
const columns = [
{ title: '菜单名称', dataIndex: 'menuName', key: 'menuName' },
{ title: '路由路径', dataIndex: 'menuPath', key: 'menuPath', width: 160 },
{ title: '组件', dataIndex: 'component', key: 'component', width: 180 },
{ title: '图标', dataIndex: 'icon', key: 'icon', width: 120 },
{ title: '类型', dataIndex: 'menuType', key: 'menuType', width: 80, align: 'center' as const },
{ title: '排序', dataIndex: 'sortOrder', key: 'sortOrder', width: 80, align: 'center' as const },
{ title: '权限标识', dataIndex: 'permission', key: 'permission', width: 150 },
{ title: '可见性', dataIndex: 'visible', key: 'visible', width: 90, align: 'center' as const },
{ title: '操作', key: 'action', width: 240, fixed: 'right' as const },
];
// 上级菜单树(编辑时排除自身及后代节点,防止循环引用)
const parentOptions = computed(() => {
const excludeId = isEdit.value ? editId.value : null;
const filtered = excludeId ? filterTree(menuList.value, excludeId) : menuList.value;
return [{ id: 0, menuName: '根目录', children: filtered }];
});
function filterTree(nodes: MenuItem[], excludeId: number): MenuItem[] {
const result: MenuItem[] = [];
for (const node of nodes) {
if (node.id === excludeId) continue;
const children = node.children?.length ? filterTree(node.children, excludeId) : undefined;
if (children) {
result.push({ ...node, children });
} else {
result.push(node);
}
}
return result;
}
const modalVisible = ref(false);
const modalLoading = ref(false);
const modalTitle = ref('');
const formRef = ref();
const isEdit = ref(false);
const editId = ref<number | null>(null);
const formData = reactive({
parentId: 0,
menuType: 2,
menuName: '',
menuPath: '',
component: '',
icon: '',
sortOrder: 0,
permission: '',
visible: 1,
});
const formRules = {
menuName: [{ required: true, message: '请输入菜单名称', trigger: 'blur' }],
};
onMounted(() => {
fetchMenuTree();
});
async function fetchMenuTree() {
loading.value = true;
try {
const res: any = await menuApi.getMenuTree();
if (res.code === 200) {
menuList.value = res.data || [];
}
} catch (error) {
message.error('获取菜单列表失败');
} finally {
loading.value = false;
}
}
function handleAdd(parent?: MenuItem) {
isEdit.value = false;
editId.value = null;
modalTitle.value = '新增菜单';
resetForm();
if (parent) {
formData.parentId = parent.id;
formData.menuType = 2;
}
modalVisible.value = true;
}
function handleEdit(record: MenuItem) {
isEdit.value = true;
editId.value = record.id;
modalTitle.value = '编辑菜单';
formData.parentId = record.parentId || 0;
formData.menuType = record.menuType;
formData.menuName = record.menuName;
formData.menuPath = record.menuPath || '';
formData.component = record.component || '';
formData.icon = record.icon || '';
formData.sortOrder = record.sortOrder ?? 0;
formData.permission = record.permission || '';
formData.visible = record.visible ? 1 : 0;
modalVisible.value = true;
}
function resetForm() {
formData.parentId = 0;
formData.menuType = 2;
formData.menuName = '';
formData.menuPath = '';
formData.component = '';
formData.icon = '';
formData.sortOrder = 0;
formData.permission = '';
formData.visible = 1;
formRef.value?.resetFields();
}
async function handleSubmit() {
try {
await formRef.value?.validate();
} catch {
return;
}
modalLoading.value = true;
try {
const payload: any = {
parentId: formData.parentId || null,
menuType: formData.menuType,
menuName: formData.menuName,
menuPath: formData.menuPath,
component: formData.component,
icon: formData.icon,
sortOrder: formData.sortOrder,
permission: formData.permission,
visible: formData.visible,
};
let res: any;
if (isEdit.value && editId.value) {
res = await menuApi.updateMenu(editId.value, payload);
} else {
res = await menuApi.createMenu(payload);
}
if (res.code === 200) {
message.success(isEdit.value ? '更新成功' : '创建成功');
modalVisible.value = false;
fetchMenuTree();
} else {
message.error(res.message || '操作失败');
}
} catch (error) {
message.error('操作失败');
} finally {
modalLoading.value = false;
}
}
function handleCancel() {
modalVisible.value = false;
resetForm();
}
async function handleDelete(record: MenuItem) {
try {
const res: any = await menuApi.deleteMenu(record.id);
if (res.code === 200) {
message.success('删除成功');
fetchMenuTree();
} else {
message.error(res.message || '删除失败');
}
} catch (error) {
message.error('删除失败');
}
}
</script>
<style scoped lang="less">
.menu-management {
padding: 20px;
:deep(.ant-btn-link) {
padding: 0 4px;
}
}
</style>
+46 -81
View File
@@ -30,16 +30,12 @@
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'name'">
<span>{{ record.name }}</span>
<template v-if="column.dataIndex === 'roleName'">
<span>{{ record.roleName }}</span>
</template>
<template v-if="column.dataIndex === 'code'">
<a-tag color="blue">{{ record.code }}</a-tag>
</template>
<template v-if="column.dataIndex === 'status'">
<a-badge :status="record.status === 1 ? 'success' : 'error'" :text="record.status === 1 ? '启用' : '禁用'" />
<template v-if="column.dataIndex === 'roleCode'">
<a-tag color="blue">{{ record.roleCode }}</a-tag>
</template>
<template v-if="column.dataIndex === 'userCount'">
@@ -88,12 +84,12 @@
:label-col="{ span: 5 }"
:wrapper-col="{ span: 17 }"
>
<a-form-item label="角色名称" name="name">
<a-input v-model:value="formData.name" placeholder="请输入角色名称" />
<a-form-item label="角色名称" name="roleName">
<a-input v-model:value="formData.roleName" placeholder="请输入角色名称" />
</a-form-item>
<a-form-item label="角色编码" name="code">
<a-form-item label="角色编码" name="roleCode">
<a-input
v-model:value="formData.code"
v-model:value="formData.roleCode"
placeholder="请输入角色编码"
:disabled="isEdit"
/>
@@ -104,12 +100,6 @@
<a-form-item label="排序" name="sortOrder">
<a-input-number v-model:value="formData.sortOrder" :min="0" :max="999" style="width: 100%" />
</a-form-item>
<a-form-item label="状态" name="status">
<a-radio-group v-model:value="formData.status">
<a-radio :value="1">启用</a-radio>
<a-radio :value="0">禁用</a-radio>
</a-radio-group>
</a-form-item>
</a-form>
</a-modal>
@@ -124,17 +114,17 @@
<a-tree
v-if="!permissionTreeLoading"
v-model:checkedKeys="checkedKeys"
:tree-data="permissionTree"
:tree-data="menuTree"
checkable
:field-names="{ title: 'name', key: 'id', children: 'children' }"
:check-strictly="false"
:field-names="{ title: 'menuName', key: 'id', children: 'children' }"
:default-expand-all="true"
style="max-height: 500px; overflow-y: auto;"
>
<template #title="{ name, icon }">
<template #title="{ menuName, menuType }">
<span>
<component v-if="icon" :is="icon" style="margin-right: 8px" />
{{ name }}
<a-tag v-if="menuType === 1" color="blue">目录</a-tag>
<a-tag v-else color="green">菜单</a-tag>
{{ menuName }}
</span>
</template>
</a-tree>
@@ -153,9 +143,17 @@ import {
EditOutlined,
DeleteOutlined,
} from '@ant-design/icons-vue';
import { roleApi, type RoleInfo, type PermissionTreeNode } from '@/api/role';
import { roleApi, type RoleInfo } from '@/api/role';
import dayjs from 'dayjs';
interface MenuTreeNode {
id: number;
menuName: string;
menuType?: number;
children?: MenuTreeNode[];
[key: string]: any;
}
const loading = ref(false);
const roleList = ref<RoleInfo[]>([]);
const keyword = ref('');
@@ -171,13 +169,13 @@ const pagination = reactive({
const columns = [
{
title: '角色名称',
dataIndex: 'name',
key: 'name',
dataIndex: 'roleName',
key: 'roleName',
},
{
title: '角色编码',
dataIndex: 'code',
key: 'code',
dataIndex: 'roleCode',
key: 'roleCode',
width: 150,
},
{
@@ -186,13 +184,6 @@ const columns = [
key: 'description',
ellipsis: true,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
align: 'center' as const,
},
{
title: '关联用户数',
dataIndex: 'userCount',
@@ -259,16 +250,15 @@ const currentRoleId = ref<number | null>(null);
const formModalTitle = computed(() => (isEdit.value ? '编辑角色' : '新增角色'));
const formData = reactive({
name: '',
code: '',
roleName: '',
roleCode: '',
description: '',
sortOrder: 0,
status: 1,
});
const formRules = {
name: [{ required: true, message: '请输入角色名称', trigger: 'blur' }],
code: [{ required: true, message: '请输入角色编码', trigger: 'blur' }],
roleName: [{ required: true, message: '请输入角色名称', trigger: 'blur' }],
roleCode: [{ required: true, message: '请输入角色编码', trigger: 'blur' }],
};
function handleAdd() {
@@ -281,20 +271,18 @@ function handleAdd() {
function handleEdit(record: RoleInfo) {
isEdit.value = true;
currentRoleId.value = record.id;
formData.name = record.name;
formData.code = record.code;
formData.roleName = record.roleName;
formData.roleCode = record.roleCode;
formData.description = record.description || '';
formData.sortOrder = record.sortOrder;
formData.status = record.status;
formModalVisible.value = true;
}
function resetForm() {
formData.name = '';
formData.code = '';
formData.roleName = '';
formData.roleCode = '';
formData.description = '';
formData.sortOrder = 0;
formData.status = 1;
formRef.value?.resetFields();
}
@@ -310,18 +298,16 @@ async function handleSubmitForm() {
let res: any;
if (isEdit.value && currentRoleId.value) {
res = await roleApi.updateRole(currentRoleId.value, {
name: formData.name,
roleName: formData.roleName,
description: formData.description,
sortOrder: formData.sortOrder,
status: formData.status,
});
} else {
res = await roleApi.createRole({
name: formData.name,
code: formData.code,
roleName: formData.roleName,
roleCode: formData.roleCode,
description: formData.description,
sortOrder: formData.sortOrder,
status: formData.status,
});
}
@@ -360,60 +346,39 @@ function handleViewUsers(_record: RoleInfo) {
const permissionModalVisible = ref(false);
const permissionLoading = ref(false);
const permissionTreeLoading = ref(false);
const permissionTree = ref<PermissionTreeNode[]>([]);
const menuTree = ref<MenuTreeNode[]>([]);
const checkedKeys = ref<number[]>([]);
async function handlePermission(record: RoleInfo) {
currentRoleId.value = record.id;
permissionModalVisible.value = true;
checkedKeys.value = [];
await loadPermissionTree();
await loadMenuTree();
}
async function loadPermissionTree() {
async function loadMenuTree() {
permissionTreeLoading.value = true;
try {
if (!currentRoleId.value) return;
const res: any = await roleApi.getRolePermissions(currentRoleId.value);
const res: any = await roleApi.getRoleMenus(currentRoleId.value);
if (res.code === 200) {
permissionTree.value = res.data;
extractCheckedKeys(res.data);
menuTree.value = res.data.menuTree || [];
checkedKeys.value = res.data.menuIds || [];
}
} catch (error) {
message.error('获取权限数据失败');
message.error('获取菜单权限失败');
} finally {
permissionTreeLoading.value = false;
}
}
function extractCheckedKeys(tree: PermissionTreeNode[]) {
const keys: number[] = [];
function traverse(nodes: PermissionTreeNode[]) {
for (const node of nodes) {
if (node.checked) {
keys.push(node.id);
}
if (node.children?.length) {
traverse(node.children);
}
}
}
traverse(tree);
checkedKeys.value = keys;
}
async function handleSavePermissions() {
if (!currentRoleId.value) return;
permissionLoading.value = true;
try {
const res: any = await roleApi.updateRolePermissions(
currentRoleId.value,
checkedKeys.value as unknown as number[]
);
const res: any = await roleApi.updateRoleMenus(currentRoleId.value, checkedKeys.value);
if (res.code === 200) {
message.success('权限保存成功');
+75 -89
View File
@@ -1,64 +1,53 @@
<template>
<div class="user-management">
<a-card :bordered="false">
<a-form layout="inline" :model="searchForm" class="search-form">
<a-form-item label="关键词">
<a-input v-model:value="searchForm.keyword" placeholder="用户名/昵称/手机号" allow-clear style="width: 220px" @pressEnter="handleSearch" />
</a-form-item>
<a-form-item label="状态">
<a-select v-model:value="searchForm.status" placeholder="请选择状态" allow-clear style="width: 120px">
<a-select-option :value="1">启用</a-select-option>
<a-select-option :value="0">禁用</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<a-space>
<a-button type="primary" @click="handleSearch">
<template #icon><SearchOutlined /></template>
搜索
</a-button>
<a-button @click="handleReset">
<template #icon><ReloadOutlined /></template>
重置
</a-button>
</a-space>
</a-form-item>
</a-form>
</a-card>
<a-card title="用户管理">
<template #extra>
<a-space>
<a-button type="primary" @click="handleAdd">
<template #icon><PlusOutlined /></template>
新增用户
</a-button>
<a-button danger :disabled="!selectedRowKeys.length" @click="handleBatchDelete">
<template #icon><DeleteOutlined /></template>
批量删除 ({{ selectedRowKeys.length }})
</a-button>
</a-space>
</template>
<div class="table-operations">
<a-space>
<a-button type="primary" v-permission="'user:create'" @click="handleAdd">
<template #icon><PlusOutlined /></template>
新增用户
<div class="search-bar">
<a-input
v-model:value="searchForm.keyword"
placeholder="用户名/姓名/手机号"
allow-clear
style="width: 220px"
@pressEnter="handleSearch"
>
<template #prefix><SearchOutlined /></template>
</a-input>
<a-select v-model:value="searchForm.status" placeholder="状态" allow-clear style="width: 120px">
<a-select-option :value="1">启用</a-select-option>
<a-select-option :value="0">禁用</a-select-option>
</a-select>
<a-button @click="handleReset">
<template #icon><ReloadOutlined /></template>
重置
</a-button>
<a-button danger v-permission="'user:delete'" :disabled="!selectedRowKeys.length" @click="handleBatchDelete">
<template #icon><DeleteOutlined /></template>
批量删除 ({{ selectedRowKeys.length }})
</a-button>
</a-space>
</div>
</div>
<a-card :bordered="false">
<a-table
:columns="columns"
:data-source="dataSource"
:row-selection="rowSelection"
:loading="loading"
:pagination="pagination"
:scroll="{ x: 1200 }"
row-key="id"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'avatar'">
<a-avatar :src="record.avatar" :size="40">{{ record.nickname?.charAt(0) }}</a-avatar>
</template>
<template v-if="column.key === 'roles'">
<a-space>
<a-tag v-for="role in record.userRoles" :key="role.roleId" :color="getRoleColor(role.role?.code)">
{{ role.role?.name }}
<a-tag v-for="role in record.userRoles" :key="role.roleId" :color="getRoleColor(role.role?.roleCode)">
{{ role.role?.roleName }}
</a-tag>
</a-space>
</template>
@@ -69,18 +58,21 @@
checked-children="启用"
un-checked-children="禁用"
@change="(checked: boolean) => handleStatusChange(record.id, checked ? 1 : 0)"
v-permission="'user:update'"
/>
</template>
<template v-if="column.key === 'created_at'">
{{ formatDateTime(record.created_at) }}
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" v-permission="'user:update'" @click="handleEdit(record)">
<a-button type="link" size="small" @click="handleEdit(record)">
<template #icon><EditOutlined /></template>
编辑
</a-button>
<a-popconfirm title="确定要删除该用户吗?" ok-text="确定" cancel-text="取消" @confirm="handleDelete(record.id)">
<a-button type="link" size="small" danger v-permission="'user:delete'">
<a-button type="link" size="small" danger>
<template #icon><DeleteOutlined /></template>
删除
</a-button>
@@ -126,8 +118,8 @@
<a-button type="link" size="small" @click="showPassword = true">修改密码</a-button>
</a-form-item>
<a-form-item label="昵称" name="nickname">
<a-input v-model:value="formData.nickname" placeholder="请输入昵称" />
<a-form-item label="姓名" name="realName">
<a-input v-model:value="formData.realName" placeholder="请输入姓名" />
</a-form-item>
<a-form-item label="手机号" name="phone">
@@ -169,6 +161,7 @@ import {
EditOutlined,
} from '@ant-design/icons-vue';
import { userApi, type UserInfo, type UserQuery, type RoleInfo } from '@/api/user';
import dayjs from 'dayjs';
const loading = ref(false);
const dataSource = ref<UserInfo[]>([]);
@@ -193,7 +186,7 @@ const formData = reactive({
username: '',
password: '',
confirmPassword: '',
nickname: '',
realName: '',
phone: '',
email: '',
roleIds: [] as number[],
@@ -214,25 +207,25 @@ const formRules = {
{ min: 6, message: '密码长度不能小于6位', trigger: 'blur' },
],
confirmPassword: [{ required: true, message: '请确认密码', trigger: 'blur' }, { validator: validateConfirmPassword, trigger: 'blur' }],
nickname: [{ required: true, message: '请输入昵称', trigger: 'blur' }],
realName: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
phone: [{ pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }],
email: [{ type: 'email' as const, message: '邮箱格式不正确', trigger: 'blur' }],
roleIds: [{ required: true, message: '请选择角色', trigger: 'change', type: 'array' as const }],
};
const columns = [
{ title: '用户名', dataIndex: 'username', key: 'username', width: 120 },
{ title: '昵称', dataIndex: 'nickname', key: 'nickname', width: 120 },
{ title: '手机号', dataIndex: 'phone', key: 'phone', width: 130 },
{ title: '邮箱', dataIndex: 'email', key: 'email', width: 180 },
{ title: '角色', key: 'roles', width: 200 },
{ title: '用户名', dataIndex: 'username', key: 'username', ellipsis: true },
{ title: '姓名', dataIndex: 'realName', key: 'realName', ellipsis: true },
{ title: '手机号', dataIndex: 'phone', key: 'phone', ellipsis: true },
{ title: '邮箱', dataIndex: 'email', key: 'email', ellipsis: true },
{ title: '角色', key: 'roles' },
{
title: '状态',
key: 'status',
width: 100,
align: 'center' as const,
width: 80,
},
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 180 },
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', ellipsis: true },
{ title: '操作', key: 'action', width: 160, fixed: 'right' as const },
];
@@ -277,7 +270,7 @@ async function fetchRoleList() {
const res: any = await userApi.getRoleList();
if (res.code === 200) {
roleOptions.value = res.data.map((item: RoleInfo) => ({
label: item.name,
label: item.roleName,
value: item.id,
}));
}
@@ -321,7 +314,7 @@ function handleEdit(record: UserInfo) {
resetForm();
formData.username = record.username;
formData.nickname = record.nickname;
formData.realName = record.realName;
formData.phone = record.phone || '';
formData.email = record.email || '';
formData.status = record.status;
@@ -334,7 +327,7 @@ function resetForm() {
formData.username = '';
formData.password = '';
formData.confirmPassword = '';
formData.nickname = '';
formData.realName = '';
formData.phone = '';
formData.email = '';
formData.roleIds = [];
@@ -357,7 +350,7 @@ async function handleSubmit() {
let res: any;
if (isEdit.value && editId.value) {
const updateData: any = {
nickname: formData.nickname,
realName: formData.realName,
phone: formData.phone,
email: formData.email,
roleIds: formData.roleIds,
@@ -373,7 +366,7 @@ async function handleSubmit() {
res = await userApi.createUser({
username: formData.username,
password: formData.password,
nickname: formData.nickname,
realName: formData.realName,
phone: formData.phone,
email: formData.email,
roleIds: formData.roleIds,
@@ -463,37 +456,30 @@ function getRoleColor(code?: string): string {
};
return colorMap[code || ''] || 'default';
}
function formatDateTime(dateStr: string) {
if (!dateStr) return '-';
return dayjs(dateStr).format('YYYY-MM-DD HH:mm:ss');
}
</script>
<style scoped lang="less">
<style scoped>
.user-management {
padding: 24px;
padding: 20px;
}
.search-form {
margin-bottom: 16px;
}
.search-bar {
margin-bottom: 16px;
display: flex;
justify-content: flex-end;
gap: 8px;
}
.table-operations {
margin-bottom: 16px;
display: flex;
justify-content: space-between;
align-items: center;
}
:deep(.ant-table) {
font-size: 14px;
}
:deep(.ant-table) {
.ant-table-thead > tr > th {
background-color: #fafafa;
font-weight: 600;
}
.ant-table-tbody > tr:hover > td {
background-color: #e6f7ff;
}
}
:deep(.ant-avatar) {
background-color: #1890ff;
color: #fff;
}
:deep(.ant-btn-link) {
padding: 0 4px;
}
</style>