This commit is contained in:
2026-04-01 14:17:35 +08:00
parent 8eaa36342e
commit 90cecbb246
9 changed files with 296 additions and 127 deletions
@@ -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>
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -113,7 +113,7 @@
"no_events": "没有找到事件",
"delete_event": "删除事件",
"edit_event": "编辑事件",
"tody": "今天",
"today": "今天",
"week": "本周",
"month": "本月",
"previous_month": "上月",
+14 -11
View File
@@ -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"