Compare commits

..

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

View File

@ -24,6 +24,8 @@ func NewAdminCommentController(cs adminCommentUseCase) *AdminCommentController {
func (ctrl *AdminCommentController) CommentsPage(c *gin.Context) { func (ctrl *AdminCommentController) CommentsPage(c *gin.Context) {
c.HTML(http.StatusOK, "admin/comments/index.html", common.BuildAdminPageData(c, gin.H{ 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 return
} }
// 为每个审核记录附加提交者的用户名 // 为每个审核记录附加提交者的当前用户名
type auditItem struct { type auditItem struct {
model.AuditSubmission model.AuditSubmission
SubmitterUsername string `json:"submitter_username"` CurrentUsername string `json:"current_username"`
} }
items := make([]auditItem, 0, len(result.Items)) items := make([]auditItem, 0, len(result.Items))
for _, a := range result.Items { for _, a := range result.Items {
item := auditItem{AuditSubmission: a} item := auditItem{AuditSubmission: a}
if user, err := ac.userRepo.FindByID(a.UserID); err == nil { if user, err := ac.userRepo.FindByID(a.UserID); err == nil {
item.SubmitterUsername = user.Username item.CurrentUsername = user.Username
} }
items = append(items, item) items = append(items, item)
} }

View File

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

View File

@ -37,7 +37,7 @@ func (ec *EnergyController) Energize(c *gin.Context) {
return 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 { if err != nil {
switch err { switch err {
case common.ErrPostNotFound: case common.ErrPostNotFound:
@ -56,21 +56,30 @@ func (ec *EnergyController) Energize(c *gin.Context) {
return return
} }
truncated := actualAmount != req.Amount
common.Ok(c, gin.H{ common.Ok(c, gin.H{
"exp_gained": expGained, "exp_gained": expGained,
"message": buildEnergizeMessage(expGained), "actual_amount": actualAmount,
"truncated": truncated,
"message": buildEnergizeMessage(expGained, truncated),
}) })
} }
// buildEnergizeMessage 根据获得的经验生成提示消息 // buildEnergizeMessage 根据获得的经验生成提示消息
func buildEnergizeMessage(expGained int) string { func buildEnergizeMessage(expGained int, truncated bool) string {
if truncated {
if expGained > 0 { if expGained > 0 {
return "赋能成功!" + strconv.Itoa(expGained) + "经验已加入你的旅程。" 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) { func (ec *EnergyController) GetEnergyInfo(c *gin.Context) {
uid, _, ok := common.GetGinUser(c) uid, _, ok := common.GetGinUser(c)
if !ok { if !ok {
@ -78,7 +87,10 @@ func (ec *EnergyController) GetEnergyInfo(c *gin.Context) {
return 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 { if err != nil {
common.Error(c, http.StatusInternalServerError, "获取域能信息失败") common.Error(c, http.StatusInternalServerError, "获取域能信息失败")
return return
@ -91,6 +103,7 @@ func (ec *EnergyController) GetEnergyInfo(c *gin.Context) {
"daily_energize_exp": info.DailyEnergizeExp, "daily_energize_exp": info.DailyEnergizeExp,
"daily_exp_cap": info.DailyExpCap, "daily_exp_cap": info.DailyExpCap,
"daily_exp_cap_reached": info.DailyExpCapReached, "daily_exp_cap_reached": info.DailyExpCapReached,
"post_energize_total": info.PostEnergizeTotal,
}) })
} }

View File

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

View File

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

View File

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

View File

@ -25,13 +25,23 @@ type Comment struct {
ReplyToName string `gorm:"-:migration;<-:false" json:"reply_to_name,omitempty"` ReplyToName string `gorm:"-:migration;<-:false" json:"reply_to_name,omitempty"`
// RepliesCount 回复数非DB字段查询填充 // RepliesCount 回复数非DB字段查询填充
RepliesCount int `gorm:"-:migration;<-:false" json:"replies_count,omitempty"` 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 { type CommentMention struct {
ID uint `gorm:"primarykey" json:"id"` ID uint `gorm:"primarykey" json:"id"`
CommentID uint `gorm:"uniqueIndex:idx_comment_uid,priority:1;not null" json:"comment_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"` 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 @搜索用户结果 // UserSearchResult @搜索用户结果
@ -39,6 +49,7 @@ type UserSearchResult struct {
UID uint `json:"uid"` UID uint `json:"uid"`
Username string `json:"username"` Username string `json:"username"`
Level int `json:"level"` Level int `json:"level"`
Avatar string `json:"avatar"`
} }
// AdminCommentRow 后台评论列表行 // AdminCommentRow 后台评论列表行

View File

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

View File

@ -17,39 +17,98 @@ func (r *CommentRepo) FindMentionsByComment(commentID uint) ([]model.CommentMent
return list, err return list, err
} }
// SearchUsersByLevel 搜索指定等级及以上用户(用于@艾特),返回 uid/username/exp // LoadMentionsForComments 批量加载多条评论的有效@提及映射
func (r *CommentRepo) SearchUsersByLevel(keyword string, minLevel int, limit int) ([]struct { // 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 UID uint
Username string Username string
Avatar string
Exp int Exp int
}, error) { }, error) {
type result struct { type result struct {
UID uint UID uint
Username string Username string
Avatar string
Exp int Exp int
} }
var list []result var list []result
threshold := model.LevelThresholds[minLevel] threshold := model.LevelThresholds[minLevel]
err := r.db.Table("users"). q := r.db.Table("users AS u").
Select("id, username, exp"). Select("u.id AS uid, u.username, u.avatar, u.exp").
Where("username LIKE ? AND exp >= ? AND deleted_at IS NULL", "%"+keyword+"%", threshold). Where("u.username LIKE ? AND u.exp >= ? AND u.deleted_at IS NULL", "%"+keyword+"%", threshold).
Order("exp DESC"). Order("u.exp DESC")
Limit(limit).
Find(&list).Error 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 { var res []struct {
UID uint UID uint
Username string Username string
Avatar string
Exp int Exp int
} }
for _, u := range list { for _, u := range list {
res = append(res, struct { res = append(res, struct {
UID uint UID uint
Username string Username string
Avatar string
Exp int 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 return res, err
} }

View File

@ -4,6 +4,7 @@ import (
"time" "time"
"metazone.cc/metalab/internal/model" "metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/service"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
@ -40,10 +41,11 @@ type overviewRow struct {
PendingCount int64 PendingCount int64
ApprovedCount int64 ApprovedCount int64
RejectedCount int64 RejectedCount int64
TotalViews int64
} }
// GetOverviewByUserID 用单次 GROUP BY 查询聚合用户各状态文章数 // GetOverviewByUserID 用单次 GROUP BY 查询聚合用户各状态文章数 + 总阅读量
func (r *PostRepo) GetOverviewByUserID(userID uint) (totalPosts, draftCount, pendingCount, approvedCount, rejectedCount int64, err error) { func (r *PostRepo) GetOverviewByUserID(userID uint) (totalPosts, draftCount, pendingCount, approvedCount, rejectedCount, totalViews int64, err error) {
var row overviewRow var row overviewRow
err = r.db.Model(&model.Post{}). err = r.db.Model(&model.Post{}).
Select(` 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 = '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 = '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 = '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). Where("user_id = ? AND deleted_at IS NULL", userID).
Scan(&row).Error 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 两次阅读记录最小间隔 // readCooldown 两次阅读记录最小间隔
@ -106,3 +109,26 @@ func (r *PostRepo) IncrementViewsCount(postID uint) error {
return r.db.Model(&model.Post{}).Where("id = ?", postID). return r.db.Model(&model.Post{}).Where("id = ?", postID).
Update("views_count", gorm.Expr("views_count + 1")).Error 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 --- // --- 关注 API ---
// 公开(未登录返回 false // 公开(Optional 认证以获取当前用户关注状态,未登录返回 false
r.GET("/api/users/:uid/follow-status", d.followCtrl.GetStatus) followPublic := r.Group("/api/users/:uid")
r.GET("/api/users/:uid/followers", d.followCtrl.Followers) followPublic.Use(d.authMdw.Optional())
r.GET("/api/users/:uid/following", d.followCtrl.Following) {
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 := r.Group("/api/users/:uid")
followAPI.Use(d.authMdw.Required()) 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 := service.NewAuditService(auditRepo, userRepo, siteSettings, cfg.Audit, notificationService, levelSvc)
auditService.WithEnergyRefundService(energyService) auditService.WithEnergyRefundService(energyService)
auditService.WithAvatarFileCleaner(avatarSvc)
auditController := adminCtrl.NewAuditController(auditService, userRepo) auditController := adminCtrl.NewAuditController(auditService, userRepo)
messageController := controller.NewMessageController(notificationService) messageController := controller.NewMessageController(notificationService)
@ -97,7 +98,7 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
spaceCtrl.WithFavoriteService(favoriteService) spaceCtrl.WithFavoriteService(favoriteService)
// Studio 趋势图 // Studio 趋势图
trendsService := service.NewTrendsService(energyRepo, commentRepo, reactionRepo, favoriteRepo) trendsService := service.NewTrendsService(energyRepo, commentRepo, reactionRepo, favoriteRepo, postRepo)
studioCtrl.WithTrendsService(trendsService) studioCtrl.WithTrendsService(trendsService)
maintenanceMdw := middleware.NewMaintenanceMiddleware(cfg, siteSettings, sessionMgr) maintenanceMdw := middleware.NewMaintenanceMiddleware(cfg, siteSettings, sessionMgr)

View File

@ -45,17 +45,32 @@ func (s *AuditService) review(reviewerID uint, submissionID uint, approved bool,
} else { } else {
submission.Status = model.AuditStatusRejected submission.Status = model.AuditStatusRejected
submission.RejectReason = &reason 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 { if s.notifier != nil {
reasonMsg := reason reasonMsg := reason
if submission.AuditType == model.AuditTypeUsername { if shouldRefund {
reasonMsg += "。扣除的域能已被返还" reasonMsg += "。扣除的域能已被返还"
} }
s.notifier.NotifyAuditRejected(submission.UserID, submission.AuditType, reasonMsg, submission.ID) 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) return s.auditRepo.Update(submission)
@ -80,7 +95,16 @@ func (s *AuditService) applyApproval(submission *model.AuditSubmission) error {
} }
user.Username = submission.NewValue user.Username = submission.NewValue
case model.AuditTypeAvatar: case model.AuditTypeAvatar:
oldAvatar := user.Avatar
user.Avatar = submission.NewValue 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: case model.AuditTypeBio:
user.Bio = submission.NewValue user.Bio = submission.NewValue
} }

View File

@ -40,6 +40,7 @@ type auditNotifier interface {
// auditTaskCompleter 审核通过时触发首次任务奖励的接口 // auditTaskCompleter 审核通过时触发首次任务奖励的接口
type auditTaskCompleter interface { type auditTaskCompleter interface {
CompleteTask(userID uint, taskType string) (newExp int, levelUp bool, err error) CompleteTask(userID uint, taskType string) (newExp int, levelUp bool, err error)
HasCompletedTask(userID uint, taskType string) (bool, error)
} }
// energyRenameRefunder 改名审核被拒时返还域能的接口ISP // energyRenameRefunder 改名审核被拒时返还域能的接口ISP
@ -47,6 +48,12 @@ type energyRenameRefunder interface {
RefundOnRenameReject(userID uint) error RefundOnRenameReject(userID uint) error
} }
// avatarFileCleaner 头像审核通过/拒绝时清理头像文件的接口ISP
type avatarFileCleaner interface {
CleanOldAvatars(userID uint)
DeleteAvatarFile(url string) error
}
// AuditService 审核业务逻辑 // AuditService 审核业务逻辑
type AuditService struct { type AuditService struct {
auditRepo auditRepo auditRepo auditRepo
@ -56,6 +63,7 @@ type AuditService struct {
notifier auditNotifier notifier auditNotifier
levelSvc auditTaskCompleter levelSvc auditTaskCompleter
energyRefundSvc energyRenameRefunder energyRefundSvc energyRenameRefunder
avatarCleaner avatarFileCleaner
} }
// NewAuditService 构造函数 // NewAuditService 构造函数
@ -76,6 +84,12 @@ func (s *AuditService) WithEnergyRefundService(svc energyRenameRefunder) *AuditS
return s return s
} }
// WithAvatarFileCleaner 链式注入头像文件清理服务
func (s *AuditService) WithAvatarFileCleaner(svc avatarFileCleaner) *AuditService {
s.avatarCleaner = svc
return s
}
// ShouldAudit 判断指定用户是否需要走审核流程 // ShouldAudit 判断指定用户是否需要走审核流程
// 规则admin 及以上角色免审 // 规则admin 及以上角色免审
func (s *AuditService) ShouldAudit(userID uint) (bool, error) { 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 return "", err
} }
// 直接更新场景:删除旧头像文件,仅保留新文件
s.CleanOldAvatars(userID)
user, err := s.userRepo.FindByID(userID) user, err := s.userRepo.FindByID(userID)
if err != nil { if err != nil {
return "", fmt.Errorf("用户不存在") return "", fmt.Errorf("用户不存在")
@ -134,10 +137,8 @@ func (s *AvatarService) ProcessImage(userID uint, file io.Reader, contentType st
return "", fmt.Errorf("创建存储目录失败") return "", fmt.Errorf("创建存储目录失败")
} }
// 8. 删除该用户的旧头像,仅保留最新 // 8. 原子写入:先写临时文件,再 rename 到最终路径
s.cleanOldAvatars(userID) // 注意:此处不删除旧头像文件,审核场景下审核通过前旧头像仍需正常显示
// 9. 原子写入:先写临时文件,再 rename 到最终路径
ts := time.Now().Unix() ts := time.Now().Unix()
filename := fmt.Sprintf("%d_%d.webp", userID, ts) filename := fmt.Sprintf("%d_%d.webp", userID, ts)
tmpPath := filepath.Join(s.storageDir, filename+".tmp") 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 return fmt.Sprintf("/uploads/avatars/%d_%d.webp", userID, ts), nil
} }
// cleanOldAvatars 删除指定用户的所有旧头像文件(仅保留最新一次上传) // CleanOldAvatars 删除指定用户的所有旧头像文件,调用方负责确保新文件不受影响
func (s *AvatarService) cleanOldAvatars(userID uint) { func (s *AvatarService) CleanOldAvatars(userID uint) {
prefix := strconv.FormatUint(uint64(userID), 10) + "_" prefix := strconv.FormatUint(uint64(userID), 10) + "_"
entries, err := os.ReadDir(s.storageDir) entries, err := os.ReadDir(s.storageDir)
if err != nil { 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 从原图中裁切指定正方形区域 // subImage 从原图中裁切指定正方形区域
func subImage(img image.Image, x, y, size int) image.Image { func subImage(img image.Image, x, y, size int) image.Image {
cropped := image.NewNRGBA(image.Rect(0, 0, size, size)) 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 // 文章评论数+1
_ = s.repo.IncrCommentsCount(postID) _ = s.repo.IncrCommentsCount(postID)
// 解析 @ 提及 // 解析 @ 提及 + 发送通知
s.parseMentions(comment.ID, body)
// 通知文章作者(评论者 ≠ 作者RelatedID 存 postID 以便消息页生成跳转链接
if s.notifier != nil && userID != postAuthorID {
commenterName, _ := s.repo.GetUsernameByID(userID) commenterName, _ := s.repo.GetUsernameByID(userID)
if commenterName == "" { if commenterName == "" {
commenterName = "用户" commenterName = "用户"
} }
s.parseMentions(comment.ID, postID, userID, commenterName, body)
// 通知文章作者(评论者 ≠ 作者RelatedID 存 postID 以便消息页生成跳转链接
if s.notifier != nil && userID != postAuthorID {
s.notifier.Create(postAuthorID, model.NotifyComment, s.notifier.Create(postAuthorID, model.NotifyComment,
"新评论", "新评论",
fmt.Sprintf("%s 评论了你的文章", commenterName), fmt.Sprintf("%s 评论了你的文章", commenterName),
@ -103,15 +103,15 @@ func (s *CommentService) CreateReply(userID uint, parentID uint, body string) (*
// 文章评论数+1 // 文章评论数+1
_ = s.repo.IncrCommentsCount(parent.PostID) _ = s.repo.IncrCommentsCount(parent.PostID)
// 解析 @ 提及 // 解析 @ 提及 + 发送通知
s.parseMentions(comment.ID, body)
// 通知被回复者不自己回复自己RelatedID 存 postID 以便消息页生成跳转链接
if s.notifier != nil && parent.UserID != userID {
commenterName, _ := s.repo.GetUsernameByID(userID) commenterName, _ := s.repo.GetUsernameByID(userID)
if commenterName == "" { if commenterName == "" {
commenterName = "用户" commenterName = "用户"
} }
s.parseMentions(comment.ID, parent.PostID, userID, commenterName, body)
// 通知被回复者不自己回复自己RelatedID 存 postID 以便消息页生成跳转链接
if s.notifier != nil && parent.UserID != userID {
s.notifier.Create(parent.UserID, model.NotifyCommentReply, s.notifier.Create(parent.UserID, model.NotifyCommentReply,
"新回复", "新回复",
fmt.Sprintf("%s 回复了你的评论", commenterName), 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) { func (s *CommentService) ListRootComments(postID uint, page, pageSize int) ([]model.Comment, int64, error) {
p := common.Pagination{Page: page, PageSize: pageSize} p := common.Pagination{Page: page, PageSize: pageSize}
p.DefaultPagination() 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 获取某条顶级评论的全部回复 // ListReplies 获取某条顶级评论的全部回复
@ -164,6 +172,10 @@ func (s *CommentService) ListReplies(rootID uint) ([]model.Comment, error) {
if list == nil { if list == nil {
list = []model.Comment{} list = []model.Comment{}
} }
// 批量加载有效@提及映射
if len(list) > 0 {
_ = s.repo.LoadMentionsForComments(list)
}
return list, nil return list, nil
} }
@ -178,9 +190,9 @@ func (s *CommentService) ListAllComments(keyword string, showDeleted bool, page,
return rows, total, err return rows, total, err
} }
// SearchUsers 搜索LV3+用户(用于@艾特 // SearchUsers 搜索LV2+用户(用于@艾特悬浮窗已关注优先最多5条
func (s *CommentService) SearchUsers(keyword string) ([]model.UserSearchResult, error) { func (s *CommentService) SearchUsers(keyword string, followerUID uint) ([]model.UserSearchResult, error) {
rows, err := s.repo.SearchUsersByLevel(keyword, 3, 10) rows, err := s.repo.SearchUsersByLevel(keyword, 2, 5, followerUID)
if err != nil { if err != nil {
return nil, fmt.Errorf("搜索用户失败: %w", err) return nil, fmt.Errorf("搜索用户失败: %w", err)
} }
@ -191,27 +203,42 @@ func (s *CommentService) SearchUsers(keyword string) ([]model.UserSearchResult,
UID: row.UID, UID: row.UID,
Username: row.Username, Username: row.Username,
Level: model.GetLevelByExp(row.Exp), Level: model.GetLevelByExp(row.Exp),
Avatar: row.Avatar,
} }
} }
return users, nil return users, nil
} }
// parseMentions 解析@用户存储到 comment_mentions 表 // parseMentions 解析@用户存储到 comment_mentions 表,并向被@用户发送通知
func (s *CommentService) parseMentions(commentID uint, body string) { func (s *CommentService) parseMentions(commentID uint, postID uint, commenterID uint, commenterName string, body string) {
matches := mentionPattern.FindAllStringSubmatch(body, -1) matches := mentionPattern.FindAllStringSubmatch(body, -1)
seen := make(map[string]bool) seen := make(map[string]bool)
notified := make(map[uint]bool) // 防止重复通知同一用户
for _, m := range matches { for _, m := range matches {
username := m[1] username := m[1]
if seen[username] { if seen[username] {
continue continue
} }
seen[username] = true seen[username] = true
rows, _ := s.repo.SearchUsersByLevel(username, 0, 1) rows, _ := s.repo.SearchUsersByLevel(username, 2, 1, 0)
if len(rows) > 0 { if len(rows) == 0 {
continue
}
mentionedUID := rows[0].UID
_ = s.repo.CreateMention(&model.CommentMention{ _ = s.repo.CreateMention(&model.CommentMention{
CommentID: commentID, CommentID: commentID,
UID: rows[0].UID, 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 赋能文章 // Energize 赋能文章
// 返回:获得的经验值、错误 // 返回:获得的经验值、实际赋能量DB乘10错误
func (s *EnergyService) Energize(energizerID uint, postID uint, amount int) (int, error) { func (s *EnergyService) Energize(energizerID uint, postID uint, amount int) (int, int, error) {
// 验证赋能量 // 验证赋能量
if amount != EnergyEnergize1 && amount != EnergyEnergize2 { if amount != EnergyEnergize1 && amount != EnergyEnergize2 {
return 0, common.ErrInvalidEnergyAmount return 0, 0, common.ErrInvalidEnergyAmount
} }
var expGained int var expGained int
@ -21,6 +21,9 @@ func (s *EnergyService) Energize(energizerID uint, postID uint, amount int) (int
var expToday time.Time var expToday time.Time
var expTotal int var expTotal int
// 实际使用的赋能量(可能因上限被截断)
actualAmount := amount
err := s.repo.Transaction(func(txRepo EnergyStore, txFundRepo FundStore) error { err := s.repo.Transaction(func(txRepo EnergyStore, txFundRepo FundStore) error {
// 1. 检查帖子存在,获取作者 // 1. 检查帖子存在,获取作者
post, err := txRepo.FindPostByID(postID) post, err := txRepo.FindPostByID(postID)
@ -42,14 +45,21 @@ func (s *EnergyService) Energize(energizerID uint, postID uint, amount int) (int
return common.ErrInsufficientEnergy return common.ErrInsufficientEnergy
} }
// 4. 检查单用户单文章赋能总量 ≤ MaxEnergizePerPost // 4. 检查单用户单文章赋能上限,超出部分截断(类似每日经验溢出截断)
total, err := txRepo.SumUserPostEnergy(energizerID, postID) total, err := txRepo.SumUserPostEnergy(energizerID, postID)
if err != nil { if err != nil {
return fmt.Errorf("查询赋能记录失败: %w", err) return fmt.Errorf("查询赋能记录失败: %w", err)
} }
if total+amount > MaxEnergizePerPost { room := MaxEnergizePerPost - total
if room <= 0 {
return common.ErrEnergizeLimitReached return common.ErrEnergizeLimitReached
} }
if amount > room {
actualAmount = room // 截断到剩余空间
}
// 重新赋值 amount 用于后续扣域能、写日志等
amount = actualAmount
// 5. 扣赋能者域能 // 5. 扣赋能者域能
if err := txRepo.AddEnergy(energizerID, -amount); err != nil { 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 return nil
}) })
if err != nil { if err != nil {
return 0, err return 0, 0, err
} }
// 12. 事务提交后,在事务外增加经验 + 更新每日汇总 // 12. 事务提交后,在事务外增加经验 + 更新每日汇总
if needExpUpdate { if needExpUpdate {
if s.expSvc != nil { if s.expSvc != nil {
if _, _, err := s.expSvc.AddExp(energizerID, expGained); err != 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{ newSummary := &model.DailyExpSummary{
@ -171,9 +181,9 @@ func (s *EnergyService) Energize(energizerID uint, postID uint, amount int) (int
ExpEarned: expTotal, ExpEarned: expTotal,
} }
if err := s.repo.UpsertDailyExp(newSummary); err != nil { 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 获取用户域能余额和每日赋能经验状态 // 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) user, err := s.repo.FindUserByID(userID)
if err != nil { if err != nil {
return nil, err return nil, err
@ -24,12 +25,21 @@ func (s *EnergyService) GetEnergyInfo(userID uint) (*EnergyInfo, error) {
dailyExp = summary.ExpEarned dailyExp = summary.ExpEarned
} }
return &EnergyInfo{ info := &EnergyInfo{
Energy: user.Energy, Energy: user.Energy,
DailyEnergizeExp: dailyExp, DailyEnergizeExp: dailyExp,
DailyExpCap: DailyEnergizeExpCap, DailyExpCap: DailyEnergizeExpCap,
DailyExpCapReached: dailyExp >= 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 天) // GetEnergyLogs 分页查询用户域能流水(近 N 天)

View File

@ -34,6 +34,7 @@ type EnergyInfo struct {
DailyEnergizeExp int // 当日已通过赋能获得的经验值 DailyEnergizeExp int // 当日已通过赋能获得的经验值
DailyExpCap int // 每日赋能经验上限 DailyExpCap int // 每日赋能经验上限
DailyExpCapReached bool // 是否已达上限 DailyExpCapReached bool // 是否已达上限
PostEnergizeTotal int // 当前文章已赋能量DB乘10仅在传 postID 时返回)
} }
// 域能相关常量乘10存储DB值 // 域能相关常量乘10存储DB值

View File

@ -47,9 +47,9 @@ func countUIDs(uids string) int {
func categoryTypes(category string) []string { func categoryTypes(category string) []string {
switch category { switch category {
case "system": 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": case "mention":
return []string{model.NotifyComment, model.NotifyCommentReply} return []string{model.NotifyComment, model.NotifyCommentReply, model.NotifyCommentMention}
case "like": case "like":
return []string{model.NotifyLikeAggregated} return []string{model.NotifyLikeAggregated}
case "follow": case "follow":
@ -111,6 +111,24 @@ func (s *NotificationService) NotifyPostRejected(userID uint, postTitle, reason
_ = s.Create(userID, model.NotifyPostRejected, title, content, &postID, nil) _ = 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 // NotifyFollow 关注通知followerID 关注了 followeeID
func (s *NotificationService) NotifyFollow(followerID, followeeID uint) { func (s *NotificationService) NotifyFollow(followerID, followeeID uint) {
// 获取关注者用户名 // 获取关注者用户名

View File

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

View File

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

View File

@ -37,7 +37,7 @@ type postStore interface {
FindPageable(keyword string, offset, limit int) ([]model.Post, int64, error) FindPageable(keyword string, offset, limit int) ([]model.Post, int64, error)
FindAdminPageable(keyword, status 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) 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) FindPendingRevisions(keyword string, offset, limit int) ([]model.Post, int64, error)
Update(post *model.Post) error Update(post *model.Post) error
SoftDelete(id uint) error SoftDelete(id uint) error
@ -75,6 +75,8 @@ type userExpReader interface {
type postNotifier interface { type postNotifier interface {
NotifyPostApproved(userID uint, postTitle string, postID uint) NotifyPostApproved(userID uint, postTitle string, postID uint)
NotifyPostRejected(userID uint, postTitle, reason 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 所需的用户仓储接口 // userLevelStore LevelService 所需的用户仓储接口
@ -153,12 +155,14 @@ type commentStore interface {
CreateMention(mention *model.CommentMention) error CreateMention(mention *model.CommentMention) error
FindPostIDByComment(commentID uint) (uint, error) FindPostIDByComment(commentID uint) (uint, error)
FindPostAuthorID(postID 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 UID uint
Username string Username string
Avatar string
Exp int Exp int
}, error) }, error)
GetUsernameByID(userID uint) (string, error) GetUsernameByID(userID uint) (string, error)
LoadMentionsForComments(comments []model.Comment) error
ListAllComments(keyword string, showDeleted bool, offset, limit int) ([]model.AdminCommentRow, int64, error) ListAllComments(keyword string, showDeleted bool, offset, limit int) ([]model.AdminCommentRow, int64, error)
} }
@ -229,3 +233,8 @@ type LikeTrendStore interface {
type FavoriteTrendStore interface { type FavoriteTrendStore interface {
AggregateFavoritesByAuthor(userID uint, since string) ([]TrendPoint, error) 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"` Comments []TrendPoint `json:"comments"`
Likes []TrendPoint `json:"likes"` Likes []TrendPoint `json:"likes"`
Favorites []TrendPoint `json:"favorites"` Favorites []TrendPoint `json:"favorites"`
Views []TrendPoint `json:"views"`
} }
// TrendsService Studio 趋势分析服务 // TrendsService Studio 趋势分析服务
@ -20,15 +21,17 @@ type TrendsService struct {
commentRepo CommentTrendStore commentRepo CommentTrendStore
likeRepo LikeTrendStore likeRepo LikeTrendStore
favoriteRepo FavoriteTrendStore favoriteRepo FavoriteTrendStore
viewRepo ViewTrendStore
} }
// NewTrendsService 构造函数 // 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{ return &TrendsService{
energyRepo: energyRepo, energyRepo: energyRepo,
commentRepo: commentRepo, commentRepo: commentRepo,
likeRepo: likeRepo, likeRepo: likeRepo,
favoriteRepo: favoriteRepo, favoriteRepo: favoriteRepo,
viewRepo: viewRepo,
} }
} }
@ -73,6 +76,15 @@ func (s *TrendsService) GetTrends(userID uint, since string) (*TrendResult, erro
return nil 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 { if err := g.Wait(); err != nil {
return nil, err 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> <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> </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> <script src="/static/js/login.js?v={{assetV "/static/js/login.js"}}" nonce="{{.CSPNonce}}"></script>
</body> </body>
</html> </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> <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> </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> <script src="/static/js/register.js?v={{assetV "/static/js/register.js"}}" nonce="{{.CSPNonce}}"></script>
</body> </body>
</html> </html>

View File

@ -65,9 +65,8 @@
{{if .IsLoggedIn}} {{if .IsLoggedIn}}
{{if .Avatar}} {{if .Avatar}}
<img src="{{.Avatar}}" alt="{{.Username}}" class="user-avatar-img"> <img src="{{.Avatar}}" alt="{{.Username}}" class="user-avatar-img">
<div class="user-avatar-fallback" style="display:none">{{substr .Username 0 1}}</div>
{{else}} {{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}} {{end}}
<h3 class="user-card-name">{{.Username}}</h3> <h3 class="user-card-name">{{.Username}}</h3>
<p class="user-card-desc">欢迎加入 MetaLab</p> <p class="user-card-desc">欢迎加入 MetaLab</p>
@ -117,6 +116,5 @@
{{template "layout/footer.html" .}} {{template "layout/footer.html" .}}
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}" nonce="{{.CSPNonce}}"></script>
</body> </body>
</html> </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> <p class="copyright">&copy; {{.CopyrightStart}}-2026 MetaLab{{if .Framework}} | {{.Framework}}{{end}}{{if .CanAccessAdmin}} | <a href="/admin">管理面板</a>{{end}}</p>
</div> </div>
</footer> </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"> <a href="/space" class="nav-avatar-link">
{{if .Avatar}} {{if .Avatar}}
<img src="{{.Avatar}}" alt="" class="nav-avatar"> <img src="{{.Avatar}}" alt="" class="nav-avatar">
<span class="nav-avatar nav-avatar-default" style="display:none">{{substr .Username 0 1}}</span>
{{else}} {{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}} {{end}}
</a> </a>
<div class="nav-dropdown"> <div class="nav-dropdown">
@ -31,9 +30,8 @@
<div class="nav-dropdown-avatar"> <div class="nav-dropdown-avatar">
{{if .Avatar}} {{if .Avatar}}
<img src="{{.Avatar}}" alt=""> <img src="{{.Avatar}}" alt="">
<span style="display:none">{{substr .Username 0 1}}</span>
{{else}} {{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}} {{end}}
</div> </div>
<div class="nav-dropdown-meta"> <div class="nav-dropdown-meta">

View File

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

View File

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

View File

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

View File

@ -8,13 +8,18 @@
<header class="post-detail-header"> <header class="post-detail-header">
<h1 class="post-detail-title">{{.Post.Title}}</h1> <h1 class="post-detail-title">{{.Post.Title}}</h1>
<div class="post-detail-meta"> <div class="post-detail-meta">
<span class="post-author">{{if .Post.AuthorName}}{{.Post.AuthorName}}{{else}}该用户已注销{{end}}</span> {{if .Post.AuthorName}}
<span class="post-time">{{.Post.CreatedAt.Format "2006-01-02 15:04"}}</span> <span>作者:<a href="/space/{{.Post.UserID}}" class="post-meta-author">{{.Post.AuthorName}}</a></span>
{{if ne .Post.Status "approved"}} {{else}}
<span class="post-status post-status-{{.Post.Status}}">{{index $.StatusNames .Post.Status}}</span> <span>作者:该用户已注销</span>
{{end}} {{end}}
{{if .Post.IsLocked}} <span>阅读:{{.Post.ViewsCount}}</span>
<span class="post-status post-status-locked">已锁定</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}} {{end}}
{{if and .Post.PendingBody (eq $.Post.UserID $.UID)}} {{if and .Post.PendingBody (eq $.Post.UserID $.UID)}}
<span class="post-status post-status-revision">修订审核中</span> <span class="post-status post-status-revision">修订审核中</span>
@ -31,18 +36,37 @@
<div class="post-detail-body" id="postContent"></div> <div class="post-detail-body" id="postContent"></div>
</article> </article>
<!-- 赞/踩/收藏按钮 --> <!-- 右下角悬浮操作栏 -->
<div class="post-reactions" id="postReactions" data-post-id="{{.Post.ID}}"> <div class="post-float-bar" id="postFloatBar" data-post-id="{{.Post.ID}}">
<button class="reaction-btn reaction-like-btn" id="likeBtn" title="点赞"> <button class="float-btn float-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> <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="reaction-like-count" id="likesCount">{{.Post.LikesCount}}</span> <span class="float-count" id="likesCount">{{.Post.LikesCount}}</span>
</button> </button>
<button class="reaction-btn reaction-dislike-btn" id="dislikeBtn" title="踩"> <button class="float-btn float-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> <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>
<button class="reaction-btn reaction-fav-btn" id="favBtn" title="收藏"> {{if .IsLoggedIn}}
<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> <button class="float-btn float-energize-btn" id="energizeBtn" data-id="{{.Post.ID}}" data-uid="{{$.UID}}" data-author-uid="{{.Post.UserID}}" title="赋能">
<span class="reaction-like-count" id="favCount">{{.Post.FavoritesCount}}</span> <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> </button>
</div> </div>
@ -54,9 +78,6 @@
{{if and $.Post (eq $.Post.UserID $.UID) (or (eq $.Post.Status "draft") (eq $.Post.Status "rejected")) (not $.Post.IsLocked)}} {{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> <button class="btn btn-primary" id="submitAuditBtn" data-id="{{$.Post.ID}}">提交审核</button>
{{end}} {{end}}
{{if and $.Post (ne $.Post.UserID $.UID)}}
<button class="btn btn-primary" id="energizeBtn" data-id="{{$.Post.ID}}">赋能</button>
{{end}}
</div> </div>
<!-- 赋能弹窗 --> <!-- 赋能弹窗 -->
@ -147,12 +168,48 @@
<link rel="stylesheet" href="/static/vditor/dist/index.css?v={{assetV "/static/vditor/dist/index.css"}}"> <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/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/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-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-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-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-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 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> </body>
</html> </html>

View File

@ -79,7 +79,7 @@
<div class="info-row"> <div class="info-row">
<span class="info-label">用户名</span> <span class="info-label">用户名</span>
<span class="info-value"> <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 class="char-counter" id="usernameCounter"></span>
</span> </span>
</div> </div>
@ -328,7 +328,6 @@
</div> </div>
</div> </div>
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}" nonce="{{.CSPNonce}}"></script>
<script nonce="{{.CSPNonce}}"> <script nonce="{{.CSPNonce}}">
(function () { (function () {
'use strict'; 'use strict';
@ -375,6 +374,7 @@
// ---- 原始值(用于判断是否变更) ---- // ---- 原始值(用于判断是否变更) ----
var origUsername = usernameInput.value.trim(); var origUsername = usernameInput.value.trim();
var origBio = bioInput.value; var origBio = bioInput.value;
var hasCompletedRename = usernameInput.getAttribute('data-has-completed-rename') === 'true';
// 与后端一致的合法字符集:中文、英文大小写、数字、下划线、连字符 // 与后端一致的合法字符集:中文、英文大小写、数字、下划线、连字符
var usernameRe = /^[\u4e00-\u9fffa-zA-Z0-9_-]+$/; var usernameRe = /^[\u4e00-\u9fffa-zA-Z0-9_-]+$/;
@ -409,6 +409,21 @@
return; 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.disabled = true;
btn.textContent = '保存中...'; btn.textContent = '保存中...';
@ -434,7 +449,7 @@
} }
}; };
xhr.send(JSON.stringify({ username: username, bio: bio })); xhr.send(JSON.stringify({ username: username, bio: bio }));
}); }
// ---- 头像裁切上传 ---- // ---- 头像裁切上传 ----
var avatarWrap = document.getElementById('avatarWrap'); var avatarWrap = document.getElementById('avatarWrap');

View File

@ -22,7 +22,7 @@
{{if .SpaceUser.Avatar}} {{if .SpaceUser.Avatar}}
<img src="{{.SpaceUser.Avatar}}" alt="{{.SpaceUser.Username}}" class="space-avatar-img"> <img src="{{.SpaceUser.Avatar}}" alt="{{.SpaceUser.Username}}" class="space-avatar-img">
{{else}} {{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}} {{end}}
</a> </a>
<div class="space-header-info"> <div class="space-header-info">
@ -35,10 +35,8 @@
<!-- 右:操作按钮 --> <!-- 右:操作按钮 -->
<div class="space-header-actions"> <div class="space-header-actions">
{{if .IsOwnSpace}} <button id="space-follow-btn" class="space-action-btn primary" data-uid="{{.SpaceUser.ID}}" data-current-uid="{{.CurrentUID}}">+ 关注</button>
<!-- 本人无关注按钮 --> {{if not .IsOwnSpace}}
{{else}}
<button id="space-follow-btn" class="space-action-btn primary" data-uid="{{.SpaceUser.ID}}">+ 关注</button>
<button class="space-action-btn">发消息</button> <button class="space-action-btn">发消息</button>
<button class="space-action-btn icon-only" title="更多"></button> <button class="space-action-btn icon-only" title="更多"></button>
{{end}} {{end}}
@ -225,7 +223,7 @@
{{if $item.FolloweeAvatar}} {{if $item.FolloweeAvatar}}
<img src="{{$item.FolloweeAvatar}}" alt="{{$item.FolloweeUsername}}" class="space-user-avatar-img"> <img src="{{$item.FolloweeAvatar}}" alt="{{$item.FolloweeUsername}}" class="space-user-avatar-img">
{{else}} {{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}} {{end}}
</a> </a>
<div class="space-user-info"> <div class="space-user-info">
@ -293,7 +291,7 @@
{{if $item.FollowerAvatar}} {{if $item.FollowerAvatar}}
<img src="{{$item.FollowerAvatar}}" alt="{{$item.FollowerUsername}}" class="space-user-avatar-img"> <img src="{{$item.FollowerAvatar}}" alt="{{$item.FollowerUsername}}" class="space-user-avatar-img">
{{else}} {{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}} {{end}}
</a> </a>
<div class="space-user-info"> <div class="space-user-info">
@ -576,12 +574,60 @@
{{template "layout/footer.html" .}} {{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}}"> <script nonce="{{.CSPNonce}}">
(function() { (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'); var cards = document.querySelectorAll('.space-user-card');
if (!cards.length) return; if (!cards.length) return;
@ -607,7 +653,7 @@
if (state === 'following' || state === 'mutual') btn.classList.add('followed'); if (state === 'following' || state === 'mutual') btn.classList.add('followed');
} }
// 批量获取并更新每个卡片关注状态 // 批量获取并更新每个卡片关注状态(仅非自己)
cards.forEach(function(card) { cards.forEach(function(card) {
var btn = card.querySelector('.space-follow-btn'); var btn = card.querySelector('.space-follow-btn');
if (!btn || btn.disabled) return; if (!btn || btn.disabled) return;
@ -620,6 +666,10 @@
}); });
btn.addEventListener('click', function() { btn.addEventListener('click', function() {
if (currentUID && parseInt(targetUid) === currentUID) {
showToast('不能关注自己');
return;
}
btn.classList.add('waiting'); btn.classList.add('waiting');
var xhr = new XMLHttpRequest(); var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/users/' + targetUid + '/follow', true); xhr.open('POST', '/api/users/' + targetUid + '/follow', true);
@ -634,44 +684,6 @@
xhr.send(); 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> </script>
{{end}} {{end}}
@ -697,7 +709,7 @@ function createFolder() {
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: name, is_public: false}) body: JSON.stringify({name: name, is_public: false})
}).then(function(r) { return r.json(); }).then(function(d) { }).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'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({field: field, value: value}) body: JSON.stringify({field: field, value: value})
}).then(function(r){return r.json()}).then(function(d){ }).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)); }.bind(this));
}); });
}); });

