@yuku123/z-frontend-common 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/z-frontend-common.css +1 -0
  2. package/dist/z-frontend-common.es.js +6153 -300
  3. package/dist/z-frontend-common.umd.js +22 -4
  4. package/package.json +5 -4
  5. package/src/components/Ctc/Layout.jsx +328 -0
  6. package/src/components/Ctc/Layout.module.css +145 -0
  7. package/src/components/Ctc/agentTeam/index.tsx +308 -0
  8. package/src/components/Ctc/login/AuthPage.module.css +26 -0
  9. package/src/components/Ctc/login/AuthPage.tsx +235 -0
  10. package/src/components/Ctc/login/index.less +49 -0
  11. package/src/components/Ctc/login/index.tsx +142 -0
  12. package/src/components/Ctc/userPanel/index.tsx +998 -0
  13. package/src/components/Ctc/webide/index.tsx +272 -0
  14. package/src/components/LowCode/LowCodeModel.jsx +962 -0
  15. package/src/components/LowCode/LowCodePage.jsx +31 -0
  16. package/src/components/LowCode/LowCodeRuntime.jsx +335 -0
  17. package/src/components/LowCode/MaterializePage.jsx +235 -0
  18. package/src/components/LowCode/index.js +1 -0
  19. package/src/components/MockPlatform/CurlImportModal.jsx +362 -0
  20. package/src/components/MockPlatform/EndpointsTab.jsx +509 -0
  21. package/src/components/MockPlatform/EnvironmentsTab.jsx +212 -0
  22. package/src/components/MockPlatform/MockTemplateHelper.jsx +200 -0
  23. package/src/components/MockPlatform/OpenApiImportModal.jsx +305 -0
  24. package/src/components/MockPlatform/RecordingsTab.jsx +397 -0
  25. package/src/components/MockPlatform/RequestLogsTab.jsx +239 -0
  26. package/src/components/MockPlatform/ScenariosTab.jsx +236 -0
  27. package/src/components/MockPlatform/TestCasesTab.jsx +462 -0
  28. package/src/components/MockPlatform/index.jsx +127 -0
  29. package/src/components/Overview.jsx +74 -0
  30. package/src/index.js +26 -27
  31. package/src/services/agentTeam.js +7 -0
  32. package/src/services/api.js +84 -0
  33. package/src/services/ctcAc.js +7 -0
  34. package/src/services/ctcAcDomain.js +7 -0
  35. package/src/services/ctcAuthorization.js +7 -0
  36. package/src/services/ctcSurl.js +7 -0
  37. package/src/services/ctcUser.js +7 -0
  38. package/src/services/job.js +7 -0
  39. package/src/services/metaApp.js +7 -0
  40. package/src/services/privateConfig.js +7 -0
  41. package/src/services/request.js +6 -0
  42. package/src/services/webide.js +6 -0
  43. package/src/services/workspace.js +21 -0
