@civilization/dsh-drawio 0.2.1 → 0.4.0

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.
@@ -0,0 +1,80 @@
1
+ ---
2
+ name: drawio-operation
3
+ description: 读取、检查、创建和语义化编辑 Draw.io 架构图与流程图,协同原生离线画板,并指导导出与排版工作流。
4
+ ---
5
+
6
+ # Draw.io 画板与图表协同指南
7
+
8
+ `dsh-drawio` 插件为 DeepSeek Harness (DSH) 提供 100% 本地离线的 Draw.io 画板,并向 Agent 注册结构化图表编辑工具。
9
+
10
+ 当用户询问 Draw.io 功能或需要绘制图表时,直接说明支持的画板功能与工具,无需探测工作区无关文件。
11
+
12
+ ## 核心模型工具
13
+
14
+ ### 1. `drawio_inspect`
15
+ - **功能**:将现有 `.drawio` 文件解析为结构化的页面、节点、连线、文本内容及几何坐标。
16
+ - **最佳实践**:修改已有图表前必须先调用 `drawio_inspect`,获取已有 cell ID、父子层级关系及连线端点。
17
+ - **字段**:返回的 `plainText` 是已去除 HTML 格式的纯净标签;`children` 标识该容器节点包含的子节点 ID。
18
+ - 仅在需要查看原始底层 XML 属性时设置 `include_xml: true`。
19
+
20
+ ### 2. `drawio_edit`
21
+ - **功能**:对现有 `.drawio` 文件应用批量语义化增量修改,支持多页面管理。
22
+ - **支持的批量操作类型**:
23
+ - `add_node`:新增节点。属性:`id`, `label`, `style`, `parent`, `x`, `y`, `width`, `height`。
24
+ - `add_edge`:新增连线。属性:`id`, `source`, `target`, `label`, `style`, `parent`。
25
+ - `update`:更新已有节点或连线属性。
26
+ - 推荐使用 `stylePatch: { fillColor: '#dae8fc', strokeColor: '#6c8ebf' }` 局部增量修改颜色或边框,避免冲掉节点的已有样式(如圆角、对齐等)。
27
+ - 也支持整体更新 `label`, `style`, `x`, `y`, `width`, `height`。
28
+ - `delete`:安全删除节点,并自动级联清理连接到该节点的孤立连线。
29
+ - `add_page`:在当前文档中新增图表页面。属性:`id`, `name`(如 `部署拓扑`、`时序交互`)。
30
+ - `rename_page`:重命名页面。属性:`page` (索引或ID), `name`。
31
+ - `delete_page`:删除指定页面。属性:`page`。
32
+ - **规范**:节点 ID 应具备稳定且语义化的英文标识(例如 `client`, `api_gateway`, `auth_service`, `db_cluster`),严禁使用易冲突的纯数字 ID。
33
+
34
+ ### 3. `drawio_write`
35
+ - **功能**:校验并写入完整的 Draw.io `mxfile` 或 `mxGraphModel` XML 文档。
36
+ - 自动格式化为未压缩的规范 XML,便于后续 AI 审查与 Git diff 跟踪。
37
+ - **建议**:从零构建新图表时首选 `drawio_write`;对现有图表进行局部修改或扩展时首选 `drawio_edit`。
38
+
39
+ ---
40
+
41
+ ## 架构图排版与设计规范
42
+
43
+ 为了保证大模型生成的图表整洁、专业、无重叠,请遵循以下工业级排版规范:
44
+
45
+ ### 1. 坐标与网格系统
46
+ - **基准网格**:Draw.io 默认 `gridSize=10`,所有坐标 `x`, `y`, `width`, `height` 请取 10 的整数倍。
47
+ - **标准节点尺寸**:
48
+ - 常规服务/模块:`width: 140, height: 60`
49
+ - 数据库/存储:`width: 120, height: 70`
50
+ - 外部系统/客户端:`width: 130, height: 50`
51
+ - 分组容器(Group/VPC):根据内部节点数量自适应(如 `width: 600, height: 320`)
52
+ - **间距推荐**:
53
+ - 横向流式布局:节点水平间距保持在 `80px ~ 120px`(如 `x1=40, x2=240, x3=440`);
54
+ - 纵向分层架构:分层垂直间距保持在 `90px ~ 130px`(如 Layer 1: `y=40`,Layer 2: `y=160`,Layer 3: `y=280`)。
55
+
56
+ ### 2. 经典高质感现代配色方案(Flat Palette)
57
+
58
+ | 分类/层级 | 填充色 `fillColor` | 边框色 `strokeColor` | 字体色 `fontColor` | 推荐使用场景 |
59
+ | :--- | :--- | :--- | :--- | :--- |
60
+ | **接入/网关** | `#dae8fc` (浅蓝) | `#6c8ebf` (深蓝) | `#000000` | Client, Nginx, API Gateway, CDN |
61
+ | **业务服务** | `#d5e8d4` (浅绿) | `#82b366` (深绿) | `#000000` | OrderService, UserService, K8s Pods |
62
+ | **存储/数据库** | `#ffe6cc` (暖橙) | `#d79b00` (深橙) | `#000000` | MySQL, Redis, MongoDB, S3 |
63
+ | **异步队列/通信** | `#e1d5e7` (浅紫) | `#9673a6` (深紫) | `#000000` | Kafka, RabbitMQ, EventBus |
64
+ | **告警/高风险** | `#f8cecc` (浅红) | `#b85450` (深红) | `#000000` | ErrorHandler, DeadLetter, Alert |
65
+ | **容器/分组边框** | `#f5f5f5` (极浅灰) | `#cccccc` (浅灰) | `#333333` | VPC, Kubernetes Cluster, Subnet |
66
+
67
+ ### 3. 连线样式(Edge Styles)
68
+ - 统一使用正交折线:`edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;`
69
+ - 若要强调特定流向,可增加线宽 `strokeWidth=2;`,异步/订阅连线推荐增加虚线 `dashed=1;`。
70
+
71
+ ---
72
+
73
+ ## 离线画板协同与图片导出
74
+
75
+ - **右侧栏实时双向协同**:
76
+ - 用户在 DSH 中打开任何 `.drawio` 文件即可在右侧栏全功能画板中直观交互;
77
+ - 模型调用 `drawio_edit` 或 `drawio_write` 保存后,画板会自动轻量检测版本变化并同步刷新,且不会中断用户正在进行的画布拖拽。
78
+ - **纯本地离线图片导出**:
79
+ - 画板右上角内置纯本地导出面板,支持导出为 **PNG (2x 超清)**、**SVG (矢量无损)** 和 **XML-PNG (双模可编辑图)**;
80
+ - 支持一键**保存到工作区**(自动生成同名图片)、**写入系统剪贴板**(便于直接粘贴到文档或聊天窗口)或**下载到本地磁盘**。
@@ -6,7 +6,7 @@ const TAB_ID = '@civilization/dsh-drawio'
6
6
  const TAB_KIND = 'drawio'
