Compare commits

...

28 Commits

Author SHA1 Message Date
d7ba7799fe docs: 添加项目迁移声明,指向新仓库 MCE 2026-06-21 16:59:51 +08:00
8bf6ac9e51 feat: @提及发送通知 + 通知点击定位高亮
- 新增 NotifyCommentMention 通知类型,归属 mention 分类 TAB
- parseMentions 中向被@用户发送通知(排除自己和重复通知)
- 消息中心 mention 通知可点击跳转至文章页面
- 通过 mention 通知进入时,来源评论置顶并持久半透明高亮
- 普通评论链接保持原有短暂高亮行为不变
2026-06-03 13:21:27 +08:00
2ea9788ec7 fix: 关注状态 API 添加 Optional 认证中间件
- /api/users/:uid/follow-status 等公开 GET 接口缺少 Optional 认证
- 导致 GetGinUser 无法获取当前用户 uid,始终返回 i_follow=false
- 关注按钮加载时无法显示正确初始状态(始终显示"关注")
- 三条 GET 接口统一加入 authMdw.Optional() 组
- 不影响未登录用户(未登录依然返回 false)
2026-06-03 13:11:56 +08:00
be729c29a1 fix: 关注通知添加用户空间链接跳转
- 消息中心关注通知卡片 now 可点击跳转至关注者空间 (/space/:uid)
2026-06-03 13:06:44 +08:00
584d7fd146 fix: 修复 /space 关注按钮在默认标签页无法点击
- 将顶部关注按钮 JS 从 following/followers 标签页限定块中提取到全局脚本
- 按钮在所有标签页均可点击(之前仅在 following/followers 标签页有效)
- 本人点击关注时 toast 提示"不能关注自己",与赋能按钮行为一致
- 未登录用户点击关注跳转登录页
- 移除标签页限定块中的重复顶部按钮代码
2026-06-03 13:00:12 +08:00
6c9a4414cb refactor: 全局移除浏览器 alert 弹窗,统一使用页面内 showToast 通知
- 替换全部 11 个文件中 36 处 alert() 为 showToast()
- API 错误/异常使用 showToast(msg, 'error'),3秒自动消失
- 表单校验提示使用 showToast(msg, 'warning'),3秒自动消失
- 成功确认使用 showToast(msg, 'success'),3秒自动消失
- 移除 post-energize.js 中与全局 showToast 冲突的本地实现
- 删除 posts.css 中已失去引用的 .energize-toast 样式
- 保留 showConfirm() 自定义确认弹窗(需用户确认的操作)
2026-06-03 01:25:12 +08:00
864a488ee0 feat: @搜索优先展示已关注用户,结果限制最多5条
- SearchUsersByLevel SQL新增LEFT JOIN user_follows,按已关注优先+exp降序
- SearchUsers/followerUID参数贯穿repo→service→controller
- 继续输入时前端自动重新搜索新关键词
2026-06-03 01:15:38 +08:00
fb9724faed fix: space关注按钮移除隐藏逻辑,自关注改为toast提示
- 自己的space也显示关注按钮,不再隐藏
- 点击关注自己时toast'不能关注自己',不发请求
- 自己的space下,发消息/更多按钮保持隐藏
2026-06-03 01:13:01 +08:00
58aabb9bf0 fix: 临时登录cookie改用maxAge=IdleTimeout替代session模式
- rememberMe=false时maxAge从0改为IdleTimeout秒数(默认2h),与Redis TTL一致
- 解决服务器重启后部分浏览器(尤其隐私窗口)将session cookie视为失效的问题
- 关浏览器后依然自动清除(maxAge到期 + Redis TTL双保险)
2026-06-03 01:07:47 +08:00
ae15025b1d feat: @提及支持改名后链接跟随UID + 显示名自动更新
- comment_mentions新增original_username列,创建时记录原始@用户名
- Mentions map改为{original_username: {uid, name}}结构,name为当前显示名
- 用户改名后:旧@文本仍能匹配original_username,链接跟随uid不变,显示名自动更新
- 前端renderMentions适配新结构
2026-06-03 01:00:44 +08:00
be8ce04334 fix: 修复@提及uid始终为0的BUG + 空@提示优化
- SearchUsersByLevel SQL id列别名改为uid,修复GORM字段名映射导致uid=0
- @空输入时悬浮窗显示'输入以查找用户'而非空白
- 清理历史脏数据(uid=0的mention记录)
2026-06-03 00:55:51 +08:00
4ac4f64f33 feat: @提及功能优化 + 默认头像改用SVG图标
- 评论区有效@提及渲染为蓝色链接跳转/space/{uid},无效@保持纯文本
- 输入@触发悬浮窗,根据输入实时搜索用户(含头像+等级)
- 无匹配时显示未搜索到用户,选中后插入为@用户名 格式(尾部空格)
- 悬浮窗定位于输入光标上方,不遮挡输入内容
- 后端Comment新增Mentions字段,批量加载提及映射供前端判断
- SearchUsers/parseMentions 统一限制LV2+用户
- 所有默认头像由首字符文字改为统一SVG人形图标(nav/home/space/mention等7处)
2026-06-03 00:49:24 +08:00
2d05da6709 fix: 赋能弹窗已满时不再弹出 + API 补上 post_energize_total 字段
- 赋能按钮点击时先查已赋能总量,>=20 直接 showToast 不弹窗
- 修复控制响应缺失 post_energize_total 字段导致前端永远走到 showOverlay
- fetch 失败/.catch 改为 showToast 而非 showOverlay
2026-06-03 00:23:35 +08:00
1af9fba291 feat: 赋能溢出截断 + 前端按已赋能量禁用按钮
- 单篇赋能上限由硬拒绝改为溢出截断,类似每日经验处理
- 前端弹窗时查询文章已赋能总量,>=10 禁用重赋,>=20 全部禁用
- GetEnergyInfo 支持 ?post_id= 返回 post_energize_total
- 文案精简为'赋能成功!+N 经验'等短句
2026-06-03 00:13:03 +08:00
4442da31c3 fix: 收藏夹弹窗优化 — 已收藏标识 + 移除确认 + 点击可靠性
- 收藏夹选项中已收藏项显示黄色'已收藏'徽章,一目了然
- 点击判断改用 classList.contains('active') 替代 JS 变量,防 currentFolderId 为空
- addToFolder 兜底处理'已在此收藏夹中'错误,不弹 alert
- 取消收藏确认文案优化为'确定移出该收藏夹吗?'
2026-06-03 00:04:29 +08:00
0af6f7c971 feat: 文章详情页右下角悬浮操作栏 + 作者自赋能拦截
- 赞/踩/赋能/收藏/评论/回顶部移至右下角悬浮操作栏,固定定位
- 回顶部按钮页面顶部隐藏,滚动后显示,平滑滚动
- 评论按钮点击定位到评论区(NAV 下方)
- 赋能按钮作者可见但禁止自赋能,点击弹出 toast 提示
- 未登录用户赋能/收藏点击跳转登录页
- postReactions 引用统一改为 postFloatBar
2026-06-02 23:45:51 +08:00
b74ce2da93 feat: 详情页作者名链接到 /space/:uid 空间页
- 作者名可点击跳转到作者空间页,已注销用户不生成链接
- hover 时变为主题紫色 + 下划线,与 meta 行风格协调
2026-06-02 23:29:16 +08:00
fcff28ab60 feat: Studio 概览/分析补上阅读量 + 详情页 meta 行改版
- StudioOverview 新增 TotalViews 字段,GetOverviewByUserID 查询含 SUM(views_count)
- 新增 ViewTrendStore 接口和 AggregateViewsByAuthor,UNION ALL 合并已登录+访客阅读日志按日期聚合
- 数据分析页面新增阅读量趋势折线图(绿色,Chart.js)
- 概览和数据分析页面新增总阅读量统计卡片
- 详情页 meta 行改为标签格式:作者/阅读/点赞/收藏/评论/发布时间
- 移除详情页公开状态徽章,已锁定+理由仅作者可见
2026-06-02 23:26:48 +08:00
ea2f196ff7 feat: 文章锁定/解锁时触发系统通知
- 新增 NotifyPostLocked/NotifyPostUnlocked 通知类型
- Lock/Unlock 方法执行后通过 notifier 发送通知给作者
- 锁定时通知含锁定理由,解锁时通知已解除锁定
- 新增类型归入稿件审核分类和 system tab
2026-06-02 22:53:46 +08:00
b0a1ff3c3f refactor: 将 common.js 统一移至 footer.html 加载
- 所有页面的 common.js 引用移至 layout/footer.html 统一管理
- 避免新增页面遗漏 common.js 导致退出登录、消息轮询等功能失效
- 修复 messages/index.html 中 inline script 在 common.js 之前调用 getCSRFToken 的顺序问题
2026-06-02 22:47:21 +08:00
a806532d25 fix: 锁定按钮改为随时可用,不联动审核状态
- 前端:锁定按钮仅受 IsLocked 控制,移除 status!=pending 的条件
- 后端:Lock 方法移除 pending 状态的阻止,锁定状态独立
2026-06-02 22:31:34 +08:00
ca21ae7216 fix: 修复头像审核旧图裂开问题 + CSP 配置修复
- 头像提交审核不再删除旧文件,审核通过后才删除旧头像文件
- 头像审核被拒时删除已上传的新头像文件,保留旧头像
- CSP style-src 移除 nonce 以启用 unsafe-inline(JS 动态样式需要)
- CSP script-src 移除 nonce 改用 unsafe-inline + unsafe-eval(Vditor 编辑器和动态样式需要)
2026-06-02 22:26:01 +08:00
7d944cfa35 fix: 修复更名退款逻辑、审核列表显示及更名消耗确认提示
- 首次更名审核被拒不再退款,首次免费未扣费不产生退款
- 审核列表提交者列改用 UID,当前用户名列显示当前用户名
- 更名消耗提示改为确认弹窗,首次/非首次动态显示不同文案
2026-06-02 22:09:41 +08:00
dd6efd0c50 fix: 修复 utils.js 中 showConfirm/showPrompt/showToast 的 CSP style-src 违规
- showToast: style.cssText 替换为逐个 style.property 赋值
- showConfirm: 移除 style.cssText 和 innerHTML 中的 style 属性,改用 createElement + setStyles 辅助函数 + addEventListener
- showPrompt: 同上,移除所有 inline style 和 onclick
- 新增 setStyles 辅助函数,批量设置单个 style 属性以满足 CSP 要求
2026-06-02 21:45:00 +08:00
fa3b20d1bc fix: 修复审核管理页面 CSP 违规 — inline onclick/onerror 移入事件委托
- 审核操作按钮(通过/拒绝)从 onclick 替换为 data-audit-action 属性 + document 级事件委托
- 分页按钮从 onclick 替换为 data-go-page 属性 + 事件委托
- 头像加载失败处理从 inline onerror 替换为 data-fallback 标记 + addEventListener 绑定
2026-06-02 21:42:20 +08:00
543325105c fix: 修复管理后台样式丢失 + CSP inline style 违规
- 评论管理页:创建 comments.css,controller 传递 ExtraCSS/ExtraJS,模板移除所有 inline style,JS 改用 CSS 类
- 修复 comments.js 加载顺序:通过 ExtraJS 在 common.js 之后加载,解决 api is not defined
- base.html:SVG inline style 移至 common.css
- dashboard/index.html:3 处 inline style 替换为 CSS 类
- audit/index.html:modal 移除冗余 style="display:none"
- fund_logs/logs:label inline style 移至 energy.css
- common.css 新增多个 CSS 规则
- energy.css 新增 .filter-bar label 规则
2026-06-02 21:37:52 +08:00
983f539268 fix: 修复管理后台 JS api 变量冲突导致页面加载失败
- common.js 中 const api 改为 var api,避免与 utils.js 的 function api() 重复声明冲突,导致 roleLabel/statusLabel 等未定义
- common.js 新增 api.del() 方法
- comments.js 升级为对象式 api.get/api.del,修复 api('GET') 函数调用失败
- comments.js 修复 csrfToken 未定义导致删除请求 403
- users.js/audit.js catch 块改为输出真实错误信息
2026-06-02 21:13:58 +08:00
ee313675ca fix: 修复审核管理页拒绝弹窗误显示
- audit.css 中 .modal-overlay 移除 display:flex 默认值,避免与 common.css 冲突导致页面加载时弹窗可见
- 弹窗显示由 JS openRejectModal/closeRejectModal 控制
2026-06-02 20:49:54 +08:00
70 changed files with 1395 additions and 380 deletions

View File

