feat(admin): 模板精简+请求层适配 Django JWT

- 克隆 art-design-pro v3.0.2(GitHub depth1),rm .git,pnpm clean:dev 精简演示页(92 文件)
- .env.development/.env.production 均设 VITE_API_URL=/api,代理目标 127.0.0.1:8000(移除 Apifox Mock)
- http 封装信封适配:code===200 解包 data;msg/message 字段兼容(拦截器/error/类型)
- /api/admin/auth/token 登录适配(access->Bearer token, refresh->refreshToken);/api/admin/auth/me 映射 UserInfo(userName/roles/is_admin)
- 登录页演示账号替换为真实 admin;logOut 按路由名判登录页修复 redirect 嵌套
- 根 .gitignore 补 !admin/.env(模板公共 env 非密钥,需入库)
- 验证:Playwright 全链路(错误密码 401 留登页/正确登录进工作台/登出/受保护路由重定向/redirect 回跳)+ pnpm build(vue-tsc 通过,产物无 mock 域名)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-15 17:17:38 +08:00
parent e56f252ffd
commit ab4f40ba5d
298 changed files with 47933 additions and 0 deletions
+479
View File
@@ -0,0 +1,479 @@
<!-- 菜单管理页面 -->
<template>
<div class="menu-page art-full-height">
<!-- 搜索栏 -->
<ArtSearchBar
v-model="formFilters"
:items="formItems"
:showExpand="false"
@reset="handleReset"
@search="handleSearch"
/>
<ElCard class="art-table-card">
<!-- 表格头部 -->
<ArtTableHeader
:showZebra="false"
:loading="loading"
v-model:columns="columnChecks"
@refresh="handleRefresh"
>
<template #left>
<ElButton v-auth="'add'" @click="handleAddMenu" v-ripple> 添加菜单 </ElButton>
<ElButton @click="toggleExpand" v-ripple>
{{ isExpanded ? '收起' : '展开' }}
</ElButton>
</template>
</ArtTableHeader>
<ArtTable
ref="tableRef"
rowKey="path"
:loading="loading"
:columns="columns"
:data="filteredTableData"
:stripe="false"
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
:default-expand-all="false"
/>
<!-- 菜单弹窗 -->
<MenuDialog
v-model:visible="dialogVisible"
:type="dialogType"
:editData="editData"
:lockType="lockMenuType"
@submit="handleSubmit"
/>
</ElCard>
</div>
</template>
<script setup lang="ts">
import { formatMenuTitle } from '@/utils/router'
import ArtButtonTable from '@/components/core/forms/art-button-table/index.vue'
import { useTableColumns } from '@/hooks/core/useTableColumns'
import type { AppRouteRecord } from '@/types/router'
import MenuDialog from './modules/menu-dialog.vue'
import { fetchGetMenuList } from '@/api/system-manage'
import { ElTag, ElMessageBox } from 'element-plus'
defineOptions({ name: 'Menus' })
// 状态管理
const loading = ref(false)
const isExpanded = ref(false)
const tableRef = ref()
// 弹窗相关
const dialogVisible = ref(false)
const dialogType = ref<'menu' | 'button'>('menu')
const editData = ref<AppRouteRecord | any>(null)
const lockMenuType = ref(false)
// 搜索相关
const initialSearchState = {
name: '',
route: ''
}
const formFilters = reactive({ ...initialSearchState })
const appliedFilters = reactive({ ...initialSearchState })
const formItems = computed(() => [
{
label: '菜单名称',
key: 'name',
type: 'input',
props: { clearable: true }
},
{
label: '路由地址',
key: 'route',
type: 'input',
props: { clearable: true }
}
])
onMounted(() => {
getMenuList()
})
/**
* 获取菜单列表数据
*/
const getMenuList = async (): Promise<void> => {
loading.value = true
try {
const list = await fetchGetMenuList()
tableData.value = list
} catch (error) {
throw error instanceof Error ? error : new Error('获取菜单失败')
} finally {
loading.value = false
}
}
/**
* 获取菜单类型标签颜色
* @param row 菜单行数据
* @returns 标签颜色类型
*/
const getMenuTypeTag = (
row: AppRouteRecord
): 'primary' | 'success' | 'warning' | 'info' | 'danger' => {
if (row.meta?.isAuthButton) return 'danger'
if (row.children?.length) return 'info'
if (row.meta?.link && row.meta?.isIframe) return 'success'
if (row.path) return 'primary'
if (row.meta?.link) return 'warning'
return 'info'
}
/**
* 获取菜单类型文本
* @param row 菜单行数据
* @returns 菜单类型文本
*/
const getMenuTypeText = (row: AppRouteRecord): string => {
if (row.meta?.isAuthButton) return '按钮'
if (row.children?.length) return '目录'
if (row.meta?.link && row.meta?.isIframe) return '内嵌'
if (row.path) return '菜单'
if (row.meta?.link) return '外链'
return '未知'
}
// 表格列配置
const { columnChecks, columns } = useTableColumns(() => [
{
prop: 'meta.title',
label: '菜单名称',
minWidth: 120,
formatter: (row: AppRouteRecord) => formatMenuTitle(row.meta?.title)
},
{
prop: 'type',
label: '菜单类型',
formatter: (row: AppRouteRecord) => {
return h(ElTag, { type: getMenuTypeTag(row) }, () => getMenuTypeText(row))
}
},
{
prop: 'path',
label: '路由',
formatter: (row: AppRouteRecord) => {
if (row.meta?.isAuthButton) return ''
return row.meta?.link || row.path || ''
}
},
{
prop: 'meta.authList',
label: '权限标识',
formatter: (row: AppRouteRecord) => {
if (row.meta?.isAuthButton) {
return row.meta?.authMark || ''
}
if (!row.meta?.authList?.length) return ''
return `${row.meta.authList.length} 个权限标识`
}
},
{
prop: 'date',
label: '编辑时间',
formatter: () => '2022-3-12 12:00:00'
},
{
prop: 'status',
label: '状态',
formatter: () => h(ElTag, { type: 'success' }, () => '启用')
},
{
prop: 'operation',
label: '操作',
width: 180,
align: 'right',
formatter: (row: AppRouteRecord) => {
const buttonStyle = { style: 'text-align: right' }
if (row.meta?.isAuthButton) {
return h('div', buttonStyle, [
h(ArtButtonTable, {
type: 'edit',
onClick: () => handleEditAuth(row)
}),
h(ArtButtonTable, {
type: 'delete',
onClick: () => handleDeleteAuth()
})
])
}
return h('div', buttonStyle, [
h(ArtButtonTable, {
type: 'add',
onClick: () => handleAddAuth(),
title: '新增权限'
}),
h(ArtButtonTable, {
type: 'edit',
onClick: () => handleEditMenu(row)
}),
h(ArtButtonTable, {
type: 'delete',
onClick: () => handleDeleteMenu()
})
])
}
}
])
// 数据相关
const tableData = ref<AppRouteRecord[]>([])
/**
* 重置搜索条件
*/
const handleReset = (): void => {
Object.assign(formFilters, { ...initialSearchState })
Object.assign(appliedFilters, { ...initialSearchState })
getMenuList()
}
/**
* 执行搜索
*/
const handleSearch = (): void => {
Object.assign(appliedFilters, { ...formFilters })
getMenuList()
}
/**
* 刷新菜单列表
*/
const handleRefresh = (): void => {
getMenuList()
}
/**
* 深度克隆对象
* @param obj 要克隆的对象
* @returns 克隆后的对象
*/
const deepClone = <T,>(obj: T): T => {
if (obj === null || typeof obj !== 'object') return obj
if (obj instanceof Date) return new Date(obj) as T
if (Array.isArray(obj)) return obj.map((item) => deepClone(item)) as T
const cloned = {} as T
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
cloned[key] = deepClone(obj[key])
}
}
return cloned
}
/**
* 将权限列表转换为子节点
* @param items 菜单项数组
* @returns 转换后的菜单项数组
*/
const convertAuthListToChildren = (items: AppRouteRecord[]): AppRouteRecord[] => {
return items.map((item) => {
const clonedItem = deepClone(item)
if (clonedItem.children?.length) {
clonedItem.children = convertAuthListToChildren(clonedItem.children)
}
if (item.meta?.authList?.length) {
const authChildren: AppRouteRecord[] = item.meta.authList.map(
(auth: { title: string; authMark: string }) => ({
path: `${item.path}_auth_${auth.authMark}`,
name: `${String(item.name)}_auth_${auth.authMark}`,
meta: {
title: auth.title,
authMark: auth.authMark,
isAuthButton: true,
parentPath: item.path
}
})
)
clonedItem.children = clonedItem.children?.length
? [...clonedItem.children, ...authChildren]
: authChildren
}
return clonedItem
})
}
/**
* 搜索菜单
* @param items 菜单项数组
* @returns 搜索结果数组
*/
const searchMenu = (items: AppRouteRecord[]): AppRouteRecord[] => {
const results: AppRouteRecord[] = []
for (const item of items) {
const searchName = appliedFilters.name?.toLowerCase().trim() || ''
const searchRoute = appliedFilters.route?.toLowerCase().trim() || ''
const menuTitle = formatMenuTitle(item.meta?.title || '').toLowerCase()
const menuPath = (item.path || '').toLowerCase()
const nameMatch = !searchName || menuTitle.includes(searchName)
const routeMatch = !searchRoute || menuPath.includes(searchRoute)
if (item.children?.length) {
const matchedChildren = searchMenu(item.children)
if (matchedChildren.length > 0) {
const clonedItem = deepClone(item)
clonedItem.children = matchedChildren
results.push(clonedItem)
continue
}
}
if (nameMatch && routeMatch) {
results.push(deepClone(item))
}
}
return results
}
// 过滤后的表格数据
const filteredTableData = computed(() => {
const searchedData = searchMenu(tableData.value)
return convertAuthListToChildren(searchedData)
})
/**
* 添加菜单
*/
const handleAddMenu = (): void => {
dialogType.value = 'menu'
editData.value = null
lockMenuType.value = true
dialogVisible.value = true
}
/**
* 添加权限按钮
*/
const handleAddAuth = (): void => {
dialogType.value = 'menu'
editData.value = null
lockMenuType.value = false
dialogVisible.value = true
}
/**
* 编辑菜单
* @param row 菜单行数据
*/
const handleEditMenu = (row: AppRouteRecord): void => {
dialogType.value = 'menu'
editData.value = row
lockMenuType.value = true
dialogVisible.value = true
}
/**
* 编辑权限按钮
* @param row 权限行数据
*/
const handleEditAuth = (row: AppRouteRecord): void => {
dialogType.value = 'button'
editData.value = {
title: row.meta?.title,
authMark: row.meta?.authMark
}
lockMenuType.value = false
dialogVisible.value = true
}
/**
* 菜单表单数据类型
*/
interface MenuFormData {
name: string
path: string
component?: string
icon?: string
roles?: string[]
sort?: number
[key: string]: any
}
/**
* 提交表单数据
* @param formData 表单数据
*/
const handleSubmit = (formData: MenuFormData): void => {
console.log('提交数据:', formData)
// TODO: 调用API保存数据
getMenuList()
}
/**
* 删除菜单
*/
const handleDeleteMenu = async (): Promise<void> => {
try {
await ElMessageBox.confirm('确定要删除该菜单吗?删除后无法恢复', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
ElMessage.success('删除成功')
getMenuList()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败')
}
}
}
/**
* 删除权限按钮
*/
const handleDeleteAuth = async (): Promise<void> => {
try {
await ElMessageBox.confirm('确定要删除该权限吗?删除后无法恢复', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
ElMessage.success('删除成功')
getMenuList()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败')
}
}
}
/**
* 切换展开/收起所有菜单
*/
const toggleExpand = (): void => {
isExpanded.value = !isExpanded.value
nextTick(() => {
if (tableRef.value?.elTableRef && filteredTableData.value) {
const processRows = (rows: AppRouteRecord[]) => {
rows.forEach((row) => {
if (row.children?.length) {
tableRef.value.elTableRef.toggleRowExpansion(row, isExpanded.value)
processRows(row.children)
}
})
}
processRows(filteredTableData.value)
}
})
}
</script>
@@ -0,0 +1,384 @@
<template>
<ElDialog
:title="dialogTitle"
:model-value="visible"
@update:model-value="handleCancel"
width="860px"
align-center
class="menu-dialog"
@closed="handleClosed"
>
<ArtForm
ref="formRef"
v-model="form"
:items="formItems"
:rules="rules"
:span="width > 640 ? 12 : 24"
:gutter="20"
label-width="100px"
:show-reset="false"
:show-submit="false"
>
<template #menuType>
<ElRadioGroup v-model="form.menuType" :disabled="disableMenuType">
<ElRadioButton value="menu" label="menu">菜单</ElRadioButton>
<ElRadioButton value="button" label="button">按钮</ElRadioButton>
</ElRadioGroup>
</template>
</ArtForm>
<template #footer>
<span class="dialog-footer">
<ElButton @click="handleCancel">取 消</ElButton>
<ElButton type="primary" @click="handleSubmit">确 定</ElButton>
</span>
</template>
</ElDialog>
</template>
<script setup lang="ts">
import type { FormRules } from 'element-plus'
import { ElIcon, ElTooltip } from 'element-plus'
import { QuestionFilled } from '@element-plus/icons-vue'
import { formatMenuTitle } from '@/utils/router'
import type { AppRouteRecord } from '@/types/router'
import type { FormItem } from '@/components/core/forms/art-form/index.vue'
import ArtForm from '@/components/core/forms/art-form/index.vue'
import { useWindowSize } from '@vueuse/core'
const { width } = useWindowSize()
/**
* 创建带 tooltip 的表单标签
* @param label 标签文本
* @param tooltip 提示文本
* @returns 渲染函数
*/
const createLabelTooltip = (label: string, tooltip: string) => {
return () =>
h('span', { class: 'flex items-center' }, [
h('span', label),
h(
ElTooltip,
{
content: tooltip,
placement: 'top'
},
() => h(ElIcon, { class: 'ml-0.5 cursor-help' }, () => h(QuestionFilled))
)
])
}
interface MenuFormData {
id: number
name: string
path: string
label: string
component: string
icon: string
isEnable: boolean
sort: number
isMenu: boolean
keepAlive: boolean
isHide: boolean
isHideTab: boolean
link: string
isIframe: boolean
showBadge: boolean
showTextBadge: string
fixedTab: boolean
activePath: string
roles: string[]
isFullPage: boolean
authName: string
authLabel: string
authIcon: string
authSort: number
}
interface Props {
visible: boolean
editData?: AppRouteRecord | any
type?: 'menu' | 'button'
lockType?: boolean
}
interface Emits {
(e: 'update:visible', value: boolean): void
(e: 'submit', data: MenuFormData): void
}
const props = withDefaults(defineProps<Props>(), {
visible: false,
type: 'menu',
lockType: false
})
const emit = defineEmits<Emits>()
const formRef = ref()
const isEdit = ref(false)
const form = reactive<MenuFormData & { menuType: 'menu' | 'button' }>({
menuType: 'menu',
id: 0,
name: '',
path: '',
label: '',
component: '',
icon: '',
isEnable: true,
sort: 1,
isMenu: true,
keepAlive: true,
isHide: false,
isHideTab: false,
link: '',
isIframe: false,
showBadge: false,
showTextBadge: '',
fixedTab: false,
activePath: '',
roles: [],
isFullPage: false,
authName: '',
authLabel: '',
authIcon: '',
authSort: 1
})
const rules = reactive<FormRules>({
name: [
{ required: true, message: '请输入菜单名称', trigger: 'blur' },
{ min: 2, max: 20, message: '长度在 2 到 20 个字符', trigger: 'blur' }
],
path: [{ required: true, message: '请输入路由地址', trigger: 'blur' }],
label: [{ required: true, message: '输入权限标识', trigger: 'blur' }],
authName: [{ required: true, message: '请输入权限名称', trigger: 'blur' }],
authLabel: [{ required: true, message: '请输入权限标识', trigger: 'blur' }]
})
/**
* 表单项配置
*/
const formItems = computed<FormItem[]>(() => {
const baseItems: FormItem[] = [{ label: '菜单类型', key: 'menuType', span: 24 }]
// Switch 组件的 span:小屏幕 12,大屏幕 6
const switchSpan = width.value < 640 ? 12 : 6
if (form.menuType === 'menu') {
return [
...baseItems,
{ label: '菜单名称', key: 'name', type: 'input', props: { placeholder: '菜单名称' } },
{
label: createLabelTooltip(
'路由地址',
'一级菜单:以 / 开头的绝对路径(如 /dashboard)\n二级及以下:相对路径(如 console、user)'
),
key: 'path',
type: 'input',
props: { placeholder: '如:/dashboard 或 console' }
},
{ label: '权限标识', key: 'label', type: 'input', props: { placeholder: '如:User' } },
{
label: createLabelTooltip(
'组件路径',
'一级父级菜单:填写 /index/index\n具体页面:填写组件路径(如 /system/user)\n目录菜单:留空'
),
key: 'component',
type: 'input',
props: { placeholder: '如:/system/user 或留空' }
},
{ label: '图标', key: 'icon', type: 'input', props: { placeholder: '如:ri:user-line' } },
{
label: createLabelTooltip(
'角色权限',
'仅用于前端权限模式:配置角色标识(如 R_SUPER、R_ADMIN)\n后端权限模式:无需配置'
),
key: 'roles',
type: 'inputtag',
props: { placeholder: '输入角色标识后按回车,如:R_SUPER' }
},
{
label: '菜单排序',
key: 'sort',
type: 'number',
props: { min: 1, controlsPosition: 'right', style: { width: '100%' } }
},
{
label: '外部链接',
key: 'link',
type: 'input',
props: { placeholder: '如:https://www.example.com' }
},
{
label: '文本徽章',
key: 'showTextBadge',
type: 'input',
props: { placeholder: '如:New、Hot' }
},
{
label: createLabelTooltip(
'激活路径',
'用于详情页等隐藏菜单,指定高亮显示的父级菜单路径\n例如:用户详情页高亮显示"用户管理"菜单'
),
key: 'activePath',
type: 'input',
props: { placeholder: '如:/system/user' }
},
{ label: '是否启用', key: 'isEnable', type: 'switch', span: switchSpan },
{ label: '页面缓存', key: 'keepAlive', type: 'switch', span: switchSpan },
{ label: '隐藏菜单', key: 'isHide', type: 'switch', span: switchSpan },
{ label: '是否内嵌', key: 'isIframe', type: 'switch', span: switchSpan },
{ label: '显示徽章', key: 'showBadge', type: 'switch', span: switchSpan },
{ label: '固定标签', key: 'fixedTab', type: 'switch', span: switchSpan },
{ label: '标签隐藏', key: 'isHideTab', type: 'switch', span: switchSpan },
{ label: '全屏页面', key: 'isFullPage', type: 'switch', span: switchSpan }
]
} else {
return [
...baseItems,
{
label: '权限名称',
key: 'authName',
type: 'input',
props: { placeholder: '如:新增、编辑、删除' }
},
{
label: '权限标识',
key: 'authLabel',
type: 'input',
props: { placeholder: '如:add、edit、delete' }
},
{
label: '权限排序',
key: 'authSort',
type: 'number',
props: { min: 1, controlsPosition: 'right', style: { width: '100%' } }
}
]
}
})
const dialogTitle = computed(() => {
const type = form.menuType === 'menu' ? '菜单' : '按钮'
return isEdit.value ? `编辑${type}` : `新建${type}`
})
/**
* 是否禁用菜单类型切换
*/
const disableMenuType = computed(() => {
if (isEdit.value) return true
if (!isEdit.value && form.menuType === 'menu' && props.lockType) return true
return false
})
/**
* 重置表单数据
*/
const resetForm = (): void => {
formRef.value?.reset()
form.menuType = 'menu'
}
/**
* 加载表单数据(编辑模式)
*/
const loadFormData = (): void => {
if (!props.editData) return
isEdit.value = true
if (form.menuType === 'menu') {
const row = props.editData
form.id = row.id || 0
form.name = formatMenuTitle(row.meta?.title || '')
form.path = row.path || ''
form.label = row.name || ''
form.component = row.component || ''
form.icon = row.meta?.icon || ''
form.sort = row.meta?.sort || 1
form.isMenu = row.meta?.isMenu ?? true
form.keepAlive = row.meta?.keepAlive ?? false
form.isHide = row.meta?.isHide ?? false
form.isHideTab = row.meta?.isHideTab ?? false
form.isEnable = row.meta?.isEnable ?? true
form.link = row.meta?.link || ''
form.isIframe = row.meta?.isIframe ?? false
form.showBadge = row.meta?.showBadge ?? false
form.showTextBadge = row.meta?.showTextBadge || ''
form.fixedTab = row.meta?.fixedTab ?? false
form.activePath = row.meta?.activePath || ''
form.roles = row.meta?.roles || []
form.isFullPage = row.meta?.isFullPage ?? false
} else {
const row = props.editData
form.authName = row.title || ''
form.authLabel = row.authMark || ''
form.authIcon = row.icon || ''
form.authSort = row.sort || 1
}
}
/**
* 提交表单
*/
const handleSubmit = async (): Promise<void> => {
if (!formRef.value) return
try {
await formRef.value.validate()
emit('submit', { ...form })
ElMessage.success(`${isEdit.value ? '编辑' : '新增'}成功`)
handleCancel()
} catch {
ElMessage.error('表单校验失败,请检查输入')
}
}
/**
* 取消操作
*/
const handleCancel = (): void => {
emit('update:visible', false)
}
/**
* 对话框关闭后的回调
*/
const handleClosed = (): void => {
resetForm()
isEdit.value = false
}
/**
* 监听对话框显示状态
*/
watch(
() => props.visible,
(newVal) => {
if (newVal) {
form.menuType = props.type
nextTick(() => {
if (props.editData) {
loadFormData()
}
})
}
}
)
/**
* 监听菜单类型变化
*/
watch(
() => props.type,
(newType) => {
if (props.visible) {
form.menuType = newType
}
}
)
</script>
+240
View File
@@ -0,0 +1,240 @@
<!-- 角色管理页面 -->
<template>
<div class="art-full-height">
<RoleSearch
v-show="showSearchBar"
v-model="searchForm"
@search="handleSearch"
@reset="resetSearchParams"
></RoleSearch>
<ElCard class="art-table-card" :style="{ 'margin-top': showSearchBar ? '12px' : '0' }">
<ArtTableHeader
v-model:columns="columnChecks"
v-model:showSearchBar="showSearchBar"
:loading="loading"
@refresh="refreshData"
>
<template #left>
<ElSpace wrap>
<ElButton @click="showDialog('add')" v-ripple>新增角色</ElButton>
</ElSpace>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
:loading="loading"
:data="data"
:columns="columns"
:pagination="pagination"
@pagination:size-change="handleSizeChange"
@pagination:current-change="handleCurrentChange"
>
</ArtTable>
</ElCard>
<!-- 角色编辑弹窗 -->
<RoleEditDialog
v-model="dialogVisible"
:dialog-type="dialogType"
:role-data="currentRoleData"
@success="refreshData"
/>
<!-- 菜单权限弹窗 -->
<RolePermissionDialog
v-model="permissionDialog"
:role-data="currentRoleData"
@success="refreshData"
/>
</div>
</template>
<script setup lang="ts">
import { ButtonMoreItem } from '@/components/core/forms/art-button-more/index.vue'
import { useTable } from '@/hooks/core/useTable'
import { fetchGetRoleList } from '@/api/system-manage'
import ArtButtonMore from '@/components/core/forms/art-button-more/index.vue'
import RoleSearch from './modules/role-search.vue'
import RoleEditDialog from './modules/role-edit-dialog.vue'
import RolePermissionDialog from './modules/role-permission-dialog.vue'
import { ElTag, ElMessageBox } from 'element-plus'
defineOptions({ name: 'Role' })
type RoleListItem = Api.SystemManage.RoleListItem
type RoleSearchFormParams = Api.SystemManage.RoleSearchParams & {
daterange?: string[]
}
// 搜索表单
const searchForm = ref<RoleSearchFormParams>({
roleName: undefined,
roleCode: undefined,
description: undefined,
enabled: undefined,
daterange: undefined
})
const showSearchBar = ref(false)
const dialogVisible = ref(false)
const permissionDialog = ref(false)
const currentRoleData = ref<RoleListItem | undefined>(undefined)
const {
columns,
columnChecks,
data,
loading,
pagination,
getData,
replaceSearchParams,
resetSearchParams,
handleSizeChange,
handleCurrentChange,
refreshData
} = useTable({
// 核心配置
core: {
apiFn: fetchGetRoleList,
apiParams: {
current: 1,
size: 20
},
// 排除 apiParams 中的属性
excludeParams: ['daterange'],
columnsFactory: () => [
{
prop: 'roleId',
label: '角色ID',
width: 100
},
{
prop: 'roleName',
label: '角色名称',
minWidth: 120
},
{
prop: 'roleCode',
label: '角色编码',
minWidth: 120
},
{
prop: 'description',
label: '角色描述',
minWidth: 150,
showOverflowTooltip: true
},
{
prop: 'enabled',
label: '角色状态',
width: 100,
formatter: (row) => {
const statusConfig = row.enabled
? { type: 'success', text: '启用' }
: { type: 'warning', text: '禁用' }
return h(
ElTag,
{ type: statusConfig.type as 'success' | 'warning' },
() => statusConfig.text
)
}
},
{
prop: 'createTime',
label: '创建日期',
width: 180,
sortable: true
},
{
prop: 'operation',
label: '操作',
width: 80,
fixed: 'right',
formatter: (row) =>
h('div', [
h(ArtButtonMore, {
list: [
{
key: 'permission',
label: '菜单权限',
icon: 'ri:user-3-line'
},
{
key: 'edit',
label: '编辑角色',
icon: 'ri:edit-2-line'
},
{
key: 'delete',
label: '删除角色',
icon: 'ri:delete-bin-4-line',
color: '#f56c6c'
}
],
onClick: (item: ButtonMoreItem) => buttonMoreClick(item, row)
})
])
}
]
}
})
const dialogType = ref<'add' | 'edit'>('add')
const showDialog = (type: 'add' | 'edit', row?: RoleListItem) => {
dialogVisible.value = true
dialogType.value = type
currentRoleData.value = row
}
/**
* 搜索处理
* @param params 搜索参数
*/
const handleSearch = (params: RoleSearchFormParams) => {
// 处理日期区间参数,把 daterange 转换为 startTime 和 endTime
const { daterange, ...filtersParams } = params
const [startTime, endTime] = Array.isArray(daterange) ? daterange : [null, null]
replaceSearchParams({ ...filtersParams, startTime, endTime })
getData()
}
const buttonMoreClick = (item: ButtonMoreItem, row: RoleListItem) => {
switch (item.key) {
case 'permission':
showPermissionDialog(row)
break
case 'edit':
showDialog('edit', row)
break
case 'delete':
deleteRole(row)
break
}
}
const showPermissionDialog = (row?: RoleListItem) => {
permissionDialog.value = true
currentRoleData.value = row
}
const deleteRole = (row: RoleListItem) => {
ElMessageBox.confirm(`确定删除角色"${row.roleName}"吗?此操作不可恢复!`, '删除确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(() => {
// TODO: 调用删除接口
ElMessage.success('删除成功')
refreshData()
})
.catch(() => {
ElMessage.info('已取消删除')
})
}
</script>
@@ -0,0 +1,162 @@
<template>
<ElDialog
v-model="visible"
:title="dialogType === 'add' ? '新增角色' : '编辑角色'"
width="30%"
align-center
@close="handleClose"
>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="120px">
<ElFormItem label="角色名称" prop="roleName">
<ElInput v-model="form.roleName" placeholder="请输入角色名称" />
</ElFormItem>
<ElFormItem label="角色编码" prop="roleCode">
<ElInput v-model="form.roleCode" placeholder="请输入角色编码" />
</ElFormItem>
<ElFormItem label="描述" prop="description">
<ElInput
v-model="form.description"
type="textarea"
:rows="3"
placeholder="请输入角色描述"
/>
</ElFormItem>
<ElFormItem label="启用">
<ElSwitch v-model="form.enabled" />
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="handleClose">取消</ElButton>
<ElButton type="primary" @click="handleSubmit">提交</ElButton>
</template>
</ElDialog>
</template>
<script setup lang="ts">
import type { FormInstance, FormRules } from 'element-plus'
type RoleListItem = Api.SystemManage.RoleListItem
interface Props {
modelValue: boolean
dialogType: 'add' | 'edit'
roleData?: RoleListItem
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'success'): void
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
dialogType: 'add',
roleData: undefined
})
const emit = defineEmits<Emits>()
const formRef = ref<FormInstance>()
/**
* 弹窗显示状态双向绑定
*/
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
/**
* 表单验证规则
*/
const rules = reactive<FormRules>({
roleName: [
{ required: true, message: '请输入角色名称', trigger: 'blur' },
{ min: 2, max: 20, message: '长度在 2 到 20 个字符', trigger: 'blur' }
],
roleCode: [
{ required: true, message: '请输入角色编码', trigger: 'blur' },
{ min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' }
],
description: [{ required: true, message: '请输入角色描述', trigger: 'blur' }]
})
/**
* 表单数据
*/
const form = reactive<RoleListItem>({
roleId: 0,
roleName: '',
roleCode: '',
description: '',
createTime: '',
enabled: true
})
/**
* 监听弹窗打开,初始化表单数据
*/
watch(
() => props.modelValue,
(newVal) => {
if (newVal) initForm()
}
)
/**
* 监听角色数据变化,更新表单
*/
watch(
() => props.roleData,
(newData) => {
if (newData && props.modelValue) initForm()
},
{ deep: true }
)
/**
* 初始化表单数据
* 根据弹窗类型填充表单或重置表单
*/
const initForm = () => {
if (props.dialogType === 'edit' && props.roleData) {
Object.assign(form, props.roleData)
} else {
Object.assign(form, {
roleId: 0,
roleName: '',
roleCode: '',
description: '',
createTime: '',
enabled: true
})
}
}
/**
* 关闭弹窗并重置表单
*/
const handleClose = () => {
visible.value = false
formRef.value?.resetFields()
}
/**
* 提交表单
* 验证通过后调用接口保存数据
*/
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
// TODO: 调用新增/编辑接口
const message = props.dialogType === 'add' ? '新增成功' : '修改成功'
ElMessage.success(message)
emit('success')
handleClose()
} catch (error) {
console.log('表单验证失败:', error)
}
}
</script>
@@ -0,0 +1,254 @@
<template>
<ElDialog
v-model="visible"
title="菜单权限"
width="520px"
align-center
class="el-dialog-border"
@close="handleClose"
>
<ElScrollbar height="70vh">
<ElTree
ref="treeRef"
:data="processedMenuList"
show-checkbox
node-key="name"
:default-expand-all="isExpandAll"
:default-checked-keys="[1, 2, 3]"
:props="defaultProps"
@check="handleTreeCheck"
>
<template #default="{ data }">
<div style="display: flex; align-items: center">
<span v-if="data.isAuth">
{{ data.label }}
</span>
<span v-else>{{ defaultProps.label(data) }}</span>
</div>
</template>
</ElTree>
</ElScrollbar>
<template #footer>
<ElButton @click="outputSelectedData" style="margin-left: 8px">获取选中数据</ElButton>
<ElButton @click="toggleExpandAll">{{ isExpandAll ? '全部收起' : '全部展开' }}</ElButton>
<ElButton @click="toggleSelectAll" style="margin-left: 8px">{{
isSelectAll ? '取消全选' : '全部选择'
}}</ElButton>
<ElButton type="primary" @click="savePermission">保存</ElButton>
</template>
</ElDialog>
</template>
<script setup lang="ts">
import { useMenuStore } from '@/store/modules/menu'
import { formatMenuTitle } from '@/utils/router'
type RoleListItem = Api.SystemManage.RoleListItem
interface Props {
modelValue: boolean
roleData?: RoleListItem
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'success'): void
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
roleData: undefined
})
const emit = defineEmits<Emits>()
const { menuList } = storeToRefs(useMenuStore())
const treeRef = ref()
const isExpandAll = ref(true)
const isSelectAll = ref(false)
/**
* 弹窗显示状态双向绑定
*/
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
/**
* 菜单节点类型
*/
interface MenuNode {
id?: string | number
name?: string
label?: string
meta?: {
title?: string
authList?: Array<{
authMark: string
title: string
checked?: boolean
}>
}
children?: MenuNode[]
[key: string]: any
}
/**
* 处理菜单数据,将 authList 转换为树形子节点
* 递归处理菜单树,将权限列表展开为可选择的子节点
*/
const processedMenuList = computed(() => {
const processNode = (node: MenuNode): MenuNode => {
const processed = { ...node }
// 如果有 authList,将其转换为子节点
if (node.meta?.authList?.length) {
const authNodes = node.meta.authList.map((auth) => ({
id: `${node.id}_${auth.authMark}`,
name: `${node.name}_${auth.authMark}`,
label: auth.title,
authMark: auth.authMark,
isAuth: true,
checked: auth.checked || false
}))
processed.children = processed.children ? [...processed.children, ...authNodes] : authNodes
}
// 递归处理子节点
if (processed.children) {
processed.children = processed.children.map(processNode)
}
return processed
}
return (menuList.value as any[]).map(processNode)
})
/**
* 树形组件配置
*/
const defaultProps = {
children: 'children',
label: (data: any) => formatMenuTitle(data.meta?.title) || data.label || ''
}
/**
* 监听弹窗打开,初始化权限数据
*/
watch(
() => props.modelValue,
(newVal) => {
if (newVal && props.roleData) {
// TODO: 根据角色加载对应的权限数据
console.log('设置权限:', props.roleData)
}
}
)
/**
* 关闭弹窗并清空选中状态
*/
const handleClose = () => {
visible.value = false
treeRef.value?.setCheckedKeys([])
}
/**
* 保存权限配置
*/
const savePermission = () => {
// TODO: 调用保存权限接口
ElMessage.success('权限保存成功')
emit('success')
handleClose()
}
/**
* 切换全部展开/收起状态
*/
const toggleExpandAll = () => {
const tree = treeRef.value
if (!tree) return
const nodes = tree.store.nodesMap
// 这里保留 any,因为 Element Plus 的内部节点类型较复杂
Object.values(nodes).forEach((node: any) => {
node.expanded = !isExpandAll.value
})
isExpandAll.value = !isExpandAll.value
}
/**
* 切换全选/取消全选状态
*/
const toggleSelectAll = () => {
const tree = treeRef.value
if (!tree) return
if (!isSelectAll.value) {
const allKeys = getAllNodeKeys(processedMenuList.value)
tree.setCheckedKeys(allKeys)
} else {
tree.setCheckedKeys([])
}
isSelectAll.value = !isSelectAll.value
}
/**
* 递归获取所有节点的 key
* @param nodes 节点列表
* @returns 所有节点的 key 数组
*/
const getAllNodeKeys = (nodes: MenuNode[]): string[] => {
const keys: string[] = []
const traverse = (nodeList: MenuNode[]): void => {
nodeList.forEach((node) => {
if (node.name) keys.push(node.name)
if (node.children?.length) traverse(node.children)
})
}
traverse(nodes)
return keys
}
/**
* 处理树节点选中状态变化
* 同步更新全选按钮状态
*/
const handleTreeCheck = () => {
const tree = treeRef.value
if (!tree) return
const checkedKeys = tree.getCheckedKeys()
const allKeys = getAllNodeKeys(processedMenuList.value)
isSelectAll.value = checkedKeys.length === allKeys.length && allKeys.length > 0
}
/**
* 输出选中的权限数据到控制台
* 用于调试和查看当前选中的权限配置
*/
const outputSelectedData = () => {
const tree = treeRef.value
if (!tree) return
const selectedData = {
checkedKeys: tree.getCheckedKeys(),
halfCheckedKeys: tree.getHalfCheckedKeys(),
checkedNodes: tree.getCheckedNodes(),
halfCheckedNodes: tree.getHalfCheckedNodes(),
totalChecked: tree.getCheckedKeys().length,
totalHalfChecked: tree.getHalfCheckedKeys().length
}
console.log('=== 选中的权限数据 ===', selectedData)
ElMessage.success(`已输出选中数据到控制台,共选中 ${selectedData.totalChecked} 个节点`)
}
</script>
@@ -0,0 +1,125 @@
<template>
<ArtSearchBar
ref="searchBarRef"
v-model="formData"
:items="formItems"
:rules="rules"
@reset="handleReset"
@search="handleSearch"
>
</ArtSearchBar>
</template>
<script setup lang="ts">
type RoleSearchFormParams = Api.SystemManage.RoleSearchParams & {
daterange?: string[]
}
interface Props {
modelValue: RoleSearchFormParams
}
interface Emits {
(e: 'update:modelValue', value: RoleSearchFormParams): void
(e: 'search', params: RoleSearchFormParams): void
(e: 'reset'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const searchBarRef = ref()
/**
* 表单数据双向绑定
*/
const formData = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
/**
* 表单校验规则
*/
const rules = {}
/**
* 角色状态选项
*/
const statusOptions = ref([
{ label: '启用', value: true },
{ label: '禁用', value: false }
])
/**
* 搜索表单配置项
*/
const formItems = computed(() => [
{
label: '角色名称',
key: 'roleName',
type: 'input',
placeholder: '请输入角色名称',
clearable: true
},
{
label: '角色编码',
key: 'roleCode',
type: 'input',
placeholder: '请输入角色编码',
clearable: true
},
{
label: '角色描述',
key: 'description',
type: 'input',
placeholder: '请输入角色描述',
clearable: true
},
{
label: '角色状态',
key: 'enabled',
type: 'select',
props: {
placeholder: '请选择状态',
options: statusOptions.value,
clearable: true
}
},
{
label: '创建日期',
key: 'daterange',
type: 'datetime',
props: {
style: { width: '100%' },
placeholder: '请选择日期范围',
type: 'daterange',
rangeSeparator: '至',
startPlaceholder: '开始日期',
endPlaceholder: '结束日期',
valueFormat: 'YYYY-MM-DD',
shortcuts: [
{ text: '今日', value: [new Date(), new Date()] },
{ text: '最近一周', value: [new Date(Date.now() - 604800000), new Date()] },
{ text: '最近一个月', value: [new Date(Date.now() - 2592000000), new Date()] }
]
}
}
])
/**
* 处理重置事件
*/
const handleReset = () => {
emit('reset')
}
/**
* 处理搜索事件
* 验证表单后触发搜索
*/
const handleSearch = async (params: RoleSearchFormParams) => {
await searchBarRef.value.validate()
emit('search', params)
}
</script>
@@ -0,0 +1,247 @@
<!-- 个人中心页面 -->
<template>
<div class="w-full h-full p-0 bg-transparent border-none shadow-none">
<div class="relative flex-b mt-2.5 max-md:block max-md:mt-1">
<div class="w-112 mr-5 max-md:w-full max-md:mr-0">
<div class="art-card-sm relative p-9 pb-6 overflow-hidden text-center">
<img class="absolute top-0 left-0 w-full h-50 object-cover" src="@imgs/user/bg.webp" />
<img
class="relative z-10 w-20 h-20 mt-30 mx-auto object-cover border-2 border-white rounded-full"
src="@imgs/user/avatar.webp"
/>
<h2 class="mt-5 text-xl font-normal">{{ userInfo.userName }}</h2>
<p class="mt-5 text-sm">专注于用户体验跟视觉设计</p>
<div class="w-75 mx-auto mt-7.5 text-left">
<div class="mt-2.5">
<ArtSvgIcon icon="ri:mail-line" class="text-g-700" />
<span class="ml-2 text-sm">jdkjjfnndf@mall.com</span>
</div>
<div class="mt-2.5">
<ArtSvgIcon icon="ri:user-3-line" class="text-g-700" />
<span class="ml-2 text-sm">交互专家</span>
</div>
<div class="mt-2.5">
<ArtSvgIcon icon="ri:map-pin-line" class="text-g-700" />
<span class="ml-2 text-sm">广东省深圳市</span>
</div>
<div class="mt-2.5">
<ArtSvgIcon icon="ri:dribbble-fill" class="text-g-700" />
<span class="ml-2 text-sm">字节跳动-某某平台部-UED</span>
</div>
</div>
<div class="mt-10">
<h3 class="text-sm font-medium">标签</h3>
<div class="flex flex-wrap justify-center mt-3.5">
<div
v-for="item in lableList"
:key="item"
class="py-1 px-1.5 mr-2.5 mb-2.5 text-xs border border-g-300 rounded"
>
{{ item }}
</div>
</div>
</div>
</div>
</div>
<div class="flex-1 overflow-hidden max-md:w-full max-md:mt-3.5">
<div class="art-card-sm">
<h1 class="p-4 text-xl font-normal border-b border-g-300">基本设置</h1>
<ElForm
:model="form"
class="box-border p-5 [&>.el-row_.el-form-item]:w-[calc(50%-10px)] [&>.el-row_.el-input]:w-full [&>.el-row_.el-select]:w-full"
ref="ruleFormRef"
:rules="rules"
label-width="86px"
label-position="top"
>
<ElRow>
<ElFormItem label="姓名" prop="realName">
<ElInput v-model="form.realName" :disabled="!isEdit" />
</ElFormItem>
<ElFormItem label="性别" prop="sex" class="ml-5">
<ElSelect v-model="form.sex" placeholder="Select" :disabled="!isEdit">
<ElOption
v-for="item in options"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
</ElRow>
<ElRow>
<ElFormItem label="昵称" prop="nikeName">
<ElInput v-model="form.nikeName" :disabled="!isEdit" />
</ElFormItem>
<ElFormItem label="邮箱" prop="email" class="ml-5">
<ElInput v-model="form.email" :disabled="!isEdit" />
</ElFormItem>
</ElRow>
<ElRow>
<ElFormItem label="手机" prop="mobile">
<ElInput v-model="form.mobile" :disabled="!isEdit" />
</ElFormItem>
<ElFormItem label="地址" prop="address" class="ml-5">
<ElInput v-model="form.address" :disabled="!isEdit" />
</ElFormItem>
</ElRow>
<ElFormItem label="个人介绍" prop="des" class="h-32">
<ElInput type="textarea" :rows="4" v-model="form.des" :disabled="!isEdit" />
</ElFormItem>
<div class="flex-c justify-end [&_.el-button]:!w-27.5">
<ElButton type="primary" class="w-22.5" v-ripple @click="edit">
{{ isEdit ? '保存' : '编辑' }}
</ElButton>
</div>
</ElForm>
</div>
<div class="art-card-sm my-5">
<h1 class="p-4 text-xl font-normal border-b border-g-300">更改密码</h1>
<ElForm :model="pwdForm" class="box-border p-5" label-width="86px" label-position="top">
<ElFormItem label="当前密码" prop="password">
<ElInput
v-model="pwdForm.password"
type="password"
:disabled="!isEditPwd"
show-password
/>
</ElFormItem>
<ElFormItem label="新密码" prop="newPassword">
<ElInput
v-model="pwdForm.newPassword"
type="password"
:disabled="!isEditPwd"
show-password
/>
</ElFormItem>
<ElFormItem label="确认新密码" prop="confirmPassword">
<ElInput
v-model="pwdForm.confirmPassword"
type="password"
:disabled="!isEditPwd"
show-password
/>
</ElFormItem>
<div class="flex-c justify-end [&_.el-button]:!w-27.5">
<ElButton type="primary" class="w-22.5" v-ripple @click="editPwd">
{{ isEditPwd ? '保存' : '编辑' }}
</ElButton>
</div>
</ElForm>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useUserStore } from '@/store/modules/user'
import type { FormInstance, FormRules } from 'element-plus'
defineOptions({ name: 'UserCenter' })
const userStore = useUserStore()
const userInfo = computed(() => userStore.getUserInfo)
const isEdit = ref(false)
const isEditPwd = ref(false)
const date = ref('')
const ruleFormRef = ref<FormInstance>()
/**
* 用户信息表单
*/
const form = reactive({
realName: 'John Snow',
nikeName: '皮卡丘',
email: '59301283@mall.com',
mobile: '18888888888',
address: '广东省深圳市宝安区西乡街道101栋201',
sex: '2',
des: 'Art Design Pro 是一款兼具设计美学与高效开发的后台系统.'
})
/**
* 密码修改表单
*/
const pwdForm = reactive({
password: '123456',
newPassword: '123456',
confirmPassword: '123456'
})
/**
* 表单验证规则
*/
const rules = reactive<FormRules>({
realName: [
{ required: true, message: '请输入姓名', trigger: 'blur' },
{ min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' }
],
nikeName: [
{ required: true, message: '请输入昵称', trigger: 'blur' },
{ min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' }
],
email: [{ required: true, message: '请输入邮箱', trigger: 'blur' }],
mobile: [{ required: true, message: '请输入手机号码', trigger: 'blur' }],
address: [{ required: true, message: '请输入地址', trigger: 'blur' }],
sex: [{ required: true, message: '请选择性别', trigger: 'blur' }]
})
/**
* 性别选项
*/
const options = [
{ value: '1', label: '男' },
{ value: '2', label: '女' }
]
/**
* 用户标签列表
*/
const lableList: Array<string> = ['专注设计', '很有想法', '辣~', '大长腿', '川妹子', '海纳百川']
onMounted(() => {
getDate()
})
/**
* 根据当前时间获取问候语
*/
const getDate = () => {
const h = new Date().getHours()
if (h >= 6 && h < 9) date.value = '早上好'
else if (h >= 9 && h < 11) date.value = '上午好'
else if (h >= 11 && h < 13) date.value = '中午好'
else if (h >= 13 && h < 18) date.value = '下午好'
else if (h >= 18 && h < 24) date.value = '晚上好'
else date.value = '很晚了,早点睡'
}
/**
* 切换用户信息编辑状态
*/
const edit = () => {
isEdit.value = !isEdit.value
}
/**
* 切换密码编辑状态
*/
const editPwd = () => {
isEditPwd.value = !isEditPwd.value
}
</script>
+259
View File
@@ -0,0 +1,259 @@
<!-- 用户管理页面 -->
<!-- art-full-height 自动计算出页面剩余高度 -->
<!-- art-table-card 一个符合系统样式的 class,同时自动撑满剩余高度 -->
<!-- 更多 useTable 使用示例请移步至 功能示例 下面的高级表格示例或者查看官方文档 -->
<!-- useTable 文档:https://www.artd.pro/docs/zh/guide/hooks/use-table.html -->
<template>
<div class="user-page art-full-height">
<!-- 搜索栏 -->
<UserSearch v-model="searchForm" @search="handleSearch" @reset="resetSearchParams"></UserSearch>
<ElCard class="art-table-card">
<!-- 表格头部 -->
<ArtTableHeader v-model:columns="columnChecks" :loading="loading" @refresh="refreshData">
<template #left>
<ElSpace wrap>
<ElButton @click="showDialog('add')" v-ripple>新增用户</ElButton>
</ElSpace>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
:loading="loading"
:data="data"
:columns="columns"
:pagination="pagination"
@selection-change="handleSelectionChange"
@pagination:size-change="handleSizeChange"
@pagination:current-change="handleCurrentChange"
>
</ArtTable>
<!-- 用户弹窗 -->
<UserDialog
v-model:visible="dialogVisible"
:type="dialogType"
:user-data="currentUserData"
@submit="handleDialogSubmit"
/>
</ElCard>
</div>
</template>
<script setup lang="ts">
import ArtButtonTable from '@/components/core/forms/art-button-table/index.vue'
import { ACCOUNT_TABLE_DATA } from '@/mock/temp/formData'
import { useTable } from '@/hooks/core/useTable'
import { fetchGetUserList } from '@/api/system-manage'
import UserSearch from './modules/user-search.vue'
import UserDialog from './modules/user-dialog.vue'
import { ElTag, ElMessageBox, ElImage } from 'element-plus'
import { DialogType } from '@/types'
defineOptions({ name: 'User' })
type UserListItem = Api.SystemManage.UserListItem
// 弹窗相关
const dialogType = ref<DialogType>('add')
const dialogVisible = ref(false)
const currentUserData = ref<Partial<UserListItem>>({})
// 选中行
const selectedRows = ref<UserListItem[]>([])
// 搜索表单
const searchForm = ref({
userName: undefined,
userGender: undefined,
userPhone: undefined,
userEmail: undefined,
status: '1'
})
// 用户状态配置
const USER_STATUS_CONFIG = {
'1': { type: 'success' as const, text: '在线' },
'2': { type: 'info' as const, text: '离线' },
'3': { type: 'warning' as const, text: '异常' },
'4': { type: 'danger' as const, text: '注销' }
} as const
/**
* 获取用户状态配置
*/
const getUserStatusConfig = (status: string) => {
return (
USER_STATUS_CONFIG[status as keyof typeof USER_STATUS_CONFIG] || {
type: 'info' as const,
text: '未知'
}
)
}
const {
columns,
columnChecks,
data,
loading,
pagination,
getData,
replaceSearchParams,
resetSearchParams,
handleSizeChange,
handleCurrentChange,
refreshData
} = useTable({
// 核心配置
core: {
apiFn: fetchGetUserList,
apiParams: {
current: 1,
size: 20,
...searchForm.value
},
// 自定义分页字段映射,未设置时将使用全局配置 tableConfig.ts 中的 paginationKey
// paginationKey: {
// current: 'pageNum',
// size: 'pageSize'
// },
columnsFactory: () => [
{ type: 'selection' }, // 勾选列
{ type: 'index', width: 60, label: '序号' }, // 序号
{
prop: 'userInfo',
label: '用户名',
width: 280,
// visible: false, // 默认是否显示列
formatter: (row) => {
return h('div', { class: 'user flex-c' }, [
h(ElImage, {
class: 'size-9.5 rounded-md',
src: row.avatar,
previewSrcList: [row.avatar],
// 图片预览是否插入至 body 元素上,用于解决表格内部图片预览样式异常
previewTeleported: true
}),
h('div', { class: 'ml-2' }, [
h('p', { class: 'user-name' }, row.userName),
h('p', { class: 'email' }, row.userEmail)
])
])
}
},
{
prop: 'userGender',
label: '性别',
sortable: true,
formatter: (row) => row.userGender
},
{ prop: 'userPhone', label: '手机号' },
{
prop: 'status',
label: '状态',
formatter: (row) => {
const statusConfig = getUserStatusConfig(row.status)
return h(ElTag, { type: statusConfig.type }, () => statusConfig.text)
}
},
{
prop: 'createTime',
label: '创建日期',
sortable: true
},
{
prop: 'operation',
label: '操作',
width: 120,
fixed: 'right', // 固定列
formatter: (row) =>
h('div', [
h(ArtButtonTable, {
type: 'edit',
onClick: () => showDialog('edit', row)
}),
h(ArtButtonTable, {
type: 'delete',
onClick: () => deleteUser(row)
})
])
}
]
},
// 数据处理
transform: {
// 数据转换器 - 替换头像
dataTransformer: (records) => {
// 类型守卫检查
if (!Array.isArray(records)) {
console.warn('数据转换器: 期望数组类型,实际收到:', typeof records)
return []
}
// 使用本地头像替换接口返回的头像
return records.map((item, index: number) => {
return {
...item,
avatar: ACCOUNT_TABLE_DATA[index % ACCOUNT_TABLE_DATA.length].avatar
}
})
}
}
})
/**
* 搜索处理
* @param params 参数
*/
const handleSearch = (params: Api.SystemManage.UserSearchParams) => {
replaceSearchParams(params)
getData()
}
/**
* 显示用户弹窗
*/
const showDialog = (type: DialogType, row?: UserListItem): void => {
console.log('打开弹窗:', { type, row })
dialogType.value = type
currentUserData.value = row || {}
nextTick(() => {
dialogVisible.value = true
})
}
/**
* 删除用户
*/
const deleteUser = (row: UserListItem): void => {
console.log('删除用户:', row)
ElMessageBox.confirm(`确定要注销该用户吗?`, '注销用户', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'error'
}).then(() => {
ElMessage.success('注销成功')
})
}
/**
* 处理弹窗提交事件
*/
const handleDialogSubmit = async () => {
try {
dialogVisible.value = false
currentUserData.value = {}
} catch (error) {
console.error('提交失败:', error)
}
}
/**
* 处理表格行选择变化
*/
const handleSelectionChange = (selection: UserListItem[]): void => {
selectedRows.value = selection
console.log('选中行数据:', selectedRows.value)
}
</script>
@@ -0,0 +1,143 @@
<template>
<ElDialog
v-model="dialogVisible"
:title="dialogType === 'add' ? '添加用户' : '编辑用户'"
width="30%"
align-center
>
<ElForm ref="formRef" :model="formData" :rules="rules" label-width="80px">
<ElFormItem label="用户名" prop="username">
<ElInput v-model="formData.username" placeholder="请输入用户名" />
</ElFormItem>
<ElFormItem label="手机号" prop="phone">
<ElInput v-model="formData.phone" placeholder="请输入手机号" />
</ElFormItem>
<ElFormItem label="性别" prop="gender">
<ElSelect v-model="formData.gender">
<ElOption label="男" value="男" />
<ElOption label="女" value="女" />
</ElSelect>
</ElFormItem>
<ElFormItem label="角色" prop="role">
<ElSelect v-model="formData.role" multiple>
<ElOption
v-for="role in roleList"
:key="role.roleCode"
:value="role.roleCode"
:label="role.roleName"
/>
</ElSelect>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="dialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleSubmit">提交</ElButton>
</div>
</template>
</ElDialog>
</template>
<script setup lang="ts">
import { ROLE_LIST_DATA } from '@/mock/temp/formData'
import type { FormInstance, FormRules } from 'element-plus'
interface Props {
visible: boolean
type: string
userData?: Partial<Api.SystemManage.UserListItem>
}
interface Emits {
(e: 'update:visible', value: boolean): void
(e: 'submit'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
// 角色列表数据
const roleList = ref(ROLE_LIST_DATA)
// 对话框显示控制
const dialogVisible = computed({
get: () => props.visible,
set: (value) => emit('update:visible', value)
})
const dialogType = computed(() => props.type)
// 表单实例
const formRef = ref<FormInstance>()
// 表单数据
const formData = reactive({
username: '',
phone: '',
gender: '男',
role: [] as string[]
})
// 表单验证规则
const rules: FormRules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 2, max: 20, message: '长度在 2 到 20 个字符', trigger: 'blur' }
],
phone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号格式', trigger: 'blur' }
],
gender: [{ required: true, message: '请选择性别', trigger: 'blur' }],
role: [{ required: true, message: '请选择角色', trigger: 'blur' }]
}
/**
* 初始化表单数据
* 根据对话框类型(新增/编辑)填充表单
*/
const initFormData = () => {
const isEdit = props.type === 'edit' && props.userData
const row = props.userData
Object.assign(formData, {
username: isEdit && row ? row.userName || '' : '',
phone: isEdit && row ? row.userPhone || '' : '',
gender: isEdit && row ? row.userGender || '男' : '男',
role: isEdit && row ? (Array.isArray(row.userRoles) ? row.userRoles : []) : []
})
}
/**
* 监听对话框状态变化
* 当对话框打开时初始化表单数据并清除验证状态
*/
watch(
() => [props.visible, props.type, props.userData],
([visible]) => {
if (visible) {
initFormData()
nextTick(() => {
formRef.value?.clearValidate()
})
}
},
{ immediate: true }
)
/**
* 提交表单
* 验证通过后触发提交事件
*/
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate((valid) => {
if (valid) {
ElMessage.success(dialogType.value === 'add' ? '添加成功' : '更新成功')
dialogVisible.value = false
emit('submit')
}
})
}
</script>
@@ -0,0 +1,112 @@
<template>
<ArtSearchBar
ref="searchBarRef"
v-model="formData"
:items="formItems"
:rules="rules"
@reset="handleReset"
@search="handleSearch"
>
</ArtSearchBar>
</template>
<script setup lang="ts">
interface Props {
modelValue: Api.SystemManage.UserSearchParams
}
interface Emits {
(e: 'update:modelValue', value: Api.SystemManage.UserSearchParams): void
(e: 'search', params: Api.SystemManage.UserSearchParams): void
(e: 'reset'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
// 表单数据双向绑定
const searchBarRef = ref()
const formData = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
// 校验规则
const rules = {
// userName: [{ required: true, message: '请输入用户名', trigger: 'blur' }]
}
// 动态 options
const statusOptions = ref<{ label: string; value: string; disabled?: boolean }[]>([])
// 模拟接口返回状态数据
function fetchStatusOptions(): Promise<typeof statusOptions.value> {
return new Promise((resolve) => {
setTimeout(() => {
resolve([
{ label: '在线', value: '1' },
{ label: '离线', value: '2' },
{ label: '异常', value: '3' },
{ label: '注销', value: '4' }
])
}, 1000)
})
}
onMounted(async () => {
statusOptions.value = await fetchStatusOptions()
})
// 表单配置
const formItems = computed(() => [
{
label: '用户名',
key: 'userName',
type: 'input',
placeholder: '请输入用户名',
clearable: true
},
{
label: '手机号',
key: 'userPhone',
type: 'input',
props: { placeholder: '请输入手机号', maxlength: '11' }
},
{
label: '邮箱',
key: 'userEmail',
type: 'input',
props: { placeholder: '请输入邮箱' }
},
{
label: '状态',
key: 'status',
type: 'select',
props: {
placeholder: '请选择状态',
options: statusOptions.value
}
},
{
label: '性别',
key: 'userGender',
type: 'radiogroup',
props: {
options: [
{ label: '男', value: '1' },
{ label: '女', value: '2' }
]
}
}
])
// 事件
function handleReset() {
console.log('重置表单')
emit('reset')
}
async function handleSearch(params: Api.SystemManage.UserSearchParams) {
await searchBarRef.value.validate()
emit('search', params)
console.log('表单数据', params)
}
</script>