feat: unify workspace design and portable packaging

Establish shared scoped UI primitives and make repeatable Windows builds preserve existing user data safely.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
lofyer
2026-08-03 00:43:07 +08:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent e8dc4d03fd
commit 09e9fbf5e2
14 changed files with 2306 additions and 640 deletions
+30 -1
View File
@@ -121,6 +121,13 @@ describe('ActivityPanel', () => {
)
fireEvent.click(screen.getByRole('button', { name: '清空记录' }))
expect(onClear).not.toHaveBeenCalled()
expect(
screen.getByText('永久清空 1 条活动记录?此操作不可撤销。')
).toBeInTheDocument()
fireEvent.click(
screen.getByRole('button', { name: '确认清空 1 条活动记录' })
)
expect(onClear).toHaveBeenCalledOnce()
rerender(
@@ -133,7 +140,7 @@ describe('ActivityPanel', () => {
)
expect(
screen.getByText(
'尚无活动记录。任务请求、工具调用和审批决定会显示在这里。'
'任务请求、工具调用和审批决定会显示在这里。'
)
).toBeInTheDocument()
expect(
@@ -141,6 +148,28 @@ describe('ActivityPanel', () => {
).toBeDisabled()
})
it('uses the shared page hierarchy and explicit global scope', () => {
render(
<ActivityPanel
onClear={vi.fn()}
onOpenConversation={vi.fn()}
records={[]}
tokenUsage={makeTokenUsage()}
/>
)
expect(
screen.getByRole('heading', { level: 1, name: '任务与活动' })
).toBeInTheDocument()
expect(screen.getByText('全部项目')).toHaveClass('scope-badge')
expect(screen.getByLabelText('Token 用量分组')).toHaveClass(
'segmented-control'
)
expect(screen.getByLabelText('筛选活动')).toHaveClass(
'segmented-control'
)
})
it('never renders more than 500 records', () => {
const records = Array.from(
{ length: MAX_ACTIVITY_RECORDS + 1 },
+53 -65
View File
@@ -10,6 +10,12 @@ import {
groupTokenUsage,
type TokenUsageGroup
} from './token-usage'
import {
DestructiveConfirmActions,
EmptyState,
PageHeader,
SegmentedControl
} from './WorkspacePrimitives'
type ActivityFilter = 'all' | 'active' | 'failed'
@@ -122,7 +128,7 @@ function emptyMessage(filter: ActivityFilter): string {
if (filter === 'failed') {
return '当前没有失败、取消或中断的活动。'
}
return '尚无活动记录。任务请求、工具调用和审批决定会显示在这里。'
return '任务请求、工具调用和审批决定会显示在这里。'
}
export function ActivityPanel({
@@ -134,6 +140,7 @@ export function ActivityPanel({
const [filter, setFilter] = useState<ActivityFilter>('all')
const [tokenGroup, setTokenGroup] =
useState<TokenUsageGroup>('project')
const [confirmingClear, setConfirmingClear] = useState(false)
const visibleRecords = useMemo(
() => records.slice(0, MAX_ACTIVITY_RECORDS),
@@ -162,24 +169,31 @@ export function ActivityPanel({
aria-labelledby="activity-panel-title"
className="activity-panel"
>
<header className="activity-panel__header">
<div>
<p className="eyebrow">ACTIVITY AUDIT</p>
<h2 id="activity-panel-title">
<Activity aria-hidden="true" size={20} />
</h2>
</div>
<button
className="secondary-button activity-panel__clear"
disabled={visibleRecords.length === 0}
onClick={onClear}
type="button"
>
<Trash2 aria-hidden="true" size={15} />
</button>
</header>
<PageHeader
actions={
<DestructiveConfirmActions
confirmAriaLabel={`确认清空 ${visibleRecords.length} 条活动记录`}
confirmLabel={`清空 ${visibleRecords.length} 条记录`}
confirming={confirmingClear}
disabled={!confirmingClear && visibleRecords.length === 0}
icon={<Trash2 aria-hidden="true" size={15} />}
message={`永久清空 ${visibleRecords.length} 条活动记录?此操作不可撤销。`}
onCancel={() => setConfirmingClear(false)}
onConfirm={() => {
onClear()
setConfirmingClear(false)
}}
onRequestConfirm={() => setConfirmingClear(true)}
triggerLabel="清空记录"
/>
}
description="查看全部项目中的任务请求、工具调用、审批结果和 Token 用量。"
eyebrow="ACTIVITY AUDIT"
headingId="activity-panel-title"
icon={<Activity size={20} />}
scope={{ kind: 'all-projects' }}
title="任务与活动"
/>
<section
aria-labelledby="token-usage-title"
@@ -187,27 +201,12 @@ export function ActivityPanel({
>
<header className="token-usage__header">
<h3 id="token-usage-title">Token </h3>
<div
aria-label="Token 用量分组"
className="token-usage__groups"
role="group"
>
{tokenGroups.map((item) => (
<button
aria-pressed={tokenGroup === item.value}
className={
tokenGroup === item.value
? 'token-usage__group token-usage__group--active'
: 'token-usage__group'
}
key={item.value}
onClick={() => setTokenGroup(item.value)}
type="button"
>
{item.label}
</button>
))}
</div>
<SegmentedControl
ariaLabel="Token 用量分组"
onChange={setTokenGroup}
options={tokenGroups}
value={tokenGroup}
/>
</header>
<dl aria-label="Token 用量统计" className="token-usage__stats">
@@ -301,33 +300,22 @@ export function ActivityPanel({
</div>
</dl>
<div
aria-label="筛选活动"
className="activity-panel__filters"
role="group"
>
{filters.map((item) => (
<button
aria-pressed={filter === item.value}
className={
filter === item.value
? 'activity-filter activity-filter--active'
: 'activity-filter'
}
key={item.value}
onClick={() => setFilter(item.value)}
type="button"
>
{item.label}
</button>
))}
<div className="activity-panel__filters">
<SegmentedControl
ariaLabel="筛选活动"
onChange={setFilter}
options={filters}
value={filter}
/>
</div>
{filteredRecords.length === 0 ? (
<div className="activity-panel__empty">
<Activity aria-hidden="true" size={24} />
<p>{emptyMessage(filter)}</p>
</div>
<EmptyState
description={emptyMessage(filter)}
icon={<Activity size={24} />}
level="section"
title={filter === 'all' ? '尚无活动记录' : '没有匹配的活动'}
/>
) : (
<ol className="activity-list">
{filteredRecords.map((record, index) => {
@@ -344,7 +332,7 @@ export function ActivityPanel({
{kindLabels[record.kind]}
</span>
<span
className={`activity-item__status activity-item__status--${record.status}`}
className={`status-badge activity-item__status activity-item__status--${record.status}`}
>
{statusLabels[record.status]}
</span>
+8
View File
@@ -441,6 +441,7 @@ describe('App', () => {
})
expect(await screen.findByText('这是回答内容')).toBeInTheDocument()
expect(screen.getByText('项目:默认项目')).toHaveClass('scope-badge')
})
it('keeps a draft in chat when Enter is pressed while the runtime loads', async () => {
@@ -573,6 +574,13 @@ describe('App', () => {
fireEvent.click(screen.getByText('任务与活动'))
const stats = await screen.findByLabelText('Token 用量统计')
expect(
screen.getByRole('heading', { level: 1, name: '任务与活动' })
).toBeInTheDocument()
expect(screen.queryByLabelText('专家角色')).not.toBeInTheDocument()
expect(
screen.queryByLabelText('切换助手工作栏')
).not.toBeInTheDocument()
await waitFor(() =>
expect(api.usage.getTokenSummary).toHaveBeenCalledOnce()
)
+35 -21
View File
@@ -69,6 +69,7 @@ import {
import { KnowledgeWorkspace } from './KnowledgeWorkspace'
import { HeartbeatCenter } from './HeartbeatCenter'
import { MarkdownRenderer } from './MarkdownRenderer'
import { PageShell, ScopeBadge } from './WorkspacePrimitives'
import {
ProjectSwitcher,
workModeLabels
@@ -580,6 +581,10 @@ function App(): React.JSX.Element {
() => conversations.find((conversation) => conversation.id === activeId),
[activeId, conversations]
)
const activeProject = useMemo(
() => projects.find((project) => project.id === activeProjectId),
[activeProjectId, projects]
)
const filteredConversations = useMemo(() => {
const query = searchQuery.trim().toLocaleLowerCase()
return conversations.filter(
@@ -2078,7 +2083,7 @@ function App(): React.JSX.Element {
type="button"
>
<History size={17} />
<span></span>
<span></span>
</button>
<button
className={
@@ -2127,7 +2132,7 @@ function App(): React.JSX.Element {
</nav>
<div className="conversation-list">
<p className="section-label"></p>
<p className="section-label"></p>
{filteredConversations.map((conversation) => (
<div className="conversation-row" key={conversation.id}>
<button
@@ -2224,7 +2229,7 @@ function App(): React.JSX.Element {
>
<span>
{view === 'knowledge'
? '本地知识库'
? '知识库'
: view === 'heartbeat'
? '智能心跳'
: view === 'activity'
@@ -2236,6 +2241,21 @@ function App(): React.JSX.Element {
{view === 'chat' && <Edit3 size={14} />}
</button>
)}
{view === 'chat' && (
<ScopeBadge
scope={
activeProject
? {
kind: 'project',
projectName: activeProject.name
}
: {
kind: 'unavailable',
explanation: '当前项目尚未加载。'
}
}
/>
)}
<div className="topbar__actions">
{view === 'chat' && (
<>
@@ -2257,9 +2277,7 @@ function App(): React.JSX.Element {
</button>
</>
)}
{view !== 'settings' &&
view !== 'heartbeat' &&
view !== 'knowledge' && (
{view === 'chat' && (
<select
aria-label="专家角色"
className="topbar__expert"
@@ -2294,9 +2312,7 @@ function App(): React.JSX.Element {
<span className="runtime-capability-badge"></span>
)}
</span>
{view !== 'settings' &&
view !== 'heartbeat' &&
view !== 'knowledge' && (
{view === 'chat' && (
<button
aria-label="切换助手工作栏"
aria-pressed={assistantSidebarOpen}
@@ -2341,7 +2357,7 @@ function App(): React.JSX.Element {
</header>
{view === 'chat' ? (
<>
<PageShell variant="reading">
<section className="chat" ref={scrollRef}>
{activeConversation?.messages.length === 1 && (
<div className="welcome">
@@ -2924,9 +2940,9 @@ function App(): React.JSX.Element {
{appInfo?.shortcut && ` 快捷唤起:${appInfo.shortcut}`}
</p>
</footer>
</>
</PageShell>
) : view === 'knowledge' ? (
<div className="workspace-panel-scroll workspace-panel-scroll--knowledge">
<PageShell variant="master-detail">
<KnowledgeWorkspace
documents={knowledgeSnapshot.documents}
evidence={knowledgeSnapshot.evidence}
@@ -3072,11 +3088,12 @@ function App(): React.JSX.Element {
selectedLibraryId={knowledgeSnapshot.selectedLibraryId}
sources={knowledgeSnapshot.sources}
/>
</div>
</PageShell>
) : view === 'heartbeat' ? (
<div className="workspace-panel-scroll workspace-panel-scroll--heartbeat">
<PageShell variant="dashboard">
<HeartbeatCenter
configs={assistantHeartbeats}
currentProjectName={activeProject?.name}
entries={heartbeatEntries}
memories={assistantMemories}
onCreate={createHeartbeat}
@@ -3090,7 +3107,7 @@ function App(): React.JSX.Element {
runs={heartbeatRuns}
tasks={assistantTasks}
/>
</div>
</PageShell>
) : view === 'settings' ? (
<SettingsPanel
appearanceTheme={appearanceTheme}
@@ -3110,14 +3127,14 @@ function App(): React.JSX.Element {
presentation="page"
/>
) : (
<div className="workspace-panel-scroll">
<PageShell variant="dashboard">
<ActivityPanel
onClear={() => setActivityRecords([])}
onOpenConversation={openActivityConversation}
records={activityRecords}
tokenUsage={tokenUsage}
/>
</div>
</PageShell>
)}
</main>
<RightAssistantSidebar
@@ -3204,10 +3221,7 @@ function App(): React.JSX.Element {
}}
onTabChange={setAssistantSidebarTab}
open={
assistantSidebarOpen &&
view !== 'settings' &&
view !== 'heartbeat' &&
view !== 'knowledge'
assistantSidebarOpen && view === 'chat'
}
schedules={assistantSchedules}
tab={assistantSidebarTab}
+25 -1
View File
@@ -19,6 +19,7 @@ import { HeartbeatCenter, type HeartbeatCenterProps } from './HeartbeatCenter'
const config: AssistantHeartbeatConfig = {
id: 'heartbeat-1',
projectId: 'project-1',
name: '智能成长回顾',
timezone: 'Asia/Shanghai',
recurrence: {
@@ -103,6 +104,7 @@ function createProps(
entries: [entry],
memories: [memory],
tasks: [task],
currentProjectName: '默认项目',
onCreate: vi.fn(async () => {}),
onSetPaused: vi.fn(async () => {}),
onRemove: vi.fn(async () => {}),
@@ -124,8 +126,12 @@ describe('HeartbeatCenter', () => {
render(<HeartbeatCenter {...createProps()} />)
expect(
screen.getByRole('heading', { name: '智能心跳' })
screen.getByRole('heading', { level: 1, name: '智能心跳' })
).toBeInTheDocument()
expect(screen.getByText('项目:默认项目 + 全局')).toHaveClass(
'scope-badge'
)
expect(screen.getByText(/ 09:00 · /u)).toBeInTheDocument()
expect(screen.getByText('1 个计划运行中')).toBeInTheDocument()
expect(screen.getByText('50%')).toBeInTheDocument()
expect(
@@ -217,6 +223,24 @@ describe('HeartbeatCenter', () => {
expect(screen.getByText(entry.highlights[0]!)).toBeInTheDocument()
})
it('explains the irreversible impact before deleting a plan', () => {
render(<HeartbeatCenter {...createProps()} />)
fireEvent.click(screen.getByRole('tab', { name: '心跳计划' }))
fireEvent.click(
screen.getByRole('button', {
name: `删除 ${config.name}`
})
)
const confirmation = screen.getByRole('alertdialog', {
name: `确认删除 ${config.name}`
})
expect(confirmation).toHaveTextContent(
'将永久删除此计划、运行历史和关联结果,且无法恢复。'
)
})
it('guides first-time users to create a heartbeat plan', () => {
render(
<HeartbeatCenter
+75 -83
View File
@@ -21,6 +21,11 @@ import type {
HeartbeatCreateInput
} from '../../shared/assistant-contracts'
import { HeartbeatSettings } from './HeartbeatSettings'
import {
EmptyState,
PageHeader,
PageTabs
} from './WorkspacePrimitives'
type HeartbeatCenterTab =
| 'overview'
@@ -48,6 +53,7 @@ export type HeartbeatCenterProps = {
status: 'completed' | 'cancelled'
) => Promise<void>
onUseFollowUpTask: (task: AssistantTask) => void
currentProjectName?: string
}
const dateTimeFormatter = new Intl.DateTimeFormat('zh-CN', {
@@ -136,7 +142,8 @@ export function HeartbeatCenter({
onRefresh,
onSetMemoryStatus,
onSetTaskStatus,
onUseFollowUpTask
onUseFollowUpTask,
currentProjectName = '当前项目'
}: HeartbeatCenterProps): React.JSX.Element {
const [tab, setTab] = useState<HeartbeatCenterTab>('overview')
const [pendingAction, setPendingAction] = useState<string>()
@@ -260,55 +267,53 @@ export function HeartbeatCenter({
aria-labelledby="heartbeat-center-title"
className="heartbeat-center"
>
<header className="heartbeat-center__hero">
<div>
<p className="eyebrow">SMART HEARTBEAT</p>
<h2 id="heartbeat-center-title">
<HeartPulse aria-hidden="true" size={22} />
</h2>
<p>
GoodBuddy
</p>
</div>
<div className="heartbeat-center__hero-actions">
<button
aria-label="刷新智能心跳"
className="secondary-button"
disabled={pendingAction !== undefined}
onClick={() => void runAction('refresh', onRefresh)}
type="button"
>
<RefreshCw aria-hidden="true" size={14} />
</button>
{primaryConfig ? (
<PageHeader
actions={
<>
<button
className="primary-button"
aria-label="刷新智能心跳"
className="secondary-button"
disabled={pendingAction !== undefined}
onClick={() =>
void runAction(`run:${primaryConfig.id}`, () =>
onRunNow(primaryConfig.id)
)
}
onClick={() => void runAction('refresh', onRefresh)}
type="button"
>
<Play aria-hidden="true" size={14} />
{pendingAction === `run:${primaryConfig.id}`
? '心跳中…'
: '运行一次心跳'}
<RefreshCw aria-hidden="true" size={14} />
</button>
) : (
<button
className="primary-button"
onClick={() => setTab('plans')}
type="button"
>
</button>
)}
</div>
</header>
{primaryConfig ? (
<button
className="primary-button"
disabled={pendingAction !== undefined}
onClick={() =>
void runAction(`run:${primaryConfig.id}`, () =>
onRunNow(primaryConfig.id)
)
}
type="button"
>
<Play aria-hidden="true" size={14} />
{pendingAction === `run:${primaryConfig.id}`
? '心跳中…'
: '运行一次心跳'}
</button>
) : (
<button
className="primary-button"
onClick={() => setTab('plans')}
type="button"
>
</button>
)}
</>
}
description="定期回顾经历、沉淀记忆、发现问题,并把每次变化转化为可处理的成长建议。"
eyebrow="SMART HEARTBEAT"
headingId="heartbeat-center-title"
icon={<HeartPulse size={22} />}
scope={{ kind: 'mixed', projectName: currentProjectName }}
title="智能心跳"
/>
{error && (
<p className="heartbeat-center__error" role="alert">
@@ -316,31 +321,13 @@ export function HeartbeatCenter({
</p>
)}
<nav
aria-label="智能心跳视图"
className="heartbeat-center__tabs"
role="tablist"
>
{tabs.map((item) => (
<button
aria-controls={`heartbeat-panel-${item.id}`}
aria-selected={tab === item.id}
className={
tab === item.id
? 'heartbeat-center__tab heartbeat-center__tab--active'
: 'heartbeat-center__tab'
}
id={`heartbeat-tab-${item.id}`}
key={item.id}
onClick={() => setTab(item.id)}
role="tab"
type="button"
>
{item.label}
{item.count ? <span>{item.count}</span> : null}
</button>
))}
</nav>
<PageTabs
ariaLabel="智能心跳视图"
idPrefix="heartbeat"
onChange={setTab}
tabs={tabs}
value={tab}
/>
{tab === 'overview' && (
<div
@@ -372,18 +359,21 @@ export function HeartbeatCenter({
</span>
</div>
{configs.length === 0 ? (
<div className="heartbeat-center__empty">
<HeartPulse aria-hidden="true" size={24} />
<strong></strong>
<p> GoodBuddy </p>
<button
className="primary-button"
onClick={() => setTab('plans')}
type="button"
>
</button>
</div>
<EmptyState
action={
<button
className="primary-button"
onClick={() => setTab('plans')}
type="button"
>
</button>
}
description="配置每日或每周心跳,让 GoodBuddy 持续回顾和学习。"
icon={<HeartPulse size={24} />}
level="section"
title="尚未建立成长节奏"
/>
) : (
<div className="heartbeat-center__config-grid">
{configs.map((config) => (
@@ -403,7 +393,9 @@ export function HeartbeatCenter({
<strong>{config.name}</strong>
<small>
{recurrenceLabel(config)} ·{' '}
{config.projectId ? '当前项目' : '全局'}
{config.projectId
? currentProjectName
: '全局'}
</small>
</div>
</header>
+23 -38
View File
@@ -4,6 +4,7 @@ import type {
AssistantHeartbeatConfig,
HeartbeatCreateInput
} from '../../shared/assistant-contracts'
import { DestructiveConfirmActions } from './WorkspacePrimitives'
type HeartbeatSettingsProps = {
heartbeats: AssistantHeartbeatConfig[]
@@ -203,45 +204,29 @@ export function HeartbeatSettings({
>
</button>
{confirmingRemoveId === heartbeat.id ? (
<>
<button
aria-label={`确认删除 ${heartbeat.name}`}
disabled={pendingAction !== undefined}
onClick={() =>
void runAction(
`remove:${heartbeat.id}`,
async () => {
await onRemove(heartbeat.id)
setConfirmingRemoveId(undefined)
}
)
<DestructiveConfirmActions
cancelAriaLabel={`取消删除 ${heartbeat.name}`}
confirmAriaLabel={`确认删除 ${heartbeat.name}`}
confirmLabel="确认删除计划"
confirming={confirmingRemoveId === heartbeat.id}
disabled={pendingAction !== undefined}
message="将永久删除此计划、运行历史和关联结果,且无法恢复。"
onCancel={() => setConfirmingRemoveId(undefined)}
onConfirm={() =>
void runAction(
`remove:${heartbeat.id}`,
async () => {
await onRemove(heartbeat.id)
setConfirmingRemoveId(undefined)
}
type="button"
>
</button>
<button
aria-label={`取消删除 ${heartbeat.name}`}
disabled={pendingAction !== undefined}
onClick={() => setConfirmingRemoveId(undefined)}
type="button"
>
</button>
</>
) : (
<button
aria-label={`删除 ${heartbeat.name}`}
disabled={pendingAction !== undefined}
onClick={() =>
setConfirmingRemoveId(heartbeat.id)
}
type="button"
>
</button>
)}
)
}
onRequestConfirm={() =>
setConfirmingRemoveId(heartbeat.id)
}
triggerAriaLabel={`删除 ${heartbeat.name}`}
triggerLabel="删除"
/>
</div>
</article>
))}
@@ -223,12 +223,22 @@ describe('KnowledgeWorkspace', () => {
const workspace = screen.getByLabelText('知识工作区')
expect(workspace).toHaveClass('knowledge-workspace')
expect(workspace).toHaveStyle({
background: 'var(--surface-canvas)'
})
expect(workspace.querySelector('aside')).toHaveClass(
'knowledge-workspace__sidebar'
)
expect(workspace.querySelector('main')).toHaveClass(
'knowledge-workspace__main'
)
expect(workspace.querySelector('main')).toHaveStyle({
background: 'var(--surface-raised)'
})
expect(screen.getByText('全局')).toHaveClass('scope-badge')
expect(screen.getByRole('tablist', { name: '知识库视图' })).toHaveClass(
'page-tabs'
)
expect(screen.getByLabelText('搜索文档').closest('label')).toHaveClass(
'knowledge-documents__search'
)
@@ -331,6 +341,26 @@ describe('KnowledgeWorkspace', () => {
).toBeInTheDocument()
})
it('keeps loading distinct from the first-library empty state', () => {
render(
<KnowledgeWorkspace
{...createProps({
libraries: [],
loading: true,
selectedLibraryId: undefined
})}
/>
)
expect(screen.getByText('正在加载知识库')).toBeInTheDocument()
expect(
screen.queryByText('建立第一个知识库')
).not.toBeInTheDocument()
expect(
screen.getByRole('button', { name: '新建知识库' })
).toBeDisabled()
})
it('confirms that deleting a managed library removes managed copies', async () => {
const onDeleteLibrary = vi.fn()
render(
+228 -194
View File
@@ -30,6 +30,12 @@ import {
useRef,
useState
} from 'react'
import {
EmptyState,
PageHeader,
PageTabs,
type PageTab
} from './WorkspacePrimitives'
export type KnowledgeStorageMode = 'reference' | 'managed'
export type KnowledgeGraphStrategy =
@@ -242,64 +248,51 @@ const styles = {
workspace: {
display: 'grid',
overflow: 'hidden',
border: '1px solid #d9d9d9',
borderRadius: 8,
background: '#f5f5f5',
color: '#1f1f1f',
boxShadow: '0 2px 8px rgba(0, 0, 0, .06)'
border: '1px solid var(--border-default)',
borderRadius: 'var(--radius-card)',
background: 'var(--surface-canvas)',
color: 'var(--text-primary)',
boxShadow: 'var(--shadow-card)'
},
sidebar: {
display: 'flex',
flexDirection: 'column' as const,
gap: 16,
background: '#fafafa'
background: 'var(--surface-subtle)'
},
surface: {
border: '1px solid #d9d9d9',
borderRadius: 8,
background: '#ffffff'
border: '1px solid var(--border-default)',
borderRadius: 'var(--radius-control)',
background: 'var(--surface-raised)'
},
button: {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: 7,
minHeight: 36,
padding: '8px 12px',
border: '1px solid #d9d9d9',
borderRadius: 6,
background: '#ffffff',
color: '#1f1f1f',
cursor: 'pointer',
font: 'inherit'
},
primaryButton: {
background: '#1677ff',
borderColor: '#1677ff',
color: '#ffffff',
fontWeight: 700
},
input: {
width: '100%',
boxSizing: 'border-box' as const,
minHeight: 40,
padding: '9px 11px',
border: '1px solid #d9d9d9',
borderRadius: 6,
border: '1px solid var(--border-control)',
borderRadius: 'var(--radius-control)',
outline: 'none',
background: '#ffffff',
color: '#1f1f1f',
background: 'var(--surface-raised)',
color: 'var(--text-primary)',
font: 'inherit'
},
label: {
display: 'grid',
gap: 7,
color: '#595959',
color: 'var(--text-secondary)',
fontSize: 13,
fontWeight: 650
},
muted: {
color: '#8c8c8c',
color: 'var(--text-muted)',
fontSize: 13,
lineHeight: 1.55
}
@@ -380,7 +373,7 @@ function ProgressBar({
height: 5,
overflow: 'hidden',
borderRadius: 999,
background: '#f0f0f0'
background: 'var(--surface-muted)'
}}
>
<span
@@ -388,7 +381,8 @@ function ProgressBar({
display: 'block',
width: `${value}%`,
height: '100%',
background: value === 100 ? '#52c41a' : '#1677ff',
background:
value === 100 ? 'var(--success)' : 'var(--accent)',
transition: 'width .2s ease'
}}
/>
@@ -452,7 +446,7 @@ function CreateLibraryWizard({
}}
>
<div>
<span style={{ color: '#1677ff', fontSize: 12, fontWeight: 800 }}>
<span style={{ color: 'var(--accent)', fontSize: 12, fontWeight: 800 }}>
NEW KNOWLEDGE BASE
</span>
<h2 style={{ margin: '5px 0 0', fontSize: 22 }}></h2>
@@ -565,17 +559,27 @@ function CreateLibraryWizard({
</label>
)}
{error && (
<p aria-live="polite" role="alert" style={{ color: '#ff4d4f', margin: 0 }}>
<p
aria-live="polite"
role="alert"
style={{ color: 'var(--danger)', margin: 0 }}
>
{error}
</p>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button onClick={onCancel} style={styles.button} type="button">
<button
className="secondary-button"
onClick={onCancel}
style={styles.button}
type="button"
>
</button>
<button
className="primary-button"
disabled={saving}
style={{ ...styles.button, ...styles.primaryButton }}
style={styles.button}
type="submit"
>
{saving ? (
@@ -627,7 +631,7 @@ function DeleteLibraryDialog({
display: 'grid',
placeItems: 'center',
padding: 20,
background: 'rgba(0, 0, 0, .45)'
background: 'var(--overlay-backdrop)'
}}
>
<div
@@ -635,10 +639,10 @@ function DeleteLibraryDialog({
...styles.surface,
width: 'min(440px, 100%)',
padding: 20,
boxShadow: '0 6px 16px rgba(0, 0, 0, .08)'
boxShadow: 'var(--shadow-dialog)'
}}
>
<AlertCircle color="#ff4d4f" aria-hidden="true" size={26} />
<AlertCircle color="var(--danger)" aria-hidden="true" size={26} />
<h2 style={{ margin: '12px 0 8px' }}>{library.name}</h2>
<p style={{ ...styles.muted, margin: 0 }}>
{library.storageMode === 'managed'
@@ -646,7 +650,11 @@ function DeleteLibraryDialog({
: '此知识库引用原文件。删除后只会移除索引和图谱,不会删除磁盘上的原文件。'}
</p>
{error && (
<p aria-live="polite" role="alert" style={{ color: '#ff4d4f' }}>
<p
aria-live="polite"
role="alert"
style={{ color: 'var(--danger)' }}
>
{error}
</p>
)}
@@ -658,18 +666,19 @@ function DeleteLibraryDialog({
marginTop: 18
}}
>
<button disabled={deleting} onClick={onCancel} style={styles.button}>
<button
className="secondary-button"
disabled={deleting}
onClick={onCancel}
style={styles.button}
>
</button>
<button
className="danger-button"
disabled={deleting}
onClick={() => void confirm()}
style={{
...styles.button,
background: '#ff4d4f',
borderColor: '#ff4d4f',
color: '#ffffff'
}}
style={styles.button}
>
<Trash2 aria-hidden="true" size={15} />
{deleting ? '删除中…' : '确认删除'}
@@ -771,6 +780,7 @@ function DocumentsView({
</div>
<div className="knowledge-documents__import-actions">
<button
className="secondary-button"
onClick={() => fileInputRef.current?.click()}
style={styles.button}
type="button"
@@ -779,6 +789,7 @@ function DocumentsView({
</button>
<button
className="secondary-button"
onClick={() =>
void run('directory', () =>
onImportDirectory(
@@ -795,6 +806,7 @@ function DocumentsView({
</button>
<button
className="secondary-button"
onClick={() => setUrlOpen((current) => !current)}
style={styles.button}
type="button"
@@ -895,14 +907,16 @@ function DocumentsView({
value={url}
/>
<button
className="primary-button"
disabled={pending === 'url'}
style={{ ...styles.button, ...styles.primaryButton }}
style={styles.button}
type="submit"
>
</button>
<button
aria-label="关闭 URL 导入"
className="secondary-button"
onClick={() => setUrlOpen(false)}
style={styles.button}
type="button"
@@ -931,11 +945,15 @@ function DocumentsView({
style={{
marginTop: 12,
padding: 18,
border: `1px dashed ${dragging ? '#1677ff' : '#d9d9d9'}`,
border: `1px dashed ${
dragging ? 'var(--accent)' : 'var(--border-default)'
}`,
borderRadius: 8,
textAlign: 'center',
background: dragging ? '#e6f4ff' : '#fafafa',
color: dragging ? '#1677ff' : '#8c8c8c'
background: dragging
? 'var(--accent-subtle)'
: 'var(--surface-subtle)',
color: dragging ? 'var(--accent)' : 'var(--text-muted)'
}}
>
<UploadCloud aria-hidden="true" size={22} />
@@ -945,7 +963,11 @@ function DocumentsView({
</div>
{error && (
<p aria-live="polite" role="alert" style={{ color: '#ff4d4f' }}>
<p
aria-live="polite"
role="alert"
style={{ color: 'var(--danger)' }}
>
{error}
</p>
)}
@@ -1011,16 +1033,16 @@ function DocumentsView({
borderRadius: 999,
background:
source.status === 'failed'
? '#fff2f0'
? 'var(--danger-subtle)'
: source.status === 'ready'
? '#f6ffed'
: '#e6f4ff',
? 'var(--success-subtle)'
: 'var(--accent-subtle)',
color:
source.status === 'failed'
? '#ff4d4f'
? 'var(--danger)'
: source.status === 'ready'
? '#52c41a'
: '#1677ff',
? 'var(--success)'
: 'var(--accent)',
fontSize: 12
}}
>
@@ -1040,7 +1062,13 @@ function DocumentsView({
</div>
)}
{source.error && (
<div style={{ color: '#ff4d4f', fontSize: 12, marginTop: 5 }}>
<div
style={{
color: 'var(--danger)',
fontSize: 12,
marginTop: 5
}}
>
{source.error}
</div>
)}
@@ -1049,6 +1077,7 @@ function DocumentsView({
{source.status === 'syncing' ? (
<button
aria-label={`暂停 ${source.name}`}
className="secondary-button"
disabled={pending === source.id}
onClick={() =>
void run(source.id, () => onPauseSource(source.id))
@@ -1062,6 +1091,7 @@ function DocumentsView({
) : source.status === 'failed' ? (
<button
aria-label={`重试 ${source.name}`}
className="secondary-button"
disabled={pending === source.id}
onClick={() =>
void run(source.id, () => onRetrySource(source.id))
@@ -1075,6 +1105,7 @@ function DocumentsView({
) : (
<button
aria-label={`同步 ${source.name}`}
className="secondary-button"
disabled={pending === source.id}
onClick={() =>
void run(source.id, () => onSyncSource(source.id))
@@ -1088,6 +1119,7 @@ function DocumentsView({
)}
<button
aria-label={`移除来源 ${source.name}`}
className="danger-button danger-button--quiet"
disabled={pending === source.id}
onClick={() =>
void run(source.id, () => onRemoveSource(source.id))
@@ -1122,7 +1154,11 @@ function DocumentsView({
<Search
aria-hidden="true"
size={15}
style={{ position: 'absolute', left: 11, color: '#8c8c8c' }}
style={{
position: 'absolute',
left: 11,
color: 'var(--text-muted)'
}}
/>
<span style={{ position: 'absolute', clip: 'rect(0 0 0 0)' }}>
@@ -1154,9 +1190,9 @@ function DocumentsView({
>
<thead
style={{
color: '#595959',
color: 'var(--text-secondary)',
textAlign: 'left',
background: '#fafafa'
background: 'var(--surface-subtle)'
}}
>
<tr>
@@ -1172,7 +1208,7 @@ function DocumentsView({
<tr
key={document.id}
style={{
borderTop: '1px solid #f0f0f0'
borderTop: '1px solid var(--border-subtle)'
}}
>
<td style={{ padding: 10 }}>
@@ -1196,18 +1232,20 @@ function DocumentsView({
style={{
color:
document.status === 'failed'
? '#ff4d4f'
? 'var(--danger)'
: document.status === 'ready'
? '#52c41a'
? 'var(--success)'
: document.status === 'indexing'
? '#1677ff'
: '#faad14'
? 'var(--accent)'
: 'var(--warning)'
}}
>
{documentStatusLabels[document.status]}
</span>
{document.error && (
<div style={{ color: '#ff4d4f', marginTop: 4 }}>
<div
style={{ color: 'var(--danger)', marginTop: 4 }}
>
{document.error}
</div>
)}
@@ -1304,10 +1342,15 @@ function EntityEditor({
/>
</label>
<div style={{ display: 'flex', gap: 8 }}>
<button style={{ ...styles.button, ...styles.primaryButton }}>
<button className="primary-button" style={styles.button}>
{node ? '保存实体' : '新增实体'}
</button>
<button onClick={onCancel} style={styles.button} type="button">
<button
className="secondary-button"
onClick={onCancel}
style={styles.button}
type="button"
>
</button>
</div>
@@ -1414,10 +1457,15 @@ function RelationForm({
/>
</label>
<div style={{ display: 'flex', gap: 7 }}>
<button style={{ ...styles.button, ...styles.primaryButton }}>
<button className="primary-button" style={styles.button}>
{relation ? '保存关系' : '新增关系'}
</button>
<button onClick={onCancel} style={styles.button} type="button">
<button
className="secondary-button"
onClick={onCancel}
style={styles.button}
type="button"
>
</button>
</div>
@@ -1586,6 +1634,7 @@ function GraphView({
))}
</select>
<button
className="secondary-button"
onClick={() => {
setSelectedNodeId(undefined)
setCreatingEntity(true)
@@ -1598,6 +1647,7 @@ function GraphView({
</button>
<button
aria-label="缩小图谱"
className="secondary-button"
disabled={zoom <= 0.5}
onClick={() =>
setZoom((current) => Math.max(0.5, current - 0.15))
@@ -1609,12 +1659,17 @@ function GraphView({
</button>
<span
aria-live="polite"
style={{ minWidth: 42, color: '#8c8c8c', fontSize: 12 }}
style={{
minWidth: 42,
color: 'var(--text-muted)',
fontSize: 12
}}
>
{Math.round(zoom * 100)}%
</span>
<button
aria-label="放大图谱"
className="secondary-button"
disabled={zoom >= 2}
onClick={() =>
setZoom((current) => Math.min(2, current + 0.15))
@@ -1631,7 +1686,7 @@ function GraphView({
display: 'grid',
placeItems: 'center',
padding: 30,
color: '#8c8c8c',
color: 'var(--text-muted)',
textAlign: 'center'
}}
>
@@ -1667,7 +1722,7 @@ function GraphView({
className="knowledge-graph__svg"
style={{
width: '100%',
background: '#fafafa',
background: 'var(--surface-subtle)',
touchAction: 'none'
}}
viewBox={`0 0 ${900 / zoom} ${560 / zoom}`}
@@ -1681,7 +1736,10 @@ function GraphView({
refX="17"
refY="3.5"
>
<polygon fill="#8c8c8c" points="0 0, 7 3.5, 0 7" />
<polygon
fill="var(--text-muted)"
points="0 0, 7 3.5, 0 7"
/>
</marker>
</defs>
{visibleRelations.map((relation) => {
@@ -1694,7 +1752,7 @@ function GraphView({
<g key={relation.id}>
<line
markerEnd="url(#knowledge-arrow)"
stroke="#8c8c8c"
stroke="var(--text-muted)"
strokeWidth="1.5"
x1={source.x}
x2={target.x}
@@ -1702,7 +1760,7 @@ function GraphView({
y2={target.y}
/>
<text
fill="#595959"
fill="var(--text-secondary)"
fontSize="11"
textAnchor="middle"
x={(source.x + target.x) / 2}
@@ -1751,13 +1809,19 @@ function GraphView({
}}
>
<circle
fill={selected ? '#bae0ff' : '#e6f4ff'}
fill={
selected
? 'var(--accent-selected)'
: 'var(--accent-subtle)'
}
r={selected ? 30 : 26}
stroke={selected ? '#1677ff' : '#4096ff'}
stroke={
selected ? 'var(--accent)' : 'var(--accent-hover)'
}
strokeWidth={selected ? 3 : 2}
/>
<text
fill="#1f1f1f"
fill="var(--text-primary)"
fontSize="12"
fontWeight="700"
textAnchor="middle"
@@ -1768,7 +1832,7 @@ function GraphView({
: node.label}
</text>
<text
fill="#595959"
fill="var(--text-secondary)"
fontSize="10"
textAnchor="middle"
y="44"
@@ -1822,13 +1886,14 @@ function GraphView({
}}
>
<div>
<span style={{ color: '#1677ff', fontSize: 12 }}>
<span style={{ color: 'var(--accent)', fontSize: 12 }}>
{selectedNode.type}
</span>
<h3 style={{ margin: '4px 0 0' }}>{selectedNode.label}</h3>
</div>
<button
aria-label="关闭实体详情"
className="secondary-button"
onClick={() => setSelectedNodeId(undefined)}
style={{ ...styles.button, padding: 7 }}
type="button"
@@ -1860,6 +1925,7 @@ function GraphView({
)}
<div style={{ display: 'flex', gap: 7 }}>
<button
className="secondary-button"
onClick={() => setEditingEntity(true)}
style={styles.button}
type="button"
@@ -1868,6 +1934,7 @@ function GraphView({
</button>
<button
className="danger-button danger-button--quiet"
onClick={() => void onDeleteEntity(selectedNode.id)}
style={styles.button}
type="button"
@@ -1883,7 +1950,7 @@ function GraphView({
style={{
margin: '16px 0',
border: 0,
borderTop: '1px solid #f0f0f0'
borderTop: '1px solid var(--border-subtle)'
}}
/>
<div
@@ -1895,6 +1962,7 @@ function GraphView({
>
<strong></strong>
<button
className="secondary-button"
onClick={() => setRelationForm('new')}
style={{ ...styles.button, padding: '6px 9px' }}
type="button"
@@ -1964,6 +2032,7 @@ function GraphView({
<div style={{ display: 'flex', gap: 6, marginTop: 7 }}>
<button
aria-label={`编辑关系 ${relation.type}`}
className="secondary-button"
onClick={() => setRelationForm(relation)}
style={{ ...styles.button, padding: 6 }}
type="button"
@@ -1972,6 +2041,7 @@ function GraphView({
</button>
<button
aria-label={`删除关系 ${relation.type}`}
className="danger-button danger-button--quiet"
onClick={() => void onDeleteRelation(relation.id)}
style={{ ...styles.button, padding: 6 }}
type="button"
@@ -1980,6 +2050,7 @@ function GraphView({
</button>
{other && (
<button
className="secondary-button"
onClick={() => setSelectedNodeId(other.id)}
style={{
...styles.button,
@@ -2018,6 +2089,7 @@ function GraphView({
</select>
<button
aria-label="合并到目标实体"
className="secondary-button"
disabled={!mergeTargetId}
onClick={() => {
void onMergeEntities(selectedNode.id, mergeTargetId)
@@ -2034,7 +2106,7 @@ function GraphView({
style={{
margin: '16px 0',
border: 0,
borderTop: '1px solid #f0f0f0'
borderTop: '1px solid var(--border-subtle)'
}}
/>
<strong> ({selectedEvidence.length})</strong>
@@ -2046,7 +2118,7 @@ function GraphView({
display: 'grid',
gap: 8,
paddingLeft: 20,
color: '#595959'
color: 'var(--text-secondary)'
}}
>
{selectedEvidence.map((item) => (
@@ -2170,6 +2242,22 @@ export function KnowledgeWorkspace({
}, [onSelectLibrary, selectedLibrary, selectedLibraryId])
const visibleTab =
selectedLibrary?.graphEnabled === false ? 'documents' : tab
const workspaceTabs: ReadonlyArray<PageTab<WorkspaceTab>> = [
{
id: 'documents',
label: '文档与来源',
icon: <FileText aria-hidden="true" size={15} />
},
...(selectedLibrary?.graphEnabled
? [
{
id: 'graph' as const,
label: '知识图谱',
icon: <Network aria-hidden="true" size={15} />
}
]
: [])
]
return (
<section
@@ -2179,34 +2267,20 @@ export function KnowledgeWorkspace({
style={styles.workspace}
>
<aside className="knowledge-workspace__sidebar" style={styles.sidebar}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 10
}}
>
<span
style={{
display: 'grid',
width: 34,
height: 34,
placeItems: 'center',
borderRadius: 10,
background: '#1677ff',
color: '#ffffff'
}}
>
<Database aria-hidden="true" size={18} />
</span>
<div>
<strong style={{ display: 'block' }}></strong>
<span style={styles.muted}>{libraries.length} </span>
</div>
</div>
<PageHeader
compact
description={`${libraries.length} 个知识库 · 跨项目共享`}
eyebrow="KNOWLEDGE"
headingId="knowledge-workspace-title"
icon={<Database size={18} />}
scope={{ kind: 'global' }}
title="知识工作区"
/>
<button
className="primary-button"
disabled={loading}
onClick={() => setCreating(true)}
style={{ ...styles.button, ...styles.primaryButton, width: '100%' }}
style={{ ...styles.button, width: '100%' }}
type="button"
>
<Plus aria-hidden="true" size={16} />
@@ -2222,12 +2296,12 @@ export function KnowledgeWorkspace({
style={{
...styles.surface,
padding: 13,
color: '#8c8c8c',
color: 'var(--text-muted)',
fontSize: 13,
lineHeight: 1.55
}}
>
使
</div>
) : (
<ul
@@ -2254,14 +2328,16 @@ export function KnowledgeWorkspace({
padding: 11,
border: `1px solid ${
selected
? '#1677ff'
? 'var(--accent)'
: 'transparent'
}`,
borderRadius: 6,
borderRadius: 'var(--radius-control)',
background: selected
? '#e6f4ff'
? 'var(--accent-subtle)'
: 'transparent',
color: selected ? '#1677ff' : '#1f1f1f',
color: selected
? 'var(--accent)'
: 'var(--text-primary)',
textAlign: 'left',
cursor: 'pointer'
}}
@@ -2306,47 +2382,38 @@ export function KnowledgeWorkspace({
<main
className="knowledge-workspace__main"
style={{ minWidth: 0, background: '#ffffff' }}
style={{ minWidth: 0, background: 'var(--surface-raised)' }}
>
{creating ? (
{loading ? (
<EmptyState
description="正在读取知识库、来源和索引状态。"
icon={<LoaderCircle size={28} />}
level="page"
title="正在加载知识库"
/>
) : creating ? (
<CreateLibraryWizard
onCancel={() => setCreating(false)}
onCreate={onCreateLibrary}
/>
) : !selectedLibrary ? (
<div
style={{
display: 'grid',
minHeight: 620,
placeItems: 'center',
padding: 30,
textAlign: 'center'
}}
>
<div>
<BookOpen
aria-hidden="true"
color="#1677ff"
size={42}
/>
<h2></h2>
<p style={styles.muted}>
</p>
<EmptyState
action={
<button
className="primary-button"
onClick={() => setCreating(true)}
style={{
...styles.button,
...styles.primaryButton,
marginTop: 8
}}
style={styles.button}
type="button"
>
<Plus aria-hidden="true" size={16} />
</button>
</div>
</div>
}
description="集中组织文件、目录和网页来源,并生成可追溯、可跨项目使用的索引与图谱。"
icon={<BookOpen size={34} />}
level="page"
title="建立第一个知识库"
/>
) : (
<>
<header
@@ -2358,7 +2425,7 @@ export function KnowledgeWorkspace({
display: 'flex',
alignItems: 'center',
gap: 7,
color: '#1677ff',
color: 'var(--accent)',
fontSize: 12,
fontWeight: 750
}}
@@ -2383,7 +2450,7 @@ export function KnowledgeWorkspace({
style={{
display: 'flex',
alignItems: 'center',
color: '#595959',
color: 'var(--text-secondary)',
fontSize: 12,
gap: 6
}}
@@ -2424,6 +2491,7 @@ export function KnowledgeWorkspace({
)}
<button
aria-label={`删除知识库 ${selectedLibrary.name}`}
className="danger-button danger-button--quiet"
onClick={() => setDeletingLibrary(selectedLibrary)}
style={styles.button}
type="button"
@@ -2433,55 +2501,21 @@ export function KnowledgeWorkspace({
</button>
</div>
</header>
<div
aria-label="知识库视图"
className="knowledge-workspace__tabs"
role="tablist"
>
<button
aria-selected={visibleTab === 'documents'}
onClick={() => setTab('documents')}
role="tab"
style={{
...styles.button,
background:
visibleTab === 'documents'
? '#e6f4ff'
: 'transparent',
borderColor:
visibleTab === 'documents' ? '#1677ff' : 'transparent',
color:
visibleTab === 'documents' ? '#1677ff' : '#595959'
}}
type="button"
>
<FileText aria-hidden="true" size={15} />
</button>
{selectedLibrary.graphEnabled && (
<button
aria-selected={visibleTab === 'graph'}
onClick={() => setTab('graph')}
role="tab"
style={{
...styles.button,
background:
visibleTab === 'graph'
? '#e6f4ff'
: 'transparent',
borderColor:
visibleTab === 'graph' ? '#1677ff' : 'transparent',
color:
visibleTab === 'graph' ? '#1677ff' : '#595959'
}}
type="button"
>
<Network aria-hidden="true" size={15} />
</button>
)}
<div className="knowledge-workspace__tabs">
<PageTabs
ariaLabel="知识库视图"
idPrefix="knowledge"
onChange={setTab}
tabs={workspaceTabs}
value={visibleTab}
/>
</div>
<div className="knowledge-workspace__body">
<div
aria-labelledby={`knowledge-tab-${visibleTab}`}
className="knowledge-workspace__body"
id={`knowledge-panel-${visibleTab}`}
role="tabpanel"
>
{visibleTab === 'documents' ? (
<DocumentsView
documents={libraryDocuments}
@@ -0,0 +1,217 @@
/// <reference types="node" />
import {
cleanup,
fireEvent,
render,
screen
} from '@testing-library/react'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
DestructiveConfirmActions,
EmptyState,
PageHeader,
PageShell,
PageTabs,
SegmentedControl
} from './WorkspacePrimitives'
const stylesheet = readFileSync(
join(process.cwd(), 'src', 'renderer', 'src', 'styles.css'),
'utf8'
)
function themeTokens(selector: string): Record<string, string> {
const selectorIndex = stylesheet.indexOf(selector)
const blockStart = stylesheet.indexOf('{', selectorIndex)
const blockEnd = stylesheet.indexOf('}', blockStart)
if (selectorIndex < 0 || blockStart < 0 || blockEnd < 0) {
throw new Error(`Missing token block ${selector}`)
}
const block = stylesheet.slice(blockStart + 1, blockEnd)
return Object.fromEntries(
[...block.matchAll(/--([\w-]+):\s*(#[\da-f]{3,6});/giu)].map(
([, name, value]) => [name, value]
)
)
}
function contrast(foreground: string, background: string): number {
const luminance = (hex: string): number => {
const compact = hex.slice(1)
const expanded =
compact.length === 3
? compact
.split('')
.map((value) => `${value}${value}`)
.join('')
: compact
const [red, green, blue] = expanded
.match(/../gu)!
.map((value) => Number.parseInt(value, 16) / 255)
.map((value) =>
value <= 0.04045
? value / 12.92
: ((value + 0.055) / 1.055) ** 2.4
)
return red! * 0.2126 + green! * 0.7152 + blue! * 0.0722
}
const first = luminance(foreground)
const second = luminance(background)
return (
(Math.max(first, second) + 0.05) /
(Math.min(first, second) + 0.05)
)
}
describe('WorkspacePrimitives', () => {
afterEach(() => {
cleanup()
})
it('renders a consistent page shell and scoped header', () => {
render(
<PageShell variant="dashboard">
<PageHeader
description="跨项目记录"
eyebrow="AUDIT"
headingId="page-title"
scope={{ kind: 'all-projects' }}
title="任务与活动"
/>
</PageShell>
)
expect(
screen.getByRole('heading', { level: 1, name: '任务与活动' })
).toBeInTheDocument()
expect(screen.getByText('全部项目')).toHaveClass('scope-badge')
expect(screen.getByText('全部项目').closest('.page-shell')).toHaveClass(
'page-shell--dashboard'
)
})
it('supports arrow-key tab and segmented-control selection', () => {
const onTabChange = vi.fn()
const onSegmentChange = vi.fn()
render(
<>
<PageTabs
ariaLabel="视图"
idPrefix="example"
onChange={onTabChange}
tabs={[
{ id: 'first', label: '第一个' },
{ id: 'second', label: '第二个' }
]}
value="first"
/>
<SegmentedControl
ariaLabel="筛选"
onChange={onSegmentChange}
options={[
{ value: 'all', label: '全部' },
{ value: 'failed', label: '失败' }
]}
value="all"
/>
</>
)
fireEvent.keyDown(screen.getByRole('tab', { name: '第一个' }), {
key: 'ArrowRight'
})
expect(onTabChange).toHaveBeenCalledWith('second')
fireEvent.keyDown(screen.getByRole('button', { name: '全部' }), {
key: 'End'
})
expect(onSegmentChange).toHaveBeenCalledWith('failed')
})
it('distinguishes page and section empty states', () => {
const { rerender } = render(
<EmptyState
description="创建内容后会显示在这里。"
level="page"
title="尚无内容"
/>
)
expect(screen.getByText('尚无内容').parentElement).toHaveClass(
'empty-state--page'
)
rerender(
<EmptyState
description="没有符合当前筛选的内容。"
level="section"
title="没有匹配结果"
/>
)
expect(screen.getByText('没有匹配结果').parentElement).toHaveClass(
'empty-state--section'
)
})
it('uses the shared destructive confirmation flow', () => {
const onRequestConfirm = vi.fn()
const onConfirm = vi.fn()
const onCancel = vi.fn()
const { rerender } = render(
<DestructiveConfirmActions
confirmLabel="确认删除"
confirming={false}
onCancel={onCancel}
onConfirm={onConfirm}
onRequestConfirm={onRequestConfirm}
triggerLabel="删除"
/>
)
fireEvent.click(screen.getByRole('button', { name: '删除' }))
expect(onRequestConfirm).toHaveBeenCalledOnce()
rerender(
<DestructiveConfirmActions
confirmLabel="确认删除"
confirming
message="删除此对象?"
onCancel={onCancel}
onConfirm={onConfirm}
onRequestConfirm={onRequestConfirm}
triggerLabel="删除"
/>
)
expect(screen.getByRole('button', { name: '取消' })).toHaveFocus()
fireEvent.click(screen.getByRole('button', { name: '确认删除' }))
fireEvent.click(screen.getByRole('button', { name: '取消' }))
expect(onConfirm).toHaveBeenCalledOnce()
expect(onCancel).toHaveBeenCalledOnce()
})
it.each([
[':root', 'light'],
[":root[data-theme='dark']", 'dark']
])('keeps shared %s semantic tokens contrast-safe', (selector) => {
const tokens = themeTokens(selector)
const textPairs = [
['text-muted', 'surface-raised'],
['accent', 'accent-subtle'],
['success', 'success-subtle'],
['danger', 'danger-subtle'],
['text-on-accent', 'accent-solid'],
['text-on-accent', 'danger-solid']
] as const
for (const [foreground, background] of textPairs) {
expect(
contrast(tokens[foreground]!, tokens[background]!),
`${foreground} on ${background}`
).toBeGreaterThanOrEqual(4.5)
}
expect(
contrast(tokens['border-control']!, tokens['surface-raised']!)
).toBeGreaterThanOrEqual(3)
})
})
+380
View File
@@ -0,0 +1,380 @@
import {
FolderKanban,
Globe2,
Layers3,
TriangleAlert
} from 'lucide-react'
import {
useEffect,
useRef,
type KeyboardEvent,
type ReactNode
} from 'react'
export type WorkspaceScope =
| { kind: 'global' }
| { kind: 'all-projects' }
| { kind: 'project'; projectName: string }
| { kind: 'mixed'; projectName?: string }
| { kind: 'unavailable'; explanation: string }
export type PageTab<T extends string> = {
id: T
label: string
count?: number
icon?: ReactNode
}
export type SegmentedOption<T extends string> = {
value: T
label: string
}
function nextControlIndex(
event: KeyboardEvent<HTMLButtonElement>,
currentIndex: number,
itemCount: number
): number | undefined {
if (event.key === 'Home') {
return 0
}
if (event.key === 'End') {
return itemCount - 1
}
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
return (currentIndex + 1) % itemCount
}
if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
return (currentIndex - 1 + itemCount) % itemCount
}
return undefined
}
export function PageShell({
children,
variant
}: {
children: ReactNode
variant: 'reading' | 'standard' | 'dashboard' | 'master-detail'
}): React.JSX.Element {
return (
<div
className={`workspace-panel-scroll page-shell page-shell--${variant}`}
>
{children}
</div>
)
}
export function ScopeBadge({
scope
}: {
scope: WorkspaceScope
}): React.JSX.Element {
const content =
scope.kind === 'global'
? { icon: <Globe2 size={12} />, label: '全局' }
: scope.kind === 'all-projects'
? { icon: <Layers3 size={12} />, label: '全部项目' }
: scope.kind === 'project'
? {
icon: <FolderKanban size={12} />,
label: `项目:${scope.projectName}`
}
: scope.kind === 'mixed'
? {
icon: <Layers3 size={12} />,
label: scope.projectName
? `项目:${scope.projectName} + 全局`
: '当前项目 + 全局'
}
: {
icon: <TriangleAlert size={12} />,
label: '范围不可用'
}
return (
<span
className="scope-badge"
title={scope.kind === 'unavailable' ? scope.explanation : undefined}
>
<span aria-hidden="true">{content.icon}</span>
{content.label}
</span>
)
}
export function PageHeader({
actions,
compact = false,
description,
eyebrow,
headingId,
icon,
scope,
title
}: {
actions?: ReactNode
compact?: boolean
description?: ReactNode
eyebrow?: string
headingId: string
icon?: ReactNode
scope?: WorkspaceScope
title: string
}): React.JSX.Element {
return (
<header
className={
compact ? 'page-header page-header--compact' : 'page-header'
}
>
<div className="page-header__content">
{eyebrow && <p className="eyebrow">{eyebrow}</p>}
<div className="page-header__title-row">
{icon && (
<span aria-hidden="true" className="page-header__icon">
{icon}
</span>
)}
<h1 id={headingId}>{title}</h1>
{scope && <ScopeBadge scope={scope} />}
</div>
{description && (
<p className="page-header__description">{description}</p>
)}
</div>
{actions && <div className="page-header__actions">{actions}</div>}
</header>
)
}
export function PageTabs<T extends string>({
ariaLabel,
idPrefix,
onChange,
tabs,
value
}: {
ariaLabel: string
idPrefix: string
onChange: (value: T) => void
tabs: readonly PageTab<T>[]
value: T
}): React.JSX.Element {
return (
<nav aria-label={ariaLabel} className="page-tabs" role="tablist">
{tabs.map((tab, index) => (
<button
aria-controls={`${idPrefix}-panel-${tab.id}`}
aria-selected={value === tab.id}
className={
value === tab.id
? 'page-tabs__tab page-tabs__tab--active'
: 'page-tabs__tab'
}
id={`${idPrefix}-tab-${tab.id}`}
key={tab.id}
onClick={() => onChange(tab.id)}
onKeyDown={(event) => {
const nextIndex = nextControlIndex(
event,
index,
tabs.length
)
if (nextIndex === undefined) {
return
}
event.preventDefault()
onChange(tabs[nextIndex]!.id)
const controls =
event.currentTarget.parentElement?.querySelectorAll<HTMLButtonElement>(
'[role="tab"]'
)
controls?.[nextIndex]?.focus()
}}
role="tab"
tabIndex={value === tab.id ? 0 : -1}
type="button"
>
{tab.icon && (
<span aria-hidden="true" className="page-tabs__icon">
{tab.icon}
</span>
)}
{tab.label}
{tab.count ? (
<span className="page-tabs__count">{tab.count}</span>
) : null}
</button>
))}
</nav>
)
}
export function SegmentedControl<T extends string>({
ariaLabel,
onChange,
options,
value
}: {
ariaLabel: string
onChange: (value: T) => void
options: readonly SegmentedOption<T>[]
value: T
}): React.JSX.Element {
return (
<div
aria-label={ariaLabel}
className="segmented-control"
role="group"
>
{options.map((option, index) => (
<button
aria-pressed={value === option.value}
className={
value === option.value
? 'segmented-control__option segmented-control__option--active'
: 'segmented-control__option'
}
key={option.value}
onClick={() => onChange(option.value)}
onKeyDown={(event) => {
const nextIndex = nextControlIndex(
event,
index,
options.length
)
if (nextIndex === undefined) {
return
}
event.preventDefault()
onChange(options[nextIndex]!.value)
const controls =
event.currentTarget.parentElement?.querySelectorAll<HTMLButtonElement>(
'button'
)
controls?.[nextIndex]?.focus()
}}
type="button"
>
{option.label}
</button>
))}
</div>
)
}
export function EmptyState({
action,
description,
icon,
level = 'section',
title
}: {
action?: ReactNode
description: ReactNode
icon?: ReactNode
level?: 'page' | 'section' | 'table'
title?: string
}): React.JSX.Element {
return (
<div className={`empty-state empty-state--${level}`}>
{icon && (
<span aria-hidden="true" className="empty-state__icon">
{icon}
</span>
)}
{title && <strong>{title}</strong>}
<p>{description}</p>
{action && <div className="empty-state__action">{action}</div>}
</div>
)
}
export function DestructiveConfirmActions({
cancelAriaLabel,
confirmAriaLabel,
confirmLabel,
confirming,
disabled = false,
icon,
message,
onCancel,
onConfirm,
onRequestConfirm,
triggerAriaLabel,
triggerLabel
}: {
cancelAriaLabel?: string
confirmAriaLabel?: string
confirmLabel: string
confirming: boolean
disabled?: boolean
icon?: ReactNode
message?: string
onCancel: () => void
onConfirm: () => void
onRequestConfirm: () => void
triggerAriaLabel?: string
triggerLabel: string
}): React.JSX.Element {
const cancelRef = useRef<HTMLButtonElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const wasConfirming = useRef(confirming)
useEffect(() => {
if (confirming && !wasConfirming.current) {
cancelRef.current?.focus()
} else if (
!confirming &&
wasConfirming.current &&
!triggerRef.current?.disabled
) {
triggerRef.current?.focus()
}
wasConfirming.current = confirming
}, [confirming])
return confirming ? (
<div
aria-label={confirmAriaLabel}
aria-live="assertive"
className="danger-confirm"
role="alertdialog"
>
{message && <span>{message}</span>}
<button
aria-label={cancelAriaLabel}
className="secondary-button"
disabled={disabled}
onClick={onCancel}
ref={cancelRef}
type="button"
>
</button>
<button
aria-label={confirmAriaLabel}
className="danger-button"
disabled={disabled}
onClick={onConfirm}
type="button"
>
{confirmLabel}
</button>
</div>
) : (
<button
aria-label={triggerAriaLabel}
className="danger-button danger-button--quiet"
disabled={disabled}
onClick={onRequestConfirm}
ref={triggerRef}
type="button"
>
{icon}
{triggerLabel}
</button>
)
}
File diff suppressed because it is too large Load Diff