1 Commits
v2.1 ... v2.2

3 changed files with 737 additions and 14 deletions

242
app.py
View File

@@ -43,6 +43,7 @@ def init_db():
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT DEFAULT '',
core_idea TEXT DEFAULT '',
prompt TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now','localtime')),
updated_at TEXT DEFAULT (datetime('now','localtime'))
@@ -53,6 +54,7 @@ def init_db():
type TEXT NOT NULL DEFAULT 'text',
content TEXT DEFAULT '',
file_path TEXT DEFAULT '',
analysis TEXT DEFAULT '',
sort_order INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now','localtime')),
FOREIGN KEY (topic_id) REFERENCES topics(id) ON DELETE CASCADE
@@ -100,6 +102,14 @@ def init_db():
FOREIGN KEY (topic_id) REFERENCES topics(id) ON DELETE CASCADE,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS prompt_templates (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
prompt TEXT NOT NULL DEFAULT '',
is_default INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now','localtime')),
updated_at TEXT DEFAULT (datetime('now','localtime'))
);
CREATE TABLE IF NOT EXISTS topic_tags (
topic_id TEXT NOT NULL,
tag_id TEXT NOT NULL,
@@ -108,10 +118,35 @@ def init_db():
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
);
''')
# 初始化默认Prompt模板
default_templates = [
('通用写作', '请根据以下素材撰写一篇结构清晰、内容丰富的文章:'),
('深度分析', '请根据以下素材撰写一篇深度分析文章,要求:\n1. 深入挖掘素材背后的逻辑和趋势\n2. 提供独到的观点和见解\n3. 论证充分,数据支撑\n4. 结构严谨,层次分明'),
('新闻报道', '请根据以下素材撰写一篇新闻报道风格的文章,要求:\n1. 标题醒目,吸引读者\n2. 导语概括核心信息\n3. 正文详实客观\n4. 语言简洁流畅'),
('技术解读', '请根据以下素材撰写一篇技术解读文章,要求:\n1. 技术概念清晰解释\n2. 结合实际应用场景\n3. 对比分析优劣势\n4. 展望未来发展趋势'),
('自媒体爆款', '请根据以下素材撰写一篇适合自媒体发布的爆款文章,要求:\n1. 标题吸睛,引发好奇\n2. 开头设置悬念或冲突\n3. 内容有干货,有情绪共鸣\n4. 结尾引导互动,促转发'),
]
for tname, tprompt in default_templates:
conn.execute('INSERT OR IGNORE INTO prompt_templates (id, name, prompt, is_default) VALUES (?, ?, ?, 1)',
(str(uuid.uuid4())[:8], tname, tprompt))
# 初始化默认配置
conn.execute('INSERT OR IGNORE INTO llm_config (id, url, api_key, model) VALUES (1, ?, ?, ?)',
(LLM_URL, LLM_API_KEY, LLM_MODEL))
conn.commit()
# 兼容旧库:如 topics 缺少 core_idea 列则补上
try:
conn.execute('ALTER TABLE topics ADD COLUMN core_idea TEXT DEFAULT ""')
except Exception:
pass
# 兼容旧库:如 materials 缺少 analysis/selected 列则补上
try:
conn.execute('ALTER TABLE materials ADD COLUMN analysis TEXT DEFAULT ""')
except Exception:
pass
try:
conn.execute('ALTER TABLE materials ADD COLUMN selected INTEGER DEFAULT 1')
except Exception:
pass
conn.close()
@@ -199,8 +234,8 @@ def create_topic():
tid = str(uuid.uuid4())[:8]
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
conn.execute(
'INSERT INTO topics (id, name, description, prompt, created_at, updated_at) VALUES (?,?,?,?,?,?)',
(tid, name, data.get('description', ''), data.get('prompt', ''), now, now)
'INSERT INTO topics (id, name, description, core_idea, prompt, created_at, updated_at) VALUES (?,?,?,?,?,?,?)',
(tid, name, data.get('description', ''), data.get('core_idea', ''), data.get('prompt', ''), now, now)
)
# 关联分类
for cat_id in data.get('category_ids', []):
@@ -234,7 +269,7 @@ def update_topic(topic_id):
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
fields = []
values = []
for key in ['name', 'description', 'prompt']:
for key in ['name', 'description', 'core_idea', 'prompt']:
if key in data:
fields.append(f'{key}=?')
values.append(data[key])
@@ -295,6 +330,69 @@ def delete_topic(topic_id):
return jsonify({'ok': True})
# ==================== Prompt模板API ====================
@app.route('/api/prompt-templates', methods=['GET'])
def list_prompt_templates():
conn = get_db()
rows = conn.execute('SELECT * FROM prompt_templates ORDER BY is_default DESC, name').fetchall()
conn.close()
return jsonify([dict(r) for r in rows])
@app.route('/api/prompt-templates', methods=['POST'])
def create_prompt_template():
data = request.get_json(force=True)
name = data.get('name', '').strip()
prompt = data.get('prompt', '').strip()
if not name:
return jsonify({'error': '模板名称不能为空'}), 400
if not prompt:
return jsonify({'error': '模板内容不能为空'}), 400
conn = get_db()
tid = str(uuid.uuid4())[:8]
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
conn.execute('INSERT INTO prompt_templates (id, name, prompt, is_default, created_at, updated_at) VALUES (?,?,?,?,?,?)',
(tid, name, prompt, 0, now, now))
conn.commit()
tmpl = conn.execute('SELECT * FROM prompt_templates WHERE id=?', (tid,)).fetchone()
conn.close()
return jsonify(dict(tmpl))
@app.route('/api/prompt-templates/<template_id>', methods=['PUT'])
def update_prompt_template(template_id):
data = request.get_json(force=True)
conn = get_db()
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
fields = []
values = []
for key in ['name', 'prompt']:
if key in data:
fields.append(f'{key}=?')
values.append(data[key])
if fields:
fields.append('updated_at=?')
values.append(now)
values.append(template_id)
conn.execute(f'UPDATE prompt_templates SET {",".join(fields)} WHERE id=?', values)
conn.commit()
tmpl = conn.execute('SELECT * FROM prompt_templates WHERE id=?', (template_id,)).fetchone()
conn.close()
if tmpl:
return jsonify(dict(tmpl))
return jsonify({'error': '未找到'}), 404
@app.route('/api/prompt-templates/<template_id>', methods=['DELETE'])
def delete_prompt_template(template_id):
conn = get_db()
conn.execute('DELETE FROM prompt_templates WHERE id=?', (template_id,))
conn.commit()
conn.close()
return jsonify({'ok': True})
# ==================== 分类API ====================
@app.route('/api/categories', methods=['GET'])
@@ -345,7 +443,9 @@ def update_category(category_id):
conn.commit()
cat = conn.execute('SELECT * FROM categories WHERE id=?', (category_id,)).fetchone()
conn.close()
return jsonify(dict(cat)) if cat else jsonify({'error': '未找到'}), 404
if cat:
return jsonify(dict(cat))
return jsonify({'error': '未找到'}), 404
@app.route('/api/categories/<category_id>', methods=['DELETE'])
@@ -473,7 +573,7 @@ def update_material(material_id):
conn = get_db()
fields = []
values = []
for key in ['content', 'sort_order']:
for key in ['content', 'sort_order', 'analysis', 'selected']:
if key in data:
fields.append(f'{key}=?')
values.append(data[key])
@@ -483,7 +583,9 @@ def update_material(material_id):
conn.commit()
material = conn.execute('SELECT * FROM materials WHERE id=?', (material_id,)).fetchone()
conn.close()
return jsonify(dict(material)) if material else jsonify({'error': '未找到'}), 404
if material:
return jsonify(dict(material))
return jsonify({'error': '未找到'}), 404
@app.route('/api/materials/<material_id>', methods=['DELETE'])
@@ -500,6 +602,128 @@ def delete_material(material_id):
return jsonify({'ok': True})
@app.route('/api/topics/<topic_id>/materials/select', methods=['POST'])
def batch_update_material_selection(topic_id):
"""批量更新素材选中状态"""
data = request.get_json(force=True)
conn = get_db()
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
# data 格式: {"materials": [{"id": "xxx", "selected": 1/0}, ...]}
# 或: {"select_all": true/false}
if 'select_all' in data:
selected = 1 if data['select_all'] else 0
conn.execute('UPDATE materials SET selected=? WHERE topic_id=?', (selected, topic_id))
elif 'materials' in data:
for item in data['materials']:
conn.execute('UPDATE materials SET selected=? WHERE id=? AND topic_id=?',
(1 if item.get('selected') else 0, item['id'], topic_id))
conn.execute('UPDATE topics SET updated_at=? WHERE id=?', (now, topic_id))
conn.commit()
conn.close()
return jsonify({'ok': True})
@app.route('/api/materials/<material_id>/analyze', methods=['POST'])
def analyze_material(material_id):
"""大模型分析单个素材,返回分析结果并保存"""
conn = get_db()
mat = conn.execute('SELECT * FROM materials WHERE id=?', (material_id,)).fetchone()
if not mat:
conn.close()
return jsonify({'error': '素材不存在'}), 404
mat_dict = dict(mat)
# 获取主题核心思路
topic = conn.execute('SELECT core_idea FROM topics WHERE id=?', (mat_dict['topic_id'],)).fetchone()
core_idea = dict(topic)['core_idea'] if topic else ''
conn.close()
llm_config = get_llm_config()
if not llm_config['api_key']:
return jsonify({'error': '请先配置LLM API Key'}), 400
# 构建核心思路引导语
core_idea_hint = ''
if core_idea:
core_idea_hint = f'\n\n=== 主题核心思路 ===\n{core_idea}\n请围绕上述核心思路进行分析,确保分析结果与文章整体方向一致。'
# 构建分析prompt
if mat_dict['type'] == 'text':
user_content = f"""请对以下文本素材进行深度分析,要求:
1. 概括核心观点和关键信息
2. 分析文本的逻辑结构和论证方式
3. 识别潜在的写作价值和可延伸方向
4. 指出可能的不足或需要补充的信息
{core_idea_hint}
=== 文本素材 ===
{mat_dict['content']}
请直接给出分析结果,不要输出思考过程。"""
else:
# 图片素材:发送图片给多模态模型
note = mat_dict['content'] or '无备注'
img_path = os.path.join(BASE_DIR, 'static', mat_dict['file_path']) if mat_dict['file_path'] else None
# 尝试读取图片并编码为base64
image_content = None
if img_path and os.path.exists(img_path):
import base64
with open(img_path, 'rb') as f:
img_data = base64.b64encode(f.read()).decode('utf-8')
ext = mat_dict['file_path'].rsplit('.', 1)[-1].lower()
mime_map = {'png': 'image/png', 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg',
'gif': 'image/gif', 'webp': 'image/webp', 'svg': 'image/svg+xml', 'bmp': 'image/bmp'}
mime = mime_map.get(ext, 'image/png')
image_content = f'data:{mime};base64,{img_data}'
if image_content:
user_content = [
{'type': 'text', 'text': f"""请对以下图片素材进行深度分析,要求:
1. 详细描述图片内容(场景、人物、物体、文字等)
2. 分析图片的核心信息和表达意图
3. 识别图片在文章写作中的潜在用途
4. 如有备注,结合备注分析:{note}
{core_idea_hint}
请直接给出分析结果,不要输出思考过程。"""},
{'type': 'image_url', 'image_url': {'url': image_content}}
]
else:
user_content = f"""请对以下图片素材备注进行分析(图片文件不可读,仅基于备注):
备注:{note}
{core_idea_hint}
请尝试基于备注信息给出分析,并建议补充更多图片细节。"""
try:
req_body = {
'model': llm_config['model'],
'messages': [{'role': 'user', 'content': user_content}],
'temperature': 0.5,
'max_tokens': 2048
}
resp = requests.post(
llm_config['url'],
headers={'Authorization': f"Bearer {llm_config['api_key']}", 'Content-Type': 'application/json'},
json=req_body,
timeout=60
)
resp.raise_for_status()
result = resp.json()
analysis_text = result['choices'][0]['message']['content']
except Exception as e:
return jsonify({'error': f'分析失败: {str(e)}'}), 500
# 保存分析结果
conn = get_db()
conn.execute('UPDATE materials SET analysis=? WHERE id=?', (analysis_text, material_id))
conn.commit()
conn.close()
return jsonify({'analysis': analysis_text})
# ==================== LLM配置API ====================
@app.route('/api/llm-config', methods=['GET'])
@@ -563,7 +787,7 @@ def generate_article(topic_id):
return jsonify({'error': '主题不存在'}), 404
materials = conn.execute(
'SELECT * FROM materials WHERE topic_id=? ORDER BY sort_order, created_at',
'SELECT * FROM materials WHERE topic_id=? AND selected=1 ORDER BY sort_order, created_at',
(topic_id,)
).fetchall()
conn.close()
@@ -595,6 +819,7 @@ def generate_article(topic_id):
主题:{topic['name']}
{f'主题说明:{topic["description"]}' if topic['description'] else ''}
{f'核心思路:{topic["core_idea"]}' if topic['core_idea'] else ''}
=== 素材 ===
{materials_text}
@@ -603,7 +828,8 @@ def generate_article(topic_id):
1. 充分利用提供的素材
2. 文章结构完整,层次分明
3. 语言流畅,逻辑清晰
4. 不要输出思考过程,直接给出文章内容"""
4. 紧扣核心思路进行创作,保持文章方向与风格一致
5. 不要输出思考过程,直接给出文章内容"""
# 调用大模型
try:

View File

@@ -73,6 +73,9 @@
<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>
@@ -193,6 +196,11 @@
<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">
@@ -214,7 +222,14 @@
</div>
</div>
<div class="mb-3">
<label class="form-label">默认Prompt</label>
<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>
@@ -310,12 +325,44 @@
</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 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 = '';
@@ -387,11 +434,14 @@
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();
}
@@ -473,6 +523,7 @@
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,
@@ -687,6 +738,138 @@
}
}
// ===== 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(),

View File

@@ -23,6 +23,13 @@
.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); }
@@ -74,6 +81,33 @@
.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; }
</style>
</head>
<body>
@@ -105,6 +139,30 @@
<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 -->
@@ -126,6 +184,14 @@
<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>
@@ -134,13 +200,23 @@
<!-- 素材区 -->
<div class="card-dark">
<div class="card-header d-flex justify-content-between align-items-center">
<span class="section-title mb-0"><i class="bi bi-collection text-info"></i>素材库 <span class="badge bg-secondary ms-1">{{materials|length}}</span></span>
<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>
@@ -148,17 +224,51 @@
</div>
{% if materials %}
{% for m in materials %}
<div class="material-item" id="mat-{{m.id}}">
<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">
<span class="badge {{'bg-info' if m.type=='text' else 'bg-warning'}}">{{'文本' if m.type=='text' else '图片'}}</span>
<button class="btn btn-sm btn-link text-danger p-0" onclick="deleteMaterial('{{m.id}}')" title="删除"><i class="bi bi-x-lg"></i></button>
<div 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 %}
{% if m.content %}<div class="small text-muted mt-1">{{m.content}}</div>{% endif %}
<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 %}
@@ -325,6 +435,31 @@
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 %}];
@@ -492,6 +627,93 @@
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}`, {
@@ -608,6 +830,98 @@
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();
</script>
</body>
</html>