功能模块:添加演示和个人中心页面

- 添加演示页面(src/views/demo/)
  - 功能演示示例
- 添加个人中心页面(src/views/personal-center/)
  - 个人信息管理
  - 账号设置
This commit is contained in:
Leo 2025-10-08 02:32:48 +08:00
parent 387e5f75d1
commit 5e42eb9930
4 changed files with 745 additions and 0 deletions

View File

@ -0,0 +1,14 @@
<template>
<n-card title="MarkDown编辑器">
<n-space vertical :size="12">
<n-alert title="基于 md-editor-v3 封装" type="success" />
<MarkDownEditor v-model="text" />
</n-space>
</n-card>
</template>
<script setup lang="ts">
const text = ref('# Hello Editor ![图片描述](https://via.placeholder.com/350x150)')
</script>
<style scoped></style>

View File

@ -0,0 +1,38 @@
<template>
<n-card title="富文本编辑器">
<n-space vertical :size="12">
<n-alert title="基于 Quill 封装" type="success" />
<n-switch v-model:value="active">
<template #checked>
禁用
</template>
<template #unchecked>
启用
</template>
</n-switch>
<n-space :size="12">
<div class="h-300px">
<RichTextEditor v-model="text" :disabled="active" />
</div>
<div>
<n-h2>v-html 预览</n-h2>
<div v-html="text" />
</div>
</n-space>
</n-space>
</n-card>
</template>
<script setup lang="ts">
const text = ref('')
// ajax
onMounted(() => {
setTimeout(() => {
text.value = '<p>模拟 Ajax 异步设置内容</p>'
}, 1500)
})
const active = ref(false)
</script>
<style scoped></style>

View File

@ -0,0 +1,32 @@
<template>
<n-space vertical>
<n-card title="图标选择器">
<icon-select />
</n-card>
<n-card title="自动导入图标">
<div>
正常<icon-park-outline-apple />
</div>
<div>
<icon-park-outline-apple class="text-2em" />
</div>
<div>
大大大<icon-park-outline-apple class="text-4em" />
</div>
</n-card>
<n-card title="自动导入svg图标">
<div>
正常<svg-icons-cool />
</div>
<div>
<svg-icons-cool class="text-2em" />
</div>
<div>
大大大<svg-icons-cool class="text-4em" />
</div>
<div>
CoiIcon组件加载<CoiIcon icon="local:cool" />
</div>
</n-card>
</n-space>
</template>

View File

