Skip to content

[Design RFC] 扩展页重设计:双 Tab(已安装·标签 / 市场·分类 tabs)+ 分类体系提案(附可交互 demo) #1224

Description

@Tioit-Wang

背景

当前扩展页(apps/desktop/src/pages/PluginsPage.tsx + features/plugins/*)的结构是:

  • 顶层「已安装 / 市场」segment 二分,各自带搜索框;
  • 已安装按状态四组堆叠(需注意 / 可更新 / 已启用 / 已停用);
  • 市场是一排 filter chips + 卡片网格,分类(MarketPluginSummary.categories)为发布者自由 slug,客户端动态收集后原样渲染,无中文映射、无排序契约。

本 issue 附一个可交互的静态设计 demo(方向五),并给出改进建议与分类体系建议,供讨论。demo 的设计 token 逐字取自 tokens.css 暗色主题,与现版视觉语言一致;插件名/权限名与风险分层取自真实的 PERMISSION_RISK 矩阵,数据为示意 mock。

Demo 结构(方向五 · 双 Tab)

扩展
├─ Tab「已安装」(N)                ← 无分类 tabs,分类只作 tag
│   ├─ 更新横幅(出现于有更新时,支持一键全部更新)
│   ├─ 状态分组:需注意 → 可更新 → 已启用 → 已停用(空组自动隐藏)
│   └─ 行内 tag:来源(市场/本地/内置)+ 分类标签(效率、开发工具…)
└─ Tab「市场」(M)                  ← 分类 tabs
    ├─ 分类 tabs:全部 | 效率 | 开发工具 | 数据 | 工作流 | 编程 | 写作 | 文档 | 网络
    │  (计数随搜索联动;当前搜索下 0 条的分类自动隐藏)
    └─ 卡片网格:monogram + 已验证盾标 + 分类 chips + 权险点阵(高/中/低权限数量)+ 安装按钮

两个 Tab 各有独立搜索(已安装搜已装、市场搜目录);安装流转贯通:市场点安装 → 卡片消失 → 已安装计数 +1 → 出现在「已启用」组。

完整源码(与 gist 内容逐字节一致):

demo5-tabs.html — 点击展开,另存为 .html 双击打开
<!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>

已验证的交互(浏览器实测)

流程 结果
顶层 Tab 切换 panel 互斥隐藏,aria-selected 同步 ✓
已安装页 4 个状态分组 + 行内 tag(来源/分类),确认无任何分类导航 ✓
市场分类 tabs 开发工具 → 3 张卡;community 0 条自动隐藏 ✓
安装流转 卡片消失 → 已安装计数 7→8 → 出现在「已启用」组 ✓
全部更新 横幅隐藏、「可更新」分组消失 ✓
分类内搜索 sql → 1 张卡,hint 计数联动 ✓
控制台 0 错误 ✓

改进建议

对现版扩展页

  1. 已安装页不做分类导航。已装插件通常 7–30 个,为它们建分类树/侧栏是过度设计;分类以行内 tag 呈现即可,检索交给搜索框。状态分组保留现有四组顺序(attention/updates 优先的既有设计是对的)。
  2. 市场页把分类升级为 tabs。现在的 filter chips 与顶层 segment 视觉同权重,用户分不清两级结构;tabs(下划线式)能明确「一级来源 / 二级分类」的层级,且带计数。
  3. 分类标签中文化。MarketplacePanel.tsx 目前 {value} 原样渲染 slug;建议走 t("plugins.category.<slug>") 映射(参考 MCP/技能市场已有文案),未知 slug 回退原值。
  4. 市场卡片增加权险预览。安装前就能看到高/中/低权限数量(点阵),把 PermissionChips 的折叠信息一眼化,与安装确认弹窗形成两级确认。
  5. 更新横幅 + 一键全部更新。现版更新散在各行,批量操作缺失。
  6. 空分类自动隐藏。目录较小时,0 条的分类 chip 只会稀释注意力;等目录规模上来再考虑常驻全量分类。

demo5 的后续迭代方向(如方向被认可)

  • 已安装行的分类 tag 可点击 → 跳到市场对应分类(tag 即入口);
  • 卡片排序(下载量 / 最近更新),目录已有 downloads / updatedAt 字段;
  • Tab 键盘导航(←/→ 切换、Home/End),补全 ARIA tabs pattern;
  • iconUrl 支持现版类型已有,demo 用 monogram 占位,落地时接入真实图标 + 失败回退 monogram;
  • Light 主题适配(token 已成对存在,补一套 light 值即可)。

分类体系建议

现状问题

三套分类体系并存、互不兼容:

市场 slug 集合 来源
插件中心 发布者自由 slug(如 pi.file-manager → ["productivity","community"]) MarketPluginSummary.categories,客户端动态收集
MCP 市场 devtools / web / docs / data / productivity McpCatalogCategory 固定五类
技能市场 workflow / writing / coding / data / docs SkillCatalogCategory 固定五类

客户端对未知 slug 无兜底策略;PluginSummary(已安装侧)目前没有 categories 字段,已安装页想展示分类 tag 时无数据来源。

建议

  1. 统一 taxonomy(合并三套为一份):

    slug zh-CN en
    productivity 效率 Productivity
    devtools 开发工具 Dev Tools
    data 数据 Data
    workflow 工作流 Workflow
    coding 编程 Coding
    writing 写作 Writing
    docs 文档 Docs
    web 网络 Web
    community 社区 Community

    文案直接复用 packages/i18n 两个市场的既有翻译,零新增翻译成本;九类对当前插件体量足够,每个市场只会用到其中一部分(MCP 用 5 类、技能用 5 类、插件中心全量可用)。

  2. GET /v2/categories 作为唯一事实来源:返回 { slug, i18n: { en, zh-CN, … }, order };客户端不再从条目动态收集、不再原样渲染。目录可审查、可排序、可国际化。

  3. 未登记 slug 的兜底:catalog parse 对未知 slug 产生 warning(对齐 mcp-catalog.ts 的现有做法),客户端归入「未分类」分组而不是丢弃条目。

  4. 给已安装侧补数据通道:PluginSummary 增加 categories?: string[](host 从 manifest 透出,或安装时由 catalog 记录快照),已安装页的 tag 才有数据;demo 中 pi.file-manager 的分类即来自其真实 manifest。

  5. 单插件分类数上限 2–3 个:避免「什么都沾」导致分类计数失真;manifest schema 层面限制,catalog parse 报警。

  6. 分类排序契约:demo 按固定 order(效率 → 开发工具 → …)展示;若希望「热门分类靠前」,由 /v2/categories 的 order 下发,客户端不自行按计数排序(避免列表随安装量抖动)。

备注

  • 本 issue 仅提案 + demo,不含实现;若方向认可,下一步按 docs/spec/04-ux 与插件 spec 走正式设计与实现流程。
  • demo 中出现的插件均为示意 mock,权限与风险分层取自 features/plugins/model.ts 的真实矩阵。

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: pluginsPlugin platform, MCP, and plugin APIsarea: ui-uxUser interface and user experience

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions