增加更多可配置的部分

This commit is contained in:
李琦
2026-07-14 14:27:53 +08:00
parent f38a80ca71
commit 66be7a3ba0
6 changed files with 237 additions and 66 deletions

View File

@@ -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'])

View File

@@ -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 = () => {

View 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>

View File

@@ -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
}

View 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>

View File

@@ -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)