initial: blog-app snapshot

This commit is contained in:
omo
2026-08-17 17:11:27 +08:00
commit 65cb9d5ead
125 changed files with 14720 additions and 0 deletions

108
static/js/pages/analyze.js Normal file
View File

@@ -0,0 +1,108 @@
/* Analyze 页逻辑: SSE 流式分析 (共用 components/sse-client.js).
* 与 ai-panel.js 共用 createAnalysisStream, 只负责本页 DOM/Tab 交互.
*/
import { createAnalysisStream } from "../components/sse-client.js";
import { escapeHtml } from "../utils.js";
export function initAnalyzePage() {
let currentStream = null;
const scopeSelect = document.getElementById("scopeSelect");
const questionInput = document.getElementById("questionInput");
const startBtn = document.getElementById("startBtn");
const presetBtns = document.querySelectorAll(".preset-btn");
const observationContent = document.getElementById("observationContent");
const promptContent = document.getElementById("promptContent");
const reasoningContent = document.getElementById("reasoningContent");
const conclusionContent = document.getElementById("conclusionContent");
// Programmatic tab switching (Bootstrap tabs)
function switchTab(name) {
const btn = document.getElementById(name + "-tab");
if (btn) btn.click();
}
function startAnalysis(question, preset) {
if (currentStream) {
currentStream.close();
currentStream = null;
}
// Reset content
observationContent.innerHTML = '<div class="text-muted">正在收集上下文...</div>';
promptContent.innerHTML = '<div class="text-muted">等待...</div>';
reasoningContent.innerHTML = '<div class="text-muted">等待...</div>';
conclusionContent.innerHTML = '<div class="text-muted">等待...</div>';
currentStream = createAnalysisStream(scopeSelect.value, question, preset, {
onStep(step, content) {
if (step === "context") {
observationContent.innerHTML = `<div class="alert alert-info">${escapeHtml(content)}</div>`;
} else if (step === "prompt") {
promptContent.innerHTML = `<pre class="bg-light p-3 rounded" style="white-space: pre-wrap;">${escapeHtml(content)}</pre>`;
}
},
onToken(text, section) {
const pane = section === "conclusion" ? conclusionContent : reasoningContent;
if (pane.querySelector(".text-muted")) pane.innerHTML = "";
pane.innerHTML += escapeHtml(text);
pane.scrollTop = pane.scrollHeight;
},
onSectionStart(section) {
if (section === "reasoning") {
reasoningContent.innerHTML = "";
switchTab("reasoning");
} else {
conclusionContent.innerHTML = "";
switchTab("conclusion");
}
},
onError(msg) {
observationContent.innerHTML = `<div class="alert alert-danger">错误: ${escapeHtml(msg)}</div>`;
currentStream = null;
},
onComplete(content) {
// 追加提示, 不覆盖已流式写入的结论内容
conclusionContent.innerHTML += `<div class="alert alert-success">${escapeHtml(content)}</div>`;
currentStream = null;
},
});
}
// Preset button click
presetBtns.forEach((btn) => {
btn.addEventListener("click", () => {
startAnalysis(null, btn.dataset.preset);
});
});
// Start button click
startBtn.addEventListener("click", () => {
const question = questionInput.value.trim();
if (!question) {
alert("请输入自定义问题或选择预设分析");
return;
}
startAnalysis(question, null);
});
// Dynamically fill scope <select> from /api/projects so the options always
// match the DB (previously hardcoded project/1..3 which drifted out of sync).
// Same pattern as ai-panel.js loadScopes().
fetch("/api/projects")
.then((r) => r.json())
.then((data) => {
const projects = data.projects || data || [];
projects.forEach((p) => {
const opt = document.createElement("option");
opt.value = "project/" + p.id;
opt.textContent = "项目: " + (p.name || p.full_name || "#" + p.id);
scopeSelect.appendChild(opt);
});
})
.catch((err) => {
console.error("analyze: 加载项目列表失败", err);
});
}

41
static/js/pages/index.js Normal file
View File

@@ -0,0 +1,41 @@
/* 首页逻辑: 统计卡片 + 项目 dashboard.
* renderProjectCard 由模板内联 module 注入 (templates/index.html 直接引用组件,
* 保持组件对模板可见, 便于逐页切换验证).
*/
import { fetchJson } from "../utils.js";
import { renderStatsCards } from "../components/stats-card.js";
export function initIndexPage(renderProjectCard) {
async function loadStats() {
try {
const stats = await fetchJson("/api/stats");
renderStatsCards(stats);
} catch (err) {
console.error("加载统计失败:", err);
}
}
async function loadProjects() {
const container = document.getElementById("projectCards");
try {
const projects = await fetchJson("/api/projects");
if (!projects.length) {
container.innerHTML = '<div class="empty-hint">暂无项目数据</div>';
return;
}
container.innerHTML = projects
.map((p) => renderProjectCard(p, { showDescription: false }))
.join("");
} catch (err) {
console.error("加载项目失败:", err);
container.innerHTML =
'<div class="empty-hint empty-hint-error">加载失败, <button type="button" class="btn btn-sm btn-outline-danger js-retry-btn">点击重试</button></div>';
}
}
document.addEventListener("DOMContentLoaded", () => {
loadStats();
loadProjects();
});
}

