feat(web): 技能按钮换扁平 SVG 图标 + 弹框改两栏 master-detail

- 左侧 rail「技能」按钮 emoji 🧩 换成扁平 inline SVG(2x2 grid,
  描边 currentColor 跟随主题);modal 标题同步。
- modal 从"列表→点开换正文+返回"改两栏:左栏平台/我的两组列表(可选中
  高亮),右栏展示选中 skill 完整 SKILL.md;删除按钮挪到右栏正文头部
  (只对我的),左列表保持干净只显名+描述。窄屏(<=640px)两栏自动改上下堆叠。
- 纯前端,后端/测试无影响。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-06-11 10:08:26 +08:00
parent 958678aa12
commit d89ebad272
2 changed files with 105 additions and 64 deletions

View File

@ -205,12 +205,16 @@
flex-shrink: 0; border-top: 1px solid var(--border); flex-shrink: 0; border-top: 1px solid var(--border);
padding: 8px; display: flex; gap: 6px; padding: 8px; display: flex; gap: 6px;
} }
#rail-resources > button { flex: 1; font-size: 13px; } #rail-resources > button {
flex: 1; font-size: 13px;
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
}
#rail-resources > button svg { flex-shrink: 0; opacity: .85; }
/* ───── 技能查看 modal ───── */ /* ───── 技能查看 modal(两栏 master-detail)───── */
#skills-modal { z-index: 112; } #skills-modal { z-index: 112; }
#skills-modal .card { #skills-modal .card {
width: 720px; max-width: 92vw; max-height: 84vh; width: 880px; max-width: 94vw; height: 80vh; max-height: 80vh;
display: flex; flex-direction: column; display: flex; flex-direction: column;
} }
#skills-modal h3 { #skills-modal h3 {
@ -219,21 +223,28 @@
display: flex; align-items: center; gap: 8px; display: flex; align-items: center; gap: 8px;
} }
#skills-modal h3 .spacer { flex: 1; } #skills-modal h3 .spacer { flex: 1; }
#skills-modal h3 svg { opacity: .85; }
#skills-modal .sk-x { #skills-modal .sk-x {
border: none; background: transparent; font-size: 16px; border: none; background: transparent; font-size: 16px;
cursor: pointer; color: var(--muted); padding: 2px 6px; cursor: pointer; color: var(--muted); padding: 2px 6px;
} }
#skills-modal #sk-back { margin-right: 4px; } /* 两栏:左列表 / 右正文 */
#skills-modal .sk-detail-name { font-weight: 600; } #sk-cols { flex: 1; display: flex; min-height: 0; }
#skills-modal .body { padding: 12px 16px; overflow: auto; } #sk-list {
width: 290px; flex-shrink: 0; overflow: auto;
padding: 12px; border-right: 1px solid var(--border);
}
#sk-detail { flex: 1; min-width: 0; overflow: auto; padding: 16px 20px; }
.sk-empty { color: var(--muted); font-size: 13px; padding: 24px 8px; text-align: center; }
.sk-group-title { font-weight: 600; font-size: 12px; color: var(--muted); margin: 0 0 8px; } .sk-group-title { font-weight: 600; font-size: 12px; color: var(--muted); margin: 0 0 8px; }
.sk-group-title.mt { margin-top: 16px; }
.sk-item { .sk-item {
display: flex; align-items: center; gap: 10px; padding: 7px 10px; border: 1px solid var(--border);
padding: 8px 10px; border: 1px solid var(--border);
border-radius: var(--r-md); margin-bottom: 6px; cursor: pointer; border-radius: var(--r-md); margin-bottom: 6px; cursor: pointer;
} }
.sk-item:hover { border-color: var(--accent); background: #fafafa; } .sk-item:hover { border-color: var(--accent); background: #fafafa; }
.sk-item-main { flex: 1; min-width: 0; } .sk-item.active { border-color: var(--accent); background: rgba(120,120,200,0.07); }
.sk-item .sk-name { font-weight: 600; font-size: 13px; } .sk-item .sk-name { font-weight: 600; font-size: 13px; }
.sk-item .sk-desc { .sk-item .sk-desc {
font-size: 12px; color: var(--muted); margin-top: 2px; font-size: 12px; color: var(--muted); margin-top: 2px;
@ -242,23 +253,36 @@
.sk-badge { .sk-badge {
font-size: 10px; font-weight: 500; color: var(--accent); font-size: 10px; font-weight: 500; color: var(--accent);
border: 1px solid var(--accent); border-radius: 8px; padding: 0 5px; border: 1px solid var(--accent); border-radius: 8px; padding: 0 5px;
margin-left: 4px; white-space: nowrap; white-space: nowrap;
} }
.sk-del { flex-shrink: 0; } .sk-name .sk-badge { margin-left: 4px; }
.sk-loaderr { .sk-loaderr {
margin-top: 14px; padding: 8px 10px; font-size: 12px; margin-top: 14px; padding: 8px 10px; font-size: 12px;
border: 1px solid var(--accent); border-radius: var(--r-md); border: 1px solid var(--accent); border-radius: var(--r-md);
color: var(--accent); background: rgba(220,80,80,0.05); color: var(--accent); background: rgba(220,80,80,0.05);
} }
.sk-detail { font-size: 13px; line-height: 1.6; } /* 右栏正文头 + markdown */
.sk-detail pre { .sk-d-head {
display: flex; align-items: center; gap: 8px; margin-bottom: 12px;
padding-bottom: 10px; border-bottom: 1px solid var(--border);
}
.sk-d-head .sk-d-name { font-weight: 600; font-size: 15px; }
.sk-d-head .spacer { flex: 1; }
.sk-detail-md { font-size: 13px; line-height: 1.6; }
.sk-detail-md pre {
white-space: pre-wrap; word-break: break-word; white-space: pre-wrap; word-break: break-word;
background: #f5f5f5; padding: 10px; border-radius: var(--r-md); overflow: auto; background: #f5f5f5; padding: 10px; border-radius: var(--r-md); overflow: auto;
} }
.sk-detail code { word-break: break-word; } .sk-detail-md code { word-break: break-word; }
.sk-detail h1, .sk-detail h2, .sk-detail h3 { margin: 14px 0 6px; } .sk-detail-md h1, .sk-detail-md h2, .sk-detail-md h3 { margin: 14px 0 6px; }
.sk-detail table { border-collapse: collapse; } .sk-detail-md table { border-collapse: collapse; }
.sk-detail th, .sk-detail td { border: 1px solid var(--border); padding: 4px 8px; } .sk-detail-md th, .sk-detail-md td { border: 1px solid var(--border); padding: 4px 8px; }
/* 窄屏:两栏改上下堆叠 */
@media (max-width: 640px) {
#skills-modal .card { height: 88vh; max-height: 88vh; }
#sk-cols { flex-direction: column; }
#sk-list { width: auto; max-height: 38vh; border-right: none; border-bottom: 1px solid var(--border); }
}
/* ───── 3-pane layout ───── */ /* ───── 3-pane layout ───── */
#app { display: none; height: 100vh; } #app { display: none; height: 100vh; }
@ -1054,11 +1078,15 @@
<div id="skills-modal" class="modal"> <div id="skills-modal" class="modal">
<div class="card"> <div class="card">
<h3> <h3>
<span id="sk-title">技能</span> <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="7" height="7" rx="1.5"></rect><rect x="14" y="3" width="7" height="7" rx="1.5"></rect><rect x="3" y="14" width="7" height="7" rx="1.5"></rect><rect x="14" y="14" width="7" height="7" rx="1.5"></rect></svg>
<span>技能</span>
<span class="spacer"></span> <span class="spacer"></span>
<button id="sk-close" class="sk-x" title="关闭"></button> <button id="sk-close" class="sk-x" title="关闭"></button>
</h3> </h3>
<div class="body" id="sk-body"><div class="muted" style="padding:8px;">加载中…</div></div> <div id="sk-cols">
<div id="sk-list"><div class="muted" style="padding:8px;">加载中…</div></div>
<div id="sk-detail"><div class="sk-empty">← 从左侧选一个 skill 查看完整说明</div></div>
</div>
</div> </div>
</div> </div>
@ -1129,7 +1157,10 @@
<div id="task-sentinel" style="padding:10px 12px;font-size:11px;color:var(--muted);text-align:center;min-height:1px;"></div> <div id="task-sentinel" style="padding:10px 12px;font-size:11px;color:var(--muted);text-align:center;min-height:1px;"></div>
</div> </div>
<div id="rail-resources" title="我的资源"> <div id="rail-resources" title="我的资源">
<button id="hd-skills" title="查看平台 / 我的 skill">🧩 技能</button> <button id="hd-skills" title="查看平台 / 我的 skill">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="7" height="7" rx="1.5"></rect><rect x="14" y="3" width="7" height="7" rx="1.5"></rect><rect x="3" y="14" width="7" height="7" rx="1.5"></rect><rect x="14" y="14" width="7" height="7" rx="1.5"></rect></svg>
<span>技能</span>
</button>
</div> </div>
</div> </div>
<div id="split-left" class="splitter" role="separator" aria-orientation="vertical" title="拖拽调整任务栏宽度"></div> <div id="split-left" class="splitter" role="separator" aria-orientation="vertical" title="拖拽调整任务栏宽度"></div>

View File

@ -1,5 +1,6 @@
// 技能 modal:查看「平台 skill」/「我的 skill」两组列表,点任一项展开完整 SKILL.md, // 技能 modal:两栏 master-detail。左栏「平台 skill / 我的 skill」两组列表,
// 「我的」每项可删除(平台 skill 只读)。左侧 rail 底部「技能」按钮触发。 // 右栏展示选中 skill 的完整 SKILL.md;删除按钮在右栏正文头部(只对「我的」)。
// 左侧 rail 底部「技能」按钮触发。
// 后端:GET /v1/skills(列表)、GET /v1/skills/{name}(正文)、DELETE /v1/skills/{name}(只删 user 源)。 // 后端:GET /v1/skills(列表)、GET /v1/skills/{name}(正文)、DELETE /v1/skills/{name}(只删 user 源)。
// 创建 / 改 / fork 仍走对话(save_skill / fork_skill / skill-creator)。 // 创建 / 改 / fork 仍走对话(save_skill / fork_skill / skill-creator)。
import { $ } from "./dom.js"; import { $ } from "./dom.js";
@ -8,8 +9,11 @@ import { api } from "./api.js";
import { escapeHtml } from "./format.js"; import { escapeHtml } from "./format.js";
import { renderMd, highlightIn } from "./markdown.js"; import { renderMd, highlightIn } from "./markdown.js";
const PLACEHOLDER = '<div class="sk-empty">← 从左侧选一个 skill 查看完整说明</div>';
function openSkillsModal() { function openSkillsModal() {
$("skills-modal").classList.add("show"); $("skills-modal").classList.add("show");
$("sk-detail").innerHTML = PLACEHOLDER;
renderList(); renderList();
} }
export function closeSkillsModal() { export function closeSkillsModal() {
@ -20,27 +24,20 @@ function itemHtml(s) {
const badge = s.overrides_builtin const badge = s.overrides_builtin
? ' <span class="sk-badge">已覆盖平台同名</span>' ? ' <span class="sk-badge">已覆盖平台同名</span>'
: ""; : "";
const del =
s.source === "user"
? `<button class="sk-del small danger" data-del="${escapeHtml(s.name)}" title="删除我的这个 skill">删除</button>`
: "";
return `<div class="sk-item" data-name="${escapeHtml(s.name)}"> return `<div class="sk-item" data-name="${escapeHtml(s.name)}">
<div class="sk-item-main"> <div class="sk-name">${escapeHtml(s.name)}${badge}</div>
<div class="sk-name">${escapeHtml(s.name)}${badge}</div> <div class="sk-desc">${escapeHtml(s.description || "")}</div>
<div class="sk-desc">${escapeHtml(s.description || "")}</div>
</div>${del}
</div>`; </div>`;
} }
async function renderList() { async function renderList() {
const body = $("sk-body"); const list = $("sk-list");
$("sk-title").textContent = "技能"; list.innerHTML = '<div class="muted" style="padding:8px;">加载中…</div>';
body.innerHTML = '<div class="muted" style="padding:8px;">加载中…</div>';
let data; let data;
try { try {
data = await api("GET", "/v1/skills"); data = await api("GET", "/v1/skills");
} catch (e) { } catch (e) {
body.innerHTML = `<div class="err" style="padding:8px;">加载失败: ${escapeHtml(e.message)}</div>`; list.innerHTML = `<div class="err" style="padding:8px;">加载失败: ${escapeHtml(e.message)}</div>`;
return; return;
} }
state.skills = data.skills || []; // 顺手刷新新建任务下拉的缓存 state.skills = data.skills || []; // 顺手刷新新建任务下拉的缓存
@ -52,10 +49,10 @@ async function renderList() {
html += html +=
platform.map(itemHtml).join("") || platform.map(itemHtml).join("") ||
'<div class="muted" style="padding:4px 8px;">(无)</div>'; '<div class="muted" style="padding:4px 8px;">(无)</div>';
html += `<div class="sk-group-title" style="margin-top:14px;">我的 skill (${mine.length})</div>`; html += `<div class="sk-group-title mt">我的 skill (${mine.length})</div>`;
html += mine.length html += mine.length
? mine.map(itemHtml).join("") ? mine.map(itemHtml).join("")
: '<div class="muted" style="padding:4px 8px;">还没有。让助手「帮我做个 skill」或「把某个平台 skill fork 成我的」即可创建。</div>'; : '<div class="muted" style="padding:4px 8px;font-size:12px;">还没有。让助手「帮我做个 skill」或「把某个平台 skill fork 成我的」即可创建。</div>';
if (data.load_errors && data.load_errors.length) { if (data.load_errors && data.load_errors.length) {
const errs = data.load_errors const errs = data.load_errors
@ -63,23 +60,34 @@ async function renderList() {
.join(""); .join("");
html += `<div class="sk-loaderr">⚠ ${data.load_errors.length} 个 skill 因格式问题未加载:${errs}</div>`; html += `<div class="sk-loaderr">⚠ ${data.load_errors.length} 个 skill 因格式问题未加载:${errs}</div>`;
} }
body.innerHTML = html; list.innerHTML = html;
} }
async function showDetail(name) { async function selectSkill(name, itemEl) {
const body = $("sk-body"); // 左栏高亮
$("sk-title").innerHTML = `<button id="sk-back" class="small"> 返回</button><span class="sk-detail-name">${escapeHtml(name)}</span>`; $("sk-list").querySelectorAll(".sk-item.active").forEach((el) => el.classList.remove("active"));
$("sk-back").onclick = renderList; if (itemEl) itemEl.classList.add("active");
body.innerHTML = '<div class="muted" style="padding:8px;">加载中…</div>';
const detail = $("sk-detail");
detail.innerHTML = '<div class="muted" style="padding:8px;">加载中…</div>';
let data; let data;
try { try {
data = await api("GET", "/v1/skills/" + encodeURIComponent(name)); data = await api("GET", "/v1/skills/" + encodeURIComponent(name));
} catch (e) { } catch (e) {
body.innerHTML = `<div class="err" style="padding:8px;">加载失败: ${escapeHtml(e.message)}</div>`; detail.innerHTML = `<div class="err" style="padding:8px;">加载失败: ${escapeHtml(e.message)}</div>`;
return; return;
} }
body.innerHTML = `<div class="sk-detail">${renderMd(data.content)}</div>`; const isUser = data.source === "user";
highlightIn(body); const srcBadge = isUser
? `<span class="sk-badge">${data.overrides_builtin ? "我的 · 已覆盖平台" : "我的"}</span>`
: `<span class="sk-badge">平台</span>`;
const delBtn = isUser
? `<button class="sk-del small danger" data-del="${escapeHtml(data.name)}" title="删除我的这个 skill">删除</button>`
: "";
detail.innerHTML =
`<div class="sk-d-head"><span class="sk-d-name">${escapeHtml(data.name)}</span>${srcBadge}<span class="spacer"></span>${delBtn}</div>` +
`<div class="sk-detail-md">${renderMd(data.content)}</div>`;
highlightIn(detail);
} }
// ───── 顶层绑定 ───── // ───── 顶层绑定 ─────
@ -89,24 +97,26 @@ $("skills-modal").addEventListener("click", (e) => {
if (e.target.id === "skills-modal") closeSkillsModal(); // 点遮罩关闭 if (e.target.id === "skills-modal") closeSkillsModal(); // 点遮罩关闭
}); });
// 列表区事件委托:删除(冒泡到 [data-del])优先于点开详情(.sk-item) // 左栏:点 item 选中 → 右栏载正文
$("sk-body").addEventListener("click", async (e) => { $("sk-list").addEventListener("click", (e) => {
const del = e.target.closest("[data-del]");
if (del) {
e.stopPropagation();
const name = del.getAttribute("data-del");
if (!confirm(`删除你的 skill「${name}」?不可撤销(平台同名 skill 不受影响)。`)) return;
del.disabled = true;
try {
await api("DELETE", "/v1/skills/" + encodeURIComponent(name));
state.skills = null; // 失效缓存,新建任务下拉下次重拉
renderList();
} catch (err) {
alert("删除失败: " + err.message);
del.disabled = false;
}
return;
}
const item = e.target.closest(".sk-item"); const item = e.target.closest(".sk-item");
if (item) showDetail(item.getAttribute("data-name")); if (item) selectSkill(item.getAttribute("data-name"), item);
});
// 右栏:删除(冒泡到 [data-del])
$("sk-detail").addEventListener("click", async (e) => {
const del = e.target.closest("[data-del]");
if (!del) return;
const name = del.getAttribute("data-del");
if (!confirm(`删除你的 skill「${name}」?不可撤销(平台同名 skill 不受影响)。`)) return;
del.disabled = true;
try {
await api("DELETE", "/v1/skills/" + encodeURIComponent(name));
state.skills = null; // 失效缓存,新建任务下拉下次重拉
$("sk-detail").innerHTML = PLACEHOLDER;
renderList();
} catch (err) {
alert("删除失败: " + err.message);
del.disabled = false;
}
}); });