feat: 插件图标规范化——manifest/胶囊 icon 统一 lucide 图标名(白名单 101 个),未知名回落拼图图标;内联 svg 仅旧兼容

This commit is contained in:
Rick Ye
2026-09-11 11:11:40 +08:00
parent 7103675db8
commit 6098cf678a
9 changed files with 263 additions and 16 deletions
+4 -3
View File
@@ -12,7 +12,8 @@ import { SettingsModal } from './components/SettingsModal'
import { Search } from './components/Search'
import { BrowserPanel } from './components/browser/BrowserPanel'
import { SidePanel, type PanelTabIcon } from './components/SidePanel'
import { PluginPageFrame, PluginIcon } from './plugins/PluginPageFrame'
import { PluginPageFrame } from './plugins/PluginPageFrame'
import { PluginIconValue } from './plugins/plugin-icons'
import {
useExternalPluginComponents,
usePluginRendererLoader,
@@ -316,9 +317,9 @@ export default function App(): JSX.Element {
const pluginTabIcons = useMemo(() => {
const map = new Map<string, PanelTabIcon>()
for (const p of panelPlugins) {
const svg = p.manifest.icon
const icon = p.manifest.icon
const Icon = ({ size, className }: { size?: number; className?: string }): JSX.Element => (
<PluginIcon svg={svg} size={size ?? 16} className={className} />
<PluginIconValue icon={icon} size={size ?? 16} className={className} />
)
map.set(p.manifest.id, Icon)
}
@@ -31,7 +31,7 @@ import type {
import type { PluginSettingsSchema, PluginSettingsSchemas, PluginSettingValues, PluginSettingsValuesMap } from '@aso/plugin-sdk'
import { THEME_MODES, THEME_PRESETS, applyThemeColor } from '../lib/theme'
import { usePluginList } from '../plugins/store'
import { PluginIcon } from '../plugins/PluginPageFrame'
import { PluginIconValue } from '../plugins/plugin-icons'
type PluginSettingsTab = `plugin:${string}`
type Tab = 'general' | 'model' | 'prompt' | 'plugins' | 'skills' | 'commands' | PluginSettingsTab
@@ -818,12 +818,16 @@ function PluginsPanel({
// 插件设置导航图标(svg 字符串 → 组件;按 id+svg 缓存保证身份稳定,避免导航项重挂载)
const pluginSettingsIconCache = new Map<string, IconType>()
function pluginSettingsIcon(id: string, svg: string | undefined): IconType {
const key = `${id}:${svg ?? ''}`
function pluginSettingsIcon(id: string, icon: string | undefined): IconType {
const key = `${id}:${icon ?? ''}`
const cached = pluginSettingsIconCache.get(key)
if (cached) return cached
const Icon: IconType = (props) => (
<PluginIcon svg={svg} size={typeof props.size === 'number' ? props.size : 16} className={props.className} />
<PluginIconValue
icon={icon}
size={typeof props.size === 'number' ? props.size : 16}
className={props.className}
/>
)
pluginSettingsIconCache.set(key, Icon)
return Icon
@@ -15,7 +15,7 @@ import {
import { LuPanelLeft } from 'react-icons/lu'
import { Button } from '@/components/atoms/Button'
import { GlideMenu } from '@/components/GlideMenu'
import { PluginIcon } from '@/plugins/PluginPageFrame'
import { PluginIconValue } from '@/plugins/plugin-icons'
import type { PluginInfo, SessionMeta, SessionState, WorkspaceMeta } from '@shared/types'
/** 可折叠工作区导航(beautifului.dev Sidebar Nav)。
@@ -445,7 +445,7 @@ export function SidebarNav({
className={`${navRowClass} ${active ? 'bg-hover-2 text-ink' : ''}`}
>
<span className="flex size-5 shrink-0 items-center justify-center">
<PluginIcon svg={plugin.manifest.icon} size={18} />
<PluginIconValue icon={plugin.manifest.icon} size={18} />
</span>
<span className="sidebar-copy ml-1.5 min-w-0 flex-1 truncate text-[13px] font-medium text-ink-3">
{plugin.manifest.name}
@@ -9,7 +9,7 @@
import { useEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import type { PluginStatusEntry } from '@shared/types'
import { PluginIcon } from './PluginPageFrame'
import { PluginIconValue } from './plugin-icons'
const TONE_CLASS: Record<string, string> = {
default: 'border-line text-ink-2',
@@ -39,7 +39,7 @@ function StatusPopover({ entry, onOpenView }: { entry: PluginStatusEntry; onOpen
const clickable = Boolean(row.openView)
const content = (
<>
{row.icon && <PluginIcon svg={row.icon} size={14} className="shrink-0 text-ink-2" />}
{row.icon && <PluginIconValue icon={row.icon} size={14} className="shrink-0 text-ink-2" />}
<span className="min-w-0 flex-1">
<span className="block truncate text-[12px] leading-4 text-ink">{row.label}</span>
{row.detail && <span className="mt-0.5 block truncate text-[11px] leading-4 text-ink-3">{row.detail}</span>}
@@ -130,7 +130,7 @@ export function PluginStatusCapsules({
TONE_CLASS[tone] ?? TONE_CLASS.default
} ${clickable ? 'hover:opacity-80 active:scale-[0.97]' : 'cursor-default'} ${open ? 'opacity-80' : ''}`}
>
{item.icon && <PluginIcon svg={item.icon} size={12} className="shrink-0" />}
{item.icon && <PluginIconValue icon={item.icon} size={12} className="shrink-0" />}
<span className="truncate">{item.text}</span>
</button>
{open && item.detail && <StatusPopover entry={{ pluginId, itemId, item }} onOpenView={onOpenView} />}
@@ -0,0 +1,242 @@
/**
* 插件图标规范:manifest.icon / 状态胶囊 icon 统一填 **lucide 图标名**
* (PascalCase 如 "Globe",kebab-case "layout-grid" 亦可),宿主经 react-icons/lu
* 白名单渲染——线条风格、粗细、圆角与宿主完全一致(DESIGN.md 图标规范)。
* 旧版内联 `<svg>` 字符串仍兼容(sanitizeSvg 防线继续生效);
* 名单之外或缺失的名字一律回落拼图图标,杜绝来历不明的自绘图形。
*/
import type { ReactNode } from 'react'
import type { IconType } from 'react-icons'
import {
LuActivity,
LuAppWindow,
LuArchive,
LuAtom,
LuAward,
LuBadgeCheck,
LuBan,
LuBell,
LuBlocks,
LuBookOpen,
LuBot,
LuBoxes,
LuBriefcase,
LuCamera,
LuCalendar,
LuChartPie,
LuChartBar,
LuCheck,
LuCircleCheck,
LuCircle,
LuCircleDot,
LuClock,
LuCloud,
LuCode,
LuCompass,
LuCpu,
LuDatabase,
LuHardDrive,
LuDownload,
LuEye,
LuEyeOff,
LuFile,
LuFileText,
LuFilter,
LuFlag,
LuFolder,
LuFolderOpen,
LuGitBranch,
LuGitCommitHorizontal,
LuGithub,
LuGitPullRequest,
LuGlobe,
LuHammer,
LuHash,
LuHeadphones,
LuHouse,
LuImage,
LuInbox,
LuKeyboard,
LuLayers,
LuLayoutGrid,
LuLink,
LuListChecks,
LuListTodo,
LuLock,
LuMail,
LuMap,
LuMessageCircle,
LuMic,
LuMonitor,
LuMoon,
LuMusic,
LuPackage,
LuPalette,
LuPaperclip,
LuPenLine,
LuPhone,
LuPlay,
LuPlug,
LuPlugZap,
LuPuzzle,
LuRocket,
LuSave,
LuScan,
LuSearch,
LuServer,
LuSettings,
LuSettings2,
LuShare2,
LuShield,
LuShoppingCart,
LuSlidersHorizontal,
LuSparkles,
LuSquareStack,
LuStar,
LuSun,
LuTable,
LuTag,
LuTarget,
LuTerminal,
LuThumbsUp,
LuTimer,
LuTrash2,
LuTrendingUp,
LuUpload,
LuUser,
LuUsers,
LuVideo,
LuWallet,
LuWebhook,
LuWrench,
LuZap
} from 'react-icons/lu'
import { PluginIcon } from './PluginPageFrame'
/** 宿主核准的 lucide 图标白名单(manifest.icon / 胶囊 icon 可填的名字) */
export const PLUGIN_LUCIDE_ICONS = {
Activity: LuActivity,
AppWindow: LuAppWindow,
Archive: LuArchive,
Atom: LuAtom,
Award: LuAward,
BadgeCheck: LuBadgeCheck,
Ban: LuBan,
Bell: LuBell,
Blocks: LuBlocks,
BookOpen: LuBookOpen,
Bot: LuBot,
Boxes: LuBoxes,
Briefcase: LuBriefcase,
Camera: LuCamera,
Calendar: LuCalendar,
ChartPie: LuChartPie,
ChartBar: LuChartBar,
Check: LuCheck,
CircleCheck: LuCircleCheck,
Circle: LuCircle,
CircleDot: LuCircleDot,
Clock: LuClock,
Cloud: LuCloud,
Code: LuCode,
Compass: LuCompass,
Cpu: LuCpu,
Database: LuDatabase,
HardDrive: LuHardDrive,
Download: LuDownload,
Eye: LuEye,
EyeOff: LuEyeOff,
File: LuFile,
FileText: LuFileText,
Filter: LuFilter,
Flag: LuFlag,
Folder: LuFolder,
FolderOpen: LuFolderOpen,
GitBranch: LuGitBranch,
GitCommit: LuGitCommitHorizontal,
Github: LuGithub,
GitPullRequest: LuGitPullRequest,
Globe: LuGlobe,
Hammer: LuHammer,
Hash: LuHash,
Headphones: LuHeadphones,
House: LuHouse,
Image: LuImage,
Inbox: LuInbox,
Keyboard: LuKeyboard,
Layers: LuLayers,
LayoutGrid: LuLayoutGrid,
Link: LuLink,
ListChecks: LuListChecks,
ListTodo: LuListTodo,
Lock: LuLock,
Mail: LuMail,
Map: LuMap,
MessageCircle: LuMessageCircle,
Mic: LuMic,
Monitor: LuMonitor,
Moon: LuMoon,
Music: LuMusic,
Package: LuPackage,
Palette: LuPalette,
Paperclip: LuPaperclip,
PenLine: LuPenLine,
Phone: LuPhone,
Play: LuPlay,
Plug: LuPlug,
PlugZap: LuPlugZap,
Puzzle: LuPuzzle,
Rocket: LuRocket,
Save: LuSave,
Scan: LuScan,
Search: LuSearch,
Server: LuServer,
Settings: LuSettings,
Settings2: LuSettings2,
Share2: LuShare2,
Shield: LuShield,
ShoppingCart: LuShoppingCart,
SlidersHorizontal: LuSlidersHorizontal,
Sparkles: LuSparkles,
SquareStack: LuSquareStack,
Star: LuStar,
Sun: LuSun,
Table: LuTable,
Tag: LuTag,
Target: LuTarget,
Terminal: LuTerminal,
ThumbsUp: LuThumbsUp,
Timer: LuTimer,
Trash2: LuTrash2,
TrendingUp: LuTrendingUp,
Upload: LuUpload,
User: LuUser,
Users: LuUsers,
Video: LuVideo,
Wallet: LuWallet,
Webhook: LuWebhook,
Wrench: LuWrench,
Zap: LuZap
} satisfies Record<string, IconType>
/** kebab/snake/全小写 → PascalCase;已是 Pascal 的名字原样通过 */
function toPascal(name: string): string {
return name
.trim()
.replace(/[-_\s]+(.)/g, (_, c: string) => c.toUpperCase())
.replace(/^./, (c) => c.toUpperCase())
}
/** 按名字查白名单图标;未命中返回 null */
export function lucideIconByName(name: string): IconType | null {
const key = toPascal(name)
return (PLUGIN_LUCIDE_ICONS as Record<string, IconType>)[key] ?? null
}
/** 图标槽位统一渲染:lucide 名(规范用法)/ 内联 svg(旧兼容)/ 缺省与未知名回落拼图图标 */
export function PluginIconValue({ icon, size = 16, className }: { icon?: string; size?: number; className?: string }): ReactNode {
const v = icon?.trim()
if (v && v.startsWith('<svg')) return <PluginIcon svg={v} size={size} className={className} />
const Icon = (v ? lucideIconByName(v) : null) ?? LuPuzzle
return <Icon size={size} className={className} />
}
+1 -1
View File
@@ -27,7 +27,7 @@
"version": "1.2.0", // semver(x.y.z)
"minContract": "0.1.0",
"description": "…",
"icon": "<svg…/>", // 侧栏/面板图标(svg 字符串;宿主清除事件属性与 script)
"icon": "Globe", // 侧栏/面板/胶囊图标:lucide 图标名(白名单见 DESIGN.md;kebab 亦可);兼容旧 svg 字符串;未知名回落拼图图标
"order": 10, // 侧栏排序(只在插件间;核心入口恒在前)
"panel": true, // 在右侧 SidePanel 出现一个 tab
"entry": { "page": "index.html", "main": "main.mjs", "renderer": "renderer.mjs" }, // 内置插件省略 entry
+1 -1
View File
@@ -64,7 +64,7 @@
- 字号阶:`10px`(徽标)/ `11px`(辅助按钮)/ `11.5px`(次要按钮、说明)/ `12px`(正文说明) / `12.5px`(行标题/正文)/ `16px`(页面标题)。**不使用其他字号**;行高 `leading-4/5`。
- 间距基数为 4px:卡片内边距 `px-3 py-2.5`,区块间 `space-y-1.5`,小节间 `pt-3`。
- 图标 12–16px,线条风格,`currentColor`。
- 图标一律 **lucide**(宿主白名单 `plugin-icons.tsx`,约 100 个常用名):`manifest.icon` / 状态胶囊 `icon` 填 lucide 图标名(如 `"Globe"`,kebab-case 亦可),12–16px、`currentColor` 由宿主保证;**禁止自绘 svg**(旧内联 svg 仅对存量插件兼容),未知名回落拼图图标
## 4. 标准模式(复制即用)
+1 -1
View File
@@ -15,7 +15,7 @@ export interface PluginManifest {
/** 最低 harness 契约版本(semver),不满足拒载 */
minContract: string
description?: string
/** 侧栏入口图标:svg 字符串;缺省用宿主默认字形 */
/** 侧栏入口图标:lucide 图标名(如 "Globe",白名单见 docs/plugins/DESIGN.md);兼容旧内联 svg 字符串;缺省/未知名回落拼图图标 */
icon?: string
/** 侧栏入口排序(升序,只在插件之间排序;核心入口恒在前) */
order?: number
+1 -1
View File
@@ -6,7 +6,7 @@
"description": "TODO:一句话描述插件做什么",
"order": 50,
"panel": true,
"icon": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\"/></svg>",
"icon": "Puzzle",
"entry": {
"main": "main.mjs",
"renderer": "renderer.mjs"