feat: 状态胶囊升级——顶栏紧凑状态条 + 点击展开宿主渲染详情弹层(StatusItem.icon/detail:分组/行/徽标/行内深链)

This commit is contained in:
Rick Ye
2026-09-11 11:02:44 +08:00
parent 1edaf17623
commit 7103675db8
5 changed files with 202 additions and 22 deletions
@@ -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 && (