Pārlūkot izejas kodu

feat: 根据 DESIGN.md 创建博客系统所有文件

Co-authored-by: aider (deepseek/deepseek-chat) <aider@aider.chat>
Your Name 3 dienas atpakaļ
revīzija
b7ac8208f8

+ 275 - 0
app.py

@@ -0,0 +1,275 @@
+import os
+import re
+import json
+import time
+import uuid
+import shutil
+from datetime import datetime, timezone
+from pathlib import Path
+
+from flask import (
+    Flask,
+    render_template,
+    request,
+    redirect,
+    url_for,
+    send_from_directory,
+    abort,
+    jsonify,
+)
+from werkzeug.utils import secure_filename
+import markdown
+from bs4 import BeautifulSoup
+
+import config
+
+app = Flask(__name__)
+app.config.from_object(config)
+
+# 确保必要的目录存在
+for dir_path in [
+    config.UPLOAD_FOLDER,
+    config.GENERATED_FOLDER,
+    config.POSTS_DATA_FOLDER,
+    config.INDEX_FILE.parent,
+]:
+    dir_path.mkdir(parents=True, exist_ok=True)
+
+# 如果索引文件不存在,创建空列表
+if not config.INDEX_FILE.exists():
+    with open(config.INDEX_FILE, "w", encoding="utf-8") as f:
+        json.dump([], f, ensure_ascii=False, indent=2)
+
+
+def allowed_file(filename: str) -> bool:
+    """检查文件扩展名是否允许"""
+    return (
+        "." in filename
+        and filename.rsplit(".", 1)[1].lower() in config.ALLOWED_EXTENSIONS
+    )
+
+
+def load_index() -> list:
+    """加载文章索引"""
+    with open(config.INDEX_FILE, "r", encoding="utf-8") as f:
+        return json.load(f)
+
+
+def save_index(index: list):
+    """保存文章索引"""
+    with open(config.INDEX_FILE, "w", encoding="utf-8") as f:
+        json.dump(index, f, ensure_ascii=False, indent=2)
+
+
+def fix_image_paths(md_content: str, post_id: str) -> str:
+    """将 Markdown 中的本地图片路径替换为可访问的 URL"""
+    pattern = r'!\[([^\]]*)\]\(([^)]+)\)'
+
+    def repl(match):
+        alt, src = match.groups()
+        # 仅当 src 不含 '/' 或 'http' 且扩展名为图片时视为本地文件
+        if (
+            not src.startswith(("http://", "https://", "/", "#"))
+            and "." in src
+        ):
+            new_src = f"/static/uploads/posts/{post_id}/{src}"
+            return f'![{alt}]({new_src})'
+        return match.group(0)
+
+    return re.sub(pattern, repl, md_content)
+
+
+def extract_summary(html_body: str, max_chars: int = 200) -> str:
+    """从 HTML 中提取纯文本摘要"""
+    soup = BeautifulSoup(html_body, "html.parser")
+    text = soup.get_text()
+    # 去除多余空白
+    text = re.sub(r"\s+", " ", text).strip()
+    if len(text) > max_chars:
+        return text[:max_chars] + "..."
+    return text
+
+
+def extract_thumbnail(html_body: str) -> str:
+    """从 HTML 中提取第一张图片的 URL"""
+    soup = BeautifulSoup(html_body, "html.parser")
+    img = soup.find("img")
+    if img and img.get("src"):
+        return img["src"]
+    return ""
+
+
+def render_markdown(md_content: str) -> str:
+    """将 Markdown 渲染为 HTML"""
+    md = markdown.Markdown(
+        extensions=[
+            "extra",
+            "codehilite",
+            "tables",
+            "fenced_code",
+        ]
+    )
+    return md.convert(md_content)
+
+
+def generate_static_page(post_id: str, title: str, html_body: str):
+    """生成独立的静态 HTML 文件"""
+    rendered = render_template(
+        "post_template.html",
+        title=title,
+        content=html_body,
+    )
+    output_path = config.GENERATED_FOLDER / f"{post_id}.html"
+    with open(output_path, "w", encoding="utf-8") as f:
+        f.write(rendered)
+
+
+# ==================== 路由 ====================
+
+
+@app.route("/")
+def index():
+    """首页:瀑布流文章列表"""
+    posts = load_index()
+    # 按日期倒序排列
+    posts.sort(key=lambda p: p.get("date", ""), reverse=True)
+    return render_template("index.html", posts=posts)
+
+
+@app.route("/upload", methods=["GET", "POST"])
+def upload():
+    """上传文章"""
+    if request.method == "GET":
+        return render_template("upload.html")
+
+    # POST 处理
+    title = request.form.get("title", "").strip()
+    if not title:
+        return "标题不能为空", 400
+
+    markdown_file = request.files.get("markdown_file")
+    if not markdown_file or markdown_file.filename == "":
+        return "请选择 Markdown 文件", 400
+
+    if not allowed_file(markdown_file.filename):
+        return "不支持的文件类型,请上传 .md 文件", 400
+
+    # 生成唯一 ID
+    post_id = str(int(time.time() * 1000))
+
+    # 创建目录
+    upload_dir = config.UPLOAD_FOLDER / post_id
+    upload_dir.mkdir(parents=True, exist_ok=True)
+
+    posts_data_dir = config.POSTS_DATA_FOLDER / post_id
+    posts_data_dir.mkdir(parents=True, exist_ok=True)
+
+    # 保存 Markdown 文件
+    md_path = posts_data_dir / "content.md"
+    markdown_file.save(str(md_path))
+
+    # 处理图片上传
+    images = request.files.getlist("images")
+    for img in images:
+        if img and img.filename:
+            filename = secure_filename(img.filename)
+            if filename:
+                img.save(str(upload_dir / filename))
+
+    # 读取 Markdown 内容
+    with open(md_path, "r", encoding="utf-8") as f:
+        md_content = f.read()
+
+    # 修正图片路径
+    fixed_md = fix_image_paths(md_content, post_id)
+
+    # 渲染 HTML
+    html_body = render_markdown(fixed_md)
+
+    # 提取摘要
+    summary = extract_summary(html_body)
+
+    # 提取缩略图
+    thumbnail = extract_thumbnail(html_body)
+
+    # 生成静态页面
+    generate_static_page(post_id, title, html_body)
+
+    # 更新索引
+    index = load_index()
+    index.append(
+        {
+            "id": post_id,
+            "title": title,
+            "date": datetime.now(timezone.utc).isoformat(),
+            "summary": summary,
+            "thumbnail": thumbnail,
+        }
+    )
+    # 按日期倒序排序
+    index.sort(key=lambda p: p.get("date", ""), reverse=True)
+    save_index(index)
+
+    return redirect(url_for("index"))
+
+
+@app.route("/post/<post_id>")
+def view_post(post_id: str):
+    """查看文章详情"""
+    # 安全验证:只允许字母数字和下划线
+    if not re.match(r"^[a-zA-Z0-9_]+$", post_id):
+        abort(404)
+
+    try:
+        return send_from_directory(
+            config.GENERATED_FOLDER,
+            f"{post_id}.html",
+        )
+    except FileNotFoundError:
+        abort(404)
+
+
+@app.route("/admin")
+def admin():
+    """管理页面"""
+    posts = load_index()
+    return render_template("admin.html", posts=posts)
+
+
+@app.route("/admin/delete/<post_id>", methods=["POST"])
+def delete_post(post_id: str):
+    """删除文章"""
+    # 安全验证
+    if not re.match(r"^[a-zA-Z0-9_]+$", post_id):
+        abort(404)
+
+    # 删除生成的静态文件
+    generated_file = config.GENERATED_FOLDER / f"{post_id}.html"
+    if generated_file.exists():
+        generated_file.unlink()
+
+    # 删除 posts_data 目录
+    posts_data_dir = config.POSTS_DATA_FOLDER / post_id
+    if posts_data_dir.exists():
+        shutil.rmtree(posts_data_dir)
+
+    # 删除上传的图片目录
+    upload_dir = config.UPLOAD_FOLDER / post_id
+    if upload_dir.exists():
+        shutil.rmtree(upload_dir)
+
+    # 从索引中移除
+    index = load_index()
+    index = [p for p in index if p["id"] != post_id]
+    save_index(index)
+
+    return redirect(url_for("admin"))
+
+
+@app.errorhandler(404)
+def not_found(e):
+    return render_template("404.html"), 404
+
+
+if __name__ == "__main__":
+    app.run(host="0.0.0.0", port=5000, debug=True)

