Files
tech-blog/app/templates/admin/article_form.html
hz4th_coder 8e3aeaf103 修复highlight.js模块加载冲突问题
- 使用完整的highlight.min.js包替代分散的语言包
- 解决'module is not defined'和变量重复声明错误
2026-06-04 23:20:59 +08:00

588 lines
18 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% 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/highlight.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 %}