Compare commits

...

2 Commits

Author SHA1 Message Date
8c2d943dba 更新 2026-01-20 09:43:58 +08:00
9312d47430 修复引入问题 2026-01-20 09:13:52 +08:00
13 changed files with 959 additions and 162 deletions

View File

@@ -0,0 +1,320 @@
<template>
<a-form :model="formData" :rules="rules" :label-col="labelCol" :wrapper-col="wrapperCol" :layout="layout"
@finish="handleFinish" @finish-failed="handleFinishFailed">
<a-form-item v-for="item in formItems" :key="item.field" :label="item.label" :name="item.field"
:required="item.required" :colon="item.colon">
<!-- 输入框 -->
<template v-if="item.type === 'input'">
<a-input v-model:value="formData[item.field]" :placeholder="item.placeholder || `请输入${item.label}`"
:disabled="item.disabled" :allow-clear="item.allowClear !== false" :max-length="item.maxLength"
:type="item.inputType || 'text'" :prefix="item.prefix" :suffix="item.suffix"
@change="item.onChange && item.onChange(formData[item.field])" />
</template>
<!-- 文本域 -->
<template v-else-if="item.type === 'textarea'">
<a-textarea v-model:value="formData[item.field]" :placeholder="item.placeholder || `请输入${item.label}`"
:disabled="item.disabled" :allow-clear="item.allowClear !== false" :rows="item.rows || 4"
:max-length="item.maxLength" :show-count="item.showCount"
@change="item.onChange && item.onChange(formData[item.field])" />
</template>
<!-- 密码输入框 -->
<template v-else-if="item.type === 'password'">
<a-input-password v-model:value="formData[item.field]"
:placeholder="item.placeholder || `请输入${item.label}`" :disabled="item.disabled"
:max-length="item.maxLength" @change="item.onChange && item.onChange(formData[item.field])" />
</template>
<!-- 数字输入框 -->
<template v-else-if="item.type === 'number'">
<a-input-number v-model:value="formData[item.field]"
:placeholder="item.placeholder || `请输入${item.label}`" :disabled="item.disabled" :min="item.min"
:max="item.max" :step="item.step || 1" :precision="item.precision"
:controls="item.controls !== false" style="width: 100%"
@change="item.onChange && item.onChange(formData[item.field])" />
</template>
<!-- 下拉选择 -->
<template v-else-if="item.type === 'select'">
<a-select v-model:value="formData[item.field]" :placeholder="item.placeholder || `请选择${item.label}`"
:disabled="item.disabled" :allow-clear="item.allowClear !== false" :mode="item.mode"
:options="item.options" :field-names="item.fieldNames" style="width: 100%"
@change="item.onChange && item.onChange(formData[item.field])">
<template v-if="!item.options" #notFoundContent>
<a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" description="暂无数据" />
</template>
</a-select>
</template>
<!-- 单选框 -->
<template v-else-if="item.type === 'radio'">
<a-radio-group v-model:value="formData[item.field]" :disabled="item.disabled"
:button-style="item.buttonStyle" @change="item.onChange && item.onChange(formData[item.field])">
<template v-if="item.options">
<a-radio v-for="opt in item.options" :key="opt.value" :value="opt.value"
:disabled="opt.disabled">
{{ opt.label }}
</a-radio>
</template>
<template v-else-if="item.buttonStyle === 'solid'">
<a-radio-button v-for="opt in item.options" :key="opt.value" :value="opt.value"
:disabled="opt.disabled">
{{ opt.label }}
</a-radio-button>
</template>
</a-radio-group>
</template>
<!-- 多选框 -->
<template v-else-if="item.type === 'checkbox'">
<a-checkbox-group v-model:value="formData[item.field]" :disabled="item.disabled"
@change="item.onChange && item.onChange(formData[item.field])">
<template v-if="item.options">
<a-checkbox v-for="opt in item.options" :key="opt.value" :value="opt.value"
:disabled="opt.disabled">
{{ opt.label }}
</a-checkbox>
</template>
</a-checkbox-group>
</template>
<!-- 开关 -->
<template v-else-if="item.type === 'switch'">
<a-switch v-model:checked="formData[item.field]" :disabled="item.disabled"
:checked-children="item.checkedChildren || ''" :un-checked-children="item.unCheckedChildren || ''"
@change="item.onChange && item.onChange(formData[item.field])" />
</template>
<!-- 日期选择 -->
<template v-else-if="item.type === 'date'">
<a-date-picker v-model:value="formData[item.field]"
:placeholder="item.placeholder || `请选择${item.label}`" :disabled="item.disabled"
:format="item.format || 'YYYY-MM-DD'" :value-format="item.valueFormat || 'YYYY-MM-DD'"
style="width: 100%" @change="item.onChange && item.onChange(formData[item.field])" />
</template>
<!-- 日期范围选择 -->
<template v-else-if="item.type === 'dateRange'">
<a-range-picker v-model:value="formData[item.field]" :placeholder="item.placeholder || ['开始日期', '结束日期']"
:disabled="item.disabled" :format="item.format || 'YYYY-MM-DD'"
:value-format="item.valueFormat || 'YYYY-MM-DD'" style="width: 100%"
@change="item.onChange && item.onChange(formData[item.field])" />
</template>
<!-- 时间选择 -->
<template v-else-if="item.type === 'time'">
<a-time-picker v-model:value="formData[item.field]"
:placeholder="item.placeholder || `请选择${item.label}`" :disabled="item.disabled"
:format="item.format || 'HH:mm:ss'" :value-format="item.valueFormat || 'HH:mm:ss'"
style="width: 100%" @change="item.onChange && item.onChange(formData[item.field])" />
</template>
<!-- 上传 -->
<template v-else-if="item.type === 'upload'">
<a-upload v-model:file-list="formData[item.field]" :list-type="item.listType || 'text'"
:action="item.action" :max-count="item.maxCount" :before-upload="item.beforeUpload"
:custom-request="item.customRequest" :accept="item.accept" :disabled="item.disabled"
@change="(info) => item.onChange && item.onChange(info)">
<a-button v-if="item.listType !== 'picture-card'" type="primary">
<UploadOutlined />
点击上传
</a-button>
<div v-else>
<PlusOutlined />
<div class="ant-upload-text">上传</div>
</div>
</a-upload>
</template>
<!-- 评分 -->
<template v-else-if="item.type === 'rate'">
<a-rate v-model:value="formData[item.field]" :disabled="item.disabled" :count="item.count || 5"
:allow-half="item.allowHalf" @change="item.onChange && item.onChange(formData[item.field])" />
</template>
<!-- 滑块 -->
<template v-else-if="item.type === 'slider'">
<a-slider v-model:value="formData[item.field]" :disabled="item.disabled" :min="item.min || 0"
:max="item.max || 100" :step="item.step || 1" :marks="item.marks" :range="item.range"
@change="item.onChange && item.onChange(formData[item.field])" />
</template>
<!-- 级联选择 -->
<template v-else-if="item.type === 'cascader'">
<a-cascader v-model:value="formData[item.field]" :options="item.options"
:placeholder="item.placeholder || `请选择${item.label}`" :disabled="item.disabled"
:change-on-select="item.changeOnSelect" :field-names="item.fieldNames" style="width: 100%"
@change="item.onChange && item.onChange(formData[item.field])" />
</template>
<!-- 自定义插槽 -->
<template v-else-if="item.type === 'slot'">
<slot :name="item.slotName || item.field" :field="item.field" :value="formData[item.field]"></slot>
</template>
<!-- 提示信息 -->
<template v-if="item.tip">
<div class="form-item-tip">{{ item.tip }}</div>
</template>
</a-form-item>
<!-- 表单操作按钮 -->
<a-form-item v-if="showActions" :wrapper-col="actionWrapperCol">
<a-space>
<a-button type="primary" html-type="submit" :loading="loading" :size="buttonSize">
{{ submitText || '提交' }}
</a-button>
<a-button v-if="showReset" @click="handleReset" :size="buttonSize">
{{ resetText || '重置' }}
</a-button>
<a-button v-if="showCancel" @click="handleCancel" :size="buttonSize">
{{ cancelText || '取消' }}
</a-button>
<slot name="actions"></slot>
</a-space>
</a-form-item>
<!-- 自定义插槽 -->
<slot></slot>
</a-form>
</template>
<script setup>
import { ref, reactive, computed, watch } from 'vue'
import { Empty } from 'ant-design-vue'
import { UploadOutlined, PlusOutlined } from '@ant-design/icons-vue'
const props = defineProps({
// 表单项配置
formItems: {
type: Array,
default: () => [],
required: true,
},
// 表单初始值
initialValues: {
type: Object,
default: () => ({}),
},
// 表单布局
layout: {
type: String,
default: 'horizontal', // horizontal, vertical, inline
},
// 标签宽度
labelCol: {
type: Object,
default: () => ({ span: 6 }),
},
// 内容宽度
wrapperCol: {
type: Object,
default: () => ({ span: 16 }),
},
// 是否显示操作按钮
showActions: {
type: Boolean,
default: true,
},
// 操作按钮布局
actionWrapperCol: {
type: Object,
default: () => ({ offset: 6, span: 16 }),
},
// 是否显示重置按钮
showReset: {
type: Boolean,
default: true,
},
// 是否显示取消按钮
showCancel: {
type: Boolean,
default: false,
},
// 按钮文字
submitText: String,
resetText: String,
cancelText: String,
// 按钮大小
buttonSize: {
type: String,
default: 'middle',
},
// 加载状态
loading: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['finish', 'finish-failed', 'reset', 'cancel'])
// 表单数据
const formData = reactive({ ...props.initialValues })
// 表单验证规则
const rules = computed(() => {
const result = {}
props.formItems.forEach((item) => {
if (item.rules && item.rules.length > 0) {
result[item.field] = item.rules
}
})
return result
})
// 监听初始值变化
watch(
() => props.initialValues,
(newVal) => {
Object.assign(formData, newVal)
},
{ deep: true },
)
// 表单提交
const handleFinish = (values) => {
emit('finish', values)
}
// 表单验证失败
const handleFinishFailed = (errorInfo) => {
emit('finish-failed', errorInfo)
}
// 重置表单
const handleReset = () => {
Object.assign(formData, props.initialValues)
emit('reset', formData)
}
// 取消操作
const handleCancel = () => {
emit('cancel')
}
// 暴露方法给父组件
defineExpose({
formData,
resetForm: handleReset,
setFieldValue: (field, value) => {
formData[field] = value
},
getFieldValue: (field) => {
return formData[field]
},
setFieldsValue: (values) => {
Object.assign(formData, values)
},
})
</script>
<style scoped lang="scss">
.form-item-tip {
margin-top: 4px;
font-size: 12px;
color: #999;
line-height: 1.5;
}
</style>

View File

@@ -1,26 +1,26 @@
export default {
APP_NAME: 'vueadmin',
DASHBOARD_URL: '/home',
DASHBOARD_URL: '/dashboard',
// 白名单路由(不需要登录即可访问)
whiteList: ['/login', '/register', '/reset-password'],
//版本号
APP_VER: "1.6.6",
APP_VER: '1.6.6',
//内核版本号
CORE_VER: "1.6.6",
CORE_VER: '1.6.6',
//接口地址
API_URL: "https://www.tensent.cn/admin/",
API_URL: 'https://www.tensent.cn/admin/',
//请求超时
TIMEOUT: 50000,
//TokenName
TOKEN_NAME: "authorization",
TOKEN_NAME: 'authorization',
//Token前缀注意最后有个空格如不需要需设置空字符串
TOKEN_PREFIX: "Bearer ",
TOKEN_PREFIX: 'Bearer ',
//追加其他头
HEADERS: {},
@@ -28,24 +28,24 @@ export default {
//请求是否开启缓存
REQUEST_CACHE: false,
//语言
LANG: "zh-cn",
LANG: 'zh-cn',
//是否加密localStorage, 为空不加密
//支持多种加密方式: 'AES', 'BASE64', 'DES'
LS_ENCRYPTION: "",
LS_ENCRYPTION: '',
//localStorage加密秘钥位数建议填写8的倍数
LS_ENCRYPTION_key: "2XNN4K8LC0ELVWN4",
LS_ENCRYPTION_key: '2XNN4K8LC0ELVWN4',
//localStorage加密模式AES支持: 'ECB', 'CBC', 'CTR', 'OFB', 'CFB'
LS_ENCRYPTION_mode: "ECB",
LS_ENCRYPTION_mode: 'ECB',
//localStorage加密填充方式AES支持: 'Pkcs7', 'ZeroPadding', 'Iso10126', 'Iso97971'
LS_ENCRYPTION_padding: "Pkcs7",
LS_ENCRYPTION_padding: 'Pkcs7',
//localStorage默认过期时间单位小时0表示永不过期
LS_DEFAULT_EXPIRE: 720, // 30天
//DES加密秘钥必须是8字节
LS_DES_key: "12345678",
LS_DES_key: '12345678',
}

View File

@@ -2,55 +2,6 @@
* 静态路由配置
* 这些路由会根据用户角色进行过滤后添加到路由中
*/
const userRoutes = [
{
path: '/home',
name: 'Home',
component: 'home',
meta: {
title: '首页',
icon: 'HomeOutlined',
affix: true,
noCache: false
}
},
{
path: '/system',
name: 'System',
meta: {
title: '系统管理',
icon: 'SettingOutlined'
},
children: [
{
path: '/system/user',
name: 'SystemUser',
component: 'system/user',
meta: {
title: '用户管理',
icon: 'UserOutlined'
}
},
{
path: '/system/role',
name: 'SystemRole',
component: 'system/role',
meta: {
title: '角色管理',
icon: 'TeamOutlined'
}
},
{
path: '/system/menu',
name: 'SystemMenu',
component: 'system/menu',
meta: {
title: '菜单管理',
icon: 'MenuOutlined'
}
}
]
}
]
const userRoutes = []
export default userRoutes

View File

@@ -4,13 +4,9 @@
<div class="setting-item">
<div class="setting-title">布局模式</div>
<div class="layout-mode-list">
<div
v-for="mode in layoutModes"
:key="mode.value"
class="layout-mode-item"
<div v-for="mode in layoutModes" :key="mode.value" class="layout-mode-item"
:class="{ active: layoutStore.layoutMode === mode.value }"
@click="handleLayoutChange(mode.value)"
>
@click="handleLayoutChange(mode.value)">
<div class="layout-preview" :class="`preview-${mode.value}`">
<div class="preview-sidebar"></div>
<div v-if="mode.value === 'default'" class="preview-sidebar-2"></div>
@@ -28,14 +24,9 @@
<div class="setting-item">
<div class="setting-title">主题颜色</div>
<div class="color-list">
<div
v-for="color in themeColors"
:key="color"
class="color-item"
:class="{ active: themeColor === color }"
:style="{ backgroundColor: color }"
@click="changeThemeColor(color)"
>
<div v-for="color in themeColors" :key="color" class="color-item"
:class="{ active: themeColor === color }" :style="{ backgroundColor: color }"
@click="changeThemeColor(color)">
<CheckOutlined v-if="themeColor === color" />
</div>
</div>
@@ -69,14 +60,14 @@
</template>
<script setup>
import { ref, defineExpose, defineOptions, watch, onMounted } from 'vue'
import { ref, watch, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import { useLayoutStore } from '@/stores/modules/layout'
import { CheckOutlined, ReloadOutlined } from '@ant-design/icons-vue'
// 定义组件名称(多词命名)
defineOptions({
name: 'LayoutSetting'
name: 'LayoutSetting',
})
const layoutStore = useLayoutStore()
@@ -89,19 +80,10 @@ const showBreadcrumb = ref(true)
const layoutModes = [
{ value: 'default', label: '默认布局' },
{ value: 'menu', label: '菜单布局' },
{ value: 'top', label: '顶部布局' }
{ value: 'top', label: '顶部布局' },
]
const themeColors = [
'#1890ff',
'#f5222d',
'#fa541c',
'#faad14',
'#13c2c2',
'#52c41a',
'#2f54eb',
'#722ed1'
]
const themeColors = ['#1890ff', '#f5222d', '#fa541c', '#faad14', '#13c2c2', '#52c41a', '#2f54eb', '#722ed1']
const openDrawer = () => {
open.value = true
@@ -113,13 +95,13 @@ const closeDrawer = () => {
defineExpose({
openDrawer,
closeDrawer
closeDrawer,
})
// 切换布局
const handleLayoutChange = (mode) => {
layoutStore.setLayoutMode(mode)
const modeLabel = layoutModes.find(m => m.value === mode)?.label || mode
const modeLabel = layoutModes.find((m) => m.value === mode)?.label || mode
message.success(`已切换到${modeLabel}`)
}

View File

@@ -53,19 +53,15 @@
</template>
<script setup>
import { ref, computed, watch, onMounted, defineOptions } from 'vue'
import { ref, computed, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useLayoutStore } from '@/stores/modules/layout'
import {
ReloadOutlined,
CloseOutlined,
ColumnWidthOutlined,
CloseCircleOutlined
} from '@ant-design/icons-vue'
import { ReloadOutlined, CloseOutlined, ColumnWidthOutlined, CloseCircleOutlined } from '@ant-design/icons-vue'
import config from '@/config'
// 定义组件名称(多词命名)
defineOptions({
name: 'TagsView'
name: 'TagsView',
})
const route = useRoute()
@@ -91,7 +87,7 @@ const addTags = () => {
name: name,
query: route.query,
params: route.params,
meta: route.meta
meta: route.meta,
})
}
}
@@ -107,12 +103,12 @@ const closeSelectedTag = (view) => {
// 如果关闭的是当前激活的标签,需要跳转
if (isActive(view)) {
const nextTag = visitedViews.value.find(tag => tag.fullPath !== view.fullPath)
const nextTag = visitedViews.value.find((tag) => tag.fullPath !== view.fullPath)
if (nextTag) {
router.push(nextTag.fullPath)
} else {
// 如果没有其他标签,跳转到首页
router.push('/home')
router.push(config.DASHBOARD_URL)
}
}
}
@@ -124,9 +120,7 @@ const closeOthersTags = () => {
}
// 保留固定标签和当前选中的标签
const tagsToKeep = visitedViews.value.filter(tag =>
tag.meta?.affix || tag.fullPath === selectedTag.value.fullPath
)
const tagsToKeep = visitedViews.value.filter((tag) => tag.meta?.affix || tag.fullPath === selectedTag.value.fullPath)
// 更新标签列表
layoutStore.viewTags = tagsToKeep
@@ -140,7 +134,7 @@ const closeOthersTags = () => {
// 关闭所有标签
const closeAllTags = () => {
// 只保留固定标签
const affixTags = visitedViews.value.filter(tag => tag.meta?.affix)
const affixTags = visitedViews.value.filter((tag) => tag.meta?.affix)
layoutStore.viewTags = affixTags
// 如果还有固定标签,跳转到第一个固定标签
@@ -192,16 +186,20 @@ const handleMenuClick = ({ key }) => {
}
// 监听路由变化,自动添加标签
watch(() => route.fullPath, () => {
addTags()
// 更新当前选中的标签
selectedTag.value = visitedViews.value.find(tag => isActive(tag)) || {}
}, { immediate: true })
watch(
() => route.fullPath,
() => {
addTags()
// 更新当前选中的标签
selectedTag.value = visitedViews.value.find((tag) => isActive(tag)) || {}
},
{ immediate: true },
)
onMounted(() => {
addTags()
// 初始化选中的标签
selectedTag.value = visitedViews.value.find(tag => isActive(tag)) || {}
selectedTag.value = visitedViews.value.find((tag) => isActive(tag)) || {}
})
</script>

View File

@@ -111,28 +111,17 @@
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, defineOptions } from 'vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { message, Modal } from 'ant-design-vue'
import { useUserStore } from '@/stores/modules/user'
import { useI18nStore } from '@/stores/modules/i18n'
import {
DownOutlined,
UserOutlined,
LogoutOutlined,
FullscreenOutlined,
FullscreenExitOutlined,
BellOutlined,
CheckSquareOutlined,
GlobalOutlined,
SearchOutlined,
SettingOutlined
} from '@ant-design/icons-vue'
import { DownOutlined, UserOutlined, LogoutOutlined, FullscreenOutlined, FullscreenExitOutlined, BellOutlined, CheckSquareOutlined, GlobalOutlined, SearchOutlined, SettingOutlined } from '@ant-design/icons-vue'
import { useI18n } from 'vue-i18n'
// 定义组件名称(多词命名)
defineOptions({
name: 'UserBar'
name: 'UserBar',
})
const { t } = useI18n()
@@ -147,7 +136,7 @@ const messages = ref([
{ id: 1, title: '系统通知:新版本已发布', time: '10分钟前', read: false },
{ id: 2, title: '任务提醒:请完成待审核的用户', time: '30分钟前', read: false },
{ id: 3, title: '安全警告:检测到异常登录', time: '1小时前', read: true },
{ id: 4, title: '数据备份已完成', time: '2小时前', read: true }
{ id: 4, title: '数据备份已完成', time: '2小时前', read: true },
])
const messageCount = computed(() => messages.value.filter((m) => !m.read).length)
@@ -156,7 +145,7 @@ const messageCount = computed(() => messages.value.filter((m) => !m.read).length
const tasks = ref([
{ id: 1, title: '完成用户审核', completed: false },
{ id: 2, title: '更新系统文档', completed: false },
{ id: 3, title: '优化数据库查询', completed: true }
{ id: 3, title: '优化数据库查询', completed: true },
])
const taskCount = computed(() => tasks.value.filter((t) => !t.completed).length)
@@ -244,7 +233,7 @@ const handleLogout = () => {
} catch {
message.error(t('common.logoutFailed'))
}
}
},
})
}
</script>

