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

153
static/css/style.css Normal file
View File

@@ -0,0 +1,153 @@
/* Custom styles for AI Agent project tracker. */
body {
background-color: #f5f7fa;
}
/* Card shadow + hover lift */
.card {
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
transition: transform 0.15s ease, box-shadow 0.15s ease;
border: none;
}
.card.project-card:hover {
transform: translateY(-3px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.14);
cursor: pointer;
}
/* Stat cards on the index page */
.stat-card .stat-number {
font-size: 2rem;
font-weight: 700;
}
.stat-card .stat-label {
color: #6c757d;
font-size: 0.9rem;
}
/* Release timeline on detail page */
.timeline {
position: relative;
padding-left: 1.5rem;
border-left: 3px solid #0d6efd;
}
.timeline .list-group-item {
position: relative;
border: none;
border-bottom: 1px solid #e9ecef;
background: transparent;
}
.timeline .list-group-item::before {
content: "";
position: absolute;
left: -1.72rem;
top: 1.1rem;
width: 12px;
height: 12px;
border-radius: 50%;
background-color: #0d6efd;
border: 2px solid #fff;
box-shadow: 0 0 0 2px #0d6efd;
}
/* Search input focus glow */
.nav-search-input:focus,
.search-input:focus {
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
border-color: #86b7fe;
}
/* Badge spacing in project cards */
.project-card .badge {
font-weight: 500;
}
/* Empty state text */
.empty-hint {
color: #6c757d;
padding: 1.5rem 0;
text-align: center;
animation: empty-hint-pulse 1.6s ease-in-out infinite;
}
@keyframes empty-hint-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
.empty-hint-error {
animation: none;
color: #dc3545;
}
/* Issue state badges */
.badge-state-open {
background-color: #198754;
}
.badge-state-closed {
background-color: #6c757d;
}
/* Watchdog daily report badges + rendered markdown */
.badge-state-partial {
background-color: #ffc107;
color: #000;
}
.watchdog-md {
font-size: 0.95rem;
line-height: 1.7;
word-break: break-word;
}
.watchdog-md h2 {
font-size: 1.15rem;
margin-top: 1.5rem;
padding-left: .6rem;
border-left: 3px solid #0d6efd;
}
.watchdog-md h2.wd-starred {
border-left-color: #198754;
}
.watchdog-md blockquote {
background: #f8f9fa;
border-left: 3px solid #dee2e6;
padding: .5rem .8rem;
color: #495057;
font-size: .9rem;
}
.watchdog-md code {
background: #f8f9fa;
padding: .1em .35em;
border-radius: 4px;
}
.watchdog-md pre {
background: #f8f9fa;
padding: .6rem .8rem;
border-radius: 4px;
overflow-x: auto;
}
.watchdog-md ul,
.watchdog-md ol {
padding-left: 1.5rem;
}
.watchdog-md p {
margin-bottom: .6rem;
}

294
static/js/ai-panel.js Normal file
View File

