Files
wonder 8c5d540565 fix: 修复所有页面 Tab 导航下方冗余空白
- 将 body 布局从普通流改为 flex 纵向布局,navbar 和内容区自然排列
- navbar 移除 position:sticky,改为 flex-shrink:0 作为固定头部
- 新增 .main-content 工具类(flex:1; overflow:hidden),替代 calc(100vh-56px)+margin-top 方案
- builder/dashboard 页面使用 main-content 类占满剩余空间
- settings 页面 margin-top 从 80px 调整为 24px
2026-06-26 17:44:01 +08:00

370 lines
16 KiB
HTML

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Prompt Generator — 复盘看板</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="/static/common.css">
</head>
<body>
<div class="flex main-content">
<!-- 左侧项目树 -->
<div class="w-64 border-r overflow-y-auto p-4" style="border-color: var(--border-color); background: var(--bg-primary);">
<div class="search-box mb-4">
<span class="search-icon">🔍</span>
<input class="input" id="tree-search" placeholder="搜索项目..." style="font-size:12px;padding:6px 8px 6px 30px">
</div>
<div id="project-tree"></div>
</div>
<!-- 右侧内容区 -->
<div class="flex-1 overflow-y-auto p-4" style="background: var(--bg-secondary);">
<!-- 搜索栏 -->
<div class="flex gap-3 mb-4">
<div class="search-box flex-1">
<span class="search-icon">🔍</span>
<input class="input" id="prompt-search" placeholder="搜索 Prompt 内容..." style="padding-left:36px">
</div>
<button class="btn" onclick="exportPrompts('markdown')">📥 导出 Markdown</button>
<button class="btn" onclick="exportPrompts('json')">📥 导出 JSON</button>
<button class="btn" onclick="exportPrompts('text')">📥 导出文本</button>
</div>
<!-- 内容展示区 -->
<div id="content-area">
<div class="text-center text-muted p-6" style="padding-top:120px">
<div style="font-size:48px;margin-bottom:16px">📋</div>
<div class="text-lg font-medium mb-2">选择项目和会话查看 Prompt</div>
<div class="text-sm">在左侧项目树中选择一个会话开始浏览</div>
</div>
</div>
</div>
</div>
<script src="/static/common.js"></script>
<script>
let currentProject = '';
let currentSession = '';
let currentPrompts = [];
let projects = [];
async function init() {
const ok = await Auth.check();
if (!ok) {
Auth.showLoginModal();
return;
}
renderNavbar('dashboard');
await loadProjects();
setupSearch();
}
async function loadProjects() {
try {
projects = await API.get('/api/dashboard/projects');
renderProjectTree();
} catch (e) {
Toast.error('加载项目列表失败: ' + e.message);
}
}
function renderProjectTree() {
const keyword = document.getElementById('tree-search').value.toLowerCase();
const filtered = projects.filter(p => p.project_name.toLowerCase().includes(keyword));
const tree = document.getElementById('project-tree');
if (filtered.length === 0) {
tree.innerHTML = '<div class="text-sm text-muted">暂无项目</div>';
return;
}
tree.innerHTML = filtered.map((p, idx) => `
<div class="mb-2">
<div class="collapse-header ${currentProject === p.project_name ? 'open' : ''}"
onclick="toggleProject(this)" data-project="${escapeHtml(p.project_name)}">
<span class="collapse-arrow">▶</span>
<span class="text-sm font-medium">${escapeHtml(p.project_name)}</span>
<span class="text-xs text-muted ml-auto">${p.session_count}</span>
</div>
<div class="collapse-content ${currentProject === p.project_name ? 'open' : ''}"
id="project-${idx}">
<div class="sessions-container text-sm text-muted">加载中...</div>
</div>
</div>
`).join('');
}
async function toggleProject(header) {
header.classList.toggle('open');
const content = header.nextElementSibling;
content.classList.toggle('open');
if (content.classList.contains('open')) {
const projectName = header.getAttribute('data-project');
currentProject = projectName;
await loadSessions(projectName, content.querySelector('.sessions-container'));
}
}
async function loadSessions(projectName, container) {
try {
const sessions = await API.get(`/api/dashboard/projects/${encodeURIComponent(projectName)}/sessions`);
if (sessions.length === 0) {
container.innerHTML = '<div class="text-sm text-muted p-2">暂无会话</div>';
return;
}
container.innerHTML = sessions.map(s => `
<div class="p-2 rounded cursor-pointer mb-1 ${currentSession === s.session_id ? 'bg-blue-50' : ''}"
style="${currentSession === s.session_id ? 'background:var(--bg-tertiary)' : ''}"
onclick="selectSession('${escapeHtml(s.session_id)}', '${escapeHtml(projectName)}')">
<div class="text-xs font-medium truncate">${s.session_id.substring(0, 12)}...</div>
<div class="text-xs text-muted">${s.prompt_count} 条 · ${formatDate(s.last_time)}</div>
</div>
`).join('');
} catch (e) {
container.innerHTML = '<div class="text-sm text-muted p-2">加载失败</div>';
}
}
async function selectSession(sessionId, projectName) {
currentSession = sessionId;
currentProject = projectName;
// Update highlight without re-rendering the whole tree
document.querySelectorAll('.sessions-container .selected-session').forEach(el => el.classList.remove('selected-session'));
event.currentTarget.classList.add('selected-session');
try {
const data = await API.get(`/api/dashboard/prompts?session_id=${encodeURIComponent(sessionId)}&page_size=50`);
currentPrompts = data.prompts || [];
renderPrompts();
} catch (e) {
Toast.error('加载 Prompt 失败: ' + e.message);
}
}
function renderPrompts() {
const area = document.getElementById('content-area');
if (currentPrompts.length === 0) {
area.innerHTML = '<div class="text-center text-muted p-6">暂无 Prompt 记录</div>';
return;
}
area.innerHTML = `
<div class="mb-3 text-sm text-muted">
会话: ${currentSession.substring(0, 12)}... · 共 ${currentPrompts.length} 条 Prompt
</div>
<div class="flex flex-col gap-3">
${currentPrompts.map((p, i) => renderPromptCard(p, i)).join('')}
</div>
`;
}
function renderPromptCard(prompt, index) {
return `
<div class="card">
<div class="card-header">
<span class="text-sm">#${index + 1} · ${formatDate(prompt.created_at)}</span>
<button class="btn btn-sm" onclick="togglePromptView(${index})">
<span id="view-btn-${index}">切换到结构视图</span>
</button>
</div>
<div class="card-body">
<div id="prompt-text-${index}" class="prompt-text-view">
${renderPromptContent(prompt.prompt)}
</div>
<div id="prompt-struct-${index}" class="prompt-struct-view hidden">
<div class="text-sm text-muted" id="struct-content-${index}">加载中...</div>
</div>
</div>
</div>
`;
}
function renderPromptContent(text) {
if (!text) return '<div class="text-sm text-muted">空内容</div>';
// 折叠 XML 标签
const parts = [];
let lastIndex = 0;
const xmlRegex = /<([a-zA-Z_-][a-zA-Z0-9_-]*)[^>]*>[\s\S]*?<\/\1>/g;
let match;
while ((match = xmlRegex.exec(text)) !== null) {
// Add text before this tag
if (match.index > lastIndex) {
const before = text.substring(lastIndex, match.index);
if (before.trim()) {
parts.push(`<div class="mb-2" style="white-space:pre-wrap">${escapeHtml(before)}</div>`);
}
}
// Add collapsible tag
const tagName = match[1];
parts.push(`
<div class="mb-2" style="border:1px solid var(--border-color);border-radius:6px;overflow:hidden">
<div class="collapse-header" onclick="toggleCollapse(this)" style="background:var(--bg-tertiary);padding:6px 12px">
<span class="collapse-arrow">▶</span>
<span class="text-xs font-medium text-muted">&lt;${escapeHtml(tagName)}&gt;</span>
</div>
<div class="collapse-content" style="padding:8px 12px;font-size:12px;white-space:pre-wrap;max-height:300px;overflow-y:auto">${escapeHtml(match[0])}</div>
</div>
`);
lastIndex = match.index + match[0].length;
}
// Add remaining text
if (lastIndex < text.length) {
const remaining = text.substring(lastIndex);
if (remaining.trim()) {
parts.push(`<div style="white-space:pre-wrap">${escapeHtml(remaining)}</div>`);
}
}
return parts.length > 0 ? parts.join('') : `<div style="white-space:pre-wrap">${escapeHtml(text)}</div>`;
}
async function togglePromptView(index) {
const textView = document.getElementById(`prompt-text-${index}`);
const structView = document.getElementById(`prompt-struct-${index}`);
const btn = document.getElementById(`view-btn-${index}`);
if (textView.classList.contains('hidden')) {
textView.classList.remove('hidden');
structView.classList.add('hidden');
btn.textContent = '切换到结构视图';
} else {
textView.classList.add('hidden');
structView.classList.remove('hidden');
btn.textContent = '切换到文本视图';
// Load structure view data
await loadStructureView(index);
}
}
async function loadStructureView(index) {
const container = document.getElementById(`struct-content-${index}`);
if (container.dataset.loaded) return;
try {
const data = await API.get(`/api/claude/sessions/${currentSession}/builder`);
if (!data || !data.session) {
container.innerHTML = '<div class="text-sm text-muted">未找到关联的构建会话</div>';
container.dataset.loaded = 'true';
return;
}
let html = '';
// Tags
if (data.tags && data.tags.length > 0) {
html += '<div class="mb-3"><div class="text-sm font-medium mb-2">🏷️ 标签选择</div>';
html += data.tags.map(t => `
<div class="mb-2 p-2" style="background:var(--bg-tertiary);border-radius:6px">
<div class="text-sm font-medium">${escapeHtml(t.tag_name)}</div>
<div class="text-xs text-muted mt-1">选项: ${escapeHtml(t.option_label)}</div>
<div class="text-xs mt-1" style="color:var(--text-secondary)">${escapeHtml(t.constraint_text)}</div>
</div>
`).join('');
html += '</div>';
}
// Snippets
if (data.snippets && data.snippets.length > 0) {
html += '<div class="mb-3"><div class="text-sm font-medium mb-2">📄 片段</div>';
html += data.snippets.map(s => `
<div class="mb-2 p-2" style="background:var(--bg-tertiary);border-radius:6px">
<div class="text-sm font-medium">${escapeHtml(s.name)} <span class="text-xs text-muted">(${escapeHtml(s.category)})</span></div>
<div class="text-xs mt-1" style="color:var(--text-secondary);white-space:pre-wrap">${escapeHtml(s.content.substring(0, 200))}${s.content.length > 200 ? '...' : ''}</div>
</div>
`).join('');
html += '</div>';
}
if (!html) {
html = '<div class="text-sm text-muted">无标签和片段数据</div>';
}
container.innerHTML = html;
container.dataset.loaded = 'true';
} catch (e) {
container.innerHTML = '<div class="text-sm text-muted">加载结构视图失败</div>';
}
}
function toggleCollapse(header) {
header.classList.toggle('open');
const content = header.nextElementSibling;
if (content) content.classList.toggle('open');
}
// 导出
function exportPrompts(format) {
if (currentPrompts.length === 0) {
Toast.error('没有可导出的内容');
return;
}
let content, filename, mimeType;
switch (format) {
case 'markdown':
content = `# Prompt 导出\n\n项目: ${currentProject}\n会话: ${currentSession}\n\n---\n\n`;
content += currentPrompts.map((p, i) =>
`## Prompt #${i + 1} (${formatDate(p.created_at)})\n\n${p.prompt}\n\n---\n`
).join('\n');
filename = `prompts-${currentSession.substring(0, 8)}.md`;
mimeType = 'text/markdown';
break;
case 'json':
content = JSON.stringify(currentPrompts, null, 2);
filename = `prompts-${currentSession.substring(0, 8)}.json`;
mimeType = 'application/json';
break;
case 'text':
content = currentPrompts.map((p, i) =>
`[${i + 1}] ${formatDate(p.created_at)}\n${p.prompt}\n`
).join('\n---\n\n');
filename = `prompts-${currentSession.substring(0, 8)}.txt`;
mimeType = 'text/plain';
break;
}
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
Toast.success('导出成功');
}
// 搜索
function setupSearch() {
const searchInput = document.getElementById('prompt-search');
const debouncedSearch = debounce(async () => {
if (!currentSession) return;
const keyword = searchInput.value.trim();
try {
const data = await API.get(`/api/dashboard/prompts?session_id=${encodeURIComponent(currentSession)}&keyword=${encodeURIComponent(keyword)}&page_size=50`);
currentPrompts = data.prompts || [];
renderPrompts();
} catch (e) {
Toast.error('搜索失败: ' + e.message);
}
}, 500);
searchInput.addEventListener('input', debouncedSearch);
document.getElementById('tree-search').addEventListener('input', debounce(() => renderProjectTree(), 300));
}
init();
</script>
</body>
</html>