dsh-long-plugins 2.6.2 → 3.0.2

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.
package/client/client.js CHANGED
@@ -16,9 +16,16 @@ window.__ModuleLoader__.load({
16
16
  const API_PATH = '/api/dsh-uploads'
17
17
  const DOWNLOAD_PATH = '/api/dsh-uploads/download'
18
18
  const PREVIEW_PATH = '/api/dsh-uploads/preview'
19
- const SOURCE = 'local-upload-files'
20
19
  const HIDDEN_LABEL = '__dsh_upload_hidden__:'
21
20
 
21
+ function downloadUrl(name) {
22
+ return `${DOWNLOAD_PATH}?name=${encodeURIComponent(name)}`
23
+ }
24
+
25
+ function previewUrl(name) {
26
+ return `${PREVIEW_PATH}?name=${encodeURIComponent(name)}`
27
+ }
28
+
22
29
  function errorMessage(error) {
23
30
  return error instanceof Error ? error.message : String(error)
24
31
  }
@@ -29,26 +36,6 @@ window.__ModuleLoader__.load({
29
36
  return body
30
37
  }
31
38
 
32
- async function uploadFile(file) {
33
- const response = await fetch(API_PATH, {
34
- method: 'POST',
35
- headers: {
36
- 'content-type': 'application/octet-stream',
37
- 'x-file-name': encodeURIComponent(file.name),
38
- },
39
- body: file,
40
- })
41
- return (await responseJson(response)).file
42
- }
43
-
44
- function downloadUrl(name) {
45
- return `${DOWNLOAD_PATH}?name=${encodeURIComponent(name)}`
46
- }
47
-
48
- function previewUrl(name) {
49
- return `${PREVIEW_PATH}?name=${encodeURIComponent(name)}`
50
- }
51
-
52
39
  /** Extensions the browser can render inline (image/office handled separately). */
53
40
  const INLINE_PREVIEW_EXTS = new Set([
54
41
  '.pdf', '.txt', '.md', '.markdown', '.json', '.yml', '.yaml', '.xml', '.html', '.htm',
@@ -121,502 +108,6 @@ window.__ModuleLoader__.load({
121
108
  document.body.removeChild(a)
122
109
  }
123
110
 
124
- function modelLine(file) {
125
- return `上传文件:\`${file.path}\``
126
- }
127
-
128
- function serializedFile(file) {
129
- return `\n${modelLine(file)}`
130
- }
131
-
132
- function stripSerializedFiles(draft, files) {
133
- const lines = new Set(files.map(modelLine))
134
- return String(draft || '')
135
- .split('\n')
136
- .filter((line) => !lines.has(line.trim()))
137
- .join('\n')
138
- .replace(/^\n+|\n+$/g, '')
139
- }
140
-
141
- function legacyUploadPaths(draft) {
142
- const paths = []
143
- for (const line of String(draft || '').split('\n')) {
144
- const match = line.trim().match(/^上传文件:\s*`([^`]+)`$/)
145
- if (match) paths.push(match[1])
146
- }
147
- return paths
148
- }
149
-
150
- function stripLegacyPaths(draft, paths) {
151
- const remove = new Set(paths)
152
- return String(draft || '')
153
- .split('\n')
154
- .filter((line) => {
155
- const match = line.trim().match(/^上传文件:\s*`([^`]+)`$/)
156
- return !match || !remove.has(match[1])
157
- })
158
- .join('\n')
159
- .replace(/^\n+|\n+$/g, '')
160
- }
161
-
162
- class FileDraftController {
163
- constructor(ctx) {
164
- this.ctx = ctx
165
- this.pending = new Map()
166
- this.inFlight = new Map()
167
- this.refIndex = new Map()
168
- this.listeners = new Map()
169
- this.expiry = new Map()
170
- this.serializing = new Set()
171
- this.migrated = new Set()
172
- this.sinkHooked = new Set()
173
- this.counter = 0
174
- }
175
-
176
- pendingFor(sessionId) {
177
- return this.pending.get(String(sessionId)) || []
178
- }
179
-
180
- subscribe(sessionId, listener) {
181
- const key = String(sessionId)
182
- let set = this.listeners.get(key)
183
- if (!set) {
184
- set = new Set()
185
- this.listeners.set(key, set)
186
- }
187
- set.add(listener)
188
- return () => {
189
- set.delete(listener)
190
- if (set.size === 0) this.listeners.delete(key)
191
- }
192
- }
193
-
194
- publish(sessionId) {
195
- const set = this.listeners.get(String(sessionId))
196
- if (!set) return
197
- for (const listener of set) listener()
198
- }
199
-
200
- scope(sessionId) {
201
- const actx = this.ctx.sessions.scope(sessionId)
202
- if (!actx) throw new Error('当前会话尚未就绪')
203
- return { actx, shell: this.ctx.conversation.input.for(actx) }
204
- }
205
-
206
- insertReference(sessionId, entry) {
207
- const { actx, shell } = this.scope(sessionId)
208
- const input = shell.snapshot
209
- if (!input || input.phase !== 'plain') return false
210
- return actx.bail(actx, 'slash/input-insert-reference', {
211
- reference: {
212
- source: SOURCE,
213
- ref: entry.ref,
214
- label: `${HIDDEN_LABEL}${entry.ref}`,
215
- clipboardText: '',
216
- },
217
- span: {
218
- start: input.draft.length,
219
- end: input.draft.length,
220
- draftRev: input.draftRev,
221
- },
222
- }) === true
223
- }
224
-
225
- attach(sessionId, file) {
226
- const key = String(sessionId)
227
- this.clearInFlight(key)
228
- const entry = { ...file, ref: `${key}-${++this.counter}` }
229
- // 不再往草稿插入引用 token;文件仅以卡片形式显示在输入区上方,
230
- // 发送时由 ensureSinkHook 安装的 defaultSink 包装统一拼进消息。
231
- this.ensureSinkHook(key)
232
- const next = [...this.pendingFor(key), entry]
233
- this.pending.set(key, next)
234
- this.refIndex.set(entry.ref, { sessionId: key, entry })
235
- this.publish(key)
236
- return entry
237
- }
238
-
239
- ensureSinkHook(key) {
240
- const k = String(key)
241
- if (this.sinkHooked.has(k)) return
242
- let shell
243
- try {
244
- shell = this.scope(k).shell
245
- } catch (error) {
246
- return
247
- }
248
- const deps = shell.deps
249
- const original = deps && deps.defaultSink
250
- if (typeof original !== 'function') return
251
- this.sinkHooked.add(k)
252
- const controller = this
253
- deps.defaultSink = function (text, imageIds, mode, signal) {
254
- const entries = controller.pendingFor(k)
255
- let extra = ''
256
- for (const entry of entries) extra += serializedFile(entry)
257
- if (entries.length > 0) {
258
- controller.pending.delete(k)
259
- controller.inFlight.set(k, entries)
260
- controller.publish(k)
261
- }
262
- const result = original(text + extra, imageIds, mode, signal)
263
- if (entries.length === 0) return result
264
- return Promise.resolve(result).then((outcome) => {
265
- if (outcome && outcome.kind === 'success') {
266
- const expiry = controller.expiry.get(k)
267
- if (expiry) expiry()
268
- controller.expiry.delete(k)
269
- controller.inFlight.delete(k)
270
- for (const entry of entries) controller.refIndex.delete(entry.ref)
271
- }
272
- return outcome
273
- })
274
- }
275
- }
276
-
277
- remove(sessionId, ref) {
278
- const key = String(sessionId)
279
- const entries = this.pendingFor(key)
280
- const entry = entries.find((item) => item.ref === ref)
281
- if (!entry) return
282
- const next = entries.filter((item) => item.ref !== ref)
283
- if (next.length > 0) this.pending.set(key, next)
284
- else this.pending.delete(key)
285
- this.refIndex.delete(ref)
286
- this.publish(key)
287
- }
288
-
289
- fileForRef(ref) {
290
- return this.refIndex.get(ref)?.entry
291
- }
292
-
293
- markSerializing(ref) {
294
- const record = this.refIndex.get(ref)
295
- if (record) this.serializing.add(record.sessionId)
296
- }
297
-
298
- reconcile(sessionId, occurrences) {
299
- // 卡片模式:文件不再以草稿引用/occurrence 形式存在,[pending] 的发送与失败
300
- // 恢复改由 ensureSinkHook 的 defaultSink 包装 + restoreFailed 负责;这里不再按
301
- // occurrence 对账,避免草稿一变就把待发送卡片误判为已发送而提前清空。
302
- return
303
- }
304
-
305
- restoreFailed(sessionId) {
306
- const key = String(sessionId)
307
- const entries = this.inFlight.get(key)
308
- if (!entries || entries.length === 0) return
309
- const expiry = this.expiry.get(key)
310
- if (expiry) expiry()
311
- this.expiry.delete(key)
312
- this.inFlight.delete(key)
313
- // 卡片模式:失败时只把文件恢复为待发送卡片(不再回写草稿/插入引用)。
314
- this.pending.set(key, entries)
315
- for (const entry of entries) this.refIndex.set(entry.ref, { sessionId: key, entry })
316
- this.publish(key)
317
- }
318
-
319
- clearInFlight(sessionId) {
320
- const key = String(sessionId)
321
- const expiry = this.expiry.get(key)
322
- if (expiry) expiry()
323
- this.expiry.delete(key)
324
- const entries = this.inFlight.get(key) || []
325
- for (const entry of entries) this.refIndex.delete(entry.ref)
326
- this.inFlight.delete(key)
327
- }
328
-
329
- async migrateLegacy(sessionId, draft) {
330
- // 旧格式草稿兼容:只清理残留的「上传文件:`路径`」文本行,不再重新挂载附件。
331
- // (原先会把它转成待发送附件,导致刷新后幽灵文件反复出现——2026-08-19 修复)
332
- const key = String(sessionId)
333
- if (this.migrated.has(key)) return
334
- this.migrated.add(key)
335
- const paths = legacyUploadPaths(draft)
336
- if (paths.length === 0) return
337
- try {
338
- const { shell } = this.scope(sessionId)
339
- shell.setDraft(stripLegacyPaths(shell.snapshot.draft, paths))
340
- } catch (error) {
341
- console.error('[dsh-upload-manager] legacy draft cleanup failed', error)
342
- }
343
- }
344
-
345
- dispose() {
346
- for (const cancel of this.expiry.values()) cancel()
347
- this.expiry.clear()
348
- this.pending.clear()
349
- this.inFlight.clear()
350
- this.refIndex.clear()
351
- this.listeners.clear()
352
- this.serializing.clear()
353
- this.migrated.clear()
354
- this.sinkHooked.clear()
355
- }
356
- }
357
-
358
- function usePending(controller, sessionId) {
359
- const [, render] = React.useState(0)
360
- React.useEffect(
361
- () => controller.subscribe(sessionId, () => render((value) => value + 1)),
362
- [controller, sessionId],
363
- )
364
- return controller.pendingFor(sessionId)
365
- }
366
-
367
- function PaperclipIcon() {
368
- return React.createElement(
369
- 'svg',
370
- {
371
- className: 'dsh-upload-icon',
372
- viewBox: '0 0 24 24',
373
- fill: 'none',
374
- stroke: 'currentColor',
375
- strokeWidth: 1.8,
376
- strokeLinecap: 'round',
377
- strokeLinejoin: 'round',
378
- 'aria-hidden': true,
379
- },
380
- React.createElement('path', { d: 'M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48' }),
381
- )
382
- }
383
-
384
- function UploadControl(props) {
385
- const input = props.useInput((state) => state) || props.input
386
- const pickerRef = React.useRef(null)
387
- const [busy, setBusy] = React.useState(false)
388
- const [status, setStatus] = React.useState('')
389
-
390
- async function onPick(event) {
391
- const files = Array.from(event.currentTarget.files || [])
392
- event.currentTarget.value = ''
393
- if (files.length === 0) return
394
-
395
- setBusy(true)
396
- setStatus(`正在上传 ${files.length} 个文件…`)
397
- let attached = 0
398
- const failures = []
399
- try {
400
- for (const file of files) {
401
- try {
402
- const stored = await uploadFile(file)
403
- props.controller.attach(props.sessionId, stored)
404
- attached += 1
405
- } catch (error) {
406
- failures.push(`${file.name}: ${errorMessage(error)}`)
407
- }
408
- }
409
- setStatus(failures.length === 0
410
- ? `已添加 ${attached} 个待发送文件`
411
- : `已添加 ${attached} 个,失败 ${failures.length} 个:${failures.join(';')}`)
412
- } finally {
413
- setBusy(false)
414
- }
415
- }
416
-
417
- const disabled = busy || !input || input.phase !== 'plain'
418
- return React.createElement(
419
- 'div',
420
- { className: 'dsh-upload-control', title: status || '上传本地文件并加入本次消息' },
421
- React.createElement('input', {
422
- ref: pickerRef,
423
- className: 'dsh-upload-picker',
424
- type: 'file',
425
- multiple: true,
426
- onChange: onPick,
427
- }),
428
- React.createElement(
429
- 'button',
430
- {
431
- type: 'button',
432
- className: 'dsh-upload-button',
433
- disabled,
434
- 'aria-label': busy ? '正在上传文件' : '上传文件',
435
- title: status || '上传本地文件并加入本次消息',
436
- onClick: () => pickerRef.current?.click(),
437
- },
438
- React.createElement(PaperclipIcon, null),
439
- ),
440
- React.createElement('span', { className: 'dsh-upload-live', 'aria-live': 'polite' }, status),
441
- )
442
- }
443
-
444
- // ===== 拖放上传:把本地文件拖到会话框任意位置即可加入本条消息 =====
445
- // 复用与钉选上传完全相同的 uploadFile() + controller.attach() 管线;
446
- // 监听 window 级 dragenter/dragover/dragleave/drop(仅在携带 Files 时接管),
447
- // 视觉层用 fixed 全屏遮罩提示,pointer-events:none 保证不干扰拖拽本身。
448
- function DragDropOverlay(props) {
449
- const controller = props.controller
450
- const sessionId = props.sessionId
451
- const [active, setActive] = React.useState(false)
452
- const counterRef = React.useRef(0)
453
-
454
- React.useEffect(() => {
455
- // 只在真正拖入「文件」时才接管;拖文本/链接等(无 Files)一律放行给 DSH 原生行为。
456
- // 四个事件都用 捕获阶段(capture) + stopPropagation,让本插件成为文件拖放的
457
- // 唯一所有者 —— 这样 DSH 核心的「图像拖放/粘贴」验证器不会触发(否则非图片文件
458
- // 会报「仅支持 PNG、JPG、WebP、GIF」),核心自己的拖放遮罩也不会出现/卡住。
459
- const hasFiles = (e) => !!(e.dataTransfer && Array.from(e.dataTransfer.types || []).includes('Files'))
460
- const onDragEnter = (e) => {
461
- if (!hasFiles(e)) return
462
- e.preventDefault()
463
- e.stopPropagation()
464
- counterRef.current += 1
465
- setActive(true)
466
- }
467
- const onDragOver = (e) => {
468
- if (!hasFiles(e)) return
469
- e.preventDefault()
470
- e.stopPropagation()
471
- if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
472
- }
473
- const onDragLeave = (e) => {
474
- if (!hasFiles(e)) return
475
- e.stopPropagation()
476
- counterRef.current -= 1
477
- if (counterRef.current <= 0) {
478
- counterRef.current = 0
479
- setActive(false)
480
- }
481
- }
482
- const onDrop = async (e) => {
483
- if (!hasFiles(e)) return
484
- e.preventDefault()
485
- e.stopPropagation()
486
- counterRef.current = 0
487
- setActive(false)
488
- const files = Array.from((e.dataTransfer && e.dataTransfer.files) || [])
489
- if (files.length === 0) return
490
- // 复用与回形针完全相同的 uploadFile + attach 管线;结果直接体现在输入区上方的
491
- // 「待发送文件」卡片上。
492
- for (const file of files) {
493
- try {
494
- const stored = await uploadFile(file)
495
- controller.attach(sessionId, stored)
496
- } catch (error) {
497
- console.warn(`[dsh-long-plugins] 拖放上传失败:${file.name} → ${errorMessage(error)}`)
498
- }
499
- }
500
- }
501
- // 粘贴非图片文件 → 走与回形针/拖放相同的上传管线,并阻止 DSH 核心的「仅支持图片」门;
502
- // 纯文本/图片粘贴放行给 DSH 原生;上传失败(不支持的格式等)给可见提示。
503
- const onPaste = async (e) => {
504
- const cd = e.clipboardData
505
- if (!cd) return
506
- const files = []
507
- const items = cd.items
508
- if (items) {
509
- for (const item of items) {
510
- if (item.kind === 'file' && typeof item.getAsFile === 'function') {
511
- const f = item.getAsFile()
512
- // 只接管非图片文件;图片交给 DSH 原生(内联显示),避免改变已有行为
513
- if (f && f.size > 0 && !(f.type || '').startsWith('image/')) files.push(f)
514
- }
515
- }
516
- }
517
- if (files.length === 0) return // 无「非图片文件」→ 放行给 DSH
518
- // 有文件粘贴:接管整段粘贴,阻止 DSH 后续处理器报「仅支持图片」
519
- e.preventDefault()
520
- e.stopPropagation()
521
- e.stopImmediatePropagation()
522
- for (const file of files) {
523
- try {
524
- const stored = await uploadFile(file)
525
- controller.attach(sessionId, stored)
526
- } catch (error) {
527
- console.warn(`[dsh-long-plugins] 粘贴上传失败:${file.name} → ${errorMessage(error)}`)
528
- showToast(`不支持该文件格式:${file.name}`, 'error')
529
- }
530
- }
531
- }
532
- const dragOn = !!(window.__dshLongMod && window.__dshLongMod('uploadDragDrop'))
533
- const pasteOn = !!(window.__dshLongMod && window.__dshLongMod('uploadPaste'))
534
- if (dragOn) {
535
- window.addEventListener('dragenter', onDragEnter, true)
536
- window.addEventListener('dragover', onDragOver, true)
537
- window.addEventListener('dragleave', onDragLeave, true)
538
- window.addEventListener('drop', onDrop, true)
539
- }
540
- if (pasteOn) {
541
- window.addEventListener('paste', onPaste, true)
542
- }
543
- return () => {
544
- if (dragOn) {
545
- window.removeEventListener('dragenter', onDragEnter, true)
546
- window.removeEventListener('dragover', onDragOver, true)
547
- window.removeEventListener('dragleave', onDragLeave, true)
548
- window.removeEventListener('drop', onDrop, true)
549
- }
550
- if (pasteOn) {
551
- window.removeEventListener('paste', onPaste, true)
552
- }
553
- }
554
- }, [controller, sessionId])
555
-
556
- if (!active) return null
557
-
558
- return React.createElement(
559
- 'div',
560
- { className: 'dsh-upload-dropzone' },
561
- React.createElement(
562
- 'div',
563
- { className: 'dsh-upload-dropzone-card' },
564
- React.createElement(
565
- 'div',
566
- { className: 'dsh-upload-dropzone-icon' },
567
- React.createElement(PaperclipIcon, null)
568
- ),
569
- React.createElement(
570
- 'div',
571
- { className: 'dsh-upload-dropzone-text' },
572
- '松开鼠标,将文件加入本条消息'
573
- )
574
- )
575
- )
576
- }
577
-
578
- function PendingFileRail(props) {
579
- const input = props.useInput((state) => state)
580
- const promptError = props.useSession((session) => session.promptError) || null
581
- const files = usePending(props.controller, props.sessionId)
582
-
583
- React.useEffect(() => {
584
- props.controller.migrateLegacy(props.sessionId, input?.draft || '')
585
- props.controller.reconcile(props.sessionId, input?.occurrences || [])
586
- }, [props.controller, props.sessionId, input?.draftRev])
587
-
588
- React.useEffect(() => {
589
- if (promptError) props.controller.restoreFailed(props.sessionId)
590
- }, [props.controller, props.sessionId, promptError])
591
-
592
- if (files.length === 0) return null
593
- return React.createElement(
594
- 'div',
595
- { className: 'dsh-upload-rail', 'aria-label': '待发送文件' },
596
- files.map((file) => React.createElement(
597
- 'div',
598
- { className: 'dsh-upload-chip', key: file.ref },
599
- React.createElement('span', { className: 'dsh-upload-chip-icon', 'aria-hidden': true }, '▤'),
600
- React.createElement(
601
- 'span',
602
- { className: 'dsh-upload-chip-copy' },
603
- React.createElement('strong', { title: file.name }, file.name),
604
- React.createElement('small', null, sizeText(file.size)),
605
- ),
606
- React.createElement(
607
- 'button',
608
- {
609
- type: 'button',
610
- 'aria-label': `移除 ${file.name}`,
611
- title: '从本次消息移除',
612
- onClick: () => props.controller.remove(props.sessionId, file.ref),
613
- },
614
- '×',
615
- ),
616
- )),
617
- )
618
- }
619
-
620
111
  function sizeText(bytes) {
621
112
  if (bytes < 1024) return `${bytes} B`
622
113
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`
@@ -681,326 +172,27 @@ window.__ModuleLoader__.load({
681
172
  }
682
173
 
683
174
  /** 🔍 放大镜搜索:点击图标弹出搜索框,输入即过滤;✕ 清除并关闭。不自动隐藏(需人工关闭或刷新)。 */
684
- function SearchPopup({ value, onChange, placeholder }) {
685
- const [open, setOpen] = React.useState(false)
686
- const ref = React.useRef(null)
687
- React.useEffect(() => { if (open && ref.current) { try { ref.current.focus() } catch (e) {} } }, [open])
688
- const toggle = () => setOpen((o) => !o)
689
- return React.createElement('div', { className: 'dsh-searchpop' },
690
- React.createElement('button', {
691
- type: 'button', className: 'dsh-searchpop-btn',
692
- onClick: toggle,
693
- title: '搜索文件名', 'aria-label': '搜索文件名',
694
- }, '🔍'),
695
- open && React.createElement('div', { className: 'dsh-searchpop-box' },
696
- React.createElement('input', {
697
- ref, type: 'text', className: 'dsh-searchpop-input', value,
698
- onChange: (e) => onChange(e.target.value), placeholder: placeholder || '搜索文件名…', autoFocus: true,
699
- }),
700
- value !== '' && React.createElement('button', {
701
- type: 'button', className: 'dsh-searchpop-clear', title: '清除', 'aria-label': '清除搜索',
702
- onMouseDown: (e) => { e.preventDefault(); e.stopPropagation() },
703
- onClick: (e) => { e.preventDefault(); e.stopPropagation(); onChange(''); setOpen(false); },
704
- }, '✕'),
705
- ),
706
- )
707
- }
708
-
709
- function UploadSettingsSection() {
710
- const [state, setState] = React.useState({
711
- loading: true,
712
- root: '',
713
- maxFileBytes: 0,
714
- totalMaxBytes: 0,
715
- usedBytes: 0,
716
- files: [],
717
- error: '',
718
- })
719
- const [deleting, setDeleting] = React.useState('')
720
- const [batchBusy, setBatchBusy] = React.useState(false)
721
- const [selected, setSelected] = React.useState(() => new Set())
722
- const [preview, setPreview] = React.useState(null)
723
- const [previewMaximized, setPreviewMaximized] = React.useState(false)
724
- const [dayFilter, setDayFilter] = React.useState('')
725
- const [search, setSearch] = React.useState('')
726
- const [deleteEnabled, setDeleteEnabled] = React.useState(false)
727
-
728
- async function refresh() {
729
- setState((current) => ({ ...current, loading: true, error: '' }))
730
- try {
731
- const response = await fetch(API_PATH, { cache: 'no-store' })
732
- const body = await responseJson(response)
733
- setState({
734
- loading: false,
735
- root: body.root,
736
- maxFileBytes: body.maxFileBytes,
737
- totalMaxBytes: body.totalMaxBytes,
738
- usedBytes: body.usedBytes,
739
- files: body.files,
740
- error: '',
741
- })
742
- } catch (error) {
743
- setState((current) => ({ ...current, loading: false, error: errorMessage(error) }))
744
- }
745
- }
746
-
747
- React.useEffect(() => {
748
- refresh()
749
- }, [])
750
-
751
- async function remove(name) {
752
- if (!globalThis.confirm(`确定删除“${name}”吗?此操作不可恢复。`)) return
753
- setDeleting(name)
754
- try {
755
- const response = await fetch(`${API_PATH}?name=${encodeURIComponent(name)}`, { method: 'DELETE' })
756
- await responseJson(response)
757
- if (preview !== null && preview.name === name) closePreview()
758
- await refresh()
759
- } catch (error) {
760
- setState((current) => ({ ...current, error: errorMessage(error) }))
761
- } finally {
762
- setDeleting('')
763
- }
764
- }
765
-
766
- async function batchDelete() {
767
- const names = Array.from(selected || [])
768
- if (names.length === 0) return
769
- if (!globalThis.confirm(`确定删除所选 ${names.length} 个文件吗?此操作不可恢复。`)) return
770
- setBatchBusy(true)
771
- let err = ''
772
- for (const name of names) {
773
- try {
774
- const response = await fetch(`${API_PATH}?name=${encodeURIComponent(name)}`, { method: 'DELETE' })
775
- if (!response.ok) { const b = await response.json().catch(() => ({})); err = err || (b.error || `HTTP ${response.status}`) }
776
- } catch (e) { err = err || errorMessage(e) }
777
- }
778
- setState((current) => ({ ...current, error: err }))
779
- setBatchBusy(false)
780
- setSelected(new Set())
781
- await refresh()
782
- }
783
-
784
- async function previewFile(name) {
785
- setPreviewMaximized(false)
786
- try {
787
- const isOffice = /\.(docx|xlsx|pptx)$/i.test(name)
788
- if (isOffice) {
789
- // 先打开弹窗并提示转换中(NAS 上 docx/xlsx 转换可能耗时 1~2 秒)
790
- setPreview({ name, officeLoading: true })
791
- const response = await fetch(previewUrl(name), { cache: 'no-store' })
792
- if (!response.ok) {
793
- const body = await response.json().catch(() => ({}))
794
- throw new Error(body.error || `HTTP ${response.status}`)
795
- }
796
- const data = await response.json().catch(() => ({}))
797
- setPreview({
798
- name,
799
- officeHtml: data.officeHtml ?? '<p style="font-family:sans-serif;padding:12px">(无法渲染此文档)</p>',
800
- })
801
- return
802
- }
803
- // 无法内嵌预览的类型(压缩包、程序、视频、字体等):点预览直接下载。
804
- if (!isInlinePreviewable(name)) {
805
- triggerDownload(downloadUrl(name), name)
806
- return
807
- }
808
- const response = await fetch(previewUrl(name), { cache: 'no-store' })
809
- if (!response.ok) {
810
- const body = await response.json().catch(() => ({}))
811
- throw new Error(body.error || `HTTP ${response.status}`)
812
- }
813
- const type = response.headers.get('content-type') || ''
814
- if (type.startsWith('image/')) {
815
- const blob = await response.blob()
816
- const url = URL.createObjectURL(blob)
817
- setPreview({ url, name })
818
- return
819
- }
820
- // PDF / txt / 其它可内嵌文件:弹窗内嵌预览(iframe 指向预览端点),
821
- // 只有用户点「打开」才在新浏览器标签中打开。
822
- setPreview({ url: previewUrl(name), name })
823
- } catch (error) {
824
- setState((current) => ({ ...current, error: errorMessage(error) }))
825
- }
826
- }
827
-
828
- function closePreview() {
829
- if (preview?.url) URL.revokeObjectURL(preview.url)
830
- setPreview(null)
831
- }
832
-
833
- /** Group files by calendar day of their modifiedAt (YYYY-MM-DD, desc). */
834
- function groupFilesByDate(files) {
835
- const groups = []
836
- const byDay = new Map()
837
- for (const file of files) {
838
- let day = ''
839
- try {
840
- day = localDay(file.modifiedAt)
841
- } catch {
842
- day = '未知日期'
843
- }
844
- if (!byDay.has(day)) byDay.set(day, [])
845
- byDay.get(day).push(file)
846
- }
847
- const days = [...byDay.keys()].sort((a, b) => b.localeCompare(a))
848
- for (const day of days) groups.push({ day, files: byDay.get(day) })
849
- return groups
850
- }
851
- // 日期筛选 + 文件名搜索:先按所选天/关键词过滤 state.files,再始终按天分组(空=全部)。
852
- const shownFiles = (dayFilter || search)
853
- ? state.files.filter((f) => (dayFilter ? (() => { try { return localDay(f.modifiedAt) === dayFilter } catch { return false } })() : true) && matchesSearch(f, search))
854
- : state.files
855
- const dateGroups = groupFilesByDate(shownFiles)
856
-
857
- return React.createElement(
858
- 'section',
859
- { className: 'dsh-upload-settings' },
860
- React.createElement(
861
- 'div',
862
- { className: 'dsh-upload-settings-head' },
863
- React.createElement(
864
- 'div',
865
- null,
866
- React.createElement('h2', null, '上传文件'),
867
- React.createElement('p', null, '管理从输入框上传到 Harness 容器中的文件。'),
868
- ),
869
- React.createElement(
870
- 'div',
871
- { className: 'dsh-upload-head-actions' },
872
- React.createElement(SearchPopup, { value: search, onChange: setSearch, placeholder: '搜索文件名…' }),
873
- React.createElement(DateFilter, { value: dayFilter, onChange: setDayFilter, placeholder: '选择日期' }),
874
- React.createElement(
875
- 'button',
876
- { type: 'button', className: 'dsh-upload-refresh', disabled: state.loading, onClick: refresh },
877
- state.loading ? '刷新中…' : '刷新',
878
- ),
879
- React.createElement(
880
- 'label',
881
- { className: 'dsh-upload-del-toggle' },
882
- React.createElement('input', { type: 'checkbox', checked: deleteEnabled, onChange: (e) => { setDeleteEnabled(e.target.checked); if (!e.target.checked) setSelected(new Set()) } }),
883
- '开启删除',
884
- ),
885
- deleteEnabled && React.createElement('label',
886
- { className: 'dsh-upload-del-toggle' },
887
- React.createElement('input', { type: 'checkbox', checked: (state.files || []).length > 0 && (selected || new Set()).size === (state.files || []).length, onChange: (e) => { setSelected(e.target.checked ? new Set((state.files || []).map((f) => f.name)) : new Set()) } }),
888
- '全选',
889
- ),
890
- React.createElement(
891
- 'button',
892
- { type: 'button', className: 'dsh-upload-batchdel', disabled: !deleteEnabled || (selected || new Set()).size === 0 || batchBusy, onClick: batchDelete },
893
- batchBusy ? '删除中…' : `批量删除(${(selected || new Set()).size})`,
894
- ),
895
- ),
896
- ),
897
- React.createElement(
898
- 'div',
899
- { className: 'dsh-upload-root' },
900
- React.createElement('span', null, '固定目录'),
901
- React.createElement('code', null, state.root || '读取中…'),
902
- state.maxFileBytes
903
- ? React.createElement('small', null, `单文件上限 ${sizeText(state.maxFileBytes)}`)
904
- : null,
905
- state.totalMaxBytes
906
- ? React.createElement('small', null, `已使用 ${sizeText(state.usedBytes)} / ${sizeText(state.totalMaxBytes)}`)
907
- : null,
908
- ),
909
- state.error ? React.createElement('div', { className: 'dsh-upload-error' }, state.error) : null,
910
- !state.loading && state.files.length === 0
911
- ? React.createElement('div', { className: 'dsh-upload-empty' }, '当前没有已上传文件。')
912
- : null,
913
- !state.loading && (dayFilter !== '' || search !== '') && shownFiles.length === 0 && state.files.length > 0
914
- ? React.createElement('div', { className: 'dsh-upload-empty' }, '没有匹配的文件。')
915
- : null,
916
- preview
917
- ? React.createElement(
918
- 'div',
919
- { className: 'dsh-upload-preview-overlay' + (previewMaximized ? ' dsh-upload-preview-overlay-max' : ''), onClick: closePreview },
920
- React.createElement(
921
- 'div',
922
- { className: 'dsh-upload-preview-card' + (previewMaximized ? ' dsh-upload-preview-card-max' : ''), onClick: (event) => event.stopPropagation() },
923
- React.createElement(
924
- 'div',
925
- { className: 'dsh-upload-preview-head' },
926
- React.createElement('strong', null, preview.name),
927
- React.createElement(
928
- 'div',
929
- { className: 'dsh-upload-preview-actions', style: { display: 'flex', gap: 8, alignItems: 'center' } },
930
- !preview.officeLoading && !(preview.url && preview.url.startsWith('blob:')) && preview.name !== void 0
931
- ? React.createElement('a', { href: preview.officeHtml !== void 0 ? downloadUrl(preview.name) : previewUrl(preview.name), target: '_blank', rel: 'noopener noreferrer', className: 'dsh-upload-preview-open' }, '打开')
932
- : null,
933
- !preview.officeLoading && !(preview.url && preview.url.startsWith('blob:')) && preview.name !== void 0
934
- ? React.createElement('a', { href: downloadUrl(preview.name), download: preview.name, className: 'dsh-upload-preview-open' }, '下载')
935
- : null,
936
- React.createElement('button', { type: 'button', onClick: () => { const m = !previewMaximized; setPreviewMaximized(m); try { const el = document.querySelector('.dsh-upload-preview-card'); if (m && el && el.requestFullscreen) el.requestFullscreen(); else if (document.fullscreenElement) document.exitFullscreen() } catch (e) {} } }, previewMaximized ? '还原' : '放大'),
937
- React.createElement('button', { type: 'button', className: 'dsh-upload-preview-del', disabled: deleting === preview.name, onClick: () => remove(preview.name) }, deleting === preview.name ? '删除中…' : '删除'),
938
- React.createElement('button', { type: 'button', onClick: closePreview }, '关闭'),
939
- ),
940
- ),
941
- preview.url && preview.url.startsWith('blob:')
942
- ? React.createElement('img', { src: preview.url, alt: preview.name, className: 'dsh-upload-preview-img' })
943
- : React.createElement(
944
- 'div',
945
- { style: { display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, background: '#fff' } },
946
- preview.officeLoading === true
947
- ? React.createElement('div', { className: 'dsh-upload-preview-loading' }, '转换中…')
948
- : preview.officeHtml !== void 0
949
- ? React.createElement('iframe', { title: preview.name, srcDoc: preview.officeHtml, style: previewMaximized ? { width: '100%', height: 'calc(100vh - 60px)', border: 'none', background: '#fff', flex: 1 } : { width: '100%', height: '70vh', border: 'none', background: '#fff' } })
950
- : preview.url && /\.pdf$/i.test(preview.name)
951
- ? React.createElement('embed', { src: preview.url, type: 'application/pdf', title: preview.name, style: previewMaximized ? { width: '100%', height: 'calc(100vh - 60px)', border: 'none', background: '#fff', flex: 1 } : { width: '100%', height: '70vh', border: 'none', background: '#fff' } })
952
- : React.createElement('iframe', { title: preview.name, src: preview.url, style: previewMaximized ? { width: '100%', height: 'calc(100vh - 60px)', border: 'none', background: '#fff', flex: 1 } : { width: '100%', height: '70vh', border: 'none', background: '#fff' } }),
953
- ),
954
- ),
955
- )
956
- : null,
957
- React.createElement(
958
- 'div',
959
- { className: 'dsh-upload-list' },
960
- (dateGroups === null ? [{ day: null, files: shownFiles }] : dateGroups).map((group) => React.createElement(
961
- 'div',
962
- { key: group.day ?? '__all__', className: 'dsh-upload-group' },
963
- group.day !== null
964
- ? React.createElement(
965
- 'div',
966
- { className: 'dsh-upload-group-day' },
967
- group.day,
968
- React.createElement('span', null, `${group.files.length} 个文件`),
969
- )
970
- : null,
971
- group.files.map((file) => React.createElement(
972
- 'div',
973
- { className: 'dsh-upload-row', key: file.name },
974
- deleteEnabled && React.createElement('input', { type: 'checkbox', className: 'dsh-upload-select', checked: (selected || new Set()).has(file.name), onChange: (e) => { const s = new Set(selected || []); if (e.target.checked) s.add(file.name); else s.delete(file.name); setSelected(s) } }),
975
- React.createElement(
976
- 'span',
977
- { className: 'dsh-upload-file-name', title: file.path },
978
- React.createElement('span', { className: 'dsh-upload-file-label' }, file.name.replace(/\.[^.]+$/, '')),
979
- React.createElement('span', { className: 'dsh-upload-file-type' }, fileTypeLabel(file.name)),
980
- ),
981
- React.createElement('span', { className: 'dsh-upload-file-meta' }, `${sizeText(file.size)} · ${dateText(file.modifiedAt)}`),
982
- React.createElement(
983
- 'div',
984
- { className: 'dsh-upload-actions' },
985
- React.createElement(
986
- 'button',
987
- { type: 'button', className: 'dsh-upload-copy', onClick: () => {
988
- const s = String(file.path || file.name)
989
- const base = state.root ? String(state.root).replace(/\/[^/]*$/, '') : ''
990
- copyText(base && s.indexOf(base) === 0 ? s.slice(base.length).replace(/^\/+/, '') : s)
991
- } },
992
- '复制路径',
993
- ),
994
- React.createElement(
995
- 'button',
996
- { type: 'button', disabled: !deleteEnabled || deleting === file.name, onClick: () => remove(file.name) },
997
- deleting === file.name ? '删除中…' : '删除',
998
- ),
999
- React.createElement('a', { href: downloadUrl(file.name), download: file.name }, '下载'),
1000
- React.createElement('button', { type: 'button', className: 'dsh-upload-preview', onClick: () => previewFile(file.name) }, '预览'),
1001
- ),
1002
- )),
1003
- )),
175
+ function SearchPopup({ value, onChange, placeholder }) {
176
+ const [open, setOpen] = React.useState(false)
177
+ const ref = React.useRef(null)
178
+ React.useEffect(() => { if (open && ref.current) { try { ref.current.focus() } catch (e) {} } }, [open])
179
+ const toggle = () => setOpen((o) => !o)
180
+ return React.createElement('div', { className: 'dsh-searchpop' },
181
+ React.createElement('button', {
182
+ type: 'button', className: 'dsh-searchpop-btn',
183
+ onClick: toggle,
184
+ title: '搜索文件名', 'aria-label': '搜索文件名',
185
+ }, '🔍'),
186
+ open && React.createElement('div', { className: 'dsh-searchpop-box' },
187
+ React.createElement('input', {
188
+ ref, type: 'text', className: 'dsh-searchpop-input', value,
189
+ onChange: (e) => onChange(e.target.value), placeholder: placeholder || '搜索文件名…', autoFocus: true,
190
+ }),
191
+ value !== '' && React.createElement('button', {
192
+ type: 'button', className: 'dsh-searchpop-clear', title: '清除', 'aria-label': '清除搜索',
193
+ onMouseDown: (e) => { e.preventDefault(); e.stopPropagation() },
194
+ onClick: (e) => { e.preventDefault(); e.stopPropagation(); onChange(''); setOpen(false); },
195
+ }, '✕'),
1004
196
  ),
1005
197
  )
1006
198
  }
@@ -1325,171 +517,426 @@ window.__ModuleLoader__.load({
1325
517
  let err = null
1326
518
  for (const path of paths) {
1327
519
  try {
1328
- const res = await fetch('/api/dsh-uploads/workspace-file/delete', {
1329
- method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ path }),
1330
- })
1331
- const data = await res.json().catch(() => ({}))
1332
- if (!(data.ok === true)) err = err || (data.error || `HTTP ${res.status}`)
1333
- } catch (e) { err = err || String((e && e.message) || e) }
520
+ const res = await fetch('/api/dsh-uploads/workspace-file/delete', {
521
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ path }),
522
+ })
523
+ const data = await res.json().catch(() => ({}))
524
+ if (!(data.ok === true)) err = err || (data.error || `HTTP ${res.status}`)
525
+ } catch (e) { err = err || String((e && e.message) || e) }
526
+ }
527
+ setError(err)
528
+ setBatchBusy(false)
529
+ setSelected(new Set())
530
+ load()
531
+ }
532
+
533
+ return React.createElement(
534
+ 'div',
535
+ { style: { display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0 } },
536
+ React.createElement('div', { style: { fontSize: 13, color: 'var(--dsw-alias-label-tertiary)' } }, '工作区输出文件(按文件夹分类,预览 / 下载 / 删除)'),
537
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', minWidth: 0 } },
538
+ React.createElement(SearchPopup, { value: search, onChange: setSearch, placeholder: '搜索文件名…' }),
539
+ React.createElement(DateFilter, { value: dayFilter, onChange: setDayFilter, placeholder: '选择日期' }),
540
+ React.createElement('label', { className: 'dsh-upload-del-toggle' },
541
+ React.createElement('input', { type: 'checkbox', checked: deleteEnabled, onChange: (e) => { setDeleteEnabled(e.target.checked); if (!e.target.checked) setSelected(new Set()) } }),
542
+ '开启删除',
543
+ ),
544
+ deleteEnabled && React.createElement('label', { className: 'dsh-upload-del-toggle' },
545
+ React.createElement('input', { type: 'checkbox', checked: (shownGroups || []).reduce((n, g) => n + g.files.length, 0) > 0 && (selected || new Set()).size === (shownGroups || []).reduce((n, g) => n + g.files.length, 0), onChange: (e) => { setSelected(e.target.checked ? new Set((shownGroups || []).flatMap((g) => g.files.map((f) => f.path))) : new Set()) } }),
546
+ '全选',
547
+ ),
548
+ React.createElement('button', { type: 'button', className: 'dsh-upload-batchdel', disabled: !deleteEnabled || (selected || new Set()).size === 0 || batchBusy, onClick: batchDelete },
549
+ batchBusy ? '删除中…' : `批量删除(${(selected || new Set()).size})`,
550
+ ),
551
+ ),
552
+ error !== null && React.createElement('div', { style: { fontSize: 12, color: 'var(--dsw-alias-state-error-primary)' } }, error),
553
+ groups === null && error === null && React.createElement('div', { style: metaStyle }, '加载中…'),
554
+ groups !== null && groups.length === 0 && React.createElement('div', { style: metaStyle }, '目录为空'),
555
+ (dayFilter || search) && groups !== null && groups.length > 0 && shownGroups !== null && shownGroups.length === 0 && React.createElement('div', { style: metaStyle }, '没有匹配的文件'),
556
+ shownGroups !== null && shownGroups.map((group) => React.createElement(
557
+ 'div',
558
+ { key: group.folder, style: { display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0 } },
559
+ React.createElement(
560
+ 'button',
561
+ {
562
+ type: 'button',
563
+ className: 'dsh-ws-folder',
564
+ style: folderBtnStyle,
565
+ 'aria-expanded': !collapsed[group.folder],
566
+ onClick: () => toggleFolder(group.folder),
567
+ },
568
+ `${collapsed[group.folder] ? '▸' : '▾'} ${group.folder} (${group.files.length})`,
569
+ ),
570
+ !collapsed[group.folder] && group.files.map((f) => React.createElement(
571
+ 'div',
572
+ { key: f.path, className: 'dsh-ws-row', style: rowStyle },
573
+ deleteEnabled && React.createElement('input', { type: 'checkbox', className: 'dsh-upload-select', checked: (selected || new Set()).has(f.path), onChange: (e) => { const s = new Set(selected || []); if (e.target.checked) s.add(f.path); else s.delete(f.path); setSelected(s) } }),
574
+ React.createElement(
575
+ 'span',
576
+ { className: 'dsh-ws-name', style: { ...nameStyle, display: 'flex', alignItems: 'center', gap: 6 }, title: f.path },
577
+ React.createElement('span', { className: 'dsh-ws-file-label', style: { flex: 1, minWidth: 0, whiteSpace: 'normal', wordBreak: 'break-all', overflowWrap: 'anywhere' } }, basenameWithoutExt(f.path)),
578
+ React.createElement('span', { className: 'dsh-ws-file-type', style: { flex: 'none' } }, fileTypeLabel(f.path)),
579
+ ),
580
+ React.createElement('span', { className: 'dsh-ws-meta', style: metaStyle }, `${sizeText(f.size)} · ${dateText(f.mtime)}`),
581
+ React.createElement(
582
+ 'div',
583
+ { className: 'dsh-ws-actions', style: { display: 'flex', gap: 6, flex: 'none' } },
584
+ React.createElement('button', { type: 'button', style: btnStyle, disabled: busy, onClick: () => copyText(f.path) }, '复制路径'),
585
+ React.createElement('button', { type: 'button', className: 'dsh-ws-del', style: delStyle, disabled: !deleteEnabled || busy, onClick: () => doDelete(f.path) }, '删除'),
586
+ React.createElement('a', { href: '/api/dsh-uploads/workspace-file?path=' + encodeURIComponent(f.path) + '&download=1', download: f.name, style: btnStyle }, '下载'),
587
+ React.createElement('button', { type: 'button', style: btnStyle, disabled: busy, onClick: () => openPreview(f.path) }, '预览'),
588
+ ),
589
+ )),
590
+ )),
591
+ preview !== null && React.createElement(
592
+ 'div',
593
+ { className: 'dsh-ws-preview-overlay', style: overlayStyle, onClick: () => setPreview(null) },
594
+ React.createElement(
595
+ 'div',
596
+ { className: 'dsh-ws-preview-card', style: previewCardStyle, onClick: (e) => e.stopPropagation() },
597
+ React.createElement(
598
+ 'div',
599
+ { className: 'dsh-ws-preview-head', style: previewHeadStyle },
600
+ React.createElement('strong', { style: { flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 13 } }, `${preview.name ?? preview.path}`),
601
+ preview.binary !== true && preview.loading !== true && React.createElement('button', { type: 'button', style: btnStyle, disabled: busy, onClick: () => { if (editing) { setEdited(preview.content !== void 0 ? preview.content : ''); setEditing(false); } else { setEdited(preview.content !== void 0 ? preview.content : ''); setEditing(true); } } }, editing ? '取消编辑' : '编辑'),
602
+ editing && React.createElement('button', { type: 'button', style: btnStyle, disabled: busy, onClick: doSave }, savedFlash ? '已保存' : '保存'),
603
+ React.createElement('button', { type: 'button', style: btnStyle, onClick: copyContent }, copied ? '已复制' : '复制全部'),
604
+ (preview.url !== void 0 || preview.officeHtml !== void 0 || preview.mdHtml !== void 0) && preview.loading !== true && React.createElement('a', { href: '/api/dsh-uploads/workspace-file?path=' + encodeURIComponent(preview.path) + '&download=1', download: preview.name, style: { ...btnStyle, color: 'var(--dsw-alias-state-business-primary)' } }, '下载'),
605
+ (preview.url !== void 0 || preview.officeHtml !== void 0 || preview.mdHtml !== void 0) && preview.loading !== true && React.createElement('a', { href: preview.url !== void 0 ? preview.url : '/api/dsh-uploads/workspace-preview?path=' + encodeURIComponent(preview.path), target: '_blank', rel: 'noopener noreferrer', style: { ...btnStyle, color: 'var(--dsw-alias-state-business-primary)' } }, '打开'),
606
+ React.createElement('button', { type: 'button', style: delStyle, disabled: busy, onClick: () => doDelete(preview.path) }, '删除'),
607
+ React.createElement('button', { type: 'button', style: btnStyle, onClick: () => { const m = !maximized; setMaximized(m); try { const el = document.querySelector('.dsh-ws-preview-card'); if (m && el && el.requestFullscreen) el.requestFullscreen(); else if (document.fullscreenElement) document.exitFullscreen() } catch (e) {} } }, maximized ? '还原' : '放大'),
608
+ React.createElement('button', { type: 'button', style: btnStyle, onClick: () => setPreview(null) }, '关闭'),
609
+ ),
610
+ React.createElement(
611
+ 'div',
612
+ { style: { display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, overflow: 'auto' } },
613
+ preview.loading === true && React.createElement('div', { style: { ...metaStyle, padding: 10 } }, '加载中…'),
614
+ preview.error !== void 0 && preview.loading !== true && React.createElement('div', { style: { fontSize: 12, color: 'var(--dsw-alias-state-error-primary)', padding: 10 } }, preview.error),
615
+ preview.officeLoading === true && preview.loading !== true && React.createElement('div', { style: { ...metaStyle, padding: 10 } }, '转换中…'),
616
+ preview.url !== void 0 && preview.loading !== true && (preview.name && /\.pdf$/i.test(preview.name)
617
+ ? React.createElement('embed', { src: preview.url, type: 'application/pdf', title: preview.name ?? preview.path, style: { width: '100%', height: '70vh', minHeight: 0, border: 'none', background: '#fff', flex: 1 } })
618
+ : React.createElement('iframe', { title: preview.name ?? preview.path, src: preview.url, style: { width: '100%', height: '70vh', minHeight: 0, border: 'none', background: '#fff', flex: 1 } })),
619
+ preview.binary === true && preview.officeHtml === void 0 && preview.url === void 0 && React.createElement('div', { style: { ...metaStyle, padding: 10 } }, '二进制文件,无法预览,请下载后查看'),
620
+ preview.officeHtml !== void 0 && React.createElement('iframe', { title: preview.name ?? preview.path, srcDoc: preview.officeHtml, style: { width: '100%', flex: 1, minHeight: 0, border: 'none', background: '#fff' } }),
621
+ preview.mdHtml !== void 0 && editing !== true && React.createElement('iframe', { title: preview.name ?? preview.path, srcDoc: preview.mdHtml, style: { width: '100%', flex: 1, minHeight: 0, border: 'none', background: '#fff' } }),
622
+ editing && preview.binary !== true && React.createElement('textarea', { style: textareaStyle, value: edited, onChange: (e) => setEdited(e.target.value), spellCheck: false }),
623
+ !editing && preview.binary !== true && preview.content !== void 0 && preview.mdHtml === void 0 && React.createElement('pre', { style: preStyle }, preview.content),
624
+ preview.truncated === true && React.createElement('div', { style: { ...metaStyle, padding: '4px 12px 10px' } }, '(内容过大,仅显示前 256KB)'),
625
+ ),
626
+ ),
627
+ ),
628
+ )
629
+ }
630
+
631
+ function UploadSettingsSection() {
632
+ const [state, setState] = React.useState({
633
+ loading: true,
634
+ root: '',
635
+ maxFileBytes: 0,
636
+ totalMaxBytes: 0,
637
+ usedBytes: 0,
638
+ files: [],
639
+ error: '',
640
+ })
641
+ const [deleting, setDeleting] = React.useState('')
642
+ const [batchBusy, setBatchBusy] = React.useState(false)
643
+ const [selected, setSelected] = React.useState(() => new Set())
644
+ const [preview, setPreview] = React.useState(null)
645
+ const [previewMaximized, setPreviewMaximized] = React.useState(false)
646
+ const [dayFilter, setDayFilter] = React.useState('')
647
+ const [search, setSearch] = React.useState('')
648
+ const [deleteEnabled, setDeleteEnabled] = React.useState(false)
649
+
650
+ async function refresh() {
651
+ setState((current) => ({ ...current, loading: true, error: '' }))
652
+ try {
653
+ const response = await fetch(API_PATH, { cache: 'no-store' })
654
+ const body = await responseJson(response)
655
+ setState({
656
+ loading: false,
657
+ root: body.root,
658
+ maxFileBytes: body.maxFileBytes,
659
+ totalMaxBytes: body.totalMaxBytes,
660
+ usedBytes: body.usedBytes,
661
+ files: body.files,
662
+ error: '',
663
+ })
664
+ } catch (error) {
665
+ setState((current) => ({ ...current, loading: false, error: errorMessage(error) }))
666
+ }
667
+ }
668
+
669
+ React.useEffect(() => {
670
+ refresh()
671
+ }, [])
672
+
673
+ async function remove(name) {
674
+ if (!globalThis.confirm(`确定删除“${name}”吗?此操作不可恢复。`)) return
675
+ setDeleting(name)
676
+ try {
677
+ const response = await fetch(`${API_PATH}?name=${encodeURIComponent(name)}`, { method: 'DELETE' })
678
+ await responseJson(response)
679
+ if (preview !== null && preview.name === name) closePreview()
680
+ await refresh()
681
+ } catch (error) {
682
+ setState((current) => ({ ...current, error: errorMessage(error) }))
683
+ } finally {
684
+ setDeleting('')
685
+ }
686
+ }
687
+
688
+ async function batchDelete() {
689
+ const names = Array.from(selected || [])
690
+ if (names.length === 0) return
691
+ if (!globalThis.confirm(`确定删除所选 ${names.length} 个文件吗?此操作不可恢复。`)) return
692
+ setBatchBusy(true)
693
+ let err = ''
694
+ for (const name of names) {
695
+ try {
696
+ const response = await fetch(`${API_PATH}?name=${encodeURIComponent(name)}`, { method: 'DELETE' })
697
+ if (!response.ok) { const b = await response.json().catch(() => ({})); err = err || (b.error || `HTTP ${response.status}`) }
698
+ } catch (e) { err = err || errorMessage(e) }
699
+ }
700
+ setState((current) => ({ ...current, error: err }))
701
+ setBatchBusy(false)
702
+ setSelected(new Set())
703
+ await refresh()
704
+ }
705
+
706
+ async function previewFile(name) {
707
+ setPreviewMaximized(false)
708
+ try {
709
+ const isOffice = /\.(docx|xlsx|pptx)$/i.test(name)
710
+ if (isOffice) {
711
+ // 先打开弹窗并提示转换中(NAS 上 docx/xlsx 转换可能耗时 1~2 秒)
712
+ setPreview({ name, officeLoading: true })
713
+ const response = await fetch(previewUrl(name), { cache: 'no-store' })
714
+ if (!response.ok) {
715
+ const body = await response.json().catch(() => ({}))
716
+ throw new Error(body.error || `HTTP ${response.status}`)
717
+ }
718
+ const data = await response.json().catch(() => ({}))
719
+ setPreview({
720
+ name,
721
+ officeHtml: data.officeHtml ?? '<p style="font-family:sans-serif;padding:12px">(无法渲染此文档)</p>',
722
+ })
723
+ return
724
+ }
725
+ // 无法内嵌预览的类型(压缩包、程序、视频、字体等):点预览直接下载。
726
+ if (!isInlinePreviewable(name)) {
727
+ triggerDownload(downloadUrl(name), name)
728
+ return
729
+ }
730
+ const response = await fetch(previewUrl(name), { cache: 'no-store' })
731
+ if (!response.ok) {
732
+ const body = await response.json().catch(() => ({}))
733
+ throw new Error(body.error || `HTTP ${response.status}`)
734
+ }
735
+ const type = response.headers.get('content-type') || ''
736
+ if (type.startsWith('image/')) {
737
+ const blob = await response.blob()
738
+ const url = URL.createObjectURL(blob)
739
+ setPreview({ url, name })
740
+ return
741
+ }
742
+ // PDF / txt / 其它可内嵌文件:弹窗内嵌预览(iframe 指向预览端点),
743
+ // 只有用户点「打开」才在新浏览器标签中打开。
744
+ setPreview({ url: previewUrl(name), name })
745
+ } catch (error) {
746
+ setState((current) => ({ ...current, error: errorMessage(error) }))
747
+ }
748
+ }
749
+
750
+ function closePreview() {
751
+ if (preview?.url) URL.revokeObjectURL(preview.url)
752
+ setPreview(null)
753
+ }
754
+
755
+ /** Group files by calendar day of their modifiedAt (YYYY-MM-DD, desc). */
756
+ function groupFilesByDate(files) {
757
+ const groups = []
758
+ const byDay = new Map()
759
+ for (const file of files) {
760
+ let day = ''
761
+ try {
762
+ day = localDay(file.modifiedAt)
763
+ } catch {
764
+ day = '未知日期'
765
+ }
766
+ if (!byDay.has(day)) byDay.set(day, [])
767
+ byDay.get(day).push(file)
1334
768
  }
1335
- setError(err)
1336
- setBatchBusy(false)
1337
- setSelected(new Set())
1338
- load()
769
+ const days = [...byDay.keys()].sort((a, b) => b.localeCompare(a))
770
+ for (const day of days) groups.push({ day, files: byDay.get(day) })
771
+ return groups
1339
772
  }
773
+ // 日期筛选 + 文件名搜索:先按所选天/关键词过滤 state.files,再始终按天分组(空=全部)。
774
+ const shownFiles = (dayFilter || search)
775
+ ? state.files.filter((f) => (dayFilter ? (() => { try { return localDay(f.modifiedAt) === dayFilter } catch { return false } })() : true) && matchesSearch(f, search))
776
+ : state.files
777
+ const dateGroups = groupFilesByDate(shownFiles)
1340
778
 
1341
779
  return React.createElement(
1342
- 'div',
1343
- { style: { display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0 } },
1344
- React.createElement('div', { style: { fontSize: 13, color: 'var(--dsw-alias-label-tertiary)' } }, '工作区输出文件(按文件夹分类,预览 / 下载 / 删除)'),
1345
- React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', minWidth: 0 } },
1346
- React.createElement(SearchPopup, { value: search, onChange: setSearch, placeholder: '搜索文件名…' }),
1347
- React.createElement(DateFilter, { value: dayFilter, onChange: setDayFilter, placeholder: '选择日期' }),
1348
- React.createElement('label', { className: 'dsh-upload-del-toggle' },
1349
- React.createElement('input', { type: 'checkbox', checked: deleteEnabled, onChange: (e) => { setDeleteEnabled(e.target.checked); if (!e.target.checked) setSelected(new Set()) } }),
1350
- '开启删除',
1351
- ),
1352
- deleteEnabled && React.createElement('label', { className: 'dsh-upload-del-toggle' },
1353
- React.createElement('input', { type: 'checkbox', checked: (shownGroups || []).reduce((n, g) => n + g.files.length, 0) > 0 && (selected || new Set()).size === (shownGroups || []).reduce((n, g) => n + g.files.length, 0), onChange: (e) => { setSelected(e.target.checked ? new Set((shownGroups || []).flatMap((g) => g.files.map((f) => f.path))) : new Set()) } }),
1354
- '全选',
1355
- ),
1356
- React.createElement('button', { type: 'button', className: 'dsh-upload-batchdel', disabled: !deleteEnabled || (selected || new Set()).size === 0 || batchBusy, onClick: batchDelete },
1357
- batchBusy ? '删除中…' : `批量删除(${(selected || new Set()).size})`,
1358
- ),
1359
- ),
1360
- error !== null && React.createElement('div', { style: { fontSize: 12, color: 'var(--dsw-alias-state-error-primary)' } }, error),
1361
- groups === null && error === null && React.createElement('div', { style: metaStyle }, '加载中…'),
1362
- groups !== null && groups.length === 0 && React.createElement('div', { style: metaStyle }, '目录为空'),
1363
- (dayFilter || search) && groups !== null && groups.length > 0 && shownGroups !== null && shownGroups.length === 0 && React.createElement('div', { style: metaStyle }, '没有匹配的文件'),
1364
- shownGroups !== null && shownGroups.map((group) => React.createElement(
780
+ 'section',
781
+ { className: 'dsh-upload-settings' },
782
+ React.createElement(
1365
783
  'div',
1366
- { key: group.folder, style: { display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0 } },
784
+ { className: 'dsh-upload-settings-head' },
1367
785
  React.createElement(
1368
- 'button',
1369
- {
1370
- type: 'button',
1371
- className: 'dsh-ws-folder',
1372
- style: folderBtnStyle,
1373
- 'aria-expanded': !collapsed[group.folder],
1374
- onClick: () => toggleFolder(group.folder),
1375
- },
1376
- `${collapsed[group.folder] ? '▸' : '▾'} ${group.folder} (${group.files.length})`,
786
+ 'div',
787
+ null,
788
+ React.createElement('h2', null, '上传文件'),
789
+ React.createElement('p', null, '管理从输入框上传到 Harness 容器中的文件。'),
1377
790
  ),
1378
- !collapsed[group.folder] && group.files.map((f) => React.createElement(
791
+ React.createElement(
1379
792
  'div',
1380
- { key: f.path, className: 'dsh-ws-row', style: rowStyle },
1381
- deleteEnabled && React.createElement('input', { type: 'checkbox', className: 'dsh-upload-select', checked: (selected || new Set()).has(f.path), onChange: (e) => { const s = new Set(selected || []); if (e.target.checked) s.add(f.path); else s.delete(f.path); setSelected(s) } }),
793
+ { className: 'dsh-upload-head-actions' },
794
+ React.createElement(SearchPopup, { value: search, onChange: setSearch, placeholder: '搜索文件名…' }),
795
+ React.createElement(DateFilter, { value: dayFilter, onChange: setDayFilter, placeholder: '选择日期' }),
1382
796
  React.createElement(
1383
- 'span',
1384
- { className: 'dsh-ws-name', style: { ...nameStyle, display: 'flex', alignItems: 'center', gap: 6 }, title: f.path },
1385
- React.createElement('span', { className: 'dsh-ws-file-label', style: { flex: 1, minWidth: 0, whiteSpace: 'normal', wordBreak: 'break-all', overflowWrap: 'anywhere' } }, basenameWithoutExt(f.path)),
1386
- React.createElement('span', { className: 'dsh-ws-file-type', style: { flex: 'none' } }, fileTypeLabel(f.path)),
797
+ 'button',
798
+ { type: 'button', className: 'dsh-upload-refresh', disabled: state.loading, onClick: refresh },
799
+ state.loading ? '刷新中…' : '刷新',
1387
800
  ),
1388
- React.createElement('span', { className: 'dsh-ws-meta', style: metaStyle }, `${sizeText(f.size)} · ${dateText(f.mtime)}`),
1389
801
  React.createElement(
1390
- 'div',
1391
- { className: 'dsh-ws-actions', style: { display: 'flex', gap: 6, flex: 'none' } },
1392
- React.createElement('button', { type: 'button', style: btnStyle, disabled: busy, onClick: () => copyText(f.path) }, '复制路径'),
1393
- React.createElement('button', { type: 'button', className: 'dsh-ws-del', style: delStyle, disabled: !deleteEnabled || busy, onClick: () => doDelete(f.path) }, '删除'),
1394
- React.createElement('a', { href: '/api/dsh-uploads/workspace-file?path=' + encodeURIComponent(f.path) + '&download=1', download: f.name, style: btnStyle }, '下载'),
1395
- React.createElement('button', { type: 'button', style: btnStyle, disabled: busy, onClick: () => openPreview(f.path) }, '预览'),
802
+ 'label',
803
+ { className: 'dsh-upload-del-toggle' },
804
+ React.createElement('input', { type: 'checkbox', checked: deleteEnabled, onChange: (e) => { setDeleteEnabled(e.target.checked); if (!e.target.checked) setSelected(new Set()) } }),
805
+ '开启删除',
1396
806
  ),
1397
- )),
1398
- )),
1399
- preview !== null && React.createElement(
1400
- 'div',
1401
- { className: 'dsh-ws-preview-overlay', style: overlayStyle, onClick: () => setPreview(null) },
1402
- React.createElement(
1403
- 'div',
1404
- { className: 'dsh-ws-preview-card', style: previewCardStyle, onClick: (e) => e.stopPropagation() },
1405
- React.createElement(
1406
- 'div',
1407
- { className: 'dsh-ws-preview-head', style: previewHeadStyle },
1408
- React.createElement('strong', { style: { flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 13 } }, `${preview.name ?? preview.path}`),
1409
- preview.binary !== true && preview.loading !== true && React.createElement('button', { type: 'button', style: btnStyle, disabled: busy, onClick: () => { if (editing) { setEdited(preview.content !== void 0 ? preview.content : ''); setEditing(false); } else { setEdited(preview.content !== void 0 ? preview.content : ''); setEditing(true); } } }, editing ? '取消编辑' : '编辑'),
1410
- editing && React.createElement('button', { type: 'button', style: btnStyle, disabled: busy, onClick: doSave }, savedFlash ? '已保存' : '保存'),
1411
- React.createElement('button', { type: 'button', style: btnStyle, onClick: copyContent }, copied ? '已复制' : '复制全部'),
1412
- (preview.url !== void 0 || preview.officeHtml !== void 0 || preview.mdHtml !== void 0) && preview.loading !== true && React.createElement('a', { href: '/api/dsh-uploads/workspace-file?path=' + encodeURIComponent(preview.path) + '&download=1', download: preview.name, style: { ...btnStyle, color: 'var(--dsw-alias-state-business-primary)' } }, '下载'),
1413
- (preview.url !== void 0 || preview.officeHtml !== void 0 || preview.mdHtml !== void 0) && preview.loading !== true && React.createElement('a', { href: preview.url !== void 0 ? preview.url : '/api/dsh-uploads/workspace-preview?path=' + encodeURIComponent(preview.path), target: '_blank', rel: 'noopener noreferrer', style: { ...btnStyle, color: 'var(--dsw-alias-state-business-primary)' } }, '打开'),
1414
- React.createElement('button', { type: 'button', style: delStyle, disabled: busy, onClick: () => doDelete(preview.path) }, '删除'),
1415
- React.createElement('button', { type: 'button', style: btnStyle, onClick: () => { const m = !maximized; setMaximized(m); try { const el = document.querySelector('.dsh-ws-preview-card'); if (m && el && el.requestFullscreen) el.requestFullscreen(); else if (document.fullscreenElement) document.exitFullscreen() } catch (e) {} } }, maximized ? '还原' : '放大'),
1416
- React.createElement('button', { type: 'button', style: btnStyle, onClick: () => setPreview(null) }, '关闭'),
807
+ deleteEnabled && React.createElement('label',
808
+ { className: 'dsh-upload-del-toggle' },
809
+ React.createElement('input', { type: 'checkbox', checked: (state.files || []).length > 0 && (selected || new Set()).size === (state.files || []).length, onChange: (e) => { setSelected(e.target.checked ? new Set((state.files || []).map((f) => f.name)) : new Set()) } }),
810
+ '全选',
1417
811
  ),
1418
812
  React.createElement(
1419
- 'div',
1420
- { style: { display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, overflow: 'auto' } },
1421
- preview.loading === true && React.createElement('div', { style: { ...metaStyle, padding: 10 } }, '加载中…'),
1422
- preview.error !== void 0 && preview.loading !== true && React.createElement('div', { style: { fontSize: 12, color: 'var(--dsw-alias-state-error-primary)', padding: 10 } }, preview.error),
1423
- preview.officeLoading === true && preview.loading !== true && React.createElement('div', { style: { ...metaStyle, padding: 10 } }, '转换中…'),
1424
- preview.url !== void 0 && preview.loading !== true && (preview.name && /\.pdf$/i.test(preview.name)
1425
- ? React.createElement('embed', { src: preview.url, type: 'application/pdf', title: preview.name ?? preview.path, style: { width: '100%', height: '70vh', minHeight: 0, border: 'none', background: '#fff', flex: 1 } })
1426
- : React.createElement('iframe', { title: preview.name ?? preview.path, src: preview.url, style: { width: '100%', height: '70vh', minHeight: 0, border: 'none', background: '#fff', flex: 1 } })),
1427
- preview.binary === true && preview.officeHtml === void 0 && preview.url === void 0 && React.createElement('div', { style: { ...metaStyle, padding: 10 } }, '二进制文件,无法预览,请下载后查看'),
1428
- preview.officeHtml !== void 0 && React.createElement('iframe', { title: preview.name ?? preview.path, srcDoc: preview.officeHtml, style: { width: '100%', flex: 1, minHeight: 0, border: 'none', background: '#fff' } }),
1429
- preview.mdHtml !== void 0 && editing !== true && React.createElement('iframe', { title: preview.name ?? preview.path, srcDoc: preview.mdHtml, style: { width: '100%', flex: 1, minHeight: 0, border: 'none', background: '#fff' } }),
1430
- editing && preview.binary !== true && React.createElement('textarea', { style: textareaStyle, value: edited, onChange: (e) => setEdited(e.target.value), spellCheck: false }),
1431
- !editing && preview.binary !== true && preview.content !== void 0 && preview.mdHtml === void 0 && React.createElement('pre', { style: preStyle }, preview.content),
1432
- preview.truncated === true && React.createElement('div', { style: { ...metaStyle, padding: '4px 12px 10px' } }, '(内容过大,仅显示前 256KB)'),
813
+ 'button',
814
+ { type: 'button', className: 'dsh-upload-batchdel', disabled: !deleteEnabled || (selected || new Set()).size === 0 || batchBusy, onClick: batchDelete },
815
+ batchBusy ? '删除中…' : `批量删除(${(selected || new Set()).size})`,
1433
816
  ),
1434
817
  ),
1435
818
  ),
819
+ React.createElement(
820
+ 'div',
821
+ { className: 'dsh-upload-root' },
822
+ React.createElement('span', null, '固定目录'),
823
+ React.createElement('code', null, state.root || '读取中…'),
824
+ state.maxFileBytes
825
+ ? React.createElement('small', null, `单文件上限 ${sizeText(state.maxFileBytes)}`)
826
+ : null,
827
+ state.totalMaxBytes
828
+ ? React.createElement('small', null, `已使用 ${sizeText(state.usedBytes)} / ${sizeText(state.totalMaxBytes)}`)
829
+ : null,
830
+ ),
831
+ state.error ? React.createElement('div', { className: 'dsh-upload-error' }, state.error) : null,
832
+ !state.loading && state.files.length === 0
833
+ ? React.createElement('div', { className: 'dsh-upload-empty' }, '当前没有已上传文件。')
834
+ : null,
835
+ !state.loading && (dayFilter !== '' || search !== '') && shownFiles.length === 0 && state.files.length > 0
836
+ ? React.createElement('div', { className: 'dsh-upload-empty' }, '没有匹配的文件。')
837
+ : null,
838
+ preview
839
+ ? React.createElement(
840
+ 'div',
841
+ { className: 'dsh-upload-preview-overlay' + (previewMaximized ? ' dsh-upload-preview-overlay-max' : ''), onClick: closePreview },
842
+ React.createElement(
843
+ 'div',
844
+ { className: 'dsh-upload-preview-card' + (previewMaximized ? ' dsh-upload-preview-card-max' : ''), onClick: (event) => event.stopPropagation() },
845
+ React.createElement(
846
+ 'div',
847
+ { className: 'dsh-upload-preview-head' },
848
+ React.createElement('strong', null, preview.name),
849
+ React.createElement(
850
+ 'div',
851
+ { className: 'dsh-upload-preview-actions', style: { display: 'flex', gap: 8, alignItems: 'center' } },
852
+ !preview.officeLoading && !(preview.url && preview.url.startsWith('blob:')) && preview.name !== void 0
853
+ ? React.createElement('a', { href: preview.officeHtml !== void 0 ? downloadUrl(preview.name) : previewUrl(preview.name), target: '_blank', rel: 'noopener noreferrer', className: 'dsh-upload-preview-open' }, '打开')
854
+ : null,
855
+ !preview.officeLoading && !(preview.url && preview.url.startsWith('blob:')) && preview.name !== void 0
856
+ ? React.createElement('a', { href: downloadUrl(preview.name), download: preview.name, className: 'dsh-upload-preview-open' }, '下载')
857
+ : null,
858
+ React.createElement('button', { type: 'button', onClick: () => { const m = !previewMaximized; setPreviewMaximized(m); try { const el = document.querySelector('.dsh-upload-preview-card'); if (m && el && el.requestFullscreen) el.requestFullscreen(); else if (document.fullscreenElement) document.exitFullscreen() } catch (e) {} } }, previewMaximized ? '还原' : '放大'),
859
+ React.createElement('button', { type: 'button', className: 'dsh-upload-preview-del', disabled: deleting === preview.name, onClick: () => remove(preview.name) }, deleting === preview.name ? '删除中…' : '删除'),
860
+ React.createElement('button', { type: 'button', onClick: closePreview }, '关闭'),
861
+ ),
862
+ ),
863
+ preview.url && preview.url.startsWith('blob:')
864
+ ? React.createElement('img', { src: preview.url, alt: preview.name, className: 'dsh-upload-preview-img' })
865
+ : React.createElement(
866
+ 'div',
867
+ { style: { display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, background: '#fff' } },
868
+ preview.officeLoading === true
869
+ ? React.createElement('div', { className: 'dsh-upload-preview-loading' }, '转换中…')
870
+ : preview.officeHtml !== void 0
871
+ ? React.createElement('iframe', { title: preview.name, srcDoc: preview.officeHtml, style: previewMaximized ? { width: '100%', height: 'calc(100vh - 60px)', border: 'none', background: '#fff', flex: 1 } : { width: '100%', height: '70vh', border: 'none', background: '#fff' } })
872
+ : preview.url && /\.pdf$/i.test(preview.name)
873
+ ? React.createElement('embed', { src: preview.url, type: 'application/pdf', title: preview.name, style: previewMaximized ? { width: '100%', height: 'calc(100vh - 60px)', border: 'none', background: '#fff', flex: 1 } : { width: '100%', height: '70vh', border: 'none', background: '#fff' } })
874
+ : React.createElement('iframe', { title: preview.name, src: preview.url, style: previewMaximized ? { width: '100%', height: 'calc(100vh - 60px)', border: 'none', background: '#fff', flex: 1 } : { width: '100%', height: '70vh', border: 'none', background: '#fff' } }),
875
+ ),
876
+ ),
877
+ )
878
+ : null,
879
+ React.createElement(
880
+ 'div',
881
+ { className: 'dsh-upload-list' },
882
+ (dateGroups === null ? [{ day: null, files: shownFiles }] : dateGroups).map((group) => React.createElement(
883
+ 'div',
884
+ { key: group.day ?? '__all__', className: 'dsh-upload-group' },
885
+ group.day !== null
886
+ ? React.createElement(
887
+ 'div',
888
+ { className: 'dsh-upload-group-day' },
889
+ group.day,
890
+ React.createElement('span', null, `${group.files.length} 个文件`),
891
+ )
892
+ : null,
893
+ group.files.map((file) => React.createElement(
894
+ 'div',
895
+ { className: 'dsh-upload-row', key: file.name },
896
+ deleteEnabled && React.createElement('input', { type: 'checkbox', className: 'dsh-upload-select', checked: (selected || new Set()).has(file.name), onChange: (e) => { const s = new Set(selected || []); if (e.target.checked) s.add(file.name); else s.delete(file.name); setSelected(s) } }),
897
+ React.createElement(
898
+ 'span',
899
+ { className: 'dsh-upload-file-name', title: file.path },
900
+ React.createElement('span', { className: 'dsh-upload-file-label' }, file.name.replace(/\.[^.]+$/, '')),
901
+ React.createElement('span', { className: 'dsh-upload-file-type' }, fileTypeLabel(file.name)),
902
+ ),
903
+ React.createElement('span', { className: 'dsh-upload-file-meta' }, `${sizeText(file.size)} · ${dateText(file.modifiedAt)}`),
904
+ React.createElement(
905
+ 'div',
906
+ { className: 'dsh-upload-actions' },
907
+ React.createElement(
908
+ 'button',
909
+ { type: 'button', className: 'dsh-upload-copy', onClick: () => {
910
+ const s = String(file.path || file.name)
911
+ const base = state.root ? String(state.root).replace(/\/[^/]*$/, '') : ''
912
+ copyText(base && s.indexOf(base) === 0 ? s.slice(base.length).replace(/^\/+/, '') : s)
913
+ } },
914
+ '复制路径',
915
+ ),
916
+ React.createElement(
917
+ 'button',
918
+ { type: 'button', disabled: !deleteEnabled || deleting === file.name, onClick: () => remove(file.name) },
919
+ deleting === file.name ? '删除中…' : '删除',
920
+ ),
921
+ React.createElement('a', { href: downloadUrl(file.name), download: file.name }, '下载'),
922
+ React.createElement('button', { type: 'button', className: 'dsh-upload-preview', onClick: () => previewFile(file.name) }, '预览'),
923
+ ),
924
+ )),
925
+ )),
926
+ ),
1436
927
  )
1437
928
  }
1438
929
 
1439
930
  function apply(ctx) {
1440
931
  const mod = window.__dshLongMod || (() => true)
1441
- const anyUpload = mod('uploadAttach') || mod('uploadDragDrop') || mod('uploadPaste')
1442
- if (anyUpload) {
1443
- const controller = new FileDraftController(ctx)
1444
- ctx.effect(() => () => controller.dispose(), 'dsh-upload-manager: draft-file state')
1445
- ctx.effect(() => ctx.inputTriggers.registerSource({
1446
- trigger: '@',
1447
- name: SOURCE,
1448
- order: 10_000,
1449
- candidates: async () => [],
1450
- onPick: () => undefined,
1451
- codec: {
1452
- clipboardText: () => '',
1453
- serialize: async (ref) => {
1454
- const file = controller.fileForRef(ref)
1455
- if (!file) throw new Error('待发送文件已失效,请重新上传')
1456
- controller.markSerializing(ref)
1457
- return serializedFile(file)
1458
- },
1459
- },
1460
- }), 'dsh-upload-manager: hidden file reference codec')
1461
- // 输入框附件上传(回形针) + 待发送文件栏:由 uploadAttach 控制
1462
- if (mod('uploadAttach')) {
1463
- ctx.slots.inject('conversation.input.left', () => ctx.slots.register({
1464
- name: 'conversation.input.left',
1465
- id: 'local-file-upload',
1466
- order: -20,
1467
- label: '上传文件',
1468
- }, (props) => React.createElement(UploadControl, { ...props, controller })))
1469
- ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({
1470
- name: 'conversation.input.dock',
1471
- id: 'local-file-upload-rail',
1472
- order: 80,
1473
- label: '待发送文件',
1474
- }, (props) => React.createElement(PendingFileRail, { ...props, controller })))
1475
- }
1476
- // 拖放上传(遮罩+window 监听) / 粘贴上传:由各自开关控制
1477
- if (mod('uploadDragDrop') || mod('uploadPaste')) {
1478
- ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({
1479
- name: 'conversation.input.dock',
1480
- id: 'local-file-upload-dropzone',
1481
- order: 90,
1482
- label: '拖放/粘贴上传',
1483
- }, (props) => React.createElement(DragDropOverlay, { ...props, controller })))
1484
- }
1485
- }
1486
932
  ctx.effect(() => {
1487
933
  const style = document.createElement('style')
1488
- style.dataset.plugin = 'dsh-file-uploads'
934
+ style.dataset.plugin = 'dsh-long-plugins'
935
+ style.dataset.pluginCss = 'dsh-long-plugins/uploads+workspace'
1489
936
  style.textContent = CSS
1490
937
  document.head.appendChild(style)
1491
938
  return () => style.remove()
1492
- }, 'dsh-upload-manager: styles')
939
+ }, 'dsh-long-plugins: uploads+workspace styles')
1493
940
  // 上传文件预览/管理设置区:由 uploadPreview 控制
1494
941
  if (mod('uploadPreview')) {
1495
942
  ctx.slots.inject('settings.section', () => ctx.slots.register({
@@ -3169,9 +2616,6 @@ window.__ModuleLoader__.load({
3169
2616
  .dsh-glass-note{margin:0;font-size:12px;color:var(--dsw-alias-label-tertiary)}
3170
2617
  `
3171
2618
  const DSH_LONG_MODULES = [
3172
- ['uploadAttach', '附件上传(回形针)'],
3173
- ['uploadDragDrop', '附件拖放上传'],
3174
- ['uploadPaste', '附件粘贴上传'],
3175
2619
  ['uploadPreview', '上传文件预览/管理'],
3176
2620
  ['skillDocs', '技能管理'],
3177
2621
  ['balance', '账户余额'],
@@ -3250,9 +2694,25 @@ window.__ModuleLoader__.load({
3250
2694
 
3251
2695
  // 模块开关缓存:页面加载时同步读取(让 main apply 的 modEnabled 门控生效);
3252
2696
  // 配置加载(applyTheme)/保存(dsh-long 保存)后更新。开关在下次刷新生效。
2697
+ // 模块开关:以服务端为准(启动时同步拉取,避免 localStorage 缓存过期导致模块被误关)
2698
+ try {
2699
+ const _x = new XMLHttpRequest()
2700
+ _x.open('GET', '/api/dsh-uploads/modules-config', false)
2701
+ _x.send()
2702
+ if (_x.status === 200) {
2703
+ const _b = JSON.parse(_x.responseText)
2704
+ if (_b && _b.cfg && _b.cfg.modules && typeof _b.cfg.modules === 'object') {
2705
+ window.__dshLongModules = _b.cfg.modules
2706
+ try { localStorage.setItem('dsh-long:modules', JSON.stringify(_b.cfg.modules)) } catch (e) {}
2707
+ }
2708
+ }
2709
+ } catch (_) {}
2710
+ // 回退:服务端不可用时用 localStorage 缓存
3253
2711
  try {
3254
- const _m = localStorage.getItem('dsh-long:modules')
3255
- if (_m) window.__dshLongModules = JSON.parse(_m)
2712
+ if (!window.__dshLongModules) {
2713
+ const _m = localStorage.getItem('dsh-long:modules')
2714
+ if (_m) window.__dshLongModules = JSON.parse(_m)
2715
+ }
3256
2716
  } catch (_) {}
3257
2717
  // 全局模块开关查询(供各组件/apply 内部判断)
3258
2718
  window.__dshLongMod = (n) => (window.__dshLongModules ? window.__dshLongModules[n] !== false : true)