<!doctype html>
<!--
Direction 5 — full redesign of the Plugins (Extensions) page.
Structure (per review feedback):
- Two top-level tabs: 已安装 / 市场
- Installed tab: NO category tabs. Plugins carry category tags inline.
Status groups (需注意/可更新/已启用/已停用) keep decision states on top.
- Market tab: category tabs (product taxonomy slugs from the codebase:
devtools/web/docs/data/productivity/community + skill slugs
workflow/writing/coding; zh-CN labels from packages/i18n).
Design tokens are copied verbatim from apps/desktop/src/styles/tokens.css
(dark theme) — identical palette to the other demos.
-->
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>方向五 · 双 Tab — 扩展页重设计</title>
<style>
/* ---------- tokens (verbatim subset of tokens.css, dark) ---------- */
:root{
color-scheme:dark;
--gray-0:#ffffff; --gray-100:#ededed; --gray-300:#afafaf;
--gray-750:#282828; --gray-800:#212121; --gray-900:#181818; --gray-1000:#0d0d0d;
--ds-bg-primary:var(--gray-900);
--ds-bg-secondary:var(--gray-800);
--ds-bg-inset:var(--gray-1000);
--ds-tile:color-mix(in oklab,var(--gray-0) 3.5%,transparent);
--ds-tile-hover:color-mix(in oklab,var(--gray-0) 6%,transparent);
--ds-tile-deep:color-mix(in oklab,var(--gray-0) 8%,transparent);
--ds-raised:var(--gray-750);
--ds-raised-shadow:0 1px 2px rgba(0,0,0,.35);
--ds-text-primary:var(--gray-0);
--ds-text-secondary:color-mix(in oklab,var(--gray-0) 70%,transparent);
--ds-text-muted:color-mix(in oklab,var(--gray-0) 52%,transparent);
--ds-text-faint:color-mix(in oklab,var(--gray-0) 38%,transparent);
--ds-border-default:color-mix(in oklab,var(--gray-0) 8%,transparent);
--ds-border-subtle:color-mix(in oklab,var(--gray-0) 5%,transparent);
--ds-border-strong:color-mix(in oklab,var(--gray-0) 14%,transparent);
--ds-bg-chip:color-mix(in oklab,var(--gray-0) 6%,transparent);
--ds-bg-hover:color-mix(in oklab,var(--gray-0) 6%,transparent);
--ds-field-inset-bg:color-mix(in oklab,var(--ds-text-primary) 5%,transparent);
--ds-accent:var(--gray-0);
--ds-accent-hover:var(--gray-100);
--ds-focus:var(--ds-accent);
--ds-switch-track-off:color-mix(in oklab,var(--gray-0) 10%,transparent);
--ds-switch-ring-off:color-mix(in oklab,var(--gray-0) 22%,transparent);
--ds-switch-knob-off:var(--gray-300);
--ds-switch-knob-on:var(--gray-900);
--ds-success:#40c977; --ds-warning:#ff8549; --ds-error:#ff6764;
--ds-info:var(--gray-300);
--ds-scrim:color-mix(in oklab,#000 45%,transparent);
--ds-control-size:28px;
--radius-3xs:4px; --radius-2xs:6px; --radius-xs:8px; --radius-sm:10px;
--radius-md:12px; --radius-full:9999px;
--font-sans:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;
--font-mono:ui-monospace,"SFMono-Regular","SF Mono",Menlo,Consolas,"Liberation Mono",monospace;
--text-3xs:10.5px; --text-2xs:11px; --text-xs:11.5px; --text-sm:12px;
--text-md:13px; --text-base:14px; --text-lg:16px; --text-xl:20px; --text-2xl:28px;
--font-weight-medium:500; --font-weight-semibold:600;
--leading-body:1.45; --leading-relaxed:1.5;
--tracking-tight:-0.02em; --tracking-wide:0.02em;
--motion-duration-fast:150ms; --motion-duration-normal:200ms;
--motion-duration-slow:300ms;
--motion-ease-out:cubic-bezier(.22,1,.36,1);
}
/* ---------- base ---------- */
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{font-family:var(--font-sans);font-size:var(--text-base);line-height:var(--leading-body);
color:var(--ds-text-primary);background:var(--ds-bg-primary);
-webkit-font-smoothing:antialiased}
button{font:inherit;color:inherit;background:none;border:none;cursor:pointer;padding:0}
input{font:inherit;color:inherit}
ul{list-style:none}
code,.mono{font-family:var(--font-mono)}
::selection{background:color-mix(in oklab,var(--gray-0) 24%,transparent)}
:focus-visible{outline:1.5px solid var(--ds-focus);outline-offset:2px;border-radius:var(--radius-3xs)}
*::-webkit-scrollbar{width:10px;height:10px}
*::-webkit-scrollbar-thumb{background:color-mix(in oklab,var(--gray-0) 12%,transparent);
border-radius:var(--radius-full);border:3px solid transparent;background-clip:content-box}
*::-webkit-scrollbar-thumb:hover{background:color-mix(in oklab,var(--gray-0) 22%,transparent);
border:3px solid transparent;background-clip:content-box}
.icon{width:16px;height:16px;flex:none;stroke:currentColor;fill:none;
stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round}
.icon.sm{width:14px;height:14px}
/* ---------- shared parts ---------- */
.btn{display:inline-flex;align-items:center;gap:6px;height:28px;padding:0 10px;
border-radius:var(--radius-2xs);font-size:var(--text-sm);font-weight:var(--font-weight-medium);
white-space:nowrap;color:var(--ds-text-primary);
transition:background var(--motion-duration-fast) var(--motion-ease-out)}
.btn:hover{background:var(--ds-bg-hover)}
.btn.primary{background:var(--ds-accent);color:var(--gray-900)}
.btn.primary:hover{background:var(--ds-accent-hover)}
.btn.secondary{background:var(--ds-tile)}
.btn.secondary:hover{background:var(--ds-tile-hover)}
.btn.danger{color:var(--ds-error)}
.icon-btn{width:var(--ds-control-size);height:var(--ds-control-size);display:grid;
place-items:center;border-radius:var(--radius-2xs);color:var(--ds-text-muted)}
.icon-btn:hover{background:var(--ds-bg-hover);color:var(--ds-text-primary)}
.tag{font-size:var(--text-3xs);font-weight:var(--font-weight-medium);padding:1.5px 6px;
border-radius:var(--radius-3xs);background:var(--ds-bg-chip);color:var(--ds-text-muted);white-space:nowrap}
.tag.warn{color:var(--ds-warning)}
.dot{width:7px;height:7px;border-radius:50%;flex:none}
.dot.ok{background:var(--ds-success)} .dot.warn{background:var(--ds-warning)}
.dot.err{background:var(--ds-error)} .dot.off{background:var(--ds-text-faint)}
.shield{color:var(--ds-text-faint);display:inline-flex}
.switch{position:relative;width:34px;height:20px;border-radius:var(--radius-full);flex:none;
background:var(--ds-switch-track-off);box-shadow:inset 0 0 0 1px var(--ds-switch-ring-off);
transition:background var(--motion-duration-fast) var(--motion-ease-out)}
.switch .knob{position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;
background:var(--ds-switch-knob-off);
transition:transform var(--motion-duration-fast) var(--motion-ease-out),background var(--motion-duration-fast)}
.switch.on{background:var(--ds-accent);box-shadow:none}
.switch.on .knob{transform:translateX(14px);background:var(--ds-switch-knob-on)}
.seg{display:flex;gap:2px;padding:2px;border-radius:var(--radius-2xs);
background:var(--ds-field-inset-bg)}
.seg button{font-size:var(--text-3xs);color:var(--ds-text-muted);padding:2px 8px;
border-radius:var(--radius-3xs);white-space:nowrap}
.seg button.on{background:var(--ds-raised);color:var(--ds-text-primary);
box-shadow:var(--ds-raised-shadow)}
.search{display:flex;align-items:center;gap:8px;height:30px;padding:0 10px;width:280px;
border-radius:var(--radius-2xs);background:var(--ds-field-inset-bg);color:var(--ds-text-muted)}
.search input{flex:1;background:none;border:none;outline:none;color:var(--ds-text-primary);
font-size:var(--text-sm);min-width:0}
.search input::placeholder{color:color-mix(in oklab,var(--gray-0) 42%,transparent)}
.count-pill{font-size:var(--text-2xs);color:var(--ds-text-muted);
background:var(--ds-bg-chip);border-radius:var(--radius-full);padding:1px 8px;
font-variant-numeric:tabular-nums}
/* ---------- page ---------- */
.page{max-width:1100px;margin:0 auto;padding:0 32px 64px}
.page-head{display:flex;align-items:center;gap:12px;padding:20px 0 6px}
.title-glyph{width:26px;height:26px;border-radius:var(--radius-xs);background:var(--ds-tile);
display:grid;place-items:center;color:var(--ds-text-secondary)}
h1{font-size:var(--text-xl);font-weight:var(--font-weight-semibold);letter-spacing:var(--tracking-tight)}
.head-actions{margin-left:auto;display:flex;gap:8px;align-items:center}
/* ---------- top-level tabs (underline style) ---------- */
.tabs{display:flex;gap:4px;border-bottom:1px solid var(--ds-border-subtle);margin-bottom:4px}
.tab{position:relative;padding:10px 14px;font-size:var(--text-md);font-weight:var(--font-weight-medium);
color:var(--ds-text-muted);border-radius:var(--radius-2xs) var(--radius-2xs) 0 0;
transition:color var(--motion-duration-fast) var(--motion-ease-out)}
.tab:hover{color:var(--ds-text-secondary)}
.tab.on{color:var(--ds-text-primary)}
.tab .n{margin-left:6px;font-size:var(--text-2xs);color:var(--ds-text-faint);
font-variant-numeric:tabular-nums}
.tab.on .n{color:var(--ds-text-muted)}
.tab::after{content:"";position:absolute;left:10px;right:10px;bottom:-1px;height:2px;
border-radius:1px;background:transparent;
transition:background var(--motion-duration-fast) var(--motion-ease-out)}
.tab.on::after{background:var(--ds-accent)}
[role="tabpanel"]{animation:panelIn var(--motion-duration-normal) var(--motion-ease-out)}
@keyframes panelIn{from{opacity:0;transform:translateY(3px)}to{opacity:1;transform:none}}
/* ---------- toolbars ---------- */
.toolbar{display:flex;align-items:center;gap:10px;margin:14px 0 14px}
.toolbar .sp{flex:1}
/* ---------- installed: groups + rows ---------- */
.grp{display:flex;align-items:center;gap:8px;margin:18px 2px 8px}
.grp:first-child{margin-top:6px}
.grp .t{font-size:var(--text-md);font-weight:var(--font-weight-semibold);
letter-spacing:var(--tracking-tight)}
.grp .n{font-size:var(--text-2xs);color:var(--ds-text-faint);
font-variant-numeric:tabular-nums}
.rows{border:1px solid var(--ds-border-subtle);border-radius:var(--radius-md);
background:var(--ds-bg-secondary);overflow:hidden}
.irow{display:flex;align-items:center;gap:10px;padding:9px 12px}
.irow+.irow{border-top:1px solid var(--ds-border-subtle)}
.irow:hover{background:var(--ds-tile)}
.mglyph{width:30px;height:30px;border-radius:var(--radius-xs);background:var(--ds-tile-deep);
display:grid;place-items:center;font-family:var(--font-mono);font-size:var(--text-sm);
color:var(--ds-text-secondary);flex:none}
.irow.err .mglyph{background:color-mix(in oklab,var(--ds-error) 14%,transparent);color:var(--ds-error)}
.irow .grow{flex:1;min-width:0}
.irow .l1{display:flex;align-items:center;gap:6px;min-width:0}
.irow .name{font-size:var(--text-sm);font-weight:var(--font-weight-medium);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.irow .l2{font-size:var(--text-2xs);color:var(--ds-text-faint);margin-top:2px;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.irow .l2 .err-text{color:var(--ds-error)}
.irow.off .name{color:var(--ds-text-secondary)}
.irow .ctrl{display:flex;gap:8px;align-items:center;flex:none}
/* banner */
.banner{display:flex;align-items:center;gap:10px;padding:10px 14px;margin:14px 0 0;
border:1px solid color-mix(in oklab,var(--ds-warning) 25%,transparent);
border-radius:var(--radius-md);
background:color-mix(in oklab,var(--ds-warning) 7%,transparent)}
.banner .t{font-size:var(--text-sm);font-weight:var(--font-weight-medium)}
.banner .btn{margin-left:auto}
/* ---------- market: category tabs + cards ---------- */
.cattabs{display:flex;gap:2px;flex-wrap:wrap;border-bottom:1px solid var(--ds-border-subtle)}
.cattabs .tab{padding:8px 11px;font-size:var(--text-sm)}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:10px}
.card{padding:13px 14px;border-radius:var(--radius-md);background:var(--ds-tile);text-align:left;
width:100%;display:flex;flex-direction:column;gap:9px;cursor:pointer;
transition:background var(--motion-duration-fast) var(--motion-ease-out)}
.card:hover{background:var(--ds-tile-hover)}
.card .top{display:flex;gap:10px;align-items:flex-start}
.card .top .mglyph{width:34px;height:34px;font-size:var(--text-md)}
.card .name-row{display:flex;align-items:center;gap:6px;min-width:0}
.card .name{font-weight:var(--font-weight-medium);font-size:var(--text-md);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.card .id{font-size:var(--text-2xs);color:var(--ds-text-faint);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.card .desc{font-size:var(--text-sm);color:var(--ds-text-secondary);line-height:var(--leading-relaxed);
display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;
min-height:calc(var(--text-sm) * var(--leading-relaxed) * 2)}
.card .cat-chips{display:flex;gap:4px;flex-wrap:wrap}
.card .cat-chips .chip{font-size:var(--text-3xs);padding:1.5px 7px;border-radius:var(--radius-full);
background:var(--ds-bg-chip);color:var(--ds-text-muted)}
.card .foot{display:flex;align-items:center;gap:8px;margin-top:auto;padding-top:2px}
.card .meta{font-size:var(--text-2xs);color:var(--ds-text-faint)}
.card .act{margin-left:auto;display:flex;gap:8px;align-items:center}
.risk{display:inline-flex;align-items:center;gap:4px;font-size:var(--text-2xs);
color:var(--ds-text-faint)}
.risk i{width:6px;height:6px;border-radius:50%}
/* empty */
.empty{padding:56px 0;text-align:center;color:var(--ds-text-faint);font-size:var(--text-sm)}
/* ---------- detail drawer ---------- */
.veil{position:fixed;inset:0;background:var(--ds-scrim);opacity:0;pointer-events:none;
transition:opacity var(--motion-duration-normal) var(--motion-ease-out);z-index:40}
.veil.on{opacity:1;pointer-events:auto}
.sheet{position:fixed;top:0;right:0;bottom:0;width:420px;max-width:92vw;z-index:50;
background:var(--ds-bg-secondary);border-left:1px solid var(--ds-border-default);
box-shadow:-24px 0 48px rgba(0,0,0,.4);
transform:translateX(102%);transition:transform var(--motion-duration-slow) var(--motion-ease-out);
display:flex;flex-direction:column}
.sheet.on{transform:translateX(0)}
.sheet-head{display:flex;align-items:center;gap:12px;padding:16px 18px;
border-bottom:1px solid var(--ds-border-subtle)}
.sheet-body{flex:1;overflow:auto;padding:18px;display:flex;flex-direction:column;gap:14px}
.sheet-foot{display:flex;gap:8px;padding:14px 18px;border-top:1px solid var(--ds-border-subtle)}
.sheet-foot .push{margin-left:auto}
.insp-name{font-size:var(--text-lg);font-weight:var(--font-weight-semibold);
letter-spacing:var(--tracking-tight);display:flex;align-items:center;gap:6px}
.insp-id{font-size:var(--text-2xs);color:var(--ds-text-faint)}
.insp-desc{font-size:var(--text-sm);color:var(--ds-text-secondary);line-height:var(--leading-relaxed);
text-wrap:pretty}
.kv{display:flex;justify-content:space-between;gap:12px;font-size:var(--text-sm);
padding:7px 0;border-bottom:1px solid var(--ds-border-subtle)}
.kv span{color:var(--ds-text-muted);flex:none}
.kv b{font-weight:var(--font-weight-medium);text-align:right;min-width:0;
overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.insp-h{font-size:var(--text-2xs);letter-spacing:var(--tracking-wide);
color:var(--ds-text-muted);font-weight:var(--font-weight-medium);
display:flex;justify-content:space-between;align-items:center;margin-top:4px}
.insp-h .sum{color:var(--ds-text-faint);letter-spacing:0}
.chips{display:flex;flex-wrap:wrap;gap:6px}
.chip{font-size:var(--text-2xs);padding:3px 9px;border-radius:var(--radius-full);
background:var(--ds-bg-chip);color:var(--ds-text-secondary)}
.perm{display:flex;align-items:center;gap:8px;font-size:var(--text-xs);padding:6px 9px;
border-radius:var(--radius-2xs);background:var(--ds-tile)}
.perm code{font-size:var(--text-2xs)}
.perm .lbl{color:var(--ds-text-faint);margin-left:auto;font-size:var(--text-3xs);flex:none}
.corner{position:fixed;right:14px;bottom:12px;font-size:var(--text-3xs);
letter-spacing:var(--tracking-wide);color:var(--ds-text-faint);pointer-events:none;z-index:60}
</style>
</head>
<body>
<svg width="0" height="0" style="position:absolute" aria-hidden="true"><defs>
<symbol id="i-plug" viewBox="0 0 24 24"><path d="M9 7V3M15 7V3M7 7h10v4a5 5 0 0 1-10 0V7zM12 16v5"/></symbol>
<symbol id="i-search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5"/></symbol>
<symbol id="i-more" viewBox="0 0 24 24"><g fill="currentColor" stroke="none"><circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/></g></symbol>
<symbol id="i-panel" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M9.5 4v16"/></symbol>
<symbol id="i-gear" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3.2"/><path d="M12 2.5v3M12 18.5v3M2.5 12h3M18.5 12h3M5 5l2.1 2.1M16.9 16.9L19 19M19 5l-2.1 2.1M7.1 16.9L5 19"/></symbol>
<symbol id="i-trash" viewBox="0 0 24 24"><path d="M4 7h16M9.5 7V5h5v2M6.5 7l1 13h9l1-13M10 11v6M14 11v6"/></symbol>
<symbol id="i-shield" viewBox="0 0 24 24"><path d="M12 3l7 3v5c0 4.5-3 7.6-7 9-4-1.4-7-4.5-7-9V6z"/><path d="M9 11.6l2 2 4-4"/></symbol>
<symbol id="i-alert" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7.5V13M12 16.5h.01"/></symbol>
<symbol id="i-download" viewBox="0 0 24 24"><path d="M12 4v11M7.5 11.5 12 16l4.5-4.5M5 20h14"/></symbol>
<symbol id="i-x" viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18"/></symbol>
<symbol id="i-code" viewBox="0 0 24 24"><path d="M8 8l-4 4 4 4M16 8l4 4-4 4"/></symbol>
</defs></svg>
<div class="page">
<header class="page-head">
<span class="title-glyph"><svg class="icon sm"><use href="#i-plug"/></svg></span>
<h1>扩展</h1>
<span class="count-pill" id="totalPill"></span>
<div class="head-actions">
<button class="btn"><svg class="icon sm"><use href="#i-code"/></svg>从模板新建</button>
<button class="btn"><svg class="icon sm"><use href="#i-code"/></svg>加载开发版</button>
<button class="icon-btn" aria-label="更多操作"><svg class="icon"><use href="#i-more"/></svg></button>
</div>
</header>
<!-- top-level tabs -->
<div class="tabs" role="tablist" aria-label="扩展来源">
<button class="tab on" role="tab" id="tab-installed" aria-selected="true"
aria-controls="panel-installed">已安装<span class="n" id="cntInstalled"></span></button>
<button class="tab" role="tab" id="tab-market" aria-selected="false"
aria-controls="panel-market">市场<span class="n" id="cntMarket"></span></button>
</div>
<!-- ============ installed panel: no category tabs, tags only ============ -->
<section id="panel-installed" role="tabpanel" aria-labelledby="tab-installed">
<div class="banner" id="updBanner">
<svg class="icon sm" style="color:var(--ds-warning)"><use href="#i-alert"/></svg>
<span class="t" id="updText"></span>
<button class="btn primary" id="btnUpdateAll">全部更新</button>
</div>
<div class="toolbar">
<span class="count-pill" id="instHint"></span>
<span class="sp"></span>
<label class="search"><svg class="icon sm"><use href="#i-search"/></svg>
<input id="qInst" placeholder="搜索已安装扩展" aria-label="搜索已安装扩展">
</label>
</div>
<div id="instGroups"></div>
</section>
<!-- ============ market panel: category tabs ============ -->
<section id="panel-market" role="tabpanel" aria-labelledby="tab-market" hidden>
<div class="cattabs" role="tablist" aria-label="市场分类" id="catTabs" style="margin-top:14px"></div>
<div class="toolbar">
<span class="count-pill" id="mktHint"></span>
<span class="sp"></span>
<label class="search"><svg class="icon sm"><use href="#i-search"/></svg>
<input id="qMkt" placeholder="搜索市场" aria-label="搜索市场">
</label>
</div>
<div class="grid" id="mktGrid"></div>
</section>
</div>
<!-- detail drawer -->
<div class="veil" id="veil"></div>
<aside class="sheet" id="sheet" role="dialog" aria-modal="true" aria-label="扩展详情">
<div class="sheet-head">
<span class="mglyph" id="shGlyph" style="width:40px;height:40px;font-size:var(--text-lg)"></span>
<div style="min-width:0;flex:1">
<div class="insp-name" id="shName"></div>
<div class="insp-id mono" id="shId"></div>
</div>
<button class="icon-btn" id="shClose" aria-label="关闭"><svg class="icon"><use href="#i-x"/></svg></button>
</div>
<div class="sheet-body" id="shBody"></div>
<div class="sheet-foot" id="shFoot"></div>
</aside>
<span class="corner">方向 5 · 双 Tab(已安装 · 标签 / 市场 · 分类 tabs)</span>
<script>
/* ---- product taxonomy: slugs + zh-CN labels from i18n ---- */
const CATS = [
["productivity","效率"],["devtools","开发工具"],["data","数据"],
["workflow","工作流"],["coding","编程"],["writing","写作"],
["docs","文档"],["web","网络"],["community","社区"],
];
const CAT_LABEL = Object.fromEntries(CATS);
const PERM_LABEL = {
"agent.tool.register":"注册 Agent 工具","session.read":"读取会话","usage.read":"用量统计",
"notify":"通知","ui.theme":"主题","ui.panel":"面板","fs.read.workspace":"读工作区文件",
"fs.write.workspace":"写工作区文件","net.fetch":"网络请求","net.anyHost":"任意主机访问",
"clipboard.read":"读取剪贴板","browser.cdp":"浏览器 CDP","shell.openExternal":"打开外部链接",
"mcp.server.remote":"远程 MCP 服务","audio.capture.background":"后台录音",
"keyboard.globalShortcut":"全局快捷键","bus.publish":"总线发布",
};
const CAP_LABEL = {panel:"面板",views:"视图",commands:"命令",tools:"Agent 工具",
agentExtension:"Agent 扩展",skills:"技能",themes:"主题",mcp:"MCP",services:"后台服务",bus:"总线"};
const RISK_COLOR = {high:"err",medium:"warn",low:"off"};
/* installed entries carry runtime state; market-only carry catalog state */
const PLUGINS = [
/* ---- installed (7) ---- */
{id:"pi.file-manager",name:"file-manager",v:"1.0.4",size:"1.3 MB",src:"bundled",
verified:true,enabled:true,scope:"global",auto:true,cats:["productivity","community"],
desc:"工作区文件浏览器:在侧栏查看、检索与打开项目文件(内置插件,真实 manifest 分类)。",
caps:["panel","commands"],services:[],
perms:[["fs.read.workspace","medium"],["fs.write.workspace","high"],["shell.openExternal","medium"]]},
{id:"pi.session-orchestrator",name:"session-orchestrator",v:"1.4.2",size:"3.2 MB",src:"market",
verified:true,enabled:true,scope:"global",auto:true,cats:["workflow","productivity"],
desc:"把复杂任务拆分为可恢复的子会话:编排并行子代理、汇总进度,并在会话切换后继续未完成的工作。",
caps:["panel","tools","agentExtension","services"],services:[["session.orchestrator","HTTP"]],
perms:[["agent.tool.register","high"],["session.read","high"],["usage.read","medium"],["notify","low"]]},
{id:"pi.agent-browser",name:"agent-browser",v:"0.9.8",update:"0.9.9",size:"6.4 MB",src:"market",
verified:true,enabled:true,scope:"global",auto:true,cats:["devtools","web"],
desc:"为 Agent 提供受控浏览器自动化:快照定位、表单填写、截图与 CDP 桥接。",
caps:["tools","mcp"],services:[],
perms:[["browser.cdp","high"],["net.anyHost","high"],["clipboard.read","medium"]]},
{id:"pi.theme-studio",name:"theme-studio",v:"2.1.0",size:"1.8 MB",src:"market",
verified:true,enabled:true,scope:"global",auto:true,cats:["productivity"],
desc:"用真实图片与可调 token 搭建桌面主题:背景、玻璃质感与透明层实时预览。",
caps:["panel","themes"],services:[],
perms:[["fs.read.workspace","medium"],["ui.theme","low"],["ui.panel","low"]]},
{id:"pi.plugin-publish",name:"pi-plugin-publish",v:"0.3.1",size:"0.9 MB",src:"dev",
enabled:true,scope:"project",auto:false,cats:["devtools","community"],
desc:"把本地插件打包并发布到插件中心:校验 manifest、i18n 完整性与版本号。",
caps:["tools","commands"],services:[],
perms:[["net.fetch","high"],["fs.read.workspace","medium"]]},
{id:"pi.repo-insights",name:"repo-insights",v:"0.2.7",size:"1.6 MB",src:"dev",
enabled:true,status:"error",scope:"project",auto:false,cats:["devtools","data"],
error:"入口加载失败:Cannot find module './analyzer'",
desc:"从 git 历史生成交接热点与模块耦合报告,辅助重构决策。",
caps:["commands"],services:[],
perms:[["fs.read.workspace","medium"],["bus.publish","medium"]]},
{id:"pi.voice-memo",name:"voice-memo",v:"0.4.0",size:"1.4 MB",src:"market",
enabled:false,scope:"global",auto:false,cats:["productivity"],
desc:"后台拾音的语音便签:全局快捷键录音,转写结果写回当前会话。",
caps:["commands"],services:[],
perms:[["audio.capture.background","high"],["keyboard.globalShortcut","medium"]]},
/* ---- market only (10) ---- */
{id:"pi.cloudbase",name:"cloudbase",v:"1.1.0",size:"4.1 MB",downloads:"9.1k",
verified:true,cats:["devtools","coding"],
desc:"CloudBase 全栈开发工具链:网站与小程序脚手架、云函数、数据库与静态托管部署。",
caps:["tools","skills"],services:[],
perms:[["net.fetch","high"],["fs.write.workspace","high"],["shell.openExternal","medium"]]},
{id:"pi.report-center",name:"report-center",v:"1.2.0",size:"3.6 MB",downloads:"5.2k",
verified:true,cats:["data","productivity"],
desc:"接入报表中心:按游戏与时间范围拉取报表配置与数据,供会话内直接分析。",
caps:["panel","tools"],services:[["report.data","HTTP"]],
perms:[["net.fetch","high"],["session.read","high"],["usage.read","medium"]]},
{id:"pi.imagegen",name:"imagegen",v:"1.2.0",size:"2.2 MB",downloads:"11.4k",
verified:true,cats:["productivity","writing"],
desc:"调用已配置的图像模型生成与编辑位图素材,支持参考图与批量变体。",
caps:["tools","commands"],services:[],
perms:[["net.fetch","high"],["fs.write.workspace","high"]]},
{id:"pi.skill-creator",name:"skill-creator",v:"1.1.5",size:"1.1 MB",downloads:"6.8k",
verified:true,cats:["workflow","coding"],
desc:"创建、评估与优化 Agent 技能:脚手架、触发语料评测与描述回归测试。",
caps:["skills","tools"],services:[],
perms:[["fs.write.workspace","high"],["fs.read.workspace","medium"]]},
{id:"pi.commit-lint",name:"commit-lint",v:"1.0.1",size:"0.7 MB",downloads:"2.9k",
verified:true,cats:["coding","devtools"],
desc:"按仓库约定校验提交信息,越界时给出改写建议,可注册为 Agent 工具。",
caps:["tools","commands"],services:[],
perms:[["fs.read.workspace","medium"],["agent.tool.register","high"]]},
{id:"pi.pdf-export",name:"pdf-export",v:"0.6.0",size:"1.2 MB",downloads:"3.8k",
verified:false,cats:["docs","productivity"],
desc:"把会话产出导出为排版良好的 PDF:封面、目录与代码高亮可选。",
caps:["commands"],services:[],
perms:[["fs.write.workspace","high"]]},
{id:"pi.sql-console",name:"sql-console",v:"0.5.2",size:"2.4 MB",downloads:"4.6k",
verified:true,cats:["data","devtools"],
desc:"只读 SQL 控制台:连接本地数据库、浏览表结构并把查询结果带回会话。",
caps:["tools"],services:[],
perms:[["net.anyHost","high"],["clipboard.read","medium"]]},
{id:"pi.docs-writer",name:"docs-writer",v:"0.3.0",size:"0.8 MB",downloads:"1.9k",
verified:false,cats:["docs","writing"],
desc:"按仓库文档规范起草与更新 README、变更日志与 ADR 草稿。",
caps:["skills"],services:[],
perms:[["fs.write.workspace","high"],["fs.read.workspace","medium"]]},
{id:"pi.web-clipper",name:"web-clipper",v:"0.2.1",size:"1.0 MB",downloads:"2.2k",
verified:false,cats:["web","productivity"],
desc:"把网页正文剪藏进会话:自动去噪、保留标题与代码块。",
caps:["tools"],services:[],
perms:[["net.anyHost","high"],["clipboard.write","medium"]]},
{id:"pi.cloud-sample",name:"cloud-sample-adversarial",v:"1.0.0",size:"2.7 MB",downloads:"0.8k",
verified:true,cats:["data"],
desc:"云样本对抗样本库查询:按模式检索样本详情、文件地址与对抗元数据。",
caps:["mcp"],services:[["cloud-sample.mcp","stdio"]],
perms:[["mcp.server.remote","high"],["net.anyHost","high"]]},
];
const SRC_LABEL = {market:"市场",dev:"本地",bundled:"内置"};
const installed = p => "enabled" in p;
const marketOnly = p => !installed(p);
const mono = n => (n.trim()[0]||"?").toUpperCase();
const stateOf = p => p.status==="error"?"attention":p.update?"updates":p.enabled?"active":"disabled";
let view="installed", cat="all", qInst="", qMkt="", selected=null;
/* ---------- tabs ---------- */
function setView(v){
view=v;
document.getElementById("tab-installed").classList.toggle("on",v==="installed");
document.getElementById("tab-installed").setAttribute("aria-selected",String(v==="installed"));
document.getElementById("tab-market").classList.toggle("on",v==="market");
document.getElementById("tab-market").setAttribute("aria-selected",String(v==="market"));
document.getElementById("panel-installed").hidden=v!=="installed";
document.getElementById("panel-market").hidden=v!=="market";
render();
}
document.getElementById("tab-installed").addEventListener("click",()=>setView("installed"));
document.getElementById("tab-market").addEventListener("click",()=>setView("market"));
/* ---------- counts ---------- */
function renderCounts(){
const nInst=PLUGINS.filter(installed).length;
const nMkt=PLUGINS.filter(marketOnly).length;
document.getElementById("cntInstalled").textContent=nInst;
document.getElementById("cntMarket").textContent=nMkt;
document.getElementById("totalPill").textContent=`${nInst} 已安装 · ${nMkt} 可安装`;
const upd=PLUGINS.filter(p=>installed(p)&&p.update).length;
document.getElementById("updBanner").style.display=upd?"":"none";
document.getElementById("updText").textContent=`${upd} 个扩展有待更新的版本`;
document.getElementById("instHint").textContent=`共 ${nInst} 个`;
}
/* ---------- installed groups ---------- */
const GROUPS=[
["attention","需注意","err"],
["updates","可更新","warn"],
["active","已启用","ok"],
["disabled","已停用","off"],
];
function matchQuery(p,q){return !q||(p.name+p.id+p.desc).toLowerCase().includes(q.toLowerCase());}
function renderInstalled(){
const list=PLUGINS.filter(installed).filter(p=>matchQuery(p,qInst));
const el=document.getElementById("instGroups");
let html="";
for(const [key,label,dot] of GROUPS){
const rows=list.filter(p=>stateOf(p)===key);
if(!rows.length)continue;
html+=`<div class="grp"><span class="dot ${dot}"></span><span class="t">${label}</span><span class="n">${rows.length}</span></div>
<div class="rows">${rows.map(rowHTML).join("")}</div>`;
}
el.innerHTML=html||'<div class="empty">没有匹配的已安装扩展</div>';
}
function rowHTML(p){
const isErr=p.status==="error";
const dot=isErr?"err":p.update?"warn":p.enabled?"ok":"off";
return `<div class="irow ${isErr?"err":""} ${!p.enabled?"off":""}" data-id="${p.id}">
<span class="mglyph">${isErr?'<svg class="icon sm"><use href="#i-alert"/></svg>':mono(p.name)}</span>
<div class="grow">
<div class="l1">
<span class="name">${p.name}</span>
${p.verified?'<span class="shield" title="插件中心已验证"><svg class="icon sm"><use href="#i-shield"/></svg></span>':""}
<span class="tag">${SRC_LABEL[p.src]}</span>
${p.cats.map(c=>`<span class="tag">${CAT_LABEL[c]||c}</span>`).join("")}
${p.update?`<span class="tag warn">v${p.update} 可更新</span>`:""}
</div>
<div class="l2 mono">${p.id} · v${p.v}${isErr?` · <span class="err-text">${p.error}</span>`:""}</div>
</div>
<div class="ctrl">
${p.update?`<button class="btn secondary" data-upd="${p.id}">更新</button>`:""}
<span class="dot ${dot}" title="${isErr?"加载失败":p.enabled?"运行中":"已停用"}"></span>
<div class="seg" role="group" aria-label="激活范围">
<button class="${p.scope==="global"?"on":""}" data-scope="global" data-k="${p.id}">全局</button>
<button class="${p.scope==="project"?"on":""}" data-scope="project" data-k="${p.id}">本项目</button>
</div>
<button class="switch ${p.enabled?"on":""}" role="switch" aria-checked="${p.enabled}"
aria-label="启用 ${p.name}" data-tg="${p.id}"><span class="knob"></span></button>
</div>
</div>`;
}
/* ---------- market: category tabs ---------- */
function renderCats(){
const list=PLUGINS.filter(marketOnly).filter(p=>matchQuery(p,qMkt));
const entries=[["all","全部",list.length],
...CATS.map(([slug,label])=>[slug,label,list.filter(p=>p.cats.includes(slug)).length])]
.filter(([, , n])=>n>0);
document.getElementById("catTabs").innerHTML=entries.map(([slug,label,n])=>
`<button class="tab ${cat===slug?"on":""}" role="tab" aria-selected="${cat===slug}"
data-cat="${slug}">${label}<span class="n">${n}</span></button>`).join("");
const cur=entries.find(([slug])=>slug===cat);
document.getElementById("mktHint").textContent=cat==="all"
?`全部 · ${list.length} 个可安装`
:`${CAT_LABEL[cat]||cat} · ${(cur?cur[2]:list.filter(p=>p.cats.includes(cat)).length)} 个可安装`;
}
document.getElementById("catTabs").addEventListener("click",e=>{
const b=e.target.closest("[data-cat]"); if(!b)return;
cat=b.dataset.cat; renderCats(); renderMarket();
});
function renderMarket(){
let list=PLUGINS.filter(marketOnly).filter(p=>matchQuery(p,qMkt));
if(cat!=="all")list=list.filter(p=>p.cats.includes(cat));
document.getElementById("mktGrid").innerHTML=list.map(cardHTML).join("")
||'<div class="empty" style="grid-column:1/-1">这个分类下没有匹配的扩展</div>';
}
function cardHTML(p){
const hi=p.perms.filter(x=>x[1]==="high").length;
const mi=p.perms.filter(x=>x[1]==="medium").length;
const lo=p.perms.length-hi-mi;
const riskDot=(cls,n,label)=>n?`<span class="risk" title="${label} ${n} 项"><i class="dot ${cls}"></i>${n}</span>`:"";
return `<div class="card" data-id="${p.id}">
<div class="top">
<span class="mglyph">${mono(p.name)}</span>
<div style="min-width:0;flex:1">
<div class="name-row">
<span class="name">${p.name}</span>
${p.verified?'<span class="shield" title="插件中心已验证"><svg class="icon sm"><use href="#i-shield"/></svg></span>':""}
</div>
<div class="id mono">${p.id}</div>
</div>
</div>
<p class="desc">${p.desc}</p>
<div class="cat-chips">${p.cats.map(c=>`<span class="chip">${CAT_LABEL[c]||c}</span>`).join("")}</div>
<div class="foot">
<span class="meta">v${p.v} · ${p.size}${p.downloads?` · ↓${p.downloads}`:""}</span>
<span class="act">
${riskDot("err",hi,"高风险")}${riskDot("warn",mi,"中风险")}${riskDot("off",lo,"低风险")}
<button class="btn primary" data-inst="${p.id}"><svg class="icon sm"><use href="#i-download"/></svg>安装</button>
</span>
</div>
</div>`;
}
/* ---------- interactions ---------- */
document.getElementById("instGroups").addEventListener("click",e=>{
const tg=e.target.closest("[data-tg]");
if(tg){const p=PLUGINS.find(x=>x.id===tg.dataset.tg);p.enabled=!p.enabled;render();return;}
const sg=e.target.closest("[data-scope]");
if(sg){PLUGINS.find(x=>x.id===sg.dataset.k).scope=sg.dataset.scope;renderInstalled();return;}
const up=e.target.closest("[data-upd]");
if(up){const p=PLUGINS.find(x=>x.id===up.dataset.upd);p.v=p.update;delete p.update;render();return;}
const open=e.target.closest(".irow");
if(open&&!e.target.closest("button"))openSheet(open.dataset.id);
});
document.getElementById("btnUpdateAll").addEventListener("click",()=>{
PLUGINS.forEach(p=>{if(installed(p)&&p.update){p.v=p.update;delete p.update;}});
render();
});
document.getElementById("mktGrid").addEventListener("click",e=>{
const inst=e.target.closest("[data-inst]");
if(inst){
const p=PLUGINS.find(x=>x.id===inst.dataset.inst);
p.enabled=true;p.scope="global";p.src="market";delete p.downloads;
render();return;
}
const card=e.target.closest(".card");
if(card)openSheet(card.dataset.id);
});
document.getElementById("qInst").addEventListener("input",e=>{qInst=e.target.value.trim();renderInstalled();});
document.getElementById("qMkt").addEventListener("input",e=>{qMkt=e.target.value.trim();renderCats();renderMarket();});
function render(){renderCounts();renderInstalled();renderCats();renderMarket();}
/* ---------- detail drawer ---------- */
const $veil=document.getElementById("veil"),$sheet=document.getElementById("sheet");
function openSheet(id){
selected=id;
const p=PLUGINS.find(x=>x.id===id);
document.getElementById("shGlyph").textContent=mono(p.name);
document.getElementById("shName").innerHTML=
`${p.name}${p.verified?'<span class="shield" title="插件中心已验证"><svg class="icon sm"><use href="#i-shield"/></svg></span>':""}`;
document.getElementById("shId").textContent=p.id;
const hi=p.perms.filter(x=>x[1]==="high").length;
const mi=p.perms.filter(x=>x[1]==="medium").length;
document.getElementById("shBody").innerHTML=`
<p class="insp-desc">${p.desc}</p>
<div>
<div class="kv"><span>来源</span><b>${installed(p)?SRC_LABEL[p.src]:"插件市场"}${p.verified?" · 已验证":""}</b></div>
<div class="kv"><span>版本</span><b>v${p.v}${p.update?` → 可更新 v${p.update}`:""}</b></div>
<div class="kv"><span>分类</span><b>${p.cats.map(c=>CAT_LABEL[c]||c).join(" · ")}</b></div>
${installed(p)?`<div class="kv"><span>激活范围</span><b>${p.scope==="global"?"全局":"仅当前项目"}</b></div>`:""}
${installed(p)?`<div class="kv"><span>自动更新</span><b>${p.auto?"开启":"关闭"}</b></div>`:""}
${marketOnly(p)&&p.downloads?`<div class="kv"><span>下载量</span><b>${p.downloads} · ${p.size}</b></div>`:""}
</div>
<h3 class="insp-h">能力</h3>
<div class="chips">${p.caps.map(c=>`<span class="chip">${CAP_LABEL[c]||c}</span>`).join("")}</div>
<h3 class="insp-h">权限<span class="sum">${hi?`高 ${hi} · `:""}${mi?`中 ${mi} · `:""}低 ${p.perms.length-hi-mi}</span></h3>
<ul style="display:flex;flex-direction:column;gap:6px">
${p.perms.map(([k,r])=>`<li class="perm"><span class="dot ${RISK_COLOR[r]}"></span><code>${k}</code>
<span class="lbl">${PERM_LABEL[k]||k}</span></li>`).join("")}
</ul>`;
document.getElementById("shFoot").innerHTML=installed(p)
?`<button class="btn secondary"><svg class="icon sm"><use href="#i-panel"/></svg>打开面板</button>
<button class="btn secondary"><svg class="icon sm"><use href="#i-gear"/></svg>设置</button>
<button class="btn danger push"><svg class="icon sm"><use href="#i-trash"/></svg>卸载</button>`
:`<button class="btn primary" style="flex:1;justify-content:center" data-inst2="${id}">
<svg class="icon sm"><use href="#i-download"/></svg>安装</button>`;
$veil.classList.add("on");$sheet.classList.add("on");
}
document.getElementById("shFoot").addEventListener("click",e=>{
const b=e.target.closest("[data-inst2]");
if(b){const p=PLUGINS.find(x=>x.id===b.dataset.inst2);
p.enabled=true;p.scope="global";p.src="market";delete p.downloads;
render();openSheet(b.dataset.inst2);}
});
function closeSheet(){$veil.classList.remove("on");$sheet.classList.remove("on");}
document.getElementById("shClose").addEventListener("click",closeSheet);
$veil.addEventListener("click",closeSheet);
document.addEventListener("keydown",e=>{if(e.key==="Escape")closeSheet();});
render();
</script>
</body>
</html>
背景
当前扩展页(
apps/desktop/src/pages/PluginsPage.tsx+features/plugins/*)的结构是:MarketPluginSummary.categories)为发布者自由 slug,客户端动态收集后原样渲染,无中文映射、无排序契约。本 issue 附一个可交互的静态设计 demo(方向五),并给出改进建议与分类体系建议,供讨论。demo 的设计 token 逐字取自
tokens.css暗色主题,与现版视觉语言一致;插件名/权限名与风险分层取自真实的PERMISSION_RISK矩阵,数据为示意 mock。Demo 结构(方向五 · 双 Tab)
两个 Tab 各有独立搜索(已安装搜已装、市场搜目录);安装流转贯通:市场点安装 → 卡片消失 → 已安装计数 +1 → 出现在「已启用」组。
完整源码(与 gist 内容逐字节一致):
demo5-tabs.html — 点击展开,另存为 .html 双击打开
已验证的交互(浏览器实测)
改进建议
对现版扩展页
MarketplacePanel.tsx目前{value}原样渲染 slug;建议走t("plugins.category.<slug>")映射(参考 MCP/技能市场已有文案),未知 slug 回退原值。PermissionChips的折叠信息一眼化,与安装确认弹窗形成两级确认。demo5 的后续迭代方向(如方向被认可)
downloads/updatedAt字段;iconUrl支持现版类型已有,demo 用 monogram 占位,落地时接入真实图标 + 失败回退 monogram;分类体系建议
现状问题
三套分类体系并存、互不兼容:
pi.file-manager→["productivity","community"])MarketPluginSummary.categories,客户端动态收集devtools/web/docs/data/productivityMcpCatalogCategory固定五类workflow/writing/coding/data/docsSkillCatalogCategory固定五类客户端对未知 slug 无兜底策略;
PluginSummary(已安装侧)目前没有 categories 字段,已安装页想展示分类 tag 时无数据来源。建议
统一 taxonomy(合并三套为一份):
productivitydevtoolsdataworkflowcodingwritingdocswebcommunity文案直接复用
packages/i18n两个市场的既有翻译,零新增翻译成本;九类对当前插件体量足够,每个市场只会用到其中一部分(MCP 用 5 类、技能用 5 类、插件中心全量可用)。GET /v2/categories作为唯一事实来源:返回{ slug, i18n: { en, zh-CN, … }, order };客户端不再从条目动态收集、不再原样渲染。目录可审查、可排序、可国际化。未登记 slug 的兜底:catalog parse 对未知 slug 产生 warning(对齐
mcp-catalog.ts的现有做法),客户端归入「未分类」分组而不是丢弃条目。给已安装侧补数据通道:
PluginSummary增加categories?: string[](host 从 manifest 透出,或安装时由 catalog 记录快照),已安装页的 tag 才有数据;demo 中pi.file-manager的分类即来自其真实 manifest。单插件分类数上限 2–3 个:避免「什么都沾」导致分类计数失真;manifest schema 层面限制,catalog parse 报警。
分类排序契约:demo 按固定 order(效率 → 开发工具 → …)展示;若希望「热门分类靠前」,由
/v2/categories的order下发,客户端不自行按计数排序(避免列表随安装量抖动)。备注
docs/spec/04-ux与插件 spec 走正式设计与实现流程。features/plugins/model.ts的真实矩阵。