用FastAPI 一个 后端 和 两个前端 原生HTML/CSS/JS 、Vue3 写一个博客系统 例

后端:

js 复制代码
├── backend/
│   ├── main.py          # FastAPI 后端
│   ├── models.py        # 数据模型
│   ├── database.py      # 数据库/状态管理
│   └── requirements.txt

main.py

python 复制代码
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from typing import List
import uvicorn

from models import BlogPost
from database import BlogDatabase

# 创建 FastAPI 应用
app = FastAPI(
    title="博客系统 API",
    description="一个简单的博客系统后端",
    version="1.0.0"
)

# 添加 CORS 中间件
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],  # 在生产环境中应该指定具体域名
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# 初始化数据库
db = BlogDatabase()

# API 路由
@app.get("/")
async def root():
    return {
        "message": "欢迎访问博客系统 API",
        "docs": "/docs",
        "endpoints": {
            "获取所有文章": "GET /api/posts",
            "获取单篇文章": "GET /api/posts/{id}",
            "创建文章": "POST /api/posts",
            "更新文章": "PUT /api/posts/{id}",
            "删除文章": "DELETE /api/posts/{id}"
        }
    }

@app.get("/api/posts", response_model=List[BlogPost])
async def get_all_posts():
    """获取所有博客文章"""
    return db.get_all_posts()

@app.get("/api/posts/{post_id}", response_model=BlogPost)
async def get_post(post_id: int):
    """根据ID获取博客文章"""
    post = db.get_post(post_id)
    if not post:
        raise HTTPException(status_code=404, detail="文章不存在")
    return post

@app.post("/api/posts", response_model=BlogPost)
async def create_post(post: BlogPost):
    """创建新博客文章"""
    return db.create_post(post.dict())

@app.put("/api/posts/{post_id}", response_model=BlogPost)
async def update_post(post_id: int, post: BlogPost):
    """更新博客文章"""
    updated = db.update_post(post_id, post.dict())
    if not updated:
        raise HTTPException(status_code=404, detail="文章不存在")
    return updated

@app.delete("/api/posts/{post_id}")
async def delete_post(post_id: int):
    """删除博客文章"""
    if db.delete_post(post_id):
        return {"message": "文章删除成功"}
    raise HTTPException(status_code=404, detail="文章不存在")

# 健康检查端点
@app.get("/health")
async def health_check():
    return {"status": "healthy", "posts_count": len(db.get_all_posts())}

if __name__ == "__main__":
    uvicorn.run(
        "main:app",
        host="0.0.0.0",
        port=8000,
        reload=True
    )

models.py

python 复制代码
from datetime import datetime
from typing import Optional
from pydantic import BaseModel

class BlogPost(BaseModel):
    id: Optional[int] = None
    title: str
    content: str
    author: str
    created_at: Optional[str] = None
    updated_at: Optional[str] = None

database.py

python 复制代码
from datetime import datetime
from typing import List, Dict, Optional
from models import BlogPost

class BlogDatabase:
    def __init__(self):
        self.posts = []
        self.post_id_counter = 1
        self.init_sample_data()
    
    def init_sample_data(self):
        """初始化示例数据"""
        if not self.posts:
            sample_posts = [
                BlogPost(
                    id=1,
                    title="欢迎来到我的博客",
                    content="这是我的第一篇博客文章,欢迎阅读!",
                    author="管理员",
                    created_at="2024-01-01 10:00:00",
                    updated_at="2024-01-01 10:00:00"
                ),
                BlogPost(
                    id=2,
                    title="NiceGUI 3.X 新特性介绍",
                    content="NiceGUI 3.X 带来了很多令人兴奋的新特性...",
                    author="技术达人",
                    created_at="2024-01-02 14:30:00",
                    updated_at="2024-01-02 14:30:00"
                ),
                BlogPost(
                    id=3,
                    title="Python Web开发趋势",
                    content="近年来,Python在Web开发领域发展迅速...",
                    author="Python爱好者",
                    created_at="2024-01-03 09:15:00",
                    updated_at="2024-01-03 09:15:00"
                )
            ]
            
            for post in sample_posts:
                self.posts.append(post.dict())
            self.post_id_counter = 4
    
    def get_all_posts(self) -> List[Dict]:
        """获取所有文章"""
        return self.posts
    
    def get_post(self, post_id: int) -> Optional[Dict]:
        """根据ID获取文章"""
        for post in self.posts:
            if post["id"] == post_id:
                return post
        return None
    
    def create_post(self, post_data: Dict) -> Dict:
        """创建新文章"""
        post = BlogPost(**post_data)
        post.id = self.post_id_counter
        post.created_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        post.updated_at = post.created_at
        
        post_dict = post.dict()
        self.posts.append(post_dict)
        self.post_id_counter += 1
        return post_dict
    
    def update_post(self, post_id: int, post_data: Dict) -> Optional[Dict]:
        """更新文章"""
        for i, existing_post in enumerate(self.posts):
            if existing_post["id"] == post_id:
                post_data["id"] = post_id
                post_data["created_at"] = existing_post["created_at"]
                post_data["updated_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
                
                self.posts[i] = post_data
                return post_data
        return None
    
    def delete_post(self, post_id: int) -> bool:
        """删除文章"""
        for i, post in enumerate(self.posts):
            if post["id"] == post_id:
                self.posts.pop(i)
                return True
        return False

requirements.txt

python 复制代码
fastapi==0.104.1
uvicorn==0.24.0
pydantic==2.5.0
python-multipart==0.0.6

启动后端服务器 python main.py

Html/css/js 前端

js 复制代码
blog_system/

├── frontend/
│   ├── index.html       # 主页面
│   ├── css/
│   │   └── style.css    # 样式文件
│   └── js/
│       ├── app.js       # 主应用逻辑
│       ├── api.js       # API 调用封装
│       └── ui.js        # UI 组件

frontend/index.html

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>博客系统</title>
    <link rel="stylesheet" href="css/style.css">
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
    <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;700&display=swap" rel="stylesheet">
    <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.5/dist/purify.min.js"></script>
</head>
<body>
    <!-- 导航栏 -->
    <nav class="navbar">
        <div class="nav-container">
            <div class="nav-brand">
                <i class="fas fa-blog"></i>
                <span>博客系统</span>
            </div>
            <div class="nav-links">
                <a href="#" onclick="showPage('home')">
                    <i class="fas fa-home"></i> 首页
                </a>
                <a href="#" onclick="showPage('manage')">
                    <i class="fas fa-tasks"></i> 管理
                </a>
                <button class="btn btn-success" onclick="showPage('create')">
                    <i class="fas fa-plus"></i> 写文章
                </button>
                <button class="btn-toggle-theme" onclick="toggleTheme()">
                    <i class="fas fa-moon"></i>
                </button>
            </div>
        </div>
    </nav>

    <!-- 主内容区域 -->
    <main class="container" id="main-content">
        <!-- 首页内容将通过JavaScript动态加载 -->
    </main>

    <!-- 模态框容器 -->
    <div id="modal-container"></div>

    <!-- 加载指示器 -->
    <div class="loading-overlay" id="loading-overlay">
        <div class="spinner"></div>
    </div>

    <!-- JavaScript文件 -->
    <script src="js/api.js"></script>
    <script src="js/ui.js"></script>
    <script src="js/app.js"></script>

    <script>
        // 初始化应用
        document.addEventListener('DOMContentLoaded', function() {
            initApp();
        });
    </script>
</body>
</html>

frontend/css/style.css

css 复制代码
/* 基础样式 */
:root {
    --primary-color: #3b82f6;
    --primary-dark: #2563eb;
    --secondary-color: #6b7280;
    --success-color: #10b981;
    --danger-color: #ef4444;
    --warning-color: #f59e0b;
    --light-color: #f9fafb;
    --dark-color: #1f2937;
    --border-color: #e5e7eb;
    --shadow-color: rgba(0, 0, 0, 0.1);
    --card-bg: #ffffff;
    --body-bg: #f3f4f6;
    --text-color: #374151;
}

[data-theme="dark"] {
    --primary-color: #60a5fa;
    --primary-dark: #3b82f6;
    --secondary-color: #9ca3af;
    --success-color: #34d399;
    --danger-color: #f87171;
    --warning-color: #fbbf24;
    --light-color: #374151;
    --dark-color: #111827;
    --border-color: #4b5563;
    --shadow-color: rgba(0, 0, 0, 0.3);
    --card-bg: #1f2937;
    --body-bg: #111827;
    --text-color: #f3f4f6;
}

* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    font-family: 'Noto Sans SC', sans-serif;
    background-color: var(--body-bg);
    color: var(--text-color);
    line-height: 1.6;
    transition: background-color 0.3s, color 0.3s;
}

