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:
parent
958678aa12
commit
d89ebad272
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 item = e.target.closest(".sk-item");
|
||||||
|
if (item) selectSkill(item.getAttribute("data-name"), item);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 右栏:删除(冒泡到 [data-del])
|
||||||
|
$("sk-detail").addEventListener("click", async (e) => {
|
||||||
const del = e.target.closest("[data-del]");
|
const del = e.target.closest("[data-del]");
|
||||||
if (del) {
|
if (!del) return;
|
||||||
e.stopPropagation();
|
|
||||||
const name = del.getAttribute("data-del");
|
const name = del.getAttribute("data-del");
|
||||||
if (!confirm(`删除你的 skill「${name}」?不可撤销(平台同名 skill 不受影响)。`)) return;
|
if (!confirm(`删除你的 skill「${name}」?不可撤销(平台同名 skill 不受影响)。`)) return;
|
||||||
del.disabled = true;
|
del.disabled = true;
|
||||||
try {
|
try {
|
||||||
await api("DELETE", "/v1/skills/" + encodeURIComponent(name));
|
await api("DELETE", "/v1/skills/" + encodeURIComponent(name));
|
||||||
state.skills = null; // 失效缓存,新建任务下拉下次重拉
|
state.skills = null; // 失效缓存,新建任务下拉下次重拉
|
||||||
|
$("sk-detail").innerHTML = PLACEHOLDER;
|
||||||
renderList();
|
renderList();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert("删除失败: " + err.message);
|
alert("删除失败: " + err.message);
|
||||||
del.disabled = false;
|
del.disabled = false;
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
|
||||||
const item = e.target.closest(".sk-item");
|
|
||||||
if (item) showDetail(item.getAttribute("data-name"));
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue