Compare commits
26 Commits
983f539268
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d7ba7799fe | |||
| 8bf6ac9e51 | |||
| 2ea9788ec7 | |||
| be729c29a1 | |||
| 584d7fd146 | |||
| 6c9a4414cb | |||
| 864a488ee0 | |||
| fb9724faed | |||
| 58aabb9bf0 | |||
| ae15025b1d | |||
| be8ce04334 | |||
| 4ac4f64f33 | |||
| 2d05da6709 | |||
| 1af9fba291 | |||
| 4442da31c3 | |||
| 0af6f7c971 | |||
| b74ce2da93 | |||
| fcff28ab60 | |||
| ea2f196ff7 | |||
| b0a1ff3c3f | |||
| a806532d25 | |||
| ca21ae7216 | |||
| 7d944cfa35 | |||
| dd6efd0c50 | |||
| fa3b20d1bc | |||
| 543325105c |
@ -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">
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -23,7 +23,9 @@ func NewAdminCommentController(cs adminCommentUseCase) *AdminCommentController {
|
|||||||
// CommentsPage SSR 页面
|
// CommentsPage SSR 页面
|
||||||
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",
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 expGained > 0 {
|
if truncated {
|
||||||
return "赋能成功!" + strconv.Itoa(expGained) + "经验已加入你的旅程。"
|
if expGained > 0 {
|
||||||
|
return "单篇额度已满,实际赋能部分额度,+" + strconv.Itoa(expGained) + " 经验"
|
||||||
|
}
|
||||||
|
return "单篇额度已满,实际赋能部分额度"
|
||||||
}
|
}
|
||||||
return "赋能成功!今日赋能经验奖励已达上限,对方仍会获得激励。"
|
if expGained > 0 {
|
||||||
|
return "赋能成功!+" + strconv.Itoa(expGained) + " 经验"
|
||||||
|
}
|
||||||
|
return "赋能成功!今日经验已满,对方仍获得激励"
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetEnergyInfo 获取域能余额和经验上限状态
|
// GetEnergyInfo 获取域能余额和经验上限状态,支持 ?post_id= 查询文章赋能总量
|
||||||
func (ec *EnergyController) GetEnergyInfo(c *gin.Context) {
|
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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -85,8 +85,8 @@ type levelUseCase interface {
|
|||||||
|
|
||||||
// energyUseCase EnergyController 对 EnergyService 的最小依赖(ISP:3 个方法)
|
// energyUseCase EnergyController 对 EnergyService 的最小依赖(ISP:3 个方法)
|
||||||
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 的最小依赖(ISP:4 个方法)
|
// reactionUseCase ReactionController 对 ReactionService 的最小依赖(ISP:4 个方法)
|
||||||
|
|||||||
@ -58,15 +58,23 @@ 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",
|
||||||
"ActiveTab": tab,
|
"ActiveTab": tab,
|
||||||
"User": user,
|
"User": user,
|
||||||
"RoleName": model.RoleDisplayNames[user.Role],
|
"RoleName": model.RoleDisplayNames[user.Role],
|
||||||
"StatusName": model.StatusDisplayNames[user.Status],
|
"StatusName": model.StatusDisplayNames[user.Status],
|
||||||
"PendingTypes": pendingTypes,
|
"PendingTypes": pendingTypes,
|
||||||
"AuditTypes": model.AuditTypeNames,
|
"AuditTypes": model.AuditTypeNames,
|
||||||
|
"HasCompletedRename": hasCompletedRename,
|
||||||
}
|
}
|
||||||
|
|
||||||
// 登录管理 tab 需要额外数据
|
// 登录管理 tab 需要额外数据
|
||||||
|
|||||||
@ -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'")
|
||||||
|
|
||||||
|
|||||||
@ -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 后台评论列表行
|
||||||
|
|||||||
@ -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",
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
|||||||
@ -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())
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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)
|
commenterName, _ := s.repo.GetUsernameByID(userID)
|
||||||
|
if commenterName == "" {
|
||||||
|
commenterName = "用户"
|
||||||
|
}
|
||||||
|
s.parseMentions(comment.ID, postID, userID, commenterName, body)
|
||||||
|
|
||||||
// 通知文章作者(评论者 ≠ 作者),RelatedID 存 postID 以便消息页生成跳转链接
|
// 通知文章作者(评论者 ≠ 作者),RelatedID 存 postID 以便消息页生成跳转链接
|
||||||
if s.notifier != nil && userID != postAuthorID {
|
if s.notifier != nil && userID != postAuthorID {
|
||||||
commenterName, _ := s.repo.GetUsernameByID(userID)
|
|
||||||
if commenterName == "" {
|
|
||||||
commenterName = "用户"
|
|
||||||
}
|
|
||||||
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)
|
commenterName, _ := s.repo.GetUsernameByID(userID)
|
||||||
|
if commenterName == "" {
|
||||||
|
commenterName = "用户"
|
||||||
|
}
|
||||||
|
s.parseMentions(comment.ID, parent.PostID, userID, commenterName, body)
|
||||||
|
|
||||||
// 通知被回复者(不自己回复自己),RelatedID 存 postID 以便消息页生成跳转链接
|
// 通知被回复者(不自己回复自己),RelatedID 存 postID 以便消息页生成跳转链接
|
||||||
if s.notifier != nil && parent.UserID != userID {
|
if s.notifier != nil && parent.UserID != userID {
|
||||||
commenterName, _ := s.repo.GetUsernameByID(userID)
|
|
||||||
if commenterName == "" {
|
|
||||||
commenterName = "用户"
|
|
||||||
}
|
|
||||||
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 {
|
||||||
_ = s.repo.CreateMention(&model.CommentMention{
|
continue
|
||||||
CommentID: commentID,
|
}
|
||||||
UID: rows[0].UID,
|
mentionedUID := rows[0].UID
|
||||||
})
|
_ = s.repo.CreateMention(&model.CommentMention{
|
||||||
|
CommentID: commentID,
|
||||||
|
UID: mentionedUID,
|
||||||
|
OriginalUsername: username,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 向被@用户发送通知(排除自己@自己、重复通知)
|
||||||
|
if s.notifier != nil && mentionedUID != commenterID && !notified[mentionedUID] {
|
||||||
|
notified[mentionedUID] = true
|
||||||
|
s.notifier.Create(mentionedUID, model.NotifyCommentMention,
|
||||||
|
"新提及",
|
||||||
|
fmt.Sprintf("%s 在评论中 @ 了你", commenterName),
|
||||||
|
&postID, &commentID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 天)
|
||||||
|
|||||||
@ -30,10 +30,11 @@ func (s *EnergyService) SetFundRepo(repo FundStore) {
|
|||||||
|
|
||||||
// EnergyInfo 域能信息(含每日赋能经验状态)
|
// EnergyInfo 域能信息(含每日赋能经验状态)
|
||||||
type EnergyInfo struct {
|
type EnergyInfo struct {
|
||||||
Energy int // 域能余额(乘10存储)
|
Energy int // 域能余额(乘10存储)
|
||||||
DailyEnergizeExp int // 当日已通过赋能获得的经验值
|
DailyEnergizeExp int // 当日已通过赋能获得的经验值
|
||||||
DailyExpCap int // 每日赋能经验上限
|
DailyExpCap int // 每日赋能经验上限
|
||||||
DailyExpCapReached bool // 是否已达上限
|
DailyExpCapReached bool // 是否已达上限
|
||||||
|
PostEnergizeTotal int // 当前文章已赋能量(DB乘10,仅在传 postID 时返回)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 域能相关常量(乘10存储,DB值)
|
// 域能相关常量(乘10存储,DB值)
|
||||||
|
|||||||
@ -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) {
|
||||||
// 获取关注者用户名
|
// 获取关注者用户名
|
||||||
|
|||||||
@ -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 恢复软删除
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -3,3 +3,4 @@
|
|||||||
<p class="copyright">© {{.CopyrightStart}}-2026 MetaLab{{if .Framework}} | {{.Framework}}{{end}}{{if .CanAccessAdmin}} | <a href="/admin">管理面板</a>{{end}}</p>
|
<p class="copyright">© {{.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>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
@ -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));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
<!-- 状态分布 -->
|
<!-- 状态分布 -->
|
||||||
|
|||||||
@ -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');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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; }
|
||||||
|
|||||||
@ -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,26 +323,64 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
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';
|
|
||||||
|
|
||||||
for (var i = 0; i < users.length; i++) {
|
if (users.length === 0) {
|
||||||
(function(u) {
|
// 无匹配结果:显示提示(仅在有查询时)
|
||||||
var item = document.createElement('div');
|
if (query.length > 0) {
|
||||||
item.className = 'mention-item';
|
var emptyItem = document.createElement('div');
|
||||||
item.innerHTML = '<span class="mention-username">' + escapeHtml(u.username) + '</span><span class="mention-uid">' + escapeHtml(u.uid) + '</span>';
|
emptyItem.className = 'mention-no-result';
|
||||||
item.addEventListener('mousedown', function(e) {
|
emptyItem.textContent = '未搜索到用户';
|
||||||
e.preventDefault();
|
dropdown.appendChild(emptyItem);
|
||||||
selectMention(u);
|
}
|
||||||
});
|
} else {
|
||||||
dropdown.appendChild(item);
|
for (var i = 0; i < users.length; i++) {
|
||||||
})(users[i]);
|
(function(u) {
|
||||||
|
var item = document.createElement('div');
|
||||||
|
item.className = 'mention-item';
|
||||||
|
var avatarHtml = u.avatar
|
||||||
|
? '<img class="mention-item-avatar" src="' + escapeHtml(u.avatar) + '" alt="" />'
|
||||||
|
: '<span class="mention-item-avatar mention-item-avatar-placeholder"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg></span>';
|
||||||
|
item.innerHTML = avatarHtml +
|
||||||
|
'<span class="mention-username">' + escapeHtml(u.username) + '</span>' +
|
||||||
|
'<span class="mention-level">LV' + u.level + '</span>';
|
||||||
|
item.addEventListener('mousedown', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
selectMention(u);
|
||||||
|
});
|
||||||
|
dropdown.appendChild(item);
|
||||||
|
})(users[i]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 计算光标位置并定位(先渲染内容,再用 offsetHeight 定位)
|
||||||
|
var cursorIdx = input.selectionStart;
|
||||||
|
var pos = getCursorPixelPos(input, cursorIdx);
|
||||||
|
|
||||||
|
dropdown.style.display = 'block';
|
||||||
|
dropdown.style.width = '220px';
|
||||||
|
// 悬浮在光标上方(不遮挡输入内容)
|
||||||
|
var dropHeight = dropdown.offsetHeight || 60;
|
||||||
|
dropdown.style.top = (pos.top - dropHeight - 4 + window.scrollY) + 'px';
|
||||||
|
dropdown.style.left = Math.min(pos.left, window.innerWidth - 230) + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showEmptyHint(input) {
|
||||||
|
var dropdown = mentionDropdown;
|
||||||
|
dropdown.innerHTML = '';
|
||||||
|
var hint = document.createElement('div');
|
||||||
|
hint.className = 'mention-no-result';
|
||||||
|
hint.textContent = '输入以查找用户';
|
||||||
|
dropdown.appendChild(hint);
|
||||||
|
|
||||||
|
var cursorIdx = input.selectionStart;
|
||||||
|
var pos = getCursorPixelPos(input, cursorIdx);
|
||||||
|
dropdown.style.display = 'block';
|
||||||
|
dropdown.style.width = '220px';
|
||||||
|
var dropHeight = dropdown.offsetHeight || 36;
|
||||||
|
dropdown.style.top = (pos.top - dropHeight - 4 + window.scrollY) + 'px';
|
||||||
|
dropdown.style.left = Math.min(pos.left, window.innerWidth - 230) + 'px';
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideMention() {
|
function hideMention() {
|
||||||
@ -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' });
|
||||||
card.style.background = '#fff8e1';
|
if (persistent) {
|
||||||
card.style.transition = 'background 1.5s';
|
card.classList.add('highlight-mention');
|
||||||
setTimeout(function() { card.style.background = ''; }, 2000);
|
} else {
|
||||||
|
card.style.background = '#fff8e1';
|
||||||
|
card.style.transition = 'background 1.5s';
|
||||||
|
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];
|
||||||
|
|||||||
@ -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;
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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; });
|
||||||
|
|||||||
@ -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'); });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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}}
|
||||||
|
|||||||
@ -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}}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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}}
|
||||||
|
|||||||
144
templates/admin/static/css/comments.css
Normal file
144
templates/admin/static/css/comments.css
Normal 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;
|
||||||
|
}
|
||||||
@ -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); }
|
||||||
|
|
||||||
/* --- 信息卡片 --- */
|
/* --- 信息卡片 --- */
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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>';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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'); });
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user