.container {
    max-width: 1200px;
    margin: 0 auto;
    padding: 20px;
}

/* 导航栏 */
.navbar {
    background-color: var(--primary-color);
    color: white;
    padding: 1rem 0;
    box-shadow: 0 2px 10px var(--shadow-color);
    position: sticky;
    top: 0;
    z-index: 1000;
}

.nav-container {
    max-width: 1200px;
    margin: 0 auto;
    padding: 0 20px;
    display: flex;
    justify-content: space-between;
    align-items: center;
}

.nav-brand {
    display: flex;
    align-items: center;
    gap: 10px;
    font-size: 1.5rem;
    font-weight: bold;
}

.nav-brand i {
    font-size: 1.8rem;
}

.nav-links {
    display: flex;
    align-items: center;
    gap: 15px;
}

.nav-links a {
    color: white;
    text-decoration: none;
    padding: 8px 16px;
    border-radius: 6px;
    transition: background-color 0.3s;
}

.nav-links a:hover {
    background-color: rgba(255, 255, 255, 0.1);
}

/* 按钮样式 */
.btn {
    padding: 8px 16px;
    border: none;
    border-radius: 6px;
    cursor: pointer;
    font-size: 0.9rem;
    font-weight: 500;
    transition: all 0.3s;
    display: inline-flex;
    align-items: center;
    gap: 8px;
    text-decoration: none;
}

.btn:hover {
    transform: translateY(-2px);
    box-shadow: 0 4px 12px var(--shadow-color);
}

.btn-primary {
    background-color: var(--primary-color);
    color: white;
}

.btn-primary:hover {
    background-color: var(--primary-dark);
}

.btn-success {
    background-color: var(--success-color);
    color: white;
}

.btn-danger {
    background-color: var(--danger-color);
    color: white;
}

.btn-secondary {
    background-color: var(--secondary-color);
    color: white;
}

.btn-outline {
    background-color: transparent;
    border: 2px solid var(--primary-color);
    color: var(--primary-color);
}

.btn-outline:hover {
    background-color: var(--primary-color);
    color: white;
}

.btn-toggle-theme {
    background: none;
    border: none;
    color: white;
    font-size: 1.2rem;
    cursor: pointer;
    padding: 8px;
    border-radius: 50%;
    transition: background-color 0.3s;
}

.btn-toggle-theme:hover {
    background-color: rgba(255, 255, 255, 0.1);
}

/* 卡片样式 */
.card {
    background-color: var(--card-bg);
    border-radius: 12px;
    padding: 24px;
    margin-bottom: 20px;
    box-shadow: 0 4px 20px var(--shadow-color);
    transition: transform 0.3s, box-shadow 0.3s;
    border: 1px solid var(--border-color);
}

.card:hover {
    transform: translateY(-4px);
    box-shadow: 0 8px 30px var(--shadow-color);
}

/* 表单样式 */
.form-group {
    margin-bottom: 20px;
}

.form-label {
    display: block;
    margin-bottom: 8px;
    font-weight: 500;
    color: var(--text-color);
}

.form-control {
    width: 100%;
    padding: 12px;
    border: 2px solid var(--border-color);
    border-radius: 8px;
    font-size: 1rem;
    transition: border-color 0.3s;
    background-color: var(--card-bg);
    color: var(--text-color);
}

.form-control:focus {
    outline: none;
    border-color: var(--primary-color);
}

textarea.form-control {
    min-height: 200px;
    resize: vertical;
    font-family: 'Noto Sans SC', sans-serif;
}

/* 徽章 */
.badge {
    display: inline-block;
    padding: 4px 12px;
    border-radius: 20px;
    font-size: 0.85rem;
    font-weight: 500;
}

.badge-primary {
    background-color: var(--primary-color);
    color: white;
}

.badge-success {
    background-color: var(--success-color);
    color: white;
}

/* 加载动画 */
.loading-overlay {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    background-color: rgba(0, 0, 0, 0.5);
    display: none;
    justify-content: center;
    align-items: center;
    z-index: 2000;
}

.spinner {
    width: 50px;
    height: 50px;
    border: 5px solid var(--border-color);
    border-top-color: var(--primary-color);
    border-radius: 50%;
    animation: spin 1s linear infinite;
}

@keyframes spin {
    to { transform: rotate(360deg); }
}

/* 模态框 */
.modal {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    background-color: rgba(0, 0, 0, 0.5);
    display: none;
    justify-content: center;
    align-items: center;
    z-index: 1001;
    padding: 20px;
}

.modal-content {
    background-color: var(--card-bg);
    border-radius: 12px;
    max-width: 500px;
    width: 100%;
    max-height: 90vh;
    overflow-y: auto;
}

.modal-header {
    padding: 20px;
    border-bottom: 1px solid var(--border-color);
    display: flex;
    justify-content: space-between;
    align-items: center;
}

.modal-title {
    font-size: 1.5rem;
    font-weight: bold;
}

.modal-close {
    background: none;
    border: none;
    font-size: 1.5rem;
    cursor: pointer;
    color: var(--secondary-color);
}

.modal-body {
    padding: 20px;
}

.modal-footer {
    padding: 20px;
    border-top: 1px solid var(--border-color);
    display: flex;
    justify-content: flex-end;
    gap: 10px;
}

/* 文章列表 */
.post-list {
    display: grid;
    gap: 20px;
}

.post-card {
    background-color: var(--card-bg);
    border-radius: 12px;
    padding: 24px;
    border: 1px solid var(--border-color);
    transition: all 0.3s;
}

.post-card:hover {
    transform: translateY(-4px);
    box-shadow: 0 8px 30px var(--shadow-color);
}

.post-title {
    font-size: 1.5rem;
    font-weight: bold;
    margin-bottom: 10px;
    color: var(--text-color);
}

.post-meta {
    display: flex;
    gap: 15px;
    margin-bottom: 15px;
    color: var(--secondary-color);
    font-size: 0.9rem;
}

.post-content-preview {
    color: var(--text-color);
    line-height: 1.6;
    margin-bottom: 15px;
    display: -webkit-box;
    -webkit-line-clamp: 3;
    -webkit-box-orient: vertical;
    overflow: hidden;
}

.post-actions {
    display: flex;
    gap: 10px;
    margin-top: 15px;
}

/* 响应式设计 */
@media (max-width: 768px) {
    .nav-container {
        flex-direction: column;
        gap: 15px;
    }
    
    .nav-links {
        width: 100%;
        justify-content: center;
        flex-wrap: wrap;
    }
    
    .container {
        padding: 10px;
    }
    
    .post-meta {
        flex-direction: column;
        gap: 5px;
    }
}

frontend/js/api.js

js 复制代码
// API基础配置
const API_BASE_URL = 'http://localhost:8000';
const API_ENDPOINTS = {
    POSTS: '/api/posts',
    POST: (id) => `/api/posts/${id}`
};

// 显示/隐藏加载指示器
function showLoading() {
    document.getElementById('loading-overlay').style.display = 'flex';
}