7
7
 
8
8
  interface SessionScope { sessionId: string; cwd?: string }
9
- interface FileViewerProps { content?: string; truncated?: boolean; path: string; scope: SessionScope }
9
+ interface FileViewerProps { content?: string; version?: string; truncated?: boolean; path: string; scope: SessionScope }
10
10
  interface OfficialContext extends Context { slots: any; sidebarRightTabs: any }
11
11
 
12
12
  export function apply(ctx: Context): void {
@@ -30,66 +30,111 @@ export function apply(ctx: Context): void {
30
30
 
31
31
  function OfficialDrawioTabTitle({ useTabInfo }: any): JSX.Element {
32
32
  const { tab } = useTabInfo()
33
- return <><CanvasIcon size={15} /><span>{tab.title}</span></>
33
+ const title = tab?.title || (tab?.navigation?.address ? fileName(parseDrawioAddress(tab.navigation.address)?.path ?? 'Draw.io') : 'Draw.io')
34
+ return <><CanvasIcon size={15} /><span>{title}</span></>
34
35
  }
35
36
 
36
37
  function OfficialDrawioTabBody({ sessionId, useSessions, useTabInfo }: any): JSX.Element {
37
38
  const { tab } = useTabInfo()
38
- const cwd = useSessions((sessions: any) => sessions?.byId?.[sessionId]?.cwd) as string | undefined
39
- const resource = useMemo(() => parseDrawioAddress(tab.navigation.address), [tab.navigation.address])
40
- const [file, setFile] = useState<{ key: string; content: string } | null>(null)
39
+ const rawAddress = tab?.navigation?.address
40
+ const resource = useMemo(() => parseDrawioAddress(rawAddress, sessionId), [rawAddress, sessionId])
41
+
42
+ const activeCwd = useSessions((sessions: any) => sessions?.byId?.[sessionId]?.cwd) as string | undefined
43
+ const resourceCwd = useSessions((sessions: any) => resource?.sessionId ? sessions?.byId?.[resource.sessionId]?.cwd : undefined) as string | undefined
44
+ const cwd = activeCwd || resourceCwd
45
+ const effectiveSessionId = sessionId || resource?.sessionId || ''
46
+
47
+ const [file, setFile] = useState<{ key: string; content: string; version?: string } | null>(null)
41
48
  const [error, setError] = useState<string | null>(null)
42
- const key = `${sessionId}\0${cwd ?? ''}\0${resource?.path ?? ''}`
49
+ const key = `${effectiveSessionId}\0${cwd ?? ''}\0${resource?.path ?? ''}`
43
50
 
44
51
  useEffect(() => {
45
- if (!resource || resource.sessionId !== sessionId || !cwd) return
52
+ if (!resource?.path || !effectiveSessionId || !cwd) return
46
53
  const controller = new AbortController()
47
54
  setError(null)
48
- void fsRead({ sessionId, cwd }, resource.path, controller.signal)
49
- .then(content => { if (!controller.signal.aborted) setFile({ key, content }) })
55
+ void fsRead({ sessionId: effectiveSessionId, cwd }, resource.path, controller.signal)
56
+ .then(res => { if (!controller.signal.aborted) setFile({ key, content: res.content, version: res.version }) })
50
57
  .catch(reason => { if (!controller.signal.aborted) setError(messageOf(reason)) })
51
58
  return () => controller.abort()
52
- }, [key, resource?.sessionId, resource?.path, sessionId, cwd])
59
+ }, [key, resource?.path, effectiveSessionId, cwd])
53
60
 
54
- if (!resource || resource.sessionId !== sessionId) return <CenteredMessage title="无法打开画板" detail="文件地址不是当前会话中的 Draw.io 文件。" />
55
- if (!cwd) return <CenteredMessage title="无法打开画板" detail="当前会话没有可用的工作区。" />
61
+ if (!resource || !resource.path) {
62
+ return (
63
+ <CenteredMessage
64
+ title="无法打开画板"
65
+ detail={rawAddress ? `无法解析 Draw.io 文件地址:${rawAddress}` : '无法获取文件导航地址。'}
66
+ />
67
+ )
68
+ }
69
+ if (!cwd) {
70
+ return (
71
+ <CenteredMessage
72
+ title="无法打开画板"
73
+ detail="当前会话没有关联可用的工作区,请先在 DSH 中打开或选择一个工作区文件夹。"
74
+ />
75
+ )
76
+ }
56
77
  if (error) return <CenteredMessage title="画板载入失败" detail={error} />
57
78
  if (file?.key !== key) return <CenteredMessage title="正在载入画板" detail={fileName(resource.path)} />
58
- return <DrawioCanvas content={file.content} path={resource.path} scope={{ sessionId, cwd }} />
79
+ return <DrawioCanvas content={file.content} version={file.version} path={resource.path} scope={{ sessionId: effectiveSessionId, cwd }} />
59
80
  }
