feat: 项目 logo + 侧栏拖动排序 + 合并修复(主检出干净时 in-place)

- 项目 logo:仓库内文件(logo/icon/favicon 等多路径)→git remote 头像→自定义(URL/相对路径)→
  首字母徽章兜底;GET /api/projects/:id/logo(文件流/302),配置面板加 Logo 输入
- 侧栏项目拖动排序:projects.sort_order + POST /api/projects/reorder,乐观更新
- 合并修复:默认分支正被主检出占用时,若工作区干净则直接在主检出 in-place 合并
  (用户手动合并的等价操作,安全);脏工作区拒绝并提示提交/暂存或仅通过
- schema: projects.logo / sort_order(ensureColumn 平滑迁移)

测试 75/75(merge 用例改为干净→成功/脏→拒绝)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-13 03:45:24 +08:00
parent 740d2c2637
commit bb6186902a
12 changed files with 290 additions and 31 deletions
+59 -7
View File
@@ -312,11 +312,21 @@ function renderSidebar() {
ul.innerHTML = `<li class="p-empty">暂无项目<br>点「+ 新建」登记第一个仓库</li>`;
return;
}
ul.innerHTML = S.projects.map((p) => `
<li class="${p.id === S.currentProjectId ? 'active' : ''}" data-action="select-project" data-id="${esc(p.id)}">
<span class="p-name">${esc(p.name)}</span>
<span class="p-path">${esc(p.repoPath)}</span>
</li>`).join('');
ul.innerHTML = S.projects.map((p) => {
const ch = (p.name || '?').trim().charAt(0).toUpperCase();
const hue = [...(p.name || '')].reduce((a, c) => (a * 31 + c.charCodeAt(0)) % 360, 7);
return `
<li class="${p.id === S.currentProjectId ? 'active' : ''}" data-action="select-project" data-id="${esc(p.id)}" draggable="true">
<span class="p-logo-wrap">
<span class="p-logo-fb" style="--h:${hue}">${esc(ch)}</span>
<img class="p-logo" src="/api/projects/${esc(p.id)}/logo?v=${esc(p.logo || '')}" alt="" onload="this.classList.add('ok')" onerror="this.remove()">
</span>
<span class="p-meta">
<span class="p-name">${esc(p.name)}</span>
<span class="p-path">${esc(p.repoPath)}</span>
</span>
</li>`;
}).join('');
}
function renderTopbar() {
@@ -1216,6 +1226,7 @@ document.addEventListener('click', (ev) => {
if (p) {
$('#cfgConcurrency').value = p.concurrency ?? 1;
$('#cfgAutonomy').value = AUTONOMY_LABEL[p.autonomy] ? p.autonomy : 'manual';
$('#cfgLogo').value = p.logo ?? '';
}
}
break;
@@ -1225,11 +1236,13 @@ document.addEventListener('click', (ev) => {
const cc = Number($('#cfgConcurrency').value);
if (!Number.isInteger(cc) || cc < 1) { toast('最大并发必须是 ≥1 的整数'); break; }
const autonomy = $('#cfgAutonomy').value;
const logo = $('#cfgLogo').value.trim();
act(async () => {
await api(`/api/projects/${S.currentProjectId}`, {
method: 'PATCH', body: JSON.stringify({ concurrency: cc, autonomy }),
method: 'PATCH', body: JSON.stringify({ concurrency: cc, autonomy, logo: logo || null }),
});
await loadProjects(); // 刷新当前值显示
await loadProjects(); // 刷新当前值显示 + logo
renderSidebar();
$('#configPanel').hidden = true; // 保存成功自动收起
}, '配置已保存');
break;
@@ -1245,6 +1258,45 @@ document.addEventListener('click', (ev) => {
}
}, true);
// ── 项目列表拖动排序 ──
let dragId = null;
$('#projectList').addEventListener('dragstart', (ev) => {
const li = ev.target.closest('li[data-id]');
if (!li) return;
dragId = li.dataset.id;
li.classList.add('dragging');
ev.dataTransfer.effectAllowed = 'move';
});
$('#projectList').addEventListener('dragend', (ev) => {
const li = ev.target.closest('li');
if (li) li.classList.remove('dragging');
$('#projectList').querySelectorAll('.drop-over').forEach((x) => x.classList.remove('drop-over'));
dragId = null;
});
$('#projectList').addEventListener('dragover', (ev) => {
ev.preventDefault();
const li = ev.target.closest('li[data-id]');
$('#projectList').querySelectorAll('.drop-over').forEach((x) => x.classList.remove('drop-over'));
if (li && li.dataset.id !== dragId) li.classList.add('drop-over');
});
$('#projectList').addEventListener('drop', (ev) => {
ev.preventDefault();
const li = ev.target.closest('li[data-id]');
if (!li || !dragId || li.dataset.id === dragId) return;
const ids = S.projects.map((p) => p.id);
const from = ids.indexOf(dragId);
const to = ids.indexOf(li.dataset.id);
if (from < 0 || to < 0) return;
ids.splice(to, 0, ids.splice(from, 1)[0]); // 插到目标位置
S.projects.sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id)); // 乐观重排
renderSidebar();
act(async () => {
await api('/api/projects/reorder', { method: 'POST', body: JSON.stringify({ order: ids }) });
await loadProjects();
renderSidebar();
}, '项目顺序已保存');
});
// ── 表单提交 ──
$('#newProjectForm').addEventListener('submit', (ev) => {
ev.preventDefault();