function hideLoading() {
    document.getElementById('loading-overlay').style.display = 'none';
}

// 统一的错误处理
function handleApiError(error) {
    console.error('API Error:', error);
    let message = '网络错误,请稍后重试';
    
    if (error.response) {
        message = error.response.data?.detail || `服务器错误: ${error.response.status}`;
    } else if (error.request) {
        message = '无法连接到服务器,请检查网络连接';
    }
    
    alert(message);
    throw error;
}

// 博客API
const BlogAPI = {
    // 获取所有文章
    async getAllPosts() {
        try {
            showLoading();
            const response = await fetch(`${API_BASE_URL}${API_ENDPOINTS.POSTS}`);
            if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
            const data = await response.json();
            return data;
        } catch (error) {
            handleApiError(error);
        } finally {
            hideLoading();
        }
    },

    // 获取单篇文章
    async getPost(id) {
        try {
            showLoading();
            const response = await fetch(`${API_BASE_URL}${API_ENDPOINTS.POST(id)}`);
            if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
            const data = await response.json();
            return data;
        } catch (error) {
            handleApiError(error);
        } finally {
            hideLoading();
        }
    },

    // 创建文章
    async createPost(postData) {
        try {
            showLoading();
            const response = await fetch(`${API_BASE_URL}${API_ENDPOINTS.POSTS}`, {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify(postData)
            });
            
            if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
            const data = await response.json();
            return data;
        } catch (error) {
            handleApiError(error);
        } finally {
            hideLoading();
        }
    },

    // 更新文章
    async updatePost(id, postData) {
        try {
            showLoading();
            const response = await fetch(`${API_BASE_URL}${API_ENDPOINTS.POST(id)}`, {
                method: 'PUT',
                headers: {
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify(postData)
            });
            
            if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
            const data = await response.json();
            return data;
        } catch (error) {
            handleApiError(error);
        } finally {
            hideLoading();
        }
    },

    // 删除文章
    async deletePost(id) {
        try {
            showLoading();
            const response = await fetch(`${API_BASE_URL}${API_ENDPOINTS.POST(id)}`, {
                method: 'DELETE'
            });
            
            if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
            const data = await response.json();
            return data;
        } catch (error) {
            handleApiError(error);
        } finally {
            hideLoading();
        }
    }
};

frontend/js/ui.js

js 复制代码
// UI组件库

// 显示通知
function showNotification(message, type = 'info') {
    const colors = {
        success: '#10b981',
        error: '#ef4444',
        warning: '#f59e0b',
        info: '#3b82f6'
    };
    
    const notification = document.createElement('div');
    notification.style.cssText = `
        position: fixed;
        top: 20px;
        right: 20px;
        padding: 15px 20px;
        background-color: ${colors[type]};
        color: white;
        border-radius: 8px;
        box-shadow: 0 4px 20px rgba(0,0,0,0.2);
        z-index: 1002;
        animation: slideIn 0.3s ease-out;
        max-width: 400px;
    `;
    
    notification.innerHTML = `
        <div style="display: flex; align-items: center; gap: 10px;">
            <i class="fas ${type === 'success' ? 'fa-check-circle' : 
                          type === 'error' ? 'fa-exclamation-circle' : 
                          type === 'warning' ? 'fa-exclamation-triangle' : 
                          'fa-info-circle'}"></i>
            <span>${message}</span>
        </div>
    `;
    
    document.body.appendChild(notification);
    
    // 自动消失
    setTimeout(() => {
        notification.style.animation = 'slideOut 0.3s ease-out forwards';
        setTimeout(() => notification.remove(), 300);
    }, 3000);
}

// 创建模态框
function createModal(options) {
    const modal = document.createElement('div');
    modal.className = 'modal';
    modal.style.display = 'flex';
    
    modal.innerHTML = `
        <div class="modal-content">
            <div class="modal-header">
                <h3 class="modal-title">${options.title || ''}</h3>
                <button class="modal-close" onclick="this.closest('.modal').remove()">
                    <i class="fas fa-times"></i>
                </button>
            </div>
            <div class="modal-body">
                ${options.body || ''}
            </div>
            ${options.footer ? `
            <div class="modal-footer">
                ${options.footer}
            </div>
            ` : ''}
        </div>
    `;
    
    document.getElementById('modal-container').appendChild(modal);
    
    // 点击背景关闭
    modal.addEventListener('click', (e) => {
        if (e.target === modal) {
            modal.remove();
        }
    });
    
    return modal;
}

// 确认对话框
function showConfirmDialog(message, onConfirm, onCancel = null) {
    const modal = createModal({
        title: '确认操作',
        body: message,
        footer: `
            <button class="btn btn-secondary" onclick="this.closest('.modal').remove(); ${onCancel ? 'onCancel()' : ''}">
                取消
            </button>
            <button class="btn btn-danger" onclick="this.closest('.modal').remove(); onConfirm()">
                确认
            </button>
        `
    });
    
    return modal;
}

// 渲染文章卡片
function renderPostCard(post) {
    const contentPreview = post.content.length > 150 
        ? post.content.substring(0, 150) + '...' 
        : post.content;
    
    return `
        <div class="post-card">
            <div class="post-title">${post.title}</div>
            <div class="post-meta">
                <span><i class="fas fa-user"></i> ${post.author}</span>
                <span><i class="fas fa-calendar"></i> ${post.created_at}</span>
            </div>
            <div class="post-content-preview">${contentPreview}</div>
            <div class="post-actions">
                <button class="btn btn-primary" onclick="viewPost(${post.id})">
                    <i class="fas fa-eye"></i> 查看
                </button>
                <button class="btn btn-secondary" onclick="editPost(${post.id})">
                    <i class="fas fa-edit"></i> 编辑
                </button>
                <button class="btn btn-danger" onclick="deletePost(${post.id})">
                    <i class="fas fa-trash"></i> 删除
                </button>
            </div>
        </div>
    `;
}

// 渲染文章详情
function renderPostDetail(post) {
    const cleanContent = DOMPurify.sanitize(marked.parse(post.content));
    
    return `
        <div class="card">
            <div style="margin-bottom: 20px;">
                <button class="btn btn-secondary" onclick="showPage('home')">
                    <i class="fas fa-arrow-left"></i> 返回
                </button>
            </div>
            
            <h1 class="post-title" style="font-size: 2rem; margin-bottom: 20px;">
                ${post.title}
            </h1>
            
            <div class="post-meta" style="margin-bottom: 30px;">
                <span class="badge badge-primary">
                    <i class="fas fa-user"></i> ${post.author}
                </span>
                <span><i class="fas fa-calendar"></i> 创建: ${post.created_at}</span>
                ${post.updated_at !== post.created_at ? 
                    `<span><i class="fas fa-history"></i> 更新: ${post.updated_at}</span>` : ''}
            </div>
            
            <div class="post-content" style="font-size: 1.1rem; line-height: 1.8;">
                ${cleanContent}
            </div>
            
            <div class="post-actions" style="margin-top: 30px;">
                <button class="btn btn-secondary" onclick="editPost(${post.id})">
                    <i class="fas fa-edit"></i> 编辑文章
                </button>
                <button class="btn btn-danger" onclick="deletePost(${post.id})">
                    <i class="fas fa-trash"></i> 删除文章
                </button>
            </div>
        </div>
    `;
}

// 渲染文章表单
function renderPostForm(post = null) {
    const isEdit = !!post;
    
    return `
        <div class="card">
            <div style="margin-bottom: 20px;">
                <button class="btn btn-secondary" onclick="showPage('${isEdit ? `view/${post.id}` : 'home'}')">
                    <i class="fas fa-arrow-left"></i> 返回
                </button>
            </div>
            
            <h2 style="margin-bottom: 30px; color: var(--text-color);">
                ${isEdit ? '编辑文章' : '写新文章'}
            </h2>
            
            <form id="postForm" onsubmit="handleSubmitPost(event, ${isEdit ? post.id : 'null'})">
                <div class="form-group">
                    <label class="form-label">标题</label>
                    <input type="text" class="form-control" id="postTitle" 
                           value="${post ? post.title : ''}" required>
                </div>
                
                <div class="form-group">
                    <label class="form-label">作者</label>
                    <input type="text" class="form-control" id="postAuthor" 
                           value="${post ? post.author : '管理员'}" required>
                </div>
                
                <div class="form-group">
                    <label class="form-label">内容</label>
                    <textarea class="form-control" id="postContent" 
                              rows="10" required>${post ? post.content : ''}</textarea>
                </div>
                
                <div style="display: flex; gap: 10px; margin-top: 30px;">
                    <button type="button" class="btn btn-secondary" 
                            onclick="showPage('${isEdit ? `view/${post.id}` : 'home'}')">
                        取消
                    </button>
                    <button type="submit" class="btn btn-success">
                        <i class="fas fa-paper-plane"></i> 
                        ${isEdit ? '更新文章' : '发布文章'}
                    </button>
                </div>
            </form>
        </div>
    `;
}

// 渲染文章管理表格
function renderPostTable(posts) {
    if (posts.length === 0) {
        return `
            <div class="card" style="text-align: center; padding: 40px;">
                <i class="fas fa-inbox" style="font-size: 3rem; color: var(--secondary-color); margin-bottom: 20px;"></i>
                <h3 style="color: var(--text-color); margin-bottom: 10px;">暂无文章</h3>
                <p style="color: var(--secondary-color);">点击"写文章"按钮开始创作</p>
            </div>
        `;
    }
    
    const rows = posts.map(post => `
        <tr>
            <td>${post.id}</td>
            <td>${post.title}</td>
            <td>${post.author}</td>
            <td>${post.created_at}</td>
            <td>
                <button class="btn btn-primary btn-sm" onclick="viewPost(${post.id})">
                    <i class="fas fa-eye"></i>
                </button>
                <button class="btn btn-secondary btn-sm" onclick="editPost(${post.id})">
                    <i class="fas fa-edit"></i>
                </button>
                <button class="btn btn-danger btn-sm" onclick="deletePost(${post.id})">
                    <i class="fas fa-trash"></i>
                </button>
            </td>
        </tr>
    `).join('');
    
    return `
        <div class="card">
            <h2 style="margin-bottom: 20px; color: var(--text-color);">文章管理</h2>
            <div style="overflow-x: auto;">
                <table style="width: 100%; border-collapse: collapse;">
                    <thead>
                        <tr style="background-color: var(--light-color);">
                            <th style="padding: 12px; text-align: left;">ID</th>
                            <th style="padding: 12px; text-align: left;">标题</th>
                            <th style="padding: 12px; text-align: left;">作者</th>
                            <th style="padding: 12px; text-align: left;">创建时间</th>
                            <th style="padding: 12px; text-align: left;">操作</th>
                        </tr>
                    </thead>
                    <tbody>
                        ${rows}
                    </tbody>
                </table>
            </div>
        </div>
    `;
}

frontend/js/app.js

js 复制代码
// 应用主逻辑
let currentPage = 'home';

// 初始化应用
async function initApp() {
    // 设置主题
    const savedTheme = localStorage.getItem('theme') || 'light';
    document.documentElement.setAttribute('data-theme', savedTheme);
    
    // 加载首页
    await showPage('home');
}

// 切换主题
function toggleTheme() {
    const currentTheme = document.documentElement.getAttribute('data-theme');
    const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
    
    document.documentElement.setAttribute('data-theme', newTheme);
    localStorage.setItem('theme', newTheme);
    
    // 更新按钮图标
    const themeButton = document.querySelector('.btn-toggle-theme i');
    themeButton.className = newTheme === 'dark' ? 'fas fa-sun' : 'fas fa-moon';
}

// 显示页面
async function showPage(page) {
    currentPage = page;
    const mainContent = document.getElementById('main-content');
    
    try {
        if (page === 'home') {
            await loadHomePage();
        } else if (page === 'manage') {
            await loadManagePage();
        } else if (page === 'create') {
            loadCreatePage();
        } else if (page.startsWith('view/')) {
            const postId = parseInt(page.split('/')[1]);
            await loadPostPage(postId);
        } else if (page.startsWith('edit/')) {
            const postId = parseInt(page.split('/')[1]);
            await loadEditPage(postId);
        }
    } catch (error) {
        console.error('Error loading page:', error);
        mainContent.innerHTML = `
            <div class="card" style="text-align: center; padding: 40px;">
                <i class="fas fa-exclamation-triangle" style="font-size: 3rem; color: var(--danger-color); margin-bottom: 20px;"></i>
                <h3 style="color: var(--text-color); margin-bottom: 10px;">加载失败</h3>
                <p style="color: var(--secondary-color);">${error.message}</p>
                <button class="btn btn-primary" onclick="showPage('home')" style="margin-top: 20px;">
                    返回首页
                </button>
            </div>
        `;
    }
}

// 加载首页
async function loadHomePage() {
    const mainContent = document.getElementById('main-content');
    
    mainContent.innerHTML = `
        <div style="margin-bottom: 30px;">
            <h1 style="color: var(--text-color); margin-bottom: 10px;">📚 最新文章</h1>
            <p style="color: var(--secondary-color);">欢迎阅读我们的博客文章</p>
        </div>
        <div id="posts-container" style="min-height: 200px;">
            <div style="text-align: center; padding: 40px;">
                <div class="spinner" style="margin: 0 auto;"></div>
                <p style="margin-top: 20px; color: var(--secondary-color);">加载中...</p>
            </div>
        </div>
    `;
    
    try {
        const posts = await BlogAPI.getAllPosts();
        
        if (posts.length === 0) {
            document.getElementById('posts-container').innerHTML = `
                <div class="card" style="text-align: center; padding: 40px;">
                    <i class="fas fa-feather-alt" style="font-size: 3rem; color: var(--secondary-color); margin-bottom: 20px;"></i>
                    <h3 style="color: var(--text-color); margin-bottom: 10px;">暂无文章</h3>
                    <p style="color: var(--secondary-color); margin-bottom: 20px;">点击"写文章"按钮开始创作</p>
                    <button class="btn btn-success" onclick="showPage('create')">
                        <i class="fas fa-plus"></i> 写文章
                    </button>
                </div>
            `;
        } else {
            const postsHTML = posts
                .slice() // 创建副本避免修改原数组
                .reverse() // 最新的在前
                .map(post => renderPostCard(post))
                .join('');
            
            document.getElementById('posts-container').innerHTML = `
                <div class="post-list">
                    ${postsHTML}
                </div>
            `;
        }
    } catch (error) {
        document.getElementById('posts-container').innerHTML = `
            <div class="card" style="text-align: center; padding: 40px; color: var(--danger-color);">
                <i class="fas fa-exclamation-circle" style="font-size: 3rem; margin-bottom: 20px;"></i>
                <p>加载文章失败,请刷新页面重试</p>
            </div>
        `;
    }
}

// 加载文章详情页
async function loadPostPage(postId) {
    const mainContent = document.getElementById('main-content');
    
    mainContent.innerHTML = `
        <div style="text-align: center; padding: 40px;">
            <div class="spinner" style="margin: 0 auto;"></div>
            <p style="margin-top: 20px; color: var(--secondary-color);">加载中...</p>
        </div>
    `;
    
    try {
        const post = await BlogAPI.getPost(postId);
        mainContent.innerHTML = renderPostDetail(post);
    } catch (error) {
        mainContent.innerHTML = `
            <div class="card" style="text-align: center; padding: 40px;">
                <i class="fas fa-exclamation-triangle" style="font-size: 3rem; color: var(--danger-color); margin-bottom: 20px;"></i>
                <h3 style="color: var(--text-color); margin-bottom: 10px;">文章不存在</h3>
                <p style="color: var(--secondary-color);">该文章可能已被删除</p>
                <button class="btn btn-primary" onclick="showPage('home')" style="margin-top: 20px;">
                    返回首页
                </button>
            </div>
        `;
    }
}

// 加载创建文章页
function loadCreatePage() {
    const mainContent = document.getElementById('main-content');
    mainContent.innerHTML = renderPostForm();
}

// 加载编辑文章页
async function loadEditPage(postId) {
    const mainContent = document.getElementById('main-content');
    
    mainContent.innerHTML = `
        <div style="text-align: center; padding: 40px;">
            <div class="spinner" style="margin: 0 auto;"></div>
            <p style="margin-top: 20px; color: var(--secondary-color);">加载中...</p>
        </div>
    `;
    
    try {
        const post = await BlogAPI.getPost(postId);
        mainContent.innerHTML = renderPostForm(post);
    } catch (error) {
        mainContent.innerHTML = `
            <div class="card" style="text-align: center; padding: 40px;">
                <i class="fas fa-exclamation-triangle" style="font-size: 3rem; color: var(--danger-color); margin-bottom: 20px;"></i>
                <h3 style="color: var(--text-color); margin-bottom: 10px;">文章不存在</h3>
                <p style="color: var(--secondary-color);">无法编辑不存在的文章</p>
                <button class="btn btn-primary" onclick="showPage('home')" style="margin-top: 20px;">
                    返回首页
                </button>
            </div>
        `;
    }
}

// 加载管理页面
async function loadManagePage() {
    const mainContent = document.getElementById('main-content');
    
    mainContent.innerHTML = `
        <div style="margin-bottom: 30px;">
            <h1 style="color: var(--text-color); margin-bottom: 10px;">📋 文章管理</h1>
            <p style="color: var(--secondary-color);">管理所有博客文章</p>
        </div>
        <div id="manage-container" style="min-height: 200px;">
            <div style="text-align: center; padding: 40px;">
                <div class="spinner" style="margin: 0 auto;"></div>
                <p style="margin-top: 20px; color: var(--secondary-color);">加载中...</p>
            </div>
        </div>
    `;
    
    try {
        const posts = await BlogAPI.getAllPosts();
        document.getElementById('manage-container').innerHTML = renderPostTable(posts);
    } catch (error) {
        document.getElementById('manage-container').innerHTML = `
            <div class="card" style="text-align: center; padding: 40px; color: var(--danger-color);">
                <i class="fas fa-exclamation-circle" style="font-size: 3rem; margin-bottom: 20px;"></i>
                <p>加载失败,请刷新页面重试</p>
            </div>
        `;
    }
}

// 查看文章
function viewPost(postId) {
    showPage(`view/${postId}`);
}

// 编辑文章
function editPost(postId) {
    showPage(`edit/${postId}`);
}

// 删除文章
async function deletePost(postId) {
    showConfirmDialog(
        '确定要删除这篇文章吗?删除后无法恢复。',
        async () => {
            try {
                await BlogAPI.deletePost(postId);
                showNotification('文章删除成功', 'success');
                
                // 根据当前页面刷新内容
                if (currentPage === 'manage') {
                    await loadManagePage();
                } else if (currentPage === 'home') {
                    await loadHomePage();
                } else {
                    showPage('home');
                }
            } catch (error) {
                showNotification('删除失败: ' + error.message, 'error');
            }
        }
    );
}

// 处理文章提交
async function handleSubmitPost(event, postId = null) {
    event.preventDefault();
    
    const title = document.getElementById('postTitle').value.trim();
    const author = document.getElementById('postAuthor').value.trim();
    const content = document.getElementById('postContent').value.trim();
    
    if (!title || !author || !content) {
        showNotification('请填写所有必填字段', 'warning');
        return;
    }
    
    const postData = {
        title,
        author,
        content
    };
    
    try {
        if (postId) {
            // 更新文章
            await BlogAPI.updatePost(postId, postData);
            showNotification('文章更新成功', 'success');
            showPage(`view/${postId}`);
        } else {
            // 创建新文章
            const newPost = await BlogAPI.createPost(postData);
            showNotification('文章发布成功', 'success');
            showPage(`view/${newPost.id}`);
        }
    } catch (error) {
        showNotification('操作失败: ' + error.message, 'error');
    }
}

// 添加CSS动画
const style = document.createElement('style');
style.textContent = `
    @keyframes slideIn {
        from {
            transform: translateX(100%);
            opacity: 0;
        }
        to {
            transform: translateX(0);
            opacity: 1;
        }
    }
    
    @keyframes slideOut {
        from {
            transform: translateX(0);
            opacity: 1;
        }
        to {
            transform: translateX(100%);
            opacity: 0;
        }
    }
    
    .btn-sm {
        padding: 4px 8px;
        font-size: 0.8rem;
    }
`;
document.head.appendChild(style);

入前端目录 cd frontend 使用Python启动HTTP服务器 python -m http.server 3000

Vue3 前端

以下是一个完整的Vue 3 + Element Plus前端实现,与FastAPI后端配合使用:

项目结构

css 复制代码
frontend/
├── src/
│   ├── components/
│   │   ├── BlogHeader.vue
│   │   └── BlogSidebar.vue
│   ├── views/
│   │   ├── HomeView.vue
│   │   ├── BlogList.vue
│   │   ├── BlogDetail.vue
│   │   ├── CreateBlog.vue
│   │   └── EditBlog.vue
│   ├── router/
│   │   └── index.js
│   ├── api/
│   │   └── blog.js
│   ├── App.vue
│   └── main.js
└── package.json

1. 安装依赖

bash 复制代码
# 创建Vue项目
npm create vue@latest blog-frontend

# 进入项目目录
cd blog-frontend

# 安装Element Plus和axios
npm install element-plus axios
npm install @element-plus/icons-vue

2. 主要文件代码

src/main.js

javascript 复制代码
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'

const app = createApp(App)

// 注册所有图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
  app.component(key, component)
}

