feat: 添加完整的前端管理系统 (VbenAdmin)

- 添加基于 VbenAdmin + Vue3 + Element Plus 的前端管理系统
- 包含完整的 UI 组件库和工具链
- 支持多应用架构 (web-ele, backend-mock, playground)
- 包含完整的开发规范和配置
- 修复 admin 目录的子模块问题,确保正确提交
This commit is contained in:
万物街
2025-08-23 13:24:04 +08:00
parent 43626e5bf2
commit dc6e9baec0
1406 changed files with 133197 additions and 1 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,662 @@
<template>
<Page>
<div class="p-4">
<!-- 搜索表单 -->
<el-card class="mb-4">
<el-form :model="searchForm" inline>
<el-form-item label="菜单名称">
<el-input
v-model="searchForm.keyword"
placeholder="请输入菜单名称"
clearable
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="searchForm.status" placeholder="请选择状态" clearable>
<el-option label="显示" :value="1" />
<el-option label="隐藏" :value="0" />
</el-select>
</el-form-item>
<el-form-item label="菜单类型">
<el-select v-model="searchForm.type" placeholder="请选择类型" clearable>
<el-option label="目录" value="catalog" />
<el-option label="菜单" value="menu" />
<el-option label="按钮" value="button" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<Icon icon="ep:search" class="mr-1" />
搜索
</el-button>
<el-button @click="handleReset">
<Icon icon="ep:refresh" class="mr-1" />
重置
</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 操作按钮 -->
<el-card class="mb-4">
<div class="action-buttons">
<el-button type="primary" @click="handleAdd">
<Icon icon="ep:plus" class="mr-1" />
新增菜单
</el-button>
<el-button type="success" @click="handleExpandAll">
<Icon icon="ep:d-arrow-right" class="mr-1" />
展开全部
</el-button>
<el-button type="info" @click="handleCollapseAll">
<Icon icon="ep:d-arrow-left" class="mr-1" />
收起全部
</el-button>
</div>
<!-- 数据表格 -->
<el-table
v-loading="loading"
:data="tableData"
row-key="menuId"
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
:default-expand-all="false"
ref="tableRef"
>
<el-table-column prop="title" label="菜单名称" min-width="200">
<template #default="{ row }">
<div class="menu-title">
<Icon :icon="row.icon || 'ep:folder'" class="mr-2" />
<span>{{ row.title }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="type" label="类型" width="80">
<template #default="{ row }">
<el-tag :type="getTypeTagType(row.type)">
{{ getTypeText(row.type) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="path" label="路由路径" min-width="150" show-overflow-tooltip />
<el-table-column prop="component" label="组件路径" min-width="150" show-overflow-tooltip />
<el-table-column prop="permission" label="权限标识" min-width="150" show-overflow-tooltip />
<el-table-column prop="sort" label="排序" width="80" />
<el-table-column prop="status" label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'">
{{ row.status === 1 ? '显示' : '隐藏' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" width="180">
<template #default="{ row }">
{{ formatTime(row.createTime) }}
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button type="primary" size="small" @click="handleEdit(row)">
编辑
</el-button>
<el-button type="success" size="small" @click="handleAddChild(row)">
新增子菜单
</el-button>
<el-button
type="danger"
size="small"
@click="handleDelete(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 新增/编辑对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="800px"
@close="handleDialogClose"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="120px"
>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="上级菜单" prop="parentId">
<el-tree-select
v-model="formData.parentId"
:data="menuTreeData"
:props="treeSelectProps"
placeholder="请选择上级菜单"
check-strictly
:render-after-expand="false"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="菜单类型" prop="type">
<el-radio-group v-model="formData.type" @change="handleTypeChange">
<el-radio label="catalog">目录</el-radio>
<el-radio label="menu">菜单</el-radio>
<el-radio label="button">按钮</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="菜单名称" prop="title">
<el-input v-model="formData.title" placeholder="请输入菜单名称" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="菜单图标" prop="icon">
<el-input v-model="formData.icon" placeholder="请输入图标名称">
<template #prepend>
<Icon :icon="formData.icon || 'ep:folder'" />
</template>
</el-input>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20" v-if="formData.type !== 'button'">
<el-col :span="12">
<el-form-item label="路由路径" prop="path">
<el-input v-model="formData.path" placeholder="请输入路由路径" />
</el-form-item>
</el-col>
<el-col :span="12" v-if="formData.type === 'menu'">
<el-form-item label="组件路径" prop="component">
<el-input v-model="formData.component" placeholder="请输入组件路径" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="权限标识" prop="permission">
<el-input v-model="formData.permission" placeholder="请输入权限标识" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="排序" prop="sort">
<el-input-number v-model="formData.sort" :min="0" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20" v-if="formData.type !== 'button'">
<el-col :span="12">
<el-form-item label="是否隐藏" prop="hidden">
<el-radio-group v-model="formData.hidden">
<el-radio :label="0">显示</el-radio>
<el-radio :label="1">隐藏</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="是否缓存" prop="keepAlive">
<el-radio-group v-model="formData.keepAlive">
<el-radio :label="1">缓存</el-radio>
<el-radio :label="0">不缓存</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="状态" prop="status">
<el-radio-group v-model="formData.status">
<el-radio :label="1">启用</el-radio>
<el-radio :label="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="备注" prop="remark">
<el-input
v-model="formData.remark"
type="textarea"
:rows="3"
placeholder="请输入备注"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">
确定
</el-button>
</template>
</el-dialog>
</div>
</Page>
</template>
<script lang="ts" setup>
// 1. Vue 相关导入
import { ref, reactive, onMounted, computed, type FormInstance } from 'vue';
// 2. Element Plus 组件导入
import {
ElButton,
ElCard,
ElCol,
ElDialog,
ElForm,
ElFormItem,
ElInput,
ElInputNumber,
ElMessage,
ElMessageBox,
ElOption,
ElRadio,
ElRadioGroup,
ElRow,
ElSelect,
ElTable,
ElTableColumn,
ElTag,
ElTreeSelect,
type ElTable,
} from 'element-plus';
// 3. 图标组件导入
import { Icon } from '@iconify/vue';
// 4. Vben 组件导入
import { Page } from '@vben/common-ui';
// 5. 项目内部导入
import {
getMenuListApi,
getMenuTreeApi,
createMenuApi,
updateMenuApi,
deleteMenuApi,
type Menu,
type CreateMenuParams,
type UpdateMenuParams,
type MenuTreeNode,
} from '#/api/common/rbac';
// 响应式数据
const loading = ref(false);
const submitLoading = ref(false);
const tableData = ref<Menu[]>([]);
const menuTreeData = ref<MenuTreeNode[]>([]);
const currentParent = ref<Menu | null>(null);
// 搜索表单
const searchForm = reactive({
keyword: '',
status: undefined as number | undefined,
type: undefined as string | undefined,
});
// 对话框
const dialogVisible = ref(false);
const isEdit = ref(false);
const isAddChild = ref(false);
const formRef = ref<FormInstance>();
const tableRef = ref<InstanceType<typeof ElTable>>();
// 表单数据
const formData = reactive<CreateMenuParams & { menuId?: number }>({
parentId: 0,
title: '',
type: 'menu',
path: '',
component: '',
icon: '',
permission: '',
sort: 0,
hidden: 0,
keepAlive: 1,
status: 1,
remark: '',
});
// 树形选择器配置
const treeSelectProps = {
value: 'menuId',
label: 'title',
children: 'children',
};
// 表单验证规则
const formRules = {
title: [
{ required: true, message: '请输入菜单名称', trigger: 'blur' },
{ min: 2, max: 50, message: '菜单名称长度在 2 到 50 个字符', trigger: 'blur' },
],
type: [
{ required: true, message: '请选择菜单类型', trigger: 'change' },
],
path: [
{
validator: (rule: any, value: string, callback: any) => {
if (formData.type !== 'button' && !value) {
callback(new Error('请输入路由路径'));
} else {
callback();
}
},
trigger: 'blur',
},
],
component: [
{
validator: (rule: any, value: string, callback: any) => {
if (formData.type === 'menu' && !value) {
callback(new Error('请输入组件路径'));
} else {
callback();
}
},
trigger: 'blur',
},
],
permission: [
{ required: true, message: '请输入权限标识', trigger: 'blur' },
],
};
// 计算属性
const dialogTitle = computed(() => {
if (isAddChild.value) {
return `新增子菜单 - ${currentParent.value?.title}`;
}
return isEdit.value ? '编辑菜单' : '新增菜单';
});
// 方法
const formatTime = (timestamp: number) => {
if (!timestamp) return '-';
return new Date(timestamp * 1000).toLocaleString();
};
const getTypeText = (type: string) => {
const map = {
catalog: '目录',
menu: '菜单',
button: '按钮',
};
return map[type as keyof typeof map] || type;
};
const getTypeTagType = (type: string) => {
const map = {
catalog: 'warning',
menu: 'primary',
button: 'success',
};
return map[type as keyof typeof map] || 'info';
};
const loadData = async () => {
loading.value = true;
try {
const params = {
keyword: searchForm.keyword || undefined,
status: searchForm.status,
type: searchForm.type,
};
const result = await getMenuListApi(params);
tableData.value = result;
} catch (error) {
ElMessage.error('加载数据失败');
} finally {
loading.value = false;
}
};
const loadMenuTree = async () => {
try {
const result = await getMenuTreeApi();
// 添加根节点
menuTreeData.value = [
{
menuId: 0,
title: '根目录',
children: result,
},
];
} catch (error) {
ElMessage.error('加载菜单树失败');
}
};
const handleSearch = () => {
loadData();
};
const handleReset = () => {
searchForm.keyword = '';
searchForm.status = undefined;
searchForm.type = undefined;
handleSearch();
};
const handleExpandAll = () => {
const table = tableRef.value;
if (table) {
const expandAll = (data: Menu[]) => {
data.forEach(row => {
table.toggleRowExpansion(row, true);
if (row.children) {
expandAll(row.children);
}
});
};
expandAll(tableData.value);
}
};
const handleCollapseAll = () => {
const table = tableRef.value;
if (table) {
const collapseAll = (data: Menu[]) => {
data.forEach(row => {
table.toggleRowExpansion(row, false);
if (row.children) {
collapseAll(row.children);
}
});
};
collapseAll(tableData.value);
}
};
const handleAdd = async () => {
isEdit.value = false;
isAddChild.value = false;
currentParent.value = null;
await loadMenuTree();
resetForm();
dialogVisible.value = true;
};
const handleAddChild = async (row: Menu) => {
isEdit.value = false;
isAddChild.value = true;
currentParent.value = row;
await loadMenuTree();
resetForm();
formData.parentId = row.menuId;
dialogVisible.value = true;
};
const handleEdit = async (row: Menu) => {
isEdit.value = true;
isAddChild.value = false;
currentParent.value = null;
await loadMenuTree();
Object.assign(formData, {
menuId: row.menuId,
parentId: row.parentId,
title: row.title,
type: row.type,
path: row.path,
component: row.component,
icon: row.icon,
permission: row.permission,
sort: row.sort,
hidden: row.hidden,
keepAlive: row.keepAlive,
status: row.status,
remark: row.remark,
});
dialogVisible.value = true;
};
const handleDelete = async (row: Menu) => {
try {
await ElMessageBox.confirm(
`确定要删除菜单 "${row.title}" 吗?删除后子菜单也会被删除!`,
'确认删除',
{
type: 'warning',
}
);
await deleteMenuApi(row.menuId);
ElMessage.success('删除成功');
loadData();
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败');
}
}
};
const handleTypeChange = (type: string) => {
// 根据类型清空相关字段
if (type === 'button') {
formData.path = '';
formData.component = '';
formData.hidden = 0;
formData.keepAlive = 0;
} else if (type === 'catalog') {
formData.component = '';
}
};
const handleSubmit = async () => {
if (!formRef.value) return;
try {
await formRef.value.validate();
submitLoading.value = true;
if (isEdit.value) {
const updateData: UpdateMenuParams = {
menuId: formData.menuId!,
parentId: formData.parentId,
title: formData.title,
type: formData.type,
path: formData.path,
component: formData.component,
icon: formData.icon,
permission: formData.permission,
sort: formData.sort,
hidden: formData.hidden,
keepAlive: formData.keepAlive,
status: formData.status,
remark: formData.remark,
};
await updateMenuApi(updateData);
ElMessage.success('更新成功');
} else {
const createData: CreateMenuParams = {
parentId: formData.parentId,
title: formData.title,
type: formData.type,
path: formData.path,
component: formData.component,
icon: formData.icon,
permission: formData.permission,
sort: formData.sort,
hidden: formData.hidden,
keepAlive: formData.keepAlive,
status: formData.status,
remark: formData.remark,
};
await createMenuApi(createData);
ElMessage.success('创建成功');
}
dialogVisible.value = false;
loadData();
} catch (error) {
ElMessage.error(isEdit.value ? '更新失败' : '创建失败');
} finally {
submitLoading.value = false;
}
};
const handleDialogClose = () => {
formRef.value?.resetFields();
resetForm();
};
const resetForm = () => {
Object.assign(formData, {
menuId: undefined,
parentId: 0,
title: '',
type: 'menu',
path: '',
component: '',
icon: '',
permission: '',
sort: 0,
hidden: 0,
keepAlive: 1,
status: 1,
remark: '',
});
};
// 生命周期
onMounted(() => {
loadData();
});
</script>
<style scoped>
.menu-page {
padding: 20px;
}
.search-form {
background: #fff;
padding: 20px;
border-radius: 4px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.action-buttons {
background: #fff;
padding: 20px;
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.menu-title {
display: flex;
align-items: center;
}
</style>

View File

@@ -0,0 +1,660 @@
<template>
<Page>
<div class="p-4">
<!-- 搜索表单 -->
<el-card class="mb-4">
<el-form :model="searchForm" inline>
<el-form-item label="权限名称">
<el-input
v-model="searchForm.keyword"
placeholder="请输入权限名称或标识"
clearable
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="权限类型">
<el-select v-model="searchForm.type" placeholder="请选择类型" clearable>
<el-option label="菜单" value="menu" />
<el-option label="按钮" value="button" />
<el-option label="接口" value="api" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="searchForm.status" placeholder="请选择状态" clearable>
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<Icon icon="ep:search" class="mr-1" />
搜索
</el-button>
<el-button @click="handleReset">
<Icon icon="ep:refresh" class="mr-1" />
重置
</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 操作按钮 -->
<el-card class="mb-4">
<div class="action-buttons">
<el-button type="primary" @click="handleAdd">
<Icon icon="ep:plus" class="mr-1" />
新增权限
</el-button>
<el-button
type="danger"
:disabled="!selectedRows.length"
@click="handleBatchDelete"
>
<Icon icon="ep:delete" class="mr-1" />
批量删除
</el-button>
<el-button type="success" @click="handleSyncFromMenu">
<Icon icon="ep:refresh" class="mr-1" />
从菜单同步
</el-button>
</div>
<!-- 数据表格 -->
<el-table
v-loading="loading"
:data="tableData"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" />
<el-table-column prop="permissionId" label="ID" width="80" />
<el-table-column prop="name" label="权限名称" min-width="150" />
<el-table-column prop="code" label="权限标识" min-width="200" show-overflow-tooltip />
<el-table-column prop="type" label="类型" width="80">
<template #default="{ row }">
<el-tag :type="getTypeTagType(row.type)">
{{ getTypeText(row.type) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="resource" label="资源路径" min-width="200" show-overflow-tooltip />
<el-table-column prop="method" label="请求方法" width="100">
<template #default="{ row }">
<el-tag v-if="row.method" :type="getMethodTagType(row.method)" size="small">
{{ row.method }}
</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="menuId" label="关联菜单" width="100">
<template #default="{ row }">
<el-tag v-if="row.menuId" type="info" size="small">
{{ getMenuName(row.menuId) }}
</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="sort" label="排序" width="80" />
<el-table-column prop="status" label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" width="180">
<template #default="{ row }">
{{ formatTime(row.createTime) }}
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button type="primary" size="small" @click="handleEdit(row)">
编辑
</el-button>
<el-button
type="danger"
size="small"
@click="handleDelete(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.limit"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
<!-- 新增/编辑对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="700px"
@close="handleDialogClose"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="120px"
>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="权限名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入权限名称" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="权限标识" prop="code">
<el-input v-model="formData.code" placeholder="请输入权限标识" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="权限类型" prop="type">
<el-select v-model="formData.type" placeholder="请选择类型" @change="handleTypeChange">
<el-option label="菜单" value="menu" />
<el-option label="按钮" value="button" />
<el-option label="接口" value="api" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="关联菜单" prop="menuId">
<el-select v-model="formData.menuId" placeholder="请选择菜单" clearable filterable>
<el-option
v-for="menu in menuOptions"
:key="menu.menuId"
:label="menu.title"
:value="menu.menuId"
/>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="资源路径" prop="resource">
<el-input v-model="formData.resource" placeholder="请输入资源路径" />
</el-form-item>
</el-col>
<el-col :span="12" v-if="formData.type === 'api'">
<el-form-item label="请求方法" prop="method">
<el-select v-model="formData.method" placeholder="请选择请求方法">
<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-option label="PATCH" value="PATCH" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="排序" prop="sort">
<el-input-number v-model="formData.sort" :min="0" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="状态" prop="status">
<el-radio-group v-model="formData.status">
<el-radio :label="1">启用</el-radio>
<el-radio :label="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="描述" prop="description">
<el-input
v-model="formData.description"
type="textarea"
:rows="3"
placeholder="请输入权限描述"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">
确定
</el-button>
</template>
</el-dialog>
</div>
</Page>
</template>
<script lang="ts" setup>
// 1. Vue 相关导入
import { ref, reactive, onMounted, computed, type FormInstance } from 'vue';
// 2. Element Plus 组件导入
import {
ElButton,
ElCard,
ElCol,
ElDialog,
ElForm,
ElFormItem,
ElInput,
ElInputNumber,
ElMessage,
ElMessageBox,
ElOption,
ElPagination,
ElRadio,
ElRadioGroup,
ElRow,
ElSelect,
ElTable,
ElTableColumn,
ElTag,
} from 'element-plus';
// 3. 图标组件导入
import { Icon } from '@iconify/vue';
// 4. Vben 组件导入
import { Page } from '@vben/common-ui';
// 5. 项目内部导入
import {
getPermissionListApi,
createPermissionApi,
updatePermissionApi,
deletePermissionApi,
batchDeletePermissionApi,
syncPermissionFromMenuApi,
type Permission,
type CreatePermissionParams,
type UpdatePermissionParams,
} from '#/api/rbac';
import { getMenuListApi, type Menu } from '#/api/rbac';
// 响应式数据
const loading = ref(false);
const submitLoading = ref(false);
const tableData = ref<Permission[]>([]);
const selectedRows = ref<Permission[]>([]);
const menuOptions = ref<Menu[]>([]);
// 搜索表单
const searchForm = reactive({
keyword: '',
type: undefined as string | undefined,
status: undefined as number | undefined,
});
// 分页
const pagination = reactive({
page: 1,
limit: 20,
total: 0,
});
// 对话框
const dialogVisible = ref(false);
const isEdit = ref(false);
const formRef = ref<FormInstance>();
// 表单数据
const formData = reactive<CreatePermissionParams & { permissionId?: number }>({
name: '',
code: '',
type: 'button',
resource: '',
method: '',
menuId: undefined,
sort: 0,
status: 1,
description: '',
});
// 表单验证规则
const formRules = {
name: [
{ required: true, message: '请输入权限名称', trigger: 'blur' },
{ min: 2, max: 50, message: '权限名称长度在 2 到 50 个字符', trigger: 'blur' },
],
code: [
{ required: true, message: '请输入权限标识', trigger: 'blur' },
{ min: 2, max: 100, message: '权限标识长度在 2 到 100 个字符', trigger: 'blur' },
{ pattern: /^[a-zA-Z][a-zA-Z0-9_:.-]*$/, message: '权限标识格式不正确', trigger: 'blur' },
],
type: [
{ required: true, message: '请选择权限类型', trigger: 'change' },
],
resource: [
{ max: 200, message: '资源路径不能超过 200 个字符', trigger: 'blur' },
],
method: [
{
validator: (rule: any, value: string, callback: any) => {
if (formData.type === 'api' && !value) {
callback(new Error('接口类型权限必须选择请求方法'));
} else {
callback();
}
},
trigger: 'change',
},
],
description: [
{ max: 500, message: '描述不能超过 500 个字符', trigger: 'blur' },
],
};
// 计算属性
const dialogTitle = computed(() => (isEdit.value ? '编辑权限' : '新增权限'));
// 方法
const formatTime = (timestamp: number) => {
if (!timestamp) return '-';
return new Date(timestamp * 1000).toLocaleString();
};
const getTypeText = (type: string) => {
const map = {
menu: '菜单',
button: '按钮',
api: '接口',
};
return map[type as keyof typeof map] || type;
};
const getTypeTagType = (type: string) => {
const map = {
menu: 'primary',
button: 'success',
api: 'warning',
};
return map[type as keyof typeof map] || 'info';
};
const getMethodTagType = (method: string) => {
const map = {
GET: 'primary',
POST: 'success',
PUT: 'warning',
DELETE: 'danger',
PATCH: 'info',
};
return map[method as keyof typeof map] || 'info';
};
const getMenuName = (menuId: number) => {
const menu = menuOptions.value.find(m => m.menuId === menuId);
return menu ? menu.title : `菜单${menuId}`;
};
const loadData = async () => {
loading.value = true;
try {
const params = {
page: pagination.page,
limit: pagination.limit,
keyword: searchForm.keyword || undefined,
type: searchForm.type,
status: searchForm.status,
};
const result = await getPermissionListApi(params);
tableData.value = result.list;
pagination.total = result.total;
} catch (error) {
ElMessage.error('加载数据失败');
} finally {
loading.value = false;
}
};
const loadMenuOptions = async () => {
try {
const result = await getMenuListApi({});
// 扁平化菜单树
const flattenMenu = (menus: Menu[]): Menu[] => {
let result: Menu[] = [];
menus.forEach(menu => {
result.push(menu);
if (menu.children) {
result = result.concat(flattenMenu(menu.children));
}
});
return result;
};
menuOptions.value = flattenMenu(result);
} catch (error) {
ElMessage.error('加载菜单选项失败');
}
};
const handleSearch = () => {
pagination.page = 1;
loadData();
};
const handleReset = () => {
searchForm.keyword = '';
searchForm.type = undefined;
searchForm.status = undefined;
handleSearch();
};
const handleSizeChange = (size: number) => {
pagination.limit = size;
loadData();
};
const handleCurrentChange = (page: number) => {
pagination.page = page;
loadData();
};
const handleSelectionChange = (selection: Permission[]) => {
selectedRows.value = selection;
};
const handleAdd = async () => {
isEdit.value = false;
await loadMenuOptions();
resetForm();
dialogVisible.value = true;
};
const handleEdit = async (row: Permission) => {
isEdit.value = true;
await loadMenuOptions();
Object.assign(formData, {
permissionId: row.permissionId,
name: row.name,
code: row.code,
type: row.type,
resource: row.resource,
method: row.method,
menuId: row.menuId,
sort: row.sort,
status: row.status,
description: row.description,
});
dialogVisible.value = true;
};
const handleDelete = async (row: Permission) => {
try {
await ElMessageBox.confirm(
`确定要删除权限 "${row.name}" 吗?`,
'确认删除',
{
type: 'warning',
}
);
await deletePermissionApi(row.permissionId);
ElMessage.success('删除成功');
loadData();
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败');
}
}
};
const handleBatchDelete = async () => {
try {
await ElMessageBox.confirm(
`确定要删除选中的 ${selectedRows.value.length} 个权限吗?`,
'确认删除',
{
type: 'warning',
}
);
const permissionIds = selectedRows.value.map(row => row.permissionId);
await batchDeletePermissionApi(permissionIds);
ElMessage.success('删除成功');
loadData();
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败');
}
}
};
const handleSyncFromMenu = async () => {
try {
await ElMessageBox.confirm(
'确定要从菜单同步权限吗?这将根据菜单自动创建对应的权限。',
'确认同步',
{
type: 'info',
}
);
const result = await syncPermissionFromMenuApi();
ElMessage.success(`同步成功,新增 ${result.created} 个权限,更新 ${result.updated} 个权限`);
loadData();
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('同步失败');
}
}
};
const handleTypeChange = (type: string) => {
// 根据类型清空相关字段
if (type !== 'api') {
formData.method = '';
}
};
const handleSubmit = async () => {
if (!formRef.value) return;
try {
await formRef.value.validate();
submitLoading.value = true;
if (isEdit.value) {
const updateData: UpdatePermissionParams = {
permissionId: formData.permissionId!,
name: formData.name,
code: formData.code,
type: formData.type,
resource: formData.resource,
method: formData.method,
menuId: formData.menuId,
sort: formData.sort,
status: formData.status,
description: formData.description,
};
await updatePermissionApi(updateData);
ElMessage.success('更新成功');
} else {
const createData: CreatePermissionParams = {
name: formData.name,
code: formData.code,
type: formData.type,
resource: formData.resource,
method: formData.method,
menuId: formData.menuId,
sort: formData.sort,
status: formData.status,
description: formData.description,
};
await createPermissionApi(createData);
ElMessage.success('创建成功');
}
dialogVisible.value = false;
loadData();
} catch (error) {
ElMessage.error(isEdit.value ? '更新失败' : '创建失败');
} finally {
submitLoading.value = false;
}
};
const handleDialogClose = () => {
formRef.value?.resetFields();
resetForm();
};
const resetForm = () => {
Object.assign(formData, {
permissionId: undefined,
name: '',
code: '',
type: 'button',
resource: '',
method: '',
menuId: undefined,
sort: 0,
status: 1,
description: '',
});
};
// 生命周期
onMounted(() => {
loadData();
});
</script>
<style scoped>
.permission-page {
padding: 20px;
}
.search-form {
background: #fff;
padding: 20px;
border-radius: 4px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.action-buttons {
background: #fff;
padding: 20px;
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.pagination-wrapper {
display: flex;
justify-content: center;
margin-top: 20px;
}
</style>

View File

@@ -0,0 +1,826 @@
<template>
<Page
description="管理系统角色权限信息"
title="角色管理"
>
<div class="flex flex-col gap-4">
<!-- 搜索表单 -->
<el-card>
<el-form :model="searchForm" inline>
<el-form-item label="角色名称">
<el-input
v-model="searchForm.keyword"
placeholder="请输入角色名称"
clearable
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="searchForm.status" placeholder="请选择状态" clearable>
<el-option label="正常" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<Icon icon="ep:search" class="mr-1" />
搜索
</el-button>
<el-button @click="handleReset">
<Icon icon="ep:refresh" class="mr-1" />
重置
</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 操作按钮和数据表格 -->
<el-card>
<div class="mb-4">
<el-button type="primary" @click="handleAdd">
<Icon icon="ep:plus" class="mr-1" />
新增角色
</el-button>
<el-button
type="danger"
:disabled="!selectedRows.length"
@click="handleBatchDelete"
>
<Icon icon="ep:delete" class="mr-1" />
批量删除
</el-button>
</div>
<!-- 数据表格 -->
<el-table
v-loading="loading"
:data="tableData"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" />
<el-table-column prop="roleId" label="ID" width="80" />
<el-table-column prop="roleName" label="角色名称" min-width="150" />
<el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip />
<el-table-column prop="rules" label="权限规则" min-width="200">
<template #default="{ row }">
<el-tag v-if="row.rules === '*'" type="danger">超级管理员</el-tag>
<el-tag v-else-if="!row.rules" type="info">无权限</el-tag>
<el-tooltip v-else :content="row.rules" placement="top">
<el-tag type="primary">{{ getPermissionCount(row.rules) }}个权限</el-tag>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'">
{{ row.status === 1 ? '正常' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" width="180">
<template #default="{ row }">
{{ formatTime(row.createTime) }}
</template>
</el-table-column>
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<el-button type="primary" size="small" @click="handleEdit(row)">
编辑
</el-button>
<el-button type="warning" size="small" @click="handleSetPermission(row)">
设置权限
</el-button>
<el-button type="info" size="small" @click="handleViewUsers(row)">
查看用户
</el-button>
<el-button
type="danger"
size="small"
@click="handleDelete(row)"
:disabled="row.roleId === 1"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.limit"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
<!-- 新增/编辑对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="600px"
@close="handleDialogClose"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="100px"
>
<el-form-item label="角色名称" prop="roleName">
<el-input v-model="formData.roleName" 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 :label="1">正常</el-radio>
<el-radio :label="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">
确定
</el-button>
</template>
</el-dialog>
<!-- 权限设置对话框 -->
<el-dialog
v-model="permissionDialogVisible"
title="设置权限"
width="800px"
@close="handlePermissionDialogClose"
>
<div class="permission-content">
<div class="permission-header">
<span>为角色 "{{ currentRole?.roleName }}" 设置权限</span>
<div class="permission-actions">
<el-button size="small" @click="handleExpandAll">展开全部</el-button>
<el-button size="small" @click="handleCollapseAll">收起全部</el-button>
<el-button size="small" @click="handleCheckAll">全选</el-button>
<el-button size="small" @click="handleUncheckAll">取消全选</el-button>
</div>
</div>
<el-tree
ref="permissionTreeRef"
:data="permissionTreeData"
:props="treeProps"
show-checkbox
node-key="id"
:default-checked-keys="checkedPermissions"
:default-expand-all="false"
class="permission-tree"
>
<template #default="{ node, data }">
<div class="tree-node">
<Icon :icon="data.icon || 'ep:folder'" class="mr-2" />
<span>{{ data.title }}</span>
<el-tag v-if="data.type" size="small" class="ml-2">
{{ getNodeTypeText(data.type) }}
</el-tag>
</div>
</template>
</el-tree>
</div>
<template #footer>
<el-button @click="permissionDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSavePermission" :loading="permissionLoading">
确定
</el-button>
</template>
</el-dialog>
<!-- 用户列表对话框 -->
<el-dialog
v-model="userDialogVisible"
title="角色用户列表"
width="800px"
>
<div class="user-content">
<div class="user-header">
<span>角色 "{{ currentRole?.roleName }}" 的用户列表</span>
<el-button type="primary" size="small" @click="handleAddUser">
<Icon icon="ep:plus" class="mr-1" />
添加用户
</el-button>
</div>
<el-table v-loading="userLoading" :data="roleUsers">
<el-table-column prop="userId" label="用户ID" width="80" />
<el-table-column prop="username" label="用户名" min-width="120" />
<el-table-column prop="realName" label="真实姓名" min-width="120" />
<el-table-column prop="mobile" label="手机号" min-width="120" />
<el-table-column prop="email" label="邮箱" min-width="150" />
<el-table-column prop="status" label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'">
{{ row.status === 1 ? '正常' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="100">
<template #default="{ row }">
<el-button
type="danger"
size="small"
@click="handleRemoveUser(row)"
>
移除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-dialog>
<!-- 添加用户对话框 -->
<el-dialog
v-model="addUserDialogVisible"
title="添加用户到角色"
width="600px"
>
<el-form :model="addUserForm" label-width="100px">
<el-form-item label="选择用户">
<el-select
v-model="addUserForm.userIds"
multiple
placeholder="请选择用户"
style="width: 100%"
filterable
remote
:remote-method="searchUsers"
:loading="searchUserLoading"
>
<el-option
v-for="user in availableUsers"
:key="user.userId"
:label="`${user.username} (${user.realName})`"
:value="user.userId"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="addUserDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSaveAddUser" :loading="addUserLoading">
确定
</el-button>
</template>
</el-dialog>
</div>
</Page>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue';
import { ElMessage, ElMessageBox, type FormInstance, type ElTree } from 'element-plus';
import { Icon } from '@iconify/vue';
import { Page } from '@vben/common-ui';
import {
getRoleListApi,
createRoleApi,
updateRoleApi,
deleteRoleApi,
batchDeleteRolesApi,
getRoleUsersApi,
addRoleUserApi,
removeRoleUserApi,
getPermissionTreeApi,
type Role,
type CreateRoleParams,
type UpdateRoleParams,
type PermissionTreeNode,
} from '#/api/rbac';
import { getAdminListApi, type AdminUser } from '#/api/user';
// 响应式数据
const loading = ref(false);
const submitLoading = ref(false);
const permissionLoading = ref(false);
const userLoading = ref(false);
const addUserLoading = ref(false);
const searchUserLoading = ref(false);
const tableData = ref<Role[]>([]);
const selectedRows = ref<Role[]>([]);
const currentRole = ref<Role | null>(null);
const roleUsers = ref<AdminUser[]>([]);
const availableUsers = ref<AdminUser[]>([]);
const permissionTreeData = ref<PermissionTreeNode[]>([]);
const checkedPermissions = ref<string[]>([]);
// 搜索表单
const searchForm = reactive({
keyword: '',
status: undefined as number | undefined,
});
// 分页
const pagination = reactive({
page: 1,
limit: 20,
total: 0,
});
// 对话框
const dialogVisible = ref(false);
const permissionDialogVisible = ref(false);
const userDialogVisible = ref(false);
const addUserDialogVisible = ref(false);
const isEdit = ref(false);
const formRef = ref<FormInstance>();
const permissionTreeRef = ref<InstanceType<typeof ElTree>>();
// 表单数据
const formData = reactive<CreateRoleParams & { roleId?: number }>({
roleName: '',
description: '',
status: 1,
});
// 添加用户表单
const addUserForm = reactive({
userIds: [] as number[],
});
// 树形组件配置
const treeProps = {
children: 'children',
label: 'title',
};
// 表单验证规则
const formRules = {
roleName: [
{ required: true, message: '请输入角色名称', trigger: 'blur' },
{ min: 2, max: 50, message: '角色名称长度在 2 到 50 个字符', trigger: 'blur' },
],
description: [
{ max: 200, message: '描述不能超过 200 个字符', trigger: 'blur' },
],
};
// 计算属性
const dialogTitle = computed(() => (isEdit.value ? '编辑角色' : '新增角色'));
// 方法
const formatTime = (timestamp: number) => {
if (!timestamp) return '-';
return new Date(timestamp * 1000).toLocaleString();
};
const getPermissionCount = (rules: string) => {
if (!rules || rules === '*') return 0;
try {
const ruleArray = rules.split(',').filter(Boolean);
return ruleArray.length;
} catch {
return 0;
}
};
const getNodeTypeText = (type: string) => {
const map = {
menu: '菜单',
button: '按钮',
api: '接口',
};
return map[type as keyof typeof map] || type;
};
const loadData = async () => {
loading.value = true;
try {
const params = {
page: pagination.page,
limit: pagination.limit,
keyword: searchForm.keyword || undefined,
status: searchForm.status,
};
const result = await getRoleListApi(params);
tableData.value = result.list;
pagination.total = result.total;
} catch (error) {
ElMessage.error('加载数据失败');
} finally {
loading.value = false;
}
};
const loadPermissionTree = async () => {
try {
const result = await getPermissionTreeApi();
permissionTreeData.value = result;
} catch (error) {
ElMessage.error('加载权限树失败');
}
};
const loadRoleUsers = async (roleId: number) => {
userLoading.value = true;
try {
const result = await getRoleUsersApi(roleId);
roleUsers.value = result;
} catch (error) {
ElMessage.error('加载角色用户失败');
} finally {
userLoading.value = false;
}
};
const searchUsers = async (keyword: string) => {
if (!keyword) {
availableUsers.value = [];
return;
}
searchUserLoading.value = true;
try {
const result = await getAdminListApi({
page: 1,
limit: 20,
keyword,
});
availableUsers.value = result.list;
} catch (error) {
ElMessage.error('搜索用户失败');
} finally {
searchUserLoading.value = false;
}
};
const handleSearch = () => {
pagination.page = 1;
loadData();
};
const handleReset = () => {
searchForm.keyword = '';
searchForm.status = undefined;
handleSearch();
};
const handleSizeChange = (size: number) => {
pagination.limit = size;
loadData();
};
const handleCurrentChange = (page: number) => {
pagination.page = page;
loadData();
};
const handleSelectionChange = (selection: Role[]) => {
selectedRows.value = selection;
};
const handleAdd = () => {
isEdit.value = false;
resetForm();
dialogVisible.value = true;
};
const handleEdit = (row: Role) => {
isEdit.value = true;
Object.assign(formData, {
roleId: row.roleId,
roleName: row.roleName,
description: row.description,
status: row.status,
});
dialogVisible.value = true;
};
const handleDelete = async (row: Role) => {
if (row.roleId === 1) {
ElMessage.warning('超级管理员角色不能删除');
return;
}
try {
await ElMessageBox.confirm(
`确定要删除角色 "${row.roleName}" 吗?`,
'确认删除',
{
type: 'warning',
}
);
await deleteRoleApi(row.roleId);
ElMessage.success('删除成功');
loadData();
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败');
}
}
};
const handleBatchDelete = async () => {
const canDeleteRoles = selectedRows.value.filter(row => row.roleId !== 1);
if (canDeleteRoles.length === 0) {
ElMessage.warning('选中的角色中没有可删除的角色');
return;
}
try {
await ElMessageBox.confirm(
`确定要删除选中的 ${canDeleteRoles.length} 个角色吗?`,
'确认删除',
{
type: 'warning',
}
);
const roleIds = canDeleteRoles.map(row => row.roleId);
await batchDeleteRoleApi(roleIds);
ElMessage.success('删除成功');
loadData();
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败');
}
}
};
const handleSetPermission = async (row: Role) => {
currentRole.value = row;
await loadPermissionTree();
// 解析当前角色的权限
if (row.rules && row.rules !== '*') {
checkedPermissions.value = row.rules.split(',').filter(Boolean);
} else {
checkedPermissions.value = [];
}
permissionDialogVisible.value = true;
};
const handleViewUsers = async (row: Role) => {
currentRole.value = row;
await loadRoleUsers(row.roleId);
userDialogVisible.value = true;
};
const handleAddUser = () => {
addUserForm.userIds = [];
availableUsers.value = [];
addUserDialogVisible.value = true;
};
const handleRemoveUser = async (user: AdminUser) => {
try {
await ElMessageBox.confirm(
`确定要将用户 "${user.username}" 从角色中移除吗?`,
'确认移除',
{
type: 'warning',
}
);
await removeRoleUserApi(currentRole.value!.roleId, user.userId);
ElMessage.success('移除成功');
loadRoleUsers(currentRole.value!.roleId);
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('移除失败');
}
}
};
const handleSaveAddUser = async () => {
if (!addUserForm.userIds.length) {
ElMessage.warning('请选择要添加的用户');
return;
}
addUserLoading.value = true;
try {
await addRoleUsersApi(currentRole.value!.roleId, addUserForm.userIds);
ElMessage.success('添加成功');
addUserDialogVisible.value = false;
loadRoleUsers(currentRole.value!.roleId);
} catch (error) {
ElMessage.error('添加失败');
} finally {
addUserLoading.value = false;
}
};
const handleExpandAll = () => {
const tree = permissionTreeRef.value;
if (tree) {
const expandAll = (nodes: PermissionTreeNode[]) => {
nodes.forEach(node => {
tree.setExpanded(node.id, true);
if (node.children) {
expandAll(node.children);
}
});
};
expandAll(permissionTreeData.value);
}
};
const handleCollapseAll = () => {
const tree = permissionTreeRef.value;
if (tree) {
const collapseAll = (nodes: PermissionTreeNode[]) => {
nodes.forEach(node => {
tree.setExpanded(node.id, false);
if (node.children) {
collapseAll(node.children);
}
});
};
collapseAll(permissionTreeData.value);
}
};
const handleCheckAll = () => {
const tree = permissionTreeRef.value;
if (tree) {
const getAllNodeIds = (nodes: PermissionTreeNode[]): string[] => {
let ids: string[] = [];
nodes.forEach(node => {
ids.push(node.id);
if (node.children) {
ids = ids.concat(getAllNodeIds(node.children));
}
});
return ids;
};
const allIds = getAllNodeIds(permissionTreeData.value);
tree.setCheckedKeys(allIds);
}
};
const handleUncheckAll = () => {
const tree = permissionTreeRef.value;
if (tree) {
tree.setCheckedKeys([]);
}
};
const handleSavePermission = async () => {
const tree = permissionTreeRef.value;
if (!tree || !currentRole.value) return;
permissionLoading.value = true;
try {
const checkedKeys = tree.getCheckedKeys() as string[];
const halfCheckedKeys = tree.getHalfCheckedKeys() as string[];
const allCheckedKeys = [...checkedKeys, ...halfCheckedKeys];
await setRolePermissionApi(currentRole.value.roleId, allCheckedKeys);
ElMessage.success('权限设置成功');
permissionDialogVisible.value = false;
loadData();
} catch (error) {
ElMessage.error('权限设置失败');
} finally {
permissionLoading.value = false;
}
};
const handleSubmit = async () => {
if (!formRef.value) return;
try {
await formRef.value.validate();
submitLoading.value = true;
if (isEdit.value) {
const updateData: UpdateRoleParams = {
roleId: formData.roleId!,
roleName: formData.roleName,
description: formData.description,
status: formData.status,
};
await updateRoleApi(updateData);
ElMessage.success('更新成功');
} else {
const createData: CreateRoleParams = {
roleName: formData.roleName,
description: formData.description,
status: formData.status,
};
await createRoleApi(createData);
ElMessage.success('创建成功');
}
dialogVisible.value = false;
loadData();
} catch (error) {
ElMessage.error(isEdit.value ? '更新失败' : '创建失败');
} finally {
submitLoading.value = false;
}
};
const handleDialogClose = () => {
formRef.value?.resetFields();
resetForm();
};
const handlePermissionDialogClose = () => {
currentRole.value = null;
checkedPermissions.value = [];
permissionTreeData.value = [];
};
const resetForm = () => {
Object.assign(formData, {
roleId: undefined,
roleName: '',
description: '',
status: 1,
});
};
// 生命周期
onMounted(() => {
loadData();
});
</script>
<style scoped>
.role-page {
padding: 20px;
}
.search-form {
background: #fff;
padding: 20px;
border-radius: 4px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.action-buttons {
background: #fff;
padding: 20px;
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.pagination-wrapper {
display: flex;
justify-content: center;
margin-top: 20px;
}
.permission-content {
max-height: 500px;
overflow-y: auto;
}
.permission-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 1px solid #eee;
}
.permission-actions {
display: flex;
gap: 8px;
}
.permission-tree {
border: 1px solid #dcdfe6;
border-radius: 4px;
padding: 10px;
}
.tree-node {
display: flex;
align-items: center;
flex: 1;
}
.user-content {
max-height: 500px;
overflow-y: auto;
}
.user-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 1px solid #eee;
}
</style>

View File

@@ -0,0 +1,559 @@
<template>
<Page>
<VbenCard title="邮件设置">
<template #extra>
<Icon icon="lucide:mail" class="text-lg" />
</template>
<VbenTabs v-model:active-key="activeTab" type="card">
<!-- SMTP配置 -->
<VbenTabPane key="smtp" tab="SMTP配置">
<VbenForm @submit="handleSubmitSmtp">
<template #default="{ form }">
<VbenFormItem name="smtp_host" label="SMTP服务器">
<Input v-model:value="form.smtp_host" placeholder="请输入SMTP服务器地址" />
</VbenFormItem>
<VbenFormItem name="smtp_port" label="端口">
<InputNumber
v-model:value="form.smtp_port"
:min="1"
:max="65535"
placeholder="请输入端口号"
/>
</VbenFormItem>
<VbenFormItem name="smtp_username" label="用户名">
<Input v-model:value="form.smtp_username" placeholder="请输入邮箱用户名" />
</VbenFormItem>
<VbenFormItem name="smtp_password" label="密码">
<Input
v-model:value="form.smtp_password"
type="password"
placeholder="请输入邮箱密码或授权码"
show-password
/>
</VbenFormItem>
<VbenFormItem name="smtp_from_email" label="发件人邮箱">
<Input v-model:value="form.smtp_from_email" placeholder="请输入发件人邮箱" />
</VbenFormItem>
<VbenFormItem name="smtp_from_name" label="发件人名称">
<Input v-model:value="form.smtp_from_name" placeholder="请输入发件人名称" />
</VbenFormItem>
<VbenFormItem name="smtp_encryption" label="加密方式">
<Select v-model:value="form.smtp_encryption" placeholder="请选择加密方式">
<template #default>
<SelectOption value=""></SelectOption>
<SelectOption value="ssl">SSL</SelectOption>
<SelectOption value="tls">TLS</SelectOption>
</template>
</Select>
</VbenFormItem>
<VbenFormItem name="smtp_enabled" label="启用状态">
<Switch v-model:checked="form.smtp_enabled" />
</VbenFormItem>
</template>
<template #submit>
<Space>
<PrimaryButton html-type="submit" :loading="submitLoading">
<Icon icon="lucide:check" class="mr-1" />
保存设置
</PrimaryButton>
<DefaultButton @click="handleTestEmail" :loading="testLoading">
<Icon icon="lucide:send" class="mr-1" />
发送测试邮件
</DefaultButton>
<DefaultButton @click="handleResetSmtp">
<Icon icon="lucide:rotate-ccw" class="mr-1" />
重置
</DefaultButton>
</Space>
</template>
</VbenForm>
</VbenTabPane>
<!-- 邮件模板 -->
<VbenTabPane key="template" tab="邮件模板">
<VbenTabs v-model:active-key="templateTab" type="line">
<!-- 注册验证模板 -->
<VbenTabPane key="register" tab="注册验证">
<VbenForm @submit="handleSubmitTemplate">
<template #default="{ form }">
<VbenFormItem name="register_subject" label="邮件标题">
<Input
v-model:value="form.register_subject"
placeholder="请输入注册验证邮件标题"
/>
</VbenFormItem>
<VbenFormItem name="register_content" label="邮件内容">
<Input
v-model:value="form.register_content"
type="textarea"
:rows="6"
placeholder="请输入注册验证邮件内容,可使用变量:{username}、{code}、{expire}"
/>
</VbenFormItem>
<VbenFormItem name="register_enabled" label="启用状态">
<Switch v-model:checked="form.register_enabled" />
</VbenFormItem>
</template>
<template #submit>
<Space>
<PrimaryButton html-type="submit" :loading="submitLoading">
<Icon icon="lucide:check" class="mr-1" />
保存模板
</PrimaryButton>
<DefaultButton @click="handlePreviewTemplate('register')">
<Icon icon="lucide:eye" class="mr-1" />
预览模板
</DefaultButton>
</Space>
</template>
</VbenForm>
</VbenTabPane>
<!-- 找回密码模板 -->
<VbenTabPane key="reset" tab="找回密码">
<VbenForm @submit="handleSubmitTemplate">
<template #default="{ form }">
<VbenFormItem name="reset_subject" label="邮件标题">
<Input
v-model:value="form.reset_subject"
placeholder="请输入找回密码邮件标题"
/>
</VbenFormItem>
<VbenFormItem name="reset_content" label="邮件内容">
<Input
v-model:value="form.reset_content"
type="textarea"
:rows="6"
placeholder="请输入找回密码邮件内容,可使用变量:{username}、{code}、{expire}"
/>
</VbenFormItem>
<VbenFormItem name="reset_enabled" label="启用状态">
<Switch v-model:checked="form.reset_enabled" />
</VbenFormItem>
</template>
<template #submit>
<Space>
<PrimaryButton html-type="submit" :loading="submitLoading">
<Icon icon="lucide:check" class="mr-1" />
保存模板
</PrimaryButton>
<DefaultButton @click="handlePreviewTemplate('reset')">
<Icon icon="lucide:eye" class="mr-1" />
预览模板
</DefaultButton>
</Space>
</template>
</VbenForm>
</VbenTabPane>
<!-- 通知邮件模板 -->
<VbenTabPane key="notify" tab="通知邮件">
<VbenForm @submit="handleSubmitTemplate">
<template #default="{ form }">
<VbenFormItem name="notify_subject" label="邮件标题">
<Input
v-model:value="form.notify_subject"
placeholder="请输入通知邮件标题"
/>
</VbenFormItem>
<VbenFormItem name="notify_content" label="邮件内容">
<Input
v-model:value="form.notify_content"
type="textarea"
:rows="6"
placeholder="请输入通知邮件内容,可使用变量:{username}、{title}、{content}"
/>
</VbenFormItem>
<VbenFormItem name="notify_enabled" label="启用状态">
<Switch v-model:checked="form.notify_enabled" />
</VbenFormItem>
</template>
<template #submit>
<Space>
<PrimaryButton html-type="submit" :loading="submitLoading">
<Icon icon="lucide:check" class="mr-1" />
保存模板
</PrimaryButton>
<DefaultButton @click="handlePreviewTemplate('notify')">
<Icon icon="lucide:eye" class="mr-1" />
预览模板
</DefaultButton>
</Space>
</template>
</VbenForm>
</VbenTabPane>
</VbenTabs>
</VbenTabPane>
</VbenTabs>
</VbenCard>
<!-- 测试邮件对话框 -->
<VbenModal v-model:open="testDialogVisible" title="发送测试邮件" width="500px">
<VbenForm @submit="handleSendTest">
<template #default="{ form }">
<VbenFormItem name="test_email" label="收件人邮箱">
<Input
v-model:value="form.test_email"
placeholder="请输入测试邮箱地址"
/>
</VbenFormItem>
<VbenFormItem name="test_type" label="邮件类型">
<Select v-model:value="form.test_type" placeholder="请选择邮件类型">
<template #default>
<SelectOption value="register">注册验证</SelectOption>
<SelectOption value="reset">找回密码</SelectOption>
<SelectOption value="notify">通知邮件</SelectOption>
</template>
</Select>
</VbenFormItem>
</template>
<template #submit>
<Space>
<PrimaryButton html-type="submit" :loading="sendTestLoading">
<Icon icon="lucide:send" class="mr-1" />
发送
</PrimaryButton>
<DefaultButton @click="testDialogVisible = false">
取消
</DefaultButton>
</Space>
</template>
</VbenForm>
</VbenModal>
<!-- 模板预览对话框 -->
<VbenModal v-model:open="previewDialogVisible" title="模板预览" width="600px">
<div class="template-preview">
<div class="preview-item">
<label>邮件标题</label>
<div class="preview-content">{{ previewData.subject }}</div>
</div>
<div class="preview-item">
<label>邮件内容</label>
<div class="preview-content" v-html="previewData.content"></div>
</div>
</div>
<template #footer>
<DefaultButton @click="previewDialogVisible = false">
关闭
</DefaultButton>
</template>
</VbenModal>
</Page>
</template>
<script lang="ts" setup>
import { ref, reactive, onMounted } from 'vue';
import { Icon } from '@iconify/vue';
import {
Page,
VbenCard,
VbenTabs,
VbenTabPane,
VbenModal
} from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import type { VbenFormSchema } from '#/adapter/form';
import {
getEmailConfigApi,
updateEmailConfigApi,
testEmailApi,
previewEmailTemplateApi
} from '#/api/common/email';
// 响应式数据
const activeTab = ref('smtp');
const templateTab = ref('register');
const submitLoading = ref(false);
const testLoading = ref(false);
const sendTestLoading = ref(false);
const testDialogVisible = ref(false);
const previewDialogVisible = ref(false);
// 预览数据
const previewData = reactive({
subject: '',
content: '',
});
// SMTP配置表单配置
const smtpFormSchema: VbenFormSchema[] = [
{
component: 'Input',
fieldName: 'smtp_host',
label: 'SMTP服务器',
rules: 'required',
componentProps: {
placeholder: '请输入SMTP服务器地址',
},
},
{
component: 'InputNumber',
fieldName: 'smtp_port',
label: '端口',
rules: 'required',
componentProps: {
min: 1,
max: 65535,
placeholder: '请输入端口号',
},
},
{
component: 'Input',
fieldName: 'smtp_username',
label: '用户名',
rules: 'required',
componentProps: {
placeholder: '请输入邮箱用户名',
},
},
{
component: 'Input',
fieldName: 'smtp_password',
label: '密码',
rules: 'required',
componentProps: {
type: 'password',
placeholder: '请输入邮箱密码或授权码',
showPassword: true,
},
},
{
component: 'Input',
fieldName: 'smtp_from_email',
label: '发件人邮箱',
rules: 'required',
componentProps: {
placeholder: '请输入发件人邮箱',
},
},
{
component: 'Input',
fieldName: 'smtp_from_name',
label: '发件人名称',
rules: 'required',
componentProps: {
placeholder: '请输入发件人名称',
},
},
{
component: 'Select',
fieldName: 'smtp_encryption',
label: '加密方式',
componentProps: {
placeholder: '请选择加密方式',
options: [
{ label: '无', value: '' },
{ label: 'SSL', value: 'ssl' },
{ label: 'TLS', value: 'tls' },
],
},
},
{
component: 'Switch',
fieldName: 'smtp_enabled',
label: '启用状态',
},
];
// 邮件模板表单配置
const templateFormSchema: VbenFormSchema[] = [
{
component: 'Input',
fieldName: 'register_subject',
label: '邮件标题',
rules: 'required',
componentProps: {
placeholder: '请输入注册验证邮件标题',
},
},
{
component: 'Input',
fieldName: 'register_content',
label: '邮件内容',
rules: 'required',
componentProps: {
type: 'textarea',
rows: 6,
placeholder: '请输入注册验证邮件内容,可使用变量:{username}、{code}、{expire}',
},
},
{
component: 'Switch',
fieldName: 'register_enabled',
label: '启用状态',
},
];
// 测试邮件表单配置
const testFormSchema: VbenFormSchema[] = [
{
component: 'Input',
fieldName: 'test_email',
label: '收件人邮箱',
rules: 'required',
componentProps: {
placeholder: '请输入测试邮箱地址',
},
},
{
component: 'Select',
fieldName: 'test_type',
label: '邮件类型',
rules: 'required',
componentProps: {
placeholder: '请选择邮件类型',
options: [
{ label: '注册验证', value: 'register' },
{ label: '找回密码', value: 'reset' },
{ label: '通知邮件', value: 'notify' },
],
},
},
];
// 创建表单实例
const [SmtpForm, smtpFormApi] = useVbenForm({
schema: smtpFormSchema,
showDefaultActions: false,
});
const [TemplateForm, templateFormApi] = useVbenForm({
schema: templateFormSchema,
showDefaultActions: false,
});
const [TestForm, testFormApi] = useVbenForm({
schema: testFormSchema,
showDefaultActions: false,
});
// 处理SMTP配置提交
const handleSubmitSmtp = async (values: Record<string, any>) => {
try {
submitLoading.value = true;
await updateEmailConfigApi('smtp', values);
// 显示成功消息
} catch (error) {
console.error('保存SMTP配置失败:', error);
} finally {
submitLoading.value = false;
}
};
// 处理模板提交
const handleSubmitTemplate = async (values: Record<string, any>) => {
try {
submitLoading.value = true;
await updateEmailConfigApi('template', values);
// 显示成功消息
} catch (error) {
console.error('保存邮件模板失败:', error);
} finally {
submitLoading.value = false;
}
};
// 处理测试邮件
const handleTestEmail = () => {
testDialogVisible.value = true;
};
// 发送测试邮件
const handleSendTest = async (values: Record<string, any>) => {
try {
sendTestLoading.value = true;
await testEmailApi(values.test_email, values.test_type);
testDialogVisible.value = false;
// 显示成功消息
} catch (error) {
console.error('发送测试邮件失败:', error);
} finally {
sendTestLoading.value = false;
}
};
// 预览模板
const handlePreviewTemplate = async (type: string) => {
try {
const data = await previewEmailTemplateApi(type);
previewData.subject = data.subject;
previewData.content = data.content;
previewDialogVisible.value = true;
} catch (error) {
console.error('预览模板失败:', error);
}
};
// 重置SMTP配置
const handleResetSmtp = () => {
smtpFormApi.resetForm();
};
// 初始化数据
const initData = async () => {
try {
const [smtpData, templateData] = await Promise.all([
getEmailConfigApi('smtp'),
getEmailConfigApi('template'),
]);
smtpFormApi.setValues(smtpData);
templateFormApi.setValues(templateData);
} catch (error) {
console.error('初始化数据失败:', error);
}
};
// 组件挂载时初始化数据
onMounted(() => {
initData();
});
</script>
<style scoped>
.template-preview {
padding: 16px 0;
}
.preview-item {
margin-bottom: 16px;
}
.preview-item label {
font-weight: 500;
color: #333;
margin-bottom: 8px;
display: block;
}
.preview-content {
padding: 12px;
background-color: #f5f5f5;
border-radius: 4px;
border: 1px solid #e0e0e0;
white-space: pre-wrap;
word-break: break-word;
}
</style>

View File

@@ -0,0 +1,621 @@
<template>
<div class="p-4">
<VbenTabs v-model:active-key="activeTab" type="card">
<!-- 登录方式设置 -->
<VbenTabPane key="methods" tab="登录方式">
<VbenForm
ref="methodsFormRef"
:schema="methodsFormSchema"
:form-options="{
layout: 'vertical',
labelCol: { span: 24 },
wrapperCol: { span: 24 },
}"
@submit="handleSaveMethods"
>
<template #submitButton>
<div class="flex gap-2">
<VbenButton type="primary" :loading="saveLoading" @click="handleSaveMethods">
保存配置
</VbenButton>
<VbenButton @click="handleResetMethods">
重置配置
</VbenButton>
</div>
</template>
</VbenForm>
</VbenTabPane>
<!-- 安全设置 -->
<VbenTabPane key="security" tab="安全设置">
<VbenForm
ref="securityFormRef"
:schema="securityFormSchema"
:form-options="{
layout: 'vertical',
labelCol: { span: 24 },
wrapperCol: { span: 24 },
}"
@submit="handleSaveSecurity"
>
<template #submitButton>
<div class="flex gap-2">
<VbenButton type="primary" :loading="saveLoading" @click="handleSaveSecurity">
保存配置
</VbenButton>
<VbenButton @click="handleResetSecurity">
重置配置
</VbenButton>
</div>
</template>
</VbenForm>
</VbenTabPane>
<!-- 第三方登录 -->
<VbenTabPane key="oauth" tab="第三方登录">
<VbenForm
ref="oauthFormRef"
:schema="oauthFormSchema"
:form-options="{
layout: 'vertical',
labelCol: { span: 24 },
wrapperCol: { span: 24 },
}"
@submit="handleSaveOauth"
>
<template #submitButton>
<div class="flex gap-2">
<VbenButton type="primary" :loading="saveLoading" @click="handleSaveOauth">
保存配置
</VbenButton>
<VbenButton @click="handleResetOauth">
重置配置
</VbenButton>
</div>
</template>
</VbenForm>
</VbenTabPane>
<!-- 注册设置 -->
<VbenTabPane key="register" tab="注册设置">
<VbenForm
ref="registerFormRef"
:schema="registerFormSchema"
:form-options="{
layout: 'vertical',
labelCol: { span: 24 },
wrapperCol: { span: 24 },
}"
@submit="handleSaveRegister"
>
<template #submitButton>
<div class="flex gap-2">
<VbenButton type="primary" :loading="saveLoading" @click="handleSaveRegister">
保存配置
</VbenButton>
<VbenButton @click="handleResetRegister">
重置配置
</VbenButton>
</div>
</template>
</VbenForm>
</VbenTabPane>
</VbenTabs>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { VbenForm, VbenButton, VbenTabs, VbenTabPane } from '@vben/components'
import { message } from 'ant-design-vue'
import type { FormSchema } from '@vben/types'
import {
getLoginConfigApi,
updateLoginConfigApi,
resetLoginConfigApi,
type LoginConfig,
} from '@/api/common/login'
const methodsFormRef = ref()
const securityFormRef = ref()
const oauthFormRef = ref()
const registerFormRef = ref()
const saveLoading = ref(false)
const activeTab = ref('methods')
// 登录方式表单配置
const methodsFormSchema: FormSchema[] = [
{
field: 'username_enabled',
label: '用户名登录',
component: 'Switch',
defaultValue: true,
helpMessage: '允许用户使用用户名进行登录',
},
{
field: 'mobile_enabled',
label: '手机号登录',
component: 'Switch',
defaultValue: true,
helpMessage: '允许用户使用手机号进行登录',
},
{
field: 'email_enabled',
label: '邮箱登录',
component: 'Switch',
defaultValue: true,
helpMessage: '允许用户使用邮箱进行登录',
},
{
field: 'sms_enabled',
label: '短信验证码登录',
component: 'Switch',
defaultValue: false,
helpMessage: '允许用户使用短信验证码进行登录',
},
{
field: 'oauth_enabled',
label: '第三方登录',
component: 'Switch',
defaultValue: false,
helpMessage: '启用微信、QQ等第三方登录方式',
},
{
field: 'guest_enabled',
label: '游客模式',
component: 'Switch',
defaultValue: false,
helpMessage: '允许游客访问部分功能,无需注册登录',
},
]
// 安全设置表单配置
const securityFormSchema: FormSchema[] = [
{
field: 'captcha_enabled',
label: '登录验证码',
component: 'Switch',
defaultValue: true,
helpMessage: '登录时需要输入图形验证码',
},
{
field: 'captcha_type',
label: '验证码类型',
component: 'RadioGroup',
show: ({ values }) => values.captcha_enabled,
componentProps: {
options: [
{ label: '图形验证码', value: 'image' },
{ label: '滑动验证码', value: 'slide' },
{ label: '点击验证码', value: 'click' },
],
},
defaultValue: 'image',
},
{
field: 'max_fail_attempts',
label: '最大登录失败次数',
component: 'InputNumber',
required: true,
componentProps: {
min: 3,
max: 20,
addonAfter: '次',
},
defaultValue: 5,
helpMessage: '超过此次数将锁定账户',
},
{
field: 'lock_duration',
label: '账户锁定时间',
component: 'InputNumber',
required: true,
componentProps: {
min: 5,
max: 1440,
addonAfter: '分钟',
},
defaultValue: 30,
helpMessage: '账户被锁定后的解锁时间',
},
{
field: 'password_strength_enabled',
label: '密码强度检查',
component: 'Switch',
defaultValue: true,
helpMessage: '强制用户使用强密码',
},
{
field: 'password_min_length',
label: '密码最小长度',
component: 'InputNumber',
show: ({ values }) => values.password_strength_enabled,
componentProps: {
min: 6,
max: 20,
addonAfter: '位',
},
defaultValue: 8,
},
{
field: 'password_complexity',
label: '密码复杂度要求',
component: 'CheckboxGroup',
show: ({ values }) => values.password_strength_enabled,
componentProps: {
options: [
{ label: '包含大写字母', value: 'uppercase' },
{ label: '包含小写字母', value: 'lowercase' },
{ label: '包含数字', value: 'number' },
{ label: '包含特殊字符', value: 'special' },
],
},
defaultValue: ['lowercase', 'number'],
},
{
field: 'password_expire_enabled',
label: '强制定期修改密码',
component: 'Switch',
defaultValue: false,
helpMessage: '强制用户定期修改密码',
},
{
field: 'password_expire_days',
label: '密码有效期',
component: 'InputNumber',
show: ({ values }) => values.password_expire_enabled,
componentProps: {
min: 30,
max: 365,
addonAfter: '天',
},
defaultValue: 90,
},
{
field: 'single_sign_on_enabled',
label: '单点登录',
component: 'Switch',
defaultValue: false,
helpMessage: '同一账户只能在一个设备上登录',
},
]
// 第三方登录表单配置
const oauthFormSchema: FormSchema[] = [
{
field: 'wechat_enabled',
label: '启用微信登录',
component: 'Switch',
defaultValue: false,
},
{
field: 'wechat_app_id',
label: '微信应用ID',
component: 'Input',
show: ({ values }) => values.wechat_enabled,
required: true,
colProps: { span: 12 },
},
{
field: 'wechat_secret',
label: '微信应用密钥',
component: 'InputPassword',
show: ({ values }) => values.wechat_enabled,
required: true,
colProps: { span: 12 },
},
{
field: 'wechat_redirect_uri',
label: '微信回调地址',
component: 'Input',
show: ({ values }) => values.wechat_enabled,
helpMessage: '微信登录授权回调地址',
},
{
field: 'qq_enabled',
label: '启用QQ登录',
component: 'Switch',
defaultValue: false,
},
{
field: 'qq_app_id',
label: 'QQ应用ID',
component: 'Input',
show: ({ values }) => values.qq_enabled,
required: true,
colProps: { span: 12 },
},
{
field: 'qq_secret',
label: 'QQ应用密钥',
component: 'InputPassword',
show: ({ values }) => values.qq_enabled,
required: true,
colProps: { span: 12 },
},
{
field: 'qq_redirect_uri',
label: 'QQ回调地址',
component: 'Input',
show: ({ values }) => values.qq_enabled,
helpMessage: 'QQ登录授权回调地址',
},
{
field: 'github_enabled',
label: '启用GitHub登录',
component: 'Switch',
defaultValue: false,
},
{
field: 'github_client_id',
label: 'GitHub客户端ID',
component: 'Input',
show: ({ values }) => values.github_enabled,
required: true,
colProps: { span: 12 },
},
{
field: 'github_secret',
label: 'GitHub客户端密钥',
component: 'InputPassword',
show: ({ values }) => values.github_enabled,
required: true,
colProps: { span: 12 },
},
{
field: 'github_redirect_uri',
label: 'GitHub回调地址',
component: 'Input',
show: ({ values }) => values.github_enabled,
helpMessage: 'GitHub登录授权回调地址',
},
]
// 注册设置表单配置
const registerFormSchema: FormSchema[] = [
{
field: 'register_enabled',
label: '开放注册',
component: 'Switch',
defaultValue: true,
helpMessage: '是否允许用户注册新账户',
},
{
field: 'register_methods',
label: '注册方式',
component: 'CheckboxGroup',
show: ({ values }) => values.register_enabled,
componentProps: {
options: [
{ label: '用户名注册', value: 'username' },
{ label: '手机号注册', value: 'mobile' },
{ label: '邮箱注册', value: 'email' },
],
},
defaultValue: ['username', 'mobile'],
},
{
field: 'register_verification',
label: '注册验证方式',
component: 'RadioGroup',
show: ({ values }) => values.register_enabled,
componentProps: {
options: [
{ label: '无需验证', value: 'none' },
{ label: '邮箱验证', value: 'email' },
{ label: '短信验证', value: 'sms' },
{ label: '人工审核', value: 'manual' },
],
},
defaultValue: 'sms',
},
{
field: 'register_captcha_enabled',
label: '注册验证码',
component: 'Switch',
show: ({ values }) => values.register_enabled,
defaultValue: true,
helpMessage: '注册时需要输入验证码',
},
{
field: 'agreement_required',
label: '用户协议',
component: 'Switch',
show: ({ values }) => values.register_enabled,
defaultValue: true,
helpMessage: '用户注册时是否必须同意用户协议',
},
{
field: 'agreement_content',
label: '协议内容',
component: 'InputTextArea',
show: ({ values }) => values.register_enabled && values.agreement_required,
componentProps: {
rows: 6,
placeholder: '请输入用户协议内容',
},
},
{
field: 'default_role',
label: '默认用户组',
component: 'Select',
show: ({ values }) => values.register_enabled,
componentProps: {
options: [
{ label: '普通用户', value: 'user' },
{ label: 'VIP用户', value: 'vip' },
{ label: '会员', value: 'member' },
],
},
defaultValue: 'user',
},
{
field: 'reward_enabled',
label: '注册奖励',
component: 'Switch',
show: ({ values }) => values.register_enabled,
defaultValue: true,
helpMessage: '新用户注册时给予奖励',
},
{
field: 'reward_points',
label: '奖励积分',
component: 'InputNumber',
show: ({ values }) => values.register_enabled && values.reward_enabled,
componentProps: {
min: 0,
},
defaultValue: 100,
colProps: { span: 12 },
},
{
field: 'reward_balance',
label: '奖励余额',
component: 'InputNumber',
show: ({ values }) => values.register_enabled && values.reward_enabled,
componentProps: {
min: 0,
precision: 2,
addonAfter: '元',
},
defaultValue: 0,
colProps: { span: 12 },
},
]
// 加载配置
const loadConfig = async () => {
try {
const data = await getLoginConfigApi()
methodsFormRef.value?.setFieldsValue(data.methods || {})
securityFormRef.value?.setFieldsValue(data.security || {})
oauthFormRef.value?.setFieldsValue(data.oauth || {})
registerFormRef.value?.setFieldsValue(data.register || {})
} catch (error) {
message.error('加载配置失败')
}
}
// 保存登录方式配置
const handleSaveMethods = async () => {
try {
const values = await methodsFormRef.value?.validate()
if (!values) return
saveLoading.value = true
await updateLoginConfigApi({ type: 'methods', config: values })
message.success('登录方式配置保存成功')
} catch (error) {
message.error('保存失败')
} finally {
saveLoading.value = false
}
}
// 保存安全设置配置
const handleSaveSecurity = async () => {
try {
const values = await securityFormRef.value?.validate()
if (!values) return
saveLoading.value = true
await updateLoginConfigApi({ type: 'security', config: values })
message.success('安全设置配置保存成功')
} catch (error) {
message.error('保存失败')
} finally {
saveLoading.value = false
}
}
// 保存第三方登录配置
const handleSaveOauth = async () => {
try {
const values = await oauthFormRef.value?.validate()
if (!values) return
saveLoading.value = true
await updateLoginConfigApi({ type: 'oauth', config: values })
message.success('第三方登录配置保存成功')
} catch (error) {
message.error('保存失败')
} finally {
saveLoading.value = false
}
}
// 保存注册设置配置
const handleSaveRegister = async () => {
try {
const values = await registerFormRef.value?.validate()
if (!values) return
saveLoading.value = true
await updateLoginConfigApi({ type: 'register', config: values })
message.success('注册设置配置保存成功')
} catch (error) {
message.error('保存失败')
} finally {
saveLoading.value = false
}
}
// 重置配置
const handleResetMethods = async () => {
try {
await resetLoginConfigApi('methods')
await loadConfig()
message.success('登录方式配置已重置')
} catch (error) {
message.error('重置失败')
}
}
const handleResetSecurity = async () => {
try {
await resetLoginConfigApi('security')
await loadConfig()
message.success('安全设置配置已重置')
} catch (error) {
message.error('重置失败')
}
}
const handleResetOauth = async () => {
try {
await resetLoginConfigApi('oauth')
await loadConfig()
message.success('第三方登录配置已重置')
} catch (error) {
message.error('重置失败')
}
}
const handleResetRegister = async () => {
try {
await resetLoginConfigApi('register')
await loadConfig()
message.success('注册设置配置已重置')
} catch (error) {
message.error('重置失败')
}
}
onMounted(() => {
loadConfig()
})
</script>
<style scoped>
.p-4 {
padding: 16px;
}
.flex {
display: flex;
}
.gap-2 {
gap: 8px;
}
</style>

View File

@@ -0,0 +1,905 @@
<template>
<Page>
<el-card>
<template #header>
<div class="card-header">
<Icon icon="ep:bell" class="mr-2" />
<span>通知设置</span>
</div>
</template>
<el-tabs v-model="activeTab" type="border-card">
<!-- 邮件通知 -->
<el-tab-pane label="邮件通知" name="email">
<el-form
ref="emailFormRef"
:model="emailForm"
:rules="emailRules"
label-width="150px"
v-loading="loading"
>
<el-form-item label="启用邮件通知" prop="enabled">
<el-switch
v-model="emailForm.enabled"
active-text="启用"
inactive-text="禁用"
/>
<div class="form-item-tip">启用后系统将发送邮件通知</div>
</el-form-item>
<template v-if="emailForm.enabled">
<el-form-item label="通知类型" prop="types">
<el-checkbox-group v-model="emailForm.types">
<el-checkbox label="user_register">用户注册</el-checkbox>
<el-checkbox label="user_login">用户登录</el-checkbox>
<el-checkbox label="password_reset">密码重置</el-checkbox>
<el-checkbox label="order_created">订单创建</el-checkbox>
<el-checkbox label="order_paid">订单支付</el-checkbox>
<el-checkbox label="order_shipped">订单发货</el-checkbox>
<el-checkbox label="order_completed">订单完成</el-checkbox>
<el-checkbox label="system_error">系统错误</el-checkbox>
<el-checkbox label="security_alert">安全警报</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item label="管理员邮箱" prop="adminEmails">
<div class="email-list-container">
<div class="email-list">
<div v-for="(email, index) in emailForm.adminEmails" :key="index" class="email-item">
<el-input
v-model="emailForm.adminEmails[index]"
placeholder="请输入管理员邮箱"
clearable
/>
<el-button
type="danger"
text
@click="removeAdminEmail(index)"
class="ml-2"
>
<Icon icon="ep:delete" />
</el-button>
</div>
</div>
<el-button @click="addAdminEmail" type="primary" text class="mt-2">
<Icon icon="ep:plus" class="mr-1" />
添加邮箱
</el-button>
</div>
</el-form-item>
<el-form-item label="发送频率限制" prop="rateLimit">
<el-input-number
v-model="emailForm.rateLimit"
:min="1"
:max="100"
style="width: 200px"
/>
<span class="ml-2">/小时</span>
<div class="form-item-tip">限制每小时发送的邮件数量</div>
</el-form-item>
<el-form-item label="重试次数" prop="retryTimes">
<el-input-number
v-model="emailForm.retryTimes"
:min="0"
:max="5"
style="width: 200px"
/>
<span class="ml-2"></span>
</el-form-item>
<el-form-item label="队列延迟" prop="queueDelay">
<el-input-number
v-model="emailForm.queueDelay"
:min="0"
:max="3600"
style="width: 200px"
/>
<span class="ml-2"></span>
<div class="form-item-tip">邮件发送延迟时间</div>
</el-form-item>
</template>
<el-form-item>
<el-button type="primary" @click="handleSaveEmail" :loading="saveLoading">
<Icon icon="ep:check" class="mr-1" />
保存设置
</el-button>
<el-button @click="handleResetEmail">
<Icon icon="ep:refresh" class="mr-1" />
重置
</el-button>
<el-button @click="handleTestEmail" :loading="testEmailLoading" v-if="emailForm.enabled">
<Icon icon="ep:message" class="mr-1" />
发送测试邮件
</el-button>
</el-form-item>
</el-form>
</el-tab-pane>
<!-- 短信通知 -->
<el-tab-pane label="短信通知" name="sms">
<el-form
ref="smsFormRef"
:model="smsForm"
:rules="smsRules"
label-width="150px"
v-loading="loading"
>
<el-form-item label="启用短信通知" prop="enabled">
<el-switch
v-model="smsForm.enabled"
active-text="启用"
inactive-text="禁用"
/>
<div class="form-item-tip">启用后系统将发送短信通知</div>
</el-form-item>
<template v-if="smsForm.enabled">
<el-form-item label="通知类型" prop="types">
<el-checkbox-group v-model="smsForm.types">
<el-checkbox label="user_register">用户注册</el-checkbox>
<el-checkbox label="login_verify">登录验证</el-checkbox>
<el-checkbox label="password_reset">密码重置</el-checkbox>
<el-checkbox label="order_status">订单状态变更</el-checkbox>
<el-checkbox label="payment_notify">支付通知</el-checkbox>
<el-checkbox label="security_alert">安全警报</el-checkbox>
<el-checkbox label="marketing">营销推广</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item label="管理员手机" prop="adminPhones">
<div class="phone-list-container">
<div class="phone-list">
<div v-for="(phone, index) in smsForm.adminPhones" :key="index" class="phone-item">
<el-input
v-model="smsForm.adminPhones[index]"
placeholder="请输入管理员手机号"
clearable
/>
<el-button
type="danger"
text
@click="removeAdminPhone(index)"
class="ml-2"
>
<Icon icon="ep:delete" />
</el-button>
</div>
</div>
<el-button @click="addAdminPhone" type="primary" text class="mt-2">
<Icon icon="ep:plus" class="mr-1" />
添加手机号
</el-button>
</div>
</el-form-item>
<el-form-item label="发送时间限制">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="开始时间" prop="sendTimeStart" label-width="80px">
<el-time-picker
v-model="smsForm.sendTimeStart"
format="HH:mm"
value-format="HH:mm"
placeholder="选择开始时间"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="结束时间" prop="sendTimeEnd" label-width="80px">
<el-time-picker
v-model="smsForm.sendTimeEnd"
format="HH:mm"
value-format="HH:mm"
placeholder="选择结束时间"
/>
</el-form-item>
</el-col>
</el-row>
<div class="form-item-tip">限制短信发送的时间段避免打扰用户</div>
</el-form-item>
<el-form-item label="发送频率限制" prop="rateLimit">
<el-input-number
v-model="smsForm.rateLimit"
:min="1"
:max="1000"
style="width: 200px"
/>
<span class="ml-2">/小时</span>
</el-form-item>
<el-form-item label="同号码限制" prop="phoneLimit">
<el-input-number
v-model="smsForm.phoneLimit"
:min="1"
:max="10"
style="width: 200px"
/>
<span class="ml-2">/</span>
<div class="form-item-tip">限制同一手机号每天接收的短信数量</div>
</el-form-item>
</template>
<el-form-item>
<el-button type="primary" @click="handleSaveSms" :loading="saveLoading">
<Icon icon="ep:check" class="mr-1" />
保存设置
</el-button>
<el-button @click="handleResetSms">
<Icon icon="ep:refresh" class="mr-1" />
重置
</el-button>
<el-button @click="handleTestSms" :loading="testSmsLoading" v-if="smsForm.enabled">
<Icon icon="ep:chat-dot-round" class="mr-1" />
发送测试短信
</el-button>
</el-form-item>
</el-form>
</el-tab-pane>
<!-- 站内通知 -->
<el-tab-pane label="站内通知" name="system">
<el-form
ref="systemFormRef"
:model="systemForm"
:rules="systemRules"
label-width="150px"
v-loading="loading"
>
<el-form-item label="启用站内通知" prop="enabled">
<el-switch
v-model="systemForm.enabled"
active-text="启用"
inactive-text="禁用"
/>
<div class="form-item-tip">启用后系统将发送站内消息通知</div>
</el-form-item>
<template v-if="systemForm.enabled">
<el-form-item label="通知类型" prop="types">
<el-checkbox-group v-model="systemForm.types">
<el-checkbox label="user_register">用户注册</el-checkbox>
<el-checkbox label="order_created">订单创建</el-checkbox>
<el-checkbox label="order_paid">订单支付</el-checkbox>
<el-checkbox label="order_shipped">订单发货</el-checkbox>
<el-checkbox label="order_completed">订单完成</el-checkbox>
<el-checkbox label="order_refund">订单退款</el-checkbox>
<el-checkbox label="user_feedback">用户反馈</el-checkbox>
<el-checkbox label="system_maintenance">系统维护</el-checkbox>
<el-checkbox label="promotion">促销活动</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item label="消息保留期" prop="retentionDays">
<el-input-number
v-model="systemForm.retentionDays"
:min="7"
:max="365"
style="width: 200px"
/>
<span class="ml-2"></span>
<div class="form-item-tip">超过保留期的消息将被自动删除</div>
</el-form-item>
<el-form-item label="最大消息数" prop="maxMessages">
<el-input-number
v-model="systemForm.maxMessages"
:min="100"
:max="10000"
style="width: 200px"
/>
<span class="ml-2"></span>
<div class="form-item-tip">每个用户最多保留的消息数量</div>
</el-form-item>
<el-form-item label="自动标记已读" prop="autoMarkRead">
<el-switch
v-model="systemForm.autoMarkRead"
active-text="启用"
inactive-text="禁用"
/>
<div class="form-item-tip">用户查看消息后自动标记为已读</div>
</el-form-item>
<el-form-item label="推送到桌面" prop="desktopPush">
<el-switch
v-model="systemForm.desktopPush"
active-text="启用"
inactive-text="禁用"
/>
<div class="form-item-tip">支持浏览器桌面通知推送</div>
</el-form-item>
<el-form-item label="声音提醒" prop="soundAlert">
<el-switch
v-model="systemForm.soundAlert"
active-text="启用"
inactive-text="禁用"
/>
<div class="form-item-tip">新消息时播放提示音</div>
</el-form-item>
</template>
<el-form-item>
<el-button type="primary" @click="handleSaveSystem" :loading="saveLoading">
<Icon icon="ep:check" class="mr-1" />
保存设置
</el-button>
<el-button @click="handleResetSystem">
<Icon icon="ep:refresh" class="mr-1" />
重置
</el-button>
<el-button @click="handleTestSystem" :loading="testSystemLoading" v-if="systemForm.enabled">
<Icon icon="ep:bell" class="mr-1" />
发送测试通知
</el-button>
</el-form-item>
</el-form>
</el-tab-pane>
<!-- 微信通知 -->
<el-tab-pane label="微信通知" name="wechat">
<el-form
ref="wechatFormRef"
:model="wechatForm"
:rules="wechatRules"
label-width="150px"
v-loading="loading"
>
<el-form-item label="启用微信通知" prop="enabled">
<el-switch
v-model="wechatForm.enabled"
active-text="启用"
inactive-text="禁用"
/>
<div class="form-item-tip">启用后系统将发送微信模板消息</div>
</el-form-item>
<template v-if="wechatForm.enabled">
<el-form-item label="AppID" prop="appId">
<el-input
v-model="wechatForm.appId"
placeholder="请输入微信公众号AppID"
clearable
/>
</el-form-item>
<el-form-item label="AppSecret" prop="appSecret">
<el-input
v-model="wechatForm.appSecret"
type="password"
placeholder="请输入微信公众号AppSecret"
show-password
clearable
/>
</el-form-item>
<el-form-item label="通知类型" prop="types">
<el-checkbox-group v-model="wechatForm.types">
<el-checkbox label="order_created">订单创建</el-checkbox>
<el-checkbox label="order_paid">订单支付</el-checkbox>
<el-checkbox label="order_shipped">订单发货</el-checkbox>
<el-checkbox label="order_completed">订单完成</el-checkbox>
<el-checkbox label="order_refund">订单退款</el-checkbox>
<el-checkbox label="payment_success">支付成功</el-checkbox>
<el-checkbox label="account_change">账户变动</el-checkbox>
<el-checkbox label="service_notice">服务通知</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item label="模板消息配置">
<div class="template-config">
<div v-for="(template, key) in wechatForm.templates" :key="key" class="template-item">
<div class="template-label">{{ getTemplateLabel(key) }}</div>
<el-input
v-model="wechatForm.templates[key]"
placeholder="请输入模板ID"
clearable
/>
</div>
</div>
</el-form-item>
<el-form-item label="发送频率限制" prop="rateLimit">
<el-input-number
v-model="wechatForm.rateLimit"
:min="1"
:max="1000"
style="width: 200px"
/>
<span class="ml-2">/小时</span>
</el-form-item>
</template>
<el-form-item>
<el-button type="primary" @click="handleSaveWechat" :loading="saveLoading">
<Icon icon="ep:check" class="mr-1" />
保存设置
</el-button>
<el-button @click="handleResetWechat">
<Icon icon="ep:refresh" class="mr-1" />
重置
</el-button>
<el-button @click="handleTestWechat" :loading="testWechatLoading" v-if="wechatForm.enabled">
<Icon icon="ep:chat-dot-round" class="mr-1" />
发送测试消息
</el-button>
</el-form-item>
</el-form>
</el-tab-pane>
</el-tabs>
</el-card>
</Page>
</template>
<script lang="ts" setup>
// 1. Vue 相关导入
import { ref, reactive, onMounted } from 'vue';
import type { FormInstance } from 'element-plus';
// 2. Element Plus 组件导入
import {
ElButton,
ElCard,
ElCheckbox,
ElCheckboxGroup,
ElCol,
ElForm,
ElFormItem,
ElInput,
ElInputNumber,
ElMessage,
ElRow,
ElSwitch,
ElTabPane,
ElTabs,
ElTimePicker,
} from 'element-plus';
// 3. 图标组件导入
import { Icon } from '@iconify/vue';
// 4. Vben 组件导入
import { Page } from '@vben/common-ui';
// 5. 项目内部导入
import {
getNotificationSettingsApi,
updateNotificationSettingsApi,
resetNotificationSettingsApi,
testNotificationApi,
type NotificationSettings,
type UpdateNotificationSettingsParams,
} from '#/api/settings';
// 响应式数据
const loading = ref(false);
const saveLoading = ref(false);
const testEmailLoading = ref(false);
const testSmsLoading = ref(false);
const testSystemLoading = ref(false);
const testWechatLoading = ref(false);
const activeTab = ref('email');
const emailFormRef = ref<FormInstance>();
const smsFormRef = ref<FormInstance>();
const systemFormRef = ref<FormInstance>();
const wechatFormRef = ref<FormInstance>();
// 邮件通知表单
const emailForm = reactive({
enabled: true,
types: ['user_register', 'order_created', 'system_error'],
adminEmails: ['admin@example.com'],
rateLimit: 50,
retryTimes: 3,
queueDelay: 0,
});
// 短信通知表单
const smsForm = reactive({
enabled: false,
types: ['user_register', 'login_verify', 'password_reset'],
adminPhones: [''],
sendTimeStart: '08:00',
sendTimeEnd: '22:00',
rateLimit: 100,
phoneLimit: 5,
});
// 站内通知表单
const systemForm = reactive({
enabled: true,
types: ['user_register', 'order_created', 'order_paid'],
retentionDays: 30,
maxMessages: 1000,
autoMarkRead: true,
desktopPush: false,
soundAlert: true,
});
// 微信通知表单
const wechatForm = reactive({
enabled: false,
appId: '',
appSecret: '',
types: ['order_created', 'order_paid', 'payment_success'],
templates: {
order_created: '',
order_paid: '',
order_shipped: '',
order_completed: '',
order_refund: '',
payment_success: '',
account_change: '',
service_notice: '',
},
rateLimit: 100,
});
// 表单验证规则
const emailRules = {
adminEmails: [
{ required: true, message: '请输入管理员邮箱', trigger: 'blur' },
],
rateLimit: [
{ required: true, message: '请输入发送频率限制', trigger: 'blur' },
{ type: 'number', min: 1, max: 100, message: '发送频率范围为 1-100 封/小时', trigger: 'blur' },
],
};
const smsRules = {
adminPhones: [
{ required: true, message: '请输入管理员手机号', trigger: 'blur' },
],
rateLimit: [
{ required: true, message: '请输入发送频率限制', trigger: 'blur' },
{ type: 'number', min: 1, max: 1000, message: '发送频率范围为 1-1000 条/小时', trigger: 'blur' },
],
};
const systemRules = {
retentionDays: [
{ required: true, message: '请输入消息保留期', trigger: 'blur' },
{ type: 'number', min: 7, max: 365, message: '保留期范围为 7-365 天', trigger: 'blur' },
],
maxMessages: [
{ required: true, message: '请输入最大消息数', trigger: 'blur' },
{ type: 'number', min: 100, max: 10000, message: '消息数范围为 100-10000 条', trigger: 'blur' },
],
};
const wechatRules = {
appId: [
{ required: true, message: '请输入微信AppID', trigger: 'blur' },
],
appSecret: [
{ required: true, message: '请输入微信AppSecret', trigger: 'blur' },
],
};
// 方法
const addAdminEmail = () => {
emailForm.adminEmails.push('');
};
const removeAdminEmail = (index: number) => {
if (emailForm.adminEmails.length > 1) {
emailForm.adminEmails.splice(index, 1);
}
};
const addAdminPhone = () => {
smsForm.adminPhones.push('');
};
const removeAdminPhone = (index: number) => {
if (smsForm.adminPhones.length > 1) {
smsForm.adminPhones.splice(index, 1);
}
};
const getTemplateLabel = (key: string) => {
const labels: Record<string, string> = {
order_created: '订单创建',
order_paid: '订单支付',
order_shipped: '订单发货',
order_completed: '订单完成',
order_refund: '订单退款',
payment_success: '支付成功',
account_change: '账户变动',
service_notice: '服务通知',
};
return labels[key] || key;
};
const loadSettings = async () => {
loading.value = true;
try {
const settings = await getNotificationSettingsApi();
Object.assign(emailForm, settings.email || {});
Object.assign(smsForm, settings.sms || {});
Object.assign(systemForm, settings.system || {});
Object.assign(wechatForm, settings.wechat || {});
} catch (error) {
ElMessage.error('加载通知设置失败');
} finally {
loading.value = false;
}
};
const handleSaveEmail = async () => {
if (!emailFormRef.value) return;
try {
await emailFormRef.value.validate();
saveLoading.value = true;
// 过滤空的邮箱地址
const filteredEmails = emailForm.adminEmails.filter(email => email.trim());
const updateData: UpdateNotificationSettingsParams = {
type: 'email',
config: {
...emailForm,
adminEmails: filteredEmails,
},
};
await updateNotificationSettingsApi(updateData);
ElMessage.success('邮件通知设置保存成功');
} catch (error) {
ElMessage.error('保存失败');
} finally {
saveLoading.value = false;
}
};
const handleSaveSms = async () => {
if (!smsFormRef.value) return;
try {
await smsFormRef.value.validate();
saveLoading.value = true;
// 过滤空的手机号
const filteredPhones = smsForm.adminPhones.filter(phone => phone.trim());
const updateData: UpdateNotificationSettingsParams = {
type: 'sms',
config: {
...smsForm,
adminPhones: filteredPhones,
},
};
await updateNotificationSettingsApi(updateData);
ElMessage.success('短信通知设置保存成功');
} catch (error) {
ElMessage.error('保存失败');
} finally {
saveLoading.value = false;
}
};
const handleSaveSystem = async () => {
if (!systemFormRef.value) return;
try {
await systemFormRef.value.validate();
saveLoading.value = true;
const updateData: UpdateNotificationSettingsParams = {
type: 'system',
config: systemForm,
};
await updateNotificationSettingsApi(updateData);
ElMessage.success('站内通知设置保存成功');
} catch (error) {
ElMessage.error('保存失败');
} finally {
saveLoading.value = false;
}
};
const handleSaveWechat = async () => {
if (!wechatFormRef.value) return;
try {
await wechatFormRef.value.validate();
saveLoading.value = true;
const updateData: UpdateNotificationSettingsParams = {
type: 'wechat',
config: wechatForm,
};
await updateNotificationSettingsApi(updateData);
ElMessage.success('微信通知设置保存成功');
} catch (error) {
ElMessage.error('保存失败');
} finally {
saveLoading.value = false;
}
};
const handleResetEmail = async () => {
try {
await resetNotificationSettingsApi('email');
await loadSettings();
ElMessage.success('邮件通知设置已重置');
} catch (error) {
ElMessage.error('重置失败');
}
};
const handleResetSms = async () => {
try {
await resetNotificationSettingsApi('sms');
await loadSettings();
ElMessage.success('短信通知设置已重置');
} catch (error) {
ElMessage.error('重置失败');
}
};
const handleResetSystem = async () => {
try {
await resetNotificationSettingsApi('system');
await loadSettings();
ElMessage.success('站内通知设置已重置');
} catch (error) {
ElMessage.error('重置失败');
}
};
const handleResetWechat = async () => {
try {
await resetNotificationSettingsApi('wechat');
await loadSettings();
ElMessage.success('微信通知设置已重置');
} catch (error) {
ElMessage.error('重置失败');
}
};
const handleTestEmail = async () => {
testEmailLoading.value = true;
try {
await testNotificationApi('email');
ElMessage.success('测试邮件发送成功');
} catch (error) {
ElMessage.error('发送测试邮件失败');
} finally {
testEmailLoading.value = false;
}
};
const handleTestSms = async () => {
testSmsLoading.value = true;
try {
await testNotificationApi('sms');
ElMessage.success('测试短信发送成功');
} catch (error) {
ElMessage.error('发送测试短信失败');
} finally {
testSmsLoading.value = false;
}
};
const handleTestSystem = async () => {
testSystemLoading.value = true;
try {
await testNotificationApi('system');
ElMessage.success('测试通知发送成功');
} catch (error) {
ElMessage.error('发送测试通知失败');
} finally {
testSystemLoading.value = false;
}
};
const handleTestWechat = async () => {
testWechatLoading.value = true;
try {
await testNotificationApi('wechat');
ElMessage.success('测试微信消息发送成功');
} catch (error) {
ElMessage.error('发送测试微信消息失败');
} finally {
testWechatLoading.value = false;
}
};
// 生命周期
onMounted(() => {
loadSettings();
});
</script>
<style scoped>
.notification-settings-page {
padding: 20px;
}
.settings-container {
max-width: 1200px;
margin: 0 auto;
}
.settings-card {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.card-header {
display: flex;
align-items: center;
font-size: 16px;
font-weight: 600;
}
:deep(.el-tabs__content) {
padding: 20px;
}
:deep(.el-form-item__label) {
font-weight: 500;
}
.form-item-tip {
color: #999;
font-size: 12px;
margin-top: 4px;
line-height: 1.4;
}
.email-list-container,
.phone-list-container {
width: 100%;
}
.email-list,
.phone-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.email-item,
.phone-item {
display: flex;
align-items: center;
}
.template-config {
display: flex;
flex-direction: column;
gap: 12px;
}
.template-item {
display: flex;
align-items: center;
gap: 12px;
}
.template-label {
width: 80px;
font-size: 14px;
color: #666;
flex-shrink: 0;
}
:deep(.el-checkbox-group) {
display: flex;
flex-direction: column;
gap: 8px;
}
:deep(.el-checkbox-group .el-checkbox) {
margin-right: 0;
}
</style>

View File

@@ -0,0 +1,522 @@
<template>
<div class="p-4">
<VbenTabs v-model:active-key="activeTab" type="card">
<!-- 支付宝设置 -->
<VbenTabPane key="alipay" tab="支付宝">
<VbenForm
ref="alipayFormRef"
:schema="alipayFormSchema"
:form-options="{
layout: 'vertical',
labelCol: { span: 24 },
wrapperCol: { span: 24 },
}"
@submit="handleSaveAlipay"
>
<template #submitButton>
<div class="flex gap-2">
<VbenButton type="primary" :loading="saveLoading" @click="handleSaveAlipay">
保存配置
</VbenButton>
<VbenButton @click="handleTestAlipay">
测试支付
</VbenButton>
</div>
</template>
</VbenForm>
</VbenTabPane>
<!-- 微信支付设置 -->
<VbenTabPane key="wechat" tab="微信支付">
<VbenForm
ref="wechatFormRef"
:schema="wechatFormSchema"
:form-options="{
layout: 'vertical',
labelCol: { span: 24 },
wrapperCol: { span: 24 },
}"
@submit="handleSaveWechat"
>
<template #submitButton>
<div class="flex gap-2">
<VbenButton type="primary" :loading="saveLoading" @click="handleSaveWechat">
保存配置
</VbenButton>
<VbenButton @click="handleTestWechat">
测试支付
</VbenButton>
</div>
</template>
</VbenForm>
</VbenTabPane>
<!-- 通用设置 -->
<VbenTabPane key="general" tab="通用设置">
<VbenForm
ref="generalFormRef"
:schema="generalFormSchema"
:form-options="{
layout: 'vertical',
labelCol: { span: 24 },
wrapperCol: { span: 24 },
}"
@submit="handleSaveGeneral"
>
<template #submitButton>
<div class="flex gap-2">
<VbenButton type="primary" :loading="saveLoading" @click="handleSaveGeneral">
保存配置
</VbenButton>
</div>
</template>
</VbenForm>
</VbenTabPane>
</VbenTabs>
<!-- 测试支付对话框 -->
<VbenModal
v-model:open="testDialogVisible"
title="测试支付"
width="500px"
@ok="handleCreateTestOrder"
>
<VbenForm
ref="testFormRef"
:schema="testFormSchema"
:form-options="{
layout: 'vertical',
labelCol: { span: 24 },
wrapperCol: { span: 24 },
}"
/>
</VbenModal>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { VbenForm, VbenButton, VbenModal, VbenTabs, VbenTabPane } from '@vben/components'
import { message } from 'ant-design-vue'
import type { FormSchema } from '@vben/types'
import {
getPaymentConfigApi,
updatePaymentConfigApi,
testPaymentApi,
resetPaymentConfigApi,
type PaymentConfig,
} from '@/api/common/payment'
const alipayFormRef = ref()
const wechatFormRef = ref()
const generalFormRef = ref()
const testFormRef = ref()
const saveLoading = ref(false)
const testDialogVisible = ref(false)
const activeTab = ref('alipay')
// 支付宝表单配置
const alipayFormSchema: FormSchema[] = [
{
field: 'alipay_app_id',
label: '应用ID',
component: 'Input',
required: true,
colProps: { span: 12 },
},
{
field: 'alipay_gateway_url',
label: '网关地址',
component: 'Select',
required: true,
colProps: { span: 12 },
componentProps: {
options: [
{ label: '正式环境', value: 'https://openapi.alipay.com/gateway.do' },
{ label: '沙箱环境', value: 'https://openapi.alipaydev.com/gateway.do' },
],
},
defaultValue: 'https://openapi.alipaydev.com/gateway.do',
},
{
field: 'alipay_private_key',
label: '应用私钥',
component: 'InputTextArea',
required: true,
componentProps: {
rows: 4,
placeholder: '请输入应用私钥PKCS8格式',
},
},
{
field: 'alipay_public_key',
label: '支付宝公钥',
component: 'InputTextArea',
required: true,
componentProps: {
rows: 4,
placeholder: '请输入支付宝公钥',
},
},
{
field: 'alipay_sign_type',
label: '签名方式',
component: 'Select',
required: true,
colProps: { span: 12 },
componentProps: {
options: [
{ label: 'RSA2', value: 'RSA2' },
{ label: 'RSA', value: 'RSA' },
],
},
defaultValue: 'RSA2',
},
{
field: 'alipay_charset',
label: '字符集',
component: 'Select',
required: true,
colProps: { span: 12 },
componentProps: {
options: [
{ label: 'UTF-8', value: 'utf-8' },
{ label: 'GBK', value: 'gbk' },
],
},
defaultValue: 'utf-8',
},
{
field: 'alipay_notify_url',
label: '异步通知地址',
component: 'Input',
colProps: { span: 12 },
helpMessage: '支付结果异步通知地址',
},
{
field: 'alipay_return_url',
label: '同步返回地址',
component: 'Input',
colProps: { span: 12 },
helpMessage: '支付完成后同步跳转地址',
},
{
field: 'alipay_enabled',
label: '启用状态',
component: 'Switch',
defaultValue: false,
},
]
// 微信支付表单配置
const wechatFormSchema: FormSchema[] = [
{
field: 'wechat_app_id',
label: '应用ID',
component: 'Input',
required: true,
colProps: { span: 12 },
},
{
field: 'wechat_mch_id',
label: '商户号',
component: 'Input',
required: true,
colProps: { span: 12 },
},
{
field: 'wechat_key',
label: '商户密钥',
component: 'InputPassword',
required: true,
colProps: { span: 12 },
},
{
field: 'wechat_secret',
label: '应用密钥',
component: 'InputPassword',
required: true,
colProps: { span: 12 },
},
{
field: 'wechat_cert_path',
label: '证书路径',
component: 'Input',
helpMessage: '微信支付证书文件路径',
},
{
field: 'wechat_key_path',
label: '私钥路径',
component: 'Input',
helpMessage: '微信支付私钥文件路径',
},
{
field: 'wechat_notify_url',
label: '异步通知地址',
component: 'Input',
colProps: { span: 12 },
helpMessage: '支付结果异步通知地址',
},
{
field: 'wechat_trade_type',
label: '支付模式',
component: 'Select',
required: true,
colProps: { span: 12 },
componentProps: {
options: [
{ label: 'JSAPI支付', value: 'JSAPI' },
{ label: 'Native支付', value: 'NATIVE' },
{ label: 'APP支付', value: 'APP' },
{ label: 'H5支付', value: 'MWEB' },
],
},
defaultValue: 'JSAPI',
},
{
field: 'wechat_enabled',
label: '启用状态',
component: 'Switch',
defaultValue: false,
},
]
// 通用设置表单配置
const generalFormSchema: FormSchema[] = [
{
field: 'default_method',
label: '默认支付方式',
component: 'Select',
required: true,
componentProps: {
options: [
{ label: '支付宝', value: 'alipay' },
{ label: '微信支付', value: 'wechat' },
{ label: '余额支付', value: 'balance' },
],
},
defaultValue: 'alipay',
},
{
field: 'timeout',
label: '支付超时时间(分钟)',
component: 'InputNumber',
required: true,
componentProps: {
min: 1,
max: 1440,
},
defaultValue: 30,
},
{
field: 'min_amount',
label: '最小支付金额(元)',
component: 'InputNumber',
required: true,
componentProps: {
min: 0.01,
precision: 2,
},
defaultValue: 0.01,
},
{
field: 'max_amount',
label: '最大支付金额(元)',
component: 'InputNumber',
required: true,
componentProps: {
min: 1,
precision: 2,
},
defaultValue: 50000,
},
{
field: 'success_url',
label: '支付成功页面',
component: 'Input',
helpMessage: '支付成功后跳转的页面地址',
},
{
field: 'fail_url',
label: '支付失败页面',
component: 'Input',
helpMessage: '支付失败后跳转的页面地址',
},
{
field: 'balance_enabled',
label: '启用余额支付',
component: 'Switch',
defaultValue: true,
},
{
field: 'points_enabled',
label: '启用积分抵扣',
component: 'Switch',
defaultValue: false,
},
{
field: 'points_ratio',
label: '积分抵扣比例',
component: 'InputNumber',
show: ({ values }) => values.points_enabled,
componentProps: {
min: 1,
max: 1000,
addonAfter: '积分 = 1元',
},
defaultValue: 100,
helpMessage: '多少积分等于1元',
},
]
// 测试表单配置
const testFormSchema: FormSchema[] = [
{
field: 'amount',
label: '支付金额',
component: 'InputNumber',
required: true,
componentProps: {
min: 0.01,
precision: 2,
},
defaultValue: 0.01,
},
{
field: 'subject',
label: '商品名称',
component: 'Input',
required: true,
defaultValue: '测试商品',
},
{
field: 'body',
label: '商品描述',
component: 'Input',
defaultValue: '这是一个测试订单',
},
]
// 加载配置
const loadConfig = async () => {
try {
const data = await getPaymentConfigApi()
alipayFormRef.value?.setFieldsValue(data.alipay || {})
wechatFormRef.value?.setFieldsValue(data.wechat || {})
generalFormRef.value?.setFieldsValue(data.general || {})
} catch (error) {
message.error('加载配置失败')
}
}
// 保存支付宝配置
const handleSaveAlipay = async () => {
try {
const values = await alipayFormRef.value?.validate()
if (!values) return
saveLoading.value = true
await updatePaymentConfigApi({ type: 'alipay', config: values })
message.success('支付宝配置保存成功')
} catch (error) {
message.error('保存失败')
} finally {
saveLoading.value = false
}
}
// 保存微信支付配置
const handleSaveWechat = async () => {
try {
const values = await wechatFormRef.value?.validate()
if (!values) return
saveLoading.value = true
await updatePaymentConfigApi({ type: 'wechat', config: values })
message.success('微信支付配置保存成功')
} catch (error) {
message.error('保存失败')
} finally {
saveLoading.value = false
}
}
// 保存通用配置
const handleSaveGeneral = async () => {
try {
const values = await generalFormRef.value?.validate()
if (!values) return
saveLoading.value = true
await updatePaymentConfigApi({ type: 'general', config: values })
message.success('通用配置保存成功')
} catch (error) {
message.error('保存失败')
} finally {
saveLoading.value = false
}
}
// 测试支付宝
const handleTestAlipay = () => {
testFormRef.value?.setFieldsValue({
amount: 0.01,
subject: '支付宝测试商品',
body: '这是一个支付宝测试订单',
})
testDialogVisible.value = true
}
// 测试微信支付
const handleTestWechat = () => {
testFormRef.value?.setFieldsValue({
amount: 0.01,
subject: '微信支付测试商品',
body: '这是一个微信支付测试订单',
})
testDialogVisible.value = true
}
// 创建测试订单
const handleCreateTestOrder = async () => {
try {
const values = await testFormRef.value?.validate()
if (!values) return
const testData = {
method: activeTab.value,
...values,
}
const result = await testPaymentApi(testData)
message.success('测试订单创建成功')
// 打开支付页面或显示支付二维码
if (result.payUrl) {
window.open(result.payUrl, '_blank')
}
testDialogVisible.value = false
} catch (error) {
message.error('创建测试订单失败')
}
}
onMounted(() => {
loadConfig()
})
</script>
<style scoped>
.p-4 {
padding: 16px;
}
.flex {
display: flex;
}
.gap-2 {
gap: 8px;
}
</style>

View File

@@ -0,0 +1,688 @@
<template>
<div class="security-settings-page">
<div class="settings-container">
<Card title="安全设置" :loading="loading">
<Tabs v-model:activeKey="activeTab" type="card">
<!-- 密码策略 -->
<TabPane key="password" tab="密码策略">
<BasicForm
ref="passwordFormRef"
:schemas="passwordSchemas"
:model="passwordForm"
:label-width="120"
:action-col-options="{ span: 24 }"
:submit-button-options="{ text: '保存设置', loading: saveLoading }"
:reset-button-options="{ text: '重置设置' }"
@submit="handleSavePassword"
@reset="handleResetPassword"
/>
</TabPane>
<!-- 登录安全 -->
<TabPane key="login" tab="登录安全">
<BasicForm
ref="loginFormRef"
:schemas="loginSchemas"
:model="loginForm"
:label-width="120"
:action-col-options="{ span: 24 }"
:submit-button-options="{ text: '保存设置', loading: saveLoading }"
:reset-button-options="{ text: '重置设置' }"
@submit="handleSaveLogin"
@reset="handleResetLogin"
/>
</TabPane>
<!-- IP访问控制 -->
<TabPane key="ip" tab="IP访问控制">
<BasicForm
ref="ipFormRef"
:schemas="ipSchemas"
:model="ipForm"
:label-width="120"
:action-col-options="{ span: 24 }"
:submit-button-options="{ text: '保存设置', loading: saveLoading }"
:reset-button-options="{ text: '重置设置' }"
@submit="handleSaveIp"
@reset="handleResetIp"
>
<template #testIpAction>
<Button type="primary" :loading="testIpLoading" @click="handleTestIp">
测试当前IP
</Button>
</template>
</BasicForm>
</TabPane>
<!-- 操作审计 -->
<TabPane key="audit" tab="操作审计">
<BasicForm
ref="auditFormRef"
:schemas="auditSchemas"
:model="auditForm"
:label-width="120"
:action-col-options="{ span: 24 }"
:submit-button-options="{ text: '保存设置', loading: saveLoading }"
:reset-button-options="{ text: '重置设置' }"
@submit="handleSaveAudit"
@reset="handleResetAudit"
>
<template #auditLogAction>
<Button type="default" @click="handleViewAuditLog">
查看审计日志
</Button>
</template>
</BasicForm>
</TabPane>
</Tabs>
</Card>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import { Card, Tabs, TabPane, Button, message } from 'ant-design-vue';
import { BasicForm, FormSchema } from '@/components/Form';
import {
getSecurityConfigApi,
updateSecurityConfigApi,
resetSecurityConfigApi,
testIpAccessApi,
type SecurityConfig,
} from '@/api/common/security';
// 响应式数据
const loading = ref(false);
const saveLoading = ref(false);
const testIpLoading = ref(false);
const activeTab = ref('password');
// 表单引用
const passwordFormRef = ref();
const loginFormRef = ref();
const ipFormRef = ref();
const auditFormRef = ref();
// 表单数据
const passwordForm = reactive({
enablePasswordStrength: true,
minPasswordLength: 8,
requireLowercase: true,
requireUppercase: true,
requireNumbers: true,
requireSpecialChars: true,
forbidCommonPasswords: true,
passwordExpireDays: 90,
passwordHistoryLimit: 5,
forcePasswordChange: false,
});
const loginForm = reactive({
maxLoginAttempts: 5,
lockoutDuration: 30,
enableLoginCaptcha: true,
captchaTriggerAttempts: 3,
enableTwoFactor: false,
forceTwoFactor: false,
sessionTimeout: 120,
enableSingleSignOn: false,
recordLoginLog: true,
});
const ipForm = reactive({
enableIpControl: false,
accessMode: 'whitelist',
ipWhitelist: [''],
ipBlacklist: [''],
adminIpWhitelist: [''],
});
const auditForm = reactive({
enableAudit: true,
auditLoginLogout: true,
auditUserManagement: true,
auditRoleManagement: true,
auditPermissionManagement: true,
auditSystemConfig: true,
auditDataExport: true,
auditFileUpload: true,
auditSensitiveOperations: true,
auditLogRetention: 365,
enableSecondaryConfirm: true,
confirmDeleteUser: true,
confirmResetPassword: true,
confirmModifyRole: true,
confirmSystemBackup: true,
confirmSystemRestore: true,
confirmClearData: true,
enableAnomalyDetection: true,
});
// 表单配置
const passwordSchemas: FormSchema[] = [
{
field: 'enablePasswordStrength',
label: '启用密码强度检查',
component: 'Switch',
helpMessage: '开启后将检查密码复杂度',
},
{
field: 'minPasswordLength',
label: '最小密码长度',
component: 'InputNumber',
componentProps: {
min: 6,
max: 32,
placeholder: '请输入最小密码长度',
},
rules: [
{ required: true, message: '请输入最小密码长度' },
{ type: 'number', min: 6, max: 32, message: '密码长度范围为 6-32 位' },
],
},
{
field: 'requireLowercase',
label: '要求小写字母',
component: 'Switch',
helpMessage: '密码必须包含小写字母',
},
{
field: 'requireUppercase',
label: '要求大写字母',
component: 'Switch',
helpMessage: '密码必须包含大写字母',
},
{
field: 'requireNumbers',
label: '要求数字',
component: 'Switch',
helpMessage: '密码必须包含数字',
},
{
field: 'requireSpecialChars',
label: '要求特殊字符',
component: 'Switch',
helpMessage: '密码必须包含特殊字符',
},
{
field: 'forbidCommonPasswords',
label: '禁止常见密码',
component: 'Switch',
helpMessage: '禁止使用常见的弱密码',
},
{
field: 'passwordExpireDays',
label: '密码有效期(天)',
component: 'InputNumber',
componentProps: {
min: 0,
max: 365,
placeholder: '请输入密码有效期',
},
helpMessage: '0表示永不过期',
rules: [
{ required: true, message: '请输入密码有效期' },
{ type: 'number', min: 0, max: 365, message: '密码有效期范围为 0-365 天' },
],
},
{
field: 'passwordHistoryLimit',
label: '密码重复使用限制',
component: 'InputNumber',
componentProps: {
min: 0,
max: 20,
placeholder: '请输入历史密码限制数量',
},
helpMessage: '禁止重复使用最近N个密码0表示不限制',
rules: [
{ type: 'number', min: 0, max: 20, message: '历史密码限制范围为 0-20 个' },
],
},
{
field: 'forcePasswordChange',
label: '强制定期修改密码',
component: 'Switch',
helpMessage: '强制用户在密码过期前修改密码',
},
];
const loginSchemas: FormSchema[] = [
{
field: 'maxLoginAttempts',
label: '最大登录失败次数',
component: 'InputNumber',
componentProps: {
min: 3,
max: 20,
placeholder: '请输入最大登录失败次数',
},
rules: [
{ required: true, message: '请输入最大登录失败次数' },
{ type: 'number', min: 3, max: 20, message: '登录失败次数范围为 3-20 次' },
],
},
{
field: 'lockoutDuration',
label: '账户锁定时间(分钟)',
component: 'InputNumber',
componentProps: {
min: 5,
max: 1440,
placeholder: '请输入账户锁定时间',
},
rules: [
{ required: true, message: '请输入账户锁定时间' },
{ type: 'number', min: 5, max: 1440, message: '锁定时间范围为 5-1440 分钟' },
],
},
{
field: 'enableLoginCaptcha',
label: '启用登录验证码',
component: 'Switch',
helpMessage: '登录时显示验证码',
},
{
field: 'captchaTriggerAttempts',
label: '验证码触发条件',
component: 'InputNumber',
componentProps: {
min: 1,
max: 10,
placeholder: '请输入触发验证码的失败次数',
},
helpMessage: '登录失败多少次后显示验证码',
rules: [
{ type: 'number', min: 1, max: 10, message: '触发条件范围为 1-10 次' },
],
},
{
field: 'enableTwoFactor',
label: '启用双因子认证',
component: 'Switch',
helpMessage: '启用短信或邮箱二次验证',
},
{
field: 'forceTwoFactor',
label: '强制双因子认证',
component: 'Switch',
helpMessage: '强制所有用户启用双因子认证',
},
{
field: 'sessionTimeout',
label: '会话超时时间(分钟)',
component: 'InputNumber',
componentProps: {
min: 30,
max: 1440,
placeholder: '请输入会话超时时间',
},
rules: [
{ required: true, message: '请输入会话超时时间' },
{ type: 'number', min: 30, max: 1440, message: '会话超时时间范围为 30-1440 分钟' },
],
},
{
field: 'enableSingleSignOn',
label: '启用单点登录',
component: 'Switch',
helpMessage: '同一账户只能在一个地方登录',
},
{
field: 'recordLoginLog',
label: '记录登录日志',
component: 'Switch',
helpMessage: '记录用户登录和登出日志',
},
];
const ipSchemas: FormSchema[] = [
{
field: 'enableIpControl',
label: '启用IP访问控制',
component: 'Switch',
helpMessage: '启用后将根据IP地址控制访问权限',
},
{
field: 'accessMode',
label: '访问控制模式',
component: 'RadioGroup',
componentProps: {
options: [
{ label: '白名单模式', value: 'whitelist' },
{ label: '黑名单模式', value: 'blacklist' },
],
},
helpMessage: '白名单仅允许列表中的IP访问黑名单禁止列表中的IP访问',
},
{
field: 'ipWhitelist',
label: 'IP白名单',
component: 'Input',
componentProps: {
type: 'textarea',
rows: 4,
placeholder: '请输入IP地址每行一个\n支持格式192.168.1.1 或 192.168.1.0/24',
},
helpMessage: '每行一个IP地址或IP段支持CIDR格式',
show: ({ model }) => model.accessMode === 'whitelist',
},
{
field: 'ipBlacklist',
label: 'IP黑名单',
component: 'Input',
componentProps: {
type: 'textarea',
rows: 4,
placeholder: '请输入IP地址每行一个\n支持格式192.168.1.1 或 192.168.1.0/24',
},
helpMessage: '每行一个IP地址或IP段支持CIDR格式',
show: ({ model }) => model.accessMode === 'blacklist',
},
{
field: 'adminIpWhitelist',
label: '管理员IP白名单',
component: 'Input',
componentProps: {
type: 'textarea',
rows: 4,
placeholder: '请输入管理员IP地址每行一个\n支持格式192.168.1.1 或 192.168.1.0/24',
},
helpMessage: '管理员专用IP白名单优先级高于普通IP控制',
},
{
field: 'testIpAction',
label: '',
component: 'Input',
slot: 'testIpAction',
},
];
const auditSchemas: FormSchema[] = [
{
field: 'enableAudit',
label: '启用操作审计',
component: 'Switch',
helpMessage: '记录用户的重要操作行为',
},
{
field: 'auditEvents',
label: '审计事件类型',
component: 'CheckboxGroup',
componentProps: {
options: [
{ label: '登录登出', value: 'loginLogout' },
{ label: '用户管理', value: 'userManagement' },
{ label: '角色管理', value: 'roleManagement' },
{ label: '权限管理', value: 'permissionManagement' },
{ label: '系统配置', value: 'systemConfig' },
{ label: '数据导出', value: 'dataExport' },
{ label: '文件上传', value: 'fileUpload' },
{ label: '敏感操作', value: 'sensitiveOperations' },
],
},
helpMessage: '选择需要审计的事件类型',
},
{
field: 'auditLogRetention',
label: '审计日志保留期(天)',
component: 'InputNumber',
componentProps: {
min: 30,
max: 3650,
placeholder: '请输入日志保留期',
},
rules: [
{ required: true, message: '请输入审计日志保留期' },
{ type: 'number', min: 30, max: 3650, message: '日志保留期范围为 30-3650 天' },
],
},
{
field: 'enableSecondaryConfirm',
label: '敏感操作二次确认',
component: 'Switch',
helpMessage: '敏感操作需要二次确认',
},
{
field: 'sensitiveOperations',
label: '敏感操作类型',
component: 'CheckboxGroup',
componentProps: {
options: [
{ label: '删除用户', value: 'deleteUser' },
{ label: '重置密码', value: 'resetPassword' },
{ label: '修改角色', value: 'modifyRole' },
{ label: '系统备份', value: 'systemBackup' },
{ label: '系统恢复', value: 'systemRestore' },
{ label: '清除数据', value: 'clearData' },
],
},
helpMessage: '选择需要二次确认的敏感操作',
show: ({ model }) => model.enableSecondaryConfirm,
},
{
field: 'enableAnomalyDetection',
label: '异常行为检测',
component: 'Switch',
helpMessage: '检测并记录异常的用户行为',
},
{
field: 'auditLogAction',
label: '',
component: 'Input',
slot: 'auditLogAction',
},
];
// 方法
const loadSettings = async () => {
loading.value = true;
try {
const config = await getSecurityConfigApi();
// 更新表单数据
Object.assign(passwordForm, config.password || {});
Object.assign(loginForm, config.login || {});
Object.assign(ipForm, {
...config.ip,
ipWhitelist: config.ip?.ipWhitelist?.join('\n') || '',
ipBlacklist: config.ip?.ipBlacklist?.join('\n') || '',
adminIpWhitelist: config.ip?.adminIpWhitelist?.join('\n') || '',
});
// 处理审计事件类型
const auditEvents = [];
if (config.audit?.auditLoginLogout) auditEvents.push('loginLogout');
if (config.audit?.auditUserManagement) auditEvents.push('userManagement');
if (config.audit?.auditRoleManagement) auditEvents.push('roleManagement');
if (config.audit?.auditPermissionManagement) auditEvents.push('permissionManagement');
if (config.audit?.auditSystemConfig) auditEvents.push('systemConfig');
if (config.audit?.auditDataExport) auditEvents.push('dataExport');
if (config.audit?.auditFileUpload) auditEvents.push('fileUpload');
if (config.audit?.auditSensitiveOperations) auditEvents.push('sensitiveOperations');
const sensitiveOperations = [];
if (config.audit?.confirmDeleteUser) sensitiveOperations.push('deleteUser');
if (config.audit?.confirmResetPassword) sensitiveOperations.push('resetPassword');
if (config.audit?.confirmModifyRole) sensitiveOperations.push('modifyRole');
if (config.audit?.confirmSystemBackup) sensitiveOperations.push('systemBackup');
if (config.audit?.confirmSystemRestore) sensitiveOperations.push('systemRestore');
if (config.audit?.confirmClearData) sensitiveOperations.push('clearData');
Object.assign(auditForm, {
...config.audit,
auditEvents,
sensitiveOperations,
});
} catch (error) {
message.error('加载安全设置失败');
} finally {
loading.value = false;
}
};
const handleSavePassword = async (values: any) => {
saveLoading.value = true;
try {
await updateSecurityConfigApi({
type: 'password',
config: values,
});
message.success('密码策略保存成功');
} catch (error) {
message.error('保存失败');
} finally {
saveLoading.value = false;
}
};
const handleSaveLogin = async (values: any) => {
saveLoading.value = true;
try {
await updateSecurityConfigApi({
type: 'login',
config: values,
});
message.success('登录安全保存成功');
} catch (error) {
message.error('保存失败');
} finally {
saveLoading.value = false;
}
};
const handleSaveIp = async (values: any) => {
saveLoading.value = true;
try {
// 处理IP列表
const config = {
...values,
ipWhitelist: values.ipWhitelist ? values.ipWhitelist.split('\n').filter((ip: string) => ip.trim()) : [],
ipBlacklist: values.ipBlacklist ? values.ipBlacklist.split('\n').filter((ip: string) => ip.trim()) : [],
adminIpWhitelist: values.adminIpWhitelist ? values.adminIpWhitelist.split('\n').filter((ip: string) => ip.trim()) : [],
};
await updateSecurityConfigApi({
type: 'ip',
config,
});
message.success('IP访问控制保存成功');
} catch (error) {
message.error('保存失败');
} finally {
saveLoading.value = false;
}
};
const handleSaveAudit = async (values: any) => {
saveLoading.value = true;
try {
// 处理审计事件类型
const config = {
...values,
auditLoginLogout: values.auditEvents?.includes('loginLogout') || false,
auditUserManagement: values.auditEvents?.includes('userManagement') || false,
auditRoleManagement: values.auditEvents?.includes('roleManagement') || false,
auditPermissionManagement: values.auditEvents?.includes('permissionManagement') || false,
auditSystemConfig: values.auditEvents?.includes('systemConfig') || false,
auditDataExport: values.auditEvents?.includes('dataExport') || false,
auditFileUpload: values.auditEvents?.includes('fileUpload') || false,
auditSensitiveOperations: values.auditEvents?.includes('sensitiveOperations') || false,
confirmDeleteUser: values.sensitiveOperations?.includes('deleteUser') || false,
confirmResetPassword: values.sensitiveOperations?.includes('resetPassword') || false,
confirmModifyRole: values.sensitiveOperations?.includes('modifyRole') || false,
confirmSystemBackup: values.sensitiveOperations?.includes('systemBackup') || false,
confirmSystemRestore: values.sensitiveOperations?.includes('systemRestore') || false,
confirmClearData: values.sensitiveOperations?.includes('clearData') || false,
};
await updateSecurityConfigApi({
type: 'audit',
config,
});
message.success('操作审计保存成功');
} catch (error) {
message.error('保存失败');
} finally {
saveLoading.value = false;
}
};
const handleResetPassword = async () => {
try {
await resetSecurityConfigApi('password');
await loadSettings();
message.success('密码策略已重置');
} catch (error) {
message.error('重置失败');
}
};
const handleResetLogin = async () => {
try {
await resetSecurityConfigApi('login');
await loadSettings();
message.success('登录安全已重置');
} catch (error) {
message.error('重置失败');
}
};
const handleResetIp = async () => {
try {
await resetSecurityConfigApi('ip');
await loadSettings();
message.success('IP访问控制已重置');
} catch (error) {
message.error('重置失败');
}
};
const handleResetAudit = async () => {
try {
await resetSecurityConfigApi('audit');
await loadSettings();
message.success('操作审计已重置');
} catch (error) {
message.error('重置失败');
}
};
const handleTestIp = async () => {
testIpLoading.value = true;
try {
const result = await testIpAccessApi();
if (result.allowed) {
message.success(`当前IP ${result.ip} 允许访问`);
} else {
message.warning(`当前IP ${result.ip} 被拒绝访问`);
}
} catch (error) {
message.error('测试IP失败');
} finally {
testIpLoading.value = false;
}
};
const handleViewAuditLog = () => {
// 跳转到审计日志页面
console.log('查看审计日志');
};
// 生命周期
onMounted(() => {
loadSettings();
});
</script>
<style scoped>
.security-settings-page {
padding: 20px;
}
.settings-container {
max-width: 1200px;
margin: 0 auto;
}
</style>

View File

@@ -0,0 +1,352 @@
<template>
<div class="p-4">
<VbenForm
ref="formRef"
:schema="formSchema"
:form-options="{
layout: 'vertical',
labelCol: { span: 24 },
wrapperCol: { span: 24 },
}"
@submit="handleSave"
>
<template #submitButton>
<div class="flex gap-2">
<VbenButton type="primary" :loading="saveLoading" @click="handleSave">
保存配置
</VbenButton>
<VbenButton @click="handleTest">
测试发送
</VbenButton>
<VbenButton @click="handleReset">
重置配置
</VbenButton>
</div>
</template>
</VbenForm>
<!-- 测试短信发送对话框 -->
<VbenModal
v-model:open="testDialogVisible"
title="测试短信发送"
width="500px"
@ok="handleSendTest"
>
<VbenForm
ref="testFormRef"
:schema="testFormSchema"
:form-options="{
layout: 'vertical',
labelCol: { span: 24 },
wrapperCol: { span: 24 },
}"
/>
</VbenModal>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { VbenForm, VbenButton, VbenModal } from '@vben/components'
import { message } from 'ant-design-vue'
import type { FormSchema } from '@vben/types'
import {
getSmsConfigApi,
updateSmsConfigApi,
testSmsApi,
resetSmsConfigApi,
type SmsConfig,
} from '@/api/common/sms'
const formRef = ref()
const testFormRef = ref()
const saveLoading = ref(false)
const testDialogVisible = ref(false)
// 表单配置
const formSchema: FormSchema[] = [
{
field: 'provider',
label: '短信服务商',
component: 'Select',
required: true,
componentProps: {
options: [
{ label: '阿里云短信', value: 'aliyun' },
{ label: '腾讯云短信', value: 'tencent' },
],
},
},
{
field: 'access_key_id',
label: 'AccessKey ID',
component: 'Input',
required: true,
show: ({ values }) => values.provider === 'aliyun',
},
{
field: 'access_key_secret',
label: 'AccessKey Secret',
component: 'InputPassword',
required: true,
show: ({ values }) => values.provider === 'aliyun',
},
{
field: 'sign_name',
label: '签名名称',
component: 'Input',
required: true,
show: ({ values }) => values.provider === 'aliyun',
},
{
field: 'region',
label: '地域',
component: 'Select',
required: true,
show: ({ values }) => values.provider === 'aliyun',
componentProps: {
options: [
{ label: '华东1杭州', value: 'cn-hangzhou' },
{ label: '华北2北京', value: 'cn-beijing' },
{ label: '华东2上海', value: 'cn-shanghai' },
{ label: '华南1深圳', value: 'cn-shenzhen' },
],
},
},
{
field: 'secret_id',
label: 'SecretId',
component: 'Input',
required: true,
show: ({ values }) => values.provider === 'tencent',
},
{
field: 'secret_key',
label: 'SecretKey',
component: 'InputPassword',
required: true,
show: ({ values }) => values.provider === 'tencent',
},
{
field: 'app_id',
label: '应用ID',
component: 'Input',
required: true,
show: ({ values }) => values.provider === 'tencent',
},
{
field: 'sign_content',
label: '签名内容',
component: 'Input',
required: true,
show: ({ values }) => values.provider === 'tencent',
},
{
field: 'enabled',
label: '启用状态',
component: 'Switch',
defaultValue: true,
},
{
field: 'debug_mode',
label: '调试模式',
component: 'Switch',
defaultValue: false,
helpMessage: '开启后将记录详细的发送日志',
},
{
field: 'templates',
label: '短信模板',
component: 'FormList',
componentProps: {
copyIconProps: false,
deleteIconProps: false,
addButtonProps: {
text: '添加模板',
},
},
children: [
{
field: 'type',
label: '模板类型',
component: 'Select',
required: true,
componentProps: {
options: [
{ label: '验证码', value: 'verify_code' },
{ label: '通知', value: 'notification' },
{ label: '营销', value: 'marketing' },
],
},
},
{
field: 'template_id',
label: '模板ID',
component: 'Input',
required: true,
},
{
field: 'content',
label: '模板内容',
component: 'Textarea',
required: true,
},
{
field: 'variables',
label: '可用变量',
component: 'Input',
helpMessage: '多个变量用逗号分隔code,name',
},
],
},
{
field: 'rate_limit',
label: '发送限制',
component: 'FormGroup',
children: [
{
field: 'per_minute',
label: '每分钟限制',
component: 'InputNumber',
defaultValue: 10,
componentProps: {
min: 1,
max: 100,
},
},
{
field: 'per_hour',
label: '每小时限制',
component: 'InputNumber',
defaultValue: 100,
componentProps: {
min: 1,
max: 1000,
},
},
{
field: 'per_day',
label: '每日限制',
component: 'InputNumber',
defaultValue: 1000,
componentProps: {
min: 1,
max: 10000,
},
},
],
},
]
// 测试表单配置
const testFormSchema: FormSchema[] = [
{
field: 'mobile',
label: '手机号码',
component: 'Input',
required: true,
rules: [
{
pattern: /^1[3-9]\d{9}$/,
message: '请输入正确的手机号码',
},
],
},
{
field: 'template_type',
label: '模板类型',
component: 'Select',
required: true,
componentProps: {
options: [
{ label: '验证码', value: 'verify_code' },
{ label: '通知', value: 'notification' },
],
},
},
{
field: 'content',
label: '短信内容',
component: 'Textarea',
required: true,
componentProps: {
rows: 3,
placeholder: '请输入测试短信内容',
},
},
]
// 加载配置
const loadConfig = async () => {
try {
const data = await getSmsConfigApi()
formRef.value?.setFieldsValue(data)
} catch (error) {
message.error('加载配置失败')
}
}
// 保存配置
const handleSave = async () => {
try {
const values = await formRef.value?.validate()
if (!values) return
saveLoading.value = true
await updateSmsConfigApi(values as SmsConfig)
message.success('保存成功')
} catch (error) {
message.error('保存失败')
} finally {
saveLoading.value = false
}
}
// 测试发送
const handleTest = () => {
testDialogVisible.value = true
}
// 发送测试短信
const handleSendTest = async () => {
try {
const values = await testFormRef.value?.validate()
if (!values) return
await testSmsApi(values)
message.success('测试短信发送成功')
testDialogVisible.value = false
} catch (error) {
message.error('测试短信发送失败')
}
}
// 重置配置
const handleReset = async () => {
try {
await resetSmsConfigApi()
message.success('重置成功')
await loadConfig()
} catch (error) {
message.error('重置失败')
}
}
onMounted(() => {
loadConfig()
})
</script>
<style scoped>
.p-4 {
padding: 16px;
}
.flex {
display: flex;
}
.gap-2 {
gap: 8px;
}
</style>

