commit 76c241f032fedc5823ef8924d56a7beb4ceb88a9 Author: Your Name Date: Wed Sep 9 19:30:18 2026 +0800 init: 我的博客项目 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b59919e --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# 本地上传数据(不提交) +uploads/ + +# 日志 +*.log + +# 系统/编辑器文件 +Thumbs.db +.DS_Store +.vscode/ +.idea/ diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..d0f67cc --- /dev/null +++ b/.htaccess @@ -0,0 +1,15 @@ +# 启用 gzip 压缩 + + AddOutputFilterByType DEFLATE text/html text/plain text/css text/xml application/javascript application/json + + +# 静态资源浏览器缓存 + + ExpiresActive On + ExpiresByType text/css "access plus 7 days" + ExpiresByType application/javascript "access plus 7 days" + ExpiresByType image/jpeg "access plus 30 days" + ExpiresByType image/png "access plus 30 days" + ExpiresByType image/gif "access plus 30 days" + ExpiresByType image/webp "access plus 30 days" + diff --git a/about.php b/about.php new file mode 100644 index 0000000..b2c4ffe --- /dev/null +++ b/about.php @@ -0,0 +1,19 @@ + +
+

关于

+

欢迎访问我的博客。这是一个极简液态玻璃风格的个人博客系统,基于 PHP + MySQL 构建。

+

技术栈

+

前端使用原生 HTML / CSS / JavaScript,配合 backdrop-filter 实现液态玻璃视觉效果。后端使用 PHP(PDO 预处理防止 SQL 注入),数据库使用 MySQL。

+

功能

+

支持文章发布、分类、标签与按月归档,提供全文搜索与 Markdown 渲染,内置评论系统,配备文章、分类、标签、评论的后台管理,并支持明暗主题切换。

+

设计

+

界面采用极简黑白灰配色,无装饰符号,玻璃材质贯穿导航、卡片、侧栏与表单,明暗双主题由 CSS 变量驱动。

+
+ \ No newline at end of file diff --git a/admin/_layout.php b/admin/_layout.php new file mode 100644 index 0000000..411c23a --- /dev/null +++ b/admin/_layout.php @@ -0,0 +1,43 @@ + + + + + + +<?php echo isset($pageTitle) ? e($pageTitle) . " - 后台" : "后台管理"; ?> + + + + +
+
+
我的博客 后台
+
+ 查看前台 + 退出 +
+
+
+
+
+
+
内容管理
+ " href="admin/article_list.php">文章管理 + " href="admin/comment.php">评论管理 +
+
分类体系
+ " href="admin/category.php">分类管理 + " href="admin/tag.php">标签管理 +
+
总览
+ " href="admin/index.php">仪表盘 +
+
+
+
\ No newline at end of file diff --git a/admin/_tail.php b/admin/_tail.php new file mode 100644 index 0000000..abe2135 --- /dev/null +++ b/admin/_tail.php @@ -0,0 +1,18 @@ +
+
+
+ 后台管理系统 +
+
+
+
确认操作
+
+
+ + +
+
+
+ + + \ No newline at end of file diff --git a/admin/article_delete.php b/admin/article_delete.php new file mode 100644 index 0000000..3bbf13e --- /dev/null +++ b/admin/article_delete.php @@ -0,0 +1,12 @@ +prepare("DELETE FROM article_tags WHERE article_id=?")->execute([$id]); + db()->prepare("DELETE FROM comments WHERE article_id=?")->execute([$id]); + db()->prepare("DELETE FROM articles WHERE id=?")->execute([$id]); +} +header("Location: article_list.php?deleted=1"); exit; diff --git a/admin/article_edit.php b/admin/article_edit.php new file mode 100644 index 0000000..a8cb9da --- /dev/null +++ b/admin/article_edit.php @@ -0,0 +1,104 @@ +prepare("SELECT * FROM articles WHERE id=?"); + $stmt->execute([$id]); + $article = $stmt->fetch(); + if (!$article) { echo '
文章不存在或已删除
'; echo '

返回列表

'; require __DIR__ . "/_tail.php"; exit; } +} + +$cats = get_categories(); +$allTags = get_all_tags(); +$curTags = $article ? array_column(get_article_tags($article["id"]), "id") : []; + +$msg = ""; +if ($_SERVER["REQUEST_METHOD"] === "POST") { + $title = trim($_POST["title"] ?? ""); + $slug = trim($_POST["slug"] ?? ""); + $category_id = (int)($_POST["category_id"] ?? 0); + $excerpt = trim($_POST["excerpt"] ?? ""); + $content = $_POST["content"] ?? ""; + $status = ($_POST["status"] ?? "published") === "draft" ? "draft" : "published"; + $tags = $_POST["tags"] ?? []; if (!is_array($tags)) { $tags = []; } + if ($title === "" || $content === "") { + $msg = '
标题和内容不能为空
'; + } else { + if ($slug === "") { $slug = slugify($title); } + if ($msg === "") { + $user_id = current_user()["id"]; + if ($id) { + $s = db()->prepare("UPDATE articles SET title=?, slug=?, category_id=?, excerpt=?, content=?, status=? WHERE id=?"); + $s->execute([$title, $slug, $category_id ?: null, $excerpt, $content, $status, $id]); + } else { + $s = db()->prepare("INSERT INTO articles (user_id, title, slug, category_id, excerpt, content, status, views) VALUES (?,?,?,?,?,?,?,0)"); + $s->execute([$user_id, $title, $slug, $category_id ?: null, $excerpt, $content, $status]); + $id = (int)db()->lastInsertId(); + } + db()->prepare("DELETE FROM article_tags WHERE article_id=?")->execute([$id]); + if ($tags) { + $si = db()->prepare("INSERT IGNORE INTO article_tags (article_id, tag_id) VALUES (?,?)"); + foreach ($tags as $tid) { $si->execute([$id, (int)$tid]); } + } + header("Location: article_list.php?ok=1"); exit; + } + } +} +?> + +

+ +
+
+
+ + " required> +
+
+ + " placeholder="english-slug"> +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+ + + + 暂无标签,可先在标签管理中创建 +
+
+
+ + +
+ + 返回 +
+
+ \ No newline at end of file diff --git a/admin/article_list.php b/admin/article_list.php new file mode 100644 index 0000000..92799ff --- /dev/null +++ b/admin/article_list.php @@ -0,0 +1,48 @@ +query("SELECT COUNT(*) FROM articles")->fetchColumn(); +$pages = max(1,(int)ceil($total/$per)); +$page = min($page,$pages); +$off = ($page-1)*$per; +$stmt = db()->prepare("SELECT a.id,a.title,a.views,a.created_at,c.name AS cat, COUNT(t.id) AS tagcount FROM articles a LEFT JOIN categories c ON a.category_id=c.id LEFT JOIN article_tags at ON at.article_id=a.id LEFT JOIN tags t ON t.id=at.tag_id GROUP BY a.id ORDER BY a.created_at DESC LIMIT ? OFFSET ?"); +$stmt->bindValue(1,$per,PDO::PARAM_INT); +$stmt->bindValue(2,$off,PDO::PARAM_INT); +$stmt->execute(); +$list = $stmt->fetchAll(); +?> +
+

文章管理

+ 写文章 +
+
删除成功
+ +
+ + + + + + + + + + + + + + + + +
ID标题分类标签阅读日期操作
暂无文章
">编辑" data-confirm="确定删除该文章?">删除
+
+ " href="?page="> +
+
+ \ No newline at end of file diff --git a/admin/category.php b/admin/category.php new file mode 100644 index 0000000..a6496ff --- /dev/null +++ b/admin/category.php @@ -0,0 +1,55 @@ +分类名不能为空'; } + else { + $s = db()->prepare("INSERT INTO categories (name, slug) VALUES (?,?)"); + $s->execute([$name, $slug]); + header("Location: category.php?ok=1"); exit; + } +} +if (isset($_GET["del"])) { + $cid = (int)$_GET["del"]; + db()->prepare("UPDATE articles SET category_id=NULL WHERE category_id=?")->execute([$cid]); + db()->prepare("DELETE FROM categories WHERE id=?")->execute([$cid]); + header("Location: category.php?deleted=1"); exit; +} +$list = db()->query("SELECT c.*, (SELECT COUNT(*) FROM articles a WHERE a.category_id=c.id) AS cnt FROM categories c ORDER BY c.id DESC")->fetchAll(); +?> +

分类管理

+
删除成功
+
+

新增分类

+
+
+
+
+
+ +
+
+
+ + + + + + + + + + + + + +
ID名称别名文章数操作
" data-confirm="删除分类不会删除其下文章,仅取消关联,继续?">删除
+
+ \ No newline at end of file diff --git a/admin/comment.php b/admin/comment.php new file mode 100644 index 0000000..bede096 --- /dev/null +++ b/admin/comment.php @@ -0,0 +1,48 @@ +prepare("DELETE FROM comments WHERE id=?")->execute([$cid]); + header("Location: comment.php?deleted=1"); exit; +} +$page = isset($_GET["page"]) ? max(1,(int)$_GET["page"]) : 1; +$per = 15; +$total = (int)db()->query("SELECT COUNT(*) FROM comments")->fetchColumn(); +$pages = max(1,(int)ceil($total/$per)); +$page = min($page,$pages); +$off = ($page-1)*$per; +$stmt = db()->prepare("SELECT cm.*, a.title AS atitle FROM comments cm LEFT JOIN articles a ON a.id=cm.article_id ORDER BY cm.created_at DESC LIMIT ? OFFSET ?"); +$stmt->bindValue(1,$per,PDO::PARAM_INT); +$stmt->bindValue(2,$off,PDO::PARAM_INT); +$stmt->execute(); +$list = $stmt->fetchAll(); +?> +

评论管理

+
删除成功
+
+ + + + + + + + + + + + + + + +
ID评论内容昵称文章时间操作
暂无评论
" data-confirm="确定删除该评论?">删除
+
+ " href="?page="> +
+
+ \ No newline at end of file diff --git a/admin/index.php b/admin/index.php new file mode 100644 index 0000000..d3df5d3 --- /dev/null +++ b/admin/index.php @@ -0,0 +1,46 @@ +query("SELECT COUNT(*) FROM articles")->fetchColumn(); +$comments = (int)db()->query("SELECT COUNT(*) FROM comments")->fetchColumn(); +$categories = (int)db()->query("SELECT COUNT(*) FROM categories")->fetchColumn(); +$tags = (int)db()->query("SELECT COUNT(*) FROM tags")->fetchColumn(); +$views = (int)db()->query("SELECT COALESCE(SUM(views),0) FROM articles")->fetchColumn(); +$recent = db()->query("SELECT a.id, a.title, a.created_at, c.name AS cat FROM articles a LEFT JOIN categories c ON a.category_id=c.id ORDER BY a.created_at DESC LIMIT 8")->fetchAll(); +?> + +
+

仪表盘

+ 写文章 +
+ +
+
文章
+
评论
+
总阅读
+
分类
+
标签
+
+ +
+

最近文章

+ + + + + + + + + + + + + +
标题分类发布时间操作
暂无文章
">编辑" data-confirm="确定删除该文章?">删除
+
+ \ No newline at end of file diff --git a/admin/tag.php b/admin/tag.php new file mode 100644 index 0000000..e96b799 --- /dev/null +++ b/admin/tag.php @@ -0,0 +1,55 @@ +标签名不能为空'; } + else { + $s = db()->prepare("INSERT INTO tags (name, slug) VALUES (?,?)"); + $s->execute([$name, $slug]); + header("Location: tag.php?ok=1"); exit; + } +} +if (isset($_GET["del"])) { + $tid = (int)$_GET["del"]; + db()->prepare("DELETE FROM article_tags WHERE tag_id=?")->execute([$tid]); + db()->prepare("DELETE FROM tags WHERE id=?")->execute([$tid]); + header("Location: tag.php?deleted=1"); exit; +} +$list = db()->query("SELECT t.*, (SELECT COUNT(*) FROM article_tags at WHERE at.tag_id=t.id) AS cnt FROM tags t ORDER BY t.id DESC")->fetchAll(); +?> +

标签管理

+
删除成功
+
+

新增标签