+ 26 - 0
config.py

@@ -0,0 +1,26 @@
+import os
+from pathlib import Path
+
+# 项目根目录
+BASE_DIR = Path(__file__).resolve().parent
+
+# 上传文件存储目录(图片)
+UPLOAD_FOLDER = BASE_DIR / "static" / "uploads" / "posts"
+
+# 生成的静态 HTML 文件目录
+GENERATED_FOLDER = BASE_DIR / "generated"
+
+# 原始 Markdown 数据存储目录
+POSTS_DATA_FOLDER = BASE_DIR / "posts_data"
+
+# 索引文件路径
+INDEX_FILE = BASE_DIR / "posts_index.json"
+
+# 允许上传的文件扩展名
+ALLOWED_EXTENSIONS = {"md", "png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"}
+
+# 最大上传文件大小(16 MB)
+MAX_CONTENT_LENGTH = 16 * 1024 * 1024
+
+# Flask 密钥(用于 session 等,生产环境请修改)
+SECRET_KEY = os.environ.get("SECRET_KEY", "dev-secret-key-change-in-production")

+ 1 - 0
posts_index.json

@@ -0,0 +1 @@
+[]

+ 5 - 0
requirements.txt

@@ -0,0 +1,5 @@
+Flask>=2.3.0
+markdown>=3.4.0
+Pygments>=2.15.0
+beautifulsoup4>=4.11.0
+lxml>=4.9.0