View File

@@ -0,0 +1,470 @@
<template>
<div class="p-4">
<VbenForm
ref="formRef"
:schema="formSchema"
:form-options="{
layout: 'vertical',
labelCol: { span: 24 },
wrapperCol: { span: 24 },
}"
@submit="handleSave"
>
<template #submitButton>
<div class="flex gap-2">
<VbenButton type="primary" :loading="saveLoading" @click="handleSave">
保存配置
</VbenButton>
<VbenButton @click="handleTest">
测试上传
</VbenButton>
<VbenButton @click="handleReset">
重置配置
</VbenButton>
</div>
</template>
</VbenForm>
<!-- 测试上传对话框 -->
<VbenModal
v-model:open="testDialogVisible"
title="测试文件上传"
width="600px"
@ok="handleUploadTest"
>
<div class="upload-test-container">
<VbenUpload
ref="uploadRef"
v-model:file-list="testFileList"
:max-count="1"
:show-upload-list="false"
@change="handleFileChange"
>
<div class="upload-area">
<div class="upload-icon">
<Icon icon="ep:upload-filled" size="40" />
</div>
<div class="upload-text">
将文件拖到此处<em>点击上传</em>
</div>
<div class="upload-tip">
选择一个文件进行上传测试
</div>
</div>
</VbenUpload>
<div v-if="testFile" class="test-file-info">
<h4>文件信息</h4>
<p>文件名{{ testFile.name }}</p>
<p>文件大小{{ formatFileSize(testFile.size) }}</p>
<p>文件类型{{ testFile.type }}</p>
</div>
</div>
</VbenModal>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { VbenForm, VbenButton, VbenModal, VbenUpload } from '@vben/components'
import { Icon } from '@iconify/vue'
import { message } from 'ant-design-vue'
import type { FormSchema } from '@vben/types'
import {
getStorageConfigApi,
updateStorageConfigApi,
testStorageApi,
resetStorageConfigApi,
type StorageConfig,
} from '@/api/common/storage'
const formRef = ref()
const uploadRef = ref()
const saveLoading = ref(false)
const testDialogVisible = ref(false)
const testFileList = ref([])
const testFile = ref<File | null>(null)
// 表单配置
const formSchema: FormSchema[] = [
{
field: 'driver',
label: '存储驱动',
component: 'Select',
required: true,
componentProps: {
options: [
{ label: '本地存储', value: 'local' },
{ label: '阿里云OSS', value: 'oss' },
{ label: '腾讯云COS', value: 'cos' },
{ label: '七牛云', value: 'qiniu' },
{ label: '又拍云', value: 'upyun' },
{ label: 'AWS S3', value: 's3' },
],
},
},
// 本地存储配置
{
field: 'local_path',
label: '存储路径',
component: 'Input',
required: true,
show: ({ values }) => values.driver === 'local',
helpMessage: '相对于项目根目录的路径',
},
{
field: 'local_domain',
label: '访问域名',
component: 'Input',
required: true,
show: ({ values }) => values.driver === 'local',
helpMessage: '文件访问的完整域名',
},
// 阿里云OSS配置
{
field: 'oss_access_key_id',
label: 'AccessKey ID',
component: 'Input',
required: true,
show: ({ values }) => values.driver === 'oss',
},
{
field: 'oss_access_key_secret',
label: 'AccessKey Secret',
component: 'InputPassword',
required: true,
show: ({ values }) => values.driver === 'oss',
},
{
field: 'oss_bucket',
label: 'Bucket名称',
component: 'Input',
required: true,
show: ({ values }) => values.driver === 'oss',
},
{
field: 'oss_region',
label: '地域节点',
component: 'Select',
required: true,
show: ({ values }) => values.driver === 'oss',
componentProps: {
options: [
{ label: '华东1杭州', value: 'oss-cn-hangzhou' },
{ label: '华东2上海', value: 'oss-cn-shanghai' },
{ label: '华北1青岛', value: 'oss-cn-qingdao' },
{ label: '华北2北京', value: 'oss-cn-beijing' },
{ label: '华南1深圳', value: 'oss-cn-shenzhen' },
],
},
},
{
field: 'oss_domain',
label: '自定义域名',
component: 'Input',
show: ({ values }) => values.driver === 'oss',
helpMessage: '不填写则使用默认域名',
},
{
field: 'oss_is_private',
label: '是否私有',
component: 'Switch',
show: ({ values }) => values.driver === 'oss',
defaultValue: false,
},
// 腾讯云COS配置
{
field: 'cos_secret_id',
label: 'SecretId',
component: 'Input',
required: true,
show: ({ values }) => values.driver === 'cos',
},
{
field: 'cos_secret_key',
label: 'SecretKey',
component: 'InputPassword',
required: true,
show: ({ values }) => values.driver === 'cos',
},
{
field: 'cos_bucket',
label: 'Bucket名称',
component: 'Input',
required: true,
show: ({ values }) => values.driver === 'cos',
},
{
field: 'cos_region',
label: '地域',
component: 'Select',
required: true,
show: ({ values }) => values.driver === 'cos',
componentProps: {
options: [
{ label: '北京', value: 'ap-beijing' },
{ label: '上海', value: 'ap-shanghai' },
{ label: '广州', value: 'ap-guangzhou' },
{ label: '成都', value: 'ap-chengdu' },
{ label: '重庆', value: 'ap-chongqing' },
],
},
},
// 七牛云配置
{
field: 'qiniu_access_key',
label: 'AccessKey',
component: 'Input',
required: true,
show: ({ values }) => values.driver === 'qiniu',
},
{
field: 'qiniu_secret_key',
label: 'SecretKey',
component: 'InputPassword',
required: true,
show: ({ values }) => values.driver === 'qiniu',
},
{
field: 'qiniu_bucket',
label: '存储空间',
component: 'Input',
required: true,
show: ({ values }) => values.driver === 'qiniu',
},
{
field: 'qiniu_domain',
label: '访问域名',
component: 'Input',
required: true,
show: ({ values }) => values.driver === 'qiniu',
},
// 上传限制
{
field: 'max_size',
label: '最大文件大小MB',
component: 'InputNumber',
required: true,
defaultValue: 10,
componentProps: {
min: 1,
max: 1024,
},
},
{
field: 'allowed_types',
label: '允许的文件类型',
component: 'Select',
required: true,
componentProps: {
mode: 'multiple',
options: [
{ label: '图片 (jpg, jpeg, png, gif, webp)', value: 'image' },
{ label: '文档 (pdf, doc, docx, xls, xlsx, ppt, pptx)', value: 'document' },
{ label: '视频 (mp4, avi, mov, wmv, flv)', value: 'video' },
{ label: '音频 (mp3, wav, flac, aac)', value: 'audio' },
{ label: '压缩包 (zip, rar, 7z, tar, gz)', value: 'archive' },
],
},
},
// 图片处理
{
field: 'thumbnail_enabled',
label: '自动生成缩略图',
component: 'Switch',
defaultValue: true,
},
{
field: 'thumbnail_width',
label: '缩略图宽度',
component: 'InputNumber',
show: ({ values }) => values.thumbnail_enabled,
defaultValue: 200,
componentProps: {
min: 50,
max: 1000,
},
},
{
field: 'thumbnail_height',
label: '缩略图高度',
component: 'InputNumber',
show: ({ values }) => values.thumbnail_enabled,
defaultValue: 200,
componentProps: {
min: 50,
max: 1000,
},
},
// 其他设置
{
field: 'enabled',
label: '启用状态',
component: 'Switch',
defaultValue: true,
},
{
field: 'is_default',
label: '默认存储',
component: 'Switch',
defaultValue: false,
helpMessage: '设为默认存储驱动',
},
]
// 工具函数
const formatFileSize = (size: number) => {
if (size < 1024) {
return size + ' B'
} else if (size < 1024 * 1024) {
return (size / 1024).toFixed(2) + ' KB'
} else {
return (size / (1024 * 1024)).toFixed(2) + ' MB'
}
}
// 加载配置
const loadConfig = async () => {
try {
const data = await getStorageConfigApi()
formRef.value?.setFieldsValue(data)
} catch (error) {
message.error('加载配置失败')
}
}
// 保存配置
const handleSave = async () => {
try {
const values = await formRef.value?.validate()
if (!values) return
saveLoading.value = true
await updateStorageConfigApi(values as StorageConfig)
message.success('保存成功')
} catch (error) {
message.error('保存失败')
} finally {
saveLoading.value = false
}
}
// 测试上传
const handleTest = () => {
testFile.value = null
testFileList.value = []
testDialogVisible.value = true
}
// 文件变化处理
const handleFileChange = (fileList: any[]) => {
if (fileList.length > 0) {
testFile.value = fileList[0].originFileObj || fileList[0]
} else {
testFile.value = null
}
}
// 上传测试
const handleUploadTest = async () => {
if (!testFile.value) {
message.warning('请选择要上传的文件')
return
}
try {
const formData = new FormData()
formData.append('file', testFile.value)
const result = await testStorageApi(formData)
message.success(`上传测试成功文件URL: ${result.url}`)
testDialogVisible.value = false
} catch (error) {
message.error('上传测试失败')
}
}
// 重置配置
const handleReset = async () => {
try {
await resetStorageConfigApi()
message.success('重置成功')
await loadConfig()
} catch (error) {
message.error('重置失败')
}
}
onMounted(() => {
loadConfig()
})
</script>
<style scoped>
.p-4 {
padding: 16px;
}
.flex {
display: flex;
}
.gap-2 {
gap: 8px;
}
.upload-test-container {
padding: 16px;
}
.upload-area {
border: 2px dashed #d9d9d9;
border-radius: 6px;
background: #fafafa;
padding: 40px;
text-align: center;
cursor: pointer;
transition: border-color 0.3s;
}
.upload-area:hover {
border-color: #1890ff;
}
.upload-icon {
margin-bottom: 16px;
color: #999;
}
.upload-text {
margin-bottom: 8px;
color: #666;
}
.upload-text em {
color: #1890ff;
font-style: normal;
}
.upload-tip {
color: #999;
font-size: 12px;
}
.test-file-info {
margin-top: 20px;
padding: 15px;
background: #f5f7fa;
border-radius: 4px;
}
.test-file-info h4 {
margin: 0 0 10px 0;
color: #303133;
}
.test-file-info p {
margin: 5px 0;
color: #606266;
}
</style>

