feat: 门户网站初始提交

- Go + Gin + html/template 服务端渲染
- 主页:Google 风格搜索框 + 导航卡片
- 后台:卡片 CRUD、搜索引擎配置、主页背景/标题配置
- 图片上传:支持 jpg/jpeg/png/gif,自动压缩,缩略图参数 ?thumb=1
- 安全:登录日志、修改密码、IP 自动封禁、IP 白名单
- 访问统计:主页访问/卡片点击/搜索追踪、实时流量、IP 统计
- SQLite 存储(modernc.org/sqlite,纯 Go)
- 内存 Session + bcrypt 密码哈希
This commit is contained in:
2026-05-28 13:54:07 +08:00
commit c16a8dfbc4
42 changed files with 5295 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
# 编译产物
*.exe
portal
/portal
# 依赖
/vendor/
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
Desktop.ini
# 数据库
/data/*.db
/data/*.db-shm
/data/*.db-wal
# 用户上传文件
/data/uploads/
# 测试临时文件
/test/
# WorkBuddy
.workbuddy/
+181
View File
@@ -0,0 +1,181 @@
package database
import (
"database/sql"
"fmt"
"os"
"path/filepath"
_ "modernc.org/sqlite"
)
// DB is the global database connection pointer.
var DB *sql.DB
// InitDB initializes the SQLite database, creates the data directory,
// opens the connection, sets WAL mode, and creates default tables and data.
func InitDB() error {
dbPath := filepath.Join(".", "data", "portal.db")
// Create data directory if not exists
if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil {
return fmt.Errorf("failed to create data directory: %w", err)
}
var err error
DB, err = sql.Open("sqlite", dbPath)
if err != nil {
return fmt.Errorf("failed to open database: %w", err)
}
// SQLite requires max open conns = 1 for safe writes
DB.SetMaxOpenConns(1)
// Enable WAL mode for better concurrent read performance
if _, err := DB.Exec("PRAGMA journal_mode=WAL"); err != nil {
return fmt.Errorf("failed to set WAL mode: %w", err)
}
// Create tables
if err := createTables(); err != nil {
return fmt.Errorf("failed to create tables: %w", err)
}
// Seed default data
if err := seedData(); err != nil {
return fmt.Errorf("failed to seed data: %w", err)
}
return nil
}
// createTables creates the cards, settings, and admins tables.
func createTables() error {
_, err := DB.Exec(`
CREATE TABLE IF NOT EXISTS cards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
icon TEXT,
title TEXT NOT NULL,
subtitle TEXT,
url TEXT NOT NULL,
sort INTEGER DEFAULT 0,
enabled INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT
);
CREATE TABLE IF NOT EXISTS admins (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS login_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
admin_id INTEGER,
username TEXT NOT NULL,
ip TEXT NOT NULL,
user_agent TEXT,
success INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS ip_bans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip TEXT NOT NULL,
reason TEXT,
fail_count INTEGER DEFAULT 0,
banned_until DATETIME NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS ip_whitelist (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip TEXT NOT NULL,
comment TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS access_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip TEXT NOT NULL,
user_agent TEXT,
action_type TEXT NOT NULL,
detail TEXT DEFAULT '',
referer TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_access_logs_ip ON access_logs(ip);
CREATE INDEX IF NOT EXISTS idx_access_logs_action_type ON access_logs(action_type);
CREATE INDEX IF NOT EXISTS idx_access_logs_created_at ON access_logs(created_at);
`)
return err
}
// seedData inserts default admin account and search engine setting if not present.
func seedData() error {
// Insert default admin if not exists
var count int
err := DB.QueryRow("SELECT COUNT(*) FROM admins WHERE username = ?", "admin").Scan(&count)
if err != nil {
return err
}
if count == 0 {
_, err = DB.Exec(
"INSERT INTO admins (username, password) VALUES (?, ?)",
"admin",
"$2a$10$h3Csm2HmWUtvim3MJ8VG0OHx/tevZorlUXQVDtN2EgWhROtiM3Sg.", // bcrypt hash for "admin123"
)
if err != nil {
return err
}
}
// Insert default search engine setting if not exists
var settingCount int
err = DB.QueryRow("SELECT COUNT(*) FROM settings WHERE key = ?", "search_engine").Scan(&settingCount)
if err != nil {
return err
}
if settingCount == 0 {
_, err = DB.Exec(
"INSERT INTO settings (key, value) VALUES (?, ?)",
"search_engine",
"https://www.google.com/search?q=%s",
)
if err != nil {
return err
}
}
// Insert default homepage settings if not exists
defaultSettings := []struct {
key string
value string
}{
{"homepage_title", "Portal"},
{"homepage_subtitle", ""},
{"homepage_background", ""},
}
for _, s := range defaultSettings {
var cnt int
err = DB.QueryRow("SELECT COUNT(*) FROM settings WHERE key = ?", s.key).Scan(&cnt)
if err != nil {
return err
}
if cnt == 0 {
_, err = DB.Exec("INSERT INTO settings (key, value) VALUES (?, ?)", s.key, s.value)
if err != nil {
return err
}
}
}
return nil
}
// CloseDB closes the database connection.
func CloseDB() error {
if DB != nil {
return DB.Close()
}
return nil
}
+108
View File
@@ -0,0 +1,108 @@
classDiagram
direction LR
class Card {
+int ID
+string Icon
+string Title
+string Subtitle
+string URL
+int Sort
+bool Enabled
+time Time CreatedAt
}
class Setting {
+string Key
+string Value
}
class Admin {
+int ID
+string Username
+string Password
}
class SessionData {
+int AdminID
+string Username
+time Time CreatedAt
}
class SessionStore {
-map~string-SessionData~ store
-sync.RWMutex mu
+Create(adminID int, username string) string
+Get(sessionID string) *SessionData
+Delete(sessionID string)
}
class CardModel {
+GetAllCards() []Card
+GetEnabledCards() []Card
+GetCardByID(id int) *Card
+CreateCard(card *Card) error
+UpdateCard(card *Card) error
+DeleteCard(id int) error
+ToggleCard(id int) error
+MoveCardUp(id int) error
+MoveCardDown(id int) error
}
class SettingModel {
+GetSetting(key string) string
+SetSetting(key string, value string) error
+GetSearchEngines() map~string-string
+SetSearchEngine(name string, url string) error
}
class AdminModel {
+GetAdminByUsername(username string) *Admin
+CreateAdmin(username string, password string) error
+VerifyPassword(username string, password string) bool
+ChangePassword(adminID int, newPassword string) error
}
class HandlerHome {
+HomeHandler(c *gin.Context)
}
class HandlerAdmin {
+LoginGet(c *gin.Context)
+LoginPost(c *gin.Context)
+Logout(c *gin.Context)
+AdminIndex(c *gin.Context)
}
class HandlerCards {
+CardsList(c *gin.Context)
+CardCreateGet(c *gin.Context)
+CardCreatePost(c *gin.Context)
+CardEditGet(c *gin.Context)
+CardEditPost(c *gin.Context)
+CardDelete(c *gin.Context)
+CardToggle(c *gin.Context)
+CardMoveUp(c *gin.Context)
+CardMoveDown(c *gin.Context)
}
class HandlerSettings {
+SettingsGet(c *gin.Context)
+SettingsPost(c *gin.Context)
}
class AuthMiddleware {
+AuthRequired() gin.HandlerFunc
}
CardModel --> Card : returns
SettingModel --> Setting : returns
AdminModel --> Admin : returns
SessionStore --> SessionData : stores
HandlerHome --> CardModel : uses
HandlerHome --> SettingModel : uses
HandlerAdmin --> AdminModel : uses
HandlerAdmin --> SessionStore : uses
HandlerCards --> CardModel : uses
HandlerSettings --> SettingModel : uses
AuthMiddleware --> SessionStore : uses
+53
View File
@@ -0,0 +1,53 @@
sequenceDiagram
participant U as 管理员浏览器
participant G as Gin Router
participant MW as AuthMiddleware
participant H as HandlerCards
participant CM as CardModel
Note over U,CM: 卡片列表
U->>G: GET /admin/cards
G->>MW: AuthRequired()
MW->>MW: 检查 Cookie session_id
alt 未登录
MW-->>U: 302 → /admin/login
else 已登录
MW->>H: CardsList(c)
H->>CM: GetAllCards()
CM-->>H: []Card
H-->>U: 卡片列表 HTML
end
Note over U,CM: 新增卡片
U->>G: GET /admin/cards/new
G->>H: CardCreateGet(c)
H-->>U: 空表单 HTML
U->>G: POST /admin/cards {icon, title, subtitle, url}
G->>H: CardCreatePost(c)
H->>CM: CreateCard(&Card{...})
CM->>CM: INSERT INTO cards(...)
CM-->>H: nil
H-->>U: 302 Redirect → /admin/cards
Note over U,CM: 编辑卡片
U->>G: GET /admin/cards/:id/edit
G->>H: CardEditGet(c)
H->>CM: GetCardByID(id)
CM-->>H: *Card
H-->>U: 预填表单 HTML
U->>G: POST /admin/cards/:id {icon, title, subtitle, url}
G->>H: CardEditPost(c)
H->>CM: UpdateCard(&Card{...})
CM->>CM: UPDATE cards SET ... WHERE id=?
CM-->>H: nil
H-->>U: 302 Redirect → /admin/cards
Note over U,CM: 删除卡片
U->>G: POST /admin/cards/:id/delete
G->>H: CardDelete(c)
H->>CM: DeleteCard(id)
CM->>CM: DELETE FROM cards WHERE id=?
CM-->>H: nil
H-->>U: 302 Redirect → /admin/cards
+18
View File
@@ -0,0 +1,18 @@
sequenceDiagram
participant U as 用户浏览器
participant G as Gin Router
participant H as HomeHandler
participant CM as CardModel
participant SM as SettingModel
participant T as html/template
U->>G: GET /
G->>H: HomeHandler(c)
H->>CM: GetEnabledCards()
CM->>CM: SELECT * FROM cards WHERE enabled=1 ORDER BY sort
CM-->>H: []Card
H->>SM: GetSetting("search_engine")
SM->>SM: SELECT value FROM settings WHERE key='search_engine'
SM-->>H: search URL template
H->>T: ExecuteTemplate("home.html", data)
T-->>U: HTML (搜索框 + 卡片网格)
+25
View File
@@ -0,0 +1,25 @@
sequenceDiagram
participant U as 用户浏览器
participant G as Gin Router
participant H as HandlerAdmin
participant AM as AdminModel
participant SS as SessionStore
U->>G: GET /admin/login
G->>H: LoginGet(c)
H-->>U: 登录页 HTML
U->>G: POST /admin/login {username, password}
G->>H: LoginPost(c)
H->>AM: VerifyPassword(username, password)
AM->>AM: bcrypt.CompareHashAndPassword()
AM-->>H: true/false
alt 验证成功
H->>SS: Create(adminID, username)
SS-->>H: sessionID
H->>H: c.SetCookie("session_id", sessionID, 86400, "/", "", false, true)
H-->>U: 302 Redirect → /admin
else 验证失败
H-->>U: 200 + 登录页(错误提示)
end
+10
View File
@@ -0,0 +1,10 @@
sequenceDiagram
participant U as 用户浏览器
participant JS as 前端 JavaScript
participant SE as 搜索引擎
U->>JS: 输入关键词,按回车/点击搜索
JS->>JS: 读取隐藏域 search_url_template
JS->>JS: fmt.Sprintf(template, query)
JS->>U: window.location.href = 拼接后的URL
U->>SE: HTTP 请求到搜索引擎
+673
View File
@@ -0,0 +1,673 @@
# Simple Portal — 系统架构设计文档
## Part A: 系统设计
---
### 1. 实现方案分析
#### 核心技术挑战
| 挑战 | 解决方案 |
|------|---------|
| 纯服务端渲染,无 SPA 路由 | Gin 路由 + html/template 模板继承(layout 嵌套子模板),表单 POST 提交后 302 重定向 |
| SQLite 并发写入 | 单文件 SQLite + WAL 模式,Go 侧使用全局 `*sql.DB` 连接池(`SetMaxOpenConns(1)` 保证串行写) |
| Session 管理(无需外部依赖) | 内存 `map[string]*Session` + `sync.RWMutex`Cookie 存储 session ID,重启失效可接受 |
| 卡片排序 | `sort` 字段整数排序,上移/下移通过交换相邻卡片的 sort 值实现 |
| 搜索引擎占位符 | settings 表存储引擎 URL 模板(`%s` 占位符),前端 JS 拼接跳转,后端验证模板合法性 |
#### 框架与库选型
| 组件 | 选型 | 理由 |
|------|------|------|
| HTTP 框架 | `github.com/gin-gonic/gin` | PRD 指定 |
| SQLite 驱动 | `modernc.org/sqlite` | 纯 Go 实现,无需 CGO,交叉编译友好 |
| 密码哈希 | `golang.org/x/crypto/bcrypt` | PRD 指定,工业标准 |
| 模板引擎 | `html/template` | PRD 指定,Go 原生 |
| CSS | 单文件 `style.css` | PRD 约束,无 CSS 框架 |
#### 架构模式
采用 **经典 MVC 分层**
```
Request → Gin Router → Middleware(auth) → Handler → Model → SQLite
Template Rendering → HTML Response
```
- **Model**:纯数据访问层,封装 SQL 操作
- **Handler**Controller):处理 HTTP 请求,调用 Model,渲染模板
- **Template**View):html/template 模板文件
- **Middleware**:鉴权拦截,未登录重定向到登录页
- **Session**:独立包,提供 Create/Get/Delete 操作
---
### 2. 文件列表
```
Portal_page/
├── main.go # 程序入口:初始化 DB、路由、启动服务
├── go.mod # Go 模块定义
├── go.sum # 依赖校验
├── database/
│ └── db.go # DB 初始化、表创建、全局 *sql.DB
├── models/
│ ├── card.go # Card CRUD + 排序操作
│ ├── setting.go # Setting 读写(搜索引擎配置等)
│ └── admin.go # Admin 用户操作 + 密码验证
├── handlers/
│ ├── home.go # 主页渲染(卡片列表 + 搜索框)
│ ├── admin.go # 登录/登出 + 后台首页
│ ├── cards.go # 卡片增删改查 + 启用/禁用 + 排序
│ └── settings.go # 搜索引擎配置页面 + 保存
├── middleware/
│ └── auth.go # 登录鉴权中间件
├── session/
│ └── session.go # 内存 Session 管理
├── templates/
│ ├── layout.html # 基础布局(head/nav/footer
│ ├── home.html # 主页模板
│ └── admin/
│ ├── login.html # 登录页
│ ├── index.html # 后台首页(仪表盘)
│ ├── cards.html # 卡片列表管理
│ ├── card_form.html # 卡片新增/编辑表单
│ └── settings.html # 搜索引擎设置
└── static/
└── style.css # 全局样式
```
#### 各文件职责说明
| 文件 | 职责 |
|------|------|
| `main.go` | 加载配置、初始化 DB、注册路由和中间件、启动 HTTP 服务 |
| `database/db.go` | `InitDB()` 打开 SQLite 连接、建表、插入默认管理员和默认搜索引擎配置 |
| `models/card.go` | `Card` struct`GetAllCards()`/`GetEnabledCards()`/`CreateCard()`/`UpdateCard()`/`DeleteCard()`/`ToggleCard()`/`MoveCardUp()`/`MoveCardDown()` |
| `models/setting.go` | `Setting` struct`GetSetting(key)`/`SetSetting(key, value)`;预定义搜索引擎常量 |
| `models/admin.go` | `Admin` struct`GetAdminByUsername()`/`CreateAdmin()`/`VerifyPassword()`/`ChangePassword()` |
| `handlers/home.go` | `HomeHandler`:查询启用卡片 + 当前搜索引擎配置,渲染主页 |
| `handlers/admin.go` | `LoginGet`/`LoginPost`/`Logout`/`AdminIndex`:登录表单、认证、登出、后台首页 |
| `handlers/cards.go` | `CardsList`/`CardCreate`/`CardEdit`/`CardDelete`/`CardToggle`/`CardMoveUp`/`CardMoveDown` |
| `handlers/settings.go` | `SettingsGet`/`SettingsPost`:展示/保存搜索引擎配置 |
| `middleware/auth.go` | `AuthRequired()`:检查 Cookie 中的 session_id,未登录则 302 到 /admin/login |
| `session/session.go` | `SessionStore`:内存 map + RWMutex`Create()/Get()/Delete()` |
| `templates/layout.html` | HTML 骨架:`{{define "layout"}}...{{template "content" .}}{{end}}` |
| `templates/home.html` | 搜索框 + 卡片网格 |
| `templates/admin/*.html` | 后台各页面 |
| `static/style.css` | 全局 CSS:卡片网格、搜索框、表单、后台布局 |
---
### 3. 数据结构与接口
```mermaid
classDiagram
direction LR
class Card {
+int ID
+string Icon
+string Title
+string Subtitle
+string URL
+int Sort
+bool Enabled
+time Time CreatedAt
}
class Setting {
+string Key
+string Value
}
class Admin {
+int ID
+string Username
+string Password
}
class SessionData {
+int AdminID
+string Username
+time Time CreatedAt
}
class SessionStore {
-map~string-SessionData~ store
-sync.RWMutex mu
+Create(adminID int, username string) string
+Get(sessionID string) *SessionData
+Delete(sessionID string)
}
class CardModel {
+GetAllCards() []Card
+GetEnabledCards() []Card
+GetCardByID(id int) *Card
+CreateCard(card *Card) error
+UpdateCard(card *Card) error
+DeleteCard(id int) error
+ToggleCard(id int) error
+MoveCardUp(id int) error
+MoveCardDown(id int) error
}
class SettingModel {
+GetSetting(key string) string
+SetSetting(key string, value string) error
+GetSearchEngines() map~string-string
+SetSearchEngine(name string, url string) error
}
class AdminModel {
+GetAdminByUsername(username string) *Admin
+CreateAdmin(username string, password string) error
+VerifyPassword(username string, password string) bool
+ChangePassword(adminID int, newPassword string) error
}
class HandlerHome {
+HomeHandler(c *gin.Context)
}
class HandlerAdmin {
+LoginGet(c *gin.Context)
+LoginPost(c *gin.Context)
+Logout(c *gin.Context)
+AdminIndex(c *gin.Context)
}
class HandlerCards {
+CardsList(c *gin.Context)
+CardCreateGet(c *gin.Context)
+CardCreatePost(c *gin.Context)
+CardEditGet(c *gin.Context)
+CardEditPost(c *gin.Context)
+CardDelete(c *gin.Context)
+CardToggle(c *gin.Context)
+CardMoveUp(c *gin.Context)
+CardMoveDown(c *gin.Context)
}
class HandlerSettings {
+SettingsGet(c *gin.Context)
+SettingsPost(c *gin.Context)
}
class AuthMiddleware {
+AuthRequired() gin.HandlerFunc
}
CardModel --> Card : returns
SettingModel --> Setting : returns
AdminModel --> Admin : returns
SessionStore --> SessionData : stores
HandlerHome --> CardModel : uses
HandlerHome --> SettingModel : uses
HandlerAdmin --> AdminModel : uses
HandlerAdmin --> SessionStore : uses
HandlerCards --> CardModel : uses
HandlerSettings --> SettingModel : uses
AuthMiddleware --> SessionStore : uses
```
#### 路由表
| 方法 | 路径 | Handler | 中间件 | 说明 |
|------|------|---------|--------|------|
| GET | `/` | HomeHandler | — | 主页 |
| GET | `/admin/login` | LoginGet | — | 登录页 |
| POST | `/admin/login` | LoginPost | — | 登录提交 |
| POST | `/admin/logout` | Logout | AuthRequired | 登出 |
| GET | `/admin` | AdminIndex | AuthRequired | 后台首页 |
| GET | `/admin/cards` | CardsList | AuthRequired | 卡片列表 |
| GET | `/admin/cards/new` | CardCreateGet | AuthRequired | 新增卡片表单 |
| POST | `/admin/cards` | CardCreatePost | AuthRequired | 新增卡片提交 |
| GET | `/admin/cards/:id/edit` | CardEditGet | AuthRequired | 编辑卡片表单 |
| POST | `/admin/cards/:id` | CardEditPost | AuthRequired | 编辑卡片提交 |
| POST | `/admin/cards/:id/delete` | CardDelete | AuthRequired | 删除卡片 |
| POST | `/admin/cards/:id/toggle` | CardToggle | AuthRequired | 启用/禁用卡片 |
| POST | `/admin/cards/:id/move-up` | CardMoveUp | AuthRequired | 卡片上移 |
| POST | `/admin/cards/:id/move-down` | CardMoveDown | AuthRequired | 卡片下移 |
| GET | `/admin/settings` | SettingsGet | AuthRequired | 搜索引擎设置页 |
| POST | `/admin/settings` | SettingsPost | AuthRequired | 保存搜索引擎设置 |
---
### 4. 程序调用流程
#### 4.1 主页加载
```mermaid
sequenceDiagram
participant U as 用户浏览器
participant G as Gin Router
participant H as HomeHandler
participant CM as CardModel
participant SM as SettingModel
participant T as html/template
U->>G: GET /
G->>H: HomeHandler(c)
H->>CM: GetEnabledCards()
CM->>CM: SELECT * FROM cards WHERE enabled=1 ORDER BY sort
CM-->>H: []Card
H->>SM: GetSetting("search_engine")
SM->>SM: SELECT value FROM settings WHERE key='search_engine'
SM-->>H: search URL template
H->>T: ExecuteTemplate("home.html", data)
T-->>U: HTML (搜索框 + 卡片网格)
```
#### 4.2 搜索跳转
```mermaid
sequenceDiagram
participant U as 用户浏览器
participant JS as 前端 JavaScript
participant SE as 搜索引擎
U->>JS: 输入关键词,按回车/点击搜索
JS->>JS: 读取隐藏域 search_url_template
JS->>JS: fmt.Sprintf(template, query)
JS->>U: window.location.href = 拼接后的URL
U->>SE: HTTP 请求到搜索引擎
```
> 注意:搜索跳转纯前端 JS 完成,不经过后端路由。
#### 4.3 后台登录
```mermaid
sequenceDiagram
participant U as 用户浏览器
participant G as Gin Router
participant H as HandlerAdmin
participant AM as AdminModel
participant SS as SessionStore
U->>G: GET /admin/login
G->>H: LoginGet(c)
H-->>U: 登录页 HTML
U->>G: POST /admin/login {username, password}
G->>H: LoginPost(c)
H->>AM: VerifyPassword(username, password)
AM->>AM: bcrypt.CompareHashAndPassword()
AM-->>H: true/false
alt 验证成功
H->>SS: Create(adminID, username)
SS-->>H: sessionID
H->>H: c.SetCookie("session_id", sessionID, 86400, "/", "", false, true)
H-->>U: 302 Redirect → /admin
else 验证失败
H-->>U: 200 + 登录页(错误提示)
end
```
#### 4.4 卡片增删改查
```mermaid
sequenceDiagram
participant U as 管理员浏览器
participant G as Gin Router
participant MW as AuthMiddleware
participant H as HandlerCards
participant CM as CardModel
Note over U,CM: 卡片列表
U->>G: GET /admin/cards
G->>MW: AuthRequired()
MW->>MW: 检查 Cookie session_id
alt 未登录
MW-->>U: 302 → /admin/login
else 已登录
MW->>H: CardsList(c)
H->>CM: GetAllCards()
CM-->>H: []Card
H-->>U: 卡片列表 HTML
end
Note over U,CM: 新增卡片
U->>G: GET /admin/cards/new
G->>H: CardCreateGet(c)
H-->>U: 空表单 HTML
U->>G: POST /admin/cards {icon, title, subtitle, url}
G->>H: CardCreatePost(c)
H->>CM: CreateCard(&Card{...})
CM->>CM: INSERT INTO cards(...)
CM-->>H: nil
H-->>U: 302 Redirect → /admin/cards
Note over U,CM: 编辑卡片
U->>G: GET /admin/cards/:id/edit
G->>H: CardEditGet(c)
H->>CM: GetCardByID(id)
CM-->>H: *Card
H-->>U: 预填表单 HTML
U->>G: POST /admin/cards/:id {icon, title, subtitle, url}
G->>H: CardEditPost(c)
H->>CM: UpdateCard(&Card{...})
CM->>CM: UPDATE cards SET ... WHERE id=?
CM-->>H: nil
H-->>U: 302 Redirect → /admin/cards
Note over U,CM: 删除卡片
U->>G: POST /admin/cards/:id/delete
G->>H: CardDelete(c)
H->>CM: DeleteCard(id)
CM->>CM: DELETE FROM cards WHERE id=?
CM-->>H: nil
H-->>U: 302 Redirect → /admin/cards
```
#### 4.5 卡片排序(上移/下移)
```mermaid
sequenceDiagram
participant U as 管理员浏览器
participant G as Gin Router
participant H as HandlerCards
participant CM as CardModel
U->>G: POST /admin/cards/:id/move-up
G->>H: CardMoveUp(c)
H->>CM: MoveCardUp(id)
CM->>CM: 获取当前卡片 sort 值
CM->>CM: 获取 sort 值仅次于当前卡片的上一张卡片
CM->>CM: 交换两者 sort 值 (UPDATE)
CM-->>H: nil
H-->>U: 302 Redirect → /admin/cards
Note over U,CM: 下移同理,方向相反
```
#### 4.6 卡片启用/禁用
```mermaid
sequenceDiagram
participant U as 管理员浏览器
participant G as Gin Router
participant H as HandlerCards
participant CM as CardModel
U->>G: POST /admin/cards/:id/toggle
G->>H: CardToggle(c)
H->>CM: ToggleCard(id)
CM->>CM: UPDATE cards SET enabled = CASE WHEN enabled=1 THEN 0 ELSE 1 END WHERE id=?
CM-->>H: nil
H-->>U: 302 Redirect → /admin/cards
```
#### 4.7 搜索引擎配置
```mermaid
sequenceDiagram
participant U as 管理员浏览器
participant G as Gin Router
participant H as HandlerSettings
participant SM as SettingModel
U->>G: GET /admin/settings
G->>H: SettingsGet(c)
H->>SM: GetSetting("search_engine")
SM-->>H: 当前搜索引擎 URL 模板
H-->>U: 设置页 HTML(预设选项 + 自定义输入框)
U->>G: POST /admin/settings {engine: "google" | "bing" | "baidu" | "custom", custom_url: "..."}
G->>H: SettingsPost(c)
H->>H: 根据选项确定 URL 模板
alt 预设引擎
H->>H: 映射到固定 URL 模板
else 自定义
H->>H: 使用 custom_url,验证含 %s
end
H->>SM: SetSetting("search_engine", urlTemplate)
SM->>SM: INSERT OR REPLACE INTO settings(key, value) VALUES(...)
SM-->>H: nil
H-->>U: 302 Redirect → /admin/settings
```
---
### 5. 不确定事项与假设
| 编号 | 问题 | 假设/默认决策 |
|------|------|--------------|
| UNC-1 | 管理员是否支持多账号? | PRD 已定:初始 admin/admin123 硬编码,暂不考虑多账号管理 UI,但数据模型支持 |
| UNC-2 | 搜索引擎配置是否需要多个? | 当前仅支持配置一个默认搜索引擎,settings 表 key=`search_engine` |
| UNC-3 | 卡片图标是否需要文件上传? | PRD 已定:仅支持 emoji 文字和 favicon URL,通过文本输入框填写 |
| UNC-4 | CSRF 防护 | 简易项目暂不实现 CSRF Token(所有写操作需登录 + Cookie HttpOnly |
| UNC-5 | HTTPS | 不在应用层处理,由部署环境(反向代理)负责 |
| UNC-6 | 端口配置 | 默认监听 `:8080`,可通过环境变量 `PORT` 覆盖 |
---
## Part B: 任务分解
---
### 6. 依赖包列表
```
github.com/gin-gonic/gin@v1.10.0 # HTTP 框架
modernc.org/sqlite@latest # 纯 Go SQLite 驱动
golang.org/x/crypto@latest # bcrypt 密码哈希
```
> 仅 3 个直接依赖,保持极简。
---
### 7. 任务列表
#### T01: 项目基础设施
**说明**:创建项目骨架,包括模块初始化、数据库初始化、会话管理、全局布局模板、静态文件服务和程序入口。
**源文件**
- `go.mod`, `go.sum`
- `main.go`
- `database/db.go`
- `session/session.go`
- `middleware/auth.go`
- `templates/layout.html`
- `static/style.css`(基础框架样式:布局、导航、表单通用样式)
**具体内容**
1. `go mod init` + 添加依赖
2. `database/db.go``InitDB()` — 打开 SQLite 文件、建表(cards/settings/admins)、插入默认管理员(admin/admin123 bcrypt hash)、插入默认搜索引擎(Google)
3. `session/session.go``SessionStore` 结构体 + `Create/Get/Delete` 方法
4. `middleware/auth.go``AuthRequired()` 中间件,从 Cookie 读取 session_id,校验失败 302 到 `/admin/login`
5. `templates/layout.html`HTML 骨架(`{{define "layout"}}` 含 head/nav/footer`{{template "content" .}}` 插槽)
6. `static/style.css`:CSS 变量定义、reset、布局容器、导航栏、表单基础样式
7. `main.go`:初始化 DB、创建 SessionStore、注册所有路由(此阶段 handler 只返回空字符串占位)、启动服务
**依赖**:无
**优先级**P0
---
#### T02: 数据模型层
**说明**:实现所有数据模型的 CRUD 操作,与 SQLite 交互。
**源文件**
- `models/card.go`
- `models/setting.go`
- `models/admin.go`
**具体内容**
1. `models/card.go`
- `Card` struct(映射 cards 表)
- `GetAllCards()` — 查询全部卡片(后台用),按 sort ASC
- `GetEnabledCards()` — 仅查询启用的卡片(主页用),按 sort ASC
- `GetCardByID(id int)` — 单卡片查询
- `CreateCard(card *Card)` — INSERTsort 自动取 MAX(sort)+1
- `UpdateCard(card *Card)` — UPDATE by ID
- `DeleteCard(id int)` — DELETE by ID
- `ToggleCard(id int)` — 切换 enabled 字段
- `MoveCardUp(id int)` — 交换 sort 值上移
- `MoveCardDown(id int)` — 交换 sort 值下移
2. `models/setting.go`
- `Setting` struct
- 预定义搜索引擎常量:`SearchEngineGoogle = "https://www.google.com/search?q=%s"`
- `GetSetting(key string) (string, error)`
- `SetSetting(key, value string) error`
3. `models/admin.go`
- `Admin` struct
- `GetAdminByUsername(username string) (*Admin, error)`
- `CreateAdmin(username, hashedPassword string) error`
- `VerifyPassword(username, password string) (bool, *Admin, error)` — bcrypt 校验
- `ChangePassword(adminID int, newHashedPassword string) error`
**依赖**T01(需要 `database/db.go` 中初始化的 `*sql.DB`
**优先级**P0
---
#### T03: 后台管理 — 登录 + 卡片管理
**说明**:实现后台核心功能:管理员登录/登出、卡片 CRUD、启用/禁用、排序。
**源文件**
- `handlers/admin.go`
- `handlers/cards.go`
- `templates/admin/login.html`
- `templates/admin/index.html`
- `templates/admin/cards.html`
- `templates/admin/card_form.html`
**具体内容**
1. `handlers/admin.go`
- `LoginGet` — 渲染登录页
- `LoginPost` — 表单验证 → `AdminModel.VerifyPassword()` → 创建 Session → 写 Cookie → 302 到 `/admin`
- `Logout` — 删除 Session → 清 Cookie → 302 到 `/admin/login`
- `AdminIndex` — 渲染后台首页
2. `handlers/cards.go`
- `CardsList` — 调用 `GetAllCards()`,渲染列表页
- `CardCreateGet` / `CardCreatePost` — 新增卡片
- `CardEditGet` / `CardEditPost` — 编辑卡片
- `CardDelete` — 删除卡片
- `CardToggle` — 切换启用/禁用
- `CardMoveUp` / `CardMoveDown` — 排序
3. 模板文件:登录页、后台首页、卡片列表(含操作按钮)、卡片表单(新增/编辑共用)
**依赖**:T01(路由和中间件)、T02(数据模型)
**优先级**P0
---
#### T04: 主页 + 搜索引擎配置
**说明**:实现面向用户的主页(搜索框 + 导航卡片)和后台搜索引擎配置。
**源文件**
- `handlers/home.go`
- `handlers/settings.go`
- `templates/home.html`
- `templates/admin/settings.html`
**具体内容**
1. `handlers/home.go`
- `HomeHandler` — 获取启用卡片 + 当前搜索引擎 URL 模板,渲染主页
- 搜索框前端 JS:读取隐藏域中的 URL 模板,拼接 `%s` 占位符,`window.location.href` 跳转
- 搜索框自动聚焦(`autofocus` 属性)
2. `handlers/settings.go`
- `SettingsGet` — 读取当前配置,渲染设置页
- `SettingsPost` — 接收引擎选择(预设/自定义),自定义 URL 需包含 `%s`,保存到 settings 表
3. `templates/home.html`:搜索框(大尺寸居中)+ 卡片网格(图标/标题/副标题,可点击跳转)
4. `templates/admin/settings.html`:预设引擎单选 + 自定义 URL 输入
**依赖**T01、T02
**优先级**P0
---
#### T05: 样式完善 + 集成调试
**说明**:完善所有页面 CSS 样式,端到端集成测试,修复问题。
**源文件**
- `static/style.css`(补充完善所有页面样式)
- `templates/layout.html`(可能微调)
- `main.go`(最终路由确认)
**具体内容**
1. 完善主页样式:卡片网格(CSS Grid/Flexbox)、hover 效果、搜索框美观
2. 完善后台样式:表单对齐、按钮样式、卡片操作按钮布局
3. 响应式基础:卡片网格自适应列数
4. 端到端手动验证:
- 首次启动 → 默认管理员 → 登录成功
- 新增/编辑/删除/排序卡片 → 主页正确展示
- 切换搜索引擎 → 主页搜索跳转正确
- 未登录访问后台 → 重定向到登录页
5. 修复发现的问题
**依赖**T03、T04
**优先级**P1
---
### 8. 跨文件共享约定
```
- 全局 *sql.DB 实例:通过 database 包导出的 DB 变量访问,models 包直接引用 database.DB
- Session Cookie 名称:"session_id"HttpOnly=trueSameSite=LaxMaxAge=8640024小时)
- Session Store 实例:main.go 中创建,通过闭包注入到 middleware 和 handlers
- 模板数据传递:所有 handler 通过 gin.H 传递,模板中统一使用 .Cards / .Card / .SearchEngine / .Error / .Message
- 模板继承:所有页面通过 {{template "layout" .}} 包裹,子页面 {{define "content"}}...{{end}}
- 错误处理:handler 层 c.HTML(500, ...) 或 302 重定向,不向用户暴露内部错误
- 表单提交:所有写操作使用 POST + 302 重定向(PRG 模式),防止重复提交
- URL 模板占位符:%sGo fmt.Sprintf 兼容)
- 静态文件路径:/static/ → ./static/gin.Static
- SQLite 文件路径:./data/portal.db(自动创建 data 目录)
- 卡片 sort 值:数字越小越靠前,新建卡片 sort=MAX(sort)+1
- 搜索引擎配置 key"search_engine"value 为完整 URL 模板
- 管理员默认账号:admin / admin123bcrypt hash 存储于 DB
- 所有日期字段使用 SQLite CURRENT_TIMESTAMPRFC 3339 格式)
- 后台路由统一前缀:/admin/
- 模板文件路径:相对于项目根目录的 templates/ 目录
- CSS 类命名:BEM 风格简化版,如 .card-grid / .card-item / .card-item__icon / .search-box
```
---
### 9. 任务依赖图
```mermaid
graph TD
T01[T01: 项目基础设施<br/>go.mod, main.go, db, session,<br/>middleware, layout, style.css]
T02[T02: 数据模型层<br/>models/card.go, setting.go, admin.go]
T03[T03: 后台管理<br/>handlers/admin.go, cards.go,<br/>templates/admin/*]
T04[T04: 主页 + 搜索引擎配置<br/>handlers/home.go, settings.go,<br/>templates/home.html, admin/settings.html]
T05[T05: 样式完善 + 集成调试<br/>style.css 补充, 全流程验证]
T01 --> T02
T01 --> T03
T01 --> T04
T02 --> T03
T02 --> T04
T03 --> T05
T04 --> T05
```
> T03 和 T04 可并行开发(均依赖 T01 + T02),最终汇聚到 T05 集成。
+46
View File
@@ -0,0 +1,46 @@
module simple_portal
go 1.22
require (
github.com/disintegration/imaging v1.6.2
github.com/gin-gonic/gin v1.10.0
github.com/google/uuid v1.6.0
golang.org/x/crypto v0.28.0
modernc.org/sqlite v1.34.5
)
require (
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // 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/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.2 // 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/ncruces/go-strftime v0.1.9 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // 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.8.0 // indirect
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/sys v0.26.0 // indirect
golang.org/x/text v0.19.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.55.7 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
)
+134
View File
@@ -0,0 +1,134 @@
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
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/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
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/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.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
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.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.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/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
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/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/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-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.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8=
golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
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.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg=
golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/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=
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.20.5 h1:s04akhT2dysD0DFOlv9fkQ6oUTLPYgMnnDk9oaqjszM=
modernc.org/ccgo/v4 v4.20.5/go.mod h1:fYXClPUMWxWaz1Xj5sHbzW/ZENEFeuHLToqBxUk41nE=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.4.3 h1:Ik4ZcMbC7aY4ZDPUhzXVXi7GMub9QcXLTfXn3mWpNw8=
modernc.org/gc/v2 v2.4.3/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/libc v1.55.7 h1:/5PMGAF3tyZhK72WpoqeLNtgUUpYMrnhT+Gm/5tVDgs=
modernc.org/libc v1.55.7/go.mod h1:JXguUpMkbw1gknxspNE9XaG+kk9hDAAnBxpA6KGLiyA=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g=
modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
+5
View File
@@ -0,0 +1,5 @@
package handlers
// admin.go currently has no additional handlers.
// LoginGet, LoginPost, Logout, and AdminIndex are defined in home.go
// for convenience since they share the session store dependency.
+46
View File
@@ -0,0 +1,46 @@
package handlers
import (
"net/http"
"strconv"
"simple_portal/models"
"github.com/gin-gonic/gin"
)
// AccessLogsGet 渲染访问日志页面,支持按IP和动作类型筛选。
func AccessLogsGet(c *gin.Context) {
username, _ := c.Get("username")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
page = 1
}
pageSize := 30
filterIP := c.Query("ip")
filterAction := c.DefaultQuery("action", "")
logs, total, err := models.GetAccessLogs(page, pageSize, filterIP, filterAction)
if err != nil {
logs = []models.AccessLog{}
total = 0
}
totalPages := (total + pageSize - 1) / pageSize
if totalPages < 1 {
totalPages = 1
}
c.HTML(http.StatusOK, "admin/access_logs.html", gin.H{
"Title": "访问日志",
"Username": username,
"Logs": logs,
"Page": page,
"TotalPages": totalPages,
"Total": total,
"FilterIP": filterIP,
"FilterAction": filterAction,
})
}
+197
View File
@@ -0,0 +1,197 @@
package handlers
import (
"net/http"
"strconv"
"simple_portal/models"
"github.com/gin-gonic/gin"
)
// CardsList renders the admin cards management page.
func CardsList(c *gin.Context) {
username, _ := c.Get("username")
cards, err := models.GetAllCards()
if err != nil {
c.HTML(http.StatusInternalServerError, "admin/cards.html", gin.H{
"Title": "卡片管理",
"Username": username,
"Error": "Failed to load cards",
"Cards": []models.Card{},
})
return
}
c.HTML(http.StatusOK, "admin/cards.html", gin.H{
"Title": "卡片管理",
"Username": username,
"Cards": cards,
})
}
// CardCreateGet renders the form for creating a new card.
func CardCreateGet(c *gin.Context) {
username, _ := c.Get("username")
c.HTML(http.StatusOK, "admin/card_form.html", gin.H{
"Title": "新建卡片",
"Username": username,
"Card": nil,
"IsEdit": false,
})
}
// CardCreatePost handles the form submission for creating a new card.
func CardCreatePost(c *gin.Context) {
card := &models.Card{
Icon: c.PostForm("icon"),
Title: c.PostForm("title"),
Subtitle: c.PostForm("subtitle"),
URL: c.PostForm("url"),
Enabled: c.PostForm("enabled") == "1",
}
if card.Title == "" || card.URL == "" {
username, _ := c.Get("username")
c.HTML(http.StatusOK, "admin/card_form.html", gin.H{
"Title": "新建卡片",
"Username": username,
"Card": card,
"IsEdit": false,
"Error": "标题和链接不能为空",
})
return
}
if err := models.CreateCard(card); err != nil {
username, _ := c.Get("username")
c.HTML(http.StatusOK, "admin/card_form.html", gin.H{
"Title": "新建卡片",
"Username": username,
"Card": card,
"IsEdit": false,
"Error": "创建失败: " + err.Error(),
})
return
}
c.Redirect(http.StatusFound, "/admin/cards")
}
// CardEditGet renders the form for editing an existing card.
func CardEditGet(c *gin.Context) {
username, _ := c.Get("username")
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.Redirect(http.StatusFound, "/admin/cards")
return
}
card, err := models.GetCardByID(id)
if err != nil || card == nil {
c.Redirect(http.StatusFound, "/admin/cards")
return
}
c.HTML(http.StatusOK, "admin/card_form.html", gin.H{
"Title": "编辑卡片",
"Username": username,
"Card": card,
"IsEdit": true,
})
}
// CardEditPost handles the form submission for updating an existing card.
func CardEditPost(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.Redirect(http.StatusFound, "/admin/cards")
return
}
card, err := models.GetCardByID(id)
if err != nil || card == nil {
c.Redirect(http.StatusFound, "/admin/cards")
return
}
card.Icon = c.PostForm("icon")
card.Title = c.PostForm("title")
card.Subtitle = c.PostForm("subtitle")
card.URL = c.PostForm("url")
card.Enabled = c.PostForm("enabled") == "1"
if card.Title == "" || card.URL == "" {
username, _ := c.Get("username")
c.HTML(http.StatusOK, "admin/card_form.html", gin.H{
"Title": "编辑卡片",
"Username": username,
"Card": card,
"IsEdit": true,
"Error": "标题和链接不能为空",
})
return
}
if err := models.UpdateCard(card); err != nil {
username, _ := c.Get("username")
c.HTML(http.StatusOK, "admin/card_form.html", gin.H{
"Title": "编辑卡片",
"Username": username,
"Card": card,
"IsEdit": true,
"Error": "更新失败: " + err.Error(),
})
return
}
c.Redirect(http.StatusFound, "/admin/cards")
}
// CardDelete handles deleting a card.
func CardDelete(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.Redirect(http.StatusFound, "/admin/cards")
return
}
_ = models.DeleteCard(id)
c.Redirect(http.StatusFound, "/admin/cards")
}
// CardToggle handles toggling a card's enabled status.
func CardToggle(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.Redirect(http.StatusFound, "/admin/cards")
return
}
_ = models.ToggleCard(id)
c.Redirect(http.StatusFound, "/admin/cards")
}
// CardMoveUp handles moving a card up in sort order.
func CardMoveUp(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.Redirect(http.StatusFound, "/admin/cards")
return
}
_ = models.MoveCardUp(id)
c.Redirect(http.StatusFound, "/admin/cards")
}
// CardMoveDown handles moving a card down in sort order.
func CardMoveDown(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.Redirect(http.StatusFound, "/admin/cards")
return
}
_ = models.MoveCardDown(id)
c.Redirect(http.StatusFound, "/admin/cards")
}
+255
View File
@@ -0,0 +1,255 @@
package handlers
import (
"fmt"
"net/http"
"strconv"
"time"
"simple_portal/middleware"
"simple_portal/models"
"simple_portal/session"
"github.com/gin-gonic/gin"
)
// HomeHandler renders the portal home page with enabled cards and search engine.
// 同时记录主页访问日志。
func HomeHandler(c *gin.Context) {
cards, err := models.GetEnabledCards()
if err != nil {
c.HTML(http.StatusInternalServerError, "home.html", gin.H{"Error": "Failed to load cards"})
return
}
searchEngine, err := models.GetSetting(models.SettingKeySearchEngine)
if err != nil {
searchEngine = models.SearchEngineGoogle
}
if searchEngine == "" {
searchEngine = models.SearchEngineGoogle
}
// Fetch homepage configuration
siteTitle, _ := models.GetSetting(models.SettingKeyHomepageTitle)
if siteTitle == "" {
siteTitle = models.DefaultHomepageTitle
}
siteSubtitle, _ := models.GetSetting(models.SettingKeyHomepageSubtitle)
backgroundImage, _ := models.GetSetting(models.SettingKeyHomepageBackground)
// 记录主页访问日志(异步,不影响页面渲染)
ip := c.ClientIP()
ua := c.Request.UserAgent()
referer := c.Request.Referer()
go func() {
_ = models.CreateAccessLog(ip, ua, models.ActionTypeVisit, "", referer)
}()
c.HTML(http.StatusOK, "home.html", gin.H{
"Title": siteTitle,
"Cards": cards,
"SearchEngine": searchEngine,
"SiteTitle": siteTitle,
"SiteSubtitle": siteSubtitle,
"BackgroundImage": backgroundImage,
})
}
// CardClickHandler 处理卡片点击,记录访问日志后重定向到目标URL。
func CardClickHandler(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
c.Redirect(http.StatusFound, "/")
return
}
card, err := models.GetCardByID(id)
if err != nil || card == nil {
c.Redirect(http.StatusFound, "/")
return
}
// 记录卡片点击日志
ip := c.ClientIP()
ua := c.Request.UserAgent()
referer := c.Request.Referer()
cardTitle := card.Title
go func() {
_ = models.CreateAccessLog(ip, ua, models.ActionTypeClick, cardTitle, referer)
}()
c.Redirect(http.StatusFound, card.URL)
}
// SearchHandler 处理搜索请求,记录搜索日志后重定向到搜索引擎。
func SearchHandler(c *gin.Context) {
query := c.Query("q")
if query == "" {
c.Redirect(http.StatusFound, "/")
return
}
searchEngine, err := models.GetSetting(models.SettingKeySearchEngine)
if err != nil || searchEngine == "" {
searchEngine = models.SearchEngineGoogle
}
// 记录搜索日志
ip := c.ClientIP()
ua := c.Request.UserAgent()
referer := c.Request.Referer()
go func() {
_ = models.CreateAccessLog(ip, ua, models.ActionTypeSearch, query, referer)
}()
// 重定向到搜索引擎
targetURL := fmt.Sprintf(searchEngine, query)
c.Redirect(http.StatusFound, targetURL)
}
// LoginGet renders the admin login page.
func LoginGet(c *gin.Context) {
// If already logged in, redirect to admin dashboard
if sessionID, err := c.Cookie("session_id"); err == nil && sessionID != "" {
store := c.MustGet("sessionStore").(*session.SessionStore)
if store.Get(sessionID) != nil {
c.Redirect(http.StatusFound, "/admin")
return
}
}
c.HTML(http.StatusOK, "admin/login.html", gin.H{
"Title": "登录",
"Error": "",
})
}
// LoginPost handles the login form submission.
// It includes login logging, IP ban checking, and automatic IP banning on repeated failures.
func LoginPost(c *gin.Context) {
username := c.PostForm("username")
password := c.PostForm("password")
clientIP := c.ClientIP()
userAgent := c.Request.UserAgent()
if username == "" || password == "" {
_ = models.CreateLoginLog(nil, username, clientIP, userAgent, false)
c.HTML(http.StatusOK, "admin/login.html", gin.H{
"Title": "登录",
"Error": "请输入用户名和密码",
})
return
}
// Check if IP is whitelisted — whitelist IPs bypass all ban checks
isWhitelisted, _ := models.IsIPWhitelisted(clientIP)
// Check if IP is currently banned (skip for whitelisted IPs)
if !isWhitelisted {
banned, ban, _ := models.IsIPBanned(clientIP)
if banned && ban != nil {
_ = models.CreateLoginLog(nil, username, clientIP, userAgent, false)
c.HTML(http.StatusOK, "admin/login.html", gin.H{
"Title": "登录",
"Error": fmt.Sprintf("您的IP已被封禁,解封时间:%s", ban.BannedUntil.Format("2006-01-02 15:04:05")),
})
return
}
}
matched, admin, err := models.VerifyPassword(username, password)
if err != nil {
_ = models.CreateLoginLog(nil, username, clientIP, userAgent, false)
c.HTML(http.StatusOK, "admin/login.html", gin.H{
"Title": "登录",
"Error": "登录失败,请重试",
})
return
}
if !matched || admin == nil {
_ = models.CreateLoginLog(nil, username, clientIP, userAgent, false)
// Record failure in IPBanGuard (skip for whitelisted IPs)
if !isWhitelisted {
guard := c.MustGet("ipBanGuard").(*middleware.IPBanGuard)
if guard.RecordFail(clientIP) {
// Auto-ban this IP for 30 minutes
_ = models.CreateIPBan(clientIP, "登录失败次数过多(自动封禁)", 5, time.Now().Add(30*time.Minute))
}
}
c.HTML(http.StatusOK, "admin/login.html", gin.H{
"Title": "登录",
"Error": "用户名或密码错误",
})
return
}
// Successful login
adminID := admin.ID
_ = models.CreateLoginLog(&adminID, username, clientIP, userAgent, true)
// Reset fail counter for this IP on successful login
if !isWhitelisted {
guard := c.MustGet("ipBanGuard").(*middleware.IPBanGuard)
guard.ResetFail(clientIP)
}
// Create session
store := c.MustGet("sessionStore").(*session.SessionStore)
sessionID := store.Create(admin.ID, admin.Username)
// Set cookie
c.SetCookie("session_id", sessionID, 86400, "/", "", false, true)
c.Redirect(http.StatusFound, "/admin")
}
// Logout handles logging out the current admin.
func Logout(c *gin.Context) {
sessionID, _ := c.Cookie("session_id")
if sessionID != "" {
store := c.MustGet("sessionStore").(*session.SessionStore)
store.Delete(sessionID)
}
// Clear cookie
c.SetCookie("session_id", "", -1, "/", "", false, true)
c.Redirect(http.StatusFound, "/admin/login")
}
// AdminIndex renders the admin dashboard.
// 包含访问统计数据和实时流量信息。
func AdminIndex(c *gin.Context) {
username, _ := c.Get("username")
// 获取访问统计
stats, err := models.GetAccessLogStats()
if err != nil {
stats = &models.AccessLogStats{}
}
// 获取最近访问记录(20条)
recentLogs, err := models.GetRecentAccessLogs(20)
if err != nil {
recentLogs = []models.AccessLog{}
}
// 获取IP统计(Top 10
ipStats, err := models.GetAccessLogStatsByIP(10)
if err != nil {
ipStats = nil
}
c.HTML(http.StatusOK, "admin/index.html", gin.H{
"Title": "管理后台",
"Username": username,
"Stats": stats,
"RecentLogs": recentLogs,
"IPStats": ipStats,
})
}
+258
View File
@@ -0,0 +1,258 @@
package handlers
import (
"net/http"
"strconv"
"simple_portal/models"
"simple_portal/session"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
)
// LoginLogsGet 渲染登录日志页面。
func LoginLogsGet(c *gin.Context) {
username, _ := c.Get("username")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
page = 1
}
pageSize := 20
logs, total, err := models.GetLoginLogs(page, pageSize)
if err != nil {
logs = []models.LoginLog{}
total = 0
}
// 获取活跃的封禁列表
bans, _ := models.GetAllActiveBans()
if bans == nil {
bans = []models.IPBan{}
}
totalPages := (total + pageSize - 1) / pageSize
if totalPages < 1 {
totalPages = 1
}
c.HTML(http.StatusOK, "admin/logs.html", gin.H{
"Title": "登录日志",
"Username": username,
"Logs": logs,
"Bans": bans,
"Page": page,
"TotalPages": totalPages,
"Total": total,
})
}
// UnbanIP 处理手动解封IP的请求。
func UnbanIP(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.Redirect(http.StatusFound, "/admin/logs")
return
}
_ = models.DeleteIPBan(id)
c.Redirect(http.StatusFound, "/admin/logs")
}
// ChangePasswordGet 渲染修改密码页面。
func ChangePasswordGet(c *gin.Context) {
username, _ := c.Get("username")
c.HTML(http.StatusOK, "admin/password.html", gin.H{
"Title": "修改密码",
"Username": username,
"Error": "",
"Message": "",
})
}
// ChangePasswordPost 处理修改密码表单提交。
func ChangePasswordPost(c *gin.Context) {
username, _ := c.Get("username")
adminID, _ := c.Get("adminID")
oldPassword := c.PostForm("old_password")
newPassword := c.PostForm("new_password")
confirmPassword := c.PostForm("confirm_password")
// 验证输入
if oldPassword == "" || newPassword == "" || confirmPassword == "" {
c.HTML(http.StatusOK, "admin/password.html", gin.H{
"Title": "修改密码",
"Username": username,
"Error": "请填写所有字段",
"Message": "",
})
return
}
if len(newPassword) < 6 {
c.HTML(http.StatusOK, "admin/password.html", gin.H{
"Title": "修改密码",
"Username": username,
"Error": "新密码长度不能少于6位",
"Message": "",
})
return
}
if newPassword != confirmPassword {
c.HTML(http.StatusOK, "admin/password.html", gin.H{
"Title": "修改密码",
"Username": username,
"Error": "两次输入的新密码不一致",
"Message": "",
})
return
}
// 验证旧密码
admin, err := models.GetAdminByUsername(username.(string))
if err != nil || admin == nil {
c.HTML(http.StatusOK, "admin/password.html", gin.H{
"Title": "修改密码",
"Username": username,
"Error": "用户不存在",
"Message": "",
})
return
}
if err := bcrypt.CompareHashAndPassword([]byte(admin.Password), []byte(oldPassword)); err != nil {
c.HTML(http.StatusOK, "admin/password.html", gin.H{
"Title": "修改密码",
"Username": username,
"Error": "旧密码不正确",
"Message": "",
})
return
}
// 生成新密码hash
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
c.HTML(http.StatusOK, "admin/password.html", gin.H{
"Title": "修改密码",
"Username": username,
"Error": "密码加密失败",
"Message": "",
})
return
}
// 更新密码
if err := models.ChangePassword(adminID.(int), string(hashedPassword)); err != nil {
c.HTML(http.StatusOK, "admin/password.html", gin.H{
"Title": "修改密码",
"Username": username,
"Error": "密码修改失败: " + err.Error(),
"Message": "",
})
return
}
// 记录登录日志
ip := c.ClientIP()
userAgent := c.Request.UserAgent()
adminIDInt := adminID.(int)
_ = models.CreateLoginLog(&adminIDInt, username.(string), ip, userAgent, true)
// 清除当前session,强制重新登录
sessionID, _ := c.Cookie("session_id")
if sessionID != "" {
store := c.MustGet("sessionStore").(*session.SessionStore)
store.Delete(sessionID)
}
c.SetCookie("session_id", "", -1, "/", "", false, true)
c.Redirect(http.StatusFound, "/admin/login")
}
// IPWhitelistGet 渲染IP白名单管理页面。
func IPWhitelistGet(c *gin.Context) {
username, _ := c.Get("username")
list, err := models.GetAllWhitelist()
if err != nil {
list = []models.IPWhitelist{}
}
hasWhitelist := len(list) > 0
c.HTML(http.StatusOK, "admin/ip_whitelist.html", gin.H{
"Title": "IP白名单",
"Username": username,
"Whitelist": list,
"HasWhitelist": hasWhitelist,
"Error": "",
"Message": "",
})
}
// IPWhitelistAdd 处理添加IP白名单的请求。
// 当白名单从空变为非空时,自动将当前操作者的IP也加入白名单,防止锁定自己。
func IPWhitelistAdd(c *gin.Context) {
username, _ := c.Get("username")
ip := c.PostForm("ip")
comment := c.PostForm("comment")
if ip == "" {
list, _ := models.GetAllWhitelist()
c.HTML(http.StatusOK, "admin/ip_whitelist.html", gin.H{
"Title": "IP白名单",
"Username": username,
"Whitelist": list,
"HasWhitelist": len(list) > 0,
"Error": "IP地址不能为空",
"Message": "",
})
return
}
// 检查白名单是否之前为空(首次启用白名单时需自动加入当前操作者IP)
wasEmpty, _ := models.HasWhitelist()
if err := models.AddWhitelist(ip, comment); err != nil {
list, _ := models.GetAllWhitelist()
c.HTML(http.StatusOK, "admin/ip_whitelist.html", gin.H{
"Title": "IP白名单",
"Username": username,
"Whitelist": list,
"HasWhitelist": len(list) > 0,
"Error": "添加失败: " + err.Error(),
"Message": "",
})
return
}
// 首次启用白名单:自动把当前操作者IP也加入,防止锁定
if !wasEmpty {
c.Redirect(http.StatusFound, "/admin/ip-whitelist")
return
}
currentIP := c.ClientIP()
// 检查当前IP是否和刚添加的一样
isAlreadyAdded, _ := models.IsIPWhitelisted(currentIP)
if !isAlreadyAdded {
_ = models.AddWhitelist(currentIP, "自动添加(当前操作者)")
}
c.Redirect(http.StatusFound, "/admin/ip-whitelist")
}
// IPWhitelistDelete 处理删除IP白名单的请求。
func IPWhitelistDelete(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.Redirect(http.StatusFound, "/admin/ip-whitelist")
return
}
_ = models.DeleteWhitelist(id)
c.Redirect(http.StatusFound, "/admin/ip-whitelist")
}
+141
View File
@@ -0,0 +1,141 @@
package handlers
import (
"net/http"
"strings"
"simple_portal/models"
"github.com/gin-gonic/gin"
)
// SettingsGet renders the admin settings page.
func SettingsGet(c *gin.Context) {
username, _ := c.Get("username")
searchEngine, err := models.GetSetting(models.SettingKeySearchEngine)
if err != nil {
searchEngine = models.SearchEngineGoogle
}
if searchEngine == "" {
searchEngine = models.SearchEngineGoogle
}
engines := map[string]string{
"Google": models.SearchEngineGoogle,
"Bing": models.SearchEngineBing,
"百度": models.SearchEngineBaidu,
}
// Fetch homepage configuration
homepageTitle, _ := models.GetSetting(models.SettingKeyHomepageTitle)
if homepageTitle == "" {
homepageTitle = models.DefaultHomepageTitle
}
homepageSubtitle, _ := models.GetSetting(models.SettingKeyHomepageSubtitle)
homepageBackground, _ := models.GetSetting(models.SettingKeyHomepageBackground)
c.HTML(http.StatusOK, "admin/settings.html", gin.H{
"Title": "设置",
"Username": username,
"SearchEngine": searchEngine,
"Engines": engines,
"HomepageTitle": homepageTitle,
"HomepageSubtitle": homepageSubtitle,
"HomepageBackground": homepageBackground,
})
}
// SettingsPost handles the settings form submission.
func SettingsPost(c *gin.Context) {
username, _ := c.Get("username")
searchEngine := c.PostForm("search_engine")
customURL := c.PostForm("custom_url")
// 如果用户填写了自定义URL,优先使用
if customURL != "" {
if !strings.Contains(customURL, "%s") {
engines := map[string]string{
"Google": models.SearchEngineGoogle,
"Bing": models.SearchEngineBing,
"百度": models.SearchEngineBaidu,
}
homepageTitle := c.PostForm("homepage_title")
if homepageTitle == "" {
homepageTitle = models.DefaultHomepageTitle
}
homepageSubtitle := c.PostForm("homepage_subtitle")
homepageBackground := c.PostForm("homepage_background")
c.HTML(http.StatusOK, "admin/settings.html", gin.H{
"Title": "设置",
"Username": username,
"SearchEngine": searchEngine,
"Engines": engines,
"HomepageTitle": homepageTitle,
"HomepageSubtitle": homepageSubtitle,
"HomepageBackground": homepageBackground,
"Error": "自定义 URL 必须包含 %s 作为搜索词占位符",
})
return
}
searchEngine = customURL
}
if searchEngine == "" {
searchEngine = models.SearchEngineGoogle
}
// Save search engine setting
if err := models.SetSetting(models.SettingKeySearchEngine, searchEngine); err != nil {
engines := map[string]string{
"Google": models.SearchEngineGoogle,
"Bing": models.SearchEngineBing,
"百度": models.SearchEngineBaidu,
}
homepageTitle := c.PostForm("homepage_title")
if homepageTitle == "" {
homepageTitle = models.DefaultHomepageTitle
}
homepageSubtitle := c.PostForm("homepage_subtitle")
homepageBackground := c.PostForm("homepage_background")
c.HTML(http.StatusOK, "admin/settings.html", gin.H{
"Title": "设置",
"Username": username,
"SearchEngine": searchEngine,
"Engines": engines,
"HomepageTitle": homepageTitle,
"HomepageSubtitle": homepageSubtitle,
"HomepageBackground": homepageBackground,
"Error": "保存失败,请重试",
})
return
}
// Save homepage settings
homepageTitle := c.PostForm("homepage_title")
if homepageTitle == "" {
homepageTitle = models.DefaultHomepageTitle
}
homepageSubtitle := c.PostForm("homepage_subtitle")
homepageBackground := c.PostForm("homepage_background")
if err := models.SetSetting(models.SettingKeyHomepageTitle, homepageTitle); err != nil {
c.Redirect(http.StatusFound, "/admin/settings")
return
}
if err := models.SetSetting(models.SettingKeyHomepageSubtitle, homepageSubtitle); err != nil {
c.Redirect(http.StatusFound, "/admin/settings")
return
}
if err := models.SetSetting(models.SettingKeyHomepageBackground, homepageBackground); err != nil {
c.Redirect(http.StatusFound, "/admin/settings")
return
}
// 保存成功后重定向,利用 PRG 模式
c.Redirect(http.StatusFound, "/admin/settings")
}
+269
View File
@@ -0,0 +1,269 @@
package handlers
import (
"fmt"
"image"
"image/jpeg"
"io"
"os"
"path/filepath"
"strings"
"github.com/disintegration/imaging"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// allowedMIMETypes defines the permitted upload file MIME types.
var allowedMIMETypes = map[string]string{
"image/jpeg": ".jpg",
"image/png": ".png",
"image/gif": ".gif",
}
// maxUploadSize is the maximum allowed file size in bytes (5 MB).
const maxUploadSize = 5 << 20
// uploadDir is the directory where uploaded files are stored.
const uploadDir = "./data/uploads"
// thumbSuffix is the suffix appended to compressed image filenames.
const thumbSuffix = "_thumb"
// UploadResponse is the JSON response returned after a successful upload.
type UploadResponse struct {
URL string `json:"url"`
}
// UploadHandler handles image upload requests.
// POST /admin/upload
// Accepts multipart form with "file" field and optional "type" parameter ("icon" or "background").
// Returns JSON: {"url": "/uploads/{uuid}.{ext}"}
func UploadHandler(c *gin.Context) {
// Parse multipart form with size limit
if err := c.Request.ParseMultipartForm(maxUploadSize); err != nil {
c.JSON(400, gin.H{"error": "文件太大,最大允许 5MB"})
return
}
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(400, gin.H{"error": "请选择要上传的文件"})
return
}
defer file.Close()
// Validate file size
if header.Size > maxUploadSize {
c.JSON(400, gin.H{"error": "文件太大,最大允许 5MB"})
return
}
// Validate MIME type by reading first 512 bytes
buf := make([]byte, 512)
n, err := file.Read(buf)
if err != nil && err != io.EOF {
c.JSON(500, gin.H{"error": "读取文件失败"})
return
}
// Seek back to start
if _, err := file.Seek(0, 0); err != nil {
c.JSON(500, gin.H{"error": "处理文件失败"})
return
}
mimeType := strings.Split(c.Request.Header.Get("Content-Type"), ";")[0]
// Also detect from file content
detectedType := detectMIMEType(buf[:n])
if detectedType == "" {
detectedType = mimeType
}
ext, ok := allowedMIMETypes[detectedType]
if !ok {
// Try the header's content type as fallback
ext, ok = allowedMIMETypes[mimeType]
if !ok {
c.JSON(400, gin.H{"error": "不支持的文件格式,仅支持 jpg, png, gif"})
return
}
}
// Generate UUID filename
fileUUID := uuid.New().String()
filename := fileUUID + ext
// Ensure upload directory exists
if err := os.MkdirAll(uploadDir, 0755); err != nil {
c.JSON(500, gin.H{"error": "创建上传目录失败"})
return
}
// Save original file
originalPath := filepath.Join(uploadDir, filename)
dst, err := os.Create(originalPath)
if err != nil {
c.JSON(500, gin.H{"error": "保存文件失败"})
return
}
defer dst.Close()
if _, err := io.Copy(dst, file); err != nil {
os.Remove(originalPath)
c.JSON(500, gin.H{"error": "保存文件失败"})
return
}
dst.Close()
// Generate compressed thumbnail
uploadType := c.PostForm("type")
if err := generateThumbnail(originalPath, fileUUID, uploadType); err != nil {
// Log the error but don't fail the upload — thumbnail is optional
fmt.Printf("Warning: failed to generate thumbnail for %s: %v\n", filename, err)
}
url := "/uploads/" + filename
c.JSON(200, UploadResponse{URL: url})
}
// ServeUploadHandler serves uploaded files.
// GET /uploads/:filename
// If query param thumb=1, returns the compressed version.
func ServeUploadHandler(c *gin.Context) {
filename := c.Param("filename")
if filename == "" {
c.Status(404)
return
}
// Prevent directory traversal
if strings.Contains(filename, "..") || strings.Contains(filename, "/") || strings.Contains(filename, "\\") {
c.Status(404)
return
}
filePath := filepath.Join(uploadDir, filename)
// Check if thumb=1 query parameter is requested
if c.Query("thumb") == "1" {
// Try to serve the thumbnail version
ext := filepath.Ext(filename)
baseName := filename[:len(filename)-len(ext)]
thumbPath := filepath.Join(uploadDir, baseName+thumbSuffix+".jpg")
if _, err := os.Stat(thumbPath); err == nil {
c.File(thumbPath)
return
}
// If thumbnail doesn't exist, fall through to serve original
}
// Serve original file
if _, err := os.Stat(filePath); os.IsNotExist(err) {
c.Status(404)
return
}
c.File(filePath)
}
// detectMIMEType detects the MIME type from file content bytes.
func detectMIMEType(data []byte) string {
if len(data) < 3 {
return ""
}
// JPEG: starts with FF D8 FF
if data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {
return "image/jpeg"
}
// PNG: starts with 89 50 4E 47
if len(data) >= 4 && data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 {
return "image/png"
}
// GIF: starts with "GIF"
if len(data) >= 3 && data[0] == 'G' && data[1] == 'I' && data[2] == 'F' {
return "image/gif"
}
return ""
}
// generateThumbnail creates a compressed version of the uploaded image.
// Icon type: max 200x200, quality 80
// Background type: max 1920x1080, quality 85
// Default: max 800x600, quality 85
// Thumbnails are saved as JPG format.
func generateThumbnail(originalPath, fileUUID, uploadType string) error {
// Open the original image
img, err := imaging.Open(originalPath)
if err != nil {
return fmt.Errorf("failed to open image for thumbnail: %w", err)
}
var maxWidth, maxHeight int
var quality int
switch uploadType {
case "icon":
maxWidth = 200
maxHeight = 200
quality = 80
case "background":
maxWidth = 1920
maxHeight = 1080
quality = 85
default:
maxWidth = 800
maxHeight = 600
quality = 85
}
// Resize if needed, maintaining aspect ratio
thumb := fitImage(img, maxWidth, maxHeight)
// Save thumbnail as JPEG
thumbPath := filepath.Join(uploadDir, fileUUID+thumbSuffix+".jpg")
thumbFile, err := os.Create(thumbPath)
if err != nil {
return fmt.Errorf("failed to create thumbnail file: %w", err)
}
defer thumbFile.Close()
if err := jpeg.Encode(thumbFile, thumb, &jpeg.Options{Quality: quality}); err != nil {
os.Remove(thumbPath)
return fmt.Errorf("failed to encode thumbnail: %w", err)
}
return nil
}
// fitImage resizes the image to fit within maxWidth x maxHeight while
// maintaining aspect ratio. If the image is already smaller, it is returned as-is.
func fitImage(img image.Image, maxWidth, maxHeight int) image.Image {
bounds := img.Bounds()
w := bounds.Dx()
h := bounds.Dy()
if w <= maxWidth && h <= maxHeight {
return img
}
// Calculate the scaling factor to fit within bounds
scaleW := float64(maxWidth) / float64(w)
scaleH := float64(maxHeight) / float64(h)
scale := scaleW
if scaleH < scaleW {
scale = scaleH
}
newW := int(float64(w) * scale)
newH := int(float64(h) * scale)
if newW < 1 {
newW = 1
}
if newH < 1 {
newH = 1
}
return imaging.Resize(img, newW, newH, imaging.Lanczos)
}
+161
View File
@@ -0,0 +1,161 @@
package main
import (
"html/template"
"log"
"os"
"path/filepath"
"strings"
"simple_portal/database"
"simple_portal/handlers"
"simple_portal/middleware"
"simple_portal/session"
"github.com/gin-gonic/gin"
)
// loadTemplates loads HTML templates from templates/ directory recursively.
// Custom implementation because Go's ParseGlob has issues with directories on Windows.
func loadTemplates() *template.Template {
funcMap := template.FuncMap{
"hasPrefix": strings.HasPrefix,
"sub": func(a, b int) int { return a - b },
"add": func(a, b int) int { return a + b },
}
t := template.New("").Funcs(funcMap)
// 收集所有 .html 模板文件路径
var files []string
filepath.Walk("templates", func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() && filepath.Ext(path) == ".html" {
files = append(files, path)
}
return nil
})
if len(files) == 0 {
log.Fatal("No template files found in templates/")
}
// 将 Windows 反斜杠路径转为正斜杠,避免模板名问题
for i, f := range files {
files[i] = filepath.ToSlash(f)
}
var terr error
t, terr = t.ParseFiles(files...)
if terr != nil {
log.Fatalf("Failed to parse templates: %v", terr)
}
return t
}
func main() {
// Initialize database
if err := database.InitDB(); err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
defer database.CloseDB()
// Create uploads directory
if err := os.MkdirAll(filepath.Join(".", "data", "uploads"), 0755); err != nil {
log.Fatalf("Failed to create uploads directory: %v", err)
}
// Create session store
sessionStore := session.NewSessionStore()
// Create IP ban guard (in-memory fail counter)
ipBanGuard := middleware.NewIPBanGuard()
// Set Gin mode
ginMode := os.Getenv("GIN_MODE")
if ginMode == "" {
gin.SetMode(gin.DebugMode)
}
r := gin.Default()
// Load HTML templates (custom loader for nested directories)
r.SetHTMLTemplate(loadTemplates())
// Serve static files
r.Static("/static", "./static")
// Inject session store and IP ban guard into context for handlers
r.Use(func(c *gin.Context) {
c.Set("sessionStore", sessionStore)
c.Set("ipBanGuard", ipBanGuard)
c.Next()
})
// Public routes (home page and uploads — no IP restriction)
r.GET("/", handlers.HomeHandler)
r.GET("/click/:id", handlers.CardClickHandler)
r.GET("/search", handlers.SearchHandler)
r.GET("/uploads/:filename", handlers.ServeUploadHandler)
// Admin routes with IP whitelist check applied to all /admin/* routes
adminGroup := r.Group("/admin")
adminGroup.Use(middleware.IPWhitelistRequired(func(sessionID string) bool {
return sessionStore.Get(sessionID) != nil
}))
{
// Public admin routes (login — no auth required, but IP whitelist applies)
adminGroup.GET("/login", handlers.LoginGet)
adminGroup.POST("/login", handlers.LoginPost)
// Protected admin routes (auth required)
protected := adminGroup.Group("")
protected.Use(middleware.AuthRequired(sessionStore))
{
protected.POST("/logout", handlers.Logout)
protected.GET("/", handlers.AdminIndex)
// Cards management
protected.GET("/cards", handlers.CardsList)
protected.GET("/cards/new", handlers.CardCreateGet)
protected.POST("/cards", handlers.CardCreatePost)
protected.GET("/cards/:id/edit", handlers.CardEditGet)
protected.POST("/cards/:id", handlers.CardEditPost)
protected.POST("/cards/:id/delete", handlers.CardDelete)
protected.POST("/cards/:id/toggle", handlers.CardToggle)
protected.POST("/cards/:id/move-up", handlers.CardMoveUp)
protected.POST("/cards/:id/move-down", handlers.CardMoveDown)
// Image upload
protected.POST("/upload", handlers.UploadHandler)
// Settings
protected.GET("/settings", handlers.SettingsGet)
protected.POST("/settings", handlers.SettingsPost)
// Security: login logs
protected.GET("/logs", handlers.LoginLogsGet)
protected.POST("/logs/unban/:id", handlers.UnbanIP)
// Security: change password
protected.GET("/password", handlers.ChangePasswordGet)
protected.POST("/password", handlers.ChangePasswordPost)
// Security: IP whitelist management
protected.GET("/ip-whitelist", handlers.IPWhitelistGet)
protected.POST("/ip-whitelist/add", handlers.IPWhitelistAdd)
protected.POST("/ip-whitelist/:id/delete", handlers.IPWhitelistDelete)
// Analytics: access logs
protected.GET("/access-logs", handlers.AccessLogsGet)
}
}
// Determine port
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
log.Printf("Starting Portal server on :%s", port)
if err := r.Run(":" + port); err != nil {
log.Fatalf("Failed to start server: %v", err)
}
}
+35
View File
@@ -0,0 +1,35 @@
package middleware
import (
"net/http"
"simple_portal/session"
"github.com/gin-gonic/gin"
)
// AuthRequired returns a Gin middleware that checks for a valid session.
// If the session is invalid or missing, redirects to /admin/login.
func AuthRequired(store *session.SessionStore) gin.HandlerFunc {
return func(c *gin.Context) {
sessionID, err := c.Cookie("session_id")
if err != nil || sessionID == "" {
c.Redirect(http.StatusFound, "/admin/login")
c.Abort()
return
}
data := store.Get(sessionID)
if data == nil {
c.Redirect(http.StatusFound, "/admin/login")
c.Abort()
return
}
// Store session data in context for handlers to use
c.Set("adminID", data.AdminID)
c.Set("username", data.Username)
c.Set("sessionID", sessionID)
c.Next()
}
}
+116
View File
@@ -0,0 +1,116 @@
package middleware
import (
"net/http"
"sync"
"time"
"simple_portal/models"
"github.com/gin-gonic/gin"
)
// FailRecord stores the login failure count and first failure time for an IP.
type FailRecord struct {
Count int
FirstAt time.Time
}
// IPBanGuard maintains an in-memory counter of login failures per IP.
// When 5 failures occur within 5 minutes, the IP is automatically banned.
type IPBanGuard struct {
failMap sync.Map // map[string]*FailRecord
}
// NewIPBanGuard creates a new IPBanGuard instance.
func NewIPBanGuard() *IPBanGuard {
return &IPBanGuard{}
}
// RecordFail records a login failure for the given IP.
// It returns true if the IP should be auto-banned (5 failures within 5 minutes).
// The caller is responsible for creating the actual ban record in the database.
func (g *IPBanGuard) RecordFail(ip string) bool {
now := time.Now()
// Try to load existing record
if val, ok := g.failMap.Load(ip); ok {
rec := val.(*FailRecord)
// Check if within 5-minute window
if now.Sub(rec.FirstAt) > 5*time.Minute {
// Reset — outside the window
rec.Count = 1
rec.FirstAt = now
return false
}
rec.Count++
if rec.Count >= 5 {
// Auto-ban: reset the counter after creating the ban
g.failMap.Delete(ip)
return true
}
return false
}
// New record
g.failMap.Store(ip, &FailRecord{Count: 1, FirstAt: now})
return false
}
// ResetFail resets the failure counter for the given IP (on successful login).
func (g *IPBanGuard) ResetFail(ip string) {
g.failMap.Delete(ip)
}
// IPWhitelistRequired returns a Gin middleware that checks if the visitor's IP
// is in the whitelist. Only when the whitelist table has records does it restrict
// access; when the whitelist is empty, no IP is restricted.
//
// 登录页面路由(/admin/login)始终放行。
// 已通过 session 认证的用户也放行,避免管理员添加白名单后自己被锁定。
// sessionCheck 是一个函数,接收 sessionID 返回是否有效。
func IPWhitelistRequired(sessionCheck func(sessionID string) bool) gin.HandlerFunc {
return func(c *gin.Context) {
// 登录页面始终放行
path := c.Request.URL.Path
if path == "/admin/login" {
c.Next()
return
}
hasWhitelist, err := models.HasWhitelist()
if err != nil {
c.Next()
return
}
if !hasWhitelist {
c.Next()
return
}
clientIP := c.ClientIP()
allowed, err := models.IsIPWhitelisted(clientIP)
if err != nil {
c.Next()
return
}
if allowed {
c.Next()
return
}
// IP不在白名单中,但如果有有效session则放行
if sessionID, err := c.Cookie("session_id"); err == nil && sessionID != "" {
if sessionCheck(sessionID) {
c.Next()
return
}
}
c.HTML(http.StatusForbidden, "admin/403.html", gin.H{
"Title": "访问被拒绝",
"IP": clientIP,
})
c.Abort()
}
}
+193
View File
@@ -0,0 +1,193 @@
package models
import (
"fmt"
"time"
"simple_portal/database"
)
// 访问动作类型常量
const (
ActionTypeVisit = "visit" // 主页访问
ActionTypeClick = "click" // 卡片点击
ActionTypeSearch = "search" // 搜索
)
// AccessLog 表示一条访问日志记录。
type AccessLog struct {
ID int `json:"id"`
IP string `json:"ip"`
UserAgent string `json:"user_agent"`
ActionType string `json:"action_type"` // visit / click / search
Detail string `json:"detail"` // 卡片标题 / 搜索关键词
Referer string `json:"referer"`
CreatedAt time.Time `json:"created_at"`
}
// CreateAccessLog 插入一条访问日志。
func CreateAccessLog(ip, userAgent, actionType, detail, referer string) error {
_, err := database.DB.Exec(
"INSERT INTO access_logs (ip, user_agent, action_type, detail, referer, created_at) VALUES (?, ?, ?, ?, ?, ?)",
ip, userAgent, actionType, detail, referer, time.Now().Format("2006-01-02 15:04:05"),
)
if err != nil {
return fmt.Errorf("failed to create access log: %w", err)
}
return nil
}
// AccessLogStats 访问统计数据
type AccessLogStats struct {
TodayViews int `json:"today_views"` // 今日浏览次数
TotalViews int `json:"total_views"` // 总浏览次数
NewIPs int `json:"new_ips"` // 今日新IP数量
TotalIPs int `json:"total_ips"` // 总IP数量
}
// GetAccessLogStats 获取访问统计数据
func GetAccessLogStats() (*AccessLogStats, error) {
stats := &AccessLogStats{}
// 今日浏览次数
err := database.DB.QueryRow(
"SELECT COUNT(*) FROM access_logs WHERE date(created_at) = date('now', 'localtime')",
).Scan(&stats.TodayViews)
if err != nil {
return nil, fmt.Errorf("failed to count today views: %w", err)
}
// 总浏览次数
err = database.DB.QueryRow("SELECT COUNT(*) FROM access_logs").Scan(&stats.TotalViews)
if err != nil {
return nil, fmt.Errorf("failed to count total views: %w", err)
}
// 总IP数量
err = database.DB.QueryRow("SELECT COUNT(DISTINCT ip) FROM access_logs").Scan(&stats.TotalIPs)
if err != nil {
return nil, fmt.Errorf("failed to count total IPs: %w", err)
}
// 今日新IP数量(今日访问但之前从未出现过的IP)
err = database.DB.QueryRow(`
SELECT COUNT(DISTINCT a1.ip)
FROM access_logs a1
WHERE date(a1.created_at) = date('now', 'localtime')
AND NOT EXISTS (
SELECT 1 FROM access_logs a2
WHERE a2.ip = a1.ip AND date(a2.created_at) < date('now', 'localtime')
)
`).Scan(&stats.NewIPs)
if err != nil {
return nil, fmt.Errorf("failed to count new IPs: %w", err)
}
return stats, nil
}
// GetRecentAccessLogs 获取最近的访问日志(用于实时流量展示)
func GetRecentAccessLogs(limit int) ([]AccessLog, error) {
rows, err := database.DB.Query(
"SELECT id, ip, user_agent, action_type, detail, referer, created_at FROM access_logs ORDER BY created_at DESC LIMIT ?",
limit,
)
if err != nil {
return nil, fmt.Errorf("failed to query recent access logs: %w", err)
}
defer rows.Close()
var logs []AccessLog
for rows.Next() {
var l AccessLog
if err := rows.Scan(&l.ID, &l.IP, &l.UserAgent, &l.ActionType, &l.Detail, &l.Referer, &l.CreatedAt); err != nil {
return nil, fmt.Errorf("failed to scan access log: %w", err)
}
logs = append(logs, l)
}
return logs, rows.Err()
}
// GetAccessLogs 分页获取访问日志,支持按IP和动作类型筛选。
func GetAccessLogs(page, pageSize int, filterIP, filterAction string) ([]AccessLog, int, error) {
// 构建查询条件
where := "WHERE 1=1"
args := []interface{}{}
if filterIP != "" {
where += " AND ip LIKE ?"
args = append(args, "%"+filterIP+"%")
}
if filterAction != "" {
where += " AND action_type = ?"
args = append(args, filterAction)
}
// 获取总数
var total int
countArgs := make([]interface{}, len(args))
copy(countArgs, args)
err := database.DB.QueryRow("SELECT COUNT(*) FROM access_logs "+where, countArgs...).Scan(&total)
if err != nil {
return nil, 0, fmt.Errorf("failed to count access logs: %w", err)
}
// 分页查询
offset := (page - 1) * pageSize
if offset < 0 {
offset = 0
}
queryArgs := append(args, pageSize, offset)
rows, err := database.DB.Query(
"SELECT id, ip, user_agent, action_type, detail, referer, created_at FROM access_logs "+where+" ORDER BY created_at DESC LIMIT ? OFFSET ?",
queryArgs...,
)
if err != nil {
return nil, 0, fmt.Errorf("failed to query access logs: %w", err)
}
defer rows.Close()
var logs []AccessLog
for rows.Next() {
var l AccessLog
if err := rows.Scan(&l.ID, &l.IP, &l.UserAgent, &l.ActionType, &l.Detail, &l.Referer, &l.CreatedAt); err != nil {
return nil, 0, fmt.Errorf("failed to scan access log: %w", err)
}
logs = append(logs, l)
}
return logs, total, rows.Err()
}
// GetAccessLogStatsByIP 获取按IP统计的访问数据
func GetAccessLogStatsByIP(limit int) ([]struct {
IP string `json:"ip"`
Visits int `json:"visits"`
LastSeen string `json:"last_seen"`
}, error) {
rows, err := database.DB.Query(
"SELECT ip, COUNT(*) as visits, MAX(created_at) as last_seen FROM access_logs GROUP BY ip ORDER BY visits DESC LIMIT ?",
limit,
)
if err != nil {
return nil, fmt.Errorf("failed to query IP stats: %w", err)
}
defer rows.Close()
var result []struct {
IP string `json:"ip"`
Visits int `json:"visits"`
LastSeen string `json:"last_seen"`
}
for rows.Next() {
var r struct {
IP string `json:"ip"`
Visits int `json:"visits"`
LastSeen string `json:"last_seen"`
}
if err := rows.Scan(&r.IP, &r.Visits, &r.LastSeen); err != nil {
return nil, fmt.Errorf("failed to scan IP stats: %w", err)
}
result = append(result, r)
}
return result, rows.Err()
}
+75
View File
@@ -0,0 +1,75 @@
package models
import (
"database/sql"
"fmt"
"simple_portal/database"
"golang.org/x/crypto/bcrypt"
)
// Admin represents an administrator account.
type Admin struct {
ID int `json:"id"`
Username string `json:"username"`
Password string `json:"-"` // Never expose password hash in JSON
}
// GetAdminByUsername returns an admin by username. Returns nil if not found.
func GetAdminByUsername(username string) (*Admin, error) {
var a Admin
err := database.DB.QueryRow(
"SELECT id, username, password FROM admins WHERE username = ?",
username,
).Scan(&a.ID, &a.Username, &a.Password)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to get admin by username: %w", err)
}
return &a, nil
}
// CreateAdmin inserts a new admin with a bcrypt-hashed password.
func CreateAdmin(username, hashedPassword string) error {
_, err := database.DB.Exec(
"INSERT INTO admins (username, password) VALUES (?, ?)",
username, hashedPassword,
)
if err != nil {
return fmt.Errorf("failed to create admin: %w", err)
}
return nil
}
// VerifyPassword checks if the given plain-text password matches the stored bcrypt hash.
// Returns (matched, admin, error).
func VerifyPassword(username, password string) (bool, *Admin, error) {
admin, err := GetAdminByUsername(username)
if err != nil {
return false, nil, err
}
if admin == nil {
return false, nil, nil
}
err = bcrypt.CompareHashAndPassword([]byte(admin.Password), []byte(password))
if err != nil {
return false, nil, nil
}
return true, admin, nil
}
// ChangePassword updates the password hash for an admin by ID.
func ChangePassword(adminID int, newHashedPassword string) error {
_, err := database.DB.Exec(
"UPDATE admins SET password = ? WHERE id = ?",
newHashedPassword, adminID,
)
if err != nil {
return fmt.Errorf("failed to change password: %w", err)
}
return nil
}
+224
View File
@@ -0,0 +1,224 @@
package models
import (
"database/sql"
"fmt"
"simple_portal/database"
)
// Card represents a navigation card on the portal home page.
type Card struct {
ID int `json:"id"`
Icon string `json:"icon"`
Title string `json:"title"`
Subtitle string `json:"subtitle"`
URL string `json:"url"`
Sort int `json:"sort"`
Enabled bool `json:"enabled"`
CreatedAt string `json:"created_at"`
}
// GetAllCards returns all cards ordered by sort ascending.
func GetAllCards() ([]Card, error) {
rows, err := database.DB.Query("SELECT id, icon, title, subtitle, url, sort, enabled, created_at FROM cards ORDER BY sort ASC")
if err != nil {
return nil, fmt.Errorf("failed to query cards: %w", err)
}
defer rows.Close()
var cards []Card
for rows.Next() {
var c Card
var enabled int
if err := rows.Scan(&c.ID, &c.Icon, &c.Title, &c.Subtitle, &c.URL, &c.Sort, &enabled, &c.CreatedAt); err != nil {
return nil, fmt.Errorf("failed to scan card: %w", err)
}
c.Enabled = enabled == 1
cards = append(cards, c)
}
return cards, rows.Err()
}
// GetEnabledCards returns only enabled cards ordered by sort ascending.
func GetEnabledCards() ([]Card, error) {
rows, err := database.DB.Query("SELECT id, icon, title, subtitle, url, sort, enabled, created_at FROM cards WHERE enabled = 1 ORDER BY sort ASC")
if err != nil {
return nil, fmt.Errorf("failed to query enabled cards: %w", err)
}
defer rows.Close()
var cards []Card
for rows.Next() {
var c Card
var enabled int
if err := rows.Scan(&c.ID, &c.Icon, &c.Title, &c.Subtitle, &c.URL, &c.Sort, &enabled, &c.CreatedAt); err != nil {
return nil, fmt.Errorf("failed to scan card: %w", err)
}
c.Enabled = enabled == 1
cards = append(cards, c)
}
return cards, rows.Err()
}
// GetCardByID returns a single card by its ID.
func GetCardByID(id int) (*Card, error) {
var c Card
var enabled int
err := database.DB.QueryRow(
"SELECT id, icon, title, subtitle, url, sort, enabled, created_at FROM cards WHERE id = ?",
id,
).Scan(&c.ID, &c.Icon, &c.Title, &c.Subtitle, &c.URL, &c.Sort, &enabled, &c.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to query card by id: %w", err)
}
c.Enabled = enabled == 1
return &c, nil
}
// CreateCard inserts a new card with sort = MAX(sort) + 1.
func CreateCard(card *Card) error {
var maxSort sql.NullInt64
err := database.DB.QueryRow("SELECT MAX(sort) FROM cards").Scan(&maxSort)
if err != nil {
return fmt.Errorf("failed to get max sort: %w", err)
}
newSort := 0
if maxSort.Valid {
newSort = int(maxSort.Int64) + 1
}
enabledInt := 0
if card.Enabled {
enabledInt = 1
}
result, err := database.DB.Exec(
"INSERT INTO cards (icon, title, subtitle, url, sort, enabled) VALUES (?, ?, ?, ?, ?, ?)",
card.Icon, card.Title, card.Subtitle, card.URL, newSort, enabledInt,
)
if err != nil {
return fmt.Errorf("failed to insert card: %w", err)
}
lastID, err := result.LastInsertId()
if err != nil {
return fmt.Errorf("failed to get last insert id: %w", err)
}
card.ID = int(lastID)
card.Sort = newSort
return nil
}
// UpdateCard updates an existing card by ID.
func UpdateCard(card *Card) error {
enabledInt := 0
if card.Enabled {
enabledInt = 1
}
_, err := database.DB.Exec(
"UPDATE cards SET icon = ?, title = ?, subtitle = ?, url = ?, sort = ?, enabled = ? WHERE id = ?",
card.Icon, card.Title, card.Subtitle, card.URL, card.Sort, enabledInt, card.ID,
)
if err != nil {
return fmt.Errorf("failed to update card: %w", err)
}
return nil
}
// DeleteCard deletes a card by ID.
func DeleteCard(id int) error {
_, err := database.DB.Exec("DELETE FROM cards WHERE id = ?", id)
if err != nil {
return fmt.Errorf("failed to delete card: %w", err)
}
return nil
}
// ToggleCard toggles the enabled status of a card.
func ToggleCard(id int) error {
_, err := database.DB.Exec("UPDATE cards SET enabled = CASE WHEN enabled = 1 THEN 0 ELSE 1 END WHERE id = ?", id)
if err != nil {
return fmt.Errorf("failed to toggle card: %w", err)
}
return nil
}
// MoveCardUp swaps the sort value of the card with the one above it.
func MoveCardUp(id int) error {
// Get the current card
card, err := GetCardByID(id)
if err != nil || card == nil {
return fmt.Errorf("card not found: %d", id)
}
// Find the card with the next lower sort value (the one above)
var aboveCard Card
var aboveEnabled int
err = database.DB.QueryRow(
"SELECT id, icon, title, subtitle, url, sort, enabled, created_at FROM cards WHERE sort < ? ORDER BY sort DESC LIMIT 1",
card.Sort,
).Scan(&aboveCard.ID, &aboveCard.Icon, &aboveCard.Title, &aboveCard.Subtitle, &aboveCard.URL, &aboveCard.Sort, &aboveEnabled, &aboveCard.CreatedAt)
if err == sql.ErrNoRows {
// Already at the top
return nil
}
if err != nil {
return fmt.Errorf("failed to find card above: %w", err)
}
aboveCard.Enabled = aboveEnabled == 1
// Swap sort values
_, err = database.DB.Exec("UPDATE cards SET sort = ? WHERE id = ?", aboveCard.Sort, card.ID)
if err != nil {
return fmt.Errorf("failed to swap sort (current): %w", err)
}
_, err = database.DB.Exec("UPDATE cards SET sort = ? WHERE id = ?", card.Sort, aboveCard.ID)
if err != nil {
return fmt.Errorf("failed to swap sort (above): %w", err)
}
return nil
}
// MoveCardDown swaps the sort value of the card with the one below it.
func MoveCardDown(id int) error {
// Get the current card
card, err := GetCardByID(id)
if err != nil || card == nil {
return fmt.Errorf("card not found: %d", id)
}
// Find the card with the next higher sort value (the one below)
var belowCard Card
var belowEnabled int
err = database.DB.QueryRow(
"SELECT id, icon, title, subtitle, url, sort, enabled, created_at FROM cards WHERE sort > ? ORDER BY sort ASC LIMIT 1",
card.Sort,
).Scan(&belowCard.ID, &belowCard.Icon, &belowCard.Title, &belowCard.Subtitle, &belowCard.URL, &belowCard.Sort, &belowEnabled, &belowCard.CreatedAt)
if err == sql.ErrNoRows {
// Already at the bottom
return nil
}
if err != nil {
return fmt.Errorf("failed to find card below: %w", err)
}
belowCard.Enabled = belowEnabled == 1
// Swap sort values
_, err = database.DB.Exec("UPDATE cards SET sort = ? WHERE id = ?", belowCard.Sort, card.ID)
if err != nil {
return fmt.Errorf("failed to swap sort (current): %w", err)
}
_, err = database.DB.Exec("UPDATE cards SET sort = ? WHERE id = ?", card.Sort, belowCard.ID)
if err != nil {
return fmt.Errorf("failed to swap sort (below): %w", err)
}
return nil
}
+79
View File
@@ -0,0 +1,79 @@
package models
import (
"database/sql"
"fmt"
"time"
"simple_portal/database"
)
// IPBan 表示一条IP封禁记录。
type IPBan struct {
ID int `json:"id"`
IP string `json:"ip"`
Reason string `json:"reason"`
FailCount int `json:"fail_count"`
BannedUntil time.Time `json:"banned_until"`
CreatedAt time.Time `json:"created_at"`
}
// IsIPBanned 检查指定IP是否处于封禁状态(未过期)。
// 白名单IP不会被检查,需要在调用前自行判断。
func IsIPBanned(ip string) (bool, *IPBan, error) {
var ban IPBan
err := database.DB.QueryRow(
"SELECT id, ip, reason, fail_count, banned_until, created_at FROM ip_bans WHERE ip = ? AND banned_until > ? ORDER BY banned_until DESC LIMIT 1",
ip, time.Now().Format("2006-01-02 15:04:05"),
).Scan(&ban.ID, &ban.IP, &ban.Reason, &ban.FailCount, &ban.BannedUntil, &ban.CreatedAt)
if err == sql.ErrNoRows {
return false, nil, nil
}
if err != nil {
return false, nil, fmt.Errorf("failed to check IP ban: %w", err)
}
return true, &ban, nil
}
// CreateIPBan 插入一条IP封禁记录。
func CreateIPBan(ip, reason string, failCount int, bannedUntil time.Time) error {
_, err := database.DB.Exec(
"INSERT INTO ip_bans (ip, reason, fail_count, banned_until, created_at) VALUES (?, ?, ?, ?, ?)",
ip, reason, failCount, bannedUntil.Format("2006-01-02 15:04:05"), time.Now().Format("2006-01-02 15:04:05"),
)
if err != nil {
return fmt.Errorf("failed to create IP ban: %w", err)
}
return nil
}
// GetAllActiveBans 获取所有未过期的封禁记录。
func GetAllActiveBans() ([]IPBan, error) {
rows, err := database.DB.Query(
"SELECT id, ip, reason, fail_count, banned_until, created_at FROM ip_bans WHERE banned_until > ? ORDER BY banned_until DESC",
time.Now().Format("2006-01-02 15:04:05"),
)
if err != nil {
return nil, fmt.Errorf("failed to query active bans: %w", err)
}
defer rows.Close()
var bans []IPBan
for rows.Next() {
var b IPBan
if err := rows.Scan(&b.ID, &b.IP, &b.Reason, &b.FailCount, &b.BannedUntil, &b.CreatedAt); err != nil {
return nil, fmt.Errorf("failed to scan IP ban: %w", err)
}
bans = append(bans, b)
}
return bans, nil
}
// DeleteIPBan 删除一条封禁记录(手动解封)。
func DeleteIPBan(id int) error {
_, err := database.DB.Exec("DELETE FROM ip_bans WHERE id = ?", id)
if err != nil {
return fmt.Errorf("failed to delete IP ban: %w", err)
}
return nil
}
+77
View File
@@ -0,0 +1,77 @@
package models
import (
"fmt"
"time"
"simple_portal/database"
)
// IPWhitelist 表示一条IP白名单记录。
type IPWhitelist struct {
ID int `json:"id"`
IP string `json:"ip"`
Comment string `json:"comment"`
CreatedAt time.Time `json:"created_at"`
}
// HasWhitelist 检查是否存在白名单记录。
// 如果没有白名单记录,则不限制任何IP。
func HasWhitelist() (bool, error) {
var count int
err := database.DB.QueryRow("SELECT COUNT(*) FROM ip_whitelist").Scan(&count)
if err != nil {
return false, fmt.Errorf("failed to check whitelist: %w", err)
}
return count > 0, nil
}
// IsIPWhitelisted 检查IP是否在白名单中。
func IsIPWhitelisted(ip string) (bool, error) {
var count int
err := database.DB.QueryRow("SELECT COUNT(*) FROM ip_whitelist WHERE ip = ?", ip).Scan(&count)
if err != nil {
return false, fmt.Errorf("failed to check IP whitelist: %w", err)
}
return count > 0, nil
}
// GetAllWhitelist 获取所有白名单记录。
func GetAllWhitelist() ([]IPWhitelist, error) {
rows, err := database.DB.Query("SELECT id, ip, comment, created_at FROM ip_whitelist ORDER BY created_at DESC")
if err != nil {
return nil, fmt.Errorf("failed to query whitelist: %w", err)
}
defer rows.Close()
var list []IPWhitelist
for rows.Next() {
var w IPWhitelist
if err := rows.Scan(&w.ID, &w.IP, &w.Comment, &w.CreatedAt); err != nil {
return nil, fmt.Errorf("failed to scan whitelist: %w", err)
}
list = append(list, w)
}
return list, nil
}
// AddWhitelist 添加一条白名单记录。
func AddWhitelist(ip, comment string) error {
_, err := database.DB.Exec(
"INSERT INTO ip_whitelist (ip, comment, created_at) VALUES (?, ?, ?)",
ip, comment, time.Now(),
)
if err != nil {
return fmt.Errorf("failed to add whitelist: %w", err)
}
return nil
}
// DeleteWhitelist 删除一条白名单记录。
func DeleteWhitelist(id int) error {
_, err := database.DB.Exec("DELETE FROM ip_whitelist WHERE id = ?", id)
if err != nil {
return fmt.Errorf("failed to delete whitelist: %w", err)
}
return nil
}
+73
View File
@@ -0,0 +1,73 @@
package models
import (
"fmt"
"time"
"simple_portal/database"
)
// LoginLog 表示一条登录日志记录。
type LoginLog struct {
ID int `json:"id"`
AdminID *int `json:"admin_id"`
Username string `json:"username"`
IP string `json:"ip"`
UserAgent string `json:"user_agent"`
Success bool `json:"success"`
CreatedAt time.Time `json:"created_at"`
}
// CreateLoginLog 插入一条登录日志。
func CreateLoginLog(adminID *int, username, ip, userAgent string, success bool) error {
_, err := database.DB.Exec(
"INSERT INTO login_logs (admin_id, username, ip, user_agent, success, created_at) VALUES (?, ?, ?, ?, ?, ?)",
adminID, username, ip, userAgent, success, time.Now().Format("2006-01-02 15:04:05"),
)
if err != nil {
return fmt.Errorf("failed to create login log: %w", err)
}
return nil
}
// LoginLogCount 返回登录日志总数。
func LoginLogCount() (int, error) {
var count int
err := database.DB.QueryRow("SELECT COUNT(*) FROM login_logs").Scan(&count)
if err != nil {
return 0, fmt.Errorf("failed to count login logs: %w", err)
}
return count, nil
}
// GetLoginLogs 分页获取登录日志,倒序排列。
func GetLoginLogs(page, pageSize int) ([]LoginLog, int, error) {
total, err := LoginLogCount()
if err != nil {
return nil, 0, err
}
offset := (page - 1) * pageSize
if offset < 0 {
offset = 0
}
rows, err := database.DB.Query(
"SELECT id, admin_id, username, ip, user_agent, success, created_at FROM login_logs ORDER BY created_at DESC LIMIT ? OFFSET ?",
pageSize, offset,
)
if err != nil {
return nil, 0, fmt.Errorf("failed to query login logs: %w", err)
}
defer rows.Close()
var logs []LoginLog
for rows.Next() {
var l LoginLog
if err := rows.Scan(&l.ID, &l.AdminID, &l.Username, &l.IP, &l.UserAgent, &l.Success, &l.CreatedAt); err != nil {
return nil, 0, fmt.Errorf("failed to scan login log: %w", err)
}
logs = append(logs, l)
}
return logs, total, nil
}
+55
View File
@@ -0,0 +1,55 @@
package models
import (
"database/sql"
"fmt"
"simple_portal/database"
)
// Predefined search engine URL templates.
const (
SearchEngineGoogle = "https://www.google.com/search?q=%s"
SearchEngineBing = "https://www.bing.com/search?q=%s"
SearchEngineBaidu = "https://www.baidu.com/s?wd=%s"
)
// SettingKey constants.
const (
SettingKeySearchEngine = "search_engine"
SettingKeyHomepageTitle = "homepage_title"
SettingKeyHomepageSubtitle = "homepage_subtitle"
SettingKeyHomepageBackground = "homepage_background"
)
// Default setting values.
const (
DefaultHomepageTitle = "Portal"
DefaultHomepageSubtitle = ""
DefaultHomepageBackground = ""
)
// GetSetting retrieves a setting value by key. Returns empty string if not found.
func GetSetting(key string) (string, error) {
var value sql.NullString
err := database.DB.QueryRow("SELECT value FROM settings WHERE key = ?", key).Scan(&value)
if err == sql.ErrNoRows {
return "", nil
}
if err != nil {
return "", fmt.Errorf("failed to get setting %s: %w", key, err)
}
return value.String, nil
}
// SetSetting inserts or updates a setting value by key.
func SetSetting(key, value string) error {
_, err := database.DB.Exec(
"INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?",
key, value, value,
)
if err != nil {
return fmt.Errorf("failed to set setting %s: %w", key, err)
}
return nil
}
+65
View File
@@ -0,0 +1,65 @@
package session
import (
"crypto/rand"
"encoding/hex"
"sync"
"time"
)
// SessionData holds the data stored in a session.
type SessionData struct {
AdminID int
Username string
CreatedAt time.Time
}
// SessionStore is an in-memory session store with RWMutex for concurrent safety.
type SessionStore struct {
store map[string]*SessionData
mu sync.RWMutex
}
// NewSessionStore creates a new SessionStore instance.
func NewSessionStore() *SessionStore {
return &SessionStore{
store: make(map[string]*SessionData),
}
}
// Create creates a new session for the given admin and returns the session ID.
func (s *SessionStore) Create(adminID int, username string) string {
sessionID := generateSessionID()
data := &SessionData{
AdminID: adminID,
Username: username,
CreatedAt: time.Now(),
}
s.mu.Lock()
s.store[sessionID] = data
s.mu.Unlock()
return sessionID
}
// Get retrieves session data by session ID. Returns nil if not found.
func (s *SessionStore) Get(sessionID string) *SessionData {
s.mu.RLock()
defer s.mu.RUnlock()
return s.store[sessionID]
}
// Delete removes a session by session ID.
func (s *SessionStore) Delete(sessionID string) {
s.mu.Lock()
delete(s.store, sessionID)
s.mu.Unlock()
}
// generateSessionID creates a cryptographically random session ID.
func generateSessionID() string {
b := make([]byte, 32)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
+792
View File
@@ -0,0 +1,792 @@
/* ===== Reset & Base ===== */
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans SC", sans-serif;
color: #333;
background: #f5f5f5;
line-height: 1.6;
}
a {
color: #1a73e8;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
/* ===== Home Page ===== */
.home-container {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 80px 20px 40px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.home-header {
text-align: center;
margin-bottom: 40px;
}
.home-title {
font-size: 48px;
font-weight: 700;
color: #fff;
letter-spacing: 2px;
margin-bottom: 8px;
}
.home-subtitle {
font-size: 16px;
color: rgba(255, 255, 255, 0.8);
}
/* ===== Search Box ===== */
.search-box {
width: 100%;
max-width: 640px;
margin-bottom: 60px;
}
.search-box form {
display: flex;
background: #fff;
border-radius: 24px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
overflow: hidden;
transition: box-shadow 0.2s;
}
.search-box form:focus-within {
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25);
}
.search-input {
flex: 1;
border: none;
outline: none;
padding: 14px 24px;
font-size: 16px;
background: transparent;
color: #333;
}
.search-input::placeholder {
color: #aaa;
}
.search-btn {
border: none;
background: #667eea;
color: #fff;
padding: 14px 28px;
font-size: 16px;
cursor: pointer;
transition: background 0.2s;
}
.search-btn:hover {
background: #5a6fd6;
}
/* ===== Card Grid ===== */
.card-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
width: 100%;
max-width: 960px;
}
@media (max-width: 768px) {
.card-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 480px) {
.card-grid {
grid-template-columns: 1fr;
}
}
.card-item {
display: flex;
align-items: center;
gap: 12px;
background: #fff;
border-radius: 12px;
padding: 16px 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
transition: transform 0.2s, box-shadow 0.2s;
text-decoration: none;
color: #333;
}
.card-item:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
text-decoration: none;
}
.card-icon {
flex-shrink: 0;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.card-emoji {
font-size: 28px;
}
.card-content {
min-width: 0;
}
.card-title {
font-size: 15px;
font-weight: 600;
color: #333;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-subtitle {
font-size: 13px;
color: #999;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ===== Home Footer ===== */
.home-footer {
margin-top: auto;
padding-top: 40px;
}
.home-footer a {
color: rgba(255, 255, 255, 0.5);
font-size: 13px;
}
.home-footer a:hover {
color: rgba(255, 255, 255, 0.8);
text-decoration: none;
}
/* ===== Login Page ===== */
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
}
.login-card {
background: #fff;
border-radius: 12px;
padding: 40px;
width: 100%;
max-width: 400px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
}
.login-title {
font-size: 24px;
font-weight: 600;
text-align: center;
margin-bottom: 24px;
color: #333;
}
.login-error {
background: #fef2f2;
color: #dc2626;
padding: 10px 16px;
border-radius: 8px;
margin-bottom: 16px;
font-size: 14px;
text-align: center;
}
.login-form .form-group {
margin-bottom: 16px;
}
/* ===== Admin Layout ===== */
.admin-layout {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.admin-nav {
display: flex;
align-items: center;
background: #1f2937;
color: #fff;
padding: 0 24px;
height: 56px;
gap: 24px;
}
.admin-nav-brand {
font-size: 18px;
font-weight: 700;
margin-right: 16px;
}
.admin-nav-links {
display: flex;
gap: 4px;
}
.admin-nav-link {
color: rgba(255, 255, 255, 0.7);
padding: 8px 16px;
border-radius: 6px;
font-size: 14px;
transition: background 0.2s, color 0.2s;
text-decoration: none;
}
.admin-nav-link:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
text-decoration: none;
}
.admin-nav-link.active {
background: rgba(255, 255, 255, 0.15);
color: #fff;
}
.admin-nav-user {
margin-left: auto;
display: flex;
align-items: center;
gap: 12px;
font-size: 14px;
color: rgba(255, 255, 255, 0.8);
}
.admin-main {
flex: 1;
padding: 32px;
max-width: 1200px;
width: 100%;
margin: 0 auto;
}
.admin-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
}
.admin-header h1 {
font-size: 24px;
font-weight: 600;
color: #111;
}
/* ===== Admin Table ===== */
.admin-table {
width: 100%;
border-collapse: collapse;
background: #fff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
}
.admin-table th,
.admin-table td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid #eee;
font-size: 14px;
}
.admin-table th {
background: #f9fafb;
font-weight: 600;
color: #555;
}
.admin-table td a {
color: #1a73e8;
max-width: 200px;
display: inline-block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: bottom;
}
.admin-table td.actions {
white-space: nowrap;
}
/* ===== Buttons ===== */
.btn {
display: inline-block;
padding: 8px 16px;
border: none;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
text-align: center;
transition: background 0.2s, opacity 0.2s;
text-decoration: none;
line-height: 1.5;
}
.btn:hover {
opacity: 0.9;
text-decoration: none;
}
.btn-primary {
background: #667eea;
color: #fff;
}
.btn-primary:hover {
background: #5a6fd6;
}
.btn-secondary {
background: #e5e7eb;
color: #374151;
}
.btn-secondary:hover {
background: #d1d5db;
}
.btn-danger {
background: #ef4444;
color: #fff;
}
.btn-danger:hover {
background: #dc2626;
}
.btn-sm {
padding: 4px 10px;
font-size: 12px;
}
.btn-block {
display: block;
width: 100%;
}
/* ===== Forms ===== */
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 6px;
font-size: 14px;
font-weight: 500;
color: #374151;
}
.form-group input[type="text"],
.form-group input[type="password"],
.form-group input[type="url"],
.form-group select {
width: 100%;
padding: 10px 14px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 14px;
transition: border-color 0.2s;
outline: none;
background: #fff;
}
.form-group input:focus,
.form-group select:focus {
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.15);
}
.form-group input[type="checkbox"] {
margin-right: 6px;
width: auto;
}
.input-readonly {
background: #f3f4f6 !important;
color: #6b7280;
cursor: not-allowed;
}
.required {
color: #ef4444;
}
.form-actions {
display: flex;
gap: 12px;
margin-top: 24px;
}
.form-error {
background: #fef2f2;
color: #dc2626;
padding: 10px 16px;
border-radius: 8px;
margin-bottom: 16px;
font-size: 14px;
}
/* ===== Badges ===== */
.badge {
display: inline-block;
padding: 2px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
.badge-success {
background: #dcfce7;
color: #16a34a;
}
.badge-secondary {
background: #f3f4f6;
color: #6b7280;
}
.form-success {
background: #f0fdf4;
color: #16a34a;
padding: 10px 16px;
border-radius: 8px;
margin-bottom: 16px;
font-size: 14px;
}
/* ===== Home Page with Background ===== */
.home-container.has-background {
position: relative;
background: none;
}
.home-container.has-background::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.35);
z-index: 0;
}
.home-container.has-background > * {
position: relative;
z-index: 1;
}
/* ===== Site Subtitle ===== */
.site-subtitle {
font-size: 16px;
color: rgba(255, 255, 255, 0.8);
margin-top: 4px;
}
/* ===== Card Icon Image ===== */
.card-icon-img {
width: 40px;
height: 40px;
object-fit: contain;
border-radius: 4px;
}
/* ===== Upload Button ===== */
.upload-btn-wrapper {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.upload-btn {
display: inline-block;
padding: 6px 14px;
background: #667eea;
color: #fff;
border: none;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
transition: background 0.2s;
line-height: 1.5;
}
.upload-btn:hover {
background: #5a6fd6;
}
.upload-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.upload-status {
font-size: 13px;
margin-left: 4px;
}
.upload-success {
color: #16a34a;
}
.upload-error {
color: #dc2626;
}
/* ===== Background Preview ===== */
.background-preview {
margin-top: 10px;
}
.upload-preview-img {
max-width: 320px;
max-height: 180px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
display: block;
margin-bottom: 6px;
}
.preview-link {
font-size: 13px;
color: #1a73e8;
}
/* ===== Form Section Title ===== */
.form-section-title {
font-size: 18px;
font-weight: 600;
color: #374151;
margin: 32px 0 16px;
padding-bottom: 8px;
border-bottom: 1px solid #e5e7eb;
}
.form-section-title:first-of-type {
margin-top: 0;
}
/* ===== Badges — danger variant ===== */
.badge-danger {
background: #fef2f2;
color: #dc2626;
}
/* ===== Pagination ===== */
.pagination {
display: flex;
align-items: center;
gap: 12px;
margin-top: 20px;
padding: 12px 0;
}
.pagination-info {
font-size: 14px;
color: #6b7280;
}
/* ===== User-Agent cell truncation ===== */
.ua-cell {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
color: #9ca3af;
}
/* ===== Code style ===== */
code {
background: #f3f4f6;
padding: 2px 6px;
border-radius: 4px;
font-family: "Cascadia Code", "Fira Code", "Consolas", monospace;
font-size: 13px;
}
/* ===== Admin nav responsive ===== */
@media (max-width: 900px) {
.admin-nav {
flex-wrap: wrap;
height: auto;
padding: 8px 16px;
gap: 8px;
}
.admin-nav-links {
order: 3;
width: 100%;
flex-wrap: wrap;
gap: 2px;
}
.admin-nav-link {
padding: 4px 10px;
font-size: 13px;
}
}
/* ===== Whitelist Notice ===== */
.whitelist-notice {
background: #fef3c7;
color: #92400e;
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 20px;
font-size: 14px;
border: 1px solid #fbbf24;
}
.whitelist-notice.notice-info {
background: #eff6ff;
color: #1e40af;
border-color: #93c5fd;
}
/* ===== Stats Grid ===== */
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
margin-bottom: 32px;
}
@media (max-width: 768px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
.stat-card {
background: #fff;
border-radius: 12px;
padding: 24px;
text-align: center;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
transition: transform 0.2s, box-shadow 0.2s;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.stat-value {
font-size: 36px;
font-weight: 700;
color: #667eea;
margin-bottom: 4px;
}
.stat-label {
font-size: 14px;
color: #6b7280;
}
/* ===== Badge Variants ===== */
.badge-primary {
background: #eff6ff;
color: #2563eb;
}
.badge-warning {
background: #fffbeb;
color: #d97706;
}
/* ===== Detail Cell ===== */
.detail-cell {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
}
/* ===== Filter Form ===== */
.filter-form {
background: #fff;
border-radius: 8px;
padding: 16px 20px;
margin-bottom: 20px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
}
.filter-row {
display: flex;
gap: 16px;
align-items: flex-end;
flex-wrap: wrap;
}
.filter-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.filter-group label {
font-size: 13px;
font-weight: 500;
color: #374151;
}
.filter-group input[type="text"],
.filter-group select {
padding: 6px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 14px;
outline: none;
min-width: 160px;
}
.filter-group input:focus,
.filter-group select:focus {
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.15);
}
.filter-actions-group {
flex-direction: row;
align-items: center;
gap: 8px;
}
+140
View File
@@ -0,0 +1,140 @@
/**
* upload.js — Generic file upload handler for Portal admin pages.
*
* Usage:
* setupUpload(inputSelector, uploadType)
* - inputSelector: CSS selector for the target input field
* - uploadType: "icon" or "background" — determines thumbnail dimensions
*
* Adds a hidden file input and a visible "上传图片" button next to the target input.
* After successful upload, the target input value is set to the returned URL.
*/
(function () {
"use strict";
/**
* Sets up an upload button next to the specified input field.
* @param {string} inputSelector - CSS selector for the input to fill with the URL
* @param {string} uploadType - "icon" or "background"
*/
function setupUpload(inputSelector, uploadType) {
var input = document.querySelector(inputSelector);
if (!input) return;
// Create container for the upload button group
var wrapper = document.createElement("div");
wrapper.className = "upload-btn-wrapper";
// Create the visible upload button
var btn = document.createElement("button");
btn.type = "button";
btn.className = "upload-btn";
btn.textContent = "上传图片";
// Create the hidden file input
var fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.accept = "image/jpeg,image/png,image/gif";
fileInput.style.display = "none";
// Create status message element
var status = document.createElement("span");
status.className = "upload-status";
// Insert wrapper after the input
input.parentNode.insertBefore(wrapper, input.nextSibling);
wrapper.appendChild(btn);
wrapper.appendChild(fileInput);
wrapper.appendChild(status);
// Click button -> open file dialog
btn.addEventListener("click", function () {
fileInput.click();
});
// File selected -> upload
fileInput.addEventListener("change", function () {
if (fileInput.files.length === 0) return;
var file = fileInput.files[0];
// Validate file size (5MB)
if (file.size > 5 * 1024 * 1024) {
status.textContent = "文件太大,最大允许 5MB";
status.className = "upload-status upload-error";
return;
}
// Validate file type
var validTypes = ["image/jpeg", "image/png", "image/gif"];
if (validTypes.indexOf(file.type) === -1) {
status.textContent = "不支持的文件格式";
status.className = "upload-status upload-error";
return;
}
// Start upload
btn.disabled = true;
btn.textContent = "上传中...";
status.textContent = "";
status.className = "upload-status";
var formData = new FormData();
formData.append("file", file);
formData.append("type", uploadType);
var xhr = new XMLHttpRequest();
xhr.open("POST", "/admin/upload", true);
xhr.onload = function () {
btn.disabled = false;
btn.textContent = "上传图片";
if (xhr.status === 200) {
try {
var resp = JSON.parse(xhr.responseText);
if (resp.url) {
input.value = resp.url;
status.textContent = "上传成功!";
status.className = "upload-status upload-success";
// If there's a preview element, update it
var preview = input.parentNode.querySelector(".upload-preview");
if (preview) {
preview.src = resp.url + "?thumb=1";
preview.style.display = "block";
}
} else {
status.textContent = resp.error || "上传失败";
status.className = "upload-status upload-error";
}
} catch (e) {
status.textContent = "上传失败";
status.className = "upload-status upload-error";
}
} else {
try {
var resp = JSON.parse(xhr.responseText);
status.textContent = resp.error || "上传失败";
} catch (e) {
status.textContent = "上传失败 (" + xhr.status + ")";
}
status.className = "upload-status upload-error";
}
};
xhr.onerror = function () {
btn.disabled = false;
btn.textContent = "上传图片";
status.textContent = "网络错误";
status.className = "upload-status upload-error";
};
xhr.send(formData);
});
}
// Expose globally
window.setupUpload = setupUpload;
})();
+11
View File
@@ -0,0 +1,11 @@
{{define "admin/403.html"}}
{{template "header" .}}
<div class="login-container">
<div class="login-card">
<h1 class="login-title">访问被拒绝</h1>
<div class="login-error">您的 IP 地址 <code>{{.IP}}</code> 不在白名单中,无法访问后台管理页面。</div>
<p style="text-align:center;color:#999;font-size:14px;">如需访问,请联系管理员将您的 IP 添加到白名单。</p>
</div>
</div>
{{template "footer" .}}
{{end}}
+97
View File
@@ -0,0 +1,97 @@
{{define "admin/access_logs.html"}}
{{template "header" .}}
<div class="admin-layout">
<nav class="admin-nav">
<div class="admin-nav-brand">Portal 管理</div>
<div class="admin-nav-links">
<a href="/admin" class="admin-nav-link">首页</a>
<a href="/admin/cards" class="admin-nav-link">卡片管理</a>
<a href="/admin/access-logs" class="admin-nav-link active">访问日志</a>
<a href="/admin/logs" class="admin-nav-link">登录日志</a>
<a href="/admin/ip-whitelist" class="admin-nav-link">IP白名单</a>
<a href="/admin/settings" class="admin-nav-link">设置</a>
<a href="/admin/password" class="admin-nav-link">修改密码</a>
</div>
<div class="admin-nav-user">
<span>{{.Username}}</span>
<form method="POST" action="/admin/logout" style="display:inline">
<button type="submit" class="btn btn-sm btn-secondary">退出</button>
</form>
</div>
</nav>
<main class="admin-main">
<h1>访问日志</h1>
<!-- 筛选表单 -->
<form class="filter-form" method="GET" action="/admin/access-logs">
<div class="filter-row">
<div class="filter-group">
<label>IP地址</label>
<input type="text" name="ip" value="{{.FilterIP}}" placeholder="搜索IP...">
</div>
<div class="filter-group">
<label>动作类型</label>
<select name="action">
<option value="">全部</option>
<option value="visit" {{if eq .FilterAction "visit"}}selected{{end}}>访问</option>
<option value="click" {{if eq .FilterAction "click"}}selected{{end}}>点击</option>
<option value="search" {{if eq .FilterAction "search"}}selected{{end}}>搜索</option>
</select>
</div>
<div class="filter-group filter-actions-group">
<button type="submit" class="btn btn-primary btn-sm">筛选</button>
<a href="/admin/access-logs" class="btn btn-secondary btn-sm">重置</a>
</div>
</div>
</form>
<table class="admin-table">
<thead>
<tr>
<th>时间</th>
<th>IP地址</th>
<th>类型</th>
<th>详情</th>
<th>来源</th>
<th>客户端</th>
</tr>
</thead>
<tbody>
{{range .Logs}}
<tr>
<td>{{.CreatedAt.Format "2006-01-02 15:04:05"}}</td>
<td><code>{{.IP}}</code></td>
<td>
{{if eq .ActionType "visit"}}<span class="badge badge-success">访问</span>
{{else if eq .ActionType "click"}}<span class="badge badge-primary">点击</span>
{{else if eq .ActionType "search"}}<span class="badge badge-warning">搜索</span>
{{else}}<span class="badge badge-secondary">{{.ActionType}}</span>{{end}}
</td>
<td class="detail-cell" title="{{.Detail}}">{{if .Detail}}{{.Detail}}{{else}}—{{end}}</td>
<td class="ua-cell" title="{{.Referer}}">{{if .Referer}}<a href="{{.Referer}}" target="_blank">来源</a>{{else}}—{{end}}</td>
<td class="ua-cell" title="{{.UserAgent}}">{{.UserAgent}}</td>
</tr>
{{end}}
{{if not .Logs}}
<tr>
<td colspan="6" style="text-align:center;color:#999;">暂无访问日志</td>
</tr>
{{end}}
</tbody>
</table>
{{if gt .TotalPages 1}}
<div class="pagination">
{{if gt .Page 1}}
<a href="/admin/access-logs?page={{sub .Page 1}}&ip={{.FilterIP}}&action={{.FilterAction}}" class="btn btn-sm btn-secondary">上一页</a>
{{end}}
<span class="pagination-info">第 {{.Page}} / {{.TotalPages}} 页(共 {{.Total}} 条)</span>
{{if lt .Page .TotalPages}}
<a href="/admin/access-logs?page={{add .Page 1}}&ip={{.FilterIP}}&action={{.FilterAction}}" class="btn btn-sm btn-secondary">下一页</a>
{{end}}
</div>
{{end}}
</main>
</div>
{{template "footer" .}}
{{end}}
+64
View File
@@ -0,0 +1,64 @@
{{define "admin/card_form.html"}}
{{template "header" .}}
<div class="admin-layout">
<nav class="admin-nav">
<div class="admin-nav-brand">Portal 管理</div>
<div class="admin-nav-links">
<a href="/admin" class="admin-nav-link">首页</a>
<a href="/admin/cards" class="admin-nav-link active">卡片管理</a>
<a href="/admin/access-logs" class="admin-nav-link">访问日志</a>
<a href="/admin/logs" class="admin-nav-link">登录日志</a>
<a href="/admin/ip-whitelist" class="admin-nav-link">IP白名单</a>
<a href="/admin/settings" class="admin-nav-link">设置</a>
<a href="/admin/password" class="admin-nav-link">修改密码</a>
</div>
<div class="admin-nav-user">
<span>{{.Username}}</span>
<form method="POST" action="/admin/logout" style="display:inline">
<button type="submit" class="btn btn-sm btn-secondary">退出</button>
</form>
</div>
</nav>
<main class="admin-main">
<h1>{{if .IsEdit}}编辑卡片{{else}}新建卡片{{end}}</h1>
{{if .Error}}<div class="form-error">{{.Error}}</div>{{end}}
<form method="POST" action="{{if .IsEdit}}/admin/cards/{{.Card.ID}}{{else}}/admin/cards{{end}}" class="admin-form">
<div class="form-group">
<label for="icon">图标 (Emoji 或上传图片)</label>
<input type="text" id="icon" name="icon" value="{{if .Card}}{{.Card.Icon}}{{end}}" placeholder="例如: 📧 或上传图片">
</div>
<div class="form-group">
<label for="title">标题 <span class="required">*</span></label>
<input type="text" id="title" name="title" value="{{if .Card}}{{.Card.Title}}{{end}}" required>
</div>
<div class="form-group">
<label for="subtitle">副标题</label>
<input type="text" id="subtitle" name="subtitle" value="{{if .Card}}{{.Card.Subtitle}}{{end}}">
</div>
<div class="form-group">
<label for="url">链接 <span class="required">*</span></label>
<input type="url" id="url" name="url" value="{{if .Card}}{{.Card.URL}}{{end}}" required placeholder="https://">
</div>
<div class="form-group">
<label>
<input type="checkbox" name="enabled" value="1" {{if .Card}}{{if .Card.Enabled}}checked{{end}}{{else}}checked{{end}}>
启用
</label>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">{{if .IsEdit}}保存修改{{else}}创建卡片{{end}}</button>
<a href="/admin/cards" class="btn btn-secondary">取消</a>
</div>
</form>
</main>
</div>
<script src="/static/upload.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
setupUpload('#icon', 'icon');
});
</script>
{{template "footer" .}}
{{end}}
+82
View File
@@ -0,0 +1,82 @@
{{define "admin/cards.html"}}
{{template "header" .}}
<div class="admin-layout">
<nav class="admin-nav">
<div class="admin-nav-brand">Portal 管理</div>
<div class="admin-nav-links">
<a href="/admin" class="admin-nav-link">首页</a>
<a href="/admin/cards" class="admin-nav-link active">卡片管理</a>
<a href="/admin/access-logs" class="admin-nav-link">访问日志</a>
<a href="/admin/logs" class="admin-nav-link">登录日志</a>
<a href="/admin/ip-whitelist" class="admin-nav-link">IP白名单</a>
<a href="/admin/settings" class="admin-nav-link">设置</a>
<a href="/admin/password" class="admin-nav-link">修改密码</a>
</div>
<div class="admin-nav-user">
<span>{{.Username}}</span>
<form method="POST" action="/admin/logout" style="display:inline">
<button type="submit" class="btn btn-sm btn-secondary">退出</button>
</form>
</div>
</nav>
<main class="admin-main">
<div class="admin-header">
<h1>卡片管理</h1>
<a href="/admin/cards/new" class="btn btn-primary">+ 新建卡片</a>
</div>
<table class="admin-table">
<thead>
<tr>
<th>图标</th>
<th>标题</th>
<th>副标题</th>
<th>链接</th>
<th>排序</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{{range .Cards}}
<tr>
<td>{{if .Icon}}<span class="card-emoji">{{.Icon}}</span>{{else}}—{{end}}</td>
<td>{{.Title}}</td>
<td>{{if .Subtitle}}{{.Subtitle}}{{else}}—{{end}}</td>
<td><a href="{{.URL}}" target="_blank" rel="noopener">{{.URL}}</a></td>
<td>{{.Sort}}</td>
<td>
{{if .Enabled}}
<span class="badge badge-success">启用</span>
{{else}}
<span class="badge badge-secondary">禁用</span>
{{end}}
</td>
<td class="actions">
<form method="POST" action="/admin/cards/{{.ID}}/toggle" style="display:inline">
<button type="submit" class="btn btn-sm btn-secondary">{{if .Enabled}}禁用{{else}}启用{{end}}</button>
</form>
<form method="POST" action="/admin/cards/{{.ID}}/move-up" style="display:inline">
<button type="submit" class="btn btn-sm btn-secondary"></button>
</form>
<form method="POST" action="/admin/cards/{{.ID}}/move-down" style="display:inline">
<button type="submit" class="btn btn-sm btn-secondary"></button>
</form>
<a href="/admin/cards/{{.ID}}/edit" class="btn btn-sm btn-primary">编辑</a>
<form method="POST" action="/admin/cards/{{.ID}}/delete" style="display:inline" onsubmit="return confirm('确定要删除此卡片吗?')">
<button type="submit" class="btn btn-sm btn-danger">删除</button>
</form>
</td>
</tr>
{{end}}
{{if not .Cards}}
<tr>
<td colspan="7" style="text-align:center;color:#999;">暂无卡片,点击"新建卡片"添加</td>
</tr>
{{end}}
</tbody>
</table>
</main>
</div>
{{template "footer" .}}
{{end}}
+109
View File
@@ -0,0 +1,109 @@
{{define "admin/index.html"}}
{{template "header" .}}
<div class="admin-layout">
<nav class="admin-nav">
<div class="admin-nav-brand">Portal 管理</div>
<div class="admin-nav-links">
<a href="/admin" class="admin-nav-link active">首页</a>
<a href="/admin/cards" class="admin-nav-link">卡片管理</a>
<a href="/admin/access-logs" class="admin-nav-link">访问日志</a>
<a href="/admin/logs" class="admin-nav-link">登录日志</a>
<a href="/admin/ip-whitelist" class="admin-nav-link">IP白名单</a>
<a href="/admin/settings" class="admin-nav-link">设置</a>
<a href="/admin/password" class="admin-nav-link">修改密码</a>
</div>
<div class="admin-nav-user">
<span>{{.Username}}</span>
<form method="POST" action="/admin/logout" style="display:inline">
<button type="submit" class="btn btn-sm btn-secondary">退出</button>
</form>
</div>
</nav>
<main class="admin-main">
<h1>管理后台</h1>
<!-- 统计卡片 -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">{{.Stats.TodayViews}}</div>
<div class="stat-label">今日浏览</div>
</div>
<div class="stat-card">
<div class="stat-value">{{.Stats.TotalViews}}</div>
<div class="stat-label">总浏览次数</div>
</div>
<div class="stat-card">
<div class="stat-value">{{.Stats.NewIPs}}</div>
<div class="stat-label">今日新IP</div>
</div>
<div class="stat-card">
<div class="stat-value">{{.Stats.TotalIPs}}</div>
<div class="stat-label">总IP数量</div>
</div>
</div>
<!-- 实时流量 -->
<h2 class="form-section-title">实时流量</h2>
{{if .RecentLogs}}
<table class="admin-table">
<thead>
<tr>
<th>时间</th>
<th>IP</th>
<th>类型</th>
<th>详情</th>
<th>客户端</th>
</tr>
</thead>
<tbody>
{{range .RecentLogs}}
<tr>
<td>{{.CreatedAt.Format "15:04:05"}}</td>
<td><code>{{.IP}}</code></td>
<td>
{{if eq .ActionType "visit"}}<span class="badge badge-success">访问</span>
{{else if eq .ActionType "click"}}<span class="badge badge-primary">点击</span>
{{else if eq .ActionType "search"}}<span class="badge badge-warning">搜索</span>
{{else}}<span class="badge badge-secondary">{{.ActionType}}</span>{{end}}
</td>
<td class="detail-cell" title="{{.Detail}}">{{if .Detail}}{{.Detail}}{{else}}—{{end}}</td>
<td class="ua-cell" title="{{.UserAgent}}">{{.UserAgent}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p style="color:#999;">暂无访问记录</p>
{{end}}
<!-- IP 访问排行 -->
{{if .IPStats}}
<h2 class="form-section-title">IP 访问排行 (Top 10)</h2>
<table class="admin-table">
<thead>
<tr>
<th>IP地址</th>
<th>访问次数</th>
<th>最后访问</th>
</tr>
</thead>
<tbody>
{{range .IPStats}}
<tr>
<td><code>{{.IP}}</code></td>
<td>{{.Visits}}</td>
<td>{{.LastSeen}}</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
</main>
</div>
<script>
// 自动刷新页面(每30秒)
setTimeout(function() { location.reload(); }, 30000);
</script>
{{template "footer" .}}
{{end}}
+85
View File
@@ -0,0 +1,85 @@
{{define "admin/ip_whitelist.html"}}
{{template "header" .}}
<div class="admin-layout">
<nav class="admin-nav">
<div class="admin-nav-brand">Portal 管理</div>
<div class="admin-nav-links">
<a href="/admin" class="admin-nav-link">首页</a>
<a href="/admin/cards" class="admin-nav-link">卡片管理</a>
<a href="/admin/access-logs" class="admin-nav-link">访问日志</a>
<a href="/admin/logs" class="admin-nav-link">登录日志</a>
<a href="/admin/ip-whitelist" class="admin-nav-link active">IP白名单</a>
<a href="/admin/settings" class="admin-nav-link">设置</a>
<a href="/admin/password" class="admin-nav-link">修改密码</a>
</div>
<div class="admin-nav-user">
<span>{{.Username}}</span>
<form method="POST" action="/admin/logout" style="display:inline">
<button type="submit" class="btn btn-sm btn-secondary">退出</button>
</form>
</div>
</nav>
<main class="admin-main">
<h1>IP 白名单管理</h1>
{{if .HasWhitelist}}
<div class="whitelist-notice">
<strong>⚠️ 白名单模式已启用</strong>:当前仅白名单中的IP可以访问后台管理页面,非白名单IP将被拒绝访问。
</div>
{{else}}
<div class="whitelist-notice notice-info">
<strong>️ 白名单模式未启用</strong>:白名单为空时,不限制任何IP访问后台。添加至少一条记录即可启用白名单模式。
</div>
{{end}}
{{if .Error}}<div class="form-error">{{.Error}}</div>{{end}}
{{if .Message}}<div class="form-success">{{.Message}}</div>{{end}}
<h2 class="form-section-title">添加白名单</h2>
<form method="POST" action="/admin/ip-whitelist/add" class="admin-form">
<div class="form-group">
<label for="ip">IP 地址 <span class="required">*</span></label>
<input type="text" id="ip" name="ip" required placeholder="例如: 192.168.1.100">
</div>
<div class="form-group">
<label for="comment">备注</label>
<input type="text" id="comment" name="comment" placeholder="例如: 办公室网络">
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">添加</button>
</div>
</form>
<h2 class="form-section-title">当前白名单</h2>
{{if .Whitelist}}
<table class="admin-table">
<thead>
<tr>
<th>IP 地址</th>
<th>备注</th>
<th>添加时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{{range .Whitelist}}
<tr>
<td><code>{{.IP}}</code></td>
<td>{{if .Comment}}{{.Comment}}{{else}}—{{end}}</td>
<td>{{.CreatedAt.Format "2006-01-02 15:04:05"}}</td>
<td>
<form method="POST" action="/admin/ip-whitelist/{{.ID}}/delete" style="display:inline" onsubmit="return confirm('确定要删除此白名单记录吗?删除后该IP将无法访问后台。')">
<button type="submit" class="btn btn-sm btn-danger">删除</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p style="color:#999;">暂无白名单记录。添加记录后,将仅允许白名单IP访问后台。</p>
{{end}}
</main>
</div>
{{template "footer" .}}
{{end}}
+21
View File
@@ -0,0 +1,21 @@
{{define "admin/login.html"}}
{{template "header" .}}
<div class="login-container">
<div class="login-card">
<h1 class="login-title">管理后台登录</h1>
{{if .Error}}<div class="login-error">{{.Error}}</div>{{end}}
<form method="POST" action="/admin/login" class="login-form">
<div class="form-group">
<label for="username">用户名</label>
<input type="text" id="username" name="username" required autofocus>
</div>
<div class="form-group">
<label for="password">密码</label>
<input type="password" id="password" name="password" required>
</div>
<button type="submit" class="btn btn-primary btn-block">登录</button>
</form>
</div>
</div>
{{template "footer" .}}
{{end}}
+105
View File
@@ -0,0 +1,105 @@
{{define "admin/logs.html"}}
{{template "header" .}}
<div class="admin-layout">
<nav class="admin-nav">
<div class="admin-nav-brand">Portal 管理</div>
<div class="admin-nav-links">
<a href="/admin" class="admin-nav-link">首页</a>
<a href="/admin/cards" class="admin-nav-link">卡片管理</a>
<a href="/admin/access-logs" class="admin-nav-link">访问日志</a>
<a href="/admin/logs" class="admin-nav-link active">登录日志</a>
<a href="/admin/ip-whitelist" class="admin-nav-link">IP白名单</a>
<a href="/admin/settings" class="admin-nav-link">设置</a>
<a href="/admin/password" class="admin-nav-link">修改密码</a>
</div>
<div class="admin-nav-user">
<span>{{.Username}}</span>
<form method="POST" action="/admin/logout" style="display:inline">
<button type="submit" class="btn btn-sm btn-secondary">退出</button>
</form>
</div>
</nav>
<main class="admin-main">
<h1>登录日志</h1>
<table class="admin-table">
<thead>
<tr>
<th>时间</th>
<th>用户名</th>
<th>IP地址</th>
<th>User-Agent</th>
<th>状态</th>
</tr>
</thead>
<tbody>
{{range .Logs}}
<tr>
<td>{{.CreatedAt.Format "2006-01-02 15:04:05"}}</td>
<td>{{.Username}}</td>
<td><code>{{.IP}}</code></td>
<td class="ua-cell" title="{{.UserAgent}}">{{.UserAgent}}</td>
<td>
{{if .Success}}
<span class="badge badge-success">成功</span>
{{else}}
<span class="badge badge-danger">失败</span>
{{end}}
</td>
</tr>
{{end}}
{{if not .Logs}}
<tr>
<td colspan="5" style="text-align:center;color:#999;">暂无登录日志</td>
</tr>
{{end}}
</tbody>
</table>
{{if gt .TotalPages 1}}
<div class="pagination">
{{if gt .Page 1}}
<a href="/admin/logs?page={{sub .Page 1}}" class="btn btn-sm btn-secondary">上一页</a>
{{end}}
<span class="pagination-info">第 {{.Page}} / {{.TotalPages}} 页(共 {{.Total}} 条)</span>
{{if lt .Page .TotalPages}}
<a href="/admin/logs?page={{add .Page 1}}" class="btn btn-sm btn-secondary">下一页</a>
{{end}}
</div>
{{end}}
<h2 class="form-section-title">IP 封禁列表</h2>
{{if .Bans}}
<table class="admin-table">
<thead>
<tr>
<th>IP地址</th>
<th>原因</th>
<th>失败次数</th>
<th>封禁至</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{{range .Bans}}
<tr>
<td><code>{{.IP}}</code></td>
<td>{{.Reason}}</td>
<td>{{.FailCount}}</td>
<td>{{.BannedUntil.Format "2006-01-02 15:04:05"}}</td>
<td>
<form method="POST" action="/admin/logs/unban/{{.ID}}" style="display:inline">
<button type="submit" class="btn btn-sm btn-primary">解封</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p style="color:#999;">当前无封禁IP</p>
{{end}}
</main>
</div>
{{template "footer" .}}
{{end}}
+49
View File
@@ -0,0 +1,49 @@
{{define "admin/password.html"}}
{{template "header" .}}
<div class="admin-layout">
<nav class="admin-nav">
<div class="admin-nav-brand">Portal 管理</div>
<div class="admin-nav-links">
<a href="/admin" class="admin-nav-link">首页</a>
<a href="/admin/cards" class="admin-nav-link">卡片管理</a>
<a href="/admin/access-logs" class="admin-nav-link">访问日志</a>
<a href="/admin/logs" class="admin-nav-link">登录日志</a>
<a href="/admin/ip-whitelist" class="admin-nav-link">IP白名单</a>
<a href="/admin/settings" class="admin-nav-link">设置</a>
<a href="/admin/password" class="admin-nav-link active">修改密码</a>
</div>
<div class="admin-nav-user">
<span>{{.Username}}</span>
<form method="POST" action="/admin/logout" style="display:inline">
<button type="submit" class="btn btn-sm btn-secondary">退出</button>
</form>
</div>
</nav>
<main class="admin-main">
<h1>修改密码</h1>
{{if .Error}}<div class="form-error">{{.Error}}</div>{{end}}
{{if .Message}}<div class="form-success">{{.Message}}</div>{{end}}
<form method="POST" action="/admin/password" class="admin-form">
<div class="form-group">
<label for="old_password">旧密码 <span class="required">*</span></label>
<input type="password" id="old_password" name="old_password" required>
</div>
<div class="form-group">
<label for="new_password">新密码 <span class="required">*</span></label>
<input type="password" id="new_password" name="new_password" required minlength="6">
<small style="color:#999;">密码长度不少于6位</small>
</div>
<div class="form-group">
<label for="confirm_password">确认新密码 <span class="required">*</span></label>
<input type="password" id="confirm_password" name="confirm_password" required minlength="6">
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">修改密码</button>
</div>
</form>
</main>
</div>
{{template "footer" .}}
{{end}}
+80
View File
@@ -0,0 +1,80 @@
{{define "admin/settings.html"}}
{{template "header" .}}
<div class="admin-layout">
<nav class="admin-nav">
<div class="admin-nav-brand">Portal 管理</div>
<div class="admin-nav-links">
<a href="/admin" class="admin-nav-link">首页</a>
<a href="/admin/cards" class="admin-nav-link">卡片管理</a>
<a href="/admin/access-logs" class="admin-nav-link">访问日志</a>
<a href="/admin/logs" class="admin-nav-link">登录日志</a>
<a href="/admin/ip-whitelist" class="admin-nav-link">IP白名单</a>
<a href="/admin/settings" class="admin-nav-link active">设置</a>
<a href="/admin/password" class="admin-nav-link">修改密码</a>
</div>
<div class="admin-nav-user">
<span>{{.Username}}</span>
<form method="POST" action="/admin/logout" style="display:inline">
<button type="submit" class="btn btn-sm btn-secondary">退出</button>
</form>
</div>
</nav>
<main class="admin-main">
<h1>设置</h1>
{{if .Error}}<div class="form-error">{{.Error}}</div>{{end}}
{{if .Message}}<div class="form-success">{{.Message}}</div>{{end}}
<form method="POST" action="/admin/settings" class="admin-form">
<h2 class="form-section-title">搜索引擎</h2>
<div class="form-group">
<label for="search_engine">默认搜索引擎</label>
<select id="search_engine" name="search_engine">
{{range $name, $url := .Engines}}
<option value="{{$url}}" {{if eq $url $.SearchEngine}}selected{{end}}>{{$name}}</option>
{{end}}
</select>
</div>
<div class="form-group">
<label>当前引擎 URL</label>
<input type="text" value="{{.SearchEngine}}" readonly class="input-readonly">
</div>
<div class="form-group">
<label for="custom_url">自定义搜索引擎 URL(需包含 %s 作为搜索词占位符)</label>
<input type="url" id="custom_url" name="custom_url" placeholder="https://example.com/search?q=%s">
</div>
<h2 class="form-section-title">主页配置</h2>
<div class="form-group">
<label for="homepage_title">主页标题</label>
<input type="text" id="homepage_title" name="homepage_title" value="{{.HomepageTitle}}" placeholder="Portal">
</div>
<div class="form-group">
<label for="homepage_subtitle">主页副标题</label>
<input type="text" id="homepage_subtitle" name="homepage_subtitle" value="{{.HomepageSubtitle}}" placeholder="快速导航,一键直达">
</div>
<div class="form-group">
<label for="homepage_background">主页背景图片 URL</label>
<input type="text" id="homepage_background" name="homepage_background" value="{{.HomepageBackground}}" placeholder="留空则使用默认渐变色">
{{if .HomepageBackground}}
<div class="background-preview">
<img src="{{.HomepageBackground}}?thumb=1" alt="背景预览" class="upload-preview-img">
<a href="{{.HomepageBackground}}" target="_blank" class="preview-link">查看原图</a>
</div>
{{end}}
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">保存设置</button>
</div>
</form>
</main>
</div>
<script src="/static/upload.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
setupUpload('#homepage_background', 'background');
});
</script>
{{template "footer" .}}
{{end}}
+39
View File
@@ -0,0 +1,39 @@
{{define "home.html"}}
{{template "header" .}}
<div class="home-container{{if .BackgroundImage}} has-background{{end}}"{{if .BackgroundImage}} style="background-image: url('{{.BackgroundImage}}?thumb=1'); background-size: cover; background-position: center; background-attachment: fixed;"{{end}}>
<header class="home-header">
<h1 class="home-title">{{.SiteTitle}}</h1>
{{if .SiteSubtitle}}<p class="site-subtitle">{{.SiteSubtitle}}</p>{{else}}<p class="home-subtitle">快速导航,一键直达</p>{{end}}
</header>
<div class="search-box">
<form id="search-form" action="/search" method="GET">
<input type="text" id="search-input" name="q" class="search-input" placeholder="搜索..." autofocus>
<button type="submit" class="search-btn">搜索</button>
</form>
</div>
<div class="card-grid">
{{range .Cards}}
<a href="/click/{{.ID}}" class="card-item" target="_blank" rel="noopener noreferrer">
<div class="card-icon">{{if .Icon}}{{if hasPrefix .Icon "/uploads/"}}<img src="{{.Icon}}?thumb=1" alt="{{.Title}}" class="card-icon-img">{{else}}<span class="card-emoji">{{.Icon}}</span>{{end}}{{else}}<span class="card-emoji">🔗</span>{{end}}</div>
<div class="card-content">
<div class="card-title">{{.Title}}</div>
{{if .Subtitle}}<div class="card-subtitle">{{.Subtitle}}</div>{{end}}
</div>
</a>
{{end}}
</div>
<footer class="home-footer">
<a href="/admin/login">管理后台</a>
</footer>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('search-input').focus();
});
</script>
{{template "footer" .}}
{{end}}
+16
View File
@@ -0,0 +1,16 @@
{{define "header"}}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}}</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
{{end}}
{{define "footer"}}
</body>
</html>
{{end}}