优化页面、修复BUG
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -1,13 +1,53 @@
|
||||
<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">© 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">
|
||||
© {{ 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')
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
const menusRaw = getStringSetting('visible_menus')
|
||||
if (menusRaw) {
|
||||
try {
|
||||
const parsed = JSON.parse(settings.visible_menus)
|
||||
const parsed = JSON.parse(menusRaw)
|
||||
if (Array.isArray(parsed)) {
|
||||
visibleMenus.value = parsed
|
||||
}
|
||||
} catch {
|
||||
visibleMenus.value = settings.visible_menus.split(',').map((m: string) => m.trim()).filter((m: string) => m)
|
||||
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(
|
||||
|
||||
@@ -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">支持 JPG、PNG、GIF 格式</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>
|
||||
@@ -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)
|
||||
const parsed = JSON.parse(menusRaw)
|
||||
if (Array.isArray(parsed)) {
|
||||
visibleMenus.value = parsed
|
||||
}
|
||||
} catch {
|
||||
// 如果不是JSON,按逗号分隔
|
||||
visibleMenus.value = settings.visible_menus.split(',').map((m: string) => m.trim()).filter((m: string) => m)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load menu settings:', error)
|
||||
// 使用默认值
|
||||
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>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
</div>
|
||||
<p class="text-xs text-art-muted truncate">个人中心</p>
|
||||
</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, () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -24,15 +24,30 @@
|
||||
|
||||
<!-- 操作区 -->
|
||||
<div class="flex-1 min-w-0 space-y-2">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="admin-btn-secondary text-xs"
|
||||
:disabled="disabled || uploading"
|
||||
<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' : ''
|
||||
]"
|
||||
>
|
||||
{{ uploading ? '上传中...' : '上传图片' }}
|
||||
</button>
|
||||
<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"
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -116,12 +116,27 @@
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm text-art-muted mb-2">选择文件</label>
|
||||
<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="handleUploadFile"
|
||||
class="admin-input"
|
||||
@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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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">
|
||||
<!-- Tab bar -->
|
||||
<div class="flex flex-wrap gap-2 border-b border-white/10 pb-1">
|
||||
<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'
|
||||
]"
|
||||
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"
|
||||
>
|
||||
{{ getSettingDisplayName(setting.keyName) }}
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 菜单多选框(优化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">
|
||||
<!-- 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>
|
||||
|
||||
<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 menuOptions"
|
||||
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"
|
||||
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-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"
|
||||
v-model="activeSetting.value"
|
||||
class="admin-input w-full text-base"
|
||||
placeholder="配置项的值"
|
||||
>
|
||||
class="admin-input w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 保存按钮 -->
|
||||
<div class="pt-6 border-t border-white/10">
|
||||
<div v-if="getGroupItems(group.id).length" class="pt-5 mt-5 border-t border-white/5">
|
||||
<button
|
||||
@click="saveSingleSetting(activeSetting)"
|
||||
:disabled="savingSettings.has(activeSetting.id)"
|
||||
class="admin-btn-primary w-full py-3 text-base font-medium"
|
||||
type="button"
|
||||
class="admin-btn-primary"
|
||||
:disabled="savingGroup === group.id"
|
||||
@click="saveGroup(group.id)"
|
||||
>
|
||||
{{ savingSettings.has(activeSetting.id) ? '保存中...' : '保存配置' }}
|
||||
{{ savingGroup === group.id ? '保存中...' : `保存${group.label}` }}
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
<button type="button" class="admin-btn-danger text-xs shrink-0" @click="deleteSetting(setting.keyName)">删除</button>
|
||||
</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>
|
||||
</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>
|
||||
|
||||
<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 @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]
|
||||
}
|
||||
return allSettings.value.find(s => s.id === activeTab.value) || null
|
||||
})
|
||||
const tabs = computed(() => [
|
||||
...SETTING_GROUPS.map(g => ({ id: g.id as SettingSchemaItem['group'] | 'custom', label: g.label })),
|
||||
{ id: 'custom' as const, label: '自定义' },
|
||||
])
|
||||
|
||||
// 获取配置项显示名称
|
||||
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': '代码分页'
|
||||
}
|
||||
return nameMap[keyName] || keyName
|
||||
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
|
||||
}
|
||||
|
||||
const newForm = ref({
|
||||
keyName: '',
|
||||
value: '',
|
||||
description: ''
|
||||
})
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
} else {
|
||||
// 如果没有菜单配置,默认显示所有菜单
|
||||
selectedMenus.value = menuOptions.map(m => m.key)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching settings:', error)
|
||||
toast.showToast('获取系统配置失败', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// 保存单个配置项
|
||||
const saveSingleSetting = async (setting: Setting) => {
|
||||
savingSettings.value.add(setting.id)
|
||||
try {
|
||||
await updateSetting({
|
||||
id: setting.id,
|
||||
keyName: setting.keyName,
|
||||
value: setting.value,
|
||||
description: setting.description
|
||||
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,
|
||||
})
|
||||
toast.showToast('配置保存成功', 'success')
|
||||
// 重新获取配置以确保数据同步
|
||||
}
|
||||
}
|
||||
|
||||
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 persistPayload = async (payload: Record<string, string>) => {
|
||||
await batchUpdateSettings(payload)
|
||||
invalidatePublicSettings()
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('public-settings-changed'))
|
||||
}
|
||||
await fetchSettings()
|
||||
} catch (error) {
|
||||
console.error('Error saving setting:', error)
|
||||
}
|
||||
|
||||
const saveGroup = async (groupId: SettingSchemaItem['group']) => {
|
||||
savingGroup.value = groupId
|
||||
try {
|
||||
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')
|
||||
} 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}" 吗?`)) {
|
||||
if (!confirm(`确定要删除配置项 "${keyName}" 吗?`)) return
|
||||
try {
|
||||
await deleteSettingApi(keyName)
|
||||
toast.showToast('配置删除成功', 'success')
|
||||
fetchSettings()
|
||||
} catch (error) {
|
||||
console.error('Error deleting setting:', error)
|
||||
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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}))
|
||||
|
||||
@@ -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">
|
||||
@@ -72,6 +73,29 @@
|
||||
</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">
|
||||
<button type="button" class="cancel-btn" @click="handleCancel">
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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') },
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -68,6 +68,10 @@
|
||||
@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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 删除分类
|
||||
|
||||
@@ -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获取专栏
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
@@ -54,6 +58,10 @@ type UserResponse struct {
|
||||
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"`
|
||||
@@ -61,6 +69,34 @@ type UserResponse struct {
|
||||
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 登录请求模型
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 := ""
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +137,10 @@ func UpdateUser(user *models.User) error {
|
||||
"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(),
|
||||
@@ -209,6 +213,10 @@ func BuildUserResponse(user *models.User) *models.UserResponse {
|
||||
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,
|
||||
@@ -217,6 +225,40 @@ func BuildUserResponse(user *models.User) *models.UserResponse {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user