feat:增加主包(tabbar)、system(系统管理)、infra(基础设施)、bpm(工作流程)的页面

This commit is contained in:
YunaiV
2025-12-12 19:16:46 +08:00
parent c14e0d04d0
commit cc94b2a5f7
97 changed files with 12198 additions and 81 deletions

View File

@@ -0,0 +1,84 @@
<template>
<view v-if="breadcrumbList.length > 0" class="bg-white px-24rpx py-16rpx">
<scroll-view scroll-x class="whitespace-nowrap">
<view class="inline-flex items-center">
<view
class="flex items-center text-28rpx"
:class="breadcrumbList.length > 0 ? 'text-[#1890ff]' : 'text-[#333]'"
@click="handleClick(-1)"
>
<text>全部部门</text>
</view>
<template v-for="(item, index) in breadcrumbList" :key="item.id">
<wd-icon name="arrow-right" size="12px" color="#999" custom-class="mx-8rpx" />
<view
class="flex items-center text-28rpx"
:class="index < breadcrumbList.length - 1 ? 'text-[#1890ff]' : 'text-[#333]'"
@click="handleClick(index)"
>
<text>{{ item.name }}</text>
</view>
</template>
</view>
</scroll-view>
</view>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue'
interface BreadcrumbItem {
id: number
name: string
}
const props = defineProps<{
modelValue: number
}>()
const emit = defineEmits<{
'update:modelValue': [value: number]
}>()
const breadcrumbList = ref<BreadcrumbItem[]>([])
/** 监听外部值变化 */
watch(() => props.modelValue, (val) => {
if (val === 0) {
breadcrumbList.value = []
}
})
/** 点击面包屑 */
function handleClick(index: number) {
if (index === -1) {
// 点击"全部部门"
breadcrumbList.value = []
emit('update:modelValue', 0)
} else if (index < breadcrumbList.value.length - 1) {
// 点击中间层级
const item = breadcrumbList.value[index]
breadcrumbList.value = breadcrumbList.value.slice(0, index + 1)
emit('update:modelValue', item.id)
}
}
/** 进入子层级 */
function enter(item: BreadcrumbItem) {
breadcrumbList.value.push(item)
emit('update:modelValue', item.id)
}
/** 返回上一层级 */
function back(): boolean {
if (breadcrumbList.value.length === 0) {
return false
}
breadcrumbList.value.pop()
const lastItem = breadcrumbList.value[breadcrumbList.value.length - 1]
emit('update:modelValue', lastItem?.id ?? 0)
return true
}
defineExpose({ enter, back })
</script>

View File