app.use(createPinia())
app.use(router)
app.use(ElementPlus)

app.mount('#app')

src/App.vue

vue 复制代码
<template>
  <div id="app">
    <el-container class="app-container">
      <!-- 头部导航 -->
      <el-header class="app-header">
        <div class="header-content">
          <h1 class="logo">博客管理系统</h1>
          <div class="nav-links">
            <router-link to="/" class="nav-link">
              <el-icon><HomeFilled /></el-icon>
              首页
            </router-link>
            <router-link to="/blogs" class="nav-link">
              <el-icon><Document /></el-icon>
              博客列表
            </router-link>
            <router-link to="/create" class="nav-link">
              <el-icon><Edit /></el-icon>
              写文章
            </router-link>
          </div>
        </div>
      </el-header>

      <!-- 主要内容 -->
      <el-main class="app-main">
        <router-view />
      </el-main>

      <!-- 底部 -->
      <el-footer class="app-footer">
        <div class="footer-content">
          <p>© 2024 博客管理系统 | Powered by Vue 3 + Element Plus + FastAPI</p>
          <p>
            <el-link href="http://localhost:8000/docs" target="_blank" type="info">
              API文档
            </el-link>
          </p>
        </div>
      </el-footer>
    </el-container>
  </div>
</template>

<script setup>
import { HomeFilled, Document, Edit } from '@element-plus/icons-vue'
</script>

