增加更多可配置的部分
This commit is contained in:
@@ -70,15 +70,7 @@
|
||||
|
||||
<!-- Desktop Actions -->
|
||||
<div class="hidden md:flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="cycleTheme"
|
||||
class="text-art-muted hover:text-art-text transition-colors p-1"
|
||||
:title="themeLabel"
|
||||
:aria-label="themeLabel"
|
||||
>
|
||||
<Icon :name="themeIcon" :size="20" />
|
||||
</button>
|
||||
<ThemeToggle variant="icon" />
|
||||
<a href="#" class="text-art-muted hover:text-art-text transition-colors"><Icon name="github" :size="20" /></a>
|
||||
<a href="/admin" target="_blank" class="text-art-muted hover:text-art-text transition-colors" title="后台管理">
|
||||
<Icon name="layout-dashboard" :size="20" />
|
||||
@@ -182,14 +174,7 @@
|
||||
</nav>
|
||||
|
||||
<div class="pt-6 border-t border-art-border space-y-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="cycleTheme"
|
||||
class="flex items-center gap-2 text-art-muted hover:text-art-text transition-colors text-sm w-full"
|
||||
>
|
||||
<Icon :name="themeIcon" :size="18" />
|
||||
<span>{{ themeLabel }}</span>
|
||||
</button>
|
||||
<ThemeToggle variant="label" />
|
||||
<div class="flex items-center gap-4">
|
||||
<a href="#" class="flex items-center gap-2 text-art-muted hover:text-art-text transition-colors text-sm">
|
||||
<Icon name="github" :size="18" />
|
||||
@@ -219,13 +204,12 @@
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { usePublicSettings } from '../composables/usePublicSettings'
|
||||
import { useTheme } from '../composables/useTheme'
|
||||
import Icon from './Icon.vue'
|
||||
import ThemeToggle from './ThemeToggle.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { cache, getStringSetting } = usePublicSettings()
|
||||
const { themeIcon, themeLabel, cycleTheme } = useTheme()
|
||||
const activeNav = ref('home')
|
||||
const siteTitle = ref<string>('')
|
||||
const visibleMenus = ref<string[]>(['home', 'blog', 'columns', 'works', 'videos', 'snippets', 'about', 'services'])
|
||||
|
||||
@@ -9,10 +9,7 @@
|
||||
<a v-if="visibleMenus.includes('snippets')" @click="navigate('/snippets')" class="font-serif text-3xl italic text-art-text hover:text-art-accent cursor-pointer">代码</a>
|
||||
<a v-if="visibleMenus.includes('about')" @click="navigate('/about')" class="font-serif text-3xl italic text-art-text hover:text-art-accent cursor-pointer">关于</a>
|
||||
<a v-if="visibleMenus.includes('services')" @click="navigate('/services')" class="font-serif text-3xl italic text-art-text hover:text-art-accent cursor-pointer">合作</a>
|
||||
<button type="button" @click="cycleTheme" class="font-mono text-sm text-art-muted hover:text-art-accent flex items-center gap-2">
|
||||
<Icon :name="themeIcon" :size="18" />
|
||||
{{ themeLabel }}
|
||||
</button>
|
||||
<ThemeToggle variant="label" class="font-mono" />
|
||||
<a @click="openAdmin" class="font-mono text-sm text-art-muted mt-4">管理入口</a>
|
||||
</div>
|
||||
</template>
|
||||
@@ -21,12 +18,10 @@
|
||||
import { useRouter } from 'vue-router'
|
||||
import { onMounted, onUpdated, ref, watch } from 'vue'
|
||||
import { usePublicSettings } from '../composables/usePublicSettings'
|
||||
import { useTheme } from '../composables/useTheme'
|
||||
import Icon from './Icon.vue'
|
||||
import ThemeToggle from './ThemeToggle.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const { cache, getStringSetting } = usePublicSettings()
|
||||
const { themeIcon, themeLabel, cycleTheme } = useTheme()
|
||||
const visibleMenus = ref<string[]>(['home', 'blog', 'columns', 'works', 'videos', 'snippets', 'about', 'services'])
|
||||
|
||||
const applyMenuSettings = () => {
|
||||
|
||||
81
client/src/components/ThemeToggle.vue
Normal file
81
client/src/components/ThemeToggle.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<div :class="variant === 'icon' ? 'relative group' : 'w-full space-y-3'">
|
||||
<!-- Trigger: click cycles -->
|
||||
<button
|
||||
type="button"
|
||||
:class="
|
||||
variant === 'icon'
|
||||
? 'text-art-muted hover:text-art-text transition-colors p-1'
|
||||
: 'flex items-center gap-2 text-art-muted hover:text-art-text transition-colors text-sm'
|
||||
"
|
||||
:title="themeLabel"
|
||||
:aria-label="themeLabel"
|
||||
:aria-haspopup="variant === 'icon'"
|
||||
@click="cycleTheme"
|
||||
>
|
||||
<Icon :name="themeIcon" :size="variant === 'icon' ? 20 : 18" />
|
||||
<span v-if="variant === 'label'">{{ themeLabel }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Desktop: hover / focus-within dropdown -->
|
||||
<div
|
||||
v-if="variant === 'icon'"
|
||||
class="absolute right-0 top-full pt-2 opacity-0 invisible pointer-events-none group-hover:opacity-100 group-hover:visible group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:visible group-focus-within:pointer-events-auto transition-opacity duration-150 z-[60]"
|
||||
>
|
||||
<div
|
||||
class="min-w-[9.5rem] py-1 rounded-lg border border-art-border bg-art-surface/95 backdrop-blur-md shadow-xl"
|
||||
role="menu"
|
||||
>
|
||||
<button
|
||||
v-for="opt in options"
|
||||
:key="opt.value"
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
:aria-checked="themePref === opt.value"
|
||||
class="flex w-full items-center gap-2.5 px-3 py-2 text-sm text-left transition-colors hover:bg-art-text/5"
|
||||
:class="themePref === opt.value ? 'text-art-accent' : 'text-art-muted hover:text-art-text'"
|
||||
@click.stop="setThemePref(opt.value)"
|
||||
>
|
||||
<Icon :name="opt.icon" :size="16" />
|
||||
<span>{{ opt.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile label: inline three options -->
|
||||
<div v-else class="flex flex-col gap-0.5" role="menu">
|
||||
<button
|
||||
v-for="opt in options"
|
||||
:key="opt.value"
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
:aria-checked="themePref === opt.value"
|
||||
class="flex items-center gap-2.5 px-2 py-2 text-sm text-left rounded-md transition-colors"
|
||||
:class="themePref === opt.value ? 'text-art-accent bg-art-text/5' : 'text-art-muted hover:text-art-text hover:bg-art-text/5'"
|
||||
@click="setThemePref(opt.value)"
|
||||
>
|
||||
<Icon :name="opt.icon" :size="16" />
|
||||
<span>{{ opt.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTheme, type ThemePref } from '../composables/useTheme'
|
||||
import Icon from './Icon.vue'
|
||||
|
||||
withDefaults(defineProps<{
|
||||
variant?: 'icon' | 'label'
|
||||
}>(), {
|
||||
variant: 'icon',
|
||||
})
|
||||
|
||||
const { themePref, themeIcon, themeLabel, setThemePref, cycleTheme } = useTheme()
|
||||
|
||||
const options: Array<{ value: ThemePref; label: string; icon: string }> = [
|
||||
{ value: 'system', label: '跟随系统', icon: 'monitor' },
|
||||
{ value: 'light', label: '亮色', icon: 'sun' },
|
||||
{ value: 'dark', label: '暗色', icon: 'moon' },
|
||||
]
|
||||
</script>
|
||||
@@ -115,6 +115,7 @@
|
||||
</div>
|
||||
</main>
|
||||
<SessionExpiredModal />
|
||||
<ChangePasswordModal v-model:show="showChangePassword" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -124,6 +125,7 @@ import { useRouter, useRoute } from 'vue-router'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
import { useAuth } from '../../composables/useAuth'
|
||||
import SessionExpiredModal from './SessionExpiredModal.vue'
|
||||
import ChangePasswordModal from './ChangePasswordModal.vue'
|
||||
import AdminMenuItem from './AdminMenuItem.vue'
|
||||
import { getCurrentUser } from '../../services/api'
|
||||
import { openActiveMenuAncestors, findRouteTitle, type MenuItem } from './adminMenuTypes'
|
||||
@@ -137,6 +139,7 @@ const currentUser = ref(getUser())
|
||||
|
||||
// Dropdown logic
|
||||
const isUserDropdownOpen = ref(false)
|
||||
const showChangePassword = ref(false)
|
||||
const dropdownRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const toggleUserDropdown = () => {
|
||||
@@ -155,7 +158,7 @@ const goToProfile = () => {
|
||||
}
|
||||
|
||||
const goToProfilePassword = () => {
|
||||
router.push({ path: '/admin/profile', hash: '#password' })
|
||||
showChangePassword.value = true
|
||||
isUserDropdownOpen.value = false
|
||||
}
|
||||
|
||||
|
||||
146
client/src/components/admin/ChangePasswordModal.vue
Normal file
146
client/src/components/admin/ChangePasswordModal.vue
Normal file
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 z-[9999] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
|
||||
@click.self="close"
|
||||
>
|
||||
<div class="w-full max-w-sm mx-4 bg-[#121214] border border-white/10 rounded-xl p-6 shadow-2xl">
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<h3 class="text-lg font-medium text-white">修改密码</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="text-white/40 hover:text-white transition-colors"
|
||||
aria-label="关闭"
|
||||
@click="close"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleSubmit" class="space-y-4">
|
||||
<div class="form-group">
|
||||
<label for="change-pwd-old">原密码</label>
|
||||
<input
|
||||
id="change-pwd-old"
|
||||
v-model="form.oldPassword"
|
||||
type="password"
|
||||
class="admin-input"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="change-pwd-new">新密码</label>
|
||||
<input
|
||||
id="change-pwd-new"
|
||||
v-model="form.newPassword"
|
||||
type="password"
|
||||
class="admin-input"
|
||||
required
|
||||
minlength="6"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-3 pt-2">
|
||||
<button type="button" class="admin-btn-secondary flex-1" :disabled="submitting" @click="close">
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" class="admin-btn-primary flex-1" :disabled="submitting">
|
||||
{{ submitting ? '提交中...' : '更新密码' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch, onUnmounted } from 'vue'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
import { updateCurrentUserPassword } from '../../services/api'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:show': [value: boolean]
|
||||
}>()
|
||||
|
||||
const toast = useToast()
|
||||
const submitting = ref(false)
|
||||
const form = reactive({
|
||||
oldPassword: '',
|
||||
newPassword: '',
|
||||
})
|
||||
|
||||
const resetForm = () => {
|
||||
form.oldPassword = ''
|
||||
form.newPassword = ''
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
if (submitting.value) return
|
||||
emit('update:show', false)
|
||||
}
|
||||
|
||||
const onKeydown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') close()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(open) => {
|
||||
if (open) {
|
||||
resetForm()
|
||||
document.addEventListener('keydown', onKeydown)
|
||||
} else {
|
||||
document.removeEventListener('keydown', onKeydown)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', onKeydown)
|
||||
})
|
||||
|
||||
const handleSubmit = async () => {
|
||||
submitting.value = true
|
||||
try {
|
||||
await updateCurrentUserPassword(form.oldPassword, form.newPassword)
|
||||
toast.success('密码已更新')
|
||||
resetForm()
|
||||
emit('update:show', false)
|
||||
} catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '密码更新失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
margin-bottom: 0.5rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.modal-enter-from,
|
||||
.modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -65,24 +65,6 @@
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Password -->
|
||||
<div class="admin-card p-6 md:p-8 mt-6 space-y-4">
|
||||
<h2 class="text-lg font-medium text-white">修改密码</h2>
|
||||
<form @submit.prevent="handleChangePassword" class="space-y-4 max-w-md">
|
||||
<div class="form-group">
|
||||
<label>原密码</label>
|
||||
<input v-model="passwordForm.oldPassword" type="password" class="admin-input" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>新密码</label>
|
||||
<input v-model="passwordForm.newPassword" type="password" class="admin-input" required minlength="6" />
|
||||
</div>
|
||||
<button type="submit" class="admin-btn-secondary" :disabled="changingPassword">
|
||||
{{ changingPassword ? '提交中...' : '更新密码' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -91,7 +73,7 @@ import { reactive, ref, onMounted } from 'vue'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
import { useAuth } from '../../composables/useAuth'
|
||||
import { useUserProfileModal } from '../../composables/useUserProfileModal'
|
||||
import { getCurrentUser, updateCurrentUser, updateCurrentUserPassword } from '../../services/api'
|
||||
import { getCurrentUser, updateCurrentUser } from '../../services/api'
|
||||
import ImagePicker from '../../components/admin/ImagePicker.vue'
|
||||
import EmailAutocomplete from '../../components/ui/EmailAutocomplete.vue'
|
||||
|
||||
@@ -100,7 +82,6 @@ const { setUser, getUser } = useAuth()
|
||||
const { openUserProfile } = useUserProfileModal()
|
||||
|
||||
const saving = ref(false)
|
||||
const changingPassword = ref(false)
|
||||
const userId = ref<number | null>(null)
|
||||
|
||||
const form = reactive({
|
||||
@@ -113,11 +94,6 @@ const form = reactive({
|
||||
wechatQrcode: '',
|
||||
})
|
||||
|
||||
const passwordForm = reactive({
|
||||
oldPassword: '',
|
||||
newPassword: '',
|
||||
})
|
||||
|
||||
const loadProfile = async () => {
|
||||
try {
|
||||
const user = await getCurrentUser()
|
||||
@@ -156,20 +132,6 @@ const handleSaveProfile = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleChangePassword = async () => {
|
||||
changingPassword.value = true
|
||||
try {
|
||||
await updateCurrentUserPassword(passwordForm.oldPassword, passwordForm.newPassword)
|
||||
passwordForm.oldPassword = ''
|
||||
passwordForm.newPassword = ''
|
||||
toast.success('密码已更新')
|
||||
} catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '密码更新失败')
|
||||
} finally {
|
||||
changingPassword.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openPreview = () => {
|
||||
const id = userId.value || getUser()?.id
|
||||
if (id) openUserProfile(id)
|
||||
|
||||
Reference in New Issue
Block a user