View File

@@ -0,0 +1,95 @@
/* 项目详情页逻辑: 项目头部 + release 时间线 + 最近 issues. */
import { fetchJson, escapeHtml, truncate, formatDate } from "../utils.js";
export function initProjectDetailPage(projectId) {
projectId = Number.parseInt(projectId, 10);
if (Number.isNaN(projectId)) {
console.error("project-detail: invalid project id");
return;
}
async function loadProject() {
const header = document.getElementById("projectHeader");
try {
const p = await fetchJson(`/api/projects/${projectId}`);
header.innerHTML = `
<div class="card mb-3">
<div class="card-body">
<h1 class="card-title h3">${escapeHtml(p.name)}
<small class="text-muted fs-6">${escapeHtml(p.full_name || "")}</small>
</h1>
<p class="card-text">${escapeHtml(p.description || "暂无描述")}</p>
<p class="mb-0">
<span class="badge bg-warning text-dark">⭐ Stars: ${p.stars ?? 0}</span>
<span class="badge bg-secondary">🍴 Forks: ${p.forks ?? 0}</span>
<span class="badge bg-info text-dark">${escapeHtml(p.language || "N/A")}</span>
<span class="badge bg-light text-dark">更新于 ${formatDate(p.updated_at)}</span>
</p>
</div>
</div>`;
} catch (err) {
console.error("加载项目信息失败:", err);
header.innerHTML =
'<div class="empty-hint empty-hint-error">加载失败, <button type="button" class="btn btn-sm btn-outline-danger js-retry-btn">点击重试</button></div>';
}
}
async function loadReleases() {
const list = document.getElementById("releaseTimeline");
try {
const releases = await fetchJson(`/api/projects/${projectId}/releases`);
if (!releases.length) {
list.innerHTML = '<li class="list-group-item empty-hint">暂无 release</li>';
return;
}
list.innerHTML = releases
.map(
(r) => `
<li class="list-group-item">
<div class="d-flex justify-content-between align-items-baseline">
<strong>${escapeHtml(r.tag_name || r.name || "release")}</strong>
<small class="text-muted">${formatDate(r.published_at)}</small>
</div>
<div class="text-muted small mt-1">${escapeHtml(truncate(r.body, 200))}</div>
</li>`
)
.join("");
} catch (err) {
console.error("加载 releases 失败:", err);
list.innerHTML =
'<li class="list-group-item empty-hint empty-hint-error">加载失败, <button type="button" class="btn btn-sm btn-outline-danger js-retry-btn">点击重试</button></li>';
}
}
async function loadIssues() {
const tbody = document.getElementById("issueTableBody");
try {
const issues = await fetchJson(`/api/projects/${projectId}/issues`);
if (!issues.length) {
tbody.innerHTML = '<tr><td colspan="4" class="empty-hint">暂无 issue</td></tr>';
return;
}
tbody.innerHTML = issues
.map(
(i) => `
<tr>
<td>${escapeHtml(truncate(i.title, 60))}</td>
<td><span class="badge ${i.state === "open" ? "badge-state-open" : "badge-state-closed"}">${escapeHtml(i.state || "")}</span></td>
<td>${formatDate(i.created_at)}</td>
<td>${escapeHtml(i.user || "-")}</td>
</tr>`
)
.join("");
} catch (err) {
console.error("加载 issues 失败:", err);
tbody.innerHTML =
'<tr><td colspan="4" class="empty-hint empty-hint-error">加载失败, <button type="button" class="btn btn-sm btn-outline-danger js-retry-btn">点击重试</button></td></tr>';
}
}
document.addEventListener("DOMContentLoaded", () => {
loadProject();
loadReleases();
loadIssues();
});
}

View File

