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:
co-authored by
factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent
e8dc4d03fd
commit
09e9fbf5e2
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+466
-228
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user