Files
article-writer/templates/topic.html

1257 lines
68 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>{{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; }
.mat-note { color: var(--text-muted); font-size: 0.85rem; margin-top: 4px; padding: 4px 8px; background: rgba(88,166,255,0.05); border-radius: 4px; border-left: 2px solid var(--accent); }
.mat-analysis { color: var(--text); font-size: 0.85rem; margin-top: 6px; padding: 8px 10px; background: rgba(63,185,80,0.06); border-radius: 6px; border-left: 3px solid var(--success); white-space: pre-wrap; word-break: break-word; line-height: 1.6; position: relative; }
.mat-analysis-label { color: var(--success); font-size: 0.78rem; font-weight: 600; margin-bottom: 4px; display: flex; align-items: center; gap: 4px; }
.mat-analysis-actions { position: absolute; top: 6px; right: 6px; display: flex; gap: 4px; }
.mat-analysis-actions .btn-link { padding: 0; font-size: 0.75rem; }
.mat-note-edit { display: flex; gap: 6px; align-items: center; margin-top: 4px; }
.mat-note-edit input { flex: 1; }
.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; }
/* 分类和标签样式 */
.badge-cat { background: rgba(210,153,34,0.15); color: var(--warning); font-size: 0.78rem; }
.badge-tag { background: rgba(63,185,80,0.15); color: var(--success); font-size: 0.78rem; }
.info-bar { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; padding: 8px 16px; background: var(--bg-card); border-bottom: 1px solid var(--border); }
.info-bar .edit-btn { cursor: pointer; color: var(--text-muted); font-size: 0.8rem; }
.info-bar .edit-btn:hover { color: var(--accent); }
.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; }
/* 素材选择开关 */
.mat-select-toggle { position: relative; display: inline-block; width: 36px; height: 20px; flex-shrink: 0; }
.mat-select-toggle input { opacity: 0; width: 0; height: 0; }
.mat-select-toggle .slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background: var(--border); border-radius: 20px; transition: 0.2s; }
.mat-select-toggle .slider:before { content: ""; position: absolute; height: 14px; width: 14px; left: 3px; bottom: 3px; background: var(--text-muted); border-radius: 50%; transition: 0.2s; }
.mat-select-toggle input:checked + .slider { background: var(--accent); }
.mat-select-toggle input:checked + .slider:before { transform: translateX(16px); background: #fff; }
.material-item.unselected { opacity: 0.45; }
.material-item.unselected:hover { opacity: 0.7; }
.select-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; font-size: 0.82rem; }
.select-toolbar .select-count { color: var(--text-muted); }
.select-toolbar .btn-link { padding: 0; font-size: 0.82rem; text-decoration: none; }
/* 核心思路样式 */
.core-idea-bar { padding: 10px 16px; background: rgba(210,153,34,0.08); border-bottom: 1px solid var(--border); display: flex; align-items: flex-start; gap: 10px; }
.core-idea-bar .core-idea-icon { color: var(--warning); font-size: 1.1rem; flex-shrink: 0; margin-top: 2px; }
.core-idea-bar .core-idea-content { flex: 1; }
.core-idea-bar .core-idea-label { font-size: 0.78rem; font-weight: 600; color: var(--warning); margin-bottom: 2px; }
.core-idea-bar .core-idea-text { font-size: 0.9rem; color: var(--text); line-height: 1.6; white-space: pre-wrap; word-break: break-word; }
.core-idea-bar .core-idea-empty { font-size: 0.85rem; color: var(--text-muted); font-style: italic; }
.core-idea-bar .edit-btn { cursor: pointer; color: var(--text-muted); font-size: 0.8rem; flex-shrink: 0; }
.core-idea-bar .edit-btn:hover { color: var(--accent); }
.core-idea-edit-bar { padding: 10px 16px; background: rgba(210,153,34,0.08); border-bottom: 1px solid var(--border); display: none; }
.core-idea-edit-bar .edit-row { display: flex; gap: 6px; align-items: flex-end; }
.core-idea-edit-bar textarea { flex: 1; }
.core-idea-edit-bar .btn-group { display: flex; gap: 4px; flex-shrink: 0; }
/* 搜索面板样式 */
.search-panel { background: var(--bg-card); border: 1px solid var(--border); border-radius: 10px; }
.search-panel .card-header { background: transparent; border-bottom: 1px solid var(--border); padding: 12px 16px; }
.search-panel .card-body { padding: 16px; }
.search-bar { display: flex; gap: 6px; }
.search-bar input { flex: 1; }
.search-group-arrow { transition: transform 0.15s; }
.search-bar select { width: auto; min-width: 100px; }
.search-result-item { background: var(--bg-dark); border: 1px solid var(--border); border-radius: 8px; padding: 12px; margin-bottom: 8px; transition: all 0.15s; }
.search-result-item:hover { border-color: var(--accent); }
.search-result-item .result-title { color: var(--accent); font-weight: 600; font-size: 0.92rem; text-decoration: none; display: flex; align-items: center; gap: 6px; }
.search-result-item .result-title:hover { text-decoration: underline; }
.search-result-item .result-url { color: var(--text-muted); font-size: 0.78rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-top: 2px; }
.search-result-item .result-snippet { color: var(--text); font-size: 0.85rem; line-height: 1.5; margin-top: 6px; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.search-result-item .result-score { color: var(--text-muted); font-size: 0.72rem; margin-top: 4px; }
.search-result-item .result-actions { display: flex; gap: 6px; margin-top: 8px; align-items: center; }
.search-result-item .result-detail { display: none; margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border); }
.search-result-item .result-detail.show { display: block; }
.search-result-item .result-detail-content { font-size: 0.85rem; line-height: 1.7; white-space: pre-wrap; word-break: break-word; color: var(--text); max-height: 400px; overflow-y: auto; }
.search-loading { text-align: center; padding: 20px; color: var(--text-muted); }
.search-empty { text-align: center; padding: 20px; color: var(--text-muted); }
</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="info-bar">
<span class="text-muted small"><i class="bi bi-folder2 me-1"></i>分类:</span>
{% if topic.categories %}
{% for c in topic.categories %}
<span class="badge badge-cat">{{c.name}}</span>
{% endfor %}
{% else %}
<span class="text-muted small">未分类</span>
{% endif %}
<span class="text-muted small ms-2"><i class="bi bi-tags me-1"></i>标签:</span>
{% if topic.tags %}
{% for tg in topic.tags %}
<span class="badge badge-tag">{{tg.name}}</span>
{% endfor %}
{% else %}
<span class="text-muted small">无标签</span>
{% endif %}
<span class="edit-btn ms-2" onclick="showEditCatTagModal()"><i class="bi bi-pencil"></i> 编辑</span>
</div>
<!-- 核心思路显示 -->
<div class="core-idea-bar" id="coreIdeaDisplay">
<i class="bi bi-lightbulb core-idea-icon"></i>
<div class="core-idea-content">
<div class="core-idea-label">核心思路</div>
{% if topic.core_idea %}
<div class="core-idea-text" id="coreIdeaText">{{topic.core_idea}}</div>
{% else %}
<div class="core-idea-empty" id="coreIdeaText">尚未设定点击编辑添加核心思路以指导AI分析和创作</div>
{% endif %}
</div>
<span class="edit-btn" onclick="showEditCoreIdea()"><i class="bi bi-pencil"></i> 编辑</span>
</div>
<!-- 核心思路编辑区 -->
<div class="core-idea-edit-bar" id="coreIdeaEdit">
<div class="edit-row">
<textarea class="form-control form-control-sm" id="coreIdeaInput" rows="3" placeholder="概括整体思路、文章风格、创作方向等将指导AI分析和创作">{{topic.core_idea or ''}}</textarea>
<div class="btn-group">
<button class="btn btn-sm btn-accent py-0 px-2" onclick="saveCoreIdea()"><i class="bi bi-check-lg"></i></button>
<button class="btn btn-sm btn-secondary py-0 px-2" onclick="cancelEditCoreIdea()"><i class="bi bi-x-lg"></i></button>
</div>
</div>
</div>
<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>
<!-- Prompt模板选择 -->
<div class="mb-2">
<select class="form-select form-select-sm" id="topicPromptTemplate" onchange="applyTopicPromptTemplate(this)">
<option value="">-- 选择Prompt模板 --</option>
<option value="__custom__">自定义输入</option>
</select>
<div class="form-text">选择模板自动填充,也可手动编辑</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="search-panel mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<span class="section-title mb-0"><i class="bi bi-search text-accent"></i>网络搜索 <span class="badge bg-secondary ms-1" id="searchCountBadge">0</span></span>
<div class="d-flex align-items-center gap-2">
<button class="btn btn-sm btn-link text-danger p-0" onclick="clearAllSearchResults()" title="清空所有搜索结果" id="clearSearchBtn" style="display:none;">
<i class="bi bi-trash3 me-1"></i>清空
</button>
<button class="btn btn-sm btn-link text-muted p-0" onclick="toggleSearchPanel()" title="折叠/展开">
<i class="bi bi-chevron-down" id="searchPanelToggle"></i>
</button>
</div>
</div>
<div class="card-body" id="searchPanelBody">
<div class="mb-3">
<div class="d-flex gap-1 mb-2 align-items-center" style="flex-wrap:wrap;">
<input type="number" class="form-control" id="searchMaxResults" value="10" min="1" max="20" title="结果数量" style="width:52px;height:26px;font-size:0.75rem;padding:0 4px;text-align:center;">
<select class="form-select" id="searchDepth" style="width:auto;min-width:68px;height:26px;font-size:0.75rem;padding:0 4px;">
<option value="basic">基础</option>
<option value="advanced">深度</option>
</select>
<select class="form-select" id="searchTimeRange" style="width:auto;min-width:68px;height:26px;font-size:0.75rem;padding:0 4px;">
<option value="">不限</option>
<option value="day">一天</option>
<option value="week">一周</option>
<option value="month">一月</option>
<option value="year">一年</option>
</select>
<button class="btn btn-accent" onclick="doSearch()" id="searchBtn" style="height:26px;font-size:0.75rem;padding:0 10px;">
<i class="bi bi-search"></i>
</button>
</div>
<input type="text" class="form-control" id="searchQuery" placeholder="输入关键词搜索相关资料..." onkeydown="if(event.key==='Enter'){event.preventDefault();doSearch();}" style="font-size:1rem;padding:10px 14px;">
</div>
<div id="searchResults">
<div class="search-empty">
<i class="bi bi-globe2 d-block" style="font-size:2rem;opacity:0.3;"></i>
<small>搜索网络资料,结果可添加为素材</small>
</div>
</div>
</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" id="matCountBadge">{{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="select-toolbar">
<label class="mat-select-toggle" title="全选/取消全选">
<input type="checkbox" id="selectAllToggle" onchange="toggleSelectAll(this.checked)">
<span class="slider"></span>
</label>
<span class="select-count" id="selectCount"></span>
<span class="btn-link text-accent" onclick="selectAllMaterials(true)">全选</span>
<span class="btn-link text-muted" onclick="selectAllMaterials(false)">取消全选</span>
</div>
<!-- 粘贴上传区 -->
<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 {% if m.selected == 0 %}unselected{% endif %}" id="mat-{{m.id}}" data-selected="{{m.selected if m.selected is not none else 1}}">
<div class="d-flex justify-content-between align-items-start">
<div class="d-flex align-items-center gap-2">
<label class="mat-select-toggle" title="选中/取消">
<input type="checkbox" {% if m.selected != 0 %}checked{% endif %} onchange="toggleMaterialSelect('{{m.id}}', this.checked)">
<span class="slider"></span>
</label>
<span class="badge {{'bg-info' if m.type=='text' else 'bg-warning'}}">{{'文本' if m.type=='text' else '图片'}}</span>
</div>
<div class="d-flex gap-1">
<button class="btn btn-sm btn-link text-success p-0" onclick="analyzeMaterial('{{m.id}}')" title="AI分析" id="analyze-btn-{{m.id}}"><i class="bi bi-stars"></i></button>
{% if m.type == 'image' %}
<button class="btn btn-sm btn-link text-accent p-0" onclick="editMatNote('{{m.id}}', '{{(m.content or "")|replace("'", "\\'")|replace("\n", "\\n")}}')" title="编辑备注"><i class="bi bi-pencil"></i></button>
{% endif %}
<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>
</div>
{% if m.type == 'text' %}
<div class="mat-content mt-1">{{m.content}}</div>
{% else %}
<img src="/static/{{m.file_path}}" alt="素材图片">
<div id="mat-note-display-{{m.id}}">
{% if m.content %}
<div class="mat-note">{{m.content}}</div>
{% else %}
<div class="small text-muted mt-1" style="opacity:0.5;cursor:pointer;" onclick="editMatNote('{{m.id}}', '')"><i class="bi bi-plus-circle me-1"></i>添加备注</div>
{% endif %}
</div>
<div id="mat-note-edit-{{m.id}}" class="mat-note-edit" style="display:none;">
<input type="text" class="form-control form-control-sm" id="mat-note-input-{{m.id}}" placeholder="输入图片备注..." onkeydown="if(event.key==='Enter')saveMatNote('{{m.id}}');if(event.key==='Escape')cancelMatNote('{{m.id}}');">
<button class="btn btn-sm btn-accent py-0 px-2" onclick="saveMatNote('{{m.id}}')"><i class="bi bi-check-lg"></i></button>
<button class="btn btn-sm btn-secondary py-0 px-2" onclick="cancelMatNote('{{m.id}}')"><i class="bi bi-x-lg"></i></button>
</div>
{% endif %}
<div id="mat-analysis-{{m.id}}">
{% if m.analysis %}
<div class="mat-analysis">
<div class="mat-analysis-label"><i class="bi bi-stars"></i>AI 分析</div>
<div class="mat-analysis-actions">
<button class="btn btn-link text-muted" onclick="clearAnalysis('{{m.id}}')" title="清除分析"><i class="bi bi-x-lg"></i></button>
</div>
{{m.analysis}}
</div>
{% endif %}
</div>
<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="catTagModal" 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="mb-3">
<label class="form-label">分类(可多选)</label>
<div class="select-pills" id="editCategoryPills"></div>
<div class="form-text">点击选择所属分类</div>
</div>
<div class="mb-3">
<label class="form-label">标签</label>
<div class="select-pills mb-2" id="editTagPills"></div>
<div class="tag-input-group">
<input type="text" class="form-control form-control-sm" id="editNewTagInput" placeholder="输入新标签,回车添加">
<button class="btn btn-sm btn-outline-accent" onclick="addEditNewTag()">添加</button>
</div>
<div class="select-pills mt-1" id="editNewTagPills"></div>
</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="saveCatTag()">保存</button>
</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'));
const catTagModal = new bootstrap.Modal(document.getElementById('catTagModal'));
let pastedBlob = null;
// Prompt模板数据缓存
let topicPromptTemplatesCache = [];
// 页面加载时填充Prompt模板下拉
(async function() {
try {
const res = await fetch('/api/prompt-templates');
topicPromptTemplatesCache = await res.json();
const sel = document.getElementById('topicPromptTemplate');
sel.innerHTML = '<option value="">-- 选择Prompt模板 --</option><option value="__custom__">自定义输入</option>';
topicPromptTemplatesCache.forEach(t => {
sel.innerHTML += `<option value="${t.id}">${t.name}</option>`;
});
} catch(e) {}
})();
function applyTopicPromptTemplate(sel) {
const val = sel.value;
if (!val || val === '__custom__') return;
const tpl = topicPromptTemplatesCache.find(t => t.id === val);
if (tpl) {
document.getElementById('promptText').value = tpl.prompt;
}
}
// 当前主题的分类和标签
const currentCategoryIds = [{% for c in topic.categories %}'{{c.id}}',{% endfor %}];
const currentTagIds = [{% for tg in topic.tags %}'{{tg.id}}',{% endfor %}];
// ===== 分类/标签编辑 =====
let editCategoryIds = [...currentCategoryIds];
let editTagIds = [...currentTagIds];
let editNewTagNames = [];
async function showEditCatTagModal() {
editCategoryIds = [...currentCategoryIds];
editTagIds = [...currentTagIds];
editNewTagNames = [];
document.getElementById('editNewTagInput').value = '';
document.getElementById('editNewTagPills').innerHTML = '';
// 加载分类选项
try {
const res = await fetch('/api/categories');
const cats = await res.json();
document.getElementById('editCategoryPills').innerHTML = cats.map(c =>
`<span class="pill-option ${editCategoryIds.includes(c.id) ? 'selected' : ''}" data-id="${c.id}" onclick="toggleEditCategory(this,'${c.id}')">${c.name}</span>`
).join('');
} catch(e) {}
// 加载标签选项
try {
const res = await fetch('/api/tags');
const tags = await res.json();
document.getElementById('editTagPills').innerHTML = tags.map(t =>
`<span class="pill-option ${editTagIds.includes(t.id) ? 'selected-tag' : ''}" data-id="${t.id}" onclick="toggleEditTag(this,'${t.id}')">${t.name}</span>`
).join('');
} catch(e) {}
catTagModal.show();
}
function toggleEditCategory(el, id) {
const idx = editCategoryIds.indexOf(id);
if (idx >= 0) { editCategoryIds.splice(idx, 1); el.classList.remove('selected'); }
else { editCategoryIds.push(id); el.classList.add('selected'); }
}
function toggleEditTag(el, id) {
const idx = editTagIds.indexOf(id);
if (idx >= 0) { editTagIds.splice(idx, 1); el.classList.remove('selected-tag'); }
else { editTagIds.push(id); el.classList.add('selected-tag'); }
}
function addEditNewTag() {
const input = document.getElementById('editNewTagInput');
const name = input.value.trim();
if (!name || editNewTagNames.includes(name)) return;
editNewTagNames.push(name);
input.value = '';
renderEditNewTagPills();
}
document.getElementById('editNewTagInput').addEventListener('keydown', function(e) {
if (e.key === 'Enter') { e.preventDefault(); addEditNewTag(); }
});
function renderEditNewTagPills() {
document.getElementById('editNewTagPills').innerHTML = editNewTagNames.map((n, i) =>
`<span class="pill-option selected-tag" onclick="removeEditNewTag(${i})">${n} <i class="bi bi-x"></i></span>`
).join('');
}
function removeEditNewTag(idx) {
editNewTagNames.splice(idx, 1);
renderEditNewTagPills();
}
async function saveCatTag() {
const res = await fetch(`/api/topics/${topicId}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
category_ids: editCategoryIds,
tag_ids: editTagIds,
new_tags: editNewTagNames
})
});
if (res.ok) {
catTagModal.hide();
location.reload();
} else {
const d = await res.json(); alert(d.error || '保存失败');
}
}
// ===== 粘贴截图上传 =====
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();
}
// ===== AI分析素材 =====
async function analyzeMaterial(id) {
const btn = document.getElementById('analyze-btn-' + id);
const orig = btn.innerHTML;
btn.innerHTML = '<span class="spinner-generate" style="width:14px;height:14px;border-width:2px;"></span>';
btn.style.pointerEvents = 'none';
try {
const res = await fetch(`/api/materials/${id}/analyze`, {method: 'POST'});
if (!res.ok) { const d = await res.json(); throw new Error(d.error || '分析失败'); }
const data = await res.json();
// 更新分析结果显示区
const container = document.getElementById('mat-analysis-' + id);
if (container) {
container.innerHTML = `<div class="mat-analysis">
<div class="mat-analysis-label"><i class="bi bi-stars"></i>AI 分析</div>
<div class="mat-analysis-actions">
<button class="btn btn-link text-muted" onclick="clearAnalysis('${id}')" title="清除分析"><i class="bi bi-x-lg"></i></button>
</div>
${escapeHtml(data.analysis)}
</div>`;
}
} catch(e) {
alert('AI分析失败: ' + e.message);
} finally {
btn.innerHTML = orig;
btn.style.pointerEvents = '';
}
}
async function clearAnalysis(id) {
if (!confirm('确定清除AI分析结果')) return;
// 前端清空显示
const container = document.getElementById('mat-analysis-' + id);
if (container) container.innerHTML = '';
// 后端清空数据
await fetch(`/api/materials/${id}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({analysis: ''})
});
}
// ===== 图片备注编辑 =====
function editMatNote(id, currentNote) {
const displayEl = document.getElementById('mat-note-display-' + id);
const editEl = document.getElementById('mat-note-edit-' + id);
const inputEl = document.getElementById('mat-note-input-' + id);
if (displayEl) displayEl.style.display = 'none';
if (editEl) editEl.style.display = 'flex';
if (inputEl) { inputEl.value = currentNote; inputEl.focus(); }
}
function cancelMatNote(id) {
const displayEl = document.getElementById('mat-note-display-' + id);
const editEl = document.getElementById('mat-note-edit-' + id);
if (displayEl) displayEl.style.display = '';
if (editEl) editEl.style.display = 'none';
}
async function saveMatNote(id) {
const inputEl = document.getElementById('mat-note-input-' + id);
const note = inputEl ? inputEl.value.trim() : '';
const res = await fetch(`/api/materials/${id}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({content: note})
});
if (res.ok) {
// 更新显示区域
const displayEl = document.getElementById('mat-note-display-' + id);
if (displayEl) {
if (note) {
displayEl.innerHTML = `<div class="mat-note">${note}</div>`;
} else {
displayEl.innerHTML = `<div class="small text-muted mt-1" style="opacity:0.5;cursor:pointer;" onclick="editMatNote('${id}', '')"><i class="bi bi-plus-circle me-1"></i>添加备注</div>`;
}
displayEl.style.display = '';
}
const editEl = document.getElementById('mat-note-edit-' + id);
if (editEl) editEl.style.display = 'none';
} else {
const d = await res.json(); alert(d.error || '保存失败');
}
}
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');
}
// ===== 素材选择 =====
function updateSelectCount() {
const items = document.querySelectorAll('.material-item');
const total = items.length;
let selected = 0;
items.forEach(el => {
if (el.dataset.selected === '1' || el.dataset.selected === undefined) selected++;
});
const countEl = document.getElementById('selectCount');
if (countEl) countEl.textContent = `已选 ${selected}/${total}`;
// 更新全选开关状态
const toggleAll = document.getElementById('selectAllToggle');
if (toggleAll) {
toggleAll.checked = (total > 0 && selected === total);
}
}
async function toggleMaterialSelect(id, checked) {
const el = document.getElementById('mat-' + id);
if (el) {
el.dataset.selected = checked ? '1' : '0';
el.classList.toggle('unselected', !checked);
}
await fetch(`/api/materials/${id}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({selected: checked ? 1 : 0})
});
updateSelectCount();
}
async function selectAllMaterials(selectAll) {
const items = document.querySelectorAll('.material-item');
const updates = [];
items.forEach(el => {
const id = el.id.replace('mat-', '');
el.dataset.selected = selectAll ? '1' : '0';
el.classList.toggle('unselected', !selectAll);
const toggle = el.querySelector('.mat-select-toggle input');
if (toggle) toggle.checked = selectAll;
updates.push({id, selected: selectAll ? 1 : 0});
});
await fetch(`/api/topics/${topicId}/materials/select`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({materials: updates})
});
updateSelectCount();
}
function toggleSelectAll(checked) {
selectAllMaterials(checked);
}
// ===== 核心思路编辑 =====
function showEditCoreIdea() {
document.getElementById('coreIdeaDisplay').style.display = 'none';
document.getElementById('coreIdeaEdit').style.display = 'block';
document.getElementById('coreIdeaInput').focus();
}
function cancelEditCoreIdea() {
document.getElementById('coreIdeaDisplay').style.display = '';
document.getElementById('coreIdeaEdit').style.display = 'none';
}
async function saveCoreIdea() {
const coreIdea = document.getElementById('coreIdeaInput').value;
const res = await fetch(`/api/topics/${topicId}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({core_idea: coreIdea})
});
if (res.ok) {
const textEl = document.getElementById('coreIdeaText');
if (coreIdea.trim()) {
textEl.className = 'core-idea-text';
textEl.textContent = coreIdea;
} else {
textEl.className = 'core-idea-empty';
textEl.textContent = '尚未设定点击编辑添加核心思路以指导AI分析和创作';
}
document.getElementById('coreIdeaDisplay').style.display = '';
document.getElementById('coreIdeaEdit').style.display = 'none';
} else {
const d = await res.json(); alert(d.error || '保存失败');
}
}
// 初始化选择计数
updateSelectCount();
// ===== 网络搜索(持久化) =====
let searchPanelCollapsed = false;
// 搜索结果缓存从数据库加载key=result_id
let searchResultsMap = {};
function toggleSearchPanel() {
const body = document.getElementById('searchPanelBody');
const icon = document.getElementById('searchPanelToggle');
searchPanelCollapsed = !searchPanelCollapsed;
body.style.display = searchPanelCollapsed ? 'none' : '';
icon.className = searchPanelCollapsed ? 'bi bi-chevron-right' : 'bi bi-chevron-down';
}
function updateSearchCount() {
const count = Object.keys(searchResultsMap).length;
document.getElementById('searchCountBadge').textContent = count;
document.getElementById('clearSearchBtn').style.display = count > 0 ? '' : 'none';
}
// 页面加载时从数据库加载搜索结果
async function loadSearchResults() {
try {
const res = await fetch(`/api/topics/${topicId}/search-results`);
if (!res.ok) return;
const results = await res.json();
results.forEach(r => { searchResultsMap[r.id] = r; });
renderAllSearchResults();
updateSearchCount();
} catch(e) {}
}
function toggleSearchGroup(groupIdx) {
const groupEl = document.getElementById('search-group-' + groupIdx);
const iconEl = document.getElementById('search-group-icon-' + groupIdx);
if (!groupEl) return;
const isHidden = groupEl.style.display === 'none';
groupEl.style.display = isHidden ? '' : 'none';
if (iconEl) iconEl.className = isHidden ? 'bi bi-chevron-down' : 'bi bi-chevron-right';
}
function renderAllSearchResults() {
const resultsEl = document.getElementById('searchResults');
const results = Object.values(searchResultsMap);
if (results.length === 0) {
resultsEl.innerHTML = '<div class="search-empty"><i class="bi bi-globe2 d-block" style="font-size:2rem;opacity:0.3;"></i><small>搜索网络资料,结果可添加为素材</small></div>';
return;
}
// 按时间倒序
results.sort((a, b) => b.created_at.localeCompare(a.created_at));
// 按query分组显示
const groups = {};
results.forEach(r => {
if (!groups[r.query]) groups[r.query] = [];
groups[r.query].push(r);
});
let html = '';
let groupIdx = 0;
for (const [query, items] of Object.entries(groups)) {
const isCollapsed = true; // 所有搜索历史默认折叠
html += `<div class="mb-3">
<div class="d-flex align-items-center gap-2 mb-1" style="cursor:pointer;" onclick="toggleSearchGroup(${groupIdx})">
<i class="bi ${isCollapsed ? 'bi-chevron-right' : 'bi-chevron-down'}" id="search-group-icon-${groupIdx}" style="font-size:0.85rem;color:var(--text-muted);"></i>
<span class="small text-warning fw-bold"><i class="bi bi-search me-1"></i>${escapeHtml(query)} <span class="text-muted fw-normal">(${items.length}条)</span></span>
</div>
<div id="search-group-${groupIdx}" style="${isCollapsed ? 'display:none;' : ''}">`;
items.forEach(r => { html += renderSearchResultFromDB(r); });
html += '</div></div>';
groupIdx++;
}
resultsEl.innerHTML = html;
}
function renderSearchResultFromDB(r) {
const scorePercent = r.score ? Math.round(r.score * 100) : 0;
const hasRaw = r.raw_content ? true : false;
return `<div class="search-result-item" id="sr-${r.id}">
<div class="d-flex justify-content-between align-items-start">
<a class="result-title" href="${escapeHtml(r.url)}" target="_blank" rel="noopener">
<i class="bi bi-box-arrow-up-right" style="font-size:0.8rem;"></i>
${escapeHtml(r.title)}
</a>
<button class="btn btn-sm btn-link text-danger p-0" onclick="deleteSearchResult('${r.id}')" title="删除"><i class="bi bi-x-lg"></i></button>
</div>
<div class="result-url">${escapeHtml(r.url)}</div>
<div class="result-snippet">${escapeHtml(r.snippet)}</div>
<div class="result-score">相关度: ${scorePercent}%</div>
<div class="result-actions">
<button class="btn btn-sm btn-outline-accent" onclick="toggleSearchDetailDB('${r.id}')" id="detail-btn-${r.id}">
<i class="bi bi-eye me-1"></i>${hasRaw ? '查看详情' : '获取详情'}
</button>
<button class="btn btn-sm btn-outline-success" onclick="addSearchResultAsMaterialDB('${r.id}')" id="add-btn-${r.id}" title="添加为文本素材">
<i class="bi bi-plus-circle me-1"></i>添加为素材
</button>
<a class="btn btn-sm btn-secondary" href="${escapeHtml(r.url)}" target="_blank" rel="noopener" title="在新窗口打开">
<i class="bi bi-box-arrow-up-right"></i>
</a>
</div>
<div class="result-detail" id="search-detail-${r.id}">
<div class="result-detail-content" id="search-detail-content-${r.id}">${hasRaw ? escapeHtml(r.raw_content) : ''}</div>
</div>
</div>`;
}
async function doSearch() {
const query = document.getElementById('searchQuery').value.trim();
if (!query) return;
const maxResults = parseInt(document.getElementById('searchMaxResults').value) || 10;
const depth = document.getElementById('searchDepth').value;
const timeRange = document.getElementById('searchTimeRange').value;
const btn = document.getElementById('searchBtn');
const origBtn = btn.innerHTML;
btn.innerHTML = '<span class="spinner-generate" style="width:14px;height:14px;border-width:2px;"></span>';
btn.disabled = true;
try {
const body = { query, search_depth: depth, max_results: maxResults, topic_id: topicId };
if (timeRange) body.time_range = timeRange;
const res = await fetch('/api/search', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body)
});
if (!res.ok) { const d = await res.json(); throw new Error(d.error || '搜索失败'); }
const data = await res.json();
// 将保存的结果加入缓存
if (data.saved_results) {
data.saved_results.forEach(sr => {
searchResultsMap[sr.id] = {
id: sr.id,
topic_id: topicId,
query: query,
title: sr.title,
url: sr.url,
snippet: sr.content,
raw_content: '',
score: sr.score,
created_at: new Date().toLocaleString()
};
});
}
renderAllSearchResults();
updateSearchCount();
} catch(e) {
alert('搜索失败: ' + e.message);
} finally {
btn.innerHTML = origBtn;
btn.disabled = false;
}
}
async function toggleSearchDetailDB(resultId) {
const detailEl = document.getElementById(`search-detail-${resultId}`);
const btnEl = document.getElementById(`detail-btn-${resultId}`);
const contentEl = document.getElementById(`search-detail-content-${resultId}`);
if (!detailEl || !btnEl) return;
if (detailEl.classList.contains('show')) {
detailEl.classList.remove('show');
const hasRaw = searchResultsMap[resultId] && searchResultsMap[resultId].raw_content;
btnEl.innerHTML = `<i class="bi bi-eye me-1"></i>${hasRaw ? '查看详情' : '获取详情'}`;
return;
}
// 如果已有raw_content直接展示
if (contentEl.textContent.trim()) {
detailEl.classList.add('show');
btnEl.innerHTML = '<i class="bi bi-eye-slash me-1"></i>收起详情';
return;
}
// 调用详情API获取
const r = searchResultsMap[resultId];
if (!r) return;
btnEl.innerHTML = '<span class="spinner-generate" style="width:12px;height:12px;border-width:2px;"></span>';
btnEl.disabled = true;
try {
const res = await fetch('/api/search/detail', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ query: r.query, url: r.url, result_id: resultId })
});
if (!res.ok) { const d = await res.json(); throw new Error(d.error || '获取详情失败'); }
const data = await res.json();
const results = data.results || [];
let detailText = '';
if (results.length > 0 && results[0].raw_content) {
detailText = results[0].raw_content;
} else if (results.length > 0) {
detailText = results[0].content || '暂无详细内容';
} else {
detailText = '未能获取详细内容';
}
contentEl.textContent = detailText;
// 更新缓存
if (searchResultsMap[resultId]) searchResultsMap[resultId].raw_content = detailText;
detailEl.classList.add('show');
btnEl.innerHTML = '<i class="bi bi-eye-slash me-1"></i>收起详情';
} catch(e) {
contentEl.textContent = '获取详情失败: ' + e.message;
detailEl.classList.add('show');
btnEl.innerHTML = '<i class="bi bi-eye-slash me-1"></i>收起详情';
} finally {
btnEl.disabled = false;
}
}
async function addSearchResultAsMaterialDB(resultId) {
const r = searchResultsMap[resultId];
if (!r) return;
let content = `${r.title}\n来源: ${r.url}\n\n${r.snippet}`;
if (r.raw_content) {
content += `\n\n--- 详细内容 ---\n${r.raw_content}`;
}
const fd = new FormData();
fd.append('type', 'text');
fd.append('content', content);
try {
const res = await fetch(`/api/topics/${topicId}/materials`, {method: 'POST', body: fd});
if (res.ok) {
const btn = document.getElementById(`add-btn-${resultId}`);
if (btn) {
btn.innerHTML = '<i class="bi bi-check-lg me-1"></i>已添加';
btn.classList.replace('btn-outline-success', 'btn-success');
btn.disabled = true;
}
setTimeout(() => location.reload(), 800);
} else {
const d = await res.json(); alert(d.error || '添加失败');
}
} catch(e) {
alert('添加素材失败: ' + e.message);
}
}
async function deleteSearchResult(resultId) {
if (!confirm('确定删除此搜索结果?')) return;
await fetch(`/api/search-results/${resultId}`, {method: 'DELETE'});
delete searchResultsMap[resultId];
const el = document.getElementById(`sr-${resultId}`);
if (el) el.remove();
updateSearchCount();
// 如果删完了重新渲染空状态
if (Object.keys(searchResultsMap).length === 0) renderAllSearchResults();
}
async function clearAllSearchResults() {
if (!confirm('确定清空所有搜索结果?此操作不可恢复。')) return;
await fetch(`/api/topics/${topicId}/search-results`, {method: 'DELETE'});
searchResultsMap = {};
renderAllSearchResults();
updateSearchCount();
}
// 加载搜索结果
loadSearchResults();
</script>
</body>
</html>