@ -1,3 +1,10 @@
> [!IMPORTANT]
> **本项目已迁移至 [MetaZone Community Engine (MCE)](https://git.metazone.cc/MetaZone/mce)**
>
> MetaLab 已更名为 MCE 并继续开发。此仓库仅为历史存档,不再接受 Pull Request、Issue 或任何代码更新。
>
> 请前往 👉 **[mce](https://git.metazone.cc/MetaZone/mce)** 获取最新版本。
<p align="center">
<img src="https://img.shields.io/badge/Go-1.26-00ADD8?style=flat-square&logo=go" alt="Go 1.26">
<img src="https://img.shields.io/badge/Gin-1.12-0096D6?style=flat-square&logo=go" alt="Gin 1.12">

View File

@ -15,8 +15,8 @@ const (
)
// SetSessionCookie 写入会话 ID Cookie
// rememberMe=true → Cookie 持久化(30天,关浏览器后仍保持登录
// rememberMe=false → Cookie session 模式maxAge=0,关浏览器清除
// rememberMe=true → Cookie maxAge=30天关浏览器后仍保持登录
// rememberMe=false → Cookie maxAge=空闲超时默认2h与Redis TTL一致,关浏览器后自动清除
func SetSessionCookie(c *gin.Context, sid string, rememberMe bool, cfg *config.Config) {
secure := cfg.Server.Mode != "debug"
c.SetSameSite(http.SameSiteLaxMode)
@ -25,7 +25,7 @@ func SetSessionCookie(c *gin.Context, sid string, rememberMe bool, cfg *config.C
if rememberMe {
maxAge = cfg.Session.RememberTimeout * 60 // 分钟 → 秒
} else {
maxAge = 0 // session cookie
maxAge = cfg.Session.IdleTimeout * 60 // 分钟 → 秒,与 Redis TTL 一致
}
log.Printf("[SetSessionCookie] sid=%s rememberMe=%v maxAge=%ds secure=%v", sid[:16]+"...", rememberMe, maxAge, secure)

View File

@ -23,7 +23,9 @@ func NewAdminCommentController(cs adminCommentUseCase) *AdminCommentController {
// CommentsPage SSR 页面
func (ctrl *AdminCommentController) CommentsPage(c *gin.Context) {
c.HTML(http.StatusOK, "admin/comments/index.html", common.BuildAdminPageData(c, gin.H{
"Title": "评论管理",
"Title": "评论管理",
"ExtraCSS": "/admin/static/css/comments.css",
"ExtraJS": "/admin/static/js/comments.js",
}))
}

View File

@ -50,17 +50,17 @@ func (ac *AuditController) ListAudits(c *gin.Context) {
return
}
// 为每个审核记录附加提交者的用户名
// 为每个审核记录附加提交者的当前用户名
type auditItem struct {
model.AuditSubmission
SubmitterUsername string `json:"submitter_username"`
CurrentUsername string `json:"current_username"`
}
items := make([]auditItem, 0, len(result.Items))
for _, a := range result.Items {
item := auditItem{AuditSubmission: a}
if user, err := ac.userRepo.FindByID(a.UserID); err == nil {
item.SubmitterUsername = user.Username
item.CurrentUsername = user.Username
}
items = append(items, item)
}

View File

@ -41,7 +41,7 @@ func (ctrl *CommentController) Delete(c *gin.Context) {
// SearchUsers GET /api/users/search?q=keyword
func (ctrl *CommentController) SearchUsers(c *gin.Context) {
_, _, ok := common.GetGinUser(c)
uid, _, ok := common.GetGinUser(c)
if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
@ -53,7 +53,7 @@ func (ctrl *CommentController) SearchUsers(c *gin.Context) {
return
}
users, err := ctrl.commentService.SearchUsers(q)
users, err := ctrl.commentService.SearchUsers(q, uid)
if err != nil {
common.Error(c, http.StatusInternalServerError, "搜索用户失败")
return

View File

@ -37,7 +37,7 @@ func (ec *EnergyController) Energize(c *gin.Context) {
return
}
expGained, err := ec.energySvc.Energize(uid, req.PostID, req.Amount)
expGained, actualAmount, err := ec.energySvc.Energize(uid, req.PostID, req.Amount)
if err != nil {
switch err {
case common.ErrPostNotFound:
@ -56,21 +56,30 @@ func (ec *EnergyController) Energize(c *gin.Context) {
return
}
truncated := actualAmount != req.Amount
common.Ok(c, gin.H{
"exp_gained": expGained,
"message": buildEnergizeMessage(expGained),
"exp_gained": expGained,
"actual_amount": actualAmount,
"truncated": truncated,
"message": buildEnergizeMessage(expGained, truncated),
})
}
// buildEnergizeMessage 根据获得的经验生成提示消息
func buildEnergizeMessage(expGained int) string {
if expGained > 0 {
return "赋能成功!" + strconv.Itoa(expGained) + "经验已加入你的旅程。"
func buildEnergizeMessage(expGained int, truncated bool) string {
if truncated {
if expGained > 0 {
return "单篇额度已满,实际赋能部分额度,+" + strconv.Itoa(expGained) + " 经验"
}
return "单篇额度已满,实际赋能部分额度"
}
return "赋能成功!今日赋能经验奖励已达上限,对方仍会获得激励。"
if expGained > 0 {
return "赋能成功!+" + strconv.Itoa(expGained) + " 经验"
}
return "赋能成功!今日经验已满,对方仍获得激励"
}
// GetEnergyInfo 获取域能余额和经验上限状态
// GetEnergyInfo 获取域能余额和经验上限状态,支持 ?post_id= 查询文章赋能总量
func (ec *EnergyController) GetEnergyInfo(c *gin.Context) {
uid, _, ok := common.GetGinUser(c)
if !ok {
@ -78,7 +87,10 @@ func (ec *EnergyController) GetEnergyInfo(c *gin.Context) {
return
}
info, err := ec.energySvc.GetEnergyInfo(uid)
postIDStr := c.DefaultQuery("post_id", "0")
postID, _ := strconv.ParseUint(postIDStr, 10, 64)
info, err := ec.energySvc.GetEnergyInfo(uid, uint(postID))
if err != nil {
common.Error(c, http.StatusInternalServerError, "获取域能信息失败")
return
@ -91,6 +103,7 @@ func (ec *EnergyController) GetEnergyInfo(c *gin.Context) {
"daily_energize_exp": info.DailyEnergizeExp,
"daily_exp_cap": info.DailyExpCap,
"daily_exp_cap_reached": info.DailyExpCapReached,
"post_energize_total": info.PostEnergizeTotal,
})
}

View File

@ -85,8 +85,8 @@ type levelUseCase interface {
// energyUseCase EnergyController 对 EnergyService 的最小依赖ISP3 个方法)
type energyUseCase interface {
Energize(energizerID uint, postID uint, amount int) (int, error)
GetEnergyInfo(userID uint) (*service.EnergyInfo, error)
Energize(energizerID uint, postID uint, amount int) (int, int, error)
GetEnergyInfo(userID uint, postID uint) (*service.EnergyInfo, error)
GetEnergyLogs(userID uint, days, page, pageSize int) ([]model.EnergyLog, int64, error)
}
@ -115,7 +115,7 @@ type commentUseCase interface {
Delete(commentID uint, requesterID uint, isAdmin bool) error
ListRootComments(postID uint, page, pageSize int) ([]model.Comment, int64, error)
ListReplies(rootID uint) ([]model.Comment, error)
SearchUsers(keyword string) ([]model.UserSearchResult, error)
SearchUsers(keyword string, followerUID uint) ([]model.UserSearchResult, error)
}
// reactionUseCase ReactionController 对 ReactionService 的最小依赖ISP4 个方法)

View File

@ -58,15 +58,23 @@ func (sc *SettingsController) SettingsPage(c *gin.Context) {
// 查询待审核类型(用于前端显示审核提示)
pendingTypes, _ := sc.auditService.GetPendingTypes(uid)
// 是否已完成首次改名(用于前端确认弹窗显示消耗提示)
hasCompletedRename := false
if sc.levelSvc != nil {
completed, _ := sc.levelSvc.HasCompletedTask(uid, "username")
hasCompletedRename = completed
}
data := gin.H{
"Title": "个人设置",
"ExtraCSS": "/static/css/settings.css",
"ActiveTab": tab,
"User": user,
"RoleName": model.RoleDisplayNames[user.Role],
"StatusName": model.StatusDisplayNames[user.Status],
"PendingTypes": pendingTypes,
"AuditTypes": model.AuditTypeNames,
"Title": "个人设置",
"ExtraCSS": "/static/css/settings.css",
"ActiveTab": tab,
"User": user,
"RoleName": model.RoleDisplayNames[user.Role],
"StatusName": model.StatusDisplayNames[user.Status],
"PendingTypes": pendingTypes,
"AuditTypes": model.AuditTypeNames,
"HasCompletedRename": hasCompletedRename,
}
// 登录管理 tab 需要额外数据

View File

@ -32,8 +32,8 @@ func SecurityHeaders() gin.HandlerFunc {
// img-src: 本站 + data: URI头像裁切+ blob:(粘贴图片)
c.Header("Content-Security-Policy",
"default-src 'self'; "+
"script-src 'self' 'nonce-"+nonce+"'; "+
"style-src 'self' 'nonce-"+nonce+"'; "+
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "+
"style-src 'self' 'unsafe-inline'; "+
"img-src 'self' data: blob:; "+
"connect-src 'self'")

View File

@ -25,13 +25,23 @@ type Comment struct {
ReplyToName string `gorm:"-:migration;<-:false" json:"reply_to_name,omitempty"`
// RepliesCount 回复数非DB字段查询填充
RepliesCount int `gorm:"-:migration;<-:false" json:"replies_count,omitempty"`
// Mentions 有效@提及映射 original_username -> {uid, 当前显示名}非DB字段批量查询填充
// key=创建时的原始@用户名value={uid,当前显示名},用户改名后链接仍有效且显示新名
Mentions map[string]MentionInfo `gorm:"-" json:"mentions,omitempty"`
}
// CommentMention @提及映射绑定UID不是username,支持改名后自动更新显示名)
// CommentMention @提及映射绑定UID + 原始用户名,支持改名后自动更新显示名)
type CommentMention struct {
ID uint `gorm:"primarykey" json:"id"`
CommentID uint `gorm:"uniqueIndex:idx_comment_uid,priority:1;not null" json:"comment_id"`
UID uint `gorm:"uniqueIndex:idx_comment_uid,priority:2;not null" json:"uid"`
ID uint `gorm:"primarykey" json:"id"`
CommentID uint `gorm:"uniqueIndex:idx_comment_uid,priority:1;not null" json:"comment_id"`
UID uint `gorm:"uniqueIndex:idx_comment_uid,priority:2;not null" json:"uid"`
OriginalUsername string `gorm:"type:varchar(64);not null;default:''" json:"original_username"`
}
// MentionInfo @提及显示信息(前端渲染用)
type MentionInfo struct {
UID uint `json:"uid"`
Name string `json:"name"` // 当前显示名
}
// UserSearchResult @搜索用户结果
@ -39,6 +49,7 @@ type UserSearchResult struct {
UID uint `json:"uid"`
Username string `json:"username"`
Level int `json:"level"`
Avatar string `json:"avatar"`
}
// AdminCommentRow 后台评论列表行

View File

@ -6,11 +6,14 @@ const (
NotifyAuditRejected = "audit_rejected"
NotifyPostApproved = "post_approved"
NotifyPostRejected = "post_rejected"
NotifyPostLocked = "post_locked"
NotifyPostUnlocked = "post_unlocked"
NotifyLevelUp = "level_up"
NotifyComment = "comment"
NotifyCommentReply = "comment_reply"
NotifyLikeAggregated = "like_aggregated" // 每日聚合点赞通知
NotifyFollow = "follow" // 关注通知
NotifyLikeAggregated = "like_aggregated" // 每日聚合点赞通知
NotifyFollow = "follow" // 关注通知
NotifyCommentMention = "comment_mention" // 评论@提及通知
)
// Notification 消息/通知模型
@ -32,9 +35,12 @@ var NotifyTypeNames = map[string]string{
NotifyAuditRejected: "资料审核",
NotifyPostApproved: "稿件审核",
NotifyPostRejected: "稿件审核",
NotifyPostLocked: "稿件审核",
NotifyPostUnlocked: "稿件审核",
NotifyLevelUp: "等级提升",
NotifyComment: "评论",
NotifyCommentReply: "回复",
NotifyCommentMention: "@ 提及",
NotifyLikeAggregated: "点赞",
NotifyFollow: "关注",
}
@ -45,9 +51,12 @@ var NotifyCategory = map[string]string{
NotifyAuditRejected: "system",
NotifyPostApproved: "system",
NotifyPostRejected: "system",
NotifyPostLocked: "system",
NotifyPostUnlocked: "system",
NotifyLevelUp: "system",
NotifyComment: "mention",
NotifyCommentReply: "mention",
NotifyCommentMention: "mention",
NotifyLikeAggregated: "like",
NotifyFollow: "follow",
}

View File

@ -17,39 +17,98 @@ func (r *CommentRepo) FindMentionsByComment(commentID uint) ([]model.CommentMent
return list, err
}
// SearchUsersByLevel 搜索指定等级及以上用户(用于@艾特),返回 uid/username/exp
func (r *CommentRepo) SearchUsersByLevel(keyword string, minLevel int, limit int) ([]struct {
// LoadMentionsForComments 批量加载多条评论的有效@提及映射
// key=创建时的原始@用户名, value={uid, 用户当前显示名}
// 用户改名后原始名匹配body中的旧@文本显示名随users表更新
func (r *CommentRepo) LoadMentionsForComments(comments []model.Comment) error {
if len(comments) == 0 {
return nil
}
commentIDs := make([]uint, len(comments))
for i, c := range comments {
commentIDs[i] = c.ID
}
type mentionRow struct {
CommentID uint
UID uint
OriginalUsername string
CurrentName string
}
var rows []mentionRow
err := r.db.Table("comment_mentions").
Select("comment_mentions.comment_id, comment_mentions.uid, comment_mentions.original_username, users.username AS current_name").
Joins("LEFT JOIN users ON users.id = comment_mentions.uid").
Where("comment_mentions.comment_id IN ?", commentIDs).
Find(&rows).Error
if err != nil {
return err
}
// 按 comment_id 分组key = original_username, value = MentionInfo
mentionMap := make(map[uint]map[string]model.MentionInfo)
for _, row := range rows {
if mentionMap[row.CommentID] == nil {
mentionMap[row.CommentID] = make(map[string]model.MentionInfo)
}
mentionMap[row.CommentID][row.OriginalUsername] = model.MentionInfo{
UID: row.UID,
Name: row.CurrentName,
}
}
for i := range comments {
if m, ok := mentionMap[comments[i].ID]; ok {
comments[i].Mentions = m
}
}
return nil
}
// SearchUsersByLevel 搜索指定等级及以上用户(用于@艾特),返回 uid/username/avatar/exp
// followerUID > 0 时按已关注优先排序其次按exp降序
func (r *CommentRepo) SearchUsersByLevel(keyword string, minLevel, limit int, followerUID uint) ([]struct {
UID uint
Username string
Avatar string
Exp int
}, error) {
type result struct {
UID uint
Username string
Avatar string
Exp int
}
var list []result
threshold := model.LevelThresholds[minLevel]
err := r.db.Table("users").
Select("id, username, exp").
Where("username LIKE ? AND exp >= ? AND deleted_at IS NULL", "%"+keyword+"%", threshold).
Order("exp DESC").
Limit(limit).
Find(&list).Error
q := r.db.Table("users AS u").
Select("u.id AS uid, u.username, u.avatar, u.exp").
Where("u.username LIKE ? AND u.exp >= ? AND u.deleted_at IS NULL", "%"+keyword+"%", threshold).
Order("u.exp DESC")
if followerUID > 0 {
q = q.Joins("LEFT JOIN user_follows f ON f.followee_id = u.id AND f.follower_id = ?", followerUID).
Order("(f.follower_id IS NOT NULL) DESC, u.exp DESC")
}
err := q.Limit(limit).Find(&list).Error
var res []struct {
UID uint
Username string
Avatar string
Exp int
}
for _, u := range list {
res = append(res, struct {
UID uint
Username string
Avatar string
Exp int
}{UID: u.UID, Username: u.Username, Exp: u.Exp})
}{UID: u.UID, Username: u.Username, Avatar: u.Avatar, Exp: u.Exp})
}
return res, err
}

View File

@ -4,6 +4,7 @@ import (
"time"
"metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/service"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@ -40,10 +41,11 @@ type overviewRow struct {
PendingCount int64
ApprovedCount int64
RejectedCount int64
TotalViews int64
}
// GetOverviewByUserID 用单次 GROUP BY 查询聚合用户各状态文章数
func (r *PostRepo) GetOverviewByUserID(userID uint) (totalPosts, draftCount, pendingCount, approvedCount, rejectedCount int64, err error) {
// GetOverviewByUserID 用单次 GROUP BY 查询聚合用户各状态文章数 + 总阅读量
func (r *PostRepo) GetOverviewByUserID(userID uint) (totalPosts, draftCount, pendingCount, approvedCount, rejectedCount, totalViews int64, err error) {
var row overviewRow
err = r.db.Model(&model.Post{}).
Select(`
@ -51,11 +53,12 @@ func (r *PostRepo) GetOverviewByUserID(userID uint) (totalPosts, draftCount, pen
COALESCE(SUM(CASE WHEN status = 'draft' THEN 1 ELSE 0 END), 0) AS draft_count,
COALESCE(SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END), 0) AS pending_count,
COALESCE(SUM(CASE WHEN status = 'approved' THEN 1 ELSE 0 END), 0) AS approved_count,
COALESCE(SUM(CASE WHEN status = 'rejected' THEN 1 ELSE 0 END), 0) AS rejected_count
COALESCE(SUM(CASE WHEN status = 'rejected' THEN 1 ELSE 0 END), 0) AS rejected_count,
COALESCE(SUM(views_count), 0) AS total_views
`).
Where("user_id = ? AND deleted_at IS NULL", userID).
Scan(&row).Error
return row.TotalPosts, row.DraftCount, row.PendingCount, row.ApprovedCount, row.RejectedCount, err
return row.TotalPosts, row.DraftCount, row.PendingCount, row.ApprovedCount, row.RejectedCount, row.TotalViews, err
}
// readCooldown 两次阅读记录最小间隔
@ -106,3 +109,26 @@ func (r *PostRepo) IncrementViewsCount(postID uint) error {
return r.db.Model(&model.Post{}).Where("id = ?", postID).
Update("views_count", gorm.Expr("views_count + 1")).Error
}
// AggregateViewsByAuthor 按日期聚合阅读量(作者所有文章在时间段内的阅读记录,含已登录+访客)
func (r *PostRepo) AggregateViewsByAuthor(userID uint, since string) ([]service.TrendPoint, error) {
var results []service.TrendPoint
err := r.db.Raw(`
SELECT TO_CHAR(t.read_at, 'YYYY-MM-DD') AS date, COUNT(*) AS value
FROM (
SELECT prl.read_at FROM post_read_logs prl
JOIN posts p ON p.id = prl.post_id
WHERE p.user_id = ? AND prl.read_at >= ?
UNION ALL
SELECT pgrl.read_at FROM post_guest_read_logs pgrl
JOIN posts p ON p.id = pgrl.post_id
WHERE p.user_id = ? AND pgrl.read_at >= ?
) t
GROUP BY TO_CHAR(t.read_at, 'YYYY-MM-DD')
ORDER BY date ASC
`, userID, since, userID, since).Scan(&results).Error
if results == nil {
results = []service.TrendPoint{}
}
return results, err
}

View File

@ -136,10 +136,14 @@ func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
}
// --- 关注 API ---
// 公开(未登录返回 false
r.GET("/api/users/:uid/follow-status", d.followCtrl.GetStatus)
r.GET("/api/users/:uid/followers", d.followCtrl.Followers)
r.GET("/api/users/:uid/following", d.followCtrl.Following)
// 公开(Optional 认证以获取当前用户关注状态,未登录返回 false
followPublic := r.Group("/api/users/:uid")
followPublic.Use(d.authMdw.Optional())
{
followPublic.GET("/follow-status", d.followCtrl.GetStatus)
followPublic.GET("/followers", d.followCtrl.Followers)
followPublic.GET("/following", d.followCtrl.Following)
}
// 需登录:切换关注
followAPI := r.Group("/api/users/:uid")
followAPI.Use(d.authMdw.Required())

View File

@ -38,6 +38,7 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
auditService := service.NewAuditService(auditRepo, userRepo, siteSettings, cfg.Audit, notificationService, levelSvc)
auditService.WithEnergyRefundService(energyService)
auditService.WithAvatarFileCleaner(avatarSvc)
auditController := adminCtrl.NewAuditController(auditService, userRepo)
messageController := controller.NewMessageController(notificationService)
@ -97,7 +98,7 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
spaceCtrl.WithFavoriteService(favoriteService)
// Studio 趋势图
trendsService := service.NewTrendsService(energyRepo, commentRepo, reactionRepo, favoriteRepo)
trendsService := service.NewTrendsService(energyRepo, commentRepo, reactionRepo, favoriteRepo, postRepo)
studioCtrl.WithTrendsService(trendsService)
maintenanceMdw := middleware.NewMaintenanceMiddleware(cfg, siteSettings, sessionMgr)

View File

@ -45,17 +45,32 @@ func (s *AuditService) review(reviewerID uint, submissionID uint, approved bool,
} else {
submission.Status = model.AuditStatusRejected
submission.RejectReason = &reason
// 通知用户审核被拒(改名审核追加域能返还提示)
// 改名审核被拒 → 仅非首次改名(已扣费)才退还域能
// 首次改名免费,被拒不退还
shouldRefund := false
if submission.AuditType == model.AuditTypeUsername && s.energyRefundSvc != nil {
if s.levelSvc != nil {
hasCompleted, _ := s.levelSvc.HasCompletedTask(submission.UserID, "username")
shouldRefund = hasCompleted
}
if shouldRefund {
_ = s.energyRefundSvc.RefundOnRenameReject(submission.UserID)
}
}
// 通知用户审核被拒(仅实际退款时追加域能返还提示)
if s.notifier != nil {
reasonMsg := reason
if submission.AuditType == model.AuditTypeUsername {
if shouldRefund {
reasonMsg += "。扣除的域能已被返还"
}
s.notifier.NotifyAuditRejected(submission.UserID, submission.AuditType, reasonMsg, submission.ID)
}
// 改名审核被拒 → 返还域能
if submission.AuditType == model.AuditTypeUsername && s.energyRefundSvc != nil {
_ = s.energyRefundSvc.RefundOnRenameReject(submission.UserID)
// 头像审核被拒 → 删除提交的新头像文件
if submission.AuditType == model.AuditTypeAvatar && s.avatarCleaner != nil {
_ = s.avatarCleaner.DeleteAvatarFile(submission.NewValue)
}
}
return s.auditRepo.Update(submission)
@ -80,7 +95,16 @@ func (s *AuditService) applyApproval(submission *model.AuditSubmission) error {
}
user.Username = submission.NewValue
case model.AuditTypeAvatar:
oldAvatar := user.Avatar
user.Avatar = submission.NewValue
if err := s.userRepo.Update(user); err != nil {
return err
}
// 审核通过,删除旧头像文件
if s.avatarCleaner != nil {
_ = s.avatarCleaner.DeleteAvatarFile(oldAvatar)
}
return nil
case model.AuditTypeBio:
user.Bio = submission.NewValue
}

View File

@ -40,6 +40,7 @@ type auditNotifier interface {
// auditTaskCompleter 审核通过时触发首次任务奖励的接口
type auditTaskCompleter interface {
CompleteTask(userID uint, taskType string) (newExp int, levelUp bool, err error)
HasCompletedTask(userID uint, taskType string) (bool, error)
}
// energyRenameRefunder 改名审核被拒时返还域能的接口ISP
@ -47,6 +48,12 @@ type energyRenameRefunder interface {
RefundOnRenameReject(userID uint) error
}
// avatarFileCleaner 头像审核通过/拒绝时清理头像文件的接口ISP
type avatarFileCleaner interface {
CleanOldAvatars(userID uint)
DeleteAvatarFile(url string) error
}
// AuditService 审核业务逻辑
type AuditService struct {
auditRepo auditRepo
@ -56,6 +63,7 @@ type AuditService struct {
notifier auditNotifier
levelSvc auditTaskCompleter
energyRefundSvc energyRenameRefunder
avatarCleaner avatarFileCleaner
}
// NewAuditService 构造函数
@ -76,6 +84,12 @@ func (s *AuditService) WithEnergyRefundService(svc energyRenameRefunder) *AuditS
return s
}
// WithAvatarFileCleaner 链式注入头像文件清理服务
func (s *AuditService) WithAvatarFileCleaner(svc avatarFileCleaner) *AuditService {
s.avatarCleaner = svc
return s
}
// ShouldAudit 判断指定用户是否需要走审核流程
// 规则admin 及以上角色免审
func (s *AuditService) ShouldAudit(userID uint) (bool, error) {

View File

@ -57,6 +57,9 @@ func (s *AvatarService) ProcessAvatar(userID uint, file io.Reader, contentType s
return "", err
}
// 直接更新场景:删除旧头像文件,仅保留新文件
s.CleanOldAvatars(userID)
user, err := s.userRepo.FindByID(userID)
if err != nil {
return "", fmt.Errorf("用户不存在")
@ -134,10 +137,8 @@ func (s *AvatarService) ProcessImage(userID uint, file io.Reader, contentType st
return "", fmt.Errorf("创建存储目录失败")
}
// 8. 删除该用户的旧头像,仅保留最新
s.cleanOldAvatars(userID)
// 9. 原子写入:先写临时文件,再 rename 到最终路径
// 8. 原子写入:先写临时文件,再 rename 到最终路径
// 注意:此处不删除旧头像文件,审核场景下审核通过前旧头像仍需正常显示
ts := time.Now().Unix()
filename := fmt.Sprintf("%d_%d.webp", userID, ts)
tmpPath := filepath.Join(s.storageDir, filename+".tmp")
@ -153,8 +154,8 @@ func (s *AvatarService) ProcessImage(userID uint, file io.Reader, contentType st
return fmt.Sprintf("/uploads/avatars/%d_%d.webp", userID, ts), nil
}
// cleanOldAvatars 删除指定用户的所有旧头像文件(仅保留最新一次上传)
func (s *AvatarService) cleanOldAvatars(userID uint) {
// CleanOldAvatars 删除指定用户的所有旧头像文件,调用方负责确保新文件不受影响
func (s *AvatarService) CleanOldAvatars(userID uint) {
prefix := strconv.FormatUint(uint64(userID), 10) + "_"
entries, err := os.ReadDir(s.storageDir)
if err != nil {
@ -167,6 +168,19 @@ func (s *AvatarService) cleanOldAvatars(userID uint) {
}
}
// DeleteAvatarFile 根据头像 URL 删除对应的磁盘文件
func (s *AvatarService) DeleteAvatarFile(url string) error {
if url == "" {
return nil
}
filename := filepath.Base(url)
if filename == "." || filename == "/" {
return nil
}
fullPath := filepath.Join(s.storageDir, filename)
return os.Remove(fullPath)
}
// subImage 从原图中裁切指定正方形区域
func subImage(img image.Image, x, y, size int) image.Image {
cropped := image.NewNRGBA(image.Rect(0, 0, size, size))

View File

@ -55,15 +55,15 @@ func (s *CommentService) CreateRoot(userID uint, postID uint, body string) (*mod
// 文章评论数+1
_ = s.repo.IncrCommentsCount(postID)
// 解析 @ 提及
s.parseMentions(comment.ID, body)
// 解析 @ 提及 + 发送通知
commenterName, _ := s.repo.GetUsernameByID(userID)
if commenterName == "" {
commenterName = "用户"
}
s.parseMentions(comment.ID, postID, userID, commenterName, body)
// 通知文章作者(评论者 ≠ 作者RelatedID 存 postID 以便消息页生成跳转链接
if s.notifier != nil && userID != postAuthorID {
commenterName, _ := s.repo.GetUsernameByID(userID)
if commenterName == "" {
commenterName = "用户"
}
s.notifier.Create(postAuthorID, model.NotifyComment,
"新评论",
fmt.Sprintf("%s 评论了你的文章", commenterName),
@ -103,15 +103,15 @@ func (s *CommentService) CreateReply(userID uint, parentID uint, body string) (*
// 文章评论数+1
_ = s.repo.IncrCommentsCount(parent.PostID)
// 解析 @ 提及
s.parseMentions(comment.ID, body)
// 解析 @ 提及 + 发送通知
commenterName, _ := s.repo.GetUsernameByID(userID)
if commenterName == "" {
commenterName = "用户"
}
s.parseMentions(comment.ID, parent.PostID, userID, commenterName, body)
// 通知被回复者不自己回复自己RelatedID 存 postID 以便消息页生成跳转链接
if s.notifier != nil && parent.UserID != userID {
commenterName, _ := s.repo.GetUsernameByID(userID)
if commenterName == "" {
commenterName = "用户"
}
s.notifier.Create(parent.UserID, model.NotifyCommentReply,
"新回复",
fmt.Sprintf("%s 回复了你的评论", commenterName),
@ -152,7 +152,15 @@ func (s *CommentService) Delete(commentID uint, requesterID uint, isAdmin bool)
func (s *CommentService) ListRootComments(postID uint, page, pageSize int) ([]model.Comment, int64, error) {
p := common.Pagination{Page: page, PageSize: pageSize}
p.DefaultPagination()
return s.repo.FindRootComments(postID, p.Offset(), p.PageSize)
comments, total, err := s.repo.FindRootComments(postID, p.Offset(), p.PageSize)
if err != nil {
return nil, 0, err
}
// 批量加载有效@提及映射,前端据此决定是否为链接
if len(comments) > 0 {
_ = s.repo.LoadMentionsForComments(comments)
}
return comments, total, nil
}
// ListReplies 获取某条顶级评论的全部回复
@ -164,6 +172,10 @@ func (s *CommentService) ListReplies(rootID uint) ([]model.Comment, error) {
if list == nil {
list = []model.Comment{}
}
// 批量加载有效@提及映射
if len(list) > 0 {
_ = s.repo.LoadMentionsForComments(list)
}
return list, nil
}
@ -178,9 +190,9 @@ func (s *CommentService) ListAllComments(keyword string, showDeleted bool, page,
return rows, total, err
}
// SearchUsers 搜索LV3+用户(用于@艾特
func (s *CommentService) SearchUsers(keyword string) ([]model.UserSearchResult, error) {
rows, err := s.repo.SearchUsersByLevel(keyword, 3, 10)
// SearchUsers 搜索LV2+用户(用于@艾特悬浮窗已关注优先最多5条
func (s *CommentService) SearchUsers(keyword string, followerUID uint) ([]model.UserSearchResult, error) {
rows, err := s.repo.SearchUsersByLevel(keyword, 2, 5, followerUID)
if err != nil {
return nil, fmt.Errorf("搜索用户失败: %w", err)
}
@ -191,27 +203,42 @@ func (s *CommentService) SearchUsers(keyword string) ([]model.UserSearchResult,
UID: row.UID,
Username: row.Username,
Level: model.GetLevelByExp(row.Exp),
Avatar: row.Avatar,
}
}
return users, nil
}
// parseMentions 解析@用户存储到 comment_mentions 表
func (s *CommentService) parseMentions(commentID uint, body string) {
// parseMentions 解析@用户存储到 comment_mentions 表,并向被@用户发送通知
func (s *CommentService) parseMentions(commentID uint, postID uint, commenterID uint, commenterName string, body string) {
matches := mentionPattern.FindAllStringSubmatch(body, -1)
seen := make(map[string]bool)
notified := make(map[uint]bool) // 防止重复通知同一用户
for _, m := range matches {
username := m[1]
if seen[username] {
continue
}
seen[username] = true
rows, _ := s.repo.SearchUsersByLevel(username, 0, 1)
if len(rows) > 0 {
_ = s.repo.CreateMention(&model.CommentMention{
CommentID: commentID,
UID: rows[0].UID,
})
rows, _ := s.repo.SearchUsersByLevel(username, 2, 1, 0)
if len(rows) == 0 {
continue
}
mentionedUID := rows[0].UID
_ = s.repo.CreateMention(&model.CommentMention{
CommentID: commentID,
UID: mentionedUID,
OriginalUsername: username,
})
// 向被@用户发送通知(排除自己@自己、重复通知)
if s.notifier != nil && mentionedUID != commenterID && !notified[mentionedUID] {
notified[mentionedUID] = true
s.notifier.Create(mentionedUID, model.NotifyCommentMention,
"新提及",
fmt.Sprintf("%s 在评论中 @ 了你", commenterName),
&postID, &commentID)
}
}
}

View File

@ -9,11 +9,11 @@ import (
)
// Energize 赋能文章
// 返回:获得的经验值、错误
func (s *EnergyService) Energize(energizerID uint, postID uint, amount int) (int, error) {
// 返回:获得的经验值、实际赋能量DB乘10错误
func (s *EnergyService) Energize(energizerID uint, postID uint, amount int) (int, int, error) {
// 验证赋能量
if amount != EnergyEnergize1 && amount != EnergyEnergize2 {
return 0, common.ErrInvalidEnergyAmount
return 0, 0, common.ErrInvalidEnergyAmount
}
var expGained int
@ -21,6 +21,9 @@ func (s *EnergyService) Energize(energizerID uint, postID uint, amount int) (int
var expToday time.Time
var expTotal int
// 实际使用的赋能量(可能因上限被截断)
actualAmount := amount
err := s.repo.Transaction(func(txRepo EnergyStore, txFundRepo FundStore) error {
// 1. 检查帖子存在,获取作者
post, err := txRepo.FindPostByID(postID)
@ -42,14 +45,21 @@ func (s *EnergyService) Energize(energizerID uint, postID uint, amount int) (int
return common.ErrInsufficientEnergy
}
// 4. 检查单用户单文章赋能总量 ≤ MaxEnergizePerPost
// 4. 检查单用户单文章赋能上限,超出部分截断(类似每日经验溢出截断)
total, err := txRepo.SumUserPostEnergy(energizerID, postID)
if err != nil {
return fmt.Errorf("查询赋能记录失败: %w", err)
}
if total+amount > MaxEnergizePerPost {
room := MaxEnergizePerPost - total
if room <= 0 {
return common.ErrEnergizeLimitReached
}
if amount > room {
actualAmount = room // 截断到剩余空间
}
// 重新赋值 amount 用于后续扣域能、写日志等
amount = actualAmount
// 5. 扣赋能者域能
if err := txRepo.AddEnergy(energizerID, -amount); err != nil {
@ -155,14 +165,14 @@ func (s *EnergyService) Energize(energizerID uint, postID uint, amount int) (int
return nil
})
if err != nil {
return 0, err
return 0, 0, err
}
// 12. 事务提交后,在事务外增加经验 + 更新每日汇总
if needExpUpdate {
if s.expSvc != nil {
if _, _, err := s.expSvc.AddExp(energizerID, expGained); err != nil {
return expGained, fmt.Errorf("赋能成功,但增加经验失败: %w", err)
return expGained, actualAmount, fmt.Errorf("赋能成功,但增加经验失败: %w", err)
}
}
newSummary := &model.DailyExpSummary{
@ -171,9 +181,9 @@ func (s *EnergyService) Energize(energizerID uint, postID uint, amount int) (int
ExpEarned: expTotal,
}
if err := s.repo.UpsertDailyExp(newSummary); err != nil {
return expGained, fmt.Errorf("赋能成功,但更新经验汇总失败: %w", err)
return expGained, actualAmount, fmt.Errorf("赋能成功,但更新经验汇总失败: %w", err)
}
}
return expGained, nil
return expGained, actualAmount, nil
}

View File

@ -8,7 +8,8 @@ import (
)
// GetEnergyInfo 获取用户域能余额和每日赋能经验状态
func (s *EnergyService) GetEnergyInfo(userID uint) (*EnergyInfo, error) {
// postID 为 0 时不查文章赋能总量
func (s *EnergyService) GetEnergyInfo(userID uint, postID uint) (*EnergyInfo, error) {
user, err := s.repo.FindUserByID(userID)
if err != nil {
return nil, err
@ -24,12 +25,21 @@ func (s *EnergyService) GetEnergyInfo(userID uint) (*EnergyInfo, error) {
dailyExp = summary.ExpEarned
}
return &EnergyInfo{
info := &EnergyInfo{
Energy: user.Energy,
DailyEnergizeExp: dailyExp,
DailyExpCap: DailyEnergizeExpCap,
DailyExpCapReached: dailyExp >= DailyEnergizeExpCap,
}, nil
}
if postID != 0 {
total, err := s.repo.SumUserPostEnergy(userID, postID)
if err == nil {
info.PostEnergizeTotal = total
}
}
return info, nil
}
// GetEnergyLogs 分页查询用户域能流水(近 N 天)

View File

@ -30,10 +30,11 @@ func (s *EnergyService) SetFundRepo(repo FundStore) {
// EnergyInfo 域能信息(含每日赋能经验状态)
type EnergyInfo struct {
Energy int // 域能余额乘10存储
DailyEnergizeExp int // 当日已通过赋能获得的经验值
DailyExpCap int // 每日赋能经验上限
DailyExpCapReached bool // 是否已达上限
Energy int // 域能余额乘10存储
DailyEnergizeExp int // 当日已通过赋能获得的经验值
DailyExpCap int // 每日赋能经验上限
DailyExpCapReached bool // 是否已达上限
PostEnergizeTotal int // 当前文章已赋能量DB乘10仅在传 postID 时返回)
}
// 域能相关常量乘10存储DB值

View File

@ -47,9 +47,9 @@ func countUIDs(uids string) int {
func categoryTypes(category string) []string {
switch category {
case "system":
return []string{model.NotifyAuditApproved, model.NotifyAuditRejected, model.NotifyPostApproved, model.NotifyPostRejected, model.NotifyLevelUp}
return []string{model.NotifyAuditApproved, model.NotifyAuditRejected, model.NotifyPostApproved, model.NotifyPostRejected, model.NotifyPostLocked, model.NotifyPostUnlocked, model.NotifyLevelUp}
case "mention":
return []string{model.NotifyComment, model.NotifyCommentReply}
return []string{model.NotifyComment, model.NotifyCommentReply, model.NotifyCommentMention}
case "like":
return []string{model.NotifyLikeAggregated}
case "follow":
@ -111,6 +111,24 @@ func (s *NotificationService) NotifyPostRejected(userID uint, postTitle, reason
_ = s.Create(userID, model.NotifyPostRejected, title, content, &postID, nil)
}
// NotifyPostLocked 稿件锁定通知
func (s *NotificationService) NotifyPostLocked(userID uint, postTitle, reason string, postID uint) {
title := "稿件被锁定"
content := "你的稿件《" + postTitle + "》已被锁定"
if reason != "" {
content += ",理由:" + reason
}
_ = s.Create(userID, model.NotifyPostLocked, title, content, &postID, nil)
}
// NotifyPostUnlocked 稿件解除锁定通知
func (s *NotificationService) NotifyPostUnlocked(userID uint, postTitle string, postID uint) {
_ = s.Create(userID, model.NotifyPostUnlocked,
"稿件被解除锁定",
"你的稿件《"+postTitle+"》已被解除锁定",
&postID, nil)
}
// NotifyFollow 关注通知followerID 关注了 followeeID
func (s *NotificationService) NotifyFollow(followerID, followeeID uint) {
// 获取关注者用户名

View File

@ -155,18 +155,22 @@ func (s *PostService) Reject(postID uint, reason string) error {
}
// Lock 锁定:设置 IsLocked 标志,禁止编辑,不改变帖子发布状态
// 锁定状态独立于审核状态,任何状态的帖子均可锁定
func (s *PostService) Lock(postID uint, reason string) error {
post, err := s.findPost(postID)
if err != nil {
return err
}
// 待审核帖子不允许锁定(审核流程中)
if post.Status == model.PostStatusPending {
return common.ErrPostCannotLock
}
post.IsLocked = true
post.LockReason = reason
return s.repo.Update(post)
if err := s.repo.Update(post); err != nil {
return err
}
// 通知作者稿件被锁定
if s.notifier != nil {
s.notifier.NotifyPostLocked(post.UserID, post.Title, reason, post.ID)
}
return nil
}
// Unlock 解锁:清除 IsLocked 标志,恢复可编辑
@ -180,7 +184,14 @@ func (s *PostService) Unlock(postID uint) error {
}
post.IsLocked = false
post.LockReason = ""
return s.repo.Update(post)
if err := s.repo.Update(post); err != nil {
return err
}
// 通知作者稿件被解除锁定
if s.notifier != nil {
s.notifier.NotifyPostUnlocked(post.UserID, post.Title, post.ID)
}
return nil
}
// Restore 恢复软删除

View File

@ -132,11 +132,12 @@ type StudioOverview struct {
PendingCount int64 `json:"pending_count"`
ApprovedCount int64 `json:"approved_count"`
RejectedCount int64 `json:"rejected_count"`
TotalViews int64 `json:"total_views"`
}
// GetOverview 获取创作中心数据概览(单次 GROUP BY 查询)
func (s *PostService) GetOverview(userID uint) (*StudioOverview, error) {
totalPosts, draftCount, pendingCount, approvedCount, rejectedCount, err := s.repo.GetOverviewByUserID(userID)
totalPosts, draftCount, pendingCount, approvedCount, rejectedCount, totalViews, err := s.repo.GetOverviewByUserID(userID)
if err != nil {
return nil, err
}
@ -146,6 +147,7 @@ func (s *PostService) GetOverview(userID uint) (*StudioOverview, error) {
PendingCount: pendingCount,
ApprovedCount: approvedCount,
RejectedCount: rejectedCount,
TotalViews: totalViews,
}, nil
}

View File

@ -37,7 +37,7 @@ type postStore interface {
FindPageable(keyword string, offset, limit int) ([]model.Post, int64, error)
FindAdminPageable(keyword, status string, offset, limit int) ([]model.Post, int64, error)
FindByUserIDAndStatus(userID uint, status string, offset, limit int) ([]model.Post, int64, error)
GetOverviewByUserID(userID uint) (totalPosts, draftCount, pendingCount, approvedCount, rejectedCount int64, err error)
GetOverviewByUserID(userID uint) (totalPosts, draftCount, pendingCount, approvedCount, rejectedCount, totalViews int64, err error)
FindPendingRevisions(keyword string, offset, limit int) ([]model.Post, int64, error)
Update(post *model.Post) error
SoftDelete(id uint) error
@ -75,6 +75,8 @@ type userExpReader interface {
type postNotifier interface {
NotifyPostApproved(userID uint, postTitle string, postID uint)
NotifyPostRejected(userID uint, postTitle, reason string, postID uint)
NotifyPostLocked(userID uint, postTitle, reason string, postID uint)
NotifyPostUnlocked(userID uint, postTitle string, postID uint)
}
// userLevelStore LevelService 所需的用户仓储接口
@ -153,12 +155,14 @@ type commentStore interface {
CreateMention(mention *model.CommentMention) error
FindPostIDByComment(commentID uint) (uint, error)
FindPostAuthorID(postID uint) (uint, error)
SearchUsersByLevel(keyword string, minLevel, limit int) ([]struct {
SearchUsersByLevel(keyword string, minLevel, limit int, followerUID uint) ([]struct {
UID uint
Username string
Avatar string
Exp int
}, error)
GetUsernameByID(userID uint) (string, error)
LoadMentionsForComments(comments []model.Comment) error
ListAllComments(keyword string, showDeleted bool, offset, limit int) ([]model.AdminCommentRow, int64, error)
}
@ -229,3 +233,8 @@ type LikeTrendStore interface {
type FavoriteTrendStore interface {
AggregateFavoritesByAuthor(userID uint, since string) ([]TrendPoint, error)
}
// ViewTrendStore 阅读量趋势聚合ISP
type ViewTrendStore interface {
AggregateViewsByAuthor(userID uint, since string) ([]TrendPoint, error)
}

View File

@ -12,6 +12,7 @@ type TrendResult struct {
Comments []TrendPoint `json:"comments"`
Likes []TrendPoint `json:"likes"`
Favorites []TrendPoint `json:"favorites"`
Views []TrendPoint `json:"views"`
}
// TrendsService Studio 趋势分析服务
@ -20,15 +21,17 @@ type TrendsService struct {
commentRepo CommentTrendStore
likeRepo LikeTrendStore
favoriteRepo FavoriteTrendStore
viewRepo ViewTrendStore
}
// NewTrendsService 构造函数
func NewTrendsService(energyRepo EnergyTrendStore, commentRepo CommentTrendStore, likeRepo LikeTrendStore, favoriteRepo FavoriteTrendStore) *TrendsService {
func NewTrendsService(energyRepo EnergyTrendStore, commentRepo CommentTrendStore, likeRepo LikeTrendStore, favoriteRepo FavoriteTrendStore, viewRepo ViewTrendStore) *TrendsService {
return &TrendsService{
energyRepo: energyRepo,
commentRepo: commentRepo,
likeRepo: likeRepo,
favoriteRepo: favoriteRepo,
viewRepo: viewRepo,
}
}
@ -73,6 +76,15 @@ func (s *TrendsService) GetTrends(userID uint, since string) (*TrendResult, erro
return nil
})
g.Go(func() error {
var err error
result.Views, err = s.viewRepo.AggregateViewsByAuthor(userID, since)
if err != nil {
return fmt.Errorf("查询阅读趋势: %w", err)
}
return nil
})
if err := g.Wait(); err != nil {
return nil, err
}

View File

@ -42,7 +42,6 @@
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>
</button>
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}" nonce="{{.CSPNonce}}"></script>
<script src="/static/js/login.js?v={{assetV "/static/js/login.js"}}" nonce="{{.CSPNonce}}"></script>
</body>
</html>

View File

@ -89,7 +89,6 @@
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>
</button>
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}" nonce="{{.CSPNonce}}"></script>
<script src="/static/js/register.js?v={{assetV "/static/js/register.js"}}" nonce="{{.CSPNonce}}"></script>
</body>
</html>

View File

@ -65,9 +65,8 @@
{{if .IsLoggedIn}}
{{if .Avatar}}
<img src="{{.Avatar}}" alt="{{.Username}}" class="user-avatar-img">
<div class="user-avatar-fallback" style="display:none">{{substr .Username 0 1}}</div>
{{else}}
<div class="user-avatar-fallback">{{substr .Username 0 1}}</div>
<div class="user-avatar-fallback"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg></div>
{{end}}
<h3 class="user-card-name">{{.Username}}</h3>
<p class="user-card-desc">欢迎加入 MetaLab</p>
@ -117,6 +116,5 @@
{{template "layout/footer.html" .}}
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}" nonce="{{.CSPNonce}}"></script>
</body>
</html>

View File

@ -3,3 +3,4 @@
<p class="copyright">&copy; {{.CopyrightStart}}-2026 MetaLab{{if .Framework}} | {{.Framework}}{{end}}{{if .CanAccessAdmin}} | <a href="/admin">管理面板</a>{{end}}</p>
</div>
</footer>
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}" nonce="{{.CSPNonce}}"></script>

View File

@ -21,9 +21,8 @@
<a href="/space" class="nav-avatar-link">
{{if .Avatar}}
<img src="{{.Avatar}}" alt="" class="nav-avatar">
<span class="nav-avatar nav-avatar-default" style="display:none">{{substr .Username 0 1}}</span>
{{else}}
<span class="nav-avatar nav-avatar-default">{{substr .Username 0 1}}</span>
<span class="nav-avatar nav-avatar-default"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg></span>
{{end}}
</a>
<div class="nav-dropdown">
@ -31,9 +30,8 @@
<div class="nav-dropdown-avatar">
{{if .Avatar}}
<img src="{{.Avatar}}" alt="">
<span style="display:none">{{substr .Username 0 1}}</span>
{{else}}
<span>{{substr .Username 0 1}}</span>
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>
{{end}}
</div>
<div class="nav-dropdown-meta">

View File

@ -97,8 +97,16 @@
{{else}}
{{$link = printf "/posts/%d#comments" (derefUint $m.RelatedID)}}
{{end}}
{{else if eq $m.NotifyType "comment_mention"}}
{{if $m.CommentID}}
{{$link = printf "/posts/%d#mention-%d" (derefUint $m.RelatedID) (derefUint $m.CommentID)}}
{{else}}
{{$link = printf "/posts/%d#comments" (derefUint $m.RelatedID)}}
{{end}}
{{else if or (eq $m.NotifyType "audit_approved") (eq $m.NotifyType "audit_rejected")}}
{{$link = "/settings"}}
{{else if eq $m.NotifyType "follow"}}
{{$link = printf "/space/%d" (derefUint $m.RelatedID)}}
{{end}}
{{if $link}}
<a href="{{$link}}" class="msg-card-link">
@ -262,6 +270,5 @@
}
})();
</script>
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}" nonce="{{.CSPNonce}}"></script>
</body>
</html>

View File

@ -17,6 +17,5 @@
</div>
</div>
{{template "layout/footer.html" .}}
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}" nonce="{{.CSPNonce}}"></script>
</body>
</html>

View File

@ -50,6 +50,5 @@
{{template "layout/footer.html" .}}
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}" nonce="{{.CSPNonce}}"></script>
</body>
</html>

View File

@ -8,13 +8,18 @@
<header class="post-detail-header">
<h1 class="post-detail-title">{{.Post.Title}}</h1>
<div class="post-detail-meta">
<span class="post-author">{{if .Post.AuthorName}}{{.Post.AuthorName}}{{else}}该用户已注销{{end}}</span>
<span class="post-time">{{.Post.CreatedAt.Format "2006-01-02 15:04"}}</span>
{{if ne .Post.Status "approved"}}
<span class="post-status post-status-{{.Post.Status}}">{{index $.StatusNames .Post.Status}}</span>
{{if .Post.AuthorName}}
<span>作者:<a href="/space/{{.Post.UserID}}" class="post-meta-author">{{.Post.AuthorName}}</a></span>
{{else}}
<span>作者:该用户已注销</span>
{{end}}
{{if .Post.IsLocked}}
<span class="post-status post-status-locked">已锁定</span>
<span>阅读:{{.Post.ViewsCount}}</span>
<span>点赞:{{.Post.LikesCount}}</span>
<span>收藏:{{.Post.FavoritesCount}}</span>
<span>评论:{{.Post.CommentsCount}}</span>
<span>发布时间:{{.Post.CreatedAt.Format "2006-01-02 15:04"}}</span>
{{if and .Post.IsLocked (eq $.Post.UserID $.UID)}}
<span class="post-status post-status-locked">已锁定{{if .Post.LockReason}}{{.Post.LockReason}}{{end}}</span>
{{end}}
{{if and .Post.PendingBody (eq $.Post.UserID $.UID)}}
<span class="post-status post-status-revision">修订审核中</span>
@ -31,18 +36,37 @@
<div class="post-detail-body" id="postContent"></div>
</article>
<!-- 赞/踩/收藏按钮 -->
<div class="post-reactions" id="postReactions" data-post-id="{{.Post.ID}}">
<button class="reaction-btn reaction-like-btn" id="likeBtn" title="点赞">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3H14zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/></svg>
<span class="reaction-like-count" id="likesCount">{{.Post.LikesCount}}</span>
<!-- 右下角悬浮操作栏 -->
<div class="post-float-bar" id="postFloatBar" data-post-id="{{.Post.ID}}">
<button class="float-btn float-like-btn" id="likeBtn" title="点赞">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3H14zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/></svg>
<span class="float-count" id="likesCount">{{.Post.LikesCount}}</span>
</button>
<button class="reaction-btn reaction-dislike-btn" id="dislikeBtn" title="踩">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><path d="M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3H10zM17 2h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17"/></svg>
<button class="float-btn float-dislike-btn" id="dislikeBtn" title="踩">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3H10zM17 2h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17"/></svg>
</button>
<button class="reaction-btn reaction-fav-btn" id="favBtn" title="收藏">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>
<span class="reaction-like-count" id="favCount">{{.Post.FavoritesCount}}</span>
{{if .IsLoggedIn}}
<button class="float-btn float-energize-btn" id="energizeBtn" data-id="{{.Post.ID}}" data-uid="{{$.UID}}" data-author-uid="{{.Post.UserID}}" title="赋能">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
</button>
<button class="float-btn float-fav-btn" id="favBtn" title="收藏">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>
<span class="float-count" id="favCount">{{.Post.FavoritesCount}}</span>
</button>
{{else}}
<button class="float-btn float-energize-btn" onclick="location.href='/auth/login?redirect=/posts/{{.Post.ID}}'" title="赋能">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
</button>
<button class="float-btn float-fav-btn" onclick="location.href='/auth/login?redirect=/posts/{{.Post.ID}}'" title="收藏">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>
<span class="float-count">0</span>
</button>
{{end}}
<button class="float-btn float-comment-btn" id="floatCommentBtn" title="评论">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
</button>
<button class="float-btn float-top-btn" id="floatTopBtn" title="回顶部" style="display:none">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="18 15 12 9 6 15"/></svg>
</button>
</div>
@ -54,9 +78,6 @@
{{if and $.Post (eq $.Post.UserID $.UID) (or (eq $.Post.Status "draft") (eq $.Post.Status "rejected")) (not $.Post.IsLocked)}}
<button class="btn btn-primary" id="submitAuditBtn" data-id="{{$.Post.ID}}">提交审核</button>
{{end}}
{{if and $.Post (ne $.Post.UserID $.UID)}}
<button class="btn btn-primary" id="energizeBtn" data-id="{{$.Post.ID}}">赋能</button>
{{end}}
</div>
<!-- 赋能弹窗 -->
@ -147,12 +168,48 @@
<link rel="stylesheet" href="/static/vditor/dist/index.css?v={{assetV "/static/vditor/dist/index.css"}}">
<script src="/static/vditor/dist/method.min.js?v={{assetV "/static/vditor/dist/method.min.js"}}" nonce="{{.CSPNonce}}"></script>
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}" nonce="{{.CSPNonce}}"></script>
<script src="/static/js/shortcode.js?v={{assetV "/static/js/shortcode.js"}}" nonce="{{.CSPNonce}}"></script>
<script src="/static/js/post-view.js?v={{assetV "/static/js/post-view.js"}}" nonce="{{.CSPNonce}}"></script>
<script src="/static/js/post-energize.js?v={{assetV "/static/js/post-energize.js"}}" nonce="{{.CSPNonce}}"></script>
<script src="/static/js/post-reactions.js?v={{assetV "/static/js/post-reactions.js"}}" nonce="{{.CSPNonce}}"></script>
<script src="/static/js/post-favorite.js?v={{assetV "/static/js/post-favorite.js"}}" nonce="{{.CSPNonce}}"></script>
<script src="/static/js/post-comments.js?v={{assetV "/static/js/post-comments.js"}}" nonce="{{.CSPNonce}}"></script>
<script nonce="{{.CSPNonce}}">
(function() {
var topBtn = document.getElementById('floatTopBtn');
var commentBtn = document.getElementById('floatCommentBtn');
var comments = document.getElementById('comments');
var nav = document.querySelector('nav');
var navHeight = nav ? nav.offsetHeight : 60;
// 回顶部:页面顶部时隐藏按钮
function toggleTopBtn() {
if (!topBtn) return;
if (window.scrollY > 300) {
topBtn.style.display = '';
} else {
topBtn.style.display = 'none';
}
}
window.addEventListener('scroll', toggleTopBtn);
toggleTopBtn();
if (topBtn) {
topBtn.addEventListener('click', function() {
window.scrollTo({ top: 0, behavior: 'smooth' });
});
}
// 评论按钮滚动到评论区域顶部NAV 下方)
if (commentBtn && comments) {
commentBtn.addEventListener('click', function() {
var rect = comments.getBoundingClientRect();
var scrollTop = window.scrollY + rect.top - navHeight - 12;
window.scrollTo({ top: scrollTop, behavior: 'smooth' });
});
}
})();
</script>
</body>
</html>

View File

@ -79,7 +79,7 @@
<div class="info-row">
<span class="info-label">用户名</span>
<span class="info-value">
<input type="text" id="usernameInput" class="settings-text-input" value="{{.User.Username}}" maxlength="16" autocomplete="off">
<input type="text" id="usernameInput" class="settings-text-input" value="{{.User.Username}}" maxlength="16" autocomplete="off" data-has-completed-rename="{{.HasCompletedRename}}">
<span class="char-counter" id="usernameCounter"></span>
</span>
</div>
@ -328,7 +328,6 @@
</div>
</div>
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}" nonce="{{.CSPNonce}}"></script>
<script nonce="{{.CSPNonce}}">
(function () {
'use strict';
@ -375,6 +374,7 @@
// ---- 原始值(用于判断是否变更) ----
var origUsername = usernameInput.value.trim();
var origBio = bioInput.value;
var hasCompletedRename = usernameInput.getAttribute('data-has-completed-rename') === 'true';
// 与后端一致的合法字符集:中文、英文大小写、数字、下划线、连字符
var usernameRe = /^[\u4e00-\u9fffa-zA-Z0-9_-]+$/;
@ -409,6 +409,21 @@
return;
}
// 用户名有变更时弹出消耗确认
if (username !== origUsername) {
var confirmMsg = hasCompletedRename
? '确认修改用户名?\n\n本次将消耗 6 域能,是否继续?'
: '确认修改用户名?\n\n首次更名无需消耗域能。';
showConfirm('修改用户名', confirmMsg).then(function(ok) {
if (ok) doSave(username, bio);
});
return;
}
doSave(username, bio);
});
function doSave(username, bio) {
btn.disabled = true;
btn.textContent = '保存中...';
@ -434,7 +449,7 @@
}
};
xhr.send(JSON.stringify({ username: username, bio: bio }));
});
}
// ---- 头像裁切上传 ----
var avatarWrap = document.getElementById('avatarWrap');

View File

@ -22,7 +22,7 @@
{{if .SpaceUser.Avatar}}
<img src="{{.SpaceUser.Avatar}}" alt="{{.SpaceUser.Username}}" class="space-avatar-img">
{{else}}
<div class="space-avatar-placeholder">{{slice .SpaceUser.Username 0 1}}</div>
<div class="space-avatar-placeholder"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg></div>
{{end}}
</a>
<div class="space-header-info">
@ -35,10 +35,8 @@
<!-- 右:操作按钮 -->
<div class="space-header-actions">
{{if .IsOwnSpace}}
<!-- 本人无关注按钮 -->
{{else}}
<button id="space-follow-btn" class="space-action-btn primary" data-uid="{{.SpaceUser.ID}}">+ 关注</button>
<button id="space-follow-btn" class="space-action-btn primary" data-uid="{{.SpaceUser.ID}}" data-current-uid="{{.CurrentUID}}">+ 关注</button>
{{if not .IsOwnSpace}}
<button class="space-action-btn">发消息</button>
<button class="space-action-btn icon-only" title="更多"></button>
{{end}}
@ -225,7 +223,7 @@
{{if $item.FolloweeAvatar}}
<img src="{{$item.FolloweeAvatar}}" alt="{{$item.FolloweeUsername}}" class="space-user-avatar-img">
{{else}}
<div class="space-user-avatar-placeholder">{{slice $item.FolloweeUsername 0 1}}</div>
<div class="space-user-avatar-placeholder"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg></div>
{{end}}
</a>
<div class="space-user-info">
@ -293,7 +291,7 @@
{{if $item.FollowerAvatar}}
<img src="{{$item.FollowerAvatar}}" alt="{{$item.FollowerUsername}}" class="space-user-avatar-img">
{{else}}
<div class="space-user-avatar-placeholder">{{slice $item.FollowerUsername 0 1}}</div>
<div class="space-user-avatar-placeholder"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg></div>
{{end}}
</a>
<div class="space-user-info">
@ -576,12 +574,60 @@
{{template "layout/footer.html" .}}
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}" nonce="{{.CSPNonce}}"></script>
<!-- 访客空间:关注按钮交互 -->
{{if and (not .IsOwnSpace) (or (eq .ActiveTab "following") (eq .ActiveTab "followers"))}}
<!-- 关注按钮交互 -->
<!-- 顶部关注按钮(所有标签页通用) -->
<script nonce="{{.CSPNonce}}">
(function() {
var topFollowBtn = document.getElementById('space-follow-btn');
if (!topFollowBtn) return;
var topUid = topFollowBtn.getAttribute('data-uid');
var currentUID = parseInt(topFollowBtn.getAttribute('data-current-uid')) || 0;
if (currentUID && parseInt(topUid) !== currentUID) {
fetch('/api/users/' + topUid + '/follow-status')
.then(function(res) { return res.json(); })
.then(function(data) {
if (data && data.success && data.data) {
var s = data.data;
topFollowBtn.textContent = (s.i_follow ? (s.they_follow ? '已互粉' : '已关注') : (s.they_follow ? '回关' : '+ 关注'));
if (s.i_follow) topFollowBtn.classList.add('followed');
}
});
}
topFollowBtn.addEventListener('click', function() {
if (currentUID && parseInt(topUid) === currentUID) {
showToast('不能关注自己', 'warning');
return;
}
if (!currentUID) {
window.location.href = '/auth/login?redirect=/space/' + topUid;
return;
}
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/users/' + topUid + '/follow', true);
xhr.onreadystatechange = function() {
if (xhr.readyState === 4 && xhr.status === 200) {
try {
var resp = JSON.parse(xhr.responseText);
if (resp.data && resp.data.status) {
var st = resp.data.status;
topFollowBtn.textContent = (st.i_follow ? (st.they_follow ? '已互粉' : '已关注') : (st.they_follow ? '回关' : '+ 关注'));
topFollowBtn.classList.toggle('followed', st.i_follow);
}
} catch(e){}
}
};
xhr.send();
});
})();
</script>
{{if or (eq .ActiveTab "following") (eq .ActiveTab "followers")}}
<script nonce="{{.CSPNonce}}">
(function() {
var currentUID = {{.CurrentUID}} || 0;
var cards = document.querySelectorAll('.space-user-card');
if (!cards.length) return;
@ -607,7 +653,7 @@
if (state === 'following' || state === 'mutual') btn.classList.add('followed');
}
// 批量获取并更新每个卡片关注状态
// 批量获取并更新每个卡片关注状态(仅非自己)
cards.forEach(function(card) {
var btn = card.querySelector('.space-follow-btn');
if (!btn || btn.disabled) return;
@ -620,6 +666,10 @@
});
btn.addEventListener('click', function() {
if (currentUID && parseInt(targetUid) === currentUID) {
showToast('不能关注自己');
return;
}
btn.classList.add('waiting');
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/users/' + targetUid + '/follow', true);
@ -634,44 +684,6 @@
xhr.send();
});
});
// 顶部关注按钮
var topFollowBtn = document.getElementById('space-follow-btn');
if (topFollowBtn) {
var topUid = topFollowBtn.getAttribute('data-uid');
fetch('/api/users/' + topUid + '/follow-status')
.then(function(res) { return res.json(); })
.then(function(data) {
if (data && data.success && data.data) {
var s = data.data;
topFollowBtn.textContent = (s.i_follow ? (s.they_follow ? '已互粉' : '已关注') : (s.they_follow ? '回关' : '+ 关注'));
if (s.i_follow) topFollowBtn.classList.add('followed');
}
});
topFollowBtn.addEventListener('click', function() {
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/users/' + topUid + '/follow', true);
xhr.onreadystatechange = function() {
if (xhr.readyState === 4 && xhr.status === 200) {
try {
var resp = JSON.parse(xhr.responseText);
if (resp.data && resp.data.status) {
var st = resp.data.status;
topFollowBtn.textContent = (st.i_follow ? (st.they_follow ? '已互粉' : '已关注') : (st.they_follow ? '回关' : '+ 关注'));
topFollowBtn.classList.toggle('followed', st.i_follow);
// 同时更新下方卡片中的对应按钮
cards.forEach(function(c) {
if (c.getAttribute('data-uid') === topUid) {
var cb = c.querySelector('.space-follow-btn'); if(cb) updateBtn(cb, st);
}
});
}
} catch(e){}
}
};
xhr.send();
});
}
})();
</script>
{{end}}
@ -697,7 +709,7 @@ function createFolder() {
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: name, is_public: false})
}).then(function(r) { return r.json(); }).then(function(d) {
if (d.success) { location.reload(); } else { alert(d.message || '创建失败'); }
if (d.success) { location.reload(); } else { showToast(d.message || '创建失败', 'error'); }
});
}
@ -735,7 +747,7 @@ document.querySelectorAll('#privacy-follow-list, #privacy-follower-list').forEac
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({field: field, value: value})
}).then(function(r){return r.json()}).then(function(d){
if(!d.success) { this.checked=!value; alert(d.message||'保存失败'); }
if(!d.success) { this.checked=!value; showToast(d.message||'保存失败', 'error'); }
}.bind(this));
});
});

View File

@ -37,6 +37,13 @@
<span class="stat-card-label">草稿</span>
</div>
</div>
<div class="stat-card">
<div class="stat-card-icon">👁</div>
<div class="stat-card-body">
<span class="stat-card-value">{{.Overview.TotalViews}}</span>
<span class="stat-card-label">总阅读量</span>
</div>
</div>
</div>
<div class="studio-section">
@ -78,6 +85,13 @@
</div>
<div class="trends-loading" id="favoritesLoading">加载中...</div>
</div>
<div class="trends-card">
<h3 class="trends-card-title">阅读量</h3>
<div class="trends-chart-wrap">
<canvas id="viewsChart"></canvas>
</div>
<div class="trends-loading" id="viewsLoading">加载中...</div>
</div>
</div>
</div>
{{end}}
@ -98,7 +112,8 @@
energy: { border: '#f59e0b', bg: 'rgba(245, 158, 11, 0.1)' },
comments: { border: '#3b82f6', bg: 'rgba(59, 130, 246, 0.1)' },
likes: { border: '#ef4444', bg: 'rgba(239, 68, 68, 0.1)' },
favorites: { border: '#8b5cf6', bg: 'rgba(139, 92, 246, 0.1)' }
favorites: { border: '#8b5cf6', bg: 'rgba(139, 92, 246, 0.1)' },
views: { border: '#10b981', bg: 'rgba(16, 185, 129, 0.1)' }
};
function fillDates(points, days) {
@ -193,8 +208,9 @@
showLoading('commentsLoading');
showLoading('likesLoading');
showLoading('favoritesLoading');
showLoading('viewsLoading');
var canvasIds = ['energyChart', 'commentsChart', 'likesChart', 'favoritesChart'];
var canvasIds = ['energyChart', 'commentsChart', 'likesChart', 'favoritesChart', 'viewsChart'];
canvasIds.forEach(function (id) {
var canvas = document.getElementById(id);
canvas.style.display = 'none';
@ -217,7 +233,8 @@
{ id: 'energyChart', key: 'energy', color: chartColors.energy, label: '赋能值', loading: 'energyLoading' },
{ id: 'commentsChart', key: 'comments', color: chartColors.comments, label: '评论', loading: 'commentsLoading' },
{ id: 'likesChart', key: 'likes', color: chartColors.likes, label: '点赞', loading: 'likesLoading' },
{ id: 'favoritesChart', key: 'favorites', color: chartColors.favorites, label: '收藏', loading: 'favoritesLoading' }
{ id: 'favoritesChart', key: 'favorites', color: chartColors.favorites, label: '收藏', loading: 'favoritesLoading' },
{ id: 'viewsChart', key: 'views', color: chartColors.views, label: '阅读', loading: 'viewsLoading' }
];
maps.forEach(function (m) {

View File

@ -73,7 +73,7 @@ function submitPost(id) {
if (data.success) {
location.reload();
} else {
alert(data.message || '提交失败');
showToast(data.message || '提交失败', 'error');
}
});
});
@ -94,7 +94,7 @@ function deletePost(id) {
if (data.success) {
location.reload();
} else {
alert(data.message || '删除失败');
showToast(data.message || '删除失败', 'error');
}
});
});

View File

@ -52,6 +52,13 @@
<span class="stat-card-label">已退回</span>
</div>
</div>
<div class="stat-card">
<div class="stat-card-icon">👁</div>
<div class="stat-card-body">
<span class="stat-card-value">{{.Overview.TotalViews}}</span>
<span class="stat-card-label">总阅读量</span>
</div>
</div>
</div>
<!-- 状态分布 -->

View File

@ -94,7 +94,7 @@ function deletePost(id) {
if (data.success) {
location.reload();
} else {
alert(data.message || '删除失败');
showToast(data.message || '删除失败', 'error');
}
});
});

View File

@ -96,7 +96,6 @@
<link rel="stylesheet" href="/static/vditor/dist/index.css?v={{assetV "/static/vditor/dist/index.css"}}">
<script src="/static/vditor/dist/index.min.js?v={{assetV "/static/vditor/dist/index.min.js"}}" nonce="{{.CSPNonce}}"></script>
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}" nonce="{{.CSPNonce}}"></script>
<script src="/static/js/studio-editor.js?v={{assetV "/static/js/studio-editor.js"}}" nonce="{{.CSPNonce}}"></script>
</body>
</html>

View File

@ -230,11 +230,13 @@ header {
align-items: center;
justify-content: center;
background: var(--color-accent);
color: #fff;
font-size: .85rem;
font-weight: 600;
color: rgba(255,255,255,.75);
object-fit: unset;
}
.nav-avatar-default svg {
width: 55%;
height: 55%;
}
.nav-avatar-wrapper:hover .nav-avatar {
border-color: var(--color-accent);
@ -279,9 +281,11 @@ header {
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 1.1rem;
font-weight: 600;
color: rgba(255,255,255,.75);
}
.nav-dropdown-avatar svg {
width: 52%;
height: 52%;
}
.nav-dropdown-avatar img {

View File

@ -239,14 +239,15 @@
height: 64px;
border-radius: 50%;
background: linear-gradient(135deg, var(--color-accent), #2980b9);
color: #fff;
color: rgba(255,255,255,.75);
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto .75rem;
font-size: 1.6rem;
font-weight: 700;
text-transform: uppercase;
}
.user-avatar-fallback svg {
width: 55%;
height: 55%;
}
.guest-avatar {

View File

@ -97,6 +97,16 @@
color: #6b7280;
}
.post-detail-meta a {
color: #6b7280;
text-decoration: none;
}
.post-detail-meta a:hover {
color: #6366f1;
text-decoration: underline;
}
.post-detail-body {
font-size: 16px;
line-height: 1.8;
@ -917,6 +927,17 @@
color: #9ca3af;
}
.fav-active-tag {
font-size: 11px;
color: #f59e0b;
background: #fef3c7;
padding: 1px 6px;
border-radius: 4px;
font-weight: 500;
flex-shrink: 0;
margin-right: 4px;
}
.fav-folder-opt-count {
font-size: 12px;
color: #9ca3af;
@ -1075,6 +1096,12 @@
border-bottom: none;
}
/* @提及通知进入:持久半透明高亮 */
.comment-card.highlight-mention,
.comment-reply-card.highlight-mention {
background: rgba(255, 248, 225, 0.5) !important;
}
.comment-header {
display: flex;
justify-content: space-between;
@ -1106,6 +1133,18 @@
margin-right: 6px;
}
/* 有效@提及链接 */
.mention-link {
color: #6366f1;
text-decoration: none;
font-weight: 500;
}
.mention-link:hover {
text-decoration: underline;
color: #4f46e5;
}
.comment-actions {
display: flex;
gap: 16px;
@ -1217,6 +1256,9 @@
}
.mention-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
font-size: 13px;
cursor: pointer;
@ -1229,12 +1271,106 @@
color: #6366f1;
}
.mention-item .mention-username {
font-weight: 500;
.mention-item-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
background: #f3f4f6;
}
.mention-item .mention-uid {
.mention-item-avatar-placeholder {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
background: #e5e7eb;
flex-shrink: 0;
color: #9ca3af;
}
.mention-item-avatar-placeholder svg {
width: 60%;
height: 60%;
}
.mention-item .mention-username {
font-weight: 500;
flex: 1;
}
.mention-item .mention-level {
color: #9ca3af;
font-size: 11px;
margin-left: 8px;
flex-shrink: 0;
}
.mention-no-result {
padding: 12px 14px;
font-size: 13px;
color: #9ca3af;
text-align: center;
}
/* ========== 右下角悬浮操作栏 ========== */
.post-float-bar {
position: fixed;
right: 20px;
bottom: 120px;
display: flex;
flex-direction: column;
gap: 4px;
z-index: 100;
background: var(--color-surface, #fff);
border-radius: 12px;
box-shadow: 0 4px 24px rgba(0,0,0,.1);
padding: 6px;
}
.float-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border: none;
background: transparent;
border-radius: 8px;
cursor: pointer;
color: #6b7280;
transition: all .15s;
position: relative;
}
.float-btn:hover {
background: #f3f4f6;
color: #6366f1;
}
.float-btn.active {
color: #6366f1;
background: #eef2ff;
}
.float-btn svg {
width: 20px;
height: 20px;
}
.float-count {
font-size: 10px;
font-weight: 600;
line-height: 1;
margin-top: 2px;
color: inherit;
}
.float-top-btn {
border-top: 1px solid #f3f4f6;
border-radius: 0 0 8px 8px;
margin-top: 2px;
padding-top: 6px;
}

View File

@ -75,14 +75,16 @@
width: 56px; height: 56px;
border-radius: 50%;
background: rgba(255,255,255,.25);
color: #fff;
color: rgba(255,255,255,.75);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.4rem;
font-weight: 700;
border: 2px solid rgba(255,255,255,.4);
}
.space-avatar-placeholder svg {
width: 55%;
height: 55%;
}
.space-header-info {
color: #fff;
@ -377,9 +379,12 @@
}
.space-user-avatar-placeholder {
width: 48px; height: 48px; border-radius: 50%;
background: var(--color-accent); color: #fff;
background: var(--color-accent); color: rgba(255,255,255,.75);
display: flex; align-items: center; justify-content: center;
font-size: 1.15rem; font-weight: 700;
}
.space-user-avatar-placeholder svg {
width: 55%;
height: 55%;
}
.space-user-info { flex: 1; min-width: 0; }

View File

@ -4,7 +4,7 @@
var commentsSection = document.getElementById('comments');
if (!commentsSection) return;
var postId = commentsSection.closest('.container').querySelector('#postReactions').dataset.postId;
var postId = commentsSection.closest('.container').querySelector('#postFloatBar').dataset.postId;
var commentsList = document.getElementById('commentsList');
var commentsTotal = document.getElementById('commentsTotal');
var loadMoreBtn = document.getElementById('commentsLoadMore');
@ -14,13 +14,28 @@
var currentPage = 1;
var totalPages = 1;
// 渲染@提及:有效提及渲染为蓝色链接(显示当前用户名),无效提及保持纯文本
// mentions: { original_username: {uid, name} } — name为当前显示名用户改名后自动更新
function renderMentions(body, mentions) {
body = escapeHtml(body);
if (!mentions || Object.keys(mentions).length === 0) {
return body.replace(/@(\S{1,16})/g, '<span class="reply-to">@$1</span>');
}
return body.replace(/@(\S{1,16})/g, function(match, username) {
var info = mentions[username];
if (info && info.uid) {
return '<a class="mention-link" href="/space/' + info.uid + '">@' + escapeHtml(info.name) + '</a>';
}
return match;
});
}
function buildCommentCard(c) {
var time = c.created_at ? formatTime(c.created_at) : '';
var author = c.author_name ? escapeHtml(c.author_name) : '用户已注销';
var body = c.body || '';
body = escapeHtml(body);
body = body.replace(/@(\S{1,16})/g, '<span class="reply-to">@$1</span>');
body = renderMentions(body, c.mentions);
var html = '<div class="comment-card" data-id="' + c.id + '">' +
'<div class="comment-header">' +
@ -53,8 +68,8 @@
var time = r.created_at ? formatTime(r.created_at) : '';
var author = r.author_name ? escapeHtml(r.author_name) : '用户已注销';
var body = r.body || '';
body = escapeHtml(body);
body = body.replace(/@(\S{1,16})/g, '<span class="reply-to">@$1</span>');
body = renderMentions(body, r.mentions);
var prefix = '';
if (r.reply_to_name) {
@ -186,7 +201,7 @@
var input = document.getElementById('replyInput-' + id);
if (!input) return;
var body = input.value.trim();
if (!body) { alert('请输入回复内容'); return; }
if (!body) { showToast('请输入回复内容', 'warning'); return; }
btn.disabled = true;
api('POST', '/api/comments/' + id + '/reply', { body: body })
@ -221,12 +236,12 @@
}
updateTotalCount(1);
} else {
alert(d.message || '回复失败');
showToast(d.message || '回复失败', 'error');
}
})
.catch(function() {
btn.disabled = false;
alert('请求失败');
showToast('请求失败', 'error');
});
});
}
@ -236,17 +251,42 @@
var mentionPos = 0;
var searchTimer = null;
// 计算 textarea 中光标的近似像素位置(相对视口)
function getCursorPixelPos(textarea, cursorIdx) {
var text = textarea.value.substring(0, cursorIdx);
var lines = text.split('\n');
var lineCount = lines.length;
var lastLine = lines[lineCount - 1];
var style = window.getComputedStyle(textarea);
var lineHeight = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.5 || 21;
var paddingTop = parseFloat(style.paddingTop) || 0;
var paddingLeft = parseFloat(style.paddingLeft) || 0;
// 近似字符宽度 (中文字符约 2x 英文字符)
var charWidth = parseFloat(style.fontSize) * 0.6 || 8.4;
var rect = textarea.getBoundingClientRect();
var top = rect.top + paddingTop + (lineCount - 1) * lineHeight + lineHeight;
var left = rect.left + paddingLeft + lastLine.length * charWidth;
return { top: top, left: left, bottom: rect.bottom };
}
function bindMention(input) {
input.addEventListener('input', function() {
var val = input.value;
var cursor = input.selectionStart;
var atPos = val.lastIndexOf('@', cursor - 1);
// 检查 @ 前是否为空格或行首(合法位置)
if (atPos === -1 || (atPos > 0 && val[atPos - 1] !== ' ' && val[atPos - 1] !== '\n')) {
hideMention();
return;
}
var query = val.substring(atPos + 1, cursor);
if (query.length === 0 || query.includes(' ')) {
// 查询中包含空格 -> 隐藏下拉
if (query.includes(' ') || query.includes('\n')) {
hideMention();
return;
}
@ -255,6 +295,14 @@
mentionPos = atPos;
clearTimeout(searchTimer);
// 仅输入 @ 时,显示提示
if (query.length === 0) {
showEmptyHint(input);
return;
}
// 有查询内容时,延时搜索
searchTimer = setTimeout(function() {
fetch('/api/users/search?q=' + encodeURIComponent(query))
.then(function(r) { return r.json(); })
@ -262,7 +310,7 @@
if (d.success && d.data && d.data.length > 0) {
showMention(input, d.data, query);
} else {
hideMention();
showMention(input, [], query);
}
})
.catch(function() { hideMention(); });
@ -275,26 +323,64 @@
}
function showMention(input, users, query) {
var rect = input.getBoundingClientRect();
var dropdown = mentionDropdown;
dropdown.innerHTML = '';
dropdown.style.display = 'block';
dropdown.style.top = (rect.bottom + window.scrollY + 4) + 'px';
dropdown.style.left = (rect.left + window.scrollX) + 'px';
dropdown.style.width = Math.max(rect.width, 200) + 'px';
for (var i = 0; i < users.length; i++) {
(function(u) {
var item = document.createElement('div');
item.className = 'mention-item';
item.innerHTML = '<span class="mention-username">' + escapeHtml(u.username) + '</span><span class="mention-uid">' + escapeHtml(u.uid) + '</span>';
item.addEventListener('mousedown', function(e) {
e.preventDefault();
selectMention(u);
});
dropdown.appendChild(item);
})(users[i]);
if (users.length === 0) {
// 无匹配结果:显示提示(仅在有查询时)
if (query.length > 0) {
var emptyItem = document.createElement('div');
emptyItem.className = 'mention-no-result';
emptyItem.textContent = '未搜索到用户';
dropdown.appendChild(emptyItem);
}
} else {
for (var i = 0; i < users.length; i++) {
(function(u) {
var item = document.createElement('div');
item.className = 'mention-item';
var avatarHtml = u.avatar
? '<img class="mention-item-avatar" src="' + escapeHtml(u.avatar) + '" alt="" />'
: '<span class="mention-item-avatar mention-item-avatar-placeholder"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg></span>';
item.innerHTML = avatarHtml +
'<span class="mention-username">' + escapeHtml(u.username) + '</span>' +
'<span class="mention-level">LV' + u.level + '</span>';
item.addEventListener('mousedown', function(e) {
e.preventDefault();
selectMention(u);
});
dropdown.appendChild(item);
})(users[i]);
}
}
// 计算光标位置并定位(先渲染内容,再用 offsetHeight 定位)
var cursorIdx = input.selectionStart;
var pos = getCursorPixelPos(input, cursorIdx);
dropdown.style.display = 'block';
dropdown.style.width = '220px';
// 悬浮在光标上方(不遮挡输入内容)
var dropHeight = dropdown.offsetHeight || 60;
dropdown.style.top = (pos.top - dropHeight - 4 + window.scrollY) + 'px';
dropdown.style.left = Math.min(pos.left, window.innerWidth - 230) + 'px';
}
function showEmptyHint(input) {
var dropdown = mentionDropdown;
dropdown.innerHTML = '';
var hint = document.createElement('div');
hint.className = 'mention-no-result';
hint.textContent = '输入以查找用户';
dropdown.appendChild(hint);
var cursorIdx = input.selectionStart;
var pos = getCursorPixelPos(input, cursorIdx);
dropdown.style.display = 'block';
dropdown.style.width = '220px';
var dropHeight = dropdown.offsetHeight || 36;
dropdown.style.top = (pos.top - dropHeight - 4 + window.scrollY) + 'px';
dropdown.style.left = Math.min(pos.left, window.innerWidth - 230) + 'px';
}
function hideMention() {
@ -320,7 +406,7 @@
if (commentSubmitBtn) {
commentSubmitBtn.addEventListener('click', function() {
var body = commentInput.value.trim();
if (!body) { alert('请输入评论内容'); return; }
if (!body) { showToast('请输入评论内容', 'warning'); return; }
commentSubmitBtn.disabled = true;
api('POST', '/api/posts/' + postId + '/comments', { body: body })
@ -333,12 +419,12 @@
loadComments(currentPage);
updateTotalCount(1);
} else {
alert(d.message || '发表失败');
showToast(d.message || '发表失败', 'error');
}
})
.catch(function() {
commentSubmitBtn.disabled = false;
alert('请求失败');
showToast('请求失败', 'error');
});
});
@ -397,14 +483,14 @@
input.focus();
input.selectionStart = input.selectionEnd = start + imgUrl.length;
} else {
alert(d.message || '上传失败');
showToast(d.message || '上传失败', 'error');
}
})
.catch(function() {
uploadLabel.style.pointerEvents = 'auto';
uploadLabel.style.opacity = '1';
uploadLabel.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg> 图片';
alert('上传失败');
showToast('上传失败', 'error');
});
imageUploadInput.value = '';
@ -412,21 +498,45 @@
}
// ====== 评论高亮 ======
function highlightComment(commentId) {
function highlightComment(commentId, persistent) {
var card = commentsList.querySelector('.comment-card[data-id="' + commentId + '"]') ||
commentsList.querySelector('.comment-reply-card[data-id="' + commentId + '"]');
if (card) {
commentsList.insertBefore(card, commentsList.firstChild);
card.scrollIntoView({ behavior: 'smooth', block: 'start' });
card.style.background = '#fff8e1';
card.style.transition = 'background 1.5s';
setTimeout(function() { card.style.background = ''; }, 2000);
if (persistent) {
card.classList.add('highlight-mention');
} else {
card.style.background = '#fff8e1';
card.style.transition = 'background 1.5s';
setTimeout(function() { card.style.background = ''; }, 2000);
}
return true;
}
return false;
}
var hash = window.location.hash;
// @提及通知进入:置顶 + 持久高亮
var mentionMatch = hash.match(/^#mention-(\d+)$/);
if (mentionMatch) {
var mentionCommentId = mentionMatch[1];
var mentionInterval = setInterval(function() {
if (highlightComment(mentionCommentId, true)) {
clearInterval(mentionInterval);
}
}, 300);
setTimeout(function() {
clearInterval(mentionInterval);
if (!commentsList.querySelector('.comment-card[data-id="' + mentionCommentId + '"]') &&
!commentsList.querySelector('.comment-reply-card[data-id="' + mentionCommentId + '"]')) {
showToast('该评论已不存在');
}
}, 3000);
}
// 普通评论/回复链接:短暂高亮
var commentMatch = hash.match(/^#comment-(\d+)$/);
if (commentMatch) {
var targetCommentId = commentMatch[1];

View File

@ -9,6 +9,8 @@
if (!btn || !overlay) return;
var postId = btn.dataset.id;
var isAuthor = btn.dataset.uid && btn.dataset.authorUid &&
btn.dataset.uid === btn.dataset.authorUid;
function showOverlay() {
overlay.style.display = 'flex';
@ -18,18 +20,58 @@
capHint.style.display = 'none';
lightBtn.disabled = false;
heavyBtn.disabled = false;
heavyBtn.title = '';
heavyBtn.style.opacity = '';
lightBtn.title = '';
lightBtn.style.opacity = '';
}
function disableHeavy() {
heavyBtn.disabled = true;
heavyBtn.title = '已达单篇上限1/2';
heavyBtn.style.opacity = '0.4';
}
function disableAll() {
lightBtn.disabled = true;
heavyBtn.disabled = true;
lightBtn.title = '已达单篇上限2/2';
lightBtn.style.opacity = '0.4';
heavyBtn.title = '已达单篇上限2/2';
heavyBtn.style.opacity = '0.4';
}
btn.addEventListener('click', function() {
fetch('/api/energy/info')
if (isAuthor) {
showToast('无法给自己赋能', 'warning');
return;
}
btn.disabled = true;
fetch('/api/energy/info?post_id=' + postId)
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.success && d.data && d.data.daily_exp_cap_reached) {
btn.disabled = false;
if (!d.success || !d.data) {
showToast('获取状态失败,请稍后重试', 'error');
return;
}
if (d.data.daily_exp_cap_reached) {
capHint.style.display = 'block';
}
var total = d.data.post_energize_total || 0;
if (total >= 20) {
showToast('对该文章赋能已达上限', 'warning');
return;
}
if (total >= 10) {
disableHeavy();
}
showOverlay();
})
.catch(function() { showOverlay(); });
.catch(function() {
btn.disabled = false;
showToast('获取状态失败,请稍后重试', 'error');
});
});
closeBtn.addEventListener('click', hideOverlay);
@ -52,16 +94,16 @@
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.success) {
alert(d.data.message || '赋能成功!');
showToast(d.data && d.data.message ? d.data.message : '赋能成功!', 'success');
hideOverlay();
} else {
alert(d.message || '赋能失败');
showToast(d.message || '赋能失败', 'error');
lightBtn.disabled = false;
heavyBtn.disabled = false;
}
})
.catch(function() {
alert('请求失败');
showToast('请求失败', 'error');
lightBtn.disabled = false;
heavyBtn.disabled = false;
});

View File

@ -2,7 +2,7 @@
(function() {
var favBtn = document.getElementById('favBtn');
var favCountEl = document.getElementById('favCount');
var container = document.getElementById('postReactions');
var container = document.getElementById('postFloatBar');
var overlay = document.getElementById('favOverlay');
var closeBtn = document.getElementById('favClose');
var folderListEl = document.getElementById('favFolderCheckList');
@ -47,6 +47,7 @@
var isActive = currentFolderId === f.id;
html += '<div class="fav-folder-option' + (isActive ? ' active' : '') + '" data-id="' + f.id + '">'
+ '<span class="fav-folder-opt-name">' + escapeHtml(f.name) + (f.is_default ? ' <small>(默认)</small>' : '') + '</span>'
+ (isActive ? '<span class="fav-active-tag">已收藏</span>' : '')
+ '<span class="fav-folder-opt-count">' + (f.item_count || 0) + ' 篇</span>'
+ '</div>';
});
@ -110,8 +111,13 @@
updateBtn(true);
if (favCountEl) favCountEl.textContent = (parseInt(favCountEl.textContent) || 0) + 1;
hideModal();
} else if (d.message && d.message.indexOf('已在此收藏夹中') !== -1) {
// 后端返回已收藏,直接关闭弹窗(兜底)
currentFolderId = folderId;
updateBtn(true);
hideModal();
} else {
alert(d.message || '操作失败');
showToast(d.message || '操作失败', 'error');
}
})
.catch(function() { loading = false; });
@ -119,7 +125,7 @@
function removeFromFolder(folderId) {
if (loading) return;
showConfirm('确认', '确定取消收藏吗?').then(function(ok) {
showConfirm('确认取消收藏', '确定移出该收藏吗?').then(function(ok) {
if (!ok) return;
loading = true;
api('DELETE', '/api/folders/' + folderId + '/posts/' + postId)
@ -131,7 +137,7 @@
if (favCountEl) favCountEl.textContent = Math.max(0, (parseInt(favCountEl.textContent) || 0) - 1);
hideModal();
} else {
alert(d.message || '操作失败');
showToast(d.message || '操作失败', 'error');
}
})
.catch(function() { loading = false; });
@ -149,7 +155,7 @@
loading = false;
newBtn.disabled = false;
newBtn.textContent = '创建并收藏';
alert(d.message || '创建失败');
showToast(d.message || '创建失败', 'error');
return;
}
var newId = d.data && d.data.id;
@ -164,7 +170,7 @@
if (favCountEl) favCountEl.textContent = (parseInt(favCountEl.textContent) || 0) + 1;
hideModal();
} else {
alert(d2.message || '操作失败');
showToast(d2.message || '操作失败', 'error');
}
});
})
@ -194,7 +200,7 @@
if (!option) return;
var folderId = parseInt(option.getAttribute('data-id'));
if (isNaN(folderId)) return;
if (currentFolderId === folderId) {
if (option.classList.contains('active')) {
removeFromFolder(folderId);
} else {
addToFolder(folderId);
@ -206,8 +212,8 @@
if (newBtn && newInput) {
newBtn.addEventListener('click', function() {
var name = newInput.value.trim();
if (!name) { alert('请输入收藏夹名称'); return; }
if (name.length > 50) { alert('收藏夹名称不能超过 50 个字符'); return; }
if (!name) { showToast('请输入收藏夹名称', 'warning'); return; }
if (name.length > 50) { showToast('收藏夹名称不能超过 50 个字符', 'warning'); return; }
createAndAdd(name);
});
newInput.addEventListener('keydown', function(e) {

View File

@ -1,6 +1,6 @@
// 赞/踩系统
(function() {
var container = document.getElementById('postReactions');
var container = document.getElementById('postFloatBar');
var likeBtn = document.getElementById('likeBtn');
var dislikeBtn = document.getElementById('dislikeBtn');
var likesCountEl = document.getElementById('likesCount');
@ -41,7 +41,7 @@
if (d.success) {
updateUI(d.data.liked ? 'liked' : 'none', d.data.likes_count);
} else {
alert(d.message || '操作失败');
showToast(d.message || '操作失败', 'error');
}
})
.catch(function() { likeBtn.disabled = false; });
@ -63,7 +63,7 @@
if (d.success) {
updateUI(d.data.disliked ? 'disliked' : 'none');
} else {
alert(d.message || '操作失败');
showToast(d.message || '操作失败', 'error');
}
})
.catch(function() { dislikeBtn.disabled = false; });

View File

@ -41,9 +41,9 @@
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.success) { window.location.reload(); }
else { alert(d.message || '提交失败'); }
else { showToast(d.message || '提交失败', 'error'); }
})
.catch(function() { alert('请求失败'); });
.catch(function() { showToast('请求失败', 'error'); });
});
});
}