@@ -0,0 +1,294 @@
/*
* ai-panel.js — 全站浮动 AI 分析面板
* ES module (base.html 以 <script type="module"> 加载, 天然带作用域, 不再需要 IIFE).
* SSE 逻辑与 analyze 页共用 components/sse-client.js 的 createAnalysisStream.
* 所有请求用相对路径 (LAN 部署友好).
* 依赖 base.html 提供 <button id="ai-fab">, 不存在则自动创建.
*/
import { createAnalysisStream } from './components/sse-client.js';
import { escapeHtml } from './utils.js';
(function () {
'use strict';
var PANEL_ID = 'ai-panel-overlay';
var currentStream = null;
var panelBuilt = false;
var els = {}; // 缓存面板内 DOM 引用
/* ---------------- 样式注入 ---------------- */
function injectStyles() {
if (document.getElementById('ai-panel-styles')) return;
var css = ''
+ '#ai-fab{position:fixed;right:24px;bottom:24px;z-index:1040;width:56px;height:56px;'
+ 'border-radius:50%;border:none;font-size:26px;line-height:1;cursor:pointer;'
+ 'background:#0d6efd;color:#fff;box-shadow:0 4px 12px rgba(0,0,0,.35);'
+ 'transition:transform .15s ease,box-shadow .15s ease;}'
+ '#ai-fab:hover{transform:scale(1.1);box-shadow:0 6px 18px rgba(0,0,0,.45);}'
+ '#' + PANEL_ID + '{position:fixed;inset:0;z-index:1050;background:rgba(0,0,0,.6);'
+ 'display:flex;align-items:center;justify-content:center;padding:24px;}'
+ '#' + PANEL_ID + '.hidden{display:none;}'
+ '.ai-panel-box{background:#fff;border-radius:10px;width:100%;max-width:900px;'
+ 'max-height:90vh;display:flex;flex-direction:column;overflow:hidden;'
+ 'box-shadow:0 10px 40px rgba(0,0,0,.5);}'
+ '.ai-panel-head{display:flex;align-items:center;justify-content:space-between;'
+ 'padding:12px 20px;border-bottom:1px solid #dee2e6;}'
+ '.ai-panel-head h5{margin:0;}'
+ '.ai-panel-close{border:none;background:none;font-size:22px;cursor:pointer;line-height:1;}'
+ '.ai-panel-body{padding:16px 20px;overflow-y:auto;}'
+ '.ai-panel-controls{display:flex;flex-wrap:wrap;gap:10px;margin-bottom:12px;}'
+ '.ai-panel-controls select,.ai-panel-controls input{flex:1 1 180px;padding:6px 10px;'
+ 'border:1px solid #ced4da;border-radius:6px;font-size:14px;}'
+ '.ai-preset-row{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px;}'
+ '.ai-preset-btn{padding:6px 12px;border:1px solid #0d6efd;color:#0d6efd;background:#fff;'
+ 'border-radius:6px;cursor:pointer;font-size:14px;}'
+ '.ai-preset-btn:hover{background:#0d6efd;color:#fff;}'
+ '.ai-start-btn{padding:6px 16px;border:none;background:#198754;color:#fff;'
+ 'border-radius:6px;cursor:pointer;font-size:14px;}'
+ '.ai-start-btn:hover{background:#157347;}'
+ '.ai-tabs{display:flex;gap:4px;border-bottom:1px solid #dee2e6;margin-bottom:10px;}'
+ '.ai-tab-btn{border:none;background:none;padding:8px 14px;cursor:pointer;font-size:14px;'
+ 'border-bottom:2px solid transparent;color:#495057;}'
+ '.ai-tab-btn.active{color:#0d6efd;border-bottom-color:#0d6efd;font-weight:600;}'
+ '.ai-tab-pane{display:none;max-height:45vh;overflow-y:auto;font-size:14px;'
+ 'white-space:pre-wrap;word-break:break-word;}'
+ '.ai-tab-pane.active{display:block;}'
+ '.ai-muted{color:#6c757d;}'
+ '.ai-alert-info{background:#cff4fc;border:1px solid #b6effb;border-radius:6px;padding:10px;}'
+ '.ai-alert-success{background:#d1e7dd;border:1px solid #badbcc;border-radius:6px;padding:10px;}'
+ '.ai-alert-danger{background:#f8d7da;border:1px solid #f5c2c7;border-radius:6px;padding:10px;}'
+ '.ai-pre{background:#f8f9fa;border-radius:6px;padding:10px;white-space:pre-wrap;'
+ 'font-size:13px;margin:0;}';
var style = document.createElement('style');
style.id = 'ai-panel-styles';
style.textContent = css;
document.head.appendChild(style);
}
/* ---------------- 工具 ---------------- */
function closeStream() {
if (currentStream) {
currentStream.close();
currentStream = null;
}
}
/* ---------------- 面板 DOM ---------------- */
function buildPanel() {
if (panelBuilt) return;
var overlay = document.createElement('div');
overlay.id = PANEL_ID;
overlay.className = 'hidden';
overlay.innerHTML = ''
+ '<div class="ai-panel-box" role="dialog" aria-label="AI 分析面板">'
+ ' <div class="ai-panel-head">'
+ ' <h5>🤖 AI 分析</h5>'
+ ' <button type="button" class="ai-panel-close" id="aiPanelClose" title="关闭 (ESC)">&times;</button>'
+ ' </div>'
+ ' <div class="ai-panel-body">'
+ ' <div class="ai-panel-controls">'
+ ' <select id="aiScopeSelect" aria-label="分析范围">'
+ ' <option value="home">全站概览</option>'
+ ' <option value="projects">所有项目</option>'
+ ' </select>'
+ ' <input type="text" id="aiQuestionInput" placeholder="自定义问题, 例如: 这个项目最近有什么重要更新?">'
+ ' <button type="button" class="ai-start-btn" id="aiStartBtn">开始分析</button>'
+ ' </div>'
+ ' <div class="ai-preset-row" id="aiPresetRow"></div>'
+ ' <div class="ai-tabs" id="aiTabs">'
+ ' <button type="button" class="ai-tab-btn active" data-tab="observation">数据观察</button>'
+ ' <button type="button" class="ai-tab-btn" data-tab="prompt">Prompt</button>'
+ ' <button type="button" class="ai-tab-btn" data-tab="reasoning">LLM 推理</button>'
+ ' <button type="button" class="ai-tab-btn" data-tab="conclusion">结论</button>'
+ ' </div>'
+ ' <div class="ai-tab-content">'
+ ' <div class="ai-tab-pane active" id="aiPaneObservation"><span class="ai-muted">等待分析开始...</span></div>'
+ ' <div class="ai-tab-pane" id="aiPanePrompt"><span class="ai-muted">等待分析开始...</span></div>'
+ ' <div class="ai-tab-pane" id="aiPaneReasoning"><span class="ai-muted">等待分析开始...</span></div>'
+ ' <div class="ai-tab-pane" id="aiPaneConclusion"><span class="ai-muted">等待分析开始...</span></div>'
+ ' </div>'
+ ' </div>'
+ '</div>';
document.body.appendChild(overlay);
els.overlay = overlay;
els.scope = overlay.querySelector('#aiScopeSelect');
els.question = overlay.querySelector('#aiQuestionInput');
els.startBtn = overlay.querySelector('#aiStartBtn');
els.presetRow = overlay.querySelector('#aiPresetRow');
els.panes = {
observation: overlay.querySelector('#aiPaneObservation'),
prompt: overlay.querySelector('#aiPanePrompt'),
reasoning: overlay.querySelector('#aiPaneReasoning'),
conclusion: overlay.querySelector('#aiPaneConclusion')
};
// 事件绑定
overlay.querySelector('#aiPanelClose').addEventListener('click', hidePanel);
overlay.addEventListener('click', function (e) {
if (e.target === overlay) hidePanel();
});
els.startBtn.addEventListener('click', function () {
var q = els.question.value.trim();
if (!q) {
alert('请输入自定义问题或点击预设分析');
return;
}
startAnalysis(q, null);
});
overlay.querySelectorAll('.ai-tab-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
switchTab(btn.dataset.tab);
});
});
loadScopes();
loadPresets();
panelBuilt = true;
}
function switchTab(name) {
els.overlay.querySelectorAll('.ai-tab-btn').forEach(function (b) {
b.classList.toggle('active', b.dataset.tab === name);
});
Object.keys(els.panes).forEach(function (key) {
els.panes[key].classList.toggle('active', key === name);
});
}
/* ---------------- 数据加载 ---------------- */
function loadScopes() {
fetch('/api/projects')
.then(function (r) { return r.json(); })
.then(function (data) {
var projects = data.projects || data || [];
projects.forEach(function (p) {
var opt = document.createElement('option');
opt.value = 'project/' + p.id;
opt.textContent = '项目: ' + (p.name || p.full_name || ('#' + p.id));
els.scope.appendChild(opt);
});
})
.catch(function (err) {
console.error('ai-panel: 加载项目列表失败', err);
});
}
function loadPresets() {
fetch('/api/presets')
.then(function (r) { return r.json(); })
.then(function (data) {
var presets = data.presets || [];
els.presetRow.innerHTML = '';
presets.forEach(function (p) {
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'ai-preset-btn';
btn.textContent = (p.icon ? p.icon + ' ' : '') + p.label;
btn.title = p.description || '';
btn.addEventListener('click', function () {
startAnalysis(null, p.id);
});
els.presetRow.appendChild(btn);
});
})
.catch(function (err) {
console.error('ai-panel: 加载预设失败', err);
});
}
/* ---------------- SSE 分析 (与 analyze 页共用 components/sse-client.js) ---------------- */
function startAnalysis(question, preset) {
closeStream();
els.panes.observation.innerHTML = '<span class="ai-muted">正在收集上下文...</span>';
els.panes.prompt.innerHTML = '<span class="ai-muted">等待...</span>';
els.panes.reasoning.innerHTML = '<span class="ai-muted">等待...</span>';
els.panes.conclusion.innerHTML = '<span class="ai-muted">等待...</span>';
switchTab('observation');
currentStream = createAnalysisStream(els.scope.value, question, preset, {
onStep: function (step, content) {
if (step === 'context') {
els.panes.observation.innerHTML =
'<div class="ai-alert-info">' + escapeHtml(content) + '</div>';
} else if (step === 'prompt') {
els.panes.prompt.innerHTML =
'<pre class="ai-pre">' + escapeHtml(content) + '</pre>';
}
},
onToken: function (text, section) {
var target = section === 'conclusion' ? els.panes.conclusion : els.panes.reasoning;
if (target.querySelector('.ai-muted')) target.innerHTML = '';
target.innerHTML += escapeHtml(text);
target.scrollTop = target.scrollHeight;
},
onSectionStart: function (section) {
// Section 事件: 后端识别 ===REASONING=== / ===CONCLUSION=== 后显式下发
if (section === 'reasoning') {
els.panes.reasoning.innerHTML = '';
switchTab('reasoning');
} else {
els.panes.conclusion.innerHTML = '';
switchTab('conclusion');
}
},
onError: function (msg, kind) {
if (kind === 'server') {
els.panes.observation.innerHTML =
'<div class="ai-alert-danger">错误: ' + escapeHtml(msg) + '</div>';
} else {
els.panes.observation.innerHTML =
'<div class="ai-alert-danger">' + escapeHtml(msg) + '</div>';
}
closeStream();
},
onComplete: function (content) {
// 追加提示, 不覆盖已流式写入的结论内容
els.panes.conclusion.innerHTML +=
'<div class="ai-alert-success">' + escapeHtml(content) + '</div>';
switchTab('conclusion');
closeStream();
}
});
}
/* ---------------- 显示 / 隐藏 ---------------- */
function showPanel() {
buildPanel();
els.overlay.classList.remove('hidden');
}
function hidePanel() {
closeStream();
if (els.overlay) els.overlay.classList.add('hidden');
}
/* ---------------- 初始化 ---------------- */
function init() {
injectStyles();
var fab = document.getElementById('ai-fab');
if (!fab) {
fab = document.createElement('button');
fab.id = 'ai-fab';
fab.type = 'button';
fab.title = 'AI 分析';
fab.textContent = '🤖';
document.body.appendChild(fab);
}
fab.addEventListener('click', showPanel);
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && els.overlay && !els.overlay.classList.contains('hidden')) {
hidePanel();
}
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