+
+
+
+
+
+ +
+
+
+ + + + + + + + + + + + + +
ID名称别名使用次数操作
" data-confirm="删除标签将解除所有文章的该标签关联,继续?">删除
+
+ \ No newline at end of file diff --git a/archive.php b/archive.php new file mode 100644 index 0000000..3966d80 --- /dev/null +++ b/archive.php @@ -0,0 +1,48 @@ +prepare("SELECT COUNT(*) c FROM articles WHERE status=? AND DATE_FORMAT(created_at,'%Y年%m月')=?"); + $st->execute(["published",$ym]); +} else { + $st = db()->query("SELECT COUNT(*) c FROM articles WHERE status='published'"); +} +$pg = paginate((int)$st->fetch()["c"], isset($_GET["page"])?(int)$_GET["page"]:1, $perPage); +$sql = "SELECT a.*, c.name cat_name FROM articles a LEFT JOIN categories c ON a.category_id=c.id WHERE a.status=? "; +$params = ["published"]; +if ($ym) { $sql .= "AND DATE_FORMAT(a.created_at,'%Y年%m月')=? "; $params[] = $ym; } +$sql .= "ORDER BY a.created_at DESC LIMIT ? OFFSET ?"; +$stmt = db()->prepare($sql); +$i = 1; +foreach ($params as $p) { $stmt->bindValue($i++, $p, PDO::PARAM_STR); } +$stmt->bindValue($i++, $pg["perPage"], PDO::PARAM_INT); +$stmt->bindValue($i, $pg["offset"], PDO::PARAM_INT); +$stmt->execute(); +$articles = $stmt->fetchAll(); +require_once __DIR__ . "/includes/header.php"; +?> +
+

归档

+
+
+
+
+ + " href="?ym="> () + +
+
+ +
'"> +
次阅读
+

