feat: 聊天区浮动胶囊(B3)——与 B2 同源数据,折叠圆钮+计数徽标,展开纵向胶囊堆栈(detail 向上弹层/深链同顶栏)

This commit is contained in:
Rick Ye
2026-09-11 11:27:49 +08:00
parent 8866438005
commit d2fd6807aa
5 changed files with 138 additions and 6 deletions
+2
View File
@@ -22,6 +22,7 @@ import {
} from './plugins/renderer-host'
import { LoadingState, ErrorState } from '@aso/plugin-sdk/ui'
import { PluginErrorBoundary } from './plugins/PluginErrorBoundary'
import { PluginFloatingCapsules } from './plugins/PluginFloatingCapsules'
import { PluginStatusCapsules } from './plugins/PluginStatusCapsules'
import { usePluginList, usePluginsUiState, sidebarPlugins } from './plugins/store'
import { BUILTIN_PLUGIN_COMPONENTS } from './plugins/builtin-components'
@@ -456,6 +457,7 @@ export default function App(): JSX.Element {
onPickSuggestion={handlePickSuggestion}
onQuoteSelection={handleQuoteSelection}
/>
<PluginFloatingCapsules entries={pluginsUi.status} onOpenView={handleOpenView} />
<BottomSlot
approvals={state.approvals}
onResolve={resolveApproval}
@@ -0,0 +1,118 @@
/**
* 聊天区浮动胶囊(B3 槽位):与 TitleBar 胶囊(B2)同源数据——主进程单一事实源,
* 插件 setStatusItem 后两处同时出现。折叠/展开双态:
* - 折叠:右下角单个圆钮(首条图标 + 计数徽标)
* - 展开:纵向胶囊堆栈;条目点击行为与 B2 一致(detail → 向上弹层 / openView → 深链)
* 无条目不渲染;点外部 / Esc 收起。
*/
import { useEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import { LuPuzzle } from 'react-icons/lu'
import type { PluginStatusEntry } from '@shared/types'
import { PluginIconValue } from './plugin-icons'
import { StatusPopover, TONE_CLASS } from './PluginStatusCapsules'
export function PluginFloatingCapsules({
entries,
onOpenView
}: {
entries: PluginStatusEntry[]
onOpenView: (view: string, params?: unknown) => void
}): ReactNode {
const [open, setOpen] = useState(false)
const [detailKey, setDetailKey] = useState<string | null>(null)
const rootRef = useRef<HTMLDivElement | null>(null)
// 展开时:点外部 / Esc 收起
useEffect(() => {
if (!open) return
const onDown = (e: MouseEvent): void => {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
setOpen(false)
setDetailKey(null)
}
}
const onKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape') {
setOpen(false)
setDetailKey(null)
}
}
document.addEventListener('mousedown', onDown)
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('mousedown', onDown)
document.removeEventListener('keydown', onKey)
}
}, [open])
// 条目集合变化(停用/重载/删除)时收起;按 key 序列比较,避免引用变化误关
const entryKeys = entries.map((e) => `${e.pluginId}:${e.itemId}`).join(',')
useEffect(() => {
setOpen(false)
setDetailKey(null)
}, [entryKeys])
if (entries.length === 0) return null
const shown = entries.slice(0, 4)
return (
<div
ref={rootRef}
className="absolute bottom-3 right-3 z-30 flex flex-col items-end gap-1.5 [-webkit-app-region:no-drag]"
>
{open &&
shown.map(({ pluginId, itemId, item }) => {
const tone = item.tone ?? 'default'
const key = `${pluginId}:${itemId}`
const expandable = Boolean(item.detail)
const clickable = expandable || Boolean(item.openView)
const detailOpen = detailKey === key
return (
<div key={key} className="relative">
<button
type="button"
title={item.text}
onClick={() => {
if (item.detail) setDetailKey(detailOpen ? null : key)
else if (item.openView) {
setOpen(false)
setDetailKey(null)
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] shadow-btn transition-transform ${
TONE_CLASS[tone] ?? TONE_CLASS.default
} ${clickable ? 'hover:opacity-80 active:scale-[0.97]' : 'cursor-default'} bg-surface ${detailOpen ? 'opacity-80' : ''}`}
>
{item.icon && <PluginIconValue icon={item.icon} size={12} className="shrink-0" />}
<span className="truncate">{item.text}</span>
</button>
{detailOpen && item.detail && (
<StatusPopover entry={{ pluginId, itemId, item }} onOpenView={onOpenView} direction="up" />
)}
</div>
)
})}
<button
type="button"
aria-expanded={open}
aria-label={`插件状态(${entries.length} 条)`}
title="插件状态"
onClick={() => {
setOpen((v) => !v)
setDetailKey(null)
}}
className="relative flex size-9 items-center justify-center rounded-full border border-line bg-surface text-ink-2 shadow-raised transition-transform hover:text-ink active:scale-95"
>
{entries[0]?.item.icon ? (
<PluginIconValue icon={entries[0].item.icon} size={16} />
) : (
<LuPuzzle size={16} className="text-ink-3" />
)}
<span className="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-accent px-1 text-[10px] font-medium leading-none text-white">
{entries.length}
</span>
</button>
</div>
)
}
@@ -11,7 +11,7 @@ import type { ReactNode } from 'react'
import type { PluginStatusEntry } from '@shared/types'
import { PluginIconValue } from './plugin-icons'
const TONE_CLASS: Record<string, string> = {
export const TONE_CLASS: Record<string, string> = {
default: 'border-line text-ink-2',
accent: 'border-accent/40 text-accent-ink bg-accent-tint',
warn: 'border-orange/40 text-orange bg-orange-tint',
@@ -25,11 +25,23 @@ const BADGE_TONE_CLASS: Record<string, string> = {
danger: 'bg-red-tint text-red'
}
/** 详情弹层:分组列表,行数据自带动线 */
function StatusPopover({ entry, onOpenView }: { entry: PluginStatusEntry; onOpenView: (view: string, params?: unknown) => void }): ReactNode {
/** 详情弹层:分组列表,行数据自带深链(B2 顶栏向下弹、B3 浮动胶囊向上弹) */
export function StatusPopover({
entry,
onOpenView,
direction = 'down'
}: {
entry: PluginStatusEntry
onOpenView: (view: string, params?: unknown) => void
direction?: 'down' | 'up'
}): 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={`absolute right-0 z-50 w-72 overflow-hidden rounded-card border border-line bg-surface shadow-overlay ${
direction === 'up' ? 'bottom-full mb-1.5' : 'top-full mt-1.5'
}`}
>
<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) => (