View File

@@ -0,0 +1,72 @@
/* Project card component.
* 统一 index / projects / search 三处项目卡片的渲染.
* options 控制各页面差异 (对齐重构前的三种卡片变体):
* - showSubtitle (默认 true): 显示 full_name 副标题
* - showDescription (默认 false): 显示截断描述 (projects 页)
* - showForks (默认 true): 显示 🍴 badge
* - showStar (默认 true): 显示 ⭐ badge
* - showLanguage (默认 true): 显示语言 badge
* - showUpdatedAt (默认 false): 显示 "更新于" 行 (projects 页)
* - descriptionMaxLen (默认 100)
* 点击整卡跳转 /projects/<id> (与旧内联 onclick 行为一致).
*/
import { escapeHtml, truncate, formatDate } from "../utils.js";
export function renderProjectCard(p, options = {}) {
const {
showSubtitle = true,
showDescription = false,
showForks = true,
showStar = true,
showLanguage = true,
showUpdatedAt = false,
descriptionMaxLen = 100,
} = options;
const projectId = Number.parseInt(p.id, 10);
const badges = [
showStar ? `<span class="badge bg-warning text-dark">⭐ ${p.stars ?? 0}</span>` : "",
showForks ? `<span class="badge bg-secondary">🍴 ${p.forks ?? 0}</span>` : "",
showLanguage
? `<span class="badge bg-info text-dark">${escapeHtml(p.language || "N/A")}</span>`
: "",
]
.filter(Boolean)
.join(" ");
return `
<div class="col-md-4">
<div class="card project-card h-100" data-project-id="${Number.isNaN(projectId) ? "" : projectId}">
<div class="card-body">
<h5 class="card-title">${escapeHtml(p.name)}</h5>
${
showSubtitle
? `<h6 class="card-subtitle mb-2 text-muted">${escapeHtml(p.full_name || "")}</h6>`
: ""
}
${
showDescription
? `<p class="card-text">${escapeHtml(truncate(p.description, descriptionMaxLen))}</p>`
: ""
}
<p class="card-text ${showUpdatedAt ? "mb-1" : "mb-0"}">${badges}</p>
${
showUpdatedAt
? `<small class="text-muted">更新于 ${formatDate(p.updated_at)}</small>`
: ""
}
</div>
</div>
</div>`;
}
/* Delegated click handler; one listener on document covers all cards. */
export function bindProjectCardClicks() {
document.addEventListener("click", (e) => {
const card = e.target.closest(".project-card[data-project-id]");
if (!card) return;
const id = Number.parseInt(card.dataset.projectId, 10);
if (!Number.isNaN(id)) location.href = `/projects/${id}`;
});
}

