dsh-long-plugins 2.4.1 → 2.4.5

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/README.md CHANGED
@@ -122,60 +122,9 @@ MIT
122
122
 
123
123
  完整的版权声明与来源见 [`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md)。
124
124
 
125
- ## Changelog / 更新记录
125
+ 📄 更新记录见 [`CHANGELOG.md`](CHANGELOG.md)。
126
126
 
127
- ### v2.2.1
127
+ ### 中间/提取产物 → `.outputdir`(工作区列表自动隐藏)
128
+ 读取、提取、解析等生成的**中间产物**(`_sd_extract`、`weiquan_review` 之类)请统一写到 **`.outputdir`**(或 `_` 开头的目录)。插件在工作区/输出文件列表中**自动隐藏 `.` / `_` 开头的目录及其下层内容**,所以**无论产物目录叫什么名字**,只要落到 `.outputdir`(或 `_` 前缀)就不会显示;真正要展示的**文档产物**放在普通目录即可正常显示。
128
129
 
129
- 修复输入框实底、浅色主题可读性问题,并为输入框、背景罩新增浅色/深色主题的分套设置。
130
-
131
- 🐛 Bug 修复
132
-
133
- - 输入框实底跟随主题:此前用操作系统深色偏好判断,导致浅色主题下黑底黑字。改为读取 DSH 真实主题(`<html>` 上的 `color-scheme`),浅色 = 白底深字、深色 = 深底浅字,切换主题自动实时重算。
134
- - 浅色主题可读性:统一桌面浅色下,气泡、工具调用名(Bash 等)、推理(think)标签、底部状态/统计行文字在背景图上过浅看不清。已按主题提高对比度并加描边;状态/统计行改用稳定的 `data-slot` 槽位定位(不再依赖会变的混淆类名),提高升级稳定性。
135
-
136
- ✨ 新增功能
137
-
138
- - 输入框设置(设置面板新增"输入框"区):浅色主题、深色主题可分别设置背景颜色与不透明度,支持一键"恢复原生"。
139
- - 背景罩设置(设置面板新增"背景罩"区):浅色主题、深色主题可分别设置罩色与罩强度,切换主题自动应用对应的一套。
140
-
141
- 🎨 视觉 / 交互
142
-
143
- - 左栏「新会话」按钮去掉白/黑实底边框,文字改为跟随侧边栏的白字+描边,更好融入背景。
144
-
145
- ### v2.2.0
146
-
147
- 界面优化与重构
148
-
149
- - 背景图独立层:背景图改为独立图层,新增「背景图模糊 / 背景图透明度」滑杆(单独一组,不影响磨砂卡),并新增「背景罩层颜色」色盘,背景图可模糊、可调透明度、罩色可选。
150
- - 会话区:去掉会话区独立磨砂卡片,直接透出共享背景(背景图 + 罩层颜色 + 模糊),版面更通透。
151
- - 输入区:整个输入区外围(composer 根/位置座)透明透出背景。
152
- - 可读性:浅色主题下弱化标签/元信息文字加深,对比度更高。
153
-
154
- ### v2.1.0
155
- - 增强:毛玻璃界面(玻璃拟态)进一步打磨。
156
- - 主题感知磨砂:磨砂卡片颜色跟随系统深色/浅色主题自动反转(浅色=白磨砂深字 / 深色=深磨砂浅字)。
157
- - 会话区内容反底色全透明化:去掉代码块/提示/气泡等反底色小块,统一融入磨砂卡片。
158
- - 会话区「启用玻璃」开关:可单独禁用会话区磨砂(还原原生)。
159
- - 会话区/输入区各自独立「罩色 + 罩强度 + 透明度」调节。
160
- - 新增「背景图罩」:背景图上叠一层半透明罩色,深度可调。
161
- - 手机端/窄屏(≤768px)自动禁用毛玻璃,还原 DSH 原生。
162
- - 输入卡片/底部白色层透明化,更透背景。
163
- - 修复:输入框下拉/悬浮菜单(`/` 命令、`@` 提及)背景透明,改为不透明随主题色。
164
- - 修复:玻璃界面自定义背景图上传超大文件报 `Unexpected token '<'` 错误——上传上限提升到 4MiB。
165
-
166
- ### v2.0.0
167
- - 新增:毛玻璃界面(玻璃拟态)。为 DSH 网页叠加自定义毛玻璃效果,将界面打磨成"玻璃"质感。
168
- - **自定义背景图**:上传一张背景图铺满整个页面(含左边栏、顶部、会话区),实现桌面壁纸式的背景;左边栏 / 顶部半透明透出背景图,界面更通透。
169
- - **分区独立磨砂与罩色**:会话区、输入区各自独立设置「罩色 + 罩强度」,互不影响,实现"玻璃卡片"的分区质感;会话窗口磨砂玻璃悬浮卡片效果。
170
- - **磨砂模糊**:全局磨砂模糊强度可调(0–80px),文字清晰可读。
171
- - **跟随系统主题**:界面文字/内容颜色跟随系统深色/浅色主题,不强制改色,保证任意主题下可读。
172
- - **设置面板「毛玻璃界面」**:总开关、背景图上传、磨砂模糊、会话区/输入区各自的罩色与罩强度,实时保存生效。
173
-
174
- ### v1.3.10
175
- - 新增 `.xlsx` 浏览器端预览:用 SheetJS 解析并显示为表格(行列标头、合并单元格、多工作表切换),预览支持 − / + / 适合宽度缩放,默认整表适配;浏览器端处理,不占用 NAS 本地资源。
176
- - 工作区浏览、工作区文件、上传文件列表中的 `.xlsx` 均接入该预览页。
177
- - `.csv` / `.tsv` 保留表格预览(首行作表头)。
178
- - 文件列表按钮顺序调整:工作区浏览页为 复制路径、重命名、下载、预览;上传/输出文件面板为 复制路径、删除、下载、预览。
179
- - 「上传文件」「输出文件」新增「开启删除」开关:关闭时删除按钮置灰禁用,开启后可删除;上传文件的「复制路径」不再使用删除红色。
180
- - 手机端(≤640px)文件列表顶部操作行保持单行:搜索/日期等宽、刷新/开关靠右。
181
130
 
package/client/client.js CHANGED
@@ -683,23 +683,16 @@ window.__ModuleLoader__.load({
683
683
  /** 🔍 放大镜搜索:点击图标弹出搜索框,输入即过滤;✕ 清除并关闭。不自动隐藏(需人工关闭或刷新)。 */
684
684
  function SearchPopup({ value, onChange, placeholder }) {
685
685
  const [open, setOpen] = React.useState(false)
686
- const [pos, setPos] = React.useState({ top: 72, left: 8 })
687
686
  const ref = React.useRef(null)
688
687
  React.useEffect(() => { if (open && ref.current) { try { ref.current.focus() } catch (e) {} } }, [open])
689
- const toggle = (e) => {
690
- if (!open && e && e.currentTarget) {
691
- const r = e.currentTarget.getBoundingClientRect()
692
- setPos({ top: r.bottom + 8, left: Math.max(8, Math.min(r.left, (window.innerWidth || 0) - 280)) })
693
- }
694
- setOpen((o) => !o)
695
- }
688
+ const toggle = () => setOpen((o) => !o)
696
689
  return React.createElement('div', { className: 'dsh-searchpop' },
697
690
  React.createElement('button', {
698
691
  type: 'button', className: 'dsh-searchpop-btn',
699
692
  onClick: toggle,
700
693
  title: '搜索文件名', 'aria-label': '搜索文件名',
701
694
  }, '🔍'),
702
- open && React.createElement('div', { className: 'dsh-searchpop-box', style: { top: pos.top, left: pos.left } },
695
+ open && React.createElement('div', { className: 'dsh-searchpop-box' },
703
696
  React.createElement('input', {
704
697
  ref, type: 'text', className: 'dsh-searchpop-input', value,
705
698
  onChange: (e) => onChange(e.target.value), placeholder: placeholder || '搜索文件名…', autoFocus: true,
@@ -724,6 +717,8 @@ window.__ModuleLoader__.load({
724
717
  error: '',
725
718
  })
726
719
  const [deleting, setDeleting] = React.useState('')
720
+ const [batchBusy, setBatchBusy] = React.useState(false)
721
+ const [selected, setSelected] = React.useState(() => new Set())
727
722
  const [preview, setPreview] = React.useState(null)
728
723
  const [previewMaximized, setPreviewMaximized] = React.useState(false)
729
724
  const [dayFilter, setDayFilter] = React.useState('')
@@ -768,6 +763,24 @@ window.__ModuleLoader__.load({
768
763
  }
769
764
  }
770
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
+
771
784
  async function previewFile(name) {
772
785
  setPreviewMaximized(false)
773
786
  try {
@@ -866,9 +879,19 @@ window.__ModuleLoader__.load({
866
879
  React.createElement(
867
880
  'label',
868
881
  { className: 'dsh-upload-del-toggle' },
869
- React.createElement('input', { type: 'checkbox', checked: deleteEnabled, onChange: (e) => setDeleteEnabled(e.target.checked) }),
882
+ React.createElement('input', { type: 'checkbox', checked: deleteEnabled, onChange: (e) => { setDeleteEnabled(e.target.checked); if (!e.target.checked) setSelected(new Set()) } }),
870
883
  '开启删除',
871
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
+ ),
872
895
  ),
873
896
  ),
874
897
  React.createElement(
@@ -910,7 +933,7 @@ window.__ModuleLoader__.load({
910
933
  !preview.officeLoading && !(preview.url && preview.url.startsWith('blob:')) && preview.name !== void 0
911
934
  ? React.createElement('a', { href: downloadUrl(preview.name), download: preview.name, className: 'dsh-upload-preview-open' }, '下载')
912
935
  : null,
913
- React.createElement('button', { type: 'button', onClick: () => setPreviewMaximized((m) => !m) }, previewMaximized ? '还原' : '放大'),
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 ? '还原' : '放大'),
914
937
  React.createElement('button', { type: 'button', className: 'dsh-upload-preview-del', disabled: deleting === preview.name, onClick: () => remove(preview.name) }, deleting === preview.name ? '删除中…' : '删除'),
915
938
  React.createElement('button', { type: 'button', onClick: closePreview }, '关闭'),
916
939
  ),
@@ -948,6 +971,7 @@ window.__ModuleLoader__.load({
948
971
  group.files.map((file) => React.createElement(
949
972
  'div',
950
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) } }),
951
975
  React.createElement(
952
976
  'span',
953
977
  { className: 'dsh-upload-file-name', title: file.path },
@@ -1007,7 +1031,7 @@ window.__ModuleLoader__.load({
1007
1031
  .dsh-searchpop{position:relative;display:inline-flex;align-items:center;flex:none}
1008
1032
  .dsh-searchpop-btn{width:30px;height:30px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:transparent;color:var(--dsw-alias-label-secondary);font-size:14px;line-height:1;cursor:pointer;display:inline-flex;align-items:center;justify-content:center}
1009
1033
  .dsh-searchpop-btn:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
1010
- .dsh-searchpop-box{position:fixed;z-index:60;display:flex;align-items:center;gap:6px;background:var(--dsw-specific-input-major,#0f1720);border:1px solid var(--dsw-alias-border-l2);border-radius:10px;padding:6px;box-shadow:var(--dsw-shadow-lv3);max-width:calc(100vw - 16px)}
1034
+ .dsh-searchpop-box{position:absolute;top:calc(100% + 6px);left:0;z-index:60;display:flex;align-items:center;gap:6px;background:var(--dsw-specific-input-major,#0f1720);border:1px solid var(--dsw-alias-border-l2);border-radius:10px;padding:6px;box-shadow:var(--dsw-shadow-lv3);max-width:calc(100vw - 16px)}
1011
1035
  .dsh-searchpop-input{border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:transparent;color:var(--dsw-alias-label-primary);padding:5px 9px;font:inherit;font-size:12px;line-height:18px;min-width:160px}
1012
1036
  .dsh-searchpop-input::placeholder{color:var(--dsw-alias-label-tertiary)}
1013
1037
  .dsh-searchpop-clear{background:transparent;border:none;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:1;cursor:pointer;padding:0 3px}
@@ -1045,9 +1069,13 @@ window.__ModuleLoader__.load({
1045
1069
  .dsh-upload-actions button:disabled{opacity:.5;cursor:not-allowed;color:var(--dsw-alias-label-tertiary)}
1046
1070
  .dsh-upload-del-toggle{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--dsw-alias-label-secondary);cursor:pointer;white-space:nowrap;user-select:none}
1047
1071
  .dsh-upload-del-toggle input{accent-color:var(--dsw-alias-state-business-primary);cursor:pointer}
1072
+ .dsh-upload-batchdel{display:inline-flex;align-items:center;height:30px;padding:0 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:transparent;color:var(--dsw-alias-label-primary);font-size:12px;line-height:1;cursor:pointer;white-space:nowrap}
1073
+ .dsh-upload-batchdel:disabled{opacity:.5;cursor:not-allowed}
1074
+ .dsh-upload-batchdel:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}
1075
+ .dsh-upload-select{flex:none;width:15px;height:15px;margin:0 6px 0 0;accent-color:var(--dsw-alias-state-business-primary);cursor:pointer}
1048
1076
  .dsh-ws-del:disabled{opacity:.5;cursor:not-allowed;color:var(--dsw-alias-label-tertiary)!important}
1049
1077
  .dsh-upload-actions a,.dsh-upload-actions button{white-space:nowrap}
1050
- @media (max-width:640px){.dsh-upload-row{align-items:stretch;flex-direction:column;gap:4px}.dsh-upload-file-name{white-space:normal;word-break:break-all}.dsh-upload-actions{width:100%;display:flex;gap:6px}.dsh-upload-actions a,.dsh-upload-actions button{flex:1;text-align:center;white-space:nowrap;padding:6px 4px}.dsh-upload-chip{min-width:160px}.dsh-upload-settings-head{flex-direction:column;align-items:stretch;gap:10px}.dsh-upload-head-actions{width:100%;flex-wrap:nowrap;justify-content:flex-start}.dsh-upload-head-actions .dsh-searchpop,.dsh-upload-head-actions .dsh-datefilter{flex:1 1 0;min-width:0}.dsh-upload-head-actions .dsh-upload-refresh{flex:none;text-align:center;padding:7px 8px;max-width:none}.dsh-upload-head-actions .dsh-upload-del-toggle{flex:none;white-space:nowrap;padding:0 4px}}
1078
+ @media (max-width:640px){.dsh-upload-row{align-items:stretch;flex-direction:column;gap:4px}.dsh-upload-file-name{white-space:normal;word-break:break-all}.dsh-upload-actions{width:100%;display:flex;gap:6px}.dsh-upload-actions a,.dsh-upload-actions button{flex:1;text-align:center;white-space:nowrap;padding:6px 4px}.dsh-upload-chip{min-width:160px}.dsh-upload-settings-head{flex-direction:column;align-items:stretch;gap:10px}.dsh-upload-head-actions{width:100%;flex-wrap:wrap;justify-content:flex-start;gap:6px}.dsh-upload-head-actions .dsh-searchpop,.dsh-upload-head-actions .dsh-datefilter{flex:none;min-width:0}.dsh-upload-head-actions .dsh-upload-refresh{flex:none;text-align:center;padding:7px 8px;max-width:none}.dsh-upload-head-actions .dsh-upload-del-toggle,.dsh-upload-head-actions .dsh-upload-batchdel{flex:none;white-space:nowrap;padding:0 4px}}
1051
1079
  .dsh-ws-folder:hover{background:var(--dsw-alias-interactive-bg-hover)}
1052
1080
  .dsh-ws-file-type{flex:none;font-size:10px;line-height:14px;padding:1px 6px;border-radius:5px;border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-interactive-bg-hover);white-space:nowrap}
1053
1081
  .dsh-upload-preview-del{color:var(--dsw-alias-state-error-primary)!important}
@@ -1091,6 +1119,8 @@ window.__ModuleLoader__.load({
1091
1119
  const [dayFilter, setDayFilter] = React.useState('')
1092
1120
  const [search, setSearch] = React.useState('')
1093
1121
  const [deleteEnabled, setDeleteEnabled] = React.useState(false)
1122
+ const [batchBusy, setBatchBusy] = React.useState(false)
1123
+ const [selected, setSelected] = React.useState(() => new Set())
1094
1124
  const [copied, setCopied] = React.useState(false)
1095
1125
  const [editing, setEditing] = React.useState(false)
1096
1126
  const [edited, setEdited] = React.useState('')
@@ -1286,6 +1316,28 @@ window.__ModuleLoader__.load({
1286
1316
  .filter((g) => g.files.length > 0)
1287
1317
  : groups
1288
1318
 
1319
+ // 批量删除当前勾选的文件(受「开启删除」开关控制)
1320
+ const batchDelete = async () => {
1321
+ const paths = Array.from(selected || [])
1322
+ if (paths.length === 0) return
1323
+ if (!window.confirm(`确定删除所选 ${paths.length} 个文件吗?此操作不可恢复。`)) return
1324
+ setBatchBusy(true)
1325
+ let err = null
1326
+ for (const path of paths) {
1327
+ 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) }
1334
+ }
1335
+ setError(err)
1336
+ setBatchBusy(false)
1337
+ setSelected(new Set())
1338
+ load()
1339
+ }
1340
+
1289
1341
  return React.createElement(
1290
1342
  'div',
1291
1343
  { style: { display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0 } },
@@ -1294,9 +1346,16 @@ window.__ModuleLoader__.load({
1294
1346
  React.createElement(SearchPopup, { value: search, onChange: setSearch, placeholder: '搜索文件名…' }),
1295
1347
  React.createElement(DateFilter, { value: dayFilter, onChange: setDayFilter, placeholder: '选择日期' }),
1296
1348
  React.createElement('label', { className: 'dsh-upload-del-toggle' },
1297
- React.createElement('input', { type: 'checkbox', checked: deleteEnabled, onChange: (e) => setDeleteEnabled(e.target.checked) }),
1349
+ React.createElement('input', { type: 'checkbox', checked: deleteEnabled, onChange: (e) => { setDeleteEnabled(e.target.checked); if (!e.target.checked) setSelected(new Set()) } }),
1298
1350
  '开启删除',
1299
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
+ ),
1300
1359
  ),
1301
1360
  error !== null && React.createElement('div', { style: { fontSize: 12, color: 'var(--dsw-alias-state-error-primary)' } }, error),
1302
1361
  groups === null && error === null && React.createElement('div', { style: metaStyle }, '加载中…'),
@@ -1319,6 +1378,7 @@ window.__ModuleLoader__.load({
1319
1378
  !collapsed[group.folder] && group.files.map((f) => React.createElement(
1320
1379
  'div',
1321
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) } }),
1322
1382
  React.createElement(
1323
1383
  'span',
1324
1384
  { className: 'dsh-ws-name', style: { ...nameStyle, display: 'flex', alignItems: 'center', gap: 6 }, title: f.path },
@@ -1352,7 +1412,7 @@ window.__ModuleLoader__.load({
1352
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)' } }, '下载'),
1353
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)' } }, '打开'),
1354
1414
  React.createElement('button', { type: 'button', style: delStyle, disabled: busy, onClick: () => doDelete(preview.path) }, '删除'),
1355
- React.createElement('button', { type: 'button', style: btnStyle, onClick: () => setMaximized((m) => !m) }, maximized ? '还原' : '放大'),
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 ? '还原' : '放大'),
1356
1416
  React.createElement('button', { type: 'button', style: btnStyle, onClick: () => setPreview(null) }, '关闭'),
1357
1417
  ),
1358
1418
  React.createElement(
@@ -1462,8 +1522,8 @@ window.__ModuleLoader__.load({
1462
1522
  let react_jsx_runtime = require("react/jsx-runtime");
1463
1523
  /** `skillDocs` namespace dictionaries. */
1464
1524
  const zh = {
1465
- "nav": "技能文档",
1466
- "files.hint": "技能文档目录(可折叠,点击预览可编辑)",
1525
+ "nav": "技能管理",
1526
+ "files.hint": "技能管理目录(可折叠,点击预览可编辑)",
1467
1527
  "files.loading": "加载中…",
1468
1528
  "files.empty": "目录为空",
1469
1529
  "files.preview": "预览",
@@ -1509,19 +1569,12 @@ window.__ModuleLoader__.load({
1509
1569
  /** 🔍 放大镜搜索弹窗(技能文档用):点击弹出搜索框,✕ 清除并关闭。不自动隐藏。 */
1510
1570
  function SkillSearchPop({ value, onChange, placeholder }) {
1511
1571
  const [open, setOpen] = react.useState(false);
1512
- const [pos, setPos] = react.useState({ top: 72, left: 8 });
1513
1572
  const ref = react.useRef(null);
1514
1573
  react.useEffect(() => { if (open && ref.current) { try { ref.current.focus(); } catch (e) {} } }, [open]);
1515
- const toggle = (e) => {
1516
- if (!open && e && e.currentTarget) {
1517
- const r = e.currentTarget.getBoundingClientRect();
1518
- setPos({ top: r.bottom + 8, left: Math.max(8, Math.min(r.left, (window.innerWidth || 0) - 280)) });
1519
- }
1520
- setOpen((o) => !o);
1521
- };
1574
+ const toggle = () => setOpen((o) => !o);
1522
1575
  return react_jsx_runtime.jsxs("div", { className: "dsh-searchpop", children: [
1523
1576
  react_jsx_runtime.jsx("button", { type: "button", className: "dsh-searchpop-btn", onClick: toggle, title: "搜索技能/文件", "aria-label": "搜索技能/文件", children: "🔍" }),
1524
- open && react_jsx_runtime.jsxs("div", { className: "dsh-searchpop-box", style: { top: pos.top, left: pos.left }, children: [
1577
+ open && react_jsx_runtime.jsxs("div", { className: "dsh-searchpop-box", children: [
1525
1578
  react_jsx_runtime.jsx("input", { ref, type: "text", className: "dsh-searchpop-input", value, onChange: (e) => onChange(e.target.value), placeholder: placeholder || "搜索技能/文件…", autoFocus: true }),
1526
1579
  value !== "" && react_jsx_runtime.jsx("button", { type: "button", className: "dsh-searchpop-clear", title: "清除", "aria-label": "清除搜索", onMouseDown: (e) => { e.preventDefault(); e.stopPropagation(); }, onClick: (e) => { e.preventDefault(); e.stopPropagation(); onChange(""); setOpen(false); }, children: "✕" })
1527
1580
  ] })
@@ -1539,34 +1592,37 @@ window.__ModuleLoader__.load({
1539
1592
  const [busy, setBusy] = react.useState(false);
1540
1593
  const [savedFlash, setSavedFlash] = react.useState(false);
1541
1594
  const [copiedFlash, setCopiedFlash] = react.useState(false);
1595
+ const [root, setRoot] = react.useState("global");
1542
1596
  const toggleFolder = (folder) => setCollapsed((prev) => ({ ...prev, [folder]: !prev[folder] }));
1543
1597
 
1544
1598
  const load = react.useCallback(async () => {
1545
1599
  try {
1546
- const res = await fetch("/dsh-skill-docs/skill-docs", { headers: { Accept: "application/json" } });
1600
+ const cwd = (window.__dshCurrentCwd || "").replace(/\\/g, "/");
1601
+ const ws = cwd ? cwd.split("/").filter(Boolean).pop() : "";
1602
+ const res = await fetch("/dsh-skill-docs/skill-docs?root=" + encodeURIComponent(root) + "&ws=" + encodeURIComponent(ws), { headers: { Accept: "application/json" } });
1547
1603
  if (!res.ok) { setError(`HTTP ${res.status}`); return; }
1548
1604
  const data = await res.json();
1549
1605
  if (data.ok === true) { setGroups(data.groups); setError(null); }
1550
1606
  else setError(data.error);
1551
1607
  }
1552
1608
  catch (e) { setError(String((e && e.message) || e)); }
1553
- }, []);
1609
+ }, [root]);
1554
1610
  react.useEffect(() => { load(); }, [load]);
1555
1611
 
1556
- const openDoc = async (path) => {
1557
- setPreview({ path, loading: true });
1612
+ const openDoc = async (path, ws = "") => {
1613
+ setPreview({ path, loading: true, ws });
1558
1614
  setEditing(false);
1559
1615
  setMaximized(false);
1560
1616
  try {
1561
- const res = await fetch("/dsh-skill-docs/skill-doc?path=" + encodeURIComponent(path), { headers: { Accept: "application/json" } });
1617
+ const res = await fetch("/dsh-skill-docs/skill-doc?root=" + encodeURIComponent(root) + "&ws=" + encodeURIComponent(ws) + "&path=" + encodeURIComponent(path), { headers: { Accept: "application/json" } });
1562
1618
  const data = await res.json();
1563
1619
  if (data.ok === true) {
1564
- setPreview(data);
1620
+ setPreview({ ...data, ws });
1565
1621
  setEdited(data.content !== void 0 ? data.content : "");
1566
1622
  }
1567
- else setPreview({ path, error: data.error });
1623
+ else setPreview({ path, error: data.error, ws });
1568
1624
  }
1569
- catch (e) { setPreview({ path, error: String((e && e.message) || e) }); }
1625
+ catch (e) { setPreview({ path, error: String((e && e.message) || e), ws }); }
1570
1626
  };
1571
1627
 
1572
1628
  const doSave = async () => {
@@ -1576,7 +1632,7 @@ window.__ModuleLoader__.load({
1576
1632
  const res = await fetch("/dsh-skill-docs/skill-doc/save", {
1577
1633
  method: "POST",
1578
1634
  headers: { "content-type": "application/json" },
1579
- body: JSON.stringify({ path: preview.path, content: edited })
1635
+ body: JSON.stringify({ path: preview.path, content: edited, root, ws: preview.ws || "" })
1580
1636
  });
1581
1637
  const data = await res.json().catch(() => ({}));
1582
1638
  if (data.ok === true) {
@@ -1667,6 +1723,10 @@ window.__ModuleLoader__.load({
1667
1723
  return react_jsx_runtime.jsxs("div", {
1668
1724
  style: { display: "flex", flexDirection: "column", gap: 6, minWidth: 0 },
1669
1725
  children: [
1726
+ react_jsx_runtime.jsxs("div", { style: { display: "flex", gap: 6, flexWrap: "wrap" }, children: [
1727
+ react_jsx_runtime.jsx("button", { type: "button", style: { ...btnStyle, fontWeight: root === "global" ? 700 : 400 }, onClick: () => setRoot("global"), children: "全局技能" }),
1728
+ react_jsx_runtime.jsx("button", { type: "button", style: { ...btnStyle, fontWeight: root === "workspace" ? 700 : 400 }, onClick: () => setRoot("workspace"), children: "工作区技能" })
1729
+ ] }),
1670
1730
  react_jsx_runtime.jsx("div", { style: { display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap", minWidth: 0 }, children: [
1671
1731
  react_jsx_runtime.jsx("span", { style: { fontSize: 13, color: "var(--dsw-alias-label-tertiary)" }, children: t("files.hint") }),
1672
1732
  react_jsx_runtime.jsx(SkillSearchPop, { value: search, onChange: setSearch, placeholder: "搜索技能/文件…" })
@@ -1691,19 +1751,20 @@ window.__ModuleLoader__.load({
1691
1751
  children: [
1692
1752
  react_jsx_runtime.jsx("span", { style: nameStyle, title: f.path, children: f.path }),
1693
1753
  react_jsx_runtime.jsx("span", { style: metaStyle, children: `${f.size < 1024 ? `${f.size} B` : `${(f.size / 1024).toFixed(1)} KiB`}` }),
1694
- react_jsx_runtime.jsx("button", { type: "button", style: btnStyle, onClick: () => openDoc(f.path), children: t("files.preview") }),
1754
+ react_jsx_runtime.jsx("button", { type: "button", style: btnStyle, onClick: () => openDoc(f.path, group.folder), children: t("files.preview") }),
1695
1755
  react_jsx_runtime.jsx("a", { href: "/dsh-skill-docs/skill-doc?path=" + encodeURIComponent(f.path) + "&download=1", download: f.name, style: btnStyle, children: t("files.download") })
1696
1756
  ]
1697
1757
  }, f.path))
1698
1758
  ]
1699
1759
  }, group.folder);
1700
1760
  }),
1701
- searching && shownGroups !== null && shownGroups.length === 0 && react_jsx_runtime.jsx("div", { style: metaStyle, children: "没有匹配的技能文档" }),
1761
+ searching && shownGroups !== null && shownGroups.length === 0 && react_jsx_runtime.jsx("div", { style: metaStyle, children: "没有匹配的技能管理" }),
1702
1762
  preview !== null && react_jsx_runtime.jsxs("div", {
1703
1763
  style: overlayStyle,
1704
1764
  onClick: () => setPreview(null),
1705
1765
  children: [
1706
1766
  react_jsx_runtime.jsxs("div", {
1767
+ className: "dsh-skill-preview-card",
1707
1768
  style: cardStyle,
1708
1769
  onClick: (e) => e.stopPropagation(),
1709
1770
  children: [
@@ -1714,7 +1775,7 @@ window.__ModuleLoader__.load({
1714
1775
  preview.binary !== true && react_jsx_runtime.jsx("button", { type: "button", style: btnStyle, disabled: busy, onClick: () => { if (editing) { setEdited(preview.content !== void 0 ? preview.content : ""); setEditing(false); } else setEditing(true); }, children: editing ? t("files.cancelEdit") : t("files.edit") }),
1715
1776
  editing && react_jsx_runtime.jsx("button", { type: "button", style: btnStyle, disabled: busy, onClick: doSave, children: savedFlash ? t("files.saved") : t("files.save") }),
1716
1777
  react_jsx_runtime.jsx("button", { type: "button", style: btnStyle, onClick: copyAll, children: copiedFlash ? t("files.copied") : t("files.copy") }),
1717
- react_jsx_runtime.jsx("button", { type: "button", style: btnStyle, onClick: () => setMaximized((m) => !m), children: maximized ? t("files.restore") : t("files.maximize") }),
1778
+ react_jsx_runtime.jsx("button", { type: "button", style: btnStyle, onClick: () => { const m = !maximized; setMaximized(m); try { const el = document.querySelector(".dsh-skill-preview-card"); if (m && el && el.requestFullscreen) el.requestFullscreen(); else if (document.fullscreenElement) document.exitFullscreen() } catch (e) {} }, children: maximized ? t("files.restore") : t("files.maximize") }),
1718
1779
  react_jsx_runtime.jsx("button", { type: "button", style: btnStyle, onClick: () => setPreview(null), children: t("files.close") })
1719
1780
  ]
1720
1781
  }),
@@ -1805,7 +1866,7 @@ window.__ModuleLoader__.load({
1805
1866
  "files.binary": "二进制文件,无法预览,请下载后查看",
1806
1867
  "files.truncated": "(内容过大,仅显示前 256KB)",
1807
1868
  "files.close": "关闭预览",
1808
- "skills.hint": "技能文档(各技能的 SKILL.md),可预览",
1869
+ "skills.hint": "技能管理(各技能的 SKILL.md),可预览",
1809
1870
  "balance": "余额",
1810
1871
  "spend": "本会话约"
1811
1872
  };
@@ -2387,7 +2448,7 @@ window.__ModuleLoader__.load({
2387
2448
  }
2388
2449
  const WorkspaceFilesButton = ({ sessionId, useSessions }) => {
2389
2450
  const cur = useSessions((s) => (sessionId === void 0 ? void 0 : s.byId[sessionId]?.cwd))
2390
- React.useEffect(() => { if (cur) lastCwd = normPath(cur) }, [cur])
2451
+ React.useEffect(() => { if (cur) { lastCwd = normPath(cur); try { window.__dshCurrentCwd = normPath(cur) } catch (e) {} } }, [cur])
2391
2452
  // Windows 下 cwd 是反斜杠路径(C:\...\jacky),按 / 与 \ 都能切,取末段文件夹名。
2392
2453
  const ws = cur ? normPath(cur).split('/').filter(Boolean).pop() : ''
2393
2454
  return React.createElement(
@@ -3389,7 +3450,7 @@ window.__ModuleLoader__.load({
3389
3450
  ['uploadDragDrop', '附件拖放上传'],
3390
3451
  ['uploadPaste', '附件粘贴上传'],
3391
3452
  ['uploadPreview', '上传文件预览/管理'],
3392
- ['skillDocs', '技能文档'],
3453
+ ['skillDocs', '技能管理'],
3393
3454
  ['balance', '账户余额'],
3394
3455
  ['workspace', '输出文件预览/管理'],
3395
3456
  ['mobile', '移动端布局'],
package/dsh.plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-long-plugins",
3
3
  "description": "Merged DSH web plugins: upload manager (drag-and-drop attach any file to the message), workspace output files, skill docs, account balance, Markdown-to-Word (md2docx), and polished file preview (Word & PowerPoint real preview with zoom/pan, rendered Markdown), plus an auto-repair install for DSH core patches (reverse-proxy WebSocket heartbeat, upgrade relink). | 一个插件整合 DSH Web 的常用增强:上传管理(拖放上传:拖任意文件到会话框直接附加)、工作区「输出文件」面板、技能文档浏览、账户余额显示、Markdown 转 Word(md2docx)、Word/PowerPoint 真实预览(可缩放、翻页);并自带修复安装,自动重连 DSH 核心补丁(反代 WebSocket 心跳、升级重连)。",
4
- "version": "2.4.1",
4
+ "version": "2.4.5",
5
5
  "entry": {
6
6
  "name": "dsh-long-plugins",
7
7
  "inject": [
package/lib/index.js CHANGED
@@ -385,6 +385,8 @@ async function collectGroups(root, excluded, hiddenNames = []) {
385
385
  const groups = [];
386
386
  for (const entry of entries) {
387
387
  if (!entry.isDirectory() || entry.name === excluded) continue;
388
+ // 排除点开头(.dsh/.outputdir)与下划线开头(_sd_extract 等中间产物)的文件夹及其下层所有内容
389
+ if (entry.name.startsWith(".") || entry.name.startsWith("_")) continue;
388
390
  const files = [];
389
391
  const walk = async (dir) => {
390
392
  let sub;
@@ -397,6 +399,8 @@ async function collectGroups(root, excluded, hiddenNames = []) {
397
399
  sub.sort((a, b) => a.name.localeCompare(b.name));
398
400
  for (const item of sub) {
399
401
  const abs = join(dir, item.name);
402
+ // 嵌套里的点/下划线开头条目(夹/文件)也一并排除
403
+ if (item.name.startsWith(".") || item.name.startsWith("_")) continue;
400
404
  if (item.isDirectory()) {
401
405
  await walk(abs);
402
406
  } else if (item.isFile()) {
@@ -427,6 +431,47 @@ async function collectGroups(root, excluded, hiddenNames = []) {
427
431
  return groups;
428
432
  }
429
433
 
434
+ /** 扫描 workspace 根下的子目录:凡有 .dsh/skills 就收集其技能文件,按工作区名分组;无文件则该组 files 为空。 */
435
+ async function collectWorkspaceSkills(baseRoot) {
436
+ let entries;
437
+ try {
438
+ entries = await readdir(baseRoot, { withFileTypes: true });
439
+ } catch {
440
+ return [];
441
+ }
442
+ const groups = [];
443
+ for (const e of entries) {
444
+ if (!e.isDirectory() || e.name.startsWith(".")) continue;
445
+ const skillsDir = resolve(baseRoot, e.name, ".dsh", "skills");
446
+ try {
447
+ const st = await stat(skillsDir);
448
+ if (!st.isDirectory()) continue;
449
+ } catch { continue; }
450
+ const files = [];
451
+ const walk = async (dir) => {
452
+ let sub;
453
+ try { sub = await readdir(dir, { withFileTypes: true }); } catch { return; }
454
+ sub.sort((a, b) => a.name.localeCompare(b.name));
455
+ for (const item of sub) {
456
+ if (item.name.startsWith(".")) continue;
457
+ const abs = join(dir, item.name);
458
+ try {
459
+ if (item.isDirectory()) await walk(abs);
460
+ else if (item.isFile()) {
461
+ const info = await stat(abs);
462
+ files.push({ path: relative(skillsDir, abs).split(sep).join("/"), name: item.name, size: info.size, mtime: info.mtimeMs });
463
+ }
464
+ } catch {}
465
+ }
466
+ };
467
+ await walk(skillsDir);
468
+ files.sort((a, b) => a.path.localeCompare(b.path));
469
+ groups.push({ folder: e.name, files });
470
+ }
471
+ groups.sort((a, b) => a.folder.localeCompare(b.folder));
472
+ return groups;
473
+ }
474
+
430
475
  export async function sweepUploadTemps(root) {
431
476
  await mkdir(root, { recursive: true, mode: 0o700 });
432
477
  const entries = await readdir(root, { withFileTypes: true });
@@ -1069,6 +1114,8 @@ export function createHandlers(options = {}) {
1069
1114
  const size = info.size;
1070
1115
  const downloadHref = `workspace-file?path=${encodeURIComponent(rel)}&download=1`;
1071
1116
  let body;
1117
+ let editable = false;
1118
+ let rawText = "";
1072
1119
  if (OFFICE_EXTS.has(ext)) {
1073
1120
  const html = await officePreviewHtml(name, buffer);
1074
1121
  body = html ? `<div class="office">${html}</div>` : `<p class="unsupported">该 Office 文档无法渲染,请下载查看。</p>`;
@@ -1081,17 +1128,20 @@ export function createHandlers(options = {}) {
1081
1128
  // 避免 base64 data URI 在 iframe 内被 Chrome 拒绝。
1082
1129
  body = `<iframe class="pdf" src="workspace-file?path=${encodeURIComponent(rel)}&inline=1"></iframe>`;
1083
1130
  } else if (/\.(txt|log)$/i.test(name)) {
1084
- body = `<pre class="text">${escapeHtml(buffer.toString("utf8"))}</pre>`;
1131
+ editable = true; rawText = buffer.toString("utf8");
1132
+ body = `<pre class="text">${escapeHtml(rawText)}</pre>`;
1085
1133
  } else if (/\.(md|markdown)$/i.test(name)) {
1086
1134
  // Markdown → 直接渲染成 HTML(真实效果),失败回退源码文本。
1087
- body = `<article class="md">${markdownToHtml(buffer.toString("utf8"))}</article>`;
1135
+ editable = true; rawText = buffer.toString("utf8");
1136
+ body = `<article class="md">${markdownToHtml(rawText)}</article>`;
1088
1137
  } else if (/\.(json|ya?ml|py|js|mjs|cjs|ts|sh|css|html?|xml|csv|ini|conf|env|toml|sql|rs|go|c|h|cpp|java|kt|swift|rb|php|vue|jsx|tsx)$/i.test(name)) {
1089
- body = `<pre class="text">${escapeHtml(buffer.toString("utf8"))}</pre>`;
1138
+ editable = true; rawText = buffer.toString("utf8");
1139
+ body = `<pre class="text">${escapeHtml(rawText)}</pre>`;
1090
1140
  } else {
1091
1141
  body = `<p class="unsupported">该文件类型暂不支持预览,请点击右上角「下载」。</p>`;
1092
1142
  }
1093
1143
  res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
1094
- res.end(previewPageHtml(name, rel, size, downloadHref, body, `workspace-file?path=${encodeURIComponent(rel)}&inline=1`));
1144
+ res.end(previewPageHtml(name, rel, size, downloadHref, body, `workspace-file?path=${encodeURIComponent(rel)}&inline=1`, editable, rawText));
1095
1145
  } catch (error) {
1096
1146
  sendError(res, error, onError);
1097
1147
  }
@@ -2443,7 +2493,7 @@ function markdownToHtml(md) {
2443
2493
 
2444
2494
 
2445
2495
  /** 工作区文件预览页面骨架(自包含,无外部依赖)。 */
2446
- function previewPageHtml(name, rel, size, downloadHref, body, inlineHref = "") {
2496
+ function previewPageHtml(name, rel, size, downloadHref, body, inlineHref = "", editable = false, rawText = "") {
2447
2497
  return `<!DOCTYPE html>
2448
2498
  <html lang="zh-CN">
2449
2499
  <head>
@@ -2477,6 +2527,8 @@ function previewPageHtml(name, rel, size, downloadHref, body, inlineHref = "") {
2477
2527
  .hint { position:fixed; left:50%; bottom:24px; transform:translateX(-50%); background:#7c2d12; color:#fdba74; border-radius:8px; padding:10px 18px; font-size:13px; display:none; z-index:9; }
2478
2528
  .content { padding:20px; max-width:960px; margin:0 auto; }
2479
2529
  .text { background:var(--lp-text-bg); border:1px solid var(--lp-border); border-radius:10px; padding:16px; overflow:auto; font-size:13px; line-height:1.7; white-space:pre-wrap; word-break:break-word; }
2530
+ .editor { display:none; width:100%; min-height:60vh; background:var(--lp-text-bg); color:var(--lp-fg); border:1px solid var(--lp-border); border-radius:10px; padding:12px 14px; font:13px/1.7 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; resize:vertical; white-space:pre; word-break:normal; }
2531
+ body.maximized .editor { min-height:calc(100vh - 53px); }
2480
2532
  .md { line-height:1.8; font-size:14px; word-break:break-word; }
2481
2533
  .md h1,.md h2,.md h3,.md h4,.md h5,.md h6 { line-height:1.45; margin:1.7em 0 .8em; }
2482
2534
  .md h1 { font-size:1.5em; }
@@ -2523,14 +2575,36 @@ function previewPageHtml(name, rel, size, downloadHref, body, inlineHref = "") {
2523
2575
  <span class="name">${escapeHtml(name)}</span>
2524
2576
  <span class="meta">${escapeHtml(rel)} · ${(size / 1024).toFixed(1)} KB</span>
2525
2577
  <span class="spacer"></span>
2578
+ ${editable ? `<button class="btn2" type="button" id="editBtn" onclick="editMode()">${ICON_FOLDER} 编辑</button>
2579
+ <button class="btn" type="button" id="saveBtn" onclick="saveFile()" style="display:none">${ICON_DL} 保存</button>` : ""}
2526
2580
  ${inlineHref ? `<a class="btn" href="${escapeHtml(inlineHref)}" target="_blank" rel="noopener noreferrer">${ICON_EYE} 打开</a>` : ""}
2527
2581
  <a class="btn" href="${escapeHtml(downloadHref)}" download>${ICON_DL} 下载</a>
2528
2582
  <button class="btn2" type="button" id="maxBtn" onclick="toggleMax()">${ICON_FOLDER} 放大</button>
2529
2583
  <button class="btn2" type="button" onclick="closePreview()">${ICON_X} 关闭</button>
2530
2584
  </div>
2531
2585
  <div class="content">${body}</div>
2586
+ ${editable ? `<textarea id="editor" class="editor">${escapeHtml(rawText)}</textarea>` : ""}
2532
2587
  <div class="hint" id="closeHint">浏览器不允许脚本直接关闭此标签页,请手动关闭本标签页(或按 Ctrl+W / ⌘+W)。</div>
2533
2588
  <script>
2589
+ var SAVE_PATH = ${JSON.stringify("/api/dsh-uploads/workspace-file/save")};
2590
+ var SAVE_REL = ${JSON.stringify(rel)};
2591
+ function editMode() {
2592
+ var editing = document.getElementById('editor').style.display === 'none';
2593
+ document.querySelector('.content').style.display = editing ? 'none' : '';
2594
+ document.getElementById('editor').style.display = editing ? 'block' : 'none';
2595
+ document.getElementById('saveBtn').style.display = editing ? '' : 'none';
2596
+ document.getElementById('editBtn').textContent = editing ? '取消' : '编辑';
2597
+ if (editing) { var ed = document.getElementById('editor'); ed.focus(); }
2598
+ }
2599
+ function saveFile() {
2600
+ var content = document.getElementById('editor').value;
2601
+ var btn = document.getElementById('saveBtn');
2602
+ btn.disabled = true; btn.textContent = '保存中…';
2603
+ fetch(SAVE_PATH, { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ path: SAVE_REL, content: content }) })
2604
+ .then(function (r) { return r.json().catch(function(){ return {}; }).then(function(b){ return { ok: r.ok, b: b }; }); })
2605
+ .then(function (o) { btn.textContent = o.ok ? '已保存' : ('失败: ' + (o.b && o.b.error || '')); btn.disabled = false; setTimeout(function(){ btn.textContent = '保存'; }, 1400); })
2606
+ .catch(function () { btn.textContent = '网络错误'; btn.disabled = false; setTimeout(function(){ btn.textContent = '保存'; }, 1500); });
2607
+ }
2534
2608
  function toggleMax() {
2535
2609
  var max = document.body.classList.toggle('maximized');
2536
2610
  document.getElementById('maxBtn').textContent = max ? '还原' : '放大';
@@ -2565,6 +2639,7 @@ function closePreview() {
2565
2639
  export async function apply(ctx, config = {}) {
2566
2640
  const trustedHosts = Array.isArray(config.trustedHosts) ? [...config.trustedHosts] : [];
2567
2641
  const skillsRoot = resolve(config.skillsRoot ?? DEFAULT_SKILLS_ROOT);
2642
+ const workspaceRoot = resolveWorkspaceRoot(); // 供 skill-docs 路由"工作区技能"使用
2568
2643
  const onError = (error) => ctx.logger.error(error instanceof Error ? error : new Error(String(error)));
2569
2644
 
2570
2645
  const handlers = createHandlers({ trustedHosts, onError, excludedWorkspaceNames: config.excludedWorkspaceNames });
@@ -2779,6 +2854,19 @@ export async function apply(ctx, config = {}) {
2779
2854
  }), "dsh-long-plugins: workspace rename route");
2780
2855
 
2781
2856
  // ---- 技能文档 (skill docs) routes ----
2857
+ const skillDocsList = async (req, res) => {
2858
+ const rootParam = (() => { try { return new URL(req.url || "/", "http://dsh.internal").searchParams.get("root") || "global"; } catch { return "global"; } })();
2859
+ const wsParam = (() => { try { return new URL(req.url || "/", "http://dsh.internal").searchParams.get("ws") || ""; } catch { return ""; } })();
2860
+ if (rootParam === "workspace") {
2861
+ // 扫描 workspace 根下的子目录:凡有 .dsh/skills 就识别,按工作区名分组;无文件则该组 files 为空
2862
+ const groups = await collectWorkspaceSkills(workspaceRoot);
2863
+ sendJson(res, 200, { ok: true, root: workspaceRoot, groups });
2864
+ return;
2865
+ }
2866
+ const groups = await collectGroups(skillsRoot, "__dsh_none__");
2867
+ sendJson(res, 200, { ok: true, root: skillsRoot, groups });
2868
+ };
2869
+
2782
2870
  ctx.effect(() => ctx.webServer.register({
2783
2871
  kind: "exact",
2784
2872
  path: "/dsh-skill-docs/skill-docs",
@@ -2789,8 +2877,7 @@ export async function apply(ctx, config = {}) {
2789
2877
  }
2790
2878
  try {
2791
2879
  requireTrusted(req);
2792
- const groups = await collectGroups(skillsRoot, "__dsh_none__");
2793
- sendJson(res, 200, { ok: true, root: skillsRoot, groups });
2880
+ await skillDocsList(req, res);
2794
2881
  } catch (error) {
2795
2882
  sendError(res, error, onError);
2796
2883
  }
@@ -2813,7 +2900,10 @@ export async function apply(ctx, config = {}) {
2813
2900
  }
2814
2901
  try {
2815
2902
  requireTrusted(req);
2816
- const full = safeResolve(skillsRoot, rel);
2903
+ const rootParam = (() => { try { return new URL(req.url || "/", "http://dsh.internal").searchParams.get("root") || "global"; } catch { return "global"; } })();
2904
+ const wsParam = (() => { try { return new URL(req.url || "/", "http://dsh.internal").searchParams.get("ws") || ""; } catch { return ""; } })();
2905
+ const root = rootParam === "workspace" ? resolve(workspaceRoot, wsParam, ".dsh", "skills") : skillsRoot;
2906
+ const full = safeResolve(root, rel);
2817
2907
  if (full === undefined) throw new HttpError(400, "bad path");
2818
2908
  const info = await stat(full);
2819
2909
  if (!info.isFile()) throw new HttpError(400, "not a file");
@@ -2865,8 +2955,11 @@ export async function apply(ctx, config = {}) {
2865
2955
  const body = await readJsonBody(req);
2866
2956
  const rel = typeof body === "object" && body !== null ? body.path : undefined;
2867
2957
  const content = typeof body === "object" && body !== null ? body.content : undefined;
2958
+ const rootParam = typeof body === "object" && body !== null && body.root === "workspace" ? "workspace" : "global";
2959
+ const wsParam = typeof body === "object" && body !== null && typeof body.ws === "string" ? body.ws : "";
2960
+ const root = rootParam === "workspace" ? resolve(workspaceRoot, wsParam, ".dsh", "skills") : skillsRoot;
2868
2961
  if (typeof content !== "string") throw new HttpError(400, "content required");
2869
- const full = safeResolve(skillsRoot, rel);
2962
+ const full = safeResolve(root, rel);
2870
2963
  if (full === undefined) throw new HttpError(400, "bad path");
2871
2964
  const info = await stat(full);
2872
2965
  if (!info.isFile()) throw new HttpError(400, "not a file");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-long-plugins",
3
- "version": "2.4.1",
3
+ "version": "2.4.5",
4
4
  "description": "Merged DSH web plugins: upload manager (drag-and-drop attach any file to the message), workspace output files, skill docs, account balance, Markdown-to-Word (md2docx), and polished file preview (Word & PowerPoint real preview with zoom/pan, rendered Markdown), plus an auto-repair install for DSH core patches (reverse-proxy WebSocket heartbeat, upgrade relink). | 一个插件整合 DSH Web 的常用增强:上传管理(拖放上传:拖任意文件到会话框直接附加)、工作区「输出文件」面板、技能文档浏览、账户余额显示、Markdown 转 Word(md2docx)、Word/PowerPoint 真实预览(可缩放、翻页);并自带修复安装,自动重连 DSH 核心补丁(反代 WebSocket 心跳、升级重连)。",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -84,8 +84,12 @@ fi
84
84
  2. 设置页各 tab 可进、不再 403/未暴露命名空间(重启后,用浏览器访问,非 raw 127.0.0.1)。
85
85
  3. 反代域名访问正常。
86
86
  4. 提问/审批窗口:弹出一个问题,等 60s+ 不再自己消失(心跳补丁生效)。
87
- 5. 插件设置面板「上传文件」「输出文件」「技能文档」都在。
87
+ 5. 插件设置面板「上传文件」「输出文件」「技能文档」「dsh-long」「RA-Span」都在。
88
88
  6. `grep -q WEBSOCKET_HEARTBEAT_MS <核心解压路径>/dsh-client-connection/lib/index.js` → 心跳在位。
89
+ 7. 「dsh-long」区有各模块开关 +「补丁状态(只读)」;禁用某模块后对应功能不加载(下次刷新生效)。
90
+ 8. 工作区「输出文件」/「上传文件」里:点开头文件夹(如 `.dsh`/`.outputdir`)及其下层文件不显示。
91
+ 9. 设置面板「技能管理」「上传文件」「输出文件」的 🔍 搜索框:点击后弹出框紧贴按钮下方、滚动不脱离。
92
+ 10. **「技能管理」区(全局技能 / 工作区技能两 tab)**:全局技能列 `<DSH_HOME>/skills`;工作区技能扫描工作区根下各子目录的 `.dsh/skills`(无 `.dsh/skills` 则该工作区组为空)。若该区不出现/工作区技能为空,可能 DSH `settings.section` 槽或工作区结构变化——复核。
89
93
 
90
94
  ## 硬性安全边界(必须遵守)
91
95
  - **不主动 push / 打 tag / 发 Release**;需要发布版本时停下用 ask_user_question 等确认。