@ -0,0 +1,661 @@
<template>
<div class="p-1 bg-gray-50 h-screen flex flex-col">
<div class="bg-white rounded-lg shadow-sm border border-gray-100 flex-1 overflow-auto">
<div class="p-6">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- 左侧个人信息卡片 -->
<div class="lg:col-span-1">
<n-card title="个人信息" class="h-fit">
<n-spin :show="loading">
<div class="text-center mb-6">
<!-- 头像 -->
<div class="relative inline-block">
<n-avatar
:size="100"
:src="personalData.avatar"
round
class="border-4 border-blue-100 cursor-pointer hover:border-blue-300 transition-colors"
@click="handleAvatarClick"
>
<template #placeholder>
<icon-park-outline-user class="text-4xl" />
</template>
</n-avatar>
<!-- 头像上传图标 -->
<div
class="absolute bottom-0 right-0 w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center cursor-pointer hover:bg-blue-600 transition-colors shadow-md"
@click="handleAvatarClick"
>
<icon-park-outline-camera class="text-white text-sm" />
</div>
<!-- 上传中遮罩 -->
<div
v-if="uploading"
class="absolute inset-0 bg-black bg-opacity-50 rounded-full flex items-center justify-center"
>
<n-spin size="small" stroke="white" />
</div>
</div>
<!-- 隐藏的文件输入 -->
<input
ref="avatarUploadRef"
type="file"
accept="image/jpeg,image/jpg,image/png,image/gif"
class="hidden"
@change="handleAvatarChange"
>
<!-- 头像上传提示 -->
<p class="text-xs text-gray-400 mt-4">
点击头像上传新头像
</p>
</div>
<!-- 详细信息 -->
<div class="space-y-3">
<div class="flex justify-between items-center py-2">
<span class="text-gray-600">登录账号</span>
<span class="text-gray-900 text-sm">{{ personalData.loginName || '未设置' }}</span>
</div>
<div class="flex justify-between items-center py-2">
<span class="text-gray-600">用户姓名</span>
<span class="text-gray-900 text-sm">{{ personalData.userName || '未设置' }}</span>
</div>
<div class="flex justify-between items-center py-2">
<span class="text-gray-600">用户角色</span>
<div>
<n-tag
v-for="(roleName, index) in getRoleNames()"
:key="index"
type="primary"
size="small"
class="mr-1"
>
{{ roleName }}
</n-tag>
<span v-if="getRoleNames().length === 0" class="text-gray-500">未分配角色</span>
</div>
</div>
<div class="flex justify-between items-center">
<span class="text-gray-600">邮箱</span>
<span class="text-gray-900">{{ personalData.email || '未设置' }}</span>
</div>
<div class="flex justify-between items-center py-2">
<span class="text-gray-600">手机号</span>
<span class="text-gray-900">{{ personalData.phone || '未设置' }}</span>
</div>
<div class="flex justify-between items-center py-2">
<span class="text-gray-600">性别</span>
<span class="text-gray-900">{{ getGenderText(personalData.sex || '3') }}</span>
</div>
<div class="flex justify-between items-center py-2">
<span class="text-gray-600">状态</span>
<DictTag dict-type="sys_switch_status" :value="personalData.userStatus || '0'" />
</div>
<div class="flex justify-between items-center py-2">
<span class="text-gray-600">创建日期</span>
<span class="text-gray-900 text-sm">{{ formatDate(personalData.createTime || '') }}</span>
</div>
</div>
</n-spin>
</n-card>
</div>
<!-- 右侧操作区域 -->
<div class="lg:col-span-2">
<n-card>
<n-tabs type="line" animated>
<!-- 基本资料 -->
<n-tab-pane name="basic" tab="基本资料">
<div class="pt-4">
<!-- 基本资料表单 -->
<n-form
ref="basicFormRef"
:model="basicForm"
:rules="basicRules"
label-placement="left"
label-width="100px"
require-mark-placement="right-hanging"
>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<n-form-item label="用户名" path="userName">
<n-input v-model:value="basicForm.userName" placeholder="请输入用户名" />
</n-form-item>
<n-form-item label="性别" path="sex">
<n-select
v-model:value="basicForm.sex"
:options="genderOptions"
placeholder="请选择性别"
/>
</n-form-item>
<n-form-item label="邮箱地址" path="email">
<n-input v-model:value="basicForm.email" placeholder="请输入邮箱地址" />
</n-form-item>
<n-form-item label="手机号码" path="phone">
<n-input v-model:value="basicForm.phone" placeholder="请输入手机号码" />
</n-form-item>
</div>
</n-form>
<!-- 操作按钮 -->
<div class="flex justify-start mt-6">
<n-space>
<n-button :loading="loading" @click="handleBasicReset">
<template #icon>
<icon-park-outline-refresh />
</template>
重置
</n-button>
<n-button type="primary" :loading="loading" @click="handleBasicSave">
<template #icon>
<icon-park-outline-save />
</template>
保存
</n-button>
</n-space>
</div>
</div>
</n-tab-pane>
<!-- 修改密码 -->
<n-tab-pane name="password" tab="修改密码">
<div class="pt-4">
<!-- 密码安全提示 -->
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
<div class="flex items-start">
<icon-park-outline-info class="text-yellow-600 text-lg mt-0.5 mr-3" />
<div>
<h4 class="text-yellow-800 font-medium">
密码安全提示
</h4>
<p class="text-yellow-700 text-sm mt-1">
为了您的账户安全建议定期更换密码新密码应包含字母数字长度不少于6位
</p>
</div>
</div>
</div>
<!-- 密码修改表单 -->
<n-form
ref="passwordFormRef"
:model="passwordForm"
:rules="passwordRules"
label-placement="left"
label-width="100px"
require-mark-placement="right-hanging"
>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<n-form-item label="当前密码" path="password">
<n-input
v-model:value="passwordForm.password"
type="password"
show-password-on="click"
placeholder="请输入当前密码"
/>
</n-form-item>
<n-form-item label="新密码" path="newPassword">
<n-input
v-model:value="passwordForm.newPassword"
type="password"
show-password-on="click"
placeholder="请输入新密码"
/>
</n-form-item>
<n-form-item label="确认新密码" path="confirmPassword" class="md:col-span-2">
<n-input
v-model:value="passwordForm.confirmPassword"
type="password"
show-password-on="click"
placeholder="请再次输入新密码"
/>
</n-form-item>
</div>
</n-form>
<!-- 操作按钮 -->
<div class="flex justify-start mt-6">
<n-space>
<n-button @click="handlePasswordReset">
<template #icon>
<icon-park-outline-refresh />
</template>
重置
</n-button>
<n-button type="warning" :loading="loading" @click="handlePasswordSubmit">
<template #icon>
<icon-park-outline-lock />
</template>
修改密码
</n-button>
</n-space>
</div>
</div>
</n-tab-pane>
</n-tabs>
</n-card>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useAuthStore } from '@/store/auth'
import { coiMsgError, coiMsgSuccess, coiMsgWarning } from '@/utils/coi'
import { getPersonalData, updateBasicData, updatePassword, uploadAvatar } from '@/service/api/personal'
import type { PersonalDataVo, UpdatePasswordBo, UpdatePersonalBo } from '@/service/api/personal'
import { serviceConfig } from '@/../service.config'
import DictTag from '@/components/common/DictTag.vue'
import { useDict } from '@/hooks'
//
const authStore = useAuthStore()
//
const loading = ref(false)
const uploading = ref(false)
const personalData = ref<PersonalDataVo>({
userId: 0,
userName: '',
loginName: '',
email: '',
phone: '',
sex: '1',
avatar: '',
userStatus: '0',
createTime: '',
roleNames: [],
roleName: '',
})
//
const basicFormRef = ref()
const passwordFormRef = ref()
const avatarUploadRef = ref()
//
const basicForm = ref<UpdatePersonalBo>({
userName: '',
email: '',
phone: '',
sex: '1',
avatar: '',
})
//
const passwordForm = ref<UpdatePasswordBo>({
password: '',
newPassword: '',
confirmPassword: '',
})
//
const basicRules = {
userName: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
],
email: [
{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' },
],
phone: [
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' },
],
}
const passwordRules = {
password: [
{ required: true, message: '请输入当前密码', trigger: 'blur' },
],
newPassword: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{ min: 6, message: '密码长度不能少于6位', trigger: 'blur' },
],
confirmPassword: [
{ required: true, message: '请确认新密码', trigger: 'blur' },
{
validator: (_rule: any, value: string) => {
if (value !== passwordForm.value.newPassword) {
return new Error('两次输入密码不一致')
}
return true
},
trigger: 'blur',
},
],
}
const { getSelectOptions, getDictLabel } = useDict(['sys_user_sex', 'sys_switch_status'])
const genderOptions = computed(() => getSelectOptions('sys_user_sex'))
function getGenderText(sex: string) {
return getDictLabel('sys_user_sex', sex, '未知')
}
//
function getRoleNames(): string[] {
if (personalData.value.roleNames && personalData.value.roleNames.length > 0) {
return personalData.value.roleNames
}
if (personalData.value.roleName) {
return [personalData.value.roleName]
}
return []
}
//
async function fetchPersonalData() {
try {
loading.value = true
const result = await getPersonalData()
if (result.isSuccess) {
personalData.value = result.data
//
basicForm.value = {
userName: result.data.userName,
email: result.data.email,
phone: result.data.phone,
sex: result.data.sex,
avatar: result.data.avatar,
}
}
}
catch {
coiMsgError('获取个人资料失败')
}
finally {
loading.value = false
}
}
//
function handleAvatarClick() {
avatarUploadRef.value?.click()
}
//
async function handleAvatarChange(event: Event) {
const target = event.target as HTMLInputElement
if (target.files && target.files[0]) {
const file = target.files[0]
//
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif']
if (!allowedTypes.includes(file.type)) {
coiMsgError('只支持JPG、PNG、GIF格式的图片')
return
}
// 2MB
const fileSizeMB = file.size / 1024 / 1024
if (fileSizeMB > 2) {
coiMsgError(`头像文件大小超出限制!当前文件:${fileSizeMB.toFixed(2)}MB最大允许2MB`)
return
}
try {
uploading.value = true
const result = await uploadAvatar(file, 2)
if (result.isSuccess) {
// 访URL
let avatarUrl = result.data.fileUploadPath
// URL
if (!avatarUrl.startsWith('http://') && !avatarUrl.startsWith('https://')) {
const baseUrl = serviceConfig[import.meta.env.MODE].url
avatarUrl = `${baseUrl}${avatarUrl}`
}
// URLMinIOOSS使
basicForm.value.avatar = avatarUrl
personalData.value.avatar = avatarUrl
coiMsgSuccess('头像上传成功')
//
await handleAvatarSave()
}
else {
coiMsgError('头像上传失败')
}
}
catch (error: any) {
//
let errorMessage = '头像上传失败'
//
if (error?.isSuccess === false) {
// alova
if (error.message) {
errorMessage = error.message
}
else if (error.msg) {
errorMessage = error.msg
}
}
else if (error?.response?.data?.msg) {
// HTTP
errorMessage = error.response.data.msg
}
else if (error?.message) {
//
if (error.message.includes('413')) {
errorMessage = '头像文件大小超出限制,请选择较小的文件'
}
else if (error.message.includes('400')) {
errorMessage = '头像文件格式不支持或文件无效'
}
else {
errorMessage = error.message
}
}
coiMsgError(errorMessage)
}
finally {
uploading.value = false
//
target.value = ''
}
}
}
//
async function handleAvatarSave() {
try {
const result = await updateBasicData({ avatar: basicForm.value.avatar })
if (result.isSuccess) {
coiMsgSuccess('头像保存成功')
//
authStore.updateUserInfo({ avatar: basicForm.value.avatar })
await fetchPersonalData()
}
else {
coiMsgError('头像保存失败')
}
}
catch {
coiMsgError('头像保存失败')
}
}
//
async function handleBasicSave() {
if (!basicFormRef.value)
return
try {
//
await basicFormRef.value.validate()
}
catch {
//
coiMsgWarning('验证失败,请检查填写内容')
return
}
// API
try {
loading.value = true
const result = await updateBasicData(basicForm.value)
if (result.isSuccess) {
coiMsgSuccess('保存成功')
//
authStore.updateUserInfo({
userName: basicForm.value.userName,
avatar: basicForm.value.avatar,
})
await fetchPersonalData()
}
else {
coiMsgError('保存失败')
}
}
catch {
coiMsgError('保存失败')
}
finally {
loading.value = false
}
}
//
function handleBasicReset() {
basicForm.value = {
userName: personalData.value.userName,
email: personalData.value.email,
phone: personalData.value.phone,
sex: personalData.value.sex,
avatar: personalData.value.avatar,
}
}
//
function handlePasswordReset() {
passwordForm.value = {
password: '',
newPassword: '',
confirmPassword: '',
}
}
//
async function handlePasswordSubmit() {
if (!passwordFormRef.value)
return
try {
//
await passwordFormRef.value.validate()
}
catch {
//
coiMsgWarning('验证失败,请检查填写内容')
return
}
// API
try {
loading.value = true
const result = await updatePassword(passwordForm.value)
if (result.isSuccess) {
coiMsgSuccess('密码修改成功')
//
handlePasswordReset()
}
else {
coiMsgError('密码修改失败')
}
}
catch {
coiMsgError('密码修改失败')
}
finally {
loading.value = false
}
}
//
function formatDate(dateStr: string) {
if (!dateStr)
return '-'
const date = new Date(dateStr)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
//
onMounted(() => {
fetchPersonalData()
})
</script>
<style scoped>
.dark .bg-gray-50 {
background-color: #1f2937;
}
.dark .bg-white {
background-color: #374151;
}
.dark .text-gray-900 {
color: #f9fafb;
}
.dark .text-gray-600 {
color: #d1d5db;
}
.dark .text-gray-700 {
color: #e5e7eb;
}
.dark .border-gray-100 {
border-color: #4b5563;
}
.dark .shadow-sm {
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
}
.dark .bg-yellow-50 {
background-color: rgba(245, 158, 11, 0.1);
}
.dark .border-yellow-200 {
border-color: rgba(245, 158, 11, 0.3);
}
.dark .text-yellow-800 {
color: #fbbf24;
}
.dark .text-yellow-700 {
color: #f59e0b;
}
.dark .text-yellow-600 {
color: #d97706;
}
</style>