View File

@@ -0,0 +1,101 @@
/* Shared SSE client for /api/analyze/stream.
* 统一 analyze.html 内联逻辑与 ai-panel.js 的 SSE 逻辑.
* 事件契约 (后端 services 层下发):
* step: {step:'context'|'prompt'|'complete', content:string}
* token: {text:string} — 流式 LLM 输出, 分隔符已被后端剥离
* reasoning_start / conclusion_start: 无 payload, 切换当前 section
* error: {error:string} — 业务错误帧
* onerror: 连接层错误 (非 JSON)
*
* callbacks 全部可选:
* {onStep, onToken, onSectionStart, onError, onComplete}
* onStep(step, content) — step === 'complete' 时也会先调 onStep 再调 onComplete
* onToken(text, section) — section: 'reasoning' | 'conclusion'
* onSectionStart(section) — 'reasoning' | 'conclusion'
* onError(message, kind) — kind: 'server' (业务帧) | 'connection'
* onComplete(content) — step === 'complete'
*
* 返回句柄 {close(), getSection()}, close() 幂等.
* complete / error 帧后 client 自动 close 并失效 (与原两个实现一致).
*/
export function createAnalysisStream(scope, question, preset, callbacks = {}) {
const { onStep, onToken, onSectionStart, onError, onComplete } = callbacks;
let currentSection = "reasoning"; // 'reasoning' | 'conclusion', 由 SSE section 事件驱动
let closed = false;
const params = new URLSearchParams({ scope });
if (question) params.append("question", question);
if (preset) params.append("preset", preset);
const es = new EventSource(`/api/analyze/stream?${params.toString()}`);
function close() {
if (closed) return;
closed = true;
es.close();
}
es.addEventListener("step", (e) => {
let data;
try {
data = JSON.parse(e.data);
} catch (err) {
return; // 非 JSON step 帧, 忽略
}
const { step, content } = data;
if (onStep) onStep(step, content);
if (step === "complete") {
if (onComplete) onComplete(content);
close();
}
});
// Section 事件: 后端识别 ===REASONING=== / ===CONCLUSION=== 后显式下发
es.addEventListener("reasoning_start", () => {
currentSection = "reasoning";
if (onSectionStart) onSectionStart("reasoning");
});
es.addEventListener("conclusion_start", () => {
currentSection = "conclusion";
if (onSectionStart) onSectionStart("conclusion");
});
es.addEventListener("token", (e) => {
let data;
try {
data = JSON.parse(e.data);
} catch (err) {
return;
}
if (onToken) onToken(data.text || "", currentSection);
});
es.addEventListener("error", (e) => {
let msg = "未知错误";
try {
const data = JSON.parse(e.data);
msg = data.error || msg;
} catch (ignore) {
/* 非 JSON 错误帧 */
}
if (onError) onError(msg, "server");
close();
});
es.onerror = () => {
// 与旧实现一致: 仅当 stream 仍处于活跃状态时才上报连接错误,
// 避免 complete 后浏览器触发 onerror 造成的误报.
if (!closed) {
if (onError) onError("连接错误, 请检查后端日志", "connection");
close();
}
};
return {
close,
getSection: () => currentSection,
};
}

