初始化技术博客项目
功能特性: - 前端文章展示 (Markdown渲染、代码高亮) - 后台管理系统 (文章、分类、标签、作者管理) - 图片上传 (点击、拖拽、粘贴) - 网站设置 (Logo、底部宣传图片上传) - 访问统计 (IP记录、趋势图表) - 参考来源、附件支持 - 单端口部署 (16012) 技术栈: - Flask 3.0 + SQLAlchemy - SQLite数据库 - marked.js + highlight.js (Markdown渲染) - Chart.js (统计图表)
This commit is contained in:
591
app/templates/admin/article_form.html
Normal file
591
app/templates/admin/article_form.html
Normal 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 = `\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('')" 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 %}
|
||||
Reference in New Issue
Block a user