优化页面、修复BUG

This commit is contained in:
李琦
2026-07-04 16:59:57 +08:00
parent e4ab62ab7e
commit 734f82b2c6
8 changed files with 819 additions and 0 deletions

View File

@@ -0,0 +1,193 @@
<template>
<Teleport to="body">
<div
v-if="isOpen"
class="fixed inset-0 z-[100] flex items-center justify-center p-4"
@click.self="close"
>
<div class="absolute inset-0 bg-black/80 backdrop-blur-sm" @click="close" />
<div
class="relative z-10 w-full max-w-2xl max-h-[90vh] overflow-y-auto admin-card border border-white/10 shadow-2xl animate-reveal custom-scrollbar"
role="dialog"
aria-modal="true"
>
<button
type="button"
class="absolute top-4 right-4 z-10 text-white/40 hover:text-white transition-colors"
aria-label="关闭"
@click="close"
>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
<div v-if="loading" class="p-12 text-center text-art-muted">加载中...</div>
<div v-else-if="error" class="p-12 text-center text-art-error">{{ error }}</div>
<template v-else-if="profile">
<!-- Header -->
<div class="p-6 md:p-8 border-b border-white/5">
<div class="flex items-start gap-4 md:gap-6">
<div class="shrink-0 w-16 h-16 md:w-20 md:h-20 rounded-full overflow-hidden border border-white/20 bg-white/5 flex items-center justify-center">
<img v-if="profile.avatar" :src="profile.avatar" :alt="profile.username" class="w-full h-full object-cover" />
<span v-else class="text-2xl font-semibold text-art-accent uppercase">{{ profile.username.charAt(0) }}</span>
</div>
<div class="min-w-0 flex-1 pt-1">
<h2 class="text-xl md:text-2xl font-serif text-white mb-1">{{ profile.username }}</h2>
<p v-if="profile.email" class="text-sm text-art-muted truncate">{{ profile.email }}</p>
<p v-if="profile.phone" class="text-sm text-art-muted mt-0.5">{{ profile.phone }}</p>
<p v-if="profile.bio" class="text-sm text-white/70 mt-3 leading-relaxed">{{ profile.bio }}</p>
</div>
</div>
<!-- WeChat -->
<div v-if="profile.wechat || profile.wechatQrcode" class="mt-5">
<div
class="relative inline-block"
@mouseenter="showQr = true"
@mouseleave="showQr = false"
>
<button
type="button"
class="text-sm text-art-accent hover:text-white transition-colors link-underline cursor-pointer"
>
点击查看微信二维码加好友
</button>
<Transition name="fade">
<div
v-if="showQr"
class="absolute left-0 top-full mt-2 z-20 p-4 bg-art-surface border border-white/10 rounded-lg shadow-xl min-w-[180px]"
>
<img
v-if="qrImageSrc"
:src="qrImageSrc"
alt="微信二维码"
class="w-40 h-40 mx-auto rounded"
/>
<p v-if="profile.wechat" class="text-xs text-art-muted text-center mt-2 font-mono">{{ profile.wechat }}</p>
</div>
</Transition>
</div>
</div>
</div>
<!-- Posts -->
<div class="p-6 md:p-8 grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h3 class="text-sm font-medium text-white/80 mb-3 uppercase tracking-wider">最近发布</h3>
<ul v-if="recentPosts.length" class="space-y-2">
<li v-for="post in recentPosts" :key="post.id">
<router-link
:to="`/blog/${post.id}`"
class="block text-sm text-art-muted hover:text-art-accent transition-colors truncate"
@click="close"
>
{{ post.title }}
</router-link>
</li>
</ul>
<p v-else class="text-xs text-art-muted">暂无文章</p>
</div>
<div>
<h3 class="text-sm font-medium text-white/80 mb-3 uppercase tracking-wider">浏览最多</h3>
<ul v-if="popularPosts.length" class="space-y-2">
<li v-for="post in popularPosts" :key="post.id">
<router-link
:to="`/blog/${post.id}`"
class="block text-sm text-art-muted hover:text-art-accent transition-colors truncate"
@click="close"
>
{{ post.title }}
<span v-if="post.readCount" class="text-xs text-white/30 ml-1">({{ post.readCount }})</span>
</router-link>
</li>
</ul>
<p v-else class="text-xs text-art-muted">暂无文章</p>
</div>
</div>
</template>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { fetchUserProfile, fetchUserPosts, type UserPublicProfile, type Post } from '../services/api'
const props = defineProps<{
isOpen: boolean
userId: number | null
}>()
const emit = defineEmits<{ close: [] }>()
const loading = ref(false)
const error = ref('')
const profile = ref<UserPublicProfile | null>(null)
const recentPosts = ref<Post[]>([])
const popularPosts = ref<Post[]>([])
const showQr = ref(false)
const generatedQr = ref('')
const qrImageSrc = computed(() => {
if (profile.value?.wechatQrcode) return profile.value.wechatQrcode
if (generatedQr.value) return generatedQr.value
return ''
})
const close = () => emit('close')
const loadProfile = async (userId: number) => {
loading.value = true
error.value = ''
profile.value = null
recentPosts.value = []
popularPosts.value = []
generatedQr.value = ''
try {
const [profileData, recent, popular] = await Promise.all([
fetchUserProfile(userId),
fetchUserPosts(userId, 'recent', 5),
fetchUserPosts(userId, 'popular', 5),
])
profile.value = profileData
recentPosts.value = recent
popularPosts.value = popular
if (!profileData.wechatQrcode && profileData.wechat) {
generatedQr.value = `https://api.qrserver.com/v1/create-qr-code/?size=160x160&data=${encodeURIComponent(profileData.wechat)}`
}
} catch (e) {
error.value = '加载用户资料失败'
console.error(e)
} finally {
loading.value = false
}
}
watch(
[() => props.isOpen, () => props.userId],
([open, userId]) => {
if (open && userId) {
document.body.style.overflow = 'hidden'
void loadProfile(userId)
} else {
document.body.style.overflow = ''
showQr.value = false
}
},
{ immediate: true }
)
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,56 @@
import { ref, type Ref } from 'vue'
export function useImageDropUpload(options: {
disabled?: Ref<boolean> | (() => boolean)
onFiles: (files: File[]) => void
acceptImagesOnly?: boolean
}) {
const isDragging = ref(false)
const isDisabled = () => {
if (typeof options.disabled === 'function') return options.disabled()
return options.disabled?.value ?? false
}
const handleDragOver = (e: DragEvent) => {
if (isDisabled()) return
e.preventDefault()
isDragging.value = true
}
const handleDragEnter = (e: DragEvent) => {
if (isDisabled()) return
e.preventDefault()
isDragging.value = true
}
const handleDragLeave = (e: DragEvent) => {
if (isDisabled()) return
e.preventDefault()
isDragging.value = false
}
const handleDrop = (e: DragEvent) => {
if (isDisabled()) return
e.preventDefault()
isDragging.value = false
if (!e.dataTransfer?.files?.length) return
let files = Array.from(e.dataTransfer.files)
if (options.acceptImagesOnly !== false) {
files = files.filter(file => file.type.startsWith('image/'))
}
if (files.length > 0) {
options.onFiles(files)
}
}
return {
isDragging,
handleDragOver,
handleDragEnter,
handleDragLeave,
handleDrop,
}
}

