initial: blog-app snapshot
This commit is contained in:
153
static/css/style.css
Normal file
153
static/css/style.css
Normal 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
294
static/js/ai-panel.js
Normal 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)">×</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();
|
||||
}
|
||||
})();
|
||||
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);
|
||||
}
|
||||
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());
|
||||
}
|
||||
});
|
||||
}
|
||||
60
static/js/utils.js
Normal file
60
static/js/utils.js
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/** 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();
|
||||
});
|
||||
Reference in New Issue
Block a user