feat(ui): 豆包式布局—顶栏会话名/空态有什么可以帮到你的/底部工具栏(项目+新建+上传+影响调查)/历史项⋯重命名/上传去zip

This commit is contained in:
lhl
2026-09-12 14:36:07 +08:00
parent 8a3dc8826a
commit 2ec347d905
5 changed files with 232 additions and 66 deletions
+56 -1
View File
@@ -172,7 +172,7 @@ header h1 { font-size: 16px; font-weight: 700; letter-spacing: -.02em; margin: 0
.ps-current .ps-name { max-width: 160px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ps-current .ps-caret { font-size: 10px; color: var(--text-muted); }
.ps-menu {
position: absolute; top: calc(100% + 6px); left: 0; min-width: 240px;
position: absolute; bottom: calc(100% + 6px); top: auto; left: 0; min-width: 240px;
background: var(--bg-surface); border: 1px solid var(--border-strong); border-radius: var(--r-md);
box-shadow: 0 8px 32px rgba(0,0,0,.4); padding: 6px; z-index: 20;
display: none;
@@ -410,3 +410,58 @@ label.vh { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px
#sid-badge { max-width: 24%; }
#proj-drawer { width: 100vw; }
}
/* === 顶栏会话名 === */
#session-title { font-size: 15px; font-weight: 700; letter-spacing: -.02em; color: var(--text-primary);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
/* === 聊天区改为纵向 flex(空态垂直居中) === */
#chat { display: flex; flex-direction: column; }
/* === 空态(豆包式) === */
.empty-state { flex: 1 1 auto; display: flex; flex-direction: column; align-items: center;
justify-content: center; text-align: center; padding: 32px 16px; gap: 10px; min-height: 0; }
.empty-state .es-logo { width: 56px; height: 56px; border-radius: 16px; display: flex; align-items: center;
justify-content: center; font-size: 26px; font-weight: 900; color: var(--bg-base);
background: var(--accent); box-shadow: 0 0 32px var(--accent-soft); }
.empty-state .es-title { font-size: 24px; font-weight: 700; margin: 6px 0 0; letter-spacing: -.02em; }
.empty-state .es-sub { font-size: 13px; color: var(--text-secondary); line-height: 1.7; margin: 0; max-width: 560px; }
.empty-state .suggests { display: grid; grid-template-columns: repeat(2, minmax(180px, 1fr)); gap: 10px;
margin-top: 16px; width: 100%; max-width: 620px; }
.suggest-card { text-align: left; padding: 12px 16px; border-radius: var(--r-md);
background: var(--bg-surface); border: 1px solid var(--border-subtle); color: var(--text-primary);
font-size: 13px; font-family: var(--font-ui); cursor: pointer; transition: all var(--dur) var(--ease); }
.suggest-card:hover { border-color: var(--accent); background: var(--bg-elevated); transform: translateY(-1px); }
.suggest-card::before { content: "\2192 "; color: var(--accent); font-weight: 700; }
/* === 底部工具栏 === */
#composer-toolbar { max-width: 880px; margin: 10px auto 0; display: flex; align-items: center;
gap: 8px; flex-wrap: wrap; }
.tb-btn { display: inline-flex; align-items: center; gap: 6px; padding: 7px 12px;
background: transparent; border: 1px solid var(--border-strong); border-radius: var(--r-md);
color: var(--text-secondary); font-size: 13px; font-weight: 500; font-family: var(--font-ui);
cursor: pointer; transition: all var(--dur) var(--ease); }
.tb-btn:hover { border-color: var(--accent); color: var(--accent); }
.tb-btn .tb-ic { font-size: 14px; line-height: 1; }
.tb-btn .tb-caret { font-size: 10px; color: var(--text-muted); }
.proj-switcher { position: relative; }
#upload-panel { max-width: 880px; margin: 10px auto 0; }
/* === 会话项「⋯」与重命名菜单 === */
.sess .kebab { flex-shrink: 0; opacity: 0; transition: opacity .15s ease; background: transparent;
border: none; color: var(--text-muted); cursor: pointer; width: 24px; height: 24px; border-radius: 6px;
font-size: 15px; line-height: 1; display: flex; align-items: center; justify-content: center; }
.sess:hover .kebab { opacity: .7; }
.sess .kebab:hover { opacity: 1 !important; background: var(--bg-base); color: var(--accent); }
.rename-input { flex: 1; min-width: 0; background: var(--bg-input); border: 1px solid var(--accent);
border-radius: var(--r-sm); color: var(--text-primary); font-size: 13px; padding: 3px 6px;
font-family: var(--font-ui); outline: none; }
.sess-menu { position: fixed; z-index: 120; background: var(--bg-surface); border: 1px solid var(--border-strong);
border-radius: var(--r-md); box-shadow: 0 8px 32px rgba(0,0,0,.5); padding: 4px; min-width: 140px; }
.sess-menu .sm-item { display: flex; align-items: center; gap: 8px; width: 100%; padding: 8px 12px;
background: transparent; border: none; border-radius: var(--r-sm); color: var(--text-secondary);
font-size: 13px; font-family: var(--font-ui); cursor: pointer; text-align: left; }
.sess-menu .sm-item:hover { background: var(--bg-elevated); color: var(--accent); }
.sess-menu .sm-ic { color: var(--accent); }
#sidebar.collapsed .sess .kebab { display: none; }
@media (max-width: 640px) { .empty-state .suggests { grid-template-columns: 1fr; } }
+54 -52
View File
@@ -20,30 +20,39 @@
<div id="main">
<header>
<h1>Genesis 概要设计书生成</h1>
<div class="proj-switcher" id="proj-switcher">
<button class="ps-current" id="ps-current" type="button" title="切换项目">
<span class="ps-label">项目</span>
<span class="ps-name" id="ps-name">未选择</span>
<span class="ps-caret"></span>
</button>
<div class="ps-menu" id="ps-menu" role="menu"></div>
</div>
<div id="rag-bar" hidden>
<label class="rag-switch">
<input type="checkbox" id="rag-enabled">
<span>启用 RAG 影响调查</span>
</label>
<span class="rag-stats" id="rag-stats">RAG 0 片段</span>
<button id="impact-btn" type="button" disabled>开始影响调查</button>
</div>
<span id="sid-badge">未创建会话</span>
<span id="session-title" title="当前会话">未创建会话</span>
</header>
<div id="chat"></div>
<div id="upload-bar">
<div id="composer">
<div id="composer-inner">
<input id="input" placeholder="输入指令,如:生成概要设计书 / 现在什么状态?" autocomplete="off" maxlength="2000">
<button id="send">发送</button>
</div>
<div id="composer-toolbar">
<div class="proj-switcher" id="proj-switcher">
<button class="tb-btn" id="ps-current" type="button" title="切换项目">
<span class="tb-ic"></span>
<span class="tb-text" id="ps-name">未选择项目</span>
<span class="tb-caret"></span>
</button>
<div class="ps-menu" id="ps-menu" role="menu"></div>
</div>
<button class="tb-btn" id="tb-newproject" type="button" title="新建项目"><span class="tb-ic"></span><span class="tb-text">新建项目</span></button>
<button class="tb-btn" id="tb-upload" type="button" title="上传资料"><span class="tb-ic">📎</span><span class="tb-text">上传资料</span></button>
<div id="rag-bar" hidden>
<label class="rag-switch">
<input type="checkbox" id="rag-enabled">
<span>启用 RAG</span>
</label>
<span class="rag-stats" id="rag-stats">RAG 0 片段</span>
<button id="impact-btn" type="button" disabled>影响调查</button>
</div>
</div>
<div id="upload-panel" hidden>
<div id="upload-bar-inner">
<span id="proj-hint"></span>
<label for="file-type" class="vh">上传类型</label>
@@ -53,7 +62,6 @@
<option value="template">概要设计模板 docx(必需)</option>
<option value="write_instruction">做成说明书 docx</option>
<option value="rules">记入/图表规则 docx/xlsx</option>
<option value="existing_system">既有系统 zip(追加改修)</option>
</select>
<label class="file-pick" for="file-input">选择文件</label>
<input type="file" id="file-input" style="display:none">
@@ -61,12 +69,6 @@
<span id="upload-status"></span>
</div>
</div>
<div id="composer">
<div id="composer-inner">
<input id="input" placeholder="输入指令,如:上传了文件,生成概要设计书 / 现在什么状态?" autocomplete="off" maxlength="2000">
<button id="send">发送</button>
</div>
</div>
</div>
@@ -78,7 +80,7 @@
<div class="drawer-body">
<div class="drawer-list" id="drawer-list"></div>
<div class="drawer-form" id="drawer-form">
<div class="drawer-hint">选择左侧项目以查看与编辑,或点 <strong>+ 新建项目</strong> 创建一个</div>
<div class="drawer-hint">选择左侧项目以查看与编辑,或点 <strong>+ 新建项目</strong> 创建;新建时请设置该项目的资料目录</div>
<div class="field">
<label>项目名(英文/唯一)</label>
<input id="pf-name" placeholder="stock">
@@ -87,30 +89,26 @@
<label>显示名(可选)</label>
<input id="pf-display" placeholder="股票系统">
</div>
<details class="advanced">
<summary>高级(可选,服务器端预置路径</summary>
<p class="drawer-hint">这些是<strong>服务器端</strong>预置路径,普通用户无需填写,留空则在每个会话中上传对应文件。仅在管理员/CLI 集成时使用。</p>
<div class="field">
<label>模板 docx 路径</label>
<input id="pf-template" placeholder="/abs/path/template.docx">
</div>
<div class="field">
<label>做成说明书 docx 路径</label>
<input id="pf-write" placeholder="/abs/path/write_instruction.docx">
</div>
<div class="field">
<label>规则 docx 目录(逗号分隔)</label>
<input id="pf-rules" placeholder="/abs/path/rules, /abs/path/diagrams">
</div>
<div class="field">
<label>既有系统代码库目录</label>
<input id="pf-code" placeholder="/abs/path/existing-system">
</div>
<div class="field">
<label>既有设计文档目录</label>
<input id="pf-design" placeholder="/abs/path/design-docs">
</div>
</details>
<div class="field">
<label>模板 docx 路径</label>
<input id="pf-template" placeholder="/abs/path/template.docx">
</div>
<div class="field">
<label>做成说明书 docx 路径</label>
<input id="pf-write" placeholder="/abs/path/write_instruction.docx">
</div>
<div class="field">
<label>规则 docx 目录(逗号分隔)</label>
<input id="pf-rules" placeholder="/abs/path/rules, /abs/path/diagrams">
</div>
<div class="field">
<label>既有系统代码库目录</label>
<input id="pf-code" placeholder="/abs/path/existing-system">
</div>
<div class="field">
<label>既有设计文档目录</label>
<input id="pf-design" placeholder="/abs/path/design-docs">
</div>
<div class="drawer-actions">
<button class="btn-save" id="pf-save">保存项目</button>
<button class="btn-delete" id="pf-delete" hidden>删除</button>
@@ -119,6 +117,10 @@
</div>
</div>
<div id="sess-menu" class="sess-menu" hidden>
<button type="button" class="sm-item" id="sm-rename"><span class="sm-ic"></span>重命名</button>
</div>
<script src="/static/chat_state.js"></script>
<script src="/static/chat_ws.js"></script>
<script src="/static/chat.js" defer></script>
+99 -12
View File
@@ -22,12 +22,14 @@ const GenesisState = window.GenesisState || {
isDrawerDirty: (n, s) => n !== s,
buildWelcome: (d) => d === null ? '请新建项目或者选择项目'
: '你好!我是 Genesis 概要设计书生成 Agent。请先上传要件定义 xlsx(模板/规则/代码库已由项目「' + d + '」提供)。',
emptyHeadline: () => '有什么可以帮到你的?',
quickPrompts: () => ['生成概要设计书', '影响调查', '用中文生成', '现在什么状态?'],
};
const chatEl = document.getElementById('chat');
const inputEl = document.getElementById('input');
const sendBtn = document.getElementById('send');
const badge = document.getElementById('sid-badge');
const sessionTitle = document.getElementById('session-title');
const psMenu = document.getElementById('ps-menu');
const psName = document.getElementById('ps-name');
const drawer = document.getElementById('proj-drawer');
@@ -76,10 +78,13 @@ async function refreshSessions() {
'<div class="name">' + esc(s.name || '新会话') + '</div>' +
'<div class="meta">' + esc(s.status) + proj + '</div>' +
'</div>' +
'<button class="kebab" title="更多" aria-label="更多操作">⋯</button>' +
'<button class="del" title="删除会话" aria-label="删除会话 ' + esc(rawName) + '">×</button>';
el.querySelector('.info').onclick = () => loadSession(s.session_id);
const delBtn = el.querySelector('.del');
delBtn.onclick = (ev) => { ev.stopPropagation(); deleteSession(s.session_id); };
const kebab = el.querySelector('.kebab');
kebab.onclick = (ev) => openSessMenu(ev, s.session_id, s.name || '新会话', el);
box.appendChild(el);
}
}
@@ -116,7 +121,7 @@ async function loadProjects() {
}
function applyProjectContext() {
psName.textContent = draftProject || '未选择';
psName.textContent = draftProject || '未选择项目';
}
function renderProjectSwitcher() {
@@ -360,11 +365,75 @@ function updateUploadBar() {
}
}
// ---------- 空态/欢迎渲染 ----------
// ---------- 空态/欢迎渲染(豆包式:主标题 + 副提示 + 建议卡) ----------
function insertPrompt(text) {
inputEl.value = text;
inputEl.focus();
try { inputEl.setSelectionRange(text.length, text.length); } catch (e) { /* 忽略 */ }
}
function renderEmptyOrWelcome() {
chatEl.innerHTML = '';
// P1F:欢迎文由 GenesisState.buildWelcome 统一来源
addMsg('assistant', GenesisState.buildWelcome(draftProject));
const wrap = document.createElement('div');
wrap.className = 'empty-state';
const cards = GenesisState.quickPrompts().map(t =>
'<button class="suggest-card" type="button" data-prompt="' + esc(t) + '">' + esc(t) + '</button>'
).join('');
wrap.innerHTML =
'<div class="es-logo">G</div>' +
'<h2 class="es-title">' + esc(GenesisState.emptyHeadline()) + '</h2>' +
'<p class="es-sub">' + GenesisState.buildWelcome(draftProject) + '</p>' +
'<div class="suggests">' + cards + '</div>';
chatEl.appendChild(wrap);
wrap.querySelectorAll('.suggest-card').forEach(b => {
b.onclick = () => insertPrompt(b.dataset.prompt);
});
}
// ---------- 会话「⋯」菜单 + 重命名 ----------
let sessMenuTarget = null;
function openSessMenu(ev, sidVal, name, el) {
ev.stopPropagation();
sessMenuTarget = { sid: sidVal, name: name, el: el };
const m = document.getElementById('sess-menu');
if (!m) return;
m.hidden = false;
const r = ev.currentTarget.getBoundingClientRect();
m.style.top = (r.bottom + 4) + 'px';
m.style.left = Math.max(8, r.right - 150) + 'px';
}
function closeSessMenu() {
const m = document.getElementById('sess-menu');
if (m) m.hidden = true;
}
function startRenameSession(el, s) {
const nameEl = el.querySelector('.name');
if (!nameEl) return;
const input = document.createElement('input');
input.className = 'rename-input';
input.value = s.name || '';
input.maxLength = 100;
nameEl.replaceWith(input);
input.focus(); input.select();
let done = false;
const finish = async (save) => {
if (done) return; done = true;
const newName = input.value.trim();
if (save && newName && newName !== (s.name || '')) {
try {
const r = await api('PATCH', '/api/sessions/' + encodeURIComponent(s.session_id), { name: newName });
if (s.session_id === sid) sessionTitle.textContent = r.name || newName;
} catch (e) {
addMsg('error', '重命名失败:' + esc(String(e)));
}
}
await refreshSessions();
};
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); finish(true); }
else if (e.key === 'Escape') { e.preventDefault(); finish(false); }
});
input.addEventListener('blur', () => finish(true));
}
// ---------- 会话创建/加载 ----------
@@ -375,7 +444,7 @@ async function newSession() {
// 保留 draftProject(当前顶栏已选项目),使新会话继承项目绑定(§4.2)
try { localStorage.removeItem('genesis_session'); } catch (e) { /* 忽略 */ }
chatEl.innerHTML = '';
badge.textContent = '未创建会话';
sessionTitle.textContent = '未创建会话';
updateUploadBar();
await refreshRagStats();
await refreshSessions();
@@ -391,7 +460,7 @@ async function loadSession(id) {
// 分层;若不一致,由提示条让用户主动选择。
// draftProject = rec.project || null; // 删除:原行为会悄悄改用户偏好
applyProjectContext();
badge.textContent = '会话: ' + (rec.name || '新会话');
sessionTitle.textContent = (rec.name || '新会话');
if (progressWs) { try { progressWs.close(); } catch (e) {} }
progressWs = GenesisWS.connectProgressWs(id, {
onProgress: (e) => GenesisWS.applyProgressEvent(e, (role, text) => addMsg(role, text)),
@@ -433,7 +502,7 @@ function renderProjectMismatchHint() {
wrap.innerHTML =
'<div class="bubble">' +
'该会话属于项目 <strong>' + esc(activeProject) + '</strong>' +
'当前顶栏项目为 <strong>' + esc(draftProject || '(未选择)') + '</strong>。' +
'当前项目为 <strong>' + esc(draftProject || '(未选择)') + '</strong>。' +
'<div class="actions">' +
'<a href="#" data-act="switch">切到该项目</a>' +
'<a href="#" data-act="keep">保留当前项目</a>' +
@@ -471,7 +540,7 @@ async function send() {
const d = await api('POST', '/api/sessions', { user_id: 'default', project: draftProject || null });
sid = d.session_id;
activeProject = draftProject || null;
badge.textContent = '会话: ' + (d.name || '新会话');
sessionTitle.textContent = (d.name || '新会话');
try { localStorage.setItem('genesis_session', sid); } catch (e) { /* 忽略 */ }
await refreshSessions();
} catch (e) {
@@ -523,6 +592,20 @@ document.getElementById('ps-current').addEventListener('click', (e) => { e.stopP
document.getElementById('drawer-close').addEventListener('click', closeProjectDrawer);
document.getElementById('pf-save').addEventListener('click', saveDrawerProject);
document.getElementById('pf-delete').addEventListener('click', deleteDrawerProject);
document.getElementById('tb-newproject').addEventListener('click', () => {
if (!guardDrawerDirty()) return;
openProjectDrawer(null);
});
document.getElementById('tb-upload').addEventListener('click', () => {
const panel = document.getElementById('upload-panel');
if (panel) panel.hidden = !panel.hidden;
});
document.getElementById('sm-rename').addEventListener('click', (ev) => {
ev.stopPropagation();
const t = sessMenuTarget;
closeSessMenu();
if (t) startRenameSession(t.el, { session_id: t.sid, name: t.name });
});
// ---------- P0ARAG 开关 + 状态徽标 + 开始影响调查 ----------
const ragEnabledEl = document.getElementById('rag-enabled');
@@ -555,7 +638,7 @@ async function refreshRagStats() {
const n = Number(stats.chunks) || 0;
ragStatsEl.textContent = n > 0
? 'RAG 已索引 ' + n + ' 片段'
: 'RAG 0 片段(请上传既有系统 zip';
: 'RAG 0 片段(请在项目配置中设置代码库目录';
ragStatsEl.classList.toggle('ready', n > 0);
ragStatsEl.classList.toggle('zero', n === 0);
// 开关可用性:必须先有 RAG 索引才能用
@@ -563,7 +646,7 @@ async function refreshRagStats() {
if (n === 0) {
ragEnabledEl.checked = false;
writeRagFlag(false);
ragEnabledEl.parentElement.title = '请先在上传区选择"既有系统 zip"并上传';
ragEnabledEl.parentElement.title = '请在项目配置中设置既有系统代码库目录';
} else {
ragEnabledEl.checked = readRagFlag();
ragEnabledEl.parentElement.title = ragEnabledEl.checked
@@ -607,9 +690,13 @@ impactBtnEl.addEventListener('click', async () => {
document.addEventListener('click', (e) => {
// 点击切换器外部关闭下拉
if (!document.getElementById('proj-switcher').contains(e.target)) toggleProjectSwitcher(false);
// 点击「⋯」菜单外部关闭
const sm = document.getElementById('sess-menu');
if (sm && !sm.hidden && !sm.contains(e.target)) closeSessMenu();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeSessMenu();
if (psMenu.classList.contains('open')) toggleProjectSwitcher(false);
else if (drawerOpen) closeProjectDrawer();
if (lastTriggerEl) { lastTriggerEl.focus(); lastTriggerEl = null; }
@@ -675,7 +762,7 @@ document.getElementById('upload-btn').addEventListener('click', async () => {
// B5 / P1F:上传成功后同步 activeProject、刷新上传条与侧边栏状态
const rec = await api('GET', '/api/sessions/' + encodeURIComponent(sid));
activeProject = rec.project || null;
badge.textContent = '会话: ' + (rec.name || '新会话');
sessionTitle.textContent = (rec.name || '新会话');
await refreshSessions();
updateUploadBar(); // P1‑F:上传成功后同步上传条(防状态滞后)
await refreshRagStats(); // P0A:上传 existing_system 后 RAG 索引已建
+13 -1
View File
@@ -53,12 +53,22 @@
return '请新建项目或者选择项目';
}
return '你好!我是 Genesis 概要设计书生成 Agent。'
+ '请先在方📎上传<strong>要件定义 xlsx</strong>'
+ '请先在方📎上传<strong>要件定义 xlsx</strong>'
+ '(模板/规则/代码库已由项目「' + draftProject + '」提供),然后告诉我:'
+ '<br>· 「生成概要设计书」(自动解析→影响→生成→QA)'
+ '<br>· 「用中文生成」 / 「现在什么状态?」';
}
// 空态主标题(豆包式布局:新建会话居中显示)
function emptyHeadline() {
return '有什么可以帮到你的?';
}
// 空态建议卡 / 快捷入口提示词(点击填入输入框,不自动发送)
function quickPrompts() {
return ['生成概要设计书', '影响调查', '用中文生成', '现在什么状态?'];
}
return {
autoBindProject,
shouldHideUploadSelect,
@@ -66,5 +76,7 @@
computeDrawerSnapshot,
isDrawerDirty,
buildWelcome,
emptyHeadline,
quickPrompts,
};
});
+10
View File
@@ -47,3 +47,13 @@ test('buildWelcome: 有项目时提示由项目提供模板/规则/代码库', (
assert.match(txt, /项目「A」/);
assert.match(txt, /要件定义/);
});
test('emptyHeadline: 返回空态主标题', () => {
assert.match(S.emptyHeadline(), /有什么可以帮到你的/);
});
test('quickPrompts: 返回 4 条快捷提示且含影响调查', () => {
const p = S.quickPrompts();
assert.equal(p.length, 4);
assert.ok(p.includes('影响调查'));
});