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:
+59
-7
@@ -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();
|
||||
|
||||
@@ -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
@@ -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%; }
|
||||
|
||||
Reference in New Issue
Block a user