feat: Studio 概览/分析补上阅读量 + 详情页 meta 行改版
- StudioOverview 新增 TotalViews 字段,GetOverviewByUserID 查询含 SUM(views_count) - 新增 ViewTrendStore 接口和 AggregateViewsByAuthor,UNION ALL 合并已登录+访客阅读日志按日期聚合 - 数据分析页面新增阅读量趋势折线图(绿色,Chart.js) - 概览和数据分析页面新增总阅读量统计卡片 - 详情页 meta 行改为标签格式:作者/阅读/点赞/收藏/评论/发布时间 - 移除详情页公开状态徽章,已锁定+理由仅作者可见
This commit is contained in:
@ -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
|
||||||
|
}
|
||||||
|
|||||||
@ -98,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)
|
||||||
|
|||||||
@ -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
|
||||||
@ -231,3 +231,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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,13 +8,14 @@
|
|||||||
<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>
|
<span>作者:{{if .Post.AuthorName}}{{.Post.AuthorName}}{{else}}该用户已注销{{end}}</span>
|
||||||
<span class="post-time">{{.Post.CreatedAt.Format "2006-01-02 15:04"}}</span>
|
<span>阅读:{{.Post.ViewsCount}}</span>
|
||||||
{{if ne .Post.Status "approved"}}
|
<span>点赞:{{.Post.LikesCount}}</span>
|
||||||
<span class="post-status post-status-{{.Post.Status}}">{{index $.StatusNames .Post.Status}}</span>
|
<span>收藏:{{.Post.FavoritesCount}}</span>
|
||||||
{{end}}
|
<span>评论:{{.Post.CommentsCount}}</span>
|
||||||
{{if .Post.IsLocked}}
|
<span>发布时间:{{.Post.CreatedAt.Format "2006-01-02 15:04"}}</span>
|
||||||
<span class="post-status post-status-locked">已锁定</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>
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
<!-- 状态分布 -->
|
<!-- 状态分布 -->
|
||||||
|
|||||||
Reference in New Issue
Block a user