update
This commit is contained in:
@@ -132,3 +132,9 @@ func GetCurrentTheme() string {
|
|||||||
}
|
}
|
||||||
func SetCurrentTheme(theme string) {
|
func SetCurrentTheme(theme string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetJWTSecret 获取 JWT 密钥
|
||||||
|
func (c *Config) GetJWTSecret() string {
|
||||||
|
//return c.JWT.Secret
|
||||||
|
return c.Security.JWTSecret
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,16 +2,54 @@ package controllers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"go_blog/models"
|
"go_blog/models"
|
||||||
|
"go_blog/themes" // <-- 确保导入 themes 包
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm" // <-- 确保导入 gorm
|
||||||
)
|
)
|
||||||
|
|
||||||
func Home(c *gin.Context) {
|
func Home(c *gin.Context) {
|
||||||
|
tm, exists := c.Get("ThemeManager")
|
||||||
|
if !exists {
|
||||||
|
c.String(http.StatusInternalServerError, "Theme manager not found in context")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
themeManager, ok := tm.(*themes.ThemeManager)
|
||||||
|
if !ok {
|
||||||
|
c.String(http.StatusInternalServerError, "Invalid theme manager type in context")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var items []models.Content
|
var items []models.Content
|
||||||
models.DB.Select("*").Limit(5).Find(&items, "type = ?", "post")
|
// 从 Gin 上下文中获取 DB 实例
|
||||||
c.HTML(http.StatusOK, "index.tmpl", gin.H{
|
dbInterface, exists := c.Get("DB")
|
||||||
|
if !exists {
|
||||||
|
c.String(http.StatusInternalServerError, "DB not found in context")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db, ok := dbInterface.(*gorm.DB)
|
||||||
|
if !ok {
|
||||||
|
c.String(http.StatusInternalServerError, "Invalid DB type in context")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
db.Select("*").Limit(5).Find(&items, "type = ?", "post")
|
||||||
|
|
||||||
|
tpl := themeManager.GetTemplate("index") // "index" 是模板的基本名 (例如 index.tmpl -> index)
|
||||||
|
if tpl == nil {
|
||||||
|
c.String(http.StatusInternalServerError, "Template 'index' not found in current theme: "+themeManager.CurrentTheme())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
err := tpl.Execute(c.Writer, gin.H{
|
||||||
"Items": items,
|
"Items": items,
|
||||||
|
"Title": "首页", // 你可以根据需要传递更多数据
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
// 实际项目中应记录错误
|
||||||
|
c.String(http.StatusInternalServerError, "Error rendering template: "+err.Error())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,19 +2,69 @@ package controllers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"go_blog/models"
|
"go_blog/models"
|
||||||
|
"go_blog/themes" // <-- 确保导入 themes 包
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm" // <-- 确保导入 gorm
|
||||||
)
|
)
|
||||||
|
|
||||||
func ShowPost(c *gin.Context) {
|
func ShowPost(c *gin.Context) {
|
||||||
|
tm, exists := c.Get("ThemeManager")
|
||||||
id, err := strconv.ParseInt(c.Param("id"), 10, 32)
|
if !exists {
|
||||||
if err != nil {
|
c.String(http.StatusInternalServerError, "Theme manager not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
themeManager, ok := tm.(*themes.ThemeManager)
|
||||||
|
if !ok {
|
||||||
|
c.String(http.StatusInternalServerError, "Invalid theme manager type")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
idParam := c.Param("id")
|
||||||
|
id, err := strconv.ParseInt(idParam, 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.String(http.StatusBadRequest, "Invalid post ID format")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var content = models.Content{Cid: int32(id)}
|
var content = models.Content{Cid: int32(id)}
|
||||||
models.DB.First(&content)
|
// 从 Gin 上下文中获取 DB 实例
|
||||||
c.HTML(http.StatusOK, "post.tmpl", content)
|
dbInterface, exists := c.Get("DB")
|
||||||
|
if !exists {
|
||||||
|
c.String(http.StatusInternalServerError, "DB not found in context")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db, ok := dbInterface.(*gorm.DB)
|
||||||
|
if !ok {
|
||||||
|
c.String(http.StatusInternalServerError, "Invalid DB type in context")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result := db.First(&content)
|
||||||
|
if result.Error != nil {
|
||||||
|
if result.Error == gorm.ErrRecordNotFound {
|
||||||
|
c.String(http.StatusNotFound, "Post not found")
|
||||||
|
} else {
|
||||||
|
c.String(http.StatusInternalServerError, "Error fetching post: "+result.Error.Error())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 假设你的主题中有一个名为 "post" 的模板 (post.html 或 post.tmpl)
|
||||||
|
// 注意:当前的 default 主题 (web/themes/default/templates/) 并没有 post.tmpl,你需要添加它。
|
||||||
|
tpl := themeManager.GetTemplate("post")
|
||||||
|
if tpl == nil {
|
||||||
|
c.String(http.StatusInternalServerError, "Template 'post' not found in current theme: "+themeManager.CurrentTheme()+". Make sure 'post.html' or 'post.tmpl' exists in the theme's templates directory.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
// 将整个 content 对象传递给模板。模板内部通过 {{ .Title }} {{ .Text }} 等访问。
|
||||||
|
err = tpl.Execute(c.Writer, content)
|
||||||
|
if err != nil {
|
||||||
|
c.String(http.StatusInternalServerError, "Error rendering template: "+err.Error())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
39
controllers/themes.go
Normal file
39
controllers/themes.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go_blog/themes"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListThemes 获取可用主题列表
|
||||||
|
func ListThemes(c *gin.Context) {
|
||||||
|
// 从主题管理器获取当前主题
|
||||||
|
tm, exists := c.Get("ThemeManager")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(500, gin.H{"error": "主题管理器未找到"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
themeManager := tm.(*themes.ThemeManager)
|
||||||
|
|
||||||
|
// 读取 web/themes 目录下的所有主题文件夹
|
||||||
|
themesDir := "web/themes"
|
||||||
|
entries, err := os.ReadDir(themesDir)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(500, gin.H{"error": "读取主题目录失败: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var themeList []string
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
themeList = append(themeList, entry.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(200, gin.H{
|
||||||
|
"current": themeManager.CurrentTheme(),
|
||||||
|
"themes": themeList,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -21,7 +21,8 @@ func UsersLoginHandler(ctx *gin.Context) {
|
|||||||
response := Response{Ctx: ctx}
|
response := Response{Ctx: ctx}
|
||||||
var loginUser serializers.Login
|
var loginUser serializers.Login
|
||||||
if err := ctx.ShouldBind(&loginUser); err != nil {
|
if err := ctx.ShouldBind(&loginUser); err != nil {
|
||||||
panic(err)
|
response.BadRequest("请求参数错误: " + err.Error()) // 替换 panic 为错误响应
|
||||||
|
return
|
||||||
}
|
}
|
||||||
user := loginUser.GetUser()
|
user := loginUser.GetUser()
|
||||||
isLoginUser := user.CheckPassword()
|
isLoginUser := user.CheckPassword()
|
||||||
@@ -29,7 +30,7 @@ func UsersLoginHandler(ctx *gin.Context) {
|
|||||||
response.BadRequest("密码错误")
|
response.BadRequest("密码错误")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
token, err := jwt.GenToken(user.ID, user.Username)
|
token, err := jwt.GenerateToken(user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
@@ -44,7 +45,8 @@ func UsersRegisterHandler(ctx *gin.Context) {
|
|||||||
response := Response{Ctx: ctx}
|
response := Response{Ctx: ctx}
|
||||||
var registerUser serializers.Login
|
var registerUser serializers.Login
|
||||||
if err := ctx.ShouldBind(®isterUser); err != nil {
|
if err := ctx.ShouldBind(®isterUser); err != nil {
|
||||||
panic(err)
|
response.BadRequest("请求参数错误: " + err.Error()) // 替换 panic 为错误响应
|
||||||
|
return
|
||||||
}
|
}
|
||||||
user := registerUser.GetUser()
|
user := registerUser.GetUser()
|
||||||
status := user.CheckDuplicateUsername()
|
status := user.CheckDuplicateUsername()
|
||||||
@@ -73,12 +75,20 @@ func UsersSetInfoHandler(ctx *gin.Context) {
|
|||||||
response.BadRequest("获取不到参数")
|
response.BadRequest("获取不到参数")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
currentUser := jwt.AssertUser(ctx)
|
// 从上下文中获取用户(假设 JWT 中间件已将用户存入 "user" 键)
|
||||||
if currentUser != nil {
|
user, exists := ctx.Get("user")
|
||||||
models.DB.Model(¤tUser).Updates(jsonData)
|
if !exists {
|
||||||
response.Response(currentUser, nil)
|
response.Unauthenticated("未登录")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
currentUser, ok := user.(*models.Account) // 明确类型为 models.Account
|
||||||
|
if !ok {
|
||||||
|
response.ServerError("用户类型错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
models.DB.Model(currentUser).Updates(jsonData)
|
||||||
|
response.Response(currentUser, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 修改密码
|
// 修改密码
|
||||||
@@ -119,10 +129,14 @@ func UsersListHandler(ctx *gin.Context) {
|
|||||||
var pager serializers.Pager
|
var pager serializers.Pager
|
||||||
pager.InitPager(ctx)
|
pager.InitPager(ctx)
|
||||||
var users []models.Account
|
var users []models.Account
|
||||||
db := models.DB.Model(&users)
|
|
||||||
total := int64(pager.Total)
|
// 先查询总记录数
|
||||||
db.Count(&total)
|
var totalCount int64
|
||||||
db.Offset(pager.OffSet).Limit(pager.PageSize).Find(&users)
|
models.DB.Model(&models.Account{}).Count(&totalCount)
|
||||||
|
pager.Total = int(totalCount) // 正确设置总数
|
||||||
|
|
||||||
|
// 分页查询
|
||||||
|
models.DB.Offset(pager.OffSet()).Limit(pager.PageSize).Find(&users)
|
||||||
pager.GetPager()
|
pager.GetPager()
|
||||||
response.Response(users, pager)
|
response.Response(users, pager)
|
||||||
}
|
}
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -7,7 +7,7 @@ toolchain go1.23.8
|
|||||||
require (
|
require (
|
||||||
github.com/fsnotify/fsnotify v1.7.0
|
github.com/fsnotify/fsnotify v1.7.0
|
||||||
github.com/gin-gonic/gin v1.9.1
|
github.com/gin-gonic/gin v1.9.1
|
||||||
github.com/golang-jwt/jwt v3.2.2+incompatible
|
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||||
github.com/gorilla/websocket v1.5.1
|
github.com/gorilla/websocket v1.5.1
|
||||||
golang.org/x/crypto v0.33.0
|
golang.org/x/crypto v0.33.0
|
||||||
gorm.io/driver/mysql v1.5.6
|
gorm.io/driver/mysql v1.5.6
|
||||||
|
|||||||
4
go.sum
4
go.sum
@@ -30,8 +30,8 @@ github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ
|
|||||||
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||||
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
|
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||||
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
|
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
|||||||
2
main.go
2
main.go
@@ -18,7 +18,6 @@ import (
|
|||||||
// @title 个人博客系统API
|
// @title 个人博客系统API
|
||||||
// @version 1.0
|
// @version 1.0
|
||||||
// @description 基于Go语言的可定制主题博客系统
|
// @description 基于Go语言的可定制主题博客系统
|
||||||
const templatePath = "./templates/*"
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
||||||
@@ -60,7 +59,6 @@ func main() {
|
|||||||
gin.SetMode(gin.ReleaseMode)
|
gin.SetMode(gin.ReleaseMode)
|
||||||
}
|
}
|
||||||
router := gin.Default()
|
router := gin.Default()
|
||||||
router.LoadHTMLGlob(templatePath)
|
|
||||||
themeManager.RegisterStaticRoutes(router)
|
themeManager.RegisterStaticRoutes(router)
|
||||||
|
|
||||||
// 6. 注册中间件
|
// 6. 注册中间件
|
||||||
|
|||||||
@@ -69,3 +69,12 @@ func (a *Account) CheckDuplicateUsername() bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetAccountByID 根据 ID 查询用户
|
||||||
|
func GetAccountByID(id uint) (*Account, error) {
|
||||||
|
var account Account
|
||||||
|
if err := DB.First(&account, id).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &account, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,3 +7,12 @@ type User struct {
|
|||||||
Password string
|
Password string
|
||||||
PasswordHash []byte
|
PasswordHash []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetUserByID 根据 ID 查询用户
|
||||||
|
func GetUserByID(id uint) (*User, error) {
|
||||||
|
var user User
|
||||||
|
if err := DB.First(&user, id).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|||||||
125
pkg/jwt/auth.go
125
pkg/jwt/auth.go
@@ -1,77 +1,90 @@
|
|||||||
/*
|
|
||||||
@Time : 2020/6/29 9:05
|
|
||||||
@Author : xuyiqing
|
|
||||||
@File : auth.py
|
|
||||||
*/
|
|
||||||
|
|
||||||
package jwt
|
package jwt
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"go_blog/models"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"go_blog/config"
|
||||||
|
"go_blog/models"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/golang-jwt/jwt"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
"github.com/spf13/viper"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// 定义jwt载荷
|
// 定义 JWT 密钥(从配置文件读取)
|
||||||
type UserClaims struct {
|
var jwtSecret = []byte(config.GetJWTSecret())
|
||||||
jwt.StandardClaims
|
|
||||||
ID uint64 `json:"id"`
|
// CustomClaims 自定义 JWT 载荷(包含用户 ID)
|
||||||
Username string `json:"username"`
|
type CustomClaims struct {
|
||||||
|
UserID uint `json:"user_id"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
}
|
}
|
||||||
|
|
||||||
// 根据payload查询user返回
|
// GenerateToken 生成 JWT 令牌
|
||||||
func (c *UserClaims) GetUserByID() *models.Account {
|
func GenerateToken(user *models.Account) (string, error) {
|
||||||
var user models.Account
|
expiresAt := jwt.NewNumericDate(time.Now().Add(24 * time.Hour)) // 令牌有效期 24 小时
|
||||||
models.DB.Model(&models.Account{}).First(&user, c.ID)
|
claims := CustomClaims{
|
||||||
if user.ID > 0 {
|
UserID: uint(user.ID),
|
||||||
return &user
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
} else {
|
ExpiresAt: expiresAt,
|
||||||
return nil
|
Issuer: "go_blog",
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 生成jwt token字符串
|
|
||||||
func GenToken(id uint64, username string) (string, error) {
|
|
||||||
expiredTime := time.Now().Add(time.Hour * time.Duration(24)).Unix()
|
|
||||||
claims := UserClaims{
|
|
||||||
jwt.StandardClaims{
|
|
||||||
ExpiresAt: expiredTime,
|
|
||||||
},
|
},
|
||||||
id,
|
|
||||||
username,
|
|
||||||
}
|
}
|
||||||
tokenClaims := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
||||||
token, err := tokenClaims.SignedString([]byte(viper.GetString("config.JwtSecretKey.secretKey")))
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
return token, err
|
return token.SignedString(jwtSecret)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证token合法性
|
// VerifyToken 验证 JWT 令牌并返回用户信息
|
||||||
func ValidateJwtToken(token string) (*UserClaims, error) {
|
func VerifyToken(tokenStr string) (*models.Account, error) { // 修改返回类型为 *models.Account
|
||||||
tokenClaims, err := jwt.ParseWithClaims(token, &UserClaims{}, func(token *jwt.Token) (interface{}, error) {
|
token, err := jwt.ParseWithClaims(tokenStr, &CustomClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||||
return []byte(viper.GetString("config.JwtSecretKey.secretKey")), nil
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||||
|
}
|
||||||
|
return jwtSecret, nil
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
if tokenClaims != nil {
|
|
||||||
if claims, ok := tokenClaims.Claims.(*UserClaims); ok && tokenClaims.Valid {
|
|
||||||
return claims, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil, err
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if claims, ok := token.Claims.(*CustomClaims); ok && token.Valid {
|
||||||
|
return models.GetAccountByID(claims.UserID) // 假设 models 包新增 GetAccountByID 方法
|
||||||
|
}
|
||||||
|
return nil, errors.New("invalid token")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 断言设定ctx的当前用户
|
// AuthRequired Gin 中间件:验证 JWT 令牌
|
||||||
func AssertUser(ctx *gin.Context) *models.Account {
|
func AuthRequired() gin.HandlerFunc {
|
||||||
currentUser, isExists := ctx.Get("CurrentUser")
|
return func(c *gin.Context) {
|
||||||
if !isExists {
|
// 从请求头获取 Authorization 字段(格式:Bearer <token>)
|
||||||
return nil
|
authHeader := c.GetHeader("Authorization")
|
||||||
|
if authHeader == "" {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "请提供认证令牌"})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
user, ok := currentUser.(*models.Account)
|
|
||||||
if ok {
|
// 解析令牌
|
||||||
return user
|
parts := strings.SplitN(authHeader, " ", 2)
|
||||||
} else {
|
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||||
return nil
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "令牌格式错误(应为 Bearer <token>)"})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证令牌并获取用户
|
||||||
|
user, err := VerifyToken(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "无效或过期的令牌: " + err.Error()})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将用户信息存入上下文(后续路由可通过 c.Get("user") 获取)
|
||||||
|
c.Set("user", user)
|
||||||
|
c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import (
|
|||||||
"go_blog/controllers"
|
"go_blog/controllers"
|
||||||
"go_blog/models"
|
"go_blog/models"
|
||||||
"go_blog/serializers"
|
"go_blog/serializers"
|
||||||
|
"go_blog/themes" // <-- 确保导入 themes 包
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -19,32 +21,87 @@ func RegisterRoutes(r *gin.Engine) {
|
|||||||
r.GET("/events", esSSE)
|
r.GET("/events", esSSE)
|
||||||
|
|
||||||
r.GET("/page", func(c *gin.Context) {
|
r.GET("/page", func(c *gin.Context) {
|
||||||
var items []models.Content
|
tm, exists := c.Get("ThemeManager")
|
||||||
var pager serializers.Pager
|
if !exists {
|
||||||
pager.InitPager(c)
|
c.String(http.StatusInternalServerError, "Theme manager not found")
|
||||||
offset := (pager.Page - 1) * pager.PageSize
|
|
||||||
if dbInterface, ok := c.Get("DB"); ok {
|
|
||||||
if db, ok := dbInterface.(*gorm.DB); ok {
|
|
||||||
db.Select("*").Offset(offset).Limit(pager.PageSize).Find(&items, "type = ?", "post")
|
|
||||||
} else {
|
|
||||||
log.Println("无法将 DB 转换为 *gorm.DB 类型")
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "内部服务器错误"})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
themeManager, ok := tm.(*themes.ThemeManager)
|
||||||
|
if !ok {
|
||||||
|
c.String(http.StatusInternalServerError, "Invalid theme manager type")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var items []models.Content
|
||||||
|
var pager serializers.Pager
|
||||||
|
pager.InitPager(c) // 这会从查询参数中读取 page 和 pageSize
|
||||||
|
offset := (pager.Page - 1) * pager.PageSize
|
||||||
|
|
||||||
|
dbInterface, dbOk := c.Get("DB")
|
||||||
|
if !dbOk {
|
||||||
log.Println("未找到键 'DB' 的上下文值")
|
log.Println("未找到键 'DB' 的上下文值")
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "内部服务器错误"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "内部服务器错误"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
db, typeOk := dbInterface.(*gorm.DB)
|
||||||
|
if !typeOk {
|
||||||
|
log.Println("无法将 DB 转换为 *gorm.DB 类型")
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "内部服务器错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
c.HTML(http.StatusOK, "index.tmpl", gin.H{
|
db.Select("*").Offset(offset).Limit(pager.PageSize).Find(&items, "type = ?", "post")
|
||||||
|
var totalCount int64
|
||||||
|
db.Model(&models.Content{}).Where("type = ?", "post").Count(&totalCount)
|
||||||
|
pager.Total = int(totalCount) // 确保 Pager 结构体中的 Total 类型匹配
|
||||||
|
|
||||||
|
tpl := themeManager.GetTemplate("index") // 假设分页列表也使用 index 模板
|
||||||
|
if tpl == nil {
|
||||||
|
c.String(http.StatusInternalServerError, "Template 'index' not found in current theme: "+themeManager.CurrentTheme())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
err := tpl.Execute(c.Writer, gin.H{
|
||||||
"Items": items,
|
"Items": items,
|
||||||
"Pager": pager,
|
"Pager": pager, // 确保你的 index.tmpl 支持 Pager 结构
|
||||||
"Title": "文章列表",
|
"Title": "文章列表 - 第 " + strconv.Itoa(pager.Page) + " 页",
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
c.String(http.StatusInternalServerError, "Error rendering template: "+err.Error())
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
r.GET("/createcontent", func(c *gin.Context) {
|
r.GET("/createcontent", func(c *gin.Context) {
|
||||||
c.HTML(http.StatusOK, "content.tmpl", nil)
|
tm, exists := c.Get("ThemeManager")
|
||||||
|
if !exists {
|
||||||
|
c.String(http.StatusInternalServerError, "Theme manager not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
themeManager, ok := tm.(*themes.ThemeManager)
|
||||||
|
if !ok {
|
||||||
|
c.String(http.StatusInternalServerError, "Invalid theme manager type")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 假设你的主题中有一个名为 "content" 的模板 (content.html 或 content.tmpl)
|
||||||
|
// 注意:当前的 default 主题并没有 content.tmpl,你需要添加它。
|
||||||
|
// 如果这个页面是后台页面,它可能不需要主题化,或者使用一个固定的后台模板。
|
||||||
|
tpl := themeManager.GetTemplate("content")
|
||||||
|
if tpl == nil {
|
||||||
|
c.String(http.StatusInternalServerError, "Template 'content' not found in current theme: "+themeManager.CurrentTheme()+". Make sure 'content.html' or 'content.tmpl' exists.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
err := tpl.Execute(c.Writer, gin.H{
|
||||||
|
"Title": "创建新内容",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
c.String(http.StatusInternalServerError, "Error rendering template: "+err.Error())
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
r.GET("/ws", controllers.WebSocketHandler)
|
r.GET("/ws", controllers.WebSocketHandler)
|
||||||
@@ -57,12 +114,12 @@ func RegisterRoutes(r *gin.Engine) {
|
|||||||
r.GET("/", controllers.Home)
|
r.GET("/", controllers.Home)
|
||||||
r.GET("/post/:id", controllers.ShowPost)
|
r.GET("/post/:id", controllers.ShowPost)
|
||||||
|
|
||||||
// // Admin panel
|
// Admin panel(取消注释并完善)
|
||||||
// admin := r.Group("/admin", middleware.AuthRequired())
|
admin := r.Group("/admin", jwt.AuthRequired()) // 新增中间件
|
||||||
// {
|
{
|
||||||
// admin.GET("/themes", controllers.ListThemes)
|
admin.GET("/themes", controllers.ListThemes)
|
||||||
// admin.POST("/themes/switch", controllers.SwitchTheme)
|
admin.POST("/themes/switch", controllers.SwitchTheme)
|
||||||
// }
|
}
|
||||||
|
|
||||||
// Static files for themes
|
// Static files for themes
|
||||||
r.StaticFS("/themes", http.Dir("web/themes"))
|
r.StaticFS("/themes", http.Dir("web/themes"))
|
||||||
|
|||||||
29
templates/admin/themes.tmpl
Normal file
29
templates/admin/themes.tmpl
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>主题管理</title>
|
||||||
|
<style>
|
||||||
|
.container { max-width: 800px; margin: 20px auto; padding: 20px; }
|
||||||
|
.theme-list { margin-top: 20px; }
|
||||||
|
.theme-item { padding: 10px; border: 1px solid #eee; margin: 5px 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>主题管理</h1>
|
||||||
|
<p>当前主题:<strong>{{.CurrentTheme}}</strong></p>
|
||||||
|
|
||||||
|
<div class="theme-list">
|
||||||
|
<h3>可选主题</h3>
|
||||||
|
<form action="/admin/themes/switch" method="post">
|
||||||
|
<select name="theme" style="padding: 8px; width: 200px;">
|
||||||
|
{{range .Themes}}
|
||||||
|
<option value="{{.}}" {{if eq . $.CurrentTheme}}selected{{end}}>{{.}}</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
<button type="submit" style="padding: 8px 15px; margin-left: 10px;">切换主题</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -39,6 +39,12 @@ func NewManager(themesDir string) *ThemeManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 自定义错误类型
|
||||||
|
var (
|
||||||
|
ErrInvalidThemeStructure = errors.New("invalid theme structure")
|
||||||
|
ErrNoValidTemplates = errors.New("no valid templates found")
|
||||||
|
)
|
||||||
|
|
||||||
// 核心方法:加载主题
|
// 核心方法:加载主题
|
||||||
func (m *ThemeManager) LoadTheme(themeName string) error {
|
func (m *ThemeManager) LoadTheme(themeName string) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
@@ -47,7 +53,7 @@ func (m *ThemeManager) LoadTheme(themeName string) error {
|
|||||||
// 1. 验证主题目录结构
|
// 1. 验证主题目录结构
|
||||||
themePath := filepath.Join(m.themesDir, themeName)
|
themePath := filepath.Join(m.themesDir, themeName)
|
||||||
if !isValidTheme(themePath) {
|
if !isValidTheme(themePath) {
|
||||||
return fmt.Errorf("invalid theme structure: %s", themeName)
|
return fmt.Errorf("%w: %s", ErrInvalidThemeStructure, themeName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 加载模板文件
|
// 2. 加载模板文件
|
||||||
@@ -85,21 +91,50 @@ func (m *ThemeManager) RegisterStaticRoutes(router *gin.Engine) {
|
|||||||
|
|
||||||
// 校验主题完整性
|
// 校验主题完整性
|
||||||
func isValidTheme(themePath string) bool {
|
func isValidTheme(themePath string) bool {
|
||||||
requiredFiles := []string{
|
requiredBaseFiles := []string{ // 不带扩展名的基本文件名
|
||||||
"theme.yaml",
|
"theme.yaml",
|
||||||
// "templates/home.html",
|
"templates/index", // 例如: templates/index.html 或 templates/index.tmpl
|
||||||
// "templates/post.html",
|
// "templates/post", // 如果 post 模板是必须的,也加入这里
|
||||||
"templates/index.html",
|
}
|
||||||
|
supportedTemplateExtensions := []string{".html", ".tmpl"}
|
||||||
|
|
||||||
|
for _, baseFile := range requiredBaseFiles {
|
||||||
|
found := false
|
||||||
|
if strings.HasPrefix(baseFile, "templates/") {
|
||||||
|
// 对于模板文件,尝试所有支持的扩展名
|
||||||
|
for _, ext := range supportedTemplateExtensions {
|
||||||
|
if exists, _ := fileExists(filepath.Join(themePath, baseFile+ext)); exists {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 对于非模板文件 (如 theme.yaml),直接检查
|
||||||
|
if exists, _ := fileExists(filepath.Join(themePath, baseFile)); exists {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, f := range requiredFiles {
|
if !found {
|
||||||
if _, err := os.Stat(filepath.Join(themePath, f)); os.IsNotExist(err) {
|
// log.Printf("Required file/template %s not found in theme %s", baseFile, themePath) // 可选的调试日志
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查文件是否存在
|
||||||
|
func fileExists(path string) (bool, error) {
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
if err == nil {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
// 解析模板文件(支持布局继承)
|
// 解析模板文件(支持布局继承)
|
||||||
func (m *ThemeManager) parseTemplates(themePath string) (map[string]*template.Template, error) {
|
func (m *ThemeManager) parseTemplates(themePath string) (map[string]*template.Template, error) {
|
||||||
templates := make(map[string]*template.Template)
|
templates := make(map[string]*template.Template)
|
||||||
@@ -112,25 +147,37 @@ func (m *ThemeManager) parseTemplates(themePath string) (map[string]*template.Te
|
|||||||
// 从主题目录加载模板
|
// 从主题目录加载模板
|
||||||
tplDir := filepath.Join(themePath, "templates")
|
tplDir := filepath.Join(themePath, "templates")
|
||||||
err := filepath.WalkDir(tplDir, func(path string, d fs.DirEntry, err error) error {
|
err := filepath.WalkDir(tplDir, func(path string, d fs.DirEntry, err error) error {
|
||||||
if err != nil || d.IsDir() || !strings.HasSuffix(path, ".html") {
|
if err != nil {
|
||||||
return nil
|
return err // 返回错误以停止 WalkDir
|
||||||
|
}
|
||||||
|
if d.IsDir() {
|
||||||
|
return nil // 跳过目录
|
||||||
|
}
|
||||||
|
|
||||||
|
// 支持 .html 和 .tmpl 扩展名
|
||||||
|
lowerPath := strings.ToLower(path)
|
||||||
|
if !strings.HasSuffix(lowerPath, ".html") && !strings.HasSuffix(lowerPath, ".tmpl") {
|
||||||
|
return nil // 非模板文件,跳过
|
||||||
}
|
}
|
||||||
|
|
||||||
// 读取模板内容
|
// 读取模板内容
|
||||||
content, err := os.ReadFile(path)
|
content, err := readFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("failed to read template file %s: %w", path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成模板名称(相对路径)
|
// 生成模板名称(相对路径,不含扩展名)
|
||||||
name := strings.TrimPrefix(path, tplDir+string(filepath.Separator))
|
name := strings.TrimPrefix(path, tplDir+string(filepath.Separator))
|
||||||
name = strings.TrimSuffix(name, filepath.Ext(name))
|
name = strings.TrimSuffix(name, filepath.Ext(name))
|
||||||
|
|
||||||
// 克隆基础模板并解析
|
// 克隆基础模板并解析
|
||||||
tpl := template.Must(baseTpl.Clone())
|
tpl, err := baseTpl.Clone()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to clone base template for %s: %w", name, err)
|
||||||
|
}
|
||||||
tpl, err = tpl.Parse(string(content))
|
tpl, err = tpl.Parse(string(content))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("parse error in %s: %w", name, err)
|
return fmt.Errorf("parse error in %s (file: %s): %w", name, path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
templates[name] = tpl
|
templates[name] = tpl
|
||||||
@@ -142,12 +189,17 @@ func (m *ThemeManager) parseTemplates(themePath string) (map[string]*template.Te
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(templates) == 0 {
|
if len(templates) == 0 {
|
||||||
return nil, errors.New("no valid templates found")
|
return nil, ErrNoValidTemplates
|
||||||
}
|
}
|
||||||
|
|
||||||
return templates, nil
|
return templates, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 读取文件内容
|
||||||
|
func readFile(path string) ([]byte, error) {
|
||||||
|
return os.ReadFile(path)
|
||||||
|
}
|
||||||
|
|
||||||
// 获取可用主题列表
|
// 获取可用主题列表
|
||||||
func (m *ThemeManager) ListThemes() ([]string, error) {
|
func (m *ThemeManager) ListThemes() ([]string, error) {
|
||||||
dirs, err := os.ReadDir(m.themesDir)
|
dirs, err := os.ReadDir(m.themesDir)
|
||||||
|
|||||||
@@ -21,14 +21,25 @@ func RenderTemplate(c *gin.Context, tmpl string, data gin.H) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SwitchTheme handles POST requests to switch the current theme
|
// SwitchTheme handles POST requests to switch the current theme
|
||||||
|
// SwitchTheme 处理主题切换请求(修正后)
|
||||||
func SwitchTheme(c *gin.Context) {
|
func SwitchTheme(c *gin.Context) {
|
||||||
newTheme := c.PostForm("theme")
|
// 从上下文中获取主题管理器(通过 main.go 的 themeMiddleware 注入)
|
||||||
manager := &ThemeManager{} //themes.GetManager()
|
tm, exists := c.Get("ThemeManager")
|
||||||
|
if !exists {
|
||||||
if err := manager.LoadTheme(newTheme); err != nil {
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "主题管理器未找到"})
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
return
|
||||||
} else {
|
|
||||||
config.SetCurrentTheme(newTheme) // Update the configuration
|
|
||||||
c.JSON(http.StatusOK, gin.H{"status": "success"})
|
|
||||||
}
|
}
|
||||||
|
themeManager, ok := tm.(*themes.ThemeManager)
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "主题管理器类型错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
newTheme := c.PostForm("theme")
|
||||||
|
if err := themeManager.LoadTheme(newTheme); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "加载主题失败: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
config.SetCurrentTheme(newTheme) // 持久化主题配置(需确保 config 包支持)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "主题切换成功"})
|
||||||
}
|
}
|
||||||
|
|||||||
64
web/themes/default/templates/post.tmpl
Normal file
64
web/themes/default/templates/post.tmpl
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Page 1</title>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
|
||||||
|
<style type="text/css">
|
||||||
|
.container {
|
||||||
|
width: 1170px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-list {
|
||||||
|
width: 75%;
|
||||||
|
float: left;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
float: left;
|
||||||
|
width: 25%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="layui-col-md9 layui-col-lg9">
|
||||||
|
<div class="title-article">
|
||||||
|
<h1>{{.Title}}</h1>
|
||||||
|
<div class="title-msg">
|
||||||
|
<span><i class="layui-icon"></i> {{.AuthorID}} </span>
|
||||||
|
<span><i class="layui-icon"></i> {{.Created}}</span>
|
||||||
|
<span><i class="layui-icon"></i> {{.CommentsNum}}条</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text" itemprop="articleBody">
|
||||||
|
{{.Text}}
|
||||||
|
</div>
|
||||||
|
<div class="tags-text">
|
||||||
|
<i class="layui-icon"></i>标签:{{.Type}}
|
||||||
|
</div>
|
||||||
|
<div class="copy-text">
|
||||||
|
<div>
|
||||||
|
<p>非特殊说明,本博所有文章均为博主原创。</p>
|
||||||
|
<p class="hidden-xs">如若转载,请注明出处: </p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="page-text">
|
||||||
|
<div>
|
||||||
|
<span class="layui-badge layui-bg-gray">上一篇</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="layui-badge layui-bg-gray">下一篇</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="comment-text layui-form">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user