+
+ +1) { ?>
" href="?ym=&page=">
+ \ No newline at end of file diff --git a/article.php b/article.php new file mode 100644 index 0000000..ec95cf5 --- /dev/null +++ b/article.php @@ -0,0 +1,69 @@ +prepare("SELECT a.*, c.name cat_name, u.username FROM articles a LEFT JOIN categories c ON a.category_id=c.id JOIN users u ON a.user_id=u.id WHERE a.id=? AND a.status=?"); +$stmt->execute([$id, "published"]); +$article = $stmt->fetch(); +if (!$article) { header("Location: " . SITE_URL . "index.php"); exit; } +$pageTitle = $article["title"]; +$pageName = ""; +db()->prepare("UPDATE articles SET views=views+1 WHERE id=?")->execute([$id]); +record_visit("article/" . $id); +$tags = get_article_tags($id); +$err = ""; $ok = ""; +if ($_SERVER["REQUEST_METHOD"] === "POST" && isset($_POST["comment"])) { + $author = trim($_POST["author"] ?? ""); + $content = trim($_POST["content"] ?? ""); + $parent = isset($_POST["parent_id"]) ? (int)$_POST["parent_id"] : 0; + if ($author === "" || $content === "") { $err = "昵称和内容不能为空"; } + else { + $st = db()->prepare("INSERT INTO comments (article_id, parent_id, author, content, status) VALUES (?,?,?,?,'approved')"); + $st->execute([$id, $parent ?: null, $author, $content]); + $ok = "评论已提交"; + } +} +$cmtStmt = db()->prepare("SELECT * FROM comments WHERE article_id=? AND status=? ORDER BY created_at ASC"); +$cmtStmt->execute([$id, "approved"]); +$comments = $cmtStmt->fetchAll(); +require_once __DIR__ . "/includes/header.php"; +?> +
+

+
+ + + 次阅读 + 作者: +
+
+ +
+ + "> + +
+ +
+ +
+

评论 ()

+
+
+ +
"> +
+
+
+ +

暂无评论

+
+ +
+
+ +
+
+ + \ No newline at end of file diff --git a/assets/css/admin.css b/assets/css/admin.css new file mode 100644 index 0000000..5836341 --- /dev/null +++ b/assets/css/admin.css @@ -0,0 +1,103 @@ +.admin-body { } +.admin-layout { display:flex; align-items:flex-start; gap:24px; } +.admin-aside { position:sticky; top:88px; align-self:flex-start; width:224px; flex-shrink:0; } +.admin-main { flex:1; min-width:0; } + +.admin-menu { padding:12px 10px; } +.admin-menu .menu-title { padding:6px 14px 8px; font-size:11px; letter-spacing:2px; color:var(--text-muted); } +.admin-menu a.menu-item { + display:block; padding:11px 14px; border-radius:12px; margin-bottom:3px; + color:var(--text-secondary); font-size:14px; transition:background .2s,color .2s; +} +.admin-menu a.menu-item:hover { background:var(--glass-bg-strong); color:var(--text-primary); } +.admin-menu a.menu-item.active { background:var(--text-primary); color:var(--bg-0); font-weight:600; } +.admin-menu .menu-sep { height:1px; background:var(--glass-border); margin:10px 6px; } + +.admin-head { display:flex; justify-content:space-between; align-items:center; margin-bottom:24px; flex-wrap:wrap; gap:12px; } +.admin-head h1 { font-size:24px; letter-spacing:.5px; } +.admin-head .sub { color:var(--text-muted); font-size:13px; margin-top:4px; } + +.stat-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:16px; margin-bottom:26px; } +.stat-card { padding:22px 18px; text-align:center; position:relative; overflow:hidden; } +.stat-card .num { font-size:34px; font-weight:800; letter-spacing:-1px; } +.stat-card .label { color:var(--text-muted); font-size:13px; margin-top:6px; } +.stat-card .foot { font-size:12px; color:var(--text-muted); margin-top:8px; } + +.table-wrap { overflow-x:auto; } +.admin-table th.title, .admin-table td.title { max-width:340px; overflow:hidden; text-overflow:ellipsis; } + +.admin-table { width:100%; border-collapse:collapse; } +.admin-table th, .admin-table td { padding:11px 10px; text-align:left; border-bottom:1px solid var(--glass-border); font-size:14px; vertical-align:middle; white-space:nowrap; } +.admin-table th { font-weight:600; color:var(--text-secondary); white-space:nowrap; font-size:13px; } +.admin-table tbody tr { transition:background .15s; } +.admin-table tbody tr:hover { background:var(--glass-bg-strong); } +.admin-table tbody tr:last-child td { border-bottom:none; } +.admin-table .thumb { width:72px; height:44px; object-fit:cover; border-radius:8px; display:block; } +.admin-table .num-col { text-align:center; } + +.actions a, .actions button { margin-right:10px; font-size:13px; color:var(--text-secondary); background:none; border:none; cursor:pointer; padding:0; } +.actions a:hover, .actions button:hover { color:var(--text-primary); } +.actions .danger { color:#c0392b; } +[data-theme="dark"] .actions .danger { color:#ff8a8a; } + +.pagination { display:flex; gap:8px; align-items:center; margin-top:18px; flex-wrap:wrap; } +.pagination a, .pagination span { padding:7px 13px; border-radius:10px; font-size:13px; border:1px solid var(--glass-border); background:var(--glass-bg); color:var(--text-secondary); } +.pagination a:hover { color:var(--text-primary); } +.pagination .current { background:var(--text-primary); color:var(--bg-0); font-weight:600; border-color:var(--text-primary); } + +.admin-form .field { margin-bottom:18px; } +.admin-form .field label { display:block; font-weight:600; margin-bottom:8px; font-size:14px; } +.admin-form .field .hint { font-size:12px; color:var(--text-muted); margin-top:6px; } +.admin-form .form-row { display:flex; gap:16px; } +.admin-form .form-row > div { flex:1; } +.admin-form textarea.input, .admin-form .input { width:100%; padding:11px 14px; border-radius:12px; border:1px solid var(--glass-border); background:var(--glass-bg); color:var(--text-primary); font-size:14px; outline:none; transition:.2s; font-family:inherit; } +.admin-form .input:focus { border-color:var(--text-primary); } +.admin-form textarea.input { resize:vertical; min-height:140px; line-height:1.7; } +.admin-form .input.code { font-family:ui-monospace,"Cascadia Code",Consolas,monospace; font-size:13px; } +.admin-form select.input { appearance:none; -webkit-appearance:none; } +.admin-form .btn-row { display:flex; gap:10px; margin-top:6px; } +.tag-check { display:inline-flex; align-items:center; gap:6px; margin:0 14px 10px 0; font-size:14px; color:var(--text-secondary); cursor:pointer; } +.tag-check input { width:16px; height:16px; accent-color:var(--text-primary); } + +.msg { padding:13px 16px; border-radius:12px; margin-bottom:16px; background:var(--glass-bg-strong); border:1px solid var(--glass-border); font-size:14px; } +.msg.ok { border-color:rgba(34,139,34,.4); color:#2e7d32; } +[data-theme="dark"] .msg.ok { color:#8fd18f; } +.msg.err { border-color:rgba(192,57,43,.4); color:#c0392b; } +[data-theme="dark"] .msg.err { color:#ff8a8a; } + +.card-title { display:flex; justify-content:space-between; align-items:center; margin-bottom:16px; } +.card-title h3 { font-size:16px; } +.text-muted { color:var(--text-muted); } +.empty { text-align:center; color:var(--text-muted); padding:56px 20px; font-size:14px; } + +@media (max-width:900px){ + .admin-aside { display:none; } + .admin-layout { display:block; } +} + +.admin-main .card:hover { transform:none; } +.admin-main .card::after { display:none; } +.admin-main .card { margin-bottom:20px; } + +.admin-head .head-right { display:flex; gap:10px; align-items:center; } + +/* 后台输入框:清晰可见的边框与背景(浅色主题下白边框不可见) */ +.admin-main .input, +.admin-main select.input, +.admin-main textarea.input { + width:100%; padding:11px 14px; border-radius:12px; + border:1px solid var(--input-border); + background:var(--glass-bg-strong); + color:var(--text-primary); font-size:14px; outline:none; transition:.2s; font-family:inherit; +} +.admin-main .input::placeholder { color:var(--text-muted); } +.admin-main .input:focus { border-color:var(--text-primary); box-shadow:0 0 0 3px var(--glass-border); } +.admin-main textarea.input { resize:vertical; min-height:140px; line-height:1.7; } +.admin-main .input.code { font-family:ui-monospace,"Cascadia Code",Consolas,monospace; font-size:13px; } + +/* 自定义确认框 */ +.modal-mask { position:fixed; inset:0; z-index:200; background:rgba(15,18,28,0.42); display:flex; align-items:center; justify-content:center; padding:24px; } +.modal { width:100%; max-width:360px; padding:28px; } +.modal .modal-title { font-size:17px; font-weight:700; margin-bottom:14px; } +.modal .modal-text { color:var(--text-secondary); font-size:14px; line-height:1.7; margin-bottom:24px; } +.modal .modal-actions { display:flex; justify-content:flex-end; gap:10px; } diff --git a/assets/css/style.css b/assets/css/style.css new file mode 100644 index 0000000..c1a9f6d --- /dev/null +++ b/assets/css/style.css @@ -0,0 +1,178 @@ +:root { + --bg-0:#ffffff; + --bg-gradient: linear-gradient(160deg,#ffffff 0%,#f5f6fa 55%,#e9ecf3 100%); + --bg-glow: radial-gradient(circle at 50% -10%,rgba(255,255,255,0.35),transparent 55%); + --glass-bg: rgba(255,255,255,0.55); + --glass-bg-strong: rgba(255,255,255,0.8); + --glass-border: rgba(255,255,255,0.9); + --input-border: rgba(20,20,32,0.3); + --glass-highlight: rgba(255,255,255,1); + --glass-blur: 26px; + --glass-radius: 18px; + --text-primary:#141419; --text-secondary:rgba(20,20,32,0.62); --text-muted:rgba(20,20,32,0.42); + --shadow: 0 10px 36px rgba(31,45,84,0.12); +} +[data-theme="dark"] { + --bg-0:#0e0f13; + --bg-gradient: linear-gradient(160deg,#0e0f13 0%,#12141a 55%,#171a22 100%); + --bg-glow: radial-gradient(circle at 50% -10%,rgba(255,255,255,0.05),transparent 50%); + --glass-bg: rgba(255,255,255,0.06); + --glass-bg-strong: rgba(255,255,255,0.12); + --glass-border: rgba(255,255,255,0.14); + --input-border: rgba(255,255,255,0.35); + --glass-highlight: rgba(255,255,255,0.22); + --glass-blur: 26px; + --glass-radius: 18px; + --text-primary:#eef0f5; --text-secondary:rgba(255,255,255,0.66); --text-muted:rgba(255,255,255,0.4); + --shadow: 0 10px 36px rgba(0,0,0,0.5); +} + +* { margin:0; padding:0; box-sizing:border-box; } +html { -webkit-text-size-adjust:100%; } +body { font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"PingFang SC","Microsoft YaHei",sans-serif; color:var(--text-primary); min-height:100vh; background:var(--bg-gradient); transition:background .4s; } +a { color:inherit; text-decoration:none; } +.bg { position:fixed; inset:0; z-index:-1; background:var(--bg-gradient); } +.bg::before { content:""; position:absolute; inset:-10%; background:var(--bg-glow); } + +.glass { background:var(--glass-bg); backdrop-filter:blur(var(--glass-blur)) saturate(1.5); -webkit-backdrop-filter:blur(var(--glass-blur)) saturate(1.5); border:1px solid var(--glass-border); border-radius:var(--glass-radius); box-shadow:inset 0 1px 0 var(--glass-highlight), var(--shadow); } +.glass-nav { position:fixed; top:0; left:0; right:0; z-index:50; display:flex; align-items:center; justify-content:space-between; padding:0 28px; height:64px; background:var(--glass-bg); backdrop-filter:blur(var(--glass-blur)) saturate(1.6); -webkit-backdrop-filter:blur(var(--glass-blur)) saturate(1.6); border-bottom:1px solid var(--glass-border); box-shadow:inset 0 1px 0 var(--glass-highlight); } +.nav-brand { font-weight:800; font-size:20px; letter-spacing:1px; color:var(--text-primary); } +.nav-links { display:flex; gap:6px; align-items:center; } +.nav-link { padding:8px 14px; border-radius:10px; color:var(--text-secondary); font-size:14px; transition:.2s; } +.nav-link:hover,.nav-link.active { background:var(--glass-bg-strong); color:var(--text-primary); } +.glass-btn { display:inline-block; padding:8px 16px; border-radius:12px; border:1px solid var(--glass-border); background:var(--glass-bg-strong); color:var(--text-primary); cursor:pointer; font-size:14px; transition:.2s; } +.glass-btn:hover { transform:translateY(-1px); } +.glass-btn.primary { background:var(--text-primary); color:var(--bg-0); border-color:transparent; } +.glass-chip { display:inline-block; padding:3px 12px; border-radius:999px; font-size:12px; background:var(--glass-bg); border:1px solid var(--glass-border); color:var(--text-secondary); } +.glass-input { width:100%; padding:12px 16px; border-radius:12px; border:1px solid var(--input-border); background:var(--glass-bg); color:var(--text-primary); font-size:14px; outline:none; transition:.2s; } +.glass-input:focus { border-color:var(--text-primary); box-shadow:0 0 0 3px var(--input-border); } +.glass-input::placeholder{ color:var(--text-muted); } +textarea.glass-input { resize:vertical; min-height:120px; font-family:inherit; } +.glass-select { padding:12px; border-radius:12px; border:1px solid var(--glass-border); background:var(--glass-bg); color:var(--text-primary); font-size:14px; outline:none; } +.theme-toggle { border:none; background:transparent; color:var(--text-secondary); font-size:14px; cursor:pointer; padding:8px 10px; } +.theme-toggle:hover { color:var(--text-primary); } + +.container { max-width:1080px; margin:0 auto; padding:88px 24px 60px; } +.layout { display:grid; grid-template-columns:1fr 300px; gap:24px; } +@media (max-width:820px){ .layout{grid-template-columns:1fr;} .sidebar{display:none;} } + +.sidebar { position:sticky; top:88px; align-self:start; display:flex; flex-direction:column; gap:20px; } +.side-panel { padding:20px; } +.side-panel h3 { font-size:15px; margin-bottom:14px; } +.chip-row { display:flex; flex-wrap:wrap; gap:8px; } +.chip-row .glass-chip { transition:.15s; } +.chip-row .glass-chip:hover { transform:translateY(-1px); color:var(--text-primary); } +.arch-item { display:flex; justify-content:space-between; padding:9px 0; border-bottom:1px dashed var(--glass-border); font-size:14px; color:var(--text-secondary); } +.arch-item:last-child{ border-bottom:none; } +.arch-item a:hover{ color:var(--text-primary); } + +.hero { text-align:center; padding:40px 0 30px; } +.hero h1 { font-size:40px; font-weight:800; letter-spacing:2px; } +.hero h1 span{ color:var(--text-secondary); font-weight:300; } +.hero p { color:var(--text-secondary); margin-top:12px; font-size:15px; } +.search-row { max-width:520px; margin:18px auto 0; display:flex; gap:10px; } +.search-row .glass-btn{ flex-shrink:0; } + +.card { padding:24px; margin-bottom:20px; display:block; transition:transform .25s, box-shadow .25s; position:relative; overflow:hidden; } +.card:hover { transform:translateY(-4px); } +.card-meta { display:flex; gap:12px; align-items:center; font-size:12px; color:var(--text-muted); margin-bottom:12px; } +.card h2 { font-size:22px; margin-bottom:10px; line-height:1.4; } +.card p { color:var(--text-secondary); line-height:1.7; font-size:15px; } +.card-foot { display:flex; justify-content:space-between; align-items:center; margin-top:16px; font-size:13px; color:var(--text-muted); } +.card.clickable { cursor:pointer; } +.pagination { display:flex; gap:8px; justify-content:center; margin-top:24px; } +.page-btn { padding:8px 14px; border-radius:10px; background:var(--glass-bg); border:1px solid var(--glass-border); color:var(--text-secondary); font-size:14px; } +.page-btn.active { background:var(--glass-bg-strong); color:var(--text-primary); } + +.article-card { padding:32px; } +.article-card h1 { font-size:30px; line-height:1.4; margin-bottom:14px; } +.article-meta { display:flex; gap:16px; font-size:13px; color:var(--text-muted); margin-bottom:24px; flex-wrap:wrap; } +.article-body { line-height:1.9; font-size:16px; color:var(--text-primary); } +.article-body h2 { font-size:24px; margin:28px 0 14px; } +.article-body h3 { font-size:19px; margin:22px 0 10px; } +.article-body p { margin:14px 0; } +.article-body ul,.article-body ol { margin:14px 0 14px 24px; } +.article-body blockquote { border-left:3px solid var(--text-primary); padding:6px 16px; margin:14px 0; color:var(--text-secondary); font-style:italic; } +.article-body code { background:var(--glass-bg-strong); padding:2px 6px; border-radius:6px; font-size:0.9em; } +.article-body pre { background:rgba(0,0,0,0.05); padding:16px; border-radius:12px; overflow:auto; margin:14px 0; } +[data-theme="dark"] .article-body pre{ background:rgba(255,255,255,0.06);} +.article-body pre code { background:none; padding:0; } +.article-body img { max-width:100%; border-radius:12px; } + +.comments { margin-top:24px; } +.comments h3 { margin-bottom:16px; } +.comment-item { padding:16px; margin-bottom:12px; } +.comment-item.reply { margin-left:36px; } +.comment-head { display:flex; gap:10px; align-items:center; font-size:13px; color:var(--text-muted); margin-bottom:8px; } +.comment-body { color:var(--text-secondary); line-height:1.7; font-size:14px; } +.comment-form .field { margin-bottom:14px; } + +.login-wrap { max-width:380px; margin:0 auto; padding:40px 0; } +.login-box { padding:32px; } +.login-box h2 { margin-bottom:6px; font-size:22px; } +.login-box .sub { color:var(--text-muted); font-size:13px; margin-bottom:24px; } +.login-page { min-height:100vh; display:flex; align-items:center; justify-content:center; padding:24px; } +.login-page .login-box { width:100%; max-width:380px; padding:38px; } +.login-page .login-brand { text-align:center; font-size:22px; font-weight:800; letter-spacing:1px; margin-bottom:26px; } +.login-page .theme-toggle { position:fixed; top:20px; right:24px; } +.login-page .login-back { text-align:center; margin-top:22px; font-size:13px; color:var(--text-muted); } +.login-page .login-back a { color:var(--text-secondary); } +.login-page .login-back a:hover { color:var(--text-primary); } +.field { margin-bottom:16px; } +.field label { display:block; font-size:13px; color:var(--text-secondary); margin-bottom:6px; } +.alert { padding:12px 16px; border-radius:10px; margin-bottom:16px; font-size:14px; background:var(--glass-bg-strong); border:1px solid var(--glass-border); color:var(--text-primary); } +.alert.error { border-color:rgba(180,60,60,0.4); color:rgba(180,60,60,1); } +[data-theme="dark"] .alert.error{ color:#ff8a8a; border-color:rgba(255,120,120,0.4);} + +.page-head { margin-bottom:24px; } +.page-head h1 { font-size:26px; } +.page-head .desc { color:var(--text-muted); font-size:14px; margin-top:6px; } + +.about-card { padding:32px; line-height:1.9; } +.about-card h2 { margin:20px 0 10px; } + +.admin-head { display:flex; justify-content:space-between; align-items:center; margin-bottom:24px; flex-wrap:wrap; gap:12px; } +.admin-head h1 { font-size:24px; } +.stat-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(180px,1fr)); gap:16px; margin-bottom:28px; } +.stat-card { padding:20px; text-align:center; } +.stat-card .num { font-size:32px; font-weight:800; } +.stat-card .label { color:var(--text-muted); font-size:13px; margin-top:4px; } +.table-wrap { overflow-x:auto; } +.glass-table { width:100%; border-collapse:collapse; font-size:14px; } +.glass-table th { text-align:left; padding:12px 14px; color:var(--text-muted); font-weight:600; font-size:13px; border-bottom:1px solid var(--glass-border); } +.glass-table td { padding:12px 14px; border-bottom:1px solid var(--glass-border); } +.glass-table tr:hover td { background:var(--glass-bg); } +.tag-badge { display:inline-block; padding:2px 10px; border-radius:999px; font-size:12px; background:var(--glass-bg); border:1px solid var(--glass-border); } +.actions a, .actions button { margin-right:8px; font-size:13px; color:var(--text-secondary); } +.actions a:hover, .actions button:hover{ color:var(--text-primary); } +.form-card { padding:24px; } +.form-card .field label { font-weight:600; } +.form-row { display:flex; gap:12px; } +.form-row > div { flex:1; } +.btn-danger { color:#c0392b; } +[data-theme="dark"] .btn-danger{ color:#ff8a8a;} +.footnote { text-align:center; color:var(--text-muted); font-size:12px; padding:20px 0 0; margin-top:20px; } +.msg { padding:12px 16px; border-radius:10px; margin-bottom:16px; background:var(--glass-bg-strong); border:1px solid var(--glass-border); font-size:14px; } + +.side-panel a.nav-link { + display:block; padding:10px 12px; border-radius:8px; margin-bottom:4px; + color:var(--text-secondary); font-size:14px; text-decoration:none; transition:background .2s,color .2s; +} +.side-panel a.nav-link:hover { background:var(--glass-bg-strong); color:var(--text-primary); } +.side-panel a.nav-link.active { background:var(--glass-bg-strong); color:var(--text-primary); font-weight:600; } +.admin-table { width:100%; border-collapse:collapse; background:var(--glass-bg); border:1px solid var(--glass-border); border-radius:12px; overflow:hidden; } +.admin-table th, .admin-table td { padding:12px 14px; text-align:left; border-bottom:1px solid var(--glass-border); font-size:14px; } +.admin-table th { background:var(--glass-bg-strong); font-weight:600; color:var(--text-secondary); white-space:nowrap; } +.admin-table tr:last-child td { border-bottom:none; } +.admin-table tr:hover td { background:var(--glass-bg-strong); } +.admin-table .thumb { width:64px; height:40px; object-fit:cover; border-radius:6px; } +.text-muted { color:var(--text-muted); } +.input, .admin-table select, .admin-table input[type=text], .admin-table input[type=number] { + padding:8px 10px; border-radius:8px; border:1px solid var(--glass-border); background:var(--glass-bg); color:var(--text-primary); font-size:14px; +} +.admin-table input[type=checkbox]{ width:16px; height:16px; } +.pagination { display:flex; gap:8px; align-items:center; margin-top:20px; flex-wrap:wrap; } +.pagination a, .pagination span { padding:6px 12px; border-radius:8px; font-size:13px; border:1px solid var(--glass-border); background:var(--glass-bg); color:var(--text-secondary); } +.pagination a:hover { color:var(--text-primary); } +.pagination .current { background:var(--text-primary); color:var(--bg); font-weight:600; border-color:var(--text-primary); } +.badge-count { display:inline-block; min-width:20px; padding:0 6px; border-radius:999px; background:var(--glass-bg-strong); font-size:12px; text-align:center; } \ No newline at end of file diff --git a/assets/js/main.js b/assets/js/main.js new file mode 100644 index 0000000..1c560c9 --- /dev/null +++ b/assets/js/main.js @@ -0,0 +1,43 @@ +(function () { + const btn = document.getElementById("themeBtn"); + if (!btn) return; + function applyTheme(t) { + document.documentElement.setAttribute("data-theme", t); + btn.textContent = t === "dark" ? "浅色模式" : "深色模式"; + try { localStorage.setItem("glass-theme", t); } catch(e) {} + } + function currentTheme() { + return document.documentElement.getAttribute("data-theme") || "light"; + } + var saved = "light"; + try { saved = localStorage.getItem("glass-theme") || "light"; } catch(e) {} + applyTheme(saved); + btn.addEventListener("click", function () { + applyTheme(currentTheme() === "dark" ? "light" : "dark"); + }); +})(); + +(function () { + var modal = document.getElementById("confirmModal"); + if (!modal) return; + var text = document.getElementById("confirmText"); + var ok = document.getElementById("confirmOk"); + var cancel = document.getElementById("confirmCancel"); + var target = null; + function open(msg, href) { + if (text) text.textContent = msg; + target = href; + modal.style.display = "flex"; + } + function close() { modal.style.display = "none"; target = null; } + document.querySelectorAll("[data-confirm]").forEach(function (el) { + el.addEventListener("click", function (e) { + e.preventDefault(); + open(el.getAttribute("data-confirm"), el.href); + }); + }); + if (ok) ok.addEventListener("click", function () { if (target) location.href = target; }); + if (cancel) cancel.addEventListener("click", close); + modal.addEventListener("click", function (e) { if (e.target === modal) close(); }); + document.addEventListener("keydown", function (e) { if (e.key === "Escape") close(); }); +})(); diff --git a/category.php b/category.php new file mode 100644 index 0000000..457fae1 --- /dev/null +++ b/category.php @@ -0,0 +1,33 @@ +prepare("SELECT * FROM categories WHERE id=?"); $c->execute([$id]); $cat = $c->fetch(); +if (!$cat) { header("Location: " . SITE_URL . "index.php"); exit; } +$pageName = "category"; +$curCategory = $id; +$pageTitle = $cat["name"]; +record_visit("category/" . $id); +$perPage = 5; +$st = db()->prepare("SELECT COUNT(*) c FROM articles WHERE category_id=? AND status=?"); $st->execute([$id,"published"]); +$pg = paginate((int)$st->fetch()["c"], isset($_GET["page"])?(int)$_GET["page"]:1, $perPage); +$stmt = db()->prepare("SELECT a.* FROM articles a WHERE a.category_id=? AND a.status=? ORDER BY a.created_at DESC LIMIT ? OFFSET ?"); +$stmt->bindValue(1,$id,PDO::PARAM_INT); $stmt->bindValue(2,"published",PDO::PARAM_STR); +$stmt->bindValue(3,$pg["perPage"],PDO::PARAM_INT); $stmt->bindValue(4,$pg["offset"],PDO::PARAM_INT); +$stmt->execute(); $articles = $stmt->fetchAll(); +require_once __DIR__ . "/includes/header.php"; +?> +
+

+
分类下的文章
+
+
该分类暂无文章
+ +
'"> +
次阅读
+

+

+
+ +1) { ?>
" href="?id=&page=">
+ \ No newline at end of file diff --git a/includes/Parsedown.php b/includes/Parsedown.php new file mode 100644 index 0000000..1b9d6d5 --- /dev/null +++ b/includes/Parsedown.php @@ -0,0 +1,1712 @@ +DefinitionData = array(); + + # standardize line breaks + $text = str_replace(array("\r\n", "\r"), "\n", $text); + + # remove surrounding line breaks + $text = trim($text, "\n"); + + # split text into lines + $lines = explode("\n", $text); + + # iterate through lines to identify blocks + $markup = $this->lines($lines); + + # trim line breaks + $markup = trim($markup, "\n"); + + return $markup; + } + + # + # Setters + # + + function setBreaksEnabled($breaksEnabled) + { + $this->breaksEnabled = $breaksEnabled; + + return $this; + } + + protected $breaksEnabled; + + function setMarkupEscaped($markupEscaped) + { + $this->markupEscaped = $markupEscaped; + + return $this; + } + + protected $markupEscaped; + + function setUrlsLinked($urlsLinked) + { + $this->urlsLinked = $urlsLinked; + + return $this; + } + + protected $urlsLinked = true; + + function setSafeMode($safeMode) + { + $this->safeMode = (bool) $safeMode; + + return $this; + } + + protected $safeMode; + + protected $safeLinksWhitelist = array( + 'http://', + 'https://', + 'ftp://', + 'ftps://', + 'mailto:', + 'data:image/png;base64,', + 'data:image/gif;base64,', + 'data:image/jpeg;base64,', + 'irc:', + 'ircs:', + 'git:', + 'ssh:', + 'news:', + 'steam:', + ); + + # + # Lines + # + + protected $BlockTypes = array( + '#' => array('Header'), + '*' => array('Rule', 'List'), + '+' => array('List'), + '-' => array('SetextHeader', 'Table', 'Rule', 'List'), + '0' => array('List'), + '1' => array('List'), + '2' => array('List'), + '3' => array('List'), + '4' => array('List'), + '5' => array('List'), + '6' => array('List'), + '7' => array('List'), + '8' => array('List'), + '9' => array('List'), + ':' => array('Table'), + '<' => array('Comment', 'Markup'), + '=' => array('SetextHeader'), + '>' => array('Quote'), + '[' => array('Reference'), + '_' => array('Rule'), + '`' => array('FencedCode'), + '|' => array('Table'), + '~' => array('FencedCode'), + ); + + # ~ + + protected $unmarkedBlockTypes = array( + 'Code', + ); + + # + # Blocks + # + + protected function lines(array $lines) + { + $CurrentBlock = null; + + foreach ($lines as $line) + { + if (chop($line) === '') + { + if (isset($CurrentBlock)) + { + $CurrentBlock['interrupted'] = true; + } + + continue; + } + + if (strpos($line, "\t") !== false) + { + $parts = explode("\t", $line); + + $line = $parts[0]; + + unset($parts[0]); + + foreach ($parts as $part) + { + $shortage = 4 - mb_strlen($line, 'utf-8') % 4; + + $line .= str_repeat(' ', $shortage); + $line .= $part; + } + } + + $indent = 0; + + while (isset($line[$indent]) and $line[$indent] === ' ') + { + $indent ++; + } + + $text = $indent > 0 ? substr($line, $indent) : $line; + + # ~ + + $Line = array('body' => $line, 'indent' => $indent, 'text' => $text); + + # ~ + + if (isset($CurrentBlock['continuable'])) + { + $Block = $this->{'block'.$CurrentBlock['type'].'Continue'}($Line, $CurrentBlock); + + if (isset($Block)) + { + $CurrentBlock = $Block; + + continue; + } + else + { + if ($this->isBlockCompletable($CurrentBlock['type'])) + { + $CurrentBlock = $this->{'block'.$CurrentBlock['type'].'Complete'}($CurrentBlock); + } + } + } + + # ~ + + $marker = $text[0]; + + # ~ + + $blockTypes = $this->unmarkedBlockTypes; + + if (isset($this->BlockTypes[$marker])) + { + foreach ($this->BlockTypes[$marker] as $blockType) + { + $blockTypes []= $blockType; + } + } + + # + # ~ + + foreach ($blockTypes as $blockType) + { + $Block = $this->{'block'.$blockType}($Line, $CurrentBlock); + + if (isset($Block)) + { + $Block['type'] = $blockType; + + if ( ! isset($Block['identified'])) + { + $Blocks []= $CurrentBlock; + + $Block['identified'] = true; + } + + if ($this->isBlockContinuable($blockType)) + { + $Block['continuable'] = true; + } + + $CurrentBlock = $Block; + + continue 2; + } + } + + # ~ + + if (isset($CurrentBlock) and ! isset($CurrentBlock['type']) and ! isset($CurrentBlock['interrupted'])) + { + $CurrentBlock['element']['text'] .= "\n".$text; + } + else + { + $Blocks []= $CurrentBlock; + + $CurrentBlock = $this->paragraph($Line); + + $CurrentBlock['identified'] = true; + } + } + + # ~ + + if (isset($CurrentBlock['continuable']) and $this->isBlockCompletable($CurrentBlock['type'])) + { + $CurrentBlock = $this->{'block'.$CurrentBlock['type'].'Complete'}($CurrentBlock); + } + + # ~ + + $Blocks []= $CurrentBlock; + + unset($Blocks[0]); + + # ~ + + $markup = ''; + + foreach ($Blocks as $Block) + { + if (isset($Block['hidden'])) + { + continue; + } + + $markup .= "\n"; + $markup .= isset($Block['markup']) ? $Block['markup'] : $this->element($Block['element']); + } + + $markup .= "\n"; + + # ~ + + return $markup; + } + + protected function isBlockContinuable($Type) + { + return method_exists($this, 'block'.$Type.'Continue'); + } + + protected function isBlockCompletable($Type) + { + return method_exists($this, 'block'.$Type.'Complete'); + } + + # + # Code + + protected function blockCode($Line, $Block = null) + { + if (isset($Block) and ! isset($Block['type']) and ! isset($Block['interrupted'])) + { + return; + } + + if ($Line['indent'] >= 4) + { + $text = substr($Line['body'], 4); + + $Block = array( + 'element' => array( + 'name' => 'pre', + 'handler' => 'element', + 'text' => array( + 'name' => 'code', + 'text' => $text, + ), + ), + ); + + return $Block; + } + } + + protected function blockCodeContinue($Line, $Block) + { + if ($Line['indent'] >= 4) + { + if (isset($Block['interrupted'])) + { + $Block['element']['text']['text'] .= "\n"; + + unset($Block['interrupted']); + } + + $Block['element']['text']['text'] .= "\n"; + + $text = substr($Line['body'], 4); + + $Block['element']['text']['text'] .= $text; + + return $Block; + } + } + + protected function blockCodeComplete($Block) + { + $text = $Block['element']['text']['text']; + + $Block['element']['text']['text'] = $text; + + return $Block; + } + + # + # Comment + + protected function blockComment($Line) + { + if ($this->markupEscaped or $this->safeMode) + { + return; + } + + if (isset($Line['text'][3]) and $Line['text'][3] === '-' and $Line['text'][2] === '-' and $Line['text'][1] === '!') + { + $Block = array( + 'markup' => $Line['body'], + ); + + if (preg_match('/-->$/', $Line['text'])) + { + $Block['closed'] = true; + } + + return $Block; + } + } + + protected function blockCommentContinue($Line, array $Block) + { + if (isset($Block['closed'])) + { + return; + } + + $Block['markup'] .= "\n" . $Line['body']; + + if (preg_match('/-->$/', $Line['text'])) + { + $Block['closed'] = true; + } + + return $Block; + } + + # + # Fenced Code + + protected function blockFencedCode($Line) + { + if (preg_match('/^['.$Line['text'][0].']{3,}[ ]*([^`]+)?[ ]*$/', $Line['text'], $matches)) + { + $Element = array( + 'name' => 'code', + 'text' => '', + ); + + if (isset($matches[1])) + { + /** + * https://www.w3.org/TR/2011/WD-html5-20110525/elements.html#classes + * Every HTML element may have a class attribute specified. + * The attribute, if specified, must have a value that is a set + * of space-separated tokens representing the various classes + * that the element belongs to. + * [...] + * The space characters, for the purposes of this specification, + * are U+0020 SPACE, U+0009 CHARACTER TABULATION (tab), + * U+000A LINE FEED (LF), U+000C FORM FEED (FF), and + * U+000D CARRIAGE RETURN (CR). + */ + $language = substr($matches[1], 0, strcspn($matches[1], " \t\n\f\r")); + + $class = 'language-'.$language; + + $Element['attributes'] = array( + 'class' => $class, + ); + } + + $Block = array( + 'char' => $Line['text'][0], + 'element' => array( + 'name' => 'pre', + 'handler' => 'element', + 'text' => $Element, + ), + ); + + return $Block; + } + } + + protected function blockFencedCodeContinue($Line, $Block) + { + if (isset($Block['complete'])) + { + return; + } + + if (isset($Block['interrupted'])) + { + $Block['element']['text']['text'] .= "\n"; + + unset($Block['interrupted']); + } + + if (preg_match('/^'.$Block['char'].'{3,}[ ]*$/', $Line['text'])) + { + $Block['element']['text']['text'] = substr($Block['element']['text']['text'], 1); + + $Block['complete'] = true; + + return $Block; + } + + $Block['element']['text']['text'] .= "\n".$Line['body']; + + return $Block; + } + + protected function blockFencedCodeComplete($Block) + { + $text = $Block['element']['text']['text']; + + $Block['element']['text']['text'] = $text; + + return $Block; + } + + # + # Header + + protected function blockHeader($Line) + { + if (isset($Line['text'][1])) + { + $level = 1; + + while (isset($Line['text'][$level]) and $Line['text'][$level] === '#') + { + $level ++; + } + + if ($level > 6) + { + return; + } + + $text = trim($Line['text'], '# '); + + $Block = array( + 'element' => array( + 'name' => 'h' . min(6, $level), + 'text' => $text, + 'handler' => 'line', + ), + ); + + return $Block; + } + } + + # + # List + + protected function blockList($Line) + { + list($name, $pattern) = $Line['text'][0] <= '-' ? array('ul', '[*+-]') : array('ol', '[0-9]+[.]'); + + if (preg_match('/^('.$pattern.'[ ]+)(.*)/', $Line['text'], $matches)) + { + $Block = array( + 'indent' => $Line['indent'], + 'pattern' => $pattern, + 'element' => array( + 'name' => $name, + 'handler' => 'elements', + ), + ); + + if($name === 'ol') + { + $listStart = stristr($matches[0], '.', true); + + if($listStart !== '1') + { + $Block['element']['attributes'] = array('start' => $listStart); + } + } + + $Block['li'] = array( + 'name' => 'li', + 'handler' => 'li', + 'text' => array( + $matches[2], + ), + ); + + $Block['element']['text'] []= & $Block['li']; + + return $Block; + } + } + + protected function blockListContinue($Line, array $Block) + { + if ($Block['indent'] === $Line['indent'] and preg_match('/^'.$Block['pattern'].'(?:[ ]+(.*)|$)/', $Line['text'], $matches)) + { + if (isset($Block['interrupted'])) + { + $Block['li']['text'] []= ''; + + $Block['loose'] = true; + + unset($Block['interrupted']); + } + + unset($Block['li']); + + $text = isset($matches[1]) ? $matches[1] : ''; + + $Block['li'] = array( + 'name' => 'li', + 'handler' => 'li', + 'text' => array( + $text, + ), + ); + + $Block['element']['text'] []= & $Block['li']; + + return $Block; + } + + if ($Line['text'][0] === '[' and $this->blockReference($Line)) + { + return $Block; + } + + if ( ! isset($Block['interrupted'])) + { + $text = preg_replace('/^[ ]{0,4}/', '', $Line['body']); + + $Block['li']['text'] []= $text; + + return $Block; + } + + if ($Line['indent'] > 0) + { + $Block['li']['text'] []= ''; + + $text = preg_replace('/^[ ]{0,4}/', '', $Line['body']); + + $Block['li']['text'] []= $text; + + unset($Block['interrupted']); + + return $Block; + } + } + + protected function blockListComplete(array $Block) + { + if (isset($Block['loose'])) + { + foreach ($Block['element']['text'] as &$li) + { + if (end($li['text']) !== '') + { + $li['text'] []= ''; + } + } + } + + return $Block; + } + + # + # Quote + + protected function blockQuote($Line) + { + if (preg_match('/^>[ ]?(.*)/', $Line['text'], $matches)) + { + $Block = array( + 'element' => array( + 'name' => 'blockquote', + 'handler' => 'lines', + 'text' => (array) $matches[1], + ), + ); + + return $Block; + } + } + + protected function blockQuoteContinue($Line, array $Block) + { + if ($Line['text'][0] === '>' and preg_match('/^>[ ]?(.*)/', $Line['text'], $matches)) + { + if (isset($Block['interrupted'])) + { + $Block['element']['text'] []= ''; + + unset($Block['interrupted']); + } + + $Block['element']['text'] []= $matches[1]; + + return $Block; + } + + if ( ! isset($Block['interrupted'])) + { + $Block['element']['text'] []= $Line['text']; + + return $Block; + } + } + + # + # Rule + + protected function blockRule($Line) + { + if (preg_match('/^(['.$Line['text'][0].'])([ ]*\1){2,}[ ]*$/', $Line['text'])) + { + $Block = array( + 'element' => array( + 'name' => 'hr' + ), + ); + + return $Block; + } + } + + # + # Setext + + protected function blockSetextHeader($Line, array $Block = null) + { + if ( ! isset($Block) or isset($Block['type']) or isset($Block['interrupted'])) + { + return; + } + + if (chop($Line['text'], $Line['text'][0]) === '') + { + $Block['element']['name'] = $Line['text'][0] === '=' ? 'h1' : 'h2'; + + return $Block; + } + } + + # + # Markup + + protected function blockMarkup($Line) + { + if ($this->markupEscaped or $this->safeMode) + { + return; + } + + if (preg_match('/^<(\w[\w-]*)(?:[ ]*'.$this->regexHtmlAttribute.')*[ ]*(\/)?>/', $Line['text'], $matches)) + { + $element = strtolower($matches[1]); + + if (in_array($element, $this->textLevelElements)) + { + return; + } + + $Block = array( + 'name' => $matches[1], + 'depth' => 0, + 'markup' => $Line['text'], + ); + + $length = strlen($matches[0]); + + $remainder = substr($Line['text'], $length); + + if (trim($remainder) === '') + { + if (isset($matches[2]) or in_array($matches[1], $this->voidElements)) + { + $Block['closed'] = true; + + $Block['void'] = true; + } + } + else + { + if (isset($matches[2]) or in_array($matches[1], $this->voidElements)) + { + return; + } + + if (preg_match('/<\/'.$matches[1].'>[ ]*$/i', $remainder)) + { + $Block['closed'] = true; + } + } + + return $Block; + } + } + + protected function blockMarkupContinue($Line, array $Block) + { + if (isset($Block['closed'])) + { + return; + } + + if (preg_match('/^<'.$Block['name'].'(?:[ ]*'.$this->regexHtmlAttribute.')*[ ]*>/i', $Line['text'])) # open + { + $Block['depth'] ++; + } + + if (preg_match('/(.*?)<\/'.$Block['name'].'>[ ]*$/i', $Line['text'], $matches)) # close + { + if ($Block['depth'] > 0) + { + $Block['depth'] --; + } + else + { + $Block['closed'] = true; + } + } + + if (isset($Block['interrupted'])) + { + $Block['markup'] .= "\n"; + + unset($Block['interrupted']); + } + + $Block['markup'] .= "\n".$Line['body']; + + return $Block; + } + + # + # Reference + + protected function blockReference($Line) + { + if (preg_match('/^\[(.+?)\]:[ ]*?(?:[ ]+["\'(](.+)["\')])?[ ]*$/', $Line['text'], $matches)) + { + $id = strtolower($matches[1]); + + $Data = array( + 'url' => $matches[2], + 'title' => null, + ); + + if (isset($matches[3])) + { + $Data['title'] = $matches[3]; + } + + $this->DefinitionData['Reference'][$id] = $Data; + + $Block = array( + 'hidden' => true, + ); + + return $Block; + } + } + + # + # Table + + protected function blockTable($Line, array $Block = null) + { + if ( ! isset($Block) or isset($Block['type']) or isset($Block['interrupted'])) + { + return; + } + + if (strpos($Block['element']['text'], '|') !== false and chop($Line['text'], ' -:|') === '') + { + $alignments = array(); + + $divider = $Line['text']; + + $divider = trim($divider); + $divider = trim($divider, '|'); + + $dividerCells = explode('|', $divider); + + foreach ($dividerCells as $dividerCell) + { + $dividerCell = trim($dividerCell); + + if ($dividerCell === '') + { + continue; + } + + $alignment = null; + + if ($dividerCell[0] === ':') + { + $alignment = 'left'; + } + + if (substr($dividerCell, - 1) === ':') + { + $alignment = $alignment === 'left' ? 'center' : 'right'; + } + + $alignments []= $alignment; + } + + # ~ + + $HeaderElements = array(); + + $header = $Block['element']['text']; + + $header = trim($header); + $header = trim($header, '|'); + + $headerCells = explode('|', $header); + + foreach ($headerCells as $index => $headerCell) + { + $headerCell = trim($headerCell); + + $HeaderElement = array( + 'name' => 'th', + 'text' => $headerCell, + 'handler' => 'line', + ); + + if (isset($alignments[$index])) + { + $alignment = $alignments[$index]; + + $HeaderElement['attributes'] = array( + 'style' => 'text-align: '.$alignment.';', + ); + } + + $HeaderElements []= $HeaderElement; + } + + # ~ + + $Block = array( + 'alignments' => $alignments, + 'identified' => true, + 'element' => array( + 'name' => 'table', + 'handler' => 'elements', + ), + ); + + $Block['element']['text'] []= array( + 'name' => 'thead', + 'handler' => 'elements', + ); + + $Block['element']['text'] []= array( + 'name' => 'tbody', + 'handler' => 'elements', + 'text' => array(), + ); + + $Block['element']['text'][0]['text'] []= array( + 'name' => 'tr', + 'handler' => 'elements', + 'text' => $HeaderElements, + ); + + return $Block; + } + } + + protected function blockTableContinue($Line, array $Block) + { + if (isset($Block['interrupted'])) + { + return; + } + + if ($Line['text'][0] === '|' or strpos($Line['text'], '|')) + { + $Elements = array(); + + $row = $Line['text']; + + $row = trim($row); + $row = trim($row, '|'); + + preg_match_all('/(?:(\\\\[|])|[^|`]|`[^`]+`|`)+/', $row, $matches); + + foreach ($matches[0] as $index => $cell) + { + $cell = trim($cell); + + $Element = array( + 'name' => 'td', + 'handler' => 'line', + 'text' => $cell, + ); + + if (isset($Block['alignments'][$index])) + { + $Element['attributes'] = array( + 'style' => 'text-align: '.$Block['alignments'][$index].';', + ); + } + + $Elements []= $Element; + } + + $Element = array( + 'name' => 'tr', + 'handler' => 'elements', + 'text' => $Elements, + ); + + $Block['element']['text'][1]['text'] []= $Element; + + return $Block; + } + } + + # + # ~ + # + + protected function paragraph($Line) + { + $Block = array( + 'element' => array( + 'name' => 'p', + 'text' => $Line['text'], + 'handler' => 'line', + ), + ); + + return $Block; + } + + # + # Inline Elements + # + + protected $InlineTypes = array( + '"' => array('SpecialCharacter'), + '!' => array('Image'), + '&' => array('SpecialCharacter'), + '*' => array('Emphasis'), + ':' => array('Url'), + '<' => array('UrlTag', 'EmailTag', 'Markup', 'SpecialCharacter'), + '>' => array('SpecialCharacter'), + '[' => array('Link'), + '_' => array('Emphasis'), + '`' => array('Code'), + '~' => array('Strikethrough'), + '\\' => array('EscapeSequence'), + ); + + # ~ + + protected $inlineMarkerList = '!"*_&[:<>`~\\'; + + # + # ~ + # + + public function line($text, $nonNestables=array()) + { + $markup = ''; + + # $excerpt is based on the first occurrence of a marker + + while ($excerpt = strpbrk($text, $this->inlineMarkerList)) + { + $marker = $excerpt[0]; + + $markerPosition = strpos($text, $marker); + + $Excerpt = array('text' => $excerpt, 'context' => $text); + + foreach ($this->InlineTypes[$marker] as $inlineType) + { + # check to see if the current inline type is nestable in the current context + + if ( ! empty($nonNestables) and in_array($inlineType, $nonNestables)) + { + continue; + } + + $Inline = $this->{'inline'.$inlineType}($Excerpt); + + if ( ! isset($Inline)) + { + continue; + } + + # makes sure that the inline belongs to "our" marker + + if (isset($Inline['position']) and $Inline['position'] > $markerPosition) + { + continue; + } + + # sets a default inline position + + if ( ! isset($Inline['position'])) + { + $Inline['position'] = $markerPosition; + } + + # cause the new element to 'inherit' our non nestables + + foreach ($nonNestables as $non_nestable) + { + $Inline['element']['nonNestables'][] = $non_nestable; + } + + # the text that comes before the inline + $unmarkedText = substr($text, 0, $Inline['position']); + + # compile the unmarked text + $markup .= $this->unmarkedText($unmarkedText); + + # compile the inline + $markup .= isset($Inline['markup']) ? $Inline['markup'] : $this->element($Inline['element']); + + # remove the examined text + $text = substr($text, $Inline['position'] + $Inline['extent']); + + continue 2; + } + + # the marker does not belong to an inline + + $unmarkedText = substr($text, 0, $markerPosition + 1); + + $markup .= $this->unmarkedText($unmarkedText); + + $text = substr($text, $markerPosition + 1); + } + + $markup .= $this->unmarkedText($text); + + return $markup; + } + + # + # ~ + # + + protected function inlineCode($Excerpt) + { + $marker = $Excerpt['text'][0]; + + if (preg_match('/^('.$marker.'+)[ ]*(.+?)[ ]*(? strlen($matches[0]), + 'element' => array( + 'name' => 'code', + 'text' => $text, + ), + ); + } + } + + protected function inlineEmailTag($Excerpt) + { + if (strpos($Excerpt['text'], '>') !== false and preg_match('/^<((mailto:)?\S+?@\S+?)>/i', $Excerpt['text'], $matches)) + { + $url = $matches[1]; + + if ( ! isset($matches[2])) + { + $url = 'mailto:' . $url; + } + + return array( + 'extent' => strlen($matches[0]), + 'element' => array( + 'name' => 'a', + 'text' => $matches[1], + 'attributes' => array( + 'href' => $url, + ), + ), + ); + } + } + + protected function inlineEmphasis($Excerpt) + { + if ( ! isset($Excerpt['text'][1])) + { + return; + } + + $marker = $Excerpt['text'][0]; + + if ($Excerpt['text'][1] === $marker and preg_match($this->StrongRegex[$marker], $Excerpt['text'], $matches)) + { + $emphasis = 'strong'; + } + elseif (preg_match($this->EmRegex[$marker], $Excerpt['text'], $matches)) + { + $emphasis = 'em'; + } + else + { + return; + } + + return array( + 'extent' => strlen($matches[0]), + 'element' => array( + 'name' => $emphasis, + 'handler' => 'line', + 'text' => $matches[1], + ), + ); + } + + protected function inlineEscapeSequence($Excerpt) + { + if (isset($Excerpt['text'][1]) and in_array($Excerpt['text'][1], $this->specialCharacters)) + { + return array( + 'markup' => $Excerpt['text'][1], + 'extent' => 2, + ); + } + } + + protected function inlineImage($Excerpt) + { + if ( ! isset($Excerpt['text'][1]) or $Excerpt['text'][1] !== '[') + { + return; + } + + $Excerpt['text']= substr($Excerpt['text'], 1); + + $Link = $this->inlineLink($Excerpt); + + if ($Link === null) + { + return; + } + + $Inline = array( + 'extent' => $Link['extent'] + 1, + 'element' => array( + 'name' => 'img', + 'attributes' => array( + 'src' => $Link['element']['attributes']['href'], + 'alt' => $Link['element']['text'], + ), + ), + ); + + $Inline['element']['attributes'] += $Link['element']['attributes']; + + unset($Inline['element']['attributes']['href']); + + return $Inline; + } + + protected function inlineLink($Excerpt) + { + $Element = array( + 'name' => 'a', + 'handler' => 'line', + 'nonNestables' => array('Url', 'Link'), + 'text' => null, + 'attributes' => array( + 'href' => null, + 'title' => null, + ), + ); + + $extent = 0; + + $remainder = $Excerpt['text']; + + if (preg_match('/\[((?:[^][]++|(?R))*+)\]/', $remainder, $matches)) + { + $Element['text'] = $matches[1]; + + $extent += strlen($matches[0]); + + $remainder = substr($remainder, $extent); + } + else + { + return; + } + + if (preg_match('/^[(]\s*+((?:[^ ()]++|[(][^ )]+[)])++)(?:[ ]+("[^"]*"|\'[^\']*\'))?\s*[)]/', $remainder, $matches)) + { + $Element['attributes']['href'] = $matches[1]; + + if (isset($matches[2])) + { + $Element['attributes']['title'] = substr($matches[2], 1, - 1); + } + + $extent += strlen($matches[0]); + } + else + { + if (preg_match('/^\s*\[(.*?)\]/', $remainder, $matches)) + { + $definition = strlen($matches[1]) ? $matches[1] : $Element['text']; + $definition = strtolower($definition); + + $extent += strlen($matches[0]); + } + else + { + $definition = strtolower($Element['text']); + } + + if ( ! isset($this->DefinitionData['Reference'][$definition])) + { + return; + } + + $Definition = $this->DefinitionData['Reference'][$definition]; + + $Element['attributes']['href'] = $Definition['url']; + $Element['attributes']['title'] = $Definition['title']; + } + + return array( + 'extent' => $extent, + 'element' => $Element, + ); + } + + protected function inlineMarkup($Excerpt) + { + if ($this->markupEscaped or $this->safeMode or strpos($Excerpt['text'], '>') === false) + { + return; + } + + if ($Excerpt['text'][1] === '/' and preg_match('/^<\/\w[\w-]*[ ]*>/s', $Excerpt['text'], $matches)) + { + return array( + 'markup' => $matches[0], + 'extent' => strlen($matches[0]), + ); + } + + if ($Excerpt['text'][1] === '!' and preg_match('/^/s', $Excerpt['text'], $matches)) + { + return array( + 'markup' => $matches[0], + 'extent' => strlen($matches[0]), + ); + } + + if ($Excerpt['text'][1] !== ' ' and preg_match('/^<\w[\w-]*(?:[ ]*'.$this->regexHtmlAttribute.')*[ ]*\/?>/s', $Excerpt['text'], $matches)) + { + return array( + 'markup' => $matches[0], + 'extent' => strlen($matches[0]), + ); + } + } + + protected function inlineSpecialCharacter($Excerpt) + { + if ($Excerpt['text'][0] === '&' and ! preg_match('/^&#?\w+;/', $Excerpt['text'])) + { + return array( + 'markup' => '&', + 'extent' => 1, + ); + } + + $SpecialCharacter = array('>' => 'gt', '<' => 'lt', '"' => 'quot'); + + if (isset($SpecialCharacter[$Excerpt['text'][0]])) + { + return array( + 'markup' => '&'.$SpecialCharacter[$Excerpt['text'][0]].';', + 'extent' => 1, + ); + } + } + + protected function inlineStrikethrough($Excerpt) + { + if ( ! isset($Excerpt['text'][1])) + { + return; + } + + if ($Excerpt['text'][1] === '~' and preg_match('/^~~(?=\S)(.+?)(?<=\S)~~/', $Excerpt['text'], $matches)) + { + return array( + 'extent' => strlen($matches[0]), + 'element' => array( + 'name' => 'del', + 'text' => $matches[1], + 'handler' => 'line', + ), + ); + } + } + + protected function inlineUrl($Excerpt) + { + if ($this->urlsLinked !== true or ! isset($Excerpt['text'][2]) or $Excerpt['text'][2] !== '/') + { + return; + } + + if (preg_match('/\bhttps?:[\/]{2}[^\s<]+\b\/*/ui', $Excerpt['context'], $matches, PREG_OFFSET_CAPTURE)) + { + $url = $matches[0][0]; + + $Inline = array( + 'extent' => strlen($matches[0][0]), + 'position' => $matches[0][1], + 'element' => array( + 'name' => 'a', + 'text' => $url, + 'attributes' => array( + 'href' => $url, + ), + ), + ); + + return $Inline; + } + } + + protected function inlineUrlTag($Excerpt) + { + if (strpos($Excerpt['text'], '>') !== false and preg_match('/^<(\w+:\/{2}[^ >]+)>/i', $Excerpt['text'], $matches)) + { + $url = $matches[1]; + + return array( + 'extent' => strlen($matches[0]), + 'element' => array( + 'name' => 'a', + 'text' => $url, + 'attributes' => array( + 'href' => $url, + ), + ), + ); + } + } + + # ~ + + protected function unmarkedText($text) + { + if ($this->breaksEnabled) + { + $text = preg_replace('/[ ]*\n/', "
\n", $text); + } + else + { + $text = preg_replace('/(?:[ ][ ]+|[ ]*\\\\)\n/', "
\n", $text); + $text = str_replace(" \n", "\n", $text); + } + + return $text; + } + + # + # Handlers + # + + protected function element(array $Element) + { + if ($this->safeMode) + { + $Element = $this->sanitiseElement($Element); + } + + $markup = '<'.$Element['name']; + + if (isset($Element['attributes'])) + { + foreach ($Element['attributes'] as $name => $value) + { + if ($value === null) + { + continue; + } + + $markup .= ' '.$name.'="'.self::escape($value).'"'; + } + } + + $permitRawHtml = false; + + if (isset($Element['text'])) + { + $text = $Element['text']; + } + // very strongly consider an alternative if you're writing an + // extension + elseif (isset($Element['rawHtml'])) + { + $text = $Element['rawHtml']; + $allowRawHtmlInSafeMode = isset($Element['allowRawHtmlInSafeMode']) && $Element['allowRawHtmlInSafeMode']; + $permitRawHtml = !$this->safeMode || $allowRawHtmlInSafeMode; + } + + if (isset($text)) + { + $markup .= '>'; + + if (!isset($Element['nonNestables'])) + { + $Element['nonNestables'] = array(); + } + + if (isset($Element['handler'])) + { + $markup .= $this->{$Element['handler']}($text, $Element['nonNestables']); + } + elseif (!$permitRawHtml) + { + $markup .= self::escape($text, true); + } + else + { + $markup .= $text; + } + + $markup .= ''; + } + else + { + $markup .= ' />'; + } + + return $markup; + } + + protected function elements(array $Elements) + { + $markup = ''; + + foreach ($Elements as $Element) + { + $markup .= "\n" . $this->element($Element); + } + + $markup .= "\n"; + + return $markup; + } + + # ~ + + protected function li($lines) + { + $markup = $this->lines($lines); + + $trimmedMarkup = trim($markup); + + if ( ! in_array('', $lines) and substr($trimmedMarkup, 0, 3) === '

') + { + $markup = $trimmedMarkup; + $markup = substr($markup, 3); + + $position = strpos($markup, "

"); + + $markup = substr_replace($markup, '', $position, 4); + } + + return $markup; + } + + # + # Deprecated Methods + # + + function parse($text) + { + $markup = $this->text($text); + + return $markup; + } + + protected function sanitiseElement(array $Element) + { + static $goodAttribute = '/^[a-zA-Z0-9][a-zA-Z0-9-_]*+$/'; + static $safeUrlNameToAtt = array( + 'a' => 'href', + 'img' => 'src', + ); + + if (isset($safeUrlNameToAtt[$Element['name']])) + { + $Element = $this->filterUnsafeUrlInAttribute($Element, $safeUrlNameToAtt[$Element['name']]); + } + + if ( ! empty($Element['attributes'])) + { + foreach ($Element['attributes'] as $att => $val) + { + # filter out badly parsed attribute + if ( ! preg_match($goodAttribute, $att)) + { + unset($Element['attributes'][$att]); + } + # dump onevent attribute + elseif (self::striAtStart($att, 'on')) + { + unset($Element['attributes'][$att]); + } + } + } + + return $Element; + } + + protected function filterUnsafeUrlInAttribute(array $Element, $attribute) + { + foreach ($this->safeLinksWhitelist as $scheme) + { + if (self::striAtStart($Element['attributes'][$attribute], $scheme)) + { + return $Element; + } + } + + $Element['attributes'][$attribute] = str_replace(':', '%3A', $Element['attributes'][$attribute]); + + return $Element; + } + + # + # Static Methods + # + + protected static function escape($text, $allowQuotes = false) + { + return htmlspecialchars($text, $allowQuotes ? ENT_NOQUOTES : ENT_QUOTES, 'UTF-8'); + } + + protected static function striAtStart($string, $needle) + { + $len = strlen($needle); + + if ($len > strlen($string)) + { + return false; + } + else + { + return strtolower(substr($string, 0, $len)) === strtolower($needle); + } + } + + static function instance($name = 'default') + { + if (isset(self::$instances[$name])) + { + return self::$instances[$name]; + } + + $instance = new static(); + + self::$instances[$name] = $instance; + + return $instance; + } + + private static $instances = array(); + + # + # Fields + # + + protected $DefinitionData; + + # + # Read-Only + + protected $specialCharacters = array( + '\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '>', '#', '+', '-', '.', '!', '|', + ); + + protected $StrongRegex = array( + '*' => '/^[*]{2}((?:\\\\\*|[^*]|[*][^*]*[*])+?)[*]{2}(?![*])/s', + '_' => '/^__((?:\\\\_|[^_]|_[^_]*_)+?)__(?!_)/us', + ); + + protected $EmRegex = array( + '*' => '/^[*]((?:\\\\\*|[^*]|[*][*][^*]+?[*][*])+?)[*](?![*])/s', + '_' => '/^_((?:\\\\_|[^_]|__[^_]*__)+?)_(?!_)\b/us', + ); + + protected $regexHtmlAttribute = '[a-zA-Z_:][\w:.-]*(?:\s*=\s*(?:[^"\'=<>`\s]+|"[^"]*"|\'[^\']*\'))?'; + + protected $voidElements = array( + 'area', 'base', 'br', 'col', 'command', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', + ); + + protected $textLevelElements = array( + 'a', 'br', 'bdo', 'abbr', 'blink', 'nextid', 'acronym', 'basefont', + 'b', 'em', 'big', 'cite', 'small', 'spacer', 'listing', + 'i', 'rp', 'del', 'code', 'strike', 'marquee', + 'q', 'rt', 'ins', 'font', 'strong', + 's', 'tt', 'kbd', 'mark', + 'u', 'xm', 'sub', 'nobr', + 'sup', 'ruby', + 'var', 'span', + 'wbr', 'time', + ); +} diff --git a/includes/auth.php b/includes/auth.php new file mode 100644 index 0000000..29ad30f --- /dev/null +++ b/includes/auth.php @@ -0,0 +1,42 @@ +prepare("SELECT * FROM users WHERE id=?"); + $st->execute([$_SESSION["user_id"]]); + return $st->fetch(); + } + return null; +} + +function is_login() { + return !empty($_SESSION["user_id"]); +} + +function is_admin() { + $u = current_user(); + return $u && $u["role"] === "admin"; +} + +function require_admin() { + if (!is_admin()) { + header("Location: " . SITE_URL . "login.php"); + exit; + } +} + +function attempt_login($username, $password) { + $st = db()->prepare("SELECT * FROM users WHERE username=?"); + $st->execute([$username]); + $user = $st->fetch(); + if ($user && password_verify($password, $user["password_hash"])) { + $_SESSION["user_id"] = $user["id"]; + return true; + } + return false; +} + +function logout() { + unset($_SESSION["user_id"]); +} \ No newline at end of file diff --git a/includes/config.php b/includes/config.php new file mode 100644 index 0000000..548dded --- /dev/null +++ b/includes/config.php @@ -0,0 +1,16 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]); + } catch (PDOException $e) { + die("数据库连接失败: " . htmlspecialchars($e->getMessage())); + } + } + return $pdo; +} \ No newline at end of file diff --git a/includes/footer.php b/includes/footer.php new file mode 100644 index 0000000..8934d68 --- /dev/null +++ b/includes/footer.php @@ -0,0 +1,8 @@ + + + + +
· 用心记录生活
+ + + \ No newline at end of file diff --git a/includes/functions.php b/includes/functions.php new file mode 100644 index 0000000..657632f --- /dev/null +++ b/includes/functions.php @@ -0,0 +1,62 @@ +query("SELECT * FROM categories ORDER BY id")->fetchAll(); +} + +function get_all_tags() { + return db()->query("SELECT t.*, COUNT(at.article_id) cnt FROM tags t LEFT JOIN article_tags at ON t.id=at.tag_id GROUP BY t.id ORDER BY cnt DESC")->fetchAll(); +} + +function get_article_tags($articleId) { + $st = db()->prepare("SELECT t.* FROM tags t JOIN article_tags at ON t.id=at.tag_id WHERE at.article_id=?"); + $st->execute([$articleId]); + return $st->fetchAll(); +} + +function get_archives() { + $sql = "SELECT DATE_FORMAT(created_at, ?) ym, COUNT(*) cnt FROM articles WHERE status=? GROUP BY ym ORDER BY ym DESC"; + $st = db()->prepare($sql); + $st->execute(["%Y年%m月", "published"]); + return $st->fetchAll(); +} + +function record_visit($page) { + $ip = $_SERVER["REMOTE_ADDR"] ?? ""; + $ua = substr($_SERVER["HTTP_USER_AGENT"] ?? "", 0, 250); + $st = db()->prepare("INSERT IGNORE INTO visitor_stats (visit_date, ip, page, ua) VALUES (CURDATE(), ?, ?, ?)"); + $st->execute([$ip, $page, $ua]); +} + +function paginate($total, $page, $perPage) { + $pages = max(1, (int)ceil($total / $perPage)); + $page = max(1, min($pages, (int)$page)); + return [ + "page" => $page, + "pages" => $pages, + "offset" => ($page - 1) * $perPage, + "perPage" => $perPage, + ]; +} + +function markdown($text) { + static $parsedown = null; + if ($parsedown === null) { + require_once __DIR__ . "/Parsedown.php"; + $parsedown = new Parsedown(); + $parsedown->setBreaksEnabled(true); + } + return $parsedown->text((string)$text); +} \ No newline at end of file diff --git a/includes/header.php b/includes/header.php new file mode 100644 index 0000000..eb538cd --- /dev/null +++ b/includes/header.php @@ -0,0 +1,41 @@ + + + + + + +<?php echo isset($pageTitle) ? e($pageTitle) . " - " . SITE_NAME : SITE_NAME; ?> + + + + +
+
+
我的博客
+
+ " href="index.php">首页 + + " href="category.php?id="> + + " href="archive.php">归档 + " href="about.php">关于 + + + 管理 + 退出 + + 登录 + +
+
+
+
+
\ No newline at end of file diff --git a/includes/sidebar.php b/includes/sidebar.php new file mode 100644 index 0000000..9676cfd --- /dev/null +++ b/includes/sidebar.php @@ -0,0 +1,26 @@ +
+
+

热门标签

+
+ + "> + +
+
+
+

分类

+
+ + "> + +
+
+
+

归档

+
+ +
"> 篇
+ +
+
+
\ No newline at end of file diff --git a/index.php b/index.php new file mode 100644 index 0000000..21d64bb --- /dev/null +++ b/index.php @@ -0,0 +1,42 @@ +query("SELECT COUNT(*) c FROM articles WHERE status='published'")->fetch()["c"]; +$pg = paginate($total, isset($_GET["page"]) ? (int)$_GET["page"] : 1, $perPage); +$stmt = db()->prepare("SELECT a.*, c.name cat_name FROM articles a LEFT JOIN categories c ON a.category_id=c.id WHERE a.status=? ORDER BY a.created_at DESC LIMIT ? OFFSET ?"); +$stmt->bindValue(1, "published", PDO::PARAM_STR); +$stmt->bindValue(2, $pg["perPage"], PDO::PARAM_INT); +$stmt->bindValue(3, $pg["offset"], PDO::PARAM_INT); +$stmt->execute(); +$articles = $stmt->fetchAll(); +?> +
+

我的博客

+

欢迎访问

+
+
+ "> + +
+
+
+ +
暂无已发布文章
+ +
'"> +
次阅读
+

+

+
阅读全文
+
+ + 1) { ?> +
+ + " href="?page="> + +
+ + \ No newline at end of file diff --git a/login.php b/login.php new file mode 100644 index 0000000..0215330 --- /dev/null +++ b/login.php @@ -0,0 +1,48 @@ + + + + + + +登录 - <?php echo e(SITE_NAME); ?> + + + + +
+ +
+
+
+

登录

+
登录以管理你的博客
+
+
+
" required>
+
+ +
+
返回首页
+
+
+ + + diff --git a/logout.php b/logout.php new file mode 100644 index 0000000..99e86cb --- /dev/null +++ b/logout.php @@ -0,0 +1,6 @@ +prepare("SELECT a.*, c.name cat_name FROM articles a LEFT JOIN categories c ON a.category_id=c.id WHERE a.status=? AND (a.title LIKE ? OR a.content LIKE ? OR a.excerpt LIKE ?) ORDER BY a.created_at DESC"); + $st->execute(["published",$like,$like,$like]); + $articles = $st->fetchAll(); +} +require_once __DIR__ . "/includes/header.php"; +?> +
+

