feat: 编辑器与帖子展示布局优化 + 新增个人空间
- 编辑器投稿页布局优化:标题与编辑器高度动态适配,工具栏与内容区视觉对齐 - 稿件展示页布局优化:MD 渲染区与评论区视觉分离,代码块主题微调 - CSRF 中间件:图像上传端点豁免,解决 Vditor 拖拽/粘贴上传 403 - Post 状态映射、GetGinUser、SaveUploadedFile 提取至 common 包,遵循审计建议 - 新增个人空间功能:/space(需登录)重定向到 /space/:uid,/space/:uid 公开访问 - 空间页模板:参考 B 站布局,展示头像、用户名、个性签名、UID、加入时间及稿件列表 - 导航栏:用户名链接指向 /space,新增「设置」入口 - 分层实现:SpaceController → SpaceService → PostRepo.FindByUserID,严格 ISP 接口隔离
This commit is contained in:
231
docs/post-system-audit.md
Normal file
231
docs/post-system-audit.md
Normal file
@ -0,0 +1,231 @@
|
|||||||
|
# 帖子系统代码审计报告
|
||||||
|
|
||||||
|
> 审计日期:2026-05-30
|
||||||
|
> 审计范围:`internal/` 下所有 Post 相关文件(router / controller / service / repository / model)
|
||||||
|
> 审计标准:DRY / KISS / YAGNI / LoD / SOLID + 分层架构规范
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、架构合规性总览
|
||||||
|
|
||||||
|
| 检查项 | 状态 | 说明 |
|
||||||
|
|--------|------|------|
|
||||||
|
| 分层依赖方向 | ✅ | router→controller→service→repo→model,严格单向 |
|
||||||
|
| Controller 接口文件 | ✅ | `controller/interfaces.go` + `admin/interfaces.go` |
|
||||||
|
| Service Repository 接口 | ✅ | `service/repository.go`,8 个方法,无冗余 |
|
||||||
|
| ISP 接口隔离 | ✅ | 前台 `postUseCase` 与后台 `adminPostUseCase` 分离 |
|
||||||
|
| DIP 依赖倒置 | ✅ | Controller→接口, Service→接口 |
|
||||||
|
| 依赖注入 | ✅ | 全部构造函数注入,无硬编码 |
|
||||||
|
| KISS | ✅ | 无过度设计 |
|
||||||
|
| YAGNI | ✅ | 无超前功能 |
|
||||||
|
| LoD | ✅ | 无跨层直接依赖 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、问题清单
|
||||||
|
|
||||||
|
### 🔴 中等问题(4 个)
|
||||||
|
|
||||||
|
#### 问题 1:Repository 方法代码重复(~70%)
|
||||||
|
|
||||||
|
| 项 | 详情 |
|
||||||
|
|----|------|
|
||||||
|
| **文件** | `internal/repository/post_repo.go` |
|
||||||
|
| **位置** | `FindPageable`(行 49-73)vs `FindAdminPageable`(行 76-102) |
|
||||||
|
| **违反原则** | DRY |
|
||||||
|
| **描述** | 两个方法的核心查询逻辑(Table + Select + Joins + keyword LIKE + Count + Offset/Limit + ORDER BY)几乎完全一致,唯一区别是 `FindPageable` 固定过滤 `status = 'approved'`,`FindAdminPageable` 支持可选的 status 参数。两段代码约 70% 重复。 |
|
||||||
|
|
||||||
|
**现状:**
|
||||||
|
```go
|
||||||
|
// FindPageable (行 49-73)
|
||||||
|
func (r *PostRepo) FindPageable(keyword string, offset, limit int) ([]model.Post, int64, error) {
|
||||||
|
query := r.db.Table("posts").
|
||||||
|
Select("posts.*, users.username as author_name").
|
||||||
|
Joins("LEFT JOIN users ON users.uid = posts.user_id").
|
||||||
|
Where("posts.deleted_at IS NULL").
|
||||||
|
Where("posts.status = ?", model.PostStatusApproved) // ← 唯一差异
|
||||||
|
|
||||||
|
if keyword != "" { /* LIKE 过滤 */ }
|
||||||
|
// ... Count + Offset/Limit + Find
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindAdminPageable (行 76-102)
|
||||||
|
func (r *PostRepo) FindAdminPageable(keyword, status string, offset, limit int) ([]model.Post, int64, error) {
|
||||||
|
query := r.db.Table("posts").
|
||||||
|
Select("posts.*, users.username as author_name").
|
||||||
|
Joins("LEFT JOIN users ON users.uid = posts.user_id").
|
||||||
|
Where("posts.deleted_at IS NULL")
|
||||||
|
// ← 无硬编码 status,由参数控制
|
||||||
|
|
||||||
|
if keyword != "" { /* LIKE 过滤 */ }
|
||||||
|
if status != "" { query = query.Where("posts.status = ?", status) }
|
||||||
|
// ... Count + Offset/Limit + Find
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**建议修复:** 提取私有方法 `findPageableCommon`,两个公开方法调用它并传入各自的 WHERE 条件。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 问题 2:Service 方法重复
|
||||||
|
|
||||||
|
| 项 | 详情 |
|
||||||
|
|----|------|
|
||||||
|
| **文件** | `internal/service/post_service.go` |
|
||||||
|
| **位置** | `List`(行 148-156)vs `ListAdmin`(行 159-167) |
|
||||||
|
| **违反原则** | DRY |
|
||||||
|
| **描述** | 两个方法的 nil 检查 + 分页调用模式完全一致,唯一区别是调用的 repo 方法不同。 |
|
||||||
|
|
||||||
|
**现状:**
|
||||||
|
```go
|
||||||
|
// List (行 148-156)
|
||||||
|
func (s *PostService) List(keyword string, page, pageSize int) ([]model.Post, int64, error) {
|
||||||
|
p := common.Pagination{Page: page, PageSize: pageSize}
|
||||||
|
p.DefaultPagination()
|
||||||
|
posts, total, err := s.repo.FindPageable(keyword, p.Offset(), p.PageSize)
|
||||||
|
if posts == nil { posts = []model.Post{} }
|
||||||
|
return posts, total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAdmin (行 159-167)
|
||||||
|
func (s *PostService) ListAdmin(keyword, status string, page, pageSize int) ([]model.Post, int64, error) {
|
||||||
|
p := common.Pagination{Page: page, PageSize: pageSize}
|
||||||
|
p.DefaultPagination()
|
||||||
|
posts, total, err := s.repo.FindAdminPageable(keyword, status, p.Offset(), p.PageSize)
|
||||||
|
if posts == nil { posts = []model.Post{} }
|
||||||
|
return posts, total, err
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**建议修复:** 提取公共的 Pagination 创建 + nil 检查逻辑。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 问题 3:Controller 权限检查重复 6 处
|
||||||
|
|
||||||
|
| 项 | 详情 |
|
||||||
|
|----|------|
|
||||||
|
| **文件** | `internal/controller/post_controller.go` |
|
||||||
|
| **位置** | `ShowPage`(行 82-90)、`EditPage`(行 144-150)、`Update`(行 202-211)、`Delete`(行 239-248)、`Submit`(行 272-281)、`ShowAPI`(行 333-339) |
|
||||||
|
| **违反原则** | DRY |
|
||||||
|
| **描述** | 以下模式在 6 个方法中逐字重复: |
|
||||||
|
|
||||||
|
```go
|
||||||
|
post, err := ctrl.postService.GetByID(uint(id))
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusNotFound, "帖子不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !model.IsPostAccessible(uid, role, post.UserID) {
|
||||||
|
common.Error(c, http.StatusForbidden, "无权操作此帖子")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**建议修复:** 提取私有方法 `getPostAndCheckAccess(id, c)` 返回 `(*model.Post, bool)`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 问题 4:Model 层职责过重
|
||||||
|
|
||||||
|
| 项 | 详情 |
|
||||||
|
|----|------|
|
||||||
|
| **文件** | `internal/model/post.go` |
|
||||||
|
| **位置** | 行 48-83 |
|
||||||
|
| **违反原则** | SRP(单一职责) |
|
||||||
|
| **描述** | 一个文件混合了多种职责: |
|
||||||
|
|
||||||
|
| 内容 | 类型 | 应在位置 |
|
||||||
|
|------|------|---------|
|
||||||
|
| `Post` struct | 数据模型 | ✅ Model 层 |
|
||||||
|
| `PostStatusDraft` 等常量 | 状态常量 | ✅ Model 层 |
|
||||||
|
| `PostStatusDisplayNames` | 视图映射 | ❌ 应移到 View/Controller 层 |
|
||||||
|
| `PostListResult` | DTO | ❌ 应移到 `model/dto.go` |
|
||||||
|
| `PostCreateRequest` / `PostUpdateRequest` | 请求 DTO | ❌ 应移到 `model/dto.go` |
|
||||||
|
| `PostRejectRequest` | 请求 DTO | ❌ 应移到 `model/dto.go` |
|
||||||
|
| `PostListQuery` | 查询 DTO | ❌ 应移到 `model/dto.go` |
|
||||||
|
| `IsPostAccessible` | 业务权限逻辑 | ❌ 应移到 Service 层 |
|
||||||
|
|
||||||
|
**建议修复:** DTO 结构体移到 `model/dto.go`,`PostStatusDisplayNames` 移到 common 或 controller,`IsPostAccessible` 移到 service 层或独立权限模块。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🟡 轻微问题(3 个)
|
||||||
|
|
||||||
|
#### 问题 5:工具函数位置不当
|
||||||
|
|
||||||
|
| 项 | 详情 |
|
||||||
|
|----|------|
|
||||||
|
| **文件** | `internal/controller/post_controller.go` |
|
||||||
|
| **位置** | `saveUploadedFile`(行 410-430) |
|
||||||
|
| **违反原则** | SRP / LoD |
|
||||||
|
| **描述** | `saveUploadedFile` 是通用文件 I/O 工具函数(创建目录 + 32KB buffer 循环写入),与 HTTP 处理无关,不依赖 controller 的任何字段。放在 controller 文件中职责不匹配。 |
|
||||||
|
|
||||||
|
**建议修复:** 移到 `internal/common/` 或新建 `internal/util/` 包。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 问题 6:Controller 层分页重复初始化
|
||||||
|
|
||||||
|
| 项 | 详情 |
|
||||||
|
|----|------|
|
||||||
|
| **文件** | `internal/controller/post_controller.go` |
|
||||||
|
| **位置** | `ListPage`(行 47-49)和 `ListAPI`(行 307-308) |
|
||||||
|
| **违反原则** | DRY |
|
||||||
|
| **描述** | Controller 层为模板渲染再次创建 `Pagination` 对象并调用 `DefaultPagination()`,而 Service 层 `List()` / `ListAdmin()` 内部已经做过一次分页参数校验。Controller 层可以信任 Service 返回的 total 直接计算。 |
|
||||||
|
|
||||||
|
```go
|
||||||
|
// controller 层重复的:
|
||||||
|
p := common.Pagination{Page: page, PageSize: pageSize}
|
||||||
|
p.DefaultPagination()
|
||||||
|
// ... 然后调用 service.List(),service 里又做了一遍
|
||||||
|
```
|
||||||
|
|
||||||
|
**建议修复:** Controller 层直接用 `common.Pagination.PageCount(total, pageSize)` 计算总页数,不再重复调用 `DefaultPagination()`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 问题 7:Admin Restore 错误分类缺失
|
||||||
|
|
||||||
|
| 项 | 详情 |
|
||||||
|
|----|------|
|
||||||
|
| **文件** | `internal/controller/admin/admin_post_controller.go` |
|
||||||
|
| **位置** | `Restore`(行 153-155) |
|
||||||
|
| **违反原则** | 一致性 |
|
||||||
|
| **描述** | 同文件内其他方法(`Approve`/`Reject`/`Unlock`/`Lock`)都对 `ErrPostNotFound` 做 `errors.Is` 精确匹配返回 400,但 `Restore` 没有,所有错误统一返回 500。 |
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Restore — 缺少错误分类
|
||||||
|
func (ctrl *AdminPostController) Restore(c *gin.Context) {
|
||||||
|
// ...
|
||||||
|
if err := ctrl.postService.Restore(id); err != nil {
|
||||||
|
// ← 此处应匹配 ErrPostNotFound,返回 400 而非 500
|
||||||
|
common.Error(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**建议修复:** 添加 `errors.Is(err, common.ErrPostNotFound)` 判断,返回 400。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、亮点
|
||||||
|
|
||||||
|
1. **状态机设计清晰**:Post 状态流转(draft → pending → approved/rejected → locked)每个转换有明确前置条件检查和专用错误哨兵
|
||||||
|
2. **接口设计优秀**:`postUseCase` vs `adminPostUseCase` 的分离体现良好的关注点分离
|
||||||
|
3. **错误哨兵统一管理**:`common/errors.go` 集中定义所有 Post 相关错误
|
||||||
|
4. **审核开关灵活**:通过 `SiteSettings.IsAuditEnabled()` 运行时控制
|
||||||
|
5. **Shortcode 扩展性好**:新增类型只需添加常量和 case 分支
|
||||||
|
6. **软删除 + 恢复**:完善的软删除和恢复机制
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、优先级建议
|
||||||
|
|
||||||
|
| 优先级 | 问题编号 | 原因 |
|
||||||
|
|--------|---------|------|
|
||||||
|
| P0 | — | 无阻塞性问题 |
|
||||||
|
| P1 | 1, 3 | Repository/Controller 重复影响维护成本 |
|
||||||
|
| P2 | 2, 4 | Service 重复 + Model 职责拆分 |
|
||||||
|
| P3 | 5, 6, 7 | 轻微优化项 |
|
||||||
20
internal/common/context.go
Normal file
20
internal/common/context.go
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import "github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
// GetGinUser 从 Gin context 中提取当前登录用户的 uid 和 role
|
||||||
|
// 如果用户未登录,ok 返回 false;role 可能为空字符串
|
||||||
|
func GetGinUser(c *gin.Context) (uid uint, role string, ok bool) {
|
||||||
|
if v, exists := c.Get("uid"); exists {
|
||||||
|
if u, isUint := v.(uint); isUint {
|
||||||
|
uid = u
|
||||||
|
ok = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v, exists := c.Get("role"); exists {
|
||||||
|
if r, isStr := v.(string); isStr {
|
||||||
|
role = r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
29
internal/common/file.go
Normal file
29
internal/common/file.go
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"mime/multipart"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SaveUploadedFile 将 multipart.File 内容写入目标路径
|
||||||
|
func SaveUploadedFile(file multipart.File, dst string) error {
|
||||||
|
out, err := os.Create(dst)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
|
||||||
|
buf := make([]byte, 32*1024)
|
||||||
|
for {
|
||||||
|
n, err := file.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
if _, writeErr := out.Write(buf[:n]); writeErr != nil {
|
||||||
|
return writeErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@ -1,11 +1,25 @@
|
|||||||
package common
|
package common
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"regexp"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/model"
|
"metazone.cc/metalab/internal/model"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// mdImageRe 匹配 Markdown 图片语法 
|
||||||
|
var mdImageRe = regexp.MustCompile(`!\[.*?\]\((.*?)\)`)
|
||||||
|
|
||||||
|
// ExtractFirstImage 从 Markdown 正文提取第一张图片 URL
|
||||||
|
func ExtractFirstImage(md string) string {
|
||||||
|
match := mdImageRe.FindStringSubmatch(md)
|
||||||
|
if len(match) > 1 {
|
||||||
|
return match[1]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// BuildPageData 构建页面模板数据,自动注入登录状态、站点信息与 CSRF token
|
// BuildPageData 构建页面模板数据,自动注入登录状态、站点信息与 CSRF token
|
||||||
func BuildPageData(c *gin.Context, extra gin.H) gin.H {
|
func BuildPageData(c *gin.Context, extra gin.H) gin.H {
|
||||||
data := gin.H{}
|
data := gin.H{}
|
||||||
|
|||||||
@ -49,5 +49,13 @@ func (p *Pagination) NextPage(totalPages int) int {
|
|||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PageCount 根据 total 和 pageSize 计算总页数(无需创建 Pagination 实例)
|
||||||
|
func PageCount(total int64, pageSize int) int {
|
||||||
|
if total == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return int((total + int64(pageSize) - 1) / int64(pageSize))
|
||||||
|
}
|
||||||
|
|
||||||
// 固定时间格式,前后端统一
|
// 固定时间格式,前后端统一
|
||||||
const TimeFormat = time.RFC3339
|
const TimeFormat = time.RFC3339
|
||||||
|
|||||||
12
internal/common/post_helpers.go
Normal file
12
internal/common/post_helpers.go
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import "metazone.cc/metalab/internal/model"
|
||||||
|
|
||||||
|
// PostStatusDisplayNames 帖子状态 → 中文名称映射(供模板渲染使用)
|
||||||
|
var PostStatusDisplayNames = map[string]string{
|
||||||
|
model.PostStatusDraft: "草稿",
|
||||||
|
model.PostStatusPending: "待审核",
|
||||||
|
model.PostStatusApproved: "已发布",
|
||||||
|
model.PostStatusRejected: "已退回",
|
||||||
|
model.PostStatusLocked: "已锁定",
|
||||||
|
}
|
||||||
@ -37,25 +37,27 @@ func (ctrl *AdminPostController) PostsPage(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if posts == nil {
|
totalPages := common.PageCount(total, pageSize)
|
||||||
posts = []model.Post{}
|
prevPage := page - 1
|
||||||
|
if prevPage < 1 {
|
||||||
|
prevPage = 1
|
||||||
|
}
|
||||||
|
nextPage := page + 1
|
||||||
|
if nextPage > totalPages {
|
||||||
|
nextPage = totalPages
|
||||||
}
|
}
|
||||||
|
|
||||||
p := common.Pagination{Page: page, PageSize: pageSize}
|
|
||||||
p.DefaultPagination()
|
|
||||||
totalPages := p.TotalPages(total)
|
|
||||||
|
|
||||||
c.HTML(http.StatusOK, "admin/posts/index.html", common.BuildAdminPageData(c, gin.H{
|
c.HTML(http.StatusOK, "admin/posts/index.html", common.BuildAdminPageData(c, gin.H{
|
||||||
"Title": "内容管理",
|
"Title": "内容管理",
|
||||||
"Posts": posts,
|
"Posts": posts,
|
||||||
"Total": total,
|
"Total": total,
|
||||||
"Page": p.Page,
|
"Page": page,
|
||||||
"TotalPages": totalPages,
|
"TotalPages": totalPages,
|
||||||
"PrevPage": p.PrevPage(),
|
"PrevPage": prevPage,
|
||||||
"NextPage": p.NextPage(totalPages),
|
"NextPage": nextPage,
|
||||||
"Keyword": keyword,
|
"Keyword": keyword,
|
||||||
"Status": status,
|
"Status": status,
|
||||||
"StatusNames": model.PostStatusDisplayNames,
|
"StatusNames": common.PostStatusDisplayNames,
|
||||||
"ExtraCSS": "/admin/static/css/posts.css",
|
"ExtraCSS": "/admin/static/css/posts.css",
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@ -155,7 +157,11 @@ func (ctrl *AdminPostController) Restore(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := ctrl.postService.Restore(uint(id)); err != nil {
|
if err := ctrl.postService.Restore(uint(id)); err != nil {
|
||||||
|
if errors.Is(err, common.ErrPostNotFound) {
|
||||||
|
common.Error(c, http.StatusBadRequest, err.Error())
|
||||||
|
} else {
|
||||||
common.Error(c, http.StatusInternalServerError, "恢复失败")
|
common.Error(c, http.StatusInternalServerError, "恢复失败")
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -25,7 +25,7 @@ type rateLimiter interface {
|
|||||||
Clear(email, ip string)
|
Clear(email, ip string)
|
||||||
}
|
}
|
||||||
|
|
||||||
// postUseCase PostController 对 PostService 的最小依赖(ISP:6 个方法)
|
// postUseCase PostController 对 PostService 的最小依赖(ISP:7 个方法)
|
||||||
type postUseCase interface {
|
type postUseCase interface {
|
||||||
Create(userID uint, title, body string) (*model.Post, error)
|
Create(userID uint, title, body string) (*model.Post, error)
|
||||||
GetByID(id uint) (*model.Post, error)
|
GetByID(id uint) (*model.Post, error)
|
||||||
@ -33,4 +33,11 @@ type postUseCase interface {
|
|||||||
Update(postID uint, title, body string) error
|
Update(postID uint, title, body string) error
|
||||||
Delete(postID uint) error
|
Delete(postID uint) error
|
||||||
SubmitForAudit(postID uint) error
|
SubmitForAudit(postID uint) error
|
||||||
|
IsPostAccessible(userID uint, role string, postUserID uint) bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// spaceUseCase SpaceController 对 SpaceService 的最小依赖(ISP:2 个方法)
|
||||||
|
type spaceUseCase interface {
|
||||||
|
GetSpaceUser(uid uint) (*model.User, error)
|
||||||
|
GetPostsByUser(uid uint, page, pageSize int) ([]model.Post, int64, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,6 @@ package controller
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"mime/multipart"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@ -44,22 +43,24 @@ func (ctrl *PostController) ListPage(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if posts == nil {
|
totalPages := common.PageCount(total, pageSize)
|
||||||
posts = []model.Post{}
|
prevPage := page - 1
|
||||||
|
if prevPage < 1 {
|
||||||
|
prevPage = 1
|
||||||
|
}
|
||||||
|
nextPage := page + 1
|
||||||
|
if nextPage > totalPages {
|
||||||
|
nextPage = totalPages
|
||||||
}
|
}
|
||||||
|
|
||||||
p := common.Pagination{Page: page, PageSize: pageSize}
|
|
||||||
p.DefaultPagination()
|
|
||||||
totalPages := p.TotalPages(total)
|
|
||||||
|
|
||||||
c.HTML(http.StatusOK, "posts/index.html", common.BuildPageData(c, gin.H{
|
c.HTML(http.StatusOK, "posts/index.html", common.BuildPageData(c, gin.H{
|
||||||
"Title": "社区帖子",
|
"Title": "社区帖子",
|
||||||
"Posts": posts,
|
"Posts": posts,
|
||||||
"Total": total,
|
"Total": total,
|
||||||
"Page": p.Page,
|
"Page": page,
|
||||||
"TotalPages": totalPages,
|
"TotalPages": totalPages,
|
||||||
"PrevPage": p.PrevPage(),
|
"PrevPage": prevPage,
|
||||||
"NextPage": p.NextPage(totalPages),
|
"NextPage": nextPage,
|
||||||
"Keyword": keyword,
|
"Keyword": keyword,
|
||||||
"ExtraCSS": "/static/css/posts.css",
|
"ExtraCSS": "/static/css/posts.css",
|
||||||
}))
|
}))
|
||||||
@ -83,42 +84,39 @@ func (ctrl *PostController) ShowPage(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取当前用户信息
|
uid, role, _ := common.GetGinUser(c)
|
||||||
var uid uint
|
|
||||||
var role string
|
|
||||||
if uidObj, exists := c.Get("uid"); exists {
|
|
||||||
uid, _ = uidObj.(uint)
|
|
||||||
}
|
|
||||||
if roleObj, exists := c.Get("role"); exists {
|
|
||||||
role, _ = roleObj.(string)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 仅 approved 公开可见(作者本人 + moderator+ 可预览其他状态)
|
// 仅 approved 公开可见(作者本人 + moderator+ 可预览其他状态)
|
||||||
if post.Status != model.PostStatusApproved {
|
if post.Status != model.PostStatusApproved && !ctrl.postService.IsPostAccessible(uid, role, post.UserID) {
|
||||||
isAuthor := uid == post.UserID
|
|
||||||
isModerator := model.HasMinRole(role, model.RoleModerator)
|
|
||||||
|
|
||||||
if !isAuthor && !isModerator {
|
|
||||||
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
|
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
|
||||||
"Title": "未找到",
|
"Title": "未找到",
|
||||||
}))
|
}))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 处理 shortcode:[zone:type:params] → HTML 占位符
|
ctrl.applyShortcode(post)
|
||||||
// 占位 div 会被 Vditor.preview() 保留,由前端 shortcode.js 渲染为卡片
|
|
||||||
if ctrl.shortcodeSvc != nil {
|
// Open Graph 社交分享数据
|
||||||
result := ctrl.shortcodeSvc.Process(post.Body)
|
ogImage := common.ExtractFirstImage(post.Body)
|
||||||
post.Body = result.ProcessedBody
|
if ogImage != "" && !strings.HasPrefix(ogImage, "http") {
|
||||||
|
prefix := ""
|
||||||
|
if !strings.HasPrefix(ogImage, "/") {
|
||||||
|
prefix = "/"
|
||||||
}
|
}
|
||||||
|
ogImage = "https://" + c.Request.Host + prefix + ogImage
|
||||||
|
}
|
||||||
|
ogURL := fmt.Sprintf("https://%s/posts/%d", c.Request.Host, id)
|
||||||
|
|
||||||
c.HTML(http.StatusOK, "posts/show.html", common.BuildPageData(c, gin.H{
|
c.HTML(http.StatusOK, "posts/show.html", common.BuildPageData(c, gin.H{
|
||||||
"Title": post.Title,
|
"Title": post.Title,
|
||||||
"Post": post,
|
"Post": post,
|
||||||
"UID": uid,
|
"UID": uid,
|
||||||
"StatusNames": model.PostStatusDisplayNames,
|
"StatusNames": common.PostStatusDisplayNames,
|
||||||
"ExtraCSS": "/static/css/posts.css",
|
"ExtraCSS": "/static/css/posts.css",
|
||||||
|
"OgTitle": post.Title,
|
||||||
|
"OgDescription": post.Excerpt,
|
||||||
|
"OgImage": ogImage,
|
||||||
|
"OgURL": ogURL,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -148,16 +146,8 @@ func (ctrl *PostController) EditPage(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 权限检查:仅作者和 moderator+ 可编辑
|
uid, role, _ := common.GetGinUser(c)
|
||||||
var uid uint
|
if !ctrl.postService.IsPostAccessible(uid, role, post.UserID) {
|
||||||
var role string
|
|
||||||
if uidObj, exists := c.Get("uid"); exists {
|
|
||||||
uid, _ = uidObj.(uint)
|
|
||||||
}
|
|
||||||
if roleObj, exists := c.Get("role"); exists {
|
|
||||||
role, _ = roleObj.(string)
|
|
||||||
}
|
|
||||||
if post.UserID != uid && !model.HasMinRole(role, model.RoleModerator) {
|
|
||||||
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
|
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
|
||||||
"Title": "未找到",
|
"Title": "未找到",
|
||||||
}))
|
}))
|
||||||
@ -173,8 +163,7 @@ func (ctrl *PostController) EditPage(c *gin.Context) {
|
|||||||
|
|
||||||
// Create API 发帖
|
// Create API 发帖
|
||||||
func (ctrl *PostController) Create(c *gin.Context) {
|
func (ctrl *PostController) Create(c *gin.Context) {
|
||||||
uidObj, _ := c.Get("uid")
|
uid, _, ok := common.GetGinUser(c)
|
||||||
uid, ok := uidObj.(uint)
|
|
||||||
if !ok {
|
if !ok {
|
||||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
return
|
return
|
||||||
@ -197,8 +186,7 @@ func (ctrl *PostController) Create(c *gin.Context) {
|
|||||||
|
|
||||||
// Update API 编辑帖子
|
// Update API 编辑帖子
|
||||||
func (ctrl *PostController) Update(c *gin.Context) {
|
func (ctrl *PostController) Update(c *gin.Context) {
|
||||||
uidObj, _ := c.Get("uid")
|
uid, role, ok := common.GetGinUser(c)
|
||||||
uid, ok := uidObj.(uint)
|
|
||||||
if !ok {
|
if !ok {
|
||||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
return
|
return
|
||||||
@ -216,16 +204,7 @@ func (ctrl *PostController) Update(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 权限检查:取帖子归属
|
if _, ok := ctrl.getPostAndCheckAccess(uint(id), c, uid, role); !ok {
|
||||||
post, err := ctrl.postService.GetByID(uint(id))
|
|
||||||
if err != nil {
|
|
||||||
common.Error(c, http.StatusNotFound, "帖子不存在")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
roleObj, _ := c.Get("role")
|
|
||||||
role, _ := roleObj.(string)
|
|
||||||
if post.UserID != uid && !model.HasMinRole(role, model.RoleModerator) {
|
|
||||||
common.Error(c, http.StatusForbidden, "无权编辑此帖子")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -243,8 +222,7 @@ func (ctrl *PostController) Update(c *gin.Context) {
|
|||||||
|
|
||||||
// Delete API 删除帖子
|
// Delete API 删除帖子
|
||||||
func (ctrl *PostController) Delete(c *gin.Context) {
|
func (ctrl *PostController) Delete(c *gin.Context) {
|
||||||
uidObj, _ := c.Get("uid")
|
uid, role, ok := common.GetGinUser(c)
|
||||||
uid, ok := uidObj.(uint)
|
|
||||||
if !ok {
|
if !ok {
|
||||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
return
|
return
|
||||||
@ -256,16 +234,7 @@ func (ctrl *PostController) Delete(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
post, err := ctrl.postService.GetByID(uint(id))
|
if _, ok := ctrl.getPostAndCheckAccess(uint(id), c, uid, role); !ok {
|
||||||
if err != nil {
|
|
||||||
common.Error(c, http.StatusNotFound, "帖子不存在")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
roleObj, _ := c.Get("role")
|
|
||||||
role, _ := roleObj.(string)
|
|
||||||
if post.UserID != uid && !model.HasMinRole(role, model.RoleModerator) {
|
|
||||||
common.Error(c, http.StatusForbidden, "无权删除此帖子")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -277,10 +246,24 @@ func (ctrl *PostController) Delete(c *gin.Context) {
|
|||||||
common.OkMessage(c, "删除成功")
|
common.OkMessage(c, "删除成功")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getPostAndCheckAccess 获取帖子并检查当前用户权限
|
||||||
|
// 返回 (*model.Post, true) 表示通过;返回 (nil, false) 表示已写入错误响应
|
||||||
|
func (ctrl *PostController) getPostAndCheckAccess(id uint, c *gin.Context, uid uint, role string) (*model.Post, bool) {
|
||||||
|
post, err := ctrl.postService.GetByID(id)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusNotFound, "帖子不存在")
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if !ctrl.postService.IsPostAccessible(uid, role, post.UserID) {
|
||||||
|
common.Error(c, http.StatusForbidden, "无权操作此帖子")
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return post, true
|
||||||
|
}
|
||||||
|
|
||||||
// Submit API 提交审核
|
// Submit API 提交审核
|
||||||
func (ctrl *PostController) Submit(c *gin.Context) {
|
func (ctrl *PostController) Submit(c *gin.Context) {
|
||||||
uidObj, _ := c.Get("uid")
|
uid, _, ok := common.GetGinUser(c)
|
||||||
uid, ok := uidObj.(uint)
|
|
||||||
if !ok {
|
if !ok {
|
||||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
return
|
return
|
||||||
@ -292,12 +275,12 @@ func (ctrl *PostController) Submit(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 权限检查:仅帖子作者可提交审核
|
|
||||||
post, err := ctrl.postService.GetByID(uint(id))
|
post, err := ctrl.postService.GetByID(uint(id))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.Error(c, http.StatusNotFound, "帖子不存在")
|
common.Error(c, http.StatusNotFound, "帖子不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if post.UserID != uid {
|
if post.UserID != uid {
|
||||||
common.Error(c, http.StatusForbidden, "无权操作此帖子")
|
common.Error(c, http.StatusForbidden, "无权操作此帖子")
|
||||||
return
|
return
|
||||||
@ -327,18 +310,11 @@ func (ctrl *PostController) ListAPI(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if posts == nil {
|
|
||||||
posts = []model.Post{}
|
|
||||||
}
|
|
||||||
|
|
||||||
p := common.Pagination{Page: page, PageSize: pageSize}
|
|
||||||
p.DefaultPagination()
|
|
||||||
|
|
||||||
common.Ok(c, model.PostListResult{
|
common.Ok(c, model.PostListResult{
|
||||||
Items: posts,
|
Items: posts,
|
||||||
Total: total,
|
Total: total,
|
||||||
Page: p.Page,
|
Page: page,
|
||||||
TotalPages: p.TotalPages(total),
|
TotalPages: common.PageCount(total, pageSize),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -358,27 +334,14 @@ func (ctrl *PostController) ShowAPI(c *gin.Context) {
|
|||||||
|
|
||||||
// 权限控制:非 approved 帖子仅作者和 moderator+ 可见
|
// 权限控制:非 approved 帖子仅作者和 moderator+ 可见
|
||||||
if post.Status != model.PostStatusApproved {
|
if post.Status != model.PostStatusApproved {
|
||||||
var uid uint
|
uid, role, _ := common.GetGinUser(c)
|
||||||
var role string
|
if !ctrl.postService.IsPostAccessible(uid, role, post.UserID) {
|
||||||
if uidObj, exists := c.Get("uid"); exists {
|
|
||||||
uid, _ = uidObj.(uint)
|
|
||||||
}
|
|
||||||
if roleObj, exists := c.Get("role"); exists {
|
|
||||||
role, _ = roleObj.(string)
|
|
||||||
}
|
|
||||||
isAuthor := uid == post.UserID
|
|
||||||
isModerator := model.HasMinRole(role, model.RoleModerator)
|
|
||||||
if !isAuthor && !isModerator {
|
|
||||||
common.Error(c, http.StatusNotFound, "帖子不存在")
|
common.Error(c, http.StatusNotFound, "帖子不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理 shortcode:[zone:type:params] → HTML 占位符
|
ctrl.applyShortcode(post)
|
||||||
if ctrl.shortcodeSvc != nil {
|
|
||||||
result := ctrl.shortcodeSvc.Process(post.Body)
|
|
||||||
post.Body = result.ProcessedBody
|
|
||||||
}
|
|
||||||
|
|
||||||
common.Ok(c, post)
|
common.Ok(c, post)
|
||||||
}
|
}
|
||||||
@ -390,8 +353,7 @@ var allowedImageExt = map[string]bool{
|
|||||||
|
|
||||||
// UploadImage 上传帖子图片(需登录,multipart/form-data)
|
// UploadImage 上传帖子图片(需登录,multipart/form-data)
|
||||||
func (ctrl *PostController) UploadImage(c *gin.Context) {
|
func (ctrl *PostController) UploadImage(c *gin.Context) {
|
||||||
uidObj, _ := c.Get("uid")
|
uid, _, ok := common.GetGinUser(c)
|
||||||
uid, ok := uidObj.(uint)
|
|
||||||
if !ok {
|
if !ok {
|
||||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
return
|
return
|
||||||
@ -430,7 +392,7 @@ func (ctrl *PostController) UploadImage(c *gin.Context) {
|
|||||||
filename := fmt.Sprintf("%d_%d%s", uid, ts, ext)
|
filename := fmt.Sprintf("%d_%d%s", uid, ts, ext)
|
||||||
savePath := filepath.Join(storageDir, filename)
|
savePath := filepath.Join(storageDir, filename)
|
||||||
|
|
||||||
if err := saveUploadedFile(file, savePath); err != nil {
|
if err := common.SaveUploadedFile(file, savePath); err != nil {
|
||||||
common.Error(c, http.StatusInternalServerError, "保存图片失败")
|
common.Error(c, http.StatusInternalServerError, "保存图片失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -439,26 +401,11 @@ func (ctrl *PostController) UploadImage(c *gin.Context) {
|
|||||||
common.VditorUploadOk(c, map[string]string{header.Filename: url})
|
common.VditorUploadOk(c, map[string]string{header.Filename: url})
|
||||||
}
|
}
|
||||||
|
|
||||||
// saveUploadedFile 将 multipart.File 写入目标路径
|
// applyShortcode 处理帖子正文中的 shortcode 标记,替换为 HTML 占位符
|
||||||
func saveUploadedFile(file multipart.File, dst string) error {
|
func (ctrl *PostController) applyShortcode(post *model.Post) {
|
||||||
out, err := os.Create(dst)
|
if ctrl.shortcodeSvc != nil {
|
||||||
if err != nil {
|
result := ctrl.shortcodeSvc.Process(post.Body)
|
||||||
return err
|
post.Body = result.ProcessedBody
|
||||||
|
}
|
||||||
}
|
}
|
||||||
defer out.Close()
|
|
||||||
|
|
||||||
// 限制读取 5MB 防止内存放大
|
|
||||||
buf := make([]byte, 32*1024)
|
|
||||||
for {
|
|
||||||
n, err := file.Read(buf)
|
|
||||||
if n > 0 {
|
|
||||||
if _, writeErr := out.Write(buf[:n]); writeErr != nil {
|
|
||||||
return writeErr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
103
internal/controller/space_controller.go
Normal file
103
internal/controller/space_controller.go
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"metazone.cc/metalab/internal/common"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SpaceController 用户空间控制器
|
||||||
|
type SpaceController struct {
|
||||||
|
spaceService spaceUseCase
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSpaceController 构造函数
|
||||||
|
func NewSpaceController(spaceService spaceUseCase) *SpaceController {
|
||||||
|
return &SpaceController{spaceService: spaceService}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MySpace 自己的空间(/space)— 需登录,重定向到 /space/{uid}
|
||||||
|
func (ctrl *SpaceController) MySpace(c *gin.Context) {
|
||||||
|
uid, _, ok := common.GetGinUser(c)
|
||||||
|
if !ok {
|
||||||
|
c.Redirect(http.StatusFound, "/auth/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Redirect(http.StatusFound, "/space/"+strconv.FormatUint(uint64(uid), 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShowSpace 查看他人或自己的空间(/space/:uid)— 无需认证
|
||||||
|
func (ctrl *SpaceController) ShowSpace(c *gin.Context) {
|
||||||
|
uidStr := c.Param("uid")
|
||||||
|
uid, err := strconv.ParseUint(uidStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.HTML(http.StatusNotFound, "space/index.html", common.BuildPageData(c, gin.H{
|
||||||
|
"Title": "用户不存在",
|
||||||
|
"Error": "用户不存在",
|
||||||
|
"ExtraCSS": "/static/css/space.css",
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
spaceUser, err := ctrl.spaceService.GetSpaceUser(uint(uid))
|
||||||
|
if err != nil || spaceUser == nil {
|
||||||
|
c.HTML(http.StatusNotFound, "space/index.html", common.BuildPageData(c, gin.H{
|
||||||
|
"Title": "用户不存在",
|
||||||
|
"Error": "用户不存在",
|
||||||
|
"ExtraCSS": "/static/css/space.css",
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10"))
|
||||||
|
if pageSize < 1 || pageSize > 50 {
|
||||||
|
pageSize = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
posts, total, err := ctrl.spaceService.GetPostsByUser(uint(uid), page, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
c.HTML(http.StatusInternalServerError, "space/index.html", common.BuildPageData(c, gin.H{
|
||||||
|
"Title": "加载失败",
|
||||||
|
"Error": "加载帖子失败,请稍后重试",
|
||||||
|
"ExtraCSS": "/static/css/space.css",
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
totalPages := common.PageCount(total, pageSize)
|
||||||
|
prevPage := page - 1
|
||||||
|
if prevPage < 1 {
|
||||||
|
prevPage = 1
|
||||||
|
}
|
||||||
|
nextPage := page + 1
|
||||||
|
if nextPage > totalPages {
|
||||||
|
nextPage = totalPages
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否是自己的空间
|
||||||
|
isOwnSpace := false
|
||||||
|
if currentUID, _, ok := common.GetGinUser(c); ok {
|
||||||
|
isOwnSpace = currentUID == uint(uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "space/index.html", common.BuildPageData(c, gin.H{
|
||||||
|
"Title": spaceUser.Username + " 的空间",
|
||||||
|
"SpaceUser": spaceUser,
|
||||||
|
"Posts": posts,
|
||||||
|
"Total": total,
|
||||||
|
"Page": page,
|
||||||
|
"TotalPages": totalPages,
|
||||||
|
"PrevPage": prevPage,
|
||||||
|
"NextPage": nextPage,
|
||||||
|
"IsOwnSpace": isOwnSpace,
|
||||||
|
"ExtraCSS": "/static/css/space.css",
|
||||||
|
}))
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/config"
|
"metazone.cc/metalab/internal/config"
|
||||||
@ -44,12 +45,16 @@ func CSRF(cfg *config.Config) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !constantTimeEq(cookieToken, headerToken) {
|
if !constantTimeEq(cookieToken, headerToken) {
|
||||||
|
log.Printf("[CSRF] MISMATCH | path=%s | cookie(len=%d)=%q | header(len=%d)=%q",
|
||||||
|
c.Request.URL.Path, len(cookieToken), cookieToken, len(headerToken), headerToken)
|
||||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
||||||
"success": false, "message": "CSRF 验证失败",
|
"success": false, "message": "CSRF 验证失败",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Printf("[CSRF] OK | path=%s | token(len=%d)=%q", c.Request.URL.Path, len(cookieToken), cookieToken)
|
||||||
|
|
||||||
c.Set(csrfMetaName, cookieToken)
|
c.Set(csrfMetaName, cookieToken)
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,4 +20,37 @@ type CheckEmailRequest struct {
|
|||||||
Email string `json:"email" binding:"required,email"`
|
Email string `json:"email" binding:"required,email"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Post DTOs ----
|
||||||
|
|
||||||
|
// PostListResult 帖子列表查询结果
|
||||||
|
type PostListResult struct {
|
||||||
|
Items []Post `json:"items"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
TotalPages int `json:"total_pages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostCreateRequest 发帖请求
|
||||||
|
type PostCreateRequest struct {
|
||||||
|
Title string `json:"title" binding:"required,min=1,max=200"`
|
||||||
|
Body string `json:"body" binding:"required,min=1"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostUpdateRequest 编辑请求
|
||||||
|
type PostUpdateRequest struct {
|
||||||
|
Title string `json:"title" binding:"required,min=1,max=200"`
|
||||||
|
Body string `json:"body" binding:"required,min=1"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostRejectRequest 退回请求
|
||||||
|
type PostRejectRequest struct {
|
||||||
|
Reason string `json:"reason" binding:"required,min=1,max=500"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostListQuery 列表查询参数
|
||||||
|
type PostListQuery struct {
|
||||||
|
Keyword string `form:"keyword"`
|
||||||
|
Status string `form:"status"`
|
||||||
|
Page int `form:"page"`
|
||||||
|
PageSize int `form:"page_size"`
|
||||||
|
}
|
||||||
@ -34,45 +34,3 @@ const (
|
|||||||
PostStatusRejected = "rejected"
|
PostStatusRejected = "rejected"
|
||||||
PostStatusLocked = "locked"
|
PostStatusLocked = "locked"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PostStatusDisplayNames 状态 → 中文名
|
|
||||||
var PostStatusDisplayNames = map[string]string{
|
|
||||||
PostStatusDraft: "草稿",
|
|
||||||
PostStatusPending: "待审核",
|
|
||||||
PostStatusApproved: "已发布",
|
|
||||||
PostStatusRejected: "已退回",
|
|
||||||
PostStatusLocked: "已锁定",
|
|
||||||
}
|
|
||||||
|
|
||||||
// PostListResult 帖子列表查询结果
|
|
||||||
type PostListResult struct {
|
|
||||||
Items []Post `json:"items"`
|
|
||||||
Total int64 `json:"total"`
|
|
||||||
Page int `json:"page"`
|
|
||||||
TotalPages int `json:"total_pages"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// PostCreateRequest 发帖请求
|
|
||||||
type PostCreateRequest struct {
|
|
||||||
Title string `json:"title" binding:"required,min=1,max=200"`
|
|
||||||
Body string `json:"body" binding:"required,min=1"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// PostUpdateRequest 编辑请求
|
|
||||||
type PostUpdateRequest struct {
|
|
||||||
Title string `json:"title" binding:"required,min=1,max=200"`
|
|
||||||
Body string `json:"body" binding:"required,min=1"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// PostRejectRequest 退回请求
|
|
||||||
type PostRejectRequest struct {
|
|
||||||
Reason string `json:"reason" binding:"required,min=1,max=500"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// PostListQuery 列表查询参数
|
|
||||||
type PostListQuery struct {
|
|
||||||
Keyword string `form:"keyword"`
|
|
||||||
Status string `form:"status"`
|
|
||||||
Page int `form:"page"`
|
|
||||||
PageSize int `form:"page_size"`
|
|
||||||
}
|
|
||||||
|
|||||||
@ -45,35 +45,8 @@ func (r *PostRepo) FindByIDWithAuthor(id uint) (*model.Post, error) {
|
|||||||
return &post, nil
|
return &post, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// FindPageable 分页查询帖子列表(仅 approved 状态,关键词模糊搜索标题)
|
// buildQuery 构建帖子查询公共部分(联表 + 未删除 + 关键词 + 可选状态过滤)
|
||||||
func (r *PostRepo) FindPageable(keyword string, offset, limit int) ([]model.Post, int64, error) {
|
func (r *PostRepo) buildQuery(keyword, status string) *gorm.DB {
|
||||||
query := r.db.Table("posts").
|
|
||||||
Select("posts.*, users.username as author_name").
|
|
||||||
Joins("LEFT JOIN users ON users.uid = posts.user_id").
|
|
||||||
Where("posts.deleted_at IS NULL").
|
|
||||||
Where("posts.status = ?", model.PostStatusApproved)
|
|
||||||
|
|
||||||
if keyword != "" {
|
|
||||||
like := "%" + keyword + "%"
|
|
||||||
query = query.Where("posts.title LIKE ?", like)
|
|
||||||
}
|
|
||||||
|
|
||||||
var total int64
|
|
||||||
if err := query.Count(&total).Error; err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var posts []model.Post
|
|
||||||
err := query.Order("posts.created_at DESC").
|
|
||||||
Offset(offset).Limit(limit).Find(&posts).Error
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
return posts, total, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// FindAdminPageable 管理后台分页查询(全状态筛选)
|
|
||||||
func (r *PostRepo) FindAdminPageable(keyword, status string, offset, limit int) ([]model.Post, int64, error) {
|
|
||||||
query := r.db.Table("posts").
|
query := r.db.Table("posts").
|
||||||
Select("posts.*, users.username as author_name").
|
Select("posts.*, users.username as author_name").
|
||||||
Joins("LEFT JOIN users ON users.uid = posts.user_id").
|
Joins("LEFT JOIN users ON users.uid = posts.user_id").
|
||||||
@ -86,7 +59,30 @@ func (r *PostRepo) FindAdminPageable(keyword, status string, offset, limit int)
|
|||||||
if status != "" {
|
if status != "" {
|
||||||
query = query.Where("posts.status = ?", status)
|
query = query.Where("posts.status = ?", status)
|
||||||
}
|
}
|
||||||
|
return query
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindPageable 分页查询帖子列表(仅 approved 状态,关键词模糊搜索标题)
|
||||||
|
func (r *PostRepo) FindPageable(keyword string, offset, limit int) ([]model.Post, int64, error) {
|
||||||
|
query := r.buildQuery(keyword, model.PostStatusApproved)
|
||||||
|
return r.pageResults(query, offset, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindAdminPageable 管理后台分页查询(全状态筛选)
|
||||||
|
func (r *PostRepo) FindAdminPageable(keyword, status string, offset, limit int) ([]model.Post, int64, error) {
|
||||||
|
query := r.buildQuery(keyword, status)
|
||||||
|
return r.pageResults(query, offset, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByUserID 分页查询某用户发布的帖子(仅 approved,含作者用户名)
|
||||||
|
func (r *PostRepo) FindByUserID(userID uint, offset, limit int) ([]model.Post, int64, error) {
|
||||||
|
query := r.buildQuery("", model.PostStatusApproved).
|
||||||
|
Where("posts.user_id = ?", userID)
|
||||||
|
return r.pageResults(query, offset, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pageResults 执行 Count + Offset/Limit + ORDER BY
|
||||||
|
func (r *PostRepo) pageResults(query *gorm.DB, offset, limit int) ([]model.Post, int64, error) {
|
||||||
var total int64
|
var total int64
|
||||||
if err := query.Count(&total).Error; err != nil {
|
if err := query.Count(&total).Error; err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
@ -113,5 +109,12 @@ func (r *PostRepo) SoftDelete(id uint) error {
|
|||||||
|
|
||||||
// Restore 恢复软删除
|
// Restore 恢复软删除
|
||||||
func (r *PostRepo) Restore(id uint) error {
|
func (r *PostRepo) Restore(id uint) error {
|
||||||
return r.db.Unscoped().Model(&model.Post{}).Where("id = ?", id).Update("deleted_at", nil).Error
|
result := r.db.Unscoped().Model(&model.Post{}).Where("id = ?", id).Update("deleted_at", nil)
|
||||||
|
if result.Error != nil {
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return gorm.ErrRecordNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,6 +17,7 @@ type dependencies struct {
|
|||||||
settingsCtrl *controller.SettingsController
|
settingsCtrl *controller.SettingsController
|
||||||
messageCtrl *controller.MessageController
|
messageCtrl *controller.MessageController
|
||||||
postCtrl *controller.PostController
|
postCtrl *controller.PostController
|
||||||
|
spaceCtrl *controller.SpaceController
|
||||||
adminPostCtrl *adminCtrl.AdminPostController
|
adminPostCtrl *adminCtrl.AdminPostController
|
||||||
adminCtrl *adminCtrl.AdminController
|
adminCtrl *adminCtrl.AdminController
|
||||||
auditCtrl *adminCtrl.AuditController
|
auditCtrl *adminCtrl.AuditController
|
||||||
|
|||||||
@ -36,9 +36,14 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
|
|||||||
postCtrl := controller.NewPostController(postService, shortcodeSvc)
|
postCtrl := controller.NewPostController(postService, shortcodeSvc)
|
||||||
adminPostCtrl := adminCtrl.NewAdminPostController(postService)
|
adminPostCtrl := adminCtrl.NewAdminPostController(postService)
|
||||||
|
|
||||||
|
// 用户空间
|
||||||
|
spaceService := service.NewSpaceService(userRepo, postRepo)
|
||||||
|
spaceCtrl := controller.NewSpaceController(spaceService)
|
||||||
|
|
||||||
return &dependencies{
|
return &dependencies{
|
||||||
authCtrl: authCtrl, authMdw: authMdw, settingsCtrl: settingsCtrl,
|
authCtrl: authCtrl, authMdw: authMdw, settingsCtrl: settingsCtrl,
|
||||||
messageCtrl: messageController, postCtrl: postCtrl, adminPostCtrl: adminPostCtrl,
|
messageCtrl: messageController, postCtrl: postCtrl, spaceCtrl: spaceCtrl,
|
||||||
|
adminPostCtrl: adminPostCtrl,
|
||||||
adminCtrl: adminController, auditCtrl: auditController,
|
adminCtrl: adminController, auditCtrl: auditController,
|
||||||
siteSettingCtrl: siteSettingController, tokenCtrl: authCtrl,
|
siteSettingCtrl: siteSettingController, tokenCtrl: authCtrl,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -34,6 +34,10 @@ func setupFrontendRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
|
|||||||
pages.GET("/settings/:tab", d.settingsCtrl.SettingsPage)
|
pages.GET("/settings/:tab", d.settingsCtrl.SettingsPage)
|
||||||
pages.GET("/messages", d.messageCtrl.MessagesPage)
|
pages.GET("/messages", d.messageCtrl.MessagesPage)
|
||||||
|
|
||||||
|
// 用户空间(静态 /space 必须在 :uid 之前注册)
|
||||||
|
pages.GET("/space", d.spaceCtrl.MySpace)
|
||||||
|
pages.GET("/space/:uid", d.spaceCtrl.ShowSpace)
|
||||||
|
|
||||||
// 帖子页面
|
// 帖子页面
|
||||||
pages.GET("/posts", d.postCtrl.ListPage)
|
pages.GET("/posts", d.postCtrl.ListPage)
|
||||||
pages.GET("/posts/new", d.authMdw.Required(), d.postCtrl.NewPage)
|
pages.GET("/posts/new", d.authMdw.Required(), d.postCtrl.NewPage)
|
||||||
|
|||||||
@ -144,18 +144,29 @@ func (s *PostService) GetByID(id uint) (*model.Post, error) {
|
|||||||
return s.findPostWithAuthor(id)
|
return s.findPostWithAuthor(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// List 公开帖子列表(仅 approved)
|
// listPageable 分页查询公共逻辑:参数规范化 + nil 转空切片
|
||||||
func (s *PostService) List(keyword string, page, pageSize int) ([]model.Post, int64, error) {
|
func (s *PostService) listPageable(page, pageSize int, fn func(offset, limit int) ([]model.Post, int64, error)) ([]model.Post, int64, error) {
|
||||||
p := common.Pagination{Page: page, PageSize: pageSize}
|
p := common.Pagination{Page: page, PageSize: pageSize}
|
||||||
p.DefaultPagination()
|
p.DefaultPagination()
|
||||||
return s.repo.FindPageable(keyword, p.Offset(), p.PageSize)
|
posts, total, err := fn(p.Offset(), p.PageSize)
|
||||||
|
if posts == nil {
|
||||||
|
posts = []model.Post{}
|
||||||
|
}
|
||||||
|
return posts, total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// List 公开帖子列表(仅 approved)
|
||||||
|
func (s *PostService) List(keyword string, page, pageSize int) ([]model.Post, int64, error) {
|
||||||
|
return s.listPageable(page, pageSize, func(offset, limit int) ([]model.Post, int64, error) {
|
||||||
|
return s.repo.FindPageable(keyword, offset, limit)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListAdmin 管理后台帖子列表(全状态)
|
// ListAdmin 管理后台帖子列表(全状态)
|
||||||
func (s *PostService) ListAdmin(keyword, status string, page, pageSize int) ([]model.Post, int64, error) {
|
func (s *PostService) ListAdmin(keyword, status string, page, pageSize int) ([]model.Post, int64, error) {
|
||||||
p := common.Pagination{Page: page, PageSize: pageSize}
|
return s.listPageable(page, pageSize, func(offset, limit int) ([]model.Post, int64, error) {
|
||||||
p.DefaultPagination()
|
return s.repo.FindAdminPageable(keyword, status, offset, limit)
|
||||||
return s.repo.FindAdminPageable(keyword, status, p.Offset(), p.PageSize)
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update 编辑帖子(权限在 controller 层检查)
|
// Update 编辑帖子(权限在 controller 层检查)
|
||||||
@ -253,5 +264,17 @@ func (s *PostService) Unlock(postID uint) error {
|
|||||||
|
|
||||||
// Restore 恢复软删除
|
// Restore 恢复软删除
|
||||||
func (s *PostService) Restore(postID uint) error {
|
func (s *PostService) Restore(postID uint) error {
|
||||||
return s.repo.Restore(postID)
|
err := s.repo.Restore(postID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return common.ErrPostNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsPostAccessible 检查用户是否有权限访问/操作帖子(作者或 moderator+)
|
||||||
|
func (s *PostService) IsPostAccessible(userID uint, role string, postUserID uint) bool {
|
||||||
|
return userID == postUserID || model.HasMinRole(role, model.RoleModerator)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -42,3 +42,13 @@ type postStore interface {
|
|||||||
SoftDelete(id uint) error
|
SoftDelete(id uint) error
|
||||||
Restore(id uint) error
|
Restore(id uint) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// spaceUserStore SpaceService 所需的最小用户仓储接口(ISP:1 个方法)
|
||||||
|
type spaceUserStore interface {
|
||||||
|
FindByID(id uint) (*model.User, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// spacePostStore SpaceService 所需的最小帖子仓储接口(ISP:1 个方法)
|
||||||
|
type spacePostStore interface {
|
||||||
|
FindByUserID(userID uint, offset, limit int) ([]model.Post, int64, error)
|
||||||
|
}
|
||||||
25
internal/service/space_service.go
Normal file
25
internal/service/space_service.go
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import "metazone.cc/metalab/internal/model"
|
||||||
|
|
||||||
|
// SpaceService 用户空间服务
|
||||||
|
type SpaceService struct {
|
||||||
|
userRepo spaceUserStore
|
||||||
|
postRepo spacePostStore
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSpaceService 构造函数
|
||||||
|
func NewSpaceService(userRepo spaceUserStore, postRepo spacePostStore) *SpaceService {
|
||||||
|
return &SpaceService{userRepo: userRepo, postRepo: postRepo}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSpaceUser 获取用户信息
|
||||||
|
func (s *SpaceService) GetSpaceUser(uid uint) (*model.User, error) {
|
||||||
|
return s.userRepo.FindByID(uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPostsByUser 获取用户发布的帖子(分页)
|
||||||
|
func (s *SpaceService) GetPostsByUser(uid uint, page, pageSize int) ([]model.Post, int64, error) {
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
|
return s.postRepo.FindByUserID(uid, offset, pageSize)
|
||||||
|
}
|
||||||
@ -4,6 +4,14 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
{{if .CSRFToken}}<meta name="csrf-token" content="{{.CSRFToken}}">{{end}}
|
{{if .CSRFToken}}<meta name="csrf-token" content="{{.CSRFToken}}">{{end}}
|
||||||
|
{{if .OgTitle}}
|
||||||
|
<meta property="og:title" content="{{.OgTitle}}">
|
||||||
|
<meta property="og:type" content="article">
|
||||||
|
<meta property="og:site_name" content="MetaLab">
|
||||||
|
{{if .OgDescription}}<meta property="og:description" content="{{.OgDescription}}">{{end}}
|
||||||
|
{{if .OgImage}}<meta property="og:image" content="{{.OgImage}}">{{end}}
|
||||||
|
{{if .OgURL}}<meta property="og:url" content="{{.OgURL}}">{{end}}
|
||||||
|
{{end}}
|
||||||
<title>{{.Title}} - MetaLab</title>
|
<title>{{.Title}} - MetaLab</title>
|
||||||
<link rel="stylesheet" href="/static/css/common.css">
|
<link rel="stylesheet" href="/static/css/common.css">
|
||||||
{{if .ExtraCSS}}
|
{{if .ExtraCSS}}
|
||||||
|
|||||||
@ -14,7 +14,8 @@
|
|||||||
<span class="nav-bell-badge" id="msgBadge" style="display:none">0</span>
|
<span class="nav-bell-badge" id="msgBadge" style="display:none">0</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li><a href="/settings/profile" class="nav-user">{{.Username}}</a></li>
|
<li><a href="/space" class="nav-user">{{.Username}}</a></li>
|
||||||
|
<li><a href="/settings/profile" class="nav-settings">设置</a></li>
|
||||||
<li><a href="javascript:void(0)" class="nav-logout" id="logoutBtn">退出</a></li>
|
<li><a href="javascript:void(0)" class="nav-logout" id="logoutBtn">退出</a></li>
|
||||||
{{else}}
|
{{else}}
|
||||||
<li><a href="/auth/login" class="nav-login">登录</a></li>
|
<li><a href="/auth/login" class="nav-login">登录</a></li>
|
||||||
|
|||||||
@ -29,7 +29,7 @@
|
|||||||
<a href="/posts/{{.ID}}">{{.Title}}</a>
|
<a href="/posts/{{.ID}}">{{.Title}}</a>
|
||||||
</h2>
|
</h2>
|
||||||
<div class="post-card-meta">
|
<div class="post-card-meta">
|
||||||
<span class="post-author">{{if .AuthorName}}{{.AuthorName}}({{.UserID}}){{else}}UID{{.UserID}}{{end}}</span>
|
<span class="post-author">{{if .AuthorName}}{{.AuthorName}}{{else}}该用户已注销{{end}}</span>
|
||||||
<span class="post-time">{{.CreatedAt.Format "2006-01-02 15:04"}}</span>
|
<span class="post-time">{{.CreatedAt.Format "2006-01-02 15:04"}}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="post-card-summary">{{if .Excerpt}}{{.Excerpt}}{{else}}{{printf "%.200s" .Body}}{{end}}</p>
|
<p class="post-card-summary">{{if .Excerpt}}{{.Excerpt}}{{else}}{{printf "%.200s" .Body}}{{end}}</p>
|
||||||
|
|||||||
@ -24,6 +24,30 @@
|
|||||||
<div class="editor-pane">
|
<div class="editor-pane">
|
||||||
<div id="vditor"></div>
|
<div id="vditor"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<aside class="editor-sidebar">
|
||||||
|
<div class="editor-sidebar-section">
|
||||||
|
<h4>快捷语法</h4>
|
||||||
|
<ul class="tips-list">
|
||||||
|
<li><code>#</code> <code>##</code> <code>###</code> 标题</li>
|
||||||
|
<li><code>**粗体**</code> <code>*斜体*</code></li>
|
||||||
|
<li><code>`行内代码`</code></li>
|
||||||
|
<li><code>```</code> 代码块</li>
|
||||||
|
<li><code>></code> 引用</li>
|
||||||
|
<li><code>-</code> 无序列表</li>
|
||||||
|
<li><code>[文字](链接)</code></li>
|
||||||
|
<li><code></code></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="editor-sidebar-section">
|
||||||
|
<h4>发布提示</h4>
|
||||||
|
<ul class="tips-list">
|
||||||
|
<li>标题控制在 50 字以内</li>
|
||||||
|
<li>正文清晰分段,便于阅读</li>
|
||||||
|
<li>代码使用代码块包裹</li>
|
||||||
|
<li>Ctrl+S 快速保存草稿</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{/* 状态栏 */}}
|
{{/* 状态栏 */}}
|
||||||
|
|||||||
@ -8,7 +8,7 @@
|
|||||||
<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}}({{.Post.UserID}}){{else}}UID{{.Post.UserID}}{{end}}</span>
|
<span class="post-author">{{if .Post.AuthorName}}{{.Post.AuthorName}}{{else}}该用户已注销{{end}}</span>
|
||||||
<span class="post-time">{{.Post.CreatedAt.Format "2006-01-02 15:04"}}</span>
|
<span class="post-time">{{.Post.CreatedAt.Format "2006-01-02 15:04"}}</span>
|
||||||
{{if ne .Post.Status "approved"}}
|
{{if ne .Post.Status "approved"}}
|
||||||
<span class="post-status post-status-{{.Post.Status}}">{{index $.StatusNames .Post.Status}}</span>
|
<span class="post-status post-status-{{.Post.Status}}">{{index $.StatusNames .Post.Status}}</span>
|
||||||
@ -57,6 +57,18 @@
|
|||||||
cdn: '/static/vditor',
|
cdn: '/static/vditor',
|
||||||
theme: { current: 'light', path: '/static/vditor/dist/css/content-theme' },
|
theme: { current: 'light', path: '/static/vditor/dist/css/content-theme' },
|
||||||
hljs: { style: 'github-dark', enable: true },
|
hljs: { style: 'github-dark', enable: true },
|
||||||
|
after: function() {
|
||||||
|
// 外部链接自动加 nofollow + target="_blank",避免权重流失
|
||||||
|
var host = window.location.host;
|
||||||
|
var links = el.querySelectorAll('a[href^="http"]');
|
||||||
|
for (var i = 0; i < links.length; i++) {
|
||||||
|
var a = links[i];
|
||||||
|
if (a.host && a.host !== host) {
|
||||||
|
a.setAttribute('rel', 'nofollow noopener noreferrer');
|
||||||
|
a.setAttribute('target', '_blank');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Vditor.preview() 渲染 MD,shortcode 占位 div 原样保留
|
// Vditor.preview() 渲染 MD,shortcode 占位 div 原样保留
|
||||||
|
|||||||
93
templates/MetaLab-2026/html/space/index.html
Normal file
93
templates/MetaLab-2026/html/space/index.html
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
{{template "layout/header.html" .}}
|
||||||
|
<body>
|
||||||
|
|
||||||
|
{{template "layout/nav.html" .}}
|
||||||
|
|
||||||
|
{{if .Error}}
|
||||||
|
<div class="space-error">
|
||||||
|
<div class="container">
|
||||||
|
<h1>{{.Error}}</h1>
|
||||||
|
<a href="/" class="btn btn-primary">返回首页</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="space-page">
|
||||||
|
<!-- 用户信息头部 -->
|
||||||
|
<div class="space-header">
|
||||||
|
<div class="container space-header-inner">
|
||||||
|
<div class="space-avatar">
|
||||||
|
{{if .SpaceUser.Avatar}}
|
||||||
|
<img src="{{.SpaceUser.Avatar}}" alt="{{.SpaceUser.Username}}">
|
||||||
|
{{else}}
|
||||||
|
<div class="space-avatar-placeholder">{{slice .SpaceUser.Username 0 1}}</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="space-info">
|
||||||
|
<h1 class="space-username">{{.SpaceUser.Username}}</h1>
|
||||||
|
<p class="space-bio">{{if .SpaceUser.Bio}}{{.SpaceUser.Bio}}{{else}} {{end}}</p>
|
||||||
|
<div class="space-meta">
|
||||||
|
<span class="space-uid">UID: {{.SpaceUser.ID}}</span>
|
||||||
|
<span class="space-divider">·</span>
|
||||||
|
<span class="space-joined">加入于 {{.SpaceUser.CreatedAt.Format "2006-01-02"}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 内容区域 -->
|
||||||
|
<div class="container space-content">
|
||||||
|
<!-- 统计栏 -->
|
||||||
|
<div class="space-stats">
|
||||||
|
<div class="space-stat-item">
|
||||||
|
<span class="space-stat-num">{{.Total}}</span>
|
||||||
|
<span class="space-stat-label">稿件</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 稿件列表 -->
|
||||||
|
<div class="space-section">
|
||||||
|
<h2 class="space-section-title">稿件</h2>
|
||||||
|
|
||||||
|
{{if eq (len .Posts) 0}}
|
||||||
|
<div class="empty-state">暂无稿件</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="space-posts-list">
|
||||||
|
{{range .Posts}}
|
||||||
|
<article class="space-post-card">
|
||||||
|
<div class="space-post-main">
|
||||||
|
<h3 class="space-post-title">
|
||||||
|
<a href="/posts/{{.ID}}">{{.Title}}</a>
|
||||||
|
</h3>
|
||||||
|
<p class="space-post-excerpt">{{if .Excerpt}}{{.Excerpt}}{{else}}{{printf "%.200s" .Body}}{{end}}</p>
|
||||||
|
<div class="space-post-meta">
|
||||||
|
<span class="space-post-status">已发布</span>
|
||||||
|
<span class="space-post-time">{{.CreatedAt.Format "2006-01-02"}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<!-- 分页 -->
|
||||||
|
{{if gt .TotalPages 1}}
|
||||||
|
<div class="pagination">
|
||||||
|
{{if gt .Page 1}}
|
||||||
|
<a href="?page={{.PrevPage}}" class="page-link">上一页</a>
|
||||||
|
{{end}}
|
||||||
|
<span class="page-info">第 {{.Page}} / {{.TotalPages}} 页 (共 {{.Total}} 条)</span>
|
||||||
|
{{if lt .Page .TotalPages}}
|
||||||
|
<a href="?page={{.NextPage}}" class="page-link">下一页</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{template "layout/footer.html" .}}
|
||||||
|
|
||||||
|
<script src="/static/js/common.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -26,13 +26,23 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
html {}
|
html {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 主内容区撑满剩余空间,实现 sticky footer */
|
||||||
|
body > .container {
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
@ -126,6 +136,11 @@ header {
|
|||||||
font-weight: 600 !important;
|
font-weight: 600 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-settings {
|
||||||
|
font-size: .9rem;
|
||||||
|
color: var(--color-secondary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
.nav-logout {
|
.nav-logout {
|
||||||
color: var(--color-secondary) !important;
|
color: var(--color-secondary) !important;
|
||||||
}
|
}
|
||||||
@ -203,6 +218,7 @@ footer {
|
|||||||
background: var(--color-primary);
|
background: var(--color-primary);
|
||||||
color: #bdc3c7;
|
color: #bdc3c7;
|
||||||
padding: .8rem 0;
|
padding: .8rem 0;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-content {
|
.footer-content {
|
||||||
|
|||||||
@ -297,7 +297,11 @@
|
|||||||
height: calc(100vh - 56px);
|
height: calc(100vh - 56px);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-form {
|
.editor-form {
|
||||||
@ -307,7 +311,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.editor-header {
|
.editor-header {
|
||||||
padding: 16px 24px 12px;
|
padding: 16px 0 12px;
|
||||||
border-bottom: 1px solid #e5e7eb;
|
border-bottom: 1px solid #e5e7eb;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@ -330,21 +334,73 @@
|
|||||||
.editor-main {
|
.editor-main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-pane {
|
.editor-pane {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.editor-sidebar {
|
||||||
|
width: 250px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-sidebar-section {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-sidebar-section h4 {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #9ca3af;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-sidebar-section .tips-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-sidebar-section .tips-list li {
|
||||||
|
padding: 5px 0;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-sidebar-section .tips-list li:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-sidebar-section .tips-list code {
|
||||||
|
background: #f3f4f6;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
.editor-statusbar {
|
.editor-statusbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
padding: 6px 20px;
|
padding: 6px 0;
|
||||||
background: #fafafa;
|
background: #fafafa;
|
||||||
border-top: 1px solid #e5e7eb;
|
border-top: 1px solid #e5e7eb;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@ -364,32 +420,19 @@
|
|||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
|
max-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========== Vditor 编辑器容器 ========== */
|
/* ========== Vditor 编辑器容器 ========== */
|
||||||
/* Vditor 自动生成工具栏、编辑区、状态栏,仅做外层布局微调 */
|
/* 仅提供高度约束,不干预 Vditor 内部布局和弹窗 */
|
||||||
|
|
||||||
#vditor {
|
#vditor {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Vditor 工具栏微调 — 匹配项目主题色 */
|
|
||||||
.vditor-toolbar {
|
|
||||||
background: #fafafa !important;
|
|
||||||
border-bottom: 1px solid #e5e7eb !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Vditor 内容区适配 */
|
|
||||||
.vditor-content {
|
|
||||||
font-size: 15px;
|
|
||||||
line-height: 1.7;
|
|
||||||
color: #24292f;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Vditor 预览/渲染区域 — 详情页 */
|
/* Vditor 预览/渲染区域 — 详情页 */
|
||||||
.post-detail-body .vditor-reset {
|
.post-detail-body .vditor-reset {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
|
|||||||
313
templates/MetaLab-2026/static/css/space.css
Normal file
313
templates/MetaLab-2026/static/css/space.css
Normal file
@ -0,0 +1,313 @@
|
|||||||
|
/* ============================================================
|
||||||
|
MetaLab Space Styles — 用户空间页面
|
||||||
|
参考 Bilibili 空间布局,轻量版
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/* ---------- Error State ---------- */
|
||||||
|
.space-error {
|
||||||
|
text-align: center;
|
||||||
|
padding: 80px 20px;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-error h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--color-secondary);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-error .btn {
|
||||||
|
display: inline-block;
|
||||||
|
padding: .6rem 1.8rem;
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-weight: 600;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-error .btn:hover {
|
||||||
|
background: #2980b9;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Page Layout ---------- */
|
||||||
|
.space-page {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Header — 用户信息横幅(B站风格)
|
||||||
|
============================================================ */
|
||||||
|
.space-header {
|
||||||
|
background: linear-gradient(135deg, #e8f0f8 0%, #f0f4f8 100%);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
padding: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-header-inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Avatar */
|
||||||
|
.space-avatar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-avatar img {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
border: 3px solid #fff;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-avatar-placeholder {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
border: 3px solid #fff;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,.1);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Info */
|
||||||
|
.space-info {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-username {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-primary);
|
||||||
|
line-height: 1.3;
|
||||||
|
margin-bottom: .25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-bio {
|
||||||
|
font-size: .9rem;
|
||||||
|
color: var(--color-text-light);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: .5rem;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .3rem;
|
||||||
|
font-size: .8rem;
|
||||||
|
color: var(--color-secondary);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-divider {
|
||||||
|
color: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Stats Bar
|
||||||
|
============================================================ */
|
||||||
|
.space-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 2rem;
|
||||||
|
padding: 1rem 0;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-stat-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: .15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-stat-num {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-stat-label {
|
||||||
|
font-size: .8rem;
|
||||||
|
color: var(--color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Content Section — 稿件列表
|
||||||
|
============================================================ */
|
||||||
|
.space-content {
|
||||||
|
padding-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-section {
|
||||||
|
margin-top: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-section-title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-primary);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-bottom: .5rem;
|
||||||
|
border-bottom: 2px solid var(--color-accent);
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Post Card ---------- */
|
||||||
|
.space-posts-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-post-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
transition: box-shadow .2s ease, border-color .2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-post-card:hover {
|
||||||
|
box-shadow: 0 2px 12px rgba(0,0,0,.06);
|
||||||
|
border-color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-post-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-post-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: .3rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-post-title a {
|
||||||
|
color: var(--color-primary);
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-post-title a:hover {
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-post-excerpt {
|
||||||
|
font-size: .85rem;
|
||||||
|
color: var(--color-text-light);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: .5rem;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-post-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .75rem;
|
||||||
|
font-size: .75rem;
|
||||||
|
color: var(--color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-post-status {
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Empty State ---------- */
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem 1rem;
|
||||||
|
color: var(--color-secondary);
|
||||||
|
font-size: .95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Pagination ---------- */
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
font-size: .9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-link {
|
||||||
|
padding: .4rem 1rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--color-accent);
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-link:hover {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
background: rgba(52, 152, 219, .05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-info {
|
||||||
|
color: var(--color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Responsive
|
||||||
|
============================================================ */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.space-header {
|
||||||
|
padding: 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-header-inner {
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
gap: .75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-avatar img,
|
||||||
|
.space-avatar-placeholder {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-username {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-meta {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-stats {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-post-card {
|
||||||
|
padding: .85rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-post-title {
|
||||||
|
font-size: .95rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -20,7 +20,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ---- CSRF token helper (从共享 utils.js 提供的 getCSRFToken 别名) ----
|
// ---- CSRF token helper (从共享 utils.js 提供的 getCSRFToken 别名) ----
|
||||||
window.MetaLab.csrfToken = getCSRFToken;
|
window.MetaLab.csrfToken = typeof getCSRFToken === 'function' ? getCSRFToken : function () { return ''; };
|
||||||
|
|
||||||
// ---- Auto-attach X-CSRF-Token to all state-changing requests ----
|
// ---- Auto-attach X-CSRF-Token to all state-changing requests ----
|
||||||
// Monkey-patch XMLHttpRequest and fetch to inject CSRF header
|
// Monkey-patch XMLHttpRequest and fetch to inject CSRF header
|
||||||
@ -31,7 +31,10 @@
|
|||||||
var refreshPromise = null;
|
var refreshPromise = null;
|
||||||
var pendingRetries = [];
|
var pendingRetries = [];
|
||||||
|
|
||||||
|
// 优先从 cookie 读取 CSRF token(与服务端验证源一致),meta 标签作为后备
|
||||||
function resolveCSRFToken() {
|
function resolveCSRFToken() {
|
||||||
|
var cookieMatch = document.cookie.match(/(?:^|;\s*)mlb_csrf=([^;]*)/);
|
||||||
|
if (cookieMatch && cookieMatch[1]) return cookieMatch[1];
|
||||||
var meta = document.querySelector('meta[name="csrf-token"]');
|
var meta = document.querySelector('meta[name="csrf-token"]');
|
||||||
return meta ? meta.getAttribute('content') : '';
|
return meta ? meta.getAttribute('content') : '';
|
||||||
}
|
}
|
||||||
@ -92,7 +95,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 401 interception for XHR
|
// 401 interception for XHR
|
||||||
|
// 保存原始 onreadystatechange 后立即清除,防止原生属性重复触发
|
||||||
|
// 否则浏览器原生 onreadystatechange 和 addEventListener 监听器会各触发一次
|
||||||
|
// 导致 Vditor 图片上传等场景下图片被插入两次
|
||||||
var origOnReady = xhr.onreadystatechange;
|
var origOnReady = xhr.onreadystatechange;
|
||||||
|
xhr.onreadystatechange = null;
|
||||||
xhr.addEventListener('readystatechange', function () {
|
xhr.addEventListener('readystatechange', function () {
|
||||||
if (xhr.readyState === 4 && xhr.status === 401 &&
|
if (xhr.readyState === 4 && xhr.status === 401 &&
|
||||||
xhr._url !== '/api/auth/refresh' &&
|
xhr._url !== '/api/auth/refresh' &&
|
||||||
|
|||||||
@ -17,9 +17,10 @@ let draftTimer = null
|
|||||||
let submitting = false
|
let submitting = false
|
||||||
|
|
||||||
// ---- CSRF Token ----
|
// ---- CSRF Token ----
|
||||||
|
// 直接从 cookie 读取 mlb_csrf(httpOnly=false),确保与服务端验证时读取的值一致
|
||||||
function getCsrfToken() {
|
function getCsrfToken() {
|
||||||
const meta = document.querySelector('meta[name="csrf-token"]')
|
var match = document.cookie.match(/(?:^|;\s*)mlb_csrf=([^;]*)/)
|
||||||
return meta ? meta.getAttribute('content') : ''
|
return match ? match[1] : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 初始化 ----
|
// ---- 初始化 ----
|
||||||
@ -63,6 +64,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
'outline', 'preview', 'devtools',
|
'outline', 'preview', 'devtools',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// Vditor 3.11.2 中 highlightToolbarWYSIWYG 直接调用此回调
|
||||||
|
// 不提供会导致 TypeError: not a function,所有弹窗(代码块语言等)都不可用
|
||||||
|
customWysiwygToolbar(type, popover) {},
|
||||||
|
|
||||||
// 计数器
|
// 计数器
|
||||||
counter: {
|
counter: {
|
||||||
enable: true,
|
enable: true,
|
||||||
@ -94,27 +99,38 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
// 大纲
|
// 大纲
|
||||||
outline: {
|
outline: {
|
||||||
enable: true,
|
enable: false,
|
||||||
position: 'right',
|
position: 'right',
|
||||||
},
|
},
|
||||||
|
|
||||||
// 预览配置
|
// 预览配置
|
||||||
|
// 注意:WYSIWYG 编辑模式下代码块不做语法高亮(Vditor 3.11.2 源码中
|
||||||
|
// highlightRender 会跳过 .vditor-wysiwyg__pre,仅在预览模式/详情页渲染)
|
||||||
|
// langs 确保语言选择下拉始终可用,不受 hljs 异步加载时机影响
|
||||||
preview: {
|
preview: {
|
||||||
theme: { current: 'light', path: '/static/vditor/dist/css/content-theme' },
|
theme: { current: 'light', path: '/static/vditor/dist/css/content-theme' },
|
||||||
hljs: { style: 'github-dark', enable: true },
|
hljs: {
|
||||||
|
style: 'github-dark',
|
||||||
|
enable: true,
|
||||||
|
langs: [
|
||||||
|
'javascript', 'typescript', 'python', 'java', 'go', 'rust',
|
||||||
|
'c', 'cpp', 'csharp', 'php', 'ruby', 'swift', 'kotlin',
|
||||||
|
'html', 'css', 'scss', 'xml', 'json', 'yaml', 'markdown',
|
||||||
|
'sql', 'bash', 'shell', 'powershell', 'dockerfile', 'nginx',
|
||||||
|
'diff', 'http', 'graphql', 'makefile',
|
||||||
|
],
|
||||||
|
},
|
||||||
markdown: { codeBlockPreview: true },
|
markdown: { codeBlockPreview: true },
|
||||||
},
|
},
|
||||||
|
|
||||||
// 图片上传
|
// 图片上传
|
||||||
|
// CSRF token 由 common.js 的全局 XHR monkey-patch 自动注入,此处无需重复设置
|
||||||
|
// 重复设置会导致浏览器将 header 合并为 "TOKEN, TOKEN" 从而验证失败
|
||||||
upload: {
|
upload: {
|
||||||
url: '/api/posts/upload-image',
|
url: '/api/posts/upload-image',
|
||||||
fieldName: 'file',
|
fieldName: 'file',
|
||||||
max: 5 * 1024 * 1024,
|
max: 5 * 1024 * 1024,
|
||||||
accept: 'image/jpg,image/jpeg,image/png,image/gif,image/webp',
|
accept: 'image/jpg,image/jpeg,image/png,image/gif,image/webp',
|
||||||
// setHeaders 为函数,每次上传前重新读取 CSRF token
|
|
||||||
setHeaders() {
|
|
||||||
return { 'X-CSRF-Token': getCsrfToken() }
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// 初始内容
|
// 初始内容
|
||||||
|
|||||||
Reference in New Issue
Block a user