首次上传 tblog 博客项目完整代码

包含后台管理、前端页面和数据库文件
This commit is contained in:
zhang-pu 2026-06-01 10:08:11 +08:00
parent fdeba3dbbf
commit 2a5bdcbc4e
30 changed files with 4233 additions and 0 deletions

365
admin/assets/admin.css Normal file
View file

@ -0,0 +1,365 @@
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f7fa;
color: #333;
}
.admin-container {
display: flex;
min-height: 100vh;
}
.sidebar {
width: 240px;
background: #1a1a2e;
color: #fff;
padding: 20px 0;
position: fixed;
height: 100vh;
overflow-y: auto;
}
.logo {
font-size: 20px;
font-weight: 700;
padding: 0 20px 20px;
border-bottom: 1px solid rgba(255,255,255,0.1);
margin-bottom: 20px;
}
.sidebar nav a {
display: block;
padding: 12px 20px;
color: rgba(255,255,255,0.7);
text-decoration: none;
transition: all 0.3s;
}
.sidebar nav a:hover,
.sidebar nav a.active {
background: rgba(34, 197, 94, 0.2);
color: #22c55e;
}
.content {
flex: 1;
margin-left: 240px;
padding: 30px;
}
h1 {
font-size: 28px;
margin-bottom: 30px;
color: #1a1a2e;
}
h2 {
font-size: 20px;
margin: 30px 0 15px;
color: #333;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background: #fff;
padding: 24px;
border-radius: 12px;
display: flex;
align-items: center;
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
}
.stat-icon {
font-size: 40px;
margin-right: 20px;
}
.stat-value {
font-size: 32px;
font-weight: 700;
color: #22c55e;
}
.stat-label {
color: #666;
font-size: 14px;
}
.table {
width: 100%;
background: #fff;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
border-collapse: collapse;
}
.table th,
.table td {
padding: 16px 20px;
text-align: left;
border-bottom: 1px solid #eee;
}
.table th {
background: #f8f9fa;
font-weight: 600;
color: #555;
}
.table tr:last-child td {
border-bottom: none;
}
.badge {
display: inline-block;
padding: 4px 10px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
}
.badge-success {
background: #dcfce7;
color: #16a34a;
}
.badge-draft {
background: #fef3c7;
color: #d97706;
}
.badge-pending {
background: #fef3c7;
color: #d97706;
}
.badge-approved {
background: #dcfce7;
color: #16a34a;
}
.badge-rejected {
background: #fee2e2;
color: #dc2626;
}
.btn {
display: inline-block;
padding: 8px 16px;
background: #22c55e;
color: #fff;
text-decoration: none;
border-radius: 6px;
font-size: 14px;
transition: background 0.3s;
border: none;
cursor: pointer;
}
.btn:hover {
background: #16a34a;
}
.btn-sm {
padding: 6px 12px;
font-size: 12px;
}
.btn-primary {
background: #22c55e;
}
.btn-danger {
background: #dc2626;
}
.btn-danger:hover {
background: #b91c1c;
}
.btn-link {
background: transparent;
color: #22c55e;
}
.btn-link:hover {
background: #f0fdf4;
}
.actions {
margin-top: 20px;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #333;
}
input[type="text"],
input[type="url"],
input[type="number"],
input[type="password"],
textarea,
select {
width: 100%;
padding: 12px 16px;
border: 2px solid #e5e5e5;
border-radius: 8px;
font-size: 14px;
transition: border-color 0.3s;
font-family: inherit;
}
input:focus,
textarea:focus,
select:focus {
outline: none;
border-color: #22c55e;
}
textarea {
min-height: 120px;
resize: vertical;
}
.form-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
}
.form-actions {
display: flex;
gap: 10px;
margin-top: 20px;
}
.back-link {
margin-bottom: 20px;
}
.back-link a {
color: #666;
text-decoration: none;
font-size: 14px;
}
.back-link a:hover {
color: #22c55e;
}
.success {
background: #dcfce7;
color: #16a34a;
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 20px;
}
.error {
background: #fee2e2;
color: #dc2626;
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 20px;
}
.search-box {
margin-bottom: 20px;
}
.search-box input {
max-width: 300px;
}
.status-toggle {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.status-toggle label {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
}
.switch {
position: relative;
width: 48px;
height: 26px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #ccc;
transition: 0.3s;
border-radius: 26px;
}
.slider:before {
position: absolute;
content: "";
height: 20px;
width: 20px;
left: 3px;
bottom: 3px;
background: white;
transition: 0.3s;
border-radius: 50%;
}
input:checked + .slider {
background: #22c55e;
}
input:checked + .slider:before {
transform: translateX(22px);
}
@media (max-width: 768px) {
.sidebar {
width: 60px;
}
.logo {
font-size: 14px;
padding: 0 10px 10px;
}
.sidebar nav a {
padding: 12px 10px;
text-align: center;
}
.sidebar nav a span {
display: none;
}
.content {
margin-left: 60px;
}
}

169
admin/category.php Normal file
View file

@ -0,0 +1,169 @@
<?php
/**
* Admin Category Management
*/
require_once __DIR__ . '/../config.php';
require_once INCLUDE_PATH . 'function.common.php';
require_once INCLUDE_PATH . 'function.category.php';
auth_check();
$message = '';
$edit_category = null;
// Handle form submission
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$data = [
'name' => trim($_POST['name'] ?? ''),
'slug' => trim($_POST['slug'] ?? ''),
'description' => trim($_POST['description'] ?? ''),
'parent_id' => intval($_POST['parent_id'] ?? 0),
'order_num' => intval($_POST['order_num'] ?? 0)
];
if (empty($data['name']) || empty($data['slug'])) {
$message = '<div class="error">名称和别名不能为空</div>';
} else {
if (!empty($_POST['id'])) {
update_category(intval($_POST['id']), $data);
$message = '<div class="success">分类已更新</div>';
} else {
create_category($data);
$message = '<div class="success">分类已创建</div>';
}
}
}
// Load category for editing
if (!empty($_GET['edit'])) {
$edit_category = get_category_by_id(intval($_GET['edit']));
}
// Handle delete
if (!empty($_GET['delete'])) {
$count = get_category_count(intval($_GET['delete']));
if ($count > 0) {
$message = '<div class="error">该分类下有 ' . $count . ' 篇文章,无法删除</div>';
} else {
delete_category(intval($_GET['delete']));
$message = '<div class="success">分类已删除</div>';
}
}
// Get all categories
$categories = get_categories();
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>分类管理 - ZhangPu Blog</title>
<link rel="stylesheet" href="assets/admin.css">
</head>
<body>
<div class="admin-container">
<aside class="sidebar">
<div class="logo">🌿 ZhangPu Blog</div>
<nav>
<a href="index.php">📊 概览</a>
<a href="post.php">📝 文章管理</a>
<a href="category.php" class="active">📁 分类管理</a>
<a href="comment.php">💬 评论管理</a>
<a href="settings.php">⚙️ 设置</a>
<a href="logout.php">🚪 退出</a>
<a href="../">👁️ 查看博客</a>
</nav>
</aside>
<main class="content">
<div class="back-link">
<a href="index.php">← 返回概览</a>
<a href="?new=1" style="margin-left: 15px;">+ 添加分类</a>
</div>
<?php echo $message; ?>
<?php if (!empty($_GET['new']) || $edit_category): ?>
<h1><?php echo $edit_category ? '编辑分类' : '添加分类'; ?></h1>
<form method="POST">
<?php if ($edit_category): ?>
<input type="hidden" name="id" value="<?php echo $edit_category['id']; ?>">
<?php endif; ?>
<div class="form-group">
<label for="name">名称</label>
<input type="text" name="name" id="name" value="<?php echo e($edit_category['name'] ?? ''); ?>" required>
</div>
<div class="form-group">
<label for="slug">别名</label>
<input type="text" name="slug" id="slug" value="<?php echo e($edit_category['slug'] ?? ''); ?>" placeholder="如: tech" required>
</div>
<div class="form-group">
<label for="description">描述</label>
<textarea name="description" id="description" rows="3"><?php echo e($edit_category['description'] ?? ''); ?></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label for="parent_id">父分类</label>
<select name="parent_id" id="parent_id">
<option value="0">无</option>
<?php foreach ($categories as $cat): ?>
<?php if (!$edit_category || $cat['id'] != $edit_category['id']): ?>
<option value="<?php echo $cat['id']; ?>" <?php echo ($edit_category['parent_id'] ?? 0) == $cat['id'] ? 'selected' : ''; ?>><?php echo e($cat['name']); ?></option>
<?php endif; ?>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label for="order_num">排序</label>
<input type="number" name="order_num" id="order_num" value="<?php echo e($edit_category['order_num'] ?? 0); ?>">
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">保存</button>
<a href="category.php" class="btn">取消</a>
</div>
</form>
<?php else: ?>
<h1>分类管理</h1>
<table class="table">
<thead>
<tr>
<th>名称</th>
<th>别名</th>
<th>描述</th>
<th>文章数</th>
<th>排序</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<?php foreach ($categories as $cat): ?>
<tr>
<td><?php echo e($cat['name']); ?></td>
<td><?php echo e($cat['slug']); ?></td>
<td><?php echo e($cat['description']); ?></td>
<td><?php echo get_category_count($cat['id']); ?></td>
<td><?php echo $cat['order_num']; ?></td>
<td>
<a href="?edit=<?php echo $cat['id']; ?>" class="btn btn-sm">编辑</a>
<a href="?delete=<?php echo $cat['id']; ?>" class="btn btn-sm btn-danger" onclick="return confirm('确定要删除吗?')">删除</a>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($categories)): ?>
<tr>
<td colspan="6" style="text-align: center; padding: 40px;">暂无分类</td>
</tr>
<?php endif; ?>
</tbody>
</table>
<?php endif; ?>
</main>
</div>
</body>
</html>

158
admin/comment.php Normal file
View file

@ -0,0 +1,158 @@
<?php
/**
* Admin Comment Management
*/
require_once __DIR__ . '/../config.php';
require_once INCLUDE_PATH . 'function.common.php';
require_once INCLUDE_PATH . 'function.comment.php';
auth_check();
$message = '';
// Handle status update
if (!empty($_POST['action'])) {
csrf_verify();
$id = intval($_POST['id'] ?? 0);
if ($id > 0) {
if ($_POST['action'] === 'approve') {
update_comment_status($id, 'approved');
$message = '<div class="success">评论已通过</div>';
} elseif ($_POST['action'] === 'reject') {
update_comment_status($id, 'rejected');
$message = '<div class="success">评论已拒绝</div>';
} elseif ($_POST['action'] === 'delete') {
delete_comment($id);
$message = '<div class="success">评论已删除</div>';
}
}
}
// Filter
$status_filter = $_GET['status'] ?? null;
$comments = get_all_comments($status_filter);
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>评论管理 - ZhangPu Blog</title>
<link rel="stylesheet" href="assets/admin.css">
</head>
<body>
<div class="admin-container">
<aside class="sidebar">
<div class="logo">🌿 ZhangPu Blog</div>
<nav>
<a href="index.php">📊 概览</a>
<a href="post.php">📝 文章管理</a>
<a href="category.php">📁 分类管理</a>
<a href="comment.php" class="active">💬 评论管理</a>
<a href="settings.php">⚙️ 设置</a>
<a href="logout.php">🚪 退出</a>
<a href="../">👁️ 查看博客</a>
</nav>
</aside>
<main class="content">
<div class="back-link">
<a href="index.php">← 返回概览</a>
</div>
<?php echo $message; ?>
<h1>评论管理</h1>
<div class="filter-tabs">
<a href="comment.php" class="<?php echo !$status_filter ? 'active' : ''; ?>">全部</a>
<a href="comment.php?status=pending" class="<?php echo $status_filter === 'pending' ? 'active' : ''; ?>">待审核</a>
<a href="comment.php?status=approved" class="<?php echo $status_filter === 'approved' ? 'active' : ''; ?>">已通过</a>
<a href="comment.php?status=rejected" class="<?php echo $status_filter === 'rejected' ? 'active' : ''; ?>">已拒绝</a>
</div>
<table class="table">
<thead>
<tr>
<th>文章</th>
<th>作者</th>
<th>内容</th>
<th>状态</th>
<th>时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<?php foreach ($comments as $comment): ?>
<tr>
<td><a href="../post/<?php echo e($comment['post_slug']); ?>" target="_blank"><?php echo e($comment['post_title']); ?></a></td>
<td>
<?php echo e($comment['author']); ?>
<?php if ($comment['email']): ?>
<br><small style="color:#999;"><?php echo e($comment['email']); ?></small>
<?php endif; ?>
</td>
<td style="max-width: 200px;">
<div style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"><?php echo e($comment['content']); ?></div>
</td>
<td><span class="badge badge-<?php echo $comment['status']; ?>"><?php
$status_map = ['pending'=>'待审核','approved'=>'已通过','rejected'=>'已拒绝'];
echo $status_map[$comment['status']];
?></span></td>
<td><?php echo date('Y-m-d H:i', strtotime($comment['created_at'])); ?></td>
<td>
<?php if ($comment['status'] === 'pending'): ?>
<form method="POST" style="display:inline;">
<?php echo csrf_field(); ?>
<input type="hidden" name="id" value="<?php echo $comment['id']; ?>">
<input type="hidden" name="action" value="approve">
<button type="submit" class="btn btn-sm">通过</button>
</form>
<?php endif; ?>
<?php if ($comment['status'] !== 'rejected'): ?>
<form method="POST" style="display:inline;">
<?php echo csrf_field(); ?>
<input type="hidden" name="id" value="<?php echo $comment['id']; ?>">
<input type="hidden" name="action" value="reject">
<button type="submit" class="btn btn-sm">拒绝</button>
</form>
<?php endif; ?>
<form method="POST" style="display:inline;" onsubmit="return confirm('确定要删除吗?')">
<?php echo csrf_field(); ?>
<input type="hidden" name="id" value="<?php echo $comment['id']; ?>">
<input type="hidden" name="action" value="delete">
<button type="submit" class="btn btn-sm btn-danger">删除</button>
</form>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($comments)): ?>
<tr>
<td colspan="6" style="text-align: center; padding: 40px;">暂无评论</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</main>
</div>
<style>
.filter-tabs {
margin-bottom: 20px;
}
.filter-tabs a {
display: inline-block;
padding: 8px 16px;
margin-right: 10px;
background: #e5e5e5;
color: #666;
text-decoration: none;
border-radius: 6px;
font-size: 14px;
}
.filter-tabs a:hover,
.filter-tabs a.active {
background: #22c55e;
color: #fff;
}
</style>
</body>
</html>

