优化页面、修复BUG
This commit is contained in:
@@ -37,6 +37,13 @@
|
|||||||
|
|
||||||
<!-- Inquiry Modal (Hide on Admin & Login) -->
|
<!-- Inquiry Modal (Hide on Admin & Login) -->
|
||||||
<InquiryModal v-if="!isAdminOrLogin" class="relative z-[80]" />
|
<InquiryModal v-if="!isAdminOrLogin" class="relative z-[80]" />
|
||||||
|
|
||||||
|
<!-- User Profile Modal (Global) -->
|
||||||
|
<UserProfileModal
|
||||||
|
:is-open="profileModalOpen"
|
||||||
|
:user-id="profileUserId"
|
||||||
|
@close="closeUserProfile"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -48,7 +55,11 @@ import MobileMenu from './components/MobileMenu.vue'
|
|||||||
import Footer from './components/Footer.vue'
|
import Footer from './components/Footer.vue'
|
||||||
import InquiryModal from './components/InquiryModal.vue'
|
import InquiryModal from './components/InquiryModal.vue'
|
||||||
import StarBackground from './components/StarBackground.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()
|
const route = useRoute()
|
||||||
|
|
||||||
@@ -67,7 +78,7 @@ const isAdminOrLoginOrBlog = computed(() => {
|
|||||||
// 应用网站配置到页面标题和meta标签
|
// 应用网站配置到页面标题和meta标签
|
||||||
const applySiteSettings = async () => {
|
const applySiteSettings = async () => {
|
||||||
try {
|
try {
|
||||||
const settings = await getPublicSettings()
|
const settings = await loadPublicSettings()
|
||||||
|
|
||||||
// 设置页面标题
|
// 设置页面标题
|
||||||
if (settings.site_title) {
|
if (settings.site_title) {
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<component
|
||||||
class="author-info flex items-center gap-2 min-w-0"
|
:is="clickable && userId ? 'button' : 'div'"
|
||||||
|
type="button"
|
||||||
|
class="author-info flex items-center gap-2 min-w-0 text-left"
|
||||||
:class="[
|
:class="[
|
||||||
variant === 'compact' ? 'gap-2' : 'gap-3 md:gap-4',
|
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
|
<div
|
||||||
class="shrink-0 overflow-hidden flex items-center justify-center border rounded-full"
|
class="shrink-0 overflow-hidden flex items-center justify-center border rounded-full"
|
||||||
@@ -47,11 +51,12 @@
|
|||||||
用户#{{ userId }}
|
用户#{{ userId }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</component>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import { useUserProfileModal } from '../composables/useUserProfileModal'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
name?: string
|
name?: string
|
||||||
@@ -62,13 +67,23 @@ const props = withDefaults(defineProps<{
|
|||||||
variant?: 'full' | 'compact'
|
variant?: 'full' | 'compact'
|
||||||
theme?: 'default' | 'onDark' | 'onImage'
|
theme?: 'default' | 'onDark' | 'onImage'
|
||||||
showEmail?: boolean
|
showEmail?: boolean
|
||||||
|
clickable?: boolean
|
||||||
}>(), {
|
}>(), {
|
||||||
size: 'sm',
|
size: 'sm',
|
||||||
variant: 'compact',
|
variant: 'compact',
|
||||||
theme: 'default',
|
theme: 'default',
|
||||||
showEmail: true
|
showEmail: true,
|
||||||
|
clickable: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { openUserProfile } = useUserProfileModal()
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (props.clickable && props.userId) {
|
||||||
|
openUserProfile(props.userId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const displayName = computed(() => {
|
const displayName = computed(() => {
|
||||||
if (props.name) return props.name
|
if (props.name) return props.name
|
||||||
if (props.userId) return `用户#${props.userId}`
|
if (props.userId) return `用户#${props.userId}`
|
||||||
|
|||||||
@@ -1,14 +1,54 @@
|
|||||||
<template>
|
<template>
|
||||||
<footer class="py-8 text-center border-t border-white/5 relative z-10">
|
<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">
|
<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>
|
<a @click="openAdmin" class="text-xs text-art-muted/30 hover:text-art-accent cursor-pointer">管理入口</a>
|
||||||
</div>
|
</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>
|
</footer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 = () => {
|
const openAdmin = () => {
|
||||||
window.open('/admin', '_blank')
|
window.open('/admin', '_blank')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -185,11 +185,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, watch } from 'vue'
|
import { ref, onMounted, watch } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { getPublicSettings } from '../services/api'
|
import { usePublicSettings } from '../composables/usePublicSettings'
|
||||||
import Icon from './Icon.vue'
|
import Icon from './Icon.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const { cache, getStringSetting } = usePublicSettings()
|
||||||
const activeNav = ref('home')
|
const activeNav = ref('home')
|
||||||
const siteTitle = ref<string>('')
|
const siteTitle = ref<string>('')
|
||||||
const visibleMenus = ref<string[]>(['home', 'blog', 'columns', 'works', 'snippets', 'about', 'services'])
|
const visibleMenus = ref<string[]>(['home', 'blog', 'columns', 'works', 'snippets', 'about', 'services'])
|
||||||
@@ -211,31 +212,30 @@ const updateActiveNav = () => {
|
|||||||
else if (path === '/about') activeNav.value = 'about'
|
else if (path === '/about') activeNav.value = 'about'
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadSiteSettings = async () => {
|
const applySettingsFromCache = () => {
|
||||||
try {
|
const title = getStringSetting('site_title')
|
||||||
const settings = await getPublicSettings()
|
if (title) siteTitle.value = title
|
||||||
if (settings.site_title) {
|
|
||||||
siteTitle.value = settings.site_title
|
|
||||||
}
|
|
||||||
|
|
||||||
if (settings.visible_menus) {
|
const menusRaw = getStringSetting('visible_menus')
|
||||||
try {
|
if (menusRaw) {
|
||||||
const parsed = JSON.parse(settings.visible_menus)
|
try {
|
||||||
if (Array.isArray(parsed)) {
|
const parsed = JSON.parse(menusRaw)
|
||||||
visibleMenus.value = parsed
|
if (Array.isArray(parsed)) {
|
||||||
}
|
visibleMenus.value = parsed
|
||||||
} catch {
|
|
||||||
visibleMenus.value = settings.visible_menus.split(',').map((m: string) => m.trim()).filter((m: string) => m)
|
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
visibleMenus.value = menusRaw.split(',').map((m: string) => m.trim()).filter((m: string) => m)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load site settings:', error)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
updateActiveNav()
|
updateActiveNav()
|
||||||
loadSiteSettings()
|
applySettingsFromCache()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(cache, () => {
|
||||||
|
applySettingsFromCache()
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
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">
|
<script setup lang="ts">
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { onMounted, onUpdated, ref } from 'vue'
|
import { onMounted, onUpdated, ref, watch } from 'vue'
|
||||||
import { getPublicSettings } from '../services/api'
|
import { usePublicSettings } from '../composables/usePublicSettings'
|
||||||
|
|
||||||
const router = useRouter()
|
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 applyMenuSettings = () => {
|
||||||
const loadMenuSettings = async () => {
|
const menusRaw = getStringSetting('visible_menus')
|
||||||
|
if (!menusRaw) return
|
||||||
try {
|
try {
|
||||||
const settings = await getPublicSettings()
|
const parsed = JSON.parse(menusRaw)
|
||||||
if (settings.visible_menus) {
|
if (Array.isArray(parsed)) {
|
||||||
try {
|
visibleMenus.value = parsed
|
||||||
// 尝试解析JSON格式
|
|
||||||
const parsed = JSON.parse(settings.visible_menus)
|
|
||||||
if (Array.isArray(parsed)) {
|
|
||||||
visibleMenus.value = parsed
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// 如果不是JSON,按逗号分隔
|
|
||||||
visibleMenus.value = settings.visible_menus.split(',').map((m: string) => m.trim()).filter((m: string) => m)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {
|
||||||
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(() => {
|
onMounted(() => {
|
||||||
refreshIcons()
|
refreshIcons()
|
||||||
loadMenuSettings()
|
applyMenuSettings()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(cache, () => {
|
||||||
|
applyMenuSettings()
|
||||||
})
|
})
|
||||||
onUpdated(refreshIcons)
|
onUpdated(refreshIcons)
|
||||||
</script>
|
</script>
|
||||||
@@ -216,10 +216,37 @@ const showCollapseToggle = computed(
|
|||||||
() => showHtmlPreview.value && activeRightTab.value === 'preview'
|
() => showHtmlPreview.value && activeRightTab.value === 'preview'
|
||||||
)
|
)
|
||||||
|
|
||||||
const renderedDescription = computed(() => {
|
const renderedDescription = ref('')
|
||||||
if (!props.description) return ''
|
let descriptionGeneration = 0
|
||||||
return renderMarkdown(props.description)
|
|
||||||
})
|
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 = () => {
|
const highlightCode = () => {
|
||||||
if (codeBlock.value) {
|
if (codeBlock.value) {
|
||||||
|
|||||||
@@ -28,15 +28,20 @@
|
|||||||
|
|
||||||
<!-- User Footer -->
|
<!-- User Footer -->
|
||||||
<div class="p-4 border-t border-white/5">
|
<div class="p-4 border-t border-white/5">
|
||||||
<div class="flex items-center gap-3">
|
<router-link
|
||||||
<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">
|
to="/admin/profile"
|
||||||
{{ currentUser.username?.charAt(0).toUpperCase() || 'A' }}
|
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>
|
||||||
<div class="min-w-0 transition-opacity duration-300" :class="{ 'opacity-0 w-0': !isSidebarOpen }">
|
<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-sm font-medium text-white truncate">{{ currentUser.username }}</p>
|
||||||
<p class="text-xs text-art-muted truncate">Administrator</p>
|
<p class="text-xs text-art-muted truncate">个人中心</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -84,7 +89,11 @@
|
|||||||
v-if="isUserDropdownOpen"
|
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"
|
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>
|
<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>
|
</button>
|
||||||
@@ -116,12 +125,13 @@ import { useToast } from '../../composables/useToast'
|
|||||||
import { useAuth } from '../../composables/useAuth'
|
import { useAuth } from '../../composables/useAuth'
|
||||||
import SessionExpiredModal from './SessionExpiredModal.vue'
|
import SessionExpiredModal from './SessionExpiredModal.vue'
|
||||||
import AdminMenuItem from './AdminMenuItem.vue'
|
import AdminMenuItem from './AdminMenuItem.vue'
|
||||||
|
import { getCurrentUser } from '../../services/api'
|
||||||
import { openActiveMenuAncestors, findRouteTitle, type MenuItem } from './adminMenuTypes'
|
import { openActiveMenuAncestors, findRouteTitle, type MenuItem } from './adminMenuTypes'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { logout, scheduleExpiryCheck, getUser, isAuthenticated } = useAuth()
|
const { logout, scheduleExpiryCheck, getUser, isAuthenticated, setUser } = useAuth()
|
||||||
const isSidebarOpen = ref(true)
|
const isSidebarOpen = ref(true)
|
||||||
const currentUser = ref(getUser())
|
const currentUser = ref(getUser())
|
||||||
|
|
||||||
@@ -139,8 +149,13 @@ const closeDropdown = (e: MouseEvent) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleChangePassword = () => {
|
const goToProfile = () => {
|
||||||
toast.showToast('功能开发中...', 'success')
|
router.push('/admin/profile')
|
||||||
|
isUserDropdownOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const goToProfilePassword = () => {
|
||||||
|
router.push({ path: '/admin/profile', hash: '#password' })
|
||||||
isUserDropdownOpen.value = false
|
isUserDropdownOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,6 +281,15 @@ onMounted(() => {
|
|||||||
scheduleExpiryCheck()
|
scheduleExpiryCheck()
|
||||||
document.addEventListener('click', closeDropdown)
|
document.addEventListener('click', closeDropdown)
|
||||||
openActiveMenuAncestors(menuItems.value, isActive)
|
openActiveMenuAncestors(menuItems.value, isActive)
|
||||||
|
|
||||||
|
getCurrentUser()
|
||||||
|
.then(user => {
|
||||||
|
currentUser.value = user
|
||||||
|
setUser(user)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
currentUser.value = getUser()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(() => route.path, () => {
|
watch(() => route.path, () => {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="filter-actions">
|
<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>
|
<button type="button" class="admin-btn-secondary opacity-70" @click="emit('reset')">重置</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,15 +24,30 @@
|
|||||||
|
|
||||||
<!-- 操作区 -->
|
<!-- 操作区 -->
|
||||||
<div class="flex-1 min-w-0 space-y-2">
|
<div class="flex-1 min-w-0 space-y-2">
|
||||||
|
<div
|
||||||
|
@click="triggerFileInput"
|
||||||
|
@dragover.prevent="handleDragOver"
|
||||||
|
@dragenter.prevent="handleDragEnter"
|
||||||
|
@dragleave.prevent="handleDragLeave"
|
||||||
|
@drop.prevent="handleDrop"
|
||||||
|
class="upload-area border-2 border-dashed rounded-lg p-4 text-center cursor-pointer transition-colors"
|
||||||
|
:class="[
|
||||||
|
isDragging ? 'border-art-accent bg-art-accent/5' : 'border-white/20 hover:border-art-accent/50',
|
||||||
|
disabled || uploading ? 'opacity-50 cursor-not-allowed' : ''
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref="fileInput"
|
||||||
|
type="file"
|
||||||
|
:accept="accept"
|
||||||
|
class="hidden"
|
||||||
|
:disabled="disabled"
|
||||||
|
@change="handleFileSelect"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-art-muted">点击或拖拽图片到此处上传</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="admin-btn-secondary text-xs"
|
|
||||||
:disabled="disabled || uploading"
|
|
||||||
@click="triggerFileInput"
|
|
||||||
>
|
|
||||||
{{ uploading ? '上传中...' : '上传图片' }}
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="admin-btn-secondary text-xs"
|
class="admin-btn-secondary text-xs"
|
||||||
@@ -53,18 +68,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<p v-if="hint" class="text-xs text-art-muted/60">{{ hint }}</p>
|
<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="error" class="text-xs text-red-400">{{ error }}</p>
|
||||||
|
<p v-if="uploading" class="text-xs text-art-accent">上传中...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
|
||||||
ref="fileInput"
|
|
||||||
type="file"
|
|
||||||
:accept="accept"
|
|
||||||
class="hidden"
|
|
||||||
:disabled="disabled"
|
|
||||||
@change="handleFileSelect"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AttachmentLibraryModal
|
<AttachmentLibraryModal
|
||||||
:show="showLibraryModal"
|
:show="showLibraryModal"
|
||||||
:multiple="false"
|
:multiple="false"
|
||||||
@@ -78,6 +85,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { useToast } from '../../composables/useToast'
|
import { useToast } from '../../composables/useToast'
|
||||||
|
import { useImageDropUpload } from '../../composables/useImageDropUpload'
|
||||||
import { API_BASE, authFetch, parseApiResponse } from '../../services/api'
|
import { API_BASE, authFetch, parseApiResponse } from '../../services/api'
|
||||||
import AttachmentLibraryModal from './AttachmentLibraryModal.vue'
|
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'
|
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 = () => {
|
const triggerFileInput = () => {
|
||||||
if (props.disabled || uploading.value) return
|
if (props.disabled || uploading.value) return
|
||||||
fileInput.value?.click()
|
fileInput.value?.click()
|
||||||
@@ -187,3 +200,12 @@ const handleLibrarySelect = (urls: string[]) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</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" />
|
<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">
|
<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
|
<button
|
||||||
|
type="button"
|
||||||
@click.stop="() => removeImage()"
|
@click.stop="() => removeImage()"
|
||||||
class="px-4 py-2 bg-red-500/80 text-white rounded hover:bg-red-500 transition-colors text-sm"
|
class="px-4 py-2 bg-red-500/80 text-white rounded hover:bg-red-500 transition-colors text-sm"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
@@ -60,6 +61,7 @@
|
|||||||
删除
|
删除
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
@click.stop="triggerFileInput"
|
@click.stop="triggerFileInput"
|
||||||
class="px-4 py-2 bg-art-accent text-black rounded hover:opacity-90 transition-colors text-sm"
|
class="px-4 py-2 bg-art-accent text-black rounded hover:opacity-90 transition-colors text-sm"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
@@ -94,6 +96,7 @@
|
|||||||
<img :src="url" :alt="`Image ${index + 1}`" class="w-full h-full object-cover" />
|
<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">
|
<div class="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
@click="() => removeImage(index)"
|
@click="() => removeImage(index)"
|
||||||
class="px-3 py-1.5 bg-red-500/80 text-white rounded hover:bg-red-500 transition-colors text-xs"
|
class="px-3 py-1.5 bg-red-500/80 text-white rounded hover:bg-red-500 transition-colors text-xs"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<input
|
<input
|
||||||
:name="name"
|
:name="name"
|
||||||
type="text"
|
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"
|
:placeholder="inputPlaceholder"
|
||||||
:value="modelValue"
|
:value="modelValue"
|
||||||
:required="required"
|
:required="required"
|
||||||
@@ -13,14 +13,33 @@
|
|||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
>
|
>
|
||||||
<label
|
<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"
|
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>
|
>{{ label }}</label>
|
||||||
|
|
||||||
<!-- Autocomplete Dropdown -->
|
<!-- 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
|
<div
|
||||||
v-for="(suggestion, index) in suggestions"
|
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"
|
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)"
|
@mousedown.prevent="selectSuggestion(suggestion)"
|
||||||
>
|
>
|
||||||
@@ -32,43 +51,74 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
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
|
name: string
|
||||||
label: string
|
label: string
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
modelValue: string
|
modelValue: string
|
||||||
required?: boolean
|
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 showDropdown = ref(false)
|
||||||
const emailSuffixes = ref<string[]>([])
|
const emailSuffixes = ref<string[]>([])
|
||||||
|
const matchedUsers = ref<User[]>([])
|
||||||
const containerRef = ref<HTMLElement | null>(null)
|
const containerRef = ref<HTMLElement | null>(null)
|
||||||
|
let userSearchTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
// Load suffixes from API
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const suffixes = await fetchEmailSuffixes()
|
const suffixes = await fetchEmailSuffixes()
|
||||||
emailSuffixes.value = suffixes.map(s => s.suffix)
|
emailSuffixes.value = suffixes.map(s => s.suffix)
|
||||||
} catch (e) {
|
} catch {
|
||||||
// Fallback defaults if API fails
|
|
||||||
emailSuffixes.value = ['@gmail.com', '@163.com', '@qq.com', '@outlook.com']
|
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 handleInput = (e: Event) => {
|
||||||
const val = (e.target as HTMLInputElement).value
|
const val = (e.target as HTMLInputElement).value
|
||||||
emit('update:modelValue', val)
|
emit('update:modelValue', val)
|
||||||
showDropdown.value = true
|
showDropdown.value = true
|
||||||
|
searchUsers(val)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleBlur = () => {
|
const handleBlur = () => {
|
||||||
// Delay hiding to allow click event on dropdown to fire
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
showDropdown.value = false
|
showDropdown.value = false
|
||||||
}, 200)
|
}, 200)
|
||||||
@@ -83,6 +133,12 @@ const selectSuggestion = (val: string) => {
|
|||||||
emit('update:modelValue', val)
|
emit('update:modelValue', val)
|
||||||
showDropdown.value = false
|
showDropdown.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectUser = (user: User) => {
|
||||||
|
emit('user-select', user)
|
||||||
|
openUserProfile(user.id)
|
||||||
|
showDropdown.value = false
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -96,6 +96,10 @@ export function useAuth() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setUser = (user: unknown) => {
|
||||||
|
localStorage.setItem(USER_KEY, JSON.stringify(user))
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sessionExpired,
|
sessionExpired,
|
||||||
getToken,
|
getToken,
|
||||||
@@ -107,5 +111,6 @@ export function useAuth() {
|
|||||||
confirmSessionExpired,
|
confirmSessionExpired,
|
||||||
scheduleExpiryCheck,
|
scheduleExpiryCheck,
|
||||||
getUser,
|
getUser,
|
||||||
|
setUser,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,15 +90,22 @@ const posts = ref<Post[]>([])
|
|||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const { initObserver } = useScrollAnimation()
|
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(() => {
|
const lastUpdated = computed(() => {
|
||||||
if (posts.value.length === 0) {
|
if (posts.value.length === 0) {
|
||||||
// 如果没有文章,使用专栏的更新时间
|
|
||||||
if (column.value?.updatedAt) {
|
if (column.value?.updatedAt) {
|
||||||
const timestamp = typeof column.value.updatedAt === 'string'
|
return parseTimestamp(column.value.updatedAt as string | number)
|
||||||
? parseInt(column.value.updatedAt)
|
|
||||||
: column.value.updatedAt
|
|
||||||
return new Date(timestamp * 1000)
|
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -117,12 +124,8 @@ const lastUpdated = computed(() => {
|
|||||||
.filter((date): date is Date => date !== null)
|
.filter((date): date is Date => date !== null)
|
||||||
|
|
||||||
if (dates.length === 0) {
|
if (dates.length === 0) {
|
||||||
// 如果无法解析文章日期,使用专栏更新时间
|
|
||||||
if (column.value?.updatedAt) {
|
if (column.value?.updatedAt) {
|
||||||
const timestamp = typeof column.value.updatedAt === 'string'
|
return parseTimestamp(column.value.updatedAt as string | number)
|
||||||
? parseInt(column.value.updatedAt)
|
|
||||||
: column.value.updatedAt
|
|
||||||
return new Date(timestamp * 1000)
|
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,31 +97,35 @@
|
|||||||
<!-- Danmaku items will be rendered here -->
|
<!-- Danmaku items will be rendered here -->
|
||||||
<div class="danmaku-row animate-marquee hover:[animation-play-state:paused]">
|
<div class="danmaku-row animate-marquee hover:[animation-play-state:paused]">
|
||||||
<div v-for="testimonial in testimonials" :key="testimonial.id" class="danmaku-item">
|
<div v-for="testimonial in testimonials" :key="testimonial.id" class="danmaku-item">
|
||||||
<div class="w-6 h-6 rounded-full bg-blue-500/20">
|
<div class="w-6 h-6 rounded-full bg-blue-500/20 overflow-hidden shrink-0">
|
||||||
<image :src="testimonial.avatar ||'https://via.placeholder.com/50'" class="w-6 h-6" alt="Avatar" />
|
<img :src="testimonial.avatar || 'https://via.placeholder.com/50'" class="w-6 h-6 object-cover" alt="Avatar" />
|
||||||
</div>
|
</div>
|
||||||
|
<span v-if="testimonial.name" class="text-art-accent/80 text-sm shrink-0">{{ testimonial.name }}</span>
|
||||||
<span>"{{ testimonial.content }}"</span>
|
<span>"{{ testimonial.content }}"</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- 复制一份数据以实现无缝滚动 -->
|
<!-- 复制一份数据以实现无缝滚动 -->
|
||||||
<div v-for="testimonial in testimonials" :key="testimonial.id + '-copy'" class="danmaku-item">
|
<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">
|
<div class="w-6 h-6 rounded-full bg-blue-500/20 overflow-hidden shrink-0">
|
||||||
<image :src="testimonial.avatar ||'https://via.placeholder.com/50'" class="w-6 h-6" alt="Avatar" />
|
<img :src="testimonial.avatar || 'https://via.placeholder.com/50'" class="w-6 h-6 object-cover" alt="Avatar" />
|
||||||
</div>
|
</div>
|
||||||
|
<span v-if="testimonial.name" class="text-art-accent/80 text-sm shrink-0">{{ testimonial.name }}</span>
|
||||||
<span>"{{ testimonial.content }}"</span>
|
<span>"{{ testimonial.content }}"</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="danmaku-row animate-marquee-reverse hover:[animation-play-state:paused]">
|
<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 v-for="testimonial in testimonials" :key="testimonial.id" class="danmaku-item">
|
||||||
<div class="w-6 h-6 rounded-full bg-blue-500/20">
|
<div class="w-6 h-6 rounded-full bg-blue-500/20 overflow-hidden shrink-0">
|
||||||
<image :src="testimonial.avatar ||'https://via.placeholder.com/50'" class="w-6 h-6" alt="Avatar" />
|
<img :src="testimonial.avatar || 'https://via.placeholder.com/50'" class="w-6 h-6 object-cover" alt="Avatar" />
|
||||||
</div>
|
</div>
|
||||||
|
<span v-if="testimonial.name" class="text-art-accent/80 text-sm shrink-0">{{ testimonial.name }}</span>
|
||||||
<span>"{{ testimonial.content }}"</span>
|
<span>"{{ testimonial.content }}"</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- 复制一份数据以实现无缝滚动 -->
|
<!-- 复制一份数据以实现无缝滚动 -->
|
||||||
<div v-for="testimonial in testimonials" :key="testimonial.id + '-copy'" class="danmaku-item">
|
<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">
|
<div class="w-6 h-6 rounded-full bg-blue-500/20 overflow-hidden shrink-0">
|
||||||
<image :src="testimonial.avatar ||'https://via.placeholder.com/50'" class="w-6 h-6" alt="Avatar" />
|
<img :src="testimonial.avatar || 'https://via.placeholder.com/50'" class="w-6 h-6 object-cover" alt="Avatar" />
|
||||||
</div>
|
</div>
|
||||||
|
<span v-if="testimonial.name" class="text-art-accent/80 text-sm shrink-0">{{ testimonial.name }}</span>
|
||||||
<span>"{{ testimonial.content }}"</span>
|
<span>"{{ testimonial.content }}"</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -41,10 +41,11 @@
|
|||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="email">邮箱</label>
|
<label for="email">邮箱</label>
|
||||||
<input
|
<EmailAutocomplete
|
||||||
type="email"
|
name="email"
|
||||||
id="email"
|
label="邮箱"
|
||||||
v-model="form.email"
|
v-model="form.email"
|
||||||
|
variant="admin"
|
||||||
placeholder="example@domain.com"
|
placeholder="example@domain.com"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -141,6 +142,7 @@ import { ref, reactive, computed, onMounted } from 'vue'
|
|||||||
import { useToast } from '../../composables/useToast'
|
import { useToast } from '../../composables/useToast'
|
||||||
import { createAboutProfile, updateAboutProfile, getAdminAboutProfiles, Experience } from '../../services/api'
|
import { createAboutProfile, updateAboutProfile, getAdminAboutProfiles, Experience } from '../../services/api'
|
||||||
import ImageUpload from '../../components/admin/ImageUpload.vue'
|
import ImageUpload from '../../components/admin/ImageUpload.vue'
|
||||||
|
import EmailAutocomplete from '../../components/ui/EmailAutocomplete.vue'
|
||||||
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const isSubmitting = ref(false)
|
const isSubmitting = ref(false)
|
||||||
|
|||||||
@@ -41,10 +41,11 @@
|
|||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="email">邮箱</label>
|
<label for="email">邮箱</label>
|
||||||
<input
|
<EmailAutocomplete
|
||||||
type="email"
|
name="email"
|
||||||
id="email"
|
label="邮箱"
|
||||||
v-model="form.email"
|
v-model="form.email"
|
||||||
|
variant="admin"
|
||||||
placeholder="example@domain.com"
|
placeholder="example@domain.com"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -145,6 +146,7 @@ import { useRouter, useRoute } from 'vue-router'
|
|||||||
import { useToast } from '../../composables/useToast'
|
import { useToast } from '../../composables/useToast'
|
||||||
import { createAboutProfile, updateAboutProfile, getAdminAboutProfiles, Experience } from '../../services/api'
|
import { createAboutProfile, updateAboutProfile, getAdminAboutProfiles, Experience } from '../../services/api'
|
||||||
import ImageUpload from '../../components/admin/ImageUpload.vue'
|
import ImageUpload from '../../components/admin/ImageUpload.vue'
|
||||||
|
import EmailAutocomplete from '../../components/ui/EmailAutocomplete.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|||||||
@@ -116,12 +116,27 @@
|
|||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm text-art-muted mb-2">选择文件</label>
|
<label class="block text-sm text-art-muted mb-2">选择文件</label>
|
||||||
<input
|
<div
|
||||||
type="file"
|
@click="triggerUploadSelect"
|
||||||
ref="uploadInput"
|
@dragover.prevent="handleDragOver"
|
||||||
@change="handleUploadFile"
|
@dragenter.prevent="handleDragEnter"
|
||||||
class="admin-input"
|
@dragleave.prevent="handleDragLeave"
|
||||||
/>
|
@drop.prevent="handleDrop"
|
||||||
|
class="border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors"
|
||||||
|
:class="[
|
||||||
|
isDragging ? 'border-art-accent bg-art-accent/5' : 'border-white/20 hover:border-art-accent/50',
|
||||||
|
uploading ? 'opacity-50 cursor-not-allowed' : ''
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref="uploadInput"
|
||||||
|
@change="handleUploadFileSelect"
|
||||||
|
class="hidden"
|
||||||
|
/>
|
||||||
|
<p class="text-sm text-art-muted">点击或拖拽文件到此处上传</p>
|
||||||
|
<p v-if="pendingFileName" class="text-xs text-art-accent mt-2">已选择:{{ pendingFileName }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -134,10 +149,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-3">
|
<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>
|
||||||
<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 ? '上传中...' : '上传' }}
|
{{ uploading ? '上传中...' : '上传' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -154,6 +169,7 @@ import CustomSelect from '../../components/CustomSelect.vue'
|
|||||||
import AdminTableFilter from '../../components/admin/AdminTableFilter.vue'
|
import AdminTableFilter from '../../components/admin/AdminTableFilter.vue'
|
||||||
import AttachmentDetailModal from '../../components/admin/AttachmentDetailModal.vue'
|
import AttachmentDetailModal from '../../components/admin/AttachmentDetailModal.vue'
|
||||||
import { getAdminAttachments, updateAttachment, API_BASE, authFetch, parseApiResponse, type Attachment } from '../../services/api'
|
import { getAdminAttachments, updateAttachment, API_BASE, authFetch, parseApiResponse, type Attachment } from '../../services/api'
|
||||||
|
import { useImageDropUpload } from '../../composables/useImageDropUpload'
|
||||||
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
@@ -178,6 +194,7 @@ const selectedAttachment = ref<LocalAttachment | null>(null)
|
|||||||
const uploadInput = ref<HTMLInputElement | null>(null)
|
const uploadInput = ref<HTMLInputElement | null>(null)
|
||||||
const uploadCategoryId = ref(0)
|
const uploadCategoryId = ref(0)
|
||||||
const uploading = ref(false)
|
const uploading = ref(false)
|
||||||
|
const pendingFileName = ref('')
|
||||||
const filterCategoryId = ref(0)
|
const filterCategoryId = ref(0)
|
||||||
const filterFileType = ref('')
|
const filterFileType = ref('')
|
||||||
const searchKeyword = ref('')
|
const searchKeyword = ref('')
|
||||||
@@ -244,20 +261,35 @@ const loadCategories = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUploadFile = () => {
|
const handleUploadFileSelect = () => {
|
||||||
// File selection handled in uploadFile
|
const file = uploadInput.value?.files?.[0]
|
||||||
|
if (file) {
|
||||||
|
pendingFileName.value = file.name
|
||||||
|
void uploadFileWithFile(file)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const uploadFile = async () => {
|
const triggerUploadSelect = () => {
|
||||||
if (!uploadInput.value?.files || uploadInput.value.files.length === 0) {
|
if (uploading.value) return
|
||||||
toast.showToast('请选择文件', 'error')
|
uploadInput.value?.click()
|
||||||
return
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
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
|
uploading.value = true
|
||||||
try {
|
try {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('file', uploadInput.value.files[0])
|
formData.append('file', file)
|
||||||
if (uploadCategoryId.value > 0) {
|
if (uploadCategoryId.value > 0) {
|
||||||
formData.append('categoryId', uploadCategoryId.value.toString())
|
formData.append('categoryId', uploadCategoryId.value.toString())
|
||||||
}
|
}
|
||||||
@@ -271,8 +303,9 @@ const uploadFile = async () => {
|
|||||||
|
|
||||||
toast.showToast('上传成功', 'success')
|
toast.showToast('上传成功', 'success')
|
||||||
showUploadModal.value = false
|
showUploadModal.value = false
|
||||||
uploadInput.value.value = ''
|
if (uploadInput.value) uploadInput.value.value = ''
|
||||||
uploadCategoryId.value = 0
|
uploadCategoryId.value = 0
|
||||||
|
pendingFileName.value = ''
|
||||||
loadAttachments()
|
loadAttachments()
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
toast.showToast(err.message || '上传失败', 'error')
|
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) => {
|
const deleteAttachment = async (id: number) => {
|
||||||
if (!confirm('确定要删除这个附件吗?')) return
|
if (!confirm('确定要删除这个附件吗?')) return
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">官网链接</label>
|
<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>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -52,7 +52,7 @@ const isEdit = computed(() => !!route.params.id)
|
|||||||
const formData = ref({
|
const formData = ref({
|
||||||
name: '',
|
name: '',
|
||||||
logo: '',
|
logo: '',
|
||||||
website: '',
|
url: '',
|
||||||
description: ''
|
description: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ const loadData = async () => {
|
|||||||
formData.value = {
|
formData.value = {
|
||||||
name: item.name,
|
name: item.name,
|
||||||
logo: item.logo,
|
logo: item.logo,
|
||||||
website: item.website,
|
url: item.url,
|
||||||
description: item.description
|
description: item.description
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="table-cell">{{ p.name }}</td>
|
<td class="table-cell">{{ p.name }}</td>
|
||||||
<td class="table-cell">
|
<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>
|
<span v-else>-</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="table-cell" :title="p.description">
|
<td class="table-cell" :title="p.description">
|
||||||
@@ -81,7 +81,7 @@ const partners = ref<Partner[]>([])
|
|||||||
const { keyword, appliedKeyword, apply, reset } = useAppliedKeyword()
|
const { keyword, appliedKeyword, apply, reset } = useAppliedKeyword()
|
||||||
|
|
||||||
const filteredPartners = computed(() => partners.value.filter(p =>
|
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()
|
const applyFilter = () => apply()
|
||||||
|
|||||||
@@ -2,399 +2,293 @@
|
|||||||
<div class="w-full animate-reveal">
|
<div class="w-full animate-reveal">
|
||||||
<div class="flex items-center justify-between mb-6">
|
<div class="flex items-center justify-between mb-6">
|
||||||
<h1 class="text-2xl font-serif italic text-white">系统配置管理</h1>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Tab + 卡片布局 -->
|
<div v-if="loading" class="admin-card p-16 text-center text-art-muted">加载中...</div>
|
||||||
<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-else class="space-y-6">
|
<div v-else class="space-y-6">
|
||||||
<!-- Tab 导航 -->
|
<!-- Tab bar -->
|
||||||
<div class="border-b border-white/10 overflow-x-auto">
|
<div class="flex flex-wrap gap-2 border-b border-white/10 pb-1">
|
||||||
<div class="flex gap-2 min-w-max">
|
<button
|
||||||
<button
|
v-for="tab in tabs"
|
||||||
v-for="setting in allSettings"
|
:key="tab.id"
|
||||||
:key="setting.id"
|
type="button"
|
||||||
@click="activeTab = setting.id"
|
class="px-4 py-2 text-sm rounded-t-lg transition-colors"
|
||||||
:class="[
|
:class="activeTab === tab.id
|
||||||
'px-4 py-3 text-sm font-medium transition-all duration-200 border-b-2 relative',
|
? 'text-art-accent border-b-2 border-art-accent bg-white/5'
|
||||||
activeTab === setting.id
|
: 'text-art-muted hover:text-white hover:bg-white/5'"
|
||||||
? 'text-art-accent border-art-accent'
|
@click="activeTab = tab.id"
|
||||||
: 'text-art-muted border-transparent hover:text-white/80'
|
>
|
||||||
]"
|
{{ tab.label }}
|
||||||
>
|
</button>
|
||||||
{{ getSettingDisplayName(setting.keyName) }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 卡片内容 -->
|
<!-- Schema group tabs -->
|
||||||
<div class="admin-card p-6 border border-white/10 rounded-lg bg-white/5 shadow-lg">
|
<div
|
||||||
<!-- 菜单显示配置卡片 -->
|
v-for="group in SETTING_GROUPS"
|
||||||
<div v-if="activeSetting && activeSetting.keyName === 'visible_menus'" class="space-y-6">
|
v-show="activeTab === group.id"
|
||||||
<!-- 标题 -->
|
:key="group.id"
|
||||||
<div class="flex items-center justify-between pb-4 border-b border-white/10">
|
class="admin-card p-6 border border-white/10"
|
||||||
<div>
|
>
|
||||||
<h3 class="text-xl font-semibold text-white mb-1">{{ activeSetting.keyName }}</h3>
|
<h2 class="text-lg font-medium text-white mb-1">{{ group.label }}</h2>
|
||||||
<p class="text-sm text-art-muted">{{ activeSetting.description || '前台菜单显示配置' }}</p>
|
<p class="text-xs text-art-muted mb-5">管理 {{ group.label }} 相关配置项</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>
|
|
||||||
|
|
||||||
<!-- 菜单多选框(优化UI) -->
|
<div class="space-y-5">
|
||||||
<div class="space-y-3">
|
<div
|
||||||
<label class="block text-base font-medium text-white mb-4">显示菜单项</label>
|
v-for="schema in getGroupItems(group.id)"
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
:key="schema.key"
|
||||||
<label
|
class="space-y-2"
|
||||||
v-for="menu in menuOptions"
|
>
|
||||||
|
<label class="block text-sm font-medium text-white">{{ schema.label }}</label>
|
||||||
|
<p class="text-xs text-art-muted">{{ schema.description }}</p>
|
||||||
|
|
||||||
|
<div v-if="schema.type === 'menu-checkboxes'" class="grid grid-cols-1 md:grid-cols-2 gap-3 mt-2">
|
||||||
|
<label
|
||||||
|
v-for="menu in MENU_OPTIONS"
|
||||||
:key="menu.key"
|
: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="accent-art-accent" />
|
||||||
<input
|
<span class="text-sm text-white/90">{{ menu.label }}</span>
|
||||||
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>
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 保存按钮 -->
|
<textarea
|
||||||
<div class="pt-6 border-t border-white/10">
|
v-else-if="schema.type === 'textarea'"
|
||||||
<button
|
v-model="formValues[schema.key]"
|
||||||
@click="saveMenuSetting"
|
rows="3"
|
||||||
: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"
|
|
||||||
class="admin-input resize-none w-full"
|
class="admin-input resize-none w-full"
|
||||||
placeholder="配置项的值"
|
/>
|
||||||
></textarea>
|
|
||||||
<input
|
|
||||||
v-else
|
|
||||||
type="text"
|
|
||||||
v-model="activeSetting.value"
|
|
||||||
class="admin-input w-full text-base"
|
|
||||||
placeholder="配置项的值"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 保存按钮 -->
|
<input
|
||||||
<div class="pt-6 border-t border-white/10">
|
v-else-if="schema.type === 'number'"
|
||||||
<button
|
v-model="formValues[schema.key]"
|
||||||
@click="saveSingleSetting(activeSetting)"
|
type="number"
|
||||||
:disabled="savingSettings.has(activeSetting.id)"
|
min="1"
|
||||||
class="admin-btn-primary w-full py-3 text-base font-medium"
|
class="admin-input w-full max-w-xs"
|
||||||
>
|
/>
|
||||||
{{ savingSettings.has(activeSetting.id) ? '保存中...' : '保存配置' }}
|
|
||||||
</button>
|
<EmailAutocomplete
|
||||||
|
v-else-if="schema.type === 'email'"
|
||||||
|
:name="schema.key"
|
||||||
|
:label="schema.label"
|
||||||
|
v-model="formValues[schema.key]"
|
||||||
|
variant="admin"
|
||||||
|
:placeholder="schema.default || 'example@domain.com'"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
v-else
|
||||||
|
v-model="formValues[schema.key]"
|
||||||
|
type="text"
|
||||||
|
class="admin-input w-full"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
<div v-if="getGroupItems(group.id).length" class="pt-5 mt-5 border-t border-white/5">
|
||||||
|
<button
|
||||||
<!-- 新增配置 Modal -->
|
type="button"
|
||||||
<div v-if="showAddModal" class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
class="admin-btn-primary"
|
||||||
<div class="absolute inset-0 bg-black/80 backdrop-blur-sm transition-opacity" @click="closeAddModal"></div>
|
:disabled="savingGroup === group.id"
|
||||||
|
@click="saveGroup(group.id)"
|
||||||
<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">
|
{{ savingGroup === group.id ? '保存中...' : `保存${group.label}` }}
|
||||||
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="p-6 overflow-y-auto">
|
|
||||||
<form @submit.prevent="saveNewSetting" class="space-y-4">
|
<!-- Custom tab -->
|
||||||
<div class="space-y-2">
|
<div v-show="activeTab === 'custom'" class="admin-card p-6 border border-white/10 border-dashed">
|
||||||
<label for="keyName" class="block text-xs font-medium text-art-muted uppercase tracking-wider">键名</label>
|
<h2 class="text-lg font-medium text-white mb-4">自定义配置</h2>
|
||||||
<input
|
<div v-if="customSettings.length === 0" class="text-sm text-art-muted">暂无自定义配置项</div>
|
||||||
type="text"
|
<div v-else class="space-y-4">
|
||||||
id="keyName"
|
<div v-for="setting in customSettings" :key="setting.id" class="flex gap-3 items-start">
|
||||||
v-model="newForm.keyName"
|
<div class="flex-1 space-y-2">
|
||||||
required
|
<input v-model="setting.value" class="admin-input w-full font-mono text-sm" />
|
||||||
class="admin-input font-mono"
|
<p class="text-xs text-art-muted">{{ setting.keyName }} — {{ setting.description || '无描述' }}</p>
|
||||||
placeholder="例如: site_title"
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
|
<button type="button" class="admin-btn-danger text-xs shrink-0" @click="deleteSetting(setting.keyName)">删除</button>
|
||||||
<div class="space-y-2">
|
</div>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<form @submit.prevent="saveNewSetting" class="mt-6 pt-6 border-t border-white/5 grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
|
<input v-model="newForm.keyName" class="admin-input" placeholder="键名" required />
|
||||||
|
<input v-model="newForm.value" class="admin-input" placeholder="值" required />
|
||||||
|
<input v-model="newForm.description" class="admin-input" placeholder="描述" />
|
||||||
|
<button type="submit" class="admin-btn-secondary md:col-span-3">新增自定义配置</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button type="button" class="admin-btn-search px-8" :disabled="savingAll" @click="saveAll">
|
||||||
|
{{ savingAll ? '保存中...' : '保存全部配置' }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, reactive, onMounted, computed } from 'vue'
|
||||||
import { getSettings, createSetting, updateSetting, deleteSetting as deleteSettingApi, Setting } from '../../services/api'
|
import {
|
||||||
|
getSettings,
|
||||||
|
createSetting,
|
||||||
|
batchUpdateSettings,
|
||||||
|
deleteSetting as deleteSettingApi,
|
||||||
|
type Setting,
|
||||||
|
} from '../../services/api'
|
||||||
import { useToast } from '../../composables/useToast'
|
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 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 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 selectedMenus = ref<string[]>([])
|
||||||
|
const formValues = reactive<Record<string, string>>({})
|
||||||
|
|
||||||
// 菜单配置项
|
const newForm = ref({ keyName: '', value: '', description: '' })
|
||||||
const menuSetting = computed(() => {
|
|
||||||
return settings.value.find(s => s.keyName === 'visible_menus')
|
|
||||||
})
|
|
||||||
|
|
||||||
// 所有配置项(用于tab)
|
const schemaKeys = getSchemaKeys()
|
||||||
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 tabs = computed(() => [
|
||||||
const activeSetting = computed(() => {
|
...SETTING_GROUPS.map(g => ({ id: g.id as SettingSchemaItem['group'] | 'custom', label: g.label })),
|
||||||
if (activeTab.value === null && allSettings.value.length > 0) {
|
{ id: 'custom' as const, label: '自定义' },
|
||||||
return allSettings.value[0]
|
])
|
||||||
|
|
||||||
|
const customSettings = computed(() =>
|
||||||
|
settings.value.filter(s => !schemaKeys.includes(s.keyName))
|
||||||
|
)
|
||||||
|
|
||||||
|
const getGroupItems = (groupId: SettingSchemaItem['group']) =>
|
||||||
|
SETTINGS_SCHEMA.filter(s => s.group === groupId)
|
||||||
|
|
||||||
|
const initFormValues = () => {
|
||||||
|
for (const schema of SETTINGS_SCHEMA) {
|
||||||
|
const existing = settings.value.find(s => s.keyName === schema.key)
|
||||||
|
formValues[schema.key] = existing?.value ?? schema.default
|
||||||
}
|
}
|
||||||
return allSettings.value.find(s => s.id === activeTab.value) || null
|
|
||||||
})
|
|
||||||
|
|
||||||
// 获取配置项显示名称
|
const menuVal = formValues.visible_menus || ''
|
||||||
const getSettingDisplayName = (keyName: string) => {
|
try {
|
||||||
const nameMap: Record<string, string> = {
|
const parsed = JSON.parse(menuVal)
|
||||||
'visible_menus': '菜单显示',
|
selectedMenus.value = Array.isArray(parsed) ? parsed : MENU_OPTIONS.map(m => m.key)
|
||||||
'site_title': '网站标题',
|
} catch {
|
||||||
'site_description': '网站描述',
|
selectedMenus.value = menuVal
|
||||||
'site_author': '网站作者',
|
? menuVal.split(',').map(m => m.trim()).filter(Boolean)
|
||||||
'site_keywords': '网站关键词',
|
: MENU_OPTIONS.map(m => m.key)
|
||||||
'posts_per_page': '文章分页',
|
|
||||||
'works_per_page': '作品分页',
|
|
||||||
'snippets_per_page': '代码分页'
|
|
||||||
}
|
}
|
||||||
return nameMap[keyName] || keyName
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const newForm = ref({
|
const buildPayload = (): Record<string, string> => {
|
||||||
keyName: '',
|
const payload: Record<string, string> = { ...formValues }
|
||||||
value: '',
|
payload.visible_menus = JSON.stringify(selectedMenus.value)
|
||||||
description: ''
|
return payload
|
||||||
})
|
}
|
||||||
|
|
||||||
const fetchSettings = async () => {
|
const fetchSettings = async () => {
|
||||||
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
settings.value = await getSettings()
|
settings.value = await getSettings()
|
||||||
|
|
||||||
// 初始化激活的tab(第一个配置项)
|
for (const schema of SETTINGS_SCHEMA) {
|
||||||
if (settings.value.length > 0 && activeTab.value === null) {
|
if (!settings.value.find(s => s.keyName === schema.key)) {
|
||||||
activeTab.value = allSettings.value[0]?.id || null
|
await createSetting({
|
||||||
}
|
keyName: schema.key,
|
||||||
|
value: schema.default,
|
||||||
// 初始化菜单选择
|
description: schema.description,
|
||||||
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)
|
if (SETTINGS_SCHEMA.some(s => !settings.value.find(existing => existing.keyName === s.key))) {
|
||||||
|
settings.value = await getSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
initFormValues()
|
||||||
|
} catch {
|
||||||
toast.showToast('获取系统配置失败', 'error')
|
toast.showToast('获取系统配置失败', 'error')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存单个配置项
|
const persistPayload = async (payload: Record<string, string>) => {
|
||||||
const saveSingleSetting = async (setting: Setting) => {
|
await batchUpdateSettings(payload)
|
||||||
savingSettings.value.add(setting.id)
|
invalidatePublicSettings()
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.dispatchEvent(new CustomEvent('public-settings-changed'))
|
||||||
|
}
|
||||||
|
await fetchSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveGroup = async (groupId: SettingSchemaItem['group']) => {
|
||||||
|
savingGroup.value = groupId
|
||||||
try {
|
try {
|
||||||
await updateSetting({
|
const keys = getGroupItems(groupId).map(s => s.key)
|
||||||
id: setting.id,
|
const payload: Record<string, string> = {}
|
||||||
keyName: setting.keyName,
|
for (const key of keys) {
|
||||||
value: setting.value,
|
if (key === 'visible_menus') {
|
||||||
description: setting.description
|
payload[key] = JSON.stringify(selectedMenus.value)
|
||||||
})
|
} else {
|
||||||
|
payload[key] = formValues[key] ?? ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await persistPayload(payload)
|
||||||
toast.showToast('配置保存成功', 'success')
|
toast.showToast('配置保存成功', 'success')
|
||||||
// 重新获取配置以确保数据同步
|
} catch {
|
||||||
await fetchSettings()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving setting:', error)
|
|
||||||
toast.showToast('保存配置失败', 'error')
|
toast.showToast('保存配置失败', 'error')
|
||||||
} finally {
|
} finally {
|
||||||
savingSettings.value.delete(setting.id)
|
savingGroup.value = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存菜单配置
|
const saveAll = async () => {
|
||||||
const saveMenuSetting = async () => {
|
savingAll.value = true
|
||||||
if (!menuSetting.value) return
|
|
||||||
|
|
||||||
savingSettings.value.add(menuSetting.value.id)
|
|
||||||
try {
|
try {
|
||||||
// 将选中的菜单保存为JSON数组格式
|
await persistPayload(buildPayload())
|
||||||
const menuValue = JSON.stringify(selectedMenus.value)
|
toast.showToast('全部配置已保存', 'success')
|
||||||
|
} catch {
|
||||||
await updateSetting({
|
toast.showToast('保存配置失败', 'error')
|
||||||
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')
|
|
||||||
} finally {
|
} finally {
|
||||||
savingSettings.value.delete(menuSetting.value.id)
|
savingAll.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveNewSetting = async () => {
|
const saveNewSetting = async () => {
|
||||||
try {
|
try {
|
||||||
await createSetting({
|
await createSetting(newForm.value)
|
||||||
keyName: newForm.value.keyName,
|
newForm.value = { keyName: '', value: '', description: '' }
|
||||||
value: newForm.value.value,
|
|
||||||
description: newForm.value.description
|
|
||||||
})
|
|
||||||
toast.showToast('配置创建成功', 'success')
|
toast.showToast('配置创建成功', 'success')
|
||||||
closeAddModal()
|
await fetchSettings()
|
||||||
fetchSettings()
|
} catch {
|
||||||
} catch (error) {
|
|
||||||
console.error('Error creating setting:', error)
|
|
||||||
toast.showToast('创建配置失败', 'error')
|
toast.showToast('创建配置失败', 'error')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteSetting = async (keyName: string) => {
|
const deleteSetting = async (keyName: string) => {
|
||||||
if (confirm(`确定要删除配置项 "${keyName}" 吗?`)) {
|
if (!confirm(`确定要删除配置项 "${keyName}" 吗?`)) return
|
||||||
try {
|
try {
|
||||||
await deleteSettingApi(keyName)
|
await deleteSettingApi(keyName)
|
||||||
toast.showToast('配置删除成功', 'success')
|
toast.showToast('配置删除成功', 'success')
|
||||||
fetchSettings()
|
await fetchSettings()
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('Error deleting setting:', error)
|
toast.showToast('删除配置失败', 'error')
|
||||||
toast.showToast('删除配置失败', 'error')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const closeAddModal = () => {
|
onMounted(fetchSettings)
|
||||||
showAddModal.value = false
|
|
||||||
newForm.value = {
|
|
||||||
keyName: '',
|
|
||||||
value: '',
|
|
||||||
description: ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
fetchSettings()
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* Scoped styles removed */
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<form @submit.prevent="submitForm">
|
<form @submit.prevent="submitForm">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">作者</label>
|
<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>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -55,7 +55,7 @@ const toast = useToast()
|
|||||||
const isEdit = computed(() => !!route.params.id)
|
const isEdit = computed(() => !!route.params.id)
|
||||||
|
|
||||||
const formData = ref({
|
const formData = ref({
|
||||||
author: '',
|
name: '',
|
||||||
role: '',
|
role: '',
|
||||||
avatar: '',
|
avatar: '',
|
||||||
rating: 5,
|
rating: 5,
|
||||||
@@ -74,7 +74,7 @@ const loadData = async () => {
|
|||||||
const item = list.find(t => t.id === id)
|
const item = list.find(t => t.id === id)
|
||||||
if (item) {
|
if (item) {
|
||||||
formData.value = {
|
formData.value = {
|
||||||
author: item.author,
|
name: item.name,
|
||||||
role: item.role,
|
role: item.role,
|
||||||
avatar: item.avatar,
|
avatar: item.avatar,
|
||||||
rating: item.rating,
|
rating: item.rating,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
|
<th>头像</th>
|
||||||
<th>作者</th>
|
<th>作者</th>
|
||||||
<th>角色</th>
|
<th>角色</th>
|
||||||
<th>内容</th>
|
<th>内容</th>
|
||||||
@@ -41,7 +42,16 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="t in filteredTestimonials" :key="t.id">
|
<tr v-for="t in filteredTestimonials" :key="t.id">
|
||||||
<td class="table-cell">{{ t.id }}</td>
|
<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">{{ t.role }}</td>
|
||||||
<td class="table-cell" :title="t.content">
|
<td class="table-cell" :title="t.content">
|
||||||
{{ t.content.length > 30 ? t.content.substring(0, 30) + '...' : 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 => {
|
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
|
if (appliedRating.value !== '' && t.rating !== Number(appliedRating.value)) return false
|
||||||
return true
|
return true
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -22,11 +22,12 @@
|
|||||||
<!-- Email Field -->
|
<!-- Email Field -->
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="email">邮箱</label>
|
<label for="email">邮箱</label>
|
||||||
<input
|
<EmailAutocomplete
|
||||||
type="email"
|
name="email"
|
||||||
id="email"
|
label="邮箱"
|
||||||
v-model="form.email"
|
v-model="form.email"
|
||||||
placeholder="请输入邮箱"
|
variant="admin"
|
||||||
|
:user-lookup="true"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<div class="error-message" v-if="errors.email">
|
<div class="error-message" v-if="errors.email">
|
||||||
@@ -71,6 +72,29 @@
|
|||||||
{{ errors.isActive }}
|
{{ errors.isActive }}
|
||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Submit Buttons -->
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
@@ -93,6 +117,7 @@ import { useToast } from '../../composables/useToast'
|
|||||||
import { createUser, updateUser, fetchUser } from '../../services/api'
|
import { createUser, updateUser, fetchUser } from '../../services/api'
|
||||||
import CustomSelect from '../../components/CustomSelect.vue'
|
import CustomSelect from '../../components/CustomSelect.vue'
|
||||||
import ImagePicker from '../../components/admin/ImagePicker.vue'
|
import ImagePicker from '../../components/admin/ImagePicker.vue'
|
||||||
|
import EmailAutocomplete from '../../components/ui/EmailAutocomplete.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -108,6 +133,10 @@ const form = reactive({
|
|||||||
username: '',
|
username: '',
|
||||||
email: '',
|
email: '',
|
||||||
avatar: '',
|
avatar: '',
|
||||||
|
bio: '',
|
||||||
|
phone: '',
|
||||||
|
wechat: '',
|
||||||
|
wechatQrcode: '',
|
||||||
role: 'viewer',
|
role: 'viewer',
|
||||||
isActive: 1
|
isActive: 1
|
||||||
})
|
})
|
||||||
@@ -198,6 +227,10 @@ onMounted(async () => {
|
|||||||
form.username = user.username
|
form.username = user.username
|
||||||
form.email = user.email
|
form.email = user.email
|
||||||
form.avatar = user.avatar || ''
|
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.role = user.role
|
||||||
form.isActive = user.isActive
|
form.isActive = user.isActive
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -249,7 +282,8 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.form-group input,
|
.form-group input,
|
||||||
.form-group select {
|
.form-group select,
|
||||||
|
.form-group textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
|||||||
@@ -57,7 +57,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ user.username }}</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>
|
<td>
|
||||||
<span class="role-badge" :class="user.role">
|
<span class="role-badge" :class="user.role">
|
||||||
{{ getUserRoleText(user.role) }}
|
{{ getUserRoleText(user.role) }}
|
||||||
@@ -111,9 +113,11 @@ import { useToast } from '../../composables/useToast'
|
|||||||
import { getUsersPaginated, deleteUser, User } from '../../services/api'
|
import { getUsersPaginated, deleteUser, User } from '../../services/api'
|
||||||
import CustomSelect from '../../components/CustomSelect.vue'
|
import CustomSelect from '../../components/CustomSelect.vue'
|
||||||
import AdminTableFilter from '../../components/admin/AdminTableFilter.vue'
|
import AdminTableFilter from '../../components/admin/AdminTableFilter.vue'
|
||||||
|
import { useUserProfileModal } from '../../composables/useUserProfileModal'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
const { openUserProfile } = useUserProfileModal()
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const users = ref<User[]>([])
|
const users = ref<User[]>([])
|
||||||
@@ -380,6 +384,21 @@ onMounted(() => {
|
|||||||
background: rgba(212, 179, 131, 0.05);
|
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 */
|
/* Avatar */
|
||||||
.user-avatar-cell {
|
.user-avatar-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -73,7 +73,7 @@
|
|||||||
<span class="text-xs text-white/40 whitespace-nowrap">{{ formatDate(post.date) }}</span>
|
<span class="text-xs text-white/40 whitespace-nowrap">{{ formatDate(post.date) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2 mt-2">
|
<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">
|
<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>
|
<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 }}
|
{{ post.readCount || 0 }}
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed } from 'vue'
|
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 stats = ref<any>({})
|
||||||
const operationLogs = ref<any[]>([])
|
const operationLogs = ref<any[]>([])
|
||||||
@@ -112,7 +112,7 @@ const loadData = async () => {
|
|||||||
const [statsData, logsData, postsData] = await Promise.all([
|
const [statsData, logsData, postsData] = await Promise.all([
|
||||||
getDashboardStats(),
|
getDashboardStats(),
|
||||||
getOperationLogs(1, 5),
|
getOperationLogs(1, 5),
|
||||||
fetchPosts()
|
getAdminPosts(1, 5)
|
||||||
])
|
])
|
||||||
|
|
||||||
stats.value = statsData
|
stats.value = statsData
|
||||||
|
|||||||
@@ -31,6 +31,16 @@ export const getCachedSiteTitle = (): string => {
|
|||||||
return cachedSiteTitle || document.title.split(' | ')[0] || '年糕崽崽.Dev'
|
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 setPageTitle = async (to: any) => {
|
||||||
const siteTitle = await loadSiteTitle()
|
const siteTitle = await loadSiteTitle()
|
||||||
@@ -85,6 +95,7 @@ const routes = [
|
|||||||
{ path: 'users', name: 'admin-users', component: () => import('./pages/admin/Users.vue') },
|
{ 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/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: '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') },
|
{ path: 'roles', name: 'admin-roles', component: () => import('./pages/admin/Roles.vue') },
|
||||||
|
|||||||
@@ -79,12 +79,36 @@ export interface User {
|
|||||||
username: string
|
username: string
|
||||||
email: string
|
email: string
|
||||||
avatar?: string
|
avatar?: string
|
||||||
|
bio?: string
|
||||||
|
phone?: string
|
||||||
|
wechat?: string
|
||||||
|
wechatQrcode?: string
|
||||||
role: string
|
role: string
|
||||||
isActive: number
|
isActive: number
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: 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 {
|
export interface LoginRequest {
|
||||||
username: string
|
username: string
|
||||||
password: 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> => {
|
export const createUser = async (userData: Omit<User, 'id' | 'createdAt' | 'updatedAt'>): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const response = await authFetch(`${API_BASE}/admin/users`, {
|
const response = await authFetch(`${API_BASE}/admin/users`, {
|
||||||
@@ -1288,6 +1352,19 @@ export interface PublicSettings {
|
|||||||
site_keywords?: string
|
site_keywords?: string
|
||||||
visible_menus?: string
|
visible_menus?: string
|
||||||
posts_per_page?: 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> => {
|
export const getPublicSettings = async (): Promise<PublicSettings> => {
|
||||||
@@ -1548,7 +1625,7 @@ export const deleteAboutProfile = async (id: number): Promise<void> => {
|
|||||||
export interface Testimonial {
|
export interface Testimonial {
|
||||||
id: number
|
id: number
|
||||||
content: string
|
content: string
|
||||||
author: string
|
name: string
|
||||||
role: string
|
role: string
|
||||||
avatar: string
|
avatar: string
|
||||||
rating: number
|
rating: number
|
||||||
@@ -1562,7 +1639,7 @@ export interface Partner {
|
|||||||
name: string
|
name: string
|
||||||
logo: string
|
logo: string
|
||||||
description: string
|
description: string
|
||||||
website: string
|
url: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,10 @@
|
|||||||
.admin-btn-secondary {
|
.admin-btn-secondary {
|
||||||
@apply bg-transparent text-white border border-white/10 px-5 py-2.5 rounded-md hover:bg-white/5 active:scale-95 transition-all text-sm;
|
@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 {
|
.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;
|
@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{
|
utils.Success(c, gin.H{
|
||||||
"token": tokenString,
|
"token": tokenString,
|
||||||
"expire": expireUnix,
|
"expire": expireUnix,
|
||||||
"user": gin.H{
|
"user": repositories.BuildUserResponse(user),
|
||||||
"id": user.ID,
|
|
||||||
"username": user.Username,
|
|
||||||
"email": user.Email,
|
|
||||||
"role": user.Role,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ func GetCategories(c *gin.Context) {
|
|||||||
utils.ServerError(c, err)
|
utils.ServerError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
utils.Success(c, categories)
|
utils.Success(c, repositories.BuildCategoriesResponse(categories))
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCategoryByID 根据ID获取分类
|
// GetCategoryByID 根据ID获取分类
|
||||||
@@ -38,7 +38,7 @@ func GetCategoryByID(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.Success(c, category)
|
utils.Success(c, repositories.BuildCategoryResponse(category))
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminCreateCategory 创建分类
|
// AdminCreateCategory 创建分类
|
||||||
@@ -54,7 +54,7 @@ func AdminCreateCategory(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.Success(c, category)
|
utils.Success(c, repositories.BuildCategoryResponse(&category))
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminUpdateCategory 更新分类
|
// AdminUpdateCategory 更新分类
|
||||||
@@ -78,7 +78,7 @@ func AdminUpdateCategory(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.Success(c, category)
|
utils.Success(c, repositories.BuildCategoryResponse(&category))
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminDeleteCategory 删除分类
|
// AdminDeleteCategory 删除分类
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ func GetColumns(c *gin.Context) {
|
|||||||
utils.ServerError(c, err)
|
utils.ServerError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
utils.Success(c, columns)
|
utils.Success(c, repositories.BuildColumnsResponse(columns))
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetColumnByID 根据ID获取专栏
|
// GetColumnByID 根据ID获取专栏
|
||||||
|
|||||||
@@ -20,7 +20,11 @@ func GetSettings(c *gin.Context) {
|
|||||||
|
|
||||||
// 只返回前端需要的公开配置项
|
// 只返回前端需要的公开配置项
|
||||||
publicSettings := make(map[string]string)
|
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 {
|
for _, key := range publicKeys {
|
||||||
if value, exists := settingsMap[key]; exists {
|
if value, exists := settingsMap[key]; exists {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ func main() {
|
|||||||
repositories.MigratePostSnippets()
|
repositories.MigratePostSnippets()
|
||||||
repositories.MigratePostUserID()
|
repositories.MigratePostUserID()
|
||||||
repositories.MigrateUserAvatar()
|
repositories.MigrateUserAvatar()
|
||||||
|
repositories.MigrateUserProfileFields()
|
||||||
|
|
||||||
// 初始化ip2region (如果文件不存在,将降级为普通IP记录)
|
// 初始化ip2region (如果文件不存在,将降级为普通IP记录)
|
||||||
// 函数会自动从环境变量或可执行文件目录查找 ip2region.xdb
|
// 函数会自动从环境变量或可执行文件目录查找 ip2region.xdb
|
||||||
@@ -101,6 +102,10 @@ func main() {
|
|||||||
// 咨询相关路由
|
// 咨询相关路由
|
||||||
api.POST("/inquiries", handlers.SubmitInquiry)
|
api.POST("/inquiries", handlers.SubmitInquiry)
|
||||||
api.GET("/email-suffixes", handlers.GetEmailSuffixes)
|
api.GET("/email-suffixes", handlers.GetEmailSuffixes)
|
||||||
|
|
||||||
|
// 用户公开资料
|
||||||
|
api.GET("/users/:id/profile", handlers.GetUserProfile)
|
||||||
|
api.GET("/users/:id/posts", handlers.GetUserPosts)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 管理员API路由组
|
// 管理员API路由组
|
||||||
@@ -113,6 +118,11 @@ func main() {
|
|||||||
authAdmin := admin.Group("/")
|
authAdmin := admin.Group("/")
|
||||||
authAdmin.Use(middleware.AuthMiddleware(), middleware.OperationLogMiddleware())
|
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", middleware.PermissionMiddleware("users", "read"), handlers.AdminGetUsers)
|
||||||
authAdmin.GET("/users/:id", middleware.PermissionMiddleware("users", "read"), handlers.AdminGetUser)
|
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", 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.POST("/tags", middleware.PermissionMiddleware("tags", "create"), handlers.AdminCreateTag)
|
||||||
authAdmin.PUT("/tags/:id", middleware.PermissionMiddleware("tags", "update"), handlers.AdminUpdateTag)
|
authAdmin.PUT("/tags/:id", middleware.PermissionMiddleware("tags", "update"), handlers.AdminUpdateTag)
|
||||||
authAdmin.DELETE("/tags/:id", middleware.PermissionMiddleware("tags", "delete"), handlers.AdminDeleteTag)
|
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()
|
c.UpdatedAt = time.Now().Unix()
|
||||||
return nil
|
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
|
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 专栏文章关联模型
|
// ColumnPost 专栏文章关联模型
|
||||||
type ColumnPost struct {
|
type ColumnPost struct {
|
||||||
ColumnID uint `json:"columnId" gorm:"primaryKey;column:column_id"`
|
ColumnID uint `json:"columnId" gorm:"primaryKey;column:column_id"`
|
||||||
|
|||||||
@@ -42,6 +42,15 @@ func (t *Tag) BeforeUpdate(tx *gorm.DB) error {
|
|||||||
return nil
|
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 文章标签关联模型
|
// PostTag 文章标签关联模型
|
||||||
type PostTag struct {
|
type PostTag struct {
|
||||||
PostID uint `json:"postId" gorm:"primaryKey;column:post_id"`
|
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"`
|
Username string `json:"username" gorm:"column:username;uniqueIndex;not null"`
|
||||||
Email string `json:"email" gorm:"column:email"`
|
Email string `json:"email" gorm:"column:email"`
|
||||||
Avatar string `json:"avatar" gorm:"column:avatar"`
|
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
|
Password string `json:"password,omitempty" gorm:"-"` // Virtual field for input
|
||||||
PasswordHash string `json:"-" gorm:"column:password_hash"`
|
PasswordHash string `json:"-" gorm:"column:password_hash"`
|
||||||
RoleID uint `json:"roleId" gorm:"column:role_id"`
|
RoleID uint `json:"roleId" gorm:"column:role_id"`
|
||||||
@@ -50,15 +54,47 @@ func (u *User) BeforeUpdate(tx *gorm.DB) error {
|
|||||||
|
|
||||||
// UserResponse 用户响应模型
|
// UserResponse 用户响应模型
|
||||||
type UserResponse struct {
|
type UserResponse struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Avatar string `json:"avatar,omitempty"`
|
Avatar string `json:"avatar,omitempty"`
|
||||||
RoleID uint `json:"roleId"`
|
Bio string `json:"bio,omitempty"`
|
||||||
Role string `json:"role"`
|
Phone string `json:"phone,omitempty"`
|
||||||
IsActive int `json:"isActive"`
|
Wechat string `json:"wechat,omitempty"`
|
||||||
CreatedAt string `json:"createdAt"`
|
WechatQrcode string `json:"wechatQrcode,omitempty"`
|
||||||
UpdatedAt string `json:"updatedAt"`
|
RoleID uint `json:"roleId"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
IsActive int `json:"isActive"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
UpdatedAt string `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserPublicProfile 公开用户资料(前台展示)
|
||||||
|
type UserPublicProfile struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Avatar string `json:"avatar,omitempty"`
|
||||||
|
Bio string `json:"bio,omitempty"`
|
||||||
|
Phone string `json:"phone,omitempty"`
|
||||||
|
Wechat string `json:"wechat,omitempty"`
|
||||||
|
WechatQrcode string `json:"wechatQrcode,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateProfileRequest 当前用户更新资料请求
|
||||||
|
type UpdateProfileRequest struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Avatar string `json:"avatar"`
|
||||||
|
Bio string `json:"bio"`
|
||||||
|
Phone string `json:"phone"`
|
||||||
|
Wechat string `json:"wechat"`
|
||||||
|
WechatQrcode string `json:"wechatQrcode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePasswordRequest 修改密码请求
|
||||||
|
type UpdatePasswordRequest struct {
|
||||||
|
OldPassword string `json:"oldPassword" binding:"required"`
|
||||||
|
NewPassword string `json:"newPassword" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoginRequest 登录请求模型
|
// LoginRequest 登录请求模型
|
||||||
|
|||||||
@@ -78,3 +78,25 @@ func DeleteCategory(id uint) error {
|
|||||||
}
|
}
|
||||||
return nil
|
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 构建专栏响应(包含统计信息)
|
// BuildColumnResponse 构建专栏响应(包含统计信息)
|
||||||
func BuildColumnResponse(col *models.Column) map[string]interface{} {
|
func BuildColumnResponse(col *models.Column) *models.ColumnResponse {
|
||||||
postCount, lastUpdated, _ := GetColumnStats(col.ID)
|
postCount, lastUpdated, _ := GetColumnStats(col.ID)
|
||||||
|
|
||||||
return map[string]interface{}{
|
return &models.ColumnResponse{
|
||||||
"id": col.ID,
|
ID: col.ID,
|
||||||
"name": col.Name,
|
Name: col.Name,
|
||||||
"description": col.Description,
|
Description: col.Description,
|
||||||
"cover": col.Cover,
|
Cover: col.Cover,
|
||||||
"isActive": col.IsActive,
|
IsActive: col.IsActive,
|
||||||
"sortOrder": col.SortOrder,
|
SortOrder: col.SortOrder,
|
||||||
"createdAt": col.CreatedAt,
|
CreatedAt: formatTimestamp(col.CreatedAt),
|
||||||
"updatedAt": col.UpdatedAt,
|
UpdatedAt: formatTimestamp(col.UpdatedAt),
|
||||||
"deletedAt": col.DeletedAt,
|
PostCount: postCount,
|
||||||
"postCount": postCount,
|
LastUpdated: formatTimestamp(lastUpdated),
|
||||||
"lastUpdated": 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 创建专栏
|
// CreateColumn 创建专栏
|
||||||
func CreateColumn(col *models.Column) error {
|
func CreateColumn(col *models.Column) error {
|
||||||
err := config.DB.Create(col).Error
|
err := config.DB.Create(col).Error
|
||||||
|
|||||||
@@ -175,3 +175,23 @@ func MigrateUserAvatar() {
|
|||||||
log.Printf("Adding users.avatar...")
|
log.Printf("Adding users.avatar...")
|
||||||
execSQL("ALTER TABLE `users` ADD COLUMN `avatar` VARCHAR(500) NULL DEFAULT NULL COMMENT '头像URL' AFTER `email`")
|
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
|
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获取博客文章
|
// GetPostByID 根据ID获取博客文章
|
||||||
func GetPostByID(id uint) (*models.Post, error) {
|
func GetPostByID(id uint) (*models.Post, error) {
|
||||||
var post models.Post
|
var post models.Post
|
||||||
@@ -452,6 +482,8 @@ func buildPostResponse(post *models.Post, includeContent, includeSnippets bool,
|
|||||||
if post.Category != nil {
|
if post.Category != nil {
|
||||||
catName = post.Category.Name
|
catName = post.Category.Name
|
||||||
catSlug = post.Category.Slug
|
catSlug = post.Category.Slug
|
||||||
|
} else if post.CategoryID > 0 {
|
||||||
|
catName = lookupCategoryName(post.CategoryID)
|
||||||
}
|
}
|
||||||
|
|
||||||
colName := ""
|
colName := ""
|
||||||
|
|||||||
@@ -160,11 +160,21 @@ func GetPostsByTagID(tagID uint) ([]models.Post, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// BuildTagsResponse 构建标签列表响应
|
// BuildTagsResponse 构建标签列表响应
|
||||||
func BuildTagsResponse(tags []models.Tag) []models.Tag {
|
func BuildTagsResponse(tags []models.Tag) []models.TagResponse {
|
||||||
return tags
|
responses := make([]models.TagResponse, 0, len(tags))
|
||||||
|
for _, tag := range tags {
|
||||||
|
responses = append(responses, *BuildTagResponse(&tag))
|
||||||
|
}
|
||||||
|
return responses
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildTagResponse 构建标签响应
|
// BuildTagResponse 构建标签响应
|
||||||
func BuildTagResponse(tag *models.Tag) *models.Tag {
|
func BuildTagResponse(tag *models.Tag) *models.TagResponse {
|
||||||
return tag
|
return &models.TagResponse{
|
||||||
|
ID: tag.ID,
|
||||||
|
Name: tag.Name,
|
||||||
|
Slug: tag.Slug,
|
||||||
|
CreatedAt: formatTimestamp(tag.CreatedAt),
|
||||||
|
UpdatedAt: formatTimestamp(tag.UpdatedAt),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,12 +134,16 @@ func UpdateUser(user *models.User) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updates := map[string]interface{}{
|
updates := map[string]interface{}{
|
||||||
"username": user.Username,
|
"username": user.Username,
|
||||||
"email": user.Email,
|
"email": user.Email,
|
||||||
"avatar": user.Avatar,
|
"avatar": user.Avatar,
|
||||||
"role": user.Role,
|
"bio": user.Bio,
|
||||||
"is_active": user.IsActive,
|
"phone": user.Phone,
|
||||||
"updated_at": time.Now().Unix(),
|
"wechat": user.Wechat,
|
||||||
|
"wechat_qrcode": user.WechatQrcode,
|
||||||
|
"role": user.Role,
|
||||||
|
"is_active": user.IsActive,
|
||||||
|
"updated_at": time.Now().Unix(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if user.RoleID != 0 {
|
if user.RoleID != 0 {
|
||||||
@@ -205,18 +209,56 @@ func GetUserCount() (int, error) {
|
|||||||
// BuildUserResponse 构建用户响应
|
// BuildUserResponse 构建用户响应
|
||||||
func BuildUserResponse(user *models.User) *models.UserResponse {
|
func BuildUserResponse(user *models.User) *models.UserResponse {
|
||||||
return &models.UserResponse{
|
return &models.UserResponse{
|
||||||
ID: user.ID,
|
ID: user.ID,
|
||||||
Username: user.Username,
|
Username: user.Username,
|
||||||
Email: user.Email,
|
Email: user.Email,
|
||||||
Avatar: user.Avatar,
|
Avatar: user.Avatar,
|
||||||
RoleID: user.RoleID,
|
Bio: user.Bio,
|
||||||
Role: user.Role,
|
Phone: user.Phone,
|
||||||
IsActive: user.IsActive,
|
Wechat: user.Wechat,
|
||||||
CreatedAt: time.Unix(user.CreatedAt, 0).Format("2006-01-02 15:04:05"),
|
WechatQrcode: user.WechatQrcode,
|
||||||
UpdatedAt: time.Unix(user.UpdatedAt, 0).Format("2006-01-02 15:04:05"),
|
RoleID: user.RoleID,
|
||||||
|
Role: user.Role,
|
||||||
|
IsActive: user.IsActive,
|
||||||
|
CreatedAt: time.Unix(user.CreatedAt, 0).Format("2006-01-02 15:04:05"),
|
||||||
|
UpdatedAt: time.Unix(user.UpdatedAt, 0).Format("2006-01-02 15:04:05"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BuildUserPublicProfile 构建公开用户资料
|
||||||
|
func BuildUserPublicProfile(user *models.User) *models.UserPublicProfile {
|
||||||
|
return &models.UserPublicProfile{
|
||||||
|
ID: user.ID,
|
||||||
|
Username: user.Username,
|
||||||
|
Email: user.Email,
|
||||||
|
Avatar: user.Avatar,
|
||||||
|
Bio: user.Bio,
|
||||||
|
Phone: user.Phone,
|
||||||
|
Wechat: user.Wechat,
|
||||||
|
WechatQrcode: user.WechatQrcode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateCurrentUserProfile 更新当前用户资料(不含角色/状态)
|
||||||
|
func UpdateCurrentUserProfile(id uint, req *models.UpdateProfileRequest) error {
|
||||||
|
err := config.DB.Model(&models.User{}).
|
||||||
|
Where("id = ? AND deleted_at = ?", id, 0).
|
||||||
|
Updates(map[string]interface{}{
|
||||||
|
"email": req.Email,
|
||||||
|
"avatar": req.Avatar,
|
||||||
|
"bio": req.Bio,
|
||||||
|
"phone": req.Phone,
|
||||||
|
"wechat": req.Wechat,
|
||||||
|
"wechat_qrcode": req.WechatQrcode,
|
||||||
|
"updated_at": time.Now().Unix(),
|
||||||
|
}).Error
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error updating user profile: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// BuildUsersResponse 构建用户列表响应
|
// BuildUsersResponse 构建用户列表响应
|
||||||
func BuildUsersResponse(users []models.User) []models.UserResponse {
|
func BuildUsersResponse(users []models.User) []models.UserResponse {
|
||||||
var responses []models.UserResponse
|
var responses []models.UserResponse
|
||||||
|
|||||||
Reference in New Issue
Block a user