添加系统管理、登录模块的接口、标准化开发流程

This commit is contained in:
super
2026-01-08 20:49:42 +08:00
parent fef12b01e2
commit 8fa07e4952
40 changed files with 3126 additions and 1701 deletions

View File

@@ -0,0 +1,205 @@
<template>
<div class="dict-container">
<a-page-header title="字典管理" sub-title="管理系统数据字典">
<template #extra>
<a-button type="primary" @click="handleAdd">
<template #icon><PlusOutlined /></template>
新增字典
</a-button>
</template>
</a-page-header>
<a-card>
<!-- 搜索区域 -->
<a-form layout="inline" class="search-form">
<a-form-item label="字典名称">
<a-input v-model:value="searchForm.name" placeholder="请输入字典名称" allow-clear />
</a-form-item>
<a-form-item label="字典编码">
<a-input v-model:value="searchForm.code" placeholder="请输入字典编码" allow-clear />
</a-form-item>
<a-form-item>
<a-space>
<a-button type="primary" @click="handleSearch">
<template #icon><SearchOutlined /></template>
查询
</a-button>
<a-button @click="handleReset">
<template #icon><ReloadOutlined /></template>
重置
</a-button>
</a-space>
</a-form-item>
</a-form>
<!-- 表格 -->
<a-table
:columns="columns"
:data-source="tableData"
:loading="loading"
:pagination="pagination"
row-key="id"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'status'">
<a-tag :color="record.status === 1 ? 'green' : 'red'">
{{ record.status === 1 ? '正常' : '禁用' }}
</a-tag>
</template>
<template v-if="column.dataIndex === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleEdit(record)">编辑</a-button>
<a-button type="link" size="small" @click="handleViewItems(record)">字典项</a-button>
<a-popconfirm
title="确定删除此字典吗?"
@confirm="handleDelete(record.id)"
>
<a-button type="link" size="small" danger>删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-card>
<!-- 新增/编辑字典弹窗 -->
<DictFormModal
v-model:visible="modalVisible"
:edit-data="editData"
@success="loadData"
/>
<!-- 字典项管理抽屉 -->
<DictItemDrawer
v-model:visible="itemDrawerVisible"
:dict-data="currentDict"
/>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import { PlusOutlined, SearchOutlined, ReloadOutlined } from '@ant-design/icons-vue'
import DictFormModal from '@/components/system/dict/DictFormModal.vue'
import DictItemDrawer from '@/components/system/dict/DictItemDrawer.vue'
// 类型定义
interface DictRecord {
id: number
name: string
code: string
remark?: string
status: number
createdAt?: string
}
// 搜索表单
const searchForm = reactive({
name: '',
code: ''
})
// 表格
const loading = ref(false)
const tableData = ref<DictRecord[]>([])
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total: number) => `${total}`
})
const columns = [
{ title: '字典名称', dataIndex: 'name', width: 150 },
{ title: '字典编码', dataIndex: 'code', width: 150 },
{ title: '备注', dataIndex: 'remark', ellipsis: true },
{ title: '状态', dataIndex: 'status', width: 80 },
{ title: '创建时间', dataIndex: 'createdAt', width: 170 },
{ title: '操作', dataIndex: 'action', width: 160, fixed: 'right' }
]
// 字典弹窗
const modalVisible = ref(false)
const editData = ref<DictRecord | null>(null)
// 字典项抽屉
const itemDrawerVisible = ref(false)
const currentDict = ref<DictRecord | null>(null)
// 加载字典数据
async function loadData() {
loading.value = true
try {
// TODO: 接入真实API
// const res = await getDictList({ ...searchForm, page: pagination.current, pageSize: pagination.pageSize })
// tableData.value = res.data.data.records
// pagination.total = res.data.data.total
// 模拟数据
tableData.value = [
{ id: 1, name: '性别', code: 'gender', remark: '用户性别', status: 1, createdAt: '2026-01-08 10:00:00' },
{ id: 2, name: '状态', code: 'status', remark: '通用状态', status: 1, createdAt: '2026-01-08 10:00:00' },
{ id: 3, name: '审批状态', code: 'approval_status', remark: '审批流程状态', status: 1, createdAt: '2026-01-08 10:00:00' }
]
pagination.total = 3
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.current = 1
loadData()
}
function handleReset() {
searchForm.name = ''
searchForm.code = ''
handleSearch()
}
function handleTableChange(pag: any) {
pagination.current = pag.current
pagination.pageSize = pag.pageSize
loadData()
}
function handleAdd() {
editData.value = null
modalVisible.value = true
}
function handleEdit(record: DictRecord) {
editData.value = record
modalVisible.value = true
}
function handleViewItems(record: DictRecord) {
currentDict.value = record
itemDrawerVisible.value = true
}
async function handleDelete(id: number) {
// TODO: 接入真实API
// await deleteDict(id)
message.success('删除成功')
loadData()
}
onMounted(() => {
loadData()
})
</script>
<style scoped>
.dict-container {
padding: 0;
}
.search-form {
margin-bottom: 16px;
}
</style>

View File

@@ -0,0 +1,166 @@
<template>
<div class="page-container">
<!-- 操作栏 -->
<a-card class="table-card" :bordered="false">
<template #title>
<a-button type="primary" @click="handleAdd()">
<PlusOutlined /> 新增菜单
</a-button>
</template>
<!-- 表格树形 -->
<a-table
:columns="columns"
:data-source="treeData"
:loading="loading"
:pagination="false"
row-key="id"
:default-expand-all-rows="true"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'icon'">
<span v-if="record.icon" class="menu-icon"><component :is="record.icon" /> {{ record.icon }}</span>
<span v-else>-</span>
</template>
<template v-else-if="column.key === 'type'">
<a-tag :color="getTypeColor(record.type)">{{ getTypeName(record.type) }}</a-tag>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="record.status === 1 ? 'green' : 'red'">
{{ record.status === 1 ? '正常' : '禁用' }}
</a-tag>
</template>
<template v-else-if="column.key === 'hidden'">
<a-tag :color="record.hidden === 1 ? 'orange' : 'default'">
{{ record.hidden === 1 ? '隐藏' : '显示' }}
</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleAdd(record.id)">添加子菜单</a-button>
<a-button type="link" size="small" @click="handleEdit(record as MenuRecord)">编辑</a-button>
<a-popconfirm title="确定删除该菜单吗?" @confirm="handleDelete(record.id)">
<a-button type="link" size="small" danger>删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-card>
<!-- 新增/编辑弹窗 -->
<MenuFormModal
v-model:visible="modalVisible"
:record="currentRecord"
:menu-tree="treeData"
@success="loadData"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import { PlusOutlined } from '@ant-design/icons-vue'
import type { MenuRecord, MenuFormData } from '@/types/system/menu'
import { getMenuList, deleteMenu } from '@/api/system/menu'
import MenuFormModal from '@/components/system/menu/MenuFormModal.vue'
import { buildMenuTree } from '@/utils/route'
// Note: buildMenuTree expects SysMenu, MenuRecord extends SysMenu so it's fine.
// Using 'any' cast if strict checking fails due to optional properties.
const loading = ref(false)
const treeData = ref<MenuRecord[]>([])
// 表格列定义
const columns = [
{ title: '菜单名称', dataIndex: 'name', key: 'name', width: 200 },
{ title: '编码', dataIndex: 'code', key: 'code', width: 150 },
{ title: '图标', dataIndex: 'icon', key: 'icon', width: 120 },
{ title: '类型', dataIndex: 'type', key: 'type', width: 100 },
{ title: '路径', dataIndex: 'path', key: 'path' },
{ title: '排序', dataIndex: 'sort', key: 'sort', width: 80 },
{ title: '状态', dataIndex: 'status', key: 'status', width: 80 },
{ title: '显示', dataIndex: 'hidden', key: 'hidden', width: 80 },
{ title: '操作', key: 'action', width: 220, fixed: 'right' as const }
]
// 弹窗相关
const modalVisible = ref(false)
const currentRecord = ref<MenuFormData | undefined>(undefined)
function getTypeColor(type: string): string {
const colorMap: Record<string, string> = {
directory: 'blue',
menu: 'green',
button: 'orange'
}
return colorMap[type] || 'default'
}
function getTypeName(type: string): string {
const nameMap: Record<string, string> = {
directory: '目录',
menu: '菜单',
button: '按钮'
}
return nameMap[type] || type
}
// 加载数据
async function loadData() {
loading.value = true
try {
const res = await getMenuList()
// res.data.data is the array
const list = res.data.data || []
// Cast to any because buildMenuTree typings might be strict about SysMenu properties
treeData.value = buildMenuTree(list as any[]) as unknown as MenuRecord[]
} catch (error) {
console.error('加载数据失败:', error)
} finally {
loading.value = false
}
}
function handleAdd(parentId?: number) {
currentRecord.value = {
parentId: parentId || 0
} as MenuFormData
modalVisible.value = true
}
function handleEdit(record: MenuRecord) {
currentRecord.value = { ...record }
modalVisible.value = true
}
async function handleDelete(id: number) {
try {
await deleteMenu(id)
message.success('删除成功')
loadData()
} catch (error) {
console.error('删除失败:', error)
}
}
onMounted(() => {
loadData()
})
</script>
<style scoped>
.page-container {
padding: 0;
}
.table-card {
background: #fff;
}
.menu-icon {
font-size: 14px;
color: #666;
}
</style>

View File

@@ -0,0 +1,199 @@
<template>
<div class="page-container">
<!-- 操作栏 -->
<a-card class="table-card" :bordered="false">
<template #title>
<a-button type="primary" @click="handleAdd">
<PlusOutlined /> 新增角色
</a-button>
</template>
<!-- 表格 -->
<a-table
:columns="columns"
:data-source="tableData"
:loading="loading"
:pagination="false"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="record.status === 1 ? 'green' : 'red'">
{{ record.status === 1 ? '正常' : '禁用' }}
</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleEdit(record as RoleRecord)">编辑</a-button>
<a-button type="link" size="small" @click="handleAssignMenus(record as RoleRecord)">分配权限</a-button>
<a-popconfirm
title="确定删除该角色吗?"
@confirm="handleDelete(record.id)"
:disabled="['super_admin', 'admin'].includes(record.code)"
>
<a-button
type="link"
size="small"
danger
:disabled="['super_admin', 'admin'].includes(record.code)"
>
删除
</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-card>
<!-- 新增/编辑弹窗 -->
<RoleFormModal
v-model:visible="modalVisible"
:record="currentRecord"
@success="loadData"
/>
<!-- 分配权限弹窗 -->
<a-modal
v-model:open="assignMenusVisible"
title="分配菜单权限"
:confirm-loading="assignMenusLoading"
width="500px"
@ok="handleAssignMenusOk"
>
<a-spin :spinning="menuTreeLoading">
<a-tree
v-model:checkedKeys="checkedMenuIds"
:tree-data="menuTree"
checkable
:field-names="{ title: 'name', key: 'id', children: 'children' }"
default-expand-all
/>
</a-spin>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import { PlusOutlined } from '@ant-design/icons-vue'
import { buildMenuTree } from '@/utils/route'
import { getRoleList, getRolePage, deleteRole, getRoleMenuIds, assignRoleMenus } from '@/api/system/role'
import { getMenuList } from '@/api/system/menu'
import type { RoleRecord, RoleFormData } from '@/types/system/role'
import type { SysMenu } from '@/types/api/auth'
import RoleFormModal from '@/components/system/role/RoleFormModal.vue'
const loading = ref(false)
const tableData = ref<RoleRecord[]>([])
// 表格列定义
const columns = [
{ title: '角色编码', dataIndex: 'code', key: 'code' },
{ title: '角色名称', dataIndex: 'name', key: 'name' },
{ title: '描述', dataIndex: 'description', key: 'description' },
{ title: '排序', dataIndex: 'sort', key: 'sort', width: 80 },
{ title: '状态', dataIndex: 'status', key: 'status', width: 100 },
{ title: '创建时间', dataIndex: 'createdAt', key: 'createdAt', width: 180 },
{ title: '操作', key: 'action', width: 220, fixed: 'right' as const }
]
// 弹窗相关
const modalVisible = ref(false)
const currentRecord = ref<RoleFormData | undefined>(undefined)
// 分配权限相关
const assignMenusVisible = ref(false)
const assignMenusLoading = ref(false)
const menuTreeLoading = ref(false)
const menuTree = ref<any[]>([])
const checkedMenuIds = ref<number[]>([])
const currentRoleId = ref<number>()
// 加载数据
async function loadData() {
loading.value = true
try {
const res = await getRolePage({ pageSize: 100 })
tableData.value = res.data.data.records || []
} catch (error) {
console.error('加载数据失败:', error)
} finally {
loading.value = false
}
}
function handleAdd() {
currentRecord.value = undefined
modalVisible.value = true
}
function handleEdit(record: RoleRecord) {
currentRecord.value = { ...record }
modalVisible.value = true
}
async function handleDelete(id: number) {
try {
await deleteRole(id)
message.success('删除成功')
loadData()
} catch (error) {
console.error('删除失败:', error)
}
}
async function handleAssignMenus(record: RoleRecord) {
currentRoleId.value = record.id
checkedMenuIds.value = []
assignMenusVisible.value = true
menuTreeLoading.value = true
try {
// 加载菜单树
// 加载菜单树
const menuRes = await getMenuList()
menuTree.value = buildMenuTree(menuRes.data.data || [])
// 加载角色已有的菜单
const roleMenuRes = await getRoleMenuIds(record.id)
checkedMenuIds.value = roleMenuRes.data.data || []
} catch (error) {
console.error('加载数据失败:', error)
} finally {
menuTreeLoading.value = false
}
}
async function handleAssignMenusOk() {
if (!currentRoleId.value) return
assignMenusLoading.value = true
try {
await assignRoleMenus(currentRoleId.value, checkedMenuIds.value)
message.success('权限分配成功')
assignMenusVisible.value = false
} catch (error) {
console.error('权限分配失败:', error)
} finally {
assignMenusLoading.value = false
}
}
onMounted(() => {
loadData()
})
</script>
<style scoped>
.page-container {
padding: 0;
}
.table-card {
background: #fff;
}
</style>

View File

@@ -0,0 +1,277 @@
<template>
<div class="page-container">
<!-- 搜索栏 -->
<a-card class="search-card" :bordered="false">
<a-form layout="inline" :model="searchForm" @finish="handleSearch">
<a-form-item label="关键词">
<a-input v-model:value="searchForm.keyword" placeholder="用户名/昵称/手机号" allow-clear style="width: 200px" />
</a-form-item>
<a-form-item label="角色">
<a-select v-model:value="searchForm.role" placeholder="请选择角色" allow-clear style="width: 150px">
<a-select-option v-for="role in roleOptions" :key="role.code" :value="role.code">
{{ role.name }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="状态">
<a-select v-model:value="searchForm.status" placeholder="请选择状态" allow-clear style="width: 120px">
<a-select-option :value="1">正常</a-select-option>
<a-select-option :value="0">禁用</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<a-space>
<a-button type="primary" html-type="submit">
<SearchOutlined /> 查询
</a-button>
<a-button @click="handleReset">
<ReloadOutlined /> 重置
</a-button>
</a-space>
</a-form-item>
</a-form>
</a-card>
<!-- 操作栏 -->
<a-card class="table-card" :bordered="false">
<template #title>
<a-button type="primary" @click="handleAdd">
<PlusOutlined /> 新增用户
</a-button>
</template>
<!-- 表格 -->
<a-table
:columns="columns"
:data-source="tableData"
:loading="loading"
:pagination="pagination"
row-key="id"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'avatar'">
<a-avatar :src="record.avatar" :size="40">
<template #icon><UserOutlined /></template>
</a-avatar>
</template>
<template v-else-if="column.key === 'role'">
<a-tag :color="getRoleColor(record.role)">{{ getRoleName(record.role) }}</a-tag>
</template>
<template v-else-if="column.key === 'status'">
<a-switch
:checked="record.status === 1"
checked-children="正常"
un-checked-children="禁用"
@change="(checked: boolean) => handleStatusChange(record.id, checked ? 1 : 0)"
/>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleEdit(record)">编辑</a-button>
<a-button type="link" size="small" @click="handleResetPassword(record)">重置密码</a-button>
<a-popconfirm title="确定删除该用户吗?" @confirm="handleDelete(record.id)">
<a-button type="link" size="small" danger>删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-card>
<!-- 组件 -->
<UserFormModal
v-model:visible="modalVisible"
:record="currentRecord"
:role-options="roleOptions"
@success="loadData"
/>
<ResetPasswordModal
v-model:visible="resetPasswordVisible"
:user-id="currentUserId"
/>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import {
SearchOutlined,
ReloadOutlined,
PlusOutlined,
UserOutlined
} from '@ant-design/icons-vue'
import { getUserList, deleteUser, updateUserStatus } from '@/api/system/user'
import { getRoleList } from '@/api/system/role'
import type { UserRecord, UserFormData } from '@/types/system/user'
import type { RoleRecord } from '@/types/system/role'
// 引入组件
import UserFormModal from '@/components/system/user/UserFormModal.vue'
import ResetPasswordModal from '@/components/system/user/ResetPasswordModal.vue'
const loading = ref(false)
const tableData = ref<UserRecord[]>([])
const roleOptions = ref<{ code: string; name: string }[]>([]) // Role List for display/select
// 搜索表单
const searchForm = reactive({
keyword: '',
role: undefined as string | undefined,
status: undefined as number | undefined
})
// 分页
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0,
showSizeChanger: true,
showTotal: (total: number) => `${total}`
})
// 表格列定义
const columns = [
{ title: '头像', dataIndex: 'avatar', key: 'avatar', width: 80 },
{ title: '用户名', dataIndex: 'username', key: 'username' },
{ title: '昵称', dataIndex: 'nickname', key: 'nickname' },
{ title: '手机号', dataIndex: 'phone', key: 'phone' },
{ title: '邮箱', dataIndex: 'email', key: 'email' },
{ title: '角色', dataIndex: 'role', key: 'role', width: 120 },
{ title: '状态', dataIndex: 'status', key: 'status', width: 100 },
{ title: '创建时间', dataIndex: 'createdAt', key: 'createdAt', width: 180 },
{ title: '操作', key: 'action', width: 200, fixed: 'right' }
]
// Modal State
const modalVisible = ref(false)
const currentRecord = ref<UserFormData | undefined>(undefined)
const resetPasswordVisible = ref(false)
const currentUserId = ref<number>()
// 角色颜色
function getRoleColor(role: string): string {
const colorMap: Record<string, string> = {
super_admin: 'red',
admin: 'blue',
finance: 'green',
customer_service: 'orange'
}
return colorMap[role] || 'default'
}
function getRoleName(role: string): string {
const option = roleOptions.value.find(r => r.code === role)
return option?.name || role
}
// 加载数据
async function loadData() {
loading.value = true
try {
const res = await getUserList({
page: pagination.current,
pageSize: pagination.pageSize,
...searchForm
})
tableData.value = res.data.data.records || []
pagination.total = res.data.data.total || 0
} catch (error) {
console.error('加载数据失败:', error)
} finally {
loading.value = false
}
}
// 加载角色列表
async function loadRoles() {
try {
// 这里如果后端没有 list-all我们可以用 list 且不传参(默认第一页),或者传大 pageSize
// 假设后端支持 list 并返回 records
const res = await getRoleList()
// Mapping RoleRecord to Option { code, name }
roleOptions.value = (res.data.data || []).map((r: RoleRecord) => ({ code: r.code, name: r.name }))
} catch (error) {
console.error('加载角色列表失败:', error)
}
}
function handleSearch() {
pagination.current = 1
loadData()
}
function handleReset() {
searchForm.keyword = ''
searchForm.role = undefined
searchForm.status = undefined
handleSearch()
}
function handleTableChange(pag: { current?: number; pageSize?: number }) {
pagination.current = pag.current || 1
pagination.pageSize = pag.pageSize || 10
loadData()
}
function handleAdd() {
currentRecord.value = undefined
modalVisible.value = true
}
function handleEdit(record: UserRecord) {
currentRecord.value = {
...record,
password: '' // Password usually not editable directly here, treated separately or optional
}
modalVisible.value = true
}
async function handleStatusChange(id: number, status: number) {
try {
await updateUserStatus(id, status)
message.success('状态更新成功')
loadData()
} catch (error) {
console.error('状态更新失败:', error)
}
}
async function handleDelete(id: number) {
try {
await deleteUser(id)
message.success('删除成功')
loadData()
} catch (error) {
console.error('删除失败:', error)
}
}
function handleResetPassword(record: UserRecord) {
currentUserId.value = record.id
resetPasswordVisible.value = true
}
onMounted(() => {
loadRoles()
loadData()
})
</script>
<style scoped>
.page-container {
padding: 0;
}
.search-card {
margin-bottom: 16px;
}
.table-card {
background: #fff;
}
</style>