View File

@ -37,6 +37,13 @@
<span class="stat-card-label">草稿</span> <span class="stat-card-label">草稿</span>
</div> </div>
</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>
<div class="studio-section"> <div class="studio-section">
@ -78,6 +85,13 @@
</div> </div>
<div class="trends-loading" id="favoritesLoading">加载中...</div> <div class="trends-loading" id="favoritesLoading">加载中...</div>
</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>
</div> </div>
{{end}} {{end}}
@ -98,7 +112,8 @@
energy: { border: '#f59e0b', bg: 'rgba(245, 158, 11, 0.1)' }, energy: { border: '#f59e0b', bg: 'rgba(245, 158, 11, 0.1)' },
comments: { border: '#3b82f6', bg: 'rgba(59, 130, 246, 0.1)' }, comments: { border: '#3b82f6', bg: 'rgba(59, 130, 246, 0.1)' },
likes: { border: '#ef4444', bg: 'rgba(239, 68, 68, 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) { function fillDates(points, days) {
@ -193,8 +208,9 @@
showLoading('commentsLoading'); showLoading('commentsLoading');
showLoading('likesLoading'); showLoading('likesLoading');
showLoading('favoritesLoading'); showLoading('favoritesLoading');
showLoading('viewsLoading');
var canvasIds = ['energyChart', 'commentsChart', 'likesChart', 'favoritesChart']; var canvasIds = ['energyChart', 'commentsChart', 'likesChart', 'favoritesChart', 'viewsChart'];
canvasIds.forEach(function (id) { canvasIds.forEach(function (id) {
var canvas = document.getElementById(id); var canvas = document.getElementById(id);
canvas.style.display = 'none'; canvas.style.display = 'none';
@ -217,7 +233,8 @@
{ id: 'energyChart', key: 'energy', color: chartColors.energy, label: '赋能值', loading: 'energyLoading' }, { id: 'energyChart', key: 'energy', color: chartColors.energy, label: '赋能值', loading: 'energyLoading' },
{ id: 'commentsChart', key: 'comments', color: chartColors.comments, label: '评论', loading: 'commentsLoading' }, { id: 'commentsChart', key: 'comments', color: chartColors.comments, label: '评论', loading: 'commentsLoading' },
{ id: 'likesChart', key: 'likes', color: chartColors.likes, label: '点赞', loading: 'likesLoading' }, { 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) { maps.forEach(function (m) {

View File

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

View File

@ -52,6 +52,13 @@
<span class="stat-card-label">已退回</span> <span class="stat-card-label">已退回</span>
</div> </div>
</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>
<!-- 状态分布 --> <!-- 状态分布 -->

View File

@ -94,7 +94,7 @@ function deletePost(id) {
if (data.success) { if (data.success) {
location.reload(); location.reload();
} else { } 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"}}"> <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/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> <script src="/static/js/studio-editor.js?v={{assetV "/static/js/studio-editor.js"}}" nonce="{{.CSPNonce}}"></script>
</body> </body>
</html> </html>

View File

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

View File

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

View File

@ -97,6 +97,16 @@
color: #6b7280; color: #6b7280;
} }
.post-detail-meta a {
color: #6b7280;
text-decoration: none;
}
.post-detail-meta a:hover {
color: #6366f1;
text-decoration: underline;
}
.post-detail-body { .post-detail-body {
font-size: 16px; font-size: 16px;
line-height: 1.8; line-height: 1.8;
@ -917,6 +927,17 @@
color: #9ca3af; 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 { .fav-folder-opt-count {
font-size: 12px; font-size: 12px;
color: #9ca3af; color: #9ca3af;
@ -1075,6 +1096,12 @@
border-bottom: none; border-bottom: none;
} }
/* @提及通知进入:持久半透明高亮 */
.comment-card.highlight-mention,
.comment-reply-card.highlight-mention {
background: rgba(255, 248, 225, 0.5) !important;
}
.comment-header { .comment-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@ -1106,6 +1133,18 @@
margin-right: 6px; margin-right: 6px;
} }
/* 有效@提及链接 */
.mention-link {
color: #6366f1;
text-decoration: none;
font-weight: 500;
}
.mention-link:hover {
text-decoration: underline;
color: #4f46e5;
}
.comment-actions { .comment-actions {
display: flex; display: flex;
gap: 16px; gap: 16px;
@ -1217,6 +1256,9 @@
} }
.mention-item { .mention-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px; padding: 8px 14px;
font-size: 13px; font-size: 13px;
cursor: pointer; cursor: pointer;
@ -1229,12 +1271,106 @@
color: #6366f1; color: #6366f1;
} }
.mention-item .mention-username { .mention-item-avatar {
font-weight: 500; 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; color: #9ca3af;
font-size: 11px; 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; width: 56px; height: 56px;
border-radius: 50%; border-radius: 50%;
background: rgba(255,255,255,.25); background: rgba(255,255,255,.25);
color: #fff; color: rgba(255,255,255,.75);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 1.4rem;
font-weight: 700;
border: 2px solid rgba(255,255,255,.4); border: 2px solid rgba(255,255,255,.4);
} }
.space-avatar-placeholder svg {
width: 55%;
height: 55%;
}
.space-header-info { .space-header-info {
color: #fff; color: #fff;
@ -377,9 +379,12 @@
} }
.space-user-avatar-placeholder { .space-user-avatar-placeholder {
width: 48px; height: 48px; border-radius: 50%; 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; 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; } .space-user-info { flex: 1; min-width: 0; }

View File

@ -4,7 +4,7 @@
var commentsSection = document.getElementById('comments'); var commentsSection = document.getElementById('comments');
if (!commentsSection) return; 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 commentsList = document.getElementById('commentsList');
var commentsTotal = document.getElementById('commentsTotal'); var commentsTotal = document.getElementById('commentsTotal');
var loadMoreBtn = document.getElementById('commentsLoadMore'); var loadMoreBtn = document.getElementById('commentsLoadMore');
@ -14,13 +14,28 @@
var currentPage = 1; var currentPage = 1;
var totalPages = 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) { function buildCommentCard(c) {
var time = c.created_at ? formatTime(c.created_at) : ''; var time = c.created_at ? formatTime(c.created_at) : '';
var author = c.author_name ? escapeHtml(c.author_name) : '用户已注销'; var author = c.author_name ? escapeHtml(c.author_name) : '用户已注销';
var body = c.body || ''; var body = c.body || '';
body = escapeHtml(body); body = renderMentions(body, c.mentions);
body = body.replace(/@(\S{1,16})/g, '<span class="reply-to">@$1</span>');
var html = '<div class="comment-card" data-id="' + c.id + '">' + var html = '<div class="comment-card" data-id="' + c.id + '">' +
'<div class="comment-header">' + '<div class="comment-header">' +
@ -53,8 +68,8 @@
var time = r.created_at ? formatTime(r.created_at) : ''; var time = r.created_at ? formatTime(r.created_at) : '';
var author = r.author_name ? escapeHtml(r.author_name) : '用户已注销'; var author = r.author_name ? escapeHtml(r.author_name) : '用户已注销';
var body = r.body || ''; 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 = ''; var prefix = '';
if (r.reply_to_name) { if (r.reply_to_name) {
@ -186,7 +201,7 @@
var input = document.getElementById('replyInput-' + id); var input = document.getElementById('replyInput-' + id);
if (!input) return; if (!input) return;
var body = input.value.trim(); var body = input.value.trim();
if (!body) { alert('请输入回复内容'); return; } if (!body) { showToast('请输入回复内容', 'warning'); return; }
btn.disabled = true; btn.disabled = true;
api('POST', '/api/comments/' + id + '/reply', { body: body }) api('POST', '/api/comments/' + id + '/reply', { body: body })
@ -221,12 +236,12 @@
} }
updateTotalCount(1); updateTotalCount(1);
} else { } else {
alert(d.message || '回复失败'); showToast(d.message || '回复失败', 'error');
} }
}) })
.catch(function() { .catch(function() {
btn.disabled = false; btn.disabled = false;
alert('请求失败'); showToast('请求失败', 'error');
}); });
}); });
} }
@ -236,17 +251,42 @@
var mentionPos = 0; var mentionPos = 0;
var searchTimer = null; 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) { function bindMention(input) {
input.addEventListener('input', function() { input.addEventListener('input', function() {
var val = input.value; var val = input.value;
var cursor = input.selectionStart; var cursor = input.selectionStart;
var atPos = val.lastIndexOf('@', cursor - 1); var atPos = val.lastIndexOf('@', cursor - 1);
// 检查 @ 前是否为空格或行首(合法位置)
if (atPos === -1 || (atPos > 0 && val[atPos - 1] !== ' ' && val[atPos - 1] !== '\n')) { if (atPos === -1 || (atPos > 0 && val[atPos - 1] !== ' ' && val[atPos - 1] !== '\n')) {
hideMention(); hideMention();
return; return;
} }
var query = val.substring(atPos + 1, cursor); var query = val.substring(atPos + 1, cursor);
if (query.length === 0 || query.includes(' ')) { // 查询中包含空格 -> 隐藏下拉
if (query.includes(' ') || query.includes('\n')) {
hideMention(); hideMention();
return; return;
} }
@ -255,6 +295,14 @@
mentionPos = atPos; mentionPos = atPos;
clearTimeout(searchTimer); clearTimeout(searchTimer);
// 仅输入 @ 时,显示提示
if (query.length === 0) {
showEmptyHint(input);
return;
}
// 有查询内容时,延时搜索
searchTimer = setTimeout(function() { searchTimer = setTimeout(function() {
fetch('/api/users/search?q=' + encodeURIComponent(query)) fetch('/api/users/search?q=' + encodeURIComponent(query))
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
@ -262,7 +310,7 @@
if (d.success && d.data && d.data.length > 0) { if (d.success && d.data && d.data.length > 0) {
showMention(input, d.data, query); showMention(input, d.data, query);
} else { } else {
hideMention(); showMention(input, [], query);
} }
}) })
.catch(function() { hideMention(); }); .catch(function() { hideMention(); });
@ -275,19 +323,28 @@
} }
function showMention(input, users, query) { function showMention(input, users, query) {
var rect = input.getBoundingClientRect();
var dropdown = mentionDropdown; var dropdown = mentionDropdown;
dropdown.innerHTML = ''; 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';
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++) { for (var i = 0; i < users.length; i++) {
(function(u) { (function(u) {
var item = document.createElement('div'); var item = document.createElement('div');
item.className = 'mention-item'; item.className = 'mention-item';
item.innerHTML = '<span class="mention-username">' + escapeHtml(u.username) + '</span><span class="mention-uid">' + escapeHtml(u.uid) + '</span>'; 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) { item.addEventListener('mousedown', function(e) {
e.preventDefault(); e.preventDefault();
selectMention(u); selectMention(u);
@ -297,6 +354,35 @@
} }
} }
// 计算光标位置并定位(先渲染内容,再用 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() { function hideMention() {
mentionDropdown.style.display = 'none'; mentionDropdown.style.display = 'none';
mentionDropdown.innerHTML = ''; mentionDropdown.innerHTML = '';
@ -320,7 +406,7 @@
if (commentSubmitBtn) { if (commentSubmitBtn) {
commentSubmitBtn.addEventListener('click', function() { commentSubmitBtn.addEventListener('click', function() {
var body = commentInput.value.trim(); var body = commentInput.value.trim();
if (!body) { alert('请输入评论内容'); return; } if (!body) { showToast('请输入评论内容', 'warning'); return; }
commentSubmitBtn.disabled = true; commentSubmitBtn.disabled = true;
api('POST', '/api/posts/' + postId + '/comments', { body: body }) api('POST', '/api/posts/' + postId + '/comments', { body: body })
@ -333,12 +419,12 @@
loadComments(currentPage); loadComments(currentPage);
updateTotalCount(1); updateTotalCount(1);
} else { } else {
alert(d.message || '发表失败'); showToast(d.message || '发表失败', 'error');
} }
}) })
.catch(function() { .catch(function() {
commentSubmitBtn.disabled = false; commentSubmitBtn.disabled = false;
alert('请求失败'); showToast('请求失败', 'error');
}); });
}); });
@ -397,14 +483,14 @@
input.focus(); input.focus();
input.selectionStart = input.selectionEnd = start + imgUrl.length; input.selectionStart = input.selectionEnd = start + imgUrl.length;
} else { } else {
alert(d.message || '上传失败'); showToast(d.message || '上传失败', 'error');
} }
}) })
.catch(function() { .catch(function() {
uploadLabel.style.pointerEvents = 'auto'; uploadLabel.style.pointerEvents = 'auto';
uploadLabel.style.opacity = '1'; 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> 图片'; 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 = ''; imageUploadInput.value = '';
@ -412,21 +498,45 @@
} }
// ====== 评论高亮 ====== // ====== 评论高亮 ======
function highlightComment(commentId) { function highlightComment(commentId, persistent) {
var card = commentsList.querySelector('.comment-card[data-id="' + commentId + '"]') || var card = commentsList.querySelector('.comment-card[data-id="' + commentId + '"]') ||
commentsList.querySelector('.comment-reply-card[data-id="' + commentId + '"]'); commentsList.querySelector('.comment-reply-card[data-id="' + commentId + '"]');
if (card) { if (card) {
commentsList.insertBefore(card, commentsList.firstChild); commentsList.insertBefore(card, commentsList.firstChild);
card.scrollIntoView({ behavior: 'smooth', block: 'start' }); card.scrollIntoView({ behavior: 'smooth', block: 'start' });
if (persistent) {
card.classList.add('highlight-mention');
} else {
card.style.background = '#fff8e1'; card.style.background = '#fff8e1';
card.style.transition = 'background 1.5s'; card.style.transition = 'background 1.5s';
setTimeout(function() { card.style.background = ''; }, 2000); setTimeout(function() { card.style.background = ''; }, 2000);
}
return true; return true;
} }
return false; return false;
} }
var hash = window.location.hash; 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+)$/); var commentMatch = hash.match(/^#comment-(\d+)$/);
if (commentMatch) { if (commentMatch) {
var targetCommentId = commentMatch[1]; var targetCommentId = commentMatch[1];

View File

@ -9,6 +9,8 @@
if (!btn || !overlay) return; if (!btn || !overlay) return;
var postId = btn.dataset.id; var postId = btn.dataset.id;
var isAuthor = btn.dataset.uid && btn.dataset.authorUid &&
btn.dataset.uid === btn.dataset.authorUid;
function showOverlay() { function showOverlay() {
overlay.style.display = 'flex'; overlay.style.display = 'flex';
@ -18,18 +20,58 @@
capHint.style.display = 'none'; capHint.style.display = 'none';
lightBtn.disabled = false; lightBtn.disabled = false;
heavyBtn.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() { 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(r) { return r.json(); })
.then(function(d) { .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'; capHint.style.display = 'block';
} }
var total = d.data.post_energize_total || 0;
if (total >= 20) {
showToast('对该文章赋能已达上限', 'warning');
return;
}
if (total >= 10) {
disableHeavy();
}
showOverlay(); showOverlay();
}) })
.catch(function() { showOverlay(); }); .catch(function() {
btn.disabled = false;
showToast('获取状态失败,请稍后重试', 'error');
});
}); });
closeBtn.addEventListener('click', hideOverlay); closeBtn.addEventListener('click', hideOverlay);
@ -52,16 +94,16 @@
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(d) { .then(function(d) {
if (d.success) { if (d.success) {
alert(d.data.message || '赋能成功!'); showToast(d.data && d.data.message ? d.data.message : '赋能成功!', 'success');
hideOverlay(); hideOverlay();
} else { } else {
alert(d.message || '赋能失败'); showToast(d.message || '赋能失败', 'error');
lightBtn.disabled = false; lightBtn.disabled = false;
heavyBtn.disabled = false; heavyBtn.disabled = false;
} }
}) })
.catch(function() { .catch(function() {
alert('请求失败'); showToast('请求失败', 'error');
lightBtn.disabled = false; lightBtn.disabled = false;
heavyBtn.disabled = false; heavyBtn.disabled = false;
}); });