<style scoped>
.app-container {
  min-height: 100vh;
  display: flex;
  flex-direction: column;
}

.app-header {
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  color: white;
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}

.header-content {
  display: flex;
  justify-content: space-between;
  align-items: center;
  height: 100%;
  max-width: 1200px;
  margin: 0 auto;
  padding: 0 20px;
}

.logo {
  margin: 0;
  font-size: 24px;
  font-weight: 600;
}

.nav-links {
  display: flex;
  gap: 30px;
}

.nav-link {
  display: flex;
  align-items: center;
  gap: 5px;
  color: white;
  text-decoration: none;
  font-size: 16px;
  padding: 8px 16px;
  border-radius: 4px;
  transition: all 0.3s;
}

.nav-link:hover {
  background: rgba(255, 255, 255, 0.1);
}

.nav-link.router-link-active {
  background: rgba(255, 255, 255, 0.2);
}

.app-main {
  flex: 1;
  max-width: 1200px;
  margin: 0 auto;
  padding: 20px;
  width: 100%;
}

.app-footer {
  background: #f8f9fa;
  color: #666;
  border-top: 1px solid #e4e7ed;
}

.footer-content {
  max-width: 1200px;
  margin: 0 auto;
  padding: 20px;
  text-align: center;
}

.footer-content p {
  margin: 5px 0;
}
</style>