View File

@@ -0,0 +1,19 @@
/* Stats cards component — 首页 #statsRow 四项统计数字.
* 复用 index.html 现有的 id (statProjects/statReleases/statIssues/statStars),
* 与模板中预置的占位卡片结构对应, 只填数字不重排 DOM.
*/
/**
* @param {{total_projects:number, total_releases:number, total_issues:number, total_stars:number}} stats
* @param {HTMLElement|Document} [root] - 作用域根节点, 默认 document
*/
export function renderStatsCards(stats, root = document) {
const set = (id, value) => {
const el = root.getElementById ? root.getElementById(id) : root.querySelector(`#${id}`);
if (el) el.textContent = value;
};
set("statProjects", stats.total_projects);
set("statReleases", stats.total_releases);
set("statIssues", stats.total_issues);
set("statStars", stats.total_stars);
}

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());
}
});
}

60
static/js/utils.js Normal file
View File

@@ -0,0 +1,60 @@
/* Common helpers shared across pages (ES module). */
/**
* Debounce a function call.
* @param {Function} fn - function to debounce
* @param {number} delay - delay in ms
*/
export function debounce(fn, delay) {
let timer = null;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
/**
* Format an ISO date string to YYYY-MM-DD.
* Returns empty string for falsy input.
*/
export function formatDate(isoString) {
if (!isoString) return "";
const d = new Date(isoString);
if (isNaN(d.getTime())) return String(isoString).slice(0, 10);
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
/** Truncate text to maxLen chars, appending ellipsis. */
export function truncate(text, maxLen) {
if (!text) return "";
const s = String(text);
return s.length > maxLen ? s.slice(0, maxLen) + "…" : s;
}
/** Escape HTML special chars to prevent XSS when injecting via innerHTML. */
export function escapeHtml(text) {
if (text === null || text === undefined) return "";
return String(text)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
/** Fetch JSON with basic error handling. */
export async function fetchJson(url) {
const resp = await fetch(url);
if (!resp.ok) {
throw new Error(`请求失败: ${resp.status} ${resp.statusText}`);
}
return resp.json();
}
/* Delegated retry: covers .js-retry-btn injected into error hints after load. */
document.addEventListener("click", (e) => {
if (e.target.closest(".js-retry-btn")) location.reload();
});