View File

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

View File

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

View File

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

View File

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

View File

@ -7,19 +7,19 @@
<div class="admin-toolbar"> <div class="admin-toolbar">
<input type="text" id="commentSearch" class="admin-search-input" placeholder="搜索评论内容、作者或文章标题..." /> <input type="text" id="commentSearch" class="admin-search-input" placeholder="搜索评论内容、作者或文章标题..." />
<button id="commentSearchBtn" class="btn btn-secondary">搜索</button> <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>
<div class="admin-table-wrap" id="commentTableWrap"> <div class="admin-table-wrap" id="commentTableWrap">
<table class="admin-table"> <table class="admin-table">
<thead> <thead>
<tr> <tr>
<th style="width:60px">ID</th> <th class="th-id">ID</th>
<th style="width:100px">作者</th> <th class="th-author">作者</th>
<th>评论内容</th> <th>评论内容</th>
<th style="width:160px">所属文章</th> <th class="th-post">所属文章</th>
<th style="width:140px">时间</th> <th class="th-time">时间</th>
<th style="width:100px">操作</th> <th class="th-actions">操作</th>
</tr> </tr>
</thead> </thead>
<tbody id="commentTableBody"></tbody> <tbody id="commentTableBody"></tbody>
@ -27,7 +27,6 @@
</div> </div>
<div class="admin-pagination" id="commentPagination"></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" .}} {{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-info">
<div class="stat-label">帖子总数</div> <div class="stat-label">帖子总数</div>
<div class="stat-value">{{.PostTotal}}</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> </div>
<div class="stat-card"> <div class="stat-card">
@ -23,8 +23,8 @@
</div> </div>
</div> </div>
<div class="stats-grid" style="margin-top: 1rem;"> <div class="stats-grid mt-grid">
<div class="stat-card" style="grid-column: span 2;"> <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}}"> <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> <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> </div>

