初始化技术博客项目

功能特性:
- 前端文章展示 (Markdown渲染、代码高亮)
- 后台管理系统 (文章、分类、标签、作者管理)
- 图片上传 (点击、拖拽、粘贴)
- 网站设置 (Logo、底部宣传图片上传)
- 访问统计 (IP记录、趋势图表)
- 参考来源、附件支持
- 单端口部署 (16012)

技术栈:
- Flask 3.0 + SQLAlchemy
- SQLite数据库
- marked.js + highlight.js (Markdown渲染)
- Chart.js (统计图表)
This commit is contained in:
2026-06-04 23:12:34 +08:00
commit e689fed71e
53 changed files with 6506 additions and 0 deletions

View File

@@ -0,0 +1,591 @@
{% extends 'admin/base.html' %}
{% block title %}{% if article %}编辑文章{% else %}新建文章{% endif %}{% endblock %}
{% block page_title %}{% if article %}编辑文章{% else %}新建文章{% endif %}{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/styles/github.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/markdown.css') }}">
<style>
.editor-container {
display: flex;
gap: 20px;
min-height: 500px;
}
.editor-pane {
flex: 1;
display: flex;
flex-direction: column;
}
.editor-pane textarea {
flex: 1;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 0.95em;
line-height: 1.5;
padding: 15px;
border: 1px solid var(--admin-border);
border-radius: 4px;
resize: vertical;
}
.preview-pane {
flex: 1;
background: #fff;
border: 1px solid var(--admin-border);
border-radius: 4px;
padding: 15px;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.preview-pane h3 {
margin-bottom: 15px;
color: #666;
font-size: 0.9em;
padding-bottom: 8px;
border-bottom: 1px solid #eee;
}
.editor-toolbar {
margin-bottom: 10px;
display: flex;
gap: 5px;
flex-wrap: wrap;
}
.editor-toolbar button {
padding: 5px 10px;
background: #f0f0f0;
border: 1px solid #ddd;
border-radius: 3px;
cursor: pointer;
font-size: 0.9em;
transition: all 0.2s;
}
.editor-toolbar button:hover {
background: #e0e0e0;
}
.mode-toggle {
display: flex;
gap: 10px;
margin-bottom: 15px;
}
.mode-toggle button {
padding: 8px 20px;
background: #f8f9fa;
border: 1px solid var(--admin-border);
border-radius: 4px;
cursor: pointer;
}
.mode-toggle button.active {
background: var(--admin-primary);
color: #fff;
border-color: var(--admin-primary);
}
/* 图片上传区域 */
.upload-area {
border: 2px dashed var(--admin-border);
border-radius: 8px;
padding: 20px;
text-align: center;
margin-bottom: 15px;
background: #f8f9fa;
cursor: pointer;
transition: all 0.3s;
}
.upload-area:hover {
border-color: var(--admin-primary);
background: #e8f4fc;
}
.upload-area.dragover {
border-color: var(--admin-primary);
background: #d4edda;
}
.upload-area input[type="file"] {
display: none;
}
/* 参考来源 */
.references-section {
background: #f8f9fa;
border-radius: 8px;
padding: 15px;
margin-bottom: 15px;
}
.reference-item {
display: flex;
gap: 10px;
margin-bottom: 10px;
align-items: center;
}
.reference-item input {
flex: 1;
}
/* 附件 */
.attachments-section {
background: #f8f9fa;
border-radius: 8px;
padding: 15px;
}
.attachment-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
background: #fff;
border-radius: 4px;
margin-bottom: 8px;
}
.attachment-info {
display: flex;
gap: 15px;
align-items: center;
}
.attachment-info .filename {
font-weight: 500;
}
.attachment-info .filesize {
color: var(--admin-text-light);
font-size: 0.85em;
}
/* 进度条 */
.upload-progress {
width: 100%;
height: 4px;
background: #e0e0e0;
border-radius: 2px;
overflow: hidden;
display: none;
}
.upload-progress-bar {
height: 100%;
background: var(--admin-primary);
width: 0;
transition: width 0.3s;
}
</style>
{% endblock %}
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/lib/core.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/lib/languages/python.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/lib/languages/javascript.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/lib/languages/bash.min.js"></script>
<script>
// 配置 marked
marked.setOptions({
highlight: function(code, lang) {
if (lang && hlse.getLanguage(lang)) {
try {
return hlse.highlight(code, { language: lang }).value;
} catch (e) {}
}
return hlse.highlightAuto(code).value;
},
breaks: true,
gfm: true
});
let editorMode = 'split';
const editor = document.getElementById('content');
const previewContent = document.getElementById('preview-content');
// 实时预览
function updatePreview() {
const markdown = editor.value;
previewContent.innerHTML = marked.parse(markdown);
}
// 编辑器模式切换
function setEditorMode(mode) {
editorMode = mode;
const editPane = document.querySelector('.editor-pane');
const previewPane = document.querySelector('.preview-pane');
const buttons = document.querySelectorAll('.mode-toggle button');
buttons.forEach(btn => btn.classList.remove('active'));
document.querySelector(`.mode-toggle button[data-mode="${mode}"]`).classList.add('active');
if (mode === 'edit') {
editPane.style.flex = '1';
editPane.style.display = 'flex';
previewPane.style.display = 'none';
} else if (mode === 'preview') {
editPane.style.display = 'none';
previewPane.style.flex = '1';
previewPane.style.display = 'flex';
updatePreview();
} else {
editPane.style.flex = '1';
editPane.style.display = 'flex';
previewPane.style.flex = '1';
previewPane.style.display = 'flex';
}
}
// Markdown 快捷插入
function insertMarkdown(before, after = '') {
const start = editor.selectionStart;
const end = editor.selectionEnd;
const text = editor.value;
const selected = text.substring(start, end);
editor.value = text.substring(0, start) + before + selected + after + text.substring(end);
editor.focus();
editor.setSelectionRange(start + before.length, start + before.length + selected.length);
updatePreview();
}
// 插入图片到编辑器
function insertImage(url) {
const imageUrl = `![image](${url})\n`;
insertMarkdown(imageUrl, '');
}
// 图片上传
const uploadArea = document.getElementById('upload-area');
const fileInput = document.getElementById('image-upload');
if (uploadArea) {
// 点击上传
uploadArea.addEventListener('click', () => fileInput.click());
// 拖拽上传
uploadArea.addEventListener('dragover', (e) => {
e.preventDefault();
uploadArea.classList.add('dragover');
});
uploadArea.addEventListener('dragleave', () => {
uploadArea.classList.remove('dragover');
});
uploadArea.addEventListener('drop', (e) => {
e.preventDefault();
uploadArea.classList.remove('dragover');
const files = e.dataTransfer.files;
if (files.length > 0) {
uploadImage(files[0]);
}
});
// 文件选择
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
uploadImage(e.target.files[0]);
}
});
}
// 粘贴上传
editor.addEventListener('paste', (e) => {
const items = e.clipboardData.items;
for (let item of items) {
if (item.type.indexOf('image') !== -1) {
const file = item.getAsFile();
uploadImage(file, true);
e.preventDefault();
break;
}
}
});
// 上传图片
function uploadImage(file, isPaste = false) {
const formData = new FormData();
formData.append('file', file);
formData.append('article_id', '{{ article.id if article else "" }}');
if (isPaste) {
// 粘贴图片转Base64上传
const reader = new FileReader();
reader.onload = (e) => {
formData.delete('file');
formData.append('image_data', e.target.result);
fetch('{{ url_for("admin.upload_paste") }}', {
method: 'POST',
body: formData
})
.then(res => res.json())
.then(data => {
if (data.success) {
insertImage(data.url);
updatePreview();
}
});
};
reader.readAsDataURL(file);
} else {
// 普通文件上传
fetch('{{ url_for("admin.upload_image") }}', {
method: 'POST',
body: formData
})
.then(res => res.json())
.then(data => {
if (data.success) {
insertImage(data.url);
updatePreview();
}
});
}
}
// 参考来源管理
let references = {% if references %}[{% for ref in references %}{"id": {{ ref.id }}, "title": "{{ ref.title }}", "url": "{{ ref.url or '' }}", "description": "{{ ref.description or '' }}"}{% if not loop.last %}, {% endif %}{% endfor %}]{% else %}[]{% endif %};
function addReference() {
references.push({id: null, title: '', url: '', description: ''});
renderReferences();
}
function removeReference(index) {
references.splice(index, 1);
renderReferences();
}
function updateReference(index, field, value) {
references[index][field] = value;
document.getElementById('references-json').value = JSON.stringify(references);
}
function renderReferences() {
const container = document.getElementById('references-list');
container.innerHTML = references.map((ref, i) => `
<div class="reference-item">
<input type="text" placeholder="来源标题" value="${ref.title}" onchange="updateReference(${i}, 'title', this.value)">
<input type="url" placeholder="链接URL" value="${ref.url}" onchange="updateReference(${i}, 'url', this.value)">
<input type="text" placeholder="描述" value="${ref.description}" onchange="updateReference(${i}, 'description', this.value)">
<button type="button" onclick="removeReference(${i})" class="btn btn-sm btn-danger">删除</button>
</div>
`).join('');
document.getElementById('references-json').value = JSON.stringify(references);
}
renderReferences();
// 监听编辑器变化
editor.addEventListener('input', updatePreview);
// 初始化预览
updatePreview();
</script>
{% endblock %}
{% block content %}
<form method="POST" class="article-form">
<input type="hidden" id="references-json" name="references" value="{{ references | tojson | safe if references else '[]' }}">
<div class="form-row">
<div class="form-group flex-grow">
<label for="title">标题 *</label>
<input type="text" id="title" name="title" required
value="{{ article.title if article else '' }}">
</div>
<div class="form-group">
<label for="author_id">作者</label>
<select id="author_id" name="author_id">
<option value="">未指定</option>
{% for author in authors %}
<option value="{{ author.id }}"
{% if article and article.author_id == author.id %}selected{% endif %}>
{{ author.name }}
</option>
{% endfor %}
</select>
<a href="{{ url_for('admin.authors') }}" style="font-size: 0.85em;">管理作者</a>
</div>
<div class="form-group">
<label for="category_id">分类</label>
<select id="category_id" name="category_id">
<option value="">无分类</option>
{% for cat in categories %}
<option value="{{ cat.id }}"
{% if article and article.category_id == cat.id %}selected{% endif %}>
{{ cat.name }}
</option>
{% endfor %}
</select>
</div>
</div>
<div class="form-group">
<label for="summary">摘要</label>
<textarea id="summary" name="summary" rows="2" placeholder="文章简介用于列表展示和SEO">{{ article.summary if article else '' }}</textarea>
</div>
<div class="form-group">
<label for="cover_image">封面图片URL</label>
<input type="url" id="cover_image" name="cover_image"
value="{{ article.cover_image if article else '' }}"
placeholder="https://example.com/image.jpg">
</div>
<!-- 图片上传区域 -->
<div class="upload-area" id="upload-area">
<input type="file" id="image-upload" accept="image/*">
<p>📷 点击、拖拽或粘贴图片到编辑器中上传</p>
<p style="font-size: 0.85em; color: #666;">支持 PNG, JPG, GIF, WebP 格式,最大 10MB</p>
</div>
<div class="form-group">
<label>内容 * (Markdown 格式)</label>
<!-- 编辑模式切换 -->
<div class="mode-toggle">
<button type="button" data-mode="split" onclick="setEditorMode('split')" class="active">📝 编辑+预览</button>
<button type="button" data-mode="edit" onclick="setEditorMode('edit')">✏️ 仅编辑</button>
<button type="button" data-mode="preview" onclick="setEditorMode('preview')">👁️ 仅预览</button>
</div>
<!-- Markdown 快捷工具栏 -->
<div class="editor-toolbar">
<button type="button" onclick="insertMarkdown('**', '**')" title="粗体"><strong>B</strong></button>
<button type="button" onclick="insertMarkdown('*', '*')" title="斜体"><em>I</em></button>
<button type="button" onclick="insertMarkdown('~~', '~~')" title="删除线"><s>S</s></button>
<button type="button" onclick="insertMarkdown('# ', '')" title="标题1">H1</button>
<button type="button" onclick="insertMarkdown('## ', '')" title="标题2">H2</button>
<button type="button" onclick="insertMarkdown('### ', '')" title="标题3">H3</button>
<button type="button" onclick="insertMarkdown('> ', '')" title="引用"></button>
<button type="button" onclick="insertMarkdown('- ', '')" title="列表"></button>
<button type="button" onclick="insertMarkdown('1. ', '')" title="有序列表">1.</button>
<button type="button" onclick="insertMarkdown('```\\n', '\\n```')" title="代码块">{ }</button>
<button type="button" onclick="insertMarkdown('`', '`')" title="行内代码">`</button>
<button type="button" onclick="insertMarkdown('[', '](url)')" title="链接">🔗</button>
<button type="button" onclick="insertMarkdown('![alt](', ')')" title="图片">🖼</button>
<button type="button" onclick="insertMarkdown('---\\n', '')" title="分隔线"></button>
</div>
<!-- 编辑器和预览区域 -->
<div class="editor-container">
<div class="editor-pane">
<textarea id="content" name="content" rows="25" required
placeholder="使用 Markdown 格式编写文章内容...\n\n支持直接粘贴图片">{{ article.content if article else '' }}</textarea>
</div>
<div class="preview-pane">
<h3>📖 实时预览</h3>
<div id="preview-content" class="markdown-body"></div>
</div>
</div>
</div>
<!-- 参考来源 -->
<div class="references-section">
<h4>📚 参考来源</h4>
<div id="references-list"></div>
<button type="button" onclick="addReference()" class="btn btn-sm btn-secondary">+ 添加参考来源</button>
</div>
<!-- 附件 -->
{% if article %}
<div class="attachments-section">
<h4>📎 文章附件</h4>
<div id="attachments-list">
{% for att in attachments %}
<div class="attachment-item">
<div class="attachment-info">
<span class="filename">{{ att.filename }}</span>
<span class="filesize">{{ att.get_filesize_display() }}</span>
</div>
<div>
<a href="/uploads/{{ att.filename }}" target="_blank" class="btn btn-sm">下载</a>
<button type="button" onclick="deleteAttachment({{ att.id }})" class="btn btn-sm btn-danger">删除</button>
</div>
</div>
{% endfor %}
</div>
<form id="attachment-upload-form" enctype="multipart/form-data">
<input type="hidden" name="article_id" value="{{ article.id }}">
<input type="file" name="file" id="attachment-upload">
<input type="text" name="description" placeholder="文件描述(可选)" style="display: inline-block; width: 200px;">
<button type="button" onclick="uploadAttachment()" class="btn btn-sm btn-secondary">上传附件</button>
</form>
</div>
<script>
function uploadAttachment() {
const form = document.getElementById('attachment-upload-form');
const formData = new FormData(form);
fetch('{{ url_for("admin.upload_attachment") }}', {
method: 'POST',
body: formData
})
.then(res => res.json())
.then(data => {
if (data.success) {
location.reload();
}
});
}
function deleteAttachment(id) {
if (confirm('确定删除此附件?')) {
fetch(`/admin/attachment/${id}/delete`, {method: 'POST'})
.then(res => res.json())
.then(data => {
if (data.success) {
location.reload();
}
});
}
}
</script>
{% endif %}
<div class="form-group">
<label>标签</label>
<div class="checkbox-group">
{% for tag in tags %}
<label class="checkbox-label">
<input type="checkbox" name="tags" value="{{ tag.id }}"
{% if article and tag in article.tags %}checked{% endif %}>
{{ tag.name }}
</label>
{% endfor %}
</div>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" name="is_published"
{% if article and article.is_published %}checked{% endif %}>
立即发布
</label>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">
{% if article %}保存修改{% else %}创建文章{% endif %}
</button>
<a href="{{ url_for('admin.articles') }}" class="btn btn-secondary">取消</a>
{% if article and article.is_published %}
<a href="{{ url_for('frontend.article', slug=article.slug) }}" target="_blank" class="btn btn-secondary">查看文章</a>
{% endif %}
</div>
</form>
{% endblock %}