部分重构

This commit is contained in:
2026-03-31 20:06:53 +08:00
parent 6d79836682
commit 498cc78dce
43 changed files with 6049 additions and 131 deletions
@@ -25,7 +25,7 @@ const userDropdownOpen = ref(false);
function toggleTheme() {
isDark.value = !isDark.value;
document.documentElement.classList.toggle("dark", isDark.value);
localStorage.setItem("tablerTheme", isDark.value ? "dark" : "light");
localStorage.setItem("theme", isDark.value ? "dark" : "light"); // 使用统一的'theme' key
}
function toggleLocale() {
@@ -114,7 +114,11 @@ const navItems = computed(() => [
class="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-sm text-gray-600 transition-colors hover:bg-gray-100 dark:text-dk-subtle dark:hover:bg-dk-card"
@click="userDropdownOpen = !userDropdownOpen"
>
<IconUser :size="20" />
<img
:src="userStore.avatarUrl"
class="h-6 w-6 rounded-full object-cover"
alt="avatar"
/>
<span class="max-w-24 truncate">{{
userStore.user?.Name || ""
}}</span>
@@ -23,18 +23,18 @@ const icons = {
>
<div
v-if="toastStore.visible"
class="fixed left-1/2 top-5 z-[9999] flex max-w-sm -translate-x-1/2 transform items-start gap-3 rounded-lg border-0 bg-white px-4 py-3 shadow-lg dark:bg-dk-card dark:text-dk-text"
class="fixed left-1/2 top-5 z-[9999] flex max-w-sm -translate-x-1/2 transform items-start gap-3 rounded-lg border-0 px-4 py-3 shadow-lg text-white"
:class="{
'text-green-700': toastStore.type === 'success',
'text-blue-700': toastStore.type === 'warning',
'text-red-700': toastStore.type === 'error',
'text-gray-700': toastStore.type === 'info',
'bg-green-600': toastStore.type === 'success',
'bg-yellow-600': toastStore.type === 'warning',
'bg-red-600': toastStore.type === 'danger',
'bg-slate-700': toastStore.type === 'info',
}"
role="alert"
>
<component :is="icons[toastStore.type] || IconInfoCircle" :size="20" class="mt-0.5 shrink-0" />
<span class="flex-1 text-sm">{{ toastStore.message }}</span>
<button class="ml-1 text-white/70 hover:text-white" @click="toastStore.hide()">×</button>
<button class="ml-1 opacity-70 hover:opacity-100" @click="toastStore.hide()">×</button>
</div>
</Transition>
</template>
@@ -37,27 +37,112 @@ function getsele() {
</script>
<template>
<div class="flex flex-col md:flex-row">
<div v-show="!is_have_URL" class="w-full py-3 md:w-auto md:px-3">
<button class="rounded-lg border border-blue-600 px-4 py-2 text-sm font-medium text-blue-600 transition-colors hover:bg-blue-50 dark:border-blue-400 dark:text-blue-400 dark:hover:bg-blue-900/20" @click="openFilePicker">{{ t("cropper.select_image") }}</button>
<div class="space-y-4">
<!-- Header and Instruction -->
<div class="flex items-center justify-between">
<div class="space-y-1">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">{{ t('cropper.upload_image') }}</h4>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('cropper.supported_formats') }}</p>
</div>
<button
v-show="!is_have_URL"
class="group flex items-center gap-2 rounded-lg bg-gradient-to-r from-blue-600 to-blue-500 px-4 py-2.5 text-sm font-medium 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"
@click="openFilePicker"
>
<svg 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="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
{{ t("cropper.select_image") }}
</button>
</div>
<cropper-canvas ref="cro_canv" class="cropper-container" :hidden="!is_have_URL" background scale-step="0.1">
<cropper-image ref="cro_imag" src="" alt="Picture" initialCenterSize="cover" rotatable scalable skewable translatable></cropper-image>
<cropper-shade hidden></cropper-shade>
<cropper-handle action="move" plain></cropper-handle>
<cropper-selection ref="cro_sele">
<cropper-grid role="grid" covered></cropper-grid>
<cropper-crosshair centered></cropper-crosshair>
<cropper-handle action="move" theme-color="rgba(255, 255, 255, 0)"></cropper-handle>
</cropper-selection>
</cropper-canvas>
<div v-show="is_have_URL" class="mt-3 flex gap-2 md:ml-3 md:mt-0">
<button class="rounded-lg border border-blue-600 px-4 py-2 text-sm font-medium text-blue-600 transition-colors hover:bg-blue-50 dark:border-blue-400 dark:text-blue-400 dark:hover:bg-blue-900/20" @click="getsele">{{ t("cropper.crop_image") }}</button>
<button class="rounded-lg border border-red-600 px-4 py-2 text-sm font-medium text-red-600 transition-colors hover:bg-red-50 dark:border-red-400 dark:text-red-400 dark:hover:bg-red-900/20" @click="cancel">{{ t("cropper.closs") }}</button>
<!-- Cropper Area -->
<div v-show="is_have_URL" class="rounded-xl border border-gray-200 bg-gray-50/50 p-4 dark:border-gray-800 dark:bg-gray-900/30">
<cropper-canvas
ref="cro_canv"
class="cropper-container mx-auto"
:hidden="!is_have_URL"
background
scale-step="0.1"
>
<cropper-image ref="cro_imag" src="" alt="Picture" initialCenterSize="cover" rotatable scalable skewable translatable></cropper-image>
<cropper-shade hidden></cropper-shade>
<cropper-handle action="move" plain></cropper-handle>
<cropper-selection ref="cro_sele">
<cropper-grid role="grid" covered></cropper-grid>
<cropper-crosshair centered></cropper-crosshair>
<cropper-handle action="move" theme-color="rgba(255, 255, 255, 0)"></cropper-handle>
</cropper-selection>
</cropper-canvas>
<!-- Cropper Actions -->
<div class="mt-4 flex flex-wrap items-center justify-between gap-3">
<div class="text-xs text-gray-500 dark:text-gray-400">
<span class="inline-flex items-center gap-1">
<svg class="h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-11a1 1 0 10-2 0v3.586L7.707 9.293a1 1 0 00-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L11 10.586V7z" clip-rule="evenodd" />
</svg>
{{ t('cropper.drag_to_resize') }}
</span>
</div>
<div class="flex gap-2">
<button
class="group flex items-center gap-2 rounded-lg bg-gradient-to-r from-blue-600 to-blue-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition-all hover:from-blue-700 hover:to-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500/30"
@click="getsele"
>
<svg 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>
{{ t("cropper.crop_image") }}
</button>
<button
class="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="cancel"
>
{{ t("cropper.closs") }}
</button>
</div>
</div>
</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 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" />
</svg>
<h4 class="mt-4 text-sm font-medium text-gray-900 dark:text-white">{{ t('cropper.ready_to_upload') }}</h4>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('cropper.click_button_or_drag') }}
</p>
</div>
</div>
</div>
</template>
<style scoped>
.cropper-container { box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); border-radius: 0.5rem; }
.cropper-container {
width: 100%;
max-width: 400px;
height: 300px;
border-radius: 12px;
box-shadow: 0 8px 25px -5px rgba(0, 0, 0, 0.08), 0 4px 10px -2px rgba(0, 0, 0, 0.04);
overflow: hidden;
border: 1px solid rgba(0, 0, 0, 0.05);
transition: box-shadow 0.2s ease;
}
.cropper-container:hover {
box-shadow: 0 12px 32px -8px rgba(0, 0, 0, 0.12), 0 6px 14px -4px rgba(0, 0, 0, 0.06);
}
.dark .cropper-container {
box-shadow: 0 8px 25px -5px rgba(0, 0, 0, 0.25), 0 4px 10px -2px rgba(0, 0, 0, 0.15);
border: 1px solid rgba(255, 255, 255, 0.05);
}
.dark .cropper-container:hover {
box-shadow: 0 12px 32px -8px rgba(0, 0, 0, 0.35), 0 6px 14px -4px rgba(0, 0, 0, 0.2);
}
</style>
+15 -2
View File
@@ -39,7 +39,12 @@
"select_File": "Select File",
"crop_image": "Crop Image",
"cancel": "Cancel",
"closs": "Closs"
"closs": "Close",
"upload_image": "Upload Image",
"supported_formats": "Supports JPG, PNG, GIF formats",
"drag_to_resize": "Drag to resize and reposition",
"ready_to_upload": "Ready to upload avatar",
"click_button_or_drag": "Click upload button or drag & drop image file"
},
"purchase": {
"purchase_list": "Purchase List",
@@ -169,12 +174,14 @@
},
"settings": {
"cancel": "Cancel",
"cancel_changes": "Cancel Changes",
"basic_information": "Basic Information",
"contact_information": "Contact Information",
"security_settings": "Security Settings",
"account_settings": "Account Settings",
"my_account": "My Account",
"profile_information": "Profile Information",
"profile_picture": "Profile Picture",
"change_avatar": "Change Avatar",
"change_email": "Change Email",
"name": "Name",
@@ -191,8 +198,14 @@
"site_description": "Site Description",
"site_keywords": "Site Keywords",
"save_changes": "Save Changes",
"saving": "Saving...",
"password": "Password",
"set_new_password": "Set New Password"
"set_new_password": "Set New Password",
"avatar_description": "Upload a clear profile picture. Supports JPG, PNG formats. Recommended size is at least 256×256 pixels.",
"avatar_unsaved": "Avatar changes not saved",
"optional": "Optional",
"birthday_help": "Select your birthday for personalized services",
"save_notice": "Your personal information will be updated after saving"
},
"button": {
"submit": "Submit",
+15 -2
View File
@@ -39,7 +39,12 @@
"select_File": "选择文件",
"crop_image": "裁剪图片",
"cancel": "取消",
"closs": "关闭"
"closs": "关闭",
"upload_image": "上传图片",
"supported_formats": "支持JPG、PNG、GIF格式",
"drag_to_resize": "可拖动调整大小与位置",
"ready_to_upload": "准备上传头像",
"click_button_or_drag": "点击上传按钮或拖放图片文件"
},
"purchase": {
"purchase_list": "采购列表",
@@ -169,12 +174,14 @@
},
"settings": {
"cancel": "取消",
"cancel_changes": "取消更改",
"basic_information": "基本信息",
"contact_information": "联系信息",
"security_settings": "安全设置",
"account_settings": "个人设置",
"my_account": "我的账户",
"profile_information": "个人信息",
"profile_picture": "个人头像",
"change_avatar": "更改头像",
"change_email": "更改邮箱",
"name": "姓名",
@@ -191,8 +198,14 @@
"site_description": "网站描述",
"site_keywords": "网站关键词",
"save_changes": "保存更改",
"saving": "保存中...",
"password": "密码",
"set_new_password": "设置新密码"
"set_new_password": "设置新密码",
"avatar_description": "上传一张清晰的头像照片,支持JPG、PNG格式,建议尺寸不少于256×256像素。",
"avatar_unsaved": "头像修改未保存",
"optional": "选填",
"birthday_help": "选择您的生日,用于个性化服务",
"save_notice": "保存后将更新您的个人信息"
},
"button": {
"submit": "提交",
@@ -5,7 +5,7 @@ import AppToast from '@/components/AppToast.vue'
</script>
<template>
<div class="flex min-h-screen flex-col">
<div class="flex min-h-screen flex-col bg-gray-50 dark:bg-dk-base">
<AppHeader />
<main class="flex-1">
<RouterView />
+5 -3
View File
@@ -6,9 +6,11 @@ import router from './router'
import './assets/main.css'
// Restore saved theme before app mounts
const savedTheme = localStorage.getItem('tablerTheme')
if (savedTheme === 'dark' || (!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
// Initialize theme
const savedTheme = localStorage.getItem('theme') // 改用 'theme' key
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
if (savedTheme === 'dark' || (!savedTheme && prefersDark)) {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
+5 -4
View File
@@ -45,14 +45,15 @@ async function handleLogin() {
case 0:
userStore.login(data.cookie)
toast.success(t('message.login_successful'))
const redirectPath = router.query.redirect || '/'
router.push(redirectPath)
// 有 redirect 则跳转到原页面,否则去首页
const redirect = router.currentRoute.value.query.redirect
router.replace(redirect || '/')
break
case -42:
toast.danger(t('message.username_or_password_incorrect'))
toast.warning(t('message.username_or_password_incorrect'))
break
default:
toast.error(t('message.server_error'))
toast.danger(t('message.server_error'))
}
} catch {
// 拦截器已处理
@@ -95,67 +95,156 @@ async function handleSave() {
<div class="flex flex-col gap-6 lg:flex-row lg:gap-8">
<SettingNav />
<div class="flex-1 space-y-6">
<!-- Avatar -->
<div class="mb-6 flex items-center gap-4">
<div>
<img
:src="avatarHasChanged ? avatarDataUrl : userStore.avatarUrl"
alt="Avatar"
class="h-16 w-16 rounded-full border-2 border-gray-200 object-cover dark:border-dk-muted"
/>
</div>
<div>
<ImageCropper @crop-data-url="handleCrop" />
<button v-if="avatarHasChanged" class="mt-2 rounded-lg border border-gray-300 px-3 py-1 text-xs text-gray-600 hover:bg-gray-50 dark:border-dk-muted dark:text-gray-400 dark:hover:bg-dk-card" @click="cancelAvatar">
{{ t('settings.cancel') }}
</button>
<!-- 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
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"
>
{{ t('settings.cancel_changes') }}
</button>
</div>
</div>
</div>
</div>
</div>
<h3 class="mb-4 text-sm font-semibold uppercase text-gray-400 tracking-wider dark:text-gray-500">Profile</h3>
<!-- 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">
{{ t('settings.basic_information') }}
</p>
</div>
<!-- Form -->
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('settings.name') }}</label>
<input
v-model="form.username"
type="text"
class="w-full rounded-lg border border-gray-300 bg-white px-3.5 py-2 text-sm outline-none transition-colors focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 dark:border-dk-muted dark:bg-dk-base dark:text-white"
:class="errors.username ? 'border-red-500' : 'border-gray-300'"
/>
<span v-if="errors.username" class="mt-1 block text-xs text-red-500">{{ errors.username }}</span>
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('settings.remark') }}</label>
<input
v-model="form.remark"
type="text"
class="w-full rounded-lg border border-gray-300 bg-white px-3.5 py-2 text-sm outline-none transition-colors focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 dark:border-dk-muted dark:bg-dk-base dark:text-white"
:class="errors.remark ? 'border-red-500' : 'border-gray-300'"
/>
<span v-if="errors.remark" class="mt-1 block text-xs text-red-500">{{ errors.remark }}</span>
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('settings.birthday') }}</label>
<input
v-model="form.birthday"
type="date"
class="w-full rounded-lg border border-gray-300 bg-white px-3.5 py-2 text-sm outline-none transition-colors focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 dark:border-dk-muted dark:bg-dk-base dark:text-white"
:class="errors.birthday ? 'border-red-500' : 'border-gray-300'"
/>
<span v-if="errors.birthday" class="mt-1 block text-xs text-red-500">{{ errors.birthday }}</span>
</div>
</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="请输入您的姓名"
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">
<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="mt-6">
<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"
@click="handleSave"
>
{{ t('settings.save_changes') }}
</button>
<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-gray-400">({{ t('settings.optional') }})</span>
</label>
<div class="relative">
<input
v-model="form.remark"
type="text"
placeholder="个人简介或备注"
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">
<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"
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.birthday ? '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">
<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>
</div>
</div>
</div>
</div>