feat: add remote channels and richer notes
This commit is contained in:
@@ -178,6 +178,34 @@ describe('ActivityPanel', () => {
|
||||
expect(within(item).getByText('进行中')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('groups activity by conversation in collapsible sections', () => {
|
||||
const first = makeRecord(1)
|
||||
const second = {
|
||||
...makeRecord(2),
|
||||
conversationId: first.conversationId
|
||||
}
|
||||
const { container } = render(
|
||||
<ActivityPanel
|
||||
onClear={vi.fn()}
|
||||
onOpenConversation={vi.fn()}
|
||||
records={[first, second]}
|
||||
tokenUsage={makeTokenUsage()}
|
||||
/>
|
||||
)
|
||||
|
||||
const groups =
|
||||
container.querySelectorAll<HTMLDetailsElement>(
|
||||
'details.activity-group'
|
||||
)
|
||||
expect(groups).toHaveLength(1)
|
||||
expect(groups[0]).not.toHaveAttribute('open')
|
||||
expect(within(groups[0]!).getByText('2 条活动')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(within(groups[0]!).getByText('对话:活动 1'))
|
||||
expect(groups[0]).toHaveAttribute('open')
|
||||
expect(groups[0]!.querySelectorAll('article')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('uses the shared page hierarchy and explicit global scope', () => {
|
||||
render(
|
||||
<ActivityPanel
|
||||
|
||||
@@ -132,6 +132,51 @@ function emptyMessage(filter: ActivityFilter): string {
|
||||
return '任务请求、子专家、工具调用和审批决定会显示在这里。'
|
||||
}
|
||||
|
||||
type ActivityGroup = {
|
||||
conversationId: string
|
||||
title: string
|
||||
records: ActivityRecord[]
|
||||
latestAt: number
|
||||
status: ActivityRecord['status']
|
||||
}
|
||||
|
||||
function groupActivityRecords(
|
||||
records: readonly ActivityRecord[],
|
||||
allRecords: readonly ActivityRecord[]
|
||||
): ActivityGroup[] {
|
||||
const conversationTitles = new Map<string, string>()
|
||||
for (const record of allRecords) {
|
||||
if (
|
||||
record.kind === 'request' &&
|
||||
!conversationTitles.has(record.conversationId)
|
||||
) {
|
||||
conversationTitles.set(record.conversationId, record.title)
|
||||
}
|
||||
}
|
||||
const groups = new Map<string, ActivityRecord[]>()
|
||||
for (const record of records) {
|
||||
const current = groups.get(record.conversationId) ?? []
|
||||
current.push(record)
|
||||
groups.set(record.conversationId, current)
|
||||
}
|
||||
return [...groups.entries()].map(([conversationId, items]) => {
|
||||
const request = items.find((record) => record.kind === 'request')
|
||||
const activeRecord = items.find(isActive)
|
||||
const failedRecord = items.find(isFailed)
|
||||
const status = activeRecord?.status ?? failedRecord?.status ?? 'completed'
|
||||
return {
|
||||
conversationId,
|
||||
title:
|
||||
conversationTitles.get(conversationId) ??
|
||||
request?.title ??
|
||||
items[0]!.title,
|
||||
records: items,
|
||||
latestAt: Math.max(...items.map((record) => record.createdAt)),
|
||||
status
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function ActivityPanel({
|
||||
records,
|
||||
tokenUsage,
|
||||
@@ -151,6 +196,10 @@ export function ActivityPanel({
|
||||
() => visibleRecords.filter((record) => matchesFilter(record, filter)),
|
||||
[filter, visibleRecords]
|
||||
)
|
||||
const activityGroups = useMemo(
|
||||
() => groupActivityRecords(filteredRecords, records),
|
||||
[filteredRecords, records]
|
||||
)
|
||||
const activeCount = visibleRecords.filter(isActive).length
|
||||
const failedCount = visibleRecords.filter(isFailed).length
|
||||
const tokenTotals = useMemo(
|
||||
@@ -318,46 +367,79 @@ export function ActivityPanel({
|
||||
title={filter === 'all' ? '尚无活动记录' : '没有匹配的活动'}
|
||||
/>
|
||||
) : (
|
||||
<ol className="activity-list">
|
||||
{filteredRecords.map((record, index) => {
|
||||
const time = formatTime(record.createdAt)
|
||||
<div className="activity-groups">
|
||||
{activityGroups.map((group) => {
|
||||
const groupTime = formatTime(group.latestAt)
|
||||
return (
|
||||
<li
|
||||
className={`activity-item activity-item--${record.status}`}
|
||||
key={`${record.id}-${index}`}
|
||||
<details
|
||||
className="activity-group"
|
||||
key={group.conversationId}
|
||||
open={
|
||||
group.records.some(
|
||||
(record) => isActive(record) || isFailed(record)
|
||||
)
|
||||
? true
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<article>
|
||||
<header className="activity-item__header">
|
||||
<div className="activity-item__labels">
|
||||
<span className="activity-item__kind">
|
||||
{kindLabels[record.kind]}
|
||||
</span>
|
||||
<span
|
||||
className={`status-badge activity-item__status activity-item__status--${record.status}`}
|
||||
>
|
||||
{statusLabels[record.status]}
|
||||
</span>
|
||||
</div>
|
||||
<time dateTime={time.machineReadable}>
|
||||
{time.display}
|
||||
</time>
|
||||
</header>
|
||||
<h3>{record.title}</h3>
|
||||
{record.detail.length > 0 && <p>{record.detail}</p>}
|
||||
<button
|
||||
className="activity-item__conversation"
|
||||
onClick={() =>
|
||||
onOpenConversation(record.conversationId)
|
||||
}
|
||||
type="button"
|
||||
<summary>
|
||||
<span>
|
||||
<strong>对话:{group.title}</strong>
|
||||
<small>{group.records.length} 条活动</small>
|
||||
</span>
|
||||
<span
|
||||
className={`status-badge activity-item__status activity-item__status--${group.status}`}
|
||||
>
|
||||
打开所属对话
|
||||
</button>
|
||||
</article>
|
||||
</li>
|
||||
{statusLabels[group.status]}
|
||||
</span>
|
||||
<time dateTime={groupTime.machineReadable}>
|
||||
{groupTime.display}
|
||||
</time>
|
||||
</summary>
|
||||
<ol className="activity-list">
|
||||
{group.records.map((record, index) => {
|
||||
const time = formatTime(record.createdAt)
|
||||
return (
|
||||
<li
|
||||
className={`activity-item activity-item--${record.status}`}
|
||||
key={`${record.id}-${index}`}
|
||||
>
|
||||
<article>
|
||||
<header className="activity-item__header">
|
||||
<div className="activity-item__labels">
|
||||
<span className="activity-item__kind">
|
||||
{kindLabels[record.kind]}
|
||||
</span>
|
||||
<span
|
||||
className={`status-badge activity-item__status activity-item__status--${record.status}`}
|
||||
>
|
||||
{statusLabels[record.status]}
|
||||
</span>
|
||||
</div>
|
||||
<time dateTime={time.machineReadable}>
|
||||
{time.display}
|
||||
</time>
|
||||
</header>
|
||||
<h3>{record.title}</h3>
|
||||
{record.detail.length > 0 && <p>{record.detail}</p>}
|
||||
<button
|
||||
className="activity-item__conversation"
|
||||
onClick={() =>
|
||||
onOpenConversation(record.conversationId)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
打开所属对话
|
||||
</button>
|
||||
</article>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
</details>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -41,6 +41,7 @@ const project = {
|
||||
description: '测试项目',
|
||||
rootPath: 'C:\\Users\\test',
|
||||
defaultWorkMode: 'ask' as const,
|
||||
kind: 'user' as const,
|
||||
status: 'active' as const,
|
||||
createdAt: '2026-07-31T00:00:00.000Z',
|
||||
updatedAt: '2026-07-31T00:00:00.000Z'
|
||||
@@ -272,7 +273,8 @@ const api: DesktopApi = {
|
||||
},
|
||||
conversations: {
|
||||
list: vi.fn(async () => []),
|
||||
replace: vi.fn(async () => {})
|
||||
replace: vi.fn(async () => {}),
|
||||
onChanged: vi.fn(() => () => undefined)
|
||||
},
|
||||
workspace: {
|
||||
getChanges: vi.fn(async () => ({
|
||||
@@ -444,6 +446,42 @@ const api: DesktopApi = {
|
||||
}),
|
||||
remove: vi.fn(async () => {})
|
||||
},
|
||||
magicNotes: {
|
||||
list: vi.fn(async () => ({ notes: [] })),
|
||||
get: vi.fn(async () => {
|
||||
throw new Error('not used')
|
||||
}),
|
||||
create: vi.fn(async () => {
|
||||
throw new Error('not used')
|
||||
}),
|
||||
update: vi.fn(async () => {
|
||||
throw new Error('not used')
|
||||
}),
|
||||
remove: vi.fn(async () => {}),
|
||||
createEntry: vi.fn(async () => {
|
||||
throw new Error('not used')
|
||||
}),
|
||||
updateEntry: vi.fn(async () => {
|
||||
throw new Error('not used')
|
||||
}),
|
||||
removeEntry: vi.fn(async () => {
|
||||
throw new Error('not used')
|
||||
}),
|
||||
analyze: vi.fn(async () => {
|
||||
throw new Error('not used')
|
||||
}),
|
||||
listTodos: vi.fn(async () => ({ todos: [] })),
|
||||
createTodo: vi.fn(async () => {
|
||||
throw new Error('not used')
|
||||
}),
|
||||
updateTodo: vi.fn(async () => {
|
||||
throw new Error('not used')
|
||||
}),
|
||||
removeTodo: vi.fn(async () => {}),
|
||||
analyzeTodo: vi.fn(async () => {
|
||||
throw new Error('not used')
|
||||
})
|
||||
},
|
||||
knowledge: {
|
||||
getSnapshot: vi.fn(async () => ({
|
||||
libraries: [],
|
||||
@@ -3209,6 +3247,27 @@ describe('App', () => {
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens Magic Notes as a scoped first-class workspace', async () => {
|
||||
render(<App />)
|
||||
await screen.findByText('项目:默认项目')
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '魔法笔记' })
|
||||
)
|
||||
|
||||
expect(
|
||||
await screen.findByRole('heading', { name: '魔法笔记' })
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('项目:默认项目')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: '新建笔记' })
|
||||
).toBeInTheDocument()
|
||||
expect(api.magicNotes.list).toHaveBeenCalled()
|
||||
expect(
|
||||
screen.queryByLabelText('切换助手工作栏')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('gives the knowledge workspace the full content width', async () => {
|
||||
render(<App />)
|
||||
|
||||
|
||||
+261
-33
@@ -88,7 +88,8 @@ import {
|
||||
conversationAttachmentSchema,
|
||||
conversationMessageBlocksSchema,
|
||||
interactiveWorkModes,
|
||||
normalizeInteractiveWorkMode
|
||||
normalizeInteractiveWorkMode,
|
||||
projectChannelLabels
|
||||
} from '../../shared/assistant-contracts'
|
||||
import { ActivityPanel } from './ActivityPanel'
|
||||
import { AgentQuestionCard } from './AgentQuestionCard'
|
||||
@@ -101,6 +102,7 @@ import {
|
||||
} from './activity-store'
|
||||
import { KnowledgeWorkspace } from './KnowledgeWorkspace'
|
||||
import { HeartbeatCenter } from './HeartbeatCenter'
|
||||
import { MagicNotesWorkspace } from './MagicNotesWorkspace'
|
||||
import { MarkdownRenderer } from './MarkdownRenderer'
|
||||
import { PageShell, ScopeBadge } from './WorkspacePrimitives'
|
||||
import {
|
||||
@@ -114,6 +116,7 @@ import {
|
||||
type SidebarArtifact
|
||||
} from './RightAssistantSidebar'
|
||||
import { SettingsPanel } from './SettingsPanel'
|
||||
import { RemoteChannelApprovalDialog } from './RemoteChannelApprovalDialog'
|
||||
import goodbuddyDarkIcon from './assets/goodbuddy-dark.png'
|
||||
import goodbuddyLightIcon from './assets/goodbuddy-light.png'
|
||||
import {
|
||||
@@ -130,8 +133,10 @@ import {
|
||||
startPcmRecording,
|
||||
type PcmRecording
|
||||
} from './speech-recognition'
|
||||
|
||||
type AppNotificationTone = 'success' | 'info' | 'error'
|
||||
import type {
|
||||
AppNotificationInput,
|
||||
AppNotificationTone
|
||||
} from './notifications'
|
||||
|
||||
type AppNotification = {
|
||||
id: string
|
||||
@@ -140,13 +145,7 @@ type AppNotification = {
|
||||
revision: number
|
||||
}
|
||||
|
||||
type AppNotificationAction =
|
||||
| {
|
||||
tone: AppNotificationTone
|
||||
message: string
|
||||
dedupeKey?: string
|
||||
}
|
||||
| { dismiss: string }
|
||||
type AppNotificationAction = AppNotificationInput | { dismiss: string }
|
||||
|
||||
function appNotificationReducer(
|
||||
current: AppNotification[],
|
||||
@@ -157,15 +156,23 @@ function appNotificationReducer(
|
||||
(notification) => notification.id !== action.dismiss
|
||||
)
|
||||
}
|
||||
const id = action.dedupeKey ?? `${action.tone}:${action.message}`
|
||||
const message = action.message.slice(0, 2_000)
|
||||
const id = action.dedupeKey ?? `${action.tone}:${message}`
|
||||
const existing = current.find(
|
||||
(notification) => notification.id === id
|
||||
)
|
||||
if (
|
||||
existing?.tone === 'error' &&
|
||||
action.tone === 'error' &&
|
||||
existing.message === message
|
||||
) {
|
||||
return current
|
||||
}
|
||||
const updated = [
|
||||
...current.filter((notification) => notification.id !== id),
|
||||
{
|
||||
id,
|
||||
message: action.message.slice(0, 2_000),
|
||||
message,
|
||||
tone: action.tone,
|
||||
revision: (existing?.revision ?? 0) + 1
|
||||
}
|
||||
@@ -315,6 +322,7 @@ type Conversation = {
|
||||
id: string
|
||||
projectId?: string
|
||||
runtimeSelection?: AgentRuntimeSelection
|
||||
remote?: ConversationSnapshot['remote']
|
||||
title: string
|
||||
updatedAt: number
|
||||
messages: Message[]
|
||||
@@ -333,6 +341,7 @@ type ActiveRun = {
|
||||
|
||||
type WorkspaceView =
|
||||
| 'chat'
|
||||
| 'magic-notes'
|
||||
| 'knowledge'
|
||||
| 'heartbeat'
|
||||
| 'activity'
|
||||
@@ -664,6 +673,12 @@ function isConversation(value: unknown): value is Conversation {
|
||||
(item.runtimeSelection === undefined ||
|
||||
agentRuntimeSelectionSchema.safeParse(item.runtimeSelection)
|
||||
.success) &&
|
||||
(item.remote === undefined ||
|
||||
(typeof item.remote === 'object' &&
|
||||
item.remote !== null &&
|
||||
['weixin', 'wecom', 'dingtalk'].includes(
|
||||
String((item.remote as Record<string, unknown>).channel)
|
||||
))) &&
|
||||
typeof item.title === 'string' &&
|
||||
item.title.length <= 200 &&
|
||||
typeof item.updatedAt === 'number' &&
|
||||
@@ -709,6 +724,7 @@ function toConversationSnapshots(
|
||||
id: conversation.id,
|
||||
projectId: conversation.projectId,
|
||||
runtimeSelection: conversation.runtimeSelection,
|
||||
remote: conversation.remote,
|
||||
title: conversation.title,
|
||||
updatedAt: conversation.updatedAt,
|
||||
messages: conversation.messages.slice(-500).map((message) => ({
|
||||
@@ -987,6 +1003,11 @@ function WindowControls({
|
||||
function App(): React.JSX.Element {
|
||||
const [conversations, setConversations] = useState(loadConversations)
|
||||
const [activeId, setActiveId] = useState(() => conversations[0]?.id ?? '')
|
||||
const activeConversationIdRef = useRef(activeId)
|
||||
const conversationsRef = useRef(conversations)
|
||||
const [unreadConversationIds, setUnreadConversationIds] = useState<
|
||||
Set<string>
|
||||
>(() => new Set())
|
||||
const [conversationStoreReady, setConversationStoreReady] =
|
||||
useState(false)
|
||||
const migrationConversations = useRef(conversations)
|
||||
@@ -1148,6 +1169,14 @@ function App(): React.JSX.Element {
|
||||
new Map<string, HTMLButtonElement>()
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
activeConversationIdRef.current = activeId
|
||||
}, [activeId])
|
||||
|
||||
useEffect(() => {
|
||||
conversationsRef.current = conversations
|
||||
}, [conversations])
|
||||
|
||||
useEffect(() => {
|
||||
if (!topbarMenuOpen) {
|
||||
return
|
||||
@@ -1678,6 +1707,31 @@ function App(): React.JSX.Element {
|
||||
[]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const api = window.goodbuddy.channels
|
||||
if (!api) {
|
||||
return
|
||||
}
|
||||
return api.onRemoteActivity((activity) => {
|
||||
if (activity.kind === 'result') {
|
||||
updateRequestActivity(
|
||||
activity.requestId,
|
||||
activity.status,
|
||||
activity.detail
|
||||
)
|
||||
}
|
||||
recordActivity({
|
||||
requestId: activity.requestId,
|
||||
conversationId: activity.conversationId,
|
||||
callId: activity.callId,
|
||||
kind: activity.kind,
|
||||
title: activity.title,
|
||||
detail: activity.detail,
|
||||
status: activity.status
|
||||
})
|
||||
})
|
||||
}, [recordActivity, updateRequestActivity])
|
||||
|
||||
const refreshKnowledge = useCallback(
|
||||
async (libraryId?: string): Promise<KnowledgeSnapshot> => {
|
||||
const snapshot = await window.goodbuddy.knowledge.getSnapshot(libraryId)
|
||||
@@ -2241,6 +2295,83 @@ function App(): React.JSX.Element {
|
||||
return () => clearTimeout(timeout)
|
||||
}, [conversationStoreReady, conversations])
|
||||
|
||||
useEffect(() => {
|
||||
if (!conversationStoreReady) {
|
||||
return
|
||||
}
|
||||
let active = true
|
||||
let refreshSequence = 0
|
||||
const remove = window.goodbuddy.conversations.onChanged(() => {
|
||||
const sequence = ++refreshSequence
|
||||
void window.goodbuddy.conversations
|
||||
.list()
|
||||
.then((persisted) => {
|
||||
if (!active || sequence !== refreshSequence) {
|
||||
return
|
||||
}
|
||||
const remote = persisted.filter(
|
||||
(conversation) => conversation.remote
|
||||
)
|
||||
const previousById = new Map(
|
||||
conversationsRef.current.map((conversation) => [
|
||||
conversation.id,
|
||||
conversation
|
||||
])
|
||||
)
|
||||
const updated = remote.filter((conversation) => {
|
||||
const previous = previousById.get(conversation.id)
|
||||
return (
|
||||
previous === undefined ||
|
||||
conversation.updatedAt > previous.updatedAt
|
||||
)
|
||||
})
|
||||
const unread = updated.filter(
|
||||
(conversation) =>
|
||||
conversation.id !== activeConversationIdRef.current
|
||||
)
|
||||
if (unread.length > 0) {
|
||||
setUnreadConversationIds((current) => {
|
||||
const next = new Set(current)
|
||||
unread.forEach((conversation) =>
|
||||
next.add(conversation.id)
|
||||
)
|
||||
return next
|
||||
})
|
||||
notify({
|
||||
tone: 'info',
|
||||
message: `${
|
||||
projectChannelLabels[
|
||||
unread[0]!.remote!.channel
|
||||
]
|
||||
} 收到新消息`,
|
||||
dedupeKey: 'remote-channel-message'
|
||||
})
|
||||
}
|
||||
const local = conversationsRef.current.filter(
|
||||
(conversation) => !conversation.remote
|
||||
)
|
||||
setConversations(
|
||||
[...remote, ...local].sort(
|
||||
(left, right) => right.updatedAt - left.updatedAt
|
||||
)
|
||||
)
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) {
|
||||
notify({
|
||||
tone: 'error',
|
||||
message: '远程通道会话刷新失败',
|
||||
dedupeKey: 'remote-conversation-refresh'
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
remove()
|
||||
}
|
||||
}, [conversationStoreReady])
|
||||
|
||||
useEffect(() => {
|
||||
saveActivityRecords(activityRecords)
|
||||
}, [activityRecords])
|
||||
@@ -3086,6 +3217,13 @@ function App(): React.JSX.Element {
|
||||
if (!prompt || !activeConversation) {
|
||||
return
|
||||
}
|
||||
if (activeConversation.remote) {
|
||||
notify({
|
||||
tone: 'info',
|
||||
message: '远程通道会话只能从对应消息应用继续发起'
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!runtime) {
|
||||
notify({
|
||||
tone: 'info',
|
||||
@@ -3673,6 +3811,14 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
}
|
||||
setActiveId(conversationId)
|
||||
setUnreadConversationIds((current) => {
|
||||
if (!current.has(conversationId)) {
|
||||
return current
|
||||
}
|
||||
const next = new Set(current)
|
||||
next.delete(conversationId)
|
||||
return next
|
||||
})
|
||||
setView('chat')
|
||||
}
|
||||
|
||||
@@ -3789,6 +3935,18 @@ function App(): React.JSX.Element {
|
||||
<MessageSquare size={17} />
|
||||
<span>对话</span>
|
||||
</button>
|
||||
<button
|
||||
className={
|
||||
view === 'magic-notes'
|
||||
? 'nav-item nav-item--active'
|
||||
: 'nav-item'
|
||||
}
|
||||
onClick={() => setView('magic-notes')}
|
||||
type="button"
|
||||
>
|
||||
<Sparkles size={17} />
|
||||
<span>魔法笔记</span>
|
||||
</button>
|
||||
<button
|
||||
className={
|
||||
view === 'knowledge'
|
||||
@@ -3856,10 +4014,36 @@ function App(): React.JSX.Element {
|
||||
onClick={() => {
|
||||
setConversationActionsId('')
|
||||
setActiveId(conversation.id)
|
||||
setUnreadConversationIds((current) => {
|
||||
if (!current.has(conversation.id)) {
|
||||
return current
|
||||
}
|
||||
const next = new Set(current)
|
||||
next.delete(conversation.id)
|
||||
return next
|
||||
})
|
||||
setView('chat')
|
||||
}}
|
||||
>
|
||||
<span>{conversation.title}</span>
|
||||
<span>
|
||||
{conversation.remote && (
|
||||
<b className="conversation-source-badge">
|
||||
{
|
||||
projectChannelLabels[
|
||||
conversation.remote.channel
|
||||
]
|
||||
}
|
||||
</b>
|
||||
)}
|
||||
{conversation.title}
|
||||
{unreadConversationIds.has(conversation.id) && (
|
||||
<i
|
||||
aria-label="未读"
|
||||
className="conversation-unread"
|
||||
title="未读远程消息"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<small>{formatTime(conversation.updatedAt)}</small>
|
||||
</button>
|
||||
<button
|
||||
@@ -3891,14 +4075,16 @@ function App(): React.JSX.Element {
|
||||
>
|
||||
<MoreHorizontal size={14} />
|
||||
</button>
|
||||
<button
|
||||
aria-label={`删除对话 ${conversation.title}`}
|
||||
className="conversation-delete"
|
||||
onClick={() => deleteConversation(conversation.id)}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
{!conversation.remote && (
|
||||
<button
|
||||
aria-label={`删除对话 ${conversation.title}`}
|
||||
className="conversation-delete"
|
||||
onClick={() => deleteConversation(conversation.id)}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{conversationActionsId === conversation.id && (
|
||||
<div
|
||||
@@ -3906,16 +4092,18 @@ function App(): React.JSX.Element {
|
||||
className="conversation-actions"
|
||||
id={`conversation-actions-${conversation.id}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
setConversationActionsId('')
|
||||
setRenamingConversationId(conversation.id)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Edit3 size={14} />
|
||||
重命名会话
|
||||
</button>
|
||||
{!conversation.remote && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setConversationActionsId('')
|
||||
setRenamingConversationId(conversation.id)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Edit3 size={14} />
|
||||
重命名会话
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
setConversationActionsId('')
|
||||
@@ -3941,7 +4129,8 @@ function App(): React.JSX.Element {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{renamingConversationId === conversation.id && (
|
||||
{!conversation.remote &&
|
||||
renamingConversationId === conversation.id && (
|
||||
<form
|
||||
className="conversation-rename"
|
||||
onSubmit={(event) => {
|
||||
@@ -3985,7 +4174,7 @@ function App(): React.JSX.Element {
|
||||
<X size={14} />
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{filteredConversations.length === 0 && (
|
||||
@@ -4026,6 +4215,15 @@ function App(): React.JSX.Element {
|
||||
title={activeConversation?.title}
|
||||
>
|
||||
<span>{activeConversation?.title ?? '新对话'}</span>
|
||||
{activeConversation?.remote && (
|
||||
<b className="conversation-source-badge">
|
||||
{
|
||||
projectChannelLabels[
|
||||
activeConversation.remote.channel
|
||||
]
|
||||
}
|
||||
</b>
|
||||
)}
|
||||
</div>
|
||||
<ScopeBadge
|
||||
scope={
|
||||
@@ -4624,6 +4822,24 @@ function App(): React.JSX.Element {
|
||||
</section>
|
||||
|
||||
<footer className="composer-wrap">
|
||||
{activeConversation?.remote ? (
|
||||
<div className="remote-conversation-notice">
|
||||
<MessageSquare aria-hidden="true" size={18} />
|
||||
<div>
|
||||
<strong>远程通道会话</strong>
|
||||
<span>
|
||||
请从
|
||||
{
|
||||
projectChannelLabels[
|
||||
activeConversation.remote.channel
|
||||
]
|
||||
}
|
||||
继续发送消息。本窗口用于查看历史与审批执行。
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="composer">
|
||||
{attachments.length > 0 && (
|
||||
<div className="context-list">
|
||||
@@ -5068,8 +5284,19 @@ function App(): React.JSX.Element {
|
||||
: 'Execute 模式:已启用工具自动授权,调用仍会记录到活动。')}
|
||||
{appInfo?.shortcut && ` 快捷唤起:${appInfo.shortcut}`}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</footer>
|
||||
</PageShell>
|
||||
) : view === 'magic-notes' ? (
|
||||
<PageShell variant="master-detail">
|
||||
<MagicNotesWorkspace
|
||||
key={activeProject?.id ?? 'global'}
|
||||
onNotify={notify}
|
||||
projectId={activeProject?.id}
|
||||
projectName={activeProject?.name}
|
||||
/>
|
||||
</PageShell>
|
||||
) : view === 'knowledge' ? (
|
||||
<PageShell variant="master-detail">
|
||||
<KnowledgeWorkspace
|
||||
@@ -5283,6 +5510,7 @@ function App(): React.JSX.Element {
|
||||
dispatch={notify}
|
||||
notifications={notifications}
|
||||
/>
|
||||
<RemoteChannelApprovalDialog />
|
||||
{imageViewerItem && (
|
||||
<div
|
||||
className="image-viewer-backdrop"
|
||||
|
||||
@@ -3,14 +3,25 @@ import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
within,
|
||||
waitFor
|
||||
} from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ChannelSettingsSnapshot } from '../../shared/channel-settings-contracts'
|
||||
import type { DesktopApi } from '../../shared/contracts'
|
||||
import type {
|
||||
AssistantProject,
|
||||
ProjectCreateInput
|
||||
} from '../../shared/assistant-contracts'
|
||||
import { ChannelSettingsSection } from './ChannelSettingsSection'
|
||||
|
||||
const snapshot: ChannelSettingsSnapshot = {
|
||||
weixin: {
|
||||
enabled: false,
|
||||
bindingConfigured: false,
|
||||
source: 'none',
|
||||
status: { state: 'disabled' }
|
||||
},
|
||||
wecom: {
|
||||
enabled: false,
|
||||
botId: '',
|
||||
@@ -33,6 +44,42 @@ const snapshot: ChannelSettingsSnapshot = {
|
||||
}
|
||||
}
|
||||
|
||||
const projects: AssistantProject[] = [
|
||||
['weixin', '微信 ClawBot'],
|
||||
['wecom', '企业微信'],
|
||||
['dingtalk', '钉钉']
|
||||
].map(([channel, name], index) => ({
|
||||
id: `00000000-0000-4000-8000-00000000000${index + 1}`,
|
||||
name: name!,
|
||||
description: `${name}通道项目`,
|
||||
rootPath: 'C:\\Users\\tester',
|
||||
defaultWorkMode: 'ask',
|
||||
kind: 'channel',
|
||||
channel: channel as 'weixin' | 'wecom' | 'dingtalk',
|
||||
status: 'active',
|
||||
createdAt: '2026-08-04T00:00:00.000Z',
|
||||
updatedAt: '2026-08-04T00:00:00.000Z'
|
||||
}))
|
||||
|
||||
function bindingApi() {
|
||||
return {
|
||||
getWeixinBinding: vi.fn(async () => ({ status: 'stopped' as const })),
|
||||
startWeixinBinding: vi.fn(async () => ({
|
||||
status: 'starting' as const
|
||||
})),
|
||||
submitWeixinVerification: vi.fn(async () => ({
|
||||
status: 'scanned' as const
|
||||
})),
|
||||
disconnectWeixin: vi.fn(async () => ({
|
||||
status: 'stopped' as const
|
||||
})),
|
||||
onWeixinBindingChanged: vi.fn(() => () => undefined),
|
||||
respondRemoteApproval: vi.fn(async () => true),
|
||||
getPendingRemoteApprovals: vi.fn(async () => []),
|
||||
onRemoteApproval: vi.fn(() => () => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
@@ -40,6 +87,13 @@ afterEach(() => {
|
||||
|
||||
describe('ChannelSettingsSection', () => {
|
||||
it('saves editable channel settings without returning stored secrets', async () => {
|
||||
const updateProject = vi.fn(async (
|
||||
projectId: string,
|
||||
input: ProjectCreateInput
|
||||
) => ({
|
||||
...projects.find((project) => project.id === projectId)!,
|
||||
...input
|
||||
}))
|
||||
const apply = vi.fn(async () => ({
|
||||
...snapshot,
|
||||
wecom: {
|
||||
@@ -56,17 +110,28 @@ describe('ChannelSettingsSection', () => {
|
||||
configurable: true,
|
||||
value: {
|
||||
channels: {
|
||||
...bindingApi(),
|
||||
getSnapshot: vi.fn(async () => snapshot),
|
||||
apply,
|
||||
testConnection: vi.fn(async () => ({
|
||||
channel: 'wecom',
|
||||
ok: true
|
||||
}))
|
||||
},
|
||||
projects: {
|
||||
list: vi.fn(async () => projects),
|
||||
update: updateProject
|
||||
},
|
||||
settings: {
|
||||
selectWorkspace: vi.fn(async () => undefined)
|
||||
}
|
||||
} as unknown as DesktopApi
|
||||
})
|
||||
|
||||
render(<ChannelSettingsSection />)
|
||||
fireEvent.click(
|
||||
await screen.findByRole('tab', { name: '企业微信' })
|
||||
)
|
||||
fireEvent.click(
|
||||
await screen.findByRole('checkbox', {
|
||||
name: '启用企业微信通道'
|
||||
@@ -81,12 +146,32 @@ describe('ChannelSettingsSection', () => {
|
||||
fireEvent.change(screen.getByLabelText('企业微信允许的发送者 ID'), {
|
||||
target: { value: 'user-1\nuser-2\nuser-1' }
|
||||
})
|
||||
fireEvent.change(screen.getByLabelText('企业微信默认工作目录'), {
|
||||
target: { value: 'C:\\RemoteWorkspace' }
|
||||
})
|
||||
fireEvent.click(
|
||||
within(
|
||||
screen.getByRole('group', {
|
||||
name: '企业微信默认模式'
|
||||
})
|
||||
).getByRole('button', { name: '执行' })
|
||||
)
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '保存通道设置' })
|
||||
)
|
||||
expect(updateProject).toHaveBeenCalledWith(
|
||||
projects[1]!.id,
|
||||
expect.objectContaining({
|
||||
rootPath: 'C:\\RemoteWorkspace',
|
||||
defaultWorkMode: 'execute'
|
||||
})
|
||||
)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(apply).toHaveBeenCalledWith({
|
||||
weixin: {
|
||||
enabled: false
|
||||
},
|
||||
wecom: {
|
||||
enabled: true,
|
||||
botId: 'bot-1',
|
||||
@@ -100,7 +185,7 @@ describe('ChannelSettingsSection', () => {
|
||||
})
|
||||
)
|
||||
expect(screen.queryByDisplayValue('channel-secret')).toBeNull()
|
||||
expect(await screen.findByText('企业通信设置已保存并应用'))
|
||||
expect(await screen.findByText('消息通道设置已保存并应用'))
|
||||
.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -113,14 +198,25 @@ describe('ChannelSettingsSection', () => {
|
||||
configurable: true,
|
||||
value: {
|
||||
channels: {
|
||||
...bindingApi(),
|
||||
getSnapshot: vi.fn(async () => snapshot),
|
||||
apply: vi.fn(),
|
||||
testConnection
|
||||
},
|
||||
projects: {
|
||||
list: vi.fn(async () => projects),
|
||||
update: vi.fn()
|
||||
},
|
||||
settings: {
|
||||
selectWorkspace: vi.fn(async () => undefined)
|
||||
}
|
||||
} as unknown as DesktopApi
|
||||
})
|
||||
|
||||
render(<ChannelSettingsSection />)
|
||||
fireEvent.click(
|
||||
await screen.findByRole('tab', { name: '钉钉' })
|
||||
)
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', { name: '测试钉钉连接' })
|
||||
)
|
||||
@@ -133,4 +229,96 @@ describe('ChannelSettingsSection', () => {
|
||||
)
|
||||
expect(screen.getByText('钉钉连接成功')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('focuses and restores the Weixin binding trigger', async () => {
|
||||
const api = bindingApi()
|
||||
Object.defineProperty(window, 'goodbuddy', {
|
||||
configurable: true,
|
||||
value: {
|
||||
channels: {
|
||||
...api,
|
||||
getSnapshot: vi.fn(async () => snapshot),
|
||||
apply: vi.fn(),
|
||||
testConnection: vi.fn()
|
||||
},
|
||||
projects: {
|
||||
list: vi.fn(async () => projects),
|
||||
update: vi.fn()
|
||||
},
|
||||
settings: {
|
||||
selectWorkspace: vi.fn(async () => undefined)
|
||||
}
|
||||
} as unknown as DesktopApi
|
||||
})
|
||||
|
||||
render(<ChannelSettingsSection />)
|
||||
const trigger = await screen.findByRole('button', {
|
||||
name: '扫码绑定'
|
||||
})
|
||||
fireEvent.click(trigger)
|
||||
const close = await screen.findByRole('button', {
|
||||
name: '关闭微信绑定'
|
||||
})
|
||||
await waitFor(() => expect(close).toHaveFocus())
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
)
|
||||
expect(trigger).toHaveFocus()
|
||||
})
|
||||
|
||||
it('presents the three channel configurations as keyboard tabs', async () => {
|
||||
Object.defineProperty(window, 'goodbuddy', {
|
||||
configurable: true,
|
||||
value: {
|
||||
channels: {
|
||||
...bindingApi(),
|
||||
getSnapshot: vi.fn(async () => snapshot),
|
||||
apply: vi.fn(),
|
||||
testConnection: vi.fn()
|
||||
},
|
||||
projects: {
|
||||
list: vi.fn(async () => projects),
|
||||
update: vi.fn()
|
||||
},
|
||||
settings: {
|
||||
selectWorkspace: vi.fn(async () => undefined)
|
||||
}
|
||||
} as unknown as DesktopApi
|
||||
})
|
||||
|
||||
render(<ChannelSettingsSection />)
|
||||
const tablist = await screen.findByRole('tablist', {
|
||||
name: '消息通道配置'
|
||||
})
|
||||
const weixinTab = within(tablist).getByRole('tab', {
|
||||
name: '微信 ClawBot'
|
||||
})
|
||||
const wecomTab = within(tablist).getByRole('tab', {
|
||||
name: '企业微信'
|
||||
})
|
||||
const dingtalkTab = within(tablist).getByRole('tab', {
|
||||
name: '钉钉'
|
||||
})
|
||||
|
||||
expect(weixinTab).toHaveAttribute('aria-selected', 'true')
|
||||
expect(wecomTab).toHaveAttribute('tabindex', '-1')
|
||||
expect(dingtalkTab).toHaveAttribute('tabindex', '-1')
|
||||
expect(
|
||||
screen.queryByRole('checkbox', { name: '启用企业微信通道' })
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(weixinTab, { key: 'ArrowRight' })
|
||||
|
||||
expect(wecomTab).toHaveFocus()
|
||||
expect(wecomTab).toHaveAttribute('aria-selected', 'true')
|
||||
expect(screen.getByRole('tabpanel')).toHaveAttribute(
|
||||
'aria-labelledby',
|
||||
'channel-settings-tab-wecom'
|
||||
)
|
||||
expect(
|
||||
screen.getByRole('checkbox', { name: '启用企业微信通道' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,13 +1,31 @@
|
||||
import { FlaskConical, MessageSquare, Save } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
FlaskConical,
|
||||
FolderOpen,
|
||||
MessageSquare,
|
||||
Save,
|
||||
Smartphone,
|
||||
Unplug
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import QRCode from 'qrcode'
|
||||
import type {
|
||||
ChannelConnectionTestResult,
|
||||
ChannelSettingsApply,
|
||||
ChannelSettingsSnapshot,
|
||||
CredentialChannel,
|
||||
DingTalkChannelSettingsInput,
|
||||
ManagedChannel,
|
||||
WeComChannelSettingsInput
|
||||
} from '../../shared/channel-settings-contracts'
|
||||
import {
|
||||
normalizeInteractiveWorkMode,
|
||||
projectChannels,
|
||||
type AssistantProject,
|
||||
type InteractiveWorkMode,
|
||||
type ProjectChannel
|
||||
} from '../../shared/assistant-contracts'
|
||||
import type { WeixinBindingSnapshot } from '../../shared/weixin-channel-contracts'
|
||||
import { trapTabFocus } from './dialog-focus'
|
||||
import { PageTabs, SegmentedControl } from './WorkspacePrimitives'
|
||||
|
||||
type ChannelDraft = {
|
||||
enabled: boolean
|
||||
@@ -18,6 +36,21 @@ type ChannelDraft = {
|
||||
allowGroupMessages: boolean
|
||||
}
|
||||
|
||||
type ChannelProjectDraft = {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
rootPath: string
|
||||
defaultWorkMode: InteractiveWorkMode
|
||||
}
|
||||
|
||||
const channelOrder: readonly ProjectChannel[] = projectChannels
|
||||
const channelTabs = [
|
||||
{ id: 'weixin', label: '微信 ClawBot' },
|
||||
{ id: 'wecom', label: '企业微信' },
|
||||
{ id: 'dingtalk', label: '钉钉' }
|
||||
] as const
|
||||
|
||||
const emptyDraft: ChannelDraft = {
|
||||
enabled: false,
|
||||
identifier: '',
|
||||
@@ -58,7 +91,7 @@ function secretUpdate(draft: ChannelDraft) {
|
||||
}
|
||||
|
||||
function draftFromSnapshot(
|
||||
channel: ManagedChannel,
|
||||
channel: CredentialChannel,
|
||||
snapshot: ChannelSettingsSnapshot
|
||||
): ChannelDraft {
|
||||
const settings = snapshot[channel]
|
||||
@@ -84,7 +117,7 @@ function inputFor(
|
||||
draft: ChannelDraft
|
||||
): DingTalkChannelSettingsInput
|
||||
function inputFor(
|
||||
channel: ManagedChannel,
|
||||
channel: CredentialChannel,
|
||||
draft: ChannelDraft
|
||||
): WeComChannelSettingsInput | DingTalkChannelSettingsInput {
|
||||
const common = {
|
||||
@@ -98,19 +131,114 @@ function inputFor(
|
||||
: { ...common, clientId: draft.identifier.trim() }
|
||||
}
|
||||
|
||||
function projectDraftsFrom(
|
||||
projects: AssistantProject[]
|
||||
): Partial<Record<ProjectChannel, ChannelProjectDraft>> {
|
||||
return Object.fromEntries(
|
||||
projects
|
||||
.filter(
|
||||
(
|
||||
project
|
||||
): project is AssistantProject & {
|
||||
channel: ProjectChannel
|
||||
} => project.kind === 'channel' && Boolean(project.channel)
|
||||
)
|
||||
.map((project) => [
|
||||
project.channel,
|
||||
{
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
description: project.description,
|
||||
rootPath: project.rootPath,
|
||||
defaultWorkMode: normalizeInteractiveWorkMode(
|
||||
project.defaultWorkMode
|
||||
)
|
||||
}
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
function ChannelProjectControls({
|
||||
draft,
|
||||
onChange,
|
||||
onSelectRoot
|
||||
}: {
|
||||
draft: ChannelProjectDraft
|
||||
onChange: (draft: ChannelProjectDraft) => void
|
||||
onSelectRoot: () => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<section
|
||||
aria-label={`${draft.name}通道项目设置`}
|
||||
className="channel-project-settings"
|
||||
>
|
||||
<div className="channel-project-settings__identity">
|
||||
<span>通道项目</span>
|
||||
<strong>{draft.name}</strong>
|
||||
</div>
|
||||
<label className="field">
|
||||
<span>默认工作目录</span>
|
||||
<div className="channel-project-settings__root">
|
||||
<input
|
||||
aria-label={`${draft.name}默认工作目录`}
|
||||
maxLength={4_096}
|
||||
onChange={(event) =>
|
||||
onChange({ ...draft, rootPath: event.target.value })
|
||||
}
|
||||
value={draft.rootPath}
|
||||
/>
|
||||
<button
|
||||
aria-label={`选择${draft.name}默认工作目录`}
|
||||
className="secondary-button"
|
||||
onClick={onSelectRoot}
|
||||
type="button"
|
||||
>
|
||||
<FolderOpen aria-hidden="true" size={14} />
|
||||
选择
|
||||
</button>
|
||||
</div>
|
||||
<small>远程 Execute 只能在此项目目录范围内运行。</small>
|
||||
</label>
|
||||
<fieldset className="channel-work-mode">
|
||||
<legend>默认模式</legend>
|
||||
<SegmentedControl
|
||||
ariaLabel={`${draft.name}默认模式`}
|
||||
onChange={(defaultWorkMode) =>
|
||||
onChange({ ...draft, defaultWorkMode })
|
||||
}
|
||||
options={[
|
||||
{ value: 'ask', label: '对话' },
|
||||
{ value: 'execute', label: '执行' }
|
||||
]}
|
||||
value={draft.defaultWorkMode}
|
||||
/>
|
||||
<small>
|
||||
可在消息前加 /ask、/execute、对话:或执行:临时覆盖。
|
||||
</small>
|
||||
</fieldset>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ChannelEditor({
|
||||
channel,
|
||||
draft,
|
||||
onChange,
|
||||
onProjectChange,
|
||||
onSelectRoot,
|
||||
onTest,
|
||||
project,
|
||||
settings,
|
||||
testing
|
||||
}: {
|
||||
channel: ManagedChannel
|
||||
channel: CredentialChannel
|
||||
draft: ChannelDraft
|
||||
onChange: (next: ChannelDraft) => void
|
||||
onProjectChange: (next: ChannelProjectDraft) => void
|
||||
onSelectRoot: () => void
|
||||
onTest: () => void
|
||||
settings: ChannelSettingsSnapshot[ManagedChannel]
|
||||
project: ChannelProjectDraft
|
||||
settings: ChannelSettingsSnapshot[CredentialChannel]
|
||||
testing: boolean
|
||||
}): React.JSX.Element {
|
||||
const title = channel === 'wecom' ? '企业微信' : '钉钉'
|
||||
@@ -241,6 +369,12 @@ function ChannelEditor({
|
||||
<span>允许群聊中被提及时响应</span>
|
||||
</label>
|
||||
|
||||
<ChannelProjectControls
|
||||
draft={project}
|
||||
onChange={onProjectChange}
|
||||
onSelectRoot={onSelectRoot}
|
||||
/>
|
||||
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={testing}
|
||||
@@ -254,19 +388,353 @@ function ChannelEditor({
|
||||
)
|
||||
}
|
||||
|
||||
function WeixinQrDialog({
|
||||
binding,
|
||||
busy,
|
||||
onClose,
|
||||
onRestart,
|
||||
onVerify
|
||||
}: {
|
||||
binding: WeixinBindingSnapshot
|
||||
busy: boolean
|
||||
onClose: () => void
|
||||
onRestart: () => void
|
||||
onVerify: (code: string) => void
|
||||
}): React.JSX.Element {
|
||||
const [qrImage, setQrImage] = useState<{
|
||||
payload: string
|
||||
image: string
|
||||
}>()
|
||||
const [verificationCode, setVerificationCode] = useState('')
|
||||
const [now, setNow] = useState(0)
|
||||
const dialogRef = useRef<HTMLElement>(null)
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
if (busy) {
|
||||
dialogRef.current?.focus()
|
||||
} else {
|
||||
closeButtonRef.current?.focus()
|
||||
}
|
||||
})
|
||||
return () => window.cancelAnimationFrame(frame)
|
||||
}, [busy])
|
||||
|
||||
useEffect(() => {
|
||||
if (!binding.qrPayload) {
|
||||
return
|
||||
}
|
||||
let active = true
|
||||
void QRCode.toDataURL(binding.qrPayload, {
|
||||
errorCorrectionLevel: 'M',
|
||||
margin: 2,
|
||||
width: 280
|
||||
}).then((value) => {
|
||||
if (active) {
|
||||
setQrImage({
|
||||
payload: binding.qrPayload!,
|
||||
image: value
|
||||
})
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [binding.qrPayload])
|
||||
|
||||
useEffect(() => {
|
||||
const initial = window.setTimeout(() => setNow(Date.now()), 0)
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 1_000)
|
||||
return () => {
|
||||
window.clearTimeout(initial)
|
||||
window.clearInterval(timer)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent): void => {
|
||||
if (event.key === 'Escape' && !busy) {
|
||||
event.preventDefault()
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
trapTabFocus(event, dialogRef.current)
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
return () => document.removeEventListener('keydown', onKeyDown)
|
||||
}, [busy, onClose])
|
||||
|
||||
const remaining = binding.qrExpiresAt && now > 0
|
||||
? Math.max(
|
||||
0,
|
||||
Math.ceil(
|
||||
(new Date(binding.qrExpiresAt).getTime() - now) / 1_000
|
||||
)
|
||||
)
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<div className="channel-qr-backdrop">
|
||||
<section
|
||||
aria-labelledby="channel-qr-title"
|
||||
aria-modal="true"
|
||||
className="channel-qr-dialog"
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<strong id="channel-qr-title">绑定微信 ClawBot</strong>
|
||||
<small>请使用个人微信扫码。二维码不会发送到第三方页面。</small>
|
||||
</div>
|
||||
<button
|
||||
aria-label="关闭微信绑定"
|
||||
className="icon-button"
|
||||
disabled={busy}
|
||||
onClick={onClose}
|
||||
ref={closeButtonRef}
|
||||
type="button"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{(binding.status === 'starting' ||
|
||||
binding.status === 'pending' ||
|
||||
binding.status === 'scanned' ||
|
||||
binding.status === 'verification_required') && (
|
||||
<div className="channel-qr-dialog__content">
|
||||
{qrImage && qrImage.payload === binding.qrPayload ? (
|
||||
<img
|
||||
alt="微信 ClawBot 绑定二维码"
|
||||
src={qrImage.image}
|
||||
/>
|
||||
) : (
|
||||
<div className="channel-qr-dialog__placeholder">
|
||||
正在生成二维码…
|
||||
</div>
|
||||
)}
|
||||
<strong>
|
||||
{binding.status === 'scanned'
|
||||
? '已扫码,正在确认…'
|
||||
: binding.status === 'verification_required'
|
||||
? '需要输入微信验证码'
|
||||
: '等待扫码'}
|
||||
</strong>
|
||||
{remaining !== undefined && (
|
||||
<small>二维码剩余 {remaining} 秒</small>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{binding.status === 'verification_required' && (
|
||||
<form
|
||||
className="channel-verification-form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
onVerify(verificationCode)
|
||||
}}
|
||||
>
|
||||
<label className="field">
|
||||
<span>验证码</span>
|
||||
<input
|
||||
autoComplete="one-time-code"
|
||||
inputMode="numeric"
|
||||
maxLength={32}
|
||||
onChange={(event) =>
|
||||
setVerificationCode(
|
||||
event.target.value.replace(/\D/gu, '')
|
||||
)
|
||||
}
|
||||
required
|
||||
value={verificationCode}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={busy || !verificationCode}
|
||||
type="submit"
|
||||
>
|
||||
提交验证码
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{(binding.status === 'expired' ||
|
||||
binding.status === 'failed') && (
|
||||
<div className="channel-qr-dialog__failure" role="alert">
|
||||
<strong>
|
||||
{binding.status === 'expired'
|
||||
? '二维码已过期'
|
||||
: '绑定失败'}
|
||||
</strong>
|
||||
<p>{binding.detail ?? '请重新生成二维码后再试。'}</p>
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={busy}
|
||||
onClick={onRestart}
|
||||
type="button"
|
||||
>
|
||||
重新生成二维码
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WeixinChannelEditor({
|
||||
binding,
|
||||
bindingButtonRef,
|
||||
bindingOpen,
|
||||
busy,
|
||||
enabled,
|
||||
onBindingClose,
|
||||
onDisconnect,
|
||||
onEnabledChange,
|
||||
onProjectChange,
|
||||
onSelectRoot,
|
||||
onStartBinding,
|
||||
onVerify,
|
||||
project,
|
||||
settings
|
||||
}: {
|
||||
binding: WeixinBindingSnapshot
|
||||
bindingButtonRef: React.RefObject<HTMLButtonElement | null>
|
||||
bindingOpen: boolean
|
||||
busy: boolean
|
||||
enabled: boolean
|
||||
onBindingClose: () => void
|
||||
onDisconnect: () => void
|
||||
onEnabledChange: (enabled: boolean) => void
|
||||
onProjectChange: (next: ChannelProjectDraft) => void
|
||||
onSelectRoot: () => void
|
||||
onStartBinding: () => void
|
||||
onVerify: (code: string) => void
|
||||
project: ChannelProjectDraft
|
||||
settings: ChannelSettingsSnapshot['weixin']
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<article className="capability-card channel-settings-card">
|
||||
<div className="capability-card__header">
|
||||
<div>
|
||||
<strong>微信 ClawBot</strong>
|
||||
<small>
|
||||
{settings.bindingConfigured
|
||||
? `${settings.accountDisplay ?? '微信账号'} · 凭据已加密保存`
|
||||
: '尚未绑定个人微信'}
|
||||
</small>
|
||||
</div>
|
||||
<span>{statusLabels[settings.status.state]}</span>
|
||||
</div>
|
||||
|
||||
{settings.status.lastError && (
|
||||
<p className="settings-warning" role="alert">
|
||||
{settings.status.lastError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<label className="toggle-row" htmlFor="channel-weixin-enabled">
|
||||
<input
|
||||
checked={enabled}
|
||||
disabled={!settings.bindingConfigured}
|
||||
id="channel-weixin-enabled"
|
||||
onChange={(event) =>
|
||||
onEnabledChange(event.target.checked)
|
||||
}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>启用微信 ClawBot 通道</span>
|
||||
</label>
|
||||
|
||||
<div className="channel-binding-actions">
|
||||
<button
|
||||
className={
|
||||
settings.bindingConfigured
|
||||
? 'secondary-button'
|
||||
: 'primary-button'
|
||||
}
|
||||
disabled={busy}
|
||||
onClick={onStartBinding}
|
||||
ref={bindingButtonRef}
|
||||
type="button"
|
||||
>
|
||||
<Smartphone aria-hidden="true" size={14} />
|
||||
{settings.bindingConfigured ? '重新绑定' : '扫码绑定'}
|
||||
</button>
|
||||
{settings.bindingConfigured && (
|
||||
<button
|
||||
className="danger-ghost"
|
||||
disabled={busy}
|
||||
onClick={onDisconnect}
|
||||
type="button"
|
||||
>
|
||||
<Unplug aria-hidden="true" size={14} />
|
||||
断开本机绑定
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{settings.bindingConfigured && (
|
||||
<small>
|
||||
断开会删除本机保存的绑定,不保证解除微信服务端授权。
|
||||
</small>
|
||||
)}
|
||||
|
||||
<ChannelProjectControls
|
||||
draft={project}
|
||||
onChange={onProjectChange}
|
||||
onSelectRoot={onSelectRoot}
|
||||
/>
|
||||
</article>
|
||||
{bindingOpen && (
|
||||
<WeixinQrDialog
|
||||
binding={binding}
|
||||
busy={busy}
|
||||
onClose={onBindingClose}
|
||||
onRestart={onStartBinding}
|
||||
onVerify={onVerify}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChannelSettingsSection(): React.JSX.Element {
|
||||
const [snapshot, setSnapshot] = useState<ChannelSettingsSnapshot>()
|
||||
const [drafts, setDrafts] = useState<Record<ManagedChannel, ChannelDraft>>({
|
||||
const [projects, setProjects] = useState<
|
||||
Partial<Record<ProjectChannel, ChannelProjectDraft>>
|
||||
>({})
|
||||
const [weixinEnabled, setWeixinEnabled] = useState(false)
|
||||
const [binding, setBinding] = useState<WeixinBindingSnapshot>({
|
||||
status: 'stopped'
|
||||
})
|
||||
const [bindingOpen, setBindingOpen] = useState(false)
|
||||
const [activeChannel, setActiveChannel] =
|
||||
useState<ProjectChannel>('weixin')
|
||||
const [drafts, setDrafts] = useState<
|
||||
Record<CredentialChannel, ChannelDraft>
|
||||
>({
|
||||
wecom: { ...emptyDraft },
|
||||
dingtalk: { ...emptyDraft }
|
||||
})
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [testing, setTesting] = useState<ManagedChannel>()
|
||||
const [testing, setTesting] = useState<CredentialChannel>()
|
||||
const [error, setError] = useState<string>()
|
||||
const [notice, setNotice] = useState<string>()
|
||||
const bindingButtonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const closeBinding = useCallback((): void => {
|
||||
bindingButtonRef.current?.focus()
|
||||
setBindingOpen(false)
|
||||
}, [])
|
||||
|
||||
const applySnapshot = (next: ChannelSettingsSnapshot): void => {
|
||||
setSnapshot(next)
|
||||
setWeixinEnabled(next.weixin.enabled)
|
||||
setDrafts({
|
||||
wecom: draftFromSnapshot('wecom', next),
|
||||
dingtalk: draftFromSnapshot('dingtalk', next)
|
||||
@@ -278,33 +746,59 @@ export function ChannelSettingsSection(): React.JSX.Element {
|
||||
let active = true
|
||||
void (async () => {
|
||||
if (!api) {
|
||||
throw new Error('当前版本未提供企业通信设置服务')
|
||||
throw new Error('当前版本未提供消息通道设置服务')
|
||||
}
|
||||
return api.getSnapshot()
|
||||
return Promise.all([
|
||||
api.getSnapshot(),
|
||||
window.goodbuddy.projects.list(false),
|
||||
api.getWeixinBinding()
|
||||
])
|
||||
})()
|
||||
.then((next) => {
|
||||
.then(([next, projectList, bindingSnapshot]) => {
|
||||
if (active) {
|
||||
applySnapshot(next)
|
||||
setProjects(projectDraftsFrom(projectList))
|
||||
setBinding(bindingSnapshot)
|
||||
}
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (active) {
|
||||
setError(
|
||||
reason instanceof Error ? reason.message : '读取企业通信设置失败'
|
||||
reason instanceof Error ? reason.message : '读取消息通道设置失败'
|
||||
)
|
||||
}
|
||||
})
|
||||
const removeBindingListener = api?.onWeixinBindingChanged(
|
||||
(next) => {
|
||||
if (active) {
|
||||
setBinding(next)
|
||||
if (next.status === 'connected') {
|
||||
closeBinding()
|
||||
void api.getSnapshot().then(applySnapshot)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
return () => {
|
||||
active = false
|
||||
removeBindingListener?.()
|
||||
}
|
||||
}, [])
|
||||
}, [closeBinding])
|
||||
|
||||
const save = async (): Promise<void> => {
|
||||
const api = window.goodbuddy.channels
|
||||
if (!api || !snapshot) {
|
||||
return
|
||||
}
|
||||
const channelProjects = channelOrder.map(
|
||||
(channel) => projects[channel]
|
||||
)
|
||||
if (channelProjects.some((project) => !project)) {
|
||||
setError('通道项目尚未加载')
|
||||
return
|
||||
}
|
||||
const input: ChannelSettingsApply = {
|
||||
weixin: { enabled: weixinEnabled },
|
||||
...(snapshot.wecom.readOnly
|
||||
? {}
|
||||
: { wecom: inputFor('wecom', drafts.wecom) }),
|
||||
@@ -312,24 +806,120 @@ export function ChannelSettingsSection(): React.JSX.Element {
|
||||
? {}
|
||||
: { dingtalk: inputFor('dingtalk', drafts.dingtalk) })
|
||||
}
|
||||
if (!input.wecom && !input.dingtalk) {
|
||||
setError('所有通道均由环境变量管理,不能在设置中修改')
|
||||
setBusy(true)
|
||||
setError(undefined)
|
||||
setNotice(undefined)
|
||||
try {
|
||||
const updatedProjects = await Promise.all(
|
||||
channelProjects.map((project) =>
|
||||
window.goodbuddy.projects.update(project!.id, {
|
||||
name: project!.name,
|
||||
description: project!.description,
|
||||
rootPath: project!.rootPath,
|
||||
defaultWorkMode: project!.defaultWorkMode
|
||||
})
|
||||
)
|
||||
)
|
||||
setProjects(projectDraftsFrom(updatedProjects))
|
||||
applySnapshot(await api.apply(input))
|
||||
setNotice('消息通道设置已保存并应用')
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : '保存消息通道设置失败')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const updateProject = (
|
||||
channel: ProjectChannel,
|
||||
next: ChannelProjectDraft
|
||||
): void => {
|
||||
setProjects((current) => ({ ...current, [channel]: next }))
|
||||
}
|
||||
|
||||
const selectRoot = async (
|
||||
channel: ProjectChannel
|
||||
): Promise<void> => {
|
||||
const project = projects[channel]
|
||||
if (!project) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const rootPath = await window.goodbuddy.settings.selectWorkspace()
|
||||
if (rootPath) {
|
||||
updateProject(channel, { ...project, rootPath })
|
||||
}
|
||||
} catch (reason) {
|
||||
setError(
|
||||
reason instanceof Error ? reason.message : '选择工作目录失败'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const startBinding = async (): Promise<void> => {
|
||||
const api = window.goodbuddy.channels
|
||||
if (!api) {
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setError(undefined)
|
||||
setBindingOpen(true)
|
||||
try {
|
||||
setBinding(await api.startWeixinBinding())
|
||||
} catch (reason) {
|
||||
setError(
|
||||
reason instanceof Error ? reason.message : '启动微信绑定失败'
|
||||
)
|
||||
setBinding({
|
||||
status: 'failed',
|
||||
detail:
|
||||
reason instanceof Error ? reason.message : '启动微信绑定失败'
|
||||
})
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const verifyBinding = async (code: string): Promise<void> => {
|
||||
const api = window.goodbuddy.channels
|
||||
if (!api) {
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setError(undefined)
|
||||
try {
|
||||
setBinding(await api.submitWeixinVerification(code))
|
||||
} catch (reason) {
|
||||
setError(
|
||||
reason instanceof Error ? reason.message : '提交微信验证码失败'
|
||||
)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const disconnectWeixin = async (): Promise<void> => {
|
||||
const api = window.goodbuddy.channels
|
||||
if (!api) {
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setError(undefined)
|
||||
setNotice(undefined)
|
||||
try {
|
||||
applySnapshot(await api.apply(input))
|
||||
setNotice('企业通信设置已保存并应用')
|
||||
setBinding(await api.disconnectWeixin())
|
||||
applySnapshot(await api.getSnapshot())
|
||||
setNotice('已删除本机保存的微信绑定')
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : '保存企业通信设置失败')
|
||||
setError(
|
||||
reason instanceof Error ? reason.message : '断开微信绑定失败'
|
||||
)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const test = async (channel: ManagedChannel): Promise<void> => {
|
||||
const test = async (channel: CredentialChannel): Promise<void> => {
|
||||
const api = window.goodbuddy.channels
|
||||
if (!api || !snapshot) {
|
||||
return
|
||||
@@ -356,11 +946,19 @@ export function ChannelSettingsSection(): React.JSX.Element {
|
||||
}
|
||||
}
|
||||
|
||||
if (!snapshot) {
|
||||
const weixinProject = projects.weixin
|
||||
const wecomProject = projects.wecom
|
||||
const dingtalkProject = projects.dingtalk
|
||||
if (
|
||||
!snapshot ||
|
||||
!weixinProject ||
|
||||
!wecomProject ||
|
||||
!dingtalkProject
|
||||
) {
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<p className={error ? 'settings-warning' : 'settings-empty'}>
|
||||
{error ?? '正在读取企业通信设置…'}
|
||||
{error ?? '正在读取消息通道设置…'}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
@@ -374,8 +972,10 @@ export function ChannelSettingsSection(): React.JSX.Element {
|
||||
<div className="settings-section__title settings-section__title--actions">
|
||||
<MessageSquare aria-hidden="true" size={17} />
|
||||
<div>
|
||||
<strong id="channel-settings-heading">企业通信</strong>
|
||||
<small>连接企业微信与钉钉,远程消息仅以只读模式执行</small>
|
||||
<strong id="channel-settings-heading">消息通道</strong>
|
||||
<small>
|
||||
连接微信、企业微信与钉钉;远程执行始终需要电脑端逐次确认
|
||||
</small>
|
||||
</div>
|
||||
<button
|
||||
className="primary-button"
|
||||
@@ -392,28 +992,73 @@ export function ChannelSettingsSection(): React.JSX.Element {
|
||||
{error && <p className="settings-warning" role="alert">{error}</p>}
|
||||
{notice && <p className="settings-success" role="status">{notice}</p>}
|
||||
|
||||
<div className="channel-settings__grid">
|
||||
<ChannelEditor
|
||||
channel="wecom"
|
||||
draft={drafts.wecom}
|
||||
onChange={(next) =>
|
||||
setDrafts((current) => ({ ...current, wecom: next }))
|
||||
}
|
||||
onTest={() => void test('wecom')}
|
||||
settings={snapshot.wecom}
|
||||
testing={testing === 'wecom'}
|
||||
/>
|
||||
<ChannelEditor
|
||||
channel="dingtalk"
|
||||
draft={drafts.dingtalk}
|
||||
onChange={(next) =>
|
||||
setDrafts((current) => ({ ...current, dingtalk: next }))
|
||||
}
|
||||
onTest={() => void test('dingtalk')}
|
||||
settings={snapshot.dingtalk}
|
||||
testing={testing === 'dingtalk'}
|
||||
<div className="channel-settings__tabs">
|
||||
<PageTabs
|
||||
ariaLabel="消息通道配置"
|
||||
idPrefix="channel-settings"
|
||||
onChange={setActiveChannel}
|
||||
tabs={channelTabs}
|
||||
value={activeChannel}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-labelledby={`channel-settings-tab-${activeChannel}`}
|
||||
className="channel-settings__panel"
|
||||
id={`channel-settings-panel-${activeChannel}`}
|
||||
role="tabpanel"
|
||||
>
|
||||
{activeChannel === 'weixin' ? (
|
||||
<WeixinChannelEditor
|
||||
binding={binding}
|
||||
bindingButtonRef={bindingButtonRef}
|
||||
bindingOpen={bindingOpen}
|
||||
busy={busy}
|
||||
enabled={weixinEnabled}
|
||||
onBindingClose={closeBinding}
|
||||
onDisconnect={() => void disconnectWeixin()}
|
||||
onEnabledChange={setWeixinEnabled}
|
||||
onProjectChange={(next) =>
|
||||
updateProject('weixin', next)
|
||||
}
|
||||
onSelectRoot={() => void selectRoot('weixin')}
|
||||
onStartBinding={() => void startBinding()}
|
||||
onVerify={(code) => void verifyBinding(code)}
|
||||
project={weixinProject}
|
||||
settings={snapshot.weixin}
|
||||
/>
|
||||
) : activeChannel === 'wecom' ? (
|
||||
<ChannelEditor
|
||||
channel="wecom"
|
||||
draft={drafts.wecom}
|
||||
onChange={(next) =>
|
||||
setDrafts((current) => ({ ...current, wecom: next }))
|
||||
}
|
||||
onProjectChange={(next) => updateProject('wecom', next)}
|
||||
onSelectRoot={() => void selectRoot('wecom')}
|
||||
onTest={() => void test('wecom')}
|
||||
project={wecomProject}
|
||||
settings={snapshot.wecom}
|
||||
testing={testing === 'wecom'}
|
||||
/>
|
||||
) : (
|
||||
<ChannelEditor
|
||||
channel="dingtalk"
|
||||
draft={drafts.dingtalk}
|
||||
onChange={(next) =>
|
||||
setDrafts((current) => ({ ...current, dingtalk: next }))
|
||||
}
|
||||
onProjectChange={(next) =>
|
||||
updateProject('dingtalk', next)
|
||||
}
|
||||
onSelectRoot={() => void selectRoot('dingtalk')}
|
||||
onTest={() => void test('dingtalk')}
|
||||
project={dingtalkProject}
|
||||
settings={snapshot.dingtalk}
|
||||
testing={testing === 'dingtalk'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -254,8 +254,7 @@ const styles = {
|
||||
border: '1px solid var(--border-default)',
|
||||
borderRadius: 'var(--radius-card)',
|
||||
background: 'var(--surface-canvas)',
|
||||
color: 'var(--text-primary)',
|
||||
boxShadow: 'var(--shadow-card)'
|
||||
color: 'var(--text-primary)'
|
||||
},
|
||||
surface: {
|
||||
border: '1px solid var(--border-default)',
|
||||
@@ -2189,131 +2188,140 @@ export function KnowledgeWorkspace({
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-busy={loading}
|
||||
aria-label="知识工作区"
|
||||
className={`knowledge-workspace${
|
||||
mobileListOpen ? ' knowledge-workspace--mobile-list' : ''
|
||||
}`}
|
||||
style={styles.workspace}
|
||||
>
|
||||
<aside className="knowledge-workspace__sidebar">
|
||||
<PageHeader
|
||||
compact
|
||||
description={`${libraries.length} 个知识库 · 跨项目共享`}
|
||||
eyebrow="知识库"
|
||||
headingId="knowledge-workspace-title"
|
||||
icon={<Database size={18} />}
|
||||
scope={{ kind: 'global' }}
|
||||
title="知识工作区"
|
||||
/>
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
setCreating(true)
|
||||
setMobileListOpen(false)
|
||||
}}
|
||||
style={{ ...styles.button, width: '100%' }}
|
||||
type="button"
|
||||
>
|
||||
<Plus aria-hidden="true" size={16} />
|
||||
新建知识库
|
||||
</button>
|
||||
<nav
|
||||
aria-label="知识库列表"
|
||||
className="knowledge-workspace__library-nav"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
{libraries.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
...styles.surface,
|
||||
padding: 13,
|
||||
color: 'var(--text-muted)',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.55
|
||||
}}
|
||||
>
|
||||
创建知识库,集中管理可跨项目使用的来源、索引和实体关系。
|
||||
</div>
|
||||
) : (
|
||||
<ul
|
||||
style={{
|
||||
display: 'grid',
|
||||
gap: 7,
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
listStyle: 'none'
|
||||
}}
|
||||
>
|
||||
{libraries.map((library) => {
|
||||
const selected = library.id === selectedLibrary?.id
|
||||
return (
|
||||
<li key={library.id}>
|
||||
<button
|
||||
aria-current={selected ? 'page' : undefined}
|
||||
onClick={() => {
|
||||
onSelectLibrary(library.id)
|
||||
setTab('documents')
|
||||
setMobileListOpen(false)
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: 11,
|
||||
border: `1px solid ${
|
||||
selected
|
||||
? 'var(--accent)'
|
||||
: 'transparent'
|
||||
}`,
|
||||
borderRadius: 'var(--radius-control)',
|
||||
background: selected
|
||||
? 'var(--accent-subtle)'
|
||||
: 'transparent',
|
||||
color: selected
|
||||
? 'var(--accent)'
|
||||
: 'var(--text-primary)',
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 7
|
||||
<div className="knowledge-page">
|
||||
<PageHeader
|
||||
actions={
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
setCreating(true)
|
||||
setMobileListOpen(false)
|
||||
}}
|
||||
style={styles.button}
|
||||
type="button"
|
||||
>
|
||||
<Plus aria-hidden="true" size={16} />
|
||||
新建知识库
|
||||
</button>
|
||||
}
|
||||
description="集中组织文件、目录和网页来源,建立可追溯、可跨项目使用的索引与图谱。"
|
||||
eyebrow="KNOWLEDGE"
|
||||
headingId="knowledge-workspace-title"
|
||||
icon={<Database size={20} />}
|
||||
scope={{ kind: 'global' }}
|
||||
title="知识库"
|
||||
/>
|
||||
<section
|
||||
aria-busy={loading}
|
||||
aria-label="知识工作区"
|
||||
className={`knowledge-workspace${
|
||||
mobileListOpen ? ' knowledge-workspace--mobile-list' : ''
|
||||
}`}
|
||||
style={styles.workspace}
|
||||
>
|
||||
<aside className="knowledge-workspace__sidebar">
|
||||
<div className="knowledge-workspace__sidebar-heading">
|
||||
<span>
|
||||
<BookOpen aria-hidden="true" size={16} />
|
||||
<strong>知识库列表</strong>
|
||||
</span>
|
||||
<small>{libraries.length}</small>
|
||||
</div>
|
||||
<nav
|
||||
aria-label="知识库列表"
|
||||
className="knowledge-workspace__library-nav"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
{libraries.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
...styles.surface,
|
||||
padding: 13,
|
||||
color: 'var(--text-muted)',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.55
|
||||
}}
|
||||
>
|
||||
创建知识库,集中管理可跨项目使用的来源、索引和实体关系。
|
||||
</div>
|
||||
) : (
|
||||
<ul
|
||||
style={{
|
||||
display: 'grid',
|
||||
gap: 7,
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
listStyle: 'none'
|
||||
}}
|
||||
>
|
||||
{libraries.map((library) => {
|
||||
const selected = library.id === selectedLibrary?.id
|
||||
return (
|
||||
<li key={library.id}>
|
||||
<button
|
||||
aria-current={selected ? 'page' : undefined}
|
||||
onClick={() => {
|
||||
onSelectLibrary(library.id)
|
||||
setTab('documents')
|
||||
setMobileListOpen(false)
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: 11,
|
||||
border: `1px solid ${
|
||||
selected
|
||||
? 'var(--accent)'
|
||||
: 'transparent'
|
||||
}`,
|
||||
borderRadius: 'var(--radius-control)',
|
||||
background: selected
|
||||
? 'var(--accent-subtle)'
|
||||
: 'transparent',
|
||||
color: selected
|
||||
? 'var(--accent)'
|
||||
: 'var(--text-primary)',
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<BookOpen aria-hidden="true" size={15} />
|
||||
<strong
|
||||
<span
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 7
|
||||
}}
|
||||
>
|
||||
{library.name}
|
||||
</strong>
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
...styles.muted,
|
||||
display: 'block',
|
||||
marginTop: 5
|
||||
}}
|
||||
>
|
||||
{library.documentCount} 个文档 ·{' '}
|
||||
{storageModeLabels[library.storageMode]}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</nav>
|
||||
</aside>
|
||||
<BookOpen aria-hidden="true" size={15} />
|
||||
<strong
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
{library.name}
|
||||
</strong>
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
...styles.muted,
|
||||
display: 'block',
|
||||
marginTop: 5
|
||||
}}
|
||||
>
|
||||
{library.documentCount} 个文档 ·{' '}
|
||||
{storageModeLabels[library.storageMode]}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main
|
||||
className="knowledge-workspace__main"
|
||||
@@ -2502,6 +2510,7 @@ export function KnowledgeWorkspace({
|
||||
onConfirm={() => onDeleteLibrary(deletingLibrary.id)}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import Quill from 'quill'
|
||||
import type { MagicNoteRichContent } from '../../shared/magic-notes-contracts'
|
||||
|
||||
export function MagicNoteContent({
|
||||
content
|
||||
}: {
|
||||
content: MagicNoteRichContent
|
||||
}): React.JSX.Element {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const quillRef = useRef<Quill | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) {
|
||||
return
|
||||
}
|
||||
const quill = new Quill(container, {
|
||||
readOnly: true,
|
||||
theme: 'snow',
|
||||
modules: { toolbar: false }
|
||||
})
|
||||
quill.disable()
|
||||
quillRef.current = quill
|
||||
return () => {
|
||||
quillRef.current = null
|
||||
container.replaceChildren()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
quillRef.current?.setContents(content.ops, 'silent')
|
||||
}, [content])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
aria-label="笔记记录内容"
|
||||
className="magic-note-content"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
type ClipboardEvent as ReactClipboardEvent,
|
||||
type DragEvent as ReactDragEvent
|
||||
} from 'react'
|
||||
import Quill from 'quill'
|
||||
import 'quill/dist/quill.snow.css'
|
||||
import {
|
||||
MAGIC_NOTE_MAX_IMAGES,
|
||||
MAGIC_NOTE_MAX_IMAGE_BYTES,
|
||||
MAGIC_NOTE_MAX_TOTAL_IMAGE_BYTES,
|
||||
magicNoteImageDataBytes,
|
||||
type MagicNoteRichContent
|
||||
} from '../../shared/magic-notes-contracts'
|
||||
|
||||
const supportedImageTypes = new Set([
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp'
|
||||
])
|
||||
|
||||
export type MagicNoteEditorProps = {
|
||||
initialContent?: MagicNoteRichContent
|
||||
ariaDescribedBy?: string
|
||||
ariaInvalid?: boolean
|
||||
ariaLabel: string
|
||||
onChange: (content: MagicNoteRichContent) => void
|
||||
onError: (message: string) => void
|
||||
}
|
||||
|
||||
function readFileAsDataUrl(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () =>
|
||||
typeof reader.result === 'string'
|
||||
? resolve(reader.result)
|
||||
: reject(new Error('图片读取失败'))
|
||||
reader.onerror = () => reject(new Error('图片读取失败'))
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
function richContentFromQuill(quill: Quill): MagicNoteRichContent {
|
||||
return {
|
||||
version: 1,
|
||||
ops: quill.getContents().ops as MagicNoteRichContent['ops']
|
||||
}
|
||||
}
|
||||
|
||||
export function MagicNoteEditor({
|
||||
initialContent,
|
||||
ariaDescribedBy,
|
||||
ariaInvalid = false,
|
||||
ariaLabel,
|
||||
onChange,
|
||||
onError
|
||||
}: MagicNoteEditorProps): React.JSX.Element {
|
||||
const toolbarRef = useRef<HTMLDivElement>(null)
|
||||
const editorRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const quillRef = useRef<Quill | null>(null)
|
||||
const onChangeRef = useRef(onChange)
|
||||
const onErrorRef = useRef(onError)
|
||||
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange
|
||||
onErrorRef.current = onError
|
||||
}, [onChange, onError])
|
||||
|
||||
const insertImages = async (files: File[]): Promise<void> => {
|
||||
const quill = quillRef.current
|
||||
if (!quill || files.length === 0) {
|
||||
return
|
||||
}
|
||||
const currentImageData = quill
|
||||
.getContents()
|
||||
.ops.filter(
|
||||
(operation) =>
|
||||
typeof operation.insert === 'object' &&
|
||||
operation.insert !== null &&
|
||||
'image' in operation.insert
|
||||
)
|
||||
.map((operation) => {
|
||||
const insert = operation.insert as { image?: unknown }
|
||||
return typeof insert.image === 'string' ? insert.image : ''
|
||||
})
|
||||
.filter(Boolean)
|
||||
if (currentImageData.length + files.length > MAGIC_NOTE_MAX_IMAGES) {
|
||||
onErrorRef.current(
|
||||
`每条记录最多包含 ${MAGIC_NOTE_MAX_IMAGES} 张图片`
|
||||
)
|
||||
return
|
||||
}
|
||||
if (
|
||||
files.some(
|
||||
(file) =>
|
||||
!supportedImageTypes.has(file.type) ||
|
||||
file.size <= 0 ||
|
||||
file.size > MAGIC_NOTE_MAX_IMAGE_BYTES
|
||||
)
|
||||
) {
|
||||
onErrorRef.current(
|
||||
'只支持小于 2 MB 的 JPEG、PNG、GIF 或 WebP 图片'
|
||||
)
|
||||
return
|
||||
}
|
||||
const currentImageBytes = currentImageData.reduce((total, dataUrl) => {
|
||||
return total + magicNoteImageDataBytes(dataUrl)
|
||||
}, 0)
|
||||
if (
|
||||
currentImageBytes +
|
||||
files.reduce((total, file) => total + file.size, 0) >
|
||||
MAGIC_NOTE_MAX_TOTAL_IMAGE_BYTES
|
||||
) {
|
||||
onErrorRef.current('本次添加的图片总大小不能超过 8 MB')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const dataUrls = await Promise.all(files.map(readFileAsDataUrl))
|
||||
let index = quill.getSelection(true)?.index ?? quill.getLength() - 1
|
||||
for (const dataUrl of dataUrls) {
|
||||
quill.insertEmbed(index, 'image', dataUrl, 'user')
|
||||
quill.insertText(index + 1, '\n', 'user')
|
||||
index += 2
|
||||
}
|
||||
quill.setSelection(index, 0, 'silent')
|
||||
} catch (error) {
|
||||
onErrorRef.current(
|
||||
error instanceof Error ? error.message : '图片读取失败'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const toolbar = toolbarRef.current
|
||||
const editor = editorRef.current
|
||||
if (!toolbar || !editor) {
|
||||
return
|
||||
}
|
||||
const quill = new Quill(editor, {
|
||||
theme: 'snow',
|
||||
placeholder: '记录想法、会议内容或待办线索…',
|
||||
formats: [
|
||||
'header',
|
||||
'bold',
|
||||
'italic',
|
||||
'underline',
|
||||
'strike',
|
||||
'blockquote',
|
||||
'code-block',
|
||||
'code',
|
||||
'list',
|
||||
'indent',
|
||||
'align',
|
||||
'image'
|
||||
],
|
||||
modules: {
|
||||
toolbar: {
|
||||
container: toolbar,
|
||||
handlers: {
|
||||
image: () => inputRef.current?.click()
|
||||
}
|
||||
},
|
||||
history: {
|
||||
delay: 500,
|
||||
maxStack: 100,
|
||||
userOnly: true
|
||||
}
|
||||
}
|
||||
})
|
||||
quillRef.current = quill
|
||||
if (initialContent) {
|
||||
quill.setContents(initialContent.ops, 'silent')
|
||||
}
|
||||
const handleChange = (): void => {
|
||||
onChangeRef.current(richContentFromQuill(quill))
|
||||
}
|
||||
quill.on('text-change', handleChange)
|
||||
handleChange()
|
||||
return () => {
|
||||
quill.off('text-change', handleChange)
|
||||
quillRef.current = null
|
||||
}
|
||||
}, [initialContent])
|
||||
|
||||
useEffect(() => {
|
||||
const root = quillRef.current?.root
|
||||
if (!root) {
|
||||
return
|
||||
}
|
||||
root.setAttribute('aria-label', ariaLabel)
|
||||
if (ariaDescribedBy) {
|
||||
root.setAttribute('aria-describedby', ariaDescribedBy)
|
||||
} else {
|
||||
root.removeAttribute('aria-describedby')
|
||||
}
|
||||
if (ariaInvalid) {
|
||||
root.setAttribute('aria-invalid', 'true')
|
||||
} else {
|
||||
root.removeAttribute('aria-invalid')
|
||||
}
|
||||
}, [ariaDescribedBy, ariaInvalid, ariaLabel])
|
||||
|
||||
const imageFilesFromClipboard = (
|
||||
event: ReactClipboardEvent<HTMLDivElement>
|
||||
): File[] =>
|
||||
[...event.clipboardData.items]
|
||||
.filter((item) => item.kind === 'file')
|
||||
.map((item) => item.getAsFile())
|
||||
.filter((file): file is File => file !== null)
|
||||
|
||||
const imageFilesFromDrop = (
|
||||
event: ReactDragEvent<HTMLDivElement>
|
||||
): File[] => [...event.dataTransfer.files]
|
||||
|
||||
return (
|
||||
<div
|
||||
className="magic-note-editor"
|
||||
onDragOver={(event) => {
|
||||
if (event.dataTransfer.types.includes('Files')) {
|
||||
event.preventDefault()
|
||||
event.dataTransfer.dropEffect = 'copy'
|
||||
}
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
const files = imageFilesFromDrop(event)
|
||||
if (files.length > 0) {
|
||||
event.preventDefault()
|
||||
void insertImages(files)
|
||||
}
|
||||
}}
|
||||
onPaste={(event) => {
|
||||
const files = imageFilesFromClipboard(event)
|
||||
if (files.length > 0) {
|
||||
event.preventDefault()
|
||||
void insertImages(files)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div ref={toolbarRef} className="magic-note-editor__toolbar">
|
||||
<select aria-label="段落样式" className="ql-header" defaultValue="">
|
||||
<option value="1">标题 1</option>
|
||||
<option value="2">标题 2</option>
|
||||
<option value="3">标题 3</option>
|
||||
<option value="">正文</option>
|
||||
</select>
|
||||
<button aria-label="粗体" className="ql-bold" type="button" />
|
||||
<button aria-label="斜体" className="ql-italic" type="button" />
|
||||
<button aria-label="下划线" className="ql-underline" type="button" />
|
||||
<button aria-label="删除线" className="ql-strike" type="button" />
|
||||
<button
|
||||
aria-label="待办清单"
|
||||
className="ql-list"
|
||||
type="button"
|
||||
value="check"
|
||||
/>
|
||||
<button
|
||||
aria-label="项目符号列表"
|
||||
className="ql-list"
|
||||
type="button"
|
||||
value="bullet"
|
||||
/>
|
||||
<button
|
||||
aria-label="编号列表"
|
||||
className="ql-list"
|
||||
type="button"
|
||||
value="ordered"
|
||||
/>
|
||||
<button aria-label="引用" className="ql-blockquote" type="button" />
|
||||
<button aria-label="代码块" className="ql-code-block" type="button" />
|
||||
<button aria-label="插入本地图片" className="ql-image" type="button" />
|
||||
<button
|
||||
aria-label="撤销"
|
||||
type="button"
|
||||
onClick={() => quillRef.current?.history.undo()}
|
||||
>
|
||||
↶
|
||||
</button>
|
||||
<button
|
||||
aria-label="重做"
|
||||
type="button"
|
||||
onClick={() => quillRef.current?.history.redo()}
|
||||
>
|
||||
↷
|
||||
</button>
|
||||
</div>
|
||||
<div ref={editorRef} className="magic-note-editor__content" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
hidden
|
||||
multiple
|
||||
accept="image/jpeg,image/png,image/gif,image/webp"
|
||||
type="file"
|
||||
onChange={(event) => {
|
||||
const files = event.target.files
|
||||
? [...event.target.files]
|
||||
: []
|
||||
event.target.value = ''
|
||||
void insertImages(files)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor
|
||||
} from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { DesktopApi } from '../../shared/contracts'
|
||||
import type {
|
||||
MagicNoteDetail,
|
||||
MagicNotesSnapshot,
|
||||
MagicTodoItem,
|
||||
MagicTodosSnapshot
|
||||
} from '../../shared/magic-notes-contracts'
|
||||
import { MagicNotesWorkspace } from './MagicNotesWorkspace'
|
||||
|
||||
vi.mock('./MagicNoteEditor', () => ({
|
||||
MagicNoteEditor: () => <div data-testid="magic-note-editor" />
|
||||
}))
|
||||
|
||||
vi.mock('./MagicNoteContent', () => ({
|
||||
MagicNoteContent: () => <div>记录正文</div>
|
||||
}))
|
||||
|
||||
const noteId = '00000000-0000-4000-8000-000000000601'
|
||||
const entryId = '00000000-0000-4000-8000-000000000602'
|
||||
const noteTodoId = '00000000-0000-4000-8000-000000000603'
|
||||
const manualTodoId = '00000000-0000-4000-8000-000000000604'
|
||||
const secondNoteId = '00000000-0000-4000-8000-000000000608'
|
||||
const thirdNoteId = '00000000-0000-4000-8000-000000000609'
|
||||
|
||||
const detail: MagicNoteDetail = {
|
||||
id: noteId,
|
||||
projectId: '00000000-0000-4000-8000-000000000101',
|
||||
title: '发布笔记',
|
||||
preview: '整理发布清单',
|
||||
entryCount: 1,
|
||||
pinned: false,
|
||||
revision: 1,
|
||||
createdAt: '2026-08-01T00:00:00.000Z',
|
||||
updatedAt: '2026-08-01T00:01:00.000Z',
|
||||
entries: [
|
||||
{
|
||||
id: entryId,
|
||||
noteId,
|
||||
content: {
|
||||
version: 1,
|
||||
ops: [{ insert: '整理发布清单\n' }]
|
||||
},
|
||||
plainText: '整理发布清单',
|
||||
comments: [
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000605',
|
||||
kind: 'suggestion',
|
||||
content: '先核对发布材料。'
|
||||
}
|
||||
],
|
||||
analyzedAt: '2026-08-01T00:02:00.000Z',
|
||||
revision: 1,
|
||||
createdAt: '2026-08-01T00:01:00.000Z',
|
||||
updatedAt: '2026-08-01T00:02:00.000Z'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const noteTodo: MagicTodoItem = {
|
||||
id: noteTodoId,
|
||||
projectId: detail.projectId,
|
||||
noteId,
|
||||
noteTitle: detail.title,
|
||||
entryId,
|
||||
sourceIndex: 0,
|
||||
source: 'note',
|
||||
title: '核对发布材料',
|
||||
instructions: '',
|
||||
completed: false,
|
||||
comments: [],
|
||||
revision: 1,
|
||||
createdAt: '2026-08-01T00:01:00.000Z',
|
||||
updatedAt: '2026-08-01T00:02:00.000Z'
|
||||
}
|
||||
|
||||
const manualTodo: MagicTodoItem = {
|
||||
id: manualTodoId,
|
||||
projectId: detail.projectId,
|
||||
source: 'manual',
|
||||
title: '准备演示',
|
||||
instructions: '确认演示环境和样例数据。',
|
||||
completed: false,
|
||||
comments: [],
|
||||
revision: 0,
|
||||
createdAt: '2026-08-01T00:03:00.000Z',
|
||||
updatedAt: '2026-08-01T00:03:00.000Z'
|
||||
}
|
||||
|
||||
const alternateDetail = (
|
||||
id: string,
|
||||
title: string
|
||||
): MagicNoteDetail => ({
|
||||
...detail,
|
||||
id,
|
||||
title,
|
||||
preview: '',
|
||||
entryCount: 0,
|
||||
entries: []
|
||||
})
|
||||
|
||||
const summaryFromDetail = (
|
||||
note: MagicNoteDetail
|
||||
): MagicNotesSnapshot['notes'][number] => ({
|
||||
id: note.id,
|
||||
projectId: note.projectId,
|
||||
title: note.title,
|
||||
preview: note.preview,
|
||||
entryCount: note.entryCount,
|
||||
pinned: note.pinned,
|
||||
revision: note.revision,
|
||||
createdAt: note.createdAt,
|
||||
updatedAt: note.updatedAt
|
||||
})
|
||||
|
||||
const list = vi.fn<() => Promise<MagicNotesSnapshot>>()
|
||||
const get = vi.fn<(noteId: string) => Promise<MagicNoteDetail>>()
|
||||
const listTodos = vi.fn<() => Promise<MagicTodosSnapshot>>()
|
||||
const createTodo = vi.fn<DesktopApi['magicNotes']['createTodo']>()
|
||||
const updateTodo = vi.fn<DesktopApi['magicNotes']['updateTodo']>()
|
||||
const removeTodo = vi.fn<DesktopApi['magicNotes']['removeTodo']>()
|
||||
const analyzeTodo = vi.fn<DesktopApi['magicNotes']['analyzeTodo']>()
|
||||
const onNotify = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
list.mockResolvedValue({ notes: [detail] })
|
||||
get.mockResolvedValue(detail)
|
||||
listTodos.mockResolvedValue({ todos: [noteTodo, manualTodo] })
|
||||
createTodo.mockResolvedValue({
|
||||
...manualTodo,
|
||||
id: '00000000-0000-4000-8000-000000000606',
|
||||
title: '新增手动待办',
|
||||
instructions: '新增说明'
|
||||
})
|
||||
updateTodo.mockImplementation(async (input) => ({
|
||||
...(input.todoId === noteTodo.id ? noteTodo : manualTodo),
|
||||
...input,
|
||||
revision:
|
||||
(input.todoId === noteTodo.id ? noteTodo.revision : manualTodo.revision) +
|
||||
1
|
||||
}))
|
||||
removeTodo.mockResolvedValue()
|
||||
analyzeTodo.mockResolvedValue({
|
||||
...noteTodo,
|
||||
comments: [
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000607',
|
||||
kind: 'suggestion',
|
||||
content: '先补充明确的验收条件。'
|
||||
}
|
||||
],
|
||||
analyzedAt: '2026-08-01T00:04:00.000Z',
|
||||
revision: 2
|
||||
})
|
||||
Object.defineProperty(window, 'goodbuddy', {
|
||||
configurable: true,
|
||||
value: {
|
||||
magicNotes: {
|
||||
list,
|
||||
get,
|
||||
listTodos,
|
||||
createTodo,
|
||||
updateTodo,
|
||||
removeTodo,
|
||||
analyzeTodo
|
||||
}
|
||||
} as unknown as DesktopApi
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('MagicNotesWorkspace', () => {
|
||||
it('aggregates note and manual todos without AI-created todo actions', async () => {
|
||||
render(
|
||||
<MagicNotesWorkspace
|
||||
onNotify={onNotify}
|
||||
projectId={detail.projectId}
|
||||
projectName="默认项目"
|
||||
/>
|
||||
)
|
||||
|
||||
expect(await screen.findByText('先核对发布材料。')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: '创建待办' })
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '待办' }))
|
||||
expect(
|
||||
screen.getByRole('tablist', { name: '魔法笔记内容' })
|
||||
).toHaveClass('page-tabs--segmented')
|
||||
expect(await screen.findAllByText('核对发布材料')).toHaveLength(2)
|
||||
expect(screen.getByText('准备演示')).toBeInTheDocument()
|
||||
expect(screen.getByText('笔记:发布笔记')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '标记为已完成' })
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(updateTodo).toHaveBeenCalledWith({
|
||||
todoId: noteTodo.id,
|
||||
completed: true,
|
||||
expectedRevision: noteTodo.revision
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('can hide and restore the AI comments pane', async () => {
|
||||
render(
|
||||
<MagicNotesWorkspace
|
||||
onNotify={onNotify}
|
||||
projectId={detail.projectId}
|
||||
projectName="默认项目"
|
||||
/>
|
||||
)
|
||||
|
||||
const pane = await screen.findByLabelText('AI 评论')
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '关闭 AI 评论面板' })
|
||||
)
|
||||
expect(pane).not.toBeVisible()
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '显示 AI 评论' })
|
||||
)
|
||||
expect(pane).toBeVisible()
|
||||
})
|
||||
|
||||
it('keeps the selected note aligned with the latest detail request', async () => {
|
||||
const second = alternateDetail(secondNoteId, '第二篇笔记')
|
||||
const third = alternateDetail(thirdNoteId, '第三篇笔记')
|
||||
list.mockResolvedValue({
|
||||
notes: [
|
||||
summaryFromDetail(detail),
|
||||
summaryFromDetail(second),
|
||||
summaryFromDetail(third)
|
||||
]
|
||||
})
|
||||
let resolveSecond: (value: MagicNoteDetail) => void = () => undefined
|
||||
const delayedSecond = new Promise<MagicNoteDetail>((resolve) => {
|
||||
resolveSecond = resolve
|
||||
})
|
||||
get.mockImplementation((requestedId) => {
|
||||
if (requestedId === second.id) {
|
||||
return delayedSecond
|
||||
}
|
||||
return Promise.resolve(requestedId === third.id ? third : detail)
|
||||
})
|
||||
|
||||
render(
|
||||
<MagicNotesWorkspace
|
||||
onNotify={onNotify}
|
||||
projectId={detail.projectId}
|
||||
projectName="默认项目"
|
||||
/>
|
||||
)
|
||||
|
||||
await screen.findByText('记录正文')
|
||||
fireEvent.click(screen.getByText(second.title).closest('button')!)
|
||||
fireEvent.click(screen.getByText(third.title).closest('button')!)
|
||||
expect(await screen.findByDisplayValue(third.title)).toBeInTheDocument()
|
||||
|
||||
resolveSecond(second)
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText('笔记标题')).toHaveValue(third.title)
|
||||
)
|
||||
})
|
||||
|
||||
it('creates a manual todo with a dedicated title and details form', async () => {
|
||||
render(
|
||||
<MagicNotesWorkspace
|
||||
onNotify={onNotify}
|
||||
projectId={detail.projectId}
|
||||
projectName="默认项目"
|
||||
/>
|
||||
)
|
||||
|
||||
await screen.findByText('记录正文')
|
||||
fireEvent.click(screen.getByRole('tab', { name: '待办' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '新建待办' }))
|
||||
expect(createTodo).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: '创建' }))
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('请输入待办标题')
|
||||
expect(onNotify).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.change(screen.getByLabelText('待办标题'), {
|
||||
target: { value: '新增手动待办' }
|
||||
})
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
fireEvent.change(screen.getByLabelText('说明'), {
|
||||
target: { value: '新增说明' }
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '创建' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(createTodo).toHaveBeenCalledWith({
|
||||
projectId: detail.projectId,
|
||||
title: '新增手动待办',
|
||||
instructions: '新增说明'
|
||||
})
|
||||
)
|
||||
expect(onNotify).toHaveBeenCalledWith({
|
||||
tone: 'success',
|
||||
message: '待办已创建'
|
||||
})
|
||||
expect(screen.queryByText('待办已创建')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reuses the AI comments pane for selected todos', async () => {
|
||||
render(
|
||||
<MagicNotesWorkspace
|
||||
onNotify={onNotify}
|
||||
projectId={detail.projectId}
|
||||
projectName="默认项目"
|
||||
/>
|
||||
)
|
||||
|
||||
await screen.findByText('记录正文')
|
||||
fireEvent.click(screen.getByRole('tab', { name: '待办' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'AI 分析' }))
|
||||
|
||||
await waitFor(() => expect(analyzeTodo).toHaveBeenCalledWith(noteTodo.id))
|
||||
expect(
|
||||
await screen.findByText('先补充明确的验收条件。')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('clears delete confirmation before selecting the next todo', async () => {
|
||||
render(
|
||||
<MagicNotesWorkspace
|
||||
onNotify={onNotify}
|
||||
projectId={detail.projectId}
|
||||
projectName="默认项目"
|
||||
/>
|
||||
)
|
||||
|
||||
await screen.findByText('记录正文')
|
||||
fireEvent.click(screen.getByRole('tab', { name: '待办' }))
|
||||
fireEvent.click(screen.getByText('准备演示').closest('button')!)
|
||||
fireEvent.click(screen.getByRole('button', { name: '删除待办' }))
|
||||
expect(
|
||||
screen.getByText('删除“准备演示”?此操作不可撤销。')
|
||||
).toBeInTheDocument()
|
||||
listTodos.mockResolvedValue({ todos: [noteTodo] })
|
||||
fireEvent.click(
|
||||
screen.getAllByRole('button', { name: '删除待办' })[1]!
|
||||
)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(removeTodo).toHaveBeenCalledWith(manualTodo.id)
|
||||
)
|
||||
expect(
|
||||
screen.queryByText('删除“核对发布材料”?此操作不可撤销。')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -72,6 +72,12 @@ export function ProjectSwitcher({
|
||||
const activeProject = projects.find(
|
||||
(project) => project.id === activeProjectId
|
||||
)
|
||||
const userProjects = projects.filter(
|
||||
(project) => project.kind === 'user'
|
||||
)
|
||||
const channelProjects = projects.filter(
|
||||
(project) => project.kind === 'channel'
|
||||
)
|
||||
const busy = saving || archiving || deleting
|
||||
|
||||
useEffect(() => {
|
||||
@@ -188,11 +194,24 @@ export function ProjectSwitcher({
|
||||
onChange={(event) => onSelect(event.target.value)}
|
||||
value={activeProjectId}
|
||||
>
|
||||
{projects.map((project) => (
|
||||
<option key={project.id} value={project.id}>
|
||||
{project.name}
|
||||
</option>
|
||||
))}
|
||||
{userProjects.length > 0 && (
|
||||
<optgroup label="普通项目">
|
||||
{userProjects.map((project) => (
|
||||
<option key={project.id} value={project.id}>
|
||||
{project.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
{channelProjects.length > 0 && (
|
||||
<optgroup label="远程通道">
|
||||
{channelProjects.map((project) => (
|
||||
<option key={project.id} value={project.id}>
|
||||
{project.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
<button
|
||||
aria-label="新建项目"
|
||||
@@ -281,6 +300,7 @@ export function ProjectSwitcher({
|
||||
<span>名称</span>
|
||||
<input
|
||||
autoFocus={!confirmingDelete}
|
||||
disabled={busy || activeProject?.kind === 'channel'}
|
||||
maxLength={120}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({
|
||||
@@ -290,6 +310,9 @@ export function ProjectSwitcher({
|
||||
}
|
||||
value={draft.name}
|
||||
/>
|
||||
{activeProject?.kind === 'channel' && (
|
||||
<small>通道项目名称由 GoodBuddy 管理。</small>
|
||||
)}
|
||||
</label>
|
||||
<label>
|
||||
<span>说明</span>
|
||||
@@ -343,83 +366,85 @@ export function ProjectSwitcher({
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{dialogMode === 'settings' && (
|
||||
<section
|
||||
aria-labelledby="project-danger-title"
|
||||
className="project-danger-zone"
|
||||
>
|
||||
<div>
|
||||
<strong id="project-danger-title">危险操作</strong>
|
||||
<p>
|
||||
删除项目会永久移除 GoodBuddy
|
||||
中的项目、对话、任务、计划、心跳、记忆和成果,但不会删除磁盘上的项目目录或文件。
|
||||
</p>
|
||||
</div>
|
||||
{!confirmingDelete ? (
|
||||
<button
|
||||
className="danger-button danger-button--quiet"
|
||||
disabled={busy || projects.length <= 1}
|
||||
onClick={() => {
|
||||
setError(undefined)
|
||||
setDeleteConfirmation('')
|
||||
setConfirmingDelete(true)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
删除项目
|
||||
</button>
|
||||
) : (
|
||||
<div className="project-delete-confirmation">
|
||||
<label>
|
||||
<span>
|
||||
输入“{activeProject?.name}”确认删除
|
||||
</span>
|
||||
<input
|
||||
autoFocus
|
||||
disabled={busy}
|
||||
onChange={(event) =>
|
||||
setDeleteConfirmation(event.target.value)
|
||||
}
|
||||
value={deleteConfirmation}
|
||||
/>
|
||||
</label>
|
||||
<div>
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setError(undefined)
|
||||
setDeleteConfirmation('')
|
||||
setConfirmingDelete(false)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
取消删除
|
||||
</button>
|
||||
<button
|
||||
className="danger-button"
|
||||
disabled={
|
||||
busy ||
|
||||
deleteConfirmation !== activeProject?.name
|
||||
}
|
||||
onClick={() => void deleteProject()}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
{deleting ? '删除中' : '永久删除项目'}
|
||||
</button>
|
||||
</div>
|
||||
{dialogMode === 'settings' &&
|
||||
activeProject?.kind !== 'channel' && (
|
||||
<section
|
||||
aria-labelledby="project-danger-title"
|
||||
className="project-danger-zone"
|
||||
>
|
||||
<div>
|
||||
<strong id="project-danger-title">危险操作</strong>
|
||||
<p>
|
||||
删除项目会永久移除 GoodBuddy
|
||||
中的项目、对话、任务、计划、心跳、记忆和成果,但不会删除磁盘上的项目目录或文件。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{projects.length <= 1 && (
|
||||
<small>至少需要保留一个可用项目。</small>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
{!confirmingDelete ? (
|
||||
<button
|
||||
className="danger-button danger-button--quiet"
|
||||
disabled={busy || userProjects.length <= 1}
|
||||
onClick={() => {
|
||||
setError(undefined)
|
||||
setDeleteConfirmation('')
|
||||
setConfirmingDelete(true)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
删除项目
|
||||
</button>
|
||||
) : (
|
||||
<div className="project-delete-confirmation">
|
||||
<label>
|
||||
<span>
|
||||
输入“{activeProject?.name}”确认删除
|
||||
</span>
|
||||
<input
|
||||
autoFocus
|
||||
disabled={busy}
|
||||
onChange={(event) =>
|
||||
setDeleteConfirmation(event.target.value)
|
||||
}
|
||||
value={deleteConfirmation}
|
||||
/>
|
||||
</label>
|
||||
<div>
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setError(undefined)
|
||||
setDeleteConfirmation('')
|
||||
setConfirmingDelete(false)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
取消删除
|
||||
</button>
|
||||
<button
|
||||
className="danger-button"
|
||||
disabled={
|
||||
busy ||
|
||||
deleteConfirmation !== activeProject?.name
|
||||
}
|
||||
onClick={() => void deleteProject()}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
{deleting ? '删除中' : '永久删除项目'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{userProjects.length <= 1 && (
|
||||
<small>至少需要保留一个可用项目。</small>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
<div className="project-create-card__actions">
|
||||
{dialogMode === 'settings' &&
|
||||
projects.length > 1 &&
|
||||
activeProject?.kind !== 'channel' &&
|
||||
userProjects.length > 1 &&
|
||||
activeProjectId && (
|
||||
<button
|
||||
className="secondary-button"
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor
|
||||
} from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { DesktopApi } from '../../shared/contracts'
|
||||
import type { RemoteChannelApproval } from '../../shared/remote-channel-contracts'
|
||||
import { RemoteChannelApprovalDialog } from './RemoteChannelApprovalDialog'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('RemoteChannelApprovalDialog', () => {
|
||||
it('requires an explicit local one-time decision', async () => {
|
||||
let publish: ((approval: RemoteChannelApproval) => void) | undefined
|
||||
const respondRemoteApproval = vi.fn(async () => true)
|
||||
Object.defineProperty(window, 'goodbuddy', {
|
||||
configurable: true,
|
||||
value: {
|
||||
channels: {
|
||||
getPendingRemoteApprovals: vi.fn(async () => []),
|
||||
onRemoteApproval: vi.fn((listener) => {
|
||||
publish = listener
|
||||
return () => undefined
|
||||
}),
|
||||
respondRemoteApproval
|
||||
}
|
||||
} as unknown as DesktopApi
|
||||
})
|
||||
|
||||
render(<RemoteChannelApprovalDialog />)
|
||||
publish?.({
|
||||
approvalId: '00000000-0000-4000-8000-000000000001',
|
||||
requestId: '00000000-0000-4000-8000-000000000002',
|
||||
kind: 'request',
|
||||
channel: 'weixin',
|
||||
channelLabel: '微信 ClawBot',
|
||||
senderDisplay: '发送者 ****1234',
|
||||
projectName: '微信 ClawBot',
|
||||
rootPath: 'C:\\Users\\tester',
|
||||
title: '请求执行任务',
|
||||
description: '创建一份本地报告',
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString()
|
||||
})
|
||||
|
||||
expect(
|
||||
await screen.findByRole('alertdialog', {
|
||||
name: '确认远程执行请求'
|
||||
})
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('创建一份本地报告')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /永久|会话/u })
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '仅批准本次执行' })
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(respondRemoteApproval).toHaveBeenCalledWith(
|
||||
'00000000-0000-4000-8000-000000000001',
|
||||
'once'
|
||||
)
|
||||
)
|
||||
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,222 @@
|
||||
import { ShieldCheck } from 'lucide-react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type {
|
||||
RemoteChannelApproval,
|
||||
RemoteChannelApprovalDecision
|
||||
} from '../../shared/remote-channel-contracts'
|
||||
import { trapTabFocus } from './dialog-focus'
|
||||
|
||||
export function RemoteChannelApprovalDialog(): React.JSX.Element | null {
|
||||
const [requests, setRequests] = useState<RemoteChannelApproval[]>([])
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string>()
|
||||
const dialogRef = useRef<HTMLDivElement>(null)
|
||||
const current = requests[0]
|
||||
|
||||
useEffect(() => {
|
||||
const api = window.goodbuddy.channels
|
||||
if (!api) {
|
||||
return
|
||||
}
|
||||
let active = true
|
||||
void api
|
||||
.getPendingRemoteApprovals()
|
||||
.then((pending) => {
|
||||
if (active) {
|
||||
setRequests((existing) => {
|
||||
const merged = new Map(
|
||||
[...pending, ...existing].map((request) => [
|
||||
request.approvalId,
|
||||
request
|
||||
])
|
||||
)
|
||||
return [...merged.values()]
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
const remove = api.onRemoteApproval((approval) => {
|
||||
setRequests((existing) =>
|
||||
existing.some(
|
||||
(candidate) => candidate.approvalId === approval.approvalId
|
||||
)
|
||||
? existing
|
||||
: [...existing, approval]
|
||||
)
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
remove()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!current) {
|
||||
return
|
||||
}
|
||||
const remaining = Math.max(
|
||||
0,
|
||||
new Date(current.expiresAt).getTime() - Date.now()
|
||||
)
|
||||
const timeout = window.setTimeout(() => {
|
||||
setRequests((existing) =>
|
||||
existing.filter(
|
||||
(request) => request.approvalId !== current.approvalId
|
||||
)
|
||||
)
|
||||
setError(undefined)
|
||||
}, remaining)
|
||||
return () => window.clearTimeout(timeout)
|
||||
}, [current])
|
||||
|
||||
const respond = useCallback(
|
||||
async (
|
||||
decision: RemoteChannelApprovalDecision
|
||||
): Promise<void> => {
|
||||
if (!current || busy) {
|
||||
return
|
||||
}
|
||||
const api = window.goodbuddy.channels
|
||||
if (!api) {
|
||||
setError('本机审批服务不可用')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setError(undefined)
|
||||
try {
|
||||
const accepted = await api.respondRemoteApproval(
|
||||
current.approvalId,
|
||||
decision
|
||||
)
|
||||
if (!accepted) {
|
||||
throw new Error('审批请求已超时或不再有效')
|
||||
}
|
||||
setRequests((existing) => existing.slice(1))
|
||||
} catch (reason) {
|
||||
setError(
|
||||
reason instanceof Error ? reason.message : '提交审批结果失败'
|
||||
)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
},
|
||||
[busy, current]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!current) {
|
||||
return
|
||||
}
|
||||
const onKeyDown = (event: KeyboardEvent): void => {
|
||||
if (event.key === 'Escape' && !busy) {
|
||||
event.preventDefault()
|
||||
void respond('deny')
|
||||
return
|
||||
}
|
||||
trapTabFocus(event, dialogRef.current)
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
return () => document.removeEventListener('keydown', onKeyDown)
|
||||
}, [busy, current, respond])
|
||||
|
||||
if (!current) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="remote-approval-backdrop">
|
||||
<section
|
||||
aria-describedby="remote-approval-description"
|
||||
aria-labelledby="remote-approval-title"
|
||||
aria-modal="true"
|
||||
className="remote-approval-dialog"
|
||||
ref={dialogRef}
|
||||
role="alertdialog"
|
||||
>
|
||||
<header>
|
||||
<span className="remote-approval-dialog__icon">
|
||||
<ShieldCheck aria-hidden="true" size={20} />
|
||||
</span>
|
||||
<div>
|
||||
<strong id="remote-approval-title">
|
||||
{current.kind === 'request'
|
||||
? '确认远程执行请求'
|
||||
: '确认远程工具调用'}
|
||||
</strong>
|
||||
<small>
|
||||
{current.channelLabel} · {current.senderDisplay}
|
||||
</small>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="remote-approval-dialog__scope">
|
||||
<span>项目:{current.projectName}</span>
|
||||
<span title={current.rootPath}>
|
||||
工作目录:{current.rootPath || '未设置'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="remote-approval-dialog__request"
|
||||
id="remote-approval-description"
|
||||
>
|
||||
<strong>{current.title}</strong>
|
||||
<p>{current.description}</p>
|
||||
{current.toolName && (
|
||||
<dl>
|
||||
<div>
|
||||
<dt>工具</dt>
|
||||
<dd>{current.toolName}</dd>
|
||||
</div>
|
||||
{current.argumentSummary && (
|
||||
<div>
|
||||
<dt>参数摘要</dt>
|
||||
<dd>{current.argumentSummary}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="remote-approval-dialog__warning">
|
||||
此请求来自远程消息。批准只对本次请求有效,不能从消息应用中自行批准。
|
||||
</p>
|
||||
{error && (
|
||||
<p className="settings-warning" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<footer>
|
||||
<button
|
||||
autoFocus
|
||||
className="secondary-button"
|
||||
disabled={busy}
|
||||
onClick={() => void respond('deny')}
|
||||
type="button"
|
||||
>
|
||||
拒绝
|
||||
</button>
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={busy}
|
||||
onClick={() => void respond('once')}
|
||||
type="button"
|
||||
>
|
||||
{busy
|
||||
? '提交中…'
|
||||
: current.kind === 'request'
|
||||
? '仅批准本次执行'
|
||||
: '仅允许本次调用'}
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
{requests.length > 1 && (
|
||||
<small className="remote-approval-dialog__queue">
|
||||
还有 {requests.length - 1} 个远程审批请求
|
||||
</small>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1055,7 +1055,7 @@ export function SettingsPanel({
|
||||
</button>
|
||||
<button
|
||||
aria-controls="settings-panel-channels"
|
||||
aria-label="企业通信"
|
||||
aria-label="消息通道"
|
||||
aria-selected={activeTab === 'channels'}
|
||||
id="settings-tab-channels"
|
||||
onClick={() => setActiveTab('channels')}
|
||||
@@ -1066,8 +1066,8 @@ export function SettingsPanel({
|
||||
tabIndex={activeTab === 'channels' ? 0 : -1}
|
||||
type="button"
|
||||
>
|
||||
<strong>企业通信</strong>
|
||||
<small>企业微信与钉钉</small>
|
||||
<strong>消息通道</strong>
|
||||
<small>微信、企业微信与钉钉</small>
|
||||
</button>
|
||||
<button
|
||||
aria-controls="settings-panel-roles"
|
||||
|
||||
@@ -22,6 +22,14 @@ const stylesheet = readFileSync(
|
||||
join(process.cwd(), 'src', 'renderer', 'src', 'styles.css'),
|
||||
'utf8'
|
||||
)
|
||||
const rendererEntry = readFileSync(
|
||||
join(process.cwd(), 'src', 'renderer', 'src', 'main.tsx'),
|
||||
'utf8'
|
||||
)
|
||||
const fontSetup = readFileSync(
|
||||
join(process.cwd(), 'src', 'renderer', 'src', 'fonts.ts'),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
function themeTokens(selector: string): Record<string, string> {
|
||||
const selectorIndex = stylesheet.indexOf(selector)
|
||||
@@ -71,6 +79,25 @@ describe('WorkspacePrimitives', () => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('uses bundled variable fonts and readable shared type tokens', () => {
|
||||
expect(rendererEntry).toContain(
|
||||
"@fontsource-variable/noto-sans-sc/wght.css"
|
||||
)
|
||||
expect(rendererEntry).toContain('installBundledUiFonts()')
|
||||
expect(fontSetup).toContain(
|
||||
'inter-latin-standard-normal.woff2?url'
|
||||
)
|
||||
expect(fontSetup).toContain(
|
||||
'inter-latin-standard-italic.woff2?url'
|
||||
)
|
||||
expect(stylesheet).toMatch(/--font-body:\s*13px/u)
|
||||
expect(stylesheet).toMatch(/--font-caption:\s*11px/u)
|
||||
expect(stylesheet).toMatch(/font-synthesis:\s*style/u)
|
||||
expect(stylesheet).toContain(
|
||||
'"Inter Variable", "Noto Sans SC Variable"'
|
||||
)
|
||||
})
|
||||
|
||||
it('renders a consistent page shell and scoped header', () => {
|
||||
render(
|
||||
<PageShell variant="dashboard">
|
||||
|
||||
@@ -154,16 +154,22 @@ export function PageTabs<T extends string>({
|
||||
idPrefix,
|
||||
onChange,
|
||||
tabs,
|
||||
value
|
||||
value,
|
||||
variant = 'default'
|
||||
}: {
|
||||
ariaLabel: string
|
||||
idPrefix: string
|
||||
onChange: (value: T) => void
|
||||
tabs: readonly PageTab<T>[]
|
||||
value: T
|
||||
variant?: 'default' | 'segmented'
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<nav aria-label={ariaLabel} className="page-tabs" role="tablist">
|
||||
<nav
|
||||
aria-label={ariaLabel}
|
||||
className={`page-tabs page-tabs--${variant}`}
|
||||
role="tablist"
|
||||
>
|
||||
{tabs.map((tab, index) => (
|
||||
<button
|
||||
aria-controls={`${idPrefix}-panel-${tab.id}`}
|
||||
|
||||
@@ -17,6 +17,8 @@ export function trapTabFocus(
|
||||
const focusable =
|
||||
container.querySelectorAll<HTMLElement>(focusableSelector)
|
||||
if (focusable.length === 0) {
|
||||
event.preventDefault()
|
||||
container.focus()
|
||||
return
|
||||
}
|
||||
const first = focusable[0]!
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import interItalicUrl from '@fontsource-variable/inter/files/inter-latin-standard-italic.woff2?url'
|
||||
import interNormalUrl from '@fontsource-variable/inter/files/inter-latin-standard-normal.woff2?url'
|
||||
|
||||
const interFaces = [
|
||||
{ style: 'normal', url: interNormalUrl },
|
||||
{ style: 'italic', url: interItalicUrl }
|
||||
] as const
|
||||
|
||||
export function installBundledUiFonts(): void {
|
||||
for (const face of interFaces) {
|
||||
document.fonts.add(
|
||||
new FontFace(
|
||||
'Inter Variable',
|
||||
`url("${face.url}") format("woff2-variations")`,
|
||||
{
|
||||
display: 'swap',
|
||||
style: face.style,
|
||||
weight: '100 900'
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import '@fontsource-variable/noto-sans-sc/wght.css'
|
||||
import App from './App'
|
||||
import { installBundledUiFonts } from './fonts'
|
||||
import {
|
||||
applyAppearanceTheme,
|
||||
loadAppearanceTheme,
|
||||
@@ -14,6 +16,8 @@ if (!root) {
|
||||
throw new Error('Root element not found')
|
||||
}
|
||||
|
||||
installBundledUiFonts()
|
||||
|
||||
applyAppearanceTheme(
|
||||
resolveAppearanceTheme(
|
||||
loadAppearanceTheme(),
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export type AppNotificationTone = 'success' | 'info' | 'error'
|
||||
|
||||
export type AppNotificationInput = {
|
||||
tone: AppNotificationTone
|
||||
message: string
|
||||
dedupeKey?: string
|
||||
}
|
||||
+1118
-20
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user