View File

@@ -0,0 +1,476 @@
<template>
<Page>
<VbenCard title="系统设置">
<template #extra>
<Icon icon="lucide:settings" class="text-lg" />
</template>
<VbenTabs v-model:active-key="activeTab" type="card">
<!-- 基本信息 -->
<VbenTabPane key="basic" tab="基本信息">
<VbenForm @submit="handleSubmitBasic">
<template #default="{ form }">
<VbenFormItem name="site_name" label="网站名称">
<Input v-model:value="form.site_name" placeholder="请输入网站名称" />
</VbenFormItem>
<VbenFormItem name="site_title" label="网站标题">
<Input v-model:value="form.site_title" placeholder="请输入网站标题" />
</VbenFormItem>
<VbenFormItem name="site_description" label="网站描述">
<Input
v-model:value="form.site_description"
type="textarea"
:rows="3"
placeholder="请输入网站描述"
/>
</VbenFormItem>
<VbenFormItem name="site_keywords" label="网站关键词">
<Input
v-model:value="form.site_keywords"
placeholder="请输入网站关键词,多个关键词用逗号分隔"
/>
</VbenFormItem>
<VbenFormItem name="site_icp" label="ICP备案号">
<Input v-model:value="form.site_icp" placeholder="请输入ICP备案号" />
</VbenFormItem>
<VbenFormItem name="site_copyright" label="版权信息">
<Input
v-model:value="form.site_copyright"
type="textarea"
:rows="2"
placeholder="请输入版权信息"
/>
</VbenFormItem>
</template>
<template #submit>
<Space>
<PrimaryButton html-type="submit" :loading="submitLoading">
<Icon icon="lucide:check" class="mr-1" />
保存设置
</PrimaryButton>
<DefaultButton @click="handleResetBasic">
<Icon icon="lucide:rotate-ccw" class="mr-1" />
重置
</DefaultButton>
</Space>
</template>
</VbenForm>
</VbenTabPane>
<!-- 系统配置 -->
<VbenTabPane key="config" tab="系统配置">
<VbenForm @submit="handleSubmitConfig">
<template #default="{ form }">
<VbenFormItem name="site_status" label="系统状态">
<RadioGroup v-model:value="form.site_status">
<template #default>
<Radio value="1">正常运行</Radio>
<Radio value="0">维护模式</Radio>
</template>
</RadioGroup>
</VbenFormItem>
<VbenFormItem
v-if="form.site_status === '0'"
name="site_close_reason"
label="维护提示"
>
<Input
v-model:value="form.site_close_reason"
type="textarea"
:rows="3"
placeholder="请输入维护提示信息"
/>
</VbenFormItem>
<VbenFormItem name="timezone" label="时区设置">
<Select v-model:value="form.timezone" placeholder="请选择时区">
<template #default>
<SelectOption value="Asia/Shanghai">北京时间 (UTC+8)</SelectOption>
<SelectOption value="Asia/Tokyo">东京时间 (UTC+9)</SelectOption>
<SelectOption value="America/New_York">纽约时间 (UTC-5)</SelectOption>
<SelectOption value="Europe/London">伦敦时间 (UTC+0)</SelectOption>
<SelectOption value="Europe/Paris">巴黎时间 (UTC+1)</SelectOption>
</template>
</Select>
</VbenFormItem>
<VbenFormItem name="default_language" label="默认语言">
<Select v-model:value="form.default_language" placeholder="请选择默认语言">
<template #default>
<SelectOption value="zh-CN">简体中文</SelectOption>
<SelectOption value="zh-TW">繁体中文</SelectOption>
<SelectOption value="en-US">English</SelectOption>
<SelectOption value="ja-JP">日本語</SelectOption>
<SelectOption value="ko-KR">한국어</SelectOption>
</template>
</Select>
</VbenFormItem>
<VbenFormItem name="page_size" label="分页大小">
<InputNumber
v-model:value="form.page_size"
:min="10"
:max="100"
:step="10"
placeholder="请输入分页大小"
/>
</VbenFormItem>
<VbenFormItem name="cache_enabled" label="缓存启用">
<Switch v-model:checked="form.cache_enabled" />
</VbenFormItem>
<VbenFormItem name="debug_enabled" label="调试模式">
<Switch v-model:checked="form.debug_enabled" />
</VbenFormItem>
</template>
<template #submit>
<Space>
<PrimaryButton html-type="submit" :loading="submitLoading">
<Icon icon="lucide:check" class="mr-1" />
保存设置
</PrimaryButton>
<DefaultButton @click="handleResetConfig">
<Icon icon="lucide:rotate-ccw" class="mr-1" />
重置
</DefaultButton>
</Space>
</template>
</VbenForm>
</VbenTabPane>
<!-- 系统信息 -->
<VbenTabPane key="info" tab="系统信息">
<VbenDescriptions title="服务器信息" :column="2" bordered>
<VbenDescriptionsItem label="操作系统">
{{ systemInfo.os || '-' }}
</VbenDescriptionsItem>
<VbenDescriptionsItem label="服务器软件">
{{ systemInfo.server || '-' }}
</VbenDescriptionsItem>
<VbenDescriptionsItem label="PHP版本">
{{ systemInfo.php_version || '-' }}
</VbenDescriptionsItem>
<VbenDescriptionsItem label="MySQL版本">
{{ systemInfo.mysql_version || '-' }}
</VbenDescriptionsItem>
<VbenDescriptionsItem label="Redis版本">
{{ systemInfo.redis_version || '-' }}
</VbenDescriptionsItem>
<VbenDescriptionsItem label="Node.js版本">
{{ systemInfo.node_version || '-' }}
</VbenDescriptionsItem>
<VbenDescriptionsItem label="内存使用">
{{ systemInfo.memory_usage || '-' }}
</VbenDescriptionsItem>
<VbenDescriptionsItem label="磁盘使用">
{{ systemInfo.disk_usage || '-' }}
</VbenDescriptionsItem>
</VbenDescriptions>
<div class="mt-4">
<Space>
<DefaultButton @click="handleRefreshInfo" :loading="refreshLoading">
<Icon icon="lucide:refresh-cw" class="mr-1" />
刷新信息
</DefaultButton>
<DefaultButton @click="handleExportInfo" :loading="exportLoading">
<Icon icon="lucide:download" class="mr-1" />
导出信息
</DefaultButton>
</Space>
</div>
</VbenTabPane>
</VbenTabs>
</VbenCard>
</Page>
</template>
<script lang="ts" setup>
import { ref, reactive, onMounted } from 'vue';
import { Icon } from '@iconify/vue';
import {
Page,
VbenCard,
VbenTabs,
VbenTabPane,
VbenDescriptions,
VbenDescriptionsItem
} from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import type { VbenFormSchema } from '#/adapter/form';
import {
getSystemConfigApi,
updateSystemConfigApi,
getSystemInfoApi,
exportSystemInfoApi
} from '#/api/common/system';
// 响应式数据
const activeTab = ref('basic');
const submitLoading = ref(false);
const refreshLoading = ref(false);
const exportLoading = ref(false);
// 系统信息
const systemInfo = reactive({
os: '',
server: '',
php_version: '',
mysql_version: '',
redis_version: '',
node_version: '',
memory_usage: '',
disk_usage: '',
});
// 基本信息表单配置
const basicFormSchema: VbenFormSchema[] = [
{
component: 'Input',
fieldName: 'site_name',
label: '网站名称',
rules: 'required',
componentProps: {
placeholder: '请输入网站名称',
},
},
{
component: 'Input',
fieldName: 'site_title',
label: '网站标题',
rules: 'required',
componentProps: {
placeholder: '请输入网站标题',
},
},
{
component: 'Input',
fieldName: 'site_description',
label: '网站描述',
componentProps: {
type: 'textarea',
rows: 3,
placeholder: '请输入网站描述',
},
},
{
component: 'Input',
fieldName: 'site_keywords',
label: '网站关键词',
componentProps: {
placeholder: '请输入网站关键词,多个关键词用逗号分隔',
},
},
{
component: 'Input',
fieldName: 'site_icp',
label: 'ICP备案号',
componentProps: {
placeholder: '请输入ICP备案号',
},
},
{
component: 'Input',
fieldName: 'site_copyright',
label: '版权信息',
componentProps: {
type: 'textarea',
rows: 2,
placeholder: '请输入版权信息',
},
},
];
// 系统配置表单配置
const configFormSchema: VbenFormSchema[] = [
{
component: 'RadioGroup',
fieldName: 'site_status',
label: '系统状态',
rules: 'required',
componentProps: {
options: [
{ label: '正常运行', value: '1' },
{ label: '维护模式', value: '0' },
],
},
},
{
component: 'Input',
fieldName: 'site_close_reason',
label: '维护提示',
dependencies: {
triggerFields: ['site_status'],
show: (values) => values.site_status === '0',
},
componentProps: {
type: 'textarea',
rows: 3,
placeholder: '请输入维护提示信息',
},
},
{
component: 'Select',
fieldName: 'timezone',
label: '时区设置',
rules: 'required',
componentProps: {
placeholder: '请选择时区',
options: [
{ label: '北京时间 (UTC+8)', value: 'Asia/Shanghai' },
{ label: '东京时间 (UTC+9)', value: 'Asia/Tokyo' },
{ label: '纽约时间 (UTC-5)', value: 'America/New_York' },
{ label: '伦敦时间 (UTC+0)', value: 'Europe/London' },
{ label: '巴黎时间 (UTC+1)', value: 'Europe/Paris' },
],
},
},
{
component: 'Select',
fieldName: 'default_language',
label: '默认语言',
rules: 'required',
componentProps: {
placeholder: '请选择默认语言',
options: [
{ label: '简体中文', value: 'zh-CN' },
{ label: '繁体中文', value: 'zh-TW' },
{ label: 'English', value: 'en-US' },
{ label: '日本語', value: 'ja-JP' },
{ label: '한국어', value: 'ko-KR' },
],
},
},
{
component: 'InputNumber',
fieldName: 'page_size',
label: '分页大小',
componentProps: {
min: 10,
max: 100,
step: 10,
placeholder: '请输入分页大小',
},
},
{
component: 'Switch',
fieldName: 'cache_enabled',
label: '缓存启用',
},
{
component: 'Switch',
fieldName: 'debug_enabled',
label: '调试模式',
},
];
// 创建表单实例
const [BasicForm, basicFormApi] = useVbenForm({
schema: basicFormSchema,
showDefaultActions: false,
});
const [ConfigForm, configFormApi] = useVbenForm({
schema: configFormSchema,
showDefaultActions: false,
});
// 处理基本信息提交
const handleSubmitBasic = async (values: Record<string, any>) => {
try {
submitLoading.value = true;
await updateSystemConfigApi('basic', values);
// 显示成功消息
} catch (error) {
console.error('保存基本信息失败:', error);
} finally {
submitLoading.value = false;
}
};
// 处理系统配置提交
const handleSubmitConfig = async (values: Record<string, any>) => {
try {
submitLoading.value = true;
await updateSystemConfigApi('config', values);
// 显示成功消息
} catch (error) {
console.error('保存系统配置失败:', error);
} finally {
submitLoading.value = false;
}
};
// 重置基本信息
const handleResetBasic = () => {
basicFormApi.resetForm();
};
// 重置系统配置
const handleResetConfig = () => {
configFormApi.resetForm();
};
// 刷新系统信息
const handleRefreshInfo = async () => {
try {
refreshLoading.value = true;
const data = await getSystemInfoApi();
Object.assign(systemInfo, data);
} catch (error) {
console.error('刷新系统信息失败:', error);
} finally {
refreshLoading.value = false;
}
};
// 导出系统信息
const handleExportInfo = async () => {
try {
exportLoading.value = true;
await exportSystemInfoApi();
} catch (error) {
console.error('导出系统信息失败:', error);
} finally {
exportLoading.value = false;
}
};
// 初始化数据
const initData = async () => {
try {
const [basicData, configData, infoData] = await Promise.all([
getSystemConfigApi('basic'),
getSystemConfigApi('config'),
getSystemInfoApi(),
]);
basicFormApi.setValues(basicData);
configFormApi.setValues(configData);
Object.assign(systemInfo, infoData);
} catch (error) {
console.error('初始化数据失败:', error);
}
};
// 组件挂载时初始化数据
onMounted(() => {
initData();
});
</script>
<style scoped>
.card-header {
display: flex;
align-items: center;
}
</style>