+ 348 - 0
static/css/style.css

@@ -0,0 +1,348 @@
+/* ==================== 全局重置 ==================== */
+*,
+*::before,
+*::after {
+    box-sizing: border-box;
+    margin: 0;
+    padding: 0;
+}
+
+html {
+    font-size: 16px;
+    scroll-behavior: smooth;
+}
+
+body {
+    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+    background-color: #f8fafc;
+    color: #1a202c;
+    line-height: 1.6;
+    min-height: 100vh;
+    display: flex;
+    flex-direction: column;
+}
+
+/* ==================== 导航栏 ==================== */
+.navbar {
+    position: sticky;
+    top: 0;
+    z-index: 100;
+    background: rgba(255, 255, 255, 0.85);
+    backdrop-filter: blur(12px);
+    -webkit-backdrop-filter: blur(12px);
+    border-bottom: 1px solid rgba(0, 0, 0, 0.05);
+    padding: 0.75rem 1.5rem;
+}
+
+.nav-container {
+    max-width: 1200px;
+    margin: 0 auto;
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+}
+
+.nav-brand {
+    font-size: 1.25rem;
+    font-weight: 700;
+    color: #2d3748;
+    text-decoration: none;
+}
+
+.nav-links {
+    list-style: none;
+    display: flex;
+    gap: 1.5rem;
+}
+
+.nav-links a {
+    text-decoration: none;
+    color: #4a5568;
+    font-weight: 500;
+    transition: color 0.2s;
+}
+
+.nav-links a:hover {
+    color: #3182ce;
+}
+
+/* ==================== 主内容区 ==================== */
+.main-content {
+    flex: 1;
+    max-width: 1200px;
+    margin: 2rem auto;
+    padding: 0 1.5rem;
+    width: 100%;
+}
+
+/* ==================== 瀑布流布局 ==================== */
+.waterfall {
+    display: grid;
+    grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
+    gap: 1.5rem;
+}
+
+/* ==================== 卡片 ==================== */
+.card {
+    background: #ffffff;
+    border-radius: 20px;
+    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
+    overflow: hidden;
+    transition: transform 0.2s ease, box-shadow 0.2s ease;
+    display: flex;
+    flex-direction: column;
+}
+
+.card:hover {
+    transform: translateY(-4px);
+    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
+}
+
+.card-image {
+    width: 100%;
+    height: 180px;
+    overflow: hidden;
+}
+
+.card-image img {
+    width: 100%;
+    height: 100%;
+    object-fit: cover;
+    display: block;
+}
+
+.card-body {
+    padding: 1.25rem;
+    flex: 1;
+    display: flex;
+    flex-direction: column;
+}
+
+.card-title {
+    font-size: 1.25rem;
+    font-weight: 700;
+    margin-bottom: 0.5rem;
+    line-height: 1.3;
+}
+
+.card-title a {
+    color: #2d3748;
+    text-decoration: none;
+    transition: color 0.2s;
+}
+
+.card-title a:hover {
+    color: #3182ce;
+}
+
+.card-date {
+    display: block;
+    font-size: 0.875rem;
+    color: #a0aec0;
+    margin-bottom: 0.75rem;
+}
+
+.card-summary {
+    color: #718096;
+    font-size: 0.9375rem;
+    line-height: 1.6;
+    flex: 1;
+    margin-bottom: 1rem;
+}
+
+.card-link {
+    color: #3182ce;
+    text-decoration: none;
+    font-weight: 500;
+    font-size: 0.9375rem;
+    transition: color 0.2s;
+    align-self: flex-start;
+}
+
+.card-link:hover {
+    color: #2b6cb0;
+}
+
+/* ==================== 空状态 ==================== */
+.empty-state {
+    grid-column: 1 / -1;
+    text-align: center;
+    padding: 4rem 1rem;
+    color: #a0aec0;
+    font-size: 1.125rem;
+}
+
+.empty-state a {
+    color: #3182ce;
+    text-decoration: none;
+}
+
+/* ==================== 上传表单 ==================== */
+.upload-container {
+    max-width: 600px;
+    margin: 0 auto;
+}
+
+.upload-container h1 {
+    margin-bottom: 1.5rem;
+    font-size: 1.75rem;
+    font-weight: 700;
+}
+
+.upload-form {
+    display: flex;
+    flex-direction: column;
+    gap: 1.25rem;
+}
+
+.form-group {
+    display: flex;
+    flex-direction: column;
+    gap: 0.5rem;
+}
+
+.form-group label {
+    font-weight: 600;
+    color: #2d3748;
+}
+
+.form-group input[type="text"],
+.form-group input[type="file"] {
+    padding: 0.625rem 0.75rem;
+    border: 1px solid #e2e8f0;
+    border-radius: 8px;
+    font-size: 1rem;
+    transition: border-color 0.2s;
+}
+
+.form-group input[type="text"]:focus,
+.form-group input[type="file"]:focus {
+    outline: none;
+    border-color: #3182ce;
+    box-shadow: 0 0 0 3px rgba(49, 130, 206, 0.1);
+}
+
+/* ==================== 按钮 ==================== */
+.btn {
+    display: inline-block;
+    padding: 0.625rem 1.25rem;
+    border: none;
+    border-radius: 8px;
+    font-size: 1rem;
+    font-weight: 600;
+    cursor: pointer;
+    transition: background-color 0.2s, transform 0.1s;
+}
+
+.btn:active {
+    transform: scale(0.98);
+}
+
+.btn-primary {
+    background-color: #3182ce;
+    color: #ffffff;
+}
+
+.btn-primary:hover {
+    background-color: #2b6cb0;
+}
+
+.btn-danger {
+    background-color: #e53e3e;
+    color: #ffffff;
+}
+
+.btn-danger:hover {
+    background-color: #c53030;
+}
+
+/* ==================== 管理页面表格 ==================== */
+.admin-container {
+    max-width: 900px;
+    margin: 0 auto;
+}
+
+.admin-container h1 {
+    margin-bottom: 1.5rem;
+    font-size: 1.75rem;
+    font-weight: 700;
+}
+
+.admin-table {
+    width: 100%;
+    border-collapse: collapse;
+    background: #ffffff;
+    border-radius: 12px;
+    overflow: hidden;
+    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
+}
+
+.admin-table th,
+.admin-table td {
+    padding: 0.75rem 1rem;
+    text-align: left;
+    border-bottom: 1px solid #e2e8f0;
+}
+
+.admin-table th {
+    background-color: #f7fafc;
+    font-weight: 600;
+    color: #4a5568;
+    font-size: 0.875rem;
+    text-transform: uppercase;
+    letter-spacing: 0.05em;
+}
+
+.admin-table td a {
+    color: #3182ce;
+    text-decoration: none;
+}
+
+.admin-table td a:hover {
+    text-decoration: underline;
+}
+
+/* ==================== 页脚 ==================== */
+.footer {
+    text-align: center;
+    padding: 1.5rem;
+    color: #a0aec0;
+    font-size: 0.875rem;
+    border-top: 1px solid #e2e8f0;
+    margin-top: auto;
+}
+
+/* ==================== 响应式 ==================== */
+@media (max-width: 768px) {
+    .waterfall {
+        grid-template-columns: 1fr;
+    }
+
+    .navbar {
+        padding: 0.5rem 1rem;
+    }
+
+    .nav-links {
+        gap: 1rem;
+    }
+
+    .main-content {
+        padding: 0 1rem;
+    }
+}
+
+/* ==================== 代码高亮(Pygments 默认主题) ==================== */
+.codehilite {
+    background: #f7fafc;
+    border-radius: 8px;
+    padding: 1rem;
+    overflow-x: auto;
+    margin: 1rem 0;
+}
+
+.codehilite pre {
+    margin: 0;
+    font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
+    font-size: 0.875rem;
+    line-height: 1.5;
+}