99
admin/index.php Normal file
View file

@ -0,0 +1,99 @@
<?php
/**
* Admin Dashboard
*/
require_once __DIR__ . '/../config.php';
require_once INCLUDE_PATH . 'function.common.php';
require_once INCLUDE_PATH . 'function.post.php';
require_once INCLUDE_PATH . 'function.category.php';
require_once INCLUDE_PATH . 'function.comment.php';
auth_check();
$total_posts = get_total_posts();
$categories = get_categories();
$total_comments = get_comment_count();
$recent_posts = get_posts(1, 5);
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>管理后台 - ZhangPu Blog</title>
<link rel="stylesheet" href="assets/admin.css">
</head>
<body>
<div class="admin-container">
<aside class="sidebar">
<div class="logo">🌿 ZhangPu Blog</div>
<nav>
<a href="index.php" class="active">📊 概览</a>
<a href="post.php">📝 文章管理</a>
<a href="category.php">📁 分类管理</a>
<a href="comment.php">💬 评论管理</a>
<a href="settings.php">⚙️ 设置</a>
<a href="logout.php">🚪 退出</a>
<a href="../">👁️ 查看博客</a>
</nav>
</aside>
<main class="content">
<h1>管理后台</h1>
<div class="stats">
<div class="stat-card">
<div class="stat-icon">📝</div>
<div class="stat-info">
<div class="stat-value"><?php echo $total_posts; ?></div>
<div class="stat-label">文章总数</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">📁</div>
<div class="stat-info">
<div class="stat-value"><?php echo count($categories); ?></div>
<div class="stat-label">分类数量</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">💬</div>
<div class="stat-info">
<div class="stat-value"><?php echo $total_comments; ?></div>
<div class="stat-label">评论总数</div>
</div>
</div>
</div>
<h2>最新文章</h2>
<div class="actions">
<a href="post.php?new=1" class="btn btn-primary">写新文章</a>
</div>
<table class="table">
<thead>
<tr>
<th>标题</th>
<th>分类</th>
<th>状态</th>
<th>发布时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<?php foreach ($recent_posts as $post): ?>
<tr>
<td><?php echo e($post['title']); ?></td>
<td><?php echo e($post['category_name']); ?></td>
<td><span class="badge badge-<?php echo $post['status'] === 'published' ? 'success' : 'draft'; ?>"><?php echo $post['status'] === 'published' ? '已发布' : '草稿'; ?></span></td>
<td><?php echo date('Y-m-d', strtotime($post['created_at'])); ?></td>
<td>
<a href="post.php?edit=<?php echo $post['id']; ?>" class="btn btn-sm">编辑</a>
<a href="../post/<?php echo e($post['slug']); ?>" target="_blank" class="btn btn-sm btn-link">查看</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</main>
</div>
</body>
</html>

135
admin/login.php Normal file
View file

@ -0,0 +1,135 @@
<?php
/**
* Admin Login
*/
require_once __DIR__ . '/../config.php';
require_once INCLUDE_PATH . 'function.common.php';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_verify();
$username = trim($_POST['username'] ?? '');
$password = $_POST['password'] ?? '';
if (auth_verify($username, $password)) {
$_SESSION['admin'] = true;
$_SESSION['admin_user'] = $username;
// Rotate CSRF token on login
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
redirect('index.php');
} else {
$error = '用户名或密码错误';
}
}
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录 - ZhangPu Blog 管理后台</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #22c55e 0%, #0ea5e9 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.login-box {
background: #fff;
padding: 40px;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
width: 100%;
max-width: 400px;
}
h1 {
color: #22c55e;
text-align: center;
margin-bottom: 30px;
font-size: 24px;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 8px;
color: #333;
font-weight: 500;
}
input[type="text"],
input[type="password"] {
width: 100%;
padding: 12px 16px;
border: 2px solid #e5e5e5;
border-radius: 8px;
font-size: 16px;
transition: border-color 0.3s;
}
input:focus {
outline: none;
border-color: #22c55e;
}
button {
width: 100%;
padding: 14px;
background: #22c55e;
color: #fff;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: background 0.3s;
}
button:hover {
background: #16a34a;
}
.error {
background: #fef2f2;
color: #dc2626;
padding: 12px;
border-radius: 8px;
margin-bottom: 20px;
text-align: center;
}
.back-link {
text-align: center;
margin-top: 20px;
}
.back-link a {
color: #666;
text-decoration: none;
}
.back-link a:hover {
color: #22c55e;
}
</style>
</head>
<body>
<div class="login-box">
<h1>🌿 ZhangPu Blog 管理后台</h1>
<?php if (!empty($error)): ?>
<div class="error"><?php echo e($error); ?></div>
<?php endif; ?>
<form method="POST">
<input type="hidden" name="csrf_token" value="<?php echo csrf_token(); ?>">
<div class="form-group">
<label for="username">用户名</label>
<input type="text" name="username" id="username" placeholder="请输入用户名" required autofocus>
</div>
<div class="form-group">
<label for="password">管理密码</label>
<input type="password" name="password" id="password" placeholder="请输入管理密码" required>
</div>
<button type="submit">登 录</button>
</form>
<div class="back-link">
<a href="../">← 返回博客</a>
</div>
</div>
</body>
</html>

9
admin/logout.php Normal file
View file

@ -0,0 +1,9 @@
<?php
/**
* Admin Logout
*/
session_start();
$_SESSION['admin'] = false;
session_destroy();
header('Location: login.php');
exit;

379
admin/post.php Normal file
View file

