Files
article-writer/templates/index.html
hubian 6bf20062d2 feat: 添加网络搜索功能(Tavily Search)
- 后端:新增 search_config 表存储搜索接口配置
- 后端:实现 /api/search 和 /api/search/detail API
- 前端:首页添加搜索配置入口(模态框)
- 前端:主题详情页添加搜索面板(搜索框+结果列表+详情查看)
- 搜索结果可一键添加为素材
- 支持搜索深度和时间范围筛选
- 更新 README 文档
2026-05-23 23:22:30 +08:00

980 lines
49 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="showPromptTemplateModal()">
<i class="bi bi-chat-square-text me-1"></i>Prompt模板
</button>
<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="showSearchConfigModal()">
<i class="bi bi-search 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-outline-accent btn-sm" onclick="showTagModal()">
<i class="bi bi-tags 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>
<textarea class="form-control" id="topicCoreIdea" rows="3" placeholder="概括整体思路、文章风格、创作方向等将指导AI分析和创作"></textarea>
<div class="form-text">简洁概括核心思路与风格要求AI分析和生成文章时会参考此内容</div>
</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>
<div class="mb-2">
<select class="form-select form-select-sm" id="topicPromptTemplate" onchange="applyPromptTemplate(this)">
<option value="">-- 选择Prompt模板 --</option>
<option value="__custom__">自定义输入</option>
</select>
<div class="form-text">选择模板自动填充,也可手动编辑</div>
</div>
<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>
<!-- 标签管理模态框 -->
<div class="modal fade" id="tagModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-tags 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="newTagName" placeholder="新标签名称">
<button class="btn btn-sm btn-accent" onclick="createTag()">添加</button>
</div>
<div id="tagList">
<!-- 动态填充 -->
</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>
<!-- 搜索配置模态框 -->
<div class="modal fade" id="searchConfigModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-search 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>
<select class="form-select" id="searchProvider">
<option value="tavily">Tavily Search</option>
</select>
<div class="form-text text-muted">当前仅支持 Tavily Search后续可扩展更多搜索服务</div>
</div>
<div class="mb-3">
<label class="form-label">API Key</label>
<div class="input-group">
<input type="password" class="form-control" id="searchApiKey" placeholder="留空则保持原配置不变">
<button class="btn btn-outline-accent" type="button" onclick="toggleSearchKeyVisibility()">
<i class="bi bi-eye" id="searchKeyEyeIcon"></i>
</button>
</div>
<div class="form-text text-muted" id="searchKeyHint"></div>
</div>
<div class="alert alert-info py-2 small">
<i class="bi bi-info-circle me-1"></i>配置保存后,在各主题页面可使用搜索功能搜集素材。<br>
获取 Tavily API Key: <a href="https://tavily.com" target="_blank" style="color:var(--accent)">tavily.com</a>
</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="saveSearchConfig()">
<i class="bi bi-check-lg me-1"></i>保存配置
</button>
</div>
</div>
</div>
</div>
<!-- Prompt模板管理模态框 -->
<div class="modal fade" id="promptTemplateModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-chat-square-text me-2"></i>Prompt模板管理</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="card-dark p-3 mb-3" style="border:1px solid var(--border);">
<div class="mb-2">
<label class="form-label small">模板名称</label>
<input type="text" class="form-control form-control-sm" id="newTplName" placeholder="如:通用写作">
</div>
<div class="mb-2">
<label class="form-label small">Prompt内容</label>
<textarea class="form-control form-control-sm" id="newTplPrompt" rows="4" placeholder="请根据以下素材撰写一篇..."></textarea>
</div>
<button class="btn btn-sm btn-accent" onclick="createPromptTemplate()"><i class="bi bi-plus-lg me-1"></i>添加模板</button>
</div>
<div id="promptTemplateList">
<!-- 动态填充 -->
</div>
</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 searchConfigModalInstance = new bootstrap.Modal(document.getElementById('searchConfigModal'));
const categoryModalInstance = new bootstrap.Modal(document.getElementById('categoryModal'));
const tagModalInstance = new bootstrap.Modal(document.getElementById('tagModal'));
const promptTemplateModalInstance = new bootstrap.Modal(document.getElementById('promptTemplateModal'));
// Prompt模板数据缓存
let promptTemplatesCache = [];
// 筛选状态
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('topicCoreIdea').value = '';
document.getElementById('topicPrompt').value = '';
document.getElementById('topicPromptTemplate').value = '';
document.getElementById('newTagInput').value = '';
document.getElementById('newTagPills').innerHTML = '';
loadCategoryPills();
loadTagPills();
loadPromptTemplates();
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,
core_idea: document.getElementById('topicCoreIdea').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;" id="cat-row-${c.id}">
<span id="cat-display-${c.id}"><i class="bi bi-folder2 text-warning me-2"></i>${c.name} <span class="text-muted small">(${c.topic_count || 0})</span></span>
<span id="cat-edit-${c.id}" style="display:none;" class="d-flex align-items-center gap-1 flex-grow-1 ms-2">
<input type="text" class="form-control form-control-sm" id="cat-input-${c.id}" value="${c.name}" style="max-width:160px;">
<button class="btn btn-sm btn-accent py-0 px-2" onclick="saveCategory('${c.id}')"><i class="bi bi-check-lg"></i></button>
<button class="btn btn-sm btn-secondary py-0 px-2" onclick="cancelCategoryEdit('${c.id}','${c.name}')"><i class="bi bi-x-lg"></i></button>
</span>
<span id="cat-actions-${c.id}" class="d-flex gap-1">
<button class="btn btn-sm btn-link text-accent p-0" onclick="editCategory('${c.id}')" title="编辑"><i class="bi bi-pencil"></i></button>
<button class="btn btn-sm btn-link text-danger p-0" onclick="deleteCategory('${c.id}','${c.name}')" title="删除"><i class="bi bi-trash3"></i></button>
</span>
</div>
`).join('');
} catch(e) {}
}
function editCategory(id) {
document.getElementById('cat-display-' + id).style.display = 'none';
document.getElementById('cat-edit-' + id).style.display = 'flex';
document.getElementById('cat-actions-' + id).style.display = 'none';
const input = document.getElementById('cat-input-' + id);
input.focus();
input.select();
// 回车保存
input.onkeydown = function(e) { if (e.key === 'Enter') saveCategory(id); if (e.key === 'Escape') cancelCategoryEdit(id, input.value); };
}
function cancelCategoryEdit(id, originalName) {
document.getElementById('cat-display-' + id).style.display = '';
document.getElementById('cat-edit-' + id).style.display = 'none';
document.getElementById('cat-actions-' + id).style.display = 'flex';
document.getElementById('cat-input-' + id).value = originalName;
}
async function saveCategory(id) {
const name = document.getElementById('cat-input-' + id).value.trim();
if (!name) return alert('分类名不能为空');
const res = await fetch(`/api/categories/${id}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name})
});
if (res.ok) {
await loadCategoryList();
} else {
const d = await res.json(); alert(d.error || '修改失败');
}
}
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();
}
// ===== 标签管理 =====
async function showTagModal() {
tagModalInstance.show();
await loadTagList();
}
async function loadTagList() {
try {
const res = await fetch('/api/tags');
const tags = await res.json();
const container = document.getElementById('tagList');
if (tags.length === 0) {
container.innerHTML = '<div class="text-muted small text-center py-2">暂无标签</div>';
return;
}
container.innerHTML = tags.map(t => `
<div class="d-flex justify-content-between align-items-center p-2 mb-1" style="background:var(--bg-dark);border-radius:6px;" id="tag-row-${t.id}">
<span id="tag-display-${t.id}"><i class="bi bi-tag text-success me-2"></i>${t.name} <span class="text-muted small">(${t.topic_count || 0})</span></span>
<span id="tag-edit-${t.id}" style="display:none;" class="d-flex align-items-center gap-1 flex-grow-1 ms-2">
<input type="text" class="form-control form-control-sm" id="tag-input-${t.id}" value="${t.name}" style="max-width:160px;">
<button class="btn btn-sm btn-accent py-0 px-2" onclick="saveTag('${t.id}')"><i class="bi bi-check-lg"></i></button>
<button class="btn btn-sm btn-secondary py-0 px-2" onclick="cancelTagEdit('${t.id}','${t.name}')"><i class="bi bi-x-lg"></i></button>
</span>
<span id="tag-actions-${t.id}" class="d-flex gap-1">
<button class="btn btn-sm btn-link text-accent p-0" onclick="editTag('${t.id}')" title="编辑"><i class="bi bi-pencil"></i></button>
<button class="btn btn-sm btn-link text-danger p-0" onclick="deleteTag('${t.id}','${t.name}')" title="删除"><i class="bi bi-trash3"></i></button>
</span>
</div>
`).join('');
} catch(e) {}
}
function editTag(id) {
document.getElementById('tag-display-' + id).style.display = 'none';
document.getElementById('tag-edit-' + id).style.display = 'flex';
document.getElementById('tag-actions-' + id).style.display = 'none';
const input = document.getElementById('tag-input-' + id);
input.focus();
input.select();
input.onkeydown = function(e) { if (e.key === 'Enter') saveTag(id); if (e.key === 'Escape') cancelTagEdit(id, input.value); };
}
function cancelTagEdit(id, originalName) {
document.getElementById('tag-display-' + id).style.display = '';
document.getElementById('tag-edit-' + id).style.display = 'none';
document.getElementById('tag-actions-' + id).style.display = 'flex';
document.getElementById('tag-input-' + id).value = originalName;
}
async function saveTag(id) {
const name = document.getElementById('tag-input-' + id).value.trim();
if (!name) return alert('标签名不能为空');
const res = await fetch(`/api/tags/${id}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name})
});
if (res.ok) {
await loadTagList();
} else {
const d = await res.json(); alert(d.error || '修改失败');
}
}
async function createTag() {
const name = document.getElementById('newTagName').value.trim();
if (!name) return alert('请输入标签名称');
const res = await fetch('/api/tags', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name})
});
if (res.ok) {
document.getElementById('newTagName').value = '';
await loadTagList();
} else {
const d = await res.json(); alert(d.error || '创建失败');
}
}
async function deleteTag(id, name) {
if (!confirm(`确定删除标签「${name}」?`)) return;
await fetch(`/api/tags/${id}`, {method: 'DELETE'});
await loadTagList();
}
// ===== 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';
}
}
// ===== Prompt模板管理 =====
async function loadPromptTemplates() {
try {
const res = await fetch('/api/prompt-templates');
promptTemplatesCache = await res.json();
// 填充新建主题的模板下拉
const sel = document.getElementById('topicPromptTemplate');
const currentVal = sel.value;
sel.innerHTML = '<option value="">-- 选择Prompt模板 --</option><option value="__custom__">自定义输入</option>';
promptTemplatesCache.forEach(t => {
sel.innerHTML += `<option value="${t.id}">${t.name}</option>`;
});
sel.value = currentVal;
} catch(e) {}
}
function applyPromptTemplate(sel) {
const val = sel.value;
if (!val || val === '__custom__') return;
const tpl = promptTemplatesCache.find(t => t.id === val);
if (tpl) {
document.getElementById('topicPrompt').value = tpl.prompt;
}
}
async function showPromptTemplateModal() {
promptTemplateModalInstance.show();
await loadPromptTemplateList();
}
async function loadPromptTemplateList() {
try {
const res = await fetch('/api/prompt-templates');
const templates = await res.json();
promptTemplatesCache = templates;
const container = document.getElementById('promptTemplateList');
if (templates.length === 0) {
container.innerHTML = '<div class="text-muted small text-center py-2">暂无模板</div>';
return;
}
container.innerHTML = templates.map(t => `
<div class="d-flex align-items-start p-2 mb-2" style="background:var(--bg-dark);border-radius:8px;border:1px solid var(--border);" id="tpl-row-${t.id}">
<div class="flex-grow-1 me-2">
<div id="tpl-display-${t.id}">
<div class="d-flex align-items-center gap-2 mb-1">
<strong class="small">${escapeHtml(t.name)}</strong>
${t.is_default ? '<span class="badge bg-secondary" style="font-size:0.65rem;">内置</span>' : ''}
</div>
<div class="small text-muted" style="white-space:pre-wrap;max-height:60px;overflow:hidden;">${escapeHtml(t.prompt)}</div>
</div>
<div id="tpl-edit-${t.id}" style="display:none;">
<input type="text" class="form-control form-control-sm mb-1" id="tpl-input-name-${t.id}" value="${escapeHtml(t.name)}">
<textarea class="form-control form-control-sm" id="tpl-input-prompt-${t.id}" rows="3">${escapeHtml(t.prompt)}</textarea>
<div class="mt-1 d-flex gap-1">
<button class="btn btn-sm btn-accent py-0 px-2" onclick="savePromptTemplate('${t.id}')"><i class="bi bi-check-lg"></i></button>
<button class="btn btn-sm btn-secondary py-0 px-2" onclick="cancelTplEdit('${t.id}')"><i class="bi bi-x-lg"></i></button>
</div>
</div>
</div>
<div class="d-flex gap-1 flex-shrink-0" id="tpl-actions-${t.id}">
<button class="btn btn-sm btn-link text-accent p-0" onclick="editPromptTemplate('${t.id}')" title="编辑"><i class="bi bi-pencil"></i></button>
<button class="btn btn-sm btn-link text-danger p-0" onclick="deletePromptTemplate('${t.id}','${escapeHtml(t.name)}')" title="删除"><i class="bi bi-trash3"></i></button>
</div>
</div>
`).join('');
} catch(e) {}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function editPromptTemplate(id) {
document.getElementById('tpl-display-' + id).style.display = 'none';
document.getElementById('tpl-edit-' + id).style.display = '';
document.getElementById('tpl-actions-' + id).style.display = 'none';
document.getElementById('tpl-input-name-' + id).focus();
}
function cancelTplEdit(id) {
document.getElementById('tpl-display-' + id).style.display = '';
document.getElementById('tpl-edit-' + id).style.display = 'none';
document.getElementById('tpl-actions-' + id).style.display = 'flex';
}
async function savePromptTemplate(id) {
const name = document.getElementById('tpl-input-name-' + id).value.trim();
const prompt = document.getElementById('tpl-input-prompt-' + id).value.trim();
if (!name) return alert('模板名称不能为空');
if (!prompt) return alert('模板内容不能为空');
const res = await fetch(`/api/prompt-templates/${id}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name, prompt})
});
if (res.ok) {
await loadPromptTemplateList();
await loadPromptTemplates();
} else {
const d = await res.json(); alert(d.error || '保存失败');
}
}
async function createPromptTemplate() {
const name = document.getElementById('newTplName').value.trim();
const prompt = document.getElementById('newTplPrompt').value.trim();
if (!name) return alert('请输入模板名称');
if (!prompt) return alert('请输入Prompt内容');
const res = await fetch('/api/prompt-templates', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name, prompt})
});
if (res.ok) {
document.getElementById('newTplName').value = '';
document.getElementById('newTplPrompt').value = '';
await loadPromptTemplateList();
await loadPromptTemplates();
} else {
const d = await res.json(); alert(d.error || '创建失败');
}
}
async function deletePromptTemplate(id, name) {
if (!confirm(`确定删除Prompt模板「${name}」?`)) return;
await fetch(`/api/prompt-templates/${id}`, {method: 'DELETE'});
await loadPromptTemplateList();
await loadPromptTemplates();
}
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 || '保存失败');
}
}
// ===== 搜索配置 =====
async function showSearchConfigModal() {
document.getElementById('searchApiKey').value = '';
try {
const res = await fetch('/api/search-config');
const cfg = await res.json();
document.getElementById('searchProvider').value = cfg.provider || 'tavily';
document.getElementById('searchKeyHint').textContent = cfg.api_key_set ? `当前: ${cfg.api_key_masked}` : '尚未配置';
} catch(e) {}
searchConfigModalInstance.show();
}
function toggleSearchKeyVisibility() {
const inp = document.getElementById('searchApiKey');
const icon = document.getElementById('searchKeyEyeIcon');
if (inp.type === 'password') { inp.type = 'text'; icon.className = 'bi bi-eye-slash'; }
else { inp.type = 'password'; icon.className = 'bi bi-eye'; }
}
async function saveSearchConfig() {
const data = {
provider: document.getElementById('searchProvider').value
};
const key = document.getElementById('searchApiKey').value.trim();
if (key) data.api_key = key;
const res = await fetch('/api/search-config', {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
});
if (res.ok) {
searchConfigModalInstance.hide();
} else {
const d = await res.json(); alert(d.error || '保存失败');
}
}
</script>
</body>
</html>