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
+77
View File
@@ -0,0 +1,77 @@
import { existsSync, statSync } from 'node:fs';
import { resolve, sep } from 'node:path';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import type { Project } from '../model/types.js';
const exec = promisify(execFile);
/** logo 解析结果:file=读仓库内文件流;redirect=重定向到外链头像 */
export type LogoResolution =
| { type: 'file'; path: string }
| { type: 'redirect'; url: string }
| null;
/** 仓库内常见 logo 文件候选(按优先级),相对 repoPath */
const CANDIDATES = [
'logo.svg', 'logo.png', 'logo.webp', 'logo.jpg', 'logo.jpeg',
'icon.svg', 'icon.png', 'app_icon.png', 'favicon.svg', 'favicon.png', 'favicon.ico',
'public/logo.svg', 'public/logo.png', 'public/favicon.png', 'public/favicon.ico',
'assets/logo.svg', 'assets/logo.png', 'static/logo.svg', 'static/logo.png',
'web/favicon.png', 'web/logo.png', 'client/web/favicon.png', 'client/web/logo.png',
'src/assets/logo.svg', 'src/assets/logo.png', 'app/src/main/res/mipmap-hdpi/ic_launcher.png',
];
const URL_RE = /^https?:\/\//i;
/** 安全地把 repo 内相对路径解析为绝对路径(防穿越) */
function safeRepoPath(repoPath: string, rel: string): string | null {
const base = resolve(repoPath);
const abs = resolve(base, '.' + sep + rel);
if (abs !== base && !abs.startsWith(base + sep)) return null;
return abs;
}
/** 从 git remote 推导头像 URLGitHub owner 头像;其他 Gitea 主机尽力而为,失败由前端兜底) */
async function remoteAvatar(repoPath: string): Promise<string | null> {
let url = '';
try {
url = (await exec('git', ['-C', repoPath, 'remote', 'get-url', 'origin'])).stdout.trim();
} catch { return null; }
if (!url) return null;
// github.com/<owner>/<repo>(.git) — ssh 或 https 形式
const gh = url.match(/github\.com[:/]([^/]+)\//i);
if (gh) return `https://github.com/${gh[1]}.png?size=80`;
// 通用 Gitea<scheme|ssh>://[git@]<host>[:port]/<owner>/<repo> → https://<host>/<owner>.png(尽力,前端 onerror 兜底)
const m = url.match(/(?:https?:\/\/|ssh:\/\/[^@]*@|[^@]*@)([^/:]+)(?::\d+)?[/:]([^/]+)\//);
if (m) return `https://${m[1]}/${m[2]}.png`;
return null;
}
/**
* 解析项目 logo,优先级:
* 1. 自定义 logoproject.logo):URL → 重定向;否则视作仓库内相对路径 → 文件
* 2. 仓库内常见 logo 文件
* 3. git remote 推导头像(重定向)
* 4. null(前端用首字母徽章兜底)
*/
export async function resolveLogo(project: Project): Promise<LogoResolution> {
if (project.logo) {
if (URL_RE.test(project.logo)) return { type: 'redirect', url: project.logo };
const abs = safeRepoPath(project.repoPath, project.logo);
if (abs && existsSync(abs) && statSync(abs).isFile()) return { type: 'file', path: abs };
return null; // 自定义路径无效 → 兜底
}
for (const rel of CANDIDATES) {
const abs = resolve(project.repoPath, rel);
if (existsSync(abs) && statSync(abs).isFile()) return { type: 'file', path: abs };
}
const avatar = await remoteAvatar(project.repoPath);
if (avatar) return { type: 'redirect', url: avatar };
return null;
}
export const LOGO_MIME: Record<string, string> = {
svg: 'image/svg+xml', png: 'image/png', webp: 'image/webp',
jpg: 'image/jpeg', jpeg: 'image/jpeg', ico: 'image/x-icon', gif: 'image/gif',
};
+23
View File
@@ -9,6 +9,8 @@ import { syncProject, hasTodoJson } from '../sync/todo-sync.js';
import { resolvedExecutorModels } from '../executor/models.js';
import { mergeBranch } from '../executor/merge.js';
import { git, removeWorktree } from '../executor/worktree.js';
import { resolveLogo, LOGO_MIME } from './logo.js';
import { createReadStream } from 'node:fs';
import { createUsageFetcher, type UsageInfo } from '../daemon/usage.js';
/** Project 出参:附加 hasTodoJson<repoPath>/todo/todo.json 是否存在,每次序列化时算) */
@@ -70,9 +72,30 @@ export function buildServer(opts: ApiOptions): FastifyInstance {
if (b.status !== undefined) patch.status = b.status as 'active' | 'paused';
if (b.verifyCmd !== undefined) patch.verifyCmd = b.verifyCmd === null ? null : String(b.verifyCmd);
if (b.model !== undefined) patch.model = b.model === null ? null : String(b.model);
if (b.logo !== undefined) patch.logo = b.logo === null || b.logo === '' ? null : String(b.logo);
return projectOut(store.patchProject(id, patch));
});
// 项目重排序(侧栏拖动):body { order: [projectId, ...] }
app.post('/api/projects/reorder', (req) => {
const b = (req.body ?? {}) as { order?: unknown };
if (!Array.isArray(b.order)) throw new StoreError('order 必须是项目 id 数组');
return store.reorderProjects(b.order.map(String)).map(projectOut);
});
// 项目 logo:仓库内文件 → 流式返回;外链头像 → 302;无 → 404(前端用首字母徽章兜底)
app.get('/api/projects/:id/logo', async (req, reply) => {
const { id } = req.params as { id: string };
const p = store.getProject(id);
if (!p) return reply.code(404).send();
const r = await resolveLogo(p);
if (!r) return reply.code(404).send();
if (r.type === 'redirect') return reply.redirect(r.url);
const ext = r.path.split('.').pop()?.toLowerCase() ?? '';
reply.header('cache-control', 'no-cache').type(LOGO_MIME[ext] ?? 'application/octet-stream');
return reply.send(createReadStream(r.path));
});
// 单向同步 <repoPath>/todo/todo.json → maestro(导入=首次同步,幂等)
app.post('/api/projects/:id/sync', (req) => {
const { id } = req.params as { id: string };
+48 -7
View File
@@ -14,17 +14,52 @@ export function mergeWorktreeDirFor(taskId: string): string {
}
/**
* 把任务分支合并进 defaultBranchPR 闭环的"merge"步)。
* 绝不触碰用户工作区:在 <dataDir>/worktrees/_merge/<taskId>/ 临时 git worktree 检出 defaultBranch
* 在其中 `git merge --no-ff <branch>`,成功后删除临时 worktree。不 push。
* 全部 execFile('git')(见 worktree.ts 的 git()),不走 shell。
* 把任务分支合并进 defaultBranchPR 闭环的"merge"步)。不 push。全部 execFile('git'),不走 shell。
*
* 两条路径:
* - defaultBranch 正被主检出(repoPath)占用 → 直接在主检出里 `git merge --no-ff`(工作区必须干净,
* 否则拒绝并提示提交/暂存或「仅通过」)。这是用户手动合并会做的事,安全。
* - 否则 → 在 <dataDir>/worktrees/_merge/<taskId>/ 临时 worktree 检出 defaultBranch 后合并,不碰任何检出。
*
* 边界:
* - 分支不存在(已删/已清理)→ {ok:false, error}
* - defaultBranch 正被用户工作区检出 → git worktree add 失败 → {ok:false, error}(宁可失败也不动用户检出
* - 冲突 → `git merge --abort` 后清理临时 worktreeerror 含冲突文件列表;defaultBranch 与任务分支均无损
* - 重复合并(分支已在 defaultBranch 里)→ git 返回 Already up to dateok:true、不新建提交
* - 主检出有未提交改动 → 拒绝(提示提交/暂存或仅通过
* - 冲突 → `git merge --abort` 后复原error 含冲突文件列表;分支均无损
* - 重复合并(分支已在 defaultBranch 里)→ Already up to dateok:true、不新建提交
*/
/**
* 目标分支正被主检出(repoPath)占用时的合并:直接在主检出里 `git merge --no-ff`。
* 这是用户手动合并会做的事,安全;但要求工作区干净,否则可能与未提交改动纠缠 → 拒绝。
*/
async function mergeInPrimaryCheckout(
repoPath: string,
branch: string,
defaultBranch: string,
taskId: string,
): Promise<MergeResult> {
const dirty = (await git(repoPath, ['status', '--porcelain']).catch(() => '')).trim();
if (dirty) {
return {
ok: false,
error: `默认分支 ${defaultBranch} 检出于工作区且有未提交改动,无法安全合并;请先提交/暂存这些改动,或点「仅通过」(任务保留在审核闸)`,
};
}
try {
await git(repoPath, [
'-c', 'user.name=maestro', '-c', 'user.email=maestro@local',
'merge', '--no-ff', branch, '-m', `merge: ${branch} [${taskId}]`,
]);
const mergeCommit = (await git(repoPath, ['rev-parse', 'HEAD'])).trim();
return { ok: true, mergeCommit };
} catch (e) {
let conflicted = '';
try { conflicted = (await git(repoPath, ['diff', '--name-only', '--diff-filter=U'])).trim(); } catch { /* ignore */ }
await git(repoPath, ['merge', '--abort']).catch(() => undefined);
const files = conflicted ? `;冲突文件:${conflicted.split('\n').join('、')}` : '';
return { ok: false, error: `${(e as Error).message}${files}` };
}
}
export async function mergeBranch(
repoPath: string,
branch: string,
@@ -38,6 +73,12 @@ export async function mergeBranch(
return { ok: false, error: `任务分支不存在:${branch}(可能已被删除或清理)` };
}
// 0.5) 目标分支正被主检出占用 → 直接在主检出里合并(工作区干净时),避免临时 worktree 二次检出失败
const headBranch = (await git(repoPath, ['rev-parse', '--abbrev-ref', 'HEAD']).catch(() => '')).trim();
if (headBranch === defaultBranch) {
return mergeInPrimaryCheckout(repoPath, branch, defaultBranch, taskId);
}
// 1) 清理残留的临时合并 worktree(上次中断/失败遗留),再新建
const dir = mergeWorktreeDirFor(taskId);
await git(repoPath, ['worktree', 'prune']).catch(() => undefined);
+2
View File
@@ -20,6 +20,8 @@ export interface Project {
model: string | null;
concurrency: number; // 每项目并发执行上限
status: 'active' | 'paused';
logo: string | null; // 自定义 logoURL/仓库内相对路径;null=自动解析)
sortOrder: number; // 侧栏排序(小在前)
createdAt: string;
lastSyncAt: string | null; // 最近一次 todo.json 同步完成时间
}
+2
View File
@@ -24,6 +24,8 @@ export function openDb(file: string): Database.Database {
db.pragma('foreign_keys = ON');
ensureColumn(db, 'tasks', 'source_ref', 'source_ref TEXT');
ensureColumn(db, 'projects', 'last_sync_at', 'last_sync_at TEXT');
ensureColumn(db, 'projects', 'logo', 'logo TEXT'); // 自定义 logoURL/仓库内相对路径,null=自动解析)
ensureColumn(db, 'projects', 'sort_order', 'sort_order INTEGER NOT NULL DEFAULT 0'); // 侧栏排序
const schema = readFileSync(join(HERE, 'schema.sql'), 'utf8');
db.exec(schema);
return db;
+2 -2
View File
@@ -8,7 +8,7 @@ export interface ProjectRow {
id: string; name: string; repo_path: string; default_branch: string;
verify_cmd: string | null; autonomy: string; model: string | null;
concurrency: number; status: string; created_at: string;
last_sync_at: string | null;
last_sync_at: string | null; logo: string | null; sort_order: number;
}
export interface TaskRow {
id: string; project_id: string; parent_id: string | null; depth: number;
@@ -35,7 +35,7 @@ export function rowToProject(r: ProjectRow): Project {
id: r.id, name: r.name, repoPath: r.repo_path, defaultBranch: r.default_branch,
verifyCmd: r.verify_cmd, autonomy: r.autonomy as Autonomy, model: r.model,
concurrency: r.concurrency, status: r.status as Project['status'], createdAt: r.created_at,
lastSyncAt: r.last_sync_at,
lastSyncAt: r.last_sync_at, logo: r.logo, sortOrder: r.sort_order,
};
}
+3 -1
View File
@@ -13,7 +13,9 @@ CREATE TABLE IF NOT EXISTS projects (
concurrency INTEGER NOT NULL DEFAULT 1,
status TEXT NOT NULL DEFAULT 'active', -- active | paused
created_at TEXT NOT NULL,
last_sync_at TEXT -- 最近一次 todo.json 同步时间
last_sync_at TEXT, -- 最近一次 todo.json 同步时间
logo TEXT, -- 自定义 logoURL / 仓库内相对路径;null=自动)
sort_order INTEGER NOT NULL DEFAULT 0 -- 侧栏排序(小在前)
);
CREATE TABLE IF NOT EXISTS tasks (
+23 -5
View File
@@ -27,7 +27,7 @@ export interface CreateTaskInput {
}
export interface PatchProjectInput {
autonomy?: Autonomy; concurrency?: number; verifyCmd?: string | null;
model?: string | null; status?: 'active' | 'paused';
model?: string | null; status?: 'active' | 'paused'; logo?: string | null;
}
export interface PatchTaskInput {
title?: string; priority?: number; complexity?: Complexity;
@@ -89,26 +89,43 @@ export class Store {
// ---------- Projects ----------
createProject(input: CreateProjectInput): Project {
const maxOrder = (this.db.prepare(`SELECT COALESCE(MAX(sort_order), -1) AS m FROM projects`).get() as { m: number }).m;
const row: ProjectRow = {
id: id('prj'), name: input.name, repo_path: input.repoPath,
default_branch: input.defaultBranch ?? 'main', verify_cmd: input.verifyCmd ?? null,
autonomy: input.autonomy ?? 'manual', model: input.model ?? null,
concurrency: input.concurrency ?? 1, status: 'active', created_at: now(),
last_sync_at: null,
last_sync_at: null, logo: null, sort_order: maxOrder + 1,
};
this.db.prepare(
`INSERT INTO projects (id,name,repo_path,default_branch,verify_cmd,autonomy,model,concurrency,status,created_at,last_sync_at)
VALUES (@id,@name,@repo_path,@default_branch,@verify_cmd,@autonomy,@model,@concurrency,@status,@created_at,@last_sync_at)`,
`INSERT INTO projects (id,name,repo_path,default_branch,verify_cmd,autonomy,model,concurrency,status,created_at,last_sync_at,logo,sort_order)
VALUES (@id,@name,@repo_path,@default_branch,@verify_cmd,@autonomy,@model,@concurrency,@status,@created_at,@last_sync_at,@logo,@sort_order)`,
).run(row);
this.emit(row.id, null, 'task.created', { kind: 'project', name: row.name });
return rowToProject(row);
}
listProjects(): Project[] {
const rows = this.db.prepare(`SELECT * FROM projects ORDER BY created_at`).all() as ProjectRow[];
const rows = this.db.prepare(`SELECT * FROM projects ORDER BY sort_order, created_at`).all() as ProjectRow[];
return rows.map(rowToProject);
}
/** 重排项目(侧栏拖动):按给定 id 顺序写 sort_order;未列出的排在后面、相对顺序不变。 */
reorderProjects(orderedIds: string[]): Project[] {
const txn = this.db.transaction(() => {
let i = 0;
const upd = this.db.prepare(`UPDATE projects SET sort_order = ? WHERE id = ?`);
for (const pid of orderedIds) upd.run(i++, pid);
// 未列出的项目顺延到末尾(保持原相对序)
const rest = this.db.prepare(
`SELECT id FROM projects WHERE id NOT IN (${orderedIds.map(() => '?').join(',') || 'NULL'}) ORDER BY sort_order, created_at`,
).all(...orderedIds) as Array<{ id: string }>;
for (const r of rest) upd.run(i++, r.id);
});
txn();
return this.listProjects();
}
getProject(projectId: string): Project | null {
const row = this.db.prepare(`SELECT * FROM projects WHERE id = ?`).get(projectId) as ProjectRow | undefined;
return row ? rowToProject(row) : null;
@@ -141,6 +158,7 @@ export class Store {
}
if (patch.verifyCmd !== undefined) { sets.push('verify_cmd = @verify_cmd'); args.verify_cmd = patch.verifyCmd; }
if (patch.model !== undefined) { sets.push('model = @model'); args.model = patch.model; }
if (patch.logo !== undefined) { sets.push('logo = @logo'); args.logo = patch.logo; }
if (sets.length > 0) {
this.db.prepare(`UPDATE projects SET ${sets.join(', ')} WHERE id = @id`).run(args);
+23 -7
View File
@@ -111,19 +111,35 @@ test('mergeBranch:分支不存在(已删)→ 明确错误', async (t) => {
assert.match(r.error ?? '', /分支不存在/);
});
test('mergeBranchdefaultBranch 正被工作区检出失败且不动用户检出', async (t) => {
const repo = setup(t); // main 在主工作区检出
gitSync(repo, ['branch', 'maestro/tsk_co']); // 分支存在但 main 被占用
test('mergeBranchmain 被主检出占用且工作区干净直接在主检出里合并成功', async (t) => {
const repo = setup(t); // main 在主工作区检出,干净
gitSync(repo, ['checkout', '-b', 'maestro/tsk_co']);
commitFile(repo, 'c.txt', 'C\n', 'maestro(tsk_co): add c');
gitSync(repo, ['checkout', 'main']); // 回到 main(仍被主检出占用)
const mainBefore = gitSync(repo, ['rev-parse', 'main']).trim();
const r = await mergeBranch(repo, 'maestro/tsk_co', 'main', 'tsk_co');
assert.equal(r.ok, false);
assert.match(r.error ?? '', /检出/, `应提示 defaultBranch 被检出:${r.error}`);
assert.equal(gitSync(repo, ['rev-parse', 'main']).trim(), mainBefore);
assert.equal(gitSync(repo, ['rev-parse', '--abbrev-ref', 'HEAD']).trim(), 'main', '用户检出不应被切走');
assert.equal(r.ok, true, r.error);
assert.notEqual(gitSync(repo, ['rev-parse', 'main']).trim(), mainBefore, 'main 应前进');
assert.equal(gitSync(repo, ['rev-parse', '--abbrev-ref', 'HEAD']).trim(), 'main', '仍在 main');
assert.equal(gitSync(repo, ['show', 'main:c.txt']), 'C\n', '合并内容已进工作区');
assertMergeWorktreeCleaned(repo, 'tsk_co');
});
test('mergeBranchmain 被主检出占用但工作区有未提交改动 → 拒绝,提示提交/暂存', async (t) => {
const repo = setup(t);
gitSync(repo, ['checkout', '-b', 'maestro/tsk_dirty']);
commitFile(repo, 'd.txt', 'D\n', 'maestro(tsk_dirty): add d');
gitSync(repo, ['checkout', 'main']);
writeFileSync(join(repo, 'README.md'), '# demo\nlocal edit\n'); // 制造脏工作区
const mainBefore = gitSync(repo, ['rev-parse', 'main']).trim();
const r = await mergeBranch(repo, 'maestro/tsk_dirty', 'main', 'tsk_dirty');
assert.equal(r.ok, false);
assert.match(r.error ?? '', /未提交改动/, `应提示未提交改动:${r.error}`);
assert.equal(gitSync(repo, ['rev-parse', 'main']).trim(), mainBefore, 'main 无损');
});
test('mergeBranch:重复合并(分支已在 main 里)→ ok,不新建提交', async (t) => {
const repo = setup(t);
gitSync(repo, ['checkout', '-b', 'maestro/tsk_re']);
+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%; }