initial: blog-app snapshot
This commit is contained in:
108
static/js/pages/analyze.js
Normal file
108
static/js/pages/analyze.js
Normal 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
41
static/js/pages/index.js
Normal 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();
|
||||
});
|
||||
}
|
||||
95
static/js/pages/project-detail.js
Normal file
95
static/js/pages/project-detail.js
Normal 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();
|
||||
});
|
||||
}
|
||||
65
static/js/pages/projects.js
Normal file
65
static/js/pages/projects.js
Normal 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
104
static/js/pages/search.js
Normal 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());
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user