@@ -0,0 +1,152 @@
<template>
<view class="min-h-screen bg-[#f5f5f5]">
<!-- 顶部导航栏 -->
<wd-navbar
title="部门详情"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 详情内容 -->
<view class="p-24rpx pb-200rpx">
<wd-cell-group custom-class="cell-group" border>
<wd-cell title="部门名称" :value="formData?.name || '-'" />
<wd-cell title="上级部门" :value="getParentName() || '-'" />
<wd-cell title="负责人" :value="getLeaderName() || '-'" />
<wd-cell title="联系电话" :value="formData?.phone || '-'" />
<wd-cell title="邮箱" :value="formData?.email || '-'" />
<wd-cell title="显示顺序" :value="String(formData?.sort ?? '-')" />
<wd-cell title="状态">
<dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="formData?.status" />
</wd-cell>
<wd-cell title="创建时间" :value="formatDateTime(formData?.createTime) || '-'" />
</wd-cell-group>
</view>
<!-- 底部操作按钮 -->
<view class="safe-area-inset-bottom fixed bottom-0 left-0 right-0 bg-white p-24rpx">
<view class="w-full flex gap-24rpx">
<wd-button class="flex-1" type="warning" @click="handleEdit">
编辑
</wd-button>
<wd-button class="flex-1" type="error" :loading="deleting" @click="handleDelete">
删除
</wd-button>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import type { Dept } from '@/api/system/dept'
import type { User } from '@/api/system/user'
import { onMounted, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { deleteDept, getDept, getSimpleDeptList } from '@/api/system/dept'
import { getSimpleUserList } from '@/api/system/user'
import { DICT_TYPE } from '@/utils/constants'
import { formatDateTime } from '@/utils/date'
const props = defineProps<{
id: number
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const toast = useToast()
const formData = ref<Dept>() // 详情数据
const deleting = ref(false) // 删除中
const deptList = ref<Dept[]>([]) // 部门列表
const userList = ref<User[]>([]) // 用户列表
/** 返回上一页 */
function handleBack() {
uni.navigateBack()
}
/** 获取上级部门名称 */
function getParentName(): string {
if (!formData.value?.parentId || formData.value.parentId === 0) {
return '顶级部门'
}
const parent = deptList.value.find(d => d.id === formData.value?.parentId)
return parent?.name || '未知'
}
/** 获取负责人名称 */
function getLeaderName(): string {
if (!formData.value?.leaderUserId) {
return '未设置'
}
const user = userList.value.find(u => u.id === formData.value?.leaderUserId)
return user?.nickname || '未知'
}
/** 加载部门详情 */
async function getDetail() {
if (!props.id) {
return
}
formData.value = await getDept(props.id)
}
/** 编辑部门 */
function handleEdit() {
uni.navigateTo({
url: `/pages-system/dept/form/index?id=${props.id}`,
})
}
/** 删除部门 */
function handleDelete() {
if (!props.id) {
return
}
uni.showModal({
title: '提示',
content: '确定要删除该部门吗?',
success: async (res) => {
if (!res.confirm) {
return
}
deleting.value = true
try {
await deleteDept(props.id)
toast.success('删除成功')
setTimeout(() => {
handleBack()
}, 500)
} finally {
deleting.value = false
}
},
})
}
/** 初始化 */
onMounted(async () => {
// 获取部门列表
deptList.value = await getSimpleDeptList()
// 获取用户列表
userList.value = await getSimpleUserList()
// 获取详情
await getDetail()
})
</script>
<style lang="scss" scoped>
:deep(.cell-group) {
border-radius: 12rpx;
overflow: hidden;
box-shadow: 0 3rpx 8rpx rgba(24, 144, 255, 0.06);
}
.safe-area-inset-bottom {
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
}
</style>

View File

@@ -0,0 +1,258 @@
<template>
<view class="min-h-screen bg-[#f5f5f5]">
<!-- 顶部导航栏 -->
<wd-navbar
:title="getTitle"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 表单区域 -->
<view class="p-24rpx">
<wd-form ref="formRef" :model="formData" :rules="formRules">
<wd-cell-group custom-class="cell-group" border>
<wd-cell
title="上级部门"
title-width="180rpx"
prop="parentId"
is-link
:value="getParentName()"
@click="showDeptPicker = true"
/>
<wd-input
v-model="formData.name"
label="部门名称"
label-width="180rpx"
prop="name"
clearable
placeholder="请输入部门名称"
/>
<wd-cell title="显示顺序" title-width="180rpx" prop="sort" center>
<wd-input-number
v-model="formData.sort"
:min="0"
/>
</wd-cell>
<wd-cell
title="负责人"
title-width="180rpx"
prop="leaderUserId"
is-link
:value="getLeaderName()"
@click="showUserPicker = true"
/>
<wd-input
v-model="formData.phone"
label="联系电话"
label-width="180rpx"
prop="phone"
clearable
placeholder="请输入联系电话"
/>
<wd-input
v-model="formData.email"
label="邮箱"
label-width="180rpx"
prop="email"
clearable
placeholder="请输入邮箱"
/>
<wd-cell title="状态" title-width="180rpx" prop="status" center>
<wd-switch
v-model="formData.status"
:active-value="CommonStatusEnum.ENABLE"
:inactive-value="CommonStatusEnum.DISABLE"
/>
</wd-cell>
</wd-cell-group>
</wd-form>
</view>
<!-- 底部保存按钮 -->
<view class="safe-area-inset-bottom fixed bottom-0 left-0 right-0 bg-white p-24rpx">
<wd-button
type="primary"
block
:loading="formLoading"
@click="handleSubmit"
>
保存
</wd-button>
</view>
<!-- 上级部门选择器 -->
<wd-picker
:model-value="showDeptPicker"
:columns="deptPickerColumns"
title="选择上级部门"
@confirm="handleDeptConfirm"
@close="showDeptPicker = false"
/>
<!-- 负责人选择器 -->
<wd-picker
:model-value="showUserPicker"
:columns="userPickerColumns"
title="选择负责人"
@confirm="handleUserConfirm"
@close="showUserPicker = false"
/>
</view>
</template>
<script lang="ts" setup>
import type { Dept } from '@/api/system/dept'
import type { User } from '@/api/system/user'
import { computed, onMounted, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { createDept, getDept, getSimpleDeptList, updateDept } from '@/api/system/dept'
import { getSimpleUserList } from '@/api/system/user'
import { CommonStatusEnum } from '@/utils/constants'
const props = defineProps<{
id?: number
parentId?: number
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const toast = useToast()
const getTitle = computed(() => props.id ? '编辑部门' : '新增部门')
const formLoading = ref(false) // 提交中状态
const formData = ref<Dept>({
id: undefined,
name: '',
parentId: props.parentId || 0,
sort: 0,
status: CommonStatusEnum.ENABLE,
leaderUserId: undefined,
phone: '',
email: '',
})
const formRules = {
parentId: [{ required: true, message: '上级部门不能为空' }],
name: [{ required: true, message: '部门名称不能为空' }],
sort: [{ required: true, message: '显示顺序不能为空' }],
status: [{ required: true, message: '状态不能为空' }],
}
const formRef = ref()
const deptList = ref<Dept[]>([]) // 部门列表
const userList = ref<User[]>([]) // 用户列表
const showDeptPicker = ref(false) // 部门选择器
const showUserPicker = ref(false) // 负责人选择器
/** 部门选择器列 */
const deptPickerColumns = computed(() => {
const items = [{ label: '顶级部门', value: 0 }]
deptList.value.forEach((dept) => {
// 编辑时排除自己和子部门
if (props.id && dept.id === props.id) {
return
}
items.push({ label: dept.name, value: dept.id! })
})
return items
})
/** 用户选择器列 */
const userPickerColumns = computed(() => {
const items = [{ label: '不设置', value: 0 }]
userList.value.forEach((user) => {
items.push({ label: user.nickname, value: user.id! })
})
return items
})
/** 获取上级部门名称 */
function getParentName(): string {
if (!formData.value.parentId || formData.value.parentId === 0) {
return '顶级部门'
}
const parent = deptList.value.find(d => d.id === formData.value.parentId)
return parent?.name || '请选择'
}
/** 获取负责人名称 */
function getLeaderName(): string {
if (!formData.value.leaderUserId) {
return '请选择'
}
const user = userList.value.find(u => u.id === formData.value.leaderUserId)
return user?.nickname || '请选择'
}
/** 返回上一页 */
function handleBack() {
uni.navigateBack()
}
/** 加载部门详情 */
async function getDetail() {
if (!props.id) {
return
}
formData.value = await getDept(props.id)
}
/** 部门选择确认 */
function handleDeptConfirm({ value }: { value: number }) {
formData.value.parentId = value
}
/** 负责人选择确认 */
function handleUserConfirm({ value }: { value: number }) {
formData.value.leaderUserId = value || undefined
}
/** 提交表单 */
async function handleSubmit() {
const { valid } = await formRef.value.validate()
if (!valid) {
return
}
formLoading.value = true
try {
if (props.id) {
await updateDept(formData.value)
toast.success('修改成功')
} else {
await createDept(formData.value)
toast.success('新增成功')
}
setTimeout(() => {
handleBack()
}, 500)
} finally {
formLoading.value = false
}
}
/** 初始化 */
onMounted(async () => {
// 获取部门列表
deptList.value = await getSimpleDeptList()
// 获取用户列表
userList.value = await getSimpleUserList()
// 获取详情
await getDetail()
})
</script>
<style lang="scss" scoped>
:deep(.cell-group) {
border-radius: 12rpx;
overflow: hidden;
box-shadow: 0 3rpx 8rpx rgba(24, 144, 255, 0.06);
}
.safe-area-inset-bottom {
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
}
</style>

View File

@@ -0,0 +1,153 @@
<template>
<view class="min-h-screen bg-[#f5f5f5]">
<!-- 顶部导航栏 -->
<wd-navbar
title="部门管理"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 面包屑导航 -->
<Breadcrumb ref="breadcrumbRef" v-model="currentParentId" />
<!-- 部门列表 -->
<view class="p-24rpx">
<view
v-for="item in currentList"
:key="item.id"
class="mb-24rpx overflow-hidden rounded-12rpx bg-white shadow-sm"
>
<!-- 主内容区域点击进入详情 -->
<view class="p-24rpx" @click="handleDetail(item)">
<!-- 第一行名称状态标签 -->
<view class="flex items-center justify-between">
<view class="flex items-center">
<view class="mr-16rpx h-48rpx w-48rpx flex items-center justify-center rounded-8rpx bg-[#1890ff]">
<wd-icon name="folder" size="20px" color="#fff" />
</view>
<view class="text-32rpx text-[#333] font-semibold">
{{ item.name }}
</view>
</view>
<dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="item.status" />
</view>
<!-- 第二行负责人子部门入口 -->
<view class="mt-12rpx flex items-center justify-between pl-64rpx">
<view class="text-24rpx text-[#999]">
负责人{{ getLeaderName(item.leaderUserId) }}
</view>
<view
v-if="item.children && item.children.length > 0"
class="flex items-center"
@click.stop="handleEnterChildren(item)"
>
<text class="text-24rpx text-[#1890ff]">子部门 ({{ item.children.length }})</text>
<wd-icon name="arrow-right" size="12px" color="#1890ff" />
</view>
</view>
</view>
</view>
<!-- 空状态 -->
<view v-if="!loading && currentList.length === 0" class="py-100rpx text-center">
<wd-status-tip image="content" tip="暂无部门数据" />
</view>
</view>
<!-- 新增按钮 -->
<view
class="fixed bottom-100rpx right-32rpx z-10 h-100rpx w-100rpx flex items-center justify-center rounded-full bg-[#1890ff] shadow-lg"
@click="handleAdd"
>
<wd-icon name="add" size="24px" color="#fff" />
</view>
</view>
</template>
<script lang="ts" setup>
import type { Dept } from '@/api/system/dept'
import type { User } from '@/api/system/user'
import { computed, onMounted, ref } from 'vue'
import { getDeptList } from '@/api/system/dept'
import { getSimpleUserList } from '@/api/system/user'
import { DICT_TYPE } from '@/utils/constants'
import { findChildren, handleTree } from '@/utils/tree'
import Breadcrumb from './components/breadcrumb.vue'
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const loading = ref(false)
const list = ref<Dept[]>([]) // 完整部门列表(树形结构)
const userList = ref<User[]>([]) // 用户列表
const currentParentId = ref(0) // 当前层级的父节点编号
const currentList = computed(() => {
if (currentParentId.value === 0) {
return list.value.filter(item => item.parentId === 0)
}
return findChildren(list.value, currentParentId.value)
}) // 当前层级的部门列表
const breadcrumbRef = ref<InstanceType<typeof Breadcrumb>>()
/** 返回上一页或上一层级 */
function handleBack() {
if (!breadcrumbRef.value?.back()) {
uni.navigateBack()
}
}
/** 获取负责人名称 */
function getLeaderName(leaderUserId?: number): string {
if (!leaderUserId) {
return '未设置'
}
const user = userList.value.find(u => u.id === leaderUserId)
return user?.nickname || '未知'
}
/** 进入子部门层级 */
function handleEnterChildren(item: Dept) {
breadcrumbRef.value?.enter({ id: item.id!, name: item.name })
}
/** 查询部门列表 */
async function getList() {
loading.value = true
try {
const data = await getDeptList()
list.value = handleTree(data)
} finally {
loading.value = false
}
}
/** 新增部门 */
function handleAdd() {
uni.navigateTo({
url: `/pages-system/dept/form/index?parentId=${currentParentId.value}`,
})
}
/** 查看详情 */
function handleDetail(item: Dept) {
uni.navigateTo({
url: `/pages-system/dept/detail/index?id=${item.id}`,
})
}
/** 初始化 */
onMounted(async () => {
// 获取用户列表
userList.value = await getSimpleUserList()
// 获取部门列表
await getList()
})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,98 @@
<template>
<view class="bg-white px-24rpx py-16rpx">
<scroll-view scroll-x class="whitespace-nowrap">
<view class="inline-flex items-center text-28rpx">
<template v-for="(item, index) in breadcrumbItems" :key="item.id">
<text v-if="index > 0" class="mx-8rpx text-[#999]">/</text>
<text
:class="index === breadcrumbItems.length - 1 ? 'text-[#333]' : 'text-[#1890ff]'"
@click="handleClick(index)"
>
{{ item.name }}
</text>
</template>
</view>
</scroll-view>
</view>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue'
export interface BreadcrumbNode {
id: number
name: string
[key: string]: any
}
const props = withDefaults(defineProps<{
modelValue?: number // 当前父节点编号
rootName?: string // 根目录名称
}>(), {
modelValue: 0,
rootName: '根目录',
})
const emit = defineEmits<{
'update:modelValue': [value: number]
back: [] // 返回上一层级事件
}>()
const breadcrumbs = ref<BreadcrumbNode[]>([]) // 面包屑路径(不包含根目录)
const breadcrumbItems = computed(() => [
{ id: 0, name: props.rootName },
...breadcrumbs.value,
]) // 面包屑显示数据(包含根目录)
const currentParentId = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
}) // 当前父节点编号
/** 面包屑点击 */
function handleClick(index: number) {
if (index === breadcrumbItems.value.length - 1) return // 点击当前层级不处理
if (index === 0) {
breadcrumbs.value = []
currentParentId.value = 0
} else {
breadcrumbs.value = breadcrumbs.value.slice(0, index)
currentParentId.value = breadcrumbs.value[index - 1].id
}
}
/** 进入子层级 */
function enter(node: BreadcrumbNode) {
breadcrumbs.value.push({ id: node.id, name: node.name })
currentParentId.value = node.id
}
/** 返回上一层级,返回 true 表示还有上层false 表示已在根目录 */
function back(): boolean {
if (breadcrumbs.value.length > 0) {
breadcrumbs.value.pop()
currentParentId.value = breadcrumbs.value.length > 0
? breadcrumbs.value[breadcrumbs.value.length - 1].id
: 0
return true
}
return false
}
/** 监听外部 modelValue 变化,重置面包屑(用于外部重置场景) */
watch(() => props.modelValue, (val) => {
if (val === 0 && breadcrumbs.value.length > 0) {
breadcrumbs.value = []
}
})
defineExpose({
enter,
back,
breadcrumbs,
})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,155 @@
<template>
<view class="min-h-screen bg-[#f5f5f5]">
<!-- 顶部导航栏 -->
<wd-navbar
title="菜单详情"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 详情内容 -->
<view class="p-24rpx pb-200rpx">
<wd-cell-group custom-class="cell-group" border>
<wd-cell title="菜单名称" :value="formData?.name || '-'" />
<wd-cell title="菜单类型">
<dict-tag :type="DICT_TYPE.SYSTEM_MENU_TYPE" :value="formData?.type" />
</wd-cell>
<wd-cell title="上级菜单" :value="parentMenuName" />
<wd-cell title="显示排序" :value="String(formData?.sort ?? '-')" />
<wd-cell title="路由地址" :value="formData?.path || '-'" />
<wd-cell v-if="formData?.type === SystemMenuTypeEnum.MENU" title="组件路径" :value="formData?.component || '-'" />
<wd-cell v-if="formData?.type === SystemMenuTypeEnum.MENU" title="组件名称" :value="formData?.componentName || '-'" />
<wd-cell v-if="formData?.type !== SystemMenuTypeEnum.DIR" title="权限标识" :value="formData?.permission || '-'" />
<wd-cell title="菜单状态">
<dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="formData?.status" />
</wd-cell>
<wd-cell v-if="formData?.type !== SystemMenuTypeEnum.BUTTON" title="显示状态">
<wd-tag v-if="formData?.visible" type="success" plain>
显示
</wd-tag>
<wd-tag v-else type="warning" plain>
隐藏
</wd-tag>
</wd-cell>
<wd-cell v-if="formData?.type === SystemMenuTypeEnum.MENU" title="缓存状态">
<wd-tag v-if="formData?.keepAlive" type="success" plain>
缓存
</wd-tag>
<wd-tag v-else type="default" plain>
不缓存
</wd-tag>
</wd-cell>
<wd-cell title="创建时间" :value="formatDateTime(formData?.createTime) || '-'" />
</wd-cell-group>
</view>
<!-- 底部操作按钮 -->
<view class="safe-area-inset-bottom fixed bottom-0 left-0 right-0 bg-white p-24rpx">
<view class="w-full flex gap-24rpx">
<wd-button class="flex-1" type="warning" @click="handleEdit">
编辑
</wd-button>
<wd-button class="flex-1" type="error" :loading="deleting" @click="handleDelete">
删除
</wd-button>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import type { Menu } from '@/api/system/menu'
import { onMounted, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { deleteMenu, getMenu, getSimpleMenuList } from '@/api/system/menu'
import { DICT_TYPE, SystemMenuTypeEnum } from '@/utils/constants'
import { formatDateTime } from '@/utils/date'
const props = defineProps<{
id: number
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const toast = useToast()
const formData = ref<Menu>() // 详情数据
const deleting = ref(false) // 删除中
const parentMenuName = ref('-') // 上级菜单名称
/** 返回上一页 */
function handleBack() {
uni.navigateBack()
}
/** 加载菜单详情 */
async function getDetail() {
if (!props.id) {
return
}
formData.value = await getMenu(props.id)
// 获取上级菜单名称
if (formData.value?.parentId === 0) {
parentMenuName.value = '主类目'
} else if (formData.value?.parentId) {
// TODO @芋艿:后续这里可以优化,由后端返回 menuName
const menuList = await getSimpleMenuList()
const parent = menuList.find(item => item.id === formData.value?.parentId)
parentMenuName.value = parent?.name || '-'
}
}
/** 编辑菜单 */
function handleEdit() {
uni.navigateTo({
url: `/pages-system/menu/form/index?id=${props.id}`,
})
}
/** 删除菜单 */
function handleDelete() {
if (!props.id) {
return
}
uni.showModal({
title: '提示',
content: '确定要删除该菜单吗?',
success: async (res) => {
if (!res.confirm) {
return
}
deleting.value = true
try {
await deleteMenu(props.id)
toast.success('删除成功')
setTimeout(() => {
handleBack()
}, 500)
} finally {
deleting.value = false
}
},
})
}
/** 初始化 */
onMounted(() => {
getDetail()
})
</script>
<style lang="scss" scoped>
:deep(.cell-group) {
border-radius: 12rpx;
overflow: hidden;
box-shadow: 0 3rpx 8rpx rgba(24, 144, 255, 0.06);
}
.safe-area-inset-bottom {
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
}
</style>

View File

@@ -0,0 +1,143 @@
<template>
<wd-col-picker
v-model="selectedValue"
label="上级菜单"
label-width="180rpx"
:columns="menuColumns"
:column-change="handleColumnChange"
:display-format="displayFormat"
@confirm="handleConfirm"
/>
</template>
<script lang="ts" setup>
import type { Menu } from '@/api/system/menu'
import { onMounted, ref, watch } from 'vue'
import { getSimpleMenuList } from '@/api/system/menu'
import { SystemMenuTypeEnum } from '@/utils/constants'
const props = defineProps<{
modelValue?: number
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: number): void
}>()
const menuList = ref<Menu[]>([])
const menuColumns = ref<any[]>([])
const selectedValue = ref<number[]>([])
/** 监听外部值变化,回显选中值 */
watch(
() => props.modelValue,
(val) => {
if (val !== undefined && val !== 0 && menuList.value.length > 0) {
const path = findMenuPath(val)
selectedValue.value = path
buildColumnsForPath(path)
} else {
selectedValue.value = [0]
}
},
)
/** 加载菜单列表 */
async function loadMenuList() {
const list = await getSimpleMenuList()
// 只保留目录和菜单
menuList.value = list.filter(item => item.type !== SystemMenuTypeEnum.BUTTON)
// 构建第一列数据(主类目 + 顶级菜单)
const topMenus = menuList.value.filter(item => item.parentId === 0)
menuColumns.value = [[
{ value: 0, label: '主类目' },
...topMenus.map(item => ({ value: item.id, label: item.name })),
]]
// 如果有初始值,回显
if (props.modelValue !== undefined && props.modelValue !== 0) {
const path = findMenuPath(props.modelValue)
selectedValue.value = path
buildColumnsForPath(path)
} else {
selectedValue.value = [0]
}
}
/** 查找菜单路径 */
function findMenuPath(targetId: number): number[] {
if (targetId === 0) {
return [0]
}
const path: number[] = []
const findPath = (parentId: number, id: number): boolean => {
const items = menuList.value.filter(m => m.parentId === parentId)
for (const item of items) {
if (item.id === id) {
path.push(item.id!)
return true
}
if (findPath(item.id!, id)) {
path.unshift(item.id!)
return true
}
}
return false
}
findPath(0, targetId)
return path.length > 0 ? path : [0]
}
/** 根据路径构建列数据 */
function buildColumnsForPath(path: number[]) {
if (path.length === 0 || (path.length === 1 && path[0] === 0)) {
return
}
// 第一列已经有了,从第二列开始构建
const columns = [menuColumns.value[0]]
for (let i = 0; i < path.length; i++) {
const parentId = path[i]
if (parentId === 0) {
continue
}
const children = menuList.value.filter(item => item.parentId === parentId)
if (children.length > 0) {
columns.push(children.map(item => ({ value: item.id, label: item.name })))
}
}
menuColumns.value = columns
}
/** 列变化 */
function handleColumnChange({ selectedItem, resolve, finish }: any) {
if (selectedItem.value === 0) {
// 选择主类目,结束
finish()
return
}
const children = menuList.value.filter(item => item.parentId === selectedItem.value)
if (children.length > 0) {
resolve(children.map(item => ({ value: item.id, label: item.name })))
} else {
finish()
}
}
/** 格式化显示 */
function displayFormat(selectedItems: any[]) {
return selectedItems.map(item => item.label).join(' / ')
}
/** 确认选择 */
function handleConfirm({ value }: { value: number[] }) {
if (value && value.length > 0) {
emit('update:modelValue', value[value.length - 1])
} else {
emit('update:modelValue', 0)
}
}
/** 初始化 */
onMounted(() => {
loadMenuList()
})
</script>

View File

@@ -0,0 +1,257 @@
<template>
<view class="min-h-screen bg-[#f5f5f5]">
<!-- 顶部导航栏 -->
<wd-navbar
:title="getTitle"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 表单区域 -->
<view class="p-24rpx pb-200rpx">
<wd-form ref="formRef" :model="formData" :rules="formRules">
<wd-cell-group custom-class="cell-group" border>
<MenuPicker v-model="formData.parentId" />
<wd-cell title="菜单类型" title-width="180rpx" prop="type">
<wd-radio-group v-model="formData.type" shape="button" @change="handleTypeChange">
<wd-radio v-for="dict in getIntDictOptions(DICT_TYPE.SYSTEM_MENU_TYPE)" :key="dict.value" :value="dict.value">
{{ dict.label }}
</wd-radio>
</wd-radio-group>
</wd-cell>
<wd-input
v-model="formData.name"
label="菜单名称"
label-width="180rpx"
prop="name"
clearable
placeholder="请输入菜单名称"
/>
<wd-input
v-if="formData.type !== SystemMenuTypeEnum.BUTTON"
v-model="formData.icon"
label="菜单图标"
label-width="180rpx"
clearable
placeholder="请输入菜单图标"
/>
<wd-input
v-if="formData.type !== SystemMenuTypeEnum.BUTTON"
v-model="formData.path"
label="路由地址"
label-width="180rpx"
prop="path"
clearable
placeholder="请输入路由地址"
/>
<wd-input
v-if="formData.type === SystemMenuTypeEnum.MENU"
v-model="formData.component"
label="组件路径"
label-width="180rpx"
clearable
placeholder="例如system/user/index"
/>
<wd-input
v-if="formData.type === SystemMenuTypeEnum.MENU"
v-model="formData.componentName"
label="组件名称"
label-width="180rpx"
clearable
placeholder="例如SystemUser"
/>
<wd-input
v-if="formData.type !== SystemMenuTypeEnum.DIR"
v-model="formData.permission"
label="权限标识"
label-width="180rpx"
clearable
placeholder="请输入权限标识"
/>
<wd-cell title="显示排序" title-width="180rpx" prop="sort" center>
<wd-input-number
v-model="formData.sort"
:min="0"
/>
</wd-cell>
<wd-cell title="菜单状态" title-width="180rpx" prop="status" center>
<wd-switch
v-model="formData.status"
:active-value="CommonStatusEnum.ENABLE"
:inactive-value="CommonStatusEnum.DISABLE"
/>
</wd-cell>
<wd-cell v-if="formData.type !== SystemMenuTypeEnum.BUTTON" title="显示状态" title-width="180rpx" center>
<wd-switch
v-model="formData.visible"
:active-value="true"
:inactive-value="false"
/>
</wd-cell>
<wd-cell v-if="formData.type !== SystemMenuTypeEnum.BUTTON" title="总是显示" title-width="180rpx" center>
<wd-switch
v-model="formData.alwaysShow"
:active-value="true"
:inactive-value="false"
/>
</wd-cell>
<wd-cell v-if="formData.type === SystemMenuTypeEnum.MENU" title="缓存状态" title-width="180rpx" center>
<wd-switch
v-model="formData.keepAlive"
:active-value="true"
:inactive-value="false"
/>
</wd-cell>
</wd-cell-group>
</wd-form>
</view>
<!-- 底部保存按钮 -->
<view class="safe-area-inset-bottom fixed bottom-0 left-0 right-0 bg-white p-24rpx">
<wd-button
type="primary"
block
:loading="formLoading"
@click="handleSubmit"
>
保存
</wd-button>
</view>
</view>
</template>
<script lang="ts" setup>
import type { Menu } from '@/api/system/menu'
import { computed, onMounted, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { createMenu, getMenu, updateMenu } from '@/api/system/menu'
import { getIntDictOptions } from '@/hooks/useDict'
import { CommonStatusEnum, DICT_TYPE, SystemMenuTypeEnum } from '@/utils/constants'
import MenuPicker from './components/menu-picker.vue'
const props = defineProps<{
id?: number
parentId?: number
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const toast = useToast()
const getTitle = computed(() => props.id ? '编辑菜单' : '新增菜单')
const formLoading = ref(false) // 提交中状态
const formData = ref<Menu>({
id: undefined,
name: '',
permission: '',
type: SystemMenuTypeEnum.DIR,
sort: 0,
parentId: 0,
path: '',
icon: '',
component: '',
componentName: '',
status: CommonStatusEnum.ENABLE,
visible: true,
keepAlive: true,
alwaysShow: true,
})
const formRules = {
name: [{ required: true, message: '菜单名称不能为空' }],
type: [{ required: true, message: '菜单类型不能为空' }],
sort: [{ required: true, message: '显示排序不能为空' }],
status: [{ required: true, message: '状态不能为空' }],
}
const formRef = ref()
/** 返回上一页 */
function handleBack() {
uni.navigateBack()
}
/** 菜单类型变更: */
function handleTypeChange() {
// 切换类型时,清空不需要的字段
if (formData.value.type === SystemMenuTypeEnum.BUTTON) {
formData.value.path = ''
formData.value.component = ''
formData.value.componentName = ''
formData.value.icon = ''
} else if (formData.value.type === SystemMenuTypeEnum.DIR) {
formData.value.component = ''
formData.value.componentName = ''
formData.value.permission = ''
}
}
/** 加载菜单详情 */
async function getDetail() {
if (!props.id) {
// 新增时,设置默认的上级菜单
if (props.parentId) {
formData.value.parentId = props.parentId
}
return
}
formData.value = await getMenu(props.id)
}
/** 提交表单 */
async function handleSubmit() {
const { valid } = await formRef.value.validate()
if (!valid) {
return
}
// 路由地址校验
if (formData.value.type !== SystemMenuTypeEnum.BUTTON) {
const path = formData.value.path
const isExternal = /^(?:https?:|mailto:|tel:)/.test(path)
if (!isExternal) {
if (formData.value.parentId === 0 && path.charAt(0) !== '/') {
toast.error('路径必须以 / 开头')
return
} else if (formData.value.parentId !== 0 && path.charAt(0) === '/') {
toast.error('路径不能以 / 开头')
return
}
}
}
formLoading.value = true
try {
if (props.id) {
await updateMenu(formData.value)
toast.success('修改成功')
} else {
await createMenu(formData.value)
toast.success('新增成功')
}
setTimeout(() => {
handleBack()
}, 500)
} finally {
formLoading.value = false
}
}
/** 初始化 */
onMounted(() => {
getDetail()
})
</script>
<style lang="scss" scoped>
:deep(.cell-group) {
border-radius: 12rpx;
overflow: hidden;
box-shadow: 0 3rpx 8rpx rgba(24, 144, 255, 0.06);
}
.safe-area-inset-bottom {
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
}
</style>

View File

@@ -0,0 +1,180 @@
<template>
<view class="min-h-screen bg-[#f5f5f5]">
<!-- 顶部导航栏 -->
<wd-navbar
title="菜单管理"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 面包屑导航 -->
<Breadcrumb ref="breadcrumbRef" v-model="currentParentId" />
<!-- 菜单列表 -->
<view class="p-24rpx">
<view
v-for="item in currentList"
:key="item.id"
class="mb-24rpx overflow-hidden rounded-12rpx bg-white shadow-sm"
>
<!-- 主内容区域点击进入详情 -->
<view class="p-24rpx" @click="handleDetail(item)">
<!-- 第一行图标名称状态标签 -->
<view class="flex items-center justify-between">
<view class="flex items-center">
<view class="mr-16rpx h-48rpx w-48rpx flex items-center justify-center rounded-8rpx" :class="getTypeIconBg(item.type)">
<wd-icon :name="getTypeIcon(item.type)" size="20px" color="#fff" />
</view>
<view class="text-32rpx text-[#333] font-semibold">
{{ item.name }}
</view>
</view>
<dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="item.status" />
</view>
<!-- 第二行类型描述子菜单入口 -->
<view class="mt-12rpx flex items-center justify-between pl-64rpx">
<view class="text-24rpx text-[#999]">
{{ getTypeDesc(item) }}
</view>
<view
v-if="item.children && item.children.length > 0"
class="flex items-center"
@click.stop="handleEnterChildren(item)"
>
<text class="text-24rpx text-[#1890ff]">子菜单 ({{ item.children.length }})</text>
<wd-icon name="arrow-right" size="12px" color="#1890ff" />
</view>
</view>
</view>
</view>
<!-- 空状态 -->
<view v-if="!loading && currentList.length === 0" class="py-100rpx text-center">
<wd-status-tip image="content" tip="暂无菜单数据" />
</view>
</view>
<!-- 新增按钮 -->
<view
class="fixed bottom-100rpx right-32rpx z-10 h-100rpx w-100rpx flex items-center justify-center rounded-full bg-[#1890ff] shadow-lg"
@click="handleAdd"
>
<wd-icon name="add" size="24px" color="#fff" />
</view>
</view>
</template>
<script lang="ts" setup>
import type { Menu } from '@/api/system/menu'
import { computed, onMounted, ref } from 'vue'
import { getMenuList } from '@/api/system/menu'
import { DICT_TYPE, SystemMenuTypeEnum } from '@/utils/constants'
import { findChildren, handleTree } from '@/utils/tree'
import Breadcrumb from './components/breadcrumb.vue'
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const loading = ref(false)
const list = ref<Menu[]>([]) // 完整菜单列表(树形结构)
const currentParentId = ref(0) // 当前层级的父节点编号
const currentList = computed(() => {
if (currentParentId.value === 0) {
return list.value.filter(item => item.parentId === 0)
}
return findChildren(list.value, currentParentId.value)
}) // 当前层级的菜单列表
const breadcrumbRef = ref<InstanceType<typeof Breadcrumb>>()
/** 返回上一页或上一层级 */
function handleBack() {
if (!breadcrumbRef.value?.back()) {
uni.navigateBack()
}
}
/** 获取菜单类型图标 */
function getTypeIcon(type: number): string {
switch (type) {
case SystemMenuTypeEnum.DIR:
return 'folder'
case SystemMenuTypeEnum.MENU:
return 'read'
case SystemMenuTypeEnum.BUTTON:
return 'tips'
default:
return 'folder'
}
}
/** 获取菜单类型图标背景色 */
function getTypeIconBg(type: number): string {
switch (type) {
case SystemMenuTypeEnum.DIR:
return 'bg-[#1890ff]'
case SystemMenuTypeEnum.MENU:
return 'bg-[#52c41a]'
case SystemMenuTypeEnum.BUTTON:
return 'bg-[#faad14]'
default:
return 'bg-[#1890ff]'
}
}
/** 获取菜单类型描述(根据类型展示不同信息) */
function getTypeDesc(item: Menu): string {
switch (item.type) {
case SystemMenuTypeEnum.DIR:
return `路由:${item.path}`
case SystemMenuTypeEnum.MENU:
return `路由:${item.path}`
case SystemMenuTypeEnum.BUTTON:
return `权限:${item.permission}`
default:
return ''
}
}
/** 进入子菜单层级 */
function handleEnterChildren(item: Menu) {
breadcrumbRef.value?.enter({ id: item.id!, name: item.name })
}
/** 查询菜单列表 */
async function getList() {
loading.value = true
try {
const data = await getMenuList()
list.value = handleTree(data)
} finally {
loading.value = false
}
}
/** 新增菜单 */
function handleAdd() {
uni.navigateTo({
url: `/pages-system/menu/form/index?parentId=${currentParentId.value}`,
})
}
/** 查看详情 */
function handleDetail(item: Menu) {
uni.navigateTo({
url: `/pages-system/menu/detail/index?id=${item.id}`,
})
}
/** 初始化 */
onMounted(() => {
getList()
})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,116 @@
<template>
<wd-popup
v-model="visible"
position="top"
custom-style="border-radius: 0 0 24rpx 24rpx;"
safe-area-inset-top
@close="visible = false"
>
<view class="p-32rpx">
<view class="mb-24rpx text-32rpx text-[#333] font-semibold">
搜索岗位
</view>
<view class="mb-24rpx">
<view class="mb-12rpx text-28rpx text-[#666]">
岗位名称
</view>
<wd-input
v-model="formData.name"
placeholder="请输入岗位名称"
clearable
/>
</view>
<view class="mb-24rpx">
<view class="mb-12rpx text-28rpx text-[#666]">
岗位编码
</view>
<wd-input
v-model="formData.code"
placeholder="请输入岗位编码"
clearable
/>
</view>
<view class="mb-32rpx">
<view class="mb-12rpx text-28rpx text-[#666]">
状态
</view>
<wd-radio-group v-model="formData.status" shape="button" size="medium">
<wd-radio :value="-1">
全部
</wd-radio>
<wd-radio :value="0">
启用
</wd-radio>
<wd-radio :value="1">
禁用
</wd-radio>
</wd-radio-group>
</view>
<view class="w-full flex justify-center gap-24rpx">
<wd-button class="flex-1" plain @click="handleReset">
重置
</wd-button>
<wd-button class="flex-1" type="primary" @click="handleSearch">
搜索
</wd-button>
</view>
</view>
</wd-popup>
</template>
<script lang="ts" setup>
import { computed, reactive, watch } from 'vue'
/** 搜索表单数据 */
export interface SearchFormData {
name?: string
code?: string
status: number // -1 表示全部
}
const props = defineProps<{
modelValue: boolean
searchParams?: Partial<SearchFormData> // 初始搜索参数
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'search': [data: SearchFormData]
'reset': []
}>()
const visible = computed({
get: () => props.modelValue,
set: (val: boolean) => emit('update:modelValue', val),
})
const formData = reactive<SearchFormData>({
name: undefined,
code: undefined,
status: -1,
})
/** 监听弹窗打开,同步外部参数 */
watch(() => props.modelValue, (val) => {
if (val && props.searchParams) {
formData.name = props.searchParams.name
formData.code = props.searchParams.code
formData.status = props.searchParams.status ?? -1
}
})
/** 搜索 */
function handleSearch() {
visible.value = false
emit('search', { ...formData })
}
/** 重置 */
function handleReset() {
formData.name = undefined
formData.code = undefined
formData.status = -1
visible.value = false
emit('reset')
}
</script>

View File

@@ -0,0 +1,123 @@
<template>
<view class="min-h-screen bg-[#f5f5f5]">
<!-- 顶部导航栏 -->
<wd-navbar
title="岗位详情"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 详情内容 -->
<view class="p-24rpx pb-200rpx">
<wd-cell-group custom-class="cell-group" border>
<wd-cell title="岗位名称" :value="formData?.name || '-'" />
<wd-cell title="岗位编码" :value="formData?.code || '-'" />
<wd-cell title="显示顺序" :value="String(formData?.sort ?? '-')" />
<wd-cell title="状态">
<dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="formData?.status" />
</wd-cell>
<wd-cell title="备注" :value="formData?.remark || '-'" />
<wd-cell title="创建时间" :value="formatDateTime(formData?.createTime) || '-'" />
</wd-cell-group>
</view>
<!-- 底部操作按钮 -->
<view class="safe-area-inset-bottom fixed bottom-0 left-0 right-0 bg-white p-24rpx">
<view class="w-full flex gap-24rpx">
<wd-button class="flex-1" type="warning" @click="handleEdit">
编辑
</wd-button>
<wd-button class="flex-1" type="error" :loading="deleting" @click="handleDelete">
删除
</wd-button>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import type { Post } from '@/api/system/post'
import { onMounted, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { deletePost, getPost } from '@/api/system/post'
import { DICT_TYPE } from '@/utils/constants'
import { formatDateTime } from '@/utils/date'
const props = defineProps<{
id: number
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const toast = useToast()
const formData = ref<Post>() // 详情数据
const deleting = ref(false) // 删除中
/** 返回上一页 */
function handleBack() {
uni.navigateBack()
}
/** 加载岗位详情 */
async function getDetail() {
if (!props.id) {
return
}
formData.value = await getPost(props.id)
}
/** 编辑岗位 */
function handleEdit() {
uni.navigateTo({
url: `/pages-system/post/form/index?id=${props.id}`,
})
}
/** 删除岗位 */
function handleDelete() {
if (!props.id) {
return
}
uni.showModal({
title: '提示',
content: '确定要删除该岗位吗?',
success: async (res) => {
if (!res.confirm) {
return
}
deleting.value = true
try {
await deletePost(props.id)
toast.success('删除成功')
setTimeout(() => {
handleBack()
}, 500)
} finally {
deleting.value = false
}
},
})
}
/** 初始化 */
onMounted(() => {
getDetail()
})
</script>
<style lang="scss" scoped>
:deep(.cell-group) {
border-radius: 12rpx;
overflow: hidden;
box-shadow: 0 3rpx 8rpx rgba(24, 144, 255, 0.06);
}
.safe-area-inset-bottom {
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
}
</style>

View File

@@ -0,0 +1,160 @@
<template>
<view class="min-h-screen bg-[#f5f5f5]">
<!-- 顶部导航栏 -->
<wd-navbar
:title="getTitle"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 表单区域 -->
<view class="p-24rpx">
<wd-form ref="formRef" :model="formData" :rules="formRules">
<wd-cell-group custom-class="cell-group" border>
<wd-input
v-model="formData.name"
label="岗位名称"
label-width="180rpx"
prop="name"
clearable
placeholder="请输入岗位名称"
/>
<wd-input
v-model="formData.code"
label="岗位编码"
label-width="180rpx"
prop="code"
clearable
placeholder="请输入岗位编码"
/>
<wd-cell title="显示顺序" title-width="180rpx" prop="sort" center>
<wd-input-number
v-model="formData.sort"
:min="0"
/>
</wd-cell>
<wd-cell title="状态" title-width="180rpx" prop="status" center>
<wd-switch
v-model="formData.status"
:active-value="CommonStatusEnum.ENABLE"
:inactive-value="CommonStatusEnum.DISABLE"
/>
</wd-cell>
<wd-textarea
v-model="formData.remark"
label="备注"
label-width="180rpx"
placeholder="请输入备注"
:maxlength="200"
show-word-limit
clearable
/>
</wd-cell-group>
</wd-form>
</view>
<!-- 底部保存按钮 -->
<view class="safe-area-inset-bottom fixed bottom-0 left-0 right-0 bg-white p-24rpx">
<wd-button
type="primary"
block
:loading="formLoading"
@click="handleSubmit"
>
保存
</wd-button>
</view>
</view>
</template>
<script lang="ts" setup>
import type { Post } from '@/api/system/post'
import { computed, onMounted, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { createPost, getPost, updatePost } from '@/api/system/post'
import { CommonStatusEnum } from '@/utils/constants'
const props = defineProps<{
id?: number
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const toast = useToast()
const getTitle = computed(() => props.id ? '编辑岗位' : '新增岗位')
const formLoading = ref(false) // 提交中状态
const formData = ref<Post>({
id: undefined,
name: '',
code: '',
sort: 0,
status: CommonStatusEnum.ENABLE,
remark: '',
})
const formRules = {
name: [{ required: true, message: '岗位名称不能为空' }],
code: [{ required: true, message: '岗位编码不能为空' }],
sort: [{ required: true, message: '显示顺序不能为空' }],
status: [{ required: true, message: '状态不能为空' }],
}
const formRef = ref()
/** 返回上一页 */
function handleBack() {
uni.navigateBack()
}
/** 加载岗位详情 */
async function getDetail() {
if (!props.id) {
return
}
formData.value = await getPost(props.id)
}
/** 提交表单 */
async function handleSubmit() {
const { valid } = await formRef.value.validate()
if (!valid) {
return
}
formLoading.value = true
try {
if (props.id) {
await updatePost(formData.value)
toast.success('修改成功')
} else {
await createPost(formData.value)
toast.success('新增成功')
}
setTimeout(() => {
handleBack()
}, 500)
} finally {
formLoading.value = false
}
}
/** 初始化 */
onMounted(() => {
getDetail()
})
</script>
<style lang="scss" scoped>
:deep(.cell-group) {
border-radius: 12rpx;
overflow: hidden;
box-shadow: 0 3rpx 8rpx rgba(24, 144, 255, 0.06);
}
.safe-area-inset-bottom {
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
}
</style>

View File

@@ -0,0 +1,171 @@
<template>
<view class="min-h-screen bg-[#f5f5f5]">
<!-- 顶部导航栏 -->
<wd-navbar
title="岗位管理"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
>
<template #right>
<view class="flex items-center" @click="searchVisible = !searchVisible">
<wd-icon name="search" size="20px" />
</view>
</template>
</wd-navbar>
<!-- 岗位列表 -->
<view class="p-24rpx">
<view
v-for="item in list"
:key="item.id"
class="mb-24rpx overflow-hidden rounded-12rpx bg-white shadow-sm"
@click="handleDetail(item)"
>
<view class="p-24rpx">
<view class="mb-16rpx flex items-center justify-between">
<view class="text-32rpx text-[#333] font-semibold">
{{ item.name }}
</view>
<dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="item.status" />
</view>
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx text-[#999]">岗位编码</text>
<text>{{ item.code }}</text>
</view>
<view v-if="item.remark" class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx text-[#999]">备注</text>
<text class="line-clamp-1">{{ item.remark }}</text>
</view>
</view>
</view>
<!-- 加载更多 -->
<view v-if="loadMoreState !== 'loading' && list.length === 0" class="py-100rpx text-center">
<wd-status-tip image="content" tip="暂无岗位数据" />
</view>
<wd-loadmore
v-if="list.length > 0"
:state="loadMoreState"
@reload="loadMore"
/>
</view>
<!-- 搜索弹窗 -->
<SearchForm
v-model="searchVisible"
:search-params="queryParams"
@search="handleQuery"
@reset="handleReset"
/>
<!-- 新增按钮 -->
<view
class="fixed bottom-100rpx right-32rpx z-10 h-100rpx w-100rpx flex items-center justify-center rounded-full bg-[#1890ff] shadow-lg"
@click="handleAdd"
>
<wd-icon name="add" size="24px" color="#fff" />
</view>
</view>
</template>
<script lang="ts" setup>
import type { Post } from '@/api/system/post'
import type { LoadMoreState } from '@/http/types'
import { onReachBottom } from '@dcloudio/uni-app'
import { onMounted, reactive, ref } from 'vue'
import { getPostPage } from '@/api/system/post'
import { DICT_TYPE } from '@/utils/constants'
import SearchForm from './components/search-form.vue'
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const total = ref(0) // 列表的总页数
const list = ref<Post[]>([]) // 列表的数据
const loadMoreState = ref<LoadMoreState>('loading') // 加载更多状态
const searchVisible = ref(false) // 搜索弹窗
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
name: undefined as string | undefined,
code: undefined as string | undefined,
status: -1 as number, // -1 表示全部
})
/** 返回上一页 */
function handleBack() {
uni.navigateBack()
}
/** 查询岗位列表 */
async function getList() {
loadMoreState.value = 'loading'
try {
const data = await getPostPage({
...queryParams,
status: queryParams.status === -1 ? undefined : queryParams.status,
})
list.value = [...list.value, ...data.list]
total.value = data.total
loadMoreState.value = list.value.length >= total.value ? 'finished' : 'loading'
} catch {
queryParams.pageNo = queryParams.pageNo > 1 ? queryParams.pageNo - 1 : 1
loadMoreState.value = 'error'
}
}
/** 搜索按钮操作 */
function handleQuery(data?: { name?: string, code?: string, status?: number }) {
queryParams.name = data?.name
queryParams.code = data?.code
queryParams.status = data?.status ?? -1
queryParams.pageNo = 1
list.value = [] // 清空列表
getList()
}
/** 重置按钮操作 */
function handleReset() {
handleQuery()
}
/** 加载更多 */
function loadMore() {
if (loadMoreState.value === 'finished') {
return
}
queryParams.pageNo++
getList()
}
/** 新增岗位 */
function handleAdd() {
uni.navigateTo({
url: '/pages-system/post/form/index',
})
}
/** 查看详情 */
function handleDetail(item: Post) {
uni.navigateTo({
url: `/pages-system/post/detail/index?id=${item.id}`,
})
}
/** 触底加载更多 */
onReachBottom(() => {
loadMore()
})
/** 初始化 */
onMounted(() => {
getList()
})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,116 @@
<template>
<wd-popup
v-model="visible"
position="top"
custom-style="border-radius: 0 0 24rpx 24rpx;"
safe-area-inset-top
@close="visible = false"
>
<view class="p-32rpx">
<view class="mb-24rpx text-32rpx text-[#333] font-semibold">
搜索角色
</view>
<view class="mb-24rpx">
<view class="mb-12rpx text-28rpx text-[#666]">
角色名称
</view>
<wd-input
v-model="formData.name"
placeholder="请输入角色名称"
clearable
/>
</view>
<view class="mb-24rpx">
<view class="mb-12rpx text-28rpx text-[#666]">
角色标识
</view>
<wd-input
v-model="formData.code"
placeholder="请输入角色标识"
clearable
/>
</view>
<view class="mb-32rpx">
<view class="mb-12rpx text-28rpx text-[#666]">
状态
</view>
<wd-radio-group v-model="formData.status" shape="button" size="medium">
<wd-radio :value="-1">
全部
</wd-radio>
<wd-radio :value="0">
启用
</wd-radio>
<wd-radio :value="1">
禁用
</wd-radio>
</wd-radio-group>
</view>
<view class="w-full flex justify-center gap-24rpx">
<wd-button class="flex-1" plain @click="handleReset">
重置
</wd-button>
<wd-button class="flex-1" type="primary" @click="handleSearch">
搜索
</wd-button>
</view>
</view>
</wd-popup>
</template>
<script lang="ts" setup>
import { computed, reactive, watch } from 'vue'
/** 搜索表单数据 */
export interface SearchFormData {
name?: string
code?: string
status: number // -1 表示全部
}
const props = defineProps<{
modelValue: boolean
searchParams?: Partial<SearchFormData> // 初始搜索参数
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'search': [data: SearchFormData]
'reset': []
}>()
const visible = computed({
get: () => props.modelValue,
set: (val: boolean) => emit('update:modelValue', val),
})
const formData = reactive<SearchFormData>({
name: undefined,
code: undefined,
status: -1,
})
/** 监听弹窗打开,同步外部参数 */
watch(() => props.modelValue, (val) => {
if (val && props.searchParams) {
formData.name = props.searchParams.name
formData.code = props.searchParams.code
formData.status = props.searchParams.status ?? -1
}
})
/** 搜索 */
function handleSearch() {
visible.value = false
emit('search', { ...formData })
}
/** 重置 */
function handleReset() {
formData.name = undefined
formData.code = undefined
formData.status = -1
visible.value = false
emit('reset')
}
</script>

View File

@@ -0,0 +1,124 @@
<template>
<view class="min-h-screen bg-[#f5f5f5]">
<!-- 顶部导航栏 -->
<wd-navbar
title="角色详情"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 详情内容 -->
<view class="p-24rpx pb-200rpx">
<wd-cell-group custom-class="cell-group" border>
<wd-cell title="角色名称" :value="formData?.name || '-'" />
<wd-cell title="角色标识" :value="formData?.code || '-'" />
<wd-cell title="显示顺序" :value="String(formData?.sort ?? '-')" />
<wd-cell title="状态">
<dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="formData?.status" />
</wd-cell>
<wd-cell title="备注" :value="formData?.remark || '-'" />
<wd-cell title="创建时间" :value="formatDateTime(formData?.createTime) || '-'" />
</wd-cell-group>
</view>
<!-- 底部操作按钮 -->
<view class="safe-area-inset-bottom fixed bottom-0 left-0 right-0 bg-white p-24rpx">
<view class="w-full flex gap-24rpx">
<wd-button class="flex-1" type="warning" @click="handleEdit">
编辑
</wd-button>
<wd-button class="flex-1" type="error" :loading="deleting" @click="handleDelete">
删除
</wd-button>
</view>
<!-- TODO @芋艿1数据权限2菜单权限 -->
</view>
</view>
</template>
<script lang="ts" setup>
import type { Role } from '@/api/system/role'
import { onMounted, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { deleteRole, getRole } from '@/api/system/role'
import { DICT_TYPE } from '@/utils/constants'
import { formatDateTime } from '@/utils/date'
const props = defineProps<{
id: number
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const toast = useToast()
const formData = ref<Role>() // 详情数据
const deleting = ref(false) // 删除中
/** 返回上一页 */
function handleBack() {
uni.navigateBack()
}
/** 加载角色详情 */
async function getDetail() {
if (!props.id) {
return
}
formData.value = await getRole(props.id)
}
/** 编辑角色 */
function handleEdit() {
uni.navigateTo({
url: `/pages-system/role/form/index?id=${props.id}`,
})
}
/** 删除角色 */
function handleDelete() {
if (!props.id) {
return
}
uni.showModal({
title: '提示',
content: '确定要删除该角色吗?',
success: async (res) => {
if (!res.confirm) {
return
}
deleting.value = true
try {
await deleteRole(props.id)
toast.success('删除成功')
setTimeout(() => {
handleBack()
}, 500)
} finally {
deleting.value = false
}
},
})
}
/** 初始化 */
onMounted(() => {
getDetail()
})
</script>
<style lang="scss" scoped>
:deep(.cell-group) {
border-radius: 12rpx;
overflow: hidden;
box-shadow: 0 3rpx 8rpx rgba(24, 144, 255, 0.06);
}
.safe-area-inset-bottom {
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
}
</style>

View File

@@ -0,0 +1,161 @@
<template>
<view class="min-h-screen bg-[#f5f5f5]">
<!-- 顶部导航栏 -->
<wd-navbar
:title="getTitle"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 表单区域 -->
<view class="p-24rpx">
<wd-form ref="formRef" :model="formData" :rules="formRules">
<wd-cell-group custom-class="cell-group" border>
<wd-input
v-model="formData.name"
label="角色名称"
label-width="180rpx"
prop="name"
clearable
placeholder="请输入角色名称"
/>
<wd-input
v-model="formData.code"
label="角色标识"
label-width="180rpx"
prop="code"
clearable
placeholder="请输入角色标识"
/>
<wd-cell title="显示顺序" title-width="180rpx" prop="sort" center>
<wd-input-number
v-model="formData.sort"
:min="0"
/>
</wd-cell>
<wd-cell title="状态" title-width="180rpx" prop="status" center>
<wd-switch
v-model="formData.status"
:active-value="CommonStatusEnum.ENABLE"
:inactive-value="CommonStatusEnum.DISABLE"
/>
</wd-cell>
<wd-textarea
v-model="formData.remark"
label="备注"
label-width="180rpx"
placeholder="请输入备注"
:maxlength="200"
show-word-limit
clearable
/>
</wd-cell-group>
</wd-form>
</view>
<!-- 底部保存按钮 -->
<view class="safe-area-inset-bottom fixed bottom-0 left-0 right-0 bg-white p-24rpx">
<wd-button
type="primary"
block
:loading="formLoading"
@click="handleSubmit"
>
保存
</wd-button>
</view>
</view>
</template>
<script lang="ts" setup>
import type { Role } from '@/api/system/role'
import { computed, onMounted, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { createRole, getRole, updateRole } from '@/api/system/role'
import { CommonStatusEnum } from '@/utils/constants'
const props = defineProps<{
id?: number
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const toast = useToast()
const getTitle = computed(() => props.id ? '编辑角色' : '新增角色')
const formLoading = ref(false) // 提交中状态
const formData = ref<Role>({
id: undefined,
name: '',
code: '',
sort: 0,
status: CommonStatusEnum.ENABLE,
remark: '',
createTime: '',
})
const formRules = {
name: [{ required: true, message: '角色名称不能为空' }],
code: [{ required: true, message: '角色标识不能为空' }],
sort: [{ required: true, message: '显示顺序不能为空' }],
status: [{ required: true, message: '状态不能为空' }],
}
const formRef = ref()
/** 返回上一页 */
function handleBack() {
uni.navigateBack()
}
/** 加载角色详情 */
async function getDetail() {
if (!props.id) {
return
}
formData.value = await getRole(props.id)
}
/** 提交表单 */
async function handleSubmit() {
const { valid } = await formRef.value.validate()
if (!valid) {
return
}
formLoading.value = true
try {
if (props.id) {
await updateRole(formData.value)
toast.success('修改成功')
} else {
await createRole(formData.value)
toast.success('新增成功')
}
setTimeout(() => {
handleBack()
}, 500)
} finally {
formLoading.value = false
}
}
/** 初始化 */
onMounted(() => {
getDetail()
})
</script>
<style lang="scss" scoped>
:deep(.cell-group) {
border-radius: 12rpx;
overflow: hidden;
box-shadow: 0 3rpx 8rpx rgba(24, 144, 255, 0.06);
}
.safe-area-inset-bottom {
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
}
</style>

View File

@@ -0,0 +1,174 @@
<template>
<!-- TODO @芋艿优化全局样式后续要全局样式么 -->
<view class="min-h-screen bg-[#f5f5f5]">
<!-- 顶部导航栏 -->
<wd-navbar
title="角色管理"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
>
<template #right>
<view class="flex items-center" @click="searchVisible = !searchVisible">
<wd-icon name="search" size="20px" />
</view>
</template>
</wd-navbar>
<!-- 角色列表 -->
<view class="p-24rpx">
<view
v-for="item in list"
:key="item.id"
class="mb-24rpx overflow-hidden rounded-12rpx bg-white shadow-sm"
@click="handleDetail(item)"
>
<view class="p-24rpx">
<view class="mb-16rpx flex items-center justify-between">
<view class="text-32rpx text-[#333] font-semibold">
{{ item.name }}
</view>
<dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="item.status" />
</view>
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx text-[#999]">角色标识</text>
<text>{{ item.code }}</text>
</view>
<view v-if="item.remark" class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx text-[#999]">备注</text>
<text class="line-clamp-1">{{ item.remark }}</text>
</view>
</view>
</view>
<!-- 加载更多 -->
<view v-if="loadMoreState !== 'loading' && list.length === 0" class="py-100rpx text-center">
<wd-status-tip image="content" tip="暂无角色数据" />
</view>
<wd-loadmore
v-if="list.length > 0"
:state="loadMoreState"
@reload="loadMore"
/>
</view>
<!-- 搜索弹窗 -->
<SearchForm
v-model="searchVisible"
:search-params="queryParams"
@search="handleQuery"
@reset="handleReset"
/>
<!-- 新增按钮 -->
<!-- TODO @芋艿优化全局样式后续要全局样式么 -->
<view
class="fixed bottom-100rpx right-32rpx z-10 h-100rpx w-100rpx flex items-center justify-center rounded-full bg-[#1890ff] shadow-lg"
@click="handleAdd"
>
<wd-icon name="add" size="24px" color="#fff" />
</view>
</view>
</template>
<script lang="ts" setup>
import type { SearchFormData } from './components/search-form.vue'
import type { Role } from '@/api/system/role'
import type { LoadMoreState } from '@/http/types'
import { onReachBottom } from '@dcloudio/uni-app'
import { onMounted, reactive, ref } from 'vue'
import { getRolePage } from '@/api/system/role'
import { DICT_TYPE } from '@/utils/constants'
import SearchForm from './components/search-form.vue'
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const total = ref(0) // 列表的总页数
const list = ref<Role[]>([]) // 列表的数据
const loadMoreState = ref<LoadMoreState>('loading') // 加载更多状态
const searchVisible = ref(false) // 搜索弹窗
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
name: undefined as string | undefined,
code: undefined as string | undefined,
status: -1 as number, // -1 表示全部
})
/** 返回上一页 */
function handleBack() {
uni.navigateBack()
}
/** 查询角色列表 */
async function getList() {
loadMoreState.value = 'loading'
try {
const data = await getRolePage({
...queryParams,
status: queryParams.status === -1 ? undefined : queryParams.status,
})
list.value = [...list.value, ...data.list]
total.value = data.total
loadMoreState.value = list.value.length >= total.value ? 'finished' : 'loading'
} catch {
queryParams.pageNo = queryParams.pageNo > 1 ? queryParams.pageNo - 1 : 1
loadMoreState.value = 'error'
}
}
/** 搜索按钮操作 */
function handleQuery(data?: SearchFormData) {
queryParams.name = data?.name
queryParams.code = data?.code
queryParams.status = data?.status
queryParams.pageNo = 1
list.value = [] // 清空列表
getList()
}
/** 重置按钮操作 */
function handleReset() {
handleQuery()
}
/** 加载更多 */
function loadMore() {
if (loadMoreState.value === 'finished') {
return
}
queryParams.pageNo++
getList()
}
/** 新增角色 */
function handleAdd() {
uni.navigateTo({
url: '/pages-system/role/form/index',
})
}
/** 查看详情 */
function handleDetail(item: Role) {
uni.navigateTo({
url: `/pages-system/role/detail/index?id=${item.id}`,
})
}
/** 触底加载更多 */
onReachBottom(() => {
loadMore()
})
/** 初始化 */
onMounted(() => {
getList()
})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,96 @@
<template>
<wd-popup
v-model="visible"
position="top"
custom-style="border-radius: 0 0 24rpx 24rpx;"
safe-area-inset-top
@close="visible = false"
>
<view class="p-32rpx">
<view class="mb-24rpx text-32rpx text-[#333] font-semibold">
搜索用户
</view>
<view class="mb-24rpx">
<view class="mb-12rpx text-28rpx text-[#666]">
用户名称
</view>
<wd-input
v-model="formData.username"
placeholder="请输入用户名称"
clearable
/>
</view>
<view class="mb-24rpx">
<view class="mb-12rpx text-28rpx text-[#666]">
用户昵称
</view>
<wd-input
v-model="formData.nickname"
placeholder="请输入用户昵称"
clearable
/>
</view>
<view class="w-full flex justify-center gap-24rpx">
<wd-button class="flex-1" plain @click="handleReset">
重置
</wd-button>
<wd-button class="flex-1" type="primary" @click="handleSearch">
搜索
</wd-button>
</view>
</view>
</wd-popup>
</template>
<script lang="ts" setup>
import { computed, reactive, watch } from 'vue'
/** 搜索表单数据 */
export interface SearchFormData {
username?: string
nickname?: string
}
const props = defineProps<{
modelValue: boolean
searchParams?: Partial<SearchFormData> // 初始搜索参数
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'search': [data: SearchFormData]
'reset': []
}>()
const visible = computed({
get: () => props.modelValue,
set: (val: boolean) => emit('update:modelValue', val),
})
const formData = reactive<SearchFormData>({
username: undefined,
nickname: undefined,
})
/** 监听弹窗打开,同步外部参数 */
watch(() => props.modelValue, (val) => {
if (val && props.searchParams) {
formData.username = props.searchParams.username
formData.nickname = props.searchParams.nickname
}
})
/** 搜索 */
function handleSearch() {
visible.value = false
emit('search', { ...formData })
}
/** 重置 */
function handleReset() {
formData.username = undefined
formData.nickname = undefined
visible.value = false
emit('reset')
}
</script>

View File

@@ -0,0 +1,106 @@
<template>
<wd-popup v-model="visible" position="bottom" custom-style="border-radius: 24rpx 24rpx 0 0;" @close="handleClose">
<view class="p-32rpx">
<view class="mb-24rpx flex items-center justify-between">
<text class="text-32rpx text-[#333] font-semibold">重置密码</text>
<wd-icon name="close" size="20px" @click="handleClose" />
</view>
<wd-form ref="formRef" :model="formData" :rules="formRules">
<wd-input
v-model="formData.password"
label="新密码"
label-width="160rpx"
prop="password"
show-password
clearable
placeholder="请输入新密码"
/>
<wd-input
v-model="formData.confirmPassword"
label="确认密码"
label-width="160rpx"
prop="confirmPassword"
show-password
clearable
placeholder="请再次输入新密码"
/>
</wd-form>
<view class="mt-32rpx">
<wd-button type="primary" block :loading="loading" @click="handleConfirm">
确定
</wd-button>
</view>
</view>
</wd-popup>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue'
import { useToast } from 'wot-design-uni'
import { resetUserPassword } from '@/api/system/user'
const props = defineProps<{
modelValue: boolean
userId: number
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'success': []
}>()
const toast = useToast()
const visible = computed({
get: () => props.modelValue,
set: val => emit('update:modelValue', val),
})
const loading = ref(false)
const formRef = ref()
const formData = ref({
password: '',
confirmPassword: '',
})
const formRules = {
password: [{ required: true, message: '请输入新密码' }],
confirmPassword: [
{ required: true, message: '请再次输入新密码' },
{
required: false,
validator: (value: string) => value === formData.value.password,
message: '两次输入的密码不一致',
},
],
}
/** 监听弹窗打开,重置表单 */
watch(
() => props.modelValue,
(val) => {
if (val) {
formData.value = { password: '', confirmPassword: '' }
}
},
)
/** 关闭弹窗 */
function handleClose() {
visible.value = false
}
/** 确认提交 */
async function handleConfirm() {
const { valid } = await formRef.value.validate()
if (!valid) {
return
}
loading.value = true
try {
await resetUserPassword(props.userId, formData.value.password)
toast.success('密码重置成功')
handleClose()
emit('success')
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,80 @@
<template>
<wd-popup v-model="visible" position="bottom" custom-style="border-radius: 24rpx 24rpx 0 0;" @close="handleClose">
<view class="p-32rpx">
<view class="mb-24rpx flex items-center justify-between">
<text class="text-32rpx text-[#333] font-semibold">分配角色</text>
<wd-icon name="close" size="20px" @click="handleClose" />
</view>
<wd-checkbox-group v-model="selectedIds" cell shape="button">
<wd-checkbox v-for="item in roleList" :key="item.id" :model-value="item.id">
{{ item.name }}
</wd-checkbox>
</wd-checkbox-group>
<view class="mt-32rpx">
<wd-button type="primary" block :loading="loading" @click="handleConfirm">
确定
</wd-button>
</view>
</view>
</wd-popup>
</template>
<script lang="ts" setup>
import type { Role } from '@/api/system/role'
import { computed, ref, watch } from 'vue'
import { useToast } from 'wot-design-uni'
import { getSimpleRoleList } from '@/api/system/role'
import { assignUserRole, getUserRoleIds } from '@/api/system/user'
const props = defineProps<{
modelValue: boolean
userId: number
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'success': []
}>()
const toast = useToast()
const visible = computed({
get: () => props.modelValue,
set: val => emit('update:modelValue', val),
})
const loading = ref(false)
const roleList = ref<Role[]>([])
const selectedIds = ref<number[]>([])
/** 监听弹窗打开,加载数据 */
watch(
() => props.modelValue,
async (val) => {
if (val) {
// 加载角色列表
if (roleList.value.length === 0) {
roleList.value = await getSimpleRoleList()
}
// 加载用户已有角色
selectedIds.value = await getUserRoleIds(props.userId)
}
},
)
/** 关闭弹窗 */
function handleClose() {
visible.value = false
}
/** 确认提交 */
async function handleConfirm() {
loading.value = true
try {
await assignUserRole(props.userId, selectedIds.value)
toast.success('角色分配成功')
handleClose()
emit('success')
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,207 @@
<template>
<view class="min-h-screen bg-[#f5f5f5]">
<!-- 顶部导航栏 -->
<wd-navbar
title="用户详情"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 详情内容 -->
<view class="p-24rpx pb-200rpx">
<wd-cell-group custom-class="cell-group" border>
<wd-cell title="头像">
<view v-if="formData?.avatar" class="h-80rpx w-80rpx overflow-hidden rounded-full">
<image :src="formData.avatar" class="h-full w-full" mode="aspectFill" />
</view>
<text v-else>-</text>
</wd-cell>
<wd-cell title="用户昵称" :value="formData?.nickname || '-'" />
<wd-cell title="用户账号" :value="formData?.username || '-'" />
<wd-cell title="手机号码" :value="formData?.mobile || '-'" />
<wd-cell title="邮箱" :value="formData?.email || '-'" />
<wd-cell title="部门" :value="formData?.deptName || '-'" />
<wd-cell title="性别">
<dict-tag :type="DICT_TYPE.SYSTEM_USER_SEX" :value="formData?.sex" />
</wd-cell>
<wd-cell title="状态">
<dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="formData?.sex" />
</wd-cell>
<wd-cell title="备注" :value="formData?.remark || '-'" />
<wd-cell title="最后登录 IP" :value="formData?.loginIp || '-'" />
<wd-cell title="最后登录时间" :value="formatDateTime(formData?.loginDate) || '-'" />
<wd-cell title="创建时间" :value="formatDateTime(formData?.createTime) || '-'" />
</wd-cell-group>
</view>
<!-- 底部操作按钮 -->
<view class="safe-area-inset-bottom fixed bottom-0 left-0 right-0 bg-white p-24rpx">
<view class="w-full flex gap-24rpx">
<wd-button
v-if="hasAccessByCodes(['system:user:update'])"
class="flex-1" type="warning" @click="handleEdit"
>
编辑
</wd-button>
<wd-button
v-if="hasAccessByCodes(['system:user:delete'])"
class="flex-1" type="error" :loading="deleting" @click="handleDelete"
>
删除
</wd-button>
<wd-button
v-if="hasMoreActions"
class="flex-1" type="info" @click="moreActionVisible = true"
>
更多
</wd-button>
</view>
</view>
<!-- 更多操作菜单 -->
<wd-action-sheet v-model="moreActionVisible" :actions="moreActions" @select="handleMoreAction" />
<!-- 重置密码弹窗 -->
<PasswordForm v-model="passwordFormVisible" :user-id="props.id" @success="getDetail" />
<!-- 分配角色弹窗 -->
<RoleAssignForm v-model="roleAssignFormVisible" :user-id="props.id" @success="getDetail" />
</view>
</template>
<script lang="ts" setup>
import type { User } from '@/api/system/user'
import { computed, onMounted, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { deleteUser, getUser, updateUserStatus } from '@/api/system/user'
import { useAccess } from '@/hooks/useAccess'
import { CommonStatusEnum, DICT_TYPE } from '@/utils/constants'
import { formatDateTime } from '@/utils/date'
import PasswordForm from './components/password-form.vue'
import RoleAssignForm from './components/role-assign-form.vue'
const props = defineProps<{
id: number
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const { hasAccessByCodes } = useAccess()
const toast = useToast()
const formData = ref<User>() // 详情数据
const deleting = ref(false) // 删除中
const moreActionVisible = ref(false) // 更多操作菜单
const passwordFormVisible = ref(false) // 密码表单弹窗
const roleAssignFormVisible = ref(false) // 角色分配弹窗
const moreActions = computed(() => {
const actions = []
// 修改状态权限
if (hasAccessByCodes(['system:user:update'])) {
actions.push({ name: formData.value?.status === 1 ? '禁用用户' : '开启用户', value: 'update-status' })
}
// 重置密码权限
if (hasAccessByCodes(['system:user:update-password'])) {
actions.push({ name: '重置密码', value: 'resetPassword' })
}
// 分配角色权限
if (hasAccessByCodes(['system:permission:assign-user-role'])) {
actions.push({ name: '分配角色', value: 'assignRole' })
}
return actions
})
const hasMoreActions = computed(() => moreActions.value.length > 0)
/** 返回上一页 */
function handleBack() {
uni.navigateBack()
}
/** 加载用户详情 */
async function getDetail() {
if (!props.id) {
return
}
formData.value = await getUser(props.id)
}
/** 编辑用户 */
function handleEdit() {
uni.navigateTo({
url: `/pages-system/user/form/index?id=${props.id}`,
})
}
/** 删除用户 */
function handleDelete() {
if (!props.id) {
return
}
uni.showModal({
title: '提示',
content: '确定要删除该用户吗?',
success: async (res) => {
if (!res.confirm) {
return
}
deleting.value = true
try {
await deleteUser(props.id)
toast.success('删除成功')
setTimeout(() => {
handleBack()
}, 500)
} finally {
deleting.value = false
}
},
})
}
/** 更多操作 */
function handleMoreAction({ item }: { item: { value: string } }) {
if (item.value === 'resetPassword') {
passwordFormVisible.value = true
} else if (item.value === 'assignRole') {
roleAssignFormVisible.value = true
} else if (item.value === 'update-status') {
handleUpdateStatus()
}
}
/** 修改用户状态 */
function handleUpdateStatus() {
const isDisable = formData.value.status === CommonStatusEnum.DISABLE
uni.showModal({
title: '提示',
content: isDisable ? '确定要禁用该用户吗?' : '确定要开启该用户吗?',
success: async (res) => {
if (!res.confirm) {
return
}
await updateUserStatus(props.id, formData.value.status === 1 ? 0 : 1)
toast.success(isDisable ? '禁用成功' : '开启成功')
await getDetail()
},
})
}
/** 初始化 */
onMounted(() => {
getDetail()
})
</script>
<style lang="scss" scoped>
:deep(.cell-group) {
border-radius: 12rpx;
overflow: hidden;
box-shadow: 0 3rpx 8rpx rgba(24, 144, 255, 0.06);
}
.safe-area-inset-bottom {
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
}
</style>

View File

@@ -0,0 +1,129 @@
<!-- TODO @芋艿优化看看后续要不要抽成组件 -->
<template>
<wd-col-picker
v-model="selectedValue"
label="归属部门"
label-width="180rpx"
:columns="deptColumns"
:column-change="handleColumnChange"
:display-format="displayFormat"
@confirm="handleConfirm"
/>
</template>
<script lang="ts" setup>
import type { Dept } from '@/api/system/dept'
import { onMounted, ref, watch } from 'vue'
import { getSimpleDeptList } from '@/api/system/dept'
const props = defineProps<{
modelValue?: number
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: number | undefined): void
}>()
const deptList = ref<Dept[]>([])
const deptColumns = ref<any[]>([])
const selectedValue = ref<number[]>([])
/** 监听外部值变化,回显选中值 */
watch(
() => props.modelValue,
(val) => {
if (val && deptList.value.length > 0) {
const path = findDeptPath(val)
selectedValue.value = path
// 构建列数据以支持回显
buildColumnsForPath(path)
}
else {
selectedValue.value = []
}
},
)
/** 加载部门列表 */
async function loadDeptList() {
deptList.value = await getSimpleDeptList()
// 构建第一列数据(顶级部门)
const topDepts = deptList.value.filter(item => item.parentId === 0)
deptColumns.value = [topDepts.map(item => ({ value: item.id, label: item.name }))]
// 如果有初始值,回显
if (props.modelValue) {
const path = findDeptPath(props.modelValue)
selectedValue.value = path
buildColumnsForPath(path)
}
}
/** 查找部门路径 */
function findDeptPath(targetId: number): number[] {
const path: number[] = []
const findPath = (parentId: number, id: number): boolean => {
const items = deptList.value.filter(d => d.parentId === parentId)
for (const item of items) {
if (item.id === id) {
path.push(item.id)
return true
}
if (findPath(item.id, id)) {
path.unshift(item.id)
return true
}
}
return false
}
findPath(0, targetId)
return path
}
/** 根据路径构建列数据 */
function buildColumnsForPath(path: number[]) {
if (path.length === 0) {
return
}
// 第一列已经有了,从第二列开始构建
const columns = [deptColumns.value[0]]
for (let i = 0; i < path.length - 1; i++) {
const parentId = path[i]
const children = deptList.value.filter(item => item.parentId === parentId)
if (children.length > 0) {
columns.push(children.map(item => ({ value: item.id, label: item.name })))
}
}
deptColumns.value = columns
}
/** 列变化 */
function handleColumnChange({ selectedItem, resolve, finish }: any) {
const children = deptList.value.filter(item => item.parentId === selectedItem.value)
if (children.length > 0) {
resolve(children.map(item => ({ value: item.id, label: item.name })))
}
else {
finish()
}
}
/** 格式化显示 */
function displayFormat(selectedItems: any[]) {
return selectedItems.map(item => item.label).join('/')
}
/** 确认选择 */
function handleConfirm({ value }: { value: number[] }) {
if (value && value.length > 0) {
emit('update:modelValue', value[value.length - 1])
}
else {
emit('update:modelValue', undefined)
}
}
/** 初始化 */
onMounted(() => {
loadDeptList()
})
</script>

View File

@@ -0,0 +1,82 @@
<!-- TODO @芋艿优化看看后续要不要抽成组件 -->
<template>
<!-- 岗位选择单元格 -->
<wd-cell title="岗位" title-width="180rpx" is-link @click="popupVisible = true">
<view class="text-left">
{{ displayText }}
</view>
</wd-cell>
<!-- 岗位选择弹窗 -->
<wd-popup v-model="popupVisible" position="bottom" custom-style="border-radius: 24rpx 24rpx 0 0;">
<view class="p-32rpx">
<view class="mb-24rpx flex items-center justify-between">
<text class="text-32rpx text-[#333] font-semibold">选择岗位</text>
<wd-icon name="close" size="20px" @click="popupVisible = false" />
</view>
<wd-checkbox-group v-model="selectedIds" cell shape="button">
<wd-checkbox v-for="item in postList" :key="item.id" :model-value="item.id">
{{ item.name }}
</wd-checkbox>
</wd-checkbox-group>
<view class="mt-32rpx">
<wd-button type="primary" block @click="handleConfirm">
确定
</wd-button>
</view>
</view>
</wd-popup>
</template>
<script lang="ts" setup>
import type { Post } from '@/api/system/post'
import { computed, onMounted, ref, watch } from 'vue'
import { getSimplePostList } from '@/api/system/post'
const props = defineProps<{
modelValue?: number[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: number[]): void
}>()
const popupVisible = ref(false)
const postList = ref<Post[]>([])
const selectedIds = ref<number[]>([])
const displayText = computed(() => {
if (!selectedIds.value || selectedIds.value.length === 0) {
return ''
}
return postList.value
.filter(item => selectedIds.value.includes(item.id))
.map(item => item.name)
.join('、')
}) // 显示文本
/** 监听外部值变化 */
watch(
() => props.modelValue,
(val) => {
selectedIds.value = val || []
},
{ immediate: true },
)
/** 加载岗位列表 */
async function loadPostList() {
postList.value = await getSimplePostList()
}
/** 确认选择 */
function handleConfirm() {
emit('update:modelValue', selectedIds.value)
popupVisible.value = false
}
/** 初始化 */
onMounted(() => {
loadPostList()
})
</script>

View File

@@ -0,0 +1,201 @@
<template>
<view class="min-h-screen bg-[#f5f5f5]">
<!-- 顶部导航栏 -->
<wd-navbar
:title="getTitle"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 表单区域 -->
<view class="p-24rpx">
<wd-form ref="formRef" :model="formData" :rules="formRules">
<wd-cell-group custom-class="cell-group" border>
<wd-input
v-model="formData.username"
label="用户名称"
label-width="180rpx"
prop="username"
clearable
placeholder="请输入用户名称"
/>
<wd-input
v-if="!props.id"
v-model="formData.password"
label="用户密码"
label-width="180rpx"
prop="password"
show-password
clearable
placeholder="请输入用户密码"
/>
<wd-input
v-model="formData.nickname"
label="用户昵称"
label-width="180rpx"
prop="nickname"
clearable
placeholder="请输入用户昵称"
/>
<DeptPicker v-model="formData.deptId" />
<PostPicker v-model="formData.postIds" />
<wd-input
v-model="formData.email"
label="邮箱"
label-width="180rpx"
prop="email"
clearable
placeholder="请输入邮箱"
/>
<wd-input
v-model="formData.mobile"
label="手机号码"
label-width="180rpx"
prop="mobile"
clearable
placeholder="请输入手机号码"
/>
<wd-cell title="性别" title-width="180rpx" center prop="sex">
<wd-radio-group v-model="formData.sex" shape="button" size="medium">
<wd-radio v-for="dict in getIntDictOptions(DICT_TYPE.SYSTEM_USER_SEX)" :key="dict.value" :value="dict.value">
{{ dict.label }}
</wd-radio>
</wd-radio-group>
</wd-cell>
<wd-cell title="状态" title-width="180rpx" center prop="status">
<wd-switch
v-model="formData.status"
:active-value="CommonStatusEnum.ENABLE"
:inactive-value="CommonStatusEnum.DISABLE"
/>
</wd-cell>
<wd-textarea
v-model="formData.remark"
label="备注"
label-width="180rpx"
placeholder="请输入备注"
:maxlength="200"
show-word-limit
clearable
/>
</wd-cell-group>
</wd-form>
</view>
<!-- 底部保存按钮 -->
<view class="safe-area-inset-bottom fixed bottom-0 left-0 right-0 bg-white p-24rpx">
<wd-button
type="primary"
block
:loading="formLoading"
@click="handleSubmit"
>
保存
</wd-button>
</view>
</view>
</template>
<script lang="ts" setup>
import type { User } from '@/api/system/user'
import { computed, onMounted, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { createUser, getUser, updateUser } from '@/api/system/user'
import { getIntDictOptions } from '@/hooks/useDict'
import { CommonStatusEnum, DICT_TYPE } from '@/utils/constants'
import { isEmail, isMobile } from '@/utils/validator'
import DeptPicker from './components/dept-picker.vue'
import PostPicker from './components/post-picker.vue'
const props = defineProps<{
id?: number
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const toast = useToast()
const getTitle = computed(() => props.id ? '编辑用户' : '新增用户')
const formLoading = ref(false) // 提交中状态
const formData = ref<User>({
id: undefined,
username: '',
nickname: '',
password: '',
mobile: '',
email: '',
sex: undefined,
deptId: undefined,
postIds: [],
status: CommonStatusEnum.ENABLE,
remark: '',
})
const formRules = {
username: [{ required: true, message: '用户名称不能为空' }],
password: [{ required: true, message: '用户密码不能为空' }],
nickname: [{ required: true, message: '用户昵称不能为空' }],
email: [{ required: false, validator: (value: string) => !value || isEmail(value), message: '请输入正确的邮箱地址' }],
mobile: [{ required: false, validator: (value: string) => !value || isMobile(value), message: '请输入正确的手机号码' }],
sex: [{ required: true, message: '性别不能为空' }],
status: [{ required: true, message: '状态不能为空' }],
}
const formRef = ref()
/** 返回上一页 */
function handleBack() {
uni.navigateBack()
}
/** 加载用户详情 */
async function getDetail() {
if (!props.id) {
return
}
formData.value = await getUser(props.id)
}
/** 提交表单 */
async function handleSubmit() {
const { valid } = await formRef.value.validate()
if (!valid) {
return
}
formLoading.value = true
try {
if (props.id) {
await updateUser(formData.value)
toast.success('修改成功')
} else {
await createUser(formData.value)
toast.success('新增成功')
}
setTimeout(() => {
handleBack()
}, 500)
} finally {
formLoading.value = false
}
}
/** 初始化 */
onMounted(() => {
getDetail()
})
</script>
<style lang="scss" scoped>
:deep(.cell-group) {
border-radius: 12rpx;
overflow: hidden;
box-shadow: 0 3rpx 8rpx rgba(24, 144, 255, 0.06);
}
.safe-area-inset-bottom {
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
}
</style>

View File

@@ -0,0 +1,183 @@
<template>
<!-- TODO @芋艿优化全局样式后续要全局样式么 -->
<view class="min-h-screen bg-[#f5f5f5]">
<!-- 顶部导航栏 -->
<wd-navbar
title="用户管理"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
>
<template #right>
<view class="flex items-center" @click="searchVisible = !searchVisible">
<wd-icon name="search" size="20px" />
</view>
</template>
</wd-navbar>
<!-- 用户列表 -->
<view class="p-24rpx">
<view
v-for="item in list"
:key="item.id"
class="mb-24rpx overflow-hidden rounded-12rpx bg-white shadow-sm"
@click="handleDetail(item)"
>
<view class="relative p-24rpx">
<view class="absolute right-24rpx top-24rpx">
<dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="item.status" />
</view>
<view class="flex items-center gap-16rpx">
<view
v-if="item.avatar"
class="h-80rpx w-80rpx overflow-hidden rounded-full"
>
<image :src="item.avatar" class="h-full w-full" mode="aspectFill" />
</view>
<view
v-else
class="h-80rpx w-80rpx flex items-center justify-center rounded-full bg-[#1890ff] text-32rpx text-white"
>
{{ item.nickname?.charAt(0) || item.username?.charAt(0) }}
</view>
<view>
<view class="text-32rpx text-[#333] font-semibold">
{{ item.nickname || item.username }}
</view>
<view class="text-24rpx text-[#999]">
{{ item.deptName || '未分配部门' }}
</view>
</view>
</view>
</view>
</view>
<!-- 加载更多 -->
<view v-if="loadMoreState !== 'loading' && list.length === 0" class="py-100rpx text-center">
<wd-status-tip image="content" tip="暂无用户数据" />
</view>
<wd-loadmore
v-if="list.length > 0"
:state="loadMoreState"
@reload="loadMore"
/>
</view>
<!-- 搜索弹窗 -->
<SearchForm
v-model="searchVisible"
:search-params="queryParams"
@search="handleQuery"
@reset="handleReset"
/>
<!-- 新增按钮 -->
<!-- TODO @芋艿优化全局样式后续要全局样式么 -->
<view
v-if="hasAccessByCodes(['system:user:create'])"
class="fixed bottom-100rpx right-32rpx z-10 h-100rpx w-100rpx flex items-center justify-center rounded-full bg-[#1890ff] shadow-lg"
@click="handleAdd"
>
<wd-icon name="add" size="24px" color="#fff" />
</view>
</view>
</template>
<script lang="ts" setup>
import type { SearchFormData } from './components/search-form.vue'
import type { User } from '@/api/system/user'
import type { LoadMoreState } from '@/http/types'
import { onReachBottom } from '@dcloudio/uni-app'
import { onMounted, reactive, ref } from 'vue'
import { getUserPage } from '@/api/system/user'
import { useAccess } from '@/hooks/useAccess'
import { DICT_TYPE } from '@/utils/constants'
import SearchForm from './components/search-form.vue'
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const { hasAccessByCodes } = useAccess()
const total = ref(0) // 列表的总页数
const list = ref<User[]>([]) // 列表的数据
const loadMoreState = ref<LoadMoreState>('loading') // 加载更多状态
const searchVisible = ref(false) // 搜索弹窗
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
username: undefined as string | undefined,
nickname: undefined as string | undefined,
})
/** 返回上一页 */
function handleBack() {
uni.navigateBack()
}
/** 查询用户列表 */
async function getList() {
loadMoreState.value = 'loading'
try {
const data = await getUserPage(queryParams)
list.value = [...list.value, ...data.list]
total.value = data.total
loadMoreState.value = list.value.length >= total.value ? 'finished' : 'loading'
} catch {
queryParams.pageNo = queryParams.pageNo > 1 ? queryParams.pageNo - 1 : 1
loadMoreState.value = 'error'
}
}
/** 搜索按钮操作 */
function handleQuery(data?: SearchFormData) {
queryParams.username = data?.username
queryParams.nickname = data?.nickname
queryParams.pageNo = 1
list.value = [] // 清空列表
getList()
}
/** 重置按钮操作 */
function handleReset() {
getList()
}
/** 加载更多 */
function loadMore() {
if (loadMoreState.value === 'finished') {
return
}
queryParams.pageNo++
getList()
}
/** 新增用户 */
function handleAdd() {
uni.navigateTo({
url: '/pages-system/user/form/index',
})
}
/** 查看详情 */
function handleDetail(item: User) {
uni.navigateTo({
url: `/pages-system/user/detail/index?id=${item.id}`,
})
}
/** 触底加载更多 */
onReachBottom(() => {
loadMore()
})
/** 初始化 */
onMounted(() => {
getList()
})
</script>
<style lang="scss" scoped>
</style>