diff --git a/src/api/logo.ts b/src/api/logo.ts new file mode 100644 index 0000000..da6eca0 --- /dev/null +++ b/src/api/logo.ts @@ -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 推导头像 URL(GitHub owner 头像;其他 Gitea 主机尽力而为,失败由前端兜底) */ +async function remoteAvatar(repoPath: string): Promise { + let url = ''; + try { + url = (await exec('git', ['-C', repoPath, 'remote', 'get-url', 'origin'])).stdout.trim(); + } catch { return null; } + if (!url) return null; + // github.com//(.git) — ssh 或 https 形式 + const gh = url.match(/github\.com[:/]([^/]+)\//i); + if (gh) return `https://github.com/${gh[1]}.png?size=80`; + // 通用 Gitea:://[git@][:port]// → https:///.png(尽力,前端 onerror 兜底) + const m = url.match(/(?:https?:\/\/|ssh:\/\/[^@]*@|[^@]*@)([^/:]+)(?::\d+)?[/:]([^/]+)\//); + if (m) return `https://${m[1]}/${m[2]}.png`; + return null; +} + +/** + * 解析项目 logo,优先级: + * 1. 自定义 logo(project.logo):URL → 重定向;否则视作仓库内相对路径 → 文件 + * 2. 仓库内常见 logo 文件 + * 3. git remote 推导头像(重定向) + * 4. null(前端用首字母徽章兜底) + */ +export async function resolveLogo(project: Project): Promise { + 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 = { + svg: 'image/svg+xml', png: 'image/png', webp: 'image/webp', + jpg: 'image/jpeg', jpeg: 'image/jpeg', ico: 'image/x-icon', gif: 'image/gif', +}; diff --git a/src/api/server.ts b/src/api/server.ts index 2e584d2..8b76090 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -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(/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)); + }); + // 单向同步 /todo/todo.json → maestro(导入=首次同步,幂等) app.post('/api/projects/:id/sync', (req) => { const { id } = req.params as { id: string }; diff --git a/src/executor/merge.ts b/src/executor/merge.ts index 5d3a4fb..d8d4439 100644 --- a/src/executor/merge.ts +++ b/src/executor/merge.ts @@ -14,17 +14,52 @@ export function mergeWorktreeDirFor(taskId: string): string { } /** - * 把任务分支合并进 defaultBranch(PR 闭环的"merge"步)。 - * 绝不触碰用户工作区:在 /worktrees/_merge// 临时 git worktree 检出 defaultBranch, - * 在其中 `git merge --no-ff `,成功后删除临时 worktree。不 push。 - * 全部 execFile('git')(见 worktree.ts 的 git()),不走 shell。 + * 把任务分支合并进 defaultBranch(PR 闭环的"merge"步)。不 push。全部 execFile('git'),不走 shell。 + * + * 两条路径: + * - defaultBranch 正被主检出(repoPath)占用 → 直接在主检出里 `git merge --no-ff`(工作区必须干净, + * 否则拒绝并提示提交/暂存或「仅通过」)。这是用户手动合并会做的事,安全。 + * - 否则 → 在 /worktrees/_merge// 临时 worktree 检出 defaultBranch 后合并,不碰任何检出。 * * 边界: * - 分支不存在(已删/已清理)→ {ok:false, error} - * - defaultBranch 正被用户工作区检出 → git worktree add 失败 → {ok:false, error}(宁可失败也不动用户检出) - * - 冲突 → `git merge --abort` 后清理临时 worktree,error 含冲突文件列表;defaultBranch 与任务分支均无损 - * - 重复合并(分支已在 defaultBranch 里)→ git 返回 Already up to date,ok:true、不新建提交 + * - 主检出有未提交改动 → 拒绝(提示提交/暂存或仅通过) + * - 冲突 → `git merge --abort` 后复原,error 含冲突文件列表;两分支均无损 + * - 重复合并(分支已在 defaultBranch 里)→ Already up to date,ok:true、不新建提交 */ +/** + * 目标分支正被主检出(repoPath)占用时的合并:直接在主检出里 `git merge --no-ff`。 + * 这是用户手动合并会做的事,安全;但要求工作区干净,否则可能与未提交改动纠缠 → 拒绝。 + */ +async function mergeInPrimaryCheckout( + repoPath: string, + branch: string, + defaultBranch: string, + taskId: string, +): Promise { + 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); diff --git a/src/model/types.ts b/src/model/types.ts index a1186aa..87ca9ce 100644 --- a/src/model/types.ts +++ b/src/model/types.ts @@ -20,6 +20,8 @@ export interface Project { model: string | null; concurrency: number; // 每项目并发执行上限 status: 'active' | 'paused'; + logo: string | null; // 自定义 logo(URL/仓库内相对路径;null=自动解析) + sortOrder: number; // 侧栏排序(小在前) createdAt: string; lastSyncAt: string | null; // 最近一次 todo.json 同步完成时间 } diff --git a/src/store/db.ts b/src/store/db.ts index ea54a25..5198547 100644 --- a/src/store/db.ts +++ b/src/store/db.ts @@ -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'); // 自定义 logo(URL/仓库内相对路径,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; diff --git a/src/store/mappers.ts b/src/store/mappers.ts index 47c4fd6..fd413cc 100644 --- a/src/store/mappers.ts +++ b/src/store/mappers.ts @@ -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, }; } diff --git a/src/store/schema.sql b/src/store/schema.sql index 8f1eebb..a6d70a6 100644 --- a/src/store/schema.sql +++ b/src/store/schema.sql @@ -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, -- 自定义 logo(URL / 仓库内相对路径;null=自动) + sort_order INTEGER NOT NULL DEFAULT 0 -- 侧栏排序(小在前) ); CREATE TABLE IF NOT EXISTS tasks ( diff --git a/src/store/store.ts b/src/store/store.ts index 0f74ccb..1e41a0e 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -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); diff --git a/test/merge.test.ts b/test/merge.test.ts index 3d57fa5..1e8c09c 100644 --- a/test/merge.test.ts +++ b/test/merge.test.ts @@ -111,19 +111,35 @@ test('mergeBranch:分支不存在(已删)→ 明确错误', async (t) => { assert.match(r.error ?? '', /分支不存在/); }); -test('mergeBranch:defaultBranch 正被工作区检出 → 失败且不动用户检出', async (t) => { - const repo = setup(t); // main 仍在主工作区检出 - gitSync(repo, ['branch', 'maestro/tsk_co']); // 分支存在但 main 被占用 +test('mergeBranch:main 被主检出占用且工作区干净 → 直接在主检出里合并成功', 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('mergeBranch:main 被主检出占用但工作区有未提交改动 → 拒绝,提示提交/暂存', 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']); diff --git a/web/app.js b/web/app.js index 733f9d5..bbd4aad 100644 --- a/web/app.js +++ b/web/app.js @@ -312,11 +312,21 @@ function renderSidebar() { ul.innerHTML = `
  • 暂无项目
    点「+ 新建」登记第一个仓库
  • `; return; } - ul.innerHTML = S.projects.map((p) => ` -
  • - ${esc(p.name)} - ${esc(p.repoPath)} -
  • `).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 ` +
  • + + ${esc(ch)} + + + + ${esc(p.name)} + ${esc(p.repoPath)} + +
  • `; + }).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(); diff --git a/web/index.html b/web/index.html index d38ddb0..834d5e9 100644 --- a/web/index.html +++ b/web/index.html @@ -77,6 +77,11 @@ +
    + +
    diff --git a/web/style.css b/web/style.css index 871d497..ac5fab1 100644 --- a/web/style.css +++ b/web/style.css @@ -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%; }