up
This commit is contained in:
@@ -285,7 +285,7 @@ function getsele() {
|
||||
</div>
|
||||
|
||||
<!-- Preview Area (when image selected but not cropped) -->
|
||||
<div v-show="!is_have_URL" class="rounded-lg border-2 border-dashed border-gray-300 bg-gray-50/50 p-8 text-center dark:border-gray-700 dark:bg-gray-900/20">
|
||||
<!-- <div v-show="!is_have_URL" class="rounded-lg border-2 border-dashed border-gray-300 bg-gray-50/50 p-8 text-center dark:border-gray-700 dark:bg-gray-900/20">
|
||||
<div class="mx-auto max-w-xs">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
@@ -295,7 +295,7 @@ function getsele() {
|
||||
{{ t('cropper.click_button_or_drag') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div> -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@
|
||||
"no_events": "No events found",
|
||||
"delete_event": "Delete Event",
|
||||
"edit_event": "Edit Event",
|
||||
"tody": "Today",
|
||||
"today": "Today",
|
||||
"week": "This Week",
|
||||
"month": "This Month",
|
||||
"previous_month": "Prev Month",
|
||||
|
||||
@@ -113,7 +113,7 @@
|
||||
"no_events": "没有找到事件",
|
||||
"delete_event": "删除事件",
|
||||
"edit_event": "编辑事件",
|
||||
"tody": "今天",
|
||||
"today": "今天",
|
||||
"week": "本周",
|
||||
"month": "本月",
|
||||
"previous_month": "上月",
|
||||
|
||||
@@ -37,7 +37,7 @@ const calendarOptions = ref({
|
||||
},
|
||||
|
||||
headerToolbar: {
|
||||
left: 'prevYear,prev,next,nextYear',
|
||||
left: 'prevYear,prev,today,next,nextYear',
|
||||
center: 'title',
|
||||
right: '',
|
||||
},
|
||||
@@ -51,14 +51,18 @@ const calendarOptions = ref({
|
||||
text: t('schedule.next_year'),
|
||||
click() { calendarRef.value.getApi().nextYear() },
|
||||
},
|
||||
prevMonth: {
|
||||
prev: {
|
||||
text: t('schedule.previous_month'),
|
||||
click() { calendarRef.value.getApi().prev() },
|
||||
},
|
||||
nextMonth: {
|
||||
next: {
|
||||
text: t('schedule.next_month'),
|
||||
click() { calendarRef.value.getApi().next() },
|
||||
},
|
||||
today: {
|
||||
text: t('schedule.today'),
|
||||
click() { calendarRef.value.getApi().today() },
|
||||
},
|
||||
week: {
|
||||
text: t('schedule.week'),
|
||||
click() { calendarRef.value.getApi().changeView('timeGridWeek') },
|
||||
@@ -66,19 +70,18 @@ const calendarOptions = ref({
|
||||
},
|
||||
|
||||
events: [
|
||||
{ title: 'Event1', date: '2025-11-10' },
|
||||
{ title: 'Event2', date: '2025-11-15', end: '2025-11-17' },
|
||||
{ title: 'Event3', date: '2025-11-20T10:30:00', end: '2025-11-20T12:30:00' },
|
||||
|
||||
],
|
||||
})
|
||||
|
||||
watch(locale, () => {
|
||||
calendarOptions.value.locale = locale.value
|
||||
calendarOptions.value.headerToolbar.customButtons.prevYear.text = t('schedule.previous_year')
|
||||
calendarOptions.value.headerToolbar.customButtons.nextYear.text = t('schedule.next_year')
|
||||
calendarOptions.value.headerToolbar.customButtons.prevMonth.text = t('schedule.previous_month')
|
||||
calendarOptions.value.headerToolbar.customButtons.nextMonth.text = t('schedule.next_month')
|
||||
calendarOptions.value.headerToolbar.customButtons.week.text = t('schedule.week')
|
||||
calendarOptions.value.customButtons.prevYear.text = t('schedule.previous_year')
|
||||
calendarOptions.value.customButtons.nextYear.text = t('schedule.next_year')
|
||||
calendarOptions.value.customButtons.prev.text = t('schedule.previous_month')
|
||||
calendarOptions.value.customButtons.next.text = t('schedule.next_month')
|
||||
calendarOptions.value.customButtons.today.text = t('schedule.today')
|
||||
calendarOptions.value.customButtons.week.text = t('schedule.week')
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,32 +1,60 @@
|
||||
<script setup>
|
||||
// Vue 核心响应式 API
|
||||
import { reactive, ref, onMounted } from 'vue'
|
||||
// 国际化 hook,用于翻译文本
|
||||
import { useI18n } from 'vue-i18n'
|
||||
// 用户状态管理
|
||||
import { useUserStore } from '@/stores/user'
|
||||
// Toast 通知状态管理
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
// 页面标题 composable
|
||||
import { usePageTitle } from '@/composables/usePageTitle'
|
||||
// 表单验证 composable
|
||||
import { useValidation } from '@/composables'
|
||||
// 认证 API
|
||||
import { authApi } from '@/api/auth'
|
||||
// 设置页面导航侧边栏组件
|
||||
import SettingNav from '@/components/SettingNav.vue'
|
||||
// 图片裁剪组件
|
||||
import ImageCropper from '@/components/imageCropper.vue'
|
||||
|
||||
// 设置页面标题,使用国际化 key
|
||||
usePageTitle('settings.account_settings')
|
||||
|
||||
// 初始化国际化翻译函数
|
||||
const { t } = useI18n()
|
||||
|
||||
// 获取用户 store 实例,用于访问和更新用户信息
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 获取 toast store 实例,用于显示操作结果通知
|
||||
const toast = useToastStore()
|
||||
|
||||
// 解构验证函数和错误状态,用于表单字段验证
|
||||
const { validate, errors, clearErrors } = useValidation()
|
||||
|
||||
// 表单数据,使用 reactive 创建响应式对象
|
||||
const form = reactive({
|
||||
username: '',
|
||||
remark: '',
|
||||
birthday: '',
|
||||
username: '', // 用户姓名
|
||||
remark: '', // 备注/昵称
|
||||
birthday: '', // 生日日期
|
||||
})
|
||||
|
||||
// 头像是否已被修改的标记
|
||||
const avatarHasChanged = ref(false)
|
||||
|
||||
// 裁剪后的头像 Base64 数据
|
||||
const avatarDataUrl = ref('')
|
||||
|
||||
// 保存按钮的加载状态,防止重复提交
|
||||
const loading = ref(false)
|
||||
|
||||
// 生日输入框的 DOM 引用,用于调用原生日期选择器
|
||||
const birthdayInput = ref(null)
|
||||
|
||||
// 组件挂载时执行的初始化逻辑
|
||||
onMounted(() => {
|
||||
// 如果用户信息已加载,则填充表单
|
||||
if (userStore.user || userStore.userInfo) {
|
||||
// 姓名从 userInfo.Username 获取
|
||||
form.username = userStore.userInfo?.Username || userStore.user?.Name || ''
|
||||
@@ -37,111 +65,167 @@ onMounted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 打开原生日期选择器
|
||||
* 使用 HTML5 showPicker API(在支持的浏览器中)或回退到 focus 事件
|
||||
*/
|
||||
function openDatePicker() {
|
||||
// 使用showPicker API打开日期选择器
|
||||
// 使用showPicker API打开日期选择器(现代浏览器支持)
|
||||
if (birthdayInput.value && birthdayInput.value.showPicker) {
|
||||
birthdayInput.value.showPicker()
|
||||
} else {
|
||||
// 对于不支持showPicker的老浏览器,聚焦输入框
|
||||
// 对于不支持showPicker的老浏览器,聚焦输入框触发选择器
|
||||
birthdayInput.value?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理裁剪后的头像数据
|
||||
* @param {string} dataUrl - 裁剪后的图片 Base64 URL
|
||||
*/
|
||||
function handleCrop(dataUrl) {
|
||||
avatarHasChanged.value = true
|
||||
avatarDataUrl.value = dataUrl
|
||||
avatarHasChanged.value = true // 标记头像已修改
|
||||
avatarDataUrl.value = dataUrl // 保存裁剪后的数据
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消头像修改,恢复原状
|
||||
*/
|
||||
function cancelAvatar() {
|
||||
avatarHasChanged.value = false
|
||||
avatarDataUrl.value = ''
|
||||
avatarHasChanged.value = false // 重置修改标记
|
||||
avatarDataUrl.value = '' // 清空裁剪数据
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Base64 字符串转换为 File 对象
|
||||
* @param {string} base64 - 包含 MIME 类型和数据的前缀的 Base64 字符串
|
||||
* @returns {File} 转换后的文件对象
|
||||
*/
|
||||
function base64ToFile(base64) {
|
||||
// 分离 MIME 信息和实际数据部分
|
||||
const [info, data] = base64.split(',')
|
||||
// 从 MIME 信息中提取文件类型
|
||||
const mime = info.match(/:(.*?);/)[1]
|
||||
// 解码 Base64 数据为二进制字符串
|
||||
const bytes = atob(data)
|
||||
// 转换为字节数组
|
||||
const arr = new Uint8Array(bytes.length)
|
||||
for (let i = 0; i < bytes.length; i++) arr[i] = bytes.charCodeAt(i)
|
||||
// 创建并返回 File 对象
|
||||
return new File([arr], 'avatar.png', { type: mime })
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存账户信息
|
||||
* 包含头像上传和个人资料更新
|
||||
*/
|
||||
async function handleSave() {
|
||||
// 清空之前的验证错误
|
||||
clearErrors()
|
||||
|
||||
// 验证必填字段:用户名
|
||||
const err1 = validate('username', form.username, t('settings.name'))
|
||||
// 验证必填字段:备注
|
||||
const err2 = validate('remark', form.remark, t('settings.remark'))
|
||||
// 验证必填字段:生日
|
||||
const err3 = validate('birthday', form.birthday, t('settings.birthday'))
|
||||
|
||||
// 如果任一验证失败,则阻止提交
|
||||
if (!err1 || !err2 || !err3) return
|
||||
|
||||
// 设置加载状态,防止重复提交
|
||||
loading.value = true
|
||||
try {
|
||||
// 如果头像有修改,先上传新头像
|
||||
if (avatarHasChanged.value) {
|
||||
// 将 Base64 转换为 File 对象
|
||||
const file = base64ToFile(avatarDataUrl.value)
|
||||
// 调用 API 上传头像
|
||||
await authApi.updateAvatar(file)
|
||||
// 重置头像修改状态
|
||||
avatarHasChanged.value = false
|
||||
}
|
||||
|
||||
// 更新用户基本信息
|
||||
const { errCode } = await authApi.updateInfo({
|
||||
username: form.username,
|
||||
remark: form.remark,
|
||||
birthday: form.birthday,
|
||||
username: form.username, // 用户名
|
||||
remark: form.remark, // 备注
|
||||
birthday: form.birthday, // 生日
|
||||
})
|
||||
|
||||
// 根据返回的错误码判断操作结果
|
||||
if (errCode === 0) {
|
||||
// 保存成功,显示成功提示
|
||||
toast.success(t('message.save_ok'))
|
||||
// 重新获取用户信息以更新本地状态
|
||||
await userStore.fetchUserInfo()
|
||||
} else {
|
||||
// 服务器错误,显示错误提示
|
||||
toast.error(t('message.server_error'))
|
||||
}
|
||||
|
||||
|
||||
|
||||
} catch {
|
||||
// 拦截器已处理
|
||||
// 网络错误等异常已被 axios 拦截器统一处理
|
||||
} finally {
|
||||
// 无论成功或失败,都要关闭加载状态
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 页面容器,最大宽度限制并居中 -->
|
||||
<div class="mx-auto max-w-5xl px-6 py-6">
|
||||
<!-- 页面标题 -->
|
||||
<h2 class="mb-6 text-2xl font-bold text-gray-900 dark:text-white">{{ t('settings.my_account') }}</h2>
|
||||
|
||||
<!-- 主内容区:左侧导航 + 右侧表单 -->
|
||||
<div class="flex flex-col gap-6 lg:flex-row lg:gap-8">
|
||||
<!-- 左侧设置导航菜单 -->
|
||||
<SettingNav />
|
||||
|
||||
<!-- 右侧表单区域 -->
|
||||
<div class="flex-1 space-y-6">
|
||||
<!-- Avatar Section -->
|
||||
|
||||
<!-- ========== 头像上传区域 ========== -->
|
||||
<div class="mb-8 rounded-xl border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-800 dark:bg-gray-900/50">
|
||||
<!-- 区域标题 -->
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">{{ t('settings.profile_picture') }}</h3>
|
||||
|
||||
<!-- 头像和操作横向布局 -->
|
||||
<div class="flex flex-col items-start gap-6 md:flex-row md:items-center">
|
||||
<!-- Avatar Preview -->
|
||||
<!-- 头像预览区域 -->
|
||||
<div class="relative">
|
||||
<!-- 头像图片:根据是否有修改显示新头像或原头像 -->
|
||||
<img
|
||||
:src="avatarHasChanged ? avatarDataUrl : userStore.avatarUrl"
|
||||
alt="Avatar"
|
||||
class="h-24 w-24 rounded-full border-4 border-white shadow-lg dark:border-gray-800"
|
||||
/>
|
||||
<div class="absolute -right-1 -top-1 h-6 w-6 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 p-0.5">
|
||||
<div class="h-full w-full rounded-full bg-white dark:bg-gray-900"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Avatar Actions -->
|
||||
|
||||
<!-- 头像操作区域 -->
|
||||
<div class="flex-1 space-y-4">
|
||||
<div class="space-y-3">
|
||||
<!-- 头像上传说明文字 -->
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ t('settings.avatar_description') }}
|
||||
</p>
|
||||
|
||||
<!-- Image Cropper Component -->
|
||||
|
||||
<!-- 图片裁剪组件:用户选择并裁剪新头像 -->
|
||||
<ImageCropper @crop-data-url="handleCrop" />
|
||||
|
||||
<!-- Cancel Button (when avatar changed) -->
|
||||
|
||||
<!-- 取消按钮(仅在头像有修改时显示) -->
|
||||
<div v-if="avatarHasChanged" class="flex items-center gap-3 pt-2">
|
||||
<!-- 未保存提示指示器 -->
|
||||
<div class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<div class="h-2 w-2 rounded-full bg-blue-500 animate-pulse"></div>
|
||||
{{ t('settings.avatar_unsaved') }}
|
||||
</div>
|
||||
<button
|
||||
<!-- 取消修改按钮 -->
|
||||
<button
|
||||
class="ml-auto rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 hover:border-gray-400 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:border-gray-600"
|
||||
@click="cancelAvatar"
|
||||
>
|
||||
@@ -153,8 +237,9 @@ async function handleSave() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Profile Information Form -->
|
||||
<!-- ========== 个人资料表单区域 ========== -->
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-800 dark:bg-gray-900/50">
|
||||
<!-- 表单区域标题 -->
|
||||
<div class="mb-6">
|
||||
<h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">{{ t('settings.profile_information') }}</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
@@ -162,62 +247,75 @@ async function handleSave() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Form Grid -->
|
||||
<!-- 表单字段区域 -->
|
||||
<div class="space-y-6">
|
||||
<!-- Name and Remark Row -->
|
||||
<!-- 用户名和备注行(桌面端双列布局) -->
|
||||
<div class="grid gap-6 md:grid-cols-2">
|
||||
|
||||
<!-- 用户名字段 -->
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('settings.name') }}
|
||||
<!-- 必填标记 -->
|
||||
<span class="ml-1 text-red-500">*</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="form.username"
|
||||
type="text"
|
||||
:placeholder="t('settings.placeholder_name')"
|
||||
class="w-full rounded-lg border bg-white px-4 py-3 text-sm outline-none transition-all focus:ring-2 dark:bg-gray-900 dark:text-white"
|
||||
:class="errors.username ? 'border-red-500 focus:border-red-500 focus:ring-red-500/20' : 'border-gray-300 focus:border-blue-500 focus:ring-blue-500/20 dark:border-gray-700'"
|
||||
/>
|
||||
<!-- 用户名输入框 -->
|
||||
<input
|
||||
v-model="form.username"
|
||||
type="text"
|
||||
:placeholder="t('settings.placeholder_name')"
|
||||
class="w-full rounded-lg border bg-white px-4 py-3 text-sm outline-none transition-all focus:ring-2 dark:bg-gray-900 dark:text-white"
|
||||
:class="errors.username ? 'border-red-500 focus:border-red-500 focus:ring-red-500/20' : 'border-gray-300 focus:border-blue-500 focus:ring-blue-500/20 dark:border-gray-700'"
|
||||
/>
|
||||
<!-- 用户图标(右侧装饰) -->
|
||||
<div class="absolute right-3 top-3 pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 验证错误提示 -->
|
||||
<span v-if="errors.username" class="block text-xs text-red-500">{{ errors.username }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 备注/昵称字段 -->
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('settings.remark') }}
|
||||
<!-- 必填标记 -->
|
||||
<span class="ml-1 text-red-500">*</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="form.remark"
|
||||
type="text"
|
||||
:placeholder="t('settings.placeholder_remark')"
|
||||
class="w-full rounded-lg border bg-white px-4 py-3 text-sm outline-none transition-all focus:ring-2 dark:bg-gray-900 dark:text-white"
|
||||
:class="errors.remark ? 'border-red-500 focus:border-red-500 focus:ring-red-500/20' : 'border-gray-300 focus:border-blue-500 focus:ring-blue-500/20 dark:border-gray-700'"
|
||||
/>
|
||||
<!-- 备注输入框 -->
|
||||
<input
|
||||
v-model="form.remark"
|
||||
type="text"
|
||||
:placeholder="t('settings.placeholder_remark')"
|
||||
class="w-full rounded-lg border bg-white px-4 py-3 text-sm outline-none transition-all focus:ring-2 dark:bg-gray-900 dark:text-white"
|
||||
:class="errors.remark ? 'border-red-500 focus:border-red-500 focus:ring-red-500/20' : 'border-gray-300 focus:border-blue-500 focus:ring-blue-500/20 dark:border-gray-700'"
|
||||
/>
|
||||
<!-- 备注图标(右侧装饰) -->
|
||||
<div class="absolute right-3 top-3 pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 验证错误提示 -->
|
||||
<span v-if="errors.remark" class="block text-xs text-red-500">{{ errors.remark }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Birthday Row -->
|
||||
<!-- 生日字段 -->
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('settings.birthday') }}
|
||||
<!-- 可选标记 -->
|
||||
<span class="ml-1 text-gray-400">({{ t('settings.optional') }})</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<!-- 日期选择输入框 -->
|
||||
<input
|
||||
v-model="form.birthday"
|
||||
type="date"
|
||||
@@ -226,37 +324,39 @@ async function handleSave() {
|
||||
@click="openDatePicker"
|
||||
ref="birthdayInput"
|
||||
/>
|
||||
<div class="absolute right-3 top-3 pointer-events-none">
|
||||
<!-- <svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg> -->
|
||||
</div>
|
||||
</div>
|
||||
<!-- 验证错误提示 -->
|
||||
<span v-if="errors.birthday" class="block text-xs text-red-500">{{ errors.birthday }}</span>
|
||||
<!-- 帮助提示文字 -->
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('settings.birthday_help') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save Button -->
|
||||
<!-- 保存按钮区域 -->
|
||||
<div class="mt-8 border-t border-gray-100 pt-6 dark:border-gray-800">
|
||||
<div class="flex items-center justify-between">
|
||||
<!-- 保存提示文字 -->
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ t('settings.save_notice') }}
|
||||
</div>
|
||||
<!-- 保存按钮 -->
|
||||
<button
|
||||
class="group flex items-center gap-2 rounded-lg bg-gradient-to-r from-blue-600 to-blue-500 px-6 py-3 text-sm font-semibold text-white shadow-sm transition-all hover:from-blue-700 hover:to-blue-600 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-blue-500/30"
|
||||
:disabled="loading"
|
||||
@click="handleSave"
|
||||
>
|
||||
<!-- 保存成功图标(未加载时显示) -->
|
||||
<svg v-if="!loading" class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<!-- 加载中旋转图标 -->
|
||||
<svg v-else class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
<!-- 按钮文字:加载中显示"保存中",否则显示"保存更改" -->
|
||||
{{ loading ? t('settings.saving') : t('settings.save_changes') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,61 +1,111 @@
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
// Vue 核心响应式 API
|
||||
import { reactive, ref ,onMounted} from 'vue'
|
||||
// 国际化 hook,用于翻译文本
|
||||
import { useI18n } from 'vue-i18n'
|
||||
// 用户状态管理
|
||||
import { useUserStore } from '@/stores/user'
|
||||
// Toast 通知状态管理
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
// 页面标题 composable
|
||||
import { usePageTitle } from '@/composables/usePageTitle'
|
||||
// 表单验证 composable 和邮箱验证函数
|
||||
import { useValidation, isValidEmail } from '@/composables'
|
||||
// 认证 API
|
||||
import { authApi } from '@/api/auth'
|
||||
// 设置页面导航侧边栏组件
|
||||
import SettingNav from '@/components/SettingNav.vue'
|
||||
|
||||
// 设置页面标题
|
||||
usePageTitle('settings.contact_information')
|
||||
|
||||
// 初始化国际化翻译函数
|
||||
const { t } = useI18n()
|
||||
|
||||
// 获取用户 store 实例
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 获取 toast store 实例,用于显示操作结果通知
|
||||
const toast = useToastStore()
|
||||
|
||||
// 解构验证函数和错误状态
|
||||
const { validate, errors, clearErrors } = useValidation()
|
||||
|
||||
// 表单数据:仅包含邮箱字段
|
||||
const form = reactive({ email: '' })
|
||||
|
||||
// 保存按钮的加载状态
|
||||
const loading = ref(false)
|
||||
|
||||
/**
|
||||
* 修改邮箱地址
|
||||
* 验证邮箱格式后调用 API 更新
|
||||
*/
|
||||
async function handleChangeEmail() {
|
||||
// 清空之前的验证错误
|
||||
clearErrors()
|
||||
|
||||
// 验证邮箱:必填 + 格式校验
|
||||
const err = validate('email', form.email, t('message.please_enter_your_email'), isValidEmail)
|
||||
// 验证失败则阻止提交
|
||||
if (!err) return
|
||||
|
||||
// 设置加载状态
|
||||
loading.value = true
|
||||
try {
|
||||
// 调用 API 修改邮箱
|
||||
const { errCode } = await authApi.changeEmail(form.email)
|
||||
|
||||
// 根据错误码处理不同结果
|
||||
switch (errCode) {
|
||||
case 0:
|
||||
// 成功:显示成功提示并刷新用户信息
|
||||
toast.success(t('message.change_ok'))
|
||||
await userStore.fetchUserInfo()
|
||||
break
|
||||
case -43:
|
||||
form.email = t('message.this_not_email')
|
||||
// 邮箱格式无效:在输入框显示提示并弹出错误
|
||||
//form.email = t('message.this_not_email')
|
||||
toast.error(t('message.this_not_email'))
|
||||
break
|
||||
default:
|
||||
// 其他错误:显示通用服务器错误
|
||||
toast.error(t('message.server_error'))
|
||||
}
|
||||
} catch {
|
||||
// 拦截器已处理
|
||||
// 网络错误等异常已被 axios 拦截器统一处理
|
||||
} finally {
|
||||
// 关闭加载状态
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(()=>{
|
||||
form.email=userStore.user.Email
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<!-- 页面容器,最大宽度限制并居中 -->
|
||||
<div class="mx-auto max-w-5xl px-6 py-6">
|
||||
<!-- 页面标题 -->
|
||||
<h2 class="mb-6 text-2xl font-bold text-gray-900 dark:text-white">{{ t('settings.my_account') }}</h2>
|
||||
|
||||
<!-- 主内容区:左侧导航 + 右侧表单 -->
|
||||
<div class="flex flex-col gap-6 lg:flex-row lg:gap-8">
|
||||
<!-- 左侧设置导航菜单 -->
|
||||
<SettingNav />
|
||||
|
||||
<!-- 右侧内容区域 -->
|
||||
<div class="flex-1 space-y-6">
|
||||
<!-- 区块标题 -->
|
||||
<h3 class="mb-4 text-sm font-semibold uppercase text-gray-400 tracking-wider dark:text-gray-500">{{ t('settings.email') }}</h3>
|
||||
|
||||
<!-- 邮箱输入和修改按钮区域 -->
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start">
|
||||
<!-- 邮箱输入框 -->
|
||||
<div class="flex-1">
|
||||
<input
|
||||
v-model="form.email"
|
||||
@@ -65,8 +115,11 @@ async function handleChangeEmail() {
|
||||
:placeholder="t('message.your_email_address')"
|
||||
@keydown.enter="handleChangeEmail"
|
||||
/>
|
||||
<!-- 验证错误提示 -->
|
||||
<span v-if="errors.email" class="mt-1 block text-xs text-red-500">{{ errors.email }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 修改邮箱按钮 -->
|
||||
<button
|
||||
class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-blue-700 focus:ring-2 focus:ring-blue-500/20 focus:outline-none disabled:active:scale-100"
|
||||
:disabled="loading"
|
||||
|
||||
Reference in New Issue
Block a user