feat: add frontend — builder, dashboard, and settings pages

- common.css: 全局样式,支持亮色/暗色主题,完整组件库
- common.js: API 封装、认证模块、Toast 提示、主题切换、草稿管理
- index.html: Prompt 构建器完整 UI
  - 左侧面板:项目/会话选择、标签系统(搜索/折叠/单选)、片段选择(分组/搜索)、CLAUDE.md 输入、自定义内容
  - 右侧面板:实时 Prompt 预览、智能建议列表、复制到剪贴板
  - 草稿自动保存(localStorage,防抖 1s)
  - 新增个人标签模态框
  - 保存构建会话功能
- dashboard.html: 复盘看板
  - 左侧项目树(项目→会话层级)
  - 右侧 Prompt 列表(文本视图,XML 标签折叠)
  - 导出功能(Markdown/JSON/纯文本)
  - 关键词搜索
- settings.html: 设置页
  - LLM 配置(API URL/Key/模型)
  - 主题切换
  - 退出登录

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-26 15:05:46 +08:00
parent 01ecdc8c23
commit f456cdb565
5 changed files with 1687 additions and 0 deletions
+315
View File
@@ -0,0 +1,315 @@
<!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" style="height: calc(100vh - 56px); margin-top: 56px;">
<!-- 左侧项目树 -->
<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 => `
<div class="mb-2">
<div class="collapse-header ${currentProject === p.project_name ? 'open' : ''}"
onclick="toggleProject(this, '${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-${escapeHtml(p.project_name).replace(/[^a-zA-Z0-9]/g, '_')}">
<div class="sessions-container text-sm text-muted">加载中...</div>
</div>
</div>
`).join('');
}
async function toggleProject(header, projectName) {
header.classList.toggle('open');
const content = header.nextElementSibling;
content.classList.toggle('open');
if (content.classList.contains('open')) {
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;
renderProjectTree(); // Re-render to highlight
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) {
const preview = truncateText(prompt.prompt, 150);
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">结构视图暂不可用(需要关联构建会话数据)</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>`;
}
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 = '切换到文本视图';
}
}
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>
+656
View File
@@ -0,0 +1,656 @@
<!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" style="height: calc(100vh - 56px); margin-top: 56px;">
<!-- 左侧面板 -->
<div class="w-1/3 border-r overflow-y-auto p-4 flex flex-col gap-4" style="border-color: var(--border-color); background: var(--bg-primary);">
<!-- 项目/会话 -->
<div class="card">
<div class="card-header">
📁 项目 / 会话
</div>
<div class="card-body flex flex-col gap-3">
<div>
<label class="text-sm font-medium mb-2" style="display:block">项目名称</label>
<input class="input" id="project-name" placeholder="输入项目名称(可选)">
</div>
<div>
<label class="text-sm font-medium mb-2" style="display:block">关联 Claude Session</label>
<select class="select" id="claude-session">
<option value="">不关联</option>
</select>
</div>
<div id="session-prompts" class="hidden">
<div class="collapse-header" onclick="toggleCollapse(this)">
<span class="collapse-arrow">▶</span>
<span class="text-sm font-medium">历史 Prompt 参考</span>
</div>
<div class="collapse-content" id="session-prompts-list"></div>
</div>
</div>
</div>
<!-- 标签选择 -->
<div class="card">
<div class="card-header">
🏷️ 标签选择
<div class="search-box" style="width:160px">
<span class="search-icon">🔍</span>
<input class="input" id="tag-search" placeholder="搜索标签..." style="font-size:12px;padding:4px 8px 4px 30px">
</div>
</div>
<div class="card-body">
<div id="system-tags" class="mb-3">
<div class="text-xs font-medium text-muted mb-2">系统标签</div>
<div id="system-tags-list"></div>
</div>
<div id="personal-tags">
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-medium text-muted">个人标签</span>
<button class="btn btn-sm" onclick="showAddTagModal()">+ 新增</button>
</div>
<div id="personal-tags-list"></div>
</div>
</div>
</div>
<!-- 片段选择 -->
<div class="card">
<div class="card-header">
📄 片段选择
<div class="search-box" style="width:160px">
<span class="search-icon">🔍</span>
<input class="input" id="snippet-search" placeholder="搜索片段..." style="font-size:12px;padding:4px 8px 4px 30px">
</div>
</div>
<div class="card-body" id="snippets-container"></div>
</div>
<!-- CLAUDE.md -->
<div class="card">
<div class="collapse-header" onclick="toggleCollapse(this)">
<span class="collapse-arrow">▶</span>
<span class="text-sm font-medium">📋 CLAUDE.md 内容(可选)</span>
</div>
<div class="collapse-content">
<textarea class="textarea" id="claude-md" placeholder="粘贴 CLAUDE.md 内容,用于智能建议分析(不会注入到最终 Prompt)" rows="4"></textarea>
</div>
</div>
<!-- 自定义输入 -->
<div class="card">
<div class="card-header">✏️ 自定义输入</div>
<div class="card-body">
<textarea class="textarea" id="custom-content" placeholder="输入自定义的 Prompt 内容..." rows="6"></textarea>
</div>
</div>
</div>
<!-- 右侧面板 -->
<div class="w-2/3 overflow-y-auto p-4 flex flex-col gap-4" style="background: var(--bg-secondary);">
<!-- 预览区 -->
<div class="card flex-1">
<div class="card-header">
📝 最终 Prompt 预览
<div class="flex gap-2">
<button class="btn btn-sm" onclick="saveSession()">💾 保存</button>
<button class="btn btn-sm btn-primary" onclick="copyPrompt()">📋 复制到剪贴板</button>
</div>
</div>
<div class="card-body">
<pre id="prompt-preview" style="white-space: pre-wrap; word-break: break-word; font-size: 13px; line-height: 1.6; min-height: 200px; color: var(--text-secondary);">选择标签和片段后,这里将实时预览最终的 Prompt...</pre>
</div>
</div>
<!-- 智能建议区 -->
<div class="card" id="suggestions-card" style="display:none">
<div class="card-header">
💡 智能建议
<div class="flex gap-2">
<button class="btn btn-sm" onclick="toggleAllSuggestions(true)">全选</button>
<button class="btn btn-sm" onclick="toggleAllSuggestions(false)">全不选</button>
</div>
</div>
<div class="card-body" id="suggestions-list"></div>
</div>
<div class="flex gap-2">
<button class="btn btn-primary" id="suggest-btn" onclick="getSuggestions()">🤖 智能建议</button>
<button class="btn" onclick="resetBuilder()">🔄 清空重置</button>
</div>
</div>
</div>
<!-- 新增标签模态框 -->
<div id="add-tag-modal" class="modal-overlay hidden">
<div class="modal">
<div class="modal-title">新增个人标签</div>
<div class="flex flex-col gap-3">
<div>
<label class="text-sm font-medium mb-2" style="display:block">标签名称</label>
<input class="input" id="new-tag-name" placeholder="如:部署环境">
</div>
<div>
<label class="text-sm font-medium mb-2" style="display:block">标签说明</label>
<input class="input" id="new-tag-desc" placeholder="简要说明标签用途">
</div>
</div>
<div class="flex gap-2 mt-4" style="justify-content:flex-end">
<button class="btn" onclick="closeAddTagModal()">取消</button>
<button class="btn btn-primary" onclick="createTag()">创建</button>
</div>
</div>
</div>
<!-- 保存会话模态框 -->
<div id="save-session-modal" class="modal-overlay hidden">
<div class="modal">
<div class="modal-title">保存构建会话</div>
<div class="flex flex-col gap-3">
<div>
<label class="text-sm font-medium mb-2" style="display:block">会话标题</label>
<input class="input" id="session-title" placeholder="为这次构建起个名字">
</div>
</div>
<div class="flex gap-2 mt-4" style="justify-content:flex-end">
<button class="btn" onclick="closeSaveModal()">取消</button>
<button class="btn btn-primary" onclick="doSaveSession()">保存</button>
</div>
</div>
</div>
<script src="/static/common.js"></script>
<script>
// 状态
let allTags = [];
let allSnippets = [];
let selectedTags = {}; // tagId -> optionId
let selectedSnippets = []; // [{snippetId, sortOrder}]
let suggestions = [];
// 初始化
async function init() {
const ok = await Auth.check();
if (!ok) {
Auth.showLoginModal();
return;
}
renderNavbar('builder');
loadDraft();
await Promise.all([loadTags(), loadSnippets()]);
setupProjectNameListener();
setupAutoSave();
}
// 加载标签
async function loadTags() {
try {
allTags = await API.get('/api/tags');
renderTags();
} catch (e) {
Toast.error('加载标签失败: ' + e.message);
}
}
function renderTags() {
const keyword = document.getElementById('tag-search').value.toLowerCase();
const systemTags = allTags.filter(t => t.scope === 'system' && t.name.toLowerCase().includes(keyword));
const personalTags = allTags.filter(t => t.scope === 'personal' && t.name.toLowerCase().includes(keyword));
document.getElementById('system-tags-list').innerHTML = systemTags.map(t => renderTagItem(t)).join('');
document.getElementById('personal-tags-list').innerHTML = personalTags.length
? personalTags.map(t => renderTagItem(t)).join('')
: '<div class="text-sm text-muted">暂无个人标签</div>';
}
function renderTagItem(tag) {
const checked = selectedTags[tag.id] !== undefined;
const optionsHtml = tag.options.map(o => `
<label class="radio-label">
<input type="radio" name="tag_${tag.id}" value="${o.id}" ${selectedTags[tag.id] === o.id ? 'checked' : ''} onchange="selectTagOption(${tag.id}, ${o.id})">
<span class="text-sm">${escapeHtml(o.label)}</span>
</label>
`).join('');
return `
<div class="mb-2">
<div class="collapse-header ${checked ? 'open' : ''}" onclick="toggleTagCollapse(this, ${tag.id})">
<span class="collapse-arrow">▶</span>
<input type="checkbox" ${checked ? 'checked' : ''} onclick="event.stopPropagation(); toggleTag(${tag.id}, this.checked)" style="accent-color:var(--accent-color)">
<span class="text-sm font-medium">${escapeHtml(tag.name)}</span>
<span class="tag ${tag.scope === 'system' ? 'tag-system' : 'tag-personal'}">${tag.scope === 'system' ? '系统' : '个人'}</span>
</div>
<div class="collapse-content ${checked ? 'open' : ''}" id="tag-options-${tag.id}">
<div class="radio-group">${optionsHtml}</div>
</div>
</div>
`;
}
function toggleTagCollapse(header, tagId) {
header.classList.toggle('open');
const content = document.getElementById(`tag-options-${tagId}`);
content.classList.toggle('open');
}
function toggleTag(tagId, checked) {
if (checked) {
// Select first option by default
const tag = allTags.find(t => t.id === tagId);
if (tag && tag.options.length > 0) {
selectedTags[tagId] = tag.options[0].id;
}
} else {
delete selectedTags[tagId];
}
renderTags();
updatePreview();
}
function selectTagOption(tagId, optionId) {
selectedTags[tagId] = optionId;
updatePreview();
}
// 加载片段
async function loadSnippets() {
try {
allSnippets = await API.get('/api/snippets');
renderSnippets();
} catch (e) {
Toast.error('加载片段失败: ' + e.message);
}
}
function renderSnippets() {
const keyword = document.getElementById('snippet-search').value.toLowerCase();
const filtered = allSnippets.filter(s =>
s.name.toLowerCase().includes(keyword) || s.category.toLowerCase().includes(keyword)
);
// Group by category
const groups = {};
filtered.forEach(s => {
const cat = s.category || '未分类';
if (!groups[cat]) groups[cat] = [];
groups[cat].push(s);
});
const container = document.getElementById('snippets-container');
if (Object.keys(groups).length === 0) {
container.innerHTML = '<div class="text-sm text-muted">暂无片段</div>';
return;
}
container.innerHTML = Object.entries(groups).map(([cat, snippets]) => `
<div class="mb-3">
<div class="collapse-header open" onclick="toggleCollapse(this)">
<span class="collapse-arrow">▶</span>
<span class="text-sm font-medium">${escapeHtml(cat)}</span>
</div>
<div class="collapse-content open">
${snippets.map(s => renderSnippetItem(s)).join('')}
</div>
</div>
`).join('');
}
function renderSnippetItem(snippet) {
const isSelected = selectedSnippets.some(s => s.snippetId === snippet.id);
const preview = snippet.content.substring(0, 50) + (snippet.content.length > 50 ? '...' : '');
return `
<label class="checkbox-label mb-1" title="${escapeHtml(snippet.content)}">
<input type="checkbox" ${isSelected ? 'checked' : ''} onchange="toggleSnippet(${snippet.id}, this.checked)">
<span class="text-sm">${escapeHtml(snippet.name)}</span>
<span class="text-xs text-muted truncate" style="max-width:120px">${escapeHtml(preview)}</span>
<span class="tag ${snippet.scope === 'system' ? 'tag-system' : 'tag-personal'}">${snippet.scope === 'system' ? '系统' : '个人'}</span>
</label>
`;
}
function toggleSnippet(snippetId, checked) {
if (checked) {
selectedSnippets.push({ snippetId, sortOrder: selectedSnippets.length });
} else {
selectedSnippets = selectedSnippets.filter(s => s.snippetId !== snippetId);
// Re-order
selectedSnippets.forEach((s, i) => s.sortOrder = i);
}
updatePreview();
}
// 更新预览
function updatePreview() {
const parts = [];
// Project context
const projectName = document.getElementById('project-name').value.trim();
if (projectName) {
parts.push(`## 项目上下文\n\n项目名称: ${projectName}`);
}
// Custom content
const customContent = document.getElementById('custom-content').value.trim();
if (customContent) {
parts.push(`## 用户自定义内容\n\n${customContent}`);
}
// Tag constraints
const tagConstraints = [];
for (const [tagId, optionId] of Object.entries(selectedTags)) {
const tag = allTags.find(t => t.id == tagId);
if (!tag) continue;
const option = tag.options.find(o => o.id == optionId);
if (!option) continue;
tagConstraints.push(`- ${option.constraint_text}`);
}
if (tagConstraints.length > 0) {
parts.push(`## 标签约束\n\n${tagConstraints.join('\n')}`);
}
// Snippets
if (selectedSnippets.length > 0) {
const snippetParts = selectedSnippets
.sort((a, b) => a.sortOrder - b.sortOrder)
.map(s => {
const snippet = allSnippets.find(sn => sn.id === s.snippetId);
return snippet ? `--- 片段: ${snippet.name} ---\n${snippet.content}` : '';
})
.filter(Boolean);
if (snippetParts.length > 0) {
parts.push(`## 片段内容\n\n${snippetParts.join('\n\n')}`);
}
}
// Suggestions
const adoptedSuggestions = suggestions.filter(s => s.adopted);
if (adoptedSuggestions.length > 0) {
parts.push(`## 智能建议补充\n\n${adoptedSuggestions.map(s => s.constraint_text).join('\n')}`);
}
const preview = parts.length > 0 ? parts.join('\n\n') : '选择标签和片段后,这里将实时预览最终的 Prompt...';
document.getElementById('prompt-preview').textContent = preview;
}
// 项目名输入监听 — 加载 Claude Session 列表
function setupProjectNameListener() {
const input = document.getElementById('project-name');
const debouncedLoad = debounce(async () => {
const name = input.value.trim();
const select = document.getElementById('claude-session');
if (!name) {
select.innerHTML = '<option value="">不关联</option>';
return;
}
try {
const sessions = await API.get(`/api/claude/sessions?project_name=${encodeURIComponent(name)}`);
select.innerHTML = '<option value="">不关联</option>';
sessions.forEach(s => {
const shortId = s.session_id.substring(0, 8);
const opt = document.createElement('option');
opt.value = s.session_id;
opt.textContent = `${shortId}... (${s.prompt_count}条, ${formatDate(s.last_time)})`;
select.appendChild(opt);
});
} catch {}
}, 800);
input.addEventListener('input', debouncedLoad);
// Session 选择变化时加载历史 Prompt
document.getElementById('claude-session').addEventListener('change', async (e) => {
const sessionId = e.target.value;
const container = document.getElementById('session-prompts');
const list = document.getElementById('session-prompts-list');
if (!sessionId) {
container.classList.add('hidden');
return;
}
try {
const prompts = await API.get(`/api/claude/sessions/${sessionId}/prompts`);
container.classList.remove('hidden');
list.innerHTML = prompts.map(p => `
<div class="mb-2 p-2" style="background:var(--bg-tertiary);border-radius:6px;cursor:pointer" onclick="this.querySelector('.full-content').classList.toggle('hidden')">
<div class="text-xs text-muted">${formatDate(p.created_at)}</div>
<div class="text-sm mt-1">${escapeHtml(p.prompt.substring(0, 100))}${p.prompt.length > 100 ? '...' : ''}</div>
<div class="full-content hidden mt-2 text-sm" style="white-space:pre-wrap">${escapeHtml(p.prompt)}</div>
</div>
`).join('') || '<div class="text-sm text-muted">暂无历史 Prompt</div>';
} catch {}
});
}
// 标签搜索
document.getElementById('tag-search')?.addEventListener('input', debounce(() => renderTags(), 300));
document.getElementById('snippet-search')?.addEventListener('input', debounce(() => renderSnippets(), 300));
// 自定义输入变化时更新预览
document.getElementById('custom-content')?.addEventListener('input', debounce(() => updatePreview(), 200));
document.getElementById('project-name')?.addEventListener('input', debounce(() => updatePreview(), 200));
// 折叠切换
function toggleCollapse(header) {
header.classList.toggle('open');
const content = header.nextElementSibling;
content.classList.toggle('open');
}
// 智能建议
async function getSuggestions() {
const prompt = document.getElementById('prompt-preview').textContent;
if (!prompt || prompt.startsWith('选择标签')) {
Toast.error('请先组装 Prompt 内容');
return;
}
const btn = document.getElementById('suggest-btn');
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> 分析中...';
try {
const claudeMD = document.getElementById('claude-md').value;
const result = await API.post('/api/suggestions', {
current_prompt: prompt,
claude_md: claudeMD
});
suggestions = (result || []).map(s => ({ ...s, adopted: false }));
renderSuggestions();
document.getElementById('suggestions-card').style.display = 'block';
} catch (e) {
Toast.error('获取建议失败: ' + e.message);
} finally {
btn.disabled = false;
btn.innerHTML = '🤖 智能建议';
}
}
function renderSuggestions() {
const list = document.getElementById('suggestions-list');
if (suggestions.length === 0) {
list.innerHTML = '<div class="text-sm text-muted">暂无建议</div>';
return;
}
list.innerHTML = suggestions.map((s, i) => `
<label class="checkbox-label mb-2 p-2" style="background:var(--bg-tertiary);border-radius:6px;align-items:flex-start">
<input type="checkbox" ${s.adopted ? 'checked' : ''} onchange="toggleSuggestion(${i}, this.checked)" style="margin-top:3px">
<div>
<div class="text-sm font-medium">${escapeHtml(s.title)}</div>
<div class="text-xs text-muted mt-1">${escapeHtml(s.description)}</div>
</div>
</label>
`).join('');
}
function toggleSuggestion(index, checked) {
suggestions[index].adopted = checked;
updatePreview();
}
function toggleAllSuggestions(checked) {
suggestions.forEach(s => s.adopted = checked);
renderSuggestions();
updatePreview();
}
// 复制到剪贴板
async function copyPrompt() {
const text = document.getElementById('prompt-preview').textContent;
if (!text || text.startsWith('选择标签')) {
Toast.error('没有可复制的内容');
return;
}
try {
await navigator.clipboard.writeText(text);
Toast.success('已复制到剪贴板');
} catch {
// Fallback
const textarea = document.createElement('textarea');
textarea.value = text;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
textarea.remove();
Toast.success('已复制到剪贴板');
}
}
// 新增标签
function showAddTagModal() {
document.getElementById('add-tag-modal').classList.remove('hidden');
document.getElementById('new-tag-name').focus();
}
function closeAddTagModal() {
document.getElementById('add-tag-modal').classList.add('hidden');
document.getElementById('new-tag-name').value = '';
document.getElementById('new-tag-desc').value = '';
}
async function createTag() {
const name = document.getElementById('new-tag-name').value.trim();
const desc = document.getElementById('new-tag-desc').value.trim();
if (!name) {
Toast.error('标签名称不能为空');
return;
}
try {
await API.post('/api/tags', { name, description: desc });
closeAddTagModal();
Toast.success('标签创建成功');
await loadTags();
} catch (e) {
Toast.error(e.message);
}
}
// 保存会话
function saveSession() {
document.getElementById('save-session-modal').classList.remove('hidden');
document.getElementById('session-title').focus();
}
function closeSaveModal() {
document.getElementById('save-session-modal').classList.add('hidden');
}
async function doSaveSession() {
const title = document.getElementById('session-title').value.trim();
if (!title) {
Toast.error('请输入会话标题');
return;
}
const projectName = document.getElementById('project-name').value.trim();
const claudeSessionId = document.getElementById('claude-session').value;
const finalPrompt = document.getElementById('prompt-preview').textContent;
const tagOptionIds = Object.entries(selectedTags).map(([tagId, optionId]) => ({
tag_id: parseInt(tagId),
tag_option_id: optionId
}));
const snippetIds = selectedSnippets.map(s => ({
snippet_id: s.snippetId,
sort_order: s.sortOrder
}));
try {
await API.post('/api/builder/sessions', {
title,
project_name: projectName,
final_prompt: finalPrompt,
claude_session_id: claudeSessionId,
tag_option_ids: tagOptionIds,
snippet_ids: snippetIds
});
closeSaveModal();
Toast.success('会话已保存');
} catch (e) {
Toast.error(e.message);
}
}
// 草稿自动保存
function setupAutoSave() {
const saveDraftDebounced = debounce(() => {
Draft.save('builder', {
projectName: document.getElementById('project-name').value,
customContent: document.getElementById('custom-content').value,
claudeMD: document.getElementById('claude-md').value,
selectedTags: { ...selectedTags },
selectedSnippets: [...selectedSnippets],
});
}, 1000);
document.getElementById('project-name').addEventListener('input', saveDraftDebounced);
document.getElementById('custom-content').addEventListener('input', saveDraftDebounced);
document.getElementById('claude-md').addEventListener('input', saveDraftDebounced);
}
function loadDraft() {
const draft = Draft.load('builder');
if (!draft) return;
document.getElementById('project-name').value = draft.projectName || '';
document.getElementById('custom-content').value = draft.customContent || '';
document.getElementById('claude-md').value = draft.claudeMD || '';
selectedTags = draft.selectedTags || {};
selectedSnippets = draft.selectedSnippets || [];
}
function resetBuilder() {
if (!confirm('确定要清空所有内容吗?')) return;
document.getElementById('project-name').value = '';
document.getElementById('custom-content').value = '';
document.getElementById('claude-md').value = '';
document.getElementById('claude-session').innerHTML = '<option value="">不关联</option>';
document.getElementById('session-prompts').classList.add('hidden');
selectedTags = {};
selectedSnippets = [];
suggestions = [];
document.getElementById('suggestions-card').style.display = 'none';
Draft.clear('builder');
renderTags();
renderSnippets();
updatePreview();
Toast.success('已重置');
}
// 全局回车键处理
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeAddTagModal();
closeSaveModal();
}
});
init();
</script>
</body>
</html>
+112
View File
@@ -0,0 +1,112 @@
<!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 style="max-width:640px;margin:80px auto 0;padding:0 24px">
<h1 style="font-size:24px;font-weight:700;margin-bottom:24px">⚙️ 设置</h1>
<!-- LLM 配置 -->
<div class="card mb-4">
<div class="card-header">🤖 LLM 配置</div>
<div class="card-body flex flex-col gap-4">
<div>
<label class="text-sm font-medium mb-2" style="display:block">API Base URL</label>
<input class="input" id="llm-url" placeholder="https://api.deepseek.com/v1">
<div class="text-xs text-muted mt-1">兼容 OpenAI 接口的 API 地址</div>
</div>
<div>
<label class="text-sm font-medium mb-2" style="display:block">API Key</label>
<input class="input" id="llm-key" type="password" placeholder="sk-xxx">
<div class="text-xs text-muted mt-1">留空则不更新。API Key 仅存储在服务端</div>
</div>
<div>
<label class="text-sm font-medium mb-2" style="display:block">模型名称</label>
<input class="input" id="llm-model" placeholder="deepseek-chat">
</div>
<button class="btn btn-primary" onclick="saveSettings()" id="save-btn">保存配置</button>
</div>
</div>
<!-- 主题设置 -->
<div class="card mb-4">
<div class="card-header">🎨 主题设置</div>
<div class="card-body">
<div class="flex items-center justify-between">
<div>
<div class="text-sm font-medium">暗色模式</div>
<div class="text-xs text-muted">切换亮色/暗色主题</div>
</div>
<button class="btn" onclick="Theme.toggle(); updateThemeBtn()">
<span id="theme-label">${Theme.isDark() ? '🌙 暗色' : '☀️ 亮色'}</span>
</button>
</div>
</div>
</div>
<!-- 账号 -->
<div class="card mb-4">
<div class="card-header">🔐 账号</div>
<div class="card-body">
<button class="btn btn-danger" onclick="Auth.logout()">退出登录</button>
</div>
</div>
</div>
<script src="/static/common.js"></script>
<script>
async function init() {
const ok = await Auth.check();
if (!ok) {
Auth.showLoginModal();
return;
}
renderNavbar('settings');
await loadSettings();
updateThemeBtn();
}
async function loadSettings() {
try {
const settings = await API.get('/api/settings');
document.getElementById('llm-url').value = settings.llm_api_base_url || '';
document.getElementById('llm-model').value = settings.llm_model_name || '';
} catch (e) {
Toast.error('加载设置失败: ' + e.message);
}
}
async function saveSettings() {
const btn = document.getElementById('save-btn');
btn.disabled = true;
btn.textContent = '保存中...';
try {
await API.put('/api/settings', {
llm_api_base_url: document.getElementById('llm-url').value,
llm_api_key: document.getElementById('llm-key').value,
llm_model_name: document.getElementById('llm-model').value
});
document.getElementById('llm-key').value = '';
Toast.success('配置已保存');
} catch (e) {
Toast.error(e.message);
} finally {
btn.disabled = false;
btn.textContent = '保存配置';
}
}
function updateThemeBtn() {
document.getElementById('theme-label').textContent = Theme.isDark() ? '🌙 暗色' : '☀️ 亮色';
}
init();
</script>
</body>
</html>
+408
View File
@@ -0,0 +1,408 @@
/* Prompt Generator — 全局样式 */
/* 暗色主题变量 */
:root {
--bg-primary: #ffffff;
--bg-secondary: #f8fafc;
--bg-tertiary: #f1f5f9;
--text-primary: #0f172a;
--text-secondary: #475569;
--text-muted: #94a3b8;
--border-color: #e2e8f0;
--accent-color: #3b82f6;
--accent-hover: #2563eb;
--success-color: #22c55e;
--danger-color: #ef4444;
--warning-color: #f59e0b;
--shadow-sm: 0 1px 2px rgba(0,0,0,0.05);
--shadow-md: 0 4px 6px -1px rgba(0,0,0,0.1);
--radius: 8px;
}
[data-theme="dark"] {
--bg-primary: #0f172a;
--bg-secondary: #1e293b;
--bg-tertiary: #334155;
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--border-color: #334155;
--accent-color: #60a5fa;
--accent-hover: #3b82f6;
--shadow-sm: 0 1px 2px rgba(0,0,0,0.2);
--shadow-md: 0 4px 6px -1px rgba(0,0,0,0.3);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: var(--bg-secondary);
color: var(--text-primary);
min-height: 100vh;
}
/* 导航栏 */
.navbar {
background: var(--bg-primary);
border-bottom: 1px solid var(--border-color);
padding: 0 24px;
height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 100;
box-shadow: var(--shadow-sm);
}
.nav-brand {
font-size: 18px;
font-weight: 700;
color: var(--accent-color);
display: flex;
align-items: center;
gap: 8px;
}
.nav-tabs {
display: flex;
gap: 4px;
}
.nav-tab {
padding: 8px 16px;
border-radius: 6px;
color: var(--text-secondary);
text-decoration: none;
font-size: 14px;
font-weight: 500;
transition: all 0.15s;
cursor: pointer;
}
.nav-tab:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.nav-tab.active {
background: var(--accent-color);
color: white;
}
.nav-actions {
display: flex;
align-items: center;
gap: 12px;
}
/* 按钮 */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
border: 1px solid var(--border-color);
background: var(--bg-primary);
color: var(--text-primary);
transition: all 0.15s;
white-space: nowrap;
}
.btn:hover {
background: var(--bg-tertiary);
}
.btn-primary {
background: var(--accent-color);
color: white;
border-color: var(--accent-color);
}
.btn-primary:hover {
background: var(--accent-hover);
}
.btn-danger {
background: var(--danger-color);
color: white;
border-color: var(--danger-color);
}
.btn-danger:hover {
opacity: 0.9;
}
.btn-sm {
padding: 4px 10px;
font-size: 12px;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 输入框 */
.input, .textarea, .select {
padding: 8px 12px;
border: 1px solid var(--border-color);
border-radius: 6px;
font-size: 14px;
background: var(--bg-primary);
color: var(--text-primary);
width: 100%;
transition: border-color 0.15s;
}
.input:focus, .textarea:focus, .select:focus {
outline: none;
border-color: var(--accent-color);
box-shadow: 0 0 0 3px rgba(59,130,246,0.1);
}
.textarea {
resize: vertical;
min-height: 80px;
font-family: inherit;
}
/* 卡片 */
.card {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
}
.card-header {
padding: 12px 16px;
border-bottom: 1px solid var(--border-color);
font-weight: 600;
font-size: 14px;
display: flex;
align-items: center;
justify-content: space-between;
}
.card-body {
padding: 16px;
}
/* 复选框和单选框 */
.checkbox-group, .radio-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.checkbox-label, .radio-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 14px;
color: var(--text-primary);
}
.checkbox-label input, .radio-label input {
accent-color: var(--accent-color);
width: 16px;
height: 16px;
}
/* 可折叠区域 */
.collapse-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
user-select: none;
border-radius: 6px;
transition: background 0.15s;
}
.collapse-header:hover {
background: var(--bg-tertiary);
}
.collapse-arrow {
transition: transform 0.2s;
font-size: 12px;
color: var(--text-muted);
}
.collapse-header.open .collapse-arrow {
transform: rotate(90deg);
}
.collapse-content {
display: none;
padding: 8px 12px 8px 32px;
}
.collapse-content.open {
display: block;
}
/* 标签 */
.tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.tag-system {
background: #dbeafe;
color: #1d4ed8;
}
.tag-personal {
background: #dcfce7;
color: #15803d;
}
[data-theme="dark"] .tag-system {
background: #1e3a5f;
color: #93c5fd;
}
[data-theme="dark"] .tag-personal {
background: #14532d;
color: #86efac;
}
/* 加载状态 */
.spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 2px solid var(--border-color);
border-top-color: var(--accent-color);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* 消息提示 */
.toast {
position: fixed;
top: 72px;
right: 24px;
padding: 12px 20px;
border-radius: 8px;
font-size: 14px;
z-index: 1000;
animation: slideIn 0.3s ease;
box-shadow: var(--shadow-md);
}
.toast-success {
background: var(--success-color);
color: white;
}
.toast-error {
background: var(--danger-color);
color: white;
}
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
/* 模态框 */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.modal {
background: var(--bg-primary);
border-radius: 12px;
box-shadow: var(--shadow-md);
width: 100%;
max-width: 480px;
padding: 24px;
}
.modal-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 16px;
}
/* 分页 */
.pagination {
display: flex;
align-items: center;
gap: 8px;
justify-content: center;
padding: 16px 0;
}
/* 搜索框 */
.search-box {
position: relative;
}
.search-box .input {
padding-left: 36px;
}
.search-icon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: var(--text-muted);
font-size: 14px;
}
/* 工具类 */
.flex { display: flex; }
.flex-col { flex-direction: column; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.gap-2 { gap: 8px; }
.gap-3 { gap: 12px; }
.gap-4 { gap: 16px; }
.p-4 { padding: 16px; }
.p-6 { padding: 24px; }
.mb-2 { margin-bottom: 8px; }
.mb-3 { margin-bottom: 12px; }
.mb-4 { margin-bottom: 16px; }
.mt-2 { margin-top: 8px; }
.mt-4 { margin-top: 16px; }
.text-sm { font-size: 13px; }
.text-xs { font-size: 12px; }
.text-muted { color: var(--text-muted); }
.font-medium { font-weight: 500; }
.font-bold { font-weight: 700; }
.w-full { width: 100%; }
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hidden { display: none !important; }
+196
View File
@@ -0,0 +1,196 @@
// Prompt Generator — 全局 JS 模块
const API = {
async request(method, path, body) {
const opts = {
method,
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
};
if (body !== undefined) {
opts.body = JSON.stringify(body);
}
const resp = await fetch(path, opts);
const data = await resp.json();
if (resp.status === 401) {
Auth.showLoginModal();
throw new Error('未登录');
}
if (data.code !== 0) {
throw new Error(data.message || '请求失败');
}
return data.data;
},
get(path) { return this.request('GET', path); },
post(path, body) { return this.request('POST', path, body); },
put(path, body) { return this.request('PUT', path, body); },
del(path) { return this.request('DELETE', path); },
};
// 认证模块
const Auth = {
modal: null,
showLoginModal() {
if (this.modal) return;
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.innerHTML = `
<div class="modal">
<div class="modal-title">🔐 输入访问密码</div>
<input type="password" class="input mb-3" id="login-password" placeholder="请输入密码" autofocus>
<div id="login-error" class="text-sm mb-3" style="color:var(--danger-color);display:none"></div>
<button class="btn btn-primary w-full" id="login-btn">登录</button>
</div>
`;
document.body.appendChild(overlay);
this.modal = overlay;
const input = overlay.querySelector('#login-password');
const btn = overlay.querySelector('#login-btn');
const err = overlay.querySelector('#login-error');
const doLogin = async () => {
try {
btn.disabled = true;
btn.textContent = '登录中...';
await API.post('/api/auth/login', { password: input.value });
overlay.remove();
this.modal = null;
Toast.success('登录成功');
// Reload page data
if (typeof onLogin === 'function') onLogin();
} catch (e) {
err.textContent = e.message;
err.style.display = 'block';
btn.disabled = false;
btn.textContent = '登录';
}
};
btn.onclick = doLogin;
input.onkeydown = (e) => { if (e.key === 'Enter') doLogin(); };
},
async check() {
try {
await API.get('/api/auth/check');
return true;
} catch {
return false;
}
},
async logout() {
try {
await API.post('/api/auth/logout');
} catch {}
location.reload();
}
};
// Toast 提示
const Toast = {
show(message, type = 'success', duration = 3000) {
const el = document.createElement('div');
el.className = `toast toast-${type}`;
el.textContent = message;
document.body.appendChild(el);
setTimeout(() => el.remove(), duration);
},
success(msg) { this.show(msg, 'success'); },
error(msg) { this.show(msg, 'error', 5000); },
};
// 主题切换
const Theme = {
init() {
const saved = localStorage.getItem('theme');
if (saved) {
document.documentElement.setAttribute('data-theme', saved);
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.setAttribute('data-theme', 'dark');
}
},
toggle() {
const current = document.documentElement.getAttribute('data-theme');
const next = current === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('theme', next);
},
isDark() {
return document.documentElement.getAttribute('data-theme') === 'dark';
}
};
// 草稿管理(localStorage)
const Draft = {
save(key, data) {
localStorage.setItem(`draft_${key}`, JSON.stringify(data));
},
load(key) {
const raw = localStorage.getItem(`draft_${key}`);
if (!raw) return null;
try { return JSON.parse(raw); } catch { return null; }
},
clear(key) {
localStorage.removeItem(`draft_${key}`);
}
};
// 防抖
function debounce(fn, delay = 1000) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
// 工具函数
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function truncateText(text, maxLen = 150) {
if (!text) return '';
// 跳过 XML 标签内容,提取用户实际输入
const cleaned = text.replace(/<[a-zA-Z_-][^>]*>[\s\S]*?<\/[a-zA-Z_-][^>]*>/g, '').trim();
if (cleaned.length <= maxLen) return cleaned;
return cleaned.substring(0, maxLen) + '...';
}
function formatDate(dateStr) {
if (!dateStr) return '';
const d = new Date(dateStr);
const pad = n => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
// 初始化
Theme.init();
// 导航栏渲染
function renderNavbar(activePage) {
const pages = [
{ id: 'builder', label: '构建器', href: '/' },
{ id: 'dashboard', label: '复盘看板', href: '/dashboard.html' },
{ id: 'settings', label: '设置', href: '/settings.html' },
];
const nav = document.createElement('nav');
nav.className = 'navbar';
nav.innerHTML = `
<div class="nav-brand">🔥 Prompt Generator</div>
<div class="nav-tabs">
${pages.map(p => `<a class="nav-tab ${p.id === activePage ? 'active' : ''}" href="${p.href}">${p.label}</a>`).join('')}
</div>
<div class="nav-actions">
<button class="btn btn-sm" onclick="Theme.toggle()" title="切换主题">${Theme.isDark() ? '☀️' : '🌙'}</button>
<button class="btn btn-sm" onclick="Auth.logout()">退出</button>
</div>
`;
document.body.insertBefore(nav, document.body.firstChild);
}