diff --git a/admin/assets/admin.css b/admin/assets/admin.css new file mode 100644 index 0000000..28e7eb8 --- /dev/null +++ b/admin/assets/admin.css @@ -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; + } +} diff --git a/admin/category.php b/admin/category.php new file mode 100644 index 0000000..17c3c3c --- /dev/null +++ b/admin/category.php @@ -0,0 +1,169 @@ + 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 = '
名称和别名不能为空
'; + } else { + if (!empty($_POST['id'])) { + update_category(intval($_POST['id']), $data); + $message = '
分类已更新
'; + } else { + create_category($data); + $message = '
分类已创建
'; + } + } +} + +// 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 = '
该分类下有 ' . $count . ' 篇文章,无法删除
'; + } else { + delete_category(intval($_GET['delete'])); + $message = '
分类已删除
'; + } +} + +// Get all categories +$categories = get_categories(); +?> + + + + + + 分类管理 - ZhangPu Blog + + + +
+ +
+ + + + + +

+
+ + + + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + 取消 +
+
+ +

分类管理

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
名称别名描述文章数排序操作
+ 编辑 + 删除 +
暂无分类
+ +
+
+ + diff --git a/admin/comment.php b/admin/comment.php new file mode 100644 index 0000000..8c09c2b --- /dev/null +++ b/admin/comment.php @@ -0,0 +1,158 @@ + 0) { + if ($_POST['action'] === 'approve') { + update_comment_status($id, 'approved'); + $message = '
评论已通过
'; + } elseif ($_POST['action'] === 'reject') { + update_comment_status($id, 'rejected'); + $message = '
评论已拒绝
'; + } elseif ($_POST['action'] === 'delete') { + delete_comment($id); + $message = '
评论已删除
'; + } + } +} + +// Filter +$status_filter = $_GET['status'] ?? null; +$comments = get_all_comments($status_filter); +?> + + + + + + 评论管理 - ZhangPu Blog + + + +
+ +
+ + + + +

评论管理

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
文章作者内容状态时间操作
+ + +
+ +
+
+
'待审核','approved'=>'已通过','rejected'=>'已拒绝']; + echo $status_map[$comment['status']]; + ?> + +
+ + + + +
+ + +
+ + + + +
+ +
+ + + + +
+
暂无评论
+
+
+ + + \ No newline at end of file diff --git a/admin/index.php b/admin/index.php new file mode 100644 index 0000000..cabd3e7 --- /dev/null +++ b/admin/index.php @@ -0,0 +1,99 @@ + + + + + + + 管理后台 - ZhangPu Blog + + + +
+ +
+

管理后台

+ +
+
+
📝
+
+
+
文章总数
+
+
+
+
📁
+
+
+
分类数量
+
+
+
+
💬
+
+
+
评论总数
+
+
+
+ +

最新文章

+ + + + + + + + + + + + + + + + + + + + + + +
标题分类状态发布时间操作
+ 编辑 + 查看 +
+
+
+ + diff --git a/admin/login.php b/admin/login.php new file mode 100644 index 0000000..5483199 --- /dev/null +++ b/admin/login.php @@ -0,0 +1,135 @@ + + + + + + + 登录 - ZhangPu Blog 管理后台 + + + +
+

🌿 ZhangPu Blog 管理后台

+ +
+ +
+ +
+ + +
+
+ + +
+ +
+ +
+ + \ No newline at end of file diff --git a/admin/logout.php b/admin/logout.php new file mode 100644 index 0000000..a03a4ba --- /dev/null +++ b/admin/logout.php @@ -0,0 +1,9 @@ + 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 = '
标题和内容不能为空
'; + } 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 = '
文章已更新
'; + } else { + create_post($data); + $message = '
文章已创建
'; + } + } +} + +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 = '
文章已删除
'; +} + +$categories = get_categories(); +?> + + + + + + 文章管理 - ZhangPu Blog + + + + +
+ +
+ + + + + +

+
+ + + + + +
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ +
+
+ +
+ + + + +
+ + +
+
+ +
+
+ + + + +
+ + + +
+ +
+ +
+ + +
+ +
+ +
+ + +
+ +
+ + 取消 +
+
+ +

文章管理 (共 篇)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
标题分类模板状态浏览发布时间操作
+ 编辑 + 查看 + 删除 +
暂无文章
+ + 1): ?> + + + + +
+
+ + + \ No newline at end of file diff --git a/admin/settings.php b/admin/settings.php new file mode 100644 index 0000000..6d81227 --- /dev/null +++ b/admin/settings.php @@ -0,0 +1,153 @@ +当前密码错误'; + } else { + if (!empty($new_username)) { + update_setting('admin_user', $new_username); + } + if (!empty($new_password)) { + set_admin_password($new_password); + } + $message = '
账号信息已更新(下次登录生效)
'; + } + } 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 = '
设置已保存
'; + } +} + +// 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(); +?> + + + + + + 设置 - ZhangPu Blog + + + +
+ +
+ + + + +