@ -0,0 +1,379 @@
<?php
/**
* Admin Post Management
*/
require_once __DIR__ . '/../config.php';
require_once INCLUDE_PATH . 'function.common.php';
require_once INCLUDE_PATH . 'function.post.php';
require_once INCLUDE_PATH . 'function.category.php';
auth_check();
// Pagination
$page = max(1, intval($_GET['page'] ?? 1));
$per_page = 20;
$total = get_total_posts();
$total_pages = max(1, ceil($total / $per_page));
$all_posts = get_posts($page, $per_page);
$message = '';
$edit_post = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_verify();
$data = [
'title' => trim($_POST['title'] ?? ''),
'content' => trim($_POST['content'] ?? ''),
'excerpt' => trim($_POST['excerpt'] ?? ''),
'slug' => trim($_POST['slug'] ?? '') ?: generate_slug($_POST['title'] ?? ''),
'category_id' => intval($_POST['category_id'] ?? 1),
'template' => $_POST['template'] ?? 'flow',
'status' => $_POST['status'] ?? 'published'
];
// Sanitize rich-text content to prevent XSS
$data['content'] = sanitize_html($data['content']);
if (empty($data['title']) || empty($data['content'])) {
$message = '<div class="error">标题和内容不能为空</div>';
} else {
// Check slug uniqueness (only if slug changed or new post)
$existing = null;
if (!empty($_POST['id'])) {
$existing = get_post_by_id(intval($_POST['id']));
}
$slug_check = $data['slug'];
$db = get_db();
$stmt = $db->prepare("SELECT id FROM posts WHERE slug = ? AND deleted_at IS NULL AND id != ?");
$stmt->bind_param('si', $slug_check, $existing['id'] ?? 0);
$stmt->execute();
$res = $stmt->get_result();
if ($res->num_rows > 0) {
$data['slug'] = $data['slug'] . '-' . time();
}
if (!empty($_POST['id'])) {
update_post(intval($_POST['id']), $data);
$message = '<div class="success">文章已更新</div>';
} else {
create_post($data);
$message = '<div class="success">文章已创建</div>';
}
}
}
if (!empty($_GET['edit'])) {
$edit_post = get_post_by_id(intval($_GET['edit']));
}
if (!empty($_GET['delete'])) {
csrf_verify('BOTH');
delete_post(intval($_GET['delete']));
$message = '<div class="success">文章已删除</div>';
}
$categories = get_categories();
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>文章管理 - ZhangPu Blog</title>
<link rel="stylesheet" href="assets/admin.css">
<style>
.editor-wrap { border: 1px solid #ddd; border-radius: 8px; overflow: hidden; margin-bottom: 20px; }
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 10px 14px;
border-bottom: 2px solid #e5e5e5;
background: #fafafa;
}
.toolbar button {
width: 34px; height: 34px;
border: 1px solid #e0e0e0;
background: #fff;
border-radius: 6px;
cursor: pointer;
font-size: 15px;
display: flex; align-items: center; justify-content: center;
transition: all 0.2s;
}
.toolbar button:hover { background: #22c55e; color: #fff; border-color: #22c55e; }
.toolbar .sep { width: 1px; height: 24px; background: #e0e0e0; margin: 5px 4px; }
.toolbar select { height: 34px; padding: 0 8px; border: 1px solid #e0e0e0; border-radius: 6px; background: #fff; font-size: 13px; cursor: pointer; }
.color-wrap { position: relative; }
.color-picker {
width: 34px; height: 34px; border: 1px solid #e0e0e0; border-radius: 6px; cursor: pointer; padding: 2px; background: #fff;
}
.color-picker::-webkit-color-swatch-wrapper { padding: 2px; }
.color-picker::-webkit-color-swatch { border: none; border-radius: 4px; }
#editor {
min-height: 400px;
padding: 16px 20px;
outline: none;
font-size: 15px;
line-height: 1.8;
}
#editor img { max-width: 100%; height: auto; border-radius: 8px; margin: 10px 0; }
#editor blockquote { border-left: 4px solid #22c55e; padding-left: 16px; margin: 10px 0; color: #555; }
#editor pre { background: #1e1e2e; color: #e0e0e8; padding: 16px; border-radius: 8px; overflow-x: auto; font-family: monospace; }
#editor ul, #editor ol { padding-left: 28px; margin: 8px 0; }
.footer-bar { padding: 10px 14px; border-top: 1px solid #e5e5e5; background: #fafafa; display: flex; justify-content: space-between; align-items: center; font-size: 12px; color: #888; }
.footer-bar .word-count span { color: #22c55e; font-weight: bold; }
.pagination { margin-top: 20px; display: flex; gap: 4px; align-items: center; }
.pagination a, .pagination span { padding: 6px 12px; border: 1px solid #e5e5e5; border-radius: 4px; text-decoration: none; color: #333; }
.pagination a:hover { background: #22c55e; color: #fff; border-color: #22c55e; }
.pagination .active { background: #22c55e; color: #fff; border-color: #22c55e; }
.pagination .disabled { color: #ccc; pointer-events: none; }
</style>
</head>
<body>
<div class="admin-container">
<aside class="sidebar">
<div class="logo">🌿 ZhangPu Blog</div>
<nav>
<a href="index.php">📊 概览</a>
<a href="post.php" class="active">📝 文章管理</a>
<a href="category.php">📁 分类管理</a>
<a href="comment.php">💬 评论管理</a>
<a href="settings.php">⚙️ 设置</a>
<a href="logout.php">🚪 退出</a>
<a href="../">👁️ 查看博客</a>
</nav>
</aside>
<main class="content">
<div class="back-link">
<a href="index.php">← 返回概览</a>
<a href="?new=1" style="margin-left: 15px;">+ 写新文章</a>
</div>
<?php echo $message; ?>
<?php if (!empty($_GET['new']) || $edit_post): ?>
<h1><?php echo $edit_post ? '编辑文章' : '写新文章'; ?></h1>
<form method="POST" class="post-form" onsubmit="return submitForm()">
<?php echo csrf_field(); ?>
<?php if ($edit_post): ?>
<input type="hidden" name="id" value="<?php echo $edit_post['id']; ?>">
<?php endif; ?>
<div class="form-group">
<label for="title">标题</label>
<input type="text" name="title" id="title" value="<?php echo e($edit_post['title'] ?? ''); ?>" required>
</div>
<div class="form-row">
<div class="form-group">
<label for="slug">URL别名</label>
<input type="text" name="slug" id="slug" value="<?php echo e($edit_post['slug'] ?? ''); ?>" placeholder="auto-generated">
</div>
<div class="form-group">
<label for="category_id">分类</label>
<select name="category_id" id="category_id">
<?php foreach ($categories as $cat): ?>
<option value="<?php echo $cat['id']; ?>" <?php echo ($edit_post['category_id'] ?? 1) == $cat['id'] ? 'selected' : ''; ?>><?php echo e($cat['name']); ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label for="template">模板</label>
<select name="template" id="template">
<option value="flow" <?php echo ($edit_post['template'] ?? 'flow') === 'flow' ? 'selected' : ''; ?>>Flow (浅色)</option>
<option value="magine" <?php echo ($edit_post['template'] ?? '') === 'magine' ? 'selected' : ''; ?>>Magine (深色)</option>
</select>
</div>
</div>
<div class="form-group">
<label for="excerpt">摘要</label>
<textarea name="excerpt" id="excerpt" rows="2"><?php echo e($edit_post['excerpt'] ?? ''); ?></textarea>
</div>
<div class="form-group">
<label>正文</label>
<div class="editor-wrap">
<div class="toolbar">
<select onchange="formatBlock(this.value)" title="标题">
<option value="p">正文</option>
<option value="h1">标题1</option>
<option value="h2">标题2</option>
<option value="h3">标题3</option>
</select>
<div class="sep"></div>
<button type="button" onclick="execCmd('bold')" title="加粗"><b>B</b></button>
<button type="button" onclick="execCmd('italic')" title="斜体"><i>I</i></button>
<button type="button" onclick="execCmd('underline')" title="下划线"><u>U</u></button>
<button type="button" onclick="execCmd('strikeThrough')" title="删除线"><s>S</s></button>
<div class="sep"></div>
<button type="button" onclick="execCmd('insertUnorderedList')" title="无序列表">•</button>
<button type="button" onclick="execCmd('insertOrderedList')" title="有序列表">1.</button>
<div class="sep"></div>
<div class="color-wrap">
<input type="color" class="color-picker" value="#333333" onchange="execForeColor(this.value)" title="文字颜色">
</div>
<div class="sep"></div>
<button type="button" onclick="execCmd('formatBlock', 'blockquote')" title="引用">❝</button>
<button type="button" onclick="formatBlock('pre')" title="代码块">&lt;/&gt;</button>
<button type="button" onclick="insertLink()" title="链接">🔗</button>
<button type="button" onclick="insertImage()" title="图片">🖼️</button>
<div class="sep"></div>
<button type="button" onclick="execCmd('justifyLeft')" title="左对齐">⬅</button>
<button type="button" onclick="execCmd('justifyCenter')" title="居中">⬛</button>
<button type="button" onclick="execCmd('justifyRight')" title="右对齐">➡</button>
<div class="sep"></div>
<button type="button" onclick="execCmd('removeFormat')" title="清除格式">🧹</button>
</div>
<div id="editor" contenteditable="true" oninput="updateCount()"><?php echo $edit_post['content'] ?? ''; ?></div>
<div class="footer-bar">
<span>字数:<span class="word-count"><span id="charCount">0</span> 字</span></span>
</div>
</div>
<input type="hidden" name="content" id="content" value="<?php echo e($edit_post['content'] ?? ''); ?>">
</div>
<div class="status-toggle">
<label>
<input type="radio" name="status" value="draft" <?php echo ($edit_post['status'] ?? 'published') === 'draft' ? 'checked' : ''; ?>>
草稿
</label>
<label>
<input type="radio" name="status" value="published" <?php echo ($edit_post['status'] ?? 'published') === 'published' ? 'checked' : ''; ?>>
发布
</label>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">保存</button>
<a href="post.php" class="btn">取消</a>
</div>
</form>
<?php else: ?>
<h1>文章管理 <small style="font-size:14px;color:#888;">(共 <?php echo $total; ?> 篇)</small></h1>
<table class="table">
<thead>
<tr>
<th>标题</th>
<th>分类</th>
<th>模板</th>
<th>状态</th>
<th>浏览</th>
<th>发布时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<?php foreach ($all_posts as $post): ?>
<tr>
<td><?php echo e($post['title']); ?></td>
<td><?php echo e($post['category_name']); ?></td>
<td><?php echo e($post['template']); ?></td>
<td><span class="badge badge-<?php echo $post['status'] === 'published' ? 'success' : 'draft'; ?>"><?php echo $post['status'] === 'published' ? '已发布' : '草稿'; ?></span></td>
<td><?php echo $post['views']; ?></td>
<td><?php echo date('Y-m-d', strtotime($post['created_at'])); ?></td>
<td>
<a href="?edit=<?php echo $post['id']; ?>" class="btn btn-sm">编辑</a>
<a href="../post/<?php echo e($post['slug']); ?>" target="_blank" class="btn btn-sm btn-link">查看</a>
<a href="?delete=<?php echo $post['id']; ?>&csrf_token=<?php echo csrf_token(); ?>" class="btn btn-sm btn-danger" onclick="return confirm('确定要删除吗?')">删除</a>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($all_posts)): ?>
<tr>
<td colspan="7" style="text-align: center; padding: 40px;">暂无文章</td>
</tr>
<?php endif; ?>
</tbody>
</table>
<?php if ($total_pages > 1): ?>
<div class="pagination">
<?php if ($page > 1): ?>
<a href="?page=<?php echo $page - 1; ?>">← 上一页</a>
<?php else: ?>
<span class="disabled">← 上一页</span>
<?php endif; ?>
<?php
$start = max(1, $page - 2);
$end = min($total_pages, $page + 2);
if ($start > 1) echo '<a href="?page=1">1</a>';
if ($start > 2) echo '<span>...</span>';
for ($i = $start; $i <= $end; $i++) {
if ($i == $page) {
echo '<span class="active">' . $i . '</span>';
} else {
echo '<a href="?page=' . $i . '">' . $i . '</a>';
}
}
if ($end < $total_pages) echo '<span>...</span>';
if ($end < $total_pages) echo '<a href="?page=' . $total_pages . '">' . $total_pages . '</a>';
?>
<?php if ($page < $total_pages): ?>
<a href="?page=<?php echo $page + 1; ?>">下一页 →</a>
<?php else: ?>
<span class="disabled">下一页 →</span>
<?php endif; ?>
</div>
<?php endif; ?>
<?php endif; ?>
</main>
</div>
<script>
const editor = document.getElementById('editor');
const contentInput = document.getElementById('content');
function execCmd(cmd, val) {
document.execCommand(cmd, false, val || null);
editor.focus();
}
function execForeColor(color) {
document.execCommand('foreColor', false, color);
}
function formatBlock(tag) {
if (tag === 'blockquote' || tag === 'pre') {
execCmd('formatBlock', '<' + tag + '>');
} else {
execCmd('formatBlock', '<' + tag + '>');
}
}
function insertLink() {
const url = prompt('输入链接地址:', 'https://');
if (url) execCmd('createLink', url);
}
function insertImage() {
const url = prompt('输入图片链接地址:', 'https://');
if (url) execCmd('insertImage', url);
}
function updateCount() {
const text = editor.innerText.replace(/\s/g, '');
document.getElementById('charCount').textContent = text.length;
}
function submitForm() {
contentInput.value = editor.innerHTML;
return true;
}
document.addEventListener('keydown', function(e) {
if (e.ctrlKey && e.key === 'b') { e.preventDefault(); execCmd('bold'); }
if (e.ctrlKey && e.key === 'i') { e.preventDefault(); execCmd('italic'); }
if (e.ctrlKey && e.key === 'u') { e.preventDefault(); execCmd('underline'); }
});
updateCount();
</script>
</body>
</html>

153
admin/settings.php Normal file
View file

@ -0,0 +1,153 @@
<?php
/**
* Admin Settings
*/
require_once __DIR__ . '/../config.php';
require_once INCLUDE_PATH . 'function.common.php';
auth_check();
$message = '';
// Handle form submission
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_verify();
// Admin credentials change
if (!empty($_POST['new_username']) || !empty($_POST['new_password'])) {
$new_username = trim($_POST['new_username'] ?? '');
$new_password = trim($_POST['new_password'] ?? '');
$current_pass = $_POST['current_password'] ?? '';
// Verify current password first
if (!auth_verify($_SESSION['admin_user'] ?? '', $current_pass)) {
$message = '<div class="error">当前密码错误</div>';
} else {
if (!empty($new_username)) {
update_setting('admin_user', $new_username);
}
if (!empty($new_password)) {
set_admin_password($new_password);
}
$message = '<div class="success">账号信息已更新(下次登录生效)</div>';
}
} else {
// Site settings
update_setting('site_name', trim($_POST['site_name'] ?? ''));
update_setting('site_description', trim($_POST['site_description'] ?? ''));
update_setting('template', $_POST['template'] ?? 'flow');
update_setting('posts_per_page', max(1, intval($_POST['posts_per_page'] ?? 10)));
$message = '<div class="success">设置已保存</div>';
}
}
// Load settings
$site_name = get_setting('site_name', '张璞博客');
$site_description = get_setting('site_description', '一个简洁优雅的博客');
$template = get_setting('template', 'flow');
$posts_per_page = get_setting('posts_per_page', '10');
$admin_user = get_admin_user();
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>设置 - ZhangPu Blog</title>
<link rel="stylesheet" href="assets/admin.css">
</head>
<body>
<div class="admin-container">
<aside class="sidebar">
<div class="logo">🌿 ZhangPu Blog</div>
<nav>
<a href="index.php">📊 概览</a>
<a href="post.php">📝 文章管理</a>
<a href="category.php">📁 分类管理</a>
<a href="comment.php">💬 评论管理</a>
<a href="settings.php" class="active">⚙️ 设置</a>
<a href="logout.php">🚪 退出</a>
<a href="../">👁️ 查看博客</a>
</nav>
</aside>
<main class="content">
<div class="back-link">
<a href="index.php">← 返回概览</a>
</div>
<?php echo $message; ?>
<h1>修改登录账号</h1>
<form method="POST" class="settings-form" style="margin-bottom: 40px; max-width: 600px;">
<?php echo csrf_field(); ?>
<div class="form-group">
<label for="current_password">当前密码(验证身份)</label>
<input type="password" name="current_password" id="current_password" required>
</div>
<div class="form-group">
<label for="new_username">用户名</label>
<input type="text" name="new_username" id="new_username" value="<?php echo e($admin_user); ?>" placeholder="留空则不修改">
</div>
<div class="form-group">
<label for="new_password">新密码</label>
<input type="password" name="new_password" id="new_password" placeholder="留空则不修改,建议8位以上">
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">修改账号</button>
</div>
</form>
<h1>网站设置</h1>
<form method="POST">
<?php echo csrf_field(); ?>
<div class="form-group">
<label for="site_name">网站名称</label>
<input type="text" name="site_name" id="site_name" value="<?php echo e($site_name); ?>">
</div>
<div class="form-group">
<label for="site_description">网站描述</label>
<textarea name="site_description" id="site_description" rows="3"><?php echo e($site_description); ?></textarea>
</div>
<div class="form-group">
<label for="template">默认模板</label>
<select name="template" id="template">
<option value="flow" <?php echo $template === 'flow' ? 'selected' : ''; ?>>Flow (浅色主题)</option>
<option value="magine" <?php echo $template === 'magine' ? 'selected' : ''; ?>>Magine (深色主题)</option>
</select>
</div>
<div class="form-group">
<label for="posts_per_page">每页文章数</label>
<input type="number" name="posts_per_page" id="posts_per_page" value="<?php echo e($posts_per_page); ?>" min="1" max="50">
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">保存设置</button>
</div>
</form>
<h2 style="margin-top: 40px;">系统信息</h2>
<table class="table">
<tr>
<td>PHP 版本</td>
<td><?php echo PHP_VERSION; ?></td>
</tr>
<tr>
<td>MySQL 客户端版本</td>
<td><?php echo mysqli_get_client_info(); ?></td>
</tr>
<tr>
<td>服务器软件</td>
<td><?php echo $_SERVER['SERVER_SOFTWARE'] ?? 'Unknown'; ?></td>
</tr>
<tr>
<td>当前登录用户</td>
<td><?php echo e($admin_user); ?></td>
</tr>
</table>
</main>
</div>
</body>
</html>

View file

@ -0,0 +1,121 @@
/**
* Lightweight HTML sanitizer using DOMDocument + blacklist
* For production use, consider installing HTMLPurifier via Composer.
*/
function sanitize_html($html) {
if (empty($html)) return '';
// Step 1: Remove script, style, and dangerous tags via regex pre-clean
$html = preg_replace('#<(script|style|iframe|object|embed|form|input|button)[^>]*>.*?</\1>#is', '', $html);
$html = preg_replace('/<[^>]+\s+on\w+\s*=\s*["\'][^"\']*["\']/i', '', $html); // remove on* attributes
$html = preg_replace('/<[^>]+\s+style\s*=\s*["\'][^"\']*["\']/i', '', $html); // remove style attrs
// Step 2: Use DOMDocument to re-serialize (normalizes malformed HTML)
libxml_use_internal_errors(true);
$dom = new DOMDocument();
$dom->loadHTML('<meta http-equiv="Content-Type" content="text/html; charset=utf-8">' . "\n" . $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
libxml_clear_errors();
// Step 3: Walk all elements and enforce whitelist
$allowed_tags = [
'h1','h2','h3','h4','h5','h6',
'p','br','strong','em','u','s','del','sup','sub',
'blockquote','pre','code',
'ul','ol','li',
'a','img',
'table','thead','tbody','tr','th','td',
'hr','span',
'div','section','article','header','footer',
];
$allowed_attrs = [
'href', 'target', 'src', 'alt', 'width', 'height', 'class', 'id',
];
$xpath = new DOMXPath($dom);
$all_elements = $xpath->query('//*');
$to_remove = [];
foreach ($all_elements as $el) {
$tag = strtolower($el->nodeName);
// Remove disallowed tags entirely
if (!in_array($tag, $allowed_tags)) {
// Keep children, remove tag wrapper
foreach ($el->childNodes as $child) {
$el->parentNode->insertBefore($child->cloneNode(true), $el);
}
$to_remove[] = $el;
continue;
}
// Remove disallowed attributes
$attrs_to_remove = [];
if ($el->attributes) {
foreach ($el->attributes as $attr) {
if (!in_array(strtolower($attr->nodeName), $allowed_attrs)) {
$attrs_to_remove[] = $attr;
}
}
foreach ($attrs_to_remove as $attr) {
$el->removeAttributeNode($attr);
}
}
// Sanitize href/src URLs (allow only http/https/data)
if ($el->hasAttribute('href')) {
$href = $el->getAttribute('href');
if (!preg_match('#^https?://|mailto:|tel:|data:#i', $href)) {
$el->removeAttribute('href');
}
}
if ($el->hasAttribute('src')) {
$src = $el->getAttribute('src');
if (!preg_match('#^https?://|data:#i', $src)) {
$el->removeAttribute('src');
}
}
}
foreach ($to_remove as $el) {
$el->parentNode->removeChild($el);
}
$body = $dom->getElementsByTagName('body')->item(0);
$output = '';
if ($body) {
foreach ($body->childNodes as $child) {
$output .= $dom->saveHTML($child);
}
}
return trim($output);
}
/**
* Auto-detect base URL from request
*/
function get_site_url() {
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
return $scheme . '://' . $host;
}
/**
* Rate limiting for comment submissions
*/
function rate_limit($key, $max_requests = 5, $window_seconds = 60) {
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
$cache_file = '/tmp/rate_limit_' . md5($key . '_' . $ip) . '.json';
$now = time();
$data = [];
if (file_exists($cache_file)) {
$data = json_decode(file_get_contents($cache_file), true) ?: [];
}
// Remove expired entries
$data = array_filter($data, function($ts) use ($now, $window_seconds) {
return ($now - $ts) < $window_seconds;
});
if (count($data) >= $max_requests) {
return false;
}
$data[] = $now;
file_put_contents($cache_file, json_encode($data));
return true;
}

16
include/db.php Normal file
View file

@ -0,0 +1,16 @@
<?php
/**
* Database Connection
*/
function get_db() {
static $db = null;
if ($db === null) {
$db = new mysqli(DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT);
if ($db->connect_error) {
die('数据库连接失败: ' . $db->connect_error);
}
$db->set_charset('utf8mb4');
}
return $db;
}

View file

@ -0,0 +1,56 @@
<?php
/**
* Category Functions
*/
function get_categories() {
$db = get_db();
$result = $db->query("SELECT * FROM categories ORDER BY order_num ASC, id ASC");
return $result->fetch_all(MYSQLI_ASSOC);
}
function get_category_by_slug($slug) {
$db = get_db();
$stmt = $db->prepare("SELECT * FROM categories WHERE slug = ?");
$stmt->bind_param('s', $slug);
$stmt->execute();
return $stmt->get_result()->fetch_assoc();
}
function get_category_by_id($id) {
$db = get_db();
$stmt = $db->prepare("SELECT * FROM categories WHERE id = ?");
$stmt->bind_param('i', $id);
$stmt->execute();
return $stmt->get_result()->fetch_assoc();
}
function create_category($data) {
$db = get_db();
$stmt = $db->prepare("INSERT INTO categories (name, slug, description, parent_id, order_num) VALUES (?, ?, ?, ?, ?)");
$stmt->bind_param('sssii', $data['name'], $data['slug'], $data['description'], $data['parent_id'], $data['order_num']);
return $stmt->execute();
}
function update_category($id, $data) {
$db = get_db();
$stmt = $db->prepare("UPDATE categories SET name = ?, slug = ?, description = ?, parent_id = ?, order_num = ? WHERE id = ?");
$stmt->bind_param('sssiii', $data['name'], $data['slug'], $data['description'], $data['parent_id'], $data['order_num'], $id);
return $stmt->execute();
}
function delete_category($id) {
$db = get_db();
$stmt = $db->prepare("DELETE FROM categories WHERE id = ?");
$stmt->bind_param('i', $id);
return $stmt->execute();
}
function get_category_count($category_id) {
$db = get_db();
$stmt = $db->prepare("SELECT COUNT(*) FROM posts WHERE category_id = ? AND status = 'published' AND deleted_at IS NULL");
$stmt->bind_param('i', $category_id);
$stmt->execute();
$result = $stmt->get_result();
return $result->fetch_row()[0];
}

View file

@ -0,0 +1,103 @@
<?php
/**
* Comment Functions
*/
function get_comments($post_id) {
$db = get_db();
$stmt = $db->prepare("SELECT * FROM comments WHERE post_id = ? AND status = 'approved' ORDER BY created_at ASC");
$stmt->bind_param('i', $post_id);
$stmt->execute();
return $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
}
function get_all_comments($status = null) {
$db = get_db();
if ($status) {
$stmt = $db->prepare("SELECT c.*, p.title as post_title, p.slug as post_slug
FROM comments c
LEFT JOIN posts p ON c.post_id = p.id
WHERE c.status = ?
ORDER BY c.created_at DESC");
$stmt->bind_param('s', $status);
$stmt->execute();
return $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
} else {
$result = $db->query("SELECT c.*, p.title as post_title, p.slug as post_slug
FROM comments c
LEFT JOIN posts p ON c.post_id = p.id
ORDER BY c.created_at DESC");
return $result->fetch_all(MYSQLI_ASSOC);
}
}
// Spam keywords (case-insensitive)
function is_spam_content($content, $author = '', $url = '') {
$spam_keywords = [
'casino', 'viagra', 'cialis', 'loan', 'mortgage', 'bitcoin',
'cheap jerseys', 'nfl jerseys', 'michael kors outlet',
'louis vuitton', 'ugg boots', 'pandora', 'tiffany',
'viagra', 'cialis', 'levitra', 'cialis',
'adult', 'porn', 'nude',
'essay', 'dissertation', 'thesis',
'backlink', 'sexxx', 'fuck',
];
$check = strtolower($author . ' ' . $content . ' ' . $url);
foreach ($spam_keywords as $keyword) {
if (strpos($check, strtolower($keyword)) !== false) {
return true;
}
}
return false;
}
function create_comment($data) {
// Rate limiting: max 5 comments per minute per IP
if (!rate_limit('comment', 5, 60)) {
return false;
}
// Honeypot check
if (!empty($data['website'])) {
return false; // Bot detected
}
// Keyword spam check
if (is_spam_content($data['content'], $data['author'], $data['url'])) {
return false;
}
$db = get_db();
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
$stmt = $db->prepare("INSERT INTO comments (post_id, author, email, url, content, ip, status) VALUES (?, ?, ?, ?, ?, ?, 'pending')");
$stmt->bind_param('isssss', $data['post_id'], $data['author'], $data['email'], $data['url'], $data['content'], $ip);
return $stmt->execute();
}
function update_comment_status($id, $status) {
$db = get_db();
$stmt = $db->prepare("UPDATE comments SET status = ? WHERE id = ?");
$stmt->bind_param('si', $status, $id);
return $stmt->execute();
}
function delete_comment($id) {
$db = get_db();
$stmt = $db->prepare("DELETE FROM comments WHERE id = ?");
$stmt->bind_param('i', $id);
return $stmt->execute();
}
function get_comment_count($status = null) {
$db = get_db();
if ($status) {
$stmt = $db->prepare("SELECT COUNT(*) FROM comments WHERE status = ?");
$stmt->bind_param('s', $status);
$stmt->execute();
return $stmt->get_result()->fetch_row()[0];
} else {
$result = $db->query("SELECT COUNT(*) FROM comments");
return $result->fetch_row()[0];
}
}

251
include/function.common.php Normal file
View file

@ -0,0 +1,251 @@
<?php
/**
* Common Functions
*/
function get_db() {
static $db = null;
if ($db === null) {
$db = new mysqli(DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT);
if ($db->connect_error) {
die('数据库连接失败: ' . $db->connect_error);
}
$db->set_charset('utf8mb4');
}
return $db;
}
function e($str) {
return htmlspecialchars($str ?? '', ENT_QUOTES, 'UTF-8');
}
function redirect($url) {
header('Location: ' . $url);
exit;
}
function json_response($data, $code = 200) {
http_response_code($code);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($data, JSON_UNESCAPED_UNICODE);
exit;
}
/**
* CSRF Protection
*/
function csrf_token() {
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
function csrf_field() {
return '<input type="hidden" name="csrf_token" value="' . csrf_token() . '">';
}
function csrf_verify($method = 'POST') {
if ($method === 'POST' || $method === 'BOTH') {
$token = $_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (empty($token) || !hash_equals($_SESSION['csrf_token'] ?? '', $token)) {
http_response_code(403);
echo 'CSRF token mismatch';
exit;
}
}
}
/**
* Authentication
*/
function get_admin_user() {
return get_setting('admin_user', 'admin');
}
function get_admin_hash() {
return get_setting('admin_hash', '');
}
function verify_admin_password($password) {
$hash = get_admin_hash();
if (empty($hash)) {
// Fallback: no hash set yet, use legacy plain-text check
// After first settings save this branch is never reached
return false;
}
return password_verify($password, $hash);
}
function set_admin_password($password) {
$hash = password_hash($password, PASSWORD_DEFAULT);
update_setting('admin_hash', $hash);
}
function auth_check() {
if (empty($_SESSION['admin'])) {
redirect('login.php');
}
}
function auth_verify($username, $password) {
if ($username !== get_admin_user()) {
return false;
}
return verify_admin_password($password);
}
function get_setting($key, $default = '') {
$db = get_db();
$stmt = $db->prepare("SELECT value FROM settings WHERE `key` = ?");
$stmt->bind_param('s', $key);
$stmt->execute();
$result = $stmt->get_result();
if ($row = $result->fetch_assoc()) {
return $row['value'];
}
return $default;
}
function update_setting($key, $value) {
$db = get_db();
$stmt = $db->prepare("INSERT INTO settings (`key`, value) VALUES (?, ?) ON DUPLICATE KEY UPDATE value = VALUES(value)");
$stmt->bind_param('ss', $key, $value);
return $stmt->execute();
}
function time_ago($datetime) {
$timestamp = strtotime($datetime);
$diff = time() - $timestamp;
if ($diff < 60) return '刚刚';
if ($diff < 3600) return floor($diff / 60) . '分钟前';
if ($diff < 86400) return floor($diff / 3600) . '小时前';
if ($diff < 2592000) return floor($diff / 86400) . '天前';
return date('Y-m-d', $timestamp);
}
/**
* HTML sanitizer - prevent XSS in rich-text content
* Uses DOMDocument + whitelist approach (no external dependencies needed)
*/
function sanitize_html($html) {
if (empty($html)) return '';
// Step 1: Remove dangerous tags via regex pre-clean (before DOM parsing)
$html = preg_replace('#<(script|style|iframe|object|embed|form|input|button|select|textarea)[^>]*>.*?</\1>#is', '', $html);
// Step 2: Remove event-handler attributes (onclick, onerror, etc.)
$html = preg_replace('/\s+on\w+\s*=\s*["\'][^"\']*["\']/i', '', $html);
// Step 3: Use DOMDocument to normalize and re-serialize
libxml_use_internal_errors(true);
$dom = new DOMDocument();
// Wrap in a div so we can use loadHTML on fragment
$dom->loadHTML('<meta http-equiv="Content-Type" content="text/html; charset=utf-8">' . "\n" . '<div>' . $html . '</div>', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
libxml_clear_errors();
// Whitelist of allowed tags and attributes
$allowed_tags = [
'h1','h2','h3','h4','h5','h6',
'p','br','hr',
'strong','b','em','i','u','s','del','sup','sub','mark',
'blockquote','pre','code',
'ul','ol','li',
'a','img',
'table','thead','tbody','tfoot','tr','th','td',
'div','span','section','article','header','footer','aside',
];
$allowed_attrs = [
'href', 'src', 'alt', 'title', 'width', 'height',
'class', 'id', 'style',
];
$xpath = new DOMXPath($dom);
$all = $xpath->query('//*');
$remove = [];
foreach ($all as $el) {
$tag = strtolower($el->nodeName);
if (!in_array($tag, $allowed_tags)) {
// Replace tag with its children
foreach ($el->childNodes as $child) {
$el->parentNode->insertBefore($child->cloneNode(true), $el);
}
$remove[] = $el;
continue;
}
// Filter attributes
$bad = [];
if ($el->attributes) {
foreach ($el->attributes as $attr) {
$name = strtolower($attr->nodeName);
if (!in_array($name, $allowed_attrs)) {
$bad[] = $attr;
continue;
}
// Sanitize URL attributes
if (in_array($name, ['href', 'src'])) {
$val = $attr->nodeValue;
if (!preg_match('#^(https?://|mailto:|tel:|data:)#i', $val)) {
// Remove javascript: and other dangerous protocols
if (preg_match('#^javascript:#i', $val)) {
$bad[] = $attr;
continue;
}
// For src/href, if it doesn't match safe schemes, clear it
if (!preg_match('#^https?://|data:#i', $val)) {
$bad[] = $attr;
}
}
}
}
foreach ($bad as $attr) {
$el->removeAttributeNode($attr);
}
}
}
foreach ($remove as $el) {
$el->parentNode->removeChild($el);
}
// Extract content from the wrapper div
$out = '';
foreach ($dom->getElementsByTagName('div')->item(0)->childNodes as $node) {
$out .= $dom->saveHTML($node);
}
return trim($out);
}
/**
* Rate limiting for comment submissions
*/
function rate_limit($key, $max_requests = 5, $window_seconds = 60) {
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
$cache_file = '/tmp/rate_limit_' . md5($key . '_' . $ip) . '.json';
$now = time();
$data = [];
if (file_exists($cache_file)) {
$data = json_decode(file_get_contents($cache_file), true) ?: [];
}
$data = array_values(array_filter($data, function($ts) use ($now, $window_seconds) {
return ($now - $ts) < $window_seconds;
}));
if (count($data) >= $max_requests) {
return false;
}
$data[] = $now;
@file_put_contents($cache_file, json_encode($data));
return true;
}
/**
* Auto-detect site URL from current request
*/
function get_site_url() {
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
$base = $scheme . '://' . $host;
$script = dirname($_SERVER['SCRIPT_NAME'] ?? '');
return rtrim($base . ($script === '/' || $script === '\\' ? '' : $script), '/');
}

139
include/function.post.php Normal file
View file

@ -0,0 +1,139 @@
<?php
/**
* Post Functions
*/
function get_posts($page = 1, $per_page = 10, $category_id = null) {
$db = get_db();
$offset = ($page - 1) * $per_page;
$where = "WHERE p.status = 'published' AND p.deleted_at IS NULL";
$params = [];
$types = '';
if ($category_id) {
$where .= " AND p.category_id = ?";
$params[] = $category_id;
$types .= 'i';
}
$sql = "SELECT p.*, c.name as category_name, c.slug as category_slug
FROM posts p
LEFT JOIN categories c ON p.category_id = c.id
$where
ORDER BY p.created_at DESC
LIMIT ? OFFSET ?";
$params[] = $per_page;
$params[] = $offset;
$types .= 'ii';
$stmt = $db->prepare($sql);
if ($params) {
$stmt->bind_param($types, ...$params);
}
$stmt->execute();
return $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
}
function get_total_posts($category_id = null) {
$db = get_db();
$where = "WHERE status = 'published' AND deleted_at IS NULL";
$params = [];
$types = '';
if ($category_id) {
$where .= " AND category_id = ?";
$params[] = $category_id;
$types .= 'i';
}
$sql = "SELECT COUNT(*) FROM posts $where";
$stmt = $db->prepare($sql);
if ($params) {
$stmt->bind_param($types, ...$params);
}
$stmt->execute();
$result = $stmt->get_result();
return $result->fetch_row()[0];
}
function get_post_by_slug($slug) {
$db = get_db();
$stmt = $db->prepare("SELECT p.*, c.name as category_name, c.slug as category_slug
FROM posts p
LEFT JOIN categories c ON p.category_id = c.id
WHERE p.slug = ? AND p.status = 'published' AND p.deleted_at IS NULL");
$stmt->bind_param('s', $slug);
$stmt->execute();
$post = $stmt->get_result()->fetch_assoc();
if ($post) {
$stmt2 = $db->prepare("UPDATE posts SET views = views + 1 WHERE id = ?");
$stmt2->bind_param('i', $post['id']);
$stmt2->execute();
}
return $post;
}
function get_post_by_id($id) {
$db = get_db();
$stmt = $db->prepare("SELECT * FROM posts WHERE id = ?");
$stmt->bind_param('i', $id);
$stmt->execute();
return $stmt->get_result()->fetch_assoc();
}
function create_post($data) {
$db = get_db();
$stmt = $db->prepare("INSERT INTO posts (title, content, excerpt, slug, category_id, template, status)
VALUES (?, ?, ?, ?, ?, ?, ?)");
$stmt->bind_param('sssssss',
$data['title'],
$data['content'],
$data['excerpt'],
$data['slug'],
$data['category_id'],
$data['template'],
$data['status']
);
return $stmt->execute();
}
function update_post($id, $data) {
$db = get_db();
$stmt = $db->prepare("UPDATE posts SET title = ?, content = ?, excerpt = ?, slug = ?, category_id = ?, template = ?, status = ?, updated_at = NOW() WHERE id = ?");
$stmt->bind_param('sssssssi',
$data['title'],
$data['content'],
$data['excerpt'],
$data['slug'],
$data['category_id'],
$data['template'],
$data['status'],
$id
);
return $stmt->execute();
}
function delete_post($id) {
$db = get_db();
$stmt = $db->prepare("UPDATE posts SET deleted_at = NOW() WHERE id = ?");
$stmt->bind_param('i', $id);
return $stmt->execute();
}
function generate_slug($title) {
$slug = preg_replace('/[^\x{4e00}-\x{9fa5}a-zA-Z0-9]+/u', '-', $title);
$slug = trim($slug, '-');
$slug = strtolower($slug);
return $slug ?: 'post-' . time();
}
function get_recent_posts($limit = 5) {
$db = get_db();
$stmt = $db->prepare("SELECT id, title, slug, created_at FROM posts WHERE status = 'published' AND deleted_at IS NULL ORDER BY created_at DESC LIMIT ?");
$stmt->bind_param('i', $limit);
$stmt->execute();
return $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
}

70
include/router.php Normal file
View file

@ -0,0 +1,70 @@
<?php
/**
* URL Router
*/
function route_request($route) {
$route = trim($route, '/');
if (empty($route) || $route === 'index.php') {
include TEMPLATE_PATH . 'flow/index.php';
return;
}
// Admin routes
if (strpos($route, 'admin/') === 0) {
$admin_route = substr($route, 6);
if (empty($admin_route) || $admin_route === 'index.php') {
include ROOT_PATH . 'admin/index.php';
} elseif ($admin_route === 'login') {
include ROOT_PATH . 'admin/login.php';
} elseif ($admin_route === 'logout') {
include ROOT_PATH . 'admin/logout.php';
} elseif ($admin_route === 'post') {
include ROOT_PATH . 'admin/post.php';
} elseif ($admin_route === 'category') {
include ROOT_PATH . 'admin/category.php';
} elseif ($admin_route === 'comment') {
include ROOT_PATH . 'admin/comment.php';
} elseif ($admin_route === 'settings') {
include ROOT_PATH . 'admin/settings.php';
} else {
http_response_code(404);
echo '管理员页面不存在';
}
return;
}
// Post routes
if (strpos($route, 'post/') === 0) {
$slug = substr($route, 5);
$_GET['slug'] = $slug;
include TEMPLATE_PATH . 'flow/post.php';
return;
}
// Category routes
if (strpos($route, 'category/') === 0) {
$slug = substr($route, 9);
$_GET['slug'] = $slug;
include TEMPLATE_PATH . 'flow/category.php';
return;
}
// Archive route
if ($route === 'archive') {
include TEMPLATE_PATH . 'flow/archive.php';
return;
}
// Check for static files
if (file_exists(ROOT_PATH . $route)) {
return false; // Let Apache serve it
}
// 404
http_response_code(404);
echo '页面不存在';
return true;
}

21
static/css/style.css Normal file
View file

@ -0,0 +1,21 @@
/* ZhangPu Blog - Base Styles */
:root {
--bg: #fafafa;
--surface: #fff;
--border: #e5e5e5;
--text: #333;
--accent: #22c55e;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
}

26
static/js/script.js Normal file
View file

@ -0,0 +1,26 @@
/**
* ZhangPu Blog - Base JavaScript
*/
// Mobile menu toggle
function toggleMobileMenu() {
var nav = document.querySelector('.main-nav');
if (nav) {
nav.classList.toggle('show');
}
}
// Add event listeners when DOM is ready
document.addEventListener('DOMContentLoaded', function() {
// Auto-hide messages after 5 seconds
var messages = document.querySelectorAll('.success, .error');
messages.forEach(function(msg) {
setTimeout(function() {
msg.style.opacity = '0';
msg.style.transition = 'opacity 0.5s';
setTimeout(function() {
msg.remove();
}, 500);
}, 5000);
});
});

View file

@ -0,0 +1,94 @@
<?php
/**
* Flow Template - Archive Page
*/
require_once __DIR__ . '/../../config.php';
require_once INCLUDE_PATH . 'function.common.php';
require_once INCLUDE_PATH . 'function.post.php';
require_once INCLUDE_PATH . 'function.category.php';
$base_url = '';
$page_title = '归档';
$db = get_db();
$result = $db->query("SELECT id, title, slug, created_at FROM posts WHERE status = 'published' AND deleted_at IS NULL ORDER BY created_at DESC");
$all_posts = $result->fetch_all(MYSQLI_ASSOC);
// Group by year and month
$archives = [];
foreach ($all_posts as $post) {
$year = date('Y', strtotime($post['created_at']));
$month = date('m', strtotime($post['created_at']));
if (!isset($archives[$year])) {
$archives[$year] = [];
}
if (!isset($archives[$year][$month])) {
$archives[$year][$month] = [];
}
$archives[$year][$month][] = $post;
}
$categories = get_categories();
$recent_posts = get_recent_posts(5);
include __DIR__ . '/header.php';
?>
<div class="main-area">
<div class="archive-header">
<h1>📚 文章归档</h1>
<p class="archive-stats">共 <?php echo count($all_posts); ?> 篇文章</p>
</div>
<?php if (!empty($archives)): ?>
<?php foreach ($archives as $year => $months): ?>
<div class="archive-year">
<h2 class="year-title"><?php echo $year; ?> 年</h2>
<?php foreach ($months as $month => $posts): ?>
<div class="archive-month">
<h3 class="month-title"><?php echo $month; ?> 月</h3>
<ul class="archive-list">
<?php foreach ($posts as $post): ?>
<li>
<span class="archive-date"><?php echo date('m-d', strtotime($post['created_at'])); ?></span>
<a href="<?php echo $base_url; ?>post/<?php echo e($post['slug']); ?>" class="archive-title"><?php echo e($post['title']); ?></a>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
<?php else: ?>
<div class="empty-state">
<p>暂无文章</p>
</div>
<?php endif; ?>
</div>
<aside class="sidebar-area">
<div class="sidebar-widget">
<h3>📁 分类</h3>
<ul class="category-list">
<?php foreach ($categories as $cat): ?>
<li>
<a href="<?php echo $base_url; ?>category/<?php echo e($cat['slug']); ?>">
<span class="cat-name"><?php echo e($cat['name']); ?></span>
<span class="cat-count"><?php echo get_category_count($cat['id']); ?></span>
</a>
</li>
<?php endforeach; ?>
</ul>
</div>
<div class="sidebar-widget">
<h3>📝 最新文章</h3>
<ul class="recent-list">
<?php foreach ($recent_posts as $recent): ?>
<li><a href="<?php echo $base_url; ?>post/<?php echo e($recent['slug']); ?>"><?php echo e($recent['title']); ?></a></li>
<?php endforeach; ?>
</ul>
</div>
</aside>
<?php include __DIR__ . '/footer.php'; ?>

110
templates/flow/category.php Normal file
View file

@ -0,0 +1,110 @@
<?php
/**
* Flow Template - Category Page
*/
require_once __DIR__ . '/../../config.php';
require_once INCLUDE_PATH . 'function.common.php';
require_once INCLUDE_PATH . 'function.post.php';
require_once INCLUDE_PATH . 'function.category.php';
$base_url = '';
$slug = $_GET['slug'] ?? '';
if (empty($slug)) {
http_response_code(404);
echo '分类不存在';
exit;
}
$category = get_category_by_slug($slug);
if (!$category) {
http_response_code(404);
echo '分类不存在';
exit;
}
$page_title = $category['name'];
$page = isset($_GET['page']) ? max(1, intval($_GET['page'])) : 1;
$per_page = intval(get_setting('posts_per_page', '10'));
$posts = get_posts($page, $per_page, $category['id']);
$total_posts = get_category_count($category['id']);
$total_pages = ceil($total_posts / $per_page);
$recent_posts = get_recent_posts(5);
$categories = get_categories();
include __DIR__ . '/header.php';
?>
<div class="main-area">
<div class="category-header">
<h1>📁 <?php echo e($category['name']); ?></h1>
<?php if ($category['description']): ?>
<p class="category-desc"><?php echo e($category['description']); ?></p>
<?php endif; ?>
</div>
<?php if (empty($posts)): ?>
<div class="empty-state">
<p>该分类下暂无文章</p>
</div>
<?php else: ?>
<?php foreach ($posts as $post): ?>
<article class="post-card">
<div class="post-meta">
<span class="post-date"><?php echo date('Y-m-d', strtotime($post['created_at'])); ?></span>
<span class="post-views">👁 <?php echo $post['views']; ?></span>
</div>
<h2 class="post-title"><a href="<?php echo $base_url; ?>post/<?php echo e($post['slug']); ?>"><?php echo e($post['title']); ?></a></h2>
<?php if ($post['excerpt']): ?>
<p class="post-excerpt"><?php echo e($post['excerpt']); ?></p>
<?php endif; ?>
</article>
<?php endforeach; ?>
<?php if ($total_pages > 1): ?>
<nav class="pagination">
<?php if ($page > 1): ?>
<a href="?slug=<?php echo e($slug); ?>&page=<?php echo $page - 1; ?>" class="prev">← 上一页</a>
<?php endif; ?>
<?php for ($i = max(1, $page - 2); $i <= min($total_pages, $page + 2); $i++): ?>
<a href="?slug=<?php echo e($slug); ?>&page=<?php echo $i; ?>" class="<?php echo $i === $page ? 'active' : ''; ?>"><?php echo $i; ?></a>
<?php endfor; ?>
<?php if ($page < $total_pages): ?>
<a href="?slug=<?php echo e($slug); ?>&page=<?php echo $page + 1; ?>" class="next">下一页 →</a>
<?php endif; ?>
</nav>
<?php endif; ?>
<?php endif; ?>
</div>
<aside class="sidebar-area">
<div class="sidebar-widget">
<h3>📁 分类</h3>
<ul class="category-list">
<?php foreach ($categories as $cat): ?>
<li>
<a href="<?php echo $base_url; ?>category/<?php echo e($cat['slug']); ?>" class="<?php echo $cat['id'] === $category['id'] ? 'active' : ''; ?>">
<span class="cat-name"><?php echo e($cat['name']); ?></span>
<span class="cat-count"><?php echo get_category_count($cat['id']); ?></span>
</a>
</li>
<?php endforeach; ?>
</ul>
</div>
<div class="sidebar-widget">
<h3>📝 最新文章</h3>
<ul class="recent-list">
<?php foreach ($recent_posts as $recent): ?>
<li><a href="<?php echo $base_url; ?>post/<?php echo e($recent['slug']); ?>"><?php echo e($recent['title']); ?></a></li>
<?php endforeach; ?>
</ul>
</div>
</aside>
<?php include __DIR__ . '/footer.php'; ?>

35
templates/flow/footer.php Normal file
View file

@ -0,0 +1,35 @@
<?php
/**
* Flow Template - Footer
*/
$site_name = get_setting('site_name', 'TBlog');
$github_url = 'https://github.com/tblog-cn/tblog';
?>
</div>
</div>
</main>
<footer class="site-footer">
<div class="container">
<div class="footer-links">
<a href="https://tblog.cn" target="_blank">TBlog</a>
<span class="sep">·</span>
<a href="<?php echo $github_url; ?>" target="_blank" title="GitHub">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:middle">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z"/>
</svg>
开源
</a>
<span class="sep">·</span>
<a href="https://github.com/tblog-cn/tblog/releases" target="_blank">更新日志</a>
</div>
<p>© <?php echo date('Y'); ?> <?php echo e($site_name); ?> · MIT License</p>
</div>
</footer>
<script>
function toggleMobileMenu() {
var nav = document.querySelector('.main-nav');
nav.classList.toggle('show');
}
</script>
</body>
</html>

42
templates/flow/header.php Normal file
View file

@ -0,0 +1,42 @@
<?php
/**
* Flow Template - Header
*/
$base_url = '/';
require_once __DIR__ . '/../../config.php';
require_once INCLUDE_PATH . 'function.common.php';
require_once INCLUDE_PATH . 'function.category.php';
$site_name = get_setting('site_name', 'TBlog');
$site_description = get_setting('site_description', '一个简洁优雅的开源博客系统');
$categories = get_categories();
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?php echo isset($page_title) ? e($page_title) . ' - ' : ''; ?><?php echo e($site_name); ?></title>
<meta name="description" content="<?php echo e($site_description); ?>">
<link rel="stylesheet" href="<?php echo $base_url ?? ''; ?>static/css/style.css">
<link rel="stylesheet" href="<?php echo $base_url ?? ''; ?>templates/flow/static/css/style.css">
</head>
<body>
<header class="site-header">
<div class="container">
<div class="header-inner">
<a href="<?php echo $base_url ?? ''; ?>" class="logo">🌿 <?php echo e($site_name); ?></a>
<nav class="main-nav">
<a href="<?php echo $base_url ?? ''; ?>">首页</a>
<?php foreach ($categories as $cat): ?>
<a href="<?php echo ($base_url ?? '') . 'category/' . e($cat['slug']); ?>"><?php echo e($cat['name']); ?></a>
<?php endforeach; ?>
<a href="<?php echo ($base_url ?? '') . 'admin/'; ?>" class="admin-link">管理</a>
</nav>
<button class="mobile-menu-btn" onclick="toggleMobileMenu()">☰</button>
</div>
</div>
</header>
<main class="main-content">
<div class="container">
<div class="content-wrapper">

89
templates/flow/index.php Normal file
View file

@ -0,0 +1,89 @@
<?php
/**
* Flow Template - Home Page
*/
require_once __DIR__ . '/../../config.php';
require_once INCLUDE_PATH . 'function.common.php';
require_once INCLUDE_PATH . 'function.post.php';
require_once INCLUDE_PATH . 'function.category.php';
$base_url = '/';
$page_title = '首页';
$page = isset($_GET['page']) ? max(1, intval($_GET['page'])) : 1;
$per_page = intval(get_setting('posts_per_page', '10'));
$posts = get_posts($page, $per_page);
$total_posts = get_total_posts();
$total_pages = ceil($total_posts / $per_page);
$recent_posts = get_recent_posts(5);
$categories = get_categories();
include __DIR__ . '/header.php';
?>
<div class="main-area">
<?php if (empty($posts)): ?>
<div class="empty-state">
<p>暂无文章</p>
</div>
<?php else: ?>
<?php foreach ($posts as $post): ?>
<article class="post-card">
<div class="post-meta">
<a href="<?php echo $base_url; ?>category/<?php echo e($post['category_slug']); ?>" class="category-tag"><?php echo e($post['category_name']); ?></a>
<span class="post-date"><?php echo date('Y-m-d', strtotime($post['created_at'])); ?></span>
<span class="post-views">👁 <?php echo $post['views']; ?></span>
</div>
<h2 class="post-title"><a href="<?php echo $base_url; ?>post/<?php echo e($post['slug']); ?>"><?php echo e($post['title']); ?></a></h2>
<?php if ($post['excerpt']): ?>
<p class="post-excerpt"><?php echo e($post['excerpt']); ?></p>
<?php endif; ?>
</article>
<?php endforeach; ?>
<?php if ($total_pages > 1): ?>
<nav class="pagination">
<?php if ($page > 1): ?>
<a href="?page=<?php echo $page - 1; ?>" class="prev">← 上一页</a>
<?php endif; ?>
<?php for ($i = max(1, $page - 2); $i <= min($total_pages, $page + 2); $i++): ?>
<a href="?page=<?php echo $i; ?>" class="<?php echo $i === $page ? 'active' : ''; ?>"><?php echo $i; ?></a>
<?php endfor; ?>
<?php if ($page < $total_pages): ?>
<a href="?page=<?php echo $page + 1; ?>" class="next">下一页 →</a>
<?php endif; ?>
</nav>
<?php endif; ?>
<?php endif; ?>
</div>
<aside class="sidebar-area">
<div class="sidebar-widget">
<h3>📁 分类</h3>
<ul class="category-list">
<?php foreach ($categories as $cat): ?>
<li>
<a href="<?php echo $base_url; ?>category/<?php echo e($cat['slug']); ?>">
<span class="cat-name"><?php echo e($cat['name']); ?></span>
<span class="cat-count"><?php echo get_category_count($cat['id']); ?></span>
</a>
</li>
<?php endforeach; ?>
</ul>
</div>
<div class="sidebar-widget">
<h3>📝 最新文章</h3>
<ul class="recent-list">
<?php foreach ($recent_posts as $recent): ?>
<li><a href="<?php echo $base_url; ?>post/<?php echo e($recent['slug']); ?>"><?php echo e($recent['title']); ?></a></li>
<?php endforeach; ?>
</ul>
</div>
</aside>
<?php include __DIR__ . '/footer.php'; ?>

108
templates/flow/post.php Normal file
View file

@ -0,0 +1,108 @@
<?php
/**
* Flow Template - Single Post
*/
require_once __DIR__ . '/../../config.php';
require_once INCLUDE_PATH . 'function.common.php';
require_once INCLUDE_PATH . 'function.post.php';
require_once INCLUDE_PATH . 'function.comment.php';
$base_url = '';
$slug = $_GET['slug'] ?? '';
if (empty($slug)) {
http_response_code(404);
echo '文章不存在';
exit;
}
$post = get_post_by_slug($slug);
if (!$post) {
http_response_code(404);
echo '文章不存在';
exit;
}
$page_title = $post['title'];
$comments = get_comments($post['id']);
// Handle comment submission
$comment_message = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST' && !empty($_POST['action']) && $_POST['action'] === 'add_comment') {
$data = [
'post_id' => $post['id'],
'author' => trim($_POST['author'] ?? ''),
'email' => trim($_POST['email'] ?? ''),
'url' => trim($_POST['url'] ?? ''),
'content' => trim($_POST['content'] ?? '')
];
if (empty($data['author']) || empty($data['content'])) {
$comment_message = '<div class="comment-error">请填写昵称和评论内容</div>';
} else {
if (create_comment($data)) {
$comment_message = '<div class="comment-success">评论已提交,等待审核</div>';
$data = ['post_id' => $post['id'], 'author' => '', 'email' => '', 'url' => '', 'content' => ''];
} else {
$comment_message = '<div class="comment-error">评论提交失败</div>';
}
}
} else {
$data = ['post_id' => $post['id'], 'author' => '', 'email' => '', 'url' => '', 'content' => ''];
}
include __DIR__ . '/header.php';
?>
<article class="post-single">
<header class="post-header">
<div class="post-meta">
<a href="<?php echo $base_url; ?>category/<?php echo e($post['category_slug']); ?>" class="category-tag"><?php echo e($post['category_name']); ?></a>
<span class="post-date"><?php echo date('Y-m-d', strtotime($post['created_at'])); ?></span>
<span class="post-views">👁 <?php echo $post['views']; ?> 次阅读</span>
</div>
<h1 class="post-title"><?php echo e($post['title']); ?></h1>
</header>
<div class="post-content">
<?php echo $post['content']; ?>
</div>
</article>
<section class="comments-section">
<h3>💬 评论 (<?php echo count($comments); ?>)</h3>
<?php if (!empty($comment_message)): ?>
<?php echo $comment_message; ?>
<?php endif; ?>
<div class="comment-list">
<?php if (!empty($comments)): ?>
<?php foreach ($comments as $comment): ?>
<div class="comment-item">
<div class="comment-author"><?php echo e($comment['author']); ?></div>
<div class="comment-date"><?php echo date('Y-m-d H:i', strtotime($comment['created_at'])); ?></div>
<div class="comment-content"><?php echo e($comment['content']); ?></div>
</div>
<?php endforeach; ?>
<?php else: ?>
<p class="no-comments">暂无评论,来说两句吧~</p>
<?php endif; ?>
</div>
<form method="POST" class="comment-form">
<input type="hidden" name="action" value="add_comment">
<input type="text" name="website" style="display:none" tabindex="-1" autocomplete="off">
<h4>发表评论</h4>
<div class="form-row">
<input type="text" name="author" placeholder="昵称 *" value="<?php echo e($data['author']); ?>" required>
<input type="email" name="email" placeholder="邮箱" value="<?php echo e($data['email']); ?>">
</div>
<input type="url" name="url" placeholder="网站" value="<?php echo e($data['url']); ?>">
<textarea name="content" rows="4" placeholder="评论内容 *" required><?php echo e($data['content']); ?></textarea>
<button type="submit" class="btn-submit">提交评论</button>
</form>
</section>
<?php include __DIR__ . '/footer.php'; ?>

View file

@ -0,0 +1,543 @@
:root {
--bg: #fafafa;
--surface: #fff;
--border: #e5e5e5;
--text: #333;
--text-secondary: #666;
--accent: #22c55e;
--accent2: #0ea5e9;
--shadow: 0 2px 8px rgba(0,0,0,0.08);
--radius: 12px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
}
a {
color: var(--accent);
text-decoration: none;
transition: color 0.2s;
}
a:hover {
color: #16a34a;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
/* Header */
.site-header {
background: var(--surface);
box-shadow: var(--shadow);
position: sticky;
top: 0;
z-index: 100;
}
.header-inner {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 0;
}
.logo {
font-size: 20px;
font-weight: 700;
color: var(--accent);
}
.main-nav {
display: flex;
gap: 24px;
}
.main-nav a {
color: var(--text);
font-weight: 500;
padding: 8px 0;
position: relative;
}
.main-nav a:hover,
.main-nav a.active {
color: var(--accent);
}
.main-nav a::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 0;
height: 2px;
background: var(--accent);
transition: width 0.2s;
}
.main-nav a:hover::after,
.main-nav a.active::after {
width: 100%;
}
.admin-link {
color: var(--text-secondary) !important;
font-size: 14px;
}
.mobile-menu-btn {
display: none;
background: none;
border: none;
font-size: 24px;
cursor: pointer;
}
/* Content Layout */
.main-content {
padding: 40px 0;
}
.content-wrapper {
display: grid;
grid-template-columns: 1fr 300px;
gap: 40px;
}
.main-area {
min-width: 0;
}
.sidebar-area {
display: flex;
flex-direction: column;
gap: 24px;
}
/* Post Card */
.post-card {
background: var(--surface);
padding: 24px;
border-radius: var(--radius);
margin-bottom: 20px;
box-shadow: var(--shadow);
transition: transform 0.2s, box-shadow 0.2s;
}
.post-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0,0,0,0.12);
}
.post-meta {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
font-size: 14px;
color: var(--text-secondary);
}
.category-tag {
background: var(--accent);
color: #fff;
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
}
.category-tag:hover {
background: #16a34a;
color: #fff;
}
.post-title {
font-size: 20px;
margin-bottom: 12px;
}
.post-title a {
color: var(--text);
}
.post-title a:hover {
color: var(--accent);
}
.post-excerpt {
color: var(--text-secondary);
line-height: 1.7;
}
/* Sidebar Widgets */
.sidebar-widget {
background: var(--surface);
padding: 20px;
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.sidebar-widget h3 {
font-size: 16px;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 2px solid var(--accent);
}
.category-list,
.recent-list {
list-style: none;
}
.category-list li a,
.recent-list li a {
display: flex;
justify-content: space-between;
padding: 10px 0;
color: var(--text);
border-bottom: 1px solid var(--border);
}
.category-list li:last-child a,
.recent-list li:last-child a {
border-bottom: none;
}
.category-list li a:hover,
.recent-list li a:hover {
color: var(--accent);
}
.cat-count {
background: var(--bg);
padding: 2px 8px;
border-radius: 10px;
font-size: 12px;
color: var(--text-secondary);
}
/* Single Post */
.post-single {
background: var(--surface);
padding: 40px;
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.post-header {
margin-bottom: 32px;
padding-bottom: 24px;
border-bottom: 1px solid var(--border);
}
.post-header .post-meta {
margin-bottom: 16px;
}
.post-header .post-title {
font-size: 32px;
margin-bottom: 0;
}
.post-content {
line-height: 1.8;
font-size: 16px;
}
.post-content p {
margin-bottom: 16px;
}
/* Comments */
.comments-section {
margin-top: 40px;
}
.comments-section h3 {
font-size: 20px;
margin-bottom: 24px;
}
.comment-list {
margin-bottom: 40px;
}
.comment-item {
background: var(--surface);
padding: 20px;
border-radius: var(--radius);
margin-bottom: 16px;
border: 1px solid var(--border);
}
.comment-author {
font-weight: 600;
color: var(--accent);
}
.comment-date {
font-size: 12px;
color: var(--text-secondary);
margin: 4px 0 12px;
}
.comment-form {
background: var(--surface);
padding: 24px;
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.comment-form h4 {
margin-bottom: 16px;
}
.comment-form .form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.comment-form input,
.comment-form textarea {
width: 100%;
padding: 12px 16px;
border: 2px solid var(--border);
border-radius: 8px;
font-size: 14px;
margin-bottom: 16px;
transition: border-color 0.2s;
font-family: inherit;
}
.comment-form input:focus,
.comment-form textarea:focus {
outline: none;
border-color: var(--accent);
}
.btn-submit {
background: var(--accent);
color: #fff;
border: none;
padding: 12px 32px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.btn-submit:hover {
background: #16a34a;
}
.comment-success {
background: #dcfce7;
color: #16a34a;
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 16px;
}
.comment-error {
background: #fee2e2;
color: #dc2626;
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 16px;
}
.no-comments {
color: var(--text-secondary);
text-align: center;
padding: 40px 0;
}
/* Pagination */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
margin-top: 32px;
}
.pagination a {
display: inline-block;
padding: 8px 16px;
background: var(--surface);
border-radius: 8px;
color: var(--text);
box-shadow: var(--shadow);
transition: all 0.2s;
}
.pagination a:hover {
background: var(--accent);
color: #fff;
}
.pagination a.active {
background: var(--accent);
color: #fff;
}
/* Archive */
.archive-header {
margin-bottom: 32px;
}
.archive-stats {
color: var(--text-secondary);
margin-top: 8px;
}
.archive-year {
margin-bottom: 32px;
}
.year-title {
font-size: 28px;
color: var(--accent);
margin-bottom: 16px;
}
.archive-month {
margin-bottom: 24px;
padding-left: 20px;
}
.month-title {
font-size: 18px;
color: var(--text-secondary);
margin-bottom: 12px;
}
.archive-list {
list-style: none;
}
.archive-list li {
display: flex;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid var(--border);
}
.archive-date {
color: var(--text-secondary);
font-size: 14px;
width: 60px;
flex-shrink: 0;
}
.archive-title {
color: var(--text);
}
.archive-title:hover {
color: var(--accent);
}
/* Category Header */
.category-header {
background: var(--surface);
padding: 32px;
border-radius: var(--radius);
margin-bottom: 24px;
box-shadow: var(--shadow);
}
.category-desc {
color: var(--text-secondary);
margin-top: 12px;
}
/* Empty State */
.empty-state {
background: var(--surface);
padding: 60px 20px;
text-align: center;
border-radius: var(--radius);
color: var(--text-secondary);
}
/* Footer */
.site-footer {
background: var(--surface);
padding: 24px 0;
text-align: center;
color: var(--text-secondary);
margin-top: 60px;
border-top: 1px solid var(--border);
}
/* Responsive */
@media (max-width: 900px) {
.content-wrapper {
grid-template-columns: 1fr;
}
.sidebar-area {
order: 2;
}
.main-area {
order: 1;
}
}
@media (max-width: 600px) {
.main-nav {
display: none;
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--surface);
flex-direction: column;
padding: 16px;
gap: 12px;
box-shadow: var(--shadow);
}
.main-nav.show {
display: flex;
}
.mobile-menu-btn {
display: block;
}
.header-inner {
position: relative;
}
.post-header .post-title {
font-size: 24px;
}
.post-single {
padding: 24px;
}
.comment-form .form-row {
grid-template-columns: 1fr;
}
}

View file

@ -0,0 +1,110 @@
<?php
/**
* Magine Template - Category Page (Dark Theme)
*/
require_once __DIR__ . '/../../config.php';
require_once INCLUDE_PATH . 'function.common.php';
require_once INCLUDE_PATH . 'function.post.php';
require_once INCLUDE_PATH . 'function.category.php';
$base_url = '';
$slug = $_GET['slug'] ?? '';
if (empty($slug)) {
http_response_code(404);
echo '分类不存在';
exit;
}
$category = get_category_by_slug($slug);
if (!$category) {
http_response_code(404);
echo '分类不存在';
exit;
}
$page_title = $category['name'];
$page = isset($_GET['page']) ? max(1, intval($_GET['page'])) : 1;
$per_page = intval(get_setting('posts_per_page', '10'));
$posts = get_posts($page, $per_page, $category['id']);
$total_posts = get_category_count($category['id']);
$total_pages = ceil($total_posts / $per_page);
$recent_posts = get_recent_posts(5);
$categories = get_categories();
include __DIR__ . '/header.php';
?>
<div class="main-area">
<div class="category-header">
<h1>📁 <?php echo e($category['name']); ?></h1>
<?php if ($category['description']): ?>
<p class="category-desc"><?php echo e($category['description']); ?></p>
<?php endif; ?>
</div>
<?php if (empty($posts)): ?>
<div class="empty-state">
<p>该分类下暂无文章</p>
</div>
<?php else: ?>
<?php foreach ($posts as $post): ?>
<article class="post-card">
<div class="post-meta">
<span class="post-date"><?php echo date('Y-m-d', strtotime($post['created_at'])); ?></span>
<span class="post-views">👁 <?php echo $post['views']; ?></span>
</div>
<h2 class="post-title"><a href="<?php echo $base_url; ?>post/<?php echo e($post['slug']); ?>"><?php echo e($post['title']); ?></a></h2>
<?php if ($post['excerpt']): ?>
<p class="post-excerpt"><?php echo e($post['excerpt']); ?></p>
<?php endif; ?>
</article>
<?php endforeach; ?>
<?php if ($total_pages > 1): ?>
<nav class="pagination">
<?php if ($page > 1): ?>
<a href="?slug=<?php echo e($slug); ?>&page=<?php echo $page - 1; ?>" class="prev">← 上一页</a>
<?php endif; ?>
<?php for ($i = max(1, $page - 2); $i <= min($total_pages, $page + 2); $i++): ?>
<a href="?slug=<?php echo e($slug); ?>&page=<?php echo $i; ?>" class="<?php echo $i === $page ? 'active' : ''; ?>"><?php echo $i; ?></a>
<?php endfor; ?>
<?php if ($page < $total_pages): ?>
<a href="?slug=<?php echo e($slug); ?>&page=<?php echo $page + 1; ?>" class="next">下一页 →</a>
<?php endif; ?>
</nav>
<?php endif; ?>
<?php endif; ?>
</div>
<aside class="sidebar-area">
<div class="sidebar-widget">
<h3>📁 分类</h3>
<ul class="category-list">
<?php foreach ($categories as $cat): ?>
<li>
<a href="<?php echo $base_url; ?>category/<?php echo e($cat['slug']); ?>" class="<?php echo $cat['id'] === $category['id'] ? 'active' : ''; ?>">
<span class="cat-name"><?php echo e($cat['name']); ?></span>
<span class="cat-count"><?php echo get_category_count($cat['id']); ?></span>
</a>
</li>
<?php endforeach; ?>
</ul>
</div>
<div class="sidebar-widget">
<h3>📝 最新文章</h3>
<ul class="recent-list">
<?php foreach ($recent_posts as $recent): ?>
<li><a href="<?php echo $base_url; ?>post/<?php echo e($recent['slug']); ?>"><?php echo e($recent['title']); ?></a></li>
<?php endforeach; ?>
</ul>
</div>
</aside>
<?php include __DIR__ . '/footer.php'; ?>

View file

@ -0,0 +1,35 @@
<?php
/**
* Magine Template - Footer (Dark Theme)
*/
$site_name = get_setting('site_name', 'TBlog');
$github_url = 'https://github.com/tblog-cn/tblog';
?>
</div>
</div>
</main>
<footer class="site-footer">
<div class="container">
<div class="footer-links">
<a href="https://tblog.cn" target="_blank">TBlog</a>
<span class="sep">·</span>
<a href="<?php echo $github_url; ?>" target="_blank" title="GitHub">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:middle">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z"/>
</svg>
开源
</a>
<span class="sep">·</span>
<a href="https://github.com/tblog-cn/tblog/releases" target="_blank">更新日志</a>
</div>
<p>© <?php echo date('Y'); ?> <?php echo e($site_name); ?> · MIT License</p>
</div>
</footer>
<script>
function toggleMobileMenu() {
var nav = document.querySelector('.main-nav');
nav.classList.toggle('show');
}
</script>
</body>
</html>

View file

@ -0,0 +1,40 @@
<?php
/**
* Magine Template - Header (Dark Theme)
*/
require_once __DIR__ . '/../../config.php';
require_once INCLUDE_PATH . 'function.common.php';
require_once INCLUDE_PATH . 'function.category.php';
$site_name = get_setting('site_name', 'TBlog');
$site_description = get_setting('site_description', '一个简洁优雅的开源博客系统');
$categories = get_categories();
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?php echo isset($page_title) ? e($page_title) . ' - ' : ''; ?><?php echo e($site_name); ?></title>
<meta name="description" content="<?php echo e($site_description); ?>">
<link rel="stylesheet" href="<?php echo $base_url ?? ''; ?>templates/magine/static/css/style.css">
</head>
<body>
<header class="site-header">
<div class="container">
<div class="header-inner">
<a href="<?php echo $base_url ?? ''; ?>" class="logo">🌙 <?php echo e($site_name); ?></a>
<nav class="main-nav">
<a href="<?php echo $base_url ?? ''; ?>">首页</a>
<?php foreach ($categories as $cat): ?>
<a href="<?php echo ($base_url ?? '') . 'category/' . e($cat['slug']); ?>"><?php echo e($cat['name']); ?></a>
<?php endforeach; ?>
<a href="<?php echo ($base_url ?? '') . 'admin/'; ?>" class="admin-link">管理</a>
</nav>
<button class="mobile-menu-btn" onclick="toggleMobileMenu()">☰</button>
</div>
</div>
</header>
<main class="main-content">
<div class="container">
<div class="content-wrapper">

View file

@ -0,0 +1,89 @@
<?php
/**
* Magine Template - Home Page (Dark Theme)
*/
require_once __DIR__ . '/../../config.php';
require_once INCLUDE_PATH . 'function.common.php';
require_once INCLUDE_PATH . 'function.post.php';
require_once INCLUDE_PATH . 'function.category.php';
$base_url = '';
$page_title = '首页';
$page = isset($_GET['page']) ? max(1, intval($_GET['page'])) : 1;
$per_page = intval(get_setting('posts_per_page', '10'));
$posts = get_posts($page, $per_page);
$total_posts = get_total_posts();
$total_pages = ceil($total_posts / $per_page);
$recent_posts = get_recent_posts(5);
$categories = get_categories();
include __DIR__ . '/header.php';
?>
<div class="main-area">
<?php if (empty($posts)): ?>
<div class="empty-state">
<p>暂无文章</p>
</div>
<?php else: ?>
<?php foreach ($posts as $post): ?>
<article class="post-card">
<div class="post-meta">
<a href="<?php echo $base_url; ?>category/<?php echo e($post['category_slug']); ?>" class="category-tag"><?php echo e($post['category_name']); ?></a>
<span class="post-date"><?php echo date('Y-m-d', strtotime($post['created_at'])); ?></span>
<span class="post-views">👁 <?php echo $post['views']; ?></span>
</div>
<h2 class="post-title"><a href="<?php echo $base_url; ?>post/<?php echo e($post['slug']); ?>"><?php echo e($post['title']); ?></a></h2>
<?php if ($post['excerpt']): ?>
<p class="post-excerpt"><?php echo e($post['excerpt']); ?></p>
<?php endif; ?>
</article>
<?php endforeach; ?>
<?php if ($total_pages > 1): ?>
<nav class="pagination">
<?php if ($page > 1): ?>
<a href="?page=<?php echo $page - 1; ?>" class="prev">← 上一页</a>
<?php endif; ?>
<?php for ($i = max(1, $page - 2); $i <= min($total_pages, $page + 2); $i++): ?>
<a href="?page=<?php echo $i; ?>" class="<?php echo $i === $page ? 'active' : ''; ?>"><?php echo $i; ?></a>
<?php endfor; ?>
<?php if ($page < $total_pages): ?>
<a href="?page=<?php echo $page + 1; ?>" class="next">下一页 →</a>
<?php endif; ?>
</nav>
<?php endif; ?>
<?php endif; ?>
</div>
<aside class="sidebar-area">
<div class="sidebar-widget">
<h3>📁 分类</h3>
<ul class="category-list">
<?php foreach ($categories as $cat): ?>
<li>
<a href="<?php echo $base_url; ?>category/<?php echo e($cat['slug']); ?>">
<span class="cat-name"><?php echo e($cat['name']); ?></span>
<span class="cat-count"><?php echo get_category_count($cat['id']); ?></span>
</a>
</li>
<?php endforeach; ?>
</ul>
</div>
<div class="sidebar-widget">
<h3>📝 最新文章</h3>
<ul class="recent-list">
<?php foreach ($recent_posts as $recent): ?>
<li><a href="<?php echo $base_url; ?>post/<?php echo e($recent['slug']); ?>"><?php echo e($recent['title']); ?></a></li>
<?php endforeach; ?>
</ul>
</div>
</aside>
<?php include __DIR__ . '/footer.php'; ?>

107
templates/magine/post.php Normal file
View file

@ -0,0 +1,107 @@
<?php
/**
* Magine Template - Single Post (Dark Theme)
*/
require_once __DIR__ . '/../../config.php';
require_once INCLUDE_PATH . 'function.common.php';
require_once INCLUDE_PATH . 'function.post.php';
require_once INCLUDE_PATH . 'function.comment.php';
$base_url = '';
$slug = $_GET['slug'] ?? '';
if (empty($slug)) {
http_response_code(404);
echo '文章不存在';
exit;
}
$post = get_post_by_slug($slug);
if (!$post) {
http_response_code(404);
echo '文章不存在';
exit;
}
$page_title = $post['title'];
$comments = get_comments($post['id']);
$comment_message = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST' && !empty($_POST['action']) && $_POST['action'] === 'add_comment') {
$data = [
'post_id' => $post['id'],
'author' => trim($_POST['author'] ?? ''),
'email' => trim($_POST['email'] ?? ''),
'url' => trim($_POST['url'] ?? ''),
'content' => trim($_POST['content'] ?? '')
];
if (empty($data['author']) || empty($data['content'])) {
$comment_message = '<div class="comment-error">请填写昵称和评论内容</div>';
} else {
if (create_comment($data)) {
$comment_message = '<div class="comment-success">评论已提交,等待审核</div>';
$data = ['post_id' => $post['id'], 'author' => '', 'email' => '', 'url' => '', 'content' => ''];
} else {
$comment_message = '<div class="comment-error">评论提交失败</div>';
}
}
} else {
$data = ['post_id' => $post['id'], 'author' => '', 'email' => '', 'url' => '', 'content' => ''];
}
include __DIR__ . '/header.php';
?>
<article class="post-single">
<header class="post-header">
<div class="post-meta">
<a href="<?php echo $base_url; ?>category/<?php echo e($post['category_slug']); ?>" class="category-tag"><?php echo e($post['category_name']); ?></a>
<span class="post-date"><?php echo date('Y-m-d', strtotime($post['created_at'])); ?></span>
<span class="post-views">👁 <?php echo $post['views']; ?> 次阅读</span>
</div>
<h1 class="post-title"><?php echo e($post['title']); ?></h1>
</header>
<div class="post-content">
<?php echo $post['content']; ?>
</div>
</article>
<section class="comments-section">
<h3>💬 评论 (<?php echo count($comments); ?>)</h3>
<?php if (!empty($comment_message)): ?>
<?php echo $comment_message; ?>
<?php endif; ?>
<div class="comment-list">
<?php if (!empty($comments)): ?>
<?php foreach ($comments as $comment): ?>
<div class="comment-item">
<div class="comment-author"><?php echo e($comment['author']); ?></div>
<div class="comment-date"><?php echo date('Y-m-d H:i', strtotime($comment['created_at'])); ?></div>
<div class="comment-content"><?php echo e($comment['content']); ?></div>
</div>
<?php endforeach; ?>
<?php else: ?>
<p class="no-comments">暂无评论,来说两句吧~</p>
<?php endif; ?>
</div>
<form method="POST" class="comment-form">
<input type="hidden" name="action" value="add_comment">
<input type="text" name="website" style="display:none" tabindex="-1" autocomplete="off">
<h4>发表评论</h4>
<div class="form-row">
<input type="text" name="author" placeholder="昵称 *" value="<?php echo e($data['author']); ?>" required>
<input type="email" name="email" placeholder="邮箱" value="<?php echo e($data['email']); ?>">
</div>
<input type="url" name="url" placeholder="网站" value="<?php echo e($data['url']); ?>">
<textarea name="content" rows="4" placeholder="评论内容 *" required><?php echo e($data['content']); ?></textarea>
<button type="submit" class="btn-submit">提交评论</button>
</form>
</section>
<?php include __DIR__ . '/footer.php'; ?>

View file

@ -0,0 +1,561 @@
:root {
--bg: #0a0a0f;
--surface: #12121a;
--surface-hover: #1a1a25;
--border: #2a2a3a;
--text: #e0e0e8;
--text-secondary: #8888a0;
--accent: #4ade80;
--accent2: #38bdf8;
--shadow: 0 2px 8px rgba(0,0,0,0.4);
--radius: 12px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
}
a {
color: var(--accent);
text-decoration: none;
transition: color 0.2s;
}
a:hover {
color: #86efac;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
/* Header */
.site-header {
background: var(--surface);
box-shadow: var(--shadow);
position: sticky;
top: 0;
z-index: 100;
border-bottom: 1px solid var(--border);
}
.header-inner {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 0;
}
.logo {
font-size: 20px;
font-weight: 700;
color: var(--accent);
}
.main-nav {
display: flex;
gap: 24px;
}
.main-nav a {
color: var(--text);
font-weight: 500;
padding: 8px 0;
position: relative;
}
.main-nav a:hover,
.main-nav a.active {
color: var(--accent);
}
.main-nav a::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 0;
height: 2px;
background: var(--accent);
transition: width 0.2s;
}
.main-nav a:hover::after,
.main-nav a.active::after {
width: 100%;
}
.admin-link {
color: var(--text-secondary) !important;
font-size: 14px;
}
.mobile-menu-btn {
display: none;
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: var(--text);
}
/* Content Layout */
.main-content {
padding: 40px 0;
}
.content-wrapper {
display: grid;
grid-template-columns: 1fr 300px;
gap: 40px;
}
.main-area {
min-width: 0;
}
.sidebar-area {
display: flex;
flex-direction: column;
gap: 24px;
}
/* Post Card */
.post-card {
background: var(--surface);
padding: 24px;
border-radius: var(--radius);
margin-bottom: 20px;
box-shadow: var(--shadow);
border: 1px solid var(--border);
transition: transform 0.2s, box-shadow 0.2s, border-color 0.2s;
}
.post-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0,0,0,0.5);
border-color: var(--accent);
}
.post-meta {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
font-size: 14px;
color: var(--text-secondary);
}
.category-tag {
background: var(--accent);
color: #0a0a0f;
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
}
.category-tag:hover {
background: #86efac;
color: #0a0a0f;
}
.post-title {
font-size: 20px;
margin-bottom: 12px;
}
.post-title a {
color: var(--text);
}
.post-title a:hover {
color: var(--accent);
}
.post-excerpt {
color: var(--text-secondary);
line-height: 1.7;
}
/* Sidebar Widgets */
.sidebar-widget {
background: var(--surface);
padding: 20px;
border-radius: var(--radius);
box-shadow: var(--shadow);
border: 1px solid var(--border);
}
.sidebar-widget h3 {
font-size: 16px;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 2px solid var(--accent);
}
.category-list,
.recent-list {
list-style: none;
}
.category-list li a,
.recent-list li a {
display: flex;
justify-content: space-between;
padding: 10px 0;
color: var(--text);
border-bottom: 1px solid var(--border);
}
.category-list li:last-child a,
.recent-list li:last-child a {
border-bottom: none;
}
.category-list li a:hover,
.recent-list li a:hover {
color: var(--accent);
}
.cat-count {
background: var(--bg);
padding: 2px 8px;
border-radius: 10px;
font-size: 12px;
color: var(--text-secondary);
}
/* Single Post */
.post-single {
background: var(--surface);
padding: 40px;
border-radius: var(--radius);
box-shadow: var(--shadow);
border: 1px solid var(--border);
}
.post-header {
margin-bottom: 32px;
padding-bottom: 24px;
border-bottom: 1px solid var(--border);
}
.post-header .post-meta {
margin-bottom: 16px;
}
.post-header .post-title {
font-size: 32px;
margin-bottom: 0;
}
.post-content {
line-height: 1.8;
font-size: 16px;
}
.post-content p {
margin-bottom: 16px;
}
/* Comments */
.comments-section {
margin-top: 40px;
}
.comments-section h3 {
font-size: 20px;
margin-bottom: 24px;
}
.comment-list {
margin-bottom: 40px;
}
.comment-item {
background: var(--surface);
padding: 20px;
border-radius: var(--radius);
margin-bottom: 16px;
border: 1px solid var(--border);
}
.comment-author {
font-weight: 600;
color: var(--accent);
}
.comment-date {
font-size: 12px;
color: var(--text-secondary);
margin: 4px 0 12px;
}
.comment-form {
background: var(--surface);
padding: 24px;
border-radius: var(--radius);
box-shadow: var(--shadow);
border: 1px solid var(--border);
}
.comment-form h4 {
margin-bottom: 16px;
}
.comment-form .form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.comment-form input,
.comment-form textarea {
width: 100%;
padding: 12px 16px;
border: 2px solid var(--border);
border-radius: 8px;
font-size: 14px;
margin-bottom: 16px;
transition: border-color 0.2s;
font-family: inherit;
background: var(--bg);
color: var(--text);
}
.comment-form input:focus,
.comment-form textarea:focus {
outline: none;
border-color: var(--accent);
}
.btn-submit {
background: var(--accent);
color: #0a0a0f;
border: none;
padding: 12px 32px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.btn-submit:hover {
background: #86efac;
}
.comment-success {
background: rgba(74, 222, 128, 0.15);
color: var(--accent);
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 16px;
border: 1px solid var(--accent);
}
.comment-error {
background: rgba(248, 113, 113, 0.15);
color: #f87171;
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 16px;
border: 1px solid #f87171;
}
.no-comments {
color: var(--text-secondary);
text-align: center;
padding: 40px 0;
}
/* Pagination */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
margin-top: 32px;
}
.pagination a {
display: inline-block;
padding: 8px 16px;
background: var(--surface);
border-radius: 8px;
color: var(--text);
box-shadow: var(--shadow);
border: 1px solid var(--border);
transition: all 0.2s;
}
.pagination a:hover {
background: var(--accent);
color: #0a0a0f;
border-color: var(--accent);
}
.pagination a.active {
background: var(--accent);
color: #0a0a0f;
border-color: var(--accent);
}
/* Archive */
.archive-header {
margin-bottom: 32px;
}
.archive-stats {
color: var(--text-secondary);
margin-top: 8px;
}
.archive-year {
margin-bottom: 32px;
}
.year-title {
font-size: 28px;
color: var(--accent);
margin-bottom: 16px;
}
.archive-month {
margin-bottom: 24px;
padding-left: 20px;
}
.month-title {
font-size: 18px;
color: var(--text-secondary);
margin-bottom: 12px;
}
.archive-list {
list-style: none;
}
.archive-list li {
display: flex;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid var(--border);
}
.archive-date {
color: var(--text-secondary);
font-size: 14px;
width: 60px;
flex-shrink: 0;
}
.archive-title {
color: var(--text);
}
.archive-title:hover {
color: var(--accent);
}
/* Category Header */
.category-header {
background: var(--surface);
padding: 32px;
border-radius: var(--radius);
margin-bottom: 24px;
box-shadow: var(--shadow);
border: 1px solid var(--border);
}
.category-desc {
color: var(--text-secondary);
margin-top: 12px;
}
/* Empty State */
.empty-state {
background: var(--surface);
padding: 60px 20px;
text-align: center;
border-radius: var(--radius);
color: var(--text-secondary);
border: 1px solid var(--border);
}
/* Footer */
.site-footer {
background: var(--surface);
padding: 24px 0;
text-align: center;
color: var(--text-secondary);
margin-top: 60px;
border-top: 1px solid var(--border);
}
/* Responsive */
@media (max-width: 900px) {
.content-wrapper {
grid-template-columns: 1fr;
}
.sidebar-area {
order: 2;
}
.main-area {
order: 1;
}
}
@media (max-width: 600px) {
.main-nav {
display: none;
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--surface);
flex-direction: column;
padding: 16px;
gap: 12px;
box-shadow: var(--shadow);
border-top: 1px solid var(--border);
}
.main-nav.show {
display: flex;
}
.mobile-menu-btn {
display: block;
}
.header-inner {
position: relative;
}
.post-header .post-title {
font-size: 24px;
}
.post-single {
padding: 24px;
}
.comment-form .form-row {
grid-template-columns: 1fr;
}
}