diff --git a/client/src/components/UserProfileModal.vue b/client/src/components/UserProfileModal.vue new file mode 100644 index 0000000..88311d6 --- /dev/null +++ b/client/src/components/UserProfileModal.vue @@ -0,0 +1,193 @@ + + + + + + + + + + + 加载中... + {{ error }} + + + + + + + {{ profile.username.charAt(0) }} + + + {{ profile.username }} + {{ profile.email }} + {{ profile.phone }} + {{ profile.bio }} + + + + + + + + 点击查看微信二维码加好友 + + + + + {{ profile.wechat }} + + + + + + + + + + 最近发布 + + + + {{ post.title }} + + + + 暂无文章 + + + 浏览最多 + + + + {{ post.title }} + ({{ post.readCount }}) + + + + 暂无文章 + + + + + + + + + + + diff --git a/client/src/composables/useImageDropUpload.ts b/client/src/composables/useImageDropUpload.ts new file mode 100644 index 0000000..db3e33f --- /dev/null +++ b/client/src/composables/useImageDropUpload.ts @@ -0,0 +1,56 @@ +import { ref, type Ref } from 'vue' + +export function useImageDropUpload(options: { + disabled?: Ref | (() => 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, + } +} diff --git a/client/src/composables/usePublicSettings.ts b/client/src/composables/usePublicSettings.ts new file mode 100644 index 0000000..8ac2e77 --- /dev/null +++ b/client/src/composables/usePublicSettings.ts @@ -0,0 +1,58 @@ +import { onMounted, ref } from 'vue' +import { getPublicSettings, type PublicSettings } from '../services/api' + +const cache = ref(null) +let loading: Promise | 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 { + 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 } +} diff --git a/client/src/composables/useUserProfileModal.ts b/client/src/composables/useUserProfileModal.ts new file mode 100644 index 0000000..e293b5f --- /dev/null +++ b/client/src/composables/useUserProfileModal.ts @@ -0,0 +1,24 @@ +import { ref } from 'vue' + +const isOpen = ref(false) +const activeUserId = ref(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, + } +} diff --git a/client/src/config/settingsSchema.ts b/client/src/config/settingsSchema.ts new file mode 100644 index 0000000..beb5209 --- /dev/null +++ b/client/src/config/settingsSchema.ts @@ -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) diff --git a/client/src/pages/admin/Profile.vue b/client/src/pages/admin/Profile.vue new file mode 100644 index 0000000..2397176 --- /dev/null +++ b/client/src/pages/admin/Profile.vue @@ -0,0 +1,191 @@ + + + 个人中心 + + + + + + + {{ form.username }} + 用户名不可修改 + + + + + 邮箱 + + + + + 个人介绍 + + + + + + 手机号 + + + + 微信号 + + + + + + + + + + + {{ saving ? '保存中...' : '保存资料' }} + + + 预览我的主页 + + + + + + + + 修改密码 + + + 原密码 + + + + 新密码 + + + + {{ changingPassword ? '提交中...' : '更新密码' }} + + + + + + + + + diff --git a/server/handlers/user_profile.go b/server/handlers/user_profile.go new file mode 100644 index 0000000..21ccf27 --- /dev/null +++ b/server/handlers/user_profile.go @@ -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)) +} diff --git a/server/repositories/time_format.go b/server/repositories/time_format.go new file mode 100644 index 0000000..ba90fb4 --- /dev/null +++ b/server/repositories/time_format.go @@ -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") +}
{{ profile.email }}
{{ profile.phone }}
{{ profile.bio }}
{{ profile.wechat }}
暂无文章
{{ form.username }}
用户名不可修改