View File

@ -169,10 +169,10 @@ async function handleSubmit(e) {
try {
const postId = document.getElementById('postId')?.value
const title = document.getElementById('postTitle')?.value.trim()
if (!title) { alert('请输入标题'); return }
if (!title) { showToast('请输入标题', 'warning'); return }
const body = vditor ? vditor.getValue() : ''
if (!body.trim()) { alert('请输入正文'); return }
if (!body.trim()) { showToast('请输入正文', 'warning'); return }
const isEdit = !!postId
const url = isEdit ? '/api/studio/posts/' + postId : '/api/studio/posts'
@ -197,10 +197,10 @@ async function handleSubmit(e) {
window.location.href = '/studio/posts'
}
} else {
alert(data.message || '操作失败')
showToast(data.message || '操作失败', 'error')
}
} catch (err) {
alert('请求失败,请重试')
showToast('请求失败,请重试', 'error')
} finally {
submitting = false
if (submitBtn) {

View File

@ -86,7 +86,7 @@
</div>
<!-- 拒绝理由弹窗 -->
<div class="modal-overlay" id="rejectModal" style="display:none;">
<div class="modal-overlay" id="rejectModal">
<div class="modal-box">
<h3>拒绝审核</h3>
<p>请填写拒绝理由:</p>

View File

@ -7,19 +7,19 @@
<div class="admin-toolbar">
<input type="text" id="commentSearch" class="admin-search-input" placeholder="搜索评论内容、作者或文章标题..." />
<button id="commentSearchBtn" class="btn btn-secondary">搜索</button>
<button id="commentClearBtn" class="btn btn-secondary" style="display:none">清除</button>
<button id="commentClearBtn" class="btn btn-secondary admin-hidden">清除</button>
</div>
<div class="admin-table-wrap" id="commentTableWrap">
<table class="admin-table">
<thead>
<tr>
<th style="width:60px">ID</th>
<th style="width:100px">作者</th>
<th class="th-id">ID</th>
<th class="th-author">作者</th>
<th>评论内容</th>
<th style="width:160px">所属文章</th>
<th style="width:140px">时间</th>
<th style="width:100px">操作</th>
<th class="th-post">所属文章</th>
<th class="th-time">时间</th>
<th class="th-actions">操作</th>
</tr>
</thead>
<tbody id="commentTableBody"></tbody>
@ -27,7 +27,6 @@
</div>
<div class="admin-pagination" id="commentPagination"></div>
<div class="admin-loading" id="commentLoading" style="display:none">加载中...</div>
<div class="admin-loading admin-hidden" id="commentLoading">加载中...</div>
{{template "admin/layout/footer.html" .}}
<script src="/admin/static/js/comments.js?v={{assetV "/admin/static/js/comments.js"}}" nonce="{{.CSPNonce}}"></script>

View File

@ -14,7 +14,7 @@
<div class="stat-info">
<div class="stat-label">帖子总数</div>
<div class="stat-value">{{.PostTotal}}</div>
<div class="stat-desc">已发布 {{.PostApproved}}<span class="stat-badge badge-warn" style="margin-left: 12px;">待审核 {{.PostPending}}</span></div>
<div class="stat-desc">已发布 {{.PostApproved}}<span class="stat-badge badge-warn">待审核 {{.PostPending}}</span></div>
</div>
</div>
<div class="stat-card">
@ -23,8 +23,8 @@
</div>
</div>
<div class="stats-grid" style="margin-top: 1rem;">
<div class="stat-card" style="grid-column: span 2;">
<div class="stats-grid mt-grid">
<div class="stat-card span-2">
<div class="stat-icon {{if .StoreDegraded}}stat-red{{else if eq .StoreType "redis"}}stat-green{{else}}stat-blue{{end}}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>
</div>

View File

@ -5,7 +5,7 @@
</div>
<div class="filter-bar">
<label for="fund-log-type-filter" style="font-size:0.85rem;color:#4a5568;font-weight:500;">类型筛选:</label>
<label for="fund-log-type-filter">类型筛选:</label>
<select id="fund-log-type-filter">
<option value="">全部类型</option>
{{range $type, $name := .LogTypes}}<option value="{{$type}}">{{$name}}</option>{{end}}

View File

@ -5,7 +5,7 @@
</div>
<div class="filter-bar">
<label for="log-type-filter" style="font-size:0.85rem;color:#4a5568;font-weight:500;">类型筛选:</label>
<label for="log-type-filter">类型筛选:</label>
<select id="log-type-filter">
<option value="">全部类型</option>
{{range $type, $name := .LogTypes}}<option value="{{$type}}">{{$name}}</option>{{end}}

View File

@ -16,7 +16,7 @@
<a href="/admin" class="admin-logo">MetaLab <span>管理面板</span></a>
<div class="admin-header-right">
<span class="admin-user">{{.Username}}</span>
<a href="/" class="admin-back-btn"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="vertical-align:-2px;margin-right:3px;"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>返回前台</a>
<a href="/" class="admin-back-btn"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>返回前台</a>
</div>
</div>
</header>

View File

@ -57,7 +57,7 @@
<button class="btn btn-sm btn-success post-approve" data-id="{{.ID}}">通过修订</button>
<button class="btn btn-sm btn-warning post-reject" data-id="{{.ID}}">退回修订</button>
{{end}}
{{if and (not .IsLocked) (ne .Status "pending")}}
{{if not .IsLocked}}
<button class="btn btn-sm btn-danger post-lock" data-id="{{.ID}}">锁定</button>
{{end}}
{{if .IsLocked}}

View File

@ -136,12 +136,11 @@
grid-column: 1 / -1;
}
/* 拒绝弹窗 */
/* 拒绝弹窗(显示由 JS 控制,此处不设置 display */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 999;

View File

@ -0,0 +1,144 @@
/* ========== 管理后台评论管理 ========== */
/* --- 页面标题 --- */
.admin-page-header {
margin-bottom: 24px;
}
.admin-page-header h2 {
font-size: 22px;
font-weight: 600;
color: var(--text);
}
/* --- 工具栏/搜索 --- */
.admin-toolbar {
display: flex;
gap: 10px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.admin-search-input {
flex: 1;
min-width: 200px;
max-width: 400px;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 13px;
outline: none;
transition: border-color 0.2s;
}
.admin-search-input:focus {
border-color: var(--primary);
}
/* --- 表格 --- */
.admin-table-wrap {
background: var(--bg-card);
border-radius: var(--radius);
box-shadow: var(--shadow);
overflow-x: auto;
}
.admin-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.admin-table th {
text-align: left;
padding: 12px 16px;
background: #f9fafb;
color: var(--text-muted);
font-weight: 600;
font-size: 12px;
border-bottom: 2px solid var(--border);
white-space: nowrap;
}
.admin-table td {
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
font-size: 13px;
color: #374151;
vertical-align: middle;
}
.admin-table tbody tr:hover {
background: #f8f9ff;
}
/* 列宽 */
.th-id { width: 60px; }
.th-author { width: 100px; }
.th-post { width: 160px; }
.th-time { width: 140px; }
.th-actions { width: 100px; }
/* 表格空状态 / 加载失败 */
.admin-table-empty {
text-align: center;
padding: 40px;
color: #9ca3af;
}
.admin-table-error {
text-align: center;
padding: 40px;
color: #dc2626;
}
/* 评论内容列 */
.comment-body-cell {
max-width: 320px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* --- 分页 --- */
.admin-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 20px;
}
.admin-page-link {
padding: 6px 14px;
border: 1px solid var(--border);
background: #fff;
border-radius: 4px;
font-size: 13px;
cursor: pointer;
color: var(--text);
text-decoration: none;
transition: all 0.15s;
}
.admin-page-link:hover {
border-color: var(--primary);
color: var(--primary);
}
.admin-page-info {
padding: 6px 14px;
font-size: 13px;
color: var(--text-muted);
}
/* --- 加载状态 --- */
.admin-loading {
text-align: center;
padding: 20px;
color: var(--text-muted);
font-size: 13px;
}
/* --- 按钮适配 --- */
.btn-secondary {
background: #e5e7eb;
color: #374151;
}
.btn-secondary:hover {
background: #d1d5db;
}
/* --- 工具类 --- */
.admin-hidden {
display: none;
}

View File

@ -66,11 +66,12 @@ a:hover { color: var(--primary-hover); }
display: flex; align-items: center; gap: 16px;
}
.admin-user { color: #dfe6e9; font-size: 13px; }
.admin-back-btn {
.admin-back-btn {
font-size: 12px; color: #b2bec3;
padding: 4px 10px; border: 1px solid #636e72;
border-radius: 4px; transition: all 0.2s;
}
.admin-back-btn svg { vertical-align: -2px; margin-right: 3px; }
.admin-back-btn:hover { color: #fff; border-color: #fff; }
/* --- 整体布局 --- */
@ -123,6 +124,7 @@ a:hover { color: var(--primary-hover); }
gap: 16px;
margin-bottom: 24px;
}
.stats-grid.mt-grid { margin-top: 1rem; }
.stat-card {
background: var(--bg-card);
border-radius: var(--radius);
@ -130,6 +132,7 @@ a:hover { color: var(--primary-hover); }
box-shadow: var(--shadow);
display: flex; align-items: center; gap: 16px;
}
.stat-card.span-2 { grid-column: span 2; }
.stat-icon {
width: 44px; height: 44px; border-radius: 10px;
display: flex; align-items: center; justify-content: center;
@ -142,7 +145,7 @@ a:hover { color: var(--primary-hover); }
.stat-label { font-size: 12px; color: var(--text-muted); }
.stat-value { font-size: 22px; font-weight: 600; color: var(--text); }
.stat-desc { font-size: 12px; color: var(--text-muted); margin-top: 4px; }
.stat-badge { display: inline-block; font-size: 11px; padding: 1px 8px; border-radius: 10px; margin-left: 8px; vertical-align: middle; }
.stat-badge { display: inline-block; font-size: 11px; padding: 1px 8px; border-radius: 10px; margin-left: 12px; vertical-align: middle; }
.badge-warn { background: #fff3e0; color: var(--warning); }
/* --- 信息卡片 --- */

View File

@ -172,6 +172,12 @@
align-items: center;
}
.filter-bar label {
font-size: 0.85rem;
font-weight: 500;
color: #4a5568;
}
.filter-bar select {
padding: 0.4rem 0.75rem;
border: 1px solid #cbd5e0;

View File

@ -13,6 +13,34 @@ const pages = {
};
let pendingRejectId = null;
// 审核操作按钮事件委托(避免 inline onclick
function handleAuditClick(e) {
const btn = e.target.closest('[data-audit-action]');
if (!btn) return;
const action = btn.dataset.auditAction;
const id = parseInt(btn.dataset.auditId);
if (action === 'approve') approveAudit(id);
else if (action === 'reject') openRejectModal(id);
}
// 分页按钮事件委托
function handlePaginationClick(e) {
const btn = e.target.closest('[data-go-page]');
if (!btn) return;
goPage(parseInt(btn.dataset.goPage));
}
// 为动态插入的图片绑定 error 处理
function attachImageErrorHandlers(container) {
if (!container) return;
container.querySelectorAll('img[data-fallback]').forEach(img => {
img.addEventListener('error', function handler() {
this.src = 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80"><rect fill="#eee" width="80" height="80"/><text x="40" y="45" text-anchor="middle" fill="#999" font-size="12">加载失败</text></svg>');
this.removeEventListener('error', handler);
});
});
}
document.addEventListener('DOMContentLoaded', () => {
// TAB 切换
document.querySelectorAll('.audit-tab').forEach(tab => {
@ -26,6 +54,10 @@ document.addEventListener('DOMContentLoaded', () => {
});
});
// 全局事件委托:审核操作 & 分页
document.addEventListener('click', handleAuditClick);
document.addEventListener('click', handlePaginationClick);
// 初始加载
loadCurrentTab();
});
@ -55,7 +87,10 @@ function loadTableAudits() {
showToast(res.message || '加载失败', 'error');
}
})
.catch(() => showToast('网络错误', 'error'));
.catch((e) => {
console.error('loadTableAudits error:', e);
showToast('加载失败: ' + (e.message || '网络错误'), 'error');
});
}
function loadAvatarAudits() {
@ -69,7 +104,10 @@ function loadAvatarAudits() {
showToast(res.message || '加载失败', 'error');
}
})
.catch(() => showToast('网络错误', 'error'));
.catch((e) => {
console.error('loadAvatarAudits error:', e);
showToast('加载失败: ' + (e.message || '网络错误'), 'error');
});
}
function loadHistory() {
@ -83,7 +121,10 @@ function loadHistory() {
showToast(res.message || '加载失败', 'error');
}
})
.catch(() => showToast('网络错误', 'error'));
.catch((e) => {
console.error('loadHistory error:', e);
showToast('加载失败: ' + (e.message || '网络错误'), 'error');
});
}
/* =========================================
@ -108,13 +149,13 @@ function renderTable(items, bodyId) {
function renderUsernameRow(item) {
return `
<tr>
<td>${escapeHtml(item.submitter_username || '—')}</td>
<td></td>
<td>${item.user_id || '—'}</td>
<td>${escapeHtml(item.current_username || '—')}</td>
<td><strong>${escapeHtml(item.new_value)}</strong></td>
<td>${formatTime(item.created_at)}</td>
<td class="col-actions">
<button class="btn btn-primary btn-sm" onclick="approveAudit(${item.id})">通过</button>
<button class="btn btn-outline btn-sm" onclick="openRejectModal(${item.id})">拒绝</button>
<button class="btn btn-primary btn-sm" data-audit-action="approve" data-audit-id="${item.id}">通过</button>
<button class="btn btn-outline btn-sm" data-audit-action="reject" data-audit-id="${item.id}">拒绝</button>
</td>
</tr>`;
}
@ -124,12 +165,12 @@ function renderBioRow(item) {
const display = text.length > 40 ? text.substring(0, 40) + '...' : text;
return `
<tr>
<td>${escapeHtml(item.submitter_username || '—')}</td>
<td>${item.user_id || '—'}</td>
<td title="${escapeHtml(text)}">${escapeHtml(display)}</td>
<td>${formatTime(item.created_at)}</td>
<td class="col-actions">
<button class="btn btn-primary btn-sm" onclick="approveAudit(${item.id})">通过</button>
<button class="btn btn-outline btn-sm" onclick="openRejectModal(${item.id})">拒绝</button>
<button class="btn btn-primary btn-sm" data-audit-action="approve" data-audit-id="${item.id}">通过</button>
<button class="btn btn-outline btn-sm" data-audit-action="reject" data-audit-id="${item.id}">拒绝</button>
</td>
</tr>`;
}
@ -147,19 +188,21 @@ function renderAvatarCards(items) {
grid.innerHTML = items.map(item => `
<div class="avatar-audit-card">
<div class="card-header">
<span>提交者: <strong>${escapeHtml(item.submitter_username || '—')}</strong></span>
<span>提交者: <strong>${item.user_id || '—'}</strong></span>
<span>${formatTime(item.created_at)}</span>
</div>
<div class="avatar-compare">
<span>新头像 →</span>
<img src="${escapeHtml(item.new_value)}" alt="新头像" onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 80 80%22><rect fill=%22%23eee%22 width=%2280%22 height=%2280%22/><text x=%2240%22 y=%2245%22 text-anchor=%22middle%22 fill=%22%23999%22 font-size=%2212%22>加载失败</text></svg>'">
<img src="${escapeHtml(item.new_value)}" alt="新头像" data-fallback="true">
</div>
<div class="card-actions">
<button class="btn btn-primary btn-sm" onclick="approveAudit(${item.id})">通过</button>
<button class="btn btn-outline btn-sm" onclick="openRejectModal(${item.id})">拒绝</button>
<button class="btn btn-primary btn-sm" data-audit-action="approve" data-audit-id="${item.id}">通过</button>
<button class="btn btn-outline btn-sm" data-audit-action="reject" data-audit-id="${item.id}">拒绝</button>
</div>
</div>
`).join('');
// 为动态插入的图片绑定 error 处理器CSP 不允许 inline onerror
attachImageErrorHandlers(grid);
}
/* =========================================
@ -181,7 +224,7 @@ function renderHistoryTable(items) {
return `
<tr>
<td>${typeLabel}</td>
<td>${escapeHtml(item.submitter_username || '—')}</td>
<td>${item.user_id || '—'}</td>
<td>${escapeHtml(content)}</td>
<td><span class="audit-status-tag ${statusClass}">${statusLabel}</span></td>
<td>${escapeHtml(reviewer)}</td>
@ -249,9 +292,9 @@ function renderPagination(total, page, barId) {
const bar = document.getElementById(barId);
if (totalPages <= 1) { bar.innerHTML = ''; return; }
let html = `<button onclick="goPage(${page - 1})" ${page <= 1 ? 'disabled' : ''}>上一页</button>`;
let html = `<button data-go-page="${page - 1}" ${page <= 1 ? 'disabled' : ''}>上一页</button>`;
html += `<span class="page-current">第 ${page} / ${totalPages} 页(共 ${total} 条)</span>`;
html += `<button onclick="goPage(${page + 1})" ${page >= totalPages ? 'disabled' : ''}>下一页</button>`;
html += `<button data-go-page="${page + 1}" ${page >= totalPages ? 'disabled' : ''}>下一页</button>`;
bar.innerHTML = html;
}

View File

@ -11,21 +11,22 @@
var pagination = document.getElementById('commentPagination');
var loading = document.getElementById('commentLoading');
// escapeHtml / formatTime / api 由 shared/utils.js 统一提供
// escapeHtml / formatTime / showToast / showConfirm / getCSRFToken 由 shared/utils.js 提供
// api (对象式) 由 admin/static/js/common.js 提供
function loadComments(page) {
loading.style.display = 'block';
var url = '/api/admin/comments?page=' + page + '&page_size=20';
if (currentKeyword) url += '&keyword=' + encodeURIComponent(currentKeyword);
api('GET', url).then(function(d) {
api.get(url).then(function(d) {
loading.style.display = 'none';
if (!d.success) return;
if (!d.success) { showToast(d.message || '加载失败', 'error'); return; }
totalPages = d.data.total_pages || 1;
tbody.innerHTML = '';
var items = d.data.items || [];
if (items.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;padding:40px;color:#9ca3af">暂无评论</td></tr>';
tbody.innerHTML = '<tr><td colspan="6" class="admin-table-empty">暂无评论</td></tr>';
pagination.innerHTML = '';
return;
}
@ -46,7 +47,8 @@
bindDeleteBtns();
}).catch(function() {
loading.style.display = 'none';
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;padding:40px;color:#dc2626">加载失败</td></tr>';
showToast('加载失败', 'error');
tbody.innerHTML = '<tr><td colspan="6" class="admin-table-error">加载失败</td></tr>';
});
}
@ -55,21 +57,19 @@
for (var i = 0; i < btns.length; i++) {
if (btns[i]._bound) continue;
btns[i]._bound = true;
btns[i].addEventListener('click', async function() {
btns[i].addEventListener('click', function() {
var id = this.dataset.id;
var ok = await showConfirm('确认', '确认删除此评论?');
if (!ok) return;
fetch('/api/admin/comments/' + id, {
method: 'DELETE',
headers: { 'X-CSRF-Token': csrfToken }
}).then(function(r) { return r.json(); })
.then(function(d) {
if (d.success) {
loadComments(currentPage);
} else {
alert(d.message || '删除失败');
}
}).catch(function() { alert('请求失败'); });
showConfirm('确认', '确认删除此评论?').then(function(ok) {
if (!ok) return;
api.del('/api/admin/comments/' + id).then(function(d) {
if (d.success) {
showToast(d.message, 'success');
loadComments(currentPage);
} else {
showToast(d.message || '删除失败', 'error');
}
}).catch(function() { showToast('请求失败', 'error'); });
});
});
}
}

View File

@ -6,7 +6,8 @@
// --- AJAX 封装 (管理后台专用对象式 API) ---
// 注意escapeHtml / formatTime / showToast / getCSRFToken 由 shared/utils.js 统一提供
const api = {
// 使用变量赋值覆盖 utils.js 的 function api(),避免 const 重复声明冲突
var api = {
async get(url) {
const res = await fetch(url, {
headers: { 'Accept': 'application/json' }
@ -34,6 +35,13 @@ const api = {
body: body ? JSON.stringify(body) : undefined
});
return res.json();
},
async del(url) {
const res = await fetch(url, {
method: 'DELETE',
headers: { 'X-CSRF-Token': getCSRFToken() }
});
return res.json();
}
};

View File

@ -22,8 +22,8 @@
// 审核通过
handleClick('.post-approve', '确定审核通过此文章吗?', function(id) {
api.post('/api/admin/posts/' + id + '/approve')
.then(function(d) { if (d.success) refreshPage(); else alert(d.message); })
.catch(function() { alert('操作失败'); });
.then(function(d) { if (d.success) refreshPage(); else showToast(d.message || '操作失败', 'error'); })
.catch(function() { showToast('操作失败', 'error'); });
});
// 退回
@ -31,8 +31,8 @@
showPrompt('请输入退回理由(必填):').then(function(reason) {
if (!reason || !reason.trim()) return;
api.post('/api/admin/posts/' + id + '/reject', { reason: reason.trim() })
.then(function(d) { if (d.success) refreshPage(); else alert(d.message); })
.catch(function() { alert('操作失败'); });
.then(function(d) { if (d.success) refreshPage(); else showToast(d.message || '操作失败', 'error'); })
.catch(function() { showToast('操作失败', 'error'); });
});
});
@ -41,15 +41,15 @@
showPrompt('请输入锁定理由(必填):').then(function(reason) {
if (!reason || !reason.trim()) return;
api.post('/api/admin/posts/' + id + '/lock', { reason: reason.trim() })
.then(function(d) { if (d.success) refreshPage(); else alert(d.message); })
.catch(function() { alert('操作失败'); });
.then(function(d) { if (d.success) refreshPage(); else showToast(d.message || '操作失败', 'error'); })
.catch(function() { showToast('操作失败', 'error'); });
});
});
// 解锁
handleClick('.post-unlock', '确定解锁此文章吗?', function(id) {
api.post('/api/admin/posts/' + id + '/unlock')
.then(function(d) { if (d.success) refreshPage(); else alert(d.message); })
.catch(function() { alert('操作失败'); });
.then(function(d) { if (d.success) refreshPage(); else showToast(d.message || '操作失败', 'error'); })
.catch(function() { showToast('操作失败', 'error'); });
});
})();

View File

@ -44,7 +44,8 @@ async function loadUsers() {
showToast(res.message || '加载失败', 'error');
}
} catch (e) {
showToast('网络错误', 'error');
console.error('loadUsers error:', e);
showToast('加载失败: ' + (e.message || '网络错误'), 'error');
}
}

View File

@ -57,13 +57,25 @@ function showToast(message, type) {
if (!container) {
container = document.createElement('div');
container.className = 'toast-container';
container.style.cssText = 'position:fixed;top:20px;right:20px;z-index:9999;display:flex;flex-direction:column;gap:8px;pointer-events:none';
container.style.position = 'fixed';
container.style.top = '20px';
container.style.right = '20px';
container.style.zIndex = '9999';
container.style.display = 'flex';
container.style.flexDirection = 'column';
container.style.gap = '8px';
container.style.pointerEvents = 'none';
document.body.appendChild(container);
}
var toast = document.createElement('div');
toast.className = 'toast toast-' + type;
toast.textContent = message;
toast.style.cssText = 'background:' + bg + ';color:#fff;padding:10px 24px;border-radius:6px;font-size:14px;pointer-events:auto';
toast.style.background = bg;
toast.style.color = '#fff';
toast.style.padding = '10px 24px';
toast.style.borderRadius = '6px';
toast.style.fontSize = '14px';
toast.style.pointerEvents = 'auto';
container.appendChild(toast);
setTimeout(function () {
toast.style.opacity = '0';
@ -91,6 +103,15 @@ function api(method, url, data) {
return fetch(url, opts).then(function (r) { return r.json(); });
}
/**
* 批量设置元素 style 属性(满足 CSP style-src 限制,避免 cssText 被拦截)
* @param {HTMLElement} el — 目标元素
* @param {object} styles — 键值对 { property: value }
*/
function setStyles(el, styles) {
Object.keys(styles).forEach(function(k) { el.style[k] = styles[k]; });
}
/**
* 确认弹窗 — 替代原生 confirm(),统一全站风格
* @param {string} title — 弹窗标题
@ -102,15 +123,59 @@ function showConfirm(title, message) {
var safeTitle = escapeHtml(title);
var safeMsg = escapeHtml(message).replace(/\n/g, '<br>');
// 遮罩层
var overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:9998;display:flex;align-items:center;justify-content:center;';
overlay.innerHTML = '<div style="background:#fff;border-radius:8px;padding:28px 32px;min-width:320px;max-width:440px;box-shadow:0 8px 30px rgba(0,0,0,0.15);">'
+ '<h3 style="margin:0 0 12px 0;font-size:17px;color:#2d3436;">' + safeTitle + '</h3>'
+ '<p style="margin:0 0 20px 0;font-size:14px;line-height:1.6;color:#636e72;">' + safeMsg + '</p>'
+ '<div style="display:flex;gap:10px;justify-content:flex-end;">'
+ '<button id="mlbModalCancel" style="padding:8px 20px;border:1px solid #d1d5db;border-radius:6px;background:#fff;color:#374151;font-size:14px;cursor:pointer;">取消</button>'
+ '<button id="mlbModalConfirm" style="padding:8px 20px;border:none;border-radius:6px;background:#dc2626;color:#fff;font-size:14px;cursor:pointer;">确认</button>'
+ '</div></div>';
setStyles(overlay, {
position: 'fixed', inset: '0', background: 'rgba(0,0,0,0.4)',
zIndex: '10000', display: 'flex', alignItems: 'center', justifyContent: 'center'
});
// 弹窗容器
var box = document.createElement('div');
setStyles(box, {
background: '#fff', borderRadius: '8px', padding: '28px 32px',
minWidth: '320px', maxWidth: '440px',
boxShadow: '0 8px 30px rgba(0,0,0,0.15)'
});
// 标题
var h3 = document.createElement('h3');
setStyles(h3, { margin: '0 0 12px 0', fontSize: '17px', color: '#2d3436' });
h3.textContent = safeTitle;
box.appendChild(h3);
// 消息
var p = document.createElement('p');
setStyles(p, { margin: '0 0 20px 0', fontSize: '14px', lineHeight: '1.6', color: '#636e72' });
p.innerHTML = safeMsg; // 已转义,安全
box.appendChild(p);
// 按钮容器
var btnRow = document.createElement('div');
setStyles(btnRow, { display: 'flex', gap: '10px', justifyContent: 'flex-end' });
// 取消按钮
var btnCancel = document.createElement('button');
setStyles(btnCancel, {
padding: '8px 20px', border: '1px solid #d1d5db', borderRadius: '6px',
background: '#fff', color: '#374151', fontSize: '14px', cursor: 'pointer'
});
btnCancel.textContent = '取消';
btnCancel.addEventListener('click', function() { close(false); });
btnRow.appendChild(btnCancel);
// 确认按钮
var btnConfirm = document.createElement('button');
setStyles(btnConfirm, {
padding: '8px 20px', border: 'none', borderRadius: '6px',
background: '#dc2626', color: '#fff', fontSize: '14px', cursor: 'pointer'
});
btnConfirm.textContent = '确认';
btnConfirm.addEventListener('click', function() { close(true); });
btnRow.appendChild(btnConfirm);
box.appendChild(btnRow);
overlay.appendChild(box);
document.body.appendChild(overlay);
function close(val) {
@ -118,8 +183,6 @@ function showConfirm(title, message) {
resolve(val);
}
overlay.querySelector('#mlbModalCancel').onclick = function() { close(false); };
overlay.querySelector('#mlbModalConfirm').onclick = function() { close(true); };
overlay.addEventListener('click', function(e) {
if (e.target === overlay) close(false);
});
@ -142,26 +205,72 @@ function showPrompt(message, defaultValue) {
return new Promise(function(resolve) {
var safeMsg = escapeHtml(message);
// 遮罩层
var overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:9998;display:flex;align-items:center;justify-content:center;';
overlay.innerHTML = '<div style="background:#fff;border-radius:8px;padding:28px 32px;min-width:320px;max-width:440px;box-shadow:0 8px 30px rgba(0,0,0,0.15);">'
+ '<p style="margin:0 0 16px 0;font-size:14px;line-height:1.6;color:#2d3436;">' + safeMsg + '</p>'
+ '<input id="mlbPromptInput" type="text" style="width:100%;padding:10px 12px;border:1px solid #d1d5db;border-radius:6px;font-size:14px;color:#2d3436;outline:none;box-sizing:border-box;" value="' + escapeHtml(defaultValue || '') + '" placeholder="">'
+ '<div style="display:flex;gap:10px;justify-content:flex-end;margin-top:16px;">'
+ '<button id="mlbPromptCancel" style="padding:8px 20px;border:1px solid #d1d5db;border-radius:6px;background:#fff;color:#374151;font-size:14px;cursor:pointer;">取消</button>'
+ '<button id="mlbPromptConfirm" style="padding:8px 20px;border:none;border-radius:6px;background:#0984e3;color:#fff;font-size:14px;cursor:pointer;">确认</button>'
+ '</div></div>';
document.body.appendChild(overlay);
setStyles(overlay, {
position: 'fixed', inset: '0', background: 'rgba(0,0,0,0.4)',
zIndex: '10000', display: 'flex', alignItems: 'center', justifyContent: 'center'
});
var input = overlay.querySelector('#mlbPromptInput');
// 弹窗容器
var box = document.createElement('div');
setStyles(box, {
background: '#fff', borderRadius: '8px', padding: '28px 32px',
minWidth: '320px', maxWidth: '440px',
boxShadow: '0 8px 30px rgba(0,0,0,0.15)'
});
// 消息
var p = document.createElement('p');
setStyles(p, { margin: '0 0 16px 0', fontSize: '14px', lineHeight: '1.6', color: '#2d3436' });
p.innerHTML = safeMsg;
box.appendChild(p);
// 输入框
var input = document.createElement('input');
input.type = 'text';
input.id = 'mlbPromptInput';
setStyles(input, {
width: '100%', padding: '10px 12px', border: '1px solid #d1d5db',
borderRadius: '6px', fontSize: '14px', color: '#2d3436',
outline: 'none', boxSizing: 'border-box'
});
input.value = escapeHtml(defaultValue || '');
box.appendChild(input);
// 按钮容器
var btnRow = document.createElement('div');
setStyles(btnRow, { display: 'flex', gap: '10px', justifyContent: 'flex-end', marginTop: '16px' });
// 取消按钮
var btnCancel = document.createElement('button');
setStyles(btnCancel, {
padding: '8px 20px', border: '1px solid #d1d5db', borderRadius: '6px',
background: '#fff', color: '#374151', fontSize: '14px', cursor: 'pointer'
});
btnCancel.textContent = '取消';
btnCancel.addEventListener('click', function() { close(null); });
btnRow.appendChild(btnCancel);
// 确认按钮
var btnConfirm = document.createElement('button');
setStyles(btnConfirm, {
padding: '8px 20px', border: 'none', borderRadius: '6px',
background: '#0984e3', color: '#fff', fontSize: '14px', cursor: 'pointer'
});
btnConfirm.textContent = '确认';
btnConfirm.addEventListener('click', function() { close(input.value); });
btnRow.appendChild(btnConfirm);
box.appendChild(btnRow);
overlay.appendChild(box);
document.body.appendChild(overlay);
function close(val) {
overlay.remove();
resolve(val);
}
overlay.querySelector('#mlbPromptCancel').onclick = function() { close(null); };
overlay.querySelector('#mlbPromptConfirm').onclick = function() { close(input.value); };
overlay.addEventListener('click', function(e) {
if (e.target === overlay) close(null);
});