This commit is contained in:
ygx
2026-03-08 22:41:42 +08:00
parent f7bbb5ee46
commit 180d980514
23 changed files with 1392 additions and 850 deletions

View File

@@ -46,7 +46,7 @@
<script lang="ts" setup>
import type { LoginData } from '@/api/types'
import useLoading from '@/hooks/loading'
import { useUserStore } from '@/store'
import { useUserStore, useAppStore } from '@/store'
import { Message } from '@arco-design/web-vue'
import { ValidatedError } from '@arco-design/web-vue/es/form/interface'
import { useStorage } from '@vueuse/core'
@@ -59,6 +59,7 @@ const { t } = useI18n()
const errorMessage = ref('')
const { loading, setLoading } = useLoading()
const userStore = useUserStore()
const appStore = useAppStore()
const loginConfig = useStorage('login-config', {
rememberPassword: true,
@@ -76,12 +77,10 @@ const handleSubmit = async ({ errors, values }: { errors: Record<string, Validat
setLoading(true)
try {
await userStore.login(values as LoginData)
await appStore.fetchServerMenuConfig()
const { redirect, ...othersQuery } = router.currentRoute.value.query
router.push({
name: (redirect as string) || 'Workplace',
query: {
...othersQuery,
},
path: '/overview'
})
Message.success(t('login.form.login.success'))
const { rememberPassword } = loginConfig.value

View File

@@ -0,0 +1,5 @@
<template>
<div class="menu-container">
帮助中心
</div>
</template>

View File

@@ -0,0 +1,5 @@
<template>
<div class="pmn-container">
overview
</div>
</template>

View File

@@ -0,0 +1,138 @@
<template>
<a-modal
v-model:visible="visible"
title="修改用户密码"
:mask-closable="false"
:width="500"
@cancel="handleClose"
>
<a-form
ref="formRef"
:model="formData"
:rules="rules"
layout="vertical"
>
<a-form-item label="用户账号">
<a-input :model-value="user?.account" disabled />
</a-form-item>
<a-form-item field="password" label="新密码">
<a-input-password
v-model="formData.password"
placeholder="请输入密码"
allow-clear
/>
</a-form-item>
<a-form-item field="confirmPassword" label="确认新密码">
<a-input-password
v-model="formData.confirmPassword"
placeholder="请确认密码"
allow-clear
/>
</a-form-item>
</a-form>
<template #footer>
<a-space>
<a-button @click="handleClose">取消</a-button>
<a-button type="primary" :loading="loading" @click="handleSubmit">
确定
</a-button>
</a-space>
</template>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, watch } from 'vue'
import { Message } from '@arco-design/web-vue'
import { adminResetUserPassword } from '@/api/module/user'
import type { UserItem } from '@/api/types'
const props = defineProps<{
user: UserItem | null
}>()
const emit = defineEmits<{
(e: 'update:visible', visible: boolean): void
(e: 'success'): void
}>()
const visible = computed({
get: () => !!props.user,
set: (val) => emit('update:visible', val),
})
const formRef = ref()
const loading = ref(false)
const formData = reactive({
password: '',
confirmPassword: '',
})
const rules = {
password: [
{ required: true, message: '请输入密码' },
{ minLength: 6, message: '密码至少6个字符' },
],
confirmPassword: [
{ required: true, message: '请确认密码' },
{
validator: (value: string, callback: (error?: string) => void) => {
if (value !== formData.password) {
callback('两次密码输入不一致')
} else {
callback()
}
},
},
],
}
// 重置表单
watch(
() => props.user,
(val) => {
if (val) {
formData.password = ''
formData.confirmPassword = ''
}
}
)
const handleClose = () => {
formRef.value?.resetFields()
emit('update:visible', false)
}
const handleSubmit = async () => {
try {
const valid = await formRef.value?.validate()
if (valid) return
loading.value = true
const res = await adminResetUserPassword({
id: props.user!.id!,
password: formData.password,
})
if (res.code === 0) {
Message.success(res.message || '密码修改成功')
handleClose()
emit('success')
}
} catch (error) {
console.error('修改密码失败', error)
} finally {
loading.value = false
}
}
</script>
<script lang="ts">
export default {
name: 'PasswordChangeDialog',
}
</script>

View File

@@ -0,0 +1,172 @@
<template>
<a-modal
v-model:visible="visible"
title="设置权限"
:mask-closable="false"
:width="600"
@cancel="handleClose"
>
<a-spin :loading="loading" style="width: 100%">
<div class="permission-header">
<span>用户账号: {{ user?.account }}</span>
</div>
<div class="permission-content">
<PermissionTreeItem
v-if="permissionTree.length > 0"
:items="permissionTree"
:selected-permissions="selectedPermissions"
:expanded-keys="expandedKeys"
@change="handlePermissionChange"
/>
<a-empty v-else description="暂无权限数据" />
</div>
</a-spin>
<template #footer>
<a-space>
<a-button @click="handleClose">取消</a-button>
<a-button type="primary" :loading="submitting" @click="handleSubmit">
确定
</a-button>
</a-space>
</template>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, computed, watch } from 'vue'
import { Message } from '@arco-design/web-vue'
import PermissionTreeItem from './PermissionTree.vue'
import {
getUserPmnTree,
userPmn,
userModifyPmn,
} from '@/api/module/user'
import type { UserItem } from '@/api/types'
import { useAppStore } from '@/store'
const props = defineProps<{
user: UserItem | null
}>()
const emit = defineEmits<{
(e: 'update:visible', visible: boolean): void
(e: 'success'): void
}>()
const appStore = useAppStore()
const visible = computed({
get: () => !!props.user,
set: (val) => emit('update:visible', val),
})
const loading = ref(false)
const submitting = ref(false)
const permissionTree = ref<any[]>([])
const selectedPermissions = ref<number[]>([])
const originalPermissions = ref<number[]>([])
const expandedKeys = ref<Set<number>>(new Set())
// 获取权限数据
const fetchPermissions = async () => {
if (!props.user?.id) return
loading.value = true
try {
// 并行获取权限树和用户当前权限
const [treeRes, pmnRes] = await Promise.all([
getUserPmnTree({ id: props.user.id, workspace: import.meta.env.VITE_APP_WORKSPACE || '' }),
userPmn({
id: props.user.id,
workspace: import.meta.env.VITE_APP_WORKSPACE || '',
}),
])
console.log('treeRes', treeRes)
if (treeRes.details) {
permissionTree.value = treeRes.data
// 默认展开第一层
treeRes.data.forEach((item: any) => {
expandedKeys.value.add(item.id)
})
}
if (pmnRes.data) {
selectedPermissions.value = pmnRes.data.map((item: any) => item.pmn_id || item)
originalPermissions.value = [...selectedPermissions.value]
}
} catch (error) {
console.error('获取权限数据失败', error)
} finally {
loading.value = false
}
}
// 监听 user 变化
watch(
() => props.user,
(val) => {
if (val?.id) {
fetchPermissions()
} else {
permissionTree.value = []
selectedPermissions.value = []
}
}
)
// 处理权限变化
const handlePermissionChange = (permissions: number[]) => {
selectedPermissions.value = permissions
}
const handleClose = () => {
emit('update:visible', false)
}
const handleSubmit = async () => {
if (!props.user?.id) return
submitting.value = true
try {
const res = await userModifyPmn({
id: props.user.id,
list: selectedPermissions.value,
})
if (res.code === 0) {
Message.success('权限设置成功')
handleClose()
emit('success')
}
} catch (error) {
console.error('设置权限失败', error)
} finally {
submitting.value = false
}
}
</script>
<script lang="ts">
export default {
name: 'PermissionSettingDialog',
}
</script>
<style scoped lang="less">
.permission-header {
margin-bottom: 16px;
padding: 12px;
background-color: var(--color-fill-1);
border-radius: 4px;
}
.permission-content {
max-height: 400px;
overflow-y: auto;
padding: 8px;
border: 1px solid var(--color-border);
border-radius: 4px;
}
</style>

View File

@@ -0,0 +1,241 @@
<template>
<div class="permission-tree">
<div v-for="item in items" :key="item.id" class="tree-node">
<div class="node-content" :style="{ paddingLeft: `${level * 24}px` }">
<!-- 展开/折叠按钮 -->
<span
v-if="hasChildren(item)"
class="expand-icon"
@click="toggleExpand(item.id)"
>
<icon-right v-if="!isExpanded(item.id)" />
<icon-down v-else />
</span>
<span v-else class="expand-icon-placeholder" />
<!-- 复选框 -->
<a-checkbox
:model-value="isChecked(item.id)"
:indeterminate="isIndeterminate(item)"
@change="handleCheck(item.id, $event)"
/>
<!-- 名称 -->
<span class="node-name">{{ item.name }}</span>
</div>
<!-- 子节点 -->
<div v-if="hasChildren(item) && isExpanded(item.id)" class="children">
<PermissionTree
:items="getChildren(item)"
:selected-permissions="selectedPermissions"
:level="level + 1"
:expanded-keys="expandedKeys"
@change="handleChange"
/>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue'
export interface PermissionItem {
id: number
name: string
children?: PermissionItem[]
}
const props = withDefaults(
defineProps<{
items: PermissionItem[]
selectedPermissions: number[]
level?: number
expandedKeys?: Set<number>
}>(),
{
level: 0,
expandedKeys: () => new Set(),
}
)
const emit = defineEmits<{
(e: 'change', permissions: number[]): void
}>()
// 本地展开状态
const localExpandedKeys = ref<Set<number>>(new Set())
const expandedKeys = computed(() => props.expandedKeys.size > 0 ? props.expandedKeys : localExpandedKeys.value)
// 检查是否有子节点
const hasChildren = (item: PermissionItem) => {
return item.children && item.children.length > 0
}
// 获取子节点
const getChildren = (item: PermissionItem): PermissionItem[] => {
return item.children || []
}
// 检查是否展开
const isExpanded = (id: number) => {
return expandedKeys.value.has(id)
}
// 切换展开状态
const toggleExpand = (id: number) => {
if (expandedKeys.value.has(id)) {
expandedKeys.value.delete(id)
} else {
expandedKeys.value.add(id)
}
}
// 检查是否选中
const isChecked = (id: number) => {
return props.selectedPermissions.includes(id)
}
// 检查是否半选状态
const isIndeterminate = (item: PermissionItem) => {
if (!hasChildren(item)) return false
const childIds = getAllChildIds(item)
const selectedCount = childIds.filter((id) => props.selectedPermissions.includes(id)).length
return selectedCount > 0 && selectedCount < childIds.length
}
// 递归获取所有子节点ID
const getAllChildIds = (item: PermissionItem): number[] => {
const ids: number[] = []
if (item.children) {
item.children.forEach((child) => {
ids.push(child.id)
ids.push(...getAllChildIds(child))
})
}
return ids
}
// 根据ID查找权限项
const findItemById = (items: PermissionItem[], id: number): PermissionItem | null => {
for (const item of items) {
if (item.id === id) return item
if (item.children) {
const found = findItemById(item.children, id)
if (found) return found
}
}
return null
}
// 获取父节点ID
const getParentIds = (
items: PermissionItem[],
targetId: number,
parentIds: number[] = []
): number[] => {
for (const item of items) {
if (item.children) {
if (item.children.some((child) => child.id === targetId)) {
return [...parentIds, item.id]
}
const result = getParentIds(item.children, targetId, [...parentIds, item.id])
if (result.length > 0) return result
}
}
return []
}
// 处理选中状态变化
const handleCheck = (id: number, checked: boolean) => {
let newPermissions = [...props.selectedPermissions]
if (checked) {
// 添加当前权限
if (!newPermissions.includes(id)) {
newPermissions.push(id)
}
// 添加所有子权限
const item = findItemById(props.items, id)
if (item) {
const childIds = getAllChildIds(item)
childIds.forEach((childId) => {
if (!newPermissions.includes(childId)) {
newPermissions.push(childId)
}
})
}
} else {
// 移除当前权限
newPermissions = newPermissions.filter((pId) => pId !== id)
// 移除所有子权限
const item = findItemById(props.items, id)
if (item) {
const childIds = getAllChildIds(item)
newPermissions = newPermissions.filter((pId) => !childIds.includes(pId))
}
// 移除父权限
const parentIds = getParentIds(props.items, id)
parentIds.forEach((parentId) => {
newPermissions = newPermissions.filter((pId) => pId !== parentId)
})
}
emit('change', newPermissions)
}
// 处理子组件变化
const handleChange = (permissions: number[]) => {
emit('change', permissions)
}
</script>
<script lang="ts">
export default {
name: 'PermissionTree',
}
</script>
<style scoped lang="less">
.permission-tree {
.tree-node {
.node-content {
display: flex;
align-items: center;
padding: 4px 0;
.expand-icon {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: var(--color-text-2);
transition: color 0.2s;
&:hover {
color: rgb(var(--primary-6));
}
}
.expand-icon-placeholder {
width: 20px;
height: 20px;
}
.node-name {
margin-left: 8px;
}
}
.children {
// 子节点样式
}
}
}
</style>

View File

@@ -0,0 +1,409 @@
<template>
<div class="container">
<Breadcrumb :items="['用户管理']" />
<SearchTable
title="用户管理"
:form-model="searchForm"
:form-items="formItems"
:data="tableData"
:columns="columns"
:loading="loading"
:pagination="pagination"
@search="handleSearch"
@reset="handleReset"
@page-change="handlePageChange"
@refresh="fetchData"
>
<!-- 工具栏左侧 - 新增按钮 -->
<template #toolbar-left>
<a-button type="primary" @click="handleAdd">
<template #icon><icon-plus /></template>
新增用户
</a-button>
</template>
<!-- 操作列 -->
<template #operation="{ record }">
<a-space>
<a-button type="text" size="small" @click="handleEdit(record)">
编辑
</a-button>
<a-button type="text" size="small" @click="handleSetPermission(record)">
权限
</a-button>
<a-button type="text" size="small" @click="handleChangePassword(record)">
改密
</a-button>
<a-button type="text" size="small" status="danger" @click="handleDelete(record)">
删除
</a-button>
</a-space>
</template>
<!-- 状态列 -->
<template #status="{ record }">
<a-tag :color="record.status === 1 ? 'green' : 'red'">
{{ record.status === 1 ? '启用' : '禁用' }}
</a-tag>
</template>
</SearchTable>
<!-- 新增/编辑用户弹窗 -->
<a-modal
v-model:visible="modalVisible"
:title="modalTitle"
:mask-closable="false"
:width="520"
@ok="handleModalOk"
@cancel="handleModalCancel"
>
<a-form
ref="formRef"
:model="formData"
:rules="formRules"
:label-col-props="{ span: 6 }"
:wrapper-col-props="{ span: 16 }"
>
<a-form-item field="account" label="账号" validate-trigger="blur">
<a-input
v-model="formData.account"
placeholder="请输入账号"
:disabled="isEdit"
/>
</a-form-item>
<a-form-item field="name" label="姓名" validate-trigger="blur">
<a-input v-model="formData.name" placeholder="请输入姓名" />
</a-form-item>
<a-form-item field="email" label="邮箱" validate-trigger="blur">
<a-input v-model="formData.email" placeholder="请输入邮箱" />
</a-form-item>
<a-form-item field="phone" label="手机号" validate-trigger="blur">
<a-input v-model="formData.phone" placeholder="请输入手机号" />
</a-form-item>
<a-form-item field="status" label="状态">
<a-select v-model="formData.status" placeholder="请选择状态">
<a-option :value="1">正常</a-option>
<a-option :value="-1">禁用</a-option>
</a-select>
</a-form-item>
</a-form>
</a-modal>
<!-- 删除确认对话框 -->
<a-modal
v-model:visible="deleteConfirmVisible"
title="删除确认"
@ok="handleConfirmDelete"
@cancel="deleteConfirmVisible = false"
>
<p>确定要删除用户 "{{ userToDelete?.name || userToDelete?.account }}" </p>
</a-modal>
<!-- 修改密码弹窗 -->
<PasswordChangeDialog
:user="passwordChangeUser"
@update:visible="passwordChangeUser = null"
@success="fetchData"
/>
<!-- 权限设置弹窗 -->
<PermissionSettingDialog
:user="permissionUser"
@update:visible="handlePermissionDialogClose"
@success="fetchData"
/>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { Message } from '@arco-design/web-vue'
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
import SearchTable from '@/components/search-table/index.vue'
import type { FormItem } from '@/components/search-form/types'
import type { UserItem } from '@/api/types'
import {
fetchUserList,
createUser,
modifyUser,
deleteUser,
} from '@/api/module/user'
import PasswordChangeDialog from './components/PasswordChangeDialog.vue'
import PermissionSettingDialog from './components/PermissionSettingDialog.vue'
// 搜索表单配置
const formItems: FormItem[] = [
{
field: 'keyword',
label: '关键词',
type: 'input',
placeholder: '请输入用户名/昵称/手机号',
span: 8,
},
]
// 表格列配置
const columns: TableColumnData[] = [
{
title: 'ID',
dataIndex: 'id',
width: 80,
},
{
title: '账号',
dataIndex: 'account',
width: 120,
},
{
title: '姓名',
dataIndex: 'name',
width: 120,
},
{
title: '邮箱',
dataIndex: 'email',
width: 180,
},
{
title: '手机号',
dataIndex: 'phone',
width: 140,
},
{
title: '状态',
dataIndex: 'status',
slotName: 'status',
width: 100,
},
{
title: '创建时间',
dataIndex: 'created_at',
width: 180,
},
{
title: '操作',
slotName: 'operation',
width: 150,
fixed: 'right',
},
]
// 搜索表单数据
const searchForm = reactive({
keyword: '',
})
// 分页配置
const pagination = reactive({
current: 1,
pageSize: 20,
total: 0,
})
// 表格数据
const tableData = ref<UserItem[]>([])
const loading = ref(false)
// 弹窗相关
const modalVisible = ref(false)
const isEdit = ref(false)
const formRef = ref()
const formData = reactive<UserItem>({
id: undefined,
account: '',
name: '',
email: '',
phone: '',
status: 1,
})
// 表单验证规则
const formRules = {
account: [
{ required: true, message: '请输入账号' },
{ minLength: 3, message: '账号至少3个字符' },
],
name: [
{ required: true, message: '请输入姓名' },
],
email: [
{ match: /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/, message: '请输入正确的邮箱格式' },
],
phone: [
{ match: /^1[3-9]\d{9}$/, message: '请输入正确的手机号' },
],
}
// 删除确认
const deleteConfirmVisible = ref(false)
const userToDelete = ref<UserItem | null>(null)
// 修改密码
const passwordChangeUser = ref<UserItem | null>(null)
// 权限设置
const permissionUser = ref<UserItem | null>(null)
// 关闭权限设置弹窗
const handlePermissionDialogClose = (visible: boolean) => {
if (!visible) {
permissionUser.value = null
}
}
// 弹窗标题
const modalTitle = computed(() => (isEdit.value ? '编辑用户' : '新增用户'))
// 获取数据
const fetchData = async () => {
try {
loading.value = true
const res = await fetchUserList({
page: pagination.current,
size: pagination.pageSize,
keyword: searchForm.keyword || undefined,
})
if (res?.code === 0) {
tableData.value = res.details?.data || []
pagination.total = res.details?.total || 0
}
} catch (error) {
console.error('Failed to fetch user list:', error)
} finally {
loading.value = false
}
}
// 搜索
const handleSearch = () => {
pagination.current = 1
fetchData()
}
// 重置
const handleReset = () => {
searchForm.keyword = ''
pagination.current = 1
fetchData()
}
// 分页变化
const handlePageChange = (current: number) => {
pagination.current = current
fetchData()
}
// 新增用户
const handleAdd = () => {
isEdit.value = false
resetFormData()
modalVisible.value = true
}
// 编辑用户
const handleEdit = (record: UserItem) => {
isEdit.value = true
Object.assign(formData, {
id: record.id,
account: record.account,
name: record.name,
email: record.email,
phone: record.phone,
status: record.status ?? 1,
})
modalVisible.value = true
}
// 删除用户
const handleDelete = (record: UserItem) => {
userToDelete.value = record
deleteConfirmVisible.value = true
}
// 修改密码
const handleChangePassword = (record: UserItem) => {
passwordChangeUser.value = record
}
// 权限设置
const handleSetPermission = (record: UserItem) => {
permissionUser.value = record
}
// 确认删除
const handleConfirmDelete = async () => {
if (!userToDelete.value?.id) return
try {
loading.value = true
await deleteUser({ id: userToDelete.value.id })
Message.success('删除成功')
deleteConfirmVisible.value = false
userToDelete.value = null
await fetchData()
} catch (error) {
console.error('Failed to delete user:', error)
} finally {
loading.value = false
}
}
// 弹窗确认
const handleModalOk = async () => {
try {
const valid = await formRef.value?.validate()
if (valid) return
loading.value = true
if (isEdit.value && formData.id) {
await modifyUser(formData)
Message.success('修改成功')
} else {
await createUser(formData)
Message.success('创建成功')
}
modalVisible.value = false
await fetchData()
} catch (error) {
console.error('Failed to save user:', error)
} finally {
loading.value = false
}
}
// 弹窗取消
const handleModalCancel = () => {
modalVisible.value = false
formRef.value?.resetFields()
}
// 重置表单数据
const resetFormData = () => {
Object.assign(formData, {
id: undefined,
account: '',
name: '',
email: '',
phone: '',
status: 1,
})
}
// 初始化
onMounted(() => {
fetchData()
})
</script>
<script lang="ts">
export default {
name: 'AccountManagement',
}
</script>
<style scoped lang="less">
.container {
padding: 0 20px 20px 20px;
}
</style>

View File

@@ -43,19 +43,34 @@
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="component" label="组件路径">
<a-input v-model="formData.component" placeholder="请输入组件路径" />
</a-form-item>
</a-col>
<!-- 根菜单才显示图标选择 -->
<a-col v-if="!parentId" :span="12">
<a-form-item field="menu_icon" label="菜单图标" :rules="[{ required: true, message: '请选择菜单图标' }]">
<a-input
v-model="formData.menu_icon"
placeholder="点击选择图标"
readonly
@click="iconPickerVisible = true"
>
<template #prefix>
<component v-if="formData.menu_icon" :is="getIconComponent(formData.menu_icon)" :size="18" />
</template>
</a-input>
<a-form-item field="menu_icon" label="菜单图标" style="width: 100%">
<a-input-group style="width: 100%;">
<a-input
v-model="formData.menu_icon"
placeholder="点击选择图标"
readonly
style="cursor: pointer"
@click="iconPickerVisible = true"
>
<template #prefix>
<component v-if="formData.menu_icon" :is="getIconComponent(formData.menu_icon)" :size="18" />
</template>
</a-input>
<a-button
v-if="formData.menu_icon"
@click="clearIcon"
>
<template #icon><icon-close /></template>
</a-button>
</a-input-group>
</a-form-item>
</a-col>
@@ -66,16 +81,29 @@
</a-col>
</a-row>
<!-- 路由配置 -->
<a-divider orientation="left">路由配置</a-divider>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="is_new_tab">
<a-switch v-model="formData.is_new_tab" />
<span class="switch-label">在新窗口打开</span>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="is_web_page">
<a-switch v-model="formData.is_web_page" />
<span class="switch-label">嵌入网页</span>
</a-form-item>
</a-col>
</a-row>
<!-- 网页嵌入配置 -->
<a-divider orientation="left">网页嵌入配置</a-divider>
<a-row :gutter="16">
<a-col :span="24">
<a-form-item field="is_web_page">
<a-switch v-model="formData.is_web_page" />
<span class="switch-label">是否为嵌入网页</span>
</a-form-item>
</a-col>
<a-col v-if="formData.is_web_page" :span="24">
<a-form-item field="web_url" label="网页地址">
@@ -154,12 +182,14 @@ const formData = ref<MenuRouteRequest>({
description: '',
menu_icon: '',
menu_path: '',
component: '',
parent_id: undefined,
title: '',
title_en: '',
type: 1,
is_web_page: false,
web_url: '',
is_new_tab: false,
// 编辑时需要的额外字段
id: undefined,
identity: undefined,
@@ -194,12 +224,14 @@ const resetForm = () => {
description: '',
menu_icon: '',
menu_path: '',
component: '',
parent_id: props.parentId || undefined,
title: '',
title_en: '',
type: 1,
is_web_page: false,
web_url: '',
is_new_tab: false,
id: undefined,
identity: undefined,
app_id: undefined,
@@ -227,12 +259,14 @@ watch(
description: initialValues.description || '',
menu_icon: initialValues.menu_icon || '',
menu_path: initialValues.menu_path || '',
component: initialValues.component || '',
parent_id: initialValues.parent_id,
title: initialValues.title || '',
title_en: initialValues.title_en || '',
type: initialValues.type || 1,
is_web_page: initialValues.is_web_page || false,
web_url: initialValues.web_url || '',
is_new_tab: initialValues.is_new_tab || false,
}
}
},
@@ -253,6 +287,11 @@ const selectIcon = (icon: string) => {
iconSearch.value = ''
}
// 清空图标
const clearIcon = () => {
formData.value.menu_icon = ''
}
// 提交表单
const handleSubmit = async () => {
try {
@@ -296,10 +335,11 @@ export default {
.icon-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
grid-template-columns: repeat(5, 1fr);
gap: 8px;
max-height: 400px;
overflow-y: auto;
overflow-x: hidden;
}
.icon-item {

View File

@@ -16,66 +16,29 @@
@drag-end="$emit('drag-end')"
@drop="$emit('drop', $event)"
/>
<!-- 根级别拖放区域 -->
<div
v-if="draggingNode"
class="root-drop-zone"
:class="{ 'drag-over': isDragOver }"
@dragover.prevent="handleDragOver"
@dragleave="handleDragLeave"
@drop.prevent="handleDropToRoot"
>
<icon-plus-circle /> 拖放到此处设为根级菜单
</div>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import MenuTreeItem from './MenuTreeItem.vue'
import type { MenuNode } from '../types'
const props = defineProps<{
defineProps<{
nodes: MenuNode[]
expandedKeys: Set<number>
selectedKey: number | null
draggingNode?: MenuNode | null
}>()
const emit = defineEmits<{
defineEmits<{
(e: 'toggle', nodeId: number): void
(e: 'select', node: MenuNode): void
(e: 'add-child', parentId: number): void
(e: 'delete', node: MenuNode): void
(e: 'drag-start', node: MenuNode): void
(e: 'drag-end'): void
(e: 'drop', data: { dragNode: MenuNode; targetNode: MenuNode | null; position: 'before' | 'after' | 'inside' | 'root' }): void
(e: 'drop', data: { dragNode: MenuNode; targetNode: MenuNode; position: 'before' | 'after' | 'inside' }): void
}>()
const isDragOver = ref(false)
const handleDragOver = (e: DragEvent) => {
e.preventDefault()
isDragOver.value = true
}
const handleDragLeave = () => {
isDragOver.value = false
}
const handleDropToRoot = () => {
isDragOver.value = false
if (props.draggingNode) {
// 拖放到根级别
emit('drop', {
dragNode: props.draggingNode,
targetNode: null,
position: 'root'
})
}
}
</script>
<script lang="ts">
@@ -88,26 +51,4 @@ export default {
.menu-tree {
user-select: none;
}
.root-drop-zone {
margin-top: 8px;
padding: 16px;
border: 2px dashed var(--color-border-2);
border-radius: 4px;
text-align: center;
color: var(--color-text-3);
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
&:hover, &.drag-over {
border-color: rgb(var(--primary-6));
color: rgb(var(--primary-6));
background-color: rgb(var(--primary-1));
}
}
</style>

View File

@@ -1,6 +1,6 @@
<template>
<div class="container">
<Breadcrumb :items="['menu.ops', 'menu.ops.systemSettings', 'menu.ops.systemSettings.menuManagement']" />
<Breadcrumb :items="['menu.ops.systemSettings', 'menu.ops.systemSettings.menuManagement']" />
<a-row :gutter="16" class="menu-container">
<!-- 左侧: 菜单树 -->
<a-col :xs="24" :md="10" :lg="8">
@@ -286,7 +286,7 @@ const handleDragEnd = () => {
}
// 处理放置
const handleDrop = async (data: { dragNode: MenuNode; targetNode: MenuNode | null; position: 'before' | 'after' | 'inside' | 'root' }) => {
const handleDrop = async (data: { dragNode: MenuNode; targetNode: MenuNode; position: 'before' | 'after' | 'inside' }) => {
const { dragNode, targetNode, position } = data
if (!dragNode.id) return
@@ -294,56 +294,47 @@ const handleDrop = async (data: { dragNode: MenuNode; targetNode: MenuNode | nul
try {
loading.value = true
if (position === 'root' && !targetNode) {
// 拖放到根级别
if (position === 'inside') {
// 拖放到某个节点内部,成为其子节点
const { children, ...dragData } = dragNode
await modifyMenu({
...dragData,
parent_id: null
parent_id: targetNode.id
} as MenuRouteRequest)
} else if (targetNode) {
if (position === 'inside') {
// 拖放到某个节点内部,成为其子节点
const { children, ...dragData } = dragNode
await modifyMenu({
...dragData,
parent_id: targetNode.id
} as MenuRouteRequest)
} else {
// 拖放到某个节点前后,需要更新排序
const sortList: { pmn_id: number; sort_key: number }[] = []
let sortKey = 1
// 获取同级节点
const siblings = menuItems.value
.filter(item => item.parent_id === targetNode.parent_id)
.sort((a, b) => (a.order || 0) - (b.order || 0))
siblings.forEach((item, index) => {
if (item.id === targetNode.id) {
if (position === 'before') {
sortList.push({ pmn_id: dragNode.id!, sort_key: sortKey++ })
sortList.push({ pmn_id: item.id!, sort_key: sortKey++ })
} else {
sortList.push({ pmn_id: item.id!, sort_key: sortKey++ })
sortList.push({ pmn_id: dragNode.id!, sort_key: sortKey++ })
}
} else if (item.id !== dragNode.id) {
} else {
// 拖放到某个节点前后,需要更新排序
const sortList: { pmn_id: number; sort_key: number }[] = []
let sortKey = 1
// 获取同级节点
const siblings = menuItems.value
.filter(item => item.parent_id === targetNode.parent_id)
.sort((a, b) => (a.order || 0) - (b.order || 0))
siblings.forEach((item) => {
if (item.id === targetNode.id) {
if (position === 'before') {
sortList.push({ pmn_id: dragNode.id!, sort_key: sortKey++ })
sortList.push({ pmn_id: item.id!, sort_key: sortKey++ })
} else {
sortList.push({ pmn_id: item.id!, sort_key: sortKey++ })
sortList.push({ pmn_id: dragNode.id!, sort_key: sortKey++ })
}
})
// 更新拖拽节点的 parent_id
const { children, ...dragData } = dragNode
await modifyMenu({
...dragData,
parent_id: targetNode.parent_id || null
} as MenuRouteRequest)
// 更新排序
if (sortList.length > 0) {
await updateMenuOrder(sortList)
} else if (item.id !== dragNode.id) {
sortList.push({ pmn_id: item.id!, sort_key: sortKey++ })
}
})
// 更新拖拽节点的 parent_id
const { children, ...dragData } = dragNode
await modifyMenu({
...dragData,
parent_id: targetNode.parent_id || null
} as MenuRouteRequest)
// 更新排序
if (sortList.length > 0) {
await updateMenuOrder(sortList)
}
}

View File

@@ -1,6 +1,6 @@
<template>
<div class="container">
<Breadcrumb :items="['menu.ops', 'menu.ops.systemSettings', 'menu.ops.systemSettings.systemLogs']" />
<Breadcrumb :items="['menu.ops.systemSettings', 'menu.ops.systemSettings.systemLogs']" />
<!-- 使用 SearchTable 公共组件 -->
<SearchTable