Files
article-writer/templates/index.html

563 lines
28 KiB
HTML
Raw Permalink 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>文章编写系统</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; min-height: 100vh; }
.text-muted { color: var(--text-muted) !important; }
.navbar { background: var(--bg-card); border-bottom: 1px solid var(--border); }
.navbar-brand { color: var(--accent) !important; font-weight: 700; font-size: 1.3rem; }
.topic-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 10px; padding: 20px; transition: all 0.2s; cursor: pointer; }
.topic-card:hover { border-color: var(--accent); background: var(--bg-hover); transform: translateY(-2px); }
.topic-card h5 { color: var(--text); margin-bottom: 8px; }
.topic-card .meta { color: var(--text-muted); font-size: 0.85rem; }
.topic-card .desc { color: var(--text-muted); font-size: 0.9rem; margin-top: 8px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.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); }
.empty-state { text-align: center; padding: 80px 20px; color: var(--text-muted); }
.empty-state i { font-size: 4rem; margin-bottom: 16px; display: block; }
.badge-mat { background: rgba(88,166,255,0.15); color: var(--accent); }
.badge-cat { background: rgba(210,153,34,0.15); color: var(--warning); font-size: 0.75rem; }
.badge-tag { background: rgba(63,185,80,0.15); color: var(--success); font-size: 0.75rem; cursor: pointer; }
.badge-tag:hover { background: rgba(63,185,80,0.3); }
.config-indicator { font-size: 0.8rem; }
.alert-info { background: rgba(88,166,255,0.12); border-color: rgba(88,166,255,0.25); color: var(--text); }
/* 分类筛选侧栏 */
.filter-sidebar { background: var(--bg-card); border: 1px solid var(--border); border-radius: 10px; padding: 16px; }
.filter-sidebar h6 { color: var(--accent); font-size: 0.9rem; margin-bottom: 10px; }
.filter-item { display: flex; align-items: center; gap: 6px; padding: 5px 8px; border-radius: 6px; cursor: pointer; transition: all 0.15s; font-size: 0.88rem; color: var(--text-muted); }
.filter-item:hover { background: var(--bg-hover); color: var(--text); }
.filter-item.active { background: rgba(88,166,255,0.12); color: var(--accent); }
.filter-item .count { margin-left: auto; font-size: 0.75rem; opacity: 0.6; }
.tag-cloud { display: flex; flex-wrap: wrap; gap: 6px; }
.tag-cloud .badge-tag { font-size: 0.78rem; padding: 3px 8px; }
.tag-cloud .badge-tag.active { background: rgba(63,185,80,0.35); border: 1px solid var(--success); }
.clear-filter { font-size: 0.78rem; color: var(--danger); cursor: pointer; opacity: 0.7; }
.clear-filter:hover { opacity: 1; }
/* 新建主题弹窗中的分类/标签选择 */
.select-pills { display: flex; flex-wrap: wrap; gap: 6px; }
.pill-option { padding: 4px 12px; border-radius: 16px; border: 1px solid var(--border); cursor: pointer; font-size: 0.85rem; transition: all 0.15s; color: var(--text-muted); }
.pill-option:hover { border-color: var(--accent); color: var(--accent); }
.pill-option.selected { background: rgba(88,166,255,0.15); border-color: var(--accent); color: var(--accent); }
.pill-option.selected-tag { background: rgba(63,185,80,0.15); border-color: var(--success); color: var(--success); }
.tag-input-group { display: flex; gap: 6px; }
.tag-input-group input { flex: 1; }
</style>
</head>
<body>
<nav class="navbar navbar-expand">
<div class="container">
<a class="navbar-brand" href="/"><i class="bi bi-pencil-square me-2"></i>文章编写系统</a>
<div class="ms-auto d-flex align-items-center gap-2">
<span class="config-indicator text-muted" id="configIndicator">
<i class="bi bi-circle text-warning"></i> 未配置
</span>
<button class="btn btn-outline-accent btn-sm" onclick="showConfigModal()">
<i class="bi bi-gear me-1"></i>模型配置
</button>
<button class="btn btn-outline-accent btn-sm" onclick="showCategoryModal()">
<i class="bi bi-folder me-1"></i>分类管理
</button>
<button class="btn btn-accent" onclick="showCreateModal()">
<i class="bi bi-plus-lg me-1"></i>新建主题
</button>
</div>
</div>
</nav>
<div class="container py-4">
<div class="row g-3">
<!-- 左侧:筛选栏 -->
<div class="col-md-3 col-lg-2">
<div class="filter-sidebar mb-3">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0"><i class="bi bi-folder2 me-1"></i>分类</h6>
<span class="clear-filter" id="clearCatFilter" style="display:none;" onclick="clearCategoryFilter()">清除</span>
</div>
<div class="filter-item {% if not request.args.get('cat') %}active{% endif %}" onclick="filterByCategory('')">
<i class="bi bi-grid"></i> 全部
</div>
{% for c in categories %}
<div class="filter-item" data-cat-id="{{c.id}}" onclick="filterByCategory('{{c.id}}')">
<i class="bi bi-folder2"></i> {{c.name}}
<span class="count" id="cat-count-{{c.id}}"></span>
</div>
{% endfor %}
</div>
<div class="filter-sidebar">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0"><i class="bi bi-tags me-1"></i>标签</h6>
<span class="clear-filter" id="clearTagFilter" style="display:none;" onclick="clearTagFilter()">清除</span>
</div>
<div class="tag-cloud">
{% for t in tags %}
<span class="badge-tag" data-tag-id="{{t.id}}" data-tag-name="{{t.name}}" onclick="filterByTag('{{t.id}}')">{{t.name}}</span>
{% endfor %}
{% if not tags %}
<span class="text-muted small">暂无标签</span>
{% endif %}
</div>
</div>
</div>
<!-- 右侧:主题卡片 -->
<div class="col-md-9 col-lg-10">
{% if topics %}
<div class="row g-3" id="topicGrid">
{% for t in topics %}
<div class="col-md-6 col-lg-4 topic-item"
data-categories="{{t.categories | map(attribute='id') | join(',')}}"
data-tags="{{t.tags | map(attribute='id') | join(',')}}">
<div class="topic-card" onclick="location.href='/topic/{{t.id}}'">
<div class="d-flex justify-content-between align-items-start">
<h5 class="mb-0"><i class="bi bi-folder2-open me-2 text-warning"></i>{{t.name}}</h5>
<button class="btn btn-sm btn-link text-danger p-0" onclick="event.stopPropagation();deleteTopic('{{t.id}}','{{t.name}}')" title="删除">
<i class="bi bi-trash3"></i>
</button>
</div>
{% if t.description %}<div class="desc">{{t.description}}</div>{% endif %}
<!-- 分类标签 -->
{% if t.categories %}
<div class="mt-2">
{% for c in t.categories %}
<span class="badge badge-cat me-1"><i class="bi bi-folder2 me-1"></i>{{c.name}}</span>
{% endfor %}
</div>
{% endif %}
<!-- 标签 -->
{% if t.tags %}
<div class="mt-1">
{% for tg in t.tags %}
<span class="badge badge-tag me-1"><i class="bi bi-tag me-1"></i>{{tg.name}}</span>
{% endfor %}
</div>
{% endif %}
<div class="meta mt-2">
<span class="badge badge-mat me-1"><i class="bi bi-file-earmark-text me-1"></i>{{t.mat_count}} 素材</span>
<span class="ms-2"><i class="bi bi-clock me-1"></i>{{t.updated_at}}</span>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<i class="bi bi-journal-text"></i>
<h5>还没有主题</h5>
<p>点击右上角「新建主题」开始吧</p>
</div>
{% endif %}
</div>
</div>
</div>
<!-- 新建主题模态框 -->
<div class="modal fade" id="createModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">新建主题</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="text" class="form-control" id="topicName" placeholder="如AI技术趋势">
</div>
<div class="mb-3">
<label class="form-label">主题说明</label>
<textarea class="form-control" id="topicDesc" rows="2" placeholder="简要描述这个主题的方向"></textarea>
</div>
<div class="mb-3">
<label class="form-label">分类(可多选)</label>
<div class="select-pills" id="categoryPills">
<!-- 动态填充 -->
</div>
<div class="form-text">点击选择所属分类</div>
</div>
<div class="mb-3">
<label class="form-label">标签</label>
<div class="select-pills mb-2" id="tagPills">
<!-- 动态填充已有标签 -->
</div>
<div class="tag-input-group">
<input type="text" class="form-control form-control-sm" id="newTagInput" placeholder="输入新标签,回车添加">
<button class="btn btn-sm btn-outline-accent" onclick="addNewTagToForm()">添加</button>
</div>
<div class="select-pills mt-1" id="newTagPills">
<!-- 动态填充新标签 -->
</div>
</div>
<div class="mb-3">
<label class="form-label">默认Prompt</label>
<textarea class="form-control" id="topicPrompt" rows="4" placeholder="请根据以下素材撰写一篇结构清晰、内容丰富的文章:"></textarea>
</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="createTopic()">创建</button>
</div>
</div>
</div>
</div>
<!-- 分类管理模态框 -->
<div class="modal fade" id="categoryModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-folder me-2"></i>分类管理</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="d-flex gap-2 mb-3">
<input type="text" class="form-control form-control-sm" id="newCatName" placeholder="新分类名称">
<button class="btn btn-sm btn-accent" onclick="createCategory()">添加</button>
</div>
<div id="categoryList">
<!-- 动态填充 -->
</div>
</div>
</div>
</div>
</div>
<!-- LLM配置模态框 -->
<div class="modal fade" id="configModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-gear 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">API 地址</label>
<input type="text" class="form-control" id="cfgUrl" placeholder="https://api.openai.com/v1/chat/completions">
<div class="form-text text-muted">兼容 OpenAI 格式的接口地址</div>
</div>
<div class="mb-3">
<label class="form-label">API Key</label>
<div class="input-group">
<input type="password" class="form-control" id="cfgKey" placeholder="留空则保持原配置不变">
<button class="btn btn-outline-accent" type="button" onclick="toggleKeyVisibility()">
<i class="bi bi-eye" id="keyEyeIcon"></i>
</button>
</div>
<div class="form-text text-muted" id="cfgKeyHint"></div>
</div>
<div class="mb-3">
<label class="form-label">模型名称</label>
<input type="text" class="form-control" id="cfgModel" placeholder="如gpt-4o、qwen3.6-plus、deepseek-chat">
</div>
<div class="alert alert-info py-2 small">
<i class="bi bi-info-circle me-1"></i>配置保存后立即生效,所有主题生成文章时使用此配置。
</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="saveConfig()">
<i class="bi bi-check-lg me-1"></i>保存配置
</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 createModalInstance = new bootstrap.Modal(document.getElementById('createModal'));
const configModalInstance = new bootstrap.Modal(document.getElementById('configModal'));
const categoryModalInstance = new bootstrap.Modal(document.getElementById('categoryModal'));
// 筛选状态
let activeCategoryId = '';
let activeTagId = '';
// 页面加载时获取配置状态
(async function() {
try {
const res = await fetch('/api/llm-config');
const cfg = await res.json();
const ind = document.getElementById('configIndicator');
if (cfg.api_key_set) {
ind.innerHTML = `<i class="bi bi-circle-fill text-success"></i> ${cfg.model}`;
} else {
ind.innerHTML = `<i class="bi bi-circle text-warning"></i> 未配置`;
}
} catch(e) {}
})();
// ===== 分类筛选 =====
function filterByCategory(catId) {
activeCategoryId = catId;
// 更新侧栏激活状态
document.querySelectorAll('.filter-sidebar .filter-item').forEach(el => {
el.classList.toggle('active', el.dataset.catId === catId || (!catId && !el.dataset.catId));
});
document.getElementById('clearCatFilter').style.display = catId ? 'inline' : 'none';
applyFilters();
}
function clearCategoryFilter() { filterByCategory(''); }
function filterByTag(tagId) {
if (activeTagId === tagId) {
activeTagId = ''; // 再次点击取消
} else {
activeTagId = tagId;
}
// 更新标签云激活状态
document.querySelectorAll('.tag-cloud .badge-tag').forEach(el => {
el.classList.toggle('active', el.dataset.tagId === activeTagId);
});
document.getElementById('clearTagFilter').style.display = activeTagId ? 'inline' : 'none';
applyFilters();
}
function clearTagFilter() { activeTagId = ''; document.querySelectorAll('.tag-cloud .badge-tag').forEach(el => el.classList.remove('active')); document.getElementById('clearTagFilter').style.display = 'none'; applyFilters(); }
function applyFilters() {
const items = document.querySelectorAll('.topic-item');
items.forEach(el => {
const cats = el.dataset.categories ? el.dataset.categories.split(',') : [];
const tags = el.dataset.tags ? el.dataset.tags.split(',') : [];
let show = true;
if (activeCategoryId && !cats.includes(activeCategoryId)) show = false;
if (activeTagId && !tags.includes(activeTagId)) show = false;
el.style.display = show ? '' : 'none';
});
}
// ===== 新建主题 =====
let selectedCategoryIds = [];
let selectedTagIds = [];
let newTagNames = [];
function showCreateModal() {
selectedCategoryIds = [];
selectedTagIds = [];
newTagNames = [];
document.getElementById('topicName').value = '';
document.getElementById('topicDesc').value = '';
document.getElementById('topicPrompt').value = '';
document.getElementById('newTagInput').value = '';
document.getElementById('newTagPills').innerHTML = '';
loadCategoryPills();
loadTagPills();
createModalInstance.show();
}
async function loadCategoryPills() {
try {
const res = await fetch('/api/categories');
const cats = await res.json();
const container = document.getElementById('categoryPills');
container.innerHTML = cats.map(c =>
`<span class="pill-option" data-id="${c.id}" onclick="toggleCategoryPill(this,'${c.id}')">${c.name}</span>`
).join('');
} catch(e) {}
}
async function loadTagPills() {
try {
const res = await fetch('/api/tags');
const tags = await res.json();
const container = document.getElementById('tagPills');
container.innerHTML = tags.map(t =>
`<span class="pill-option" data-id="${t.id}" onclick="toggleTagPill(this,'${t.id}')">${t.name}</span>`
).join('');
} catch(e) {}
}
function toggleCategoryPill(el, id) {
const idx = selectedCategoryIds.indexOf(id);
if (idx >= 0) {
selectedCategoryIds.splice(idx, 1);
el.classList.remove('selected');
} else {
selectedCategoryIds.push(id);
el.classList.add('selected');
}
}
function toggleTagPill(el, id) {
const idx = selectedTagIds.indexOf(id);
if (idx >= 0) {
selectedTagIds.splice(idx, 1);
el.classList.remove('selected-tag');
} else {
selectedTagIds.push(id);
el.classList.add('selected-tag');
}
}
function addNewTagToForm() {
const input = document.getElementById('newTagInput');
const name = input.value.trim();
if (!name || newTagNames.includes(name)) return;
newTagNames.push(name);
input.value = '';
renderNewTagPills();
}
document.getElementById('newTagInput').addEventListener('keydown', function(e) {
if (e.key === 'Enter') { e.preventDefault(); addNewTagToForm(); }
});
function renderNewTagPills() {
const container = document.getElementById('newTagPills');
container.innerHTML = newTagNames.map((n, i) =>
`<span class="pill-option selected-tag" onclick="removeNewTag(${i})">${n} <i class="bi bi-x"></i></span>`
).join('');
}
function removeNewTag(idx) {
newTagNames.splice(idx, 1);
renderNewTagPills();
}
async function createTopic() {
const name = document.getElementById('topicName').value.trim();
if (!name) return alert('请输入主题名称');
const res = await fetch('/api/topics', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
name,
description: document.getElementById('topicDesc').value,
prompt: document.getElementById('topicPrompt').value,
category_ids: selectedCategoryIds,
tag_ids: selectedTagIds,
new_tags: newTagNames
})
});
if (res.ok) location.reload();
else { const d = await res.json(); alert(d.error || '创建失败'); }
}
async function deleteTopic(id, name) {
if (!confirm(`确定删除主题「${name}」?所有素材和文章将被清除!`)) return;
const res = await fetch(`/api/topics/${id}`, {method: 'DELETE'});
if (res.ok) location.reload();
}
// ===== 分类管理 =====
async function showCategoryModal() {
categoryModalInstance.show();
await loadCategoryList();
}
async function loadCategoryList() {
try {
const res = await fetch('/api/categories');
const cats = await res.json();
const container = document.getElementById('categoryList');
if (cats.length === 0) {
container.innerHTML = '<div class="text-muted small text-center py-2">暂无分类</div>';
return;
}
container.innerHTML = cats.map(c => `
<div class="d-flex justify-content-between align-items-center p-2 mb-1" style="background:var(--bg-dark);border-radius:6px;">
<span><i class="bi bi-folder2 text-warning me-2"></i>${c.name} <span class="text-muted small">(${c.topic_count || 0})</span></span>
<button class="btn btn-sm btn-link text-danger p-0" onclick="deleteCategory('${c.id}','${c.name}')"><i class="bi bi-trash3"></i></button>
</div>
`).join('');
} catch(e) {}
}
async function createCategory() {
const name = document.getElementById('newCatName').value.trim();
if (!name) return alert('请输入分类名称');
const res = await fetch('/api/categories', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name})
});
if (res.ok) {
document.getElementById('newCatName').value = '';
await loadCategoryList();
} else {
const d = await res.json(); alert(d.error || '创建失败');
}
}
async function deleteCategory(id, name) {
if (!confirm(`确定删除分类「${name}」?`)) return;
await fetch(`/api/categories/${id}`, {method: 'DELETE'});
await loadCategoryList();
}
// ===== LLM配置 =====
async function showConfigModal() {
try {
const res = await fetch('/api/llm-config');
const cfg = await res.json();
document.getElementById('cfgUrl').value = cfg.url || '';
document.getElementById('cfgKey').value = '';
document.getElementById('cfgKeyHint').textContent = cfg.api_key_set ? `当前: ${cfg.api_key_masked}` : '尚未配置';
document.getElementById('cfgModel').value = cfg.model || '';
} catch(e) {}
configModalInstance.show();
}
function toggleKeyVisibility() {
const inp = document.getElementById('cfgKey');
const icon = document.getElementById('keyEyeIcon');
if (inp.type === 'password') {
inp.type = 'text';
icon.className = 'bi bi-eye-slash';
} else {
inp.type = 'password';
icon.className = 'bi bi-eye';
}
}
async function saveConfig() {
const data = {
url: document.getElementById('cfgUrl').value.trim(),
model: document.getElementById('cfgModel').value.trim()
};
const key = document.getElementById('cfgKey').value.trim();
if (key) data.api_key = key;
const res = await fetch('/api/llm-config', {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
});
if (res.ok) {
const ind = document.getElementById('configIndicator');
ind.innerHTML = `<i class="bi bi-circle-fill text-success"></i> ${data.model}`;
configModalInstance.hide();
} else {
const d = await res.json();
alert(d.error || '保存失败');
}
}
</script>
</body>
</html>