60
81
 
61
- function DrawioCanvas({ content, truncated, path, scope }: FileViewerProps): JSX.Element {
82
+ function DrawioCanvas({ content, version, truncated, path, scope }: FileViewerProps): JSX.Element {
62
83
  const frame = useRef<HTMLIFrameElement>(null)
63
84
  const latestFile = useRef(content ?? '')
64
85
  const latestEditor = useRef(content ?? '')
86
+ const latestVersion = useRef(version)
65
87
  const saveTimer = useRef<number | undefined>(undefined)
66
88
  const saveGeneration = useRef(0)
67
89
  const [ready, setReady] = useState(false)
68
- const [saveState, setSaveState] = useState<'loading' | 'saved' | 'saving' | 'external' | 'error'>('loading')
90
+ const [saveState, setSaveState] = useState<'loading' | 'saved' | 'saving' | 'external' | 'conflict' | 'error'>('loading')
69
91
  const [error, setError] = useState<string | null>(truncated ? '文件过大,DSH 只返回了截断内容,无法安全打开。' : null)
70
92
  const lastUserActivity = useRef(0)
71
93
  const isInteracting = useRef(false)
72
94
  const { dark, accent: accentColor } = useDshTheme()
73
- const src = useMemo(() => `/dsh-drawio/runtime/index.html?embed=1&proto=json&spin=1&offline=1&local=1&ui=min&libraries=1&configure=1&noExitBtn=1&saveAndExit=0&dark=${dark ? '1' : '0'}`, [dark])
95
+ const initialDark = useRef(dark)
96
+ const src = useMemo(() => `/dsh-drawio/runtime/index.html?embed=1&proto=json&spin=1&offline=1&local=1&ui=min&libraries=1&configure=1&noExitBtn=1&saveAndExit=0&dark=${initialDark.current ? '1' : '0'}`, [])
74
97
 
75
98
  const post = useCallback((message: unknown) => {
76
99
  frame.current?.contentWindow?.postMessage(JSON.stringify(message), window.location.origin)
77
100
  }, [])
78
101
 
102
+ const reloadFromDisk = useCallback(async () => {
103
+ try {
104
+ setSaveState('loading')
105
+ const result = await fsRead(scope, path)
106
+ latestFile.current = result.content
107
+ latestEditor.current = result.content
108
+ latestVersion.current = result.version
109
+ post({ action: 'load', xml: result.content, autosave: 1, title: fileName(path), dark, noExitBtn: 1, saveAndExit: 0 })
110
+ setSaveState('saved')
111
+ setError(null)
112
+ } catch (reason) {
113
+ setError(messageOf(reason))
114
+ }
115
+ }, [scope, path, dark, post])
116
+
79
117
  const persist = useCallback(async (xml: string) => {
80
118
  const generation = ++saveGeneration.current
81
119
  setSaveState('saving')
82
120
  try {
83
- await fsWrite(scope, path, xml)
121
+ const outcome = await fsWrite(scope, path, xml, latestVersion.current)
84
122
  if (generation !== saveGeneration.current) return
85
123
  latestFile.current = xml
86
124
  latestEditor.current = xml
125
+ if (outcome?.version) latestVersion.current = outcome.version
87
126
  setSaveState('saved')
88
127
  setError(null)
89
128
  } catch (reason) {
90
129
  if (generation !== saveGeneration.current) return
91
- setSaveState('error')
92
- setError(messageOf(reason))
130
+ const msg = messageOf(reason)
131
+ if (msg.includes('modified by an external process') || msg.includes('409')) {
132
+ setSaveState('conflict')
133
+ setError('文件已被外部或 AI 修改,当前存在并发冲突,点击可重新载入最新内容')
134
+ } else {
135
+ setSaveState('error')
136
+ setError(msg)
137
+ }
93
138
  }
94
139
  }, [scope.sessionId, scope.cwd, path])
95
140
 
@@ -113,7 +158,8 @@ function DrawioCanvas({ content, truncated, path, scope }: FileViewerProps): JSX
113
158
  useEffect(() => {
114
159
  latestFile.current = content ?? ''
115
160
  latestEditor.current = content ?? ''
116
- }, [content, path])
161
+ latestVersion.current = version
162
+ }, [content, version, path])
117
163
 