修改登录账号

+
+ +
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +

网站设置

+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+ +

系统信息

+ + + + + + + + + + + + + + + + + +
PHP 版本
MySQL 客户端版本
服务器软件
当前登录用户
+
+
+ + \ No newline at end of file diff --git a/include/HTMLPurifier.simple.php b/include/HTMLPurifier.simple.php new file mode 100644 index 0000000..a76469d --- /dev/null +++ b/include/HTMLPurifier.simple.php @@ -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)[^>]*>.*?#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('' . "\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; +} \ No newline at end of file diff --git a/include/db.php b/include/db.php new file mode 100644 index 0000000..2dcec8b --- /dev/null +++ b/include/db.php @@ -0,0 +1,16 @@ +connect_error) { + die('数据库连接失败: ' . $db->connect_error); + } + $db->set_charset('utf8mb4'); + } + return $db; +} diff --git a/include/function.category.php b/include/function.category.php new file mode 100644 index 0000000..e394710 --- /dev/null +++ b/include/function.category.php @@ -0,0 +1,56 @@ +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]; +} diff --git a/include/function.comment.php b/include/function.comment.php new file mode 100644 index 0000000..bbb330b --- /dev/null +++ b/include/function.comment.php @@ -0,0 +1,103 @@ +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]; + } +} \ No newline at end of file diff --git a/include/function.common.php b/include/function.common.php new file mode 100644 index 0000000..dcf0995 --- /dev/null +++ b/include/function.common.php @@ -0,0 +1,251 @@ +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 ''; +} + +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)[^>]*>.*?#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('' . "\n" . '
' . $html . '
', 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), '/'); +} \ No newline at end of file diff --git a/include/function.post.php b/include/function.post.php new file mode 100644 index 0000000..4a0f56e --- /dev/null +++ b/include/function.post.php @@ -0,0 +1,139 @@ +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); +} diff --git a/include/router.php b/include/router.php new file mode 100644 index 0000000..cc2cf7c --- /dev/null +++ b/include/router.php @@ -0,0 +1,70 @@ +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'; +?> + +
+
+

📚 文章归档

+

共 篇文章

+
+ + + $months): ?> +
+

年

+ $posts): ?> +
+

月

+
    + +
  • + + +
  • + +
+
+ +
+ + +
+

暂无文章

+
+ +
+ + + + diff --git a/templates/flow/category.php b/templates/flow/category.php new file mode 100644 index 0000000..cc26233 --- /dev/null +++ b/templates/flow/category.php @@ -0,0 +1,110 @@ + + +
+
+

📁

+ +

+ +
+ + +
+

该分类下暂无文章

+
+ + +
+ +

+ +

+ +
+ + + 1): ?> + + + +
+ + + + diff --git a/templates/flow/footer.php b/templates/flow/footer.php new file mode 100644 index 0000000..a0e9866 --- /dev/null +++ b/templates/flow/footer.php @@ -0,0 +1,35 @@ + + + + + + + + \ No newline at end of file diff --git a/templates/flow/header.php b/templates/flow/header.php new file mode 100644 index 0000000..dabda07 --- /dev/null +++ b/templates/flow/header.php @@ -0,0 +1,42 @@ + + + + + + + <?php echo isset($page_title) ? e($page_title) . ' - ' : ''; ?><?php echo e($site_name); ?> + + + + + + +
+
+
\ No newline at end of file diff --git a/templates/flow/index.php b/templates/flow/index.php new file mode 100644 index 0000000..2bcb728 --- /dev/null +++ b/templates/flow/index.php @@ -0,0 +1,89 @@ + + +
+ +
+

