initial: blog-app snapshot
This commit is contained in:
72
static/js/components/project-card.js
Normal file
72
static/js/components/project-card.js
Normal 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}`;
|
||||
});
|
||||
}
|
||||
101
static/js/components/sse-client.js
Normal file
101
static/js/components/sse-client.js
Normal 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,
|
||||
};
|
||||
}
|
||||
19
static/js/components/stats-card.js
Normal file
19
static/js/components/stats-card.js
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user