搜索

+
+
+
+
+ + +
+
+
没有找到匹配的文章
+ +
'"> +
+

+

+
+ + \ No newline at end of file diff --git a/sql/init.sql b/sql/init.sql new file mode 100644 index 0000000..6f68973 --- /dev/null +++ b/sql/init.sql @@ -0,0 +1,83 @@ +-- myblog 数据库初始化脚本 +-- 7 张表:users, categories, tags, articles, article_tags, comments, visitor_stats + +CREATE TABLE IF NOT EXISTS users ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(50) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + email VARCHAR(120) DEFAULT NULL, + role ENUM('admin','author') NOT NULL DEFAULT 'author', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS categories ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(60) NOT NULL, + slug VARCHAR(80) NOT NULL UNIQUE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS tags ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(60) NOT NULL, + slug VARCHAR(80) NOT NULL UNIQUE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS articles ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id INT UNSIGNED NOT NULL, + category_id INT UNSIGNED DEFAULT NULL, + title VARCHAR(200) NOT NULL, + slug VARCHAR(220) NOT NULL UNIQUE, + content MEDIUMTEXT NOT NULL, + excerpt VARCHAR(500) DEFAULT NULL, + status ENUM('published','draft') NOT NULL DEFAULT 'draft', + views INT UNSIGNED NOT NULL DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS article_tags ( + article_id INT UNSIGNED NOT NULL, + tag_id INT UNSIGNED NOT NULL, + PRIMARY KEY (article_id, tag_id), + FOREIGN KEY (article_id) REFERENCES articles(id) ON DELETE CASCADE, + FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS comments ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + article_id INT UNSIGNED NOT NULL, + parent_id INT UNSIGNED DEFAULT NULL, + author VARCHAR(60) NOT NULL, + email VARCHAR(120) DEFAULT NULL, + content TEXT NOT NULL, + status ENUM('pending','approved') NOT NULL DEFAULT 'pending', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (article_id) REFERENCES articles(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS visitor_stats ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + visit_date DATE NOT NULL, + ip VARCHAR(45) DEFAULT NULL, + page VARCHAR(200) DEFAULT NULL, + ua VARCHAR(255) DEFAULT NULL, + UNIQUE KEY uniq_visit (visit_date, ip, page) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 默认管理员:admin / 123456 (password_hash 加密) +INSERT INTO users (username, password_hash, role) VALUES +('admin', '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'admin') +ON DUPLICATE KEY UPDATE id=id; + +-- 示例分类 +INSERT INTO categories (name, slug) VALUES +('设计','design'),('前端','frontend'),('后端','backend'),('随笔','notes') +ON DUPLICATE KEY UPDATE id=id; + +-- 示例标签 +INSERT INTO tags (name, slug) VALUES +('CSS','css'),('PHP','php'),('设计系统','design-system'),('动效','motion') +ON DUPLICATE KEY UPDATE id=id; \ No newline at end of file diff --git a/sql/myblog.sql b/sql/myblog.sql new file mode 100644 index 0000000..8d192a0 --- /dev/null +++ b/sql/myblog.sql @@ -0,0 +1,233 @@ +-- MariaDB dump 10.19 Distrib 10.4.32-MariaDB, for Win64 (AMD64) +-- +-- Host: localhost Database: myblog +-- ------------------------------------------------------ +-- Server version 10.4.32-MariaDB + +/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; +/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; +/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; +/*!40101 SET NAMES utf8mb4 */; +/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; +/*!40103 SET TIME_ZONE='+00:00' */; +/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; +/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; +/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; +/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; + +-- +-- Table structure for table `article_tags` +-- + +DROP TABLE IF EXISTS `article_tags`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `article_tags` ( + `article_id` int(10) unsigned NOT NULL, + `tag_id` int(10) unsigned NOT NULL, + PRIMARY KEY (`article_id`,`tag_id`), + KEY `tag_id` (`tag_id`), + CONSTRAINT `article_tags_ibfk_1` FOREIGN KEY (`article_id`) REFERENCES `articles` (`id`) ON DELETE CASCADE, + CONSTRAINT `article_tags_ibfk_2` FOREIGN KEY (`tag_id`) REFERENCES `tags` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `article_tags` +-- + +LOCK TABLES `article_tags` WRITE; +/*!40000 ALTER TABLE `article_tags` DISABLE KEYS */; +INSERT INTO `article_tags` VALUES (2,2),(3,3); +/*!40000 ALTER TABLE `article_tags` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `articles` +-- + +DROP TABLE IF EXISTS `articles`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `articles` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `user_id` int(10) unsigned NOT NULL, + `category_id` int(10) unsigned DEFAULT NULL, + `title` varchar(200) NOT NULL, + `slug` varchar(220) NOT NULL, + `content` mediumtext NOT NULL, + `excerpt` varchar(500) DEFAULT NULL, + `status` enum('published','draft') NOT NULL DEFAULT 'draft', + `views` int(10) unsigned NOT NULL DEFAULT 0, + `created_at` timestamp NOT NULL DEFAULT current_timestamp(), + `updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`id`), + UNIQUE KEY `slug` (`slug`), + KEY `user_id` (`user_id`), + KEY `category_id` (`category_id`), + KEY `idx_status_created` (`status`,`created_at`), + CONSTRAINT `articles_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, + CONSTRAINT `articles_ibfk_2` FOREIGN KEY (`category_id`) REFERENCES `categories` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `articles` +-- + +LOCK TABLES `articles` WRITE; +/*!40000 ALTER TABLE `articles` DISABLE KEYS */; +INSERT INTO `articles` VALUES (1,1,4,'TCP 三次握手','tcp','## 什么是 TCP 三次握手\n\nTCP 是面向连接的可靠传输协议。在正式传输数据之前,通信双方需要通过三次握手建立连接,确认彼此的收发能力都正常。\n\n## 三次握手的流程\n\n第一次握手:客户端发送一个带有 SYN 标志的报文,并带上一个初始序列号,请求建立连接。\n\n第二次握手:服务端收到 SYN 后,回复一个带有 SYN 和 ACK 标志的报文,确认收到客户端的请求,同时通知自己的初始序列号。\n\n第三次握手:客户端收到服务端的 SYN+ACK 后,再发送一个 ACK 报文进行确认。此时连接建立,双方可以开始传输数据。\n\n## 为什么要三次握手\n\n两次握手是不够的。如果服务端发出的应答报文丢失,客户端会重发 SYN,可能导致服务端建立多个无效连接,浪费资源。\n\n三次握手可以确保双方都知道\"我能发、你能收\"以及\"你能发、我能收\",从而可靠地建立连接。\n\n## 简单总结\n\n连接建立的过程可以概括为:\n\nSYN -> SYN+ACK -> ACK\n\n三次握手是 TCP 可靠性的基础,它保证了连接的双方在传输数据前都已准备好,让后续的数据传输更加安全可靠。','TCP 通过三次握手建立可靠连接,确保双方就绪后才开始传输数据。','published',72,'2026-09-09 10:13:59','2026-09-09 11:12:19'),(2,1,4,'一杯清茶的时间','post-6aa131674c754','## 一杯清茶的时间\n\n下午三点,泡一杯茶。看茶叶在水中慢慢舒展,热气袅袅升起。\n\n生活的节奏太快,需要这样一杯茶的时间,让自己慢下来,也静下来。\n\n## 茶里的小事\n\n烧水、温杯、投茶、注水,每一个步骤都不急不缓。看着茶叶在热水中一点点舒展开来,仿佛整个下午也跟着慢了下来。茶汤的颜色由浅转深,香气在鼻尖萦绕,这一刻,烦恼似乎都变得遥远。\n\n## 慢一点的智慧\n\n我们习惯了赶时间,却常常忘了,有些事急不得,也催不来。就像这杯茶,火候到了,自然就香。人也一样,给自己一点时间和空间,很多事会自然明朗。\n\n一杯茶,不只是一杯茶,它是一段与自己相处的时光,是喧嚣生活里,为自己留出的一方清净。喝完这一杯,再从容地回到忙碌中去。','生活太快,需要一杯茶让自己慢下来。','published',54,'2026-09-08 10:13:59','2026-09-09 11:06:19'),(3,1,4,'写在周末','post-6aa131674cf54','## 写在周末\n\n周末适合整理,整理房间,也整理心情。\n\n把一周的疲惫卸下,把下周的清单列好,然后从容地迎接新的一周。日子虽平凡,却也值得认真对待。\n\n## 整理生活\n\n周末的早晨,拉开窗帘,让阳光照进来。把散落的物品归位,把积攒了一周的衣服洗好,把桌面的杂物清理干净。当房间变得整洁,心情也会跟着轻盈起来。\n\n## 给自己一点时间\n\n除了整理房间,也别忘了整理心情。翻一翻这周的相册,回想几件开心的小事;或者约上好友,去吃一顿喜欢的饭。生活的意义,往往就藏在这些看似平常的片段里。\n\n周末不是工作的暂停,而是生活的重启。休息好、整理好,才能以更好的状态,去迎接下一个平凡又值得期待的日子。','周末适合整理,整理房间,也整理心情。','published',71,'2026-09-07 10:13:59','2026-09-09 11:06:19'); +/*!40000 ALTER TABLE `articles` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `categories` +-- + +DROP TABLE IF EXISTS `categories`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `categories` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(60) NOT NULL, + `slug` varchar(80) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `slug` (`slug`) +) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `categories` +-- + +LOCK TABLES `categories` WRITE; +/*!40000 ALTER TABLE `categories` DISABLE KEYS */; +INSERT INTO `categories` VALUES (1,'设计','design'),(2,'前端','frontend'),(3,'后端','backend'),(4,'随笔','notes'); +/*!40000 ALTER TABLE `categories` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `comments` +-- + +DROP TABLE IF EXISTS `comments`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `comments` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `article_id` int(10) unsigned NOT NULL, + `parent_id` int(10) unsigned DEFAULT NULL, + `author` varchar(60) NOT NULL, + `email` varchar(120) DEFAULT NULL, + `content` text NOT NULL, + `status` enum('pending','approved') NOT NULL DEFAULT 'pending', + `created_at` timestamp NOT NULL DEFAULT current_timestamp(), + PRIMARY KEY (`id`), + KEY `article_id` (`article_id`), + CONSTRAINT `comments_ibfk_1` FOREIGN KEY (`article_id`) REFERENCES `articles` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `comments` +-- + +LOCK TABLES `comments` WRITE; +/*!40000 ALTER TABLE `comments` DISABLE KEYS */; +/*!40000 ALTER TABLE `comments` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `tags` +-- + +DROP TABLE IF EXISTS `tags`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `tags` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(60) NOT NULL, + `slug` varchar(80) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `slug` (`slug`) +) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `tags` +-- + +LOCK TABLES `tags` WRITE; +/*!40000 ALTER TABLE `tags` DISABLE KEYS */; +INSERT INTO `tags` VALUES (1,'随笔','notes'),(2,'生活','life'),(3,'分享','share'); +/*!40000 ALTER TABLE `tags` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `users` +-- + +DROP TABLE IF EXISTS `users`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `users` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `username` varchar(50) NOT NULL, + `password_hash` varchar(255) NOT NULL, + `email` varchar(120) DEFAULT NULL, + `role` enum('admin','author') NOT NULL DEFAULT 'author', + `created_at` timestamp NOT NULL DEFAULT current_timestamp(), + PRIMARY KEY (`id`), + UNIQUE KEY `username` (`username`) +) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `users` +-- + +LOCK TABLES `users` WRITE; +/*!40000 ALTER TABLE `users` DISABLE KEYS */; +INSERT INTO `users` VALUES (1,'admin','$2y$10$4bocGkDIxtmTyK.S7H5AQ.lm/ZzONQpeuZXHhiuRLERFsZ2iaD0qK',NULL,'admin','2026-09-09 08:07:24'); +/*!40000 ALTER TABLE `users` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `visitor_stats` +-- + +DROP TABLE IF EXISTS `visitor_stats`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `visitor_stats` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `visit_date` date NOT NULL, + `ip` varchar(45) DEFAULT NULL, + `page` varchar(200) DEFAULT NULL, + `ua` varchar(255) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_visit` (`visit_date`,`ip`,`page`) +) ENGINE=InnoDB AUTO_INCREMENT=60 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `visitor_stats` +-- + +LOCK TABLES `visitor_stats` WRITE; +/*!40000 ALTER TABLE `visitor_stats` DISABLE KEYS */; +INSERT INTO `visitor_stats` VALUES (1,'2026-09-09','::1','article/1','Mozilla/5.0 (Windows NT; Windows NT 10.0; zh-CN) WindowsPowerShell/5.1.26100.8972'),(5,'2026-09-09','::1','article/2','Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36 Edg/152.0.0.0'),(8,'2026-09-09','::1','article/3','Mozilla/5.0 (Windows NT; Windows NT 10.0; zh-CN) WindowsPowerShell/5.1.26100.8972'),(14,'2026-09-09','::1','search','Mozilla/5.0 (Windows NT; Windows NT 10.0; zh-CN) WindowsPowerShell/5.1.26100.8972'),(16,'2026-09-09','::1','archive','Mozilla/5.0 (Windows NT; Windows NT 10.0; zh-CN) WindowsPowerShell/5.1.26100.8972'),(17,'2026-09-09','::1','category/4','Mozilla/5.0 (Windows NT; Windows NT 10.0; zh-CN) WindowsPowerShell/5.1.26100.8972'),(18,'2026-09-09','::1','tag/1','Mozilla/5.0 (Windows NT; Windows NT 10.0; zh-CN) WindowsPowerShell/5.1.26100.8972'),(19,'2026-09-09','::1','about','Mozilla/5.0 (Windows NT; Windows NT 10.0; zh-CN) WindowsPowerShell/5.1.26100.8972'),(37,'2026-09-09','::1','category/1','Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36 Edg/152.0.0.0'),(38,'2026-09-09','::1','category/2','Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36 Edg/152.0.0.0'),(39,'2026-09-09','::1','category/3','Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36 Edg/152.0.0.0'); +/*!40000 ALTER TABLE `visitor_stats` ENABLE KEYS */; +UNLOCK TABLES; +/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; + +/*!40101 SET SQL_MODE=@OLD_SQL_MODE */; +/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; +/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; +/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; +/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; +/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; +/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; + +-- Dump completed on 2026-09-09 19:16:36 diff --git a/tag.php b/tag.php new file mode 100644 index 0000000..7ae02ed --- /dev/null +++ b/tag.php @@ -0,0 +1,31 @@ +prepare("SELECT * FROM tags WHERE id=?"); $t->execute([$id]); $tg = $t->fetch(); +if (!$tg) { header("Location: " . SITE_URL . "index.php"); exit; } +$pageName = "tag"; +$pageTitle = $tg["name"]; +record_visit("tag/" . $id); +$perPage = 5; +$st = db()->prepare("SELECT COUNT(*) c FROM article_tags WHERE tag_id=?"); $st->execute([$id]); +$pg = paginate((int)$st->fetch()["c"], isset($_GET["page"])?(int)$_GET["page"]:1, $perPage); +$stmt = db()->prepare("SELECT a.*, c.name cat_name FROM articles a JOIN article_tags at ON a.id=at.article_id LEFT JOIN categories c ON a.category_id=c.id WHERE at.tag_id=? AND a.status=? ORDER BY a.created_at DESC LIMIT ? OFFSET ?"); +$stmt->bindValue(1,$id,PDO::PARAM_INT); $stmt->bindValue(2,"published",PDO::PARAM_STR); +$stmt->bindValue(3,$pg["perPage"],PDO::PARAM_INT); $stmt->bindValue(4,$pg["offset"],PDO::PARAM_INT); +$stmt->execute(); $articles = $stmt->fetchAll(); +require_once __DIR__ . "/includes/header.php"; +?> +
+

标签:

+
该标签下的文章
+
+
该标签暂无文章
+ +
'"> +
+

+
+ +1) { ?>
" href="?id=&page=">
+ \ No newline at end of file diff --git a/部署指南.md b/部署指南.md new file mode 100644 index 0000000..c9e0d8b --- /dev/null +++ b/部署指南.md @@ -0,0 +1,68 @@ +# 我的博客 · 环境部署指南 + +这是一份把本博客部署到另一台 Windows 电脑的说明。跟着步骤做即可。 + +## 1. 环境要求 + +- Windows 操作系统 +- XAMPP(集成 Apache、PHP、MySQL,下载:https://www.apachefriends.org) +- 需要 PHP 的 pdo_mysql 扩展(XAMPP 默认已启用) + +## 2. 安装步骤 + +### 2.1 安装 XAMPP + +下载并安装 XAMPP,安装到任意目录,例如 `C:\xampp`。 + +### 2.2 放置项目 + +把整个 `myblog` 文件夹复制到 XAMPP 的 htdocs 目录下: + +``` +XAMPP安装目录\htdocs\myblog +``` + +例如:`C:\xampp\htdocs\myblog` + +### 2.3 启动服务 + +1. 打开 XAMPP 控制面板(`xampp-control.exe`) +2. 点击 Apache 的 **Start** +3. 点击 MySQL 的 **Start** + +### 2.4 导入数据库 + +1. 浏览器打开 `http://localhost/phpmyadmin` +2. 在左侧点 **新建**,创建一个名为 `myblog` 的数据库,排序规则选 `utf8mb4_general_ci` +3. 点进 `myblog` 数据库 → 顶部 **导入** → 选择本项目的 `sql/myblog.sql` 文件 → 执行 +4. 导入成功后会生成 7 张表,并带有测试文章数据 + +### 2.5 配置数据库密码 + +项目的数据库配置在 `includes/config.php`,里面 MySQL 的 root 密码写的是 `root`: + +- 如果你的 MySQL root 密码正好是 `root`:**无需改动** +- 如果 root 密码是空(XAMPP 默认):用 phpMyAdmin 把 root 密码设为 `root`,**或**打开 `includes/config.php` 把 `DB_PASS` 改成 `""`(空) + +### 2.6 访问网站 + +- 前台:`http://localhost/myblog/` +- 后台管理:`http://localhost/myblog/admin/` +- 默认管理员账号:用户名 `admin`,密码 `123456` + +## 3. 常见问题 + +| 问题 | 解决方法 | +|------|----------| +| Apache Start 后变红(端口被占用) | 关闭占用 80 端口的程序,或在 httpd.conf 改端口 | +| MySQL Start 后变红(端口被占用) | 关闭占用 3306 端口的程序 | +| 打开页面显示连接数据库失败 | 检查 config.php 的 DB_PASS 是否和 MySQL 实际密码一致 | +| 中文显示乱码 | 确认数据库 myblog 的排序规则是 utf8mb4 | + +## 4. 目录说明 + +- `includes/` 公共函数、数据库连接、认证 +- `admin/` 后台管理 +- `assets/` CSS / JS +- `sql/` 数据库脚本(myblog.sql 是含数据的完整备份,init.sql 是空表结构) +- `uploads/` 上传目录