118
164
  const markActivity = useCallback(() => {
119
165
  lastUserActivity.current = Date.now()
@@ -147,12 +193,101 @@ function DrawioCanvas({ content, truncated, path, scope }: FileViewerProps): JSX
147
193
  return () => window.clearInterval(timer)
148
194
  }, [])
149
195
 
196
+ const [menuOpen, setMenuOpen] = useState(false)
197
+ const [exportFormat, setExportFormat] = useState<'png' | 'svg' | 'xmlpng'>('png')
198
+ const [isTransparent, setIsTransparent] = useState(false)
199
+ const [isExporting, setIsExporting] = useState(false)
200
+ const [toast, setToast] = useState<string | null>(null)
201
+ const pendingExport = useRef<{ format: 'png' | 'svg' | 'xmlpng'; target: 'workspace' | 'download' | 'clipboard'; targetPath: string } | null>(null)
202
+ const menuRef = useRef<HTMLDivElement>(null)
203
+
204
+ const showToast = useCallback((msg: string) => {
205
+ setToast(msg)
206
+ window.setTimeout(() => setToast(current => current === msg ? null : current), 3200)
207
+ }, [])
208
+
209
+ const startExport = useCallback((target: 'workspace' | 'download' | 'clipboard') => {
210
+ if (!ready || isExporting) return
211
+ const ext = exportFormat === 'svg' ? '.svg' : exportFormat === 'xmlpng' ? '.drawio.png' : '.png'
212
+ const base = path.replace(/\.drawio$/i, '')
213
+ const targetPath = `${base}${ext}`
214
+
215
+ pendingExport.current = {
216
+ format: exportFormat,
217
+ target,
218
+ targetPath,
219
+ }
220
+ setIsExporting(true)
221
+ setMenuOpen(false)
222
+ post({
223
+ action: 'export',
224
+ format: exportFormat,
225
+ scale: exportFormat === 'svg' ? 1 : 2,
226
+ transparent: isTransparent,
227
+ border: 10,
228
+ spin: '正在渲染导出图片...',
229
+ })
230
+ }, [ready, isExporting, exportFormat, isTransparent, path, post])
231
+
232
+ const handleExportResult = useCallback(async (pending: { format: 'png' | 'svg' | 'xmlpng'; target: 'workspace' | 'download' | 'clipboard'; targetPath: string }, data: string) => {
233
+ try {
234
+ if (pending.target === 'download') {
235
+ const link = document.createElement('a')
236
+ link.download = fileName(pending.targetPath)
237
+ link.href = data
238
+ document.body.appendChild(link)
239
+ link.click()
240
+ document.body.removeChild(link)
241
+ showToast(`已开始下载:${fileName(pending.targetPath)}`)
242
+ } else if (pending.target === 'clipboard') {
243
+ try {
244
+ if (pending.format === 'svg') {
245
+ const svgText = data.startsWith('data:image/svg+xml')
246
+ ? decodeURIComponent(data.split(',')[1] || '')
247
+ : data
248
+ await navigator.clipboard.writeText(svgText)
249
+ showToast('已复制 SVG 代码到剪贴板')
250
+ } else {
251
+ const res = await fetch(data)
252
+ const blob = await res.blob()
253
+ await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
254
+ showToast('已复制图片到剪贴板')
255
+ }
256
+ } catch {
257
+ // 剪贴板受限或报错时降级为自动下载
258
+ const link = document.createElement('a')
259
+ link.download = fileName(pending.targetPath)
260
+ link.href = data
261
+ document.body.appendChild(link)
262
+ link.click()
263
+ document.body.removeChild(link)
264
+ showToast(`剪贴板无权限,已自动下载:${fileName(pending.targetPath)}`)
265
+ }
266
+ } else if (pending.target === 'workspace') {
267
+ const result = await fsSaveImage(scope, pending.targetPath, data)
268
+ showToast(`已保存到工作区:${fileName(result.path)} (${Math.round(result.bytes / 1024)} KB)`)
269
+ }
270
+ } catch (reason) {
271
+ setError(`导出失败:${messageOf(reason)}`)
272
+ }
273
+ }, [scope, showToast])
274
+
275
+ useEffect(() => {
276
+ const handleOutsideClick = (e: MouseEvent) => {
277
+ if (menuOpen && menuRef.current && !menuRef.current.contains(e.target as Node)) {
278
+ setMenuOpen(false)
279
+ }
280
+ }
281
+ window.addEventListener('pointerdown', handleOutsideClick)
282
+ return () => window.removeEventListener('pointerdown', handleOutsideClick)
283
+ }, [menuOpen])
284
+
150
285
  useEffect(() => {
151
286
  setReady(false)
152
287
  setSaveState('loading')
153
288
  const receive = (event: MessageEvent) => {
154
289
  if (event.source !== frame.current?.contentWindow || event.origin !== window.location.origin) return
155
- let message: { event?: string; xml?: string }
290
+ let message: { event?: string; xml?: string; format?: string; data?: string }
156
291
  try { message = typeof event.data === 'string' ? JSON.parse(event.data) : event.data }
157
292
  catch { return }
158
293
  if (message.event === 'configure') {
@@ -163,11 +298,18 @@ function DrawioCanvas({ content, truncated, path, scope }: FileViewerProps): JSX
163
298
  setSaveState('saved')
164
299
  } else if ((message.event === 'autosave' || message.event === 'save') && typeof message.xml === 'string') {
165
300
  scheduleSave(message.xml, message.event === 'save')
301
+ } else if (message.event === 'export' && typeof message.data === 'string') {
302
+ const pending = pendingExport.current
303
+ pendingExport.current = null
304
+ setIsExporting(false)
305
+ if (pending) {
306
+ void handleExportResult(pending, message.data)
307
+ }
166
308
  }
167
309
  }
168
310
  window.addEventListener('message', receive)
169
311
  return () => window.removeEventListener('message', receive)
170
- }, [src, dark, path, post, scheduleSave])
312
+ }, [src, dark, path, post, scheduleSave, handleExportResult])
171
313
 
