This commit is contained in:
2026-02-23 23:50:04 +08:00
commit 084d3b0faf
45 changed files with 4090 additions and 0 deletions

8
.idea/.gitignore generated vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
}

View 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
}

View 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, "/")
}

View 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)
}

View 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
}

View 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": "消息已发送!",
})
}

View 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=头像更新成功")
}

View 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
View 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"
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
View 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
}

View 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
View 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()
}

View 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)
}

View 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
View 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
View 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])
}

View 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>

View 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>

View 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>

View 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>

View 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
View 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
View 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
View 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
View 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
View 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>

View 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
View 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
View 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>

View 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>