后端:
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.vue 和 src/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. 运行说明
- 启动后端服务:
bash
cd backend
python main.py
# 后端将在 http://localhost:8000 运行
- 启动前端服务:
bash
cd frontend
npm run dev
# 前端将在 http://localhost:5173 运行
- 配置跨域:后端代码中已经配置了CORS,允许所有源访问。
4. 功能特点
- 响应式设计:适配桌面端和移动端
- 完整CRUD操作 :
- 创建博客文章
- 查看博客列表
- 查看博客详情
- 编辑博客文章
- 删除博客文章
- 用户体验优化 :
- 加载状态指示
- 操作确认对话框
- 表单验证
- 错误处理
- 美观的UI:使用Element Plus组件,现代化设计
- API集成:与FastAPI后端无缝集成
这个前端应用提供了一个完整的博客管理界面,用户可以方便地管理博客文章,所有操作都会通过API与后端交互。