chore: checkpoint ai search comments and i18n foundation
This commit is contained in:
@@ -47,7 +47,8 @@
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea {
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
@@ -455,7 +456,8 @@
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field textarea {
|
||||
.field textarea,
|
||||
.field select {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
@@ -676,6 +678,27 @@
|
||||
|
||||
location.reload();
|
||||
}
|
||||
|
||||
async function adminDelete(url, successMessage) {
|
||||
const confirmed = confirm("确认删除这条记录吗?此操作无法撤销。");
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "DELETE"
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text() || "request failed");
|
||||
}
|
||||
|
||||
if (successMessage) {
|
||||
alert(successMessage);
|
||||
}
|
||||
|
||||
location.reload();
|
||||
}
|
||||
</script>
|
||||
{% block page_scripts %}{% endblock %}
|
||||
</body>
|
||||
|
||||
@@ -1,11 +1,74 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block main_content %}
|
||||
<section class="form-panel">
|
||||
<div class="table-head">
|
||||
<div>
|
||||
<h2>评论筛选</h2>
|
||||
<div class="table-note">按 scope、审核状态、文章 slug 或关键词快速定位评论,尤其适合处理段落评论和垃圾留言。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="get" action="/admin/comments" class="inline-form compact">
|
||||
<div class="compact-grid">
|
||||
<div class="field">
|
||||
<label for="scope">评论类型</label>
|
||||
<select id="scope" name="scope">
|
||||
<option value="" {% if filters.scope == "" %}selected{% endif %}>全部</option>
|
||||
<option value="article" {% if filters.scope == "article" %}selected{% endif %}>全文评论</option>
|
||||
<option value="paragraph" {% if filters.scope == "paragraph" %}selected{% endif %}>段落评论</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="approved">审核状态</label>
|
||||
<select id="approved" name="approved">
|
||||
<option value="" {% if filters.approved == "" %}selected{% endif %}>全部</option>
|
||||
<option value="true" {% if filters.approved == "true" %}selected{% endif %}>已审核</option>
|
||||
<option value="false" {% if filters.approved == "false" %}selected{% endif %}>待审核</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="post_slug">文章</label>
|
||||
<select id="post_slug" name="post_slug">
|
||||
<option value="" {% if filters.post_slug == "" %}selected{% endif %}>全部文章</option>
|
||||
{% for slug in post_options %}
|
||||
<option value="{{ slug }}" {% if filters.post_slug == slug %}selected{% endif %}>{{ slug }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="q">关键词</label>
|
||||
<input
|
||||
id="q"
|
||||
type="text"
|
||||
name="q"
|
||||
value="{{ filters.q }}"
|
||||
placeholder="作者 / 内容 / 段落 key"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="compact-actions">
|
||||
<button type="submit" class="btn btn-primary">应用筛选</button>
|
||||
<a href="/admin/comments" class="btn btn-ghost">清空</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="inline-links" style="margin-top: 14px;">
|
||||
{% for stat in stats %}
|
||||
<span class="chip">{{ stat.label }} · {{ stat.value }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="table-panel">
|
||||
<div class="table-head">
|
||||
<div>
|
||||
<h2>评论队列</h2>
|
||||
<div class="table-note">处理前台真实评论,并能一键跳到对应文章页核对展示。</div>
|
||||
<div class="table-note">处理前台真实评论,并能一键跳到对应文章或段落核对上下文。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +79,7 @@
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>作者 / 文章</th>
|
||||
<th>内容</th>
|
||||
<th>内容与上下文</th>
|
||||
<th>状态</th>
|
||||
<th>时间</th>
|
||||
<th>操作</th>
|
||||
@@ -30,12 +93,32 @@
|
||||
<div class="item-title">
|
||||
<strong>{{ row.author }}</strong>
|
||||
<span class="item-meta">{{ row.post_slug }}</span>
|
||||
{% if row.scope == "paragraph" %}
|
||||
<span class="badge badge-warning">{{ row.scope_label }}</span>
|
||||
{% else %}
|
||||
<span class="badge">{{ row.scope_label }}</span>
|
||||
{% endif %}
|
||||
{% if row.frontend_url %}
|
||||
<a href="{{ row.frontend_url }}" class="inline-link" target="_blank" rel="noreferrer noopener">跳到前台文章</a>
|
||||
<a href="{{ row.frontend_url }}" class="inline-link" target="_blank" rel="noreferrer noopener">
|
||||
{% if row.scope == "paragraph" %}跳到前台段落{% else %}跳到前台文章{% endif %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="item-title">
|
||||
<strong>{{ row.content }}</strong>
|
||||
{% if row.reply_target != "-" %}
|
||||
<span class="item-meta">回复目标:{{ row.reply_target }}</span>
|
||||
{% endif %}
|
||||
{% if row.scope == "paragraph" and row.paragraph_excerpt != "-" %}
|
||||
<span class="item-meta">段落上下文:{{ row.paragraph_excerpt }}</span>
|
||||
{% endif %}
|
||||
{% if row.scope == "paragraph" and row.paragraph_key != "-" %}
|
||||
<span class="item-meta">段落 key:{{ row.paragraph_key }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ row.content }}</td>
|
||||
<td>
|
||||
{% if row.approved %}
|
||||
<span class="badge badge-success">已审核</span>
|
||||
@@ -48,6 +131,7 @@
|
||||
<div class="actions">
|
||||
<button class="btn btn-success" onclick='adminPatch("{{ row.api_url }}", {"approved": true}, "评论状态已更新")'>通过</button>
|
||||
<button class="btn btn-warning" onclick='adminPatch("{{ row.api_url }}", {"approved": false}, "评论状态已更新")'>待审</button>
|
||||
<button class="btn btn-danger" onclick='adminDelete("{{ row.api_url }}", "评论已删除")'>删除</button>
|
||||
<a href="{{ row.api_url }}" class="btn btn-ghost" target="_blank" rel="noreferrer noopener">API</a>
|
||||
</div>
|
||||
</td>
|
||||
@@ -57,7 +141,7 @@
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty">暂无评论数据。</div>
|
||||
<div class="empty">当前筛选条件下暂无评论数据。</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<div class="table-head">
|
||||
<div>
|
||||
<h2>站点资料</h2>
|
||||
<div class="table-note">保存后首页、关于页、页脚和友链页中的本站信息会直接读取这里的配置。</div>
|
||||
<div class="table-note">保存后首页、关于页、页脚和友链页中的本站信息会直接读取这里的配置。AI 问答也在这里统一开启和配置。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -74,11 +74,54 @@
|
||||
<label>技术栈(每行一个)</label>
|
||||
<textarea name="tech_stack">{{ form.tech_stack }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="field field-wide" style="border-top: 1px solid rgba(148, 163, 184, 0.18); padding-top: 18px; margin-top: 10px;">
|
||||
<label style="display:flex; align-items:center; gap:10px;">
|
||||
<input type="checkbox" name="ai_enabled" {% if form.ai_enabled %}checked{% endif %}>
|
||||
<span>启用前台 AI 问答</span>
|
||||
</label>
|
||||
<div class="field-hint">关闭后,前台导航不会显示 AI 页面,公开接口也不会对外提供回答。Embedding 已改为后端本地生成,并使用 PostgreSQL 的 pgvector 存储与检索。</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>聊天 Provider</label>
|
||||
<input name="ai_provider" value="{{ form.ai_provider }}" placeholder="newapi">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>聊天 API Base</label>
|
||||
<input name="ai_api_base" value="{{ form.ai_api_base }}" placeholder="http://localhost:8317/v1">
|
||||
</div>
|
||||
<div class="field field-wide">
|
||||
<label>聊天 API Key</label>
|
||||
<input name="ai_api_key" value="{{ form.ai_api_key }}" placeholder="your-api-key-1">
|
||||
<div class="field-hint">这里只保存在后端数据库里,前台公开接口不会返回这个字段。当前默认接入本地 NewAPI 网关,未配置时前台仍可做本地检索,但不会生成完整聊天回答。</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>聊天模型</label>
|
||||
<input name="ai_chat_model" value="{{ form.ai_chat_model }}" placeholder="gpt-5.4">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>本地 Embedding</label>
|
||||
<input value="{{ form.ai_local_embedding }}" disabled>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Top K</label>
|
||||
<input type="number" min="1" max="12" name="ai_top_k" value="{{ form.ai_top_k }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Chunk Size</label>
|
||||
<input type="number" min="400" max="4000" step="50" name="ai_chunk_size" value="{{ form.ai_chunk_size }}">
|
||||
</div>
|
||||
<div class="field field-wide">
|
||||
<label>系统提示词</label>
|
||||
<textarea name="ai_system_prompt">{{ form.ai_system_prompt }}</textarea>
|
||||
</div>
|
||||
<div class="field field-wide">
|
||||
<div class="table-note">AI 索引状态:已索引 {{ form.ai_chunks_count }} 个片段,最近建立时间 {{ form.ai_last_indexed_at }}。</div>
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn btn-primary">保存设置</button>
|
||||
<button type="button" id="reindex-btn" class="btn">重建 AI 索引</button>
|
||||
</div>
|
||||
<div class="field-hint" style="margin-top: 10px;">保存后可直接点击顶部“预览首页 / 预览关于页 / 预览友链页”确认前台展示。</div>
|
||||
<div class="field-hint" style="margin-top: 10px;">文章内容变化后建议手动重建一次 AI 索引。本地 embedding 使用后端内置 `fastembed` 生成,向量会写入 PostgreSQL 的 `pgvector` 列,并通过 HNSW 索引做相似度检索;聊天回答默认走 `newapi -> /responses -> gpt-5.4`。</div>
|
||||
<div id="notice" class="notice"></div>
|
||||
</div>
|
||||
</form>
|
||||
@@ -89,12 +132,18 @@
|
||||
<script>
|
||||
const form = document.getElementById("site-settings-form");
|
||||
const notice = document.getElementById("notice");
|
||||
const reindexBtn = document.getElementById("reindex-btn");
|
||||
|
||||
function showNotice(message, kind) {
|
||||
notice.textContent = message;
|
||||
notice.className = "notice show " + (kind === "success" ? "notice-success" : "notice-error");
|
||||
}
|
||||
|
||||
function numericOrNull(value) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
form?.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -118,7 +167,15 @@
|
||||
techStack: String(data.get("tech_stack") || "")
|
||||
.split("\n")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.filter(Boolean),
|
||||
aiEnabled: data.get("ai_enabled") === "on",
|
||||
aiProvider: data.get("ai_provider"),
|
||||
aiApiBase: data.get("ai_api_base"),
|
||||
aiApiKey: data.get("ai_api_key"),
|
||||
aiChatModel: data.get("ai_chat_model"),
|
||||
aiTopK: numericOrNull(data.get("ai_top_k")),
|
||||
aiChunkSize: numericOrNull(data.get("ai_chunk_size")),
|
||||
aiSystemPrompt: data.get("ai_system_prompt")
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -134,10 +191,34 @@
|
||||
throw new Error(await response.text() || "save failed");
|
||||
}
|
||||
|
||||
showNotice("站点信息已保存。", "success");
|
||||
showNotice("站点信息与 AI 配置已保存。", "success");
|
||||
} catch (error) {
|
||||
showNotice("保存失败:" + (error?.message || "unknown error"), "error");
|
||||
}
|
||||
});
|
||||
|
||||
reindexBtn?.addEventListener("click", async () => {
|
||||
reindexBtn.disabled = true;
|
||||
reindexBtn.textContent = "正在重建...";
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/ai/reindex", {
|
||||
method: "POST"
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text() || "reindex failed");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
showNotice(`AI 索引已重建,当前共有 ${data.indexed_chunks} 个片段。`, "success");
|
||||
window.setTimeout(() => window.location.reload(), 900);
|
||||
} catch (error) {
|
||||
showNotice("重建失败:" + (error?.message || "unknown error"), "error");
|
||||
} finally {
|
||||
reindexBtn.disabled = false;
|
||||
reindexBtn.textContent = "重建 AI 索引";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user