View File

@@ -89,7 +89,8 @@
<div class="logo-box-top">
<span class="logo-text">VUE ADMIN</span>
</div>
<a-menu v-model:selectedKeys="selectedKeys" mode="horizontal" :selected-keys="[route.path]" style="line-height: 60px">
<a-menu v-model:selectedKeys="selectedKeys" mode="horizontal" :selected-keys="[route.path]"
style="line-height: 60px">
<navMenu :menu-items="menuList" :active-path="route.path" />
</a-menu>
</div>
@@ -118,7 +119,7 @@
</template>
<script setup>
import { computed, defineOptions, ref, watch, onMounted } from 'vue'
import { computed, ref, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useLayoutStore } from '@/stores/modules/layout'
import { useUserStore } from '@/stores/modules/user'
@@ -133,8 +134,8 @@ import setting from './components/setting.vue'
// 定义组件名称(多词命名)
defineOptions({
name: 'AppLayouts'
});
name: 'AppLayouts',
})
const route = useRoute()
const router = useRouter()
@@ -149,9 +150,7 @@ const selectedParentMenu = computed(() => layoutStore.selectedParentMenu)
// 缓存的视图列表
const cachedViews = computed(() => {
return layoutStore.viewTags
.filter(tag => !tag.meta?.noCache)
.map(tag => tag.name)
return layoutStore.viewTags.filter((tag) => !tag.meta?.noCache).map((tag) => tag.name)
})
// 布局类名
@@ -160,7 +159,7 @@ const layoutClass = computed(() => {
'layout-default': layoutMode.value === 'default',
'layout-menu': layoutMode.value === 'menu',
'layout-top': layoutMode.value === 'top',
'is-collapse': sidebarCollapsed.value
'is-collapse': sidebarCollapsed.value,
}
})
@@ -207,8 +206,8 @@ const updateMenuState = () => {
selectedKeys.value = [route.path]
// 获取所有父级路径
const matched = route.matched.filter(item => item.path !== '/' && item.path !== route.path)
const parentPaths = matched.map(item => item.path)
const matched = route.matched.filter((item) => item.path !== '/' && item.path !== route.path)
const parentPaths = matched.map((item) => item.path)
// 对于不同的布局模式,处理方式不同
if (layoutMode.value === 'default') {
@@ -271,24 +270,34 @@ const findParentMenu = (menus, path) => {
}
// 监听路由变化,更新菜单状态
watch(() => route.path, (newPath) => {
console.log('路由变化:', newPath)
updateMenuState()
}, { immediate: true })
watch(
() => route.path,
(newPath) => {
console.log('路由变化:', newPath)
updateMenuState()
},
{ immediate: true },
)
// 监听布局模式变化,确保菜单状态正确
watch(() => layoutMode.value, () => {
updateMenuState()
})
watch(
() => layoutMode.value,
() => {
updateMenuState()
},
)
// 监听折叠状态
watch(() => sidebarCollapsed.value, (val) => {
if (val) {
openKeys.value = []
} else {
updateMenuState()
}
})
watch(
() => sidebarCollapsed.value,
(val) => {
if (val) {
openKeys.value = []
} else {
updateMenuState()
}
},
)
// 初始化
onMounted(() => {

View File

@@ -0,0 +1,105 @@
<template>
<DynamicForm :form-items="formItems" :initial-values="initialValues" :loading="loading" @finish="handleFinish"
@reset="handleReset" />
</template>
<script setup>
import { ref, computed } from 'vue'
import { message } from 'ant-design-vue'
import DynamicForm from '@/components/DynamicForm.vue'
const props = defineProps({
userInfo: {
type: Object,
default: () => ({}),
},
})
const emit = defineEmits(['update'])
const loading = ref(false)
// 表单初始值
const initialValues = computed(() => ({
username: props.userInfo.username || '',
nickname: props.userInfo.nickname || '',
phone: props.userInfo.phone || '',
email: props.userInfo.email || '',
gender: props.userInfo.gender || 0,
birthday: props.userInfo.birthday || null,
bio: props.userInfo.bio || '',
}))
// 表单项配置
const formItems = [
{
field: 'username',
label: '用户名',
type: 'input',
disabled: true,
},
{
field: 'nickname',
label: '昵称',
type: 'input',
required: true,
rules: [
{ required: true, message: '请输入昵称', trigger: 'blur' },
{ min: 2, max: 20, message: '昵称长度在 2 到 20 个字符', trigger: 'blur' },
],
},
{
field: 'phone',
label: '手机号',
type: 'input',
rules: [{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }],
},
{
field: 'email',
label: '邮箱',
type: 'input',
rules: [{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }],
},
{
field: 'gender',
label: '性别',
type: 'radio',
options: [
{ label: '男', value: 1 },
{ label: '女', value: 2 },
{ label: '保密', value: 0 },
],
},
{
field: 'birthday',
label: '生日',
type: 'date',
},
{
field: 'bio',
label: '个人简介',
type: 'textarea',
rows: 4,
maxLength: 200,
showCount: true,
},
]
// 表单提交
const handleFinish = (values) => {
loading.value = true
// 模拟接口请求
setTimeout(() => {
emit('update', values)
message.success('保存成功')
loading.value = false
}, 1000)
}
// 重置表单
const handleReset = () => {
message.info('已重置')
}
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,81 @@
<template>
<DynamicForm :form-items="formItems" :initial-values="initialValues" :loading="loading" submit-text="修改密码"
@finish="handleFinish" @reset="handleReset" />
</template>
<script setup>
import { ref } from 'vue'
import { message } from 'ant-design-vue'
import DynamicForm from '@/components/DynamicForm.vue'
const emit = defineEmits(['success'])
const loading = ref(false)
// 表单初始值
const initialValues = {
oldPassword: '',
newPassword: '',
confirmPassword: '',
}
// 表单项配置
const formItems = [
{
field: 'oldPassword',
label: '原密码',
type: 'password',
required: true,
rules: [{ required: true, message: '请输入原密码', trigger: 'blur' }],
},
{
field: 'newPassword',
label: '新密码',
type: 'password',
required: true,
rules: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{ min: 6, max: 20, message: '密码长度在 6 到 20 个字符', trigger: 'blur' },
],
},
{
field: 'confirmPassword',
label: '确认密码',
type: 'password',
required: true,
rules: [
{ required: true, message: '请再次输入新密码', trigger: 'blur' },
{
validator: (rule, value) => {
if (value !== initialValues.newPassword) {
return Promise.reject('两次输入的密码不一致')
}
return Promise.resolve()
},
trigger: 'blur',
},
],
},
]
// 表单提交
const handleFinish = (values) => {
loading.value = true
// 模拟接口请求
setTimeout(() => {
message.success('密码修改成功,请重新登录')
emit('success')
handleReset()
loading.value = false
}, 1000)
}
// 重置表单
const handleReset = () => {
initialValues.oldPassword = ''
initialValues.newPassword = ''
initialValues.confirmPassword = ''
}
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,73 @@
<template>
<div class="profile-info">
<div class="avatar-wrapper">
<a-avatar :size="100" :src="userInfo.avatar" @click="handleAvatarClick">
{{ userInfo.nickname?.charAt(0) }}
</a-avatar>
</div>
<div class="user-name">{{ userInfo.nickname || userInfo.username }}</div>
<a-tag :color="userInfo.status === 1 ? 'green' : 'red'">
{{ userInfo.status === 1 ? '正常' : '禁用' }}
</a-tag>
</div>
</template>
<script setup>
const props = defineProps({
userInfo: {
type: Object,
default: () => ({}),
},
})
const emit = defineEmits(['avatar-click'])
const handleAvatarClick = () => {
emit('avatar-click')
}
</script>
<style scoped lang="scss">
.profile-info {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 8px;
margin-bottom: 16px;
color: #fff;
.avatar-wrapper {
margin-bottom: 12px;
cursor: pointer;
.ant-avatar {
border: 3px solid rgba(255, 255, 255, 0.3);
background: rgba(255, 255, 255, 0.2);
font-size: 40px;
font-weight: bold;
color: #fff;
transition: all 0.3s;
&:hover {
border-color: rgba(255, 255, 255, 0.6);
transform: scale(1.05);
}
}
}
.user-name {
font-size: 18px;
font-weight: 600;
margin-bottom: 8px;
}
.ant-tag {
margin: 0;
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
color: #fff;
}
}
</style>

View File

@@ -0,0 +1,85 @@
<template>
<a-list :data-source="securityList" item-layout="horizontal">
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta>
<template #title>
{{ item.title }}
</template>
<template #description>
{{ item.description }}
</template>
</a-list-item-meta>
<template #actions>
<a-button type="primary" size="small" @click="handleAction(item.action)">
{{ item.buttonText }}
</a-button>
</template>
</a-list-item>
</template>
</a-list>
</template>
<script setup>
import { ref } from 'vue'
import { message } from 'ant-design-vue'
const emit = defineEmits(['change-password'])
const securityList = ref([
{
title: '登录密码',
description: '用于登录系统的密码,建议定期更换',
buttonText: '修改',
action: 'password',
},
{
title: '手机验证',
description: '用于接收重要通知和安全验证',
buttonText: '已绑定',
action: 'phone',
},
{
title: '邮箱验证',
description: '用于接收重要通知和账号找回',
buttonText: '已绑定',
action: 'email',
},
{
title: '登录设备',
description: '查看和管理已登录的设备',
buttonText: '查看',
action: 'device',
},
])
const handleAction = (action) => {
switch (action) {
case 'password':
emit('change-password')
break
case 'phone':
message.info('手机绑定功能开发中')
break
case 'email':
message.info('邮箱绑定功能开发中')
break
case 'device':
message.info('登录设备管理功能开发中')
break
default:
break
}
}
</script>
<style scoped lang="scss">
:deep(.ant-list-item) {
padding: 20px 0;
border-bottom: 1px solid #f0f0f0;
}
:deep(.ant-list-item:last-child) {
border-bottom: none;
}
</style>

202
src/pages/ucenter/index.vue Normal file
View File

@@ -0,0 +1,202 @@
<template>
<div class="ucenter">
<a-card>
<a-row :gutter="24">
<a-col :span="6">
<ProfileInfo :user-info="userInfo" @avatar-click="showAvatarModal = true" />
<a-menu v-model:selectedKeys="selectedKeys" mode="inline" class="menu">
<a-menu-item key="basic">
<UserOutlined />
基本信息
</a-menu-item>
<a-menu-item key="password">
<LockOutlined />
修改密码
</a-menu-item>
<a-menu-item key="security">
<SafetyOutlined />
账号安全
</a-menu-item>
</a-menu>
</a-col>
<a-col :span="18">
<div class="content-wrapper">
<BasicInfo v-if="selectedKeys[0] === 'basic'" :user-info="userInfo"
@update="handleUpdateUserInfo" />
<Password v-else-if="selectedKeys[0] === 'password'" @success="handlePasswordSuccess" />
<Security v-else-if="selectedKeys[0] === 'security'" @change-password="handleChangePassword" />
</div>
</a-col>
</a-row>
</a-card>
<!-- 头像上传弹窗 -->
<a-modal v-model:open="showAvatarModal" title="更换头像" :confirm-loading="loading" @ok="handleAvatarUpload"
@cancel="showAvatarModal = false">
<div class="avatar-upload">
<a-upload list-type="picture-card" :max-count="1" :before-upload="beforeUpload"
@change="handleAvatarChange" :file-list="avatarFileList">
<div v-if="avatarFileList.length === 0">
<PlusOutlined />
<div class="ant-upload-text">上传头像</div>
</div>
</a-upload>
<div class="upload-tip">
<a-typography-text type="secondary"> 支持 JPGPNG 格式文件大小不超过 2MB </a-typography-text>
</div>
</div>
</a-modal>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import { PlusOutlined, UserOutlined, LockOutlined, SafetyOutlined } from '@ant-design/icons-vue'
import dayjs from 'dayjs'
import ProfileInfo from './components/ProfileInfo.vue'
import BasicInfo from './components/BasicInfo.vue'
import Password from './components/Password.vue'
import Security from './components/Security.vue'
// 用户信息
const userInfo = ref({
username: '',
nickname: '',
phone: '',
email: '',
avatar: '',
status: 1,
gender: 0,
birthday: null,
bio: '',
})
// 选中的菜单
const selectedKeys = ref(['basic'])
// 头像上传
const showAvatarModal = ref(false)
const avatarFileList = ref([])
const loading = ref(false)
// 初始化用户信息
const initUserInfo = () => {
// 模拟用户数据
const mockUserInfo = {
username: 'admin',
nickname: '管理员',
phone: '13800138000',
email: 'admin@example.com',
avatar: '',
status: 1,
gender: 1,
birthday: dayjs('1990-01-01'),
bio: '热爱编程,专注于前端开发技术。',
}
userInfo.value = { ...mockUserInfo }
}
// 更新用户信息
const handleUpdateUserInfo = (data) => {
Object.assign(userInfo.value, data)
}
// 密码修改成功
const handlePasswordSuccess = () => {
// 密码修改成功后的处理
}
// 切换到密码修改页面
const handleChangePassword = () => {
selectedKeys.value = ['password']
}
// 头像上传前校验
const beforeUpload = (file) => {
const isJpgOrPng = file.type === 'image/jpeg' || file.type === 'image/png'
if (!isJpgOrPng) {
message.error('只能上传 JPG/PNG 格式的文件!')
return false
}
const isLt2M = file.size / 1024 / 1024 < 2
if (!isLt2M) {
message.error('图片大小不能超过 2MB!')
return false
}
return false // 阻止自动上传
}
// 头像文件变化
const handleAvatarChange = ({ fileList }) => {
avatarFileList.value = fileList
}
// 上传头像
const handleAvatarUpload = () => {
if (avatarFileList.value.length === 0) {
message.warning('请先选择头像')
return
}
loading.value = true
// 模拟上传
setTimeout(() => {
const file = avatarFileList.value[0]
userInfo.value.avatar = URL.createObjectURL(file.originFileObj)
message.success('头像更新成功')
showAvatarModal.value = false
avatarFileList.value = []
loading.value = false
}, 1000)
}
onMounted(() => {
initUserInfo()
})
</script>
<style scoped lang="scss">
.ucenter {
.content-wrapper {
padding: 24px;
background: #fafafa;
border-radius: 8px;
min-height: 400px;
}
.menu {
margin-top: 16px;
background: transparent;
border: none;
.ant-menu-item {
border-radius: 6px;
margin: 4px 0;
&:hover {
background: rgba(255, 255, 255, 0.3);
}
&.ant-menu-item-selected {
background: rgba(255, 255, 255, 0.4);
&::after {
border-right-width: 0;
}
}
}
}
.avatar-upload {
.upload-tip {
margin-top: 16px;
text-align: center;
}
}
:deep(.ant-card-head-title) {
font-size: 18px;
font-weight: 600;
}
}
</style>

View File

@@ -1,3 +1,5 @@
import config from '@/config'
/**
* 基础路由(不需要登录)
*/
@@ -8,8 +10,8 @@ const systemRoutes = [
component: () => import('../pages/login/index.vue'),
meta: {
title: 'login',
hidden: true
}
hidden: true,
},
},
{
path: '/register',
@@ -17,8 +19,8 @@ const systemRoutes = [
component: () => import('../pages/login/userRegister.vue'),
meta: {
title: 'register',
hidden: true
}
hidden: true,
},
},
{
path: '/reset-password',
@@ -26,16 +28,16 @@ const systemRoutes = [
component: () => import('../pages/login/resetPassword.vue'),
meta: {
title: 'resetPassword',
hidden: true
}
hidden: true,
},
},
{
path: '/',
name: 'Layout',
component: () => import('@/layouts/index.vue'),
redirect: '/home',
children: []
}
redirect: config.DASHBOARD_URL,
children: [],
},
]
export default systemRoutes