Compare commits

...

8 Commits

Author SHA1 Message Date
24ceea3f32 update frontend 2025-03-26 02:48:52 +08:00
0531e82b61 rm fmt 2025-03-26 02:38:38 +08:00
0c77e0a28d modify main.go 1 2025-03-26 02:36:29 +08:00
29a5bfe9d9 modify main.go 2025-03-26 02:32:18 +08:00
c2db6f3dae add login 2025-03-26 02:23:17 +08:00
a038d4a673 update frontend 2025-03-26 01:57:10 +08:00
00035a5cb1 add js 2025-03-26 01:46:27 +08:00
858d0f0bb5 update readme 2025-03-26 01:09:15 +08:00
6 changed files with 1149 additions and 160 deletions

278
README.md
View File

@@ -1,2 +1,280 @@
[TOC]
# navigation-app # navigation-app
from deepseek
## 详细部署流程
### 1. 开发环境准备
1. **安装Go语言环境** (仅开发机需要):
bash
复制
```
# Linux
sudo apt update && sudo apt install -y golang
# Mac
brew install go
# Windows
# 从 https://golang.org/dl/ 下载安装包
```
2. **初始化Go模块**:
bash
复制
```
cd backend
go mod init navigation/backend
go mod tidy
```
### 2. 编译应用程序
1. **编译Linux可执行文件**:
bash
复制
```
make build
```
这会在`bin/`目录下生成`navigation-server`二进制文件
### 3. VPS部署步骤
1. **上传文件到VPS**:
bash
复制
```
scp -r bin/ navigation-server config/ frontend/ data/ user@your_vps_ip:/opt/navigation-app
```
2. **SSH登录VPS**:
bash
复制
```
ssh user@your_vps_ip
```
3. **设置文件权限**:
bash
复制
```
cd /opt/navigation-app
chmod +x bin/navigation-server
mkdir -p data
chmod 700 data
```
4. **运行应用程序** (测试运行):
bash
复制
```
cd /opt/navigation-app
./bin/navigation-server
```
5. **配置持久化运行** (使用systemd):
创建服务文件`/etc/systemd/system/navigation.service`:
ini
复制
```
[Unit]
Description=Navigation Server
After=network.target
[Service]
Type=simple
User=www-data
WorkingDirectory=/opt/navigation-app
ExecStart=/opt/navigation-app/bin/navigation-server
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
```
启用并启动服务:
bash
复制
```
sudo systemctl daemon-reload
sudo systemctl enable navigation
sudo systemctl start navigation
```
6. **验证服务运行**:
bash
复制
```
sudo systemctl status navigation
curl http://localhost:8080/api/sites
```
### 4. 配置反向代理 (可选,推荐)
使用Nginx作为反向代理:
1. **安装Nginx**:
bash
复制
```
sudo apt update && sudo apt install -y nginx
```
2. **创建配置文件** `/etc/nginx/sites-available/navigation`:
nginx
复制
```
server {
listen 80;
server_name yourdomain.com;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
```
3. **启用配置**:
bash
复制
```
sudo ln -s /etc/nginx/sites-available/navigation /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
```
4. **配置HTTPS (使用Let's Encrypt)**:
bash
复制
```
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d yourdomain.com
```
## 维护与管理
### 常用命令
- **查看日志**:
bash
复制
```
journalctl -u navigation -f
```
- **重启服务**:
bash
复制
```
sudo systemctl restart navigation
```
- **备份数据库**:
bash
复制
```
cp /opt/navigation-app/data/navigation.db /path/to/backup/navigation_$(date +%F).db
```
### 更新流程
1. 本地修改代码后重新编译:
bash
复制
```
make build
```
2. 上传新版本到VPS:
bash
复制
```
scp bin/navigation-server user@your_vps_ip:/opt/navigation-app/bin/
```
3. 重启服务:
bash
复制
```
ssh user@your_vps_ip
sudo systemctl restart navigation
```
这个方案提供了完整的、生产就绪的Go后端实现具有以下优点
1. 单个静态二进制文件,部署简单
2. 使用SQLite数据库无需额外服务
3. 完整的CRUD API实现
4. 包含生产环境部署配置
5. 易于维护和更新

View File

@@ -3,38 +3,66 @@ package main
import ( import (
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"fmt"
"log" "log"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time"
"github.com/dgrijalva/jwt-go"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
"golang.org/x/crypto/bcrypt"
"gopkg.in/yaml.v2" "gopkg.in/yaml.v2"
) )
// 配置结构体
type Config struct { type Config struct {
Server struct { Server struct {
Port string `yaml:"port"` Port string `yaml:"port"`
SecretKey string `yaml:"secret_key"`
} `yaml:"server"` } `yaml:"server"`
Database struct { Database struct {
Path string `yaml:"path"` Path string `yaml:"path"`
} `yaml:"database"` } `yaml:"database"`
Admin struct {
Username string `yaml:"username"`
Password string `yaml:"password"`
} `yaml:"admin"`
} }
// 网站结构体
type Site struct { type Site struct {
ID int `json:"id"` ID int `json:"id"`
Name string `json:"name"` Name string `json:"name"`
URL string `json:"url"` URL string `json:"url"`
Icon string `json:"icon,omitempty"` Icon string `json:"icon,omitempty"`
Category string `json:"category"` Category string `json:"category"`
Order int `json:"order"`
} }
var db *sql.DB // 登录凭据
type Credentials struct {
Username string `json:"username"`
Password string `json:"password"`
}
// JWT声明
type Claims struct {
Username string `json:"username"`
jwt.StandardClaims
}
// 全局变量
var (
db *sql.DB
config *Config
)
func main() { func main() {
// 加载配置 // 加载配置
config, err := loadConfig("config/config.yaml") var err error
config, err = loadConfig("config/config.yaml")
if err != nil { if err != nil {
log.Fatalf("Error loading config: %v", err) log.Fatalf("Error loading config: %v", err)
} }
@@ -47,21 +75,27 @@ func main() {
defer db.Close() defer db.Close()
// 设置路由 // 设置路由
http.HandleFunc("/api/login", loginHandler)
http.HandleFunc("/api/validate", authMiddleware(validateHandler))
http.HandleFunc("/api/sites", sitesHandler) http.HandleFunc("/api/sites", sitesHandler)
http.HandleFunc("/api/add", addSiteHandler) http.HandleFunc("/api/sites/order", authMiddleware(updateSitesOrderHandler))
http.HandleFunc("/api/delete", deleteSiteHandler) http.HandleFunc("/api/sites/add", authMiddleware(addSiteHandler))
http.HandleFunc("/api/sites/delete", authMiddleware(deleteSiteHandler))
http.HandleFunc("/api/export", authMiddleware(exportHandler))
http.HandleFunc("/api/import", authMiddleware(importHandler))
// 静态文件服务 // 静态文件服务
fs := http.FileServer(http.Dir("../frontend")) fs := http.FileServer(http.Dir("../frontend"))
http.Handle("/", fs) http.Handle("/", fs)
// 启动服务器 // 启动服务器
log.Printf("Starting server on :%s", config.Server.Port) log.Printf("Server starting on :%s", config.Server.Port)
log.Fatal(http.ListenAndServe(":"+config.Server.Port, nil)) log.Fatal(http.ListenAndServe(":"+config.Server.Port, nil))
} }
// 加载配置文件
func loadConfig(path string) (*Config, error) { func loadConfig(path string) (*Config, error) {
config := &Config{} cfg := &Config{}
file, err := os.Open(path) file, err := os.Open(path)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -69,45 +103,157 @@ func loadConfig(path string) (*Config, error) {
defer file.Close() defer file.Close()
d := yaml.NewDecoder(file) d := yaml.NewDecoder(file)
if err := d.Decode(&config); err != nil { if err := d.Decode(&cfg); err != nil {
return nil, err return nil, err
} }
return config, nil return cfg, nil
} }
// 初始化数据库
func initDB(dbPath string) error { func initDB(dbPath string) error {
var err error var err error
// 确保数据库目录存在
os.MkdirAll(filepath.Dir(dbPath), 0755) os.MkdirAll(filepath.Dir(dbPath), 0755)
db, err = sql.Open("sqlite3", dbPath) db, err = sql.Open("sqlite3", dbPath)
if err != nil { if err != nil {
return err return err
} }
// 创建表 // 创建网站
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS sites ( _, err = db.Exec(`CREATE TABLE IF NOT EXISTS sites (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, name TEXT NOT NULL,
url TEXT NOT NULL, url TEXT NOT NULL,
icon TEXT, icon TEXT,
category TEXT NOT NULL category TEXT NOT NULL,
sort_order INTEGER DEFAULT 0
)`) )`)
if err != nil {
return err
}
// 创建管理员表
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS admin_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL
)`)
if err != nil {
return err
}
// 初始化管理员账户
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(config.Admin.Password), bcrypt.DefaultCost)
if err != nil {
return err
}
_, err = db.Exec("INSERT OR IGNORE INTO admin_users (username, password) VALUES (?, ?)",
config.Admin.Username, string(hashedPassword))
return err return err
} }
func sitesHandler(w http.ResponseWriter, r *http.Request) { // 登录处理器
switch r.Method { func loginHandler(w http.ResponseWriter, r *http.Request) {
case "GET": if r.Method != "POST" {
getSites(w, r)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var creds Credentials
if err := json.NewDecoder(r.Body).Decode(&creds); err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
// 验证用户
var storedPassword string
err := db.QueryRow("SELECT password FROM admin_users WHERE username = ?", creds.Username).Scan(&storedPassword)
if err != nil {
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
return
}
// 验证密码
if err := bcrypt.CompareHashAndPassword([]byte(storedPassword), []byte(creds.Password)); err != nil {
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
return
}
// 创建JWT令牌
expirationTime := time.Now().Add(24 * time.Hour)
claims := &Claims{
Username: creds.Username,
StandardClaims: jwt.StandardClaims{
ExpiresAt: expirationTime.Unix(),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString([]byte(config.Server.SecretKey))
if err != nil {
http.Error(w, "Error generating token", http.StatusInternalServerError)
return
}
// 返回令牌
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"token": tokenString,
})
}
// 验证处理器
func validateHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]bool{
"valid": true,
})
}
// 认证中间件
func authMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, "Authorization header required", http.StatusUnauthorized)
return
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
return []byte(config.Server.SecretKey), nil
})
if err != nil {
if err == jwt.ErrSignatureInvalid {
http.Error(w, "Invalid token signature", http.StatusUnauthorized)
return
}
http.Error(w, "Invalid token", http.StatusUnauthorized)
return
}
if !token.Valid {
http.Error(w, "Invalid token", http.StatusUnauthorized)
return
}
next(w, r)
} }
} }
func getSites(w http.ResponseWriter, r *http.Request) { // 获取所有网站
rows, err := db.Query("SELECT id, name, url, icon, category FROM sites") func sitesHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
rows, err := db.Query("SELECT id, name, url, icon, category, sort_order FROM sites ORDER BY category, sort_order, name")
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
@@ -117,7 +263,7 @@ func getSites(w http.ResponseWriter, r *http.Request) {
var sites []Site var sites []Site
for rows.Next() { for rows.Next() {
var s Site var s Site
if err := rows.Scan(&s.ID, &s.Name, &s.URL, &s.Icon, &s.Category); err != nil { if err := rows.Scan(&s.ID, &s.Name, &s.URL, &s.Icon, &s.Category, &s.Order); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
@@ -128,6 +274,43 @@ func getSites(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(sites) json.NewEncoder(w).Encode(sites)
} }
// 更新网站排序
func updateSitesOrderHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var sites []Site
if err := json.NewDecoder(r.Body).Decode(&sites); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
tx, err := db.Begin()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
for _, site := range sites {
_, err = tx.Exec("UPDATE sites SET sort_order = ? WHERE id = ?", site.Order, site.ID)
if err != nil {
tx.Rollback()
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
if err := tx.Commit(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
// 添加网站
func addSiteHandler(w http.ResponseWriter, r *http.Request) { func addSiteHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" { if r.Method != "POST" {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
@@ -147,8 +330,8 @@ func addSiteHandler(w http.ResponseWriter, r *http.Request) {
} }
// 插入数据库 // 插入数据库
res, err := db.Exec("INSERT INTO sites (name, url, icon, category) VALUES (?, ?, ?, ?)", res, err := db.Exec("INSERT INTO sites (name, url, icon, category, sort_order) VALUES (?, ?, ?, ?, ?)",
s.Name, s.URL, s.Icon, s.Category) s.Name, s.URL, s.Icon, s.Category, s.Order)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
@@ -162,6 +345,7 @@ func addSiteHandler(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(s) json.NewEncoder(w).Encode(s)
} }
// 删除网站
func deleteSiteHandler(w http.ResponseWriter, r *http.Request) { func deleteSiteHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" { if r.Method != "POST" {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
@@ -183,5 +367,81 @@ func deleteSiteHandler(w http.ResponseWriter, r *http.Request) {
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `{"status": "success"}`) json.NewEncoder(w).Encode(map[string]string{"status": "success"})
} }
// 导出数据
func exportHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
rows, err := db.Query("SELECT id, name, url, icon, category, sort_order FROM sites ORDER BY category, sort_order, name")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer rows.Close()
var sites []Site
for rows.Next() {
var s Site
if err := rows.Scan(&s.ID, &s.Name, &s.URL, &s.Icon, &s.Category, &s.Order); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
sites = append(sites, s)
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Disposition", "attachment; filename=navigation-sites.json")
json.NewEncoder(w).Encode(sites)
}
// 导入数据
func importHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var sites []Site
if err := json.NewDecoder(r.Body).Decode(&sites); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
tx, err := db.Begin()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// 清空现有数据
_, err = tx.Exec("DELETE FROM sites")
if err != nil {
tx.Rollback()
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// 插入新数据
for _, site := range sites {
_, err = tx.Exec("INSERT INTO sites (name, url, icon, category, sort_order) VALUES (?, ?, ?, ?, ?)",
site.Name, site.URL, site.Icon, site.Category, site.Order)
if err != nil {
tx.Rollback()
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
if err := tx.Commit(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
}

View File

@@ -1,5 +1,10 @@
server: server:
port: "8080" # 服务端口 port: "8080"
secret_key: "6Sc1OTEaU3n9t80ijBptYYYSEwPx2Y5t" # 用于JWT签名的密钥
database: database:
path: "data/navigation.db" # 数据库文件路径 path: "data/navigation.db"
admin:
username: "admin"
password: "123456" # 首次运行后请修改

View File

@@ -5,37 +5,35 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>我的导航网站</title> <title>我的导航网站</title>
<link rel="stylesheet" href="styles.css"> <link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<header> <header>
<div class="header-left"> <div class="header-left">
<h1>我的导航网站</h1> <h1><i class="fas fa-compass"></i> 我的导航</h1>
<div class="search-box"> <div class="search-box">
<input type="text" id="searchInput" placeholder="搜索网站..."> <input type="text" id="searchInput" placeholder="搜索网站...">
<i class="fas fa-search"></i> <i class="fas fa-search"></i>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">
<div class="theme-switcher"> <button id="themeBtn"><i class="fas fa-moon"></i></button>
<button id="themeBtn"><i class="fas fa-moon"></i></button> <button id="loginBtn"><i class="fas fa-sign-in-alt"></i> 登录</button>
</div> <button id="logoutBtn" style="display: none;"><i class="fas fa-sign-out-alt"></i> 注销</button>
<div class="actions"> <!-- 管理员功能默认隐藏 -->
<button id="addSiteBtn"><i class="fas fa-plus"></i> 添加网站</button> <button id="addSiteBtn" style="display: none;"><i class="fas fa-plus"></i> 添加</button>
<button id="manageCategoriesBtn"><i class="fas fa-tags"></i> 管理分类</button> <button id="exportBtn" style="display: none;"><i class="fas fa-file-export"></i> 导出</button>
<button id="exportBtn"><i class="fas fa-file-export"></i></button> <button id="importBtn" style="display: none;"><i class="fas fa-file-import"></i></button>
<button id="importBtn"><i class="fas fa-file-import"></i> 导入</button> <input type="file" id="importFile" style="display: none;" accept=".json">
<input type="file" id="importFile" style="display: none;" accept=".json">
</div>
</div> </div>
</header> </header>
<div class="categories" id="categories"> <div class="categories" id="categories">
<!-- 分类将通过JavaScript动态生成 --> <div class="loading">加载中...</div>
</div> </div>
<!-- 添加网站模态框 --> <!-- 添加网站模态框 -->
<div class="modal" id="addSiteModal"> <div class="modal" id="addSiteModal">
<div class="modal-content"> <div class="modal-content">
<span class="close">&times;</span> <span class="close">&times;</span>
@@ -51,9 +49,7 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="siteCategory"><i class="fas fa-tag"></i> 分类</label> <label for="siteCategory"><i class="fas fa-tag"></i> 分类</label>
<select id="siteCategory" required> <input type="text" id="siteCategory" required>
<!-- 分类选项将通过JavaScript动态生成 -->
</select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="siteIcon"><i class="fas fa-image"></i> 图标URL (可选)</label> <label for="siteIcon"><i class="fas fa-image"></i> 图标URL (可选)</label>
@@ -64,23 +60,26 @@
</div> </div>
</div> </div>
<!-- 管理分类的模态框 --> <!-- 登录模态框 -->
<div class="modal" id="manageCategoriesModal"> <div class="modal" id="loginModal">
<div class="modal-content"> <div class="modal-content">
<span class="close">&times;</span> <span class="close">&times;</span>
<h2><i class="fas fa-tags"></i> 管理分类</h2> <h2><i class="fas fa-sign-in-alt"></i> 管理员登录</h2>
<div class="category-list" id="categoryList"> <form id="loginForm">
<!-- 分类列表将通过JavaScript动态生成 --> <div class="form-group">
</div> <label for="username"><i class="fas fa-user"></i> 用户名</label>
<div class="add-category"> <input type="text" id="username" required>
<input type="text" id="newCategoryName" placeholder="新分类名称"> </div>
<button id="addCategoryBtn"><i class="fas fa-plus"></i> 添加分类</button> <div class="form-group">
</div> <label for="password"><i class="fas fa-lock"></i> 密码</label>
<input type="password" id="password" required>
</div>
<button type="submit"><i class="fas fa-sign-in-alt"></i> 登录</button>
</form>
</div> </div>
</div> </div>
</div> </div>
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.14.0/Sortable.min.js"></script>
<script src="script.js"></script> <script src="script.js"></script>
</body> </body>
</html> </html>

View File

@@ -0,0 +1,488 @@
document.addEventListener('DOMContentLoaded', function() {
// DOM元素
const categoriesContainer = document.getElementById('categories');
const addSiteModal = document.getElementById('addSiteModal');
const loginModal = document.getElementById('loginModal');
const addSiteBtn = document.getElementById('addSiteBtn');
const loginBtn = document.getElementById('loginBtn');
const logoutBtn = document.getElementById('logoutBtn');
const exportBtn = document.getElementById('exportBtn');
const importBtn = document.getElementById('importBtn');
const closeButtons = document.querySelectorAll('.close');
const addSiteForm = document.getElementById('addSiteForm');
const loginForm = document.getElementById('loginForm');
const searchInput = document.getElementById('searchInput');
const themeBtn = document.getElementById('themeBtn');
const importFile = document.getElementById('importFile');
// 状态变量
let authToken = localStorage.getItem('authToken');
let isAdmin = false;
let currentTheme = localStorage.getItem('theme') || 'light';
// 初始化
initTheme();
checkAuthStatus();
setupEventListeners();
loadSites();
// 初始化主题
function initTheme() {
document.documentElement.setAttribute('data-theme', currentTheme);
themeBtn.innerHTML = currentTheme === 'light' ? '<i class="fas fa-moon"></i>' : '<i class="fas fa-sun"></i>';
}
// 检查认证状态
async function checkAuthStatus() {
if (authToken) {
try {
const response = await fetchWithAuth('/api/validate');
if (response.ok) {
isAdmin = true;
setupAdminFeatures();
}
} catch (error) {
console.error('Auth check failed:', error);
logout();
}
}
updateAuthUI();
}
// 设置管理员功能
function setupAdminFeatures() {
// 显示管理按钮
addSiteBtn.style.display = 'flex';
exportBtn.style.display = 'flex';
importBtn.style.display = 'flex';
// 设置拖拽排序
setupDragAndDrop();
}
// 更新认证UI
function updateAuthUI() {
if (isAdmin) {
loginBtn.style.display = 'none';
logoutBtn.style.display = 'flex';
} else {
loginBtn.style.display = 'flex';
logoutBtn.style.display = 'none';
}
}
// 设置拖拽排序
function setupDragAndDrop() {
let draggedItem = null;
// 拖拽开始
const handleDragStart = (e) => {
draggedItem = e.target;
e.target.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/html', e.target.innerHTML);
};
// 拖拽结束
const handleDragEnd = () => {
draggedItem.classList.remove('dragging');
draggedItem = null;
};
// 拖拽经过
const handleDragOver = (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const afterElement = getDragAfterElement(categoriesContainer, e.clientY);
if (afterElement == null) {
categoriesContainer.appendChild(draggedItem);
} else {
categoriesContainer.insertBefore(draggedItem, afterElement);
}
};
// 获取拖拽后的元素
const getDragAfterElement = (container, y) => {
const draggableElements = [...container.querySelectorAll('.site:not(.dragging)')];
return draggableElements.reduce((closest, child) => {
const box = child.getBoundingClientRect();
const offset = y - box.top - box.height / 2;
if (offset < 0 && offset > closest.offset) {
return { offset: offset, element: child };
} else {
return closest;
}
}, { offset: Number.NEGATIVE_INFINITY }).element;
};
// 为所有网站添加拖拽事件
document.querySelectorAll('.site').forEach(site => {
site.draggable = isAdmin;
site.addEventListener('dragstart', handleDragStart);
site.addEventListener('dragend', handleDragEnd);
});
categoriesContainer.addEventListener('dragover', handleDragOver);
categoriesContainer.addEventListener('dragend', async () => {
// 更新排序
const sites = [];
document.querySelectorAll('.site').forEach((siteEl, index) => {
sites.push({
id: parseInt(siteEl.dataset.id),
order: index
});
});
try {
const response = await fetchWithAuth('/api/sites/order', {
method: 'POST',
body: JSON.stringify(sites)
});
if (!response.ok) throw new Error('Failed to update order');
} catch (error) {
console.error('Error updating order:', error);
alert('排序更新失败');
}
});
}
// 设置事件监听器
function setupEventListeners() {
// 主题切换
themeBtn.addEventListener('click', toggleTheme);
// 打开添加网站模态框
addSiteBtn.addEventListener('click', () => addSiteModal.style.display = 'block');
// 打开登录模态框
loginBtn.addEventListener('click', () => loginModal.style.display = 'block');
// 注销
logoutBtn.addEventListener('click', logout);
// 关闭模态框
closeButtons.forEach(btn => {
btn.addEventListener('click', function() {
this.closest('.modal').style.display = 'none';
});
});
// 点击模态框外部关闭
window.addEventListener('click', (e) => {
if (e.target.classList.contains('modal')) {
e.target.style.display = 'none';
}
});
// 添加网站表单提交
addSiteForm.addEventListener('submit', handleAddSite);
// 登录表单提交
loginForm.addEventListener('submit', handleLogin);
// 搜索功能
searchInput.addEventListener('input', handleSearch);
// 导出数据
exportBtn.addEventListener('click', handleExport);
// 导入数据
importBtn.addEventListener('click', () => importFile.click());
importFile.addEventListener('change', handleImport);
}
// 添加网站处理
async function handleAddSite(e) {
e.preventDefault();
const name = document.getElementById('siteName').value.trim();
let url = document.getElementById('siteUrl').value.trim();
const category = document.getElementById('siteCategory').value.trim();
const icon = document.getElementById('siteIcon').value.trim();
// 确保URL有协议
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = 'https://' + url;
}
try {
const response = await fetchWithAuth('/api/sites/add', {
method: 'POST',
body: JSON.stringify({
name,
url,
icon: icon || null,
category,
order: 0
})
});
if (!response.ok) throw new Error('添加失败');
// 清空表单并关闭模态框
addSiteForm.reset();
addSiteModal.style.display = 'none';
// 重新加载数据
loadSites();
} catch (error) {
console.error('Error:', error);
alert('添加网站时出错: ' + error.message);
}
}
// 登录处理
async function handleLogin(e) {
e.preventDefault();
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value.trim();
try {
const response = await fetch('/api/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password })
});
if (!response.ok) throw new Error('登录失败');
const data = await response.json();
authToken = data.token;
localStorage.setItem('authToken', authToken);
isAdmin = true;
loginModal.style.display = 'none';
loginForm.reset();
setupAdminFeatures();
updateAuthUI();
loadSites();
} catch (error) {
console.error('Login error:', error);
alert('登录失败: ' + error.message);
}
}
// 注销处理
function logout() {
localStorage.removeItem('authToken');
authToken = null;
isAdmin = false;
updateAuthUI();
window.location.reload();
}
// 搜索处理
function handleSearch(e) {
const searchTerm = e.target.value.toLowerCase();
document.querySelectorAll('.site').forEach(site => {
const name = site.getAttribute('data-name') || '';
const url = site.getAttribute('data-url') || '';
if (name.includes(searchTerm) || url.includes(searchTerm)) {
site.style.display = 'flex';
} else {
site.style.display = 'none';
}
});
}
// 导出处理
async function handleExport() {
try {
const response = await fetchWithAuth('/api/export');
if (!response.ok) throw new Error('导出失败');
const data = await response.json();
const dataStr = JSON.stringify(data, null, 2);
const dataUri = 'data:application/json;charset=utf-8,' + encodeURIComponent(dataStr);
const exportFileDefaultName = `导航数据_${new Date().toISOString().slice(0,10)}.json`;
const linkElement = document.createElement('a');
linkElement.setAttribute('href', dataUri);
linkElement.setAttribute('download', exportFileDefaultName);
linkElement.click();
} catch (error) {
console.error('导出失败:', error);
alert('导出数据时出错');
}
}
// 导入处理
async function handleImport(e) {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async (e) => {
try {
const importedData = JSON.parse(e.target.result);
if (confirm('确定要导入数据吗?这将覆盖当前所有导航数据。')) {
const response = await fetchWithAuth('/api/import', {
method: 'POST',
body: JSON.stringify(importedData)
});
if (!response.ok) throw new Error('导入失败');
alert('数据导入成功!');
loadSites();
}
} catch (error) {
console.error('导入失败:', error);
alert('导入数据时出错: ' + error.message);
}
};
reader.readAsText(file);
// 清空input以便可以重复导入同一文件
e.target.value = '';
}
// 切换主题
function toggleTheme() {
currentTheme = currentTheme === 'light' ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', currentTheme);
localStorage.setItem('theme', currentTheme);
themeBtn.innerHTML = currentTheme === 'light' ? '<i class="fas fa-moon"></i>' : '<i class="fas fa-sun"></i>';
}
// 带认证的fetch
async function fetchWithAuth(url, options = {}) {
const headers = {
'Content-Type': 'application/json',
...options.headers
};
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
return fetch(url, {
...options,
headers
});
}
// 加载网站数据
async function loadSites() {
categoriesContainer.innerHTML = '<div class="loading">加载中...</div>';
try {
const response = await fetchWithAuth('/api/sites');
if (!response.ok) throw new Error('Network response was not ok');
const sites = await response.json();
renderSites(sites);
} catch (error) {
console.error('Error loading sites:', error);
categoriesContainer.innerHTML = '<div class="error">加载失败,请刷新重试</div>';
}
}
// 渲染网站
function renderSites(sites) {
if (!sites || sites.length === 0) {
categoriesContainer.innerHTML = '<p>暂无网站,请添加您的第一个网站。</p>';
return;
}
// 按分类分组
const categories = {};
sites.forEach(site => {
if (!categories[site.category]) {
categories[site.category] = [];
}
categories[site.category].push(site);
});
// 清空容器
categoriesContainer.innerHTML = '';
// 渲染每个分类
for (const category in categories) {
const categoryElement = document.createElement('div');
categoryElement.className = 'category';
const categoryTitle = document.createElement('h2');
categoryTitle.innerHTML = `<i class="fas fa-folder"></i> ${category}`;
const sitesContainer = document.createElement('div');
sitesContainer.className = 'sites';
categories[category].forEach(site => {
const siteElement = document.createElement('a');
siteElement.className = 'site';
siteElement.href = site.url;
siteElement.target = '_blank';
siteElement.dataset.id = site.id;
siteElement.dataset.name = site.name.toLowerCase();
siteElement.dataset.url = site.url.toLowerCase();
siteElement.draggable = isAdmin;
const iconElement = document.createElement('div');
iconElement.className = 'site-icon';
if (site.icon) {
const img = document.createElement('img');
img.src = site.icon;
img.alt = site.name;
iconElement.appendChild(img);
} else {
iconElement.textContent = site.name.charAt(0).toUpperCase();
}
const nameElement = document.createElement('span');
nameElement.className = 'site-name';
nameElement.textContent = site.name;
// 管理员功能:删除按钮
if (isAdmin) {
const deleteBtn = document.createElement('button');
deleteBtn.className = 'delete-btn';
deleteBtn.innerHTML = '×';
deleteBtn.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
if (confirm(`确定要删除 "${site.name}" 吗?`)) {
try {
const response = await fetchWithAuth('/api/sites/delete', {
method: 'POST',
body: JSON.stringify({ id: site.id })
});
if (response.ok) {
loadSites();
} else {
throw new Error('删除失败');
}
} catch (error) {
console.error('Error deleting site:', error);
alert('删除网站时出错');
}
}
});
siteElement.appendChild(deleteBtn);
}
siteElement.appendChild(iconElement);
siteElement.appendChild(nameElement);
sitesContainer.appendChild(siteElement);
});
categoryElement.appendChild(categoryTitle);
categoryElement.appendChild(sitesContainer);
categoriesContainer.appendChild(categoryElement);
}
// 如果是管理员,设置拖拽功能
if (isAdmin) {
setupDragAndDrop();
}
}
});

View File

@@ -2,6 +2,7 @@
--primary-color: #3498db; --primary-color: #3498db;
--secondary-color: #2ecc71; --secondary-color: #2ecc71;
--danger-color: #e74c3c; --danger-color: #e74c3c;
--warning-color: #f39c12;
--text-color: #333; --text-color: #333;
--bg-color: #f5f5f5; --bg-color: #f5f5f5;
--card-color: #fff; --card-color: #fff;
@@ -13,6 +14,7 @@
--primary-color: #2980b9; --primary-color: #2980b9;
--secondary-color: #27ae60; --secondary-color: #27ae60;
--danger-color: #c0392b; --danger-color: #c0392b;
--warning-color: #d35400;
--text-color: #f5f5f5; --text-color: #f5f5f5;
--bg-color: #121212; --bg-color: #121212;
--card-color: #1e1e1e; --card-color: #1e1e1e;
@@ -53,12 +55,16 @@ header {
.header-left, .header-right { .header-left, .header-right {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 20px; gap: 15px;
flex-wrap: wrap;
} }
h1 { h1 {
font-size: 2rem; font-size: 2rem;
color: var(--primary-color); color: var(--primary-color);
display: flex;
align-items: center;
gap: 10px;
} }
.search-box { .search-box {
@@ -84,13 +90,7 @@ h1 {
opacity: 0.7; opacity: 0.7;
} }
.actions { button {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.actions button {
padding: 8px 16px; padding: 8px 16px;
background-color: var(--primary-color); background-color: var(--primary-color);
color: white; color: white;
@@ -100,23 +100,33 @@ h1 {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 5px; gap: 5px;
}
.actions button:hover {
opacity: 0.9;
}
.actions button i {
font-size: 0.9rem; font-size: 0.9rem;
} }
.theme-switcher button { button:hover {
background: none; opacity: 0.9;
border: none; }
color: var(--text-color);
font-size: 1.2rem; button i {
cursor: pointer; font-size: 0.9rem;
padding: 5px; }
#loginBtn, #logoutBtn {
background-color: var(--secondary-color);
}
#logoutBtn {
background-color: var(--warning-color);
}
.loading, .error {
text-align: center;
padding: 20px;
color: var(--primary-color);
}
.error {
color: var(--danger-color);
} }
.categories { .categories {
@@ -154,7 +164,7 @@ h1 {
align-items: center; align-items: center;
text-decoration: none; text-decoration: none;
color: var(--text-color); color: var(--text-color);
transition: transform 0.2s; transition: all 0.2s;
position: relative; position: relative;
padding: 10px; padding: 10px;
border-radius: 8px; border-radius: 8px;
@@ -180,6 +190,13 @@ h1 {
color: var(--primary-color); color: var(--primary-color);
} }
.site-icon img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
.site-name { .site-name {
text-align: center; text-align: center;
font-size: 0.9rem; font-size: 0.9rem;
@@ -200,12 +217,26 @@ h1 {
font-size: 12px; font-size: 12px;
cursor: pointer; cursor: pointer;
display: none; display: none;
align-items: center;
justify-content: center;
} }
.site:hover .delete-btn { .site:hover .delete-btn {
display: flex; display: flex;
align-items: center; }
justify-content: center;
/* 拖拽相关样式 */
.site[draggable="true"] {
cursor: grab;
}
.site[draggable="true"]:active {
cursor: grabbing;
}
.site.dragging {
opacity: 0.5;
background-color: var(--primary-color);
} }
/* 模态框样式 */ /* 模态框样式 */
@@ -269,8 +300,7 @@ h1 {
gap: 8px; gap: 8px;
} }
.form-group input, .form-group input {
.form-group select {
width: 100%; width: 100%;
padding: 10px; padding: 10px;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
@@ -298,73 +328,6 @@ form button:hover {
opacity: 0.9; opacity: 0.9;
} }
/* 分类管理样式 */
.category-list {
margin-bottom: 20px;
max-height: 300px;
overflow-y: auto;
}
.category-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
margin-bottom: 10px;
background-color: var(--bg-color);
border-radius: 4px;
}
.category-item span {
flex-grow: 1;
}
.category-item button {
background-color: var(--danger-color);
color: white;
border: none;
border-radius: 4px;
padding: 5px 10px;
cursor: pointer;
}
.add-category {
display: flex;
gap: 10px;
}
.add-category input {
flex-grow: 1;
padding: 10px;
border: 1px solid var(--border-color);
border-radius: 4px;
background-color: var(--bg-color);
color: var(--text-color);
}
.add-category button {
background-color: var(--secondary-color);
color: white;
border: none;
padding: 10px 15px;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
gap: 5px;
}
/* 拖拽样式 */
.sortable-ghost {
opacity: 0.5;
background: var(--primary-color);
}
.sortable-chosen {
box-shadow: 0 0 10px var(--shadow-color);
}
/* 响应式设计 */
@media (max-width: 768px) { @media (max-width: 768px) {
header { header {
flex-direction: column; flex-direction: column;
@@ -382,12 +345,8 @@ form button:hover {
width: 100%; width: 100%;
} }
.actions { .header-right button {
justify-content: space-between; width: 100%;
}
.actions button {
flex-grow: 1;
justify-content: center; justify-content: center;
} }
@@ -395,7 +354,7 @@ form button:hover {
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
} }
.add-category { .modal-content {
flex-direction: column; margin: 20% auto;
} }
} }