+ 47 - 0
static/js/main.js

@@ -0,0 +1,47 @@
+// main.js - 前端增强脚本
+
+document.addEventListener("DOMContentLoaded", function () {
+    // 卡片悬停效果(桌面端)
+    const cards = document.querySelectorAll(".card");
+    cards.forEach((card) => {
+        card.addEventListener("mouseenter", function () {
+            this.style.transform = "translateY(-4px)";
+        });
+        card.addEventListener("mouseleave", function () {
+            this.style.transform = "translateY(0)";
+        });
+    });
+
+    // 图片懒加载(浏览器原生支持,这里作为后备)
+    if ("loading" in HTMLImageElement.prototype) {
+        const images = document.querySelectorAll('img[loading="lazy"]');
+        images.forEach((img) => {
+            img.src = img.src; // 触发加载
+        });
+    } else {
+        // 降级方案:使用 IntersectionObserver
+        const lazyImages = document.querySelectorAll('img[loading="lazy"]');
+        if ("IntersectionObserver" in window) {
+            const observer = new IntersectionObserver((entries) => {
+                entries.forEach((entry) => {
+                    if (entry.isIntersecting) {
+                        const img = entry.target;
+                        img.src = img.dataset.src || img.src;
+                        observer.unobserve(img);
+                    }
+                });
+            });
+            lazyImages.forEach((img) => observer.observe(img));
+        }
+    }
+
+    // 删除确认弹窗(已在模板中使用 confirm,这里作为补充)
+    const deleteForms = document.querySelectorAll('form[action*="delete"]');
+    deleteForms.forEach((form) => {
+        form.addEventListener("submit", function (e) {
+            if (!confirm("确定要删除吗?此操作不可恢复。")) {
+                e.preventDefault();
+            }
+        });
+    });
+});