@@ -0,0 +1,998 @@
1
+ import {useEffect, useRef, useState} from 'react'
2
+ import {App, Avatar, Badge, Button, Empty, Form, Input, Modal, Popconfirm, Space, Spin, Tag, Tooltip, Tree} from 'antd'
3
+ import type {DataNode} from 'antd/es/tree'
4
+ import {
5
+ CheckCircleOutlined,
6
+ DeleteOutlined,
7
+ FileOutlined,
8
+ FileTextOutlined,
9
+ FolderOpenOutlined,
10
+ FolderOutlined,
11
+ MessageOutlined,
12
+ PlusOutlined,
13
+ ReloadOutlined,
14
+ SendOutlined,
15
+ TeamOutlined,
16
+ } from '@ant-design/icons'
17
+ import {workspaceApi, workItemApi, type Workspace, type FileNode, type ConversationWorkItem} from '@/services/workspace'
18
+ import {agentTeamApi} from '@/services/agentTeam'
19
+
20
+ /**
21
+ * 用户面板 · 工作项 + 工作空间 + IM 群聊 三合一页面
22
+ *
23
+ * 布局 (WeChat 风格):
24
+ * ┌──────────────┬─────────────────┬──────────────────────────────┐
25
+ * │ 工作项列表 │ 工作空间文件树 │ IM 群聊面板 │
26
+ * │ │ │ │
27
+ * │ + 工作项 │ (点工作项展开) │ 头部: 工作项标题 / 状态 │
28
+ * │ + 工作空间 │ FileTree │ 消息流: 用户 / Agent 气泡 │
29
+ * │ │ │ 输入框 + 发送 │
30
+ * └──────────────┴─────────────────┴──────────────────────────────┘
31
+ *
32
+ * 数据流:
33
+ * - 工作项 (Conv) 一对多 工作空间 (Workspace)
34
+ * - 工作空间 1:1 映射本地目录 (localPath)
35
+ * - IM 消息走 z-agent-team WebSocket (/api/agent/team/ws?convCode=xxx)
36
+ */
37
+
38
+ interface ChatMessage {
39
+ msgCode: string
40
+ senderType: 'USER' | 'AGENT' | 'SYSTEM' | string
41
+ senderCode: string
42
+ senderName: string
43
+ content: string
44
+ timestamp: number
45
+ messageType?: string
46
+ }
47
+
48
+ const STATUS_COLOR: Record<string, string> = {
49
+ OPEN: 'green',
50
+ DONE: 'blue',
51
+ ARCHIVED: 'default',
52
+ }
53
+
54
+ export default function UserPanel() {
55
+ const {message} = App.useApp()
56
+ const userInfoStr = localStorage.getItem('userInfo') || '{}'
57
+ let userInfo: any = {}
58
+ try {
59
+ userInfo = JSON.parse(userInfoStr)
60
+ } catch {
61
+ }
62
+ const currentUserId: string = userInfo?.userId ? String(userInfo.userId) : 'anonymous'
63
+ const currentUserName: string = userInfo?.userName || userInfo?.name || '我'
64
+
65
+ // ===== 工作项 (Conv) 列表 =====
66
+ const [workItems, setWorkItems] = useState<ConversationWorkItem[]>([])
67
+ const [loadingItems, setLoadingItems] = useState(false)
68
+ const [selectedItem, setSelectedItem] = useState<ConversationWorkItem | null>(null)
69
+
70
+ // ===== 工作空间 (Workspace) 列表 =====
71
+ const [workspaces, setWorkspaces] = useState<Workspace[]>([])
72
+ const [loadingWs, setLoadingWs] = useState(false)
73
+
74
+ // ===== 当前工作项的文件树 =====
75
+ const [fileTree, setFileTree] = useState<DataNode[]>([])
76
+ const [loadingTree, setLoadingTree] = useState(false)
77
+
78
+ // ===== 当前选中文件内容预览 =====
79
+ const [previewFile, setPreviewFile] = useState<{path: string; content: string} | null>(null)
80
+ const [loadingPreview, setLoadingPreview] = useState(false)
81
+
82
+ // ===== IM 聊天 =====
83
+ const [messages, setMessages] = useState<ChatMessage[]>([])
84
+ const [chatInput, setChatInput] = useState('')
85
+ const [wsState, setWsState] = useState<'disconnected' | 'connecting' | 'connected' | 'error'>('disconnected')
86
+ const wsRef = useRef<WebSocket | null>(null)
87
+ const messagesEndRef = useRef<HTMLDivElement | null>(null)
88
+ const [streamingText, setStreamingText] = useState('')
89
+ const [streamingFrom, setStreamingFrom] = useState('')
90
+
91
+ // ===== UI 状态 =====
92
+ const [createWsOpen, setCreateWsOpen] = useState(false)
93
+ const [createItemOpen, setCreateItemOpen] = useState(false)
94
+ const [wsForm] = Form.useForm()
95
+ const [itemForm] = Form.useForm()
96
+ const [activeWsCode, setActiveWsCode] = useState<string | null>(null) // 当前左侧选中的 workspace (用于切换文件树)
97
+
98
+ // ===== 默认 Team code (用于新建工作项) =====
99
+ const [defaultTeamCode, setDefaultTeamCode] = useState<string>('z-one-company-default')
100
+
101
+ useEffect(() => {
102
+ loadTeams()
103
+ loadWorkspaces()
104
+ loadWorkItems()
105
+ }, [])
106
+
107
+ useEffect(() => {
108
+ // 自动滚动到消息底部
109
+ if (messagesEndRef.current) {
110
+ messagesEndRef.current.scrollIntoView({behavior: 'smooth'})
111
+ }
112
+ }, [messages, streamingText])
113
+
114
+ // ===== 数据加载 =====
115
+
116
+ const loadTeams = async () => {
117
+ try {
118
+ const list = await agentTeamApi.listTeams()
119
+ if (list && list.length > 0) {
120
+ setDefaultTeamCode(list[0].teamCode)
121
+ }
122
+ } catch {
123
+ // ignore
124
+ }
125
+ }
126
+
127
+ const loadWorkspaces = async () => {
128
+ setLoadingWs(true)
129
+ try {
130
+ const list = await workspaceApi.list(currentUserId)
131
+ setWorkspaces(list || [])
132
+ } catch (e: any) {
133
+ // 容错: 后端未启动时显示空列表
134
+ setWorkspaces([])
135
+ } finally {
136
+ setLoadingWs(false)
137
+ }
138
+ }
139
+
140
+ const loadWorkItems = async () => {
141
+ setLoadingItems(true)
142
+ try {
143
+ const list = await workItemApi.listAllByOwner(currentUserId, 100)
144
+ setWorkItems(list || [])
145
+ } catch (e: any) {
146
+ setWorkItems([])
147
+ } finally {
148
+ setLoadingItems(false)
149
+ }
150
+ }
151
+
152
+ const loadFileTree = async (workspaceCode: string) => {
153
+ if (!workspaceCode) {
154
+ setFileTree([])
155
+ return
156
+ }
157
+ setLoadingTree(true)
158
+ try {
159
+ const tree = await workspaceApi.fileTree(workspaceCode)
160
+ setFileTree(tree ? [buildAntdTreeNode(tree)] : [])
161
+ } catch (e: any) {
162
+ message.error('加载文件树失败:' + (e?.message || '未知错误'))
163
+ setFileTree([])
164
+ } finally {
165
+ setLoadingTree(false)
166
+ }
167
+ }
168
+
169
+ // ===== IM 历史消息 =====
170
+
171
+ const loadMessages = async (convCode: string) => {
172
+ try {
173
+ const list = await workItemApi.messages(convCode, 200)
174
+ const mapped: ChatMessage[] = (list || []).map((m: any) => ({
175
+ msgCode: m.msgCode,
176
+ senderType: m.senderType,
177
+ senderCode: m.senderCode,
178
+ senderName: m.senderName,
179
+ content: m.content,
180
+ messageType: m.messageType,
181
+ timestamp: m.gmtCreate ? new Date(m.gmtCreate).getTime() : Date.now(),
182
+ }))
183
+ setMessages(mapped)
184
+ } catch (e: any) {
185
+ setMessages([])
186
+ }
187
+ }
188
+
189
+ // ===== WebSocket =====
190
+
191
+ const connectWs = (convCode: string) => {
192
+ disconnectWs()
193
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
194
+ const url = `${protocol}//${window.location.host}/api/agent/team/ws?convCode=${encodeURIComponent(convCode)}&userId=${encodeURIComponent(currentUserId)}&userName=${encodeURIComponent(currentUserName)}`
195
+ setWsState('connecting')
196
+ try {
197
+ const ws = new WebSocket(url)
198
+ ws.onopen = () => {
199
+ setWsState('connected')
200
+ }
201
+ ws.onmessage = (ev) => {
202
+ try {
203
+ const event = JSON.parse(ev.data)
204
+ handleStreamEvent(event)
205
+ } catch {
206
+ // ignore
207
+ }
208
+ }
209
+ ws.onerror = () => {
210
+ setWsState('error')
211
+ }
212
+ ws.onclose = () => {
213
+ setWsState('disconnected')
214
+ wsRef.current = null
215
+ }
216
+ wsRef.current = ws
217
+ } catch (e: any) {
218
+ setWsState('error')
219
+ message.error('WS 连接失败:' + (e?.message || '未知错误'))
220
+ }
221
+ }
222
+
223
+ const disconnectWs = () => {
224
+ if (wsRef.current) {
225
+ try {
226
+ wsRef.current.close()
227
+ } catch {
228
+ }
229
+ wsRef.current = null
230
+ setWsState('disconnected')
231
+ setStreamingText('')
232
+ }
233
+ }
234
+
235
+ const handleStreamEvent = (event: any) => {
236
+ const t = event.type
237
+ if (t === 'AGENT_RESPONSE_DELTA' || t === 'AGENT_THINKING_DELTA') {
238
+ setStreamingFrom(event.senderName || event.senderCode || 'Agent')
239
+ setStreamingText((prev) => prev + (event.content || ''))
240
+ } else if (t === 'AGENT_RESPONSE' || t === 'AGENT_THINKING') {
241
+ setMessages((prev) => [
242
+ ...prev,
243
+ {
244
+ msgCode: event.eventId || `evt-${Date.now()}`,
245
+ senderType: event.senderType || 'AGENT',
246
+ senderCode: event.senderCode || '',
247
+ senderName: event.senderName || 'Agent',
248
+ content: event.content || '',
249
+ messageType: t,
250
+ timestamp: event.timestamp || Date.now(),
251
+ },
252
+ ])
253
+ setStreamingText('')
254
+ setStreamingFrom('')
255
+ } else if (t === 'SYSTEM' || t === 'PARTICIPANT_JOIN' || t === 'PARTICIPANT_LEAVE') {
256
+ // 系统消息折叠进消息流 (可选)
257
+ if (event.content && event.content !== 'HEARTBEAT_ACK' && event.content !== 'PONG') {
258
+ setMessages((prev) => [
259
+ ...prev,
260
+ {
261
+ msgCode: event.eventId || `evt-${Date.now()}`,
262
+ senderType: 'SYSTEM',
263
+ senderCode: event.senderCode || 'system',
264
+ senderName: '系统',
265
+ content: event.content,
266
+ messageType: t,
267
+ timestamp: event.timestamp || Date.now(),
268
+ },
269
+ ])
270
+ }
271
+ } else if (t === 'ERROR') {
272
+ setMessages((prev) => [
273
+ ...prev,
274
+ {
275
+ msgCode: event.eventId || `err-${Date.now()}`,
276
+ senderType: 'SYSTEM',
277
+ senderCode: 'system',
278
+ senderName: '错误',
279
+ content: event.content || '未知错误',
280
+ messageType: t,
281
+ timestamp: event.timestamp || Date.now(),
282
+ },
283
+ ])
284
+ setStreamingText('')
285
+ setStreamingFrom('')
286
+ } else if (t === 'MESSAGE_END') {
287
+ setStreamingText('')
288
+ setStreamingFrom('')
289
+ }
290
+ // 其他类型 (TOOL_CALL / DELEGATION_* / TYPING / REACTION / READ_RECEIPT / PRESENCE) 暂不展示
291
+ }
292
+
293
+ const sendMessage = () => {
294
+ const text = chatInput.trim()
295
+ if (!text) return
296
+ if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
297
+ message.warning('IM 未连接')
298
+ return
299
+ }
300
+ if (!selectedItem) {
301
+ message.warning('请先选择一个工作项')
302
+ return
303
+ }
304
+ const payload = JSON.stringify({type: 'USER_MESSAGE', content: text})
305
+ wsRef.current.send(payload)
306
+ // 立即在本地展示
307
+ setMessages((prev) => [
308
+ ...prev,
309
+ {
310
+ msgCode: `local-${Date.now()}`,
311
+ senderType: 'USER',
312
+ senderCode: currentUserId,
313
+ senderName: currentUserName,
314
+ content: text,
315
+ messageType: 'TEXT',
316
+ timestamp: Date.now(),
317
+ },
318
+ ])
319
+ setChatInput('')
320
+ }
321
+
322
+ // ===== 工作项选择 =====
323
+
324
+ const handleSelectItem = (item: ConversationWorkItem) => {
325
+ setSelectedItem(item)
326
+ loadMessages(item.convCode)
327
+ connectWs(item.convCode)
328
+ // 自动加载绑定的 workspace 文件树
329
+ if (item.workspaceCode) {
330
+ setActiveWsCode(item.workspaceCode)
331
+ loadFileTree(item.workspaceCode)
332
+ } else {
333
+ setActiveWsCode(null)
334
+ setFileTree([])
335
+ }
336
+ }
337
+
338
+ // ===== 创建工作项 =====
339
+
340
+ const handleCreateItem = async () => {
341
+ try {
342
+ const values = await itemForm.validateFields()
343
+ const created = await workItemApi.create({
344
+ teamCode: defaultTeamCode,
345
+ userId: currentUserId,
346
+ userName: currentUserName,
347
+ title: values.title,
348
+ workspaceCode: values.workspaceCode || undefined,
349
+ })
350
+ message.success('工作项已创建')
351
+ setCreateItemOpen(false)
352
+ itemForm.resetFields()
353
+ await loadWorkItems()
354
+ if (created) {
355
+ handleSelectItem(created)
356
+ }
357
+ } catch (e: any) {
358
+ message.error('创建失败:' + (e?.message || '未知错误'))
359
+ }
360
+ }
361
+
362
+ // ===== 创建工作空间 =====
363
+
364
+ const handleCreateWorkspace = async () => {
365
+ try {
366
+ const values = await wsForm.validateFields()
367
+ await workspaceApi.create({
368
+ workspaceName: values.workspaceName,
369
+ localPath: values.localPath,
370
+ ownerId: currentUserId,
371
+ ownerName: currentUserName,
372
+ description: values.description,
373
+ })
374
+ message.success('工作空间已创建')
375
+ setCreateWsOpen(false)
376
+ wsForm.resetFields()
377
+ await loadWorkspaces()
378
+ } catch (e: any) {
379
+ const errMsg = e?.response?.data?.message || e?.message || '未知错误'
380
+ message.error('创建失败:' + errMsg)
381
+ }
382
+ }
383
+
384
+ // ===== 文件点击预览 =====
385
+
386
+ const handleFileClick = async (absPath: string) => {
387
+ setLoadingPreview(true)
388
+ try {
389
+ const resp = await workspaceApi.fileContent(absPath)
390
+ setPreviewFile({path: absPath, content: resp?.content || ''})
391
+ } catch (e: any) {
392
+ message.error('读取文件失败:' + (e?.message || '未知错误'))
393
+ } finally {
394
+ setLoadingPreview(false)
395
+ }
396
+ }
397
+
398
+ // ===== 关闭工作项 =====
399
+
400
+ const handleCloseItem = async (convCode: string) => {
401
+ try {
402
+ await workItemApi.close(convCode, 'DONE')
403
+ message.success('已关闭工作项')
404
+ await loadWorkItems()
405
+ if (selectedItem?.convCode === convCode) {
406
+ setSelectedItem(null)
407
+ disconnectWs()
408
+ setMessages([])
409
+ setFileTree([])
410
+ }
411
+ } catch (e: any) {
412
+ message.error('关闭失败:' + (e?.message || '未知错误'))
413
+ }
414
+ }
415
+
416
+ // ===== 删除工作空间 =====
417
+
418
+ const handleDeleteWs = async (workspaceCode: string) => {
419
+ try {
420
+ await workspaceApi.delete(workspaceCode)
421
+ message.success('已删除')
422
+ await loadWorkspaces()
423
+ if (activeWsCode === workspaceCode) {
424
+ setActiveWsCode(null)
425
+ setFileTree([])
426
+ }
427
+ } catch (e: any) {
428
+ message.error('删除失败:' + (e?.message || '未知错误'))
429
+ }
430
+ }
431
+
432
+ // ===== Tree helpers =====
433
+
434
+ const buildAntdTreeNode = (node: FileNode): DataNode => {
435
+ return {
436
+ key: node.absolutePath,
437
+ title: (
438
+ <span>
439
+ {node.isDirectory ? <FolderOutlined style={{marginRight: 4, color: '#faad14'}}/> :
440
+ <FileOutlined style={{marginRight: 4, color: '#8c8c8c'}}/>}
441
+ {node.name}
442
+ {node.isDirectory && node.totalDescendants > 0 ? (
443
+ <span style={{color: '#bfbfbf', marginLeft: 6, fontSize: 11}}>({node.totalDescendants})</span>
444
+ ) : null}
445
+ </span>
446
+ ),
447
+ children: node.children?.map(buildAntdTreeNode) || [],
448
+ isLeaf: !node.isDirectory,
449
+ disabled: false,
450
+ }
451
+ }
452
+
453
+ // ===== Render =====
454
+
455
+ return (
456
+ <div style={{
457
+ display: 'grid',
458
+ gridTemplateColumns: '240px 280px 1fr',
459
+ height: 'calc(100vh - 130px)',
460
+ background: '#fff',
461
+ border: '1px solid #f0f0f0',
462
+ borderRadius: 8,
463
+ overflow: 'hidden',
464
+ }}>
465
+ {/* ===== 第 1 列: 工作项列表 ===== */}
466
+ <div style={{
467
+ borderRight: '1px solid #f0f0f0',
468
+ display: 'flex',
469
+ flexDirection: 'column',
470
+ background: '#fafafa',
471
+ }}>
472
+ <div style={{
473
+ padding: 12,
474
+ borderBottom: '1px solid #f0f0f0',
475
+ display: 'flex',
476
+ alignItems: 'center',
477
+ justifyContent: 'space-between',
478
+ }}>
479
+ <Space size={4}>
480
+ <MessageOutlined style={{color: '#1677ff'}}/>
481
+ <strong>工作项</strong>
482
+ </Space>
483
+ <Space size={4}>
484
+ <Tooltip title="刷新">
485
+ <Button size="small" type="text" icon={<ReloadOutlined/>} onClick={loadWorkItems}/>
486
+ </Tooltip>
487
+ <Tooltip title="新建工作项">
488
+ <Button size="small" type="text" icon={<PlusOutlined/>}
489
+ onClick={() => setCreateItemOpen(true)}/>
490
+ </Tooltip>
491
+ </Space>
492
+ </div>
493
+ <div style={{flex: 1, overflow: 'auto'}}>
494
+ {loadingItems ? (
495
+ <div style={{padding: 24, textAlign: 'center'}}><Spin size="small"/></div>
496
+ ) : workItems.length === 0 ? (
497
+ <Empty
498
+ image={Empty.PRESENTED_IMAGE_SIMPLE}
499
+ description="暂无工作项"
500
+ style={{padding: 24, marginTop: 24}}
501
+ >
502
+ <Button size="small" type="primary" onClick={() => setCreateItemOpen(true)}>
503
+ 新建工作项
504
+ </Button>
505
+ </Empty>
506
+ ) : (
507
+ workItems.map((item) => (
508
+ <div
509
+ key={item.convCode}
510
+ onClick={() => handleSelectItem(item)}
511
+ style={{
512
+ padding: '10px 12px',
513
+ cursor: 'pointer',
514
+ borderBottom: '1px solid #f5f5f5',
515
+ background: selectedItem?.convCode === item.convCode ? '#e6f7ff' : 'transparent',
516
+ transition: 'background 0.2s',
517
+ }}
518
+ >
519
+ <div style={{
520
+ display: 'flex',
521
+ alignItems: 'center',
522
+ justifyContent: 'space-between',
523
+ marginBottom: 4,
524
+ }}>
525
+ <strong style={{
526
+ fontSize: 13,
527
+ overflow: 'hidden',
528
+ textOverflow: 'ellipsis',
529
+ whiteSpace: 'nowrap',
530
+ maxWidth: 150,
531
+ }}>
532
+ {item.title || '未命名'}
533
+ </strong>
534
+ <Badge count={item.unreadCount || 0} size="small"/>
535
+ </div>
536
+ <div style={{
537
+ fontSize: 11,
538
+ color: '#8c8c8c',
539
+ overflow: 'hidden',
540
+ textOverflow: 'ellipsis',
541
+ whiteSpace: 'nowrap',
542
+ }}>
543
+ {item.lastMsgPreview || '暂无消息'}
544
+ </div>
545
+ <div style={{marginTop: 4, fontSize: 11, color: '#bfbfbf'}}>
546
+ <Tag color={STATUS_COLOR[item.status || 'OPEN']} style={{marginRight: 4, fontSize: 10, padding: '0 4px'}}>
547
+ {item.status || 'OPEN'}
548
+ </Tag>
549
+ {item.workspaceCode ? <Tag color="purple" style={{fontSize: 10, padding: '0 4px'}}>有工作空间</Tag> : null}
550
+ </div>
551
+ </div>
552
+ ))
553
+ )}
554
+ </div>
555
+ </div>
556
+
557
+ {/* ===== 第 2 列: 工作空间文件树 ===== */}
558
+ <div style={{
559
+ borderRight: '1px solid #f0f0f0',
560
+ display: 'flex',
561
+ flexDirection: 'column',
562
+ background: '#fff',
563
+ }}>
564
+ <div style={{
565
+ padding: 12,
566
+ borderBottom: '1px solid #f0f0f0',
567
+ display: 'flex',
568
+ alignItems: 'center',
569
+ justifyContent: 'space-between',
570
+ }}>
571
+ <Space size={4}>
572
+ <FolderOpenOutlined style={{color: '#faad14'}}/>
573
+ <strong>工作空间</strong>
574
+ </Space>
575
+ <Space size={4}>
576
+ <Tooltip title="新建工作空间">
577
+ <Button size="small" type="text" icon={<PlusOutlined/>}
578
+ onClick={() => setCreateWsOpen(true)}/>
579
+ </Tooltip>
580
+ </Space>
581
+ </div>
582
+
583
+ {/* 当前工作项绑定的 workspace */}
584
+ {selectedItem?.workspaceCode ? (
585
+ <div style={{
586
+ padding: '8px 12px',
587
+ background: '#f0f5ff',
588
+ borderBottom: '1px solid #f0f0f0',
589
+ fontSize: 12,
590
+ }}>
591
+ <Space size={4}>
592
+ <Tag color="blue">当前工作项</Tag>
593
+ <span style={{color: '#1677ff'}}>
594
+ {workspaces.find(w => w.workspaceCode === selectedItem.workspaceCode)?.workspaceName || selectedItem.workspaceCode}
595
+ </span>
596
+ <Button
597
+ size="small"
598
+ type="text"
599
+ icon={<ReloadOutlined/>}
600
+ onClick={() => loadFileTree(selectedItem.workspaceCode!)}
601
+ />
602
+ </Space>
603
+ </div>
604
+ ) : null}
605
+
606
+ {/* 全部 workspace 列表 (可点击切换) */}
607
+ <div style={{
608
+ maxHeight: 200,
609
+ overflow: 'auto',
610
+ borderBottom: '1px solid #f0f0f0',
611
+ }}>
612
+ {loadingWs ? (
613
+ <div style={{padding: 12, textAlign: 'center'}}><Spin size="small"/></div>
614
+ ) : workspaces.length === 0 ? (
615
+ <div style={{padding: 12, color: '#8c8c8c', fontSize: 12, textAlign: 'center'}}>
616
+ 暂无工作空间
617
+ </div>
618
+ ) : (
619
+ workspaces.map((ws) => (
620
+ <div
621
+ key={ws.workspaceCode}
622
+ onClick={() => {
623
+ setActiveWsCode(ws.workspaceCode)
624
+ loadFileTree(ws.workspaceCode)
625
+ }}
626
+ style={{
627
+ padding: '6px 12px',
628
+ cursor: 'pointer',
629
+ fontSize: 12,
630
+ background: activeWsCode === ws.workspaceCode ? '#e6f7ff' : 'transparent',
631
+ borderLeft: activeWsCode === ws.workspaceCode ? '3px solid #1677ff' : '3px solid transparent',
632
+ display: 'flex',
633
+ alignItems: 'center',
634
+ justifyContent: 'space-between',
635
+ }}
636
+ >
637
+ <Space size={4} style={{overflow: 'hidden'}}>
638
+ <FolderOutlined style={{color: '#faad14', fontSize: 12}}/>
639
+ <span style={{
640
+ overflow: 'hidden',
641
+ textOverflow: 'ellipsis',
642
+ whiteSpace: 'nowrap',
643
+ }}>
644
+ {ws.workspaceName}
645
+ </span>
646
+ </Space>
647
+ <Popconfirm
648
+ title="删除此工作空间?"
649
+ onConfirm={(e) => {
650
+ e?.stopPropagation()
651
+ handleDeleteWs(ws.workspaceCode)
652
+ }}
653
+ onCancel={(e) => e?.stopPropagation()}
654
+ >
655
+ <Button
656
+ size="small"
657
+ type="text"
658
+ danger
659
+ icon={<DeleteOutlined/>}
660
+ onClick={(e) => e.stopPropagation()}
661
+ />
662
+ </Popconfirm>
663
+ </div>
664
+ ))
665
+ )}
666
+ </div>
667
+
668
+ {/* 文件树 */}
669
+ <div style={{flex: 1, overflow: 'auto', padding: 8}}>
670
+ {loadingTree ? (
671
+ <div style={{padding: 24, textAlign: 'center'}}><Spin/></div>
672
+ ) : !activeWsCode ? (
673
+ <Empty
674
+ image={Empty.PRESENTED_IMAGE_SIMPLE}
675
+ description="请选择工作空间"
676
+ style={{marginTop: 40}}
677
+ />
678
+ ) : fileTree.length === 0 ? (
679
+ <Empty
680
+ image={Empty.PRESENTED_IMAGE_SIMPLE}
681
+ description="空目录或无文件"
682
+ style={{marginTop: 40}}
683
+ />
684
+ ) : (
685
+ <Tree
686
+ treeData={fileTree}
687
+ defaultExpandAll={false}
688
+ showLine
689
+ showIcon={false}
690
+ onSelect={(keys, info) => {
691
+ const node = info.node
692
+ if (!node.isLeaf && keys.length > 0) {
693
+ // 目录: 仅展开
694
+ return
695
+ }
696
+ if (node.isLeaf && info.node.key) {
697
+ handleFileClick(String(info.node.key))
698
+ }
699
+ }}
700
+ />
701
+ )}
702
+ </div>
703
+
704
+ {/* 选中的文件预览 */}
705
+ {previewFile ? (
706
+ <div style={{
707
+ borderTop: '1px solid #f0f0f0',
708
+ maxHeight: 240,
709
+ overflow: 'hidden',
710
+ display: 'flex',
711
+ flexDirection: 'column',
712
+ }}>
713
+ <div style={{
714
+ padding: '6px 12px',
715
+ background: '#fafafa',
716
+ borderBottom: '1px solid #f0f0f0',
717
+ display: 'flex',
718
+ alignItems: 'center',
719
+ justifyContent: 'space-between',
720
+ fontSize: 11,
721
+ }}>
722
+ <Space size={4}>
723
+ <FileTextOutlined/>
724
+ <span style={{
725
+ overflow: 'hidden',
726
+ textOverflow: 'ellipsis',
727
+ whiteSpace: 'nowrap',
728
+ maxWidth: 180,
729
+ }}>
730
+ {previewFile.path.split('/').pop()}
731
+ </span>
732
+ </Space>
733
+ <Button size="small" type="text" onClick={() => setPreviewFile(null)}>关闭</Button>
734
+ </div>
735
+ <pre style={{
736
+ flex: 1,
737
+ margin: 0,
738
+ padding: 8,
739
+ overflow: 'auto',
740
+ fontSize: 11,
741
+ background: '#1e1e1e',
742
+ color: '#d4d4d4',
743
+ fontFamily: 'Menlo, monospace',
744
+ whiteSpace: 'pre-wrap',
745
+ wordBreak: 'break-all',
746
+ }}>
747
+ {loadingPreview ? '加载中...' : previewFile.content || '(空)'}
748
+ </pre>
749
+ </div>
750
+ ) : null}
751
+ </div>
752
+
753
+ {/* ===== 第 3 列: IM 群聊面板 ===== */}
754
+ <div style={{display: 'flex', flexDirection: 'column', background: '#f5f5f5'}}>
755
+ {/* 头部 */}
756
+ <div style={{
757
+ padding: '12px 16px',
758
+ background: '#fff',
759
+ borderBottom: '1px solid #f0f0f0',
760
+ display: 'flex',
761
+ alignItems: 'center',
762
+ justifyContent: 'space-between',
763
+ }}>
764
+ {selectedItem ? (
765
+ <Space size={8}>
766
+ <TeamOutlined style={{color: '#1677ff'}}/>
767
+ <strong style={{fontSize: 14}}>{selectedItem.title || '未命名'}</strong>
768
+ <Tag color={STATUS_COLOR[selectedItem.status || 'OPEN']} style={{marginLeft: 4}}>
769
+ {selectedItem.status || 'OPEN'}
770
+ </Tag>
771
+ {selectedItem.workspaceCode ? (
772
+ <Tag color="purple" icon={<FolderOpenOutlined/>}>
773
+ {workspaces.find(w => w.workspaceCode === selectedItem.workspaceCode)?.workspaceName || '工作空间'}
774
+ </Tag>
775
+ ) : null}
776
+ <span style={{color: '#8c8c8c', fontSize: 12}}>
777
+ {selectedItem.messageCount || 0} 条消息
778
+ </span>
779
+ </Space>
780
+ ) : (
781
+ <span style={{color: '#8c8c8c'}}>请从左侧选择一个工作项</span>
782
+ )}
783
+ {selectedItem ? (
784
+ <Space size={4}>
785
+ <Tag color={wsState === 'connected' ? 'green' : wsState === 'error' ? 'red' : 'default'}>
786
+ {wsState === 'connected' ? '已连接' : wsState === 'connecting' ? '连接中' : wsState === 'error' ? '错误' : '未连接'}
787
+ </Tag>
788
+ <Popconfirm
789
+ title="关闭此工作项?"
790
+ onConfirm={() => handleCloseItem(selectedItem.convCode)}
791
+ >
792
+ <Button size="small" type="text" danger icon={<CheckCircleOutlined/>}>
793
+ 完成
794
+ </Button>
795
+ </Popconfirm>
796
+ </Space>
797
+ ) : null}
798
+ </div>
799
+
800
+ {/* 消息流 */}
801
+ <div style={{
802
+ flex: 1,
803
+ overflow: 'auto',
804
+ padding: 16,
805
+ display: 'flex',
806
+ flexDirection: 'column',
807
+ gap: 12,
808
+ }}>
809
+ {!selectedItem ? (
810
+ <Empty
811
+ image={Empty.PRESENTED_IMAGE_SIMPLE}
812
+ description="从左侧选择工作项开始聊天"
813
+ style={{marginTop: 80}}
814
+ />
815
+ ) : messages.length === 0 && !streamingText ? (
816
+ <Empty
817
+ image={Empty.PRESENTED_IMAGE_SIMPLE}
818
+ description="暂无消息,开始聊吧"
819
+ style={{marginTop: 80}}
820
+ />
821
+ ) : (
822
+ <>
823
+ {messages.map((m) => <MessageBubble key={m.msgCode} msg={m} currentUserId={currentUserId}/>)}
824
+ {streamingText ? (
825
+ <MessageBubble
826
+ msg={{
827
+ msgCode: 'streaming',
828
+ senderType: 'AGENT',
829
+ senderCode: 'agent',
830
+ senderName: streamingFrom,
831
+ content: streamingText,
832
+ timestamp: Date.now(),
833
+ messageType: 'STREAMING',
834
+ }}
835
+ currentUserId={currentUserId}
836
+ />
837
+ ) : null}
838
+ <div ref={messagesEndRef}/>
839
+ </>
840
+ )}
841
+ </div>
842
+
843
+ {/* 输入框 */}
844
+ <div style={{
845
+ padding: 12,
846
+ background: '#fff',
847
+ borderTop: '1px solid #f0f0f0',
848
+ display: 'flex',
849
+ gap: 8,
850
+ }}>
851
+ <Input.TextArea
852
+ value={chatInput}
853
+ onChange={(e) => setChatInput(e.target.value)}
854
+ onPressEnter={(e) => {
855
+ if (!e.shiftKey) {
856
+ e.preventDefault()
857
+ sendMessage()
858
+ }
859
+ }}
860
+ placeholder={selectedItem ? (wsState === 'connected' ? '输入消息... (Enter 发送, Shift+Enter 换行)' : '正在连接 IM...') : '请先选择工作项'}
861
+ autoSize={{minRows: 1, maxRows: 4}}
862
+ disabled={!selectedItem || wsState !== 'connected'}
863
+ />
864
+ <Button
865
+ type="primary"
866
+ icon={<SendOutlined/>}
867
+ onClick={sendMessage}
868
+ disabled={!selectedItem || wsState !== 'connected' || !chatInput.trim()}
869
+ >
870
+ 发送
871
+ </Button>
872
+ </div>
873
+ </div>
874
+
875
+ {/* ===== Modal: 新建工作空间 ===== */}
876
+ <Modal
877
+ title="新建工作空间"
878
+ open={createWsOpen}
879
+ onCancel={() => setCreateWsOpen(false)}
880
+ onOk={handleCreateWorkspace}
881
+ okText="创建"
882
+ cancelText="取消"
883
+ >
884
+ <Form form={wsForm} layout="vertical">
885
+ <Form.Item
886
+ label="名称"
887
+ name="workspaceName"
888
+ rules={[{required: true, message: '请输入工作空间名称'}]}
889
+ >
890
+ <Input placeholder="例如:z-opc 主项目"/>
891
+ </Form.Item>
892
+ <Form.Item
893
+ label="本地路径"
894
+ name="localPath"
895
+ rules={[{required: true, message: '请输入本地路径'}]}
896
+ extra="服务器侧的文件系统绝对路径 (例: /Users/zifang/workplace)"
897
+ >
898
+ <Input placeholder="/Users/zifang/workplace"/>
899
+ </Form.Item>
900
+ <Form.Item label="描述" name="description">
901
+ <Input.TextArea rows={2} placeholder="可选:工作空间用途说明"/>
902
+ </Form.Item>
903
+ </Form>
904
+ </Modal>
905
+
906
+ {/* ===== Modal: 新建工作项 ===== */}
907
+ <Modal
908
+ title="新建工作项"
909
+ open={createItemOpen}
910
+ onCancel={() => setCreateItemOpen(false)}
911
+ onOk={handleCreateItem}
912
+ okText="创建"
913
+ cancelText="取消"
914
+ >
915
+ <Form form={itemForm} layout="vertical">
916
+ <Form.Item
917
+ label="标题"
918
+ name="title"
919
+ rules={[{required: true, message: '请输入工作项标题'}]}
920
+ >
921
+ <Input placeholder="例如:完成 z-opc 用户面板"/>
922
+ </Form.Item>
923
+ <Form.Item
924
+ label="关联工作空间 (可选)"
925
+ name="workspaceCode"
926
+ extra="绑定后,工作项右侧会显示对应工作空间的文件树"
927
+ >
928
+ <select
929
+ style={{width: '100%', height: 32, border: '1px solid #d9d9d9', borderRadius: 6, padding: '0 11px'}}
930
+ >
931
+ <option value="">无 (仅 IM 群聊)</option>
932
+ {workspaces.map((w) => (
933
+ <option key={w.workspaceCode} value={w.workspaceCode}>
934
+ {w.workspaceName} ({w.localPath})
935
+ </option>
936
+ ))}
937
+ </select>
938
+ </Form.Item>
939
+ </Form>
940
+ </Modal>
941
+ </div>
942
+ )
943
+ }
944
+
945
+ function MessageBubble({msg, currentUserId}: {msg: ChatMessage; currentUserId: string}) {
946
+ const isMe = msg.senderType === 'USER' && msg.senderCode === currentUserId
947
+ const isSystem = msg.senderType === 'SYSTEM'
948
+
949
+ if (isSystem) {
950
+ return (
951
+ <div style={{textAlign: 'center', color: '#8c8c8c', fontSize: 11}}>
952
+ <Tag color="default">{msg.content}</Tag>
953
+ </div>
954
+ )
955
+ }
956
+
957
+ const bubbleColor = isMe ? '#1677ff' : '#fff'
958
+ const textColor = isMe ? '#fff' : '#333'
959
+ const avatarBg = isMe ? '#1677ff' : '#87d068'
960
+
961
+ return (
962
+ <div style={{
963
+ display: 'flex',
964
+ flexDirection: isMe ? 'row-reverse' : 'row',
965
+ alignItems: 'flex-start',
966
+ gap: 8,
967
+ }}>
968
+ <Avatar style={{background: avatarBg, flexShrink: 0}}>
969
+ {(msg.senderName || '?').charAt(0).toUpperCase()}
970
+ </Avatar>
971
+ <div style={{maxWidth: '70%'}}>
972
+ <div style={{
973
+ fontSize: 11,
974
+ color: '#8c8c8c',
975
+ marginBottom: 2,
976
+ textAlign: isMe ? 'right' : 'left',
977
+ }}>
978
+ {msg.senderName} · {new Date(msg.timestamp).toLocaleTimeString()}
979
+ {msg.messageType && msg.messageType !== 'TEXT' ? (
980
+ <Tag style={{marginLeft: 4, fontSize: 10}}>{msg.messageType}</Tag>
981
+ ) : null}
982
+ </div>
983
+ <div style={{
984
+ padding: '8px 12px',
985
+ background: bubbleColor,
986
+ color: textColor,
987
+ borderRadius: 8,
988
+ border: isMe ? 'none' : '1px solid #e8e8e8',
989
+ wordBreak: 'break-word',
990
+ whiteSpace: 'pre-wrap',
991
+ boxShadow: '0 1px 2px rgba(0,0,0,0.04)',
992
+ }}>
993
+ {msg.content || (msg.messageType === 'STREAMING' ? '...' : '')}
994
+ </div>
995
+ </div>
996
+ </div>
997
+ )
998
+ }