lv8girl!
This commit is contained in:
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
# 默认忽略的文件
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# 基于编辑器的 HTTP 客户端请求
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
6
.idea/MarsCodeWorkspaceAppSettings.xml
generated
Normal file
6
.idea/MarsCodeWorkspaceAppSettings.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="com.codeverse.userSettings.MarscodeWorkspaceAppSettingsState">
|
||||
<option name="progress" value="1.0" />
|
||||
</component>
|
||||
</project>
|
||||
9
.idea/lv8girl-go.iml
generated
Normal file
9
.idea/lv8girl-go.iml
generated
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="Go" enabled="true" />
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/lv8girl-go.iml" filepath="$PROJECT_DIR$/.idea/lv8girl-go.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
254
README.md
Normal file
254
README.md
Normal file
@@ -0,0 +1,254 @@
|
||||
# lv8girl - 绿坝娘二次元论坛
|
||||
|
||||
一个基于 Go + Gin + SQLite 构建的二次元社区论坛系统。
|
||||
|
||||
## 项目概述
|
||||
|
||||
lv8girl 是一个功能完整的社区论坛系统,支持用户注册登录、帖子发布审核、评论点赞、私信系统和管理后台等功能。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **语言**: Go 1.21+
|
||||
- **Web框架**: Gin
|
||||
- **数据库**: SQLite (使用 glebarez/sqlite 纯Go实现,无需CGO)
|
||||
- **ORM**: GORM
|
||||
- **会话管理**: gin-sessions
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
lv8girl/
|
||||
├── cmd/
|
||||
│ └── server/
|
||||
│ └── main.go # 程序入口文件
|
||||
│
|
||||
├── internal/ # 私有应用代码
|
||||
│ ├── config/ # 配置管理
|
||||
│ │ └── config.go # 应用配置定义
|
||||
│ │
|
||||
│ ├── controllers/ # 控制器层 - 处理HTTP请求
|
||||
│ │ ├── admin.go # 管理后台控制器
|
||||
│ │ ├── auth.go # 认证控制器(登录/注册/登出)
|
||||
│ │ ├── discussion.go # 帖子发布控制器
|
||||
│ │ ├── home.go # 首页/帖子详情控制器
|
||||
│ │ ├── message.go # 私信控制器
|
||||
│ │ └── user.go # 用户资料控制器
|
||||
│ │
|
||||
│ ├── middleware/ # 中间件
|
||||
│ │ └── auth.go # 认证中间件(登录检查/管理员检查)
|
||||
│ │
|
||||
│ ├── models/ # 数据模型定义
|
||||
│ │ └── models.go # User, Discussion, Comment, Like, PrivateMessage
|
||||
│ │
|
||||
│ ├── repositories/ # 数据访问层 - 数据库操作
|
||||
│ │ ├── db.go # 数据库初始化
|
||||
│ │ ├── comment.go # 评论数据访问
|
||||
│ │ ├── discussion.go # 帖子数据访问
|
||||
│ │ ├── like.go # 点赞数据访问
|
||||
│ │ ├── message.go # 私信数据访问
|
||||
│ │ └── user.go # 用户数据访问
|
||||
│ │
|
||||
│ ├── routes/ # 路由配置
|
||||
│ │ └── routes.go # 所有路由定义
|
||||
│ │
|
||||
│ ├── services/ # 业务逻辑层
|
||||
│ │ ├── admin.go # 管理业务逻辑
|
||||
│ │ ├── auth.go # 认证业务逻辑
|
||||
│ │ ├── discussion.go # 帖子业务逻辑
|
||||
│ │ ├── message.go # 私信业务逻辑
|
||||
│ │ └── user.go # 用户业务逻辑
|
||||
│ │
|
||||
│ └── utils/ # 工具函数
|
||||
│ └── utils.go # 通用工具函数
|
||||
│
|
||||
├── templates/ # HTML模板文件
|
||||
│ ├── index.html # 首页
|
||||
│ ├── login.html # 登录页
|
||||
│ ├── register.html # 注册页
|
||||
│ ├── post.html # 帖子详情页
|
||||
│ ├── post_discussion.html # 发帖页
|
||||
│ ├── profile.html # 用户资料页
|
||||
│ ├── messages.html # 私信列表页
|
||||
│ ├── send_message.html # 发送私信页
|
||||
│ ├── admin_dashboard.html # 管理后台-仪表盘
|
||||
│ ├── admin_pending_posts.html # 管理后台-待审核帖子
|
||||
│ ├── admin_pending_users.html # 管理后台-待审核用户
|
||||
│ ├── admin_posts.html # 管理后台-帖子管理
|
||||
│ ├── admin_users.html # 管理后台-用户管理
|
||||
│ └── admin_comments.html # 管理后台-评论管理
|
||||
│
|
||||
├── static/ # 静态资源(CSS/JS)
|
||||
├── uploads/ # 用户上传文件
|
||||
│ ├── avatars/ # 用户头像
|
||||
│ └── posts/ # 帖子图片
|
||||
│
|
||||
├── data/ # 数据库文件目录
|
||||
│ └── lv8girl.db # SQLite数据库
|
||||
│
|
||||
├── go.mod # Go模块定义
|
||||
├── go.sum # 依赖校验文件
|
||||
├── Dockerfile # Docker构建文件
|
||||
├── compose.yaml # Docker Compose配置
|
||||
└── README.md # 项目说明文档
|
||||
```
|
||||
|
||||
## 架构设计
|
||||
|
||||
项目采用分层架构,职责分离清晰:
|
||||
|
||||
```
|
||||
请求 → 路由(Routes) → 中间件(Middleware) → 控制器(Controllers) → 服务(Services) → 仓库(Repositories) → 数据库
|
||||
```
|
||||
|
||||
### 各层职责
|
||||
|
||||
| 层级 | 职责 | 示例 |
|
||||
|------|------|------|
|
||||
| Routes | 定义URL路由规则,绑定控制器方法 | `r.GET("/", homeCtrl.Index)` |
|
||||
| Middleware | 请求预处理(认证、权限检查等) | `AuthRequired()`, `AdminRequired()` |
|
||||
| Controllers | 处理HTTP请求,参数验证,调用Service | 解析表单数据,返回HTML/JSON |
|
||||
| Services | 业务逻辑处理,事务管理 | 用户注册流程,帖子审核流程 |
|
||||
| Repositories | 数据库CRUD操作 | `FindByID`, `Create`, `Update` |
|
||||
| Models | 数据结构定义 | `User`, `Discussion` 结构体 |
|
||||
|
||||
## 功能特性
|
||||
|
||||
### 用户功能
|
||||
- 用户注册(需管理员审核)
|
||||
- 用户登录/登出
|
||||
- 个人资料页
|
||||
- 头像上传
|
||||
|
||||
### 帖子功能
|
||||
- 发布帖子(支持图片上传,需审核)
|
||||
- 帖子列表浏览
|
||||
- 帖子详情查看
|
||||
- 点赞功能
|
||||
- 评论功能
|
||||
|
||||
### 私信功能
|
||||
- 发送私信
|
||||
- 私信列表
|
||||
- 未读消息提示
|
||||
|
||||
### 管理后台
|
||||
- 仪表盘统计
|
||||
- 用户审核管理
|
||||
- 帖子审核管理
|
||||
- 用户角色管理
|
||||
- 评论管理
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 本地运行
|
||||
|
||||
```bash
|
||||
# 克隆项目
|
||||
git clone <repository-url>
|
||||
cd lv8girl
|
||||
|
||||
# 下载依赖
|
||||
go mod tidy
|
||||
|
||||
# 运行
|
||||
go run ./cmd/server
|
||||
```
|
||||
|
||||
访问 http://localhost:8080
|
||||
|
||||
### Docker运行
|
||||
|
||||
```bash
|
||||
# 构建并运行
|
||||
docker-compose up --build
|
||||
```
|
||||
|
||||
## 默认账号
|
||||
|
||||
管理员账号:
|
||||
- 用户名: `admin`
|
||||
- 密码: `admin123`
|
||||
|
||||
## 配置说明
|
||||
|
||||
配置文件位于 `internal/config/config.go`:
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
Server ServerConfig // 服务端口等
|
||||
Database DatabaseConfig // 数据库路径
|
||||
Session SessionConfig // 会话密钥
|
||||
App AppConfig // 应用名称版本
|
||||
}
|
||||
```
|
||||
|
||||
## 数据库模型
|
||||
|
||||
### User (用户)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | uint | 主键 |
|
||||
| username | string | 用户名 |
|
||||
| email | string | 邮箱 |
|
||||
| password_hash | string | 密码哈希 |
|
||||
| avatar | string | 头像路径 |
|
||||
| role | string | 角色(user/admin/banned) |
|
||||
| status | string | 状态(pending/approved/rejected) |
|
||||
| last_active | time | 最后活动时间 |
|
||||
|
||||
### Discussion (帖子)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | uint | 主键 |
|
||||
| user_id | uint | 作者ID |
|
||||
| title | string | 标题 |
|
||||
| content | text | 内容 |
|
||||
| image_path | string | 图片路径 |
|
||||
| status | string | 状态(pending/approved/rejected) |
|
||||
| views | int | 浏览量 |
|
||||
|
||||
### Comment (评论)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | uint | 主键 |
|
||||
| post_id | uint | 帖子ID |
|
||||
| user_id | uint | 评论者ID |
|
||||
| content | text | 内容 |
|
||||
|
||||
### Like (点赞)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | uint | 主键 |
|
||||
| post_id | uint | 帖子ID |
|
||||
| user_id | uint | 用户ID |
|
||||
|
||||
### PrivateMessage (私信)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | uint | 主键 |
|
||||
| from_user_id | uint | 发送者ID |
|
||||
| to_user_id | uint | 接收者ID |
|
||||
| content | text | 内容 |
|
||||
| is_read | bool | 是否已读 |
|
||||
|
||||
## 开发指南
|
||||
|
||||
### 添加新功能
|
||||
|
||||
1. 在 `models/` 添加数据模型
|
||||
2. 在 `repositories/` 添加数据访问方法
|
||||
3. 在 `services/` 添加业务逻辑
|
||||
4. 在 `controllers/` 添加控制器
|
||||
5. 在 `routes/` 注册路由
|
||||
6. 在 `templates/` 添加模板
|
||||
|
||||
### 代码规范
|
||||
|
||||
- 遵循 Go 命名规范
|
||||
- 每个包有明确的职责
|
||||
- 错误要妥善处理
|
||||
- 敏感信息不要硬编码
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT License
|
||||
28
cmd/server/main.go
Normal file
28
cmd/server/main.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"lv8girl/internal/config"
|
||||
"lv8girl/internal/repositories"
|
||||
"lv8girl/internal/routes"
|
||||
"lv8girl/internal/services"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg := config.GetConfig()
|
||||
|
||||
if err := repositories.Init(cfg.Database.Path); err != nil {
|
||||
log.Fatalf("Failed to initialize database: %v", err)
|
||||
}
|
||||
|
||||
authSvc := services.NewAuthService()
|
||||
authSvc.InitAdmin()
|
||||
|
||||
r := routes.SetupRouter()
|
||||
|
||||
log.Printf("Server starting on %s", cfg.Server.Port)
|
||||
if err := r.Run(cfg.Server.Port); err != nil {
|
||||
log.Fatalf("Failed to start server: %v", err)
|
||||
}
|
||||
}
|
||||
51
go.mod
Normal file
51
go.mod
Normal file
@@ -0,0 +1,51 @@
|
||||
module lv8girl
|
||||
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
github.com/gin-contrib/sessions v1.0.1
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/glebarez/sqlite v1.10.0
|
||||
golang.org/x/crypto v0.22.0
|
||||
gorm.io/gorm v1.25.8
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.11.3 // indirect
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
|
||||
github.com/chenzhuoyu/iasm v0.9.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.19.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/gorilla/context v1.1.2 // indirect
|
||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||
github.com/gorilla/sessions v1.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
golang.org/x/arch v0.7.0 // indirect
|
||||
golang.org/x/net v0.24.0 // indirect
|
||||
golang.org/x/sys v0.19.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
google.golang.org/protobuf v1.33.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.22.5 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.5.0 // indirect
|
||||
modernc.org/sqlite v1.23.1 // indirect
|
||||
)
|
||||
129
go.sum
Normal file
129
go.sum
Normal file
@@ -0,0 +1,129 @@
|
||||
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
||||
github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM=
|
||||
github.com/bytedance/sonic v1.11.3 h1:jRN+yEjakWh8aK5FzrciUHG8OFXK+4/KrAX/ysEtHAA=
|
||||
github.com/bytedance/sonic v1.11.3/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA=
|
||||
github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
|
||||
github.com/chenzhuoyu/iasm v0.9.1 h1:tUHQJXo3NhBqw6s33wkGn9SP3bvrWLdlVIJ3hQBL7P0=
|
||||
github.com/chenzhuoyu/iasm v0.9.1/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/gin-contrib/sessions v1.0.1 h1:3hsJyNs7v7N8OtelFmYXFrulAf6zSR7nW/putcPEHxI=
|
||||
github.com/gin-contrib/sessions v1.0.1/go.mod h1:ouxSFM24/OgIud5MJYQJLpy6AwxQ5EYO9yLhbtObGkM=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
||||
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
||||
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||
github.com/glebarez/sqlite v1.10.0 h1:u4gt8y7OND/cCei/NMHmfbLxF6xP2wgKcT/BJf2pYkc=
|
||||
github.com/glebarez/sqlite v1.10.0/go.mod h1:IJ+lfSOmiekhQsFTJRx/lHtGYmCdtAiTaf5wI9u5uHA=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.19.0 h1:ol+5Fu+cSq9JD7SoSqe04GMI92cbn0+wvQ3bZ8b/AU4=
|
||||
github.com/go-playground/validator/v10 v10.19.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
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/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o=
|
||||
github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM=
|
||||
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||
github.com/gorilla/sessions v1.2.2 h1:lqzMYz6bOfvn2WriPUjNByzeXIlVzURcPmgMczkmTjY=
|
||||
github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.0 h1:QLgLl2yMN7N+ruc31VynXs1vhMZa7CeHHejIeBAsoHo=
|
||||
github.com/pelletier/go-toml/v2 v2.2.0/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.7.0 h1:pskyeJh/3AmoQ8CPE95vxHLqp1G1GfGNXTmcl9NEKTc=
|
||||
golang.org/x/arch v0.7.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
|
||||
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
|
||||
golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
|
||||
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
|
||||
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
|
||||
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/gorm v1.25.8 h1:WAGEZ/aEcznN4D03laj8DKnehe1e9gYQAjW8xyPRdeo=
|
||||
gorm.io/gorm v1.25.8/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
|
||||
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
53
internal/config/config.go
Normal file
53
internal/config/config.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package config
|
||||
|
||||
import "time"
|
||||
|
||||
type Config struct {
|
||||
Server ServerConfig
|
||||
Database DatabaseConfig
|
||||
Session SessionConfig
|
||||
App AppConfig
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Port string
|
||||
ReadTimeout time.Duration
|
||||
WriteTimeout time.Duration
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
Path string
|
||||
}
|
||||
|
||||
type SessionConfig struct {
|
||||
Secret string
|
||||
Name string
|
||||
}
|
||||
|
||||
type AppConfig struct {
|
||||
Name string
|
||||
Version string
|
||||
}
|
||||
|
||||
var AppConfigInstance = &Config{
|
||||
Server: ServerConfig{
|
||||
Port: ":8080",
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
},
|
||||
Database: DatabaseConfig{
|
||||
Path: "data/lv8girl.db",
|
||||
},
|
||||
Session: SessionConfig{
|
||||
Secret: "lv8girl-secret-key-change-in-production",
|
||||
Name: "lv8girl_session",
|
||||
},
|
||||
App: AppConfig{
|
||||
Name: "lv8girl",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
}
|
||||
|
||||
func GetConfig() *Config {
|
||||
return AppConfigInstance
|
||||
}
|
||||
179
internal/controllers/admin.go
Normal file
179
internal/controllers/admin.go
Normal file
@@ -0,0 +1,179 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"lv8girl/internal/middleware"
|
||||
"lv8girl/internal/services"
|
||||
)
|
||||
|
||||
type AdminController struct {
|
||||
adminSvc *services.AdminService
|
||||
messageSvc *services.MessageService
|
||||
}
|
||||
|
||||
func NewAdminController() *AdminController {
|
||||
return &AdminController{
|
||||
adminSvc: services.NewAdminService(),
|
||||
messageSvc: services.NewMessageService(),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *AdminController) Dashboard(ctx *gin.Context) {
|
||||
username, _ := ctx.Get("username")
|
||||
stats, _ := c.adminSvc.GetStats()
|
||||
|
||||
ctx.HTML(http.StatusOK, "admin_dashboard.html", gin.H{
|
||||
"Username": username,
|
||||
"Stats": stats,
|
||||
"Page": "dashboard",
|
||||
"Message": ctx.Query("msg"),
|
||||
})
|
||||
}
|
||||
|
||||
func (c *AdminController) PendingPosts(ctx *gin.Context) {
|
||||
username, _ := ctx.Get("username")
|
||||
posts, _ := c.adminSvc.GetPendingPosts()
|
||||
|
||||
ctx.HTML(http.StatusOK, "admin_pending_posts.html", gin.H{
|
||||
"Username": username,
|
||||
"Posts": posts,
|
||||
"Page": "pending_posts",
|
||||
"Message": ctx.Query("msg"),
|
||||
})
|
||||
}
|
||||
|
||||
func (c *AdminController) ApprovePost(ctx *gin.Context) {
|
||||
postID := parseUint(ctx.Param("id"))
|
||||
action := ctx.Param("action")
|
||||
|
||||
if action == "approve" {
|
||||
c.adminSvc.ApprovePost(postID)
|
||||
ctx.Redirect(http.StatusFound, "/admin/pending_posts?msg=帖子已通过审核")
|
||||
} else if action == "reject" {
|
||||
c.adminSvc.RejectPost(postID)
|
||||
ctx.Redirect(http.StatusFound, "/admin/pending_posts?msg=帖子已拒绝")
|
||||
} else {
|
||||
ctx.Redirect(http.StatusFound, "/admin/pending_posts")
|
||||
}
|
||||
}
|
||||
|
||||
func (c *AdminController) PendingUsers(ctx *gin.Context) {
|
||||
username, _ := ctx.Get("username")
|
||||
users, _ := c.adminSvc.GetPendingUsers()
|
||||
|
||||
ctx.HTML(http.StatusOK, "admin_pending_users.html", gin.H{
|
||||
"Username": username,
|
||||
"Users": users,
|
||||
"Page": "pending_users",
|
||||
"Message": ctx.Query("msg"),
|
||||
})
|
||||
}
|
||||
|
||||
func (c *AdminController) ApproveUser(ctx *gin.Context) {
|
||||
userID := parseUint(ctx.Param("id"))
|
||||
action := ctx.Param("action")
|
||||
adminID, _ := ctx.Get("user_id")
|
||||
|
||||
if action == "approve" {
|
||||
c.adminSvc.ApproveUser(userID)
|
||||
c.messageSvc.NotifyUserApproved(adminID.(uint), userID)
|
||||
ctx.Redirect(http.StatusFound, "/admin/pending_users?msg=用户已通过审核")
|
||||
} else if action == "reject" {
|
||||
c.adminSvc.RejectUser(userID)
|
||||
c.messageSvc.NotifyUserRejected(adminID.(uint), userID)
|
||||
ctx.Redirect(http.StatusFound, "/admin/pending_users?msg=用户已拒绝")
|
||||
} else {
|
||||
ctx.Redirect(http.StatusFound, "/admin/pending_users")
|
||||
}
|
||||
}
|
||||
|
||||
func (c *AdminController) Posts(ctx *gin.Context) {
|
||||
username, _ := ctx.Get("username")
|
||||
posts, _ := c.adminSvc.GetAllPosts()
|
||||
|
||||
ctx.HTML(http.StatusOK, "admin_posts.html", gin.H{
|
||||
"Username": username,
|
||||
"Posts": posts,
|
||||
"Page": "posts",
|
||||
"Message": ctx.Query("msg"),
|
||||
})
|
||||
}
|
||||
|
||||
func (c *AdminController) DeletePost(ctx *gin.Context) {
|
||||
postID := parseUint(ctx.Param("id"))
|
||||
c.adminSvc.DeletePost(postID)
|
||||
ctx.Redirect(http.StatusFound, "/admin/posts?msg=帖子已删除")
|
||||
}
|
||||
|
||||
func (c *AdminController) Users(ctx *gin.Context) {
|
||||
username, _ := ctx.Get("username")
|
||||
currentUserID, _ := ctx.Get("user_id")
|
||||
users, _ := c.adminSvc.GetAllUsers()
|
||||
|
||||
ctx.HTML(http.StatusOK, "admin_users.html", gin.H{
|
||||
"Username": username,
|
||||
"Users": users,
|
||||
"CurrentUserID": currentUserID,
|
||||
"Page": "users",
|
||||
"Message": ctx.Query("msg"),
|
||||
})
|
||||
}
|
||||
|
||||
func (c *AdminController) UpdateUserRole(ctx *gin.Context) {
|
||||
userIDStr := ctx.PostForm("user_id")
|
||||
newRole := ctx.PostForm("new_role")
|
||||
currentUserID, _ := ctx.Get("user_id")
|
||||
|
||||
userID, _ := strconv.ParseUint(userIDStr, 10, 32)
|
||||
|
||||
if uint(userID) == currentUserID.(uint) {
|
||||
ctx.Redirect(http.StatusFound, "/admin/users?msg=不能修改自己的角色")
|
||||
return
|
||||
}
|
||||
|
||||
c.adminSvc.UpdateUserRole(uint(userID), newRole)
|
||||
|
||||
if newRole == "banned" {
|
||||
c.messageSvc.NotifyUserBanned(currentUserID.(uint), uint(userID))
|
||||
}
|
||||
|
||||
ctx.Redirect(http.StatusFound, "/admin/users?msg=用户角色已更新")
|
||||
}
|
||||
|
||||
func (c *AdminController) DeleteUser(ctx *gin.Context) {
|
||||
userID := parseUint(ctx.Param("id"))
|
||||
currentUserID, _ := ctx.Get("user_id")
|
||||
|
||||
if userID == currentUserID.(uint) {
|
||||
ctx.Redirect(http.StatusFound, "/admin/users?msg=不能删除自己")
|
||||
return
|
||||
}
|
||||
|
||||
c.adminSvc.DeleteUser(userID)
|
||||
ctx.Redirect(http.StatusFound, "/admin/users?msg=用户已删除")
|
||||
}
|
||||
|
||||
func (c *AdminController) Comments(ctx *gin.Context) {
|
||||
username, _ := ctx.Get("username")
|
||||
comments, _ := c.adminSvc.GetAllComments()
|
||||
|
||||
ctx.HTML(http.StatusOK, "admin_comments.html", gin.H{
|
||||
"Username": username,
|
||||
"Comments": comments,
|
||||
"Page": "comments",
|
||||
"Message": ctx.Query("msg"),
|
||||
})
|
||||
}
|
||||
|
||||
func (c *AdminController) DeleteComment(ctx *gin.Context) {
|
||||
commentID := parseUint(ctx.Param("id"))
|
||||
c.adminSvc.DeleteComment(commentID)
|
||||
ctx.Redirect(http.StatusFound, "/admin/comments?msg=评论已删除")
|
||||
}
|
||||
|
||||
func init() {
|
||||
_ = middleware.GetCurrentUser
|
||||
}
|
||||
99
internal/controllers/auth.go
Normal file
99
internal/controllers/auth.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
"lv8girl/internal/services"
|
||||
)
|
||||
|
||||
type AuthController struct {
|
||||
authService *services.AuthService
|
||||
}
|
||||
|
||||
func NewAuthController() *AuthController {
|
||||
return &AuthController{
|
||||
authService: services.NewAuthService(),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *AuthController) ShowLogin(ctx *gin.Context) {
|
||||
session := sessions.Default(ctx)
|
||||
if session.Get("user_id") != nil {
|
||||
ctx.Redirect(http.StatusFound, "/")
|
||||
return
|
||||
}
|
||||
ctx.HTML(http.StatusOK, "login.html", gin.H{"Error": ""})
|
||||
}
|
||||
|
||||
func (c *AuthController) Login(ctx *gin.Context) {
|
||||
session := sessions.Default(ctx)
|
||||
if session.Get("user_id") != nil {
|
||||
ctx.Redirect(http.StatusFound, "/")
|
||||
return
|
||||
}
|
||||
|
||||
login := ctx.PostForm("login")
|
||||
password := ctx.PostForm("password")
|
||||
|
||||
if login == "" || password == "" {
|
||||
ctx.HTML(http.StatusOK, "login.html", gin.H{"Error": "请输入用户名/邮箱和密码"})
|
||||
return
|
||||
}
|
||||
|
||||
result := c.authService.Login(login, password)
|
||||
if !result.Success {
|
||||
ctx.HTML(http.StatusOK, "login.html", gin.H{"Error": result.Error})
|
||||
return
|
||||
}
|
||||
|
||||
session.Set("user_id", result.User.ID)
|
||||
session.Set("username", result.User.Username)
|
||||
session.Set("user_role", result.User.Role)
|
||||
session.Save()
|
||||
|
||||
ctx.Redirect(http.StatusFound, "/")
|
||||
}
|
||||
|
||||
func (c *AuthController) ShowRegister(ctx *gin.Context) {
|
||||
session := sessions.Default(ctx)
|
||||
if session.Get("user_id") != nil {
|
||||
ctx.Redirect(http.StatusFound, "/")
|
||||
return
|
||||
}
|
||||
ctx.HTML(http.StatusOK, "register.html", gin.H{"Error": "", "Success": ""})
|
||||
}
|
||||
|
||||
func (c *AuthController) Register(ctx *gin.Context) {
|
||||
session := sessions.Default(ctx)
|
||||
if session.Get("user_id") != nil {
|
||||
ctx.Redirect(http.StatusFound, "/")
|
||||
return
|
||||
}
|
||||
|
||||
username := ctx.PostForm("username")
|
||||
email := ctx.PostForm("email")
|
||||
password := ctx.PostForm("password")
|
||||
confirmPassword := ctx.PostForm("confirm_password")
|
||||
|
||||
if password != confirmPassword {
|
||||
ctx.HTML(http.StatusOK, "register.html", gin.H{"Error": "两次输入的密码不一致", "Success": ""})
|
||||
return
|
||||
}
|
||||
|
||||
result := c.authService.Register(username, email, password)
|
||||
if !result.Success {
|
||||
ctx.HTML(http.StatusOK, "register.html", gin.H{"Error": result.Error, "Success": ""})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.HTML(http.StatusOK, "register.html", gin.H{"Error": "", "Success": "注册成功!您的账号正在等待管理员审核,请耐心等待。"})
|
||||
}
|
||||
|
||||
func (c *AuthController) Logout(ctx *gin.Context) {
|
||||
session := sessions.Default(ctx)
|
||||
session.Clear()
|
||||
session.Save()
|
||||
ctx.Redirect(http.StatusFound, "/")
|
||||
}
|
||||
121
internal/controllers/discussion.go
Normal file
121
internal/controllers/discussion.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"lv8girl/internal/middleware"
|
||||
"lv8girl/internal/services"
|
||||
)
|
||||
|
||||
type DiscussionController struct {
|
||||
discussionSvc *services.DiscussionService
|
||||
}
|
||||
|
||||
func NewDiscussionController() *DiscussionController {
|
||||
return &DiscussionController{
|
||||
discussionSvc: services.NewDiscussionService(),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *DiscussionController) ShowNewPost(ctx *gin.Context) {
|
||||
userID, username, userRole, _ := middleware.GetCurrentUser(ctx)
|
||||
ctx.HTML(http.StatusOK, "post_discussion.html", gin.H{
|
||||
"IsLoggedIn": true,
|
||||
"UserID": userID,
|
||||
"Username": username,
|
||||
"UserRole": userRole,
|
||||
"Error": "",
|
||||
"Success": "",
|
||||
})
|
||||
}
|
||||
|
||||
func (c *DiscussionController) CreatePost(ctx *gin.Context) {
|
||||
userID, username, userRole, _ := middleware.GetCurrentUser(ctx)
|
||||
|
||||
title := strings.TrimSpace(ctx.PostForm("title"))
|
||||
content := strings.TrimSpace(ctx.PostForm("content"))
|
||||
|
||||
if title == "" || content == "" {
|
||||
ctx.HTML(http.StatusOK, "post_discussion.html", gin.H{
|
||||
"IsLoggedIn": true,
|
||||
"UserID": userID,
|
||||
"Username": username,
|
||||
"UserRole": userRole,
|
||||
"Error": "标题和内容不能为空",
|
||||
"Success": "",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var imagePath string
|
||||
file, err := ctx.FormFile("image")
|
||||
if err == nil {
|
||||
if file.Size > 2*1024*1024 {
|
||||
ctx.HTML(http.StatusOK, "post_discussion.html", gin.H{
|
||||
"IsLoggedIn": true,
|
||||
"UserID": userID,
|
||||
"Username": username,
|
||||
"UserRole": userRole,
|
||||
"Error": "图片大小不能超过2MB",
|
||||
"Success": "",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(file.Filename))
|
||||
filename := "post_" + time.Now().Format("20060102150405") + "_" + randomString(8) + ext
|
||||
uploadDir := "uploads/posts"
|
||||
|
||||
if _, err := os.Stat(uploadDir); os.IsNotExist(err) {
|
||||
os.MkdirAll(uploadDir, 0755)
|
||||
}
|
||||
|
||||
imagePath = filepath.Join(uploadDir, filename)
|
||||
if err := ctx.SaveUploadedFile(file, imagePath); err != nil {
|
||||
ctx.HTML(http.StatusOK, "post_discussion.html", gin.H{
|
||||
"IsLoggedIn": true,
|
||||
"UserID": userID,
|
||||
"Username": username,
|
||||
"UserRole": userRole,
|
||||
"Error": "图片保存失败",
|
||||
"Success": "",
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := c.discussionSvc.CreatePost(userID, title, content, imagePath); err != nil {
|
||||
ctx.HTML(http.StatusOK, "post_discussion.html", gin.H{
|
||||
"IsLoggedIn": true,
|
||||
"UserID": userID,
|
||||
"Username": username,
|
||||
"UserRole": userRole,
|
||||
"Error": "发帖失败,请稍后重试",
|
||||
"Success": "",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.HTML(http.StatusOK, "post_discussion.html", gin.H{
|
||||
"IsLoggedIn": true,
|
||||
"UserID": userID,
|
||||
"Username": username,
|
||||
"UserRole": userRole,
|
||||
"Error": "",
|
||||
"Success": "帖子已提交,等待管理员审核。",
|
||||
})
|
||||
}
|
||||
|
||||
func randomString(n int) string {
|
||||
const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
b[i] = letters[time.Now().UnixNano()%int64(len(letters))]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
150
internal/controllers/home.go
Normal file
150
internal/controllers/home.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
"lv8girl/internal/middleware"
|
||||
"lv8girl/internal/repositories"
|
||||
"lv8girl/internal/services"
|
||||
)
|
||||
|
||||
type HomeController struct {
|
||||
discussionSvc *services.DiscussionService
|
||||
userSvc *services.UserService
|
||||
messageSvc *services.MessageService
|
||||
discussionRepo *repositories.DiscussionRepository
|
||||
}
|
||||
|
||||
func NewHomeController() *HomeController {
|
||||
return &HomeController{
|
||||
discussionSvc: services.NewDiscussionService(),
|
||||
userSvc: services.NewUserService(),
|
||||
messageSvc: services.NewMessageService(),
|
||||
discussionRepo: repositories.NewDiscussionRepository(),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *HomeController) Index(ctx *gin.Context) {
|
||||
session := sessions.Default(ctx)
|
||||
userID, _ := session.Get("user_id").(uint)
|
||||
username, _ := session.Get("username").(string)
|
||||
userRole, _ := session.Get("user_role").(string)
|
||||
isLoggedIn := userID != 0
|
||||
|
||||
if isLoggedIn {
|
||||
userRepo := repositories.NewUserRepository()
|
||||
userRepo.UpdateLastActive(userID)
|
||||
}
|
||||
|
||||
posts, _ := c.discussionSvc.GetApprovedPosts(30)
|
||||
|
||||
var postCount, userCount, onlineCount int64
|
||||
postCount, _ = c.discussionRepo.CountByStatus("approved")
|
||||
userCount, onlineCount, _ = c.userSvc.GetUserStats()
|
||||
|
||||
var unreadCount int64
|
||||
if isLoggedIn {
|
||||
unreadCount, _ = c.messageSvc.GetUnreadCount(userID)
|
||||
}
|
||||
|
||||
ctx.HTML(http.StatusOK, "index.html", gin.H{
|
||||
"IsLoggedIn": isLoggedIn,
|
||||
"UserID": userID,
|
||||
"Username": username,
|
||||
"UserRole": userRole,
|
||||
"Posts": posts,
|
||||
"PostCount": postCount,
|
||||
"UserCount": userCount,
|
||||
"OnlineCount": onlineCount,
|
||||
"UnreadCount": unreadCount,
|
||||
})
|
||||
}
|
||||
|
||||
func (c *HomeController) ShowPost(ctx *gin.Context) {
|
||||
postID := parseUint(ctx.Param("id"))
|
||||
userID, username, userRole, isLoggedIn := middleware.GetCurrentUser(ctx)
|
||||
|
||||
session := sessions.Default(ctx)
|
||||
viewedKey := "viewed_posts"
|
||||
viewedPosts := session.Get(viewedKey)
|
||||
var viewedMap map[uint]bool
|
||||
if viewedPosts == nil {
|
||||
viewedMap = make(map[uint]bool)
|
||||
} else {
|
||||
viewedMap = viewedPosts.(map[uint]bool)
|
||||
}
|
||||
|
||||
if !viewedMap[postID] {
|
||||
c.discussionSvc.IncrementViews(postID)
|
||||
viewedMap[postID] = true
|
||||
session.Set(viewedKey, viewedMap)
|
||||
session.Save()
|
||||
}
|
||||
|
||||
detail, err := c.discussionSvc.GetPostDetail(postID, userID)
|
||||
if err != nil {
|
||||
ctx.String(http.StatusNotFound, "帖子不存在")
|
||||
return
|
||||
}
|
||||
|
||||
comments, _ := c.discussionSvc.GetComments(postID)
|
||||
|
||||
avatar := ""
|
||||
if detail.Post.User.Avatar != "" {
|
||||
avatar = "/" + detail.Post.User.Avatar
|
||||
}
|
||||
|
||||
ctx.HTML(http.StatusOK, "post.html", gin.H{
|
||||
"IsLoggedIn": isLoggedIn,
|
||||
"UserID": userID,
|
||||
"Username": username,
|
||||
"UserRole": userRole,
|
||||
"Post": detail.Post,
|
||||
"PostAvatar": avatar,
|
||||
"Comments": comments,
|
||||
"LikeCount": detail.LikeCount,
|
||||
"UserLiked": detail.UserLiked,
|
||||
"AuthorPostCount": detail.AuthorPostCount,
|
||||
})
|
||||
}
|
||||
|
||||
func (c *HomeController) LikePost(ctx *gin.Context) {
|
||||
userID, _, _, isLoggedIn := middleware.GetCurrentUser(ctx)
|
||||
if !isLoggedIn {
|
||||
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
|
||||
return
|
||||
}
|
||||
|
||||
postID := parseUint(ctx.Param("id"))
|
||||
c.discussionSvc.AddLike(postID, userID)
|
||||
ctx.Redirect(http.StatusFound, "/post/"+ctx.Param("id"))
|
||||
}
|
||||
|
||||
func (c *HomeController) AddComment(ctx *gin.Context) {
|
||||
userID, _, _, isLoggedIn := middleware.GetCurrentUser(ctx)
|
||||
if !isLoggedIn {
|
||||
ctx.Redirect(http.StatusFound, "/login")
|
||||
return
|
||||
}
|
||||
|
||||
postID := parseUint(ctx.Param("id"))
|
||||
content := ctx.PostForm("content")
|
||||
|
||||
if content != "" {
|
||||
c.discussionSvc.AddComment(postID, userID, content)
|
||||
}
|
||||
|
||||
ctx.Redirect(http.StatusFound, "/post/"+ctx.Param("id"))
|
||||
}
|
||||
|
||||
func parseUint(s string) uint {
|
||||
var result uint
|
||||
for _, c := range s {
|
||||
if c >= '0' && c <= '9' {
|
||||
result = result*10 + uint(c-'0')
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
89
internal/controllers/message.go
Normal file
89
internal/controllers/message.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"lv8girl/internal/middleware"
|
||||
"lv8girl/internal/repositories"
|
||||
"lv8girl/internal/services"
|
||||
)
|
||||
|
||||
type MessageController struct {
|
||||
messageSvc *services.MessageService
|
||||
userRepo *repositories.UserRepository
|
||||
}
|
||||
|
||||
func NewMessageController() *MessageController {
|
||||
return &MessageController{
|
||||
messageSvc: services.NewMessageService(),
|
||||
userRepo: repositories.NewUserRepository(),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *MessageController) ShowMessages(ctx *gin.Context) {
|
||||
currentUserID, currentUsername, currentUserRole, _ := middleware.GetCurrentUser(ctx)
|
||||
|
||||
conversations, _ := c.messageSvc.GetConversations(currentUserID)
|
||||
|
||||
ctx.HTML(http.StatusOK, "messages.html", gin.H{
|
||||
"IsLoggedIn": true,
|
||||
"UserID": currentUserID,
|
||||
"Username": currentUsername,
|
||||
"UserRole": currentUserRole,
|
||||
"Conversations": conversations,
|
||||
})
|
||||
}
|
||||
|
||||
func (c *MessageController) ShowSendMessage(ctx *gin.Context) {
|
||||
currentUserID, currentUsername, currentUserRole, _ := middleware.GetCurrentUser(ctx)
|
||||
|
||||
toUserID := parseUint(ctx.Query("to"))
|
||||
if toUserID == 0 {
|
||||
ctx.Redirect(http.StatusFound, "/")
|
||||
return
|
||||
}
|
||||
|
||||
receiver, err := c.userRepo.FindByID(toUserID)
|
||||
if err != nil {
|
||||
ctx.String(http.StatusNotFound, "用户不存在")
|
||||
return
|
||||
}
|
||||
|
||||
ctx.HTML(http.StatusOK, "send_message.html", gin.H{
|
||||
"IsLoggedIn": true,
|
||||
"UserID": currentUserID,
|
||||
"Username": currentUsername,
|
||||
"UserRole": currentUserRole,
|
||||
"Receiver": receiver,
|
||||
"Error": "",
|
||||
"Success": "",
|
||||
})
|
||||
}
|
||||
|
||||
func (c *MessageController) SendMessage(ctx *gin.Context) {
|
||||
currentUserID, _, _, _ := middleware.GetCurrentUser(ctx)
|
||||
|
||||
toUserID := parseUint(ctx.Query("to"))
|
||||
content := strings.TrimSpace(ctx.PostForm("content"))
|
||||
|
||||
receiver, _ := c.userRepo.FindByID(toUserID)
|
||||
|
||||
if content == "" {
|
||||
ctx.HTML(http.StatusOK, "send_message.html", gin.H{
|
||||
"Receiver": receiver,
|
||||
"Error": "消息内容不能为空",
|
||||
"Success": "",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.messageSvc.SendMessage(currentUserID, toUserID, content)
|
||||
|
||||
ctx.HTML(http.StatusOK, "send_message.html", gin.H{
|
||||
"Receiver": receiver,
|
||||
"Error": "",
|
||||
"Success": "消息已发送!",
|
||||
})
|
||||
}
|
||||
98
internal/controllers/user.go
Normal file
98
internal/controllers/user.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"lv8girl/internal/middleware"
|
||||
"lv8girl/internal/services"
|
||||
)
|
||||
|
||||
type UserController struct {
|
||||
userSvc *services.UserService
|
||||
}
|
||||
|
||||
func NewUserController() *UserController {
|
||||
return &UserController{
|
||||
userSvc: services.NewUserService(),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *UserController) ShowProfile(ctx *gin.Context) {
|
||||
currentUserID, currentUsername, currentUserRole, isLoggedIn := middleware.GetCurrentUser(ctx)
|
||||
|
||||
userIDStr := ctx.Param("id")
|
||||
var userID uint
|
||||
if userIDStr == "" {
|
||||
userID = currentUserID
|
||||
} else {
|
||||
userID = parseUint(userIDStr)
|
||||
}
|
||||
|
||||
if userID == 0 {
|
||||
ctx.Redirect(http.StatusFound, "/")
|
||||
return
|
||||
}
|
||||
|
||||
profile, err := c.userSvc.GetUserProfile(currentUserID, userID)
|
||||
if err != nil {
|
||||
ctx.String(http.StatusNotFound, "用户不存在")
|
||||
return
|
||||
}
|
||||
|
||||
avatar := ""
|
||||
if profile.User.Avatar != "" {
|
||||
avatar = "/" + profile.User.Avatar
|
||||
}
|
||||
|
||||
ctx.HTML(http.StatusOK, "profile.html", gin.H{
|
||||
"IsLoggedIn": isLoggedIn,
|
||||
"CurrentUserID": currentUserID,
|
||||
"CurrentUsername": currentUsername,
|
||||
"CurrentUserRole": currentUserRole,
|
||||
"User": profile.User,
|
||||
"UserAvatar": avatar,
|
||||
"Posts": profile.Posts,
|
||||
"PostCount": profile.PostCount,
|
||||
"IsOwner": profile.IsOwner,
|
||||
"UnreadCount": profile.UnreadCount,
|
||||
"Message": "",
|
||||
})
|
||||
}
|
||||
|
||||
func (c *UserController) UploadAvatar(ctx *gin.Context) {
|
||||
userID, _, _, _ := middleware.GetCurrentUser(ctx)
|
||||
|
||||
file, err := ctx.FormFile("avatar")
|
||||
if err != nil {
|
||||
ctx.Redirect(http.StatusFound, "/profile?error=上传失败")
|
||||
return
|
||||
}
|
||||
|
||||
if file.Size > 2*1024*1024 {
|
||||
ctx.Redirect(http.StatusFound, "/profile?error=图片大小不能超过2MB")
|
||||
return
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(file.Filename))
|
||||
filename := "avatar_" + strconv.FormatUint(uint64(userID), 10) + "_" + time.Now().Format("20060102150405") + ext
|
||||
uploadDir := "uploads/avatars"
|
||||
|
||||
if _, err := os.Stat(uploadDir); os.IsNotExist(err) {
|
||||
os.MkdirAll(uploadDir, 0755)
|
||||
}
|
||||
|
||||
imagePath := filepath.Join(uploadDir, filename)
|
||||
if err := ctx.SaveUploadedFile(file, imagePath); err != nil {
|
||||
ctx.Redirect(http.StatusFound, "/profile?error=保存失败")
|
||||
return
|
||||
}
|
||||
|
||||
c.userSvc.UpdateAvatar(userID, imagePath)
|
||||
ctx.Redirect(http.StatusFound, "/profile?success=头像更新成功")
|
||||
}
|
||||
49
internal/middleware/auth.go
Normal file
49
internal/middleware/auth.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func AuthRequired() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
userID := session.Get("user_id")
|
||||
if userID == nil {
|
||||
c.Redirect(http.StatusFound, "/login")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Set("user_id", userID)
|
||||
c.Set("username", session.Get("username"))
|
||||
c.Set("user_role", session.Get("user_role"))
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func AdminRequired() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
userRole := session.Get("user_role")
|
||||
if userRole != "admin" {
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Set("user_id", session.Get("user_id"))
|
||||
c.Set("username", session.Get("username"))
|
||||
c.Set("user_role", userRole)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func GetCurrentUser(c *gin.Context) (uint, string, string, bool) {
|
||||
session := sessions.Default(c)
|
||||
userID := session.Get("user_id")
|
||||
if userID == nil {
|
||||
return 0, "", "", false
|
||||
}
|
||||
return userID.(uint), session.Get("username").(string), session.Get("user_role").(string), true
|
||||
}
|
||||
84
internal/models/models.go
Normal file
84
internal/models/models.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Username string `gorm:"size:50;uniqueIndex;not null" json:"username"`
|
||||
Email string `gorm:"size:100;uniqueIndex;not null" json:"email"`
|
||||
PasswordHash string `gorm:"size:255;not null" json:"-"`
|
||||
Avatar string `gorm:"size:255" json:"avatar"`
|
||||
Role string `gorm:"size:20;default:user" json:"role"`
|
||||
Status string `gorm:"size:20;default:pending" json:"status"`
|
||||
LastActive *time.Time `json:"last_active"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
func (User) TableName() string {
|
||||
return "users"
|
||||
}
|
||||
|
||||
type Discussion struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID uint `gorm:"not null;index" json:"user_id"`
|
||||
User User `gorm:"foreignKey:UserID" json:"user"`
|
||||
Title string `gorm:"size:200;not null" json:"title"`
|
||||
Content string `gorm:"type:text;not null" json:"content"`
|
||||
ImagePath string `gorm:"size:255" json:"image_path"`
|
||||
Status string `gorm:"size:20;default:pending" json:"status"`
|
||||
Views int `gorm:"default:0" json:"views"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
func (Discussion) TableName() string {
|
||||
return "discussions"
|
||||
}
|
||||
|
||||
type Comment struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
PostID uint `gorm:"not null;index" json:"post_id"`
|
||||
UserID uint `gorm:"not null;index" json:"user_id"`
|
||||
User User `gorm:"foreignKey:UserID" json:"user"`
|
||||
Content string `gorm:"type:text;not null" json:"content"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
func (Comment) TableName() string {
|
||||
return "comments"
|
||||
}
|
||||
|
||||
type Like struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
PostID uint `gorm:"not null;uniqueIndex:idx_post_user" json:"post_id"`
|
||||
UserID uint `gorm:"not null;uniqueIndex:idx_post_user" json:"user_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (Like) TableName() string {
|
||||
return "likes"
|
||||
}
|
||||
|
||||
type PrivateMessage struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
FromUserID uint `gorm:"not null;index" json:"from_user_id"`
|
||||
FromUser User `gorm:"foreignKey:FromUserID" json:"from_user"`
|
||||
ToUserID uint `gorm:"not null;index" json:"to_user_id"`
|
||||
ToUser User `gorm:"foreignKey:ToUserID" json:"to_user"`
|
||||
Content string `gorm:"type:text;not null" json:"content"`
|
||||
IsRead bool `gorm:"default:false" json:"is_read"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
func (PrivateMessage) TableName() string {
|
||||
return "private_messages"
|
||||
}
|
||||
47
internal/repositories/comment.go
Normal file
47
internal/repositories/comment.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package repositories
|
||||
|
||||
import "lv8girl/internal/models"
|
||||
|
||||
type CommentRepository struct{}
|
||||
|
||||
func NewCommentRepository() *CommentRepository {
|
||||
return &CommentRepository{}
|
||||
}
|
||||
|
||||
func (r *CommentRepository) FindByPostID(postID uint) ([]models.Comment, error) {
|
||||
var comments []models.Comment
|
||||
err := DB.Preload("User").Where("post_id = ?", postID).Order("created_at DESC").Find(&comments).Error
|
||||
return comments, err
|
||||
}
|
||||
|
||||
func (r *CommentRepository) FindAll() ([]models.Comment, error) {
|
||||
var comments []models.Comment
|
||||
err := DB.Preload("User").Order("created_at DESC").Find(&comments).Error
|
||||
return comments, err
|
||||
}
|
||||
|
||||
func (r *CommentRepository) Create(comment *models.Comment) error {
|
||||
return DB.Create(comment).Error
|
||||
}
|
||||
|
||||
func (r *CommentRepository) Delete(id uint) error {
|
||||
return DB.Delete(&models.Comment{}, id).Error
|
||||
}
|
||||
|
||||
func (r *CommentRepository) Count() (int64, error) {
|
||||
var count int64
|
||||
err := DB.Model(&models.Comment{}).Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (r *CommentRepository) CountByPostID(postID uint) (int64, error) {
|
||||
var count int64
|
||||
err := DB.Model(&models.Comment{}).Where("post_id = ?", postID).Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (r *CommentRepository) CountByUserID(userID uint) (int64, error) {
|
||||
var count int64
|
||||
err := DB.Model(&models.Comment{}).Where("user_id = ?", userID).Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
36
internal/repositories/db.go
Normal file
36
internal/repositories/db.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"lv8girl/internal/models"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
var DB *gorm.DB
|
||||
|
||||
func Init(databasePath string) error {
|
||||
var err error
|
||||
DB, err = gorm.Open(sqlite.Open(databasePath), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Info),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = DB.AutoMigrate(
|
||||
&models.User{},
|
||||
&models.Discussion{},
|
||||
&models.Comment{},
|
||||
&models.Like{},
|
||||
&models.PrivateMessage{},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
146
internal/repositories/discussion.go
Normal file
146
internal/repositories/discussion.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"lv8girl/internal/models"
|
||||
)
|
||||
|
||||
type DiscussionRepository struct{}
|
||||
|
||||
func NewDiscussionRepository() *DiscussionRepository {
|
||||
return &DiscussionRepository{}
|
||||
}
|
||||
|
||||
func (r *DiscussionRepository) FindByID(id uint) (*models.Discussion, error) {
|
||||
var discussion models.Discussion
|
||||
err := DB.Preload("User").First(&discussion, id).Error
|
||||
return &discussion, err
|
||||
}
|
||||
|
||||
func (r *DiscussionRepository) FindByIDWithStats(id uint) (*models.Discussion, int64, int64, error) {
|
||||
discussion, err := r.FindByID(id)
|
||||
if err != nil {
|
||||
return nil, 0, 0, err
|
||||
}
|
||||
|
||||
var likeCount, commentCount int64
|
||||
DB.Model(&models.Like{}).Where("post_id = ?", id).Count(&likeCount)
|
||||
DB.Model(&models.Comment{}).Where("post_id = ?", id).Count(&commentCount)
|
||||
|
||||
return discussion, likeCount, commentCount, nil
|
||||
}
|
||||
|
||||
func (r *DiscussionRepository) FindApproved(limit int) ([]models.Discussion, error) {
|
||||
var discussions []models.Discussion
|
||||
err := DB.Preload("User").
|
||||
Where("status = ?", "approved").
|
||||
Order("created_at DESC").
|
||||
Limit(limit).
|
||||
Find(&discussions).Error
|
||||
return discussions, err
|
||||
}
|
||||
|
||||
func (r *DiscussionRepository) FindByUserID(userID uint) ([]models.Discussion, error) {
|
||||
var discussions []models.Discussion
|
||||
err := DB.Where("user_id = ? AND status = ?", userID, "approved").
|
||||
Order("created_at DESC").
|
||||
Find(&discussions).Error
|
||||
return discussions, err
|
||||
}
|
||||
|
||||
func (r *DiscussionRepository) FindPending() ([]models.Discussion, error) {
|
||||
var discussions []models.Discussion
|
||||
err := DB.Preload("User").
|
||||
Where("status = ?", "pending").
|
||||
Order("created_at DESC").
|
||||
Find(&discussions).Error
|
||||
return discussions, err
|
||||
}
|
||||
|
||||
func (r *DiscussionRepository) FindAll() ([]models.Discussion, error) {
|
||||
var discussions []models.Discussion
|
||||
err := DB.Preload("User").Order("created_at DESC").Find(&discussions).Error
|
||||
return discussions, err
|
||||
}
|
||||
|
||||
func (r *DiscussionRepository) Create(discussion *models.Discussion) error {
|
||||
return DB.Create(discussion).Error
|
||||
}
|
||||
|
||||
func (r *DiscussionRepository) Update(discussion *models.Discussion) error {
|
||||
return DB.Save(discussion).Error
|
||||
}
|
||||
|
||||
func (r *DiscussionRepository) UpdateStatus(id uint, status string) error {
|
||||
return DB.Model(&models.Discussion{}).Where("id = ?", id).Update("status", status).Error
|
||||
}
|
||||
|
||||
func (r *DiscussionRepository) IncrementViews(id uint) error {
|
||||
return DB.Model(&models.Discussion{}).Where("id = ?", id).
|
||||
UpdateColumn("views", gorm.Expr("views + ?", 1)).Error
|
||||
}
|
||||
|
||||
func (r *DiscussionRepository) Delete(id uint) error {
|
||||
return DB.Delete(&models.Discussion{}, id).Error
|
||||
}
|
||||
|
||||
func (r *DiscussionRepository) Count() (int64, error) {
|
||||
var count int64
|
||||
err := DB.Model(&models.Discussion{}).Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (r *DiscussionRepository) CountByStatus(status string) (int64, error) {
|
||||
var count int64
|
||||
err := DB.Model(&models.Discussion{}).Where("status = ?", status).Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (r *DiscussionRepository) CountByUserID(userID uint) (int64, error) {
|
||||
var count int64
|
||||
err := DB.Model(&models.Discussion{}).Where("user_id = ? AND status = ?", userID, "approved").Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
type PostListItem struct {
|
||||
ID uint
|
||||
Title string
|
||||
Content string
|
||||
Username string
|
||||
Avatar string
|
||||
UserID uint
|
||||
LikeCount int64
|
||||
CommentCount int64
|
||||
Views int
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func (r *DiscussionRepository) GetPostList(limit int) ([]PostListItem, error) {
|
||||
discussions, err := r.FindApproved(limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var posts []PostListItem
|
||||
for _, d := range discussions {
|
||||
var likeCount, commentCount int64
|
||||
DB.Model(&models.Like{}).Where("post_id = ?", d.ID).Count(&likeCount)
|
||||
DB.Model(&models.Comment{}).Where("post_id = ?", d.ID).Count(&commentCount)
|
||||
|
||||
posts = append(posts, PostListItem{
|
||||
ID: d.ID,
|
||||
Title: d.Title,
|
||||
Content: d.Content,
|
||||
Username: d.User.Username,
|
||||
Avatar: d.User.Avatar,
|
||||
UserID: d.User.ID,
|
||||
LikeCount: likeCount,
|
||||
CommentCount: commentCount,
|
||||
Views: d.Views,
|
||||
CreatedAt: d.CreatedAt,
|
||||
})
|
||||
}
|
||||
return posts, nil
|
||||
}
|
||||
37
internal/repositories/like.go
Normal file
37
internal/repositories/like.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package repositories
|
||||
|
||||
import "lv8girl/internal/models"
|
||||
|
||||
type LikeRepository struct{}
|
||||
|
||||
func NewLikeRepository() *LikeRepository {
|
||||
return &LikeRepository{}
|
||||
}
|
||||
|
||||
func (r *LikeRepository) FindByPostAndUser(postID, userID uint) (*models.Like, error) {
|
||||
var like models.Like
|
||||
err := DB.Where("post_id = ? AND user_id = ?", postID, userID).First(&like).Error
|
||||
return &like, err
|
||||
}
|
||||
|
||||
func (r *LikeRepository) Exists(postID, userID uint) bool {
|
||||
var count int64
|
||||
DB.Model(&models.Like{}).Where("post_id = ? AND user_id = ?", postID, userID).Count(&count)
|
||||
return count > 0
|
||||
}
|
||||
|
||||
func (r *LikeRepository) Create(like *models.Like) error {
|
||||
return DB.Create(like).Error
|
||||
}
|
||||
|
||||
func (r *LikeRepository) Count() (int64, error) {
|
||||
var count int64
|
||||
err := DB.Model(&models.Like{}).Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (r *LikeRepository) CountByPostID(postID uint) (int64, error) {
|
||||
var count int64
|
||||
err := DB.Model(&models.Like{}).Where("post_id = ?", postID).Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
69
internal/repositories/message.go
Normal file
69
internal/repositories/message.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"lv8girl/internal/models"
|
||||
)
|
||||
|
||||
type MessageRepository struct{}
|
||||
|
||||
func NewMessageRepository() *MessageRepository {
|
||||
return &MessageRepository{}
|
||||
}
|
||||
|
||||
func (r *MessageRepository) FindByID(id uint) (*models.PrivateMessage, error) {
|
||||
var msg models.PrivateMessage
|
||||
err := DB.First(&msg, id).Error
|
||||
return &msg, err
|
||||
}
|
||||
|
||||
func (r *MessageRepository) Create(message *models.PrivateMessage) error {
|
||||
return DB.Create(message).Error
|
||||
}
|
||||
|
||||
func (r *MessageRepository) MarkAsRead(id uint) error {
|
||||
return DB.Model(&models.PrivateMessage{}).Where("id = ?", id).Update("is_read", true).Error
|
||||
}
|
||||
|
||||
func (r *MessageRepository) CountUnread(userID uint) (int64, error) {
|
||||
var count int64
|
||||
err := DB.Model(&models.PrivateMessage{}).
|
||||
Where("to_user_id = ? AND is_read = ?", userID, false).
|
||||
Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (r *MessageRepository) FindConversations(userID uint) ([]models.PrivateMessage, error) {
|
||||
var messages []models.PrivateMessage
|
||||
err := DB.Where("from_user_id = ? OR to_user_id = ?", userID, userID).
|
||||
Order("created_at DESC").
|
||||
Find(&messages).Error
|
||||
return messages, err
|
||||
}
|
||||
|
||||
func (r *MessageRepository) FindLastMessage(userID, otherUserID uint) (*models.PrivateMessage, error) {
|
||||
var msg models.PrivateMessage
|
||||
err := DB.Where(
|
||||
"(from_user_id = ? AND to_user_id = ?) OR (from_user_id = ? AND to_user_id = ?)",
|
||||
userID, otherUserID, otherUserID, userID,
|
||||
).Order("created_at DESC").First(&msg).Error
|
||||
return &msg, err
|
||||
}
|
||||
|
||||
func (r *MessageRepository) CountUnreadFromUser(fromUserID, toUserID uint) (int64, error) {
|
||||
var count int64
|
||||
err := DB.Model(&models.PrivateMessage{}).
|
||||
Where("from_user_id = ? AND to_user_id = ? AND is_read = ?", fromUserID, toUserID, false).
|
||||
Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
type ConversationSummary struct {
|
||||
UserID uint
|
||||
Username string
|
||||
Avatar string
|
||||
LastMsg string
|
||||
Time time.Time
|
||||
Unread int64
|
||||
}
|
||||
123
internal/repositories/user.go
Normal file
123
internal/repositories/user.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"lv8girl/internal/models"
|
||||
)
|
||||
|
||||
type UserRepository struct{}
|
||||
|
||||
func NewUserRepository() *UserRepository {
|
||||
return &UserRepository{}
|
||||
}
|
||||
|
||||
func (r *UserRepository) FindByID(id uint) (*models.User, error) {
|
||||
var user models.User
|
||||
err := DB.First(&user, id).Error
|
||||
return &user, err
|
||||
}
|
||||
|
||||
func (r *UserRepository) FindByUsernameOrEmail(login string) (*models.User, error) {
|
||||
var user models.User
|
||||
err := DB.Where("username = ? OR email = ?", login, login).First(&user).Error
|
||||
return &user, err
|
||||
}
|
||||
|
||||
func (r *UserRepository) FindByUsername(username string) (*models.User, error) {
|
||||
var user models.User
|
||||
err := DB.Where("username = ?", username).First(&user).Error
|
||||
return &user, err
|
||||
}
|
||||
|
||||
func (r *UserRepository) FindByEmail(email string) (*models.User, error) {
|
||||
var user models.User
|
||||
err := DB.Where("email = ?", email).First(&user).Error
|
||||
return &user, err
|
||||
}
|
||||
|
||||
func (r *UserRepository) ExistsByUsernameOrEmail(username, email string) bool {
|
||||
var count int64
|
||||
DB.Model(&models.User{}).Where("username = ? OR email = ?", username, email).Count(&count)
|
||||
return count > 0
|
||||
}
|
||||
|
||||
func (r *UserRepository) Create(user *models.User) error {
|
||||
return DB.Create(user).Error
|
||||
}
|
||||
|
||||
func (r *UserRepository) Update(user *models.User) error {
|
||||
return DB.Save(user).Error
|
||||
}
|
||||
|
||||
func (r *UserRepository) UpdateField(id uint, field string, value interface{}) error {
|
||||
return DB.Model(&models.User{}).Where("id = ?", id).Update(field, value).Error
|
||||
}
|
||||
|
||||
func (r *UserRepository) UpdateLastActive(id uint) error {
|
||||
now := time.Now()
|
||||
return r.UpdateField(id, "last_active", now)
|
||||
}
|
||||
|
||||
func (r *UserRepository) Delete(id uint) error {
|
||||
return DB.Delete(&models.User{}, id).Error
|
||||
}
|
||||
|
||||
func (r *UserRepository) Count() (int64, error) {
|
||||
var count int64
|
||||
err := DB.Model(&models.User{}).Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (r *UserRepository) CountOnline() (int64, error) {
|
||||
var count int64
|
||||
fiveMinutesAgo := time.Now().Add(-5 * time.Minute)
|
||||
err := DB.Model(&models.User{}).Where("last_active > ?", fiveMinutesAgo).Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (r *UserRepository) CountByRole(role string) (int64, error) {
|
||||
var count int64
|
||||
err := DB.Model(&models.User{}).Where("role = ?", role).Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (r *UserRepository) FindPending() ([]models.User, error) {
|
||||
var users []models.User
|
||||
err := DB.Where("status = ?", "pending").Order("created_at ASC").Find(&users).Error
|
||||
return users, err
|
||||
}
|
||||
|
||||
func (r *UserRepository) FindAll() ([]models.User, error) {
|
||||
var users []models.User
|
||||
err := DB.Order("id").Find(&users).Error
|
||||
return users, err
|
||||
}
|
||||
|
||||
func (r *UserRepository) HashPassword(password string) (string, error) {
|
||||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
return string(bytes), err
|
||||
}
|
||||
|
||||
func (r *UserRepository) CheckPassword(password, hash string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) CreateAdminIfNotExists() error {
|
||||
var count int64
|
||||
DB.Model(&models.User{}).Where("role = ?", "admin").Count(&count)
|
||||
if count == 0 {
|
||||
passwordHash, _ := r.HashPassword("admin123")
|
||||
admin := models.User{
|
||||
Username: "admin",
|
||||
Email: "admin@lv8girl.local",
|
||||
PasswordHash: passwordHash,
|
||||
Role: "admin",
|
||||
Status: "approved",
|
||||
}
|
||||
return DB.Create(&admin).Error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
72
internal/routes/routes.go
Normal file
72
internal/routes/routes.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-contrib/sessions/cookie"
|
||||
"github.com/gin-gonic/gin"
|
||||
"lv8girl/internal/config"
|
||||
"lv8girl/internal/controllers"
|
||||
"lv8girl/internal/middleware"
|
||||
)
|
||||
|
||||
func SetupRouter() *gin.Engine {
|
||||
r := gin.Default()
|
||||
|
||||
cfg := config.GetConfig()
|
||||
store := cookie.NewStore([]byte(cfg.Session.Secret))
|
||||
r.Use(sessions.Sessions(cfg.Session.Name, store))
|
||||
|
||||
r.Static("/static", "./static")
|
||||
r.Static("/uploads", "./uploads")
|
||||
r.LoadHTMLGlob("templates/*")
|
||||
|
||||
authCtrl := controllers.NewAuthController()
|
||||
homeCtrl := controllers.NewHomeController()
|
||||
discussionCtrl := controllers.NewDiscussionController()
|
||||
userCtrl := controllers.NewUserController()
|
||||
messageCtrl := controllers.NewMessageController()
|
||||
adminCtrl := controllers.NewAdminController()
|
||||
|
||||
r.GET("/", homeCtrl.Index)
|
||||
|
||||
r.GET("/login", authCtrl.ShowLogin)
|
||||
r.POST("/login", authCtrl.Login)
|
||||
r.GET("/register", authCtrl.ShowRegister)
|
||||
r.POST("/register", authCtrl.Register)
|
||||
r.GET("/logout", authCtrl.Logout)
|
||||
|
||||
r.GET("/post/:id", homeCtrl.ShowPost)
|
||||
r.POST("/post/:id/like", middleware.AuthRequired(), homeCtrl.LikePost)
|
||||
r.POST("/post/:id/comment", middleware.AuthRequired(), homeCtrl.AddComment)
|
||||
|
||||
r.GET("/new-post", middleware.AuthRequired(), discussionCtrl.ShowNewPost)
|
||||
r.POST("/new-post", middleware.AuthRequired(), discussionCtrl.CreatePost)
|
||||
|
||||
r.GET("/profile", middleware.AuthRequired(), userCtrl.ShowProfile)
|
||||
r.GET("/profile/:id", userCtrl.ShowProfile)
|
||||
r.POST("/upload-avatar", middleware.AuthRequired(), userCtrl.UploadAvatar)
|
||||
|
||||
r.GET("/messages", middleware.AuthRequired(), messageCtrl.ShowMessages)
|
||||
r.GET("/send-message", middleware.AuthRequired(), messageCtrl.ShowSendMessage)
|
||||
r.POST("/send-message", middleware.AuthRequired(), messageCtrl.SendMessage)
|
||||
|
||||
admin := r.Group("/admin")
|
||||
admin.Use(middleware.AdminRequired())
|
||||
{
|
||||
admin.GET("", adminCtrl.Dashboard)
|
||||
admin.GET("/", adminCtrl.Dashboard)
|
||||
admin.GET("/pending_posts", adminCtrl.PendingPosts)
|
||||
admin.GET("/pending_posts/:action/:id", adminCtrl.ApprovePost)
|
||||
admin.GET("/pending_users", adminCtrl.PendingUsers)
|
||||
admin.GET("/pending_users/:action/:id", adminCtrl.ApproveUser)
|
||||
admin.GET("/posts", adminCtrl.Posts)
|
||||
admin.GET("/posts/delete/:id", adminCtrl.DeletePost)
|
||||
admin.GET("/users", adminCtrl.Users)
|
||||
admin.POST("/users/role", adminCtrl.UpdateUserRole)
|
||||
admin.GET("/users/delete/:id", adminCtrl.DeleteUser)
|
||||
admin.GET("/comments", adminCtrl.Comments)
|
||||
admin.GET("/comments/delete/:id", adminCtrl.DeleteComment)
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
98
internal/services/admin.go
Normal file
98
internal/services/admin.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"lv8girl/internal/models"
|
||||
"lv8girl/internal/repositories"
|
||||
)
|
||||
|
||||
type AdminService struct {
|
||||
userRepo *repositories.UserRepository
|
||||
discussionRepo *repositories.DiscussionRepository
|
||||
commentRepo *repositories.CommentRepository
|
||||
likeRepo *repositories.LikeRepository
|
||||
}
|
||||
|
||||
func NewAdminService() *AdminService {
|
||||
return &AdminService{
|
||||
userRepo: repositories.NewUserRepository(),
|
||||
discussionRepo: repositories.NewDiscussionRepository(),
|
||||
commentRepo: repositories.NewCommentRepository(),
|
||||
likeRepo: repositories.NewLikeRepository(),
|
||||
}
|
||||
}
|
||||
|
||||
type Stats struct {
|
||||
Posts int64
|
||||
Users int64
|
||||
Comments int64
|
||||
Likes int64
|
||||
Online int64
|
||||
Approved int64
|
||||
Rejected int64
|
||||
Pending int64
|
||||
}
|
||||
|
||||
func (s *AdminService) GetStats() (*Stats, error) {
|
||||
stats := &Stats{}
|
||||
stats.Posts, _ = s.discussionRepo.Count()
|
||||
stats.Users, _ = s.userRepo.Count()
|
||||
stats.Comments, _ = s.commentRepo.Count()
|
||||
stats.Likes, _ = s.likeRepo.Count()
|
||||
stats.Online, _ = s.userRepo.CountOnline()
|
||||
stats.Approved, _ = s.discussionRepo.CountByStatus("approved")
|
||||
stats.Rejected, _ = s.discussionRepo.CountByStatus("rejected")
|
||||
stats.Pending, _ = s.discussionRepo.CountByStatus("pending")
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (s *AdminService) GetPendingPosts() ([]models.Discussion, error) {
|
||||
return s.discussionRepo.FindPending()
|
||||
}
|
||||
|
||||
func (s *AdminService) GetPendingUsers() ([]models.User, error) {
|
||||
return s.userRepo.FindPending()
|
||||
}
|
||||
|
||||
func (s *AdminService) ApprovePost(id uint) error {
|
||||
return s.discussionRepo.UpdateStatus(id, "approved")
|
||||
}
|
||||
|
||||
func (s *AdminService) RejectPost(id uint) error {
|
||||
return s.discussionRepo.UpdateStatus(id, "rejected")
|
||||
}
|
||||
|
||||
func (s *AdminService) DeletePost(id uint) error {
|
||||
return s.discussionRepo.Delete(id)
|
||||
}
|
||||
|
||||
func (s *AdminService) ApproveUser(id uint) error {
|
||||
return s.userRepo.UpdateField(id, "status", "approved")
|
||||
}
|
||||
|
||||
func (s *AdminService) RejectUser(id uint) error {
|
||||
return s.userRepo.UpdateField(id, "status", "rejected")
|
||||
}
|
||||
|
||||
func (s *AdminService) UpdateUserRole(id uint, role string) error {
|
||||
return s.userRepo.UpdateField(id, "role", role)
|
||||
}
|
||||
|
||||
func (s *AdminService) DeleteUser(id uint) error {
|
||||
return s.userRepo.Delete(id)
|
||||
}
|
||||
|
||||
func (s *AdminService) GetAllPosts() ([]models.Discussion, error) {
|
||||
return s.discussionRepo.FindAll()
|
||||
}
|
||||
|
||||
func (s *AdminService) GetAllUsers() ([]models.User, error) {
|
||||
return s.userRepo.FindAll()
|
||||
}
|
||||
|
||||
func (s *AdminService) GetAllComments() ([]models.Comment, error) {
|
||||
return s.commentRepo.FindAll()
|
||||
}
|
||||
|
||||
func (s *AdminService) DeleteComment(id uint) error {
|
||||
return s.commentRepo.Delete(id)
|
||||
}
|
||||
87
internal/services/auth.go
Normal file
87
internal/services/auth.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"lv8girl/internal/models"
|
||||
"lv8girl/internal/repositories"
|
||||
)
|
||||
|
||||
type AuthService struct {
|
||||
userRepo *repositories.UserRepository
|
||||
}
|
||||
|
||||
func NewAuthService() *AuthService {
|
||||
return &AuthService{
|
||||
userRepo: repositories.NewUserRepository(),
|
||||
}
|
||||
}
|
||||
|
||||
type LoginResult struct {
|
||||
Success bool
|
||||
User *models.User
|
||||
Error string
|
||||
}
|
||||
|
||||
func (s *AuthService) Login(login, password string) LoginResult {
|
||||
user, err := s.userRepo.FindByUsernameOrEmail(login)
|
||||
if err != nil {
|
||||
return LoginResult{Success: false, Error: "用户名/邮箱或密码错误"}
|
||||
}
|
||||
|
||||
if !s.userRepo.CheckPassword(password, user.PasswordHash) {
|
||||
return LoginResult{Success: false, Error: "用户名/邮箱或密码错误"}
|
||||
}
|
||||
|
||||
switch user.Status {
|
||||
case "pending":
|
||||
return LoginResult{Success: false, Error: "您的账号正在等待管理员审核,请耐心等待。"}
|
||||
case "rejected":
|
||||
return LoginResult{Success: false, Error: "您的账号审核未通过,无法登录。如有疑问,请联系管理员。"}
|
||||
case "approved":
|
||||
if user.Role == "banned" {
|
||||
return LoginResult{Success: false, Error: "您的账号已被封禁,请联系管理员"}
|
||||
}
|
||||
default:
|
||||
return LoginResult{Success: false, Error: "账号状态异常,请联系管理员"}
|
||||
}
|
||||
|
||||
s.userRepo.UpdateLastActive(user.ID)
|
||||
return LoginResult{Success: true, User: user}
|
||||
}
|
||||
|
||||
type RegisterResult struct {
|
||||
Success bool
|
||||
Error string
|
||||
}
|
||||
|
||||
func (s *AuthService) Register(username, email, password string) RegisterResult {
|
||||
if len(username) < 3 || len(username) > 20 {
|
||||
return RegisterResult{Success: false, Error: "用户名长度必须在3-20个字符之间"}
|
||||
}
|
||||
|
||||
if s.userRepo.ExistsByUsernameOrEmail(username, email) {
|
||||
return RegisterResult{Success: false, Error: "用户名或邮箱已被注册"}
|
||||
}
|
||||
|
||||
passwordHash, err := s.userRepo.HashPassword(password)
|
||||
if err != nil {
|
||||
return RegisterResult{Success: false, Error: "注册失败,请稍后重试"}
|
||||
}
|
||||
|
||||
user := &models.User{
|
||||
Username: username,
|
||||
Email: email,
|
||||
PasswordHash: passwordHash,
|
||||
Role: "user",
|
||||
Status: "pending",
|
||||
}
|
||||
|
||||
if err := s.userRepo.Create(user); err != nil {
|
||||
return RegisterResult{Success: false, Error: "注册失败,请稍后重试"}
|
||||
}
|
||||
|
||||
return RegisterResult{Success: true}
|
||||
}
|
||||
|
||||
func (s *AuthService) InitAdmin() error {
|
||||
return s.userRepo.CreateAdminIfNotExists()
|
||||
}
|
||||
92
internal/services/discussion.go
Normal file
92
internal/services/discussion.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"lv8girl/internal/models"
|
||||
"lv8girl/internal/repositories"
|
||||
)
|
||||
|
||||
type DiscussionService struct {
|
||||
discussionRepo *repositories.DiscussionRepository
|
||||
likeRepo *repositories.LikeRepository
|
||||
commentRepo *repositories.CommentRepository
|
||||
}
|
||||
|
||||
func NewDiscussionService() *DiscussionService {
|
||||
return &DiscussionService{
|
||||
discussionRepo: repositories.NewDiscussionRepository(),
|
||||
likeRepo: repositories.NewLikeRepository(),
|
||||
commentRepo: repositories.NewCommentRepository(),
|
||||
}
|
||||
}
|
||||
|
||||
type PostDetailView struct {
|
||||
Post *models.Discussion
|
||||
LikeCount int64
|
||||
CommentCount int64
|
||||
UserLiked bool
|
||||
AuthorPostCount int64
|
||||
}
|
||||
|
||||
func (s *DiscussionService) GetPostDetail(postID, userID uint) (*PostDetailView, error) {
|
||||
post, err := s.discussionRepo.FindByID(postID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
likeCount, _ := s.likeRepo.CountByPostID(postID)
|
||||
commentCount, _ := s.commentRepo.CountByPostID(postID)
|
||||
userLiked := s.likeRepo.Exists(postID, userID)
|
||||
authorPostCount, _ := s.discussionRepo.CountByUserID(post.UserID)
|
||||
|
||||
return &PostDetailView{
|
||||
Post: post,
|
||||
LikeCount: likeCount,
|
||||
CommentCount: commentCount,
|
||||
UserLiked: userLiked,
|
||||
AuthorPostCount: authorPostCount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *DiscussionService) IncrementViews(postID uint) error {
|
||||
return s.discussionRepo.IncrementViews(postID)
|
||||
}
|
||||
|
||||
func (s *DiscussionService) AddLike(postID, userID uint) error {
|
||||
if s.likeRepo.Exists(postID, userID) {
|
||||
return nil
|
||||
}
|
||||
|
||||
like := &models.Like{
|
||||
PostID: postID,
|
||||
UserID: userID,
|
||||
}
|
||||
return s.likeRepo.Create(like)
|
||||
}
|
||||
|
||||
func (s *DiscussionService) AddComment(postID, userID uint, content string) error {
|
||||
comment := &models.Comment{
|
||||
PostID: postID,
|
||||
UserID: userID,
|
||||
Content: content,
|
||||
}
|
||||
return s.commentRepo.Create(comment)
|
||||
}
|
||||
|
||||
func (s *DiscussionService) GetComments(postID uint) ([]models.Comment, error) {
|
||||
return s.commentRepo.FindByPostID(postID)
|
||||
}
|
||||
|
||||
func (s *DiscussionService) CreatePost(userID uint, title, content, imagePath string) error {
|
||||
post := &models.Discussion{
|
||||
UserID: userID,
|
||||
Title: title,
|
||||
Content: content,
|
||||
ImagePath: imagePath,
|
||||
Status: "pending",
|
||||
}
|
||||
return s.discussionRepo.Create(post)
|
||||
}
|
||||
|
||||
func (s *DiscussionService) GetApprovedPosts(limit int) ([]repositories.PostListItem, error) {
|
||||
return s.discussionRepo.GetPostList(limit)
|
||||
}
|
||||
97
internal/services/message.go
Normal file
97
internal/services/message.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"lv8girl/internal/models"
|
||||
"lv8girl/internal/repositories"
|
||||
)
|
||||
|
||||
type MessageService struct {
|
||||
messageRepo *repositories.MessageRepository
|
||||
userRepo *repositories.UserRepository
|
||||
}
|
||||
|
||||
func NewMessageService() *MessageService {
|
||||
return &MessageService{
|
||||
messageRepo: repositories.NewMessageRepository(),
|
||||
userRepo: repositories.NewUserRepository(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MessageService) SendMessage(fromUserID, toUserID uint, content string) error {
|
||||
message := &models.PrivateMessage{
|
||||
FromUserID: fromUserID,
|
||||
ToUserID: toUserID,
|
||||
Content: content,
|
||||
IsRead: false,
|
||||
}
|
||||
return s.messageRepo.Create(message)
|
||||
}
|
||||
|
||||
func (s *MessageService) GetUnreadCount(userID uint) (int64, error) {
|
||||
return s.messageRepo.CountUnread(userID)
|
||||
}
|
||||
|
||||
type ConversationView struct {
|
||||
UserID uint
|
||||
Username string
|
||||
Avatar string
|
||||
LastMsg string
|
||||
Time string
|
||||
Unread int64
|
||||
}
|
||||
|
||||
func (s *MessageService) GetConversations(userID uint) ([]ConversationView, error) {
|
||||
messages, err := s.messageRepo.FindConversations(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userMap := make(map[uint]bool)
|
||||
for _, msg := range messages {
|
||||
if msg.FromUserID != userID {
|
||||
userMap[msg.FromUserID] = true
|
||||
}
|
||||
if msg.ToUserID != userID {
|
||||
userMap[msg.ToUserID] = true
|
||||
}
|
||||
}
|
||||
|
||||
var conversations []ConversationView
|
||||
for otherID := range userMap {
|
||||
otherUser, err := s.userRepo.FindByID(otherID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
lastMsg, _ := s.messageRepo.FindLastMessage(userID, otherID)
|
||||
unread, _ := s.messageRepo.CountUnreadFromUser(otherID, userID)
|
||||
|
||||
avatar := ""
|
||||
if otherUser.Avatar != "" {
|
||||
avatar = "/" + otherUser.Avatar
|
||||
}
|
||||
|
||||
conversations = append(conversations, ConversationView{
|
||||
UserID: otherUser.ID,
|
||||
Username: otherUser.Username,
|
||||
Avatar: avatar,
|
||||
LastMsg: lastMsg.Content,
|
||||
Time: lastMsg.CreatedAt.Format("2006-01-02 15:04"),
|
||||
Unread: unread,
|
||||
})
|
||||
}
|
||||
|
||||
return conversations, nil
|
||||
}
|
||||
|
||||
func (s *MessageService) NotifyUserApproved(adminID, userID uint) error {
|
||||
return s.SendMessage(adminID, userID, "恭喜!您的账号已通过管理员审核,现在可以正常登录使用了。")
|
||||
}
|
||||
|
||||
func (s *MessageService) NotifyUserRejected(adminID, userID uint) error {
|
||||
return s.SendMessage(adminID, userID, "您的账号审核未通过。如有疑问,请联系管理员。")
|
||||
}
|
||||
|
||||
func (s *MessageService) NotifyUserBanned(adminID, userID uint) error {
|
||||
return s.SendMessage(adminID, userID, "您的账号已被管理员封禁。如有疑问,请联系管理员。")
|
||||
}
|
||||
94
internal/services/user.go
Normal file
94
internal/services/user.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"lv8girl/internal/models"
|
||||
"lv8girl/internal/repositories"
|
||||
)
|
||||
|
||||
type UserService struct {
|
||||
userRepo *repositories.UserRepository
|
||||
discussionRepo *repositories.DiscussionRepository
|
||||
commentRepo *repositories.CommentRepository
|
||||
messageRepo *repositories.MessageRepository
|
||||
}
|
||||
|
||||
func NewUserService() *UserService {
|
||||
return &UserService{
|
||||
userRepo: repositories.NewUserRepository(),
|
||||
discussionRepo: repositories.NewDiscussionRepository(),
|
||||
commentRepo: repositories.NewCommentRepository(),
|
||||
messageRepo: repositories.NewMessageRepository(),
|
||||
}
|
||||
}
|
||||
|
||||
type UserProfileView struct {
|
||||
User *models.User
|
||||
Posts []models.Discussion
|
||||
PostCount int64
|
||||
UnreadCount int64
|
||||
IsOwner bool
|
||||
}
|
||||
|
||||
func (s *UserService) GetUserProfile(viewerID, targetUserID uint) (*UserProfileView, error) {
|
||||
user, err := s.userRepo.FindByID(targetUserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
posts, _ := s.discussionRepo.FindByUserID(targetUserID)
|
||||
postCount, _ := s.discussionRepo.CountByUserID(targetUserID)
|
||||
|
||||
var unreadCount int64
|
||||
if viewerID != 0 {
|
||||
unreadCount, _ = s.messageRepo.CountUnread(viewerID)
|
||||
}
|
||||
|
||||
return &UserProfileView{
|
||||
User: user,
|
||||
Posts: posts,
|
||||
PostCount: postCount,
|
||||
UnreadCount: unreadCount,
|
||||
IsOwner: viewerID == targetUserID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *UserService) UpdateAvatar(userID uint, avatarPath string) error {
|
||||
user, err := s.userRepo.FindByID(userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.userRepo.UpdateField(user.ID, "avatar", avatarPath)
|
||||
}
|
||||
|
||||
func (s *UserService) GetUserByID(id uint) (*models.User, error) {
|
||||
return s.userRepo.FindByID(id)
|
||||
}
|
||||
|
||||
func (s *UserService) UpdateUserStatus(id uint, status string) error {
|
||||
return s.userRepo.UpdateField(id, "status", status)
|
||||
}
|
||||
|
||||
func (s *UserService) UpdateUserRole(id uint, role string) error {
|
||||
return s.userRepo.UpdateField(id, "role", role)
|
||||
}
|
||||
|
||||
func (s *UserService) DeleteUser(id uint) error {
|
||||
return s.userRepo.Delete(id)
|
||||
}
|
||||
|
||||
func (s *UserService) GetPendingUsers() ([]models.User, error) {
|
||||
return s.userRepo.FindPending()
|
||||
}
|
||||
|
||||
func (s *UserService) GetAllUsers() ([]models.User, error) {
|
||||
return s.userRepo.FindAll()
|
||||
}
|
||||
|
||||
func (s *UserService) GetUserStats() (int64, int64, error) {
|
||||
total, err := s.userRepo.Count()
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
online, err := s.userRepo.CountOnline()
|
||||
return total, online, err
|
||||
}
|
||||
9
internal/utils/utils.go
Normal file
9
internal/utils/utils.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package utils
|
||||
|
||||
func Substring(s string, length int) string {
|
||||
runes := []rune(s)
|
||||
if len(runes) <= length {
|
||||
return s
|
||||
}
|
||||
return string(runes[:length])
|
||||
}
|
||||
83
templates/admin_comments.html
Normal file
83
templates/admin_comments.html
Normal file
@@ -0,0 +1,83 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>评论管理 · lv8girl</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
:root {
|
||||
--bg: #0f0f1a; --surface: #1a1a2f; --surface-light: #252540; --border: #2d2d4a;
|
||||
--text: #e0e0f0; --text-soft: #b0b0d0; --text-hint: #8080a0;
|
||||
--primary: #c5a572; --gradient: linear-gradient(135deg, #c5a572, #9a7e5a);
|
||||
--sidebar-width: 220px;
|
||||
}
|
||||
body { background: var(--bg); color: var(--text); font-family: -apple-system, 'Segoe UI', 'PingFang SC', sans-serif; }
|
||||
.admin-wrapper { display: flex; min-height: 100vh; }
|
||||
.sidebar { width: var(--sidebar-width); background: var(--surface); border-right: 1px solid var(--border); padding: 20px 0; position: sticky; top: 0; height: 100vh; }
|
||||
.sidebar-header { padding: 0 20px 20px; border-bottom: 1px solid var(--border); margin-bottom: 20px; }
|
||||
.sidebar-header .logo { font-size: 1.6rem; font-weight: 800; background: var(--gradient); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
.sidebar-header p { color: var(--text-soft); font-size: 0.85rem; }
|
||||
.sidebar-menu { list-style: none; }
|
||||
.sidebar-menu li { margin: 5px 0; }
|
||||
.sidebar-menu a { display: block; padding: 10px 20px; color: var(--text-soft); text-decoration: none; border-left: 4px solid transparent; }
|
||||
.sidebar-menu a:hover, .sidebar-menu a.active { background: var(--surface-light); border-left-color: var(--primary); color: var(--primary); }
|
||||
.sidebar-menu .separator { height: 1px; background: var(--border); margin: 15px 20px; }
|
||||
.main-content { flex: 1; padding: 20px 30px; }
|
||||
.top-bar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 30px; }
|
||||
.page-title { font-size: 1.8rem; font-weight: 600; color: var(--primary); }
|
||||
.user-info span { background: var(--surface-light); padding: 6px 16px; border-radius: 30px; }
|
||||
.message { background: var(--surface-light); border-left: 4px solid var(--primary); padding: 12px 20px; margin-bottom: 20px; border-radius: 8px; }
|
||||
.table-card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; min-width: 700px; }
|
||||
th { background: var(--surface-light); padding: 12px 15px; text-align: left; font-weight: 600; color: var(--primary); border-bottom: 1px solid var(--border); }
|
||||
td { padding: 12px 15px; border-bottom: 1px solid var(--border); color: var(--text-soft); }
|
||||
tr:hover { background: var(--surface-light); }
|
||||
.actions a { color: #ff6b6b; text-decoration: none; }
|
||||
@media (max-width: 768px) { .admin-wrapper { flex-direction: column; } .sidebar { width: 100%; height: auto; position: static; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="admin-wrapper">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header"><div class="logo">lv8girl</div><p>管理面板</p></div>
|
||||
<ul class="sidebar-menu">
|
||||
<li><a href="/admin">📊 仪表盘</a></li>
|
||||
<li><a href="/admin/pending_posts">⏳ 待审核帖子</a></li>
|
||||
<li><a href="/admin/pending_users">👥 待审核用户</a></li>
|
||||
<li><a href="/admin/posts">📝 帖子管理</a></li>
|
||||
<li><a href="/admin/users">👥 用户管理</a></li>
|
||||
<li><a href="/admin/comments" class="active">💬 评论管理</a></li>
|
||||
<li class="separator"></li>
|
||||
<li><a href="/">🏠 返回首页</a></li>
|
||||
</ul>
|
||||
</aside>
|
||||
<main class="main-content">
|
||||
<div class="top-bar">
|
||||
<h1 class="page-title">评论管理</h1>
|
||||
<div class="user-info"><span>{{.Username}}</span></div>
|
||||
</div>
|
||||
{{if .Message}}<div class="message">{{.Message}}</div>{{end}}
|
||||
<div class="table-card">
|
||||
<table>
|
||||
<thead><tr><th>ID</th><th>帖子</th><th>评论者</th><th>内容</th><th>时间</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Comments}}
|
||||
<tr>
|
||||
<td>{{.ID}}</td>
|
||||
<td><a href="/post/{{.PostID}}" target="_blank">{{slice .Post.Title 0 30}}...</a></td>
|
||||
<td>{{.User.Username}}</td>
|
||||
<td>{{slice .Content 0 50}}{{if gt (len .Content) 50}}...{{end}}</td>
|
||||
<td>{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
|
||||
<td class="actions">
|
||||
<a href="/admin/comments/delete/{{.ID}}" onclick="return confirm('确定删除?')">删除</a>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
114
templates/admin_dashboard.html
Normal file
114
templates/admin_dashboard.html
Normal file
@@ -0,0 +1,114 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>管理面板 · lv8girl</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
:root {
|
||||
--bg: #0f0f1a;
|
||||
--surface: #1a1a2f;
|
||||
--surface-light: #252540;
|
||||
--border: #2d2d4a;
|
||||
--text: #e0e0f0;
|
||||
--text-soft: #b0b0d0;
|
||||
--text-hint: #8080a0;
|
||||
--primary: #c5a572;
|
||||
--primary-light: #d4b78c;
|
||||
--accent: #a58e6d;
|
||||
--gradient: linear-gradient(135deg, #c5a572, #9a7e5a);
|
||||
--sidebar-width: 220px;
|
||||
}
|
||||
body { background: var(--bg); color: var(--text); font-family: -apple-system, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif; line-height: 1.6; }
|
||||
.admin-wrapper { display: flex; min-height: 100vh; }
|
||||
.sidebar { width: var(--sidebar-width); background: var(--surface); border-right: 1px solid var(--border); padding: 20px 0; position: sticky; top: 0; height: 100vh; overflow-y: auto; }
|
||||
.sidebar-header { padding: 0 20px 20px; border-bottom: 1px solid var(--border); margin-bottom: 20px; }
|
||||
.sidebar-header .logo { font-size: 1.6rem; font-weight: 800; background: var(--gradient); -webkit-background-clip: text; -webkit-text-fill-color: transparent; margin-bottom: 5px; }
|
||||
.sidebar-header p { color: var(--text-soft); font-size: 0.85rem; }
|
||||
.sidebar-menu { list-style: none; }
|
||||
.sidebar-menu li { margin: 5px 0; }
|
||||
.sidebar-menu a { display: block; padding: 10px 20px; color: var(--text-soft); text-decoration: none; transition: 0.2s; border-left: 4px solid transparent; }
|
||||
.sidebar-menu a:hover, .sidebar-menu a.active { background: var(--surface-light); border-left-color: var(--primary); color: var(--primary); }
|
||||
.sidebar-menu .separator { height: 1px; background: var(--border); margin: 15px 20px; }
|
||||
.main-content { flex: 1; padding: 20px 30px; }
|
||||
.top-bar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 30px; }
|
||||
.page-title { font-size: 1.8rem; font-weight: 600; color: var(--primary); }
|
||||
.user-info { display: flex; align-items: center; gap: 15px; }
|
||||
.user-info span { background: var(--surface-light); padding: 6px 16px; border-radius: 30px; color: var(--text); }
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; margin-bottom: 30px; }
|
||||
.stat-card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 20px; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; }
|
||||
.stat-number { font-size: 2.5rem; font-weight: 700; color: var(--primary); line-height: 1.2; margin-bottom: 4px; }
|
||||
.stat-label { color: var(--text-hint); font-size: 0.95rem; }
|
||||
.table-card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th { background: var(--surface-light); padding: 12px 15px; text-align: left; font-weight: 600; color: var(--primary); border-bottom: 1px solid var(--border); }
|
||||
td { padding: 12px 15px; border-bottom: 1px solid var(--border); color: var(--text-soft); }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tr:hover { background: var(--surface-light); }
|
||||
.actions a { margin-right: 10px; color: var(--text-hint); text-decoration: none; }
|
||||
.actions a:hover { color: var(--primary); }
|
||||
.actions .delete { color: #ff6b6b; }
|
||||
.actions .delete:hover { color: #ff4d4d; }
|
||||
.actions .approve { color: var(--primary); }
|
||||
.actions .approve:hover { color: var(--primary-light); }
|
||||
.message { background: var(--surface-light); border-left: 4px solid var(--primary); padding: 12px 20px; margin-bottom: 20px; border-radius: 8px; color: var(--text); }
|
||||
@media (max-width: 768px) { .admin-wrapper { flex-direction: column; } .sidebar { width: 100%; height: auto; position: static; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="admin-wrapper">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<div class="logo">lv8girl</div>
|
||||
<p>管理面板</p>
|
||||
</div>
|
||||
<ul class="sidebar-menu">
|
||||
<li><a href="/admin" class="{{if eq .Page "dashboard"}}active{{end}}">📊 仪表盘</a></li>
|
||||
<li><a href="/admin/pending_posts" class="{{if eq .Page "pending_posts"}}active{{end}}">⏳ 待审核帖子</a></li>
|
||||
<li><a href="/admin/pending_users" class="{{if eq .Page "pending_users"}}active{{end}}">👥 待审核用户</a></li>
|
||||
<li><a href="/admin/posts" class="{{if eq .Page "posts"}}active{{end}}">📝 帖子管理</a></li>
|
||||
<li><a href="/admin/users" class="{{if eq .Page "users"}}active{{end}}">👥 用户管理</a></li>
|
||||
<li><a href="/admin/comments" class="{{if eq .Page "comments"}}active{{end}}">💬 评论管理</a></li>
|
||||
<li class="separator"></li>
|
||||
<li><a href="/">🏠 返回首页</a></li>
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<main class="main-content">
|
||||
<div class="top-bar">
|
||||
<h1 class="page-title">仪表盘</h1>
|
||||
<div class="user-info"><span>{{.Username}}</span></div>
|
||||
</div>
|
||||
{{if .Message}}<div class="message">{{.Message}}</div>{{end}}
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-number">{{.Stats.Posts}}</div>
|
||||
<div class="stat-label">帖子总数</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-number">{{.Stats.Users}}</div>
|
||||
<div class="stat-label">注册用户</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-number">{{.Stats.Comments}}</div>
|
||||
<div class="stat-label">评论总数</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-number">{{.Stats.Likes}}</div>
|
||||
<div class="stat-label">点赞总数</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-number">{{.Stats.Online}}</div>
|
||||
<div class="stat-label">实时在线</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-number">{{.Stats.Pending}}</div>
|
||||
<div class="stat-label">待审核帖子</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
85
templates/admin_pending_posts.html
Normal file
85
templates/admin_pending_posts.html
Normal file
@@ -0,0 +1,85 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>待审核帖子 · lv8girl</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
:root {
|
||||
--bg: #0f0f1a; --surface: #1a1a2f; --surface-light: #252540; --border: #2d2d4a;
|
||||
--text: #e0e0f0; --text-soft: #b0b0d0; --text-hint: #8080a0;
|
||||
--primary: #c5a572; --gradient: linear-gradient(135deg, #c5a572, #9a7e5a);
|
||||
--sidebar-width: 220px;
|
||||
}
|
||||
body { background: var(--bg); color: var(--text); font-family: -apple-system, 'Segoe UI', 'PingFang SC', sans-serif; }
|
||||
.admin-wrapper { display: flex; min-height: 100vh; }
|
||||
.sidebar { width: var(--sidebar-width); background: var(--surface); border-right: 1px solid var(--border); padding: 20px 0; position: sticky; top: 0; height: 100vh; }
|
||||
.sidebar-header { padding: 0 20px 20px; border-bottom: 1px solid var(--border); margin-bottom: 20px; }
|
||||
.sidebar-header .logo { font-size: 1.6rem; font-weight: 800; background: var(--gradient); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
.sidebar-header p { color: var(--text-soft); font-size: 0.85rem; }
|
||||
.sidebar-menu { list-style: none; }
|
||||
.sidebar-menu li { margin: 5px 0; }
|
||||
.sidebar-menu a { display: block; padding: 10px 20px; color: var(--text-soft); text-decoration: none; border-left: 4px solid transparent; }
|
||||
.sidebar-menu a:hover, .sidebar-menu a.active { background: var(--surface-light); border-left-color: var(--primary); color: var(--primary); }
|
||||
.sidebar-menu .separator { height: 1px; background: var(--border); margin: 15px 20px; }
|
||||
.main-content { flex: 1; padding: 20px 30px; }
|
||||
.top-bar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 30px; }
|
||||
.page-title { font-size: 1.8rem; font-weight: 600; color: var(--primary); }
|
||||
.user-info span { background: var(--surface-light); padding: 6px 16px; border-radius: 30px; }
|
||||
.message { background: var(--surface-light); border-left: 4px solid var(--primary); padding: 12px 20px; margin-bottom: 20px; border-radius: 8px; }
|
||||
.table-card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th { background: var(--surface-light); padding: 12px 15px; text-align: left; font-weight: 600; color: var(--primary); border-bottom: 1px solid var(--border); }
|
||||
td { padding: 12px 15px; border-bottom: 1px solid var(--border); color: var(--text-soft); }
|
||||
tr:hover { background: var(--surface-light); }
|
||||
.actions a { margin-right: 10px; color: var(--text-hint); text-decoration: none; }
|
||||
.actions .approve { color: var(--primary); }
|
||||
.actions .delete { color: #ff6b6b; }
|
||||
@media (max-width: 768px) { .admin-wrapper { flex-direction: column; } .sidebar { width: 100%; height: auto; position: static; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="admin-wrapper">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header"><div class="logo">lv8girl</div><p>管理面板</p></div>
|
||||
<ul class="sidebar-menu">
|
||||
<li><a href="/admin">📊 仪表盘</a></li>
|
||||
<li><a href="/admin/pending_posts" class="active">⏳ 待审核帖子</a></li>
|
||||
<li><a href="/admin/pending_users">👥 待审核用户</a></li>
|
||||
<li><a href="/admin/posts">📝 帖子管理</a></li>
|
||||
<li><a href="/admin/users">👥 用户管理</a></li>
|
||||
<li><a href="/admin/comments">💬 评论管理</a></li>
|
||||
<li class="separator"></li>
|
||||
<li><a href="/">🏠 返回首页</a></li>
|
||||
</ul>
|
||||
</aside>
|
||||
<main class="main-content">
|
||||
<div class="top-bar">
|
||||
<h1 class="page-title">待审核帖子</h1>
|
||||
<div class="user-info"><span>{{.Username}}</span></div>
|
||||
</div>
|
||||
{{if .Message}}<div class="message">{{.Message}}</div>{{end}}
|
||||
<div class="table-card">
|
||||
<table>
|
||||
<thead><tr><th>ID</th><th>标题</th><th>作者</th><th>发布时间</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Posts}}
|
||||
<tr>
|
||||
<td>{{.ID}}</td>
|
||||
<td><a href="/post/{{.ID}}" target="_blank">{{.Title}}</a></td>
|
||||
<td>{{.User.Username}}</td>
|
||||
<td>{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
|
||||
<td class="actions">
|
||||
<a href="/admin/pending_posts/approve/{{.ID}}" class="approve" onclick="return confirm('通过审核?')">通过</a>
|
||||
<a href="/admin/pending_posts/reject/{{.ID}}" class="delete" onclick="return confirm('拒绝审核?')">拒绝</a>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
85
templates/admin_pending_users.html
Normal file
85
templates/admin_pending_users.html
Normal file
@@ -0,0 +1,85 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>待审核用户 · lv8girl</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
:root {
|
||||
--bg: #0f0f1a; --surface: #1a1a2f; --surface-light: #252540; --border: #2d2d4a;
|
||||
--text: #e0e0f0; --text-soft: #b0b0d0; --text-hint: #8080a0;
|
||||
--primary: #c5a572; --gradient: linear-gradient(135deg, #c5a572, #9a7e5a);
|
||||
--sidebar-width: 220px;
|
||||
}
|
||||
body { background: var(--bg); color: var(--text); font-family: -apple-system, 'Segoe UI', 'PingFang SC', sans-serif; }
|
||||
.admin-wrapper { display: flex; min-height: 100vh; }
|
||||
.sidebar { width: var(--sidebar-width); background: var(--surface); border-right: 1px solid var(--border); padding: 20px 0; position: sticky; top: 0; height: 100vh; }
|
||||
.sidebar-header { padding: 0 20px 20px; border-bottom: 1px solid var(--border); margin-bottom: 20px; }
|
||||
.sidebar-header .logo { font-size: 1.6rem; font-weight: 800; background: var(--gradient); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
.sidebar-header p { color: var(--text-soft); font-size: 0.85rem; }
|
||||
.sidebar-menu { list-style: none; }
|
||||
.sidebar-menu li { margin: 5px 0; }
|
||||
.sidebar-menu a { display: block; padding: 10px 20px; color: var(--text-soft); text-decoration: none; border-left: 4px solid transparent; }
|
||||
.sidebar-menu a:hover, .sidebar-menu a.active { background: var(--surface-light); border-left-color: var(--primary); color: var(--primary); }
|
||||
.sidebar-menu .separator { height: 1px; background: var(--border); margin: 15px 20px; }
|
||||
.main-content { flex: 1; padding: 20px 30px; }
|
||||
.top-bar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 30px; }
|
||||
.page-title { font-size: 1.8rem; font-weight: 600; color: var(--primary); }
|
||||
.user-info span { background: var(--surface-light); padding: 6px 16px; border-radius: 30px; }
|
||||
.message { background: var(--surface-light); border-left: 4px solid var(--primary); padding: 12px 20px; margin-bottom: 20px; border-radius: 8px; }
|
||||
.table-card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th { background: var(--surface-light); padding: 12px 15px; text-align: left; font-weight: 600; color: var(--primary); border-bottom: 1px solid var(--border); }
|
||||
td { padding: 12px 15px; border-bottom: 1px solid var(--border); color: var(--text-soft); }
|
||||
tr:hover { background: var(--surface-light); }
|
||||
.actions a { margin-right: 10px; text-decoration: none; }
|
||||
.actions .approve { color: var(--primary); }
|
||||
.actions .delete { color: #ff6b6b; }
|
||||
@media (max-width: 768px) { .admin-wrapper { flex-direction: column; } .sidebar { width: 100%; height: auto; position: static; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="admin-wrapper">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header"><div class="logo">lv8girl</div><p>管理面板</p></div>
|
||||
<ul class="sidebar-menu">
|
||||
<li><a href="/admin">📊 仪表盘</a></li>
|
||||
<li><a href="/admin/pending_posts">⏳ 待审核帖子</a></li>
|
||||
<li><a href="/admin/pending_users" class="active">👥 待审核用户</a></li>
|
||||
<li><a href="/admin/posts">📝 帖子管理</a></li>
|
||||
<li><a href="/admin/users">👥 用户管理</a></li>
|
||||
<li><a href="/admin/comments">💬 评论管理</a></li>
|
||||
<li class="separator"></li>
|
||||
<li><a href="/">🏠 返回首页</a></li>
|
||||
</ul>
|
||||
</aside>
|
||||
<main class="main-content">
|
||||
<div class="top-bar">
|
||||
<h1 class="page-title">待审核用户</h1>
|
||||
<div class="user-info"><span>{{.Username}}</span></div>
|
||||
</div>
|
||||
{{if .Message}}<div class="message">{{.Message}}</div>{{end}}
|
||||
<div class="table-card">
|
||||
<table>
|
||||
<thead><tr><th>ID</th><th>用户名</th><th>邮箱</th><th>注册时间</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Users}}
|
||||
<tr>
|
||||
<td>{{.ID}}</td>
|
||||
<td>{{.Username}}</td>
|
||||
<td>{{.Email}}</td>
|
||||
<td>{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
|
||||
<td class="actions">
|
||||
<a href="/admin/pending_users/approve/{{.ID}}" class="approve" onclick="return confirm('通过审核?')">通过</a>
|
||||
<a href="/admin/pending_users/reject/{{.ID}}" class="delete" onclick="return confirm('拒绝审核?')">拒绝</a>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
90
templates/admin_posts.html
Normal file
90
templates/admin_posts.html
Normal file
@@ -0,0 +1,90 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>帖子管理 · lv8girl</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
:root {
|
||||
--bg: #0f0f1a; --surface: #1a1a2f; --surface-light: #252540; --border: #2d2d4a;
|
||||
--text: #e0e0f0; --text-soft: #b0b0d0; --text-hint: #8080a0;
|
||||
--primary: #c5a572; --gradient: linear-gradient(135deg, #c5a572, #9a7e5a);
|
||||
--sidebar-width: 220px;
|
||||
}
|
||||
body { background: var(--bg); color: var(--text); font-family: -apple-system, 'Segoe UI', 'PingFang SC', sans-serif; }
|
||||
.admin-wrapper { display: flex; min-height: 100vh; }
|
||||
.sidebar { width: var(--sidebar-width); background: var(--surface); border-right: 1px solid var(--border); padding: 20px 0; position: sticky; top: 0; height: 100vh; }
|
||||
.sidebar-header { padding: 0 20px 20px; border-bottom: 1px solid var(--border); margin-bottom: 20px; }
|
||||
.sidebar-header .logo { font-size: 1.6rem; font-weight: 800; background: var(--gradient); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
.sidebar-header p { color: var(--text-soft); font-size: 0.85rem; }
|
||||
.sidebar-menu { list-style: none; }
|
||||
.sidebar-menu li { margin: 5px 0; }
|
||||
.sidebar-menu a { display: block; padding: 10px 20px; color: var(--text-soft); text-decoration: none; border-left: 4px solid transparent; }
|
||||
.sidebar-menu a:hover, .sidebar-menu a.active { background: var(--surface-light); border-left-color: var(--primary); color: var(--primary); }
|
||||
.sidebar-menu .separator { height: 1px; background: var(--border); margin: 15px 20px; }
|
||||
.main-content { flex: 1; padding: 20px 30px; }
|
||||
.top-bar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 30px; }
|
||||
.page-title { font-size: 1.8rem; font-weight: 600; color: var(--primary); }
|
||||
.user-info span { background: var(--surface-light); padding: 6px 16px; border-radius: 30px; }
|
||||
.message { background: var(--surface-light); border-left: 4px solid var(--primary); padding: 12px 20px; margin-bottom: 20px; border-radius: 8px; }
|
||||
.table-card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; min-width: 800px; }
|
||||
th { background: var(--surface-light); padding: 12px 15px; text-align: left; font-weight: 600; color: var(--primary); border-bottom: 1px solid var(--border); }
|
||||
td { padding: 12px 15px; border-bottom: 1px solid var(--border); color: var(--text-soft); }
|
||||
tr:hover { background: var(--surface-light); }
|
||||
.actions a { margin-right: 10px; color: var(--text-hint); text-decoration: none; }
|
||||
.actions .delete { color: #ff6b6b; }
|
||||
.status-approved { color: var(--primary); }
|
||||
.status-pending { color: #ffb347; }
|
||||
.status-rejected { color: #ff6b6b; }
|
||||
@media (max-width: 768px) { .admin-wrapper { flex-direction: column; } .sidebar { width: 100%; height: auto; position: static; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="admin-wrapper">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header"><div class="logo">lv8girl</div><p>管理面板</p></div>
|
||||
<ul class="sidebar-menu">
|
||||
<li><a href="/admin">📊 仪表盘</a></li>
|
||||
<li><a href="/admin/pending_posts">⏳ 待审核帖子</a></li>
|
||||
<li><a href="/admin/pending_users">👥 待审核用户</a></li>
|
||||
<li><a href="/admin/posts" class="active">📝 帖子管理</a></li>
|
||||
<li><a href="/admin/users">👥 用户管理</a></li>
|
||||
<li><a href="/admin/comments">💬 评论管理</a></li>
|
||||
<li class="separator"></li>
|
||||
<li><a href="/">🏠 返回首页</a></li>
|
||||
</ul>
|
||||
</aside>
|
||||
<main class="main-content">
|
||||
<div class="top-bar">
|
||||
<h1 class="page-title">帖子管理</h1>
|
||||
<div class="user-info"><span>{{.Username}}</span></div>
|
||||
</div>
|
||||
{{if .Message}}<div class="message">{{.Message}}</div>{{end}}
|
||||
<div class="table-card">
|
||||
<table>
|
||||
<thead><tr><th>ID</th><th>标题</th><th>作者</th><th>状态</th><th>发布时间</th><th>阅读</th><th>点赞</th><th>评论</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Posts}}
|
||||
<tr>
|
||||
<td>{{.ID}}</td>
|
||||
<td><a href="/post/{{.ID}}" target="_blank">{{.Title}}</a></td>
|
||||
<td>{{.User.Username}}</td>
|
||||
<td><span class="status-{{.Status}}">{{if eq .Status "approved"}}已通过{{else if eq .Status "pending"}}待审核{{else}}已拒绝{{end}}</span></td>
|
||||
<td>{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
|
||||
<td>{{.Views}}</td>
|
||||
<td>{{.LikeCount}}</td>
|
||||
<td>{{.CommentCount}}</td>
|
||||
<td class="actions">
|
||||
<a href="/admin/posts/delete/{{.ID}}" class="delete" onclick="return confirm('确定删除?')">删除</a>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
109
templates/admin_users.html
Normal file
109
templates/admin_users.html
Normal file
@@ -0,0 +1,109 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>用户管理 · lv8girl</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
:root {
|
||||
--bg: #0f0f1a; --surface: #1a1a2f; --surface-light: #252540; --border: #2d2d4a;
|
||||
--text: #e0e0f0; --text-soft: #b0b0d0; --text-hint: #8080a0;
|
||||
--primary: #c5a572; --gradient: linear-gradient(135deg, #c5a572, #9a7e5a);
|
||||
--sidebar-width: 220px;
|
||||
}
|
||||
body { background: var(--bg); color: var(--text); font-family: -apple-system, 'Segoe UI', 'PingFang SC', sans-serif; }
|
||||
.admin-wrapper { display: flex; min-height: 100vh; }
|
||||
.sidebar { width: var(--sidebar-width); background: var(--surface); border-right: 1px solid var(--border); padding: 20px 0; position: sticky; top: 0; height: 100vh; }
|
||||
.sidebar-header { padding: 0 20px 20px; border-bottom: 1px solid var(--border); margin-bottom: 20px; }
|
||||
.sidebar-header .logo { font-size: 1.6rem; font-weight: 800; background: var(--gradient); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
.sidebar-header p { color: var(--text-soft); font-size: 0.85rem; }
|
||||
.sidebar-menu { list-style: none; }
|
||||
.sidebar-menu li { margin: 5px 0; }
|
||||
.sidebar-menu a { display: block; padding: 10px 20px; color: var(--text-soft); text-decoration: none; border-left: 4px solid transparent; }
|
||||
.sidebar-menu a:hover, .sidebar-menu a.active { background: var(--surface-light); border-left-color: var(--primary); color: var(--primary); }
|
||||
.sidebar-menu .separator { height: 1px; background: var(--border); margin: 15px 20px; }
|
||||
.main-content { flex: 1; padding: 20px 30px; }
|
||||
.top-bar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 30px; }
|
||||
.page-title { font-size: 1.8rem; font-weight: 600; color: var(--primary); }
|
||||
.user-info span { background: var(--surface-light); padding: 6px 16px; border-radius: 30px; }
|
||||
.message { background: var(--surface-light); border-left: 4px solid var(--primary); padding: 12px 20px; margin-bottom: 20px; border-radius: 8px; }
|
||||
.table-card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; min-width: 900px; }
|
||||
th { background: var(--surface-light); padding: 12px 15px; text-align: left; font-weight: 600; color: var(--primary); border-bottom: 1px solid var(--border); }
|
||||
td { padding: 12px 15px; border-bottom: 1px solid var(--border); color: var(--text-soft); }
|
||||
tr:hover { background: var(--surface-light); }
|
||||
.actions a { margin-right: 10px; color: var(--text-hint); text-decoration: none; }
|
||||
.actions .delete { color: #ff6b6b; }
|
||||
select { background: var(--surface-light); border: 1px solid var(--border); padding: 5px 10px; border-radius: 8px; color: var(--text); }
|
||||
.status-approved { color: var(--primary); }
|
||||
.status-pending { color: #ffb347; }
|
||||
.status-rejected { color: #ff6b6b; }
|
||||
@media (max-width: 768px) { .admin-wrapper { flex-direction: column; } .sidebar { width: 100%; height: auto; position: static; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="admin-wrapper">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header"><div class="logo">lv8girl</div><p>管理面板</p></div>
|
||||
<ul class="sidebar-menu">
|
||||
<li><a href="/admin">📊 仪表盘</a></li>
|
||||
<li><a href="/admin/pending_posts">⏳ 待审核帖子</a></li>
|
||||
<li><a href="/admin/pending_users">👥 待审核用户</a></li>
|
||||
<li><a href="/admin/posts">📝 帖子管理</a></li>
|
||||
<li><a href="/admin/users" class="active">👥 用户管理</a></li>
|
||||
<li><a href="/admin/comments">💬 评论管理</a></li>
|
||||
<li class="separator"></li>
|
||||
<li><a href="/">🏠 返回首页</a></li>
|
||||
</ul>
|
||||
</aside>
|
||||
<main class="main-content">
|
||||
<div class="top-bar">
|
||||
<h1 class="page-title">用户管理</h1>
|
||||
<div class="user-info"><span>{{.Username}}</span></div>
|
||||
</div>
|
||||
{{if .Message}}<div class="message">{{.Message}}</div>{{end}}
|
||||
<div class="table-card">
|
||||
<table>
|
||||
<thead><tr><th>ID</th><th>用户名</th><th>邮箱</th><th>角色</th><th>状态</th><th>注册时间</th><th>最后活动</th><th>帖子</th><th>评论</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Users}}
|
||||
<tr>
|
||||
<td>{{.ID}}</td>
|
||||
<td>{{.Username}}</td>
|
||||
<td>{{.Email}}</td>
|
||||
<td>
|
||||
{{if eq .ID $.CurrentUserID}}
|
||||
{{if eq .Role "admin"}}管理员{{else if eq .Role "banned"}}封禁{{else}}用户{{end}}
|
||||
{{else}}
|
||||
<form method="post" action="/admin/users/role" style="display:inline;">
|
||||
<input type="hidden" name="user_id" value="{{.ID}}">
|
||||
<select name="new_role" onchange="this.form.submit()">
|
||||
<option value="user" {{if eq .Role "user"}}selected{{end}}>用户</option>
|
||||
<option value="admin" {{if eq .Role "admin"}}selected{{end}}>管理员</option>
|
||||
<option value="banned" {{if eq .Role "banned"}}selected{{end}}>封禁</option>
|
||||
</select>
|
||||
</form>
|
||||
{{end}}
|
||||
</td>
|
||||
<td><span class="status-{{.Status}}">{{if eq .Status "approved"}}已通过{{else if eq .Status "pending"}}待审核{{else}}已拒绝{{end}}</span></td>
|
||||
<td>{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
|
||||
<td>{{if .LastActive}}{{.LastActive.Format "2006-01-02 15:04"}}{{else}}从未{{end}}</td>
|
||||
<td>{{.PostCount}}</td>
|
||||
<td>{{.CommentCount}}</td>
|
||||
<td class="actions">
|
||||
{{if ne .ID $.CurrentUserID}}
|
||||
<a href="/admin/users/delete/{{.ID}}" class="delete" onclick="return confirm('确定删除?')">删除</a>
|
||||
{{else}}
|
||||
<span style="color:var(--text-hint)">当前用户</span>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
191
templates/index.html
Normal file
191
templates/index.html
Normal file
@@ -0,0 +1,191 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>lv8girl · 绿坝娘二次元论坛</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
:root {
|
||||
--bg: #f0f7f0;
|
||||
--surface: #ffffff;
|
||||
--surface-light: #e8f0e8;
|
||||
--border: #d0e0d0;
|
||||
--border-light: #e0e8e0;
|
||||
--text: #2c3e2c;
|
||||
--text-soft: #5f6b5f;
|
||||
--text-hint: #8f9f8f;
|
||||
--primary: #3d9e4a;
|
||||
--primary-light: #6abf6e;
|
||||
--accent: #ffb347;
|
||||
--accent-dark: #d9a066;
|
||||
--gradient: linear-gradient(135deg, #3d9e4a, #5a9cff);
|
||||
}
|
||||
body.dark-mode {
|
||||
--bg: #1a1e1a;
|
||||
--surface: #1e261e;
|
||||
--surface-light: #2a3a2a;
|
||||
--border: #2a3a2a;
|
||||
--border-light: #3a4a3a;
|
||||
--text: #e0e8e0;
|
||||
--text-soft: #b0bcb0;
|
||||
--text-hint: #8a958a;
|
||||
--primary: #6b8e6b;
|
||||
--primary-light: #8aad8a;
|
||||
--accent: #ffb347;
|
||||
--accent-dark: #d9a066;
|
||||
--gradient: linear-gradient(135deg, #6b8e6b, #5a9cff);
|
||||
}
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
line-height: 1.6;
|
||||
transition: background 0.3s, color 0.3s;
|
||||
}
|
||||
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
|
||||
.header { margin-bottom: 30px; border-bottom: 1px solid var(--border); padding-bottom: 20px; }
|
||||
.top-bar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||
.logo { font-size: 2rem; font-weight: 800; background: var(--gradient); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
.logo span { font-size: 0.9rem; background: var(--accent); color: var(--surface); padding: 4px 12px; border-radius: 30px; margin-left: 10px; -webkit-text-fill-color: var(--surface); }
|
||||
.user-area { display: flex; align-items: center; gap: 15px; }
|
||||
.user-area a { color: var(--text-soft); text-decoration: none; padding: 6px 16px; border-radius: 30px; background: var(--surface-light); transition: 0.2s; }
|
||||
.user-area a:hover { background: var(--primary); color: white; }
|
||||
.user-menu { position: relative; cursor: pointer; }
|
||||
.user-name { display: flex; align-items: center; gap: 5px; background: var(--surface-light); padding: 6px 16px; border-radius: 30px; color: var(--text); }
|
||||
.dropdown { position: absolute; top: 120%; right: 0; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; min-width: 140px; opacity: 0; visibility: hidden; transform: translateY(-10px); transition: 0.2s; z-index: 100; }
|
||||
.user-menu:hover .dropdown { opacity: 1; visibility: visible; transform: translateY(0); }
|
||||
.dropdown a { display: block; padding: 10px 16px; color: var(--text); text-decoration: none; border-bottom: 1px solid var(--border); background: transparent; }
|
||||
.dropdown a:last-child { border-bottom: none; }
|
||||
.dropdown a:hover { background: var(--surface-light); }
|
||||
.theme-toggle { background: var(--surface-light); border: none; color: var(--text); font-size: 1.3rem; width: 38px; height: 38px; border-radius: 50%; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: 0.2s; }
|
||||
.theme-toggle:hover { background: var(--accent); color: var(--surface); }
|
||||
.welcome-message { background: var(--surface-light); padding: 15px 20px; border-radius: 12px; margin-bottom: 20px; border-left: 5px solid var(--accent); }
|
||||
.welcome-message p { color: var(--text-soft); }
|
||||
.welcome-message a { color: var(--accent); text-decoration: none; }
|
||||
.qq-group { background: var(--surface-light); padding: 10px 20px; border-radius: 12px; display: inline-block; margin-bottom: 20px; border: 1px solid var(--border); }
|
||||
.main-layout { display: flex; gap: 30px; }
|
||||
.content-left { flex: 2; }
|
||||
.content-right { flex: 1; }
|
||||
.post-list { background: var(--surface); border-radius: 12px; border: 1px solid var(--border); overflow: hidden; }
|
||||
.post-item { display: flex; padding: 20px; border-bottom: 1px solid var(--border); transition: 0.2s; }
|
||||
.post-item:hover { background: var(--surface-light); }
|
||||
.post-avatar { width: 50px; height: 50px; border-radius: 50%; background: var(--gradient); margin-right: 20px; flex-shrink: 0; overflow: hidden; display: flex; align-items: center; justify-content: center; color: white; font-weight: 600; }
|
||||
.post-avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.post-content { flex: 1; }
|
||||
.post-header { display: flex; align-items: center; gap: 15px; margin-bottom: 8px; flex-wrap: wrap; }
|
||||
.post-author { font-weight: 600; color: var(--accent); }
|
||||
.post-time { color: var(--text-hint); font-size: 0.85rem; }
|
||||
.post-title { font-size: 1.3rem; font-weight: 700; margin-bottom: 8px; }
|
||||
.post-title a { color: var(--text); text-decoration: none; }
|
||||
.post-title a:hover { color: var(--accent); }
|
||||
.post-excerpt { color: var(--text-soft); margin-bottom: 12px; font-size: 0.95rem; }
|
||||
.post-meta { display: flex; gap: 20px; color: var(--text-hint); font-size: 0.85rem; }
|
||||
.stats-card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 20px; }
|
||||
.stats-header { margin-bottom: 15px; border-bottom: 1px solid var(--border); padding-bottom: 10px; }
|
||||
.stats-title { font-size: 1.1rem; font-weight: 600; color: var(--accent); }
|
||||
.stats-grid { display: flex; justify-content: space-around; text-align: center; }
|
||||
.stat-item { flex: 1; }
|
||||
.stat-number { font-size: 1.8rem; font-weight: 700; color: var(--primary); line-height: 1.2; }
|
||||
.stat-label { color: var(--text-hint); font-size: 0.9rem; }
|
||||
.footer { margin-top: 40px; padding: 20px 0; border-top: 1px solid var(--border); text-align: center; color: var(--text-hint); font-size: 0.9rem; }
|
||||
.footer a { color: var(--text-hint); text-decoration: none; margin: 0 10px; }
|
||||
.footer a:hover { color: var(--accent); }
|
||||
@media (max-width: 800px) {
|
||||
.main-layout { flex-direction: column; }
|
||||
.top-bar { flex-direction: column; gap: 15px; align-items: flex-start; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="top-bar">
|
||||
<div class="logo">lv8girl<span>绿坝娘</span></div>
|
||||
<div class="user-area">
|
||||
{{if .IsLoggedIn}}
|
||||
<div class="user-menu">
|
||||
<span class="user-name">{{.Username}} ▼</span>
|
||||
<div class="dropdown">
|
||||
{{if eq .UserRole "admin"}}<a href="/admin">管理面板</a>{{end}}
|
||||
<a href="/profile">个人主页</a>
|
||||
<a href="/new-post">发表新帖</a>
|
||||
<a href="/messages">私信{{if gt .UnreadCount 0}}<span style="background:#ff6b6b;color:white;border-radius:50%;padding:2px 6px;font-size:0.7rem;margin-left:5px;">{{.UnreadCount}}</span>{{end}}</a>
|
||||
<a href="/logout">登出</a>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<a href="/login">登录</a>
|
||||
<a href="/register">注册</a>
|
||||
{{end}}
|
||||
<button class="theme-toggle" id="themeToggle">🌓</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="welcome-message">
|
||||
<p>欢迎来到 lv8girl 论坛,一个 ACG 爱好者的聚集地。</p>
|
||||
</div>
|
||||
<div class="qq-group">🍀 绿坝娘 · 守护你的二次元</div>
|
||||
</div>
|
||||
|
||||
<div class="main-layout">
|
||||
<div class="content-left">
|
||||
<div class="post-list">
|
||||
{{if not .Posts}}
|
||||
<div style="padding: 40px; text-align: center; color: var(--text-hint);">暂无帖子,快去发表第一篇吧!</div>
|
||||
{{else}}
|
||||
{{range .Posts}}
|
||||
<div class="post-item">
|
||||
<a href="/profile/{{.UserID}}" class="post-avatar">
|
||||
{{if .Avatar}}<img src="{{.Avatar}}" alt="avatar">{{else}}<span>{{slice .Username 0 1}}</span>{{end}}
|
||||
</a>
|
||||
<div class="post-content">
|
||||
<div class="post-header">
|
||||
<span class="post-author">{{.Username}}</span>
|
||||
<span class="post-time">{{.CreatedAt.Format "2006-01-02 15:04"}}</span>
|
||||
</div>
|
||||
<div class="post-title"><a href="/post/{{.ID}}">{{.Title}}</a></div>
|
||||
<div class="post-excerpt">{{slice .Content 0 100}}{{if gt (len .Content) 100}}...{{end}}</div>
|
||||
<div class="post-meta">
|
||||
<span>👍 {{.LikeCount}}</span>
|
||||
<span>💬 {{.CommentCount}}</span>
|
||||
<span>👁️ {{.Views}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-right">
|
||||
<div class="stats-card">
|
||||
<div class="stats-header"><span class="stats-title">📊 论坛统计</span></div>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item"><div class="stat-number">{{.PostCount}}</div><div class="stat-label">帖子总数</div></div>
|
||||
<div class="stat-item"><div class="stat-number">{{.UserCount}}</div><div class="stat-label">注册用户</div></div>
|
||||
<div class="stat-item"><div class="stat-number">{{.OnlineCount}}</div><div class="stat-label">实时在线</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<div>© 2025 lv8girl · 绿坝娘二次元论坛</div>
|
||||
<div>
|
||||
<a href="#">关于</a>
|
||||
<a href="#">帮助</a>
|
||||
<a href="#">隐私</a>
|
||||
<a href="#">投稿</a>
|
||||
<a href="https://icp.gov.moe/?keyword=20260911" target="_blank">萌ICP备20260911号</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const themeToggle = document.getElementById('themeToggle');
|
||||
themeToggle.addEventListener('click', () => {
|
||||
document.body.classList.toggle('dark-mode');
|
||||
themeToggle.textContent = document.body.classList.contains('dark-mode') ? '☀️' : '🌓';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
84
templates/login.html
Normal file
84
templates/login.html
Normal file
@@ -0,0 +1,84 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>lv8girl · 登录</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
:root {
|
||||
--bg: #f0f7f0;
|
||||
--surface: #ffffff;
|
||||
--surface-light: #e8f0e8;
|
||||
--border: #d0e0d0;
|
||||
--text: #2c3e2c;
|
||||
--text-soft: #5f6b5f;
|
||||
--text-hint: #8f9f8f;
|
||||
--primary: #3d9e4a;
|
||||
--accent: #ffb347;
|
||||
--gradient: linear-gradient(135deg, #3d9e4a, #5a9cff);
|
||||
}
|
||||
body.dark-mode {
|
||||
--bg: #1a1e1a;
|
||||
--surface: #1e261e;
|
||||
--surface-light: #2a3a2a;
|
||||
--border: #2a3a2a;
|
||||
--text: #e0e8e0;
|
||||
--text-soft: #b0bcb0;
|
||||
--text-hint: #8a958a;
|
||||
--primary: #6b8e6b;
|
||||
--accent: #ffb347;
|
||||
}
|
||||
body { background: var(--bg); color: var(--text); font-family: -apple-system, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif; min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; transition: background 0.3s, color 0.3s; }
|
||||
.login-wrapper { max-width: 400px; width: 100%; }
|
||||
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 30px; }
|
||||
.logo { font-size: 2rem; font-weight: 800; background: var(--gradient); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
.logo span { font-size: 0.9rem; background: var(--accent); color: var(--surface); padding: 4px 12px; border-radius: 30px; margin-left: 10px; -webkit-text-fill-color: var(--surface); }
|
||||
.theme-toggle { background: var(--surface-light); border: none; color: var(--text); font-size: 1.3rem; width: 38px; height: 38px; border-radius: 50%; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: 0.2s; }
|
||||
.theme-toggle:hover { background: var(--accent); color: var(--surface); }
|
||||
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 30px 25px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.04); }
|
||||
.card h2 { text-align: center; margin-bottom: 25px; color: var(--accent); font-weight: 600; }
|
||||
.error-message { background: var(--surface-light); border-left: 4px solid #ff6b6b; color: var(--text-soft); padding: 12px 15px; border-radius: 8px; margin-bottom: 20px; font-size: 0.9rem; }
|
||||
.form-group { margin-bottom: 20px; }
|
||||
label { display: block; margin-bottom: 8px; color: var(--text-soft); font-size: 0.9rem; }
|
||||
input { width: 100%; padding: 12px 15px; background: var(--surface-light); border: 1px solid var(--border); border-radius: 30px; color: var(--text); font-size: 1rem; outline: none; transition: border 0.2s; }
|
||||
input:focus { border-color: var(--accent); }
|
||||
.btn { width: 100%; padding: 14px; background: var(--gradient); border: none; border-radius: 40px; color: white; font-weight: 600; font-size: 1rem; cursor: pointer; transition: transform 0.2s; margin-top: 10px; }
|
||||
.btn:hover { transform: scale(1.02); }
|
||||
.footer-text { text-align: center; margin-top: 25px; color: var(--text-hint); }
|
||||
.footer-text a { color: var(--accent); text-decoration: none; font-weight: 500; }
|
||||
.footer-text a:hover { text-decoration: underline; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-wrapper">
|
||||
<div class="header">
|
||||
<div class="logo">lv8girl<span>绿坝娘</span></div>
|
||||
<button class="theme-toggle" id="themeToggle">🌓</button>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>登录</h2>
|
||||
{{if .Error}}<div class="error-message">{{.Error}}</div>{{end}}
|
||||
<form method="post">
|
||||
<div class="form-group">
|
||||
<label>用户名 / 邮箱</label>
|
||||
<input type="text" name="login" placeholder="请输入用户名或邮箱" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>密码</label>
|
||||
<input type="password" name="password" placeholder="请输入密码" required>
|
||||
</div>
|
||||
<button type="submit" class="btn">登 录</button>
|
||||
</form>
|
||||
<div class="footer-text">还没有账号? <a href="/register">立即注册</a></div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const themeToggle = document.getElementById('themeToggle');
|
||||
themeToggle.addEventListener('click', () => {
|
||||
document.body.classList.toggle('dark-mode');
|
||||
themeToggle.textContent = document.body.classList.contains('dark-mode') ? '☀️' : '🌓';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
109
templates/messages.html
Normal file
109
templates/messages.html
Normal file
@@ -0,0 +1,109 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>私信列表 - lv8girl</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
:root {
|
||||
--bg: #f0f7f0;
|
||||
--surface: #ffffff;
|
||||
--surface-light: #e8f0e8;
|
||||
--border: #d0e0d0;
|
||||
--text: #2c3e2c;
|
||||
--text-soft: #5f6b5f;
|
||||
--text-hint: #8f9f8f;
|
||||
--primary: #3d9e4a;
|
||||
--accent: #ffb347;
|
||||
--gradient: linear-gradient(135deg, #3d9e4a, #5a9cff);
|
||||
}
|
||||
body.dark-mode {
|
||||
--bg: #1a1e1a;
|
||||
--surface: #1e261e;
|
||||
--surface-light: #2a3a2a;
|
||||
--border: #2a3a2a;
|
||||
--text: #e0e8e0;
|
||||
--text-soft: #b0bcb0;
|
||||
--text-hint: #8a958a;
|
||||
--primary: #6b8e6b;
|
||||
--accent: #ffb347;
|
||||
}
|
||||
body { background: var(--bg); color: var(--text); font-family: -apple-system, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif; line-height: 1.6; transition: background 0.3s, color 0.3s; }
|
||||
.container { max-width: 800px; margin: 0 auto; padding: 20px; }
|
||||
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 30px; border-bottom: 1px solid var(--border); padding-bottom: 20px; }
|
||||
.logo { font-size: 2rem; font-weight: 800; background: var(--gradient); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
.logo span { font-size: 0.9rem; background: var(--accent); color: var(--surface); padding: 4px 12px; border-radius: 30px; margin-left: 10px; -webkit-text-fill-color: var(--surface); }
|
||||
.nav-right { display: flex; align-items: center; gap: 15px; }
|
||||
.nav-right a { color: var(--text-soft); text-decoration: none; padding: 6px 16px; border-radius: 30px; background: var(--surface-light); transition: 0.2s; }
|
||||
.nav-right a:hover { background: var(--primary); color: white; }
|
||||
.theme-toggle { background: var(--surface-light); border: none; color: var(--text); font-size: 1.3rem; width: 38px; height: 38px; border-radius: 50%; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: 0.2s; }
|
||||
.theme-toggle:hover { background: var(--accent); color: var(--surface); }
|
||||
.page-title { font-size: 1.8rem; font-weight: 600; color: var(--accent); margin-bottom: 20px; }
|
||||
.conversation-list { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
|
||||
.conversation-item { display: flex; align-items: center; padding: 15px 20px; border-bottom: 1px solid var(--border-light); text-decoration: none; color: var(--text); transition: 0.2s; }
|
||||
.conversation-item:hover { background: var(--surface-light); }
|
||||
.conversation-item:last-child { border-bottom: none; }
|
||||
.avatar { width: 50px; height: 50px; border-radius: 50%; background: var(--gradient); margin-right: 15px; overflow: hidden; display: flex; align-items: center; justify-content: center; color: white; font-weight: 600; flex-shrink: 0; }
|
||||
.avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.conversation-info { flex: 1; min-width: 0; }
|
||||
.username { font-weight: 600; color: var(--accent); margin-bottom: 4px; }
|
||||
.last-msg { color: var(--text-soft); font-size: 0.9rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 300px; }
|
||||
.time { color: var(--text-hint); font-size: 0.8rem; margin-left: 10px; white-space: nowrap; }
|
||||
.unread-badge { background: #ff6b6b; color: white; border-radius: 50%; width: 22px; height: 22px; display: flex; align-items: center; justify-content: center; font-size: 0.7rem; margin-left: 10px; flex-shrink: 0; }
|
||||
.empty-message { padding: 40px; text-align: center; color: var(--text-hint); }
|
||||
.footer { margin-top: 40px; padding: 20px 0; border-top: 1px solid var(--border); text-align: center; color: var(--text-hint); font-size: 0.9rem; }
|
||||
.footer a { color: var(--text-hint); text-decoration: none; margin: 0 10px; }
|
||||
.footer a:hover { color: var(--accent); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="logo">lv8girl<span>绿坝娘</span></div>
|
||||
<div class="nav-right">
|
||||
<a href="/">首页</a>
|
||||
<button class="theme-toggle" id="themeToggle">🌓</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 class="page-title">私信列表</h1>
|
||||
|
||||
<div class="conversation-list">
|
||||
{{if not .Conversations}}
|
||||
<div class="empty-message">暂无对话,快去给感兴趣的用户发送私信吧~</div>
|
||||
{{else}}
|
||||
{{range .Conversations}}
|
||||
<div class="conversation-item">
|
||||
<div class="avatar">{{if .Avatar}}<img src="{{.Avatar}}" alt="">{{else}}<span>{{slice .Username 0 1}}</span>{{end}}</div>
|
||||
<div class="conversation-info">
|
||||
<div class="username">{{.Username}}</div>
|
||||
<div class="last-msg">{{slice .LastMsg 0 50}}{{if gt (len .LastMsg) 50}}...{{end}}</div>
|
||||
</div>
|
||||
<div class="time">{{.Time.Format "01-02 15:04"}}</div>
|
||||
{{if gt .Unread 0}}<div class="unread-badge">{{.Unread}}</div>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<div>© 2025 lv8girl · 绿坝娘二次元论坛</div>
|
||||
<div>
|
||||
<a href="#">关于</a>
|
||||
<a href="#">帮助</a>
|
||||
<a href="#">隐私</a>
|
||||
<a href="#">投稿</a>
|
||||
<a href="https://icp.gov.moe/?keyword=20260911" target="_blank">萌ICP备20260911号</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const themeToggle = document.getElementById('themeToggle');
|
||||
themeToggle.addEventListener('click', () => {
|
||||
document.body.classList.toggle('dark-mode');
|
||||
themeToggle.textContent = document.body.classList.contains('dark-mode') ? '☀️' : '🌓';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
228
templates/post.html
Normal file
228
templates/post.html
Normal file
@@ -0,0 +1,228 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.Post.Title}} - lv8girl</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
:root {
|
||||
--bg: #f0f7f0;
|
||||
--surface: #ffffff;
|
||||
--surface-light: #e8f0e8;
|
||||
--border: #d0e0d0;
|
||||
--text: #2c3e2c;
|
||||
--text-soft: #5f6b5f;
|
||||
--text-hint: #8f9f8f;
|
||||
--primary: #3d9e4a;
|
||||
--accent: #ffb347;
|
||||
--gradient: linear-gradient(135deg, #3d9e4a, #5a9cff);
|
||||
}
|
||||
body.dark-mode {
|
||||
--bg: #1a1e1a;
|
||||
--surface: #1e261e;
|
||||
--surface-light: #2a3a2a;
|
||||
--border: #2a3a2a;
|
||||
--text: #e0e8e0;
|
||||
--text-soft: #b0bcb0;
|
||||
--text-hint: #8a958a;
|
||||
--primary: #6b8e6b;
|
||||
--accent: #ffb347;
|
||||
}
|
||||
body { background: var(--bg); color: var(--text); font-family: -apple-system, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif; line-height: 1.6; transition: background 0.3s, color 0.3s; }
|
||||
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
|
||||
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 30px; border-bottom: 1px solid var(--border); padding-bottom: 20px; }
|
||||
.logo { font-size: 2rem; font-weight: 800; background: var(--gradient); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
.logo span { font-size: 0.9rem; background: var(--accent); color: var(--surface); padding: 4px 12px; border-radius: 30px; margin-left: 10px; -webkit-text-fill-color: var(--surface); }
|
||||
.nav-right { display: flex; align-items: center; gap: 15px; }
|
||||
.nav-right a { color: var(--text-soft); text-decoration: none; padding: 6px 16px; border-radius: 30px; background: var(--surface-light); transition: 0.2s; }
|
||||
.nav-right a:hover { background: var(--primary); color: white; }
|
||||
.user-menu { position: relative; cursor: pointer; }
|
||||
.user-name { display: flex; align-items: center; gap: 5px; background: var(--surface-light); padding: 6px 16px; border-radius: 30px; color: var(--text); }
|
||||
.dropdown { position: absolute; top: 120%; right: 0; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; min-width: 140px; opacity: 0; visibility: hidden; transform: translateY(-10px); transition: 0.2s; z-index: 100; }
|
||||
.user-menu:hover .dropdown { opacity: 1; visibility: visible; transform: translateY(0); }
|
||||
.dropdown a { display: block; padding: 10px 16px; color: var(--text); text-decoration: none; border-bottom: 1px solid var(--border); background: transparent; }
|
||||
.dropdown a:last-child { border-bottom: none; }
|
||||
.dropdown a:hover { background: var(--surface-light); }
|
||||
.theme-toggle { background: var(--surface-light); border: none; color: var(--text); font-size: 1.3rem; width: 38px; height: 38px; border-radius: 50%; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: 0.2s; }
|
||||
.theme-toggle:hover { background: var(--accent); color: var(--surface); }
|
||||
.main-layout { display: flex; gap: 30px; }
|
||||
.content-left { flex: 2; }
|
||||
.content-right { flex: 1; }
|
||||
.post-card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 25px; margin-bottom: 30px; }
|
||||
.post-header { display: flex; align-items: center; gap: 15px; margin-bottom: 15px; }
|
||||
.post-author-avatar { width: 50px; height: 50px; border-radius: 50%; background: var(--gradient); overflow: hidden; display: flex; align-items: center; justify-content: center; color: white; font-weight: 600; }
|
||||
.post-author-avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.post-meta { flex: 1; }
|
||||
.post-author-name { font-size: 1.1rem; font-weight: 600; color: var(--accent); text-decoration: none; }
|
||||
.post-author-name:hover { text-decoration: underline; }
|
||||
.post-time { color: var(--text-hint); font-size: 0.85rem; }
|
||||
.post-title { font-size: 2rem; font-weight: 700; margin-bottom: 15px; color: var(--text); }
|
||||
.post-image { margin: 20px 0; max-width: 100%; border-radius: 12px; overflow: hidden; }
|
||||
.post-image img { width: 100%; max-height: 500px; object-fit: contain; background: var(--surface-light); }
|
||||
.post-content { color: var(--text); font-size: 1.1rem; line-height: 1.7; margin-bottom: 20px; white-space: pre-wrap; }
|
||||
.post-actions { display: flex; align-items: center; gap: 20px; padding: 15px 0; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); }
|
||||
.like-btn { background: var(--gradient); border: none; border-radius: 40px; padding: 8px 25px; color: white; font-weight: 600; cursor: pointer; transition: 0.2s; display: inline-flex; align-items: center; gap: 8px; text-decoration: none; }
|
||||
.like-btn:hover { transform: scale(1.02); }
|
||||
.like-btn.liked { background: #ff6b6b; }
|
||||
.like-count { font-size: 1.1rem; font-weight: 600; color: var(--text); }
|
||||
.view-count { margin-left: auto; color: var(--text-hint); font-size: 0.95rem; }
|
||||
.author-card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 20px; margin-bottom: 20px; text-align: center; }
|
||||
.author-avatar-large { width: 80px; height: 80px; border-radius: 50%; background: var(--gradient); margin: 0 auto 15px; overflow: hidden; display: flex; align-items: center; justify-content: center; color: white; font-size: 2rem; font-weight: 600; }
|
||||
.author-avatar-large img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.author-name { font-size: 1.3rem; font-weight: 600; color: var(--accent); margin-bottom: 5px; }
|
||||
.author-meta { color: var(--text-hint); font-size: 0.9rem; margin-bottom: 5px; }
|
||||
.author-uid { color: var(--text-soft); font-size: 0.85rem; margin-bottom: 10px; }
|
||||
.author-stats { display: flex; justify-content: center; gap: 20px; margin: 15px 0; padding-top: 15px; border-top: 1px solid var(--border); }
|
||||
.author-stat-item { text-align: center; }
|
||||
.author-stat-number { font-size: 1.3rem; font-weight: 700; color: var(--primary); }
|
||||
.author-stat-label { color: var(--text-hint); font-size: 0.8rem; }
|
||||
.view-profile { display: inline-block; background: var(--gradient); color: white; text-decoration: none; padding: 8px 20px; border-radius: 40px; font-weight: 600; transition: 0.2s; }
|
||||
.view-profile:hover { transform: scale(1.02); }
|
||||
.comments-section { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 25px; margin-bottom: 30px; }
|
||||
.comments-title { font-size: 1.5rem; font-weight: 600; color: var(--accent); margin-bottom: 20px; }
|
||||
.comment-form { margin-bottom: 30px; }
|
||||
.comment-form textarea { width: 100%; background: var(--surface-light); border: 1px solid var(--border); border-radius: 12px; padding: 15px; font-size: 1rem; color: var(--text); resize: vertical; min-height: 100px; margin-bottom: 10px; }
|
||||
.comment-form button { background: var(--gradient); border: none; border-radius: 40px; padding: 10px 30px; color: white; font-weight: 600; cursor: pointer; transition: 0.2s; }
|
||||
.comment-form button:hover { transform: scale(1.02); }
|
||||
.comment-item { display: flex; gap: 15px; padding: 20px 0; border-bottom: 1px solid var(--border); }
|
||||
.comment-item:last-child { border-bottom: none; }
|
||||
.comment-avatar { width: 40px; height: 40px; border-radius: 50%; background: var(--gradient); overflow: hidden; flex-shrink: 0; display: flex; align-items: center; justify-content: center; color: white; font-weight: 600; }
|
||||
.comment-avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.comment-content { flex: 1; }
|
||||
.comment-header { display: flex; align-items: center; gap: 15px; margin-bottom: 5px; }
|
||||
.comment-author { font-weight: 600; color: var(--accent); text-decoration: none; }
|
||||
.comment-author:hover { text-decoration: underline; }
|
||||
.comment-time { color: var(--text-hint); font-size: 0.8rem; }
|
||||
.comment-text { color: var(--text-soft); line-height: 1.5; }
|
||||
.no-comments { text-align: center; color: var(--text-hint); padding: 30px 0; }
|
||||
.login-prompt { text-align: center; color: var(--text-hint); margin-bottom: 20px; }
|
||||
.login-prompt a { color: var(--accent); text-decoration: none; }
|
||||
.footer { margin-top: 40px; padding: 20px 0; border-top: 1px solid var(--border); text-align: center; color: var(--text-hint); font-size: 0.9rem; }
|
||||
.footer a { color: var(--text-hint); text-decoration: none; margin: 0 10px; }
|
||||
.footer a:hover { color: var(--accent); }
|
||||
@media (max-width: 800px) { .main-layout { flex-direction: column; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="logo">lv8girl<span>绿坝娘</span></div>
|
||||
<div class="nav-right">
|
||||
{{if .IsLoggedIn}}
|
||||
<div class="user-menu">
|
||||
<span class="user-name">{{.Username}} ▼</span>
|
||||
<div class="dropdown">
|
||||
{{if eq .UserRole "admin"}}<a href="/admin">管理面板</a>{{end}}
|
||||
<a href="/profile">个人主页</a>
|
||||
<a href="/new-post">发表新帖</a>
|
||||
<a href="/messages">私信</a>
|
||||
<a href="/logout">登出</a>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<a href="/login">登录</a>
|
||||
<a href="/register">注册</a>
|
||||
{{end}}
|
||||
<button class="theme-toggle" id="themeToggle">🌓</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main-layout">
|
||||
<div class="content-left">
|
||||
<div class="post-card">
|
||||
<div class="post-header">
|
||||
<a href="/profile/{{.Post.UserID}}" class="post-author-avatar">
|
||||
{{if .PostAvatar}}<img src="{{.PostAvatar}}" alt="avatar">{{else}}<span>{{slice .Post.User.Username 0 1}}</span>{{end}}
|
||||
</a>
|
||||
<div class="post-meta">
|
||||
<a href="/profile/{{.Post.UserID}}" class="post-author-name">{{.Post.User.Username}}</a>
|
||||
<div class="post-time">{{.Post.CreatedAt.Format "2006-01-02 15:04"}}</div>
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="post-title">{{.Post.Title}}</h1>
|
||||
{{if .Post.ImagePath}}<div class="post-image"><img src="/{{.Post.ImagePath}}" alt="post image"></div>{{end}}
|
||||
<div class="post-content">{{.Post.Content}}</div>
|
||||
<div class="post-actions">
|
||||
{{if .IsLoggedIn}}
|
||||
<form method="post" action="/post/{{.Post.ID}}/like" style="display: inline;">
|
||||
<button type="submit" class="like-btn {{if .UserLiked}}liked{{end}}">{{if .UserLiked}}✓ 已点赞{{else}}👍 点赞{{end}}</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<a href="/login" class="like-btn">👍 登录后点赞</a>
|
||||
{{end}}
|
||||
<span class="like-count">{{.LikeCount}} 人点赞</span>
|
||||
<span class="view-count">👁️ {{.Post.Views}} 次阅读</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="comments-section">
|
||||
<h2 class="comments-title">评论 ({{len .Comments}})</h2>
|
||||
{{if .IsLoggedIn}}
|
||||
<form method="post" action="/post/{{.Post.ID}}/comment" class="comment-form">
|
||||
<textarea name="content" placeholder="写下你的评论..." required></textarea>
|
||||
<button type="submit">发表评论</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<div class="login-prompt"><a href="/login">登录</a> 后即可评论</div>
|
||||
{{end}}
|
||||
{{if not .Comments}}
|
||||
<div class="no-comments">暂无评论,快来抢沙发吧~</div>
|
||||
{{else}}
|
||||
{{range .Comments}}
|
||||
<div class="comment-item">
|
||||
<a href="/profile/{{.UserID}}" class="comment-avatar">
|
||||
{{if index $.CommentAvatars .UserID}}<img src="{{index $.CommentAvatars .UserID}}" alt="avatar">{{else}}<span>{{slice .User.Username 0 1}}</span>{{end}}
|
||||
</a>
|
||||
<div class="comment-content">
|
||||
<div class="comment-header">
|
||||
<a href="/profile/{{.UserID}}" class="comment-author">{{.User.Username}}</a>
|
||||
<span class="comment-time">{{.CreatedAt.Format "2006-01-02 15:04"}}</span>
|
||||
</div>
|
||||
<div class="comment-text">{{.Content}}</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-right">
|
||||
<div class="author-card">
|
||||
<div class="author-avatar-large">
|
||||
{{if .PostAvatar}}<img src="{{.PostAvatar}}" alt="avatar">{{else}}<span>{{slice .Post.User.Username 0 1}}</span>{{end}}
|
||||
</div>
|
||||
<div class="author-name">{{.Post.User.Username}}</div>
|
||||
<div class="author-meta">身份:{{if eq .Post.User.Role "admin"}}管理员{{else}}用户{{end}}</div>
|
||||
<div class="author-uid">UID: {{.Post.UserID}}</div>
|
||||
<div class="author-stats">
|
||||
<div class="author-stat-item">
|
||||
<div class="author-stat-number">{{.AuthorPostCount}}</div>
|
||||
<div class="author-stat-label">帖子</div>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/profile/{{.Post.UserID}}" class="view-profile">查看个人主页</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<div>© 2025 lv8girl · 绿坝娘二次元论坛</div>
|
||||
<div>
|
||||
<a href="#">关于</a>
|
||||
<a href="#">帮助</a>
|
||||
<a href="#">隐私</a>
|
||||
<a href="#">投稿</a>
|
||||
<a href="https://icp.gov.moe/?keyword=20260911" target="_blank">萌ICP备20260911号</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const themeToggle = document.getElementById('themeToggle');
|
||||
themeToggle.addEventListener('click', () => {
|
||||
document.body.classList.toggle('dark-mode');
|
||||
themeToggle.textContent = document.body.classList.contains('dark-mode') ? '☀️' : '🌓';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
100
templates/post_discussion.html
Normal file
100
templates/post_discussion.html
Normal file
@@ -0,0 +1,100 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>发表新帖 - lv8girl</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
:root {
|
||||
--bg: #f0f7f0;
|
||||
--surface: #ffffff;
|
||||
--surface-light: #e8f0e8;
|
||||
--border: #d0e0d0;
|
||||
--text: #2c3e2c;
|
||||
--text-soft: #5f6b5f;
|
||||
--text-hint: #8f9f8f;
|
||||
--primary: #3d9e4a;
|
||||
--accent: #ffb347;
|
||||
--gradient: linear-gradient(135deg, #3d9e4a, #5a9cff);
|
||||
}
|
||||
body.dark-mode {
|
||||
--bg: #1a1e1a;
|
||||
--surface: #1e261e;
|
||||
--surface-light: #2a3a2a;
|
||||
--border: #2a3a2a;
|
||||
--text: #e0e8e0;
|
||||
--text-soft: #b0bcb0;
|
||||
--text-hint: #8a958a;
|
||||
--primary: #6b8e6b;
|
||||
--accent: #ffb347;
|
||||
}
|
||||
body { background: var(--bg); color: var(--text); font-family: -apple-system, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif; line-height: 1.6; transition: background 0.3s, color 0.3s; min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; }
|
||||
.post-wrapper { max-width: 800px; width: 100%; }
|
||||
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 30px; }
|
||||
.logo { font-size: 2rem; font-weight: 800; background: var(--gradient); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
.logo span { font-size: 0.9rem; background: var(--accent); color: var(--surface); padding: 4px 12px; border-radius: 30px; margin-left: 10px; -webkit-text-fill-color: var(--surface); }
|
||||
.theme-toggle { background: var(--surface-light); border: none; color: var(--text); font-size: 1.3rem; width: 38px; height: 38px; border-radius: 50%; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: 0.2s; }
|
||||
.theme-toggle:hover { background: var(--accent); color: var(--surface); }
|
||||
.post-card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 30px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.04); }
|
||||
.post-card h2 { font-size: 1.8rem; font-weight: 600; color: var(--accent); margin-bottom: 25px; text-align: center; }
|
||||
.error-message { background: var(--surface-light); border-left: 4px solid #ff6b6b; color: var(--text-soft); padding: 12px 15px; border-radius: 8px; margin-bottom: 20px; }
|
||||
.success-message { background: var(--surface-light); border-left: 4px solid var(--primary); color: var(--text-soft); padding: 12px 15px; border-radius: 8px; margin-bottom: 20px; }
|
||||
.form-group { margin-bottom: 20px; }
|
||||
label { display: block; margin-bottom: 8px; color: var(--text-soft); font-weight: 500; }
|
||||
input[type="text"], textarea { width: 100%; padding: 12px 15px; background: var(--surface-light); border: 1px solid var(--border); border-radius: 30px; color: var(--text); font-size: 1rem; outline: none; transition: border 0.2s; }
|
||||
textarea { border-radius: 20px; resize: vertical; min-height: 150px; }
|
||||
input:focus, textarea:focus { border-color: var(--accent); }
|
||||
input[type="file"] { background: var(--surface-light); border: 1px solid var(--border); border-radius: 30px; padding: 10px 15px; width: 100%; color: var(--text); }
|
||||
.file-note { color: var(--text-hint); font-size: 0.85rem; margin-top: 5px; }
|
||||
.btn { background: var(--gradient); border: none; border-radius: 40px; padding: 14px 30px; color: white; font-weight: 600; font-size: 1rem; cursor: pointer; transition: transform 0.2s; width: 100%; margin-top: 10px; }
|
||||
.btn:hover { transform: scale(1.02); }
|
||||
.footer-links { margin-top: 20px; text-align: center; }
|
||||
.footer-links a { color: var(--text-hint); text-decoration: none; margin: 0 10px; }
|
||||
.footer-links a:hover { color: var(--accent); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="post-wrapper">
|
||||
<div class="header">
|
||||
<div class="logo">lv8girl<span>绿坝娘</span></div>
|
||||
<button class="theme-toggle" id="themeToggle">🌓</button>
|
||||
</div>
|
||||
<div class="post-card">
|
||||
<h2>发表新帖</h2>
|
||||
{{if .Error}}<div class="error-message">{{.Error}}</div>{{end}}
|
||||
{{if .Success}}
|
||||
<div class="success-message">{{.Success}}</div>
|
||||
{{else}}
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<div class="form-group">
|
||||
<label>标题</label>
|
||||
<input type="text" name="title" placeholder="请输入标题" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>内容</label>
|
||||
<textarea name="content" placeholder="请输入帖子内容..." required></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>上传图片(可选,不超过2MB)</label>
|
||||
<input type="file" name="image" accept="image/*">
|
||||
<div class="file-note">支持 JPEG、PNG、GIF、WEBP 格式</div>
|
||||
</div>
|
||||
<button type="submit" class="btn">发 布</button>
|
||||
</form>
|
||||
{{end}}
|
||||
<div class="footer-links">
|
||||
<a href="/">返回首页</a>
|
||||
<a href="/profile">个人主页</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const themeToggle = document.getElementById('themeToggle');
|
||||
themeToggle.addEventListener('click', () => {
|
||||
document.body.classList.toggle('dark-mode');
|
||||
themeToggle.textContent = document.body.classList.contains('dark-mode') ? '☀️' : '🌓';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
195
templates/profile.html
Normal file
195
templates/profile.html
Normal file
@@ -0,0 +1,195 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.User.Username}}的个人主页 - lv8girl</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
:root {
|
||||
--bg: #f0f7f0;
|
||||
--surface: #ffffff;
|
||||
--surface-light: #e8f0e8;
|
||||
--border: #d0e0d0;
|
||||
--text: #2c3e2c;
|
||||
--text-soft: #5f6b5f;
|
||||
--text-hint: #8f9f8f;
|
||||
--primary: #3d9e4a;
|
||||
--accent: #ffb347;
|
||||
--gradient: linear-gradient(135deg, #3d9e4a, #5a9cff);
|
||||
}
|
||||
body.dark-mode {
|
||||
--bg: #1a1e1a;
|
||||
--surface: #1e261e;
|
||||
--surface-light: #2a3a2a;
|
||||
--border: #2a3a2a;
|
||||
--text: #e0e8e0;
|
||||
--text-soft: #b0bcb0;
|
||||
--text-hint: #8a958a;
|
||||
--primary: #6b8e6b;
|
||||
--accent: #ffb347;
|
||||
}
|
||||
body { background: var(--bg); color: var(--text); font-family: -apple-system, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif; line-height: 1.6; transition: background 0.3s, color 0.3s; }
|
||||
.container { max-width: 1000px; margin: 0 auto; padding: 20px; }
|
||||
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 30px; border-bottom: 1px solid var(--border); padding-bottom: 20px; }
|
||||
.logo { font-size: 2rem; font-weight: 800; background: var(--gradient); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
.logo span { font-size: 0.9rem; background: var(--accent); color: var(--surface); padding: 4px 12px; border-radius: 30px; margin-left: 10px; -webkit-text-fill-color: var(--surface); }
|
||||
.nav-right { display: flex; align-items: center; gap: 15px; }
|
||||
.nav-right a { color: var(--text-soft); text-decoration: none; padding: 6px 16px; border-radius: 30px; background: var(--surface-light); transition: 0.2s; }
|
||||
.nav-right a:hover { background: var(--primary); color: white; }
|
||||
.user-menu { position: relative; cursor: pointer; }
|
||||
.user-name { display: flex; align-items: center; gap: 5px; background: var(--surface-light); padding: 6px 16px; border-radius: 30px; color: var(--text); }
|
||||
.dropdown { position: absolute; top: 120%; right: 0; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; min-width: 160px; opacity: 0; visibility: hidden; transform: translateY(-10px); transition: 0.2s; z-index: 100; }
|
||||
.user-menu:hover .dropdown { opacity: 1; visibility: visible; transform: translateY(0); }
|
||||
.dropdown a { display: block; padding: 10px 16px; color: var(--text); text-decoration: none; border-bottom: 1px solid var(--border); }
|
||||
.dropdown a:last-child { border-bottom: none; }
|
||||
.dropdown a:hover { background: var(--surface-light); }
|
||||
.theme-toggle { background: var(--surface-light); border: none; color: var(--text); font-size: 1.3rem; width: 38px; height: 38px; border-radius: 50%; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: 0.2s; }
|
||||
.theme-toggle:hover { background: var(--accent); color: var(--surface); }
|
||||
.success-message, .error-message { padding: 12px 20px; border-radius: 8px; margin-bottom: 20px; }
|
||||
.success-message { background: var(--primary); color: white; }
|
||||
.error-message { background: #ff6b6b; color: white; }
|
||||
.profile-card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 25px; margin-bottom: 30px; display: flex; gap: 25px; flex-wrap: wrap; }
|
||||
.profile-avatar { width: 100px; height: 100px; border-radius: 50%; background: var(--gradient); overflow: hidden; display: flex; align-items: center; justify-content: center; color: white; font-size: 2.5rem; font-weight: 600; flex-shrink: 0; }
|
||||
.profile-avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.profile-info { flex: 1; }
|
||||
.profile-info h1 { font-size: 2rem; margin-bottom: 10px; color: var(--accent); }
|
||||
.profile-info p { color: var(--text-soft); margin-bottom: 5px; }
|
||||
.profile-stats { display: flex; gap: 30px; margin-top: 15px; }
|
||||
.stat-item { text-align: center; }
|
||||
.stat-number { font-size: 1.5rem; font-weight: 700; color: var(--primary); }
|
||||
.stat-label { color: var(--text-hint); font-size: 0.85rem; }
|
||||
.edit-btn { display: inline-block; background: var(--gradient); color: white; border: none; border-radius: 30px; padding: 8px 25px; font-weight: 600; cursor: pointer; transition: 0.2s; margin-top: 15px; text-decoration: none; }
|
||||
.edit-btn:hover { transform: scale(1.02); }
|
||||
.avatar-upload-form { margin-top: 15px; padding: 15px; background: var(--surface-light); border-radius: 12px; border: 1px dashed var(--border); }
|
||||
.file-input { margin-bottom: 10px; }
|
||||
.file-input input[type="file"] { background: var(--surface); border: 1px solid var(--border); border-radius: 30px; padding: 8px 16px; width: 100%; color: var(--text); }
|
||||
.section-title { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||
.section-title h2 { font-size: 1.5rem; font-weight: 600; color: var(--accent); }
|
||||
.post-list { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
|
||||
.post-item { display: flex; padding: 20px; border-bottom: 1px solid var(--border); transition: 0.2s; }
|
||||
.post-item:hover { background: var(--surface-light); }
|
||||
.post-avatar { width: 40px; height: 40px; border-radius: 50%; background: var(--gradient); margin-right: 15px; flex-shrink: 0; overflow: hidden; display: flex; align-items: center; justify-content: center; color: white; font-weight: 600; }
|
||||
.post-avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.post-content { flex: 1; }
|
||||
.post-header { display: flex; align-items: center; gap: 15px; margin-bottom: 5px; flex-wrap: wrap; }
|
||||
.post-author { font-weight: 600; color: var(--accent); }
|
||||
.post-time { color: var(--text-hint); font-size: 0.85rem; }
|
||||
.post-title { font-size: 1.2rem; font-weight: 600; margin-bottom: 5px; }
|
||||
.post-title a { color: var(--text); text-decoration: none; }
|
||||
.post-title a:hover { color: var(--accent); }
|
||||
.post-excerpt { color: var(--text-soft); font-size: 0.95rem; margin-bottom: 10px; }
|
||||
.no-posts { padding: 40px; text-align: center; color: var(--text-hint); }
|
||||
.footer { margin-top: 40px; padding: 20px 0; border-top: 1px solid var(--border); text-align: center; color: var(--text-hint); font-size: 0.9rem; }
|
||||
.footer a { color: var(--text-hint); text-decoration: none; margin: 0 10px; }
|
||||
.footer a:hover { color: var(--accent); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="logo">lv8girl<span>绿坝娘</span></div>
|
||||
<div class="nav-right">
|
||||
{{if .IsLoggedIn}}
|
||||
<a href="/messages">私信{{if gt .UnreadCount 0}}<span style="background:#ff6b6b;color:white;border-radius:50%;padding:2px 6px;font-size:0.7rem;margin-left:5px;">{{.UnreadCount}}</span>{{end}}</a>
|
||||
<div class="user-menu">
|
||||
<span class="user-name">{{.CurrentUsername}} ▼</span>
|
||||
<div class="dropdown">
|
||||
{{if eq .CurrentUserRole "admin"}}<a href="/admin">管理面板</a>{{end}}
|
||||
<a href="/profile">个人主页</a>
|
||||
<a href="/new-post">发表新帖</a>
|
||||
<a href="/messages">私信</a>
|
||||
<a href="/logout">登出</a>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<a href="/login">登录</a>
|
||||
<a href="/register">注册</a>
|
||||
{{end}}
|
||||
<button class="theme-toggle" id="themeToggle">🌓</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="profile-card">
|
||||
<div class="profile-avatar">
|
||||
{{if .UserAvatar}}<img src="{{.UserAvatar}}" alt="avatar">{{else}}<span>{{slice .User.Username 0 1}}</span>{{end}}
|
||||
</div>
|
||||
<div class="profile-info">
|
||||
<h1>{{.User.Username}}</h1>
|
||||
<p>邮箱:{{.User.Email}}</p>
|
||||
<p>注册时间:{{.User.CreatedAt.Format "2006-01-02"}}</p>
|
||||
<div class="profile-stats">
|
||||
<div class="stat-item">
|
||||
<div class="stat-number">{{.PostCount}}</div>
|
||||
<div class="stat-label">帖子</div>
|
||||
</div>
|
||||
</div>
|
||||
{{if .IsOwner}}
|
||||
<button class="edit-btn" onclick="toggleUpload()">修改头像</button>
|
||||
<div id="uploadForm" style="display: none;" class="avatar-upload-form">
|
||||
<form action="/upload-avatar" method="post" enctype="multipart/form-data">
|
||||
<div class="file-input">
|
||||
<input type="file" name="avatar" accept="image/*" required>
|
||||
</div>
|
||||
<button type="submit" class="edit-btn">上传新头像</button>
|
||||
</form>
|
||||
</div>
|
||||
{{else}}
|
||||
<a href="/send-message?to={{.User.ID}}" class="edit-btn" style="margin-top:10px;">📩 发送私信</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-title">
|
||||
<h2>{{if .IsOwner}}我的帖子{{else}}{{.User.Username}}的帖子{{end}}</h2>
|
||||
</div>
|
||||
|
||||
{{if not .Posts}}
|
||||
<div class="no-posts">
|
||||
<p>暂无帖子</p>
|
||||
{{if .IsOwner}}<p><a href="/new-post" style="color: var(--accent);">去发表第一篇</a></p>{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="post-list">
|
||||
{{range .Posts}}
|
||||
<div class="post-item">
|
||||
<div class="post-avatar">
|
||||
{{if $.UserAvatar}}<img src="{{$.UserAvatar}}" alt="avatar">{{else}}<span>{{slice $.User.Username 0 1}}</span>{{end}}
|
||||
</div>
|
||||
<div class="post-content">
|
||||
<div class="post-header">
|
||||
<span class="post-author">{{$.User.Username}}</span>
|
||||
<span class="post-time">{{.CreatedAt.Format "2006-01-02 15:04"}}</span>
|
||||
</div>
|
||||
<div class="post-title"><a href="/post/{{.ID}}">{{.Title}}</a></div>
|
||||
<div class="post-excerpt">{{slice .Content 0 100}}{{if gt (len .Content) 100}}...{{end}}</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="footer">
|
||||
<div>© 2025 lv8girl · 绿坝娘二次元论坛</div>
|
||||
<div>
|
||||
<a href="#">关于</a>
|
||||
<a href="#">帮助</a>
|
||||
<a href="#">隐私</a>
|
||||
<a href="#">投稿</a>
|
||||
<a href="https://icp.gov.moe/?keyword=20260911" target="_blank">萌ICP备20260911号</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const themeToggle = document.getElementById('themeToggle');
|
||||
themeToggle.addEventListener('click', () => {
|
||||
document.body.classList.toggle('dark-mode');
|
||||
themeToggle.textContent = document.body.classList.contains('dark-mode') ? '☀️' : '🌓';
|
||||
});
|
||||
function toggleUpload() {
|
||||
var form = document.getElementById('uploadForm');
|
||||
form.style.display = form.style.display === 'none' ? 'block' : 'none';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
105
templates/register.html
Normal file
105
templates/register.html
Normal file
@@ -0,0 +1,105 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>lv8girl · 注册</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
:root {
|
||||
--bg: #f0f7f0;
|
||||
--surface: #ffffff;
|
||||
--surface-light: #e8f0e8;
|
||||
--border: #d0e0d0;
|
||||
--text: #2c3e2c;
|
||||
--text-soft: #5f6b5f;
|
||||
--text-hint: #8f9f8f;
|
||||
--primary: #3d9e4a;
|
||||
--accent: #ffb347;
|
||||
--gradient: linear-gradient(135deg, #3d9e4a, #5a9cff);
|
||||
}
|
||||
body.dark-mode {
|
||||
--bg: #1a1e1a;
|
||||
--surface: #1e261e;
|
||||
--surface-light: #2a3a2a;
|
||||
--border: #2a3a2a;
|
||||
--text: #e0e8e0;
|
||||
--text-soft: #b0bcb0;
|
||||
--text-hint: #8a958a;
|
||||
--primary: #6b8e6b;
|
||||
--accent: #ffb347;
|
||||
}
|
||||
body { background: var(--bg); color: var(--text); font-family: -apple-system, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif; min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; transition: background 0.3s, color 0.3s; }
|
||||
.register-wrapper { max-width: 420px; width: 100%; }
|
||||
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 30px; }
|
||||
.logo { font-size: 2rem; font-weight: 800; background: var(--gradient); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
.logo span { font-size: 0.9rem; background: var(--accent); color: var(--surface); padding: 4px 12px; border-radius: 30px; margin-left: 10px; -webkit-text-fill-color: var(--surface); }
|
||||
.theme-toggle { background: var(--surface-light); border: none; color: var(--text); font-size: 1.3rem; width: 38px; height: 38px; border-radius: 50%; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: 0.2s; }
|
||||
.theme-toggle:hover { background: var(--accent); color: var(--surface); }
|
||||
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 30px 25px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.04); }
|
||||
.card h2 { text-align: center; margin-bottom: 25px; color: var(--accent); font-weight: 600; }
|
||||
.error-message { background: var(--surface-light); border-left: 4px solid #ff6b6b; color: var(--text-soft); padding: 12px 15px; border-radius: 8px; margin-bottom: 20px; font-size: 0.9rem; }
|
||||
.success-message { background: var(--surface-light); border-left: 4px solid var(--primary); color: var(--text-soft); padding: 12px 15px; border-radius: 8px; margin-bottom: 20px; font-size: 0.9rem; }
|
||||
.form-group { margin-bottom: 20px; }
|
||||
label { display: block; margin-bottom: 8px; color: var(--text-soft); font-size: 0.9rem; }
|
||||
input { width: 100%; padding: 12px 15px; background: var(--surface-light); border: 1px solid var(--border); border-radius: 30px; color: var(--text); font-size: 1rem; outline: none; transition: border 0.2s; }
|
||||
input:focus { border-color: var(--accent); }
|
||||
.checkbox-group { display: flex; align-items: center; gap: 10px; margin-bottom: 20px; }
|
||||
.checkbox-group input[type="checkbox"] { width: 18px; height: 18px; accent-color: var(--primary); }
|
||||
.checkbox-group label { margin-bottom: 0; font-size: 0.9rem; }
|
||||
.checkbox-group a { color: var(--accent); text-decoration: none; }
|
||||
.btn { width: 100%; padding: 14px; background: var(--gradient); border: none; border-radius: 40px; color: white; font-weight: 600; font-size: 1rem; cursor: pointer; transition: transform 0.2s; margin-top: 10px; }
|
||||
.btn:hover { transform: scale(1.02); }
|
||||
.footer-text { text-align: center; margin-top: 25px; color: var(--text-hint); }
|
||||
.footer-text a { color: var(--accent); text-decoration: none; font-weight: 500; }
|
||||
.footer-text a:hover { text-decoration: underline; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="register-wrapper">
|
||||
<div class="header">
|
||||
<div class="logo">lv8girl<span>绿坝娘</span></div>
|
||||
<button class="theme-toggle" id="themeToggle">🌓</button>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>注册</h2>
|
||||
{{if .Error}}<div class="error-message">{{.Error}}</div>{{end}}
|
||||
{{if .Success}}
|
||||
<div class="success-message">{{.Success}}</div>
|
||||
{{else}}
|
||||
<form method="post">
|
||||
<div class="form-group">
|
||||
<label>用户名</label>
|
||||
<input type="text" name="username" placeholder="3-20个字符,支持中文、字母、数字、下划线" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>邮箱</label>
|
||||
<input type="email" name="email" placeholder="请输入有效邮箱" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>密码</label>
|
||||
<input type="password" name="password" placeholder="至少6位" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>确认密码</label>
|
||||
<input type="password" name="confirm_password" placeholder="请再次输入密码" required>
|
||||
</div>
|
||||
<div class="checkbox-group">
|
||||
<input type="checkbox" name="agree" id="agree" required>
|
||||
<label for="agree">我已阅读并同意 <a href="#">用户协议</a> 和 <a href="#">隐私政策</a></label>
|
||||
</div>
|
||||
<button type="submit" class="btn">注 册</button>
|
||||
</form>
|
||||
<div class="footer-text">已有账号? <a href="/login">去登录</a></div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const themeToggle = document.getElementById('themeToggle');
|
||||
themeToggle.addEventListener('click', () => {
|
||||
document.body.classList.toggle('dark-mode');
|
||||
themeToggle.textContent = document.body.classList.contains('dark-mode') ? '☀️' : '🌓';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
84
templates/send_message.html
Normal file
84
templates/send_message.html
Normal file
@@ -0,0 +1,84 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>发送私信 - lv8girl</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
:root {
|
||||
--bg: #f0f7f0;
|
||||
--surface: #ffffff;
|
||||
--surface-light: #e8f0e8;
|
||||
--border: #d0e0d0;
|
||||
--text: #2c3e2c;
|
||||
--text-soft: #5f6b5f;
|
||||
--text-hint: #8f9f8f;
|
||||
--primary: #3d9e4a;
|
||||
--accent: #ffb347;
|
||||
--gradient: linear-gradient(135deg, #3d9e4a, #5a9cff);
|
||||
}
|
||||
body.dark-mode {
|
||||
--bg: #1a1e1a;
|
||||
--surface: #1e261e;
|
||||
--surface-light: #2a3a2a;
|
||||
--border: #2a3a2a;
|
||||
--text: #e0e8e0;
|
||||
--text-soft: #b0bcb0;
|
||||
--text-hint: #8a958a;
|
||||
--primary: #6b8e6b;
|
||||
--accent: #ffb347;
|
||||
}
|
||||
body { background: var(--bg); color: var(--text); font-family: -apple-system, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif; min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; transition: background 0.3s, color 0.3s; }
|
||||
.wrapper { max-width: 600px; width: 100%; }
|
||||
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 30px; }
|
||||
.logo { font-size: 2rem; font-weight: 800; background: var(--gradient); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
.logo span { font-size: 0.9rem; background: var(--accent); color: var(--surface); padding: 4px 12px; border-radius: 30px; margin-left: 10px; -webkit-text-fill-color: var(--surface); }
|
||||
.theme-toggle { background: var(--surface-light); border: none; color: var(--text); font-size: 1.3rem; width: 38px; height: 38px; border-radius: 50%; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: 0.2s; }
|
||||
.theme-toggle:hover { background: var(--accent); color: var(--surface); }
|
||||
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 30px 25px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.04); }
|
||||
.card h2 { text-align: center; margin-bottom: 25px; color: var(--accent); font-weight: 600; }
|
||||
.error-message { background: #ff6b6b; color: white; padding: 10px; border-radius: 8px; margin-bottom: 20px; }
|
||||
.success-message { background: var(--primary); color: white; padding: 10px; border-radius: 8px; margin-bottom: 20px; }
|
||||
.form-group { margin-bottom: 20px; }
|
||||
label { display: block; margin-bottom: 8px; color: var(--text-soft); }
|
||||
textarea { width: 100%; padding: 12px; background: var(--surface-light); border: 1px solid var(--border); border-radius: 12px; color: var(--text); min-height: 150px; resize: vertical; font-size: 1rem; }
|
||||
.btn { background: var(--gradient); border: none; border-radius: 40px; padding: 12px 30px; color: white; font-weight: 600; cursor: pointer; transition: 0.2s; width: 100%; }
|
||||
.btn:hover { transform: scale(1.02); }
|
||||
.footer-links { margin-top: 20px; text-align: center; }
|
||||
.footer-links a { color: var(--text-hint); text-decoration: none; margin: 0 10px; }
|
||||
.footer-links a:hover { color: var(--accent); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrapper">
|
||||
<div class="header">
|
||||
<div class="logo">lv8girl<span>绿坝娘</span></div>
|
||||
<button class="theme-toggle" id="themeToggle">🌓</button>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>发送私信给 {{.Receiver.Username}}</h2>
|
||||
{{if .Error}}<div class="error-message">{{.Error}}</div>{{end}}
|
||||
{{if .Success}}<div class="success-message">{{.Success}}</div>{{end}}
|
||||
<form method="post">
|
||||
<div class="form-group">
|
||||
<label>内容</label>
|
||||
<textarea name="content" placeholder="请输入私信内容..."></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn">发送</button>
|
||||
</form>
|
||||
<div class="footer-links">
|
||||
<a href="/">返回首页</a>
|
||||
<a href="/messages">私信列表</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const themeToggle = document.getElementById('themeToggle');
|
||||
themeToggle.addEventListener('click', () => {
|
||||
document.body.classList.toggle('dark-mode');
|
||||
themeToggle.textContent = document.body.classList.contains('dark-mode') ? '☀️' : '🌓';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user