fix(plugins): 悬浮胶囊三点修正——位置/面板头/分组手风琴
This commit is contained in:
@@ -424,8 +424,11 @@ export default function App(): JSX.Element {
|
||||
title={activeView.view === 'chat' ? sessionTitle : activePlugin?.manifest.name}
|
||||
panelOpen={sideOpen}
|
||||
onTogglePanel={toggleSide}
|
||||
extra={<PluginFloatingCapsule entries={pluginsUi.status} plugins={plugins} onOpenView={handleOpenView} />}
|
||||
/>
|
||||
{/* 悬浮状态胶囊:main 区右上角(侧面板打开时左移让位) */}
|
||||
<div className="absolute top-12 z-40" style={{ right: sideOpen ? sideWidth + 12 : 12 }}>
|
||||
<PluginFloatingCapsule entries={pluginsUi.status} plugins={plugins} onOpenView={handleOpenView} />
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{/* 聊天列:切到插件视图时 display:none 保活(草稿/流式/滚动全保留) */}
|
||||
<div
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
import { useEffect, useState, type JSX, type ReactNode } from 'react'
|
||||
import { useEffect, useState, type JSX } from 'react'
|
||||
import { LuPanelRight } from 'react-icons/lu'
|
||||
|
||||
/**
|
||||
* 自绘标题栏(配合主进程 titleBarStyle:'hidden')。
|
||||
* 整条是拖拽区;左上角展示当前会话/插件名称(无名称时显示「新会话」);
|
||||
* title 之后可挂 extra 槽(插件状态胶囊);右侧自绘窗口控制按钮(最小化/
|
||||
* 最大化还原/关闭),高度 40px。不使用系统 WCO overlay —— 其原生背景会盖住标题栏分隔线。
|
||||
* 右侧自绘窗口控制按钮(最小化/最大化还原/关闭),高度 40px。
|
||||
* 不使用系统 WCO overlay —— 其原生背景会盖住标题栏分隔线。
|
||||
* (插件悬浮状态胶囊不再挂这里:改悬浮于 main 区域右上角。)
|
||||
*/
|
||||
export function TitleBar({
|
||||
title,
|
||||
panelOpen,
|
||||
onTogglePanel,
|
||||
extra
|
||||
onTogglePanel
|
||||
}: {
|
||||
title?: string
|
||||
panelOpen: boolean
|
||||
onTogglePanel: () => void
|
||||
/** 标题右侧插槽(插件状态胶囊等;聊天/插件视图下均可见) */
|
||||
extra?: ReactNode
|
||||
}): JSX.Element {
|
||||
const name = title?.trim()
|
||||
const [maximized, setMaximized] = useState(false)
|
||||
@@ -27,7 +25,6 @@ export function TitleBar({
|
||||
return (
|
||||
<div className="relative z-20 flex h-10 shrink-0 select-none items-center border-b border-line bg-surface pl-4 [-webkit-app-region:drag]">
|
||||
<div className="min-w-0 truncate text-[12.5px] font-medium text-ink">{name || '新会话'}</div>
|
||||
{extra && <div className="ml-3 flex shrink-0 items-center [-webkit-app-region:no-drag]">{extra}</div>}
|
||||
<div className="ml-auto flex h-full shrink-0 items-center gap-1 pr-1.5 [-webkit-app-region:no-drag]">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
/**
|
||||
* 悬浮状态胶囊(B3 槽位,复刻 ZCode「最小化药丸 ⇄ 展开面板」二态):
|
||||
* - 所有插件共用一个胶囊,数据同源 plugins:uiState(api.ui.setStatusItem)
|
||||
* - 所有插件共用一个胶囊,数据同源 plugins:uiState(api.ui.setStatusItem);
|
||||
* 挂在 main 区域右上角悬浮(侧面板打开时自动左移让位,由挂载点控制)
|
||||
* - 宿主为每条状态记录 updatedAt:最小化药丸展示「插件最新的消息」
|
||||
* (tone 点 + 插件图标 + 文本),点击展开面板
|
||||
* - 展开面板 = 每个插件一个专属区域:区域头(图标+名称)可收起/展开
|
||||
* (记忆在 localStorage),区域内是该插件的状态条目与详情行
|
||||
* (行 = 图标 + 标签 + 次级说明 + 行尾彩色数值,行内可深链)
|
||||
* (tone 点 + 插件图标 + 文本 + 总数角标),点击展开面板
|
||||
* - 展开面板:插件区域头 = 插件名(第一个区域的右侧是收回药丸的 ⤡);
|
||||
* 区域内 detail 的**分组是手风琴**——默认收起只显示「标题 + 行尾徽标」,
|
||||
* 点击展开 rows(ZCode 式:计划 ›);无标题分组直接平铺 rows
|
||||
* - 面板高度固定,内容溢出滚动;点击面板外部 / Esc 收回最小化
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { LuChevronDown, LuMinimize2 } from 'react-icons/lu'
|
||||
import { LuChevronDown, LuChevronRight, LuMinimize2 } from 'react-icons/lu'
|
||||
import type { PluginInfo, PluginStatusEntry } from '@shared/types'
|
||||
import type { StatusDetail } from '@aso/plugin-sdk'
|
||||
import type { StatusDetailRow } from '@aso/plugin-sdk'
|
||||
import { PluginIconValue } from './plugin-icons'
|
||||
|
||||
/** 胶囊前导色点:tone 的安静表达(底保持中性,不整块染色) */
|
||||
@@ -45,22 +46,17 @@ function loadCollapsedSections(): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
/** 详情分组渲染(标题层由容器决定:面板里分区头是插件名,这里只渲染 sections/rows) */
|
||||
function StatusDetailBody({
|
||||
detail,
|
||||
/** 明细行(数据驱动:图标 + 标签 + 次级说明 + 行尾彩色数值,行内可深链) */
|
||||
function StatusRows({
|
||||
rows,
|
||||
onOpenView
|
||||
}: {
|
||||
detail: StatusDetail
|
||||
rows: StatusDetailRow[]
|
||||
onOpenView: (view: string, params?: unknown) => void
|
||||
}): ReactNode {
|
||||
return (
|
||||
<>
|
||||
{detail.sections.map((section, si) => (
|
||||
<div key={si} className={si > 0 ? 'mt-0.5' : ''}>
|
||||
{section.title && (
|
||||
<div className="px-2 pb-0.5 pt-1.5 text-[11px] font-medium text-ink-2">{section.title}</div>
|
||||
)}
|
||||
{section.rows.map((row, ri) => {
|
||||
{rows.map((row, ri) => {
|
||||
const clickable = Boolean(row.openView)
|
||||
const content = (
|
||||
<>
|
||||
@@ -93,8 +89,6 @@ function StatusDetailBody({
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -109,12 +103,14 @@ export function PluginFloatingCapsule({
|
||||
onOpenView: (view: string, params?: unknown) => void
|
||||
}): ReactNode {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [collapsedSections, setCollapsedSections] = useState<string[]>(loadCollapsedSections)
|
||||
const [collapsedAreas, setCollapsedAreas] = useState<string[]>(loadCollapsedSections)
|
||||
/** 分组手风琴(展开的 section 键 pluginId:itemId:idx;默认收起,会话内记忆) */
|
||||
const [openSections, setOpenSections] = useState<Set<string>>(() => new Set())
|
||||
const rootRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(SECTION_STORAGE_KEY, JSON.stringify(collapsedSections))
|
||||
}, [collapsedSections])
|
||||
localStorage.setItem(SECTION_STORAGE_KEY, JSON.stringify(collapsedAreas))
|
||||
}, [collapsedAreas])
|
||||
|
||||
// 面板开着时:点击外部 / Esc 收回最小化
|
||||
useEffect(() => {
|
||||
@@ -149,11 +145,19 @@ export function PluginFloatingCapsule({
|
||||
byPlugin.set(entry.pluginId, arr)
|
||||
}
|
||||
|
||||
const toggleSection = (pluginId: string): void => {
|
||||
setCollapsedSections((prev) =>
|
||||
const toggleArea = (pluginId: string): void => {
|
||||
setCollapsedAreas((prev) =>
|
||||
prev.includes(pluginId) ? prev.filter((id) => id !== pluginId) : [...prev, pluginId]
|
||||
)
|
||||
}
|
||||
const toggleSection = (key: string): void => {
|
||||
setOpenSections((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(key)) next.delete(key)
|
||||
else next.add(key)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
@@ -162,7 +166,7 @@ export function PluginFloatingCapsule({
|
||||
type="button"
|
||||
title="插件状态(点击展开)"
|
||||
onClick={() => setOpen(true)}
|
||||
className="flex max-w-48 shrink-0 items-center gap-1.5 rounded-full border border-line bg-surface px-2 py-0.5 text-[11px] leading-[18px] text-ink-2 transition-colors hover:border-line-strong hover:text-ink"
|
||||
className="flex max-w-48 shrink-0 items-center gap-1.5 rounded-full border border-line bg-surface px-2 py-0.5 text-[11px] leading-[18px] text-ink-2 shadow-card transition-colors hover:border-line-strong hover:text-ink"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
@@ -180,13 +184,35 @@ export function PluginFloatingCapsule({
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="relative shrink-0">
|
||||
<div className="absolute right-0 top-full z-50 mt-1.5 flex w-80 flex-col overflow-hidden rounded-card border border-line bg-surface text-left shadow-overlay [-webkit-app-region:no-drag]">
|
||||
{/* 面板头:总量 + 收回最小化 */}
|
||||
<div className="flex shrink-0 items-center justify-between gap-2 border-b border-line py-1.5 pl-3 pr-1">
|
||||
<div className="min-w-0 truncate text-[12.5px] font-medium text-ink">
|
||||
插件状态
|
||||
<span className="ml-1.5 text-[11px] font-normal text-ink-3">{byPlugin.size} 个插件</span>
|
||||
</div>
|
||||
<div className="absolute right-0 top-full z-50 mt-1.5 flex w-80 flex-col overflow-hidden rounded-card border border-line bg-surface text-left shadow-overlay">
|
||||
{/* 固定高度内容区:分组手风琴 + 溢出滚动 */}
|
||||
<div className="h-[min(480px,58vh)] overflow-y-auto overscroll-contain px-1.5 py-1.5">
|
||||
{[...byPlugin.entries()].map(([pluginId, pluginEntries], areaIdx) => {
|
||||
const plugin = plugins.find((p) => p.manifest.id === pluginId)
|
||||
const areaCollapsed = collapsedAreas.includes(pluginId)
|
||||
return (
|
||||
<div key={pluginId} className={areaIdx > 0 ? 'mt-1 border-t border-line pt-1' : ''}>
|
||||
{/* 区域头 = 插件名;第一个区域右侧是收回药丸的 ⤡ */}
|
||||
<div className="flex w-full items-center gap-1 rounded-control pr-0.5">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={!areaCollapsed}
|
||||
onClick={() => toggleArea(pluginId)}
|
||||
className="flex min-w-0 flex-1 items-center gap-1.5 rounded-control px-2 py-1.5 text-left transition-colors hover:bg-hover"
|
||||
>
|
||||
{plugin ? (
|
||||
<PluginIconValue icon={plugin.manifest.icon} size={14} className="shrink-0 text-ink-2" />
|
||||
) : null}
|
||||
<span className="min-w-0 flex-1 truncate text-[12.5px] font-medium text-ink">
|
||||
{plugin?.manifest.name ?? pluginId}
|
||||
</span>
|
||||
<LuChevronDown
|
||||
size={12}
|
||||
aria-hidden
|
||||
className={`shrink-0 text-ink-3 transition-transform duration-150 ${areaCollapsed ? '-rotate-90' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
{areaIdx === 0 && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="收起到悬浮胶囊"
|
||||
@@ -196,40 +222,68 @@ export function PluginFloatingCapsule({
|
||||
>
|
||||
<LuMinimize2 size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* 固定高度内容区:插件太多/内容过长向下滚动 */}
|
||||
<div className="h-[min(480px,58vh)] overflow-y-auto overscroll-contain px-1.5 py-1.5">
|
||||
{[...byPlugin.entries()].map(([pluginId, pluginEntries]) => {
|
||||
const plugin = plugins.find((p) => p.manifest.id === pluginId)
|
||||
const collapsed = collapsedSections.includes(pluginId)
|
||||
return (
|
||||
<div key={pluginId} className="mb-0.5 last:mb-0">
|
||||
{/* 区域头:插件专属,可收起/展开 */}
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={!collapsed}
|
||||
onClick={() => toggleSection(pluginId)}
|
||||
className="flex w-full items-center gap-1.5 rounded-control px-2 py-1.5 text-left transition-colors hover:bg-hover"
|
||||
>
|
||||
{plugin ? (
|
||||
<PluginIconValue icon={plugin.manifest.icon} size={14} className="shrink-0 text-ink-2" />
|
||||
) : null}
|
||||
<span className="min-w-0 flex-1 truncate text-[12px] font-medium text-ink-2">
|
||||
{plugin?.manifest.name ?? pluginId}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10.5px] text-ink-3">{pluginEntries.length}</span>
|
||||
<LuChevronDown
|
||||
size={12}
|
||||
aria-hidden
|
||||
className={`shrink-0 text-ink-3 transition-transform duration-150 ${collapsed ? '-rotate-90' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
{!collapsed && (
|
||||
<div className="pb-1 pl-1">
|
||||
{!areaCollapsed && (
|
||||
<div className="pb-1">
|
||||
{pluginEntries.map((entry) => {
|
||||
const clickable = Boolean(entry.item.openView)
|
||||
const body = (
|
||||
<>
|
||||
return (
|
||||
<div key={entry.itemId}>
|
||||
{entry.item.detail ? (
|
||||
// 有详情:分组手风琴(默认收起,标题行 + 行尾徽标;无标题分组平铺)
|
||||
entry.item.detail.sections.map((section, si) => {
|
||||
if (!section.title) {
|
||||
return <StatusRows key={si} rows={section.rows} onOpenView={onOpenView} />
|
||||
}
|
||||
const key = `${pluginId}:${entry.itemId}:${si}`
|
||||
const expanded = openSections.has(key)
|
||||
return (
|
||||
<div key={key}>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
onClick={() => toggleSection(key)}
|
||||
className="flex w-full items-center gap-2 rounded-control px-2 py-1.5 text-left transition-colors hover:bg-hover"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-[12px] leading-4 text-ink">
|
||||
{section.title}
|
||||
</span>
|
||||
{section.trailing && (
|
||||
<span
|
||||
className={`shrink-0 text-[11.5px] font-medium ${
|
||||
TRAILING_TONE_CLASS[section.trailingTone ?? 'default']
|
||||
}`}
|
||||
>
|
||||
{section.trailing}
|
||||
</span>
|
||||
)}
|
||||
<LuChevronRight
|
||||
size={12}
|
||||
aria-hidden
|
||||
className={`shrink-0 text-ink-3 transition-transform duration-150 ${
|
||||
expanded ? 'rotate-90' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="pb-1 pl-1">
|
||||
<StatusRows rows={section.rows} onOpenView={onOpenView} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
// 纯文本条目行
|
||||
clickable ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (entry.item.openView) onOpenView(entry.item.openView.view, entry.item.openView.params)
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-control px-2 py-1.5 text-left transition-colors hover:bg-hover"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={`size-1.5 shrink-0 rounded-full ${
|
||||
@@ -239,28 +293,26 @@ export function PluginFloatingCapsule({
|
||||
{entry.item.icon ? (
|
||||
<PluginIconValue icon={entry.item.icon} size={13} className="shrink-0 text-ink-2" />
|
||||
) : null}
|
||||
<span className="min-w-0 flex-1 truncate text-[12px] leading-4 text-ink">{entry.item.text}</span>
|
||||
</>
|
||||
)
|
||||
return (
|
||||
<div key={entry.itemId}>
|
||||
{clickable ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (entry.item.openView) onOpenView(entry.item.openView.view, entry.item.openView.params)
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-control px-2 py-1.5 text-left transition-colors hover:bg-hover"
|
||||
>
|
||||
{body}
|
||||
<span className="min-w-0 flex-1 truncate text-[12px] leading-4 text-ink">
|
||||
{entry.item.text}
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex w-full items-center gap-2 rounded-control px-2 py-1.5">{body}</div>
|
||||
)}
|
||||
{entry.item.detail && (
|
||||
<div className="pb-1">
|
||||
<StatusDetailBody detail={entry.item.detail} onOpenView={onOpenView} />
|
||||
<div className="flex w-full items-center gap-2 rounded-control px-2 py-1.5">
|
||||
<span
|
||||
aria-hidden
|
||||
className={`size-1.5 shrink-0 rounded-full ${
|
||||
TONE_DOT_CLASS[entry.item.tone ?? 'default'] ?? TONE_DOT_CLASS.default
|
||||
}`}
|
||||
/>
|
||||
{entry.item.icon ? (
|
||||
<PluginIconValue icon={entry.item.icon} size={13} className="shrink-0 text-ink-2" />
|
||||
) : null}
|
||||
<span className="min-w-0 flex-1 truncate text-[12px] leading-4 text-ink">
|
||||
{entry.item.text}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
+1
-1
@@ -181,7 +181,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+B3) | `setStatusItem` 数据驱动,标题栏**悬浮胶囊**统一承载(ZCode 复刻):最小化药丸展示最新一条消息(宿主记录 updatedAt),点击展开面板——每插件一个专属区域、区域可收起/展开(记忆跨启动)、固定高度溢出滚动、行尾彩色数值与行内深链 | ✅ |
|
||||
| 状态胶囊(B2+B3) | `setStatusItem` 数据驱动,**悬浮胶囊**挂 main 区右上角(ZCode 复刻):最小化药丸展示最新一条消息(宿主记录 updatedAt),展开面板头即插件名、`detail` 分组为手风琴(默认收起显示 标题+trailing 徽标,点击展开行)、固定高度滚动、行尾彩色数值与行内深链 | ✅ |
|
||||
| 深链 | `openView(view, params)`(main 侧 IPC 广播 / 页侧桥直通) | ✅ |
|
||||
| 工具栏按钮(B5) | `registerToolbarButton` 数据已通,Composer 工具行渲染 | ⏳ 下版本 |
|
||||
| 聊天流富组件(B4) | ```类型``` 围栏块 → 宿主元组件(data-table/stat-grid/step-list/kv-card) | ⏳ 下版本 |
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
|
||||
## 1. 状态胶囊(悬浮胶囊:最小化药丸 ⇄ 展开面板)
|
||||
|
||||
所有插件**共用一个悬浮胶囊**(标题栏右侧,ZCode 复刻)。插件照常 `setStatusItem`,宿主记录每次写入时间,两种形态:
|
||||
所有插件**共用一个悬浮胶囊**,悬浮于 main 区域右上角(侧面板打开时自动左移让位;ZCode 复刻)。插件照常 `setStatusItem`,宿主记录每次写入时间,两种形态:
|
||||
|
||||
- **最小化药丸**:展示「最新的消息」(全部插件中 `updatedAt` 最新的一条:tone 点 + 插件图标 + 文本;多于一条时带总数角标),点击展开面板;
|
||||
- **展开面板**:每个插件一个**专属区域**——区域头(插件图标 + 名称 + 条目数)可收起/展开(记忆跨启动),区域内是该插件的条目与详情行;面板高度固定,内容太多向下滚动;点击面板外部 / Esc 收回药丸。
|
||||
- **展开面板**:面板头就是插件名(第一个区域的右侧是收回药丸的 ⤡);多个插件时各占一个区域、区域头可收起/展开(记忆跨启动)。区域内 `detail` 的**分组是手风琴**——默认收起只显示「分组标题 + 行尾徽标」,点击展开 rows(ZCode 式「计划 ›」);无标题分组直接平铺。面板高度固定,内容太多向下滚动;点击面板外部 / Esc 收回药丸。
|
||||
|
||||
```js
|
||||
// ① 纯展示(只出现在药丸/区域行)
|
||||
@@ -21,7 +21,7 @@ api.ui.setStatusItem('conn', {
|
||||
openView: { view: 'my-plugin' } // 点击跳到插件整页视图
|
||||
})
|
||||
|
||||
// ③ 携详情(声明 detail 时区域行下展开分组明细)
|
||||
// ③ 携详情(分组 = 手风琴:收起显示 标题+trailing 徽标,点击展开 rows)
|
||||
api.ui.setStatusItem('tasks', {
|
||||
text: '任务 2/5',
|
||||
tone: 'accent',
|
||||
|
||||
@@ -28,10 +28,14 @@ export interface StatusDetailRow {
|
||||
openView?: OpenViewAction
|
||||
}
|
||||
|
||||
/** 详情弹层分组 */
|
||||
/** 详情弹层分组(悬浮面板中为手风琴:默认收起只显示标题行,点击展开 rows) */
|
||||
export interface StatusDetailSection {
|
||||
/** 分组标题(如「Git 工具」「计划」);缺省无标题 */
|
||||
/** 分组标题(如「计划」「进程」);缺省无标题(rows 直接平铺、不可收起) */
|
||||
title?: string
|
||||
/** 标题行尾徽标(如「4/4」「35分7秒」;收起态也可见——ZCode 式摘要) */
|
||||
trailing?: string
|
||||
/** 行尾徽标色调,缺省 default */
|
||||
trailingTone?: StatusTone
|
||||
rows: StatusDetailRow[]
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user