View File

@@ -0,0 +1,58 @@
import { onMounted, ref } from 'vue'
import { getPublicSettings, type PublicSettings } from '../services/api'
const cache = ref<PublicSettings | null>(null)
let loading: Promise<PublicSettings> | null = null
let listenerRegistered = false
function registerSettingsListener() {
if (typeof window === 'undefined' || listenerRegistered) return
listenerRegistered = true
window.addEventListener('public-settings-changed', () => {
cache.value = null
loading = null
void loadPublicSettings(true)
})
}
export async function loadPublicSettings(force = false): Promise<PublicSettings> {
if (!force && cache.value) return cache.value
if (!force && loading) return loading
loading = getPublicSettings().then(data => {
cache.value = data
loading = null
return data
})
return loading
}
export function usePublicSettings() {
onMounted(() => {
void loadPublicSettings()
registerSettingsListener()
})
const load = loadPublicSettings
const invalidate = () => {
cache.value = null
loading = null
}
const getStringSetting = (key: keyof PublicSettings, fallback = ''): string => {
const raw = cache.value?.[key]
if (raw == null || String(raw).trim() === '') return fallback
return String(raw)
}
const getNumberSetting = (key: keyof PublicSettings, fallback: number): number => {
const raw = cache.value?.[key]
if (!raw) return fallback
const n = parseInt(String(raw), 10)
return Number.isFinite(n) && n > 0 ? n : fallback
}
return { cache, load, invalidate, getStringSetting, getNumberSetting }
}

