Files
article-writer/templates/topic.html
hubian d3dd11ca8b fix: 提升暗色主题文字对比度,解决提示文字看不清的问题
- text-muted色值从#8b949e提亮到#9eaab8(对比度4.3:1→5.0:1)
- 覆盖Bootstrap .text-muted默认色,统一走CSS变量
- 表单标签从text-muted改为text色+font-weight:500,更醒目
- 输入框placeholder设为#6e7a88,比默认更清晰
- .btn-secondary适配暗色主题,不再用Bootstrap默认灰
- modal-content显式设置color:var(--text)
- index页新增.alert-info暗色适配
- 新增.form-text颜色覆盖
2026-05-21 21:55:13 +08:00

470 lines
27 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.
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{topic.name}} - 文章编写系统</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
<style>
:root { --bg-dark: #0d1117; --bg-card: #161b22; --bg-hover: #1c2333; --border: #30363d; --text: #c9d1d9; --text-muted: #9eaab8; --accent: #58a6ff; --accent-hover: #79c0ff; --danger: #f85149; --success: #3fb950; --warning: #d29922; }
* { box-sizing: border-box; }
body { background: var(--bg-dark); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; }
.text-muted { color: var(--text-muted) !important; }
.navbar { background: var(--bg-card); border-bottom: 1px solid var(--border); }
.navbar-brand, .navbar-brand:hover { color: var(--accent) !important; font-weight: 700; }
.section-title { color: var(--text); font-size: 1.1rem; font-weight: 600; margin-bottom: 12px; display: flex; align-items: center; gap: 8px; }
.section-title i { font-size: 1.2rem; }
.card-dark { background: var(--bg-card); border: 1px solid var(--border); border-radius: 10px; }
.card-dark .card-header { background: transparent; border-bottom: 1px solid var(--border); padding: 12px 16px; }
.card-dark .card-body { padding: 16px; }
.material-item { background: var(--bg-dark); border: 1px solid var(--border); border-radius: 8px; padding: 12px; margin-bottom: 8px; transition: all 0.15s; }
.material-item:hover { border-color: var(--accent); }
.material-item .mat-content { white-space: pre-wrap; word-break: break-word; font-size: 0.92rem; line-height: 1.5; max-height: 120px; overflow: hidden; }
.material-item img { max-width: 100%; max-height: 200px; border-radius: 6px; margin-top: 6px; }
.material-item .mat-meta { color: var(--text-muted); font-size: 0.8rem; margin-top: 6px; }
.btn-accent { background: var(--accent); color: #fff; border: none; }
.btn-accent:hover { background: var(--accent-hover); color: #fff; }
.btn-outline-accent { border-color: var(--accent); color: var(--accent); }
.btn-outline-accent:hover { background: var(--accent); color: #fff; }
.btn-secondary { background: var(--border); color: var(--text); border: none; }
.btn-secondary:hover { background: #444c56; color: var(--text); }
.modal-content { background: var(--bg-card); border: 1px solid var(--border); color: var(--text); }
.modal-header { border-bottom: 1px solid var(--border); }
.modal-footer { border-top: 1px solid var(--border); }
.form-control, .form-select { background: var(--bg-dark); border-color: var(--border); color: var(--text); }
.form-control::placeholder, .form-select::placeholder { color: #6e7a88; opacity: 1; }
.form-control:focus, .form-select:focus { background: var(--bg-dark); border-color: var(--accent); color: var(--text); box-shadow: 0 0 0 2px rgba(88,166,255,0.2); }
.form-label { color: var(--text); font-size: 0.9rem; font-weight: 500; }
.form-text { color: var(--text-muted) !important; }
.btn-close { filter: invert(1); }
.article-content { white-space: pre-wrap; word-break: break-word; line-height: 1.8; font-size: 0.95rem; }
.prompt-area { background: var(--bg-dark); border: 1px solid var(--border); border-radius: 8px; padding: 12px; min-height: 80px; }
.generating { opacity: 0.7; pointer-events: none; }
.spinner-generate { display: inline-block; width: 18px; height: 18px; border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff; border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.article-card { border: 1px solid var(--border); border-radius: 8px; margin-bottom: 12px; overflow: hidden; }
.article-card .article-header { background: var(--bg-dark); padding: 10px 14px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border); }
.article-card .article-body { padding: 14px; background: var(--bg-card); max-height: 400px; overflow-y: auto; }
.copy-btn { cursor: pointer; color: var(--text-muted); }
.copy-btn:hover { color: var(--accent); }
.prompt-history-item { background: var(--bg-dark); border: 1px solid var(--border); border-radius: 6px; padding: 8px 12px; margin-bottom: 6px; cursor: pointer; transition: all 0.15s; }
.prompt-history-item:hover { border-color: var(--accent); }
.prompt-history-item .ph-text { font-size: 0.85rem; white-space: pre-wrap; max-height: 60px; overflow: hidden; }
.prompt-history-item .ph-meta { font-size: 0.75rem; color: var(--text-muted); margin-top: 4px; }
.paste-zone { border: 2px dashed var(--border); border-radius: 10px; padding: 24px; text-align: center; color: var(--text-muted); cursor: pointer; transition: all 0.2s; }
.paste-zone:hover, .paste-zone.active { border-color: var(--accent); background: rgba(88,166,255,0.05); color: var(--accent); }
.paste-zone i { font-size: 2rem; display: block; margin-bottom: 8px; }
.collapsible-prompt { max-height: 0; overflow: hidden; transition: max-height 0.3s ease; }
.collapsible-prompt.show { max-height: 200px; }
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: var(--bg-dark); }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
</style>
</head>
<body>
<nav class="navbar navbar-expand">
<div class="container-fluid px-4">
<a class="navbar-brand" href="/"><i class="bi bi-arrow-left me-2"></i>文章编写系统</a>
<span class="navbar-text text-warning fw-bold">{{topic.name}}</span>
</div>
</nav>
<div class="container-fluid px-4 py-3">
<div class="row g-3">
<!-- 左栏:素材 + Prompt -->
<div class="col-lg-5">
<!-- Prompt区 -->
<div class="card-dark mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<span class="section-title mb-0"><i class="bi bi-chat-square-text text-warning"></i>生成Prompt</span>
<div class="d-flex gap-1">
<button class="btn btn-sm btn-outline-accent" onclick="togglePromptHistory()" title="历史记录">
<i class="bi bi-clock-history"></i>
</button>
<button class="btn btn-sm btn-outline-accent" onclick="savePrompt()"><i class="bi bi-check-lg me-1"></i>保存</button>
</div>
</div>
<div class="card-body">
<!-- Prompt历史折叠区 -->
<div id="promptHistoryArea" class="collapsible-prompt mb-2">
<div class="mb-2 small text-muted"><i class="bi bi-clock-history me-1"></i>历史Prompt点击恢复</div>
<div id="promptHistoryList"></div>
</div>
<textarea class="form-control" id="promptText" rows="4" placeholder="请根据以下素材撰写一篇结构清晰、内容丰富的文章:">{{topic.prompt or ''}}</textarea>
{% if topic.description %}<div class="mt-2 text-muted small"><i class="bi bi-info-circle me-1"></i>主题说明:{{topic.description}}</div>{% endif %}
</div>
</div>
<!-- 素材区 -->
<div class="card-dark">
<div class="card-header d-flex justify-content-between align-items-center">
<span class="section-title mb-0"><i class="bi bi-collection text-info"></i>素材库 <span class="badge bg-secondary ms-1">{{materials|length}}</span></span>
<div>
<button class="btn btn-sm btn-outline-accent me-1" onclick="showAddText()"><i class="bi bi-type me-1"></i>文本</button>
<button class="btn btn-sm btn-outline-accent" onclick="showAddImage()"><i class="bi bi-image me-1"></i>图片</button>
</div>
</div>
<div class="card-body" id="materialsList" style="max-height: 60vh; overflow-y: auto;">
<!-- 粘贴上传区 -->
<div class="paste-zone mb-3" id="pasteZone" tabindex="0" onclick="document.getElementById('imageFile').click()">
<i class="bi bi-clipboard"></i>
<small>Ctrl+V 粘贴截图 或 点击选择图片</small>
</div>
{% if materials %}
{% for m in materials %}
<div class="material-item" id="mat-{{m.id}}">
<div class="d-flex justify-content-between align-items-start">
<span class="badge {{'bg-info' if m.type=='text' else 'bg-warning'}}">{{'文本' if m.type=='text' else '图片'}}</span>
<button class="btn btn-sm btn-link text-danger p-0" onclick="deleteMaterial('{{m.id}}')" title="删除"><i class="bi bi-x-lg"></i></button>
</div>
{% if m.type == 'text' %}
<div class="mat-content mt-1">{{m.content}}</div>
{% else %}
{% if m.content %}<div class="small text-muted mt-1">{{m.content}}</div>{% endif %}
<img src="/static/{{m.file_path}}" alt="素材图片">
{% endif %}
<div class="mat-meta">{{m.created_at}}</div>
</div>
{% endfor %}
{% else %}
<div class="text-center text-muted py-3" id="materialsEmpty">
<small>添加素材开始创作</small>
</div>
{% endif %}
</div>
</div>
</div>
<!-- 右栏:文章生成 -->
<div class="col-lg-7">
<div class="card-dark">
<div class="card-header d-flex justify-content-between align-items-center">
<span class="section-title mb-0"><i class="bi bi-file-earmark-richtext text-success"></i>文章生成</span>
<button class="btn btn-accent" id="generateBtn" onclick="generateArticle()">
<i class="bi bi-stars me-1"></i>生成文章
</button>
</div>
<div class="card-body" id="articlesArea" style="max-height: 80vh; overflow-y: auto;">
{% if articles %}
{% for a in articles %}
<div class="article-card" id="article-{{a.id}}">
<div class="article-header">
<span class="small text-muted">
<i class="bi bi-clock me-1"></i>{{a.created_at}} · {{a.model}}
{% if a.prompt_used %}<span class="copy-btn ms-2" onclick="toggleArticlePrompt('{{a.id}}')" title="查看Prompt"><i class="bi bi-chat-left-text"></i></span>{% endif %}
</span>
<div>
<span class="copy-btn me-2" onclick="copyArticle('{{a.id}}')" title="复制"><i class="bi bi-clipboard"></i></span>
<span class="copy-btn me-2" onclick="downloadArticle('{{a.id}}')" title="下载"><i class="bi bi-download"></i></span>
<span class="copy-btn text-danger" onclick="deleteArticle('{{a.id}}')" title="删除"><i class="bi bi-trash3"></i></span>
</div>
</div>
{% if a.prompt_used %}
<div class="collapsible-prompt" id="article-prompt-{{a.id}}">
<div style="padding: 8px 14px; background: rgba(88,166,255,0.05); border-bottom: 1px solid var(--border); font-size: 0.82rem; white-space: pre-wrap; color: var(--text-muted);">{{a.prompt_used}}</div>
</div>
{% endif %}
<div class="article-body article-content" id="article-content-{{a.id}}">{{a.content}}</div>
</div>
{% endfor %}
{% else %}
<div class="text-center text-muted py-5">
<i class="bi bi-pencil-square fs-1 d-block mb-3"></i>
<p>添加素材并编辑Prompt后点击「生成文章」</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<!-- 添加文本模态框 -->
<div class="modal fade" id="textModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-type me-2"></i>添加文本素材</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<textarea class="form-control" id="textContent" rows="8" placeholder="在此粘贴或输入文本素材..."></textarea>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-accent" onclick="addText()">添加</button>
</div>
</div>
</div>
</div>
<!-- 添加图片模态框 -->
<div class="modal fade" id="imageModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-image me-2"></i>添加图片素材</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">选择图片</label>
<input type="file" class="form-control" id="imageFile" accept="image/*">
</div>
<div class="mb-3">
<label class="form-label">图片说明(可选)</label>
<input type="text" class="form-control" id="imageDesc" placeholder="描述图片内容">
</div>
<div id="imagePreview" class="text-center"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-accent" onclick="addImage()">上传</button>
</div>
</div>
</div>
</div>
<!-- 粘贴截图快速上传模态框 -->
<div class="modal fade" id="pasteModal" tabindex="-1">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-clipboard-check me-2"></i>粘贴上传</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div id="pastePreview" class="text-center mb-2"></div>
<div class="mb-2">
<label class="form-label">图片说明(可选)</label>
<input type="text" class="form-control form-control-sm" id="pasteDesc" placeholder="描述图片内容">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-accent btn-sm" onclick="uploadPastedImage()">上传</button>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
const topicId = '{{topic.id}}';
const textModal = new bootstrap.Modal(document.getElementById('textModal'));
const imageModal = new bootstrap.Modal(document.getElementById('imageModal'));
const pasteModal = new bootstrap.Modal(document.getElementById('pasteModal'));
let pastedBlob = null; // 暂存粘贴的图片
// ===== 粘贴截图上传 =====
document.addEventListener('paste', function(e) {
const items = e.clipboardData && e.clipboardData.items;
if (!items) return;
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') !== -1) {
e.preventDefault();
const blob = items[i].getAsFile();
pastedBlob = blob;
// 预览
const reader = new FileReader();
reader.onload = (ev) => {
document.getElementById('pastePreview').innerHTML = `<img src="${ev.target.result}" style="max-width:100%;max-height:200px;border-radius:6px;">`;
document.getElementById('pasteDesc').value = '';
pasteModal.show();
};
reader.readAsDataURL(blob);
break;
}
}
});
// 粘贴区拖拽高亮
const pasteZone = document.getElementById('pasteZone');
document.addEventListener('paste', () => { pasteZone.classList.add('active'); setTimeout(() => pasteZone.classList.remove('active'), 1000); });
async function uploadPastedImage() {
if (!pastedBlob) return alert('没有粘贴的图片');
const fd = new FormData();
fd.append('type', 'image');
fd.append('file', pastedBlob, `paste_${Date.now()}.png`);
fd.append('content', document.getElementById('pasteDesc').value);
const res = await fetch(`/api/topics/${topicId}/materials`, {method: 'POST', body: fd});
if (res.ok) {
pastedBlob = null;
pasteModal.hide();
location.reload();
} else {
const d = await res.json(); alert(d.error);
}
}
// 图片预览(选择文件)
document.getElementById('imageFile').addEventListener('change', function(e) {
const preview = document.getElementById('imagePreview');
if (e.target.files[0]) {
const reader = new FileReader();
reader.onload = (ev) => { preview.innerHTML = `<img src="${ev.target.result}" style="max-width:100%;max-height:200px;border-radius:6px;">`; };
reader.readAsDataURL(e.target.files[0]);
} else { preview.innerHTML = ''; }
});
function showAddText() { document.getElementById('textContent').value = ''; textModal.show(); }
function showAddImage() { document.getElementById('imageFile').value = ''; document.getElementById('imageDesc').value = ''; document.getElementById('imagePreview').innerHTML = ''; imageModal.show(); }
async function addText() {
const content = document.getElementById('textContent').value.trim();
if (!content) return alert('请输入文本内容');
const fd = new FormData();
fd.append('type', 'text');
fd.append('content', content);
const res = await fetch(`/api/topics/${topicId}/materials`, {method: 'POST', body: fd});
if (res.ok) location.reload();
else { const d = await res.json(); alert(d.error); }
}
async function addImage() {
const file = document.getElementById('imageFile').files[0];
if (!file) return alert('请选择图片');
const fd = new FormData();
fd.append('type', 'image');
fd.append('file', file);
fd.append('content', document.getElementById('imageDesc').value);
const res = await fetch(`/api/topics/${topicId}/materials`, {method: 'POST', body: fd});
if (res.ok) location.reload();
else { const d = await res.json(); alert(d.error); }
}
async function deleteMaterial(id) {
if (!confirm('确定删除此素材?')) return;
await fetch(`/api/materials/${id}`, {method: 'DELETE'});
document.getElementById(`mat-${id}`).remove();
}
async function savePrompt() {
const prompt = document.getElementById('promptText').value;
const res = await fetch(`/api/topics/${topicId}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({prompt})
});
if (res.ok) {
const btn = event.target.closest('button');
btn.innerHTML = '<i class="bi bi-check-lg me-1"></i>已保存';
btn.classList.replace('btn-outline-accent', 'btn-success');
setTimeout(() => { btn.innerHTML = '<i class="bi bi-check-lg me-1"></i>保存'; btn.classList.replace('btn-success', 'btn-outline-accent'); }, 1500);
}
}
// ===== Prompt历史 =====
let promptHistoryLoaded = false;
async function togglePromptHistory() {
const area = document.getElementById('promptHistoryArea');
if (area.classList.contains('show')) {
area.classList.remove('show');
return;
}
if (!promptHistoryLoaded) {
const res = await fetch(`/api/topics/${topicId}/prompt-history`);
const history = await res.json();
const list = document.getElementById('promptHistoryList');
if (history.length === 0) {
list.innerHTML = '<div class="text-muted small">暂无历史记录</div>';
} else {
list.innerHTML = history.map(h => `
<div class="prompt-history-item" onclick="restorePrompt('${escapeAttr(h.prompt)}')">
<div class="ph-text">${escapeHtml(h.prompt)}</div>
<div class="ph-meta">${h.created_at} · ${h.model}</div>
</div>
`).join('');
}
promptHistoryLoaded = true;
}
area.classList.add('show');
}
function restorePrompt(text) {
document.getElementById('promptText').value = text;
}
// ===== 文章生成 =====
async function generateArticle() {
const btn = document.getElementById('generateBtn');
const orig = btn.innerHTML;
btn.innerHTML = '<span class="spinner-generate me-2"></span>生成中...';
btn.classList.add('generating');
try {
const res = await fetch(`/api/topics/${topicId}/generate`, {method: 'POST'});
if (!res.ok) { const d = await res.json(); throw new Error(d.error || '生成失败'); }
const article = await res.json();
// 清除空状态提示
const area = document.getElementById('articlesArea');
const empty = area.querySelector('.text-center');
if (empty) empty.remove();
// 插入新文章
const html = `<div class="article-card" id="article-${article.id}">
<div class="article-header">
<span class="small text-muted">
<i class="bi bi-clock me-1"></i>${article.created_at} · ${article.model}
${article.prompt_used ? `<span class="copy-btn ms-2" onclick="toggleArticlePrompt('${article.id}')" title="查看Prompt"><i class="bi bi-chat-left-text"></i></span>` : ''}
</span>
<div>
<span class="copy-btn me-2" onclick="copyArticle('${article.id}')" title="复制"><i class="bi bi-clipboard"></i></span>
<span class="copy-btn me-2" onclick="downloadArticle('${article.id}')" title="下载"><i class="bi bi-download"></i></span>
<span class="copy-btn text-danger" onclick="deleteArticle('${article.id}')" title="删除"><i class="bi bi-trash3"></i></span>
</div>
</div>
${article.prompt_used ? `<div class="collapsible-prompt" id="article-prompt-${article.id}"><div style="padding: 8px 14px; background: rgba(88,166,255,0.05); border-bottom: 1px solid var(--border); font-size: 0.82rem; white-space: pre-wrap; color: var(--text-muted);">${escapeHtml(article.prompt_used)}</div></div>` : ''}
<div class="article-body article-content" id="article-content-${article.id}">${escapeHtml(article.content)}</div>
</div>`;
area.insertAdjacentHTML('afterbegin', html);
} catch(e) {
alert('生成失败: ' + e.message);
} finally {
btn.innerHTML = orig;
btn.classList.remove('generating');
}
}
async function deleteArticle(id) {
if (!confirm('确定删除此文章?')) return;
await fetch(`/api/articles/${id}`, {method: 'DELETE'});
document.getElementById(`article-${id}`).remove();
}
function copyArticle(id) {
const el = document.getElementById(`article-content-${id}`);
navigator.clipboard.writeText(el.innerText).then(() => {});
}
function downloadArticle(id) {
const el = document.getElementById(`article-content-${id}`);
const blob = new Blob([el.innerText], {type: 'text/plain;charset=utf-8'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `article_${id}.txt`;
a.click();
}
function toggleArticlePrompt(id) {
const el = document.getElementById(`article-prompt-${id}`);
if (el) el.classList.toggle('show');
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function escapeAttr(text) {
return text.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n');
}
</script>
</body>
</html>