+ 11 - 0
templates/404.html

@@ -0,0 +1,11 @@
+{% extends "base.html" %}
+
+{% block title %}404 - 页面未找到{% endblock %}
+
+{% block content %}
+<div class="error-page">
+    <h1>404</h1>
+    <p>抱歉,您访问的页面不存在。</p>
+    <a href="{{ url_for('index') }}" class="btn btn-primary">返回首页</a>
+</div>
+{% endblock %}

+ 39 - 0
templates/admin.html

@@ -0,0 +1,39 @@
+{% extends "base.html" %}
+
+{% block title %}管理文章 - 个人博客{% endblock %}
+
+{% block content %}
+<div class="admin-container">
+    <h1>文章管理</h1>
+    {% if posts %}
+    <table class="admin-table">
+        <thead>
+            <tr>
+                <th>标题</th>
+                <th>日期</th>
+                <th>操作</th>
+            </tr>
+        </thead>
+        <tbody>
+            {% for post in posts %}
+            <tr>
+                <td>
+                    <a href="{{ url_for('view_post', post_id=post.id) }}">{{ post.title }}</a>
+                </td>
+                <td>{{ post.date[:10] }}</td>
+                <td>
+                    <form method="POST" action="{{ url_for('delete_post', post_id=post.id) }}" onsubmit="return confirm('确定要删除「{{ post.title }}」吗?此操作不可恢复。');">
+                        <button type="submit" class="btn btn-danger">删除</button>
+                    </form>
+                </td>
+            </tr>
+            {% endfor %}
+        </tbody>
+    </table>
+    {% else %}
+    <div class="empty-state">
+        <p>还没有文章。</p>
+    </div>
+    {% endif %}
+</div>
+{% endblock %}