@@ -0,0 +1,65 @@
/* 项目列表页逻辑: 语言过滤 + 排序 + 项目卡片.
* renderProjectCard 由模板内联 module 注入.
*/
import { fetchJson } from "../utils.js";
export function initProjectsPage(renderProjectCard) {
let allProjects = [];
let currentLang = "all";
let currentSort = "stars";
function renderProjects() {
const container = document.getElementById("projectList");
let list = allProjects;
if (currentLang !== "all") {
list = list.filter((p) => (p.language || "") === currentLang);
}
list = [...list].sort((a, b) => {
if (currentSort === "stars") {
return (b.stars ?? 0) - (a.stars ?? 0);
}
return String(b.updated_at || "").localeCompare(String(a.updated_at || ""));
});
if (!list.length) {
container.innerHTML = '<div class="empty-hint">没有匹配的项目</div>';
return;
}
container.innerHTML = list
.map((p) =>
renderProjectCard(p, { showDescription: true, showUpdatedAt: true })
)
.join("");
}
async function loadProjects() {
try {
allProjects = await fetchJson("/api/projects");
renderProjects();
} catch (err) {
console.error("加载项目失败:", err);
document.getElementById("projectList").innerHTML =
'<div class="empty-hint empty-hint-error">加载失败, <button type="button" class="btn btn-sm btn-outline-danger js-retry-btn">点击重试</button></div>';
}
}
document.addEventListener("DOMContentLoaded", () => {
loadProjects();
document.querySelectorAll("[data-lang]").forEach((btn) => {
btn.addEventListener("click", () => {
document
.querySelectorAll("[data-lang]")
.forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
currentLang = btn.dataset.lang;
renderProjects();
});
});
document.getElementById("sortSelect").addEventListener("change", (e) => {
currentSort = e.target.value;
renderProjects();
});
});
}

104
static/js/pages/search.js Normal file
View File

@@ -0,0 +1,104 @@
/* 全局搜索页逻辑: 防抖搜索 + 项目/release/issue 三段结果.
* renderProjectCard 由模板内联 module 注入.
*/
import { debounce, fetchJson, escapeHtml, truncate } from "../utils.js";
export function initSearchPage(renderProjectCard) {
function renderResults(data) {
const hint = document.getElementById("searchHint");
const results = document.getElementById("searchResults");
const total = data.projects.length + data.releases.length + data.issues.length;
if (!total) {
results.classList.add("d-none");
hint.classList.remove("d-none");
hint.textContent = "没有找到匹配结果";
return;
}
hint.classList.add("d-none");
results.classList.remove("d-none");
document.getElementById("projCount").textContent = data.projects.length;
document.getElementById("relCount").textContent = data.releases.length;
document.getElementById("issueCount").textContent = data.issues.length;
document.getElementById("resultProjects").innerHTML = data.projects.length
? data.projects
.map((p) =>
renderProjectCard(p, { showSubtitle: false, showForks: false })
)
.join("")
: '<div class="empty-hint">无匹配项目</div>';
document.getElementById("resultReleases").innerHTML = data.releases.length
? data.releases
.map(
(r) => `
<li class="list-group-item d-flex justify-content-between align-items-center">
<span>
<strong>${escapeHtml(r.tag_name || "")}</strong>
<span class="text-muted"> ${escapeHtml(truncate(r.name, 60))}</span>
</span>
<a class="btn btn-sm btn-outline-primary" href="/projects/${r.project_id}">查看项目</a>
</li>`
)
.join("")
: '<li class="list-group-item empty-hint">无匹配 release</li>';
document.getElementById("resultIssues").innerHTML = data.issues.length
? data.issues
.map(
(i) => `
<li class="list-group-item d-flex justify-content-between align-items-center">
<span>
<span class="badge ${i.state === "open" ? "badge-state-open" : "badge-state-closed"} me-1">${escapeHtml(i.state || "")}</span>
${escapeHtml(truncate(i.title, 80))}
</span>
<a class="btn btn-sm btn-outline-primary" href="/projects/${i.project_id}">查看项目</a>
</li>`
)
.join("")
: '<li class="list-group-item empty-hint">无匹配 issue</li>';
}
async function doSearch(keyword) {
const hint = document.getElementById("searchHint");
const results = document.getElementById("searchResults");
if (!keyword) {
results.classList.add("d-none");
hint.classList.remove("d-none");
hint.textContent = "输入关键词开始搜索";
return;
}
try {
hint.classList.remove("empty-hint-error");
const data = await fetchJson(`/api/search?q=${encodeURIComponent(keyword)}`);
renderResults(data);
} catch (err) {
console.error("搜索失败:", err);
results.classList.add("d-none");
hint.classList.remove("d-none");
hint.classList.add("empty-hint-error");
hint.innerHTML =
'加载失败, <button type="button" class="btn btn-sm btn-outline-danger" id="searchRetryBtn">点击重试</button>';
const retryBtn = document.getElementById("searchRetryBtn");
if (retryBtn) retryBtn.addEventListener("click", () => doSearch(keyword));
}
}
const debouncedSearch = debounce(doSearch, 300);
document.addEventListener("DOMContentLoaded", () => {
const input = document.getElementById("searchInput");
input.addEventListener("input", (e) => debouncedSearch(e.target.value.trim()));
// Support ?q= from the navbar search box.
const params = new URLSearchParams(window.location.search);
const q = params.get("q");
if (q) {
input.value = q;
doSearch(q.trim());
}
});
}