172
314
  useEffect(() => {
173
315
  if (!ready || truncated) return
@@ -183,14 +325,23 @@ function DrawioCanvas({ content, truncated, path, scope }: FileViewerProps): JSX
183
325
  if (typeof document !== 'undefined' && document.hidden) return
184
326
 
185
327
  try {
186
- const xml = await fsRead(scope, path)
328
+ // 轻量检查版本号
329
+ const stat = await fsStat(scope, path)
330
+ if (cancelled || isInteracting.current || Date.now() - lastUserActivity.current < 1200) return
331
+ if (stat.version && latestVersion.current && stat.version === latestVersion.current) return
332
+
333
+ const res = await fsRead(scope, path)
187
334
  // 5. 响应返回后二次防护:若这期间用户按下了鼠标或开始拖动,果断丢弃本次结果
188
335
  if (cancelled || isInteracting.current || Date.now() - lastUserActivity.current < 1200) return
189
- if (xml === latestFile.current || xml === latestEditor.current) return
336
+ if (res.content === latestFile.current || res.content === latestEditor.current) {
337
+ latestVersion.current = res.version
338
+ return
339
+ }
190
340
 
191
- latestFile.current = xml
192
- latestEditor.current = xml
193
- post({ action: 'load', xml, autosave: 1, title: fileName(path), dark, noExitBtn: 1, saveAndExit: 0 })
341
+ latestFile.current = res.content
342
+ latestEditor.current = res.content
343
+ latestVersion.current = res.version
344
+ post({ action: 'load', xml: res.content, autosave: 1, title: fileName(path), dark, noExitBtn: 1, saveAndExit: 0 })
194
345
  setSaveState('external')
195
346
  window.setTimeout(() => { if (!cancelled) setSaveState('saved') }, 1200)
196
347
  } catch (reason) {
@@ -204,17 +355,123 @@ function DrawioCanvas({ content, truncated, path, scope }: FileViewerProps): JSX
204
355
 
205
356
  useEffect(() => () => {
206
357
  if (saveTimer.current !== undefined) window.clearTimeout(saveTimer.current)
207
- if (latestEditor.current !== latestFile.current) void fsWrite(scope, path, latestEditor.current, true)
358
+ if (latestEditor.current !== latestFile.current) void fsWrite(scope, path, latestEditor.current, latestVersion.current, true)
208
359
  }, [scope.sessionId, scope.cwd, path])
209
360
 
210
361
  if (truncated) return <CenteredMessage title="无法打开画板" detail="文件内容已被截断,继续编辑可能损坏原文件。" />
362
+
363
+ const targetExt = exportFormat === 'svg' ? '.svg' : exportFormat === 'xmlpng' ? '.drawio.png' : '.png'
364
+ const previewName = `${fileName(path).replace(/\.drawio$/i, '')}${targetExt}`
365
+
211
366
  return (
212
367
  <div style={styles.root}>
213
368
  <iframe ref={frame} title={`${fileName(path)} Draw.io 画板`} src={src} style={styles.frame} />
214
- <div role="status" aria-live="polite" style={{ ...styles.status, ...(saveState === 'error' ? styles.statusError : {}) }}>
215
- <span style={{ ...styles.dot, background: accentColor }} />{statusText(saveState)}
369
+
370
+ {/* 右上角悬浮导出工具面板 */}
371
+ <div ref={menuRef} style={styles.toolbar}>
372
+ <button
373
+ type="button"
374
+ onClick={() => setMenuOpen(open => !open)}
375
+ disabled={!ready || isExporting}
376
+ style={{ ...styles.exportTrigger, ...(isExporting ? styles.triggerActive : {}) }}
377
+ title="导出当前画板为图片"
378
+ >
379
+ <CameraIcon size={14} />
380
+ <span>{isExporting ? '正在导出...' : '导出图片'}</span>
381
+ <span style={styles.arrowIcon}>▾</span>
382
+ </button>
383
+
384
+ {menuOpen ? (
385
+ <div style={styles.exportMenu}>
386
+ <div style={styles.menuHeader}>
387
+ <span style={styles.menuTitle}>导出画板图片</span>
388
+ <span style={styles.menuSub}>{previewName}</span>
389
+ </div>
390
+
391
+ <div style={styles.formatRow}>
392
+ {(['png', 'svg', 'xmlpng'] as const).map(fmt => (
393
+ <button
394
+ key={fmt}
395
+ type="button"
396
+ onClick={() => setExportFormat(fmt)}
397
+ style={{
398
+ ...styles.formatBtn,
399
+ ...(exportFormat === fmt ? { ...styles.formatBtnActive, borderColor: accentColor } : {}),
400
+ }}
401
+ >
402
+ {fmt === 'png' ? 'PNG (超清)' : fmt === 'svg' ? 'SVG (矢量)' : 'XML-PNG'}
403
+ </button>
404
+ ))}
405
+ </div>
406
+
407
+ {exportFormat !== 'svg' ? (
408
+ <label style={styles.checkRow}>
409
+ <input
410
+ type="checkbox"
411
+ checked={isTransparent}
412
+ onChange={e => setIsTransparent(e.target.checked)}
413
+ style={styles.checkbox}
414
+ />
415
+ <span>透明背景</span>
416
+ </label>
417
+ ) : null}
418
+
419
+ <div style={styles.actionCol}>
420
+ <button
421
+ type="button"
422
+ onClick={() => startExport('workspace')}
423
+ style={{ ...styles.actionBtn, ...styles.actionBtnPrimary, background: accentColor }}
424
+ >
425
+ <SaveIcon size={13} />
426
+ <span>保存到工作区 ({targetExt})</span>
427
+ </button>
428
+
429
+ <button
430
+ type="button"
431
+ onClick={() => startExport('clipboard')}
432
+ style={styles.actionBtn}
433
+ >
434
+ <CopyIcon size={13} />
435
+ <span>复制到剪贴板</span>
436
+ </button>
437
+
438
+ <button
439
+ type="button"
440
+ onClick={() => startExport('download')}
441
+ style={styles.actionBtn}
442
+ >
443
+ <DownloadIcon size={13} />
444
+ <span>下载图片文件</span>
445
+ </button>
446
+ </div>
447
+ </div>
448
+ ) : null}
449
+ </div>
450
+
451
+ {/* Toast 提示 */}
452
+ {toast ? (
453
+ <div style={styles.toast}>
454
+ <CheckIcon size={14} />
455
+ <span>{toast}</span>
456
+ </div>
457
+ ) : null}
458
+
459
+ <div role="status" aria-live="polite" style={{ ...styles.status, ...(saveState === 'error' || saveState === 'conflict' ? styles.statusError : {}) }}>
460
+ <span style={{ ...styles.dot, background: saveState === 'conflict' ? 'var(--dsw-alias-warning, #e6a23c)' : accentColor }} />{statusText(saveState)}
216
461
  </div>
217
- {error ? <button type="button" title={error} onClick={() => setError(null)} style={styles.error}>保存失败 · 点击关闭</button> : null}
462
+ {error ? (
463
+ <button
464
+ type="button"
465
+ title={error}
466
+ onClick={() => {
467
+ if (saveState === 'conflict') void reloadFromDisk()
468
+ setError(null)
469
+ }}
470
+ style={styles.error}
471
+ >
472
+ {saveState === 'conflict' ? '⚠️ 检测到外部修改 · 点击重新载入' : '保存失败 · 点击关闭'}
473
+ </button>
474
+ ) : null}
218
475
  </div>
219
476
  )
220
477
  }
@@ -260,13 +517,20 @@ function detectDark(): boolean {
260
517
  return document.documentElement.classList.contains('dark') || window.matchMedia('(prefers-color-scheme: dark)').matches
261
518
  }
262
519
 
263
- async function fsRead(scope: SessionScope, path: string, signal?: AbortSignal): Promise<string> {
264
- const value = await call<{ content: string }>('read', scope, { path }, false, signal)
265
- return value.content
520
+ async function fsStat(scope: SessionScope, path: string, signal?: AbortSignal): Promise<{ version: string; size: number }> {
521
+ return call<{ version: string; size: number }>('stat', scope, { path }, false, signal)
266
522
  }
267
523
 
268
- async function fsWrite(scope: SessionScope, path: string, content: string, keepalive = false): Promise<void> {
269
- await call('write', scope, { path, content }, keepalive)
524
+ async function fsRead(scope: SessionScope, path: string, signal?: AbortSignal): Promise<{ content: string; version: string }> {
525
+ return call<{ content: string; version: string }>('read', scope, { path }, false, signal)
526
+ }
527
+
528
+ async function fsWrite(scope: SessionScope, path: string, content: string, version?: string, keepalive = false): Promise<{ version?: string }> {
529
+ return call<{ version?: string }>('write', scope, { path, content, ...(version ? { version } : {}) }, keepalive)
530
+ }
531
+
532
+ async function fsSaveImage(scope: SessionScope, path: string, data: string): Promise<{ path: string; bytes: number }> {
533
+ return call<{ path: string; bytes: number }>('save-image', scope, { path, data })
270
534
  }
271
535
 
272
536
  async function call<T = { ok: true }>(method: string, scope: SessionScope, extra: Record<string, unknown>, keepalive = false, signal?: AbortSignal): Promise<T> {
@@ -280,24 +544,78 @@ function CanvasIcon({ size = 16, className }: { size?: number; className?: strin
280
544
  return <svg className={className} aria-hidden viewBox="0 0 20 20" width={size} height={size} fill="none"><rect x="2.5" y="2.5" width="15" height="15" rx="2.5" stroke="currentColor"/><circle cx="7" cy="7" r="1.5" fill="currentColor"/><circle cx="13" cy="13" r="1.5" fill="currentColor"/><path d="M8.4 7.8l3.2 4.4M8.2 6.2h3.6M6.2 8.2v3.6" stroke="currentColor" strokeLinecap="round"/></svg>
281
545
  }
282
546
 
547
+ function CameraIcon({ size = 16, className }: { size?: number; className?: string }): JSX.Element {
548
+ return <svg className={className} aria-hidden viewBox="0 0 20 20" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 6.5A1.5 1.5 0 0 1 5.5 5h1.8a1.5 1.5 0 0 0 1.2-.6l.5-.7A1.5 1.5 0 0 1 10.2 3h1.6a1.5 1.5 0 0 1 1.2.7l.5.7a1.5 1.5 0 0 0 1.2.6h1.8A1.5 1.5 0 0 1 18 6.5v9a1.5 1.5 0 0 1-1.5 1.5h-13A1.5 1.5 0 0 1 2 15.5v-9z"/><circle cx="10" cy="11" r="3.5"/></svg>
549
+ }
550
+
551
+ function SaveIcon({ size = 16, className }: { size?: number; className?: string }): JSX.Element {
552
+ return <svg className={className} aria-hidden viewBox="0 0 20 20" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M16 17H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h9.5l3.5 3.5V16a1 1 0 0 1-1 1z"/><path d="M13 17v-6H7v6"/><path d="M7 3v4h6"/></svg>
553
+ }
554
+
555
+ function CopyIcon({ size = 16, className }: { size?: number; className?: string }): JSX.Element {
556
+ return <svg className={className} aria-hidden viewBox="0 0 20 20" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="7" y="7" width="10" height="10" rx="1.5"/><path d="M4 13H3.5A1.5 1.5 0 0 1 2 11.5v-8A1.5 1.5 0 0 1 3.5 2h8A1.5 1.5 0 0 1 13 3.5V4"/></svg>
557
+ }
558
+
559
+ function DownloadIcon({ size = 16, className }: { size?: number; className?: string }): JSX.Element {
560
+ return <svg className={className} aria-hidden viewBox="0 0 20 20" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M10 3v10m0 0l-3.5-3.5M10 13l3.5-3.5"/><path d="M3 14v2a1.5 1.5 0 0 0 1.5 1.5h11a1.5 1.5 0 0 0 1.5-1.5v-2"/></svg>
561
+ }
562
+
563
+ function CheckIcon({ size = 16, className }: { size?: number; className?: string }): JSX.Element {
564
+ return <svg className={className} aria-hidden viewBox="0 0 20 20" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M4 10.5l4 4 8-9"/></svg>
565
+ }
566
+
283
567
  function CenteredMessage({ title, detail }: { title: string; detail: string }): JSX.Element {
284
568
  return <div style={styles.center}><strong>{title}</strong><span style={styles.muted}>{detail}</span></div>
285
569
  }
286
570
 
287
571
  const fileName = (path: string): string => path.replace(/\\/g, '/').split('/').pop() || path
288
- function parseDrawioAddress(address: string): { sessionId: string; path: string } | null {
289
- const prefix = 'dsh-resource://file/session/'
290
- if (!address.startsWith(prefix)) return null
572
+
573
+ export function parseDrawioAddress(address?: string, fallbackSessionId?: string): { sessionId: string; path: string } | null {
574
+ if (!address || typeof address !== 'string') return null
291
575
  try {
292
- const parts = address.slice(prefix.length).split('/').map(decodeURIComponent)
293
- const sessionId = parts.shift() ?? ''
294
- const path = parts.join('/')
295
- if (!sessionId || !path || !/\.drawio$/i.test(path) || parts.some(part => part.includes('/') || part.includes('\\') || part.includes('\0'))) return null
296
- return { sessionId, path }
297
- } catch { return null }
576
+ let clean = address.trim()
577
+ let parsedSessionId = ''
578
+
579
+ if (clean.startsWith('dsh-resource://file/session/')) {
580
+ const rest = clean.slice('dsh-resource://file/session/'.length)
581
+ const slashIdx = rest.indexOf('/')
582
+ if (slashIdx >= 0) {
583
+ parsedSessionId = decodeURIComponent(rest.slice(0, slashIdx))
584
+ clean = rest.slice(slashIdx + 1)
585
+ } else {
586
+ clean = rest
587
+ }
588
+ } else if (clean.startsWith('dsh-resource://file/')) {
589
+ clean = clean.slice('dsh-resource://file/'.length)
590
+ } else if (clean.startsWith('file:///')) {
591
+ clean = clean.slice('file:///'.length)
592
+ }
593
+
594
+ // 处理可能是 URL 编码的 path(支持嵌套目录 %2F)
595
+ let decodedPath = clean
596
+ try {
597
+ decodedPath = decodeURIComponent(clean)
598
+ } catch {}
599
+
600
+ // 规范化斜杠并去除开头的斜杠
601
+ decodedPath = decodedPath.replace(/\\/g, '/').replace(/^\/+/, '')
602
+
603
+ // 必须是 .drawio 文件
604
+ if (!/\.drawio$/i.test(decodedPath)) return null
605
+
606
+ // 防空字符
607
+ if (decodedPath.includes('\0')) return null
608
+
609
+ return {
610
+ sessionId: parsedSessionId || fallbackSessionId || '',
611
+ path: decodedPath,
612
+ }
613
+ } catch {
614
+ return null
615
+ }
298
616
  }
299
617
  const messageOf = (reason: unknown): string => reason instanceof Error ? reason.message : String(reason)
300
- const statusText = (state: string): string => state === 'loading' ? '正在载入' : state === 'saving' ? '正在保存' : state === 'external' ? '已同步 AI 修改' : state === 'error' ? '保存失败' : '已保存'
618
+ const statusText = (state: string): string => state === 'loading' ? '正在载入' : state === 'saving' ? '正在保存' : state === 'external' ? '已同步 AI 修改' : state === 'conflict' ? '检测到冲突' : state === 'error' ? '保存失败' : '已保存'
301
619
  const fg = 'var(--dsw-alias-label-primary, currentColor)'
302
620
  const muted = 'var(--dsw-alias-label-secondary, rgba(127,127,127,.9))'
303
621
  const layer = 'var(--dsw-alias-container-bg, Canvas)'
@@ -306,6 +624,23 @@ const accent = 'var(--dsw-alias-brand-primary, #ed67ad)'
306
624
  const styles: Record<string, React.CSSProperties> = {
307
625
  root: { position: 'relative', width: '100%', height: '100%', minHeight: 0, overflow: 'hidden', color: fg, background: layer },
308
626
  frame: { display: 'block', width: '100%', height: '100%', border: 0, background: layer },
627
+ toolbar: { position: 'absolute', right: 12, top: 12, zIndex: 10, display: 'flex', flexDirection: 'column', alignItems: 'flex-end' },
628
+ exportTrigger: { display: 'flex', alignItems: 'center', gap: 6, padding: '5px 10px', border: `1px solid ${border}`, borderRadius: 6, color: fg, background: layer, boxShadow: 'var(--dsw-alias-shadow-sm, 0 2px 8px rgba(0,0,0,.12))', fontSize: 12, fontWeight: 500, cursor: 'pointer', backdropFilter: 'blur(8px)', transition: 'all 0.15s ease' },
629
+ triggerActive: { opacity: 0.75, cursor: 'wait' },
630
+ arrowIcon: { fontSize: 10, opacity: 0.6, marginLeft: 2 },
631
+ exportMenu: { marginTop: 6, width: 220, padding: 12, border: `1px solid ${border}`, borderRadius: 8, background: layer, boxShadow: 'var(--dsw-alias-shadow-md, 0 6px 20px rgba(0,0,0,.18))', backdropFilter: 'blur(12px)', display: 'flex', flexDirection: 'column', gap: 10 },
632
+ menuHeader: { display: 'flex', flexDirection: 'column', gap: 2, paddingBottom: 6, borderBottom: `1px solid ${border}` },
633
+ menuTitle: { fontSize: 12, fontWeight: 600, color: fg },
634
+ menuSub: { fontSize: 10, color: muted, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
635
+ formatRow: { display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 4 },
636
+ formatBtn: { padding: '4px 2px', border: `1px solid ${border}`, borderRadius: 4, background: 'transparent', color: muted, fontSize: 10.5, cursor: 'pointer', textAlign: 'center', transition: 'all 0.1s ease' },
637
+ formatBtnActive: { color: fg, fontWeight: 600, background: 'var(--dsw-alias-fill-quaternary, rgba(127,127,127,0.12))' },
638
+ checkRow: { display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: muted, cursor: 'pointer', userSelect: 'none' },
639
+ checkbox: { cursor: 'pointer', margin: 0 },
640
+ actionCol: { display: 'flex', flexDirection: 'column', gap: 6, paddingTop: 4 },
641
+ actionBtn: { display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, padding: '6px 10px', border: `1px solid ${border}`, borderRadius: 5, background: 'transparent', color: fg, fontSize: 11.5, cursor: 'pointer', transition: 'all 0.12s ease' },
642
+ actionBtnPrimary: { color: '#ffffff', border: 'none', fontWeight: 500 },
643
+ toast: { position: 'absolute', top: 12, left: '50%', transform: 'translateX(-50%)', zIndex: 12, display: 'flex', alignItems: 'center', gap: 6, padding: '6px 14px', border: `1px solid ${border}`, borderRadius: 20, background: layer, color: fg, boxShadow: 'var(--dsw-alias-shadow-md, 0 4px 14px rgba(0,0,0,.16))', fontSize: 12, backdropFilter: 'blur(10px)', pointerEvents: 'none' },
309
644
  status: { position: 'absolute', right: 10, bottom: 9, zIndex: 2, display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px', border: `1px solid ${border}`, borderRadius: 999, color: muted, background: layer, boxShadow: 'var(--dsw-alias-shadow-sm, 0 2px 8px rgba(0,0,0,.12))', fontSize: 11, pointerEvents: 'none' },
310
645
  statusError: { color: 'var(--dsw-alias-danger, #d84f5f)' },
311
646
  dot: { width: 6, height: 6, borderRadius: 99, background: accent },