+ 45 - 0
templates/base.html

@@ -0,0 +1,45 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>{% block title %}个人博客{% endblock %}</title>
+    <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}" />
+    {% block extra_head %}{% endblock %}
+    <!-- MathJax 配置 -->
+    <script>
+        MathJax = {
+            tex: {
+                inlineMath: [['$', '$'], ['\\(', '\\)']]
+            },
+            svg: {
+                fontCache: 'global'
+            }
+        };
+    </script>
+    <script src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js" id="MathJax-script" async></script>
+</head>
+<body>
+    <nav class="navbar">
+        <div class="nav-container">
+            <a href="{{ url_for('index') }}" class="nav-brand">博客</a>
+            <ul class="nav-links">
+                <li><a href="{{ url_for('index') }}">首页</a></li>
+                <li><a href="{{ url_for('upload') }}">上传</a></li>
+                <li><a href="{{ url_for('admin') }}">管理</a></li>
+            </ul>
+        </div>
+    </nav>
+
+    <main class="main-content">
+        {% block content %}{% endblock %}
+    </main>
+
+    <footer class="footer">
+        <p>&copy; {{ now.year if now else 2026 }} 个人博客系统</p>
+    </footer>
+
+    <script src="{{ url_for('static', filename='js/main.js') }}"></script>
+    {% block extra_scripts %}{% endblock %}
+</body>
+</html>

+ 31 - 0
templates/index.html

@@ -0,0 +1,31 @@
+{% extends "base.html" %}
+
+{% block title %}首页 - 个人博客{% endblock %}
+
+{% block content %}
+<div class="waterfall">
+    {% if posts %}
+        {% for post in posts %}
+        <article class="card">
+            {% if post.thumbnail %}
+            <div class="card-image">
+                <img src="{{ post.thumbnail }}" alt="{{ post.title }}" loading="lazy" />
+            </div>
+            {% endif %}
+            <div class="card-body">
+                <h2 class="card-title">
+                    <a href="{{ url_for('view_post', post_id=post.id) }}">{{ post.title }}</a>
+                </h2>
+                <time class="card-date" datetime="{{ post.date }}">{{ post.date[:10] }}</time>
+                <p class="card-summary">{{ post.summary }}</p>
+                <a href="{{ url_for('view_post', post_id=post.id) }}" class="card-link">阅读全文 →</a>
+            </div>
+        </article>
+        {% endfor %}
+    {% else %}
+        <div class="empty-state">
+            <p>还没有文章,快去 <a href="{{ url_for('upload') }}">上传</a> 第一篇吧!</p>
+        </div>
+    {% endif %}
+</div>
+{% endblock %}

+ 12 - 0
templates/post_template.html

@@ -0,0 +1,12 @@
+{% extends "base.html" %}
+
+{% block title %}{{ title }} - 个人博客{% endblock %}
+
+{% block content %}
+<article class="post-article">
+    <h1 class="post-title">{{ title }}</h1>
+    <div class="post-content">
+        {{ content | safe }}
+    </div>
+</article>
+{% endblock %}

+ 27 - 0
templates/upload.html

@@ -0,0 +1,27 @@
+{% extends "base.html" %}
+
+{% block title %}上传文章 - 个人博客{% endblock %}
+
+{% block content %}
+<div class="upload-container">
+    <h1>上传新文章</h1>
+    <form method="POST" enctype="multipart/form-data" class="upload-form">
+        <div class="form-group">
+            <label for="title">文章标题</label>
+            <input type="text" id="title" name="title" required placeholder="请输入文章标题" />
+        </div>
+
+        <div class="form-group">
+            <label for="markdown_file">Markdown 文件</label>
+            <input type="file" id="markdown_file" name="markdown_file" accept=".md" required />
+        </div>
+
+        <div class="form-group">
+            <label for="images">图片(可选,可多选)</label>
+            <input type="file" id="images" name="images" accept="image/*" multiple />
+        </div>
+
+        <button type="submit" class="btn btn-primary">上传</button>
+    </form>
+</div>
+{% endblock %}