View File

@@ -0,0 +1,550 @@
<template>
<Page
description="管理系统管理员账户信息"
title="管理员管理"
>
<div class="flex flex-col gap-4">
<!-- 搜索表单 -->
<el-card>
<el-form :model="searchForm" inline>
<el-form-item label="用户名">
<el-input
v-model="searchForm.keyword"
placeholder="请输入用户名"
clearable
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="searchForm.status" placeholder="请选择状态" clearable>
<el-option label="正常" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<Icon icon="ep:search" class="mr-1" />
搜索
</el-button>
<el-button @click="handleReset">
<Icon icon="ep:refresh" class="mr-1" />
重置
</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 操作按钮和数据表格 -->
<el-card>
<div class="mb-4">
<el-button type="primary" @click="handleAdd">
<Icon icon="ep:plus" class="mr-1" />
新增管理员
</el-button>
<el-button
type="danger"
:disabled="!selectedRows.length"
@click="handleBatchDelete"
>
<Icon icon="ep:delete" class="mr-1" />
批量删除
</el-button>
</div>
<el-table
v-loading="loading"
:data="tableData"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" />
<el-table-column prop="uid" label="ID" width="80" />
<el-table-column prop="username" label="用户名" min-width="120" />
<el-table-column prop="realName" label="真实姓名" min-width="120" />
<el-table-column prop="mobile" label="手机号" min-width="120" />
<el-table-column prop="email" label="邮箱" min-width="150" />
<el-table-column prop="status" label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'">
{{ row.status === 1 ? '正常' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="isAdmin" label="超级管理员" width="120">
<template #default="{ row }">
<el-tag :type="row.isAdmin === 1 ? 'warning' : 'info'">
{{ row.isAdmin === 1 ? '是' : '否' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" width="180">
<template #default="{ row }">
{{ formatTime(row.createTime) }}
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button type="primary" size="small" @click="handleEdit(row)">
编辑
</el-button>
<el-button type="warning" size="small" @click="handleSetRoles(row)">
角色
</el-button>
<el-button
type="danger"
size="small"
@click="handleDelete(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.limit"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
<!-- 新增/编辑对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="600px"
@close="handleDialogClose"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="100px"
>
<el-form-item label="用户名" prop="username">
<el-input
v-model="formData.username"
placeholder="请输入用户名"
:disabled="isEdit"
/>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="formData.password"
type="password"
placeholder="请输入密码"
show-password
/>
</el-form-item>
<el-form-item label="真实姓名" prop="realName">
<el-input v-model="formData.realName" placeholder="请输入真实姓名" />
</el-form-item>
<el-form-item label="手机号" prop="mobile">
<el-input v-model="formData.mobile" placeholder="请输入手机号" />
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="formData.email" placeholder="请输入邮箱" />
</el-form-item>
<el-form-item label="性别" prop="sex">
<el-radio-group v-model="formData.sex">
<el-radio :label="1"></el-radio>
<el-radio :label="2"></el-radio>
<el-radio :label="0">未知</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="formData.status">
<el-radio :label="1">正常</el-radio>
<el-radio :label="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="超级管理员" prop="isAdmin">
<el-radio-group v-model="formData.isAdmin">
<el-radio :label="1"></el-radio>
<el-radio :label="0"></el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">
确定
</el-button>
</template>
</el-dialog>
<!-- 角色分配对话框 -->
<el-dialog v-model="roleDialogVisible" title="分配角色" width="500px">
<el-checkbox-group v-model="selectedRoleIds">
<el-checkbox
v-for="role in roleList"
:key="role.roleId"
:label="role.roleId"
>
{{ role.roleName }}
</el-checkbox>
</el-checkbox-group>
<template #footer>
<el-button @click="roleDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSaveRoles" :loading="roleLoading">
确定
</el-button>
</template>
</el-dialog>
</el-card>
</div>
</Page>
</template>
<script lang="ts" setup>
// 1. Vue 相关导入
import { ref, reactive, onMounted } from 'vue';
// 2. Element Plus 组件导入
import {
ElButton,
ElCard,
ElCol,
ElDialog,
ElForm,
ElFormItem,
ElInput,
ElMessage,
ElMessageBox,
ElOption,
ElPagination,
ElRow,
ElSelect,
ElSpace,
ElSwitch,
ElTable,
ElTableColumn,
ElTag,
type FormInstance,
} from 'element-plus';
// 3. 图标组件导入
import { Icon } from '@iconify/vue';
// 4. Vben 组件导入
import { Page } from '@vben/common-ui';
// 5. 项目内部导入
import {
getAdminListApi,
createAdminApi,
updateAdminApi,
deleteAdminApi,
batchDeleteAdminApi,
setAdminRolesApi,
getAdminRolesApi,
getAllRolesApi,
type AdminUser,
type CreateAdminParams,
type UpdateAdminParams,
type Role,
} from '#/api/common/auth';
// 响应式数据
const loading = ref(false);
const submitLoading = ref(false);
const roleLoading = ref(false);
const tableData = ref<AdminUser[]>([]);
const selectedRows = ref<AdminUser[]>([]);
const roleList = ref<Role[]>([]);
const selectedRoleIds = ref<number[]>([]);
const currentUserId = ref<number>(0);
// 搜索表单
const searchForm = reactive({
keyword: '',
status: undefined as number | undefined,
});
// 分页
const pagination = reactive({
page: 1,
limit: 20,
total: 0,
});
// 对话框
const dialogVisible = ref(false);
const roleDialogVisible = ref(false);
const isEdit = ref(false);
const formRef = ref<FormInstance>();
// 表单数据
const formData = reactive<CreateAdminParams & { uid?: number }>({
username: '',
password: '',
realName: '',
mobile: '',
email: '',
sex: 0,
status: 1,
isAdmin: 0,
});
// 表单验证规则
const formRules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 20, message: '用户名长度在 3 到 20 个字符', trigger: 'blur' },
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, max: 20, message: '密码长度在 6 到 20 个字符', trigger: 'blur' },
],
realName: [
{ required: true, message: '请输入真实姓名', trigger: 'blur' },
],
mobile: [
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' },
],
email: [
{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' },
],
};
// 计算属性
const dialogTitle = computed(() => (isEdit.value ? '编辑管理员' : '新增管理员'));
// 方法
const formatTime = (timestamp: number) => {
if (!timestamp) return '-';
return new Date(timestamp * 1000).toLocaleString();
};
const loadData = async () => {
loading.value = true;
try {
const params = {
page: pagination.page,
limit: pagination.limit,
keyword: searchForm.keyword || undefined,
status: searchForm.status,
};
const result = await getAdminListApi(params);
tableData.value = result.list;
pagination.total = result.total;
} catch (error) {
ElMessage.error('加载数据失败');
} finally {
loading.value = false;
}
};
const loadRoles = async () => {
try {
roleList.value = await getAllRolesApi();
} catch (error) {
ElMessage.error('加载角色列表失败');
}
};
const handleSearch = () => {
pagination.page = 1;
loadData();
};
const handleReset = () => {
searchForm.keyword = '';
searchForm.status = undefined;
handleSearch();
};
const handleSizeChange = (size: number) => {
pagination.limit = size;
loadData();
};
const handleCurrentChange = (page: number) => {
pagination.page = page;
loadData();
};
const handleSelectionChange = (selection: AdminUser[]) => {
selectedRows.value = selection;
};
const handleAdd = () => {
isEdit.value = false;
resetForm();
dialogVisible.value = true;
};
const handleEdit = (row: AdminUser) => {
isEdit.value = true;
Object.assign(formData, {
uid: row.uid,
username: row.username,
password: '',
realName: row.realName,
mobile: row.mobile,
email: row.email,
sex: row.sex,
status: row.status,
isAdmin: row.isAdmin,
});
dialogVisible.value = true;
};
const handleDelete = async (row: AdminUser) => {
try {
await ElMessageBox.confirm(
`确定要删除管理员 "${row.username}" 吗?`,
'确认删除',
{
type: 'warning',
}
);
await deleteAdminApi(row.uid);
ElMessage.success('删除成功');
loadData();
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败');
}
}
};
const handleBatchDelete = async () => {
try {
await ElMessageBox.confirm(
`确定要删除选中的 ${selectedRows.value.length} 个管理员吗?`,
'确认删除',
{
type: 'warning',
}
);
const uids = selectedRows.value.map(row => row.uid);
await batchDeleteAdminApi(uids);
ElMessage.success('删除成功');
loadData();
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败');
}
}
};
const handleSetRoles = async (row: AdminUser) => {
currentUserId.value = row.uid;
try {
const roles = await getAdminRolesApi(row.uid);
selectedRoleIds.value = roles.map(role => role.roleId);
roleDialogVisible.value = true;
} catch (error) {
ElMessage.error('加载用户角色失败');
}
};
const handleSaveRoles = async () => {
roleLoading.value = true;
try {
await setAdminRolesApi(currentUserId.value, selectedRoleIds.value);
ElMessage.success('角色分配成功');
roleDialogVisible.value = false;
loadData();
} catch (error) {
ElMessage.error('角色分配失败');
} finally {
roleLoading.value = false;
}
};
const handleSubmit = async () => {
if (!formRef.value) return;
try {
await formRef.value.validate();
submitLoading.value = true;
if (isEdit.value) {
const updateData: UpdateAdminParams = {
uid: formData.uid!,
username: formData.username,
realName: formData.realName,
mobile: formData.mobile,
email: formData.email,
sex: formData.sex,
status: formData.status,
isAdmin: formData.isAdmin,
};
if (formData.password) {
updateData.password = formData.password;
}
await updateAdminApi(updateData);
ElMessage.success('更新成功');
} else {
await createAdminApi(formData);
ElMessage.success('创建成功');
}
dialogVisible.value = false;
loadData();
} catch (error) {
ElMessage.error(isEdit.value ? '更新失败' : '创建失败');
} finally {
submitLoading.value = false;
}
};
const handleDialogClose = () => {
formRef.value?.resetFields();
resetForm();
};
const resetForm = () => {
Object.assign(formData, {
uid: undefined,
username: '',
password: '',
realName: '',
mobile: '',
email: '',
sex: 0,
status: 1,
isAdmin: 0,
});
};
// 生命周期
onMounted(() => {
loadData();
loadRoles();
});
</script>
<style scoped>
.admin-user-page {
padding: 20px;
}
.search-form {
background: #fff;
padding: 20px;
border-radius: 4px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.action-buttons {
background: #fff;
padding: 20px;
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.pagination-wrapper {
display: flex;
justify-content: center;
margin-top: 20px;
}
</style>

View File

@@ -0,0 +1,722 @@
<template>
<div class="member-user-page">
<!-- 搜索表单 -->
<div class="search-form">
<el-form :model="searchForm" inline>
<el-form-item label="用户名">
<el-input
v-model="searchForm.keyword"
placeholder="请输入用户名或手机号"
clearable
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="searchForm.status" placeholder="请选择状态" clearable>
<el-option label="正常" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
</el-form-item>
<el-form-item label="会员等级">
<el-select v-model="searchForm.memberLevel" placeholder="请选择会员等级" clearable>
<el-option label="普通会员" :value="1" />
<el-option label="银牌会员" :value="2" />
<el-option label="金牌会员" :value="3" />
<el-option label="钻石会员" :value="4" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<Icon icon="ep:search" class="mr-1" />
搜索
</el-button>
<el-button @click="handleReset">
<Icon icon="ep:refresh" class="mr-1" />
重置
</el-button>
</el-form-item>
</el-form>
</div>
<!-- 操作按钮 -->
<div class="action-buttons mb-4">
<el-button type="primary" @click="handleAdd">
<Icon icon="ep:plus" class="mr-1" />
新增会员
</el-button>
<el-button
type="danger"
:disabled="!selectedRows.length"
@click="handleBatchDelete"
>
<Icon icon="ep:delete" class="mr-1" />
批量删除
</el-button>
<el-button type="success" @click="handleExport">
<Icon icon="ep:download" class="mr-1" />
导出数据
</el-button>
</div>
<!-- 数据表格 -->
<el-table
v-loading="loading"
:data="tableData"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" />
<el-table-column prop="memberId" 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 prop="mobile" label="手机号" min-width="120" />
<el-table-column prop="sex" label="性别" width="80">
<template #default="{ row }">
<el-tag :type="getSexTagType(row.sex)">
{{ getSexText(row.sex) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="memberLevel" label="会员等级" width="100">
<template #default="{ row }">
<el-tag :type="getLevelTagType(row.memberLevel)">
{{ getLevelText(row.memberLevel) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="point" label="积分" width="80" />
<el-table-column prop="balance" label="余额" width="100">
<template #default="{ row }">
¥{{ (row.balance / 100).toFixed(2) }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'">
{{ row.status === 1 ? '正常' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="注册时间" width="180">
<template #default="{ row }">
{{ formatTime(row.createTime) }}
</template>
</el-table-column>
<el-table-column label="操作" width="250" fixed="right">
<template #default="{ row }">
<el-button type="primary" size="small" @click="handleEdit(row)">
编辑
</el-button>
<el-button type="warning" size="small" @click="handleBalance(row)">
余额
</el-button>
<el-button type="info" size="small" @click="handlePoint(row)">
积分
</el-button>
<el-button
type="danger"
size="small"
@click="handleDelete(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.limit"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
<!-- 新增/编辑对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="600px"
@close="handleDialogClose"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="100px"
>
<el-form-item label="用户名" prop="username">
<el-input
v-model="formData.username"
placeholder="请输入用户名"
:disabled="isEdit"
/>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="formData.password"
type="password"
placeholder="请输入密码"
show-password
/>
</el-form-item>
<el-form-item label="手机号" prop="mobile">
<el-input v-model="formData.mobile" 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="sex">
<el-radio-group v-model="formData.sex">
<el-radio :label="1"></el-radio>
<el-radio :label="2"></el-radio>
<el-radio :label="0">未知</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="会员等级" prop="memberLevel">
<el-select v-model="formData.memberLevel" placeholder="请选择会员等级">
<el-option label="普通会员" :value="1" />
<el-option label="银牌会员" :value="2" />
<el-option label="金牌会员" :value="3" />
<el-option label="钻石会员" :value="4" />
</el-select>
</el-form-item>
<el-form-item label="积分" prop="point">
<el-input-number v-model="formData.point" :min="0" />
</el-form-item>
<el-form-item label="余额" prop="balance">
<el-input-number v-model="formData.balance" :min="0" :precision="2" />
<span class="ml-2 text-gray-500"></span>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="formData.status">
<el-radio :label="1">正常</el-radio>
<el-radio :label="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">
确定
</el-button>
</template>
</el-dialog>
<!-- 余额调整对话框 -->
<el-dialog v-model="balanceDialogVisible" title="余额调整" width="400px">
<el-form :model="balanceForm" label-width="100px">
<el-form-item label="当前余额">
<span>¥{{ (currentMember?.balance || 0) / 100 }}</span>
</el-form-item>
<el-form-item label="调整类型">
<el-radio-group v-model="balanceForm.changeType">
<el-radio label="increase">增加</el-radio>
<el-radio label="decrease">减少</el-radio>
<el-radio label="set">设置</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="调整金额">
<el-input-number
v-model="balanceForm.amount"
:min="0"
:precision="2"
placeholder="请输入金额"
/>
<span class="ml-2 text-gray-500"></span>
</el-form-item>
<el-form-item label="备注">
<el-input
v-model="balanceForm.remark"
type="textarea"
placeholder="请输入调整原因"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="balanceDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSaveBalance" :loading="balanceLoading">
确定
</el-button>
</template>
</el-dialog>
<!-- 积分调整对话框 -->
<el-dialog v-model="pointDialogVisible" title="积分调整" width="400px">
<el-form :model="pointForm" label-width="100px">
<el-form-item label="当前积分">
<span>{{ currentMember?.point || 0 }}</span>
</el-form-item>
<el-form-item label="调整类型">
<el-radio-group v-model="pointForm.changeType">
<el-radio label="increase">增加</el-radio>
<el-radio label="decrease">减少</el-radio>
<el-radio label="set">设置</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="调整积分">
<el-input-number
v-model="pointForm.amount"
:min="0"
placeholder="请输入积分"
/>
</el-form-item>
<el-form-item label="备注">
<el-input
v-model="pointForm.remark"
type="textarea"
placeholder="请输入调整原因"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="pointDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSavePoint" :loading="pointLoading">
确定
</el-button>
</template>
</el-dialog>
</div>
</template>
<script lang="ts" setup>
// 1. Vue 相关导入
import { ref, reactive, onMounted, computed, type FormInstance } from 'vue';
// 2. Element Plus 组件导入
import {
ElButton,
ElCard,
ElCol,
ElDialog,
ElForm,
ElFormItem,
ElInput,
ElInputNumber,
ElMessage,
ElMessageBox,
ElOption,
ElPagination,
ElRadio,
ElRadioGroup,
ElRow,
ElSelect,
ElSpace,
ElSwitch,
ElTable,
ElTableColumn,
ElTag,
} from 'element-plus';
// 3. 图标组件导入
import { Icon } from '@iconify/vue';
// 4. Vben 组件导入
import { Page } from '@vben/common-ui';
// 5. 项目内部导入
import {
getMemberListApi,
createMemberApi,
updateMemberApi,
deleteMemberApi,
batchDeleteMemberApi,
updateMemberBalanceApi,
updateMemberPointApi,
type Member,
type CreateMemberParams,
type UpdateMemberParams,
} from '#/api/common/user';
// 响应式数据
const loading = ref(false);
const submitLoading = ref(false);
const balanceLoading = ref(false);
const pointLoading = ref(false);
const tableData = ref<Member[]>([]);
const selectedRows = ref<Member[]>([]);
const currentMember = ref<Member | null>(null);
// 搜索表单
const searchForm = reactive({
keyword: '',
status: undefined as number | undefined,
memberLevel: undefined as number | undefined,
});
// 分页
const pagination = reactive({
page: 1,
limit: 20,
total: 0,
});
// 对话框
const dialogVisible = ref(false);
const balanceDialogVisible = ref(false);
const pointDialogVisible = ref(false);
const isEdit = ref(false);
const formRef = ref<FormInstance>();
// 表单数据
const formData = reactive<CreateMemberParams & { memberId?: number; balance: number }>({
username: '',
password: '',
mobile: '',
nickname: '',
sex: 0,
memberLevel: 1,
point: 0,
balance: 0,
status: 1,
});
// 余额调整表单
const balanceForm = reactive({
changeType: 'increase',
amount: 0,
remark: '',
});
// 积分调整表单
const pointForm = reactive({
changeType: 'increase',
amount: 0,
remark: '',
});
// 表单验证规则
const formRules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 20, message: '用户名长度在 3 到 20 个字符', trigger: 'blur' },
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, max: 20, message: '密码长度在 6 到 20 个字符', trigger: 'blur' },
],
mobile: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' },
],
nickname: [
{ required: true, message: '请输入昵称', trigger: 'blur' },
],
};
// 计算属性
const dialogTitle = computed(() => (isEdit.value ? '编辑会员' : '新增会员'));
// 方法
const formatTime = (timestamp: number) => {
if (!timestamp) return '-';
return new Date(timestamp * 1000).toLocaleString();
};
const getSexText = (sex: number) => {
const map = { 0: '未知', 1: '男', 2: '女' };
return map[sex as keyof typeof map] || '未知';
};
const getSexTagType = (sex: number) => {
const map = { 0: 'info', 1: 'primary', 2: 'success' };
return map[sex as keyof typeof map] || 'info';
};
const getLevelText = (level: number) => {
const map = { 1: '普通', 2: '银牌', 3: '金牌', 4: '钻石' };
return map[level as keyof typeof map] || '普通';
};
const getLevelTagType = (level: number) => {
const map = { 1: 'info', 2: 'warning', 3: 'success', 4: 'danger' };
return map[level as keyof typeof map] || 'info';
};
const loadData = async () => {
loading.value = true;
try {
const params = {
page: pagination.page,
limit: pagination.limit,
keyword: searchForm.keyword || undefined,
status: searchForm.status,
};
const result = await getMemberListApi(params);
tableData.value = result.list;
pagination.total = result.total;
} catch (error) {
ElMessage.error('加载数据失败');
} finally {
loading.value = false;
}
};
const handleSearch = () => {
pagination.page = 1;
loadData();
};
const handleReset = () => {
searchForm.keyword = '';
searchForm.status = undefined;
searchForm.memberLevel = undefined;
handleSearch();
};
const handleSizeChange = (size: number) => {
pagination.limit = size;
loadData();
};
const handleCurrentChange = (page: number) => {
pagination.page = page;
loadData();
};
const handleSelectionChange = (selection: Member[]) => {
selectedRows.value = selection;
};
const handleAdd = () => {
isEdit.value = false;
resetForm();
dialogVisible.value = true;
};
const handleEdit = (row: Member) => {
isEdit.value = true;
Object.assign(formData, {
memberId: row.memberId,
username: row.username,
password: '',
mobile: row.mobile,
nickname: row.nickname,
sex: row.sex,
memberLevel: row.memberLevel,
point: row.point,
balance: row.balance / 100, // 转换为元
status: row.status,
});
dialogVisible.value = true;
};
const handleDelete = async (row: Member) => {
try {
await ElMessageBox.confirm(
`确定要删除会员 "${row.username}" 吗?`,
'确认删除',
{
type: 'warning',
}
);
await deleteMemberApi(row.memberId);
ElMessage.success('删除成功');
loadData();
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败');
}
}
};
const handleBatchDelete = async () => {
try {
await ElMessageBox.confirm(
`确定要删除选中的 ${selectedRows.value.length} 个会员吗?`,
'确认删除',
{
type: 'warning',
}
);
const memberIds = selectedRows.value.map(row => row.memberId);
await batchDeleteMemberApi(memberIds);
ElMessage.success('删除成功');
loadData();
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败');
}
}
};
const handleExport = () => {
ElMessage.info('导出功能开发中...');
};
const handleBalance = (row: Member) => {
currentMember.value = row;
balanceForm.changeType = 'increase';
balanceForm.amount = 0;
balanceForm.remark = '';
balanceDialogVisible.value = true;
};
const handlePoint = (row: Member) => {
currentMember.value = row;
pointForm.changeType = 'increase';
pointForm.amount = 0;
pointForm.remark = '';
pointDialogVisible.value = true;
};
const handleSaveBalance = async () => {
if (!currentMember.value || !balanceForm.amount) {
ElMessage.warning('请输入调整金额');
return;
}
balanceLoading.value = true;
try {
let newBalance = balanceForm.amount * 100; // 转换为分
if (balanceForm.changeType === 'decrease') {
newBalance = -newBalance;
} else if (balanceForm.changeType === 'set') {
newBalance = balanceForm.amount * 100;
}
await updateMemberBalanceApi(
currentMember.value.memberId,
newBalance,
balanceForm.changeType,
balanceForm.remark
);
ElMessage.success('余额调整成功');
balanceDialogVisible.value = false;
loadData();
} catch (error) {
ElMessage.error('余额调整失败');
} finally {
balanceLoading.value = false;
}
};
const handleSavePoint = async () => {
if (!currentMember.value || !pointForm.amount) {
ElMessage.warning('请输入调整积分');
return;
}
pointLoading.value = true;
try {
let newPoint = pointForm.amount;
if (pointForm.changeType === 'decrease') {
newPoint = -newPoint;
}
await updateMemberPointApi(
currentMember.value.memberId,
newPoint,
pointForm.changeType,
pointForm.remark
);
ElMessage.success('积分调整成功');
pointDialogVisible.value = false;
loadData();
} catch (error) {
ElMessage.error('积分调整失败');
} finally {
pointLoading.value = false;
}
};
const handleSubmit = async () => {
if (!formRef.value) return;
try {
await formRef.value.validate();
submitLoading.value = true;
if (isEdit.value) {
const updateData: UpdateMemberParams = {
memberId: formData.memberId!,
username: formData.username,
mobile: formData.mobile,
nickname: formData.nickname,
sex: formData.sex,
memberLevel: formData.memberLevel,
point: formData.point,
balance: Math.round(formData.balance * 100), // 转换为分
status: formData.status,
};
if (formData.password) {
updateData.password = formData.password;
}
await updateMemberApi(updateData);
ElMessage.success('更新成功');
} else {
const createData: CreateMemberParams = {
...formData,
balance: Math.round(formData.balance * 100), // 转换为分
};
await createMemberApi(createData);
ElMessage.success('创建成功');
}
dialogVisible.value = false;
loadData();
} catch (error) {
ElMessage.error(isEdit.value ? '更新失败' : '创建失败');
} finally {
submitLoading.value = false;
}
};
const handleDialogClose = () => {
formRef.value?.resetFields();
resetForm();
};
const resetForm = () => {
Object.assign(formData, {
memberId: undefined,
username: '',
password: '',
mobile: '',
nickname: '',
sex: 0,
memberLevel: 1,
point: 0,
balance: 0,
status: 1,
});
};
// 生命周期
onMounted(() => {
loadData();
});
</script>
<style scoped>
.member-user-page {
padding: 20px;
}
.search-form {
background: #fff;
padding: 20px;
border-radius: 4px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.action-buttons {
background: #fff;
padding: 20px;
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.pagination-wrapper {
display: flex;
justify-content: center;
margin-top: 20px;
}
</style>