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:
@@ -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"><${escapeHtml(tagName)}></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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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; }
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user