优化页面、修复BUG
This commit is contained in:
193
client/src/components/UserProfileModal.vue
Normal file
193
client/src/components/UserProfileModal.vue
Normal 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>
|
||||
56
client/src/composables/useImageDropUpload.ts
Normal file
56
client/src/composables/useImageDropUpload.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
58
client/src/composables/usePublicSettings.ts
Normal file
58
client/src/composables/usePublicSettings.ts
Normal 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 }
|
||||
}
|
||||
24
client/src/composables/useUserProfileModal.ts
Normal file
24
client/src/composables/useUserProfileModal.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
126
client/src/config/settingsSchema.ts
Normal file
126
client/src/config/settingsSchema.ts
Normal 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)
|
||||
191
client/src/pages/admin/Profile.vue
Normal file
191
client/src/pages/admin/Profile.vue
Normal 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>
|
||||
161
server/handlers/user_profile.go
Normal file
161
server/handlers/user_profile.go
Normal 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))
|
||||
}
|
||||
10
server/repositories/time_format.go
Normal file
10
server/repositories/time_format.go
Normal 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")
|
||||
}
|
||||
Reference in New Issue
Block a user