fix: organize story graph lists into tabs
Replace the stacked event, entity and relation lists with shared tabs and an independently scrolling panel. Preserve graph selection when switching tabs and follow canvas selections in the sidebar. Update translations, UI documentation and focused interaction and Electron layout coverage. Release note: Browse story graph events, entities and relations in separate sidebar tabs, with counts and synchronized canvas selection.
This commit is contained in:
@@ -91,6 +91,8 @@ Modal 按[共享表单 Modal 规则](../../../UI-DESIGN.md#615-表单-modal)复
|
||||
|
||||
## 故事线图谱
|
||||
|
||||
左侧使用共享 PageTabs 切换“事件 / 实体 / 关系”,显示各类数量,一次只展示一类列表,无记录时显示本次回顾的分类空态。页签固定在顶部,列表占满剩余高度并独立滚动。切换页签不清除当前图谱选择;从画布、事件浏览或详情选择对象时同步切到对应页签。支持方向键、Home/End 和面板键盘聚焦。
|
||||
|
||||
使用 `supervisor` 页面壳层,最大内容宽度为 `1440px`。当前生产图谱左侧为紧凑的事件、实体和关系列表,中间为 SVG 画布,右侧为详情与来源。桌面列宽为 `216px minmax(0, 1fr) 280px`,画布获得主要空间。外圈事件按发生时间排序后等距逆时针排列,不表示实际时间间隔;起止之间保留缺口和方向箭头。内部显示真实实体名称,实线仅连接实际事件与实体,虚线仅连接实际实体关系。
|
||||
|
||||
实体及其影响连线复用现有 `--graph-node-*-border` 令牌;颜色用于辅助辨认实体,不代表故事分类或确认状态。选中项加粗并突出连线,其余节点降低图形强调度但保留可读文字。详情分为名称与说明、图谱关联、确认操作和来源,来源正文按需读取。图例常显,读图说明可展开。
|
||||
|
||||
@@ -352,9 +352,17 @@ describe('SupervisorWorkspace', () => {
|
||||
render(<SupervisorWorkspace tab="graph" />)
|
||||
const graph = await screen.findByRole('group', { name: '故事线图谱' })
|
||||
const list = screen.getByRole('complementary', { name: '图谱选择' })
|
||||
fireEvent.click(within(list).getByRole('tab', { name: /实体/ }))
|
||||
fireEvent.click(within(list).getByRole('button', { name: '实体 10' }))
|
||||
expect(within(graph).getByRole('button', { name: '实体 10' })).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(within(graph).getByRole('button', { name: '事件 20' })).toHaveClass('is-selected')
|
||||
expect(graph.querySelector('[data-event-entity="event-20:entity-10"]')).toHaveClass('is-selected')
|
||||
fireEvent.keyDown(within(list).getByRole('tab', { name: /实体/ }), { key: 'ArrowRight' })
|
||||
expect(within(list).getByRole('tab', { name: /关系/ })).toHaveFocus()
|
||||
expect(within(list).getByRole('tabpanel')).toHaveTextContent('本次回顾暂无此类记录。')
|
||||
expect(within(graph).getByRole('button', { name: '实体 10' })).toHaveAttribute('aria-pressed', 'true')
|
||||
fireEvent.click(within(graph).getByRole('button', { name: '事件 20' }))
|
||||
expect(within(list).getByRole('tab', { name: /事件/ })).toHaveAttribute('aria-selected', 'true')
|
||||
expect(within(list).getAllByRole('button')).toHaveLength(24)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { BookOpen, Network, RefreshCw, X } from 'lucide-react'
|
||||
import type { SupervisionReviewExecution } from '../../shared/supervision-review-contracts'
|
||||
import { EmptyState } from './WorkspacePrimitives'
|
||||
import { EmptyState, PageTabs } from './WorkspacePrimitives'
|
||||
import type { AssistantProject } from '../../shared/assistant-contracts'
|
||||
import { heartbeatScopeSchema } from '../../shared/assistant-contracts'
|
||||
import {
|
||||
@@ -61,6 +61,7 @@ export function SupervisorWorkspace({
|
||||
const [error, setError] = useState<string>()
|
||||
const [errorAction, setErrorAction] = useState<'run' | 'source' | 'action'>()
|
||||
const [selection, setSelection] = useState<Selection>()
|
||||
const [listTab, setListTab] = useState<Selection['kind']>('event')
|
||||
const [source, setSource] = useState<{
|
||||
id: string
|
||||
title: string
|
||||
@@ -388,6 +389,7 @@ export function SupervisorWorkspace({
|
||||
const stageEvent = layout.events[stage]
|
||||
const select = (next: Selection) => {
|
||||
setSelection(next)
|
||||
setListTab(next.kind)
|
||||
setSource(undefined)
|
||||
setConfirmRemoval(false)
|
||||
setRevision(undefined)
|
||||
@@ -672,11 +674,30 @@ export function SupervisorWorkspace({
|
||||
className="supervisor-workspace__graph-list"
|
||||
aria-label={t('supervisor.selection')}
|
||||
>
|
||||
<h3>
|
||||
{t('supervisor.events')}
|
||||
<span>{layout.events.length}</span>
|
||||
</h3>
|
||||
{layout.events.map((event, index) => (
|
||||
<PageTabs
|
||||
ariaLabel={t('supervisor.selection')}
|
||||
idPrefix={`${graphId}-list`}
|
||||
value={listTab}
|
||||
onChange={setListTab}
|
||||
tabs={[
|
||||
{ id: 'event', label: `${t('supervisor.listTabs.event')} ${layout.events.length}` },
|
||||
{ id: 'entity', label: `${t('supervisor.listTabs.entity')} ${layout.entities.length}` },
|
||||
{ id: 'relation', label: `${t('supervisor.listTabs.relation')} ${graph.relations.length}` }
|
||||
]}
|
||||
/>
|
||||
<div
|
||||
key={listTab}
|
||||
className="supervisor-workspace__list-panel"
|
||||
role="tabpanel"
|
||||
id={`${graphId}-list-panel-${listTab}`}
|
||||
aria-labelledby={`${graphId}-list-tab-${listTab}`}
|
||||
tabIndex={0}
|
||||
>
|
||||
{((listTab === 'entity' && !layout.entities.length) ||
|
||||
(listTab === 'relation' && !graph.relations.length)) && (
|
||||
<p className="supervisor-workspace__muted">{t('supervisor.listEmpty')}</p>
|
||||
)}
|
||||
{listTab === 'event' && layout.events.map((event, index) => (
|
||||
<button
|
||||
key={event.id}
|
||||
aria-label={`${index + 1}. ${event.title} · ${date(event.occurred_at)}`}
|
||||
@@ -694,11 +715,7 @@ export function SupervisorWorkspace({
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
<h3>
|
||||
{t('supervisor.entities')}
|
||||
<span>{layout.entities.length}</span>
|
||||
</h3>
|
||||
{layout.entities.map((entity) => (
|
||||
{listTab === 'entity' && layout.entities.map((entity) => (
|
||||
<button
|
||||
key={entity.id}
|
||||
data-tone={entityTone(entity.id)}
|
||||
@@ -716,11 +733,7 @@ export function SupervisorWorkspace({
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
<h3>
|
||||
{t('supervisor.relations')}
|
||||
<span>{graph.relations.length}</span>
|
||||
</h3>
|
||||
{graph.relations.map((relation) => (
|
||||
{listTab === 'relation' && graph.relations.map((relation) => (
|
||||
<button
|
||||
key={relation.id}
|
||||
aria-pressed={selection?.id === relation.id}
|
||||
@@ -744,6 +757,7 @@ export function SupervisorWorkspace({
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="supervisor-workspace__muted">
|
||||
{t('supervisor.legend')}
|
||||
</p>
|
||||
|
||||
@@ -85,6 +85,8 @@ export const heartbeat = {
|
||||
newReview: 'New review', summary: 'Work summary', changes: 'Changes', runningHint: 'A new review is running. The existing result remains available to read.',
|
||||
empty: 'No successful review yet', emptyHint: 'You can review current progress manually even without an automatic supervision plan.', run: 'Review current progress', running: 'Reviewing…', retryRun: 'Retry review', dismiss: 'Dismiss',
|
||||
graphScope: 'Graph scope', graphEmpty: 'No story events in this scope', legend: 'Solid lines show event impact on entities; dashed lines show entity relations. Time runs counter-clockwise with a visible gap.', start: 'Start', end: 'End',
|
||||
listTabs: { event: 'Events', entity: 'Entities', relation: 'Relations' },
|
||||
listEmpty: 'No records of this type in this review.',
|
||||
events: 'Time events', entities: 'Knowledge entities', relations: 'Entity relations', eventSources: 'Event sources', sources: 'Related sources', noSources: 'No related sources are available.', selectHint: 'Select an event, entity, or relation to inspect it.', sourceSnapshot: 'Source details', sourceMissing: 'Source not found',
|
||||
confirm: 'Confirm', revise: 'Revise', remove: 'Remove relation', label: 'Entity name', save: 'Save revision', cancel: 'Cancel', removeHint: 'This changes graph organization only; the original source remains available.',
|
||||
relationTypes: { supports: 'Supports', 'depends-on': 'Depends on', contrasts: 'Contrasts', related: 'Related' },
|
||||
|
||||
@@ -82,6 +82,8 @@ export const heartbeat = {
|
||||
newReview: '新回顾', summary: '工作总结', changes: '本次变化', runningHint: '新回顾正在整理,已有结果仍可阅读。',
|
||||
empty: '还没有成功回顾', emptyHint: '即使没有自动监督计划,也可以手动回顾当前进展。', run: '回顾当前进展', running: '回顾整理中…', retryRun: '重试回顾', dismiss: '关闭提示',
|
||||
graphScope: '图谱范围', graphEmpty: '当前范围没有故事线事件', legend: '实线表示事件影响实体,虚线表示实体关系。时间轴逆时针排列,起止之间保留缺口。', start: '起点', end: '终点',
|
||||
listTabs: { event: '事件', entity: '实体', relation: '关系' },
|
||||
listEmpty: '本次回顾暂无此类记录。',
|
||||
events: '时间事件', entities: '知识实体', relations: '实体关系', eventSources: '事件来源', sources: '关联来源', noSources: '没有可用的关联来源。', selectHint: '选择事件、实体或关系查看详情。', sourceSnapshot: '来源详情', sourceMissing: '来源不存在',
|
||||
confirm: '确认', revise: '修订', remove: '移除关系', label: '实体名称', save: '保存修订', cancel: '取消', removeHint: '这只改变图谱中的关系组织,原始来源仍然保留。',
|
||||
relationTypes: { supports: '支持', 'depends-on': '依赖', contrasts: '对比', related: '相关' },
|
||||
|
||||
@@ -413,27 +413,27 @@
|
||||
padding: var(--space-4);
|
||||
background: var(--surface-subtle);
|
||||
border-radius: var(--radius-card) 0 0 var(--radius-card);
|
||||
overflow: hidden;
|
||||
}
|
||||
.supervisor-workspace__graph-list > .page-tabs,
|
||||
.supervisor-workspace__graph-list > p {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.supervisor-workspace__graph-list > .supervisor-workspace__list-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
gap: var(--space-1);
|
||||
overflow: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.supervisor-workspace__graph-list > * {
|
||||
.supervisor-workspace__graph-list .page-tabs__tab {
|
||||
gap: var(--space-1);
|
||||
padding-inline: var(--space-1);
|
||||
}
|
||||
.supervisor-workspace__list-panel > button {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.supervisor-workspace__graph-list h3 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin: var(--space-4) 0 var(--space-2);
|
||||
font-size: var(--font-body);
|
||||
}
|
||||
.supervisor-workspace__graph-list h3:first-child {
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
.supervisor-workspace__graph-list h3 span {
|
||||
color: var(--text-muted);
|
||||
font-weight: 400;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.supervisor-workspace__graph-list button {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-2);
|
||||
@@ -468,10 +468,10 @@
|
||||
background: currentColor;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.supervisor-workspace__graph-list button:hover {
|
||||
.supervisor-workspace__list-panel > button:hover {
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
.supervisor-workspace__graph-list button[aria-pressed='true'] {
|
||||
.supervisor-workspace__list-panel > button[aria-pressed='true'] {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
@@ -669,7 +669,8 @@ app
|
||||
win.setContentSize(width, 1100)
|
||||
await settle()
|
||||
const scrolling = await js(`(() => {
|
||||
const list = document.querySelector('.supervisor-workspace__graph-list');
|
||||
const sidebar = document.querySelector('.supervisor-workspace__graph-list');
|
||||
const list = sidebar.querySelector('[role=tabpanel]');
|
||||
const canvas = document.querySelector('.supervisor-workspace__graph-canvas');
|
||||
const shell = document.querySelector('.page-shell');
|
||||
list.scrollIntoView({block:'end'});
|
||||
@@ -678,7 +679,7 @@ app
|
||||
const last = list.lastElementChild.getBoundingClientRect();
|
||||
const button = list.querySelector('button:last-of-type');
|
||||
const b = button.getBoundingClientRect(), r = list.getBoundingClientRect();
|
||||
return { width: innerWidth, height: r.height, canvasHeight: canvas.getBoundingClientRect().height,
|
||||
return { width: innerWidth, height: sidebar.getBoundingClientRect().height, canvasHeight: canvas.getBoundingClientRect().height,
|
||||
clientHeight: list.clientHeight, scrollHeight: list.scrollHeight, scrollTop: list.scrollTop,
|
||||
bottomGap: r.bottom - last.bottom,
|
||||
paddingBottom: parseFloat(getComputedStyle(list).paddingBottom),
|
||||
@@ -703,15 +704,19 @@ app
|
||||
}
|
||||
win.setContentSize(1440, 1100)
|
||||
await js(
|
||||
'document.querySelector(".supervisor-workspace__graph-list button:last-of-type").click()'
|
||||
)
|
||||
'document.querySelectorAll(".supervisor-workspace__graph-list [role=tab]")[2].click()'
|
||||
)
|
||||
await settle()
|
||||
await js('document.querySelector(".supervisor-workspace__list-panel button:last-of-type").click()')
|
||||
await settle()
|
||||
assert(
|
||||
await js('document.querySelectorAll("[data-relation]").length === 1'),
|
||||
'Selected relation endpoints must remain visible'
|
||||
)
|
||||
await js('document.querySelectorAll(".supervisor-workspace__graph-list [role=tab]")[0].click()')
|
||||
await settle()
|
||||
const target = await js(
|
||||
`(() => { const e = document.querySelectorAll('.supervisor-workspace__graph-list button')[79]; e.scrollIntoView({block:'center'}); const r = e.getBoundingClientRect(); return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), hit: e.contains(document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)) }; })()`
|
||||
`(() => { const e = document.querySelectorAll('.supervisor-workspace__list-panel button')[79]; e.scrollIntoView({block:'center'}); const r = e.getBoundingClientRect(); return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), hit: e.contains(document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)) }; })()`
|
||||
)
|
||||
assert(target.hit, 'Last event is not reachable')
|
||||
win.webContents.sendInputEvent({
|
||||
@@ -736,9 +741,9 @@ app
|
||||
)
|
||||
assert.equal(
|
||||
await js(
|
||||
'document.querySelectorAll(".supervisor-workspace__graph-list button").length'
|
||||
'document.querySelectorAll(".supervisor-workspace__list-panel button").length'
|
||||
),
|
||||
121
|
||||
80
|
||||
)
|
||||
const positions = await js(
|
||||
'[...document.querySelectorAll(".supervisor-workspace__node:not(.supervisor-workspace__entity) circle")].map(e => [e.getAttribute("cx"), e.getAttribute("cy")].join())'
|
||||
@@ -946,7 +951,7 @@ app
|
||||
const panel = document.querySelector('#supervisor-panel-${tab}');
|
||||
return { width: innerWidth, pageWidth: document.documentElement.scrollWidth,
|
||||
panelWidth: panel.clientWidth, scrollWidth: panel.scrollWidth,
|
||||
tabs: [...document.querySelectorAll('[role=tab]')].map(e => e.textContent),
|
||||
tabs: [...document.querySelectorAll('[role=tab][id^="supervisor-tab-"]')].map(e => e.textContent),
|
||||
nestedMenus: panel.querySelectorAll('[role=tablist]').length,
|
||||
reports: !!panel.querySelector('#heartbeat-reports-title'),
|
||||
reportWidth: panel.querySelector('#heartbeat-panel-history > section')?.getBoundingClientRect().width,
|
||||
@@ -958,7 +963,7 @@ app
|
||||
refresh: [...panel.querySelectorAll('button')].some(e => e.textContent.includes('刷新')) };
|
||||
})()`)
|
||||
assert.deepEqual(menuLayout.tabs, ['工作回顾', '故事线图谱', '自动监督', '活动记录', '设置'])
|
||||
assert.equal(menuLayout.nestedMenus, 0)
|
||||
assert.equal(menuLayout.nestedMenus, tab === 'graph' ? 1 : 0)
|
||||
assert(menuLayout.pageWidth <= width && menuLayout.scrollWidth <= menuLayout.panelWidth, 'Menu panel overflow')
|
||||
assert.equal(menuLayout.reports, tab === 'plans')
|
||||
if (tab === 'plans') assert(Math.abs(menuLayout.reportWidth - menuLayout.panelWidth) <= 1, 'Reports must use full reading width')
|
||||
|
||||
Reference in New Issue
Block a user