View File

@@ -0,0 +1,24 @@
import { ref } from 'vue'
const isOpen = ref(false)
const activeUserId = ref<number | null>(null)
export function useUserProfileModal() {
const openUserProfile = (userId: number) => {
if (!userId) return
activeUserId.value = userId
isOpen.value = true
}
const closeUserProfile = () => {
isOpen.value = false
activeUserId.value = null
}
return {
isOpen,
activeUserId,
openUserProfile,
closeUserProfile,
}
}

View File

@@ -0,0 +1,126 @@
export type SettingFieldType = 'text' | 'textarea' | 'number' | 'menu-checkboxes' | 'email'
export interface SettingSchemaItem {
key: string
label: string
type: SettingFieldType
group: 'site' | 'seo' | 'navigation' | 'pagination' | 'contact'
public: boolean
default: string
description: string
}
export const SETTING_GROUPS: { id: SettingSchemaItem['group']; label: string }[] = [
{ id: 'site', label: '站点信息' },
{ id: 'seo', label: 'SEO 优化' },
{ id: 'navigation', label: '导航菜单' },
{ id: 'pagination', label: '分页设置' },
{ id: 'contact', label: '联系方式' },
]
export const SETTINGS_SCHEMA: SettingSchemaItem[] = [
{
key: 'site_title',
label: '网站标题',
type: 'text',
group: 'site',
public: true,
default: '年糕崽崽.Dev',
description: '浏览器标签页和页头显示的网站名称',
},
{
key: 'site_author',
label: '网站作者',
type: 'text',
group: 'site',
public: true,
default: '',
description: '网站作者名称,用于 meta 标签',
},
{
key: 'site_description',
label: '网站描述',
type: 'textarea',
group: 'seo',
public: true,
default: '',
description: '搜索引擎摘要描述',
},
{
key: 'site_keywords',
label: '网站关键词',
type: 'textarea',
group: 'seo',
public: true,
default: '',
description: 'SEO 关键词,逗号分隔',
},
{
key: 'visible_menus',
label: '前台菜单',
type: 'menu-checkboxes',
group: 'navigation',
public: true,
default: '["home","blog","columns","works","snippets","about","services"]',
description: '控制前台导航栏显示的菜单项',
},
{
key: 'posts_per_page',
label: '文章每页数量',
type: 'number',
group: 'pagination',
public: true,
default: '10',
description: '博客列表页每页显示的文章数',
},
{
key: 'works_per_page',
label: '作品每页数量',
type: 'number',
group: 'pagination',
public: true,
default: '12',
description: '作品列表页每页显示数量',
},
{
key: 'snippets_per_page',
label: '代码每页数量',
type: 'number',
group: 'pagination',
public: true,
default: '12',
description: '代码片段列表页每页显示数量',
},
{
key: 'footer_icp',
label: 'ICP 备案号',
type: 'text',
group: 'contact',
public: true,
default: '',
description: '页脚 ICP 备案信息',
},
{
key: 'contact_email',
label: '联系邮箱',
type: 'email',
group: 'contact',
public: true,
default: '',
description: '公开展示的联系邮箱',
},
]
export const MENU_OPTIONS = [
{ key: 'home', label: '首页' },
{ key: 'blog', label: '思考' },
{ key: 'columns', label: '专栏' },
{ key: 'works', label: '作品' },
{ key: 'snippets', label: '代码' },
{ key: 'about', label: '关于' },
{ key: 'services', label: '合作' },
]
export const getSchemaByKey = (key: string) => SETTINGS_SCHEMA.find(s => s.key === key)
export const getSchemaKeys = () => SETTINGS_SCHEMA.map(s => s.key)

View File

@@ -0,0 +1,191 @@
<template>
<div class="w-full animate-reveal max-w-3xl">
<h1 class="text-2xl font-serif italic text-white mb-6">个人中心</h1>
<div class="admin-card p-6 md:p-8 space-y-6">
<form @submit.prevent="handleSaveProfile" class="space-y-5">
<div class="flex items-center gap-4 pb-4 border-b border-white/5">
<ImagePicker
v-model="form.avatar"
label="头像"
variant="avatar"
size="lg"
placeholder="暂无头像"
hint="支持本地上传或从素材库选择"
/>
<div>
<p class="text-white font-medium">{{ form.username }}</p>
<p class="text-xs text-art-muted mt-1">用户名不可修改</p>
</div>
</div>
<div class="form-group">
<label>邮箱</label>
<EmailAutocomplete
name="email"
label="邮箱"
v-model="form.email"
variant="admin"
:user-lookup="true"
required
/>
</div>
<div class="form-group">
<label>个人介绍</label>
<textarea v-model="form.bio" rows="4" class="admin-input resize-none" placeholder="介绍一下自己..." />
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-group">
<label>手机号</label>
<input v-model="form.phone" type="tel" class="admin-input" placeholder="选填" />
</div>
<div class="form-group">
<label>微信号</label>
<input v-model="form.wechat" type="text" class="admin-input" placeholder="选填" />
</div>
</div>
<div class="form-group">
<ImagePicker
v-model="form.wechatQrcode"
label="微信二维码"
hint="上传二维码图片,悬浮展示给访客"
/>
</div>
<div class="flex flex-wrap gap-3 pt-2">
<button type="submit" class="admin-btn-primary" :disabled="saving">
{{ saving ? '保存中...' : '保存资料' }}
</button>
<button type="button" class="admin-btn-secondary" @click="openPreview">
预览我的主页
</button>
</div>
</form>
</div>
<!-- Password -->
<div class="admin-card p-6 md:p-8 mt-6 space-y-4">
<h2 class="text-lg font-medium text-white">修改密码</h2>
<form @submit.prevent="handleChangePassword" class="space-y-4 max-w-md">
<div class="form-group">
<label>原密码</label>
<input v-model="passwordForm.oldPassword" type="password" class="admin-input" required />
</div>
<div class="form-group">
<label>新密码</label>
<input v-model="passwordForm.newPassword" type="password" class="admin-input" required minlength="6" />
</div>
<button type="submit" class="admin-btn-secondary" :disabled="changingPassword">
{{ changingPassword ? '提交中...' : '更新密码' }}
</button>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { useToast } from '../../composables/useToast'
import { useAuth } from '../../composables/useAuth'
import { useUserProfileModal } from '../../composables/useUserProfileModal'
import { getCurrentUser, updateCurrentUser, updateCurrentUserPassword } from '../../services/api'
import ImagePicker from '../../components/admin/ImagePicker.vue'
import EmailAutocomplete from '../../components/ui/EmailAutocomplete.vue'
const toast = useToast()
const { setUser, getUser } = useAuth()
const { openUserProfile } = useUserProfileModal()
const saving = ref(false)
const changingPassword = ref(false)
const userId = ref<number | null>(null)
const form = reactive({
username: '',
email: '',
avatar: '',
bio: '',
phone: '',
wechat: '',
wechatQrcode: '',
})
const passwordForm = reactive({
oldPassword: '',
newPassword: '',
})
const loadProfile = async () => {
try {
const user = await getCurrentUser()
userId.value = user.id
form.username = user.username
form.email = user.email
form.avatar = user.avatar || ''
form.bio = user.bio || ''
form.phone = user.phone || ''
form.wechat = user.wechat || ''
form.wechatQrcode = user.wechatQrcode || ''
setUser(user)
} catch (e) {
console.error(e)
toast.error('加载个人资料失败')
}
}
const handleSaveProfile = async () => {
saving.value = true
try {
const updated = await updateCurrentUser({
email: form.email,
avatar: form.avatar,
bio: form.bio,
phone: form.phone,
wechat: form.wechat,
wechatQrcode: form.wechatQrcode,
})
setUser(updated)
toast.success('资料已保存')
} catch (e: unknown) {
toast.error(e instanceof Error ? e.message : '保存失败')
} finally {
saving.value = false
}
}
const handleChangePassword = async () => {
changingPassword.value = true
try {
await updateCurrentUserPassword(passwordForm.oldPassword, passwordForm.newPassword)
passwordForm.oldPassword = ''
passwordForm.newPassword = ''
toast.success('密码已更新')
} catch (e: unknown) {
toast.error(e instanceof Error ? e.message : '密码更新失败')
} finally {
changingPassword.value = false
}
}
const openPreview = () => {
const id = userId.value || getUser()?.id
if (id) openUserProfile(id)
}
onMounted(loadProfile)
</script>
<style scoped>
.form-group label {
display: block;
font-size: 0.75rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.5);
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
</style>

View File

@@ -0,0 +1,161 @@
package handlers
import (
"fmt"
"strconv"
"github.com/gin-gonic/gin"
"github.com/niangaodev/art-code/models"
"github.com/niangaodev/art-code/repositories"
"github.com/niangaodev/art-code/utils"
)
// GetUserProfile 获取公开用户资料
func GetUserProfile(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
utils.Error(c, 400, "Invalid user ID")
return
}
user, err := repositories.GetUserByID(uint(id))
if err != nil {
utils.ServerError(c, err)
return
}
if user == nil || user.IsActive != 1 {
utils.Error(c, 404, "User not found")
return
}
utils.Success(c, repositories.BuildUserPublicProfile(user))
}
// GetUserPosts 获取用户已发布文章
func GetUserPosts(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
utils.Error(c, 400, "Invalid user ID")
return
}
sort := c.DefaultQuery("sort", "recent")
if sort != "recent" && sort != "popular" {
sort = "recent"
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "5"))
user, err := repositories.GetUserByID(uint(id))
if err != nil {
utils.ServerError(c, err)
return
}
if user == nil || user.IsActive != 1 {
utils.Error(c, 404, "User not found")
return
}
posts, err := repositories.GetPostsByUserID(uint(id), sort, limit)
if err != nil {
utils.ServerError(c, err)
return
}
utils.Success(c, repositories.BuildPostsResponse(posts))
}
// UpdateCurrentUser 更新当前用户资料
func UpdateCurrentUser(c *gin.Context) {
userID, exists := c.Get("userID")
if !exists {
utils.Error(c, 401, "Unauthorized")
return
}
var req models.UpdateProfileRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.Error(c, 400, "Invalid request")
return
}
if err := repositories.UpdateCurrentUserProfile(userID.(uint), &req); err != nil {
utils.ServerError(c, err)
return
}
user, err := repositories.GetUserByID(userID.(uint))
if err != nil {
utils.ServerError(c, err)
return
}
utils.Success(c, repositories.BuildUserResponse(user))
}
// UpdateCurrentUserPassword 修改当前用户密码
func UpdateCurrentUserPassword(c *gin.Context) {
userID, exists := c.Get("userID")
if !exists {
utils.Error(c, 401, "Unauthorized")
return
}
var req models.UpdatePasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.Error(c, 400, "Invalid request")
return
}
user, err := repositories.GetUserByID(userID.(uint))
if err != nil {
utils.ServerError(c, err)
return
}
if user == nil {
utils.Error(c, 404, "User not found")
return
}
if !utils.CheckPasswordHash(req.OldPassword, user.PasswordHash) {
utils.Error(c, 400, "原密码不正确")
return
}
hashedPassword, err := utils.HashPassword(req.NewPassword)
if err != nil {
utils.ServerError(c, err)
return
}
if err := repositories.UpdateUserPassword(userID.(uint), hashedPassword); err != nil {
utils.ServerError(c, err)
return
}
utils.SuccessWithMsg(c, "Password updated successfully", nil)
}
// AdminGetUserProfile 管理员查看用户公开资料(复用公开结构)
func AdminGetUserProfile(c *gin.Context) {
idStr := c.Param("id")
var id uint
if _, err := fmt.Sscanf(idStr, "%d", &id); err != nil {
utils.Error(c, 400, "Invalid user ID")
return
}
user, err := repositories.GetUserByID(id)
if err != nil {
utils.ServerError(c, err)
return
}
if user == nil {
utils.Error(c, 404, "User not found")
return
}
utils.Success(c, repositories.BuildUserPublicProfile(user))
}

View File

@@ -0,0 +1,10 @@
package repositories
import "time"
func formatTimestamp(ts int64) string {
if ts == 0 {
return ""
}
return time.Unix(ts, 0).Format("2006-01-02 15:04:05")
}