feat: 状态胶囊升级——顶栏紧凑状态条 + 点击展开宿主渲染详情弹层(StatusItem.icon/detail:分组/行/徽标/行内深链)
This commit is contained in:
@@ -1,9 +1,15 @@
|
||||
/**
|
||||
* 插件状态胶囊(TitleBar B2 槽位):数据驱动渲染主进程广播的状态条目。
|
||||
* 点击条目 = 执行其深链(openView)。聊天视图与插件视图下均可见。
|
||||
* 顶栏胶囊只是紧凑状态条;点击行为分三档:
|
||||
* 1. 声明了 detail → 展开宿主渲染的详情弹层(分组列表,行内数据再负责深链)
|
||||
* 2. 声明了 openView → 执行深链
|
||||
* 3. 都没有 → 纯展示
|
||||
* 聊天视图与插件视图下均可见。弹层点击外部/Esc 关闭。
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { PluginStatusEntry } from '@shared/types'
|
||||
import { PluginIcon } from './PluginPageFrame'
|
||||
|
||||
const TONE_CLASS: Record<string, string> = {
|
||||
default: 'border-line text-ink-2',
|
||||
@@ -12,6 +18,63 @@ const TONE_CLASS: Record<string, string> = {
|
||||
danger: 'border-red/40 text-red bg-red-tint'
|
||||
}
|
||||
|
||||
const BADGE_TONE_CLASS: Record<string, string> = {
|
||||
default: 'bg-hover text-ink-3',
|
||||
accent: 'bg-accent-tint text-accent-ink',
|
||||
warn: 'bg-orange-tint text-orange',
|
||||
danger: 'bg-red-tint text-red'
|
||||
}
|
||||
|
||||
/** 详情弹层:分组列表,行数据自带动线 */
|
||||
function StatusPopover({ entry, onOpenView }: { entry: PluginStatusEntry; onOpenView: (view: string, params?: unknown) => void }): ReactNode {
|
||||
const { item } = entry
|
||||
return (
|
||||
<div className="absolute right-0 top-full z-50 mt-1.5 w-72 overflow-hidden rounded-card border border-line bg-surface shadow-overlay">
|
||||
<div className="px-2.5 pb-1 pt-2.5 text-[11.5px] font-medium text-ink">{item.detail?.title ?? item.text}</div>
|
||||
<div className="max-h-80 overflow-y-auto overscroll-contain px-1.5 pb-1.5">
|
||||
{item.detail?.sections.map((section, si) => (
|
||||
<div key={si} className={si > 0 ? 'mt-1.5' : ''}>
|
||||
{section.title && <div className="px-2 pb-1 pt-1.5 text-[10px] text-ink-3">{section.title}</div>}
|
||||
{section.rows.map((row, ri) => {
|
||||
const clickable = Boolean(row.openView)
|
||||
const content = (
|
||||
<>
|
||||
{row.icon && <PluginIcon svg={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>}
|
||||
</span>
|
||||
{row.trailing && (
|
||||
<span className={`shrink-0 rounded-control px-1.5 py-0.5 text-[10px] ${BADGE_TONE_CLASS[row.tone ?? 'default']}`}>
|
||||
{row.trailing}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
return clickable ? (
|
||||
<button
|
||||
key={ri}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (row.openView) onOpenView(row.openView.view, row.openView.params)
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-control px-2 py-1.5 text-left transition-colors hover:bg-hover"
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
) : (
|
||||
<div key={ri} className="flex w-full items-center gap-2 rounded-control px-2 py-1.5">
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PluginStatusCapsules({
|
||||
entries,
|
||||
onOpenView
|
||||
@@ -19,26 +82,59 @@ export function PluginStatusCapsules({
|
||||
entries: PluginStatusEntry[]
|
||||
onOpenView: (view: string, params?: unknown) => void
|
||||
}): ReactNode {
|
||||
const [openKey, setOpenKey] = useState<string | null>(null)
|
||||
const rootRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
// 弹层开着时:点外部 / Esc 关闭;条目消失(插件停用/重载)也收起
|
||||
useEffect(() => {
|
||||
if (!openKey) return
|
||||
const onDown = (e: MouseEvent): void => {
|
||||
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpenKey(null)
|
||||
}
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') setOpenKey(null)
|
||||
}
|
||||
document.addEventListener('mousedown', onDown)
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown)
|
||||
document.removeEventListener('keydown', onKey)
|
||||
}
|
||||
}, [openKey])
|
||||
|
||||
// 条目集合变化(停用/重载/删除)时收起弹层;按 key 序列比较,避免引用变化误关
|
||||
const entryKeys = entries.map((e) => `${e.pluginId}:${e.itemId}`).join(',')
|
||||
useEffect(() => {
|
||||
setOpenKey(null)
|
||||
}, [entryKeys])
|
||||
|
||||
if (entries.length === 0) return null
|
||||
return (
|
||||
<div className="ml-3 flex min-w-0 items-center gap-1.5 [-webkit-app-region:no-drag]">
|
||||
<div ref={rootRef} className="ml-3 flex min-w-0 items-center gap-1.5 [-webkit-app-region:no-drag]">
|
||||
{entries.slice(0, 4).map(({ pluginId, itemId, item }) => {
|
||||
const tone = item.tone ?? 'default'
|
||||
const clickable = Boolean(item.openView)
|
||||
const key = `${pluginId}:${itemId}`
|
||||
const expandable = Boolean(item.detail)
|
||||
const clickable = expandable || Boolean(item.openView)
|
||||
const open = openKey === key
|
||||
return (
|
||||
<button
|
||||
key={`${pluginId}:${itemId}`}
|
||||
type="button"
|
||||
title={item.text}
|
||||
onClick={() => {
|
||||
if (item.openView) onOpenView(item.openView.view, item.openView.params)
|
||||
}}
|
||||
className={`max-w-44 shrink-0 truncate rounded-full border px-2.5 py-0.5 text-[11px] leading-[18px] transition-transform ${
|
||||
TONE_CLASS[tone] ?? TONE_CLASS.default
|
||||
} ${clickable ? 'hover:opacity-80 active:scale-[0.97]' : 'cursor-default'}`}
|
||||
>
|
||||
{item.text}
|
||||
</button>
|
||||
<div key={key} className="relative shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
title={item.text}
|
||||
onClick={() => {
|
||||
if (item.detail) setOpenKey(open ? null : key)
|
||||
else if (item.openView) onOpenView(item.openView.view, item.openView.params)
|
||||
}}
|
||||
className={`flex max-w-44 shrink-0 items-center gap-1 rounded-full border px-2.5 py-0.5 text-[11px] leading-[18px] transition-transform ${
|
||||
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" />}
|
||||
<span className="truncate">{item.text}</span>
|
||||
</button>
|
||||
{open && item.detail && <StatusPopover entry={{ pluginId, itemId, item }} onOpenView={onOpenView} />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{entries.length > 4 && (
|
||||
|
||||
+1
-1
@@ -170,7 +170,7 @@ api.ui.onSettingsAction((key) => {}) // button 条目点击回调(key = 按钮
|
||||
| 侧栏入口(B1) | manifest icon/name/order;点进插件整页视图 | ✅ |
|
||||
| 插件整页 Main | 内置组件级 / 外置 renderer 组件 / 外置 iframe;聊天列 display:none 保活 | ✅ |
|
||||
| 面板 tab(B6) | `panel: true`;SidePanel 零改动承载(内置组件 / 外置 renderer 组件 / iframe) | ✅ |
|
||||
| 状态胶囊(B2) | `setStatusItem` 数据驱动,TitleBar 渲染,两种视图均可见 | ✅ |
|
||||
| 状态胶囊(B2) | `setStatusItem` 数据驱动,TitleBar 渲染,两种视图均可见;顶栏为紧凑状态条,点击可展开宿主渲染的详情弹层(`detail`:分组/行/徽标/行内深链) | ✅ |
|
||||
| 深链 | `openView(view, params)`(main 侧 IPC 广播 / 页侧桥直通) | ✅ |
|
||||
| 工具栏按钮(B5) | `registerToolbarButton` 数据已通,Composer 工具行渲染 | ⏳ 下版本 |
|
||||
| 聊天流富组件(B4) | ```类型``` 围栏块 → 宿主元组件(data-table/stat-grid/step-list/kv-card) | ⏳ 下版本 |
|
||||
|
||||
@@ -4,17 +4,47 @@
|
||||
|
||||
## 1. 状态胶囊(TitleBar 顶部)
|
||||
|
||||
轻量状态提示,点击可深链跳转:
|
||||
顶栏胶囊是**紧凑状态条**,点击行为三档:
|
||||
|
||||
```js
|
||||
// ① 纯展示
|
||||
api.ui.setStatusItem('conn', { text: '已连接 · 3 任务' })
|
||||
|
||||
// ② 点击深链
|
||||
api.ui.setStatusItem('conn', {
|
||||
text: '已连接 · 3 任务',
|
||||
tone: 'accent', // default | accent | warn | danger
|
||||
openView: { view: 'my-plugin' } // 点击跳到插件整页视图(可省略)
|
||||
icon: '<svg .../>', // 可选,12px 线条风
|
||||
openView: { view: 'my-plugin' } // 点击跳到插件整页视图
|
||||
})
|
||||
api.ui.removeStatusItem('conn')
|
||||
|
||||
// ③ 点击展开详情弹层(声明了 detail 时优先于 openView)
|
||||
api.ui.setStatusItem('tasks', {
|
||||
text: '任务 2/5',
|
||||
tone: 'accent',
|
||||
detail: {
|
||||
title: '任务概览', // 缺省用胶囊文本
|
||||
sections: [
|
||||
{
|
||||
title: '进行中', // 分组标题可省略
|
||||
rows: [
|
||||
{ label: '构建索引', detail: '已完成 60%', trailing: '2/5', tone: 'accent' },
|
||||
{ label: '清理临时文件' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
rows: [{ label: '打开任务面板', openView: { view: 'my-plugin' } }] // 行点击深链
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
api.ui.removeStatusItem('tasks')
|
||||
```
|
||||
|
||||
弹层由宿主统一渲染(分组标题 / 行 / 右侧徽标 / 深链),样式与宿主一致——插件只提交
|
||||
数据;点击弹层外部或 Esc 关闭;dispose 时宿主自动清理本插件全部条目。
|
||||
|
||||
## 2. 工具栏按钮(输入区工具行)
|
||||
|
||||
```js
|
||||
|
||||
@@ -10,12 +10,50 @@ export interface OpenViewAction {
|
||||
params?: unknown
|
||||
}
|
||||
|
||||
/** 徽标/胶囊共用色调语义 */
|
||||
export type StatusTone = 'default' | 'accent' | 'warn' | 'danger'
|
||||
|
||||
/** 详情弹层里的一行(数据驱动;openView 缺省为纯展示行) */
|
||||
export interface StatusDetailRow {
|
||||
/** svg 字符串(14px 线条风格,currentColor) */
|
||||
icon?: string
|
||||
label: string
|
||||
/** 次级说明(label 下方小字) */
|
||||
detail?: string
|
||||
/** 右侧徽标文本(如 +76 -40、7/7、✓) */
|
||||
trailing?: string
|
||||
/** 徽标色调,缺省 default */
|
||||
tone?: StatusTone
|
||||
/** 整行点击的深链;缺省不可点击 */
|
||||
openView?: OpenViewAction
|
||||
}
|
||||
|
||||
/** 详情弹层分组 */
|
||||
export interface StatusDetailSection {
|
||||
/** 分组标题(如「Git 工具」「计划」);缺省无标题 */
|
||||
title?: string
|
||||
rows: StatusDetailRow[]
|
||||
}
|
||||
|
||||
/** 状态胶囊的展开详情:点击胶囊弹出宿主渲染的分组列表弹层。
|
||||
* 声明了 detail 的胶囊,点击 = 展开弹层(行内 openView 负责深链);
|
||||
* 未声明的胶囊保持原行为:点击 = item.openView。 */
|
||||
export interface StatusDetail {
|
||||
/** 弹层标题;缺省用胶囊文本 */
|
||||
title?: string
|
||||
sections: StatusDetailSection[]
|
||||
}
|
||||
|
||||
/** 状态胶囊条目(TitleBar 胶囊区 / 聊天区浮动胶囊共用一份数据) */
|
||||
export interface StatusItem {
|
||||
text: string
|
||||
tone?: 'default' | 'accent' | 'warn' | 'danger'
|
||||
/** 点击行为:深链;缺省不可点击 */
|
||||
/** svg 字符串(12-14px 线条风格,currentColor;缺省纯文字胶囊) */
|
||||
icon?: string
|
||||
tone?: StatusTone
|
||||
/** 点击行为:深链;有 detail 时被弹层取代;两者都缺省不可点击 */
|
||||
openView?: OpenViewAction
|
||||
/** 点击展开的详情弹层(数据驱动,宿主统一渲染) */
|
||||
detail?: StatusDetail
|
||||
}
|
||||
|
||||
/** 输入区工具行按钮 */
|
||||
|
||||
@@ -34,6 +34,22 @@ export default definePlugin((api) => {
|
||||
}
|
||||
})
|
||||
|
||||
// ---- 状态胶囊示范:顶栏紧凑状态条,点击展开详情弹层(数据驱动,宿主统一渲染) ----
|
||||
api.ui.setStatusItem('summary', {
|
||||
text: '示例 1/2',
|
||||
tone: 'accent',
|
||||
detail: {
|
||||
sections: [
|
||||
{
|
||||
rows: [
|
||||
{ label: '示例任务', detail: '把这段换成真正的状态数据', trailing: '进行中', tone: 'accent' },
|
||||
{ label: '打开面板', openView: { view: api.manifest.id } }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
// ---- 面板/视图组件在 renderer.mjs(JSX 构建见 README);无 UI 需求可整段去掉 ----
|
||||
log.info('ready', { hasRenderer: true })
|
||||
|
||||
|
||||
Reference in New Issue
Block a user