View File

@ -5,7 +5,7 @@
</div> </div>
<div class="filter-bar"> <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"> <select id="fund-log-type-filter">
<option value="">全部类型</option> <option value="">全部类型</option>
{{range $type, $name := .LogTypes}}<option value="{{$type}}">{{$name}}</option>{{end}} {{range $type, $name := .LogTypes}}<option value="{{$type}}">{{$name}}</option>{{end}}

View File

@ -5,7 +5,7 @@
</div> </div>
<div class="filter-bar"> <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"> <select id="log-type-filter">
<option value="">全部类型</option> <option value="">全部类型</option>
{{range $type, $name := .LogTypes}}<option value="{{$type}}">{{$name}}</option>{{end}} {{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> <a href="/admin" class="admin-logo">MetaLab <span>管理面板</span></a>
<div class="admin-header-right"> <div class="admin-header-right">
<span class="admin-user">{{.Username}}</span> <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>
</div> </div>
</header> </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-success post-approve" data-id="{{.ID}}">通过修订</button>
<button class="btn btn-sm btn-warning post-reject" data-id="{{.ID}}">退回修订</button> <button class="btn btn-sm btn-warning post-reject" data-id="{{.ID}}">退回修订</button>
{{end}} {{end}}
{{if and (not .IsLocked) (ne .Status "pending")}} {{if not .IsLocked}}
<button class="btn btn-sm btn-danger post-lock" data-id="{{.ID}}">锁定</button> <button class="btn btn-sm btn-danger post-lock" data-id="{{.ID}}">锁定</button>
{{end}} {{end}}
{{if .IsLocked}} {{if .IsLocked}}

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

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

View File

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

View File

@ -13,6 +13,34 @@ const pages = {
}; };
let pendingRejectId = null; 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', () => { document.addEventListener('DOMContentLoaded', () => {
// TAB 切换 // TAB 切换
document.querySelectorAll('.audit-tab').forEach(tab => { document.querySelectorAll('.audit-tab').forEach(tab => {
@ -26,6 +54,10 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
}); });
// 全局事件委托:审核操作 & 分页
document.addEventListener('click', handleAuditClick);
document.addEventListener('click', handlePaginationClick);
// 初始加载 // 初始加载
loadCurrentTab(); loadCurrentTab();
}); });
@ -117,13 +149,13 @@ function renderTable(items, bodyId) {
function renderUsernameRow(item) { function renderUsernameRow(item) {
return ` return `
<tr> <tr>
<td>${escapeHtml(item.submitter_username || '—')}</td> <td>${item.user_id || '—'}</td>
<td></td> <td>${escapeHtml(item.current_username || '—')}</td>
<td><strong>${escapeHtml(item.new_value)}</strong></td> <td><strong>${escapeHtml(item.new_value)}</strong></td>
<td>${formatTime(item.created_at)}</td> <td>${formatTime(item.created_at)}</td>
<td class="col-actions"> <td class="col-actions">
<button class="btn btn-primary btn-sm" onclick="approveAudit(${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" onclick="openRejectModal(${item.id})">拒绝</button> <button class="btn btn-outline btn-sm" data-audit-action="reject" data-audit-id="${item.id}">拒绝</button>
</td> </td>
</tr>`; </tr>`;
} }
@ -133,12 +165,12 @@ function renderBioRow(item) {
const display = text.length > 40 ? text.substring(0, 40) + '...' : text; const display = text.length > 40 ? text.substring(0, 40) + '...' : text;
return ` return `
<tr> <tr>
<td>${escapeHtml(item.submitter_username || '—')}</td> <td>${item.user_id || '—'}</td>
<td title="${escapeHtml(text)}">${escapeHtml(display)}</td> <td title="${escapeHtml(text)}">${escapeHtml(display)}</td>
<td>${formatTime(item.created_at)}</td> <td>${formatTime(item.created_at)}</td>
<td class="col-actions"> <td class="col-actions">
<button class="btn btn-primary btn-sm" onclick="approveAudit(${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" onclick="openRejectModal(${item.id})">拒绝</button> <button class="btn btn-outline btn-sm" data-audit-action="reject" data-audit-id="${item.id}">拒绝</button>
</td> </td>
</tr>`; </tr>`;
} }
@ -156,19 +188,21 @@ function renderAvatarCards(items) {
grid.innerHTML = items.map(item => ` grid.innerHTML = items.map(item => `
<div class="avatar-audit-card"> <div class="avatar-audit-card">
<div class="card-header"> <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> <span>${formatTime(item.created_at)}</span>
</div> </div>
<div class="avatar-compare"> <div class="avatar-compare">
<span>新头像 →</span> <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>
<div class="card-actions"> <div class="card-actions">
<button class="btn btn-primary btn-sm" onclick="approveAudit(${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" onclick="openRejectModal(${item.id})">拒绝</button> <button class="btn btn-outline btn-sm" data-audit-action="reject" data-audit-id="${item.id}">拒绝</button>
</div> </div>
</div> </div>
`).join(''); `).join('');
// 为动态插入的图片绑定 error 处理器CSP 不允许 inline onerror
attachImageErrorHandlers(grid);
} }
/* ========================================= /* =========================================
@ -190,7 +224,7 @@ function renderHistoryTable(items) {
return ` return `
<tr> <tr>
<td>${typeLabel}</td> <td>${typeLabel}</td>
<td>${escapeHtml(item.submitter_username || '—')}</td> <td>${item.user_id || '—'}</td>
<td>${escapeHtml(content)}</td> <td>${escapeHtml(content)}</td>
<td><span class="audit-status-tag ${statusClass}">${statusLabel}</span></td> <td><span class="audit-status-tag ${statusClass}">${statusLabel}</span></td>
<td>${escapeHtml(reviewer)}</td> <td>${escapeHtml(reviewer)}</td>
@ -258,9 +292,9 @@ function renderPagination(total, page, barId) {
const bar = document.getElementById(barId); const bar = document.getElementById(barId);
if (totalPages <= 1) { bar.innerHTML = ''; return; } 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 += `<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; bar.innerHTML = html;
} }

View File

@ -26,7 +26,7 @@
tbody.innerHTML = ''; tbody.innerHTML = '';
var items = d.data.items || []; var items = d.data.items || [];
if (items.length === 0) { 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 = ''; pagination.innerHTML = '';
return; return;
} }
@ -48,7 +48,7 @@
}).catch(function() { }).catch(function() {
loading.style.display = 'none'; loading.style.display = 'none';
showToast('加载失败', 'error'); showToast('加载失败', 'error');
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;padding:40px;color:#dc2626">加载失败</td></tr>'; tbody.innerHTML = '<tr><td colspan="6" class="admin-table-error">加载失败</td></tr>';
}); });
} }

View File

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

View File

@ -57,13 +57,25 @@ function showToast(message, type) {
if (!container) { if (!container) {
container = document.createElement('div'); container = document.createElement('div');
container.className = 'toast-container'; 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); document.body.appendChild(container);
} }
var toast = document.createElement('div'); var toast = document.createElement('div');
toast.className = 'toast toast-' + type; toast.className = 'toast toast-' + type;
toast.textContent = message; 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); container.appendChild(toast);
setTimeout(function () { setTimeout(function () {
toast.style.opacity = '0'; toast.style.opacity = '0';
@ -91,6 +103,15 @@ function api(method, url, data) {
return fetch(url, opts).then(function (r) { return r.json(); }); 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(),统一全站风格 * 确认弹窗 — 替代原生 confirm(),统一全站风格
* @param {string} title — 弹窗标题 * @param {string} title — 弹窗标题
@ -102,15 +123,59 @@ function showConfirm(title, message) {
var safeTitle = escapeHtml(title); var safeTitle = escapeHtml(title);
var safeMsg = escapeHtml(message).replace(/\n/g, '<br>'); var safeMsg = escapeHtml(message).replace(/\n/g, '<br>');
// 遮罩层
var overlay = document.createElement('div'); 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;'; setStyles(overlay, {
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);">' position: 'fixed', inset: '0', background: 'rgba(0,0,0,0.4)',
+ '<h3 style="margin:0 0 12px 0;font-size:17px;color:#2d3436;">' + safeTitle + '</h3>' zIndex: '10000', display: 'flex', alignItems: 'center', justifyContent: 'center'
+ '<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>' var box = document.createElement('div');
+ '</div></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); document.body.appendChild(overlay);
function close(val) { function close(val) {
@ -118,8 +183,6 @@ function showConfirm(title, message) {
resolve(val); resolve(val);
} }
overlay.querySelector('#mlbModalCancel').onclick = function() { close(false); };
overlay.querySelector('#mlbModalConfirm').onclick = function() { close(true); };
overlay.addEventListener('click', function(e) { overlay.addEventListener('click', function(e) {
if (e.target === overlay) close(false); if (e.target === overlay) close(false);
}); });
@ -142,26 +205,72 @@ function showPrompt(message, defaultValue) {
return new Promise(function(resolve) { return new Promise(function(resolve) {
var safeMsg = escapeHtml(message); var safeMsg = escapeHtml(message);
// 遮罩层
var overlay = document.createElement('div'); 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;'; setStyles(overlay, {
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);">' position: 'fixed', inset: '0', background: 'rgba(0,0,0,0.4)',
+ '<p style="margin:0 0 16px 0;font-size:14px;line-height:1.6;color:#2d3436;">' + safeMsg + '</p>' zIndex: '10000', display: 'flex', alignItems: 'center', justifyContent: 'center'
+ '<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);
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) { function close(val) {
overlay.remove(); overlay.remove();
resolve(val); resolve(val);
} }
overlay.querySelector('#mlbPromptCancel').onclick = function() { close(null); };
overlay.querySelector('#mlbPromptConfirm').onclick = function() { close(input.value); };
overlay.addEventListener('click', function(e) { overlay.addEventListener('click', function(e) {
if (e.target === overlay) close(null); if (e.target === overlay) close(null);
}); });