暂无文章

+
+ + +
+ +

+ +

+ +
+ + + 1): ?> + + + +
+ + + + diff --git a/templates/flow/post.php b/templates/flow/post.php new file mode 100644 index 0000000..8cb5b3e --- /dev/null +++ b/templates/flow/post.php @@ -0,0 +1,108 @@ + $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 = '
请填写昵称和评论内容
'; + } else { + if (create_comment($data)) { + $comment_message = '
评论已提交,等待审核
'; + $data = ['post_id' => $post['id'], 'author' => '', 'email' => '', 'url' => '', 'content' => '']; + } else { + $comment_message = '
评论提交失败
'; + } + } +} else { + $data = ['post_id' => $post['id'], 'author' => '', 'email' => '', 'url' => '', 'content' => '']; +} + +include __DIR__ . '/header.php'; +?> + +
+
+ +

+
+ +
+ +
+
+ +
+

💬 评论 ()

+ + + + + +
+ + +
+
+
+
+
+ + +

暂无评论,来说两句吧~

+ +
+ +
+ + +

发表评论

+
+ + +
+ + + +
+
+ + diff --git a/templates/flow/static/css/style.css b/templates/flow/static/css/style.css new file mode 100644 index 0000000..1358e9e --- /dev/null +++ b/templates/flow/static/css/style.css @@ -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; + } +} diff --git a/templates/magine/category.php b/templates/magine/category.php new file mode 100644 index 0000000..58141db --- /dev/null +++ b/templates/magine/category.php @@ -0,0 +1,110 @@ + + +
+
+

📁

+ +

+ +
+ + +
+

该分类下暂无文章

+
+ + +
+ +

+ +

+ +
+ + + 1): ?> + + + +
+ + + + diff --git a/templates/magine/footer.php b/templates/magine/footer.php new file mode 100644 index 0000000..b274a5e --- /dev/null +++ b/templates/magine/footer.php @@ -0,0 +1,35 @@ + +
+
+
+ + + + \ No newline at end of file diff --git a/templates/magine/header.php b/templates/magine/header.php new file mode 100644 index 0000000..65e4d09 --- /dev/null +++ b/templates/magine/header.php @@ -0,0 +1,40 @@ + + + + + + + <?php echo isset($page_title) ? e($page_title) . ' - ' : ''; ?><?php echo e($site_name); ?> + + + + + +
+
+
\ No newline at end of file diff --git a/templates/magine/index.php b/templates/magine/index.php new file mode 100644 index 0000000..6a4bb5d --- /dev/null +++ b/templates/magine/index.php @@ -0,0 +1,89 @@ + + +
+ +
+

暂无文章

+
+ + +
+ +

+ +

+ +
+ + + 1): ?> + + + +
+ + + + diff --git a/templates/magine/post.php b/templates/magine/post.php new file mode 100644 index 0000000..9c1938b --- /dev/null +++ b/templates/magine/post.php @@ -0,0 +1,107 @@ + $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 = '
请填写昵称和评论内容
'; + } else { + if (create_comment($data)) { + $comment_message = '
评论已提交,等待审核
'; + $data = ['post_id' => $post['id'], 'author' => '', 'email' => '', 'url' => '', 'content' => '']; + } else { + $comment_message = '
评论提交失败
'; + } + } +} else { + $data = ['post_id' => $post['id'], 'author' => '', 'email' => '', 'url' => '', 'content' => '']; +} + +include __DIR__ . '/header.php'; +?> + +
+
+ +

+
+ +
+ +
+
+ +
+

💬 评论 ()

+ + + + + +
+ + +
+
+
+
+
+ + +

暂无评论,来说两句吧~

+ +
+ +
+ + +

发表评论

+
+ + +
+ + + +
+
+ + diff --git a/templates/magine/static/css/style.css b/templates/magine/static/css/style.css new file mode 100644 index 0000000..ed05730 --- /dev/null +++ b/templates/magine/static/css/style.css @@ -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; + } +}