优化页面、修复BUG

This commit is contained in:
李琦
2026-06-26 17:00:02 +08:00
parent 396272908b
commit e4ab62ab7e
44 changed files with 1058 additions and 688 deletions

View File

@@ -37,6 +37,13 @@
<!-- Inquiry Modal (Hide on Admin & Login) -->
<InquiryModal v-if="!isAdminOrLogin" class="relative z-[80]" />
<!-- User Profile Modal (Global) -->
<UserProfileModal
:is-open="profileModalOpen"
:user-id="profileUserId"
@close="closeUserProfile"
/>
</div>
</template>
@@ -48,7 +55,11 @@ import MobileMenu from './components/MobileMenu.vue'
import Footer from './components/Footer.vue'
import InquiryModal from './components/InquiryModal.vue'
import StarBackground from './components/StarBackground.vue'
import { getPublicSettings } from './services/api'
import UserProfileModal from './components/UserProfileModal.vue'
import { useUserProfileModal } from './composables/useUserProfileModal'
import { loadPublicSettings } from './composables/usePublicSettings'
const { isOpen: profileModalOpen, activeUserId: profileUserId, closeUserProfile } = useUserProfileModal()
const route = useRoute()
@@ -67,7 +78,7 @@ const isAdminOrLoginOrBlog = computed(() => {
// 应用网站配置到页面标题和meta标签
const applySiteSettings = async () => {
try {
const settings = await getPublicSettings()
const settings = await loadPublicSettings()
// 设置页面标题
if (settings.site_title) {

View File

@@ -1,10 +1,14 @@
<template>
<div
class="author-info flex items-center gap-2 min-w-0"
<component
:is="clickable && userId ? 'button' : 'div'"
type="button"
class="author-info flex items-center gap-2 min-w-0 text-left"
:class="[
variant === 'compact' ? 'gap-2' : 'gap-3 md:gap-4',
themeClasses.wrapper
themeClasses.wrapper,
clickable && userId ? 'cursor-pointer group/author hover:opacity-90 transition-opacity border-0 bg-transparent p-0 m-0' : ''
]"
@click="handleClick"
>
<div
class="shrink-0 overflow-hidden flex items-center justify-center border rounded-full"
@@ -47,11 +51,12 @@
用户#{{ userId }}
</div>
</div>
</div>
</component>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useUserProfileModal } from '../composables/useUserProfileModal'
const props = withDefaults(defineProps<{
name?: string
@@ -62,13 +67,23 @@ const props = withDefaults(defineProps<{
variant?: 'full' | 'compact'
theme?: 'default' | 'onDark' | 'onImage'
showEmail?: boolean
clickable?: boolean
}>(), {
size: 'sm',
variant: 'compact',
theme: 'default',
showEmail: true
showEmail: true,
clickable: true,
})
const { openUserProfile } = useUserProfileModal()
const handleClick = () => {
if (props.clickable && props.userId) {
openUserProfile(props.userId)
}
}
const displayName = computed(() => {
if (props.name) return props.name
if (props.userId) return `用户#${props.userId}`

View File

@@ -1,14 +1,54 @@
<template>
<footer class="py-8 text-center border-t border-white/5 relative z-10">
<div class="flex justify-center items-center gap-4 mb-4">
<a
v-if="contactEmail"
:href="`mailto:${contactEmail}`"
class="text-xs text-art-muted/50 hover:text-art-accent transition-colors"
>
{{ contactEmail }}
</a>
<a @click="openAdmin" class="text-xs text-art-muted/30 hover:text-art-accent cursor-pointer">管理入口</a>
</div>
<p class="text-art-muted text-xs font-mono tracking-widest opacity-50">&copy; 2026 年糕崽崽. 保留所有权利.</p>
<p v-if="footerIcp" class="text-art-muted text-xs font-mono tracking-widest opacity-50 mb-2">
<a
v-if="icpHref"
:href="icpHref"
target="_blank"
rel="noopener noreferrer"
class="hover:text-art-accent transition-colors"
>
{{ footerIcp }}
</a>
<span v-else>{{ footerIcp }}</span>
</p>
<p class="text-art-muted text-xs font-mono tracking-widest opacity-50">
&copy; {{ currentYear }} {{ siteTitle }}. 保留所有权利.
</p>
</footer>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { usePublicSettings } from '../composables/usePublicSettings'
const { cache } = usePublicSettings()
const currentYear = new Date().getFullYear()
const siteTitle = computed(() => cache.value?.site_title?.trim() || '年糕崽崽')
const footerIcp = computed(() => cache.value?.footer_icp?.trim() || '')
const contactEmail = computed(() => cache.value?.contact_email?.trim() || '')
const icpHref = computed(() => {
const icp = footerIcp.value
if (!icp) return null
if (/ICP|备案|备\d|icp/i.test(icp) || /\d{8,}/.test(icp)) {
return 'https://beian.miit.gov.cn/'
}
return null
})
const openAdmin = () => {
window.open('/admin', '_blank')
}
</script>
</script>

View File

@@ -185,11 +185,12 @@
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { getPublicSettings } from '../services/api'
import { usePublicSettings } from '../composables/usePublicSettings'
import Icon from './Icon.vue'
const router = useRouter()
const route = useRoute()
const { cache, getStringSetting } = usePublicSettings()
const activeNav = ref('home')
const siteTitle = ref<string>('')
const visibleMenus = ref<string[]>(['home', 'blog', 'columns', 'works', 'snippets', 'about', 'services'])
@@ -211,31 +212,30 @@ const updateActiveNav = () => {
else if (path === '/about') activeNav.value = 'about'
}
const loadSiteSettings = async () => {
try {
const settings = await getPublicSettings()
if (settings.site_title) {
siteTitle.value = settings.site_title
}
const applySettingsFromCache = () => {
const title = getStringSetting('site_title')
if (title) siteTitle.value = title
if (settings.visible_menus) {
try {
const parsed = JSON.parse(settings.visible_menus)
if (Array.isArray(parsed)) {
visibleMenus.value = parsed
}
} catch {
visibleMenus.value = settings.visible_menus.split(',').map((m: string) => m.trim()).filter((m: string) => m)
const menusRaw = getStringSetting('visible_menus')
if (menusRaw) {
try {
const parsed = JSON.parse(menusRaw)
if (Array.isArray(parsed)) {
visibleMenus.value = parsed
}
} catch {
visibleMenus.value = menusRaw.split(',').map((m: string) => m.trim()).filter((m: string) => m)
}
} catch (error) {
console.error('Failed to load site settings:', error)
}
}
onMounted(() => {
updateActiveNav()
loadSiteSettings()
applySettingsFromCache()
})
watch(cache, () => {
applySettingsFromCache()
})
watch(

View File

@@ -1,159 +0,0 @@
<template>
<div class="image-uploader">
<div
v-if="!imageUrl"
@click="triggerFileInput"
@dragover.prevent
@dragenter.prevent
@drop.prevent="handleDrop"
class="upload-area border-2 border-dashed border-white/20 rounded-lg p-8 text-center cursor-pointer hover:border-art-accent transition-colors"
:class="{ 'border-art-accent': isDragging }"
>
<input
ref="fileInput"
type="file"
accept="image/*"
@change="handleFileSelect"
class="hidden"
/>
<div class="space-y-2">
<i data-lucide="upload" class="w-12 h-12 mx-auto text-art-muted"></i>
<p class="text-sm text-art-muted">点击或拖拽图片到此处上传</p>
<p class="text-xs text-art-muted/50">支持 JPGPNGGIF 格式</p>
</div>
</div>
<div v-else class="image-preview relative group">
<img :src="imageUrl" alt="Preview" class="w-full h-auto rounded-lg" />
<div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity rounded-lg flex items-center justify-center gap-2">
<button
@click="removeImage"
class="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600 transition-colors"
>
删除
</button>
<button
@click="triggerFileInput"
class="px-4 py-2 bg-art-accent text-white rounded hover:bg-opacity-80 transition-colors"
>
更换
</button>
</div>
</div>
<div v-if="uploading" class="mt-4 text-center">
<div class="inline-flex items-center gap-2 text-art-accent">
<div class="animate-spin rounded-full h-4 w-4 border-t-2 border-b-2 border-art-accent"></div>
<span class="text-sm">上传中...</span>
</div>
</div>
<div v-if="error" class="mt-4 text-center text-red-500 text-sm">
{{ error }}
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useToast } from '../composables/useToast'
const props = defineProps<{
modelValue?: string
categoryId?: number
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const fileInput = ref<HTMLInputElement | null>(null)
const imageUrl = ref<string>(props.modelValue || '')
const uploading = ref(false)
const error = ref('')
const isDragging = ref(false)
const toast = useToast()
watch(() => props.modelValue, (newVal) => {
imageUrl.value = newVal || ''
})
const triggerFileInput = () => {
fileInput.value?.click()
}
const handleFileSelect = (e: Event) => {
const target = e.target as HTMLInputElement
if (target.files && target.files[0]) {
uploadFile(target.files[0])
}
}
const handleDrop = (e: DragEvent) => {
isDragging.value = false
if (e.dataTransfer?.files && e.dataTransfer.files[0]) {
uploadFile(e.dataTransfer.files[0])
}
}
const uploadFile = async (file: File) => {
if (!file.type.startsWith('image/')) {
error.value = '请选择图片文件'
return
}
uploading.value = true
error.value = ''
try {
const formData = new FormData()
formData.append('file', file)
if (props.categoryId) {
formData.append('categoryId', props.categoryId.toString())
}
formData.append('storageType', 'local')
const token = localStorage.getItem('token')
const response = await fetch('/api/admin/attachments/upload', {
method: 'POST',
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {})
},
body: formData
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.message || '上传失败')
}
const data = await response.json()
imageUrl.value = data.result.fileUrl
emit('update:modelValue', data.result.fileUrl)
toast.showToast('图片上传成功', 'success')
} catch (err: any) {
error.value = err.message || '上传失败,请重试'
toast.showToast(error.value, 'error')
} finally {
uploading.value = false
}
}
const removeImage = () => {
imageUrl.value = ''
emit('update:modelValue', '')
if (fileInput.value) {
fileInput.value.value = ''
}
}
</script>
<style scoped>
.upload-area {
min-height: 200px;
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

@@ -14,31 +14,23 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { onMounted, onUpdated, ref } from 'vue'
import { getPublicSettings } from '../services/api'
import { onMounted, onUpdated, ref, watch } from 'vue'
import { usePublicSettings } from '../composables/usePublicSettings'
const router = useRouter()
const visibleMenus = ref<string[]>(['home', 'blog', 'columns', 'works', 'snippets', 'about', 'services']) // 默认显示所有菜单
const { cache, getStringSetting } = usePublicSettings()
const visibleMenus = ref<string[]>(['home', 'blog', 'columns', 'works', 'snippets', 'about', 'services'])
// 获取菜单显示配置
const loadMenuSettings = async () => {
const applyMenuSettings = () => {
const menusRaw = getStringSetting('visible_menus')
if (!menusRaw) return
try {
const settings = await getPublicSettings()
if (settings.visible_menus) {
try {
// 尝试解析JSON格式
const parsed = JSON.parse(settings.visible_menus)
if (Array.isArray(parsed)) {
visibleMenus.value = parsed
}
} catch {
// 如果不是JSON按逗号分隔
visibleMenus.value = settings.visible_menus.split(',').map((m: string) => m.trim()).filter((m: string) => m)
}
const parsed = JSON.parse(menusRaw)
if (Array.isArray(parsed)) {
visibleMenus.value = parsed
}
} catch (error) {
console.error('Failed to load menu settings:', error)
// 使用默认值
} catch {
visibleMenus.value = menusRaw.split(',').map((m: string) => m.trim()).filter((m: string) => m)
}
}
@@ -68,7 +60,11 @@ const refreshIcons = () => {
onMounted(() => {
refreshIcons()
loadMenuSettings()
applyMenuSettings()
})
watch(cache, () => {
applyMenuSettings()
})
onUpdated(refreshIcons)
</script>

View File

@@ -216,10 +216,37 @@ const showCollapseToggle = computed(
() => showHtmlPreview.value && activeRightTab.value === 'preview'
)
const renderedDescription = computed(() => {
if (!props.description) return ''
return renderMarkdown(props.description)
})
const renderedDescription = ref('')
let descriptionGeneration = 0
const renderDescription = (content: string) => {
const generation = ++descriptionGeneration
renderedDescription.value = ''
const run = async () => {
try {
const html = await renderMarkdown(content)
if (generation !== descriptionGeneration) return
renderedDescription.value = html
} catch (err) {
console.error('Failed to render snippet description:', err)
}
}
void run()
}
watch(
[() => props.description, () => props.isOpen],
([description, isOpen]) => {
if (!isOpen || !description) {
renderedDescription.value = ''
return
}
renderDescription(description)
},
{ immediate: true }
)
const highlightCode = () => {
if (codeBlock.value) {

View File

@@ -28,15 +28,20 @@
<!-- User Footer -->
<div class="p-4 border-t border-white/5">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-art-accent/10 flex items-center justify-center text-art-accent font-bold text-xs shrink-0">
{{ currentUser.username?.charAt(0).toUpperCase() || 'A' }}
<router-link
to="/admin/profile"
class="flex items-center gap-3 rounded-md p-1 -m-1 hover:bg-white/5 transition-colors"
:class="{ 'justify-center': !isSidebarOpen }"
>
<div class="w-8 h-8 rounded-full bg-art-accent/10 flex items-center justify-center text-art-accent font-bold text-xs shrink-0 overflow-hidden">
<img v-if="currentUser.avatar" :src="currentUser.avatar" :alt="currentUser.username" class="w-full h-full object-cover" />
<span v-else>{{ currentUser.username?.charAt(0).toUpperCase() || 'A' }}</span>
</div>
<div class="min-w-0 transition-opacity duration-300" :class="{ 'opacity-0 w-0': !isSidebarOpen }">
<p class="text-sm font-medium text-white truncate">{{ currentUser.username }}</p>
<p class="text-xs text-art-muted truncate">Administrator</p>
<p class="text-xs text-art-muted truncate">个人中心</p>
</div>
</div>
</router-link>
</div>
</aside>
@@ -84,7 +89,11 @@
v-if="isUserDropdownOpen"
class="absolute right-0 mt-2 w-48 bg-art-admin-card border border-white/10 rounded-lg shadow-xl py-1 z-50 origin-top-right animate-reveal"
>
<button @click="handleChangePassword" class="flex items-center gap-2 w-full px-4 py-2.5 text-sm text-art-muted hover:text-white hover:bg-white/5 text-left transition-colors">
<button @click="goToProfile" class="flex items-center gap-2 w-full px-4 py-2.5 text-sm text-art-muted hover:text-white hover:bg-white/5 text-left transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>
个人中心
</button>
<button @click="goToProfilePassword" class="flex items-center gap-2 w-full px-4 py-2.5 text-sm text-art-muted hover:text-white hover:bg-white/5 text-left transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>
修改密码
</button>
@@ -116,12 +125,13 @@ import { useToast } from '../../composables/useToast'
import { useAuth } from '../../composables/useAuth'
import SessionExpiredModal from './SessionExpiredModal.vue'
import AdminMenuItem from './AdminMenuItem.vue'
import { getCurrentUser } from '../../services/api'
import { openActiveMenuAncestors, findRouteTitle, type MenuItem } from './adminMenuTypes'
const router = useRouter()
const route = useRoute()
const toast = useToast()
const { logout, scheduleExpiryCheck, getUser, isAuthenticated } = useAuth()
const { logout, scheduleExpiryCheck, getUser, isAuthenticated, setUser } = useAuth()
const isSidebarOpen = ref(true)
const currentUser = ref(getUser())
@@ -139,8 +149,13 @@ const closeDropdown = (e: MouseEvent) => {
}
}
const handleChangePassword = () => {
toast.showToast('功能开发中...', 'success')
const goToProfile = () => {
router.push('/admin/profile')
isUserDropdownOpen.value = false
}
const goToProfilePassword = () => {
router.push({ path: '/admin/profile', hash: '#password' })
isUserDropdownOpen.value = false
}
@@ -266,6 +281,15 @@ onMounted(() => {
scheduleExpiryCheck()
document.addEventListener('click', closeDropdown)
openActiveMenuAncestors(menuItems.value, isActive)
getCurrentUser()
.then(user => {
currentUser.value = user
setUser(user)
})
.catch(() => {
currentUser.value = getUser()
})
})
watch(() => route.path, () => {

View File

@@ -40,7 +40,7 @@
</button>
<div class="filter-actions">
<button type="button" class="admin-btn-secondary" @click="emit('search')">查询</button>
<button type="button" class="admin-btn-search" @click="emit('search')">查询</button>
<button type="button" class="admin-btn-secondary opacity-70" @click="emit('reset')">重置</button>
</div>
</div>

View File

@@ -24,15 +24,30 @@
<!-- 操作区 -->
<div class="flex-1 min-w-0 space-y-2">
<div
@click="triggerFileInput"
@dragover.prevent="handleDragOver"
@dragenter.prevent="handleDragEnter"
@dragleave.prevent="handleDragLeave"
@drop.prevent="handleDrop"
class="upload-area border-2 border-dashed rounded-lg p-4 text-center cursor-pointer transition-colors"
:class="[
isDragging ? 'border-art-accent bg-art-accent/5' : 'border-white/20 hover:border-art-accent/50',
disabled || uploading ? 'opacity-50 cursor-not-allowed' : ''
]"
>
<input
ref="fileInput"
type="file"
:accept="accept"
class="hidden"
:disabled="disabled"
@change="handleFileSelect"
/>
<p class="text-xs text-art-muted">点击或拖拽图片到此处上传</p>
</div>
<div class="flex flex-wrap gap-2">
<button
type="button"
class="admin-btn-secondary text-xs"
:disabled="disabled || uploading"
@click="triggerFileInput"
>
{{ uploading ? '上传中...' : '上传图片' }}
</button>
<button
type="button"
class="admin-btn-secondary text-xs"
@@ -53,18 +68,10 @@
</div>
<p v-if="hint" class="text-xs text-art-muted/60">{{ hint }}</p>
<p v-if="error" class="text-xs text-red-400">{{ error }}</p>
<p v-if="uploading" class="text-xs text-art-accent">上传中...</p>
</div>
</div>
<input
ref="fileInput"
type="file"
:accept="accept"
class="hidden"
:disabled="disabled"
@change="handleFileSelect"
/>
<AttachmentLibraryModal
:show="showLibraryModal"
:multiple="false"
@@ -78,6 +85,7 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useToast } from '../../composables/useToast'
import { useImageDropUpload } from '../../composables/useImageDropUpload'
import { API_BASE, authFetch, parseApiResponse } from '../../services/api'
import AttachmentLibraryModal from './AttachmentLibraryModal.vue'
@@ -122,6 +130,11 @@ const previewSizeClass = computed(() => {
return props.size === 'sm' ? 'w-20 h-20' : props.size === 'lg' ? 'w-40 h-40' : 'w-28 h-28'
})
const { isDragging, handleDragOver, handleDragEnter, handleDragLeave, handleDrop } = useImageDropUpload({
disabled: () => props.disabled || uploading.value,
onFiles: (files) => uploadFile(files[0]),
})
const triggerFileInput = () => {
if (props.disabled || uploading.value) return
fileInput.value?.click()
@@ -187,3 +200,12 @@ const handleLibrarySelect = (urls: string[]) => {
}
}
</script>
<style scoped>
.upload-area {
min-height: 72px;
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

@@ -53,6 +53,7 @@
<img :src="imageUrl" alt="Preview" class="w-full h-auto rounded-lg max-h-64 object-contain bg-white/5" />
<div class="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity rounded-lg flex items-center justify-center gap-2">
<button
type="button"
@click.stop="() => removeImage()"
class="px-4 py-2 bg-red-500/80 text-white rounded hover:bg-red-500 transition-colors text-sm"
:disabled="disabled"
@@ -60,6 +61,7 @@
删除
</button>
<button
type="button"
@click.stop="triggerFileInput"
class="px-4 py-2 bg-art-accent text-black rounded hover:opacity-90 transition-colors text-sm"
:disabled="disabled"
@@ -94,6 +96,7 @@
<img :src="url" :alt="`Image ${index + 1}`" class="w-full h-full object-cover" />
<div class="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
<button
type="button"
@click="() => removeImage(index)"
class="px-3 py-1.5 bg-red-500/80 text-white rounded hover:bg-red-500 transition-colors text-xs"
:disabled="disabled"

View File

@@ -3,7 +3,7 @@
<input
:name="name"
type="text"
class="inquiry-input peer w-full bg-transparent border-b border-white/20 py-2 text-white font-serif text-lg focus:border-art-accent outline-none transition-colors placeholder-transparent"
:class="inputClass"
:placeholder="inputPlaceholder"
:value="modelValue"
:required="required"
@@ -13,14 +13,33 @@
autocomplete="off"
>
<label
v-if="variant === 'inquiry'"
class="absolute left-0 top-2 text-white/30 text-xs font-mono transition-all pointer-events-none peer-focus:-top-4 peer-focus:text-art-accent peer-[:not(:placeholder-shown)]:-top-4 peer-[:not(:placeholder-shown)]:text-white/50 peer-autofill:-top-4 peer-autofill:text-white/50"
>{{ label }}</label>
<!-- Autocomplete Dropdown -->
<div v-if="showDropdown && suggestions.length > 0" class="absolute z-[9999] left-0 right-0 top-full mt-1 bg-[#1a1a1a] border border-white/10 shadow-lg max-h-48 overflow-y-auto rounded-b-md">
<div v-if="showDropdown && (suggestions.length > 0 || matchedUsers.length > 0)" class="absolute z-[9999] left-0 right-0 top-full mt-1 bg-[#1a1a1a] border border-white/10 shadow-lg max-h-48 overflow-y-auto rounded-b-md">
<div v-if="matchedUsers.length > 0" class="border-b border-white/5">
<div class="px-3 py-1.5 text-[10px] text-art-muted uppercase tracking-wider">已注册用户</div>
<div
v-for="user in matchedUsers"
:key="user.id"
class="px-4 py-2 text-sm text-white/70 hover:bg-art-accent hover:text-black cursor-pointer transition-colors flex items-center gap-2"
@mousedown.prevent="selectUser(user)"
>
<div class="w-6 h-6 rounded-full overflow-hidden bg-white/10 shrink-0 flex items-center justify-center text-xs text-art-accent">
<img v-if="user.avatar" :src="user.avatar" :alt="user.username" class="w-full h-full object-cover" />
<span v-else>{{ user.username.charAt(0).toUpperCase() }}</span>
</div>
<div class="min-w-0">
<div class="truncate font-medium">{{ user.username }}</div>
<div class="truncate text-xs opacity-70">{{ user.email }}</div>
</div>
</div>
</div>
<div
v-for="(suggestion, index) in suggestions"
:key="index"
:key="'s-' + index"
class="px-4 py-2 text-sm text-white/70 hover:bg-art-accent hover:text-black cursor-pointer transition-colors font-mono"
@mousedown.prevent="selectSuggestion(suggestion)"
>
@@ -32,43 +51,74 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { fetchEmailSuffixes } from '../../services/api'
import { fetchEmailSuffixes, getUsersPaginated, type User } from '../../services/api'
import { useUserProfileModal } from '../../composables/useUserProfileModal'
const props = defineProps<{
const props = withDefaults(defineProps<{
name: string
label: string
placeholder?: string
modelValue: string
required?: boolean
}>()
userLookup?: boolean
variant?: 'inquiry' | 'admin'
}>(), {
userLookup: false,
variant: 'inquiry',
})
const emit = defineEmits(['update:modelValue'])
const emit = defineEmits(['update:modelValue', 'user-select'])
const inputPlaceholder = computed(() => props.placeholder ?? ' ')
const { openUserProfile } = useUserProfileModal()
const inputPlaceholder = computed(() => props.placeholder ?? (props.variant === 'inquiry' ? ' ' : ''))
const inputClass = computed(() => {
if (props.variant === 'admin') {
return 'admin-input w-full peer'
}
return 'inquiry-input peer w-full bg-transparent border-b border-white/20 py-2 text-white font-serif text-lg focus:border-art-accent outline-none transition-colors placeholder-transparent'
})
const showDropdown = ref(false)
const emailSuffixes = ref<string[]>([])
const matchedUsers = ref<User[]>([])
const containerRef = ref<HTMLElement | null>(null)
let userSearchTimer: ReturnType<typeof setTimeout> | null = null
// Load suffixes from API
onMounted(async () => {
try {
const suffixes = await fetchEmailSuffixes()
emailSuffixes.value = suffixes.map(s => s.suffix)
} catch (e) {
// Fallback defaults if API fails
} catch {
emailSuffixes.value = ['@gmail.com', '@163.com', '@qq.com', '@outlook.com']
}
})
const searchUsers = (keyword: string) => {
if (!props.userLookup || !keyword.includes('@') || keyword.length < 3) {
matchedUsers.value = []
return
}
if (userSearchTimer) clearTimeout(userSearchTimer)
userSearchTimer = setTimeout(async () => {
try {
const result = await getUsersPaginated({ page: 1, pageSize: 5, keyword })
matchedUsers.value = result.list
} catch {
matchedUsers.value = []
}
}, 300)
}
const handleInput = (e: Event) => {
const val = (e.target as HTMLInputElement).value
emit('update:modelValue', val)
showDropdown.value = true
searchUsers(val)
}
const handleBlur = () => {
// Delay hiding to allow click event on dropdown to fire
setTimeout(() => {
showDropdown.value = false
}, 200)
@@ -83,6 +133,12 @@ const selectSuggestion = (val: string) => {
emit('update:modelValue', val)
showDropdown.value = false
}
const selectUser = (user: User) => {
emit('user-select', user)
openUserProfile(user.id)
showDropdown.value = false
}
</script>
<style scoped>

View File

@@ -96,6 +96,10 @@ export function useAuth() {
}
}
const setUser = (user: unknown) => {
localStorage.setItem(USER_KEY, JSON.stringify(user))
}
return {
sessionExpired,
getToken,
@@ -107,5 +111,6 @@ export function useAuth() {
confirmSessionExpired,
scheduleExpiryCheck,
getUser,
setUser,
}
}

View File

@@ -90,15 +90,22 @@ const posts = ref<Post[]>([])
const loading = ref(true)
const { initObserver } = useScrollAnimation()
const parseTimestamp = (value: string | number | undefined): Date | null => {
if (!value) return null
if (typeof value === 'number') return new Date(value * 1000)
const asNumber = parseInt(value, 10)
if (/^\d+$/.test(value) && !Number.isNaN(asNumber)) {
return new Date(asNumber * 1000)
}
const parsed = new Date(value.replace(' ', 'T'))
return Number.isNaN(parsed.getTime()) ? null : parsed
}
// 计算最近更新时间
const lastUpdated = computed(() => {
if (posts.value.length === 0) {
// 如果没有文章,使用专栏的更新时间
if (column.value?.updatedAt) {
const timestamp = typeof column.value.updatedAt === 'string'
? parseInt(column.value.updatedAt)
: column.value.updatedAt
return new Date(timestamp * 1000)
return parseTimestamp(column.value.updatedAt as string | number)
}
return null
}
@@ -117,12 +124,8 @@ const lastUpdated = computed(() => {
.filter((date): date is Date => date !== null)
if (dates.length === 0) {
// 如果无法解析文章日期,使用专栏更新时间
if (column.value?.updatedAt) {
const timestamp = typeof column.value.updatedAt === 'string'
? parseInt(column.value.updatedAt)
: column.value.updatedAt
return new Date(timestamp * 1000)
return parseTimestamp(column.value.updatedAt as string | number)
}
return null
}

View File

@@ -97,31 +97,35 @@
<!-- Danmaku items will be rendered here -->
<div class="danmaku-row animate-marquee hover:[animation-play-state:paused]">
<div v-for="testimonial in testimonials" :key="testimonial.id" class="danmaku-item">
<div class="w-6 h-6 rounded-full bg-blue-500/20">
<image :src="testimonial.avatar ||'https://via.placeholder.com/50'" class="w-6 h-6" alt="Avatar" />
<div class="w-6 h-6 rounded-full bg-blue-500/20 overflow-hidden shrink-0">
<img :src="testimonial.avatar || 'https://via.placeholder.com/50'" class="w-6 h-6 object-cover" alt="Avatar" />
</div>
<span v-if="testimonial.name" class="text-art-accent/80 text-sm shrink-0">{{ testimonial.name }}</span>
<span>"{{ testimonial.content }}"</span>
</div>
<!-- 复制一份数据以实现无缝滚动 -->
<div v-for="testimonial in testimonials" :key="testimonial.id + '-copy'" class="danmaku-item">
<div class="w-6 h-6 rounded-full bg-blue-500/20">
<image :src="testimonial.avatar ||'https://via.placeholder.com/50'" class="w-6 h-6" alt="Avatar" />
<div class="w-6 h-6 rounded-full bg-blue-500/20 overflow-hidden shrink-0">
<img :src="testimonial.avatar || 'https://via.placeholder.com/50'" class="w-6 h-6 object-cover" alt="Avatar" />
</div>
<span v-if="testimonial.name" class="text-art-accent/80 text-sm shrink-0">{{ testimonial.name }}</span>
<span>"{{ testimonial.content }}"</span>
</div>
</div>
<div class="danmaku-row animate-marquee-reverse hover:[animation-play-state:paused]">
<div v-for="testimonial in testimonials" :key="testimonial.id" class="danmaku-item">
<div class="w-6 h-6 rounded-full bg-blue-500/20">
<image :src="testimonial.avatar ||'https://via.placeholder.com/50'" class="w-6 h-6" alt="Avatar" />
<div class="w-6 h-6 rounded-full bg-blue-500/20 overflow-hidden shrink-0">
<img :src="testimonial.avatar || 'https://via.placeholder.com/50'" class="w-6 h-6 object-cover" alt="Avatar" />
</div>
<span v-if="testimonial.name" class="text-art-accent/80 text-sm shrink-0">{{ testimonial.name }}</span>
<span>"{{ testimonial.content }}"</span>
</div>
<!-- 复制一份数据以实现无缝滚动 -->
<div v-for="testimonial in testimonials" :key="testimonial.id + '-copy'" class="danmaku-item">
<div class="w-6 h-6 rounded-full bg-blue-500/20">
<image :src="testimonial.avatar ||'https://via.placeholder.com/50'" class="w-6 h-6" alt="Avatar" />
<div class="w-6 h-6 rounded-full bg-blue-500/20 overflow-hidden shrink-0">
<img :src="testimonial.avatar || 'https://via.placeholder.com/50'" class="w-6 h-6 object-cover" alt="Avatar" />
</div>
<span v-if="testimonial.name" class="text-art-accent/80 text-sm shrink-0">{{ testimonial.name }}</span>
<span>"{{ testimonial.content }}"</span>
</div>
</div>

View File

@@ -41,10 +41,11 @@
<div class="form-group">
<label for="email">邮箱</label>
<input
type="email"
id="email"
<EmailAutocomplete
name="email"
label="邮箱"
v-model="form.email"
variant="admin"
placeholder="example@domain.com"
/>
</div>
@@ -141,6 +142,7 @@ import { ref, reactive, computed, onMounted } from 'vue'
import { useToast } from '../../composables/useToast'
import { createAboutProfile, updateAboutProfile, getAdminAboutProfiles, Experience } from '../../services/api'
import ImageUpload from '../../components/admin/ImageUpload.vue'
import EmailAutocomplete from '../../components/ui/EmailAutocomplete.vue'
const toast = useToast()
const isSubmitting = ref(false)

View File

@@ -41,10 +41,11 @@
<div class="form-group">
<label for="email">邮箱</label>
<input
type="email"
id="email"
<EmailAutocomplete
name="email"
label="邮箱"
v-model="form.email"
variant="admin"
placeholder="example@domain.com"
/>
</div>
@@ -145,6 +146,7 @@ import { useRouter, useRoute } from 'vue-router'
import { useToast } from '../../composables/useToast'
import { createAboutProfile, updateAboutProfile, getAdminAboutProfiles, Experience } from '../../services/api'
import ImageUpload from '../../components/admin/ImageUpload.vue'
import EmailAutocomplete from '../../components/ui/EmailAutocomplete.vue'
const router = useRouter()
const route = useRoute()

View File

@@ -116,12 +116,27 @@
<div class="space-y-4">
<div>
<label class="block text-sm text-art-muted mb-2">选择文件</label>
<input
type="file"
ref="uploadInput"
@change="handleUploadFile"
class="admin-input"
/>
<div
@click="triggerUploadSelect"
@dragover.prevent="handleDragOver"
@dragenter.prevent="handleDragEnter"
@dragleave.prevent="handleDragLeave"
@drop.prevent="handleDrop"
class="border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors"
:class="[
isDragging ? 'border-art-accent bg-art-accent/5' : 'border-white/20 hover:border-art-accent/50',
uploading ? 'opacity-50 cursor-not-allowed' : ''
]"
>
<input
type="file"
ref="uploadInput"
@change="handleUploadFileSelect"
class="hidden"
/>
<p class="text-sm text-art-muted">点击或拖拽文件到此处上传</p>
<p v-if="pendingFileName" class="text-xs text-art-accent mt-2">已选择{{ pendingFileName }}</p>
</div>
</div>
<div>
@@ -134,10 +149,10 @@
</div>
<div class="flex gap-3">
<button @click="showUploadModal = false" class="admin-btn-secondary flex-1">
<button type="button" @click="showUploadModal = false" class="admin-btn-secondary flex-1">
取消
</button>
<button @click="uploadFile" :disabled="uploading" class="admin-btn-primary flex-1">
<button type="button" @click="uploadFile" :disabled="uploading" class="admin-btn-primary flex-1">
{{ uploading ? '上传中...' : '上传' }}
</button>
</div>
@@ -154,6 +169,7 @@ import CustomSelect from '../../components/CustomSelect.vue'
import AdminTableFilter from '../../components/admin/AdminTableFilter.vue'
import AttachmentDetailModal from '../../components/admin/AttachmentDetailModal.vue'
import { getAdminAttachments, updateAttachment, API_BASE, authFetch, parseApiResponse, type Attachment } from '../../services/api'
import { useImageDropUpload } from '../../composables/useImageDropUpload'
const toast = useToast()
@@ -178,6 +194,7 @@ const selectedAttachment = ref<LocalAttachment | null>(null)
const uploadInput = ref<HTMLInputElement | null>(null)
const uploadCategoryId = ref(0)
const uploading = ref(false)
const pendingFileName = ref('')
const filterCategoryId = ref(0)
const filterFileType = ref('')
const searchKeyword = ref('')
@@ -244,20 +261,35 @@ const loadCategories = async () => {
}
}
const handleUploadFile = () => {
// File selection handled in uploadFile
const handleUploadFileSelect = () => {
const file = uploadInput.value?.files?.[0]
if (file) {
pendingFileName.value = file.name
void uploadFileWithFile(file)
}
}
const uploadFile = async () => {
if (!uploadInput.value?.files || uploadInput.value.files.length === 0) {
toast.showToast('请选择文件', 'error')
return
}
const triggerUploadSelect = () => {
if (uploading.value) return
uploadInput.value?.click()
}
const { isDragging, handleDragOver, handleDragEnter, handleDragLeave, handleDrop } = useImageDropUpload({
disabled: () => uploading.value,
acceptImagesOnly: false,
onFiles: (files) => {
if (files[0]) {
pendingFileName.value = files[0].name
void uploadFileWithFile(files[0])
}
},
})
const uploadFileWithFile = async (file: File) => {
uploading.value = true
try {
const formData = new FormData()
formData.append('file', uploadInput.value.files[0])
formData.append('file', file)
if (uploadCategoryId.value > 0) {
formData.append('categoryId', uploadCategoryId.value.toString())
}
@@ -271,8 +303,9 @@ const uploadFile = async () => {
toast.showToast('上传成功', 'success')
showUploadModal.value = false
uploadInput.value.value = ''
if (uploadInput.value) uploadInput.value.value = ''
uploadCategoryId.value = 0
pendingFileName.value = ''
loadAttachments()
} catch (err: any) {
toast.showToast(err.message || '上传失败', 'error')
@@ -281,6 +314,15 @@ const uploadFile = async () => {
}
}
const uploadFile = async () => {
if (!uploadInput.value?.files || uploadInput.value.files.length === 0) {
toast.showToast('请选择文件', 'error')
return
}
await uploadFileWithFile(uploadInput.value.files[0])
}
const deleteAttachment = async (id: number) => {
if (!confirm('确定要删除这个附件吗?')) return

View File

@@ -19,7 +19,7 @@
<div class="form-group">
<label class="form-label">官网链接</label>
<input type="text" v-model="formData.website" class="form-input" placeholder="https://...">
<input type="text" v-model="formData.url" class="form-input" placeholder="https://...">
</div>
<div class="form-group">
@@ -52,7 +52,7 @@ const isEdit = computed(() => !!route.params.id)
const formData = ref({
name: '',
logo: '',
website: '',
url: '',
description: ''
})
@@ -68,7 +68,7 @@ const loadData = async () => {
formData.value = {
name: item.name,
logo: item.logo,
website: item.website,
url: item.url,
description: item.description
}
} else {

View File

@@ -40,7 +40,7 @@
</td>
<td class="table-cell">{{ p.name }}</td>
<td class="table-cell">
<a v-if="p.website" :href="p.website" target="_blank" class="text-blue-400 hover:underline">链接</a>
<a v-if="p.url" :href="p.url" target="_blank" class="text-blue-400 hover:underline">链接</a>
<span v-else>-</span>
</td>
<td class="table-cell" :title="p.description">
@@ -81,7 +81,7 @@ const partners = ref<Partner[]>([])
const { keyword, appliedKeyword, apply, reset } = useAppliedKeyword()
const filteredPartners = computed(() => partners.value.filter(p =>
matchKeywordFn(p, appliedKeyword.value, item => `${item.name} ${item.description || ''} ${item.website || ''}`)
matchKeywordFn(p, appliedKeyword.value, item => `${item.name} ${item.description || ''} ${item.url || ''}`)
))
const applyFilter = () => apply()

View File

@@ -2,399 +2,293 @@
<div class="w-full animate-reveal">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-serif italic text-white">系统配置管理</h1>
<button @click="showAddModal = true" class="admin-btn-primary flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
新增配置
</button>
</div>
<!-- Tab + 卡片布局 -->
<div v-if="settings.length === 0" class="admin-card p-16 text-center">
<div class="w-16 h-16 bg-white/5 rounded-full flex items-center justify-center mx-auto mb-4 text-2xl"></div>
<h3 class="text-white font-medium mb-2">暂无配置</h3>
<button @click="showAddModal = true" class="admin-btn-primary mt-4">添加第一个配置</button>
</div>
<div v-if="loading" class="admin-card p-16 text-center text-art-muted">加载中...</div>
<div v-else class="space-y-6">
<!-- Tab 导航 -->
<div class="border-b border-white/10 overflow-x-auto">
<div class="flex gap-2 min-w-max">
<button
v-for="setting in allSettings"
:key="setting.id"
@click="activeTab = setting.id"
:class="[
'px-4 py-3 text-sm font-medium transition-all duration-200 border-b-2 relative',
activeTab === setting.id
? 'text-art-accent border-art-accent'
: 'text-art-muted border-transparent hover:text-white/80'
]"
>
{{ getSettingDisplayName(setting.keyName) }}
</button>
</div>
<!-- Tab bar -->
<div class="flex flex-wrap gap-2 border-b border-white/10 pb-1">
<button
v-for="tab in tabs"
:key="tab.id"
type="button"
class="px-4 py-2 text-sm rounded-t-lg transition-colors"
:class="activeTab === tab.id
? 'text-art-accent border-b-2 border-art-accent bg-white/5'
: 'text-art-muted hover:text-white hover:bg-white/5'"
@click="activeTab = tab.id"
>
{{ tab.label }}
</button>
</div>
<!-- 卡片内容 -->
<div class="admin-card p-6 border border-white/10 rounded-lg bg-white/5 shadow-lg">
<!-- 菜单显示配置卡片 -->
<div v-if="activeSetting && activeSetting.keyName === 'visible_menus'" class="space-y-6">
<!-- 标题 -->
<div class="flex items-center justify-between pb-4 border-b border-white/10">
<div>
<h3 class="text-xl font-semibold text-white mb-1">{{ activeSetting.keyName }}</h3>
<p class="text-sm text-art-muted">{{ activeSetting.description || '前台菜单显示配置' }}</p>
</div>
<button
@click="deleteSetting(activeSetting.keyName)"
class="text-red-400 hover:text-red-300 transition-colors p-2 hover:bg-red-500/10 rounded"
title="删除配置"
>
<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"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>
</button>
</div>
<!-- Schema group tabs -->
<div
v-for="group in SETTING_GROUPS"
v-show="activeTab === group.id"
:key="group.id"
class="admin-card p-6 border border-white/10"
>
<h2 class="text-lg font-medium text-white mb-1">{{ group.label }}</h2>
<p class="text-xs text-art-muted mb-5">管理 {{ group.label }} 相关配置项</p>
<!-- 菜单多选框优化UI -->
<div class="space-y-3">
<label class="block text-base font-medium text-white mb-4">显示菜单项</label>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<label
v-for="menu in menuOptions"
<div class="space-y-5">
<div
v-for="schema in getGroupItems(group.id)"
:key="schema.key"
class="space-y-2"
>
<label class="block text-sm font-medium text-white">{{ schema.label }}</label>
<p class="text-xs text-art-muted">{{ schema.description }}</p>
<div v-if="schema.type === 'menu-checkboxes'" class="grid grid-cols-1 md:grid-cols-2 gap-3 mt-2">
<label
v-for="menu in MENU_OPTIONS"
:key="menu.key"
class="flex items-center gap-3 cursor-pointer group p-3 rounded-lg border border-white/10 bg-white/5 hover:bg-white/10 hover:border-white/20 transition-all"
class="flex items-center gap-3 cursor-pointer p-3 rounded-lg border border-white/10 bg-white/5 hover:bg-white/10 transition-all"
>
<div class="relative flex items-center justify-center">
<input
type="checkbox"
:value="menu.key"
v-model="selectedMenus"
class="sr-only peer"
>
<div class="w-5 h-5 rounded border-2 border-white/30 bg-white/5 peer-checked:bg-art-accent peer-checked:border-art-accent transition-all duration-200 flex items-center justify-center">
<svg v-if="selectedMenus.includes(menu.key)" class="w-3 h-3 text-white" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"></path>
</svg>
</div>
</div>
<span class="text-sm font-medium text-white/90 group-hover:text-white transition-colors flex-1">{{ menu.label }}</span>
<input type="checkbox" :value="menu.key" v-model="selectedMenus" class="accent-art-accent" />
<span class="text-sm text-white/90">{{ menu.label }}</span>
</label>
</div>
</div>
<!-- 保存按钮 -->
<div class="pt-6 border-t border-white/10">
<button
@click="saveMenuSetting"
:disabled="savingSettings.has(activeSetting.id)"
class="admin-btn-primary w-full py-3 text-base font-medium"
>
{{ savingSettings.has(activeSetting.id) ? '保存中...' : '保存配置' }}
</button>
</div>
</div>
<!-- 普通配置项卡片 -->
<div v-else-if="activeSetting" class="space-y-6">
<!-- 标题 -->
<div class="flex items-center justify-between pb-4 border-b border-white/10">
<div>
<h3 class="text-xl font-semibold text-white mb-1 font-mono">{{ activeSetting.keyName }}</h3>
<p class="text-sm text-art-muted">{{ activeSetting.description || '无描述' }}</p>
</div>
<button
@click="deleteSetting(activeSetting.keyName)"
class="text-red-400 hover:text-red-300 transition-colors p-2 hover:bg-red-500/10 rounded"
title="删除配置"
>
<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"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>
</button>
</div>
<!-- 值输入 -->
<div class="space-y-3">
<label class="block text-base font-medium text-white">配置值</label>
<textarea
v-if="activeSetting.keyName === 'site_description' || activeSetting.keyName === 'site_keywords'"
v-model="activeSetting.value"
rows="5"
<textarea
v-else-if="schema.type === 'textarea'"
v-model="formValues[schema.key]"
rows="3"
class="admin-input resize-none w-full"
placeholder="配置项的值"
></textarea>
<input
v-else
type="text"
v-model="activeSetting.value"
class="admin-input w-full text-base"
placeholder="配置项的值"
>
</div>
/>
<!-- 保存按钮 -->
<div class="pt-6 border-t border-white/10">
<button
@click="saveSingleSetting(activeSetting)"
:disabled="savingSettings.has(activeSetting.id)"
class="admin-btn-primary w-full py-3 text-base font-medium"
>
{{ savingSettings.has(activeSetting.id) ? '保存中...' : '保存配置' }}
</button>
<input
v-else-if="schema.type === 'number'"
v-model="formValues[schema.key]"
type="number"
min="1"
class="admin-input w-full max-w-xs"
/>
<EmailAutocomplete
v-else-if="schema.type === 'email'"
:name="schema.key"
:label="schema.label"
v-model="formValues[schema.key]"
variant="admin"
:placeholder="schema.default || 'example@domain.com'"
/>
<input
v-else
v-model="formValues[schema.key]"
type="text"
class="admin-input w-full"
/>
</div>
</div>
</div>
</div>
<!-- 新增配置 Modal -->
<div v-if="showAddModal" class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="absolute inset-0 bg-black/80 backdrop-blur-sm transition-opacity" @click="closeAddModal"></div>
<div class="admin-card w-full max-w-lg relative z-10 flex flex-col max-h-[90vh] shadow-2xl animate-reveal">
<div class="flex items-center justify-between p-6 border-b border-white/5">
<h2 class="text-lg font-medium text-white">新增配置</h2>
<button @click="closeAddModal" class="text-white/40 hover:text-white transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" 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>
<div v-if="getGroupItems(group.id).length" class="pt-5 mt-5 border-t border-white/5">
<button
type="button"
class="admin-btn-primary"
:disabled="savingGroup === group.id"
@click="saveGroup(group.id)"
>
{{ savingGroup === group.id ? '保存中...' : `保存${group.label}` }}
</button>
</div>
<div class="p-6 overflow-y-auto">
<form @submit.prevent="saveNewSetting" class="space-y-4">
<div class="space-y-2">
<label for="keyName" class="block text-xs font-medium text-art-muted uppercase tracking-wider">键名</label>
<input
type="text"
id="keyName"
v-model="newForm.keyName"
required
class="admin-input font-mono"
placeholder="例如: site_title"
>
</div>
<!-- Custom tab -->
<div v-show="activeTab === 'custom'" class="admin-card p-6 border border-white/10 border-dashed">
<h2 class="text-lg font-medium text-white mb-4">自定义配置</h2>
<div v-if="customSettings.length === 0" class="text-sm text-art-muted">暂无自定义配置项</div>
<div v-else class="space-y-4">
<div v-for="setting in customSettings" :key="setting.id" class="flex gap-3 items-start">
<div class="flex-1 space-y-2">
<input v-model="setting.value" class="admin-input w-full font-mono text-sm" />
<p class="text-xs text-art-muted">{{ setting.keyName }} {{ setting.description || '无描述' }}</p>
</div>
<div class="space-y-2">
<label for="value" class="block text-xs font-medium text-art-muted uppercase tracking-wider"></label>
<input
type="text"
id="value"
v-model="newForm.value"
required
class="admin-input"
placeholder="配置项的值"
>
</div>
<div class="space-y-2">
<label for="description" class="block text-xs font-medium text-art-muted uppercase tracking-wider">描述</label>
<textarea
id="description"
v-model="newForm.description"
rows="3"
class="admin-input resize-none"
placeholder="该配置项的用途说明"
></textarea>
</div>
<div class="pt-4 flex justify-end gap-3">
<button type="button" @click="closeAddModal" class="admin-btn-secondary">取消</button>
<button type="submit" class="admin-btn-primary">保存</button>
</div>
</form>
<button type="button" class="admin-btn-danger text-xs shrink-0" @click="deleteSetting(setting.keyName)">删除</button>
</div>
</div>
<form @submit.prevent="saveNewSetting" class="mt-6 pt-6 border-t border-white/5 grid grid-cols-1 md:grid-cols-3 gap-3">
<input v-model="newForm.keyName" class="admin-input" placeholder="键名" required />
<input v-model="newForm.value" class="admin-input" placeholder="值" required />
<input v-model="newForm.description" class="admin-input" placeholder="描述" />
<button type="submit" class="admin-btn-secondary md:col-span-3">新增自定义配置</button>
</form>
</div>
<div class="flex justify-end">
<button type="button" class="admin-btn-search px-8" :disabled="savingAll" @click="saveAll">
{{ savingAll ? '保存中...' : '保存全部配置' }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { getSettings, createSetting, updateSetting, deleteSetting as deleteSettingApi, Setting } from '../../services/api'
import { ref, reactive, onMounted, computed } from 'vue'
import {
getSettings,
createSetting,
batchUpdateSettings,
deleteSetting as deleteSettingApi,
type Setting,
} from '../../services/api'
import { useToast } from '../../composables/useToast'
import { usePublicSettings } from '../../composables/usePublicSettings'
import EmailAutocomplete from '../../components/ui/EmailAutocomplete.vue'
import {
SETTING_GROUPS,
SETTINGS_SCHEMA,
MENU_OPTIONS,
getSchemaKeys,
type SettingSchemaItem,
} from '../../config/settingsSchema'
const toast = useToast()
const { invalidate: invalidatePublicSettings } = usePublicSettings()
const loading = ref(true)
const savingAll = ref(false)
const savingGroup = ref<string | null>(null)
const activeTab = ref<SettingSchemaItem['group'] | 'custom'>('site')
const settings = ref<Setting[]>([])
const showAddModal = ref(false)
const savingSettings = ref<Set<number>>(new Set())
const activeTab = ref<number | null>(null)
// 菜单选项配置
const menuOptions = [
{ key: 'home', label: '首页' },
{ key: 'blog', label: '思考' },
{ key: 'columns', label: '专栏' },
{ key: 'works', label: '作品' },
{ key: 'snippets', label: '代码' },
{ key: 'about', label: '关于' },
{ key: 'services', label: '合作' }
]
// 选中的菜单
const selectedMenus = ref<string[]>([])
const formValues = reactive<Record<string, string>>({})
// 菜单配置项
const menuSetting = computed(() => {
return settings.value.find(s => s.keyName === 'visible_menus')
})
const newForm = ref({ keyName: '', value: '', description: '' })
// 所有配置项用于tab
const allSettings = computed(() => {
// 将菜单配置放在第一位
const menu = settings.value.find(s => s.keyName === 'visible_menus')
const others = settings.value.filter(s => s.keyName !== 'visible_menus')
return menu ? [menu, ...others] : others
})
const schemaKeys = getSchemaKeys()
// 当前激活的配置项
const activeSetting = computed(() => {
if (activeTab.value === null && allSettings.value.length > 0) {
return allSettings.value[0]
const tabs = computed(() => [
...SETTING_GROUPS.map(g => ({ id: g.id as SettingSchemaItem['group'] | 'custom', label: g.label })),
{ id: 'custom' as const, label: '自定义' },
])
const customSettings = computed(() =>
settings.value.filter(s => !schemaKeys.includes(s.keyName))
)
const getGroupItems = (groupId: SettingSchemaItem['group']) =>
SETTINGS_SCHEMA.filter(s => s.group === groupId)
const initFormValues = () => {
for (const schema of SETTINGS_SCHEMA) {
const existing = settings.value.find(s => s.keyName === schema.key)
formValues[schema.key] = existing?.value ?? schema.default
}
return allSettings.value.find(s => s.id === activeTab.value) || null
})
// 获取配置项显示名称
const getSettingDisplayName = (keyName: string) => {
const nameMap: Record<string, string> = {
'visible_menus': '菜单显示',
'site_title': '网站标题',
'site_description': '网站描述',
'site_author': '网站作者',
'site_keywords': '网站关键词',
'posts_per_page': '文章分页',
'works_per_page': '作品分页',
'snippets_per_page': '代码分页'
const menuVal = formValues.visible_menus || ''
try {
const parsed = JSON.parse(menuVal)
selectedMenus.value = Array.isArray(parsed) ? parsed : MENU_OPTIONS.map(m => m.key)
} catch {
selectedMenus.value = menuVal
? menuVal.split(',').map(m => m.trim()).filter(Boolean)
: MENU_OPTIONS.map(m => m.key)
}
return nameMap[keyName] || keyName
}
const newForm = ref({
keyName: '',
value: '',
description: ''
})
const buildPayload = (): Record<string, string> => {
const payload: Record<string, string> = { ...formValues }
payload.visible_menus = JSON.stringify(selectedMenus.value)
return payload
}
const fetchSettings = async () => {
loading.value = true
try {
settings.value = await getSettings()
// 初始化激活的tab第一个配置项
if (settings.value.length > 0 && activeTab.value === null) {
activeTab.value = allSettings.value[0]?.id || null
}
// 初始化菜单选择
if (menuSetting.value) {
const menuValue = menuSetting.value.value || ''
if (menuValue) {
// 支持逗号分隔或JSON数组格式
try {
const parsed = JSON.parse(menuValue)
selectedMenus.value = Array.isArray(parsed) ? parsed : []
} catch {
// 如果不是JSON按逗号分隔
selectedMenus.value = menuValue.split(',').map(m => m.trim()).filter(m => m)
}
} else {
// 默认显示所有菜单
selectedMenus.value = menuOptions.map(m => m.key)
for (const schema of SETTINGS_SCHEMA) {
if (!settings.value.find(s => s.keyName === schema.key)) {
await createSetting({
keyName: schema.key,
value: schema.default,
description: schema.description,
})
}
} else {
// 如果没有菜单配置,默认显示所有菜单
selectedMenus.value = menuOptions.map(m => m.key)
}
} catch (error) {
console.error('Error fetching settings:', error)
if (SETTINGS_SCHEMA.some(s => !settings.value.find(existing => existing.keyName === s.key))) {
settings.value = await getSettings()
}
initFormValues()
} catch {
toast.showToast('获取系统配置失败', 'error')
} finally {
loading.value = false
}
}
// 保存单个配置项
const saveSingleSetting = async (setting: Setting) => {
savingSettings.value.add(setting.id)
const persistPayload = async (payload: Record<string, string>) => {
await batchUpdateSettings(payload)
invalidatePublicSettings()
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('public-settings-changed'))
}
await fetchSettings()
}
const saveGroup = async (groupId: SettingSchemaItem['group']) => {
savingGroup.value = groupId
try {
await updateSetting({
id: setting.id,
keyName: setting.keyName,
value: setting.value,
description: setting.description
})
const keys = getGroupItems(groupId).map(s => s.key)
const payload: Record<string, string> = {}
for (const key of keys) {
if (key === 'visible_menus') {
payload[key] = JSON.stringify(selectedMenus.value)
} else {
payload[key] = formValues[key] ?? ''
}
}
await persistPayload(payload)
toast.showToast('配置保存成功', 'success')
// 重新获取配置以确保数据同步
await fetchSettings()
} catch (error) {
console.error('Error saving setting:', error)
} catch {
toast.showToast('保存配置失败', 'error')
} finally {
savingSettings.value.delete(setting.id)
savingGroup.value = null
}
}
// 保存菜单配置
const saveMenuSetting = async () => {
if (!menuSetting.value) return
savingSettings.value.add(menuSetting.value.id)
const saveAll = async () => {
savingAll.value = true
try {
// 将选中的菜单保存为JSON数组格式
const menuValue = JSON.stringify(selectedMenus.value)
await updateSetting({
id: menuSetting.value.id,
keyName: menuSetting.value.keyName,
value: menuValue,
description: menuSetting.value.description
})
toast.showToast('菜单配置保存成功', 'success')
// 重新获取配置以确保数据同步
await fetchSettings()
} catch (error) {
console.error('Error saving menu setting:', error)
toast.showToast('保存菜单配置失败', 'error')
await persistPayload(buildPayload())
toast.showToast('全部配置已保存', 'success')
} catch {
toast.showToast('保存配置失败', 'error')
} finally {
savingSettings.value.delete(menuSetting.value.id)
savingAll.value = false
}
}
const saveNewSetting = async () => {
try {
await createSetting({
keyName: newForm.value.keyName,
value: newForm.value.value,
description: newForm.value.description
})
await createSetting(newForm.value)
newForm.value = { keyName: '', value: '', description: '' }
toast.showToast('配置创建成功', 'success')
closeAddModal()
fetchSettings()
} catch (error) {
console.error('Error creating setting:', error)
await fetchSettings()
} catch {
toast.showToast('创建配置失败', 'error')
}
}
const deleteSetting = async (keyName: string) => {
if (confirm(`确定要删除配置项 "${keyName}" 吗?`)) {
try {
await deleteSettingApi(keyName)
toast.showToast('配置删除成功', 'success')
fetchSettings()
} catch (error) {
console.error('Error deleting setting:', error)
toast.showToast('删除配置失败', 'error')
}
if (!confirm(`确定要删除配置项 "${keyName}" 吗?`)) return
try {
await deleteSettingApi(keyName)
toast.showToast('配置删除成功', 'success')
await fetchSettings()
} catch {
toast.showToast('删除配置失败', 'error')
}
}
const closeAddModal = () => {
showAddModal.value = false
newForm.value = {
keyName: '',
value: '',
description: ''
}
}
onMounted(() => {
fetchSettings()
})
onMounted(fetchSettings)
</script>
<style scoped>
/* Scoped styles removed */
</style>

View File

@@ -6,7 +6,7 @@
<form @submit.prevent="submitForm">
<div class="form-group">
<label class="form-label">作者</label>
<input type="text" v-model="formData.author" class="form-input" required placeholder="请输入作者姓名">
<input type="text" v-model="formData.name" class="form-input" required placeholder="请输入作者姓名">
</div>
<div class="form-group">
@@ -55,7 +55,7 @@ const toast = useToast()
const isEdit = computed(() => !!route.params.id)
const formData = ref({
author: '',
name: '',
role: '',
avatar: '',
rating: 5,
@@ -74,7 +74,7 @@ const loadData = async () => {
const item = list.find(t => t.id === id)
if (item) {
formData.value = {
author: item.author,
name: item.name,
role: item.role,
avatar: item.avatar,
rating: item.rating,

View File

@@ -30,6 +30,7 @@
<thead>
<tr>
<th>ID</th>
<th>头像</th>
<th>作者</th>
<th>角色</th>
<th>内容</th>
@@ -41,7 +42,16 @@
<tbody>
<tr v-for="t in filteredTestimonials" :key="t.id">
<td class="table-cell">{{ t.id }}</td>
<td class="table-cell">{{ t.author }}</td>
<td class="table-cell">
<img
v-if="t.avatar"
:src="t.avatar"
alt=""
class="w-8 h-8 rounded-full object-cover bg-white/5"
/>
<span v-else class="text-art-muted text-xs"></span>
</td>
<td class="table-cell">{{ t.name }}</td>
<td class="table-cell">{{ t.role }}</td>
<td class="table-cell" :title="t.content">
{{ t.content.length > 30 ? t.content.substring(0, 30) + '...' : t.content }}
@@ -90,7 +100,7 @@ const ratingOptions = [
]
const filteredTestimonials = computed(() => testimonials.value.filter(t => {
if (!matchKeywordFn(t, appliedKeyword.value, item => `${item.author} ${item.content}`)) return false
if (!matchKeywordFn(t, appliedKeyword.value, item => `${item.name} ${item.content}`)) return false
if (appliedRating.value !== '' && t.rating !== Number(appliedRating.value)) return false
return true
}))

View File

@@ -22,11 +22,12 @@
<!-- Email Field -->
<div class="form-group">
<label for="email">邮箱</label>
<input
type="email"
id="email"
<EmailAutocomplete
name="email"
label="邮箱"
v-model="form.email"
placeholder="请输入邮箱"
variant="admin"
:user-lookup="true"
required
/>
<div class="error-message" v-if="errors.email">
@@ -71,6 +72,29 @@
{{ errors.isActive }}
</div>
</div>
<div class="form-group">
<label for="bio">个人介绍</label>
<textarea id="bio" v-model="form.bio" rows="4" placeholder="个人介绍" />
</div>
<div class="form-group">
<label for="phone">手机号</label>
<input id="phone" v-model="form.phone" type="tel" placeholder="选填" />
</div>
<div class="form-group">
<label for="wechat">微信号</label>
<input id="wechat" v-model="form.wechat" type="text" placeholder="选填" />
</div>
<div class="form-group">
<ImagePicker
v-model="form.wechatQrcode"
label="微信二维码"
hint="上传微信二维码图片"
/>
</div>
<!-- Submit Buttons -->
<div class="form-actions">
@@ -93,6 +117,7 @@ import { useToast } from '../../composables/useToast'
import { createUser, updateUser, fetchUser } from '../../services/api'
import CustomSelect from '../../components/CustomSelect.vue'
import ImagePicker from '../../components/admin/ImagePicker.vue'
import EmailAutocomplete from '../../components/ui/EmailAutocomplete.vue'
const router = useRouter()
const route = useRoute()
@@ -108,6 +133,10 @@ const form = reactive({
username: '',
email: '',
avatar: '',
bio: '',
phone: '',
wechat: '',
wechatQrcode: '',
role: 'viewer',
isActive: 1
})
@@ -198,6 +227,10 @@ onMounted(async () => {
form.username = user.username
form.email = user.email
form.avatar = user.avatar || ''
form.bio = user.bio || ''
form.phone = user.phone || ''
form.wechat = user.wechat || ''
form.wechatQrcode = user.wechatQrcode || ''
form.role = user.role
form.isActive = user.isActive
} catch (error: any) {
@@ -249,7 +282,8 @@ onMounted(async () => {
}
.form-group input,
.form-group select {
.form-group select,
.form-group textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.2);

View File

@@ -57,7 +57,9 @@
</div>
</td>
<td>{{ user.username }}</td>
<td>{{ user.email }}</td>
<td>
<button type="button" class="email-link" @click="openUserProfile(user.id)">{{ user.email }}</button>
</td>
<td>
<span class="role-badge" :class="user.role">
{{ getUserRoleText(user.role) }}
@@ -111,9 +113,11 @@ import { useToast } from '../../composables/useToast'
import { getUsersPaginated, deleteUser, User } from '../../services/api'
import CustomSelect from '../../components/CustomSelect.vue'
import AdminTableFilter from '../../components/admin/AdminTableFilter.vue'
import { useUserProfileModal } from '../../composables/useUserProfileModal'
const router = useRouter()
const toast = useToast()
const { openUserProfile } = useUserProfileModal()
// State
const users = ref<User[]>([])
@@ -380,6 +384,21 @@ onMounted(() => {
background: rgba(212, 179, 131, 0.05);
}
.email-link {
background: none;
border: none;
padding: 0;
color: #d4b383;
cursor: pointer;
font: inherit;
text-decoration: underline;
text-underline-offset: 2px;
}
.email-link:hover {
color: #fff;
}
/* Avatar */
.user-avatar-cell {
display: flex;

View File

@@ -73,7 +73,7 @@
<span class="text-xs text-white/40 whitespace-nowrap">{{ formatDate(post.date) }}</span>
</div>
<div class="flex items-center gap-2 mt-2">
<span class="px-2 py-0.5 rounded text-[10px] bg-white/5 text-white/60 border border-white/5">{{ post.category }}</span>
<span class="px-2 py-0.5 rounded text-[10px] bg-white/5 text-white/60 border border-white/5">{{ post.categoryName || post.category?.name || '未分类' }}</span>
<span class="text-[10px] text-white/40 flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"></circle></svg>
{{ post.readCount || 0 }}
@@ -90,7 +90,7 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { getDashboardStats, getOperationLogs, fetchPosts } from '../../../services/api'
import { getDashboardStats, getOperationLogs, getAdminPosts } from '../../../services/api'
const stats = ref<any>({})
const operationLogs = ref<any[]>([])
@@ -112,7 +112,7 @@ const loadData = async () => {
const [statsData, logsData, postsData] = await Promise.all([
getDashboardStats(),
getOperationLogs(1, 5),
fetchPosts()
getAdminPosts(1, 5)
])
stats.value = statsData

View File

@@ -31,6 +31,16 @@ export const getCachedSiteTitle = (): string => {
return cachedSiteTitle || document.title.split(' | ')[0] || '年糕崽崽.Dev'
}
/** 清除网站标题缓存(配置更新后调用) */
export const invalidateSiteTitleCache = () => {
cachedSiteTitle = null
settingsLoaded = false
}
if (typeof window !== 'undefined') {
window.addEventListener('public-settings-changed', invalidateSiteTitleCache)
}
// 设置页面标题
const setPageTitle = async (to: any) => {
const siteTitle = await loadSiteTitle()
@@ -85,6 +95,7 @@ const routes = [
{ path: 'users', name: 'admin-users', component: () => import('./pages/admin/Users.vue') },
{ path: 'users/create', name: 'admin-users-create', component: () => import('./pages/admin/UserForm.vue') },
{ path: 'users/:id/edit', name: 'admin-users-edit', component: () => import('./pages/admin/UserForm.vue') },
{ path: 'profile', name: 'admin-profile', component: () => import('./pages/admin/Profile.vue'), meta: { title: '个人中心' } },
// 角色管理
{ path: 'roles', name: 'admin-roles', component: () => import('./pages/admin/Roles.vue') },

View File

@@ -79,12 +79,36 @@ export interface User {
username: string
email: string
avatar?: string
bio?: string
phone?: string
wechat?: string
wechatQrcode?: string
role: string
isActive: number
createdAt: string
updatedAt: string
}
export interface UserPublicProfile {
id: number
username: string
email: string
avatar?: string
bio?: string
phone?: string
wechat?: string
wechatQrcode?: string
}
export interface UpdateProfileRequest {
email: string
avatar?: string
bio?: string
phone?: string
wechat?: string
wechatQrcode?: string
}
export interface LoginRequest {
username: string
password: string
@@ -391,6 +415,46 @@ export const fetchUser = async (id: number): Promise<User> => {
}
}
export const fetchUserProfile = async (id: number): Promise<UserPublicProfile> => {
const response = await fetch(`${API_BASE}/users/${id}/profile`)
return await parseApiResponse(response)
}
export const fetchUserPosts = async (
id: number,
sort: 'recent' | 'popular' = 'recent',
limit = 5
): Promise<Post[]> => {
const qs = new URLSearchParams({ sort, limit: String(limit) })
const response = await fetch(`${API_BASE}/users/${id}/posts?${qs}`)
return await parseApiResponse(response)
}
export const getCurrentUser = async (): Promise<User> => {
const response = await authFetch(`${API_BASE}/admin/me`, {
headers: getAuthHeaders()
})
return await parseApiResponse(response)
}
export const updateCurrentUser = async (data: UpdateProfileRequest): Promise<User> => {
const response = await authFetch(`${API_BASE}/admin/me`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(data)
})
return await parseApiResponse(response)
}
export const updateCurrentUserPassword = async (oldPassword: string, newPassword: string): Promise<void> => {
const response = await authFetch(`${API_BASE}/admin/me/password`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify({ oldPassword, newPassword })
})
await parseApiResponse(response)
}
export const createUser = async (userData: Omit<User, 'id' | 'createdAt' | 'updatedAt'>): Promise<void> => {
try {
const response = await authFetch(`${API_BASE}/admin/users`, {
@@ -1288,6 +1352,19 @@ export interface PublicSettings {
site_keywords?: string
visible_menus?: string
posts_per_page?: string
works_per_page?: string
snippets_per_page?: string
footer_icp?: string
contact_email?: string
}
export const batchUpdateSettings = async (values: Record<string, string>): Promise<void> => {
const response = await authFetch(`${API_BASE}/admin/settings`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(values)
})
await parseApiResponse(response)
}
export const getPublicSettings = async (): Promise<PublicSettings> => {
@@ -1548,7 +1625,7 @@ export const deleteAboutProfile = async (id: number): Promise<void> => {
export interface Testimonial {
id: number
content: string
author: string
name: string
role: string
avatar: string
rating: number
@@ -1562,7 +1639,7 @@ export interface Partner {
name: string
logo: string
description: string
website: string
url: string
createdAt: string
updatedAt: string
}

View File

@@ -67,6 +67,10 @@
.admin-btn-secondary {
@apply bg-transparent text-white border border-white/10 px-5 py-2.5 rounded-md hover:bg-white/5 active:scale-95 transition-all text-sm;
}
.admin-btn-search {
@apply bg-art-accent text-black font-medium px-5 py-2.5 rounded-md hover:opacity-90 active:scale-95 transition-all text-sm tracking-wide;
}
.admin-btn-danger {
@apply bg-art-error/10 text-art-error border border-art-error/20 px-5 py-2.5 rounded-md hover:bg-art-error/20 active:scale-95 transition-all text-sm;

View File

@@ -65,12 +65,7 @@ func Login(c *gin.Context) {
utils.Success(c, gin.H{
"token": tokenString,
"expire": expireUnix,
"user": gin.H{
"id": user.ID,
"username": user.Username,
"email": user.Email,
"role": user.Role,
},
"user": repositories.BuildUserResponse(user),
})
}

View File

@@ -16,7 +16,7 @@ func GetCategories(c *gin.Context) {
utils.ServerError(c, err)
return
}
utils.Success(c, categories)
utils.Success(c, repositories.BuildCategoriesResponse(categories))
}
// GetCategoryByID 根据ID获取分类
@@ -38,7 +38,7 @@ func GetCategoryByID(c *gin.Context) {
return
}
utils.Success(c, category)
utils.Success(c, repositories.BuildCategoryResponse(category))
}
// AdminCreateCategory 创建分类
@@ -54,7 +54,7 @@ func AdminCreateCategory(c *gin.Context) {
return
}
utils.Success(c, category)
utils.Success(c, repositories.BuildCategoryResponse(&category))
}
// AdminUpdateCategory 更新分类
@@ -78,7 +78,7 @@ func AdminUpdateCategory(c *gin.Context) {
return
}
utils.Success(c, category)
utils.Success(c, repositories.BuildCategoryResponse(&category))
}
// AdminDeleteCategory 删除分类

View File

@@ -16,7 +16,7 @@ func GetColumns(c *gin.Context) {
utils.ServerError(c, err)
return
}
utils.Success(c, columns)
utils.Success(c, repositories.BuildColumnsResponse(columns))
}
// GetColumnByID 根据ID获取专栏

View File

@@ -20,7 +20,11 @@ func GetSettings(c *gin.Context) {
// 只返回前端需要的公开配置项
publicSettings := make(map[string]string)
publicKeys := []string{"site_title", "site_description", "site_author", "site_keywords", "visible_menus", "posts_per_page"}
publicKeys := []string{
"site_title", "site_description", "site_author", "site_keywords",
"visible_menus", "posts_per_page", "works_per_page", "snippets_per_page",
"footer_icp", "contact_email",
}
for _, key := range publicKeys {
if value, exists := settingsMap[key]; exists {

View File

@@ -26,6 +26,7 @@ func main() {
repositories.MigratePostSnippets()
repositories.MigratePostUserID()
repositories.MigrateUserAvatar()
repositories.MigrateUserProfileFields()
// 初始化ip2region (如果文件不存在将降级为普通IP记录)
// 函数会自动从环境变量或可执行文件目录查找 ip2region.xdb
@@ -101,6 +102,10 @@ func main() {
// 咨询相关路由
api.POST("/inquiries", handlers.SubmitInquiry)
api.GET("/email-suffixes", handlers.GetEmailSuffixes)
// 用户公开资料
api.GET("/users/:id/profile", handlers.GetUserProfile)
api.GET("/users/:id/posts", handlers.GetUserPosts)
}
// 管理员API路由组
@@ -113,6 +118,11 @@ func main() {
authAdmin := admin.Group("/")
authAdmin.Use(middleware.AuthMiddleware(), middleware.OperationLogMiddleware())
{
// 当前用户
authAdmin.GET("/me", handlers.GetCurrentUser)
authAdmin.PUT("/me", handlers.UpdateCurrentUser)
authAdmin.PUT("/me/password", handlers.UpdateCurrentUserPassword)
// 用户管理
authAdmin.GET("/users", middleware.PermissionMiddleware("users", "read"), handlers.AdminGetUsers)
authAdmin.GET("/users/:id", middleware.PermissionMiddleware("users", "read"), handlers.AdminGetUser)
@@ -198,6 +208,7 @@ func main() {
// 标签管理
authAdmin.GET("/tags", middleware.PermissionMiddleware("tags", "read"), handlers.AdminGetTags)
authAdmin.GET("/tags/:id", middleware.PermissionMiddleware("tags", "read"), handlers.GetTag)
authAdmin.POST("/tags", middleware.PermissionMiddleware("tags", "create"), handlers.AdminCreateTag)
authAdmin.PUT("/tags/:id", middleware.PermissionMiddleware("tags", "update"), handlers.AdminUpdateTag)
authAdmin.DELETE("/tags/:id", middleware.PermissionMiddleware("tags", "delete"), handlers.AdminDeleteTag)

View File

@@ -43,3 +43,14 @@ func (c *Category) BeforeUpdate(tx *gorm.DB) error {
c.UpdatedAt = time.Now().Unix()
return nil
}
// CategoryResponse 分类 API 响应
type CategoryResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Description string `json:"description"`
SortOrder uint `json:"sortOrder"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}

View File

@@ -45,6 +45,20 @@ func (c *Column) BeforeUpdate(tx *gorm.DB) error {
return nil
}
// ColumnResponse 专栏 API 响应
type ColumnResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Cover string `json:"cover"`
IsActive int `json:"isActive"`
SortOrder uint `json:"sortOrder"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
PostCount int64 `json:"postCount,omitempty"`
LastUpdated string `json:"lastUpdated,omitempty"`
}
// ColumnPost 专栏文章关联模型
type ColumnPost struct {
ColumnID uint `json:"columnId" gorm:"primaryKey;column:column_id"`

View File

@@ -42,6 +42,15 @@ func (t *Tag) BeforeUpdate(tx *gorm.DB) error {
return nil
}
// TagResponse 标签 API 响应
type TagResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
// PostTag 文章标签关联模型
type PostTag struct {
PostID uint `json:"postId" gorm:"primaryKey;column:post_id"`

View File

@@ -12,6 +12,10 @@ type User struct {
Username string `json:"username" gorm:"column:username;uniqueIndex;not null"`
Email string `json:"email" gorm:"column:email"`
Avatar string `json:"avatar" gorm:"column:avatar"`
Bio string `json:"bio" gorm:"column:bio;type:text"`
Phone string `json:"phone" gorm:"column:phone"`
Wechat string `json:"wechat" gorm:"column:wechat"`
WechatQrcode string `json:"wechatQrcode" gorm:"column:wechat_qrcode"`
Password string `json:"password,omitempty" gorm:"-"` // Virtual field for input
PasswordHash string `json:"-" gorm:"column:password_hash"`
RoleID uint `json:"roleId" gorm:"column:role_id"`
@@ -50,15 +54,47 @@ func (u *User) BeforeUpdate(tx *gorm.DB) error {
// UserResponse 用户响应模型
type UserResponse struct {
ID uint `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
Avatar string `json:"avatar,omitempty"`
RoleID uint `json:"roleId"`
Role string `json:"role"`
IsActive int `json:"isActive"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
ID uint `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
Avatar string `json:"avatar,omitempty"`
Bio string `json:"bio,omitempty"`
Phone string `json:"phone,omitempty"`
Wechat string `json:"wechat,omitempty"`
WechatQrcode string `json:"wechatQrcode,omitempty"`
RoleID uint `json:"roleId"`
Role string `json:"role"`
IsActive int `json:"isActive"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
// UserPublicProfile 公开用户资料(前台展示)
type UserPublicProfile struct {
ID uint `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
Avatar string `json:"avatar,omitempty"`
Bio string `json:"bio,omitempty"`
Phone string `json:"phone,omitempty"`
Wechat string `json:"wechat,omitempty"`
WechatQrcode string `json:"wechatQrcode,omitempty"`
}
// UpdateProfileRequest 当前用户更新资料请求
type UpdateProfileRequest struct {
Email string `json:"email"`
Avatar string `json:"avatar"`
Bio string `json:"bio"`
Phone string `json:"phone"`
Wechat string `json:"wechat"`
WechatQrcode string `json:"wechatQrcode"`
}
// UpdatePasswordRequest 修改密码请求
type UpdatePasswordRequest struct {
OldPassword string `json:"oldPassword" binding:"required"`
NewPassword string `json:"newPassword" binding:"required"`
}
// LoginRequest 登录请求模型

View File

@@ -78,3 +78,25 @@ func DeleteCategory(id uint) error {
}
return nil
}
// BuildCategoryResponse 构建分类响应
func BuildCategoryResponse(category *models.Category) *models.CategoryResponse {
return &models.CategoryResponse{
ID: category.ID,
Name: category.Name,
Slug: category.Slug,
Description: category.Description,
SortOrder: category.SortOrder,
CreatedAt: formatTimestamp(category.CreatedAt),
UpdatedAt: formatTimestamp(category.UpdatedAt),
}
}
// BuildCategoriesResponse 构建分类列表响应
func BuildCategoriesResponse(categories []models.Category) []models.CategoryResponse {
responses := make([]models.CategoryResponse, 0, len(categories))
for _, category := range categories {
responses = append(responses, *BuildCategoryResponse(&category))
}
return responses
}

View File

@@ -87,24 +87,46 @@ func GetColumnStats(columnID uint) (int64, int64, error) {
}
// BuildColumnResponse 构建专栏响应(包含统计信息)
func BuildColumnResponse(col *models.Column) map[string]interface{} {
func BuildColumnResponse(col *models.Column) *models.ColumnResponse {
postCount, lastUpdated, _ := GetColumnStats(col.ID)
return map[string]interface{}{
"id": col.ID,
"name": col.Name,
"description": col.Description,
"cover": col.Cover,
"isActive": col.IsActive,
"sortOrder": col.SortOrder,
"createdAt": col.CreatedAt,
"updatedAt": col.UpdatedAt,
"deletedAt": col.DeletedAt,
"postCount": postCount,
"lastUpdated": lastUpdated,
return &models.ColumnResponse{
ID: col.ID,
Name: col.Name,
Description: col.Description,
Cover: col.Cover,
IsActive: col.IsActive,
SortOrder: col.SortOrder,
CreatedAt: formatTimestamp(col.CreatedAt),
UpdatedAt: formatTimestamp(col.UpdatedAt),
PostCount: postCount,
LastUpdated: formatTimestamp(lastUpdated),
}
}
// BuildColumnItemResponse 构建专栏列表项响应(不含统计)
func BuildColumnItemResponse(col *models.Column) *models.ColumnResponse {
return &models.ColumnResponse{
ID: col.ID,
Name: col.Name,
Description: col.Description,
Cover: col.Cover,
IsActive: col.IsActive,
SortOrder: col.SortOrder,
CreatedAt: formatTimestamp(col.CreatedAt),
UpdatedAt: formatTimestamp(col.UpdatedAt),
}
}
// BuildColumnsResponse 构建专栏列表响应
func BuildColumnsResponse(columns []models.Column) []models.ColumnResponse {
responses := make([]models.ColumnResponse, 0, len(columns))
for _, col := range columns {
responses = append(responses, *BuildColumnItemResponse(&col))
}
return responses
}
// CreateColumn 创建专栏
func CreateColumn(col *models.Column) error {
err := config.DB.Create(col).Error

View File

@@ -175,3 +175,23 @@ func MigrateUserAvatar() {
log.Printf("Adding users.avatar...")
execSQL("ALTER TABLE `users` ADD COLUMN `avatar` VARCHAR(500) NULL DEFAULT NULL COMMENT '头像URL' AFTER `email`")
}
// MigrateUserProfileFields adds profile fields to users if missing.
func MigrateUserProfileFields() {
if !columnExists("users", "bio") {
log.Printf("Adding users.bio...")
execSQL("ALTER TABLE `users` ADD COLUMN `bio` TEXT NULL COMMENT '个人介绍' AFTER `avatar`")
}
if !columnExists("users", "phone") {
log.Printf("Adding users.phone...")
execSQL("ALTER TABLE `users` ADD COLUMN `phone` VARCHAR(20) NULL DEFAULT NULL COMMENT '手机号' AFTER `bio`")
}
if !columnExists("users", "wechat") {
log.Printf("Adding users.wechat...")
execSQL("ALTER TABLE `users` ADD COLUMN `wechat` VARCHAR(100) NULL DEFAULT NULL COMMENT '微信号' AFTER `phone`")
}
if !columnExists("users", "wechat_qrcode") {
log.Printf("Adding users.wechat_qrcode...")
execSQL("ALTER TABLE `users` ADD COLUMN `wechat_qrcode` VARCHAR(500) NULL DEFAULT NULL COMMENT '微信二维码图片URL' AFTER `wechat`")
}
}

View File

@@ -76,6 +76,36 @@ func GetPosts(keyword string, categoryID uint, tagID uint, columnID uint, page i
return posts, total, nil
}
// GetPostsByUserID 获取用户已发布文章(用于公开资料页)
func GetPostsByUserID(userID uint, sort string, limit int) ([]models.Post, error) {
if limit <= 0 {
limit = 5
}
if limit > 20 {
limit = 20
}
orderClause := "created_at DESC"
if sort == "popular" {
orderClause = "read_count DESC, created_at DESC"
}
var posts []models.Post
err := config.DB.Model(&models.Post{}).
Where("user_id = ? AND is_published = ? AND deleted_at = ?", userID, 1, 0).
Preload("Category").
Preload("Column").
Preload("Tags").
Order(orderClause).
Limit(limit).
Find(&posts).Error
if err != nil {
log.Printf("Error querying posts by user ID: %v", err)
return nil, err
}
return posts, nil
}
// GetPostByID 根据ID获取博客文章
func GetPostByID(id uint) (*models.Post, error) {
var post models.Post
@@ -452,6 +482,8 @@ func buildPostResponse(post *models.Post, includeContent, includeSnippets bool,
if post.Category != nil {
catName = post.Category.Name
catSlug = post.Category.Slug
} else if post.CategoryID > 0 {
catName = lookupCategoryName(post.CategoryID)
}
colName := ""

View File

@@ -160,11 +160,21 @@ func GetPostsByTagID(tagID uint) ([]models.Post, error) {
}
// BuildTagsResponse 构建标签列表响应
func BuildTagsResponse(tags []models.Tag) []models.Tag {
return tags
func BuildTagsResponse(tags []models.Tag) []models.TagResponse {
responses := make([]models.TagResponse, 0, len(tags))
for _, tag := range tags {
responses = append(responses, *BuildTagResponse(&tag))
}
return responses
}
// BuildTagResponse 构建标签响应
func BuildTagResponse(tag *models.Tag) *models.Tag {
return tag
func BuildTagResponse(tag *models.Tag) *models.TagResponse {
return &models.TagResponse{
ID: tag.ID,
Name: tag.Name,
Slug: tag.Slug,
CreatedAt: formatTimestamp(tag.CreatedAt),
UpdatedAt: formatTimestamp(tag.UpdatedAt),
}
}

View File

@@ -134,12 +134,16 @@ func UpdateUser(user *models.User) error {
}
updates := map[string]interface{}{
"username": user.Username,
"email": user.Email,
"avatar": user.Avatar,
"role": user.Role,
"is_active": user.IsActive,
"updated_at": time.Now().Unix(),
"username": user.Username,
"email": user.Email,
"avatar": user.Avatar,
"bio": user.Bio,
"phone": user.Phone,
"wechat": user.Wechat,
"wechat_qrcode": user.WechatQrcode,
"role": user.Role,
"is_active": user.IsActive,
"updated_at": time.Now().Unix(),
}
if user.RoleID != 0 {
@@ -205,18 +209,56 @@ func GetUserCount() (int, error) {
// BuildUserResponse 构建用户响应
func BuildUserResponse(user *models.User) *models.UserResponse {
return &models.UserResponse{
ID: user.ID,
Username: user.Username,
Email: user.Email,
Avatar: user.Avatar,
RoleID: user.RoleID,
Role: user.Role,
IsActive: user.IsActive,
CreatedAt: time.Unix(user.CreatedAt, 0).Format("2006-01-02 15:04:05"),
UpdatedAt: time.Unix(user.UpdatedAt, 0).Format("2006-01-02 15:04:05"),
ID: user.ID,
Username: user.Username,
Email: user.Email,
Avatar: user.Avatar,
Bio: user.Bio,
Phone: user.Phone,
Wechat: user.Wechat,
WechatQrcode: user.WechatQrcode,
RoleID: user.RoleID,
Role: user.Role,
IsActive: user.IsActive,
CreatedAt: time.Unix(user.CreatedAt, 0).Format("2006-01-02 15:04:05"),
UpdatedAt: time.Unix(user.UpdatedAt, 0).Format("2006-01-02 15:04:05"),
}
}
// BuildUserPublicProfile 构建公开用户资料
func BuildUserPublicProfile(user *models.User) *models.UserPublicProfile {
return &models.UserPublicProfile{
ID: user.ID,
Username: user.Username,
Email: user.Email,
Avatar: user.Avatar,
Bio: user.Bio,
Phone: user.Phone,
Wechat: user.Wechat,
WechatQrcode: user.WechatQrcode,
}
}
// UpdateCurrentUserProfile 更新当前用户资料(不含角色/状态)
func UpdateCurrentUserProfile(id uint, req *models.UpdateProfileRequest) error {
err := config.DB.Model(&models.User{}).
Where("id = ? AND deleted_at = ?", id, 0).
Updates(map[string]interface{}{
"email": req.Email,
"avatar": req.Avatar,
"bio": req.Bio,
"phone": req.Phone,
"wechat": req.Wechat,
"wechat_qrcode": req.WechatQrcode,
"updated_at": time.Now().Unix(),
}).Error
if err != nil {
log.Printf("Error updating user profile: %v", err)
return err
}
return nil
}
// BuildUsersResponse 构建用户列表响应
func BuildUsersResponse(users []models.User) []models.UserResponse {
var responses []models.UserResponse