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();
+5
View File
@@ -77,6 +77,11 @@
<button class="btn btn-solid" data-action="save-config">保存配置</button>
<button class="btn" data-action="toggle-config">收起</button>
</div>
<div class="form-row">
<label class="grow">Logo
<input id="cfgLogo" type="text" placeholder="图片 URL 或仓库内相对路径;留空=自动(仓库 logo→git 头像→首字母)">
</label>
</div>
<div class="form-row cfg-sync-row">
<span id="syncMeta" class="sync-meta"></span>
<button id="btnSync" class="btn btn-xs" data-action="sync-todo">⟳ 同步 todo</button>
+23 -2
View File
@@ -119,10 +119,10 @@ body::before {
/* ── 项目列表 ─────────────────────────────────────────────── */
.project-list { list-style: none; flex: 1; }
.project-list li {
padding: 9px 16px 9px 14px;
padding: 8px 16px 8px 12px;
border-left: 2px solid transparent;
cursor: pointer;
display: flex; flex-direction: column; gap: 1px;
display: flex; align-items: center; gap: 9px;
}
.project-list li:hover { background: var(--panel); }
.project-list li.active {
@@ -130,8 +130,25 @@ body::before {
border-left-color: var(--green);
}
.project-list li.active .p-name { color: var(--green); }
.project-list li.dragging { opacity: .4; }
.project-list li.drop-over { box-shadow: inset 0 2px 0 var(--green); }
.p-meta { display: flex; flex-direction: column; gap: 1px; min-width: 0; flex: 1; }
.p-name { font-weight: 600; font-size: 13px; }
.p-path { font-size: 10.5px; color: var(--faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* 项目 logo:图片优先,加载失败 onerror 移除自身 → 露出首字母徽章 */
.p-logo-wrap { position: relative; flex: none; width: 26px; height: 26px; }
.p-logo-fb {
position: absolute; inset: 0; display: grid; place-items: center;
font-size: 13px; font-weight: 700; border-radius: 5px; color: #0a0d0b;
background: hsl(var(--h, 140) 45% 60%);
}
.p-logo {
position: absolute; inset: 0; width: 100%; height: 100%;
object-fit: cover; border-radius: 5px; background: var(--bg-deep);
opacity: 0;
}
.p-logo.ok { opacity: 1; }
.p-empty { padding: 14px 16px; color: var(--faint); font-size: 12px; }
.side-foot {
@@ -770,3 +787,7 @@ li.ev-updated { --ev: var(--muted); }
/* 配置面板内的同步行 */
.cfg-sync-row { margin-top: 10px; padding-top: 10px; border-top: 1px dashed var(--line-soft); align-items: center; gap: 10px; }
/* 配置面板:可伸展输入(logo) */
.config-panel label.grow { flex: 1; display: flex; flex-direction: column; gap: 4px; }
.config-panel label.grow input { width: 100%; }