src/router/index.js

javascript 复制代码
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'

const routes = [
  {
    path: '/',
    name: 'home',
    component: HomeView
  },
  {
    path: '/blogs',
    name: 'blogs',
    component: () => import('../views/BlogList.vue')
  },
  {
    path: '/blogs/:id',
    name: 'blog-detail',
    component: () => import('../views/BlogDetail.vue')
  },
  {
    path: '/create',
    name: 'create-blog',
    component: () => import('../views/CreateBlog.vue')
  },
  {
    path: '/edit/:id',
    name: 'edit-blog',
    component: () => import('../views/EditBlog.vue')
  }
]

const router = createRouter({
  history: createWebHistory(),
  routes
})

export default router

src/api/blog.js

javascript 复制代码
import axios from 'axios'

// 创建axios实例
const api = axios.create({
  baseURL: 'http://localhost:8000/api',
  timeout: 10000,
  headers: {
    'Content-Type': 'application/json'
  }
})

// 博客API
export const blogApi = {
  // 获取所有博客
  getAllBlogs() {
    return api.get('/posts')
  },
  
  // 获取单个博客
  getBlogById(id) {
    return api.get(`/posts/${id}`)
  },
  
  // 创建博客
  createBlog(blogData) {
    return api.post('/posts', blogData)
  },
  
  // 更新博客
  updateBlog(id, blogData) {
    return api.put(`/posts/${id}`, blogData)
  },
  
  // 删除博客
  deleteBlog(id) {
    return api.delete(`/posts/${id}`)
  }
}

src/views/HomeView.vue

vue 复制代码
<template>
  <div class="home">
    <el-row :gutter="20">
      <el-col :span="24">
        <el-card class="welcome-card">
          <template #header>
            <div class="card-header">
              <h2>欢迎使用博客管理系统</h2>
            </div>
          </template>
          <div class="welcome-content">
            <el-icon class="welcome-icon"><Reading /></el-icon>
            <p class="welcome-text">
              这是一个基于 Vue 3 + Element Plus + FastAPI 的完整博客管理系统。
              您可以浏览、创建、编辑和删除博客文章。
            </p>
            <div class="action-buttons">
              <el-button 
                type="primary" 
                size="large" 
                @click="$router.push('/blogs')"
                class="action-button"
              >
                <el-icon><List /></el-icon>
                浏览博客
              </el-button>
              <el-button 
                type="success" 
                size="large" 
                @click="$router.push('/create')"
                class="action-button"
              >
                <el-icon><Edit /></el-icon>
                写新文章
              </el-button>
            </div>
          </div>
        </el-card>
      </el-col>
    </el-row>

    <el-row :gutter="20" class="features">
      <el-col :xs="24" :sm="12" :md="8" v-for="feature in features" :key="feature.title">
        <el-card class="feature-card">
          <div class="feature-icon">
            <component :is="feature.icon" />
          </div>
          <h3>{{ feature.title }}</h3>
          <p>{{ feature.description }}</p>
        </el-card>
      </el-col>
    </el-row>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import { Reading, List, Edit, DataBoard, Setting, User } from '@element-plus/icons-vue'

const features = ref([
  {
    title: '简洁易用',
    description: '直观的用户界面,操作简单便捷',
    icon: 'Setting'
  },
  {
    title: '功能完整',
    description: '完整的CRUD操作,满足所有博客管理需求',
    icon: 'DataBoard'
  },
  {
    title: '响应式设计',
    description: '适配各种设备,提供良好的移动端体验',
    icon: 'User'
  }
])
</script>

<style scoped>
.home {
  padding: 20px;
}

.welcome-card {
  margin-bottom: 30px;
  text-align: center;
  border: none;
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}

.card-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.welcome-content {
  padding: 40px 20px;
}

.welcome-icon {
  font-size: 60px;
  color: #409EFF;
  margin-bottom: 20px;
}

.welcome-text {
  font-size: 18px;
  color: #666;
  margin-bottom: 30px;
  line-height: 1.6;
}

.action-buttons {
  display: flex;
  justify-content: center;
  gap: 20px;
  margin-top: 30px;
}

.action-button {
  padding: 15px 30px;
}

.features {
  margin-top: 30px;
}

.feature-card {
  height: 100%;
  text-align: center;
  border: none;
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
  transition: transform 0.3s;
}

.feature-card:hover {
  transform: translateY(-5px);
}

.feature-icon {
  font-size: 40px;
  color: #67C23A;
  margin-bottom: 20px;
}

.feature-card h3 {
  margin: 10px 0;
  color: #333;
}

.feature-card p {
  color: #666;
  line-height: 1.5;
}
</style>

src/views/BlogList.vue

vue 复制代码
<template>
  <div class="blog-list">
    <el-row :gutter="20">
      <el-col :span="24">
        <div class="page-header">
          <h2>博客文章</h2>
          <el-button 
            type="primary" 
            @click="$router.push('/create')"
            class="create-btn"
          >
            <el-icon><Plus /></el-icon>
            创建新文章
          </el-button>
        </div>
      </el-col>
    </el-row>

    <el-row :gutter="20" v-loading="loading">
      <el-col 
        :xs="24" 
        :sm="12" 
        :lg="8" 
        v-for="blog in blogs" 
        :key="blog.id"
      >
        <el-card class="blog-card" shadow="hover">
          <template #header>
            <div class="blog-header">
              <h3 class="blog-title">{{ blog.title }}</h3>
              <el-tag type="info" size="small">
                {{ blog.author }}
              </el-tag>
            </div>
          </template>
          
          <div class="blog-content">
            <p class="blog-excerpt">
              {{ truncateContent(blog.content) }}
            </p>
            
            <div class="blog-meta">
              <div class="meta-item">
                <el-icon><Calendar /></el-icon>
                <span>{{ formatDate(blog.created_at) }}</span>
              </div>
              <div class="meta-item" v-if="blog.updated_at !== blog.created_at">
                <el-icon><Refresh /></el-icon>
                <span>更新于 {{ formatDate(blog.updated_at) }}</span>
              </div>
            </div>
          </div>
          
          <template #footer>
            <div class="blog-actions">
              <el-button 
                type="primary" 
                size="small" 
                @click="viewBlog(blog.id)"
              >
                <el-icon><View /></el-icon>
                查看详情
              </el-button>
              <el-button 
                type="warning" 
                size="small" 
                @click="editBlog(blog.id)"
              >
                <el-icon><Edit /></el-icon>
                编辑
              </el-button>
              <el-button 
                type="danger" 
                size="small" 
                @click="confirmDelete(blog)"
              >
                <el-icon><Delete /></el-icon>
                删除
              </el-button>
            </div>
          </template>
        </el-card>
      </el-col>
    </el-row>

    <!-- 空状态 -->
    <el-empty 
      v-if="!loading && blogs.length === 0" 
      description="暂无博客文章"
      class="empty-state"
    >
      <el-button type="primary" @click="$router.push('/create')">
        创建第一篇博客
      </el-button>
    </el-empty>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { blogApi } from '../api/blog'
import { 
  Plus, 
  Calendar, 
  Refresh, 
  View, 
  Edit, 
  Delete 
} from '@element-plus/icons-vue'

const router = useRouter()
const blogs = ref([])
const loading = ref(false)

// 获取博客列表
const fetchBlogs = async () => {
  try {
    loading.value = true
    const response = await blogApi.getAllBlogs()
    blogs.value = response.data
  } catch (error) {
    ElMessage.error('获取博客列表失败:' + error.message)
  } finally {
    loading.value = false
  }
}

// 查看博客详情
const viewBlog = (id) => {
  router.push(`/blogs/${id}`)
}

// 编辑博客
const editBlog = (id) => {
  router.push(`/edit/${id}`)
}

// 确认删除
const confirmDelete = (blog) => {
  ElMessageBox.confirm(
    `确定要删除文章 "${blog.title}" 吗?`,
    '删除确认',
    {
      confirmButtonText: '确定删除',
      cancelButtonText: '取消',
      type: 'warning',
      center: true
    }
  ).then(async () => {
    try {
      await blogApi.deleteBlog(blog.id)
      ElMessage.success('文章删除成功')
      fetchBlogs() // 重新加载列表
    } catch (error) {
      ElMessage.error('删除失败:' + error.message)
    }
  }).catch(() => {
    // 用户取消
  })
}

// 截断内容
const truncateContent = (content, length = 100) => {
  if (content.length <= length) return content
  return content.substring(0, length) + '...'
}

// 格式化日期
const formatDate = (dateString) => {
  if (!dateString) return ''
  const date = new Date(dateString)
  return date.toLocaleDateString('zh-CN')
}

onMounted(() => {
  fetchBlogs()
})
</script>

<style scoped>
.blog-list {
  padding: 20px;
}

.page-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 30px;
}

.page-header h2 {
  margin: 0;
  color: #333;
}

.create-btn {
  padding: 10px 20px;
}

.blog-card {
  margin-bottom: 20px;
  height: 100%;
  transition: transform 0.3s;
}

.blog-card:hover {
  transform: translateY(-5px);
}

.blog-header {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
}

.blog-title {
  margin: 0;
  font-size: 18px;
  color: #333;
  flex: 1;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.blog-content {
  min-height: 120px;
}

.blog-excerpt {
  color: #666;
  line-height: 1.6;
  margin-bottom: 15px;
  display: -webkit-box;
  -webkit-line-clamp: 3;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.blog-meta {
  display: flex;
  flex-direction: column;
  gap: 8px;
  font-size: 12px;
  color: #999;
}

.meta-item {
  display: flex;
  align-items: center;
  gap: 5px;
}

.blog-actions {
  display: flex;
  justify-content: flex-end;
  gap: 10px;
}

.empty-state {
  margin-top: 50px;
}

@media (max-width: 768px) {
  .page-header {
    flex-direction: column;
    align-items: stretch;
    gap: 15px;
  }
  
  .create-btn {
    width: 100%;
  }
}
</style>

src/views/BlogDetail.vue

vue 复制代码
<template>
  <div class="blog-detail">
    <div v-if="loading" class="loading-container">
      <el-skeleton :rows="10" animated />
    </div>
    
    <div v-else-if="blog" class="blog-content">
      <!-- 返回按钮 -->
      <el-button 
        type="info" 
        @click="$router.back()" 
        class="back-btn"
      >
        <el-icon><ArrowLeft /></el-icon>
        返回列表
      </el-button>
      
      <!-- 博客内容 -->
      <el-card class="blog-card">
        <template #header>
          <div class="blog-header">
            <h1 class="blog-title">{{ blog.title }}</h1>
            <div class="blog-meta">
              <el-tag type="primary" size="large">
                {{ blog.author }}
              </el-tag>
              <div class="meta-info">
                <div class="meta-item">
                  <el-icon><Calendar /></el-icon>
                  <span>发布于 {{ formatDateTime(blog.created_at) }}</span>
                </div>
                <div class="meta-item" v-if="blog.updated_at !== blog.created_at">
                  <el-icon><Refresh /></el-icon>
                  <span>更新于 {{ formatDateTime(blog.updated_at) }}</span>
                </div>
              </div>
            </div>
          </div>
        </template>
        
        <div class="content-body">
          <div class="content-text">
            <p v-for="(paragraph, index) in contentParagraphs" 
               :key="index"
               class="paragraph">
              {{ paragraph }}
            </p>
          </div>
        </div>
        
        <template #footer>
          <div class="blog-actions">
            <el-button-group>
              <el-button 
                type="primary" 
                @click="editBlog"
              >
                <el-icon><Edit /></el-icon>
                编辑文章
              </el-button>
              <el-button 
                type="danger" 
                @click="confirmDelete"
              >
                <el-icon><Delete /></el-icon>
                删除文章
              </el-button>
            </el-button-group>
          </div>
        </template>
      </el-card>
    </div>
    
    <div v-else class="not-found">
      <el-result
        icon="error"
        title="文章不存在"
        sub-title="您访问的文章可能已被删除或不存在"
      >
        <template #extra>
          <el-button type="primary" @click="$router.push('/blogs')">
            返回博客列表
          </el-button>
        </template>
      </el-result>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { blogApi } from '../api/blog'
import { 
  ArrowLeft, 
  Calendar, 
  Refresh, 
  Edit, 
  Delete 
} from '@element-plus/icons-vue'

const route = useRoute()
const router = useRouter()
const blog = ref(null)
const loading = ref(true)

// 获取博客详情
const fetchBlog = async () => {
  try {
    loading.value = true
    const id = parseInt(route.params.id)
    const response = await blogApi.getBlogById(id)
    blog.value = response.data
  } catch (error) {
    ElMessage.error('获取博客详情失败:' + error.message)
    blog.value = null
  } finally {
    loading.value = false
  }
}

// 编辑博客
const editBlog = () => {
  router.push(`/edit/${blog.value.id}`)
}

// 确认删除
const confirmDelete = () => {
  ElMessageBox.confirm(
    `确定要删除文章 "${blog.value.title}" 吗?此操作不可恢复。`,
    '删除确认',
    {
      confirmButtonText: '确定删除',
      cancelButtonText: '取消',
      type: 'warning',
      center: true
    }
  ).then(async () => {
    try {
      await blogApi.deleteBlog(blog.value.id)
      ElMessage.success('文章删除成功')
      router.push('/blogs')
    } catch (error) {
      ElMessage.error('删除失败:' + error.message)
    }
  }).catch(() => {
    // 用户取消
  })
}

// 格式化日期时间
const formatDateTime = (dateString) => {
  if (!dateString) return ''
  const date = new Date(dateString)
  return date.toLocaleString('zh-CN')
}

// 将内容分割为段落
const contentParagraphs = computed(() => {
  if (!blog.value?.content) return []
  return blog.value.content.split('\n').filter(p => p.trim())
})

onMounted(() => {
  fetchBlog()
})
</script>

<style scoped>
.blog-detail {
  padding: 20px;
  max-width: 900px;
  margin: 0 auto;
}

.loading-container {
  padding: 40px;
}

.back-btn {
  margin-bottom: 20px;
}

.blog-card {
  border: none;
  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
}

.blog-header {
  padding-bottom: 20px;
  border-bottom: 1px solid #e4e7ed;
}

.blog-title {
  margin: 0 0 20px 0;
  font-size: 32px;
  color: #333;
  line-height: 1.3;
}

.blog-meta {
  display: flex;
  justify-content: space-between;
  align-items: center;
  flex-wrap: wrap;
  gap: 15px;
}

.meta-info {
  display: flex;
  gap: 20px;
  font-size: 14px;
  color: #666;
}

.meta-item {
  display: flex;
  align-items: center;
  gap: 5px;
}

.content-body {
  padding: 30px 0;
}

.content-text {
  font-size: 16px;
  line-height: 1.8;
  color: #333;
}

.paragraph {
  margin-bottom: 20px;
  text-indent: 2em;
}

.paragraph:last-child {
  margin-bottom: 0;
}

.blog-actions {
  padding-top: 20px;
  border-top: 1px solid #e4e7ed;
  display: flex;
  justify-content: flex-end;
}

.not-found {
  padding: 100px 20px;
}

@media (max-width: 768px) {
  .blog-title {
    font-size: 24px;
  }
  
  .blog-meta {
    flex-direction: column;
    align-items: flex-start;
    gap: 10px;
  }
  
  .meta-info {
    flex-direction: column;
    gap: 10px;
  }
  
  .content-body {
    padding: 20px 0;
  }
  
  .content-text {
    font-size: 15px;
  }
}
</style>

src/views/CreateBlog.vuesrc/views/EditBlog.vue

vue 复制代码
<template>
  <div class="blog-editor">
    <el-page-header 
      @back="$router.back()" 
      :content="isEditMode ? '编辑文章' : '创建新文章'"
      class="page-header"
    />
    
    <el-card class="editor-card">
      <template #header>
        <h3>{{ isEditMode ? '编辑文章' : '写新文章' }}</h3>
      </template>
      
      <el-form 
        ref="formRef" 
        :model="form" 
        :rules="rules" 
        label-width="80px"
        class="blog-form"
      >
        <el-form-item label="文章标题" prop="title">
          <el-input 
            v-model="form.title" 
            placeholder="请输入文章标题" 
            size="large"
            clearable
          />
        </el-form-item>
        
        <el-form-item label="作者" prop="author">
          <el-input 
            v-model="form.author" 
            placeholder="请输入作者姓名" 
            size="large"
            clearable
          />
        </el-form-item>
        
        <el-form-item label="文章内容" prop="content">
          <el-input
            v-model="form.content"
            type="textarea"
            :rows="15"
            placeholder="请输入文章内容,支持 Markdown 格式..."
            resize="none"
          />
          <div class="editor-tips">
            <el-tag type="info" size="small">
              <el-icon><InfoFilled /></el-icon>
              提示:您可以使用 Markdown 语法格式化文本
            </el-tag>
          </div>
        </el-form-item>
        
        <el-form-item>
          <el-button 
            type="primary" 
            @click="submitForm" 
            :loading="submitting"
            size="large"
            class="submit-btn"
          >
            {{ isEditMode ? '更新文章' : '发布文章' }}
          </el-button>
          <el-button 
            @click="$router.back()" 
            size="large"
          >
            取消
          </el-button>
        </el-form-item>
      </el-form>
    </el-card>
  </div>
</template>

<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { blogApi } from '../api/blog'
import { InfoFilled } from '@element-plus/icons-vue'

const route = useRoute()
const router = useRouter()
const formRef = ref(null)
const submitting = ref(false)

// 判断是否为编辑模式
const isEditMode = route.name === 'edit-blog'

// 表单数据
const form = reactive({
  title: '',
  author: '',
  content: ''
})

// 表单验证规则
const rules = {
  title: [
    { required: true, message: '请输入文章标题', trigger: 'blur' },
    { min: 3, message: '标题长度至少 3 个字符', trigger: 'blur' },
    { max: 100, message: '标题长度不能超过 100 个字符', trigger: 'blur' }
  ],
  author: [
    { required: true, message: '请输入作者姓名', trigger: 'blur' },
    { min: 2, message: '作者姓名至少 2 个字符', trigger: 'blur' },
    { max: 50, message: '作者姓名不能超过 50 个字符', trigger: 'blur' }
  ],
  content: [
    { required: true, message: '请输入文章内容', trigger: 'blur' },
    { min: 10, message: '文章内容至少 10 个字符', trigger: 'blur' }
  ]
}

// 编辑模式下加载数据
const loadBlogData = async () => {
  if (!isEditMode) return
  
  try {
    const id = parseInt(route.params.id)
    const response = await blogApi.getBlogById(id)
    const blog = response.data
    
    form.title = blog.title
    form.author = blog.author
    form.content = blog.content
  } catch (error) {
    ElMessage.error('加载文章失败:' + error.message)
    router.push('/blogs')
  }
}

// 提交表单
const submitForm = async () => {
  if (!formRef.value) return
  
  try {
    await formRef.value.validate()
    submitting.value = true
    
    if (isEditMode) {
      const id = parseInt(route.params.id)
      await blogApi.updateBlog(id, {
        ...form
      })
      ElMessage.success('文章更新成功')
    } else {
      await blogApi.createBlog({
        ...form
      })
      ElMessage.success('文章创建成功')
    }
    
    router.push('/blogs')
  } catch (error) {
    if (error.name !== 'ValidationError') {
      ElMessage.error(isEditMode ? '更新失败:' + error.message : '创建失败:' + error.message)
    }
  } finally {
    submitting.value = false
  }
}

onMounted(() => {
  loadBlogData()
})
</script>

<style scoped>
.blog-editor {
  padding: 20px;
  max-width: 800px;
  margin: 0 auto;
}

.page-header {
  margin-bottom: 20px;
}

.editor-card {
  border: none;
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}

.editor-card :deep(.el-card__header) {
  border-bottom: 1px solid #e4e7ed;
  padding: 20px;
}

.editor-card h3 {
  margin: 0;
  color: #333;
  font-size: 18px;
}

.blog-form {
  padding: 10px;
}

.editor-tips {
  margin-top: 10px;
}

.submit-btn {
  min-width: 120px;
}

@media (max-width: 768px) {
  .blog-editor {
    padding: 10px;
  }
  
  .editor-card {
    margin: 0 -10px;
  }
}
</style>

3. 运行说明

  1. 启动后端服务
bash 复制代码
cd backend
python main.py
# 后端将在 http://localhost:8000 运行
  1. 启动前端服务
bash 复制代码
cd frontend
npm run dev
# 前端将在 http://localhost:5173 运行
  1. 配置跨域:后端代码中已经配置了CORS,允许所有源访问。

4. 功能特点

  1. 响应式设计:适配桌面端和移动端
  2. 完整CRUD操作
    • 创建博客文章
    • 查看博客列表
    • 查看博客详情
    • 编辑博客文章
    • 删除博客文章
  3. 用户体验优化
    • 加载状态指示
    • 操作确认对话框
    • 表单验证
    • 错误处理
  4. 美观的UI:使用Element Plus组件,现代化设计
  5. API集成:与FastAPI后端无缝集成

这个前端应用提供了一个完整的博客管理界面,用户可以方便地管理博客文章,所有操作都会通过API与后端交互。

相关推荐
Way2top18 小时前
Go语言动手写Web框架 - Gee第五天 中间件
后端·go
wulijuan88866618 小时前
BroadcastChannel API 同源的多个标签页可以使用 BroadcastChannel 进行通讯
前端·javascript·vue.js
Way2top18 小时前
Go语言动手写Web框架 - Gee第四天 分组控制
后端·go
喵叔哟18 小时前
17.核心服务实现(上)
后端·.net
逝川长叹18 小时前
利用 SSI-COV 算法自动识别线状结构在环境振动下的模态参数研究(Matlab代码实现)
前端·算法·支持向量机·matlab
李梨同学丶18 小时前
好虫子周刊:1-bit LLM、物理 AI、DeepSeek-R1
后端
xkxnq19 小时前
第一阶段:Vue 基础入门(第 13天)
前端·javascript·vue.js
qq_4198540519 小时前
Excel预览
前端
bruce_哈哈哈19 小时前
go语言初认识
开发语言·后端·golang