588 lines
18 KiB
HTML
588 lines
18 KiB
HTML
{% 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 = `\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 %} |