dsh-app-deploy 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.js CHANGED
@@ -48,17 +48,19 @@ window.__ModuleLoader__.load({
48
48
  }
49
49
  function clone(v) { return JSON.parse(JSON.stringify(v)) }
50
50
  function normPath(p) { return (p || '').replace(/\/+$/, '') }
51
+ // 绝对路径:posix 以 / 开头,Windows 为盘符或 \ 开头(如 C:\proj、D:/proj)
52
+ function isAbsPath(p) { return /^([a-zA-Z]:[\\/]|[\\/])/.test(p || '') }
51
53
  function joinPath(base, name) {
52
54
  if (!name) return base || ''
53
- if (name.startsWith('/')) return name
55
+ if (isAbsPath(name)) return name
54
56
  const b = base || ''
55
57
  if (!b) return name
56
- return b === '/' ? '/' + name : b.replace(/\/+$/, '') + '/' + name
58
+ return b === '/' ? '/' + name : b.replace(/[\\/]+$/, '') + '/' + name
57
59
  }
58
60
  function parentPath(p) {
59
61
  const base = normPath(p || '')
60
- if (!base || base === '/') return '/'
61
- const i = base.lastIndexOf('/')
62
+ if (!base || base === '/' || /^[a-zA-Z]:[\\/]?$/.test(base)) return base || '/'
63
+ const i = Math.max(base.lastIndexOf('/'), base.lastIndexOf('\\'))
62
64
  return i <= 0 ? '/' : base.slice(0, i)
63
65
  }
64
66
  function newEnvId(envs) {
@@ -72,7 +74,7 @@ window.__ModuleLoader__.load({
72
74
  while (modules.some((m) => m.id === 'mod' + n)) n++
73
75
  return {
74
76
  id: 'mod' + n, name: '新模块', kind: 'front', enabled: true,
75
- gradleTask: '', key: '', extraArgs: '', localPath: '', remotePath: '',
77
+ buildCmd: '', sshId: '', localPath: '', remotePath: '',
76
78
  postCommand: '', healthCheckUrl: '', frontEnv: '',
77
79
  }
78
80
  }
@@ -80,14 +82,21 @@ window.__ModuleLoader__.load({
80
82
  return {
81
83
  id: m.id || '', name: m.name || '', kind: m.kind === 'server' ? 'server' : 'front',
82
84
  enabled: m.enabled !== false,
83
- gradleTask: m.gradleTask || '', key: m.key || '', extraArgs: m.extraArgs || '',
85
+ buildCmd: m.buildCmd || '', sshId: m.sshId || '',
84
86
  localPath: m.localPath || '', remotePath: m.remotePath || '', postCommand: m.postCommand || '',
85
87
  healthCheckUrl: m.healthCheckUrl || '', frontEnv: m.frontEnv || '',
86
88
  }
87
89
  }
90
+ // 模块的发布目标(与服务端 config.js targetFor 同规则:sshId 落空→第一个目标)
91
+ function targetOf(env, m) {
92
+ const list = (env && env.sshs) || []
93
+ return list.find((s) => s.id === (m && m.sshId)) || list[0] || {}
94
+ }
95
+
88
96
  function normalizeEnv(env) {
89
97
  const e = clone(env || {})
90
- const ssh = e.ssh || {}
98
+ // 旧格式 env.ssh 单对象自动迁移为 sshs 数组
99
+ const rawSshs = Array.isArray(e.sshs) && e.sshs.length ? e.sshs : (e.ssh ? [e.ssh] : [])
91
100
  return {
92
101
  id: e.id || '',
93
102
  name: e.name || '',
@@ -95,11 +104,17 @@ window.__ModuleLoader__.load({
95
104
  group: e.group || '',
96
105
  projectPath: e.projectPath || '',
97
106
  jdkHome: e.jdkHome || '',
107
+ nodeHome: e.nodeHome || '',
108
+ nodeOptions: e.nodeOptions || '',
109
+ pmPath: e.pmPath || '',
110
+ buildEnv: e.buildEnv || '',
98
111
  expTime: e.expTime || '',
99
- ssh: {
100
- host: ssh.host || '', port: Number(ssh.port) || 22, user: ssh.user || 'root',
101
- password: ssh.password || '', options: ssh.options || '',
102
- },
112
+ sshs: rawSshs.map((s, i) => ({
113
+ id: s.id || (i === 0 ? 'default' : 'ssh' + (i + 1)),
114
+ name: s.name || (i === 0 ? '默认' : 'SSH ' + (i + 1)),
115
+ host: s.host || '', port: Number(s.port) || 22, user: s.user || 'root',
116
+ password: s.password || '', keyFile: s.keyFile || '', options: s.options || '',
117
+ })),
103
118
  modules: (e.modules || []).map(normalizeModule),
104
119
  }
105
120
  }
@@ -125,9 +140,40 @@ window.__ModuleLoader__.load({
125
140
  children)
126
141
  }
127
142
 
143
+ // 路径下拉选择器(JDK / Node 共用):输入框 + 候选项弹层
144
+ function PathPicker({ value, aria, items, open, wrapRef, onFocus, onChange, onPick, emptyText }) {
145
+ return h('div', { className: 'dad-jdkwrap', ref: wrapRef },
146
+ h('input', {
147
+ className: 'dad-input', type: 'text', value, 'aria-label': aria, onFocus,
148
+ onChange: (ev) => onChange(ev.target.value),
149
+ }),
150
+ open ? h('div', { className: 'dad-jdkdd' },
151
+ items.length
152
+ ? items.map((j) => h('button', {
153
+ key: j.path, type: 'button', className: 'dad-jitem',
154
+ onClick: () => onPick(j.path),
155
+ },
156
+ h('span', { className: 'dad-jlbl' }, j.label),
157
+ h('span', { className: 'dad-jfull' }, j.path)))
158
+ : h('div', { className: 'dad-jempty' }, emptyText)) : null)
159
+ }
160
+
161
+ // 构建工具预设:选中即填充构建命令(可再手动编辑)
162
+ const BUILD_PRESETS = [
163
+ { tool: 'Gradle', cmd: './gradlew' },
164
+ { tool: 'Maven', cmd: 'mvn -DskipTests package' },
165
+ { tool: 'npm', cmd: 'npm run build' },
166
+ { tool: 'pnpm', cmd: 'pnpm run build' },
167
+ { tool: 'yarn', cmd: 'yarn build' },
168
+ ]
169
+
128
170
  function EnvCard({ env, selected, lastBuild, onSelect, onCopy, onDelete }) {
129
171
  const lb = lastBuild[env.id]
130
- const ssh = env.ssh || {}
172
+ const sshList = env.sshs || []
173
+ const ssh = sshList[0] || {}
174
+ const sshText = ssh.host
175
+ ? (ssh.user || 'root') + '@' + ssh.host + ':' + (ssh.port || 22) + (sshList.length > 1 ? ' 等 ' + sshList.length + ' 个目标' : '')
176
+ : 'SSH 未配置'
131
177
  return h('div', {
132
178
  className: 'dad-env',
133
179
  'data-sel': selected ? '1' : '0',
@@ -140,7 +186,7 @@ window.__ModuleLoader__.load({
140
186
  h('i', { className: 'dad-dot' + (lb ? ' st-' + lb.status : ''), 'aria-hidden': true }),
141
187
  env.name || env.id),
142
188
  h('span', { className: 'dad-sub' }, env.id + (env.note ? ' · ' + env.note : '')),
143
- h('span', { className: 'dad-sub' }, ssh.host ? (ssh.user || 'root') + '@' + ssh.host + ':' + (ssh.port || 22) : 'SSH 未配置'),
189
+ h('span', { className: 'dad-sub' }, sshText),
144
190
  h('span', { className: 'dad-sub dad-build' + (lb ? ' ' + lb.status : '') },
145
191
  h('i', { className: 'dad-dot', 'aria-hidden': true }),
146
192
  lb ? '最后构建: ' + fmtTime(lb.time) + '(' + (HSTATUS[lb.status] || lb.status) + ')' : '最后构建: 从未')),
@@ -203,11 +249,11 @@ window.__ModuleLoader__.load({
203
249
  nodes))
204
250
  }
205
251
 
206
- function ModuleCard({ mod, onChange, onDelete }) {
252
+ function ModuleCard({ mod, onChange, onDelete, sshs }) {
207
253
  const envFile = mod.id === 'bsFront' ? 'client/client-front/.env'
208
254
  : mod.id === 'adminFront' ? 'admin/admin-front/.env' : ''
209
255
  const feLabel = 'frontEnv(KEY=VALUE 多行,构建时注入'
210
- + (envFile ? ' ' + envFile + (mod.key ? '.' + mod.key : '') : '') + ')'
256
+ + (envFile ? ' ' + envFile : '') + ')'
211
257
  return h('div', { className: 'dad-mcard', 'data-off': mod.enabled ? '0' : '1' },
212
258
  h('button', {
213
259
  type: 'button', className: 'dad-btndel', title: '删除模块', 'aria-label': '删除模块',
@@ -237,21 +283,19 @@ window.__ModuleLoader__.load({
237
283
  onChange: (ev) => onChange('enabled', ev.target.checked),
238
284
  }), '启用')),
239
285
  h('div', { className: 'dad-row' },
240
- h(Field, { label: 'gradle 任务' },
241
- h('input', {
242
- className: 'dad-input', type: 'text', value: mod.gradleTask, 'aria-label': 'gradle 任务',
243
- onChange: (ev) => onChange('gradleTask', ev.target.value),
244
- })),
245
- h(Field, { label: '-Pkey(可空)' },
246
- h('input', {
247
- className: 'dad-input', type: 'text', value: mod.key, 'aria-label': '-Pkey',
248
- onChange: (ev) => onChange('key', ev.target.value),
249
- })),
250
- h(Field, { label: '额外参数(如 -PexpTime=…)' },
251
- h('input', {
252
- className: 'dad-input', type: 'text', value: mod.extraArgs, 'aria-label': '额外参数',
253
- onChange: (ev) => onChange('extraArgs', ev.target.value),
254
- }))),
286
+ h(Field, { label: '构建命令(可左侧选工具自动填充,再手动补任务/参数)', wide: true },
287
+ h('div', { className: 'dad-inline' },
288
+ h('select', {
289
+ className: 'dad-select', 'aria-label': '构建工具',
290
+ value: (BUILD_PRESETS.find((p) => p.cmd === (mod.buildCmd || '').trim()) || {}).cmd || '',
291
+ onChange: (ev) => { if (ev.target.value) onChange('buildCmd', ev.target.value) },
292
+ },
293
+ h('option', { value: '' }, '构建工具…'),
294
+ BUILD_PRESETS.map((p) => h('option', { key: p.cmd, value: p.cmd }, p.tool))),
295
+ h('input', {
296
+ className: 'dad-input', type: 'text', value: mod.buildCmd, 'aria-label': '构建命令',
297
+ onChange: (ev) => onChange('buildCmd', ev.target.value),
298
+ })))),
255
299
  h('div', { className: 'dad-row' },
256
300
  h(Field, { label: '本地产物路径' },
257
301
  h('input', {
@@ -262,7 +306,13 @@ window.__ModuleLoader__.load({
262
306
  h('input', {
263
307
  className: 'dad-input', type: 'text', value: mod.remotePath, 'aria-label': '远程目标路径',
264
308
  onChange: (ev) => onChange('remotePath', ev.target.value),
265
- }))),
309
+ })),
310
+ h(Field, { label: 'SSH 目标' },
311
+ h('select', {
312
+ className: 'dad-select', value: mod.sshId || '', 'aria-label': 'SSH 目标',
313
+ onChange: (ev) => onChange('sshId', ev.target.value),
314
+ },
315
+ (sshs || []).map((s) => h('option', { key: s.id, value: s.id }, s.name || s.id))))),
266
316
  h('div', { className: 'dad-row' },
267
317
  h(Field, { label: '部署后远程命令(可空)' },
268
318
  h('input', {
@@ -290,12 +340,15 @@ window.__ModuleLoader__.load({
290
340
  function EnvForm({
291
341
  env, isNew, groups, message, open, onToggle,
292
342
  onPatch, onPatchSsh, onPatchModule, onAddModule, onDeleteModule,
293
- onSave, onDelete, onBrowse,
343
+ onSave, onDelete, onBrowse, onBrowseKey,
344
+ onAddSsh, onDeleteSsh, onTestSsh, sshTest,
294
345
  jdks, jdkOpen, jdkRef, onJdkFocus, onJdkPick,
346
+ nodes, nodeOpen, nodeRef, nodeErr, onNodeFocus, onNodePick,
347
+ pms, pmOpen, pmRef, pmErr, onPmFocus, onPmPick,
295
348
  }) {
296
349
  const title = env ? '环境配置' : '选择左侧环境进行编辑'
297
350
  const groupOptions = groups.map((g) => h('option', { key: g, value: g }))
298
- const ssh = env ? env.ssh : { host: '', port: 22, user: '', password: '', options: '' }
351
+ const sshList = env ? (env.sshs || []) : []
299
352
  return h('div', { className: 'dad-card' },
300
353
  h('div', { className: 'dad-card-head' },
301
354
  h('span', { className: 'dad-card-title', title }, title),
@@ -343,20 +396,37 @@ window.__ModuleLoader__.load({
343
396
  }),
344
397
  h('button', { type: 'button', className: 'dad-btn', onClick: onBrowse }, '浏览…'))),
345
398
  h(Field, { label: 'JDK 路径(JAVA_HOME,可留空)', wide: true },
346
- h('div', { className: 'dad-jdkwrap', ref: jdkRef },
347
- h('input', {
348
- className: 'dad-input', type: 'text', value: env.jdkHome, 'aria-label': 'JDK 路径',
349
- onFocus: onJdkFocus, onChange: (ev) => onPatch({ jdkHome: ev.target.value }),
350
- }),
351
- jdkOpen ? h('div', { className: 'dad-jdkdd' },
352
- jdks.length
353
- ? jdks.map((j) => h('button', {
354
- key: j.path, type: 'button', className: 'dad-jitem',
355
- onClick: () => onJdkPick(j.path),
356
- },
357
- h('span', { className: 'dad-jlbl' }, j.label),
358
- h('span', { className: 'dad-jfull' }, j.path)))
359
- : h('div', { className: 'dad-jempty' }, '未发现可用 JDK')) : null))),
399
+ h(PathPicker, {
400
+ value: env.jdkHome, aria: 'JDK 路径', items: jdks, open: jdkOpen, wrapRef: jdkRef,
401
+ onFocus: onJdkFocus, onChange: (v) => onPatch({ jdkHome: v }), onPick: onJdkPick,
402
+ emptyText: '未发现可用 JDK',
403
+ }))),
404
+ h('div', { className: 'dad-row' },
405
+ h(Field, { label: 'Node 路径(构建时加入 PATH,可留空)', wide: true },
406
+ h(PathPicker, {
407
+ value: env.nodeHome || '', aria: 'Node 路径', items: nodes, open: nodeOpen, wrapRef: nodeRef,
408
+ onFocus: onNodeFocus, onChange: (v) => onPatch({ nodeHome: v }), onPick: onNodePick,
409
+ emptyText: nodeErr || '未发现已装 Node(支持 nvm / n / fnm / volta)',
410
+ })),
411
+ h(Field, { label: 'Node options(NODE_OPTIONS,可留空)' },
412
+ h('input', {
413
+ className: 'dad-input', type: 'text', value: env.nodeOptions || '', 'aria-label': 'Node options',
414
+ onChange: (ev) => onPatch({ nodeOptions: ev.target.value }),
415
+ }))),
416
+ h('div', { className: 'dad-row' },
417
+ h(Field, { label: '包管理器路径(如 …/pnpm,构建时加入 PATH,可留空)', wide: true },
418
+ h(PathPicker, {
419
+ value: env.pmPath || '', aria: '包管理器路径', items: pms, open: pmOpen, wrapRef: pmRef,
420
+ onFocus: onPmFocus, onChange: (v) => onPatch({ pmPath: v }), onPick: onPmPick,
421
+ emptyText: pmErr || '未发现 pnpm / yarn(npm 随 Node 自动可用)',
422
+ }))),
423
+ h('div', { className: 'dad-row' },
424
+ h(Field, { label: '构建环境变量(KEY=VALUE 多行,构建时注入所有模块,可留空)', wide: true },
425
+ h('textarea', {
426
+ className: 'dad-textarea', rows: 3, spellCheck: false, value: env.buildEnv || '',
427
+ 'aria-label': '构建环境变量',
428
+ onChange: (ev) => onPatch({ buildEnv: ev.target.value }),
429
+ }))),
360
430
  h('div', { className: 'dad-row' },
361
431
  h(Field, { label: '过期时间 expTime(YYYY-MM-DD,可留空)' },
362
432
  h('input', {
@@ -364,44 +434,81 @@ window.__ModuleLoader__.load({
364
434
  onChange: (ev) => onPatch({ expTime: ev.target.value }),
365
435
  })))),
366
436
  h('div', { className: 'dad-sec' },
367
- h('div', { className: 'dad-sec-title' }, 'SSH'),
368
- h('div', { className: 'dad-row' },
369
- h(Field, { label: 'Host' },
370
- h('input', {
371
- className: 'dad-input', type: 'text', value: ssh.host, 'aria-label': 'SSH Host',
372
- onChange: (ev) => onPatchSsh({ host: ev.target.value }),
373
- })),
374
- h(Field, { label: 'Port' },
375
- h('input', {
376
- className: 'dad-input', type: 'number', value: ssh.port, 'aria-label': 'SSH Port',
377
- onChange: (ev) => onPatchSsh({ port: ev.target.value }),
378
- })),
379
- h(Field, { label: 'User' },
380
- h('input', {
381
- className: 'dad-input', type: 'text', value: ssh.user, 'aria-label': 'SSH User',
382
- onChange: (ev) => onPatchSsh({ user: ev.target.value }),
383
- }))),
437
+ h('div', { className: 'dad-sec-title' }, 'SSH 目标(模块可选发布到任一目标)'),
438
+ sshList.map((s, i) => {
439
+ const t = (sshTest || {})[i]
440
+ return h('div', { key: s.id || i, className: 'dad-sshtarget' },
441
+ h('div', { className: 'dad-row' },
442
+ h(Field, { label: '目标名称' },
443
+ h('input', {
444
+ className: 'dad-input', type: 'text', value: s.name, 'aria-label': 'SSH 目标名称',
445
+ onChange: (ev) => onPatchSsh(i, { name: ev.target.value }),
446
+ })),
447
+ h(Field, { label: 'ID(字母/数字/连字符)' },
448
+ h('input', {
449
+ className: 'dad-input', type: 'text', value: s.id, 'aria-label': 'SSH 目标 ID',
450
+ onChange: (ev) => onPatchSsh(i, { id: ev.target.value }),
451
+ })),
452
+ sshList.length > 1
453
+ ? h('button', {
454
+ type: 'button', className: 'dad-btndel', title: '删除目标', 'aria-label': '删除 SSH 目标',
455
+ onClick: () => onDeleteSsh(i),
456
+ }, '×')
457
+ : null),
458
+ h('div', { className: 'dad-row' },
459
+ h(Field, { label: 'Host' },
460
+ h('input', {
461
+ className: 'dad-input', type: 'text', value: s.host, 'aria-label': 'SSH Host',
462
+ onChange: (ev) => onPatchSsh(i, { host: ev.target.value }),
463
+ })),
464
+ h(Field, { label: 'Port' },
465
+ h('input', {
466
+ className: 'dad-input', type: 'number', value: s.port, 'aria-label': 'SSH Port',
467
+ onChange: (ev) => onPatchSsh(i, { port: ev.target.value }),
468
+ })),
469
+ h(Field, { label: 'User' },
470
+ h('input', {
471
+ className: 'dad-input', type: 'text', value: s.user, 'aria-label': 'SSH User',
472
+ onChange: (ev) => onPatchSsh(i, { user: ev.target.value }),
473
+ }))),
474
+ h('div', { className: 'dad-row' },
475
+ h(Field, { label: '密码(留空=ssh-agent/密钥)' },
476
+ h('input', {
477
+ className: 'dad-input', type: 'password', value: s.password, 'aria-label': 'SSH 密码',
478
+ autoComplete: 'new-password',
479
+ onChange: (ev) => onPatchSsh(i, { password: ev.target.value }),
480
+ })),
481
+ h(Field, { label: '私钥路径(留空=默认密钥/agent,优先于选项里的 -i)', wide: true },
482
+ h('div', { className: 'dad-inline' },
483
+ h('input', {
484
+ className: 'dad-input', type: 'text', value: s.keyFile || '', 'aria-label': '私钥路径',
485
+ onChange: (ev) => onPatchSsh(i, { keyFile: ev.target.value }),
486
+ }),
487
+ h('button', { type: 'button', className: 'dad-btn', onClick: () => onBrowseKey(i) }, '浏览…')))),
488
+ h('div', { className: 'dad-row' },
489
+ h(Field, { label: '自定义 ssh 选项(如 -o StrictHostKeyChecking=no)', wide: true },
490
+ h('input', {
491
+ className: 'dad-input', type: 'text', value: s.options, 'aria-label': '自定义 ssh 选项',
492
+ onChange: (ev) => onPatchSsh(i, { options: ev.target.value }),
493
+ }))),
494
+ h('div', { className: 'dad-inline' },
495
+ h('button', {
496
+ type: 'button', className: 'dad-btn', onClick: () => onTestSsh(i),
497
+ disabled: t && t.state === 'run',
498
+ }, t && t.state === 'run' ? '测试中…' : '测试连接'),
499
+ t && t.state !== 'run'
500
+ ? h('span', { className: 'dad-msg ' + (t.state === 'ok' ? 'ok' : 'err') }, t.text)
501
+ : null))
502
+ }),
384
503
  h('div', { className: 'dad-row' },
385
- h(Field, { label: '密码(留空=ssh-agent/密钥)' },
386
- h('input', {
387
- className: 'dad-input', type: 'password', value: ssh.password, 'aria-label': 'SSH 密码',
388
- autoComplete: 'new-password',
389
- onChange: (ev) => onPatchSsh({ password: ev.target.value }),
390
- })),
391
- h(Field, { label: '自定义 ssh 选项(如 -o StrictHostKeyChecking=no)', wide: true },
392
- h('input', {
393
- className: 'dad-input', type: 'text', value: ssh.options, 'aria-label': '自定义 ssh 选项',
394
- onChange: (ev) => onPatchSsh({ options: ev.target.value }),
395
- }))),
504
+ h('button', { type: 'button', className: 'dad-btn', onClick: onAddSsh }, '+ 添加 SSH 目标')),
396
505
  h('div', { className: 'dad-hint' },
397
- ssh.password
398
- ? '已配置密码,部署时将使用 sshpass(密码不会写入日志)'
399
- : '密码留空时使用本机 ssh-agent / 密钥认证')),
506
+ '密码留空时使用本机 ssh-agent / 密钥认证;密码只经 sshpass 环境变量传递,不会写入日志')),
400
507
  h('div', { className: 'dad-sec' },
401
508
  h('div', { className: 'dad-sec-title' }, '构建 / 发布模块'),
402
509
  env.modules.length === 0 ? h('div', { className: 'dad-hint' }, '(暂无模块)') : null,
403
510
  env.modules.map((m, i) => h(ModuleCard, {
404
- key: i, mod: m,
511
+ key: i, mod: m, sshs: sshList,
405
512
  onChange: (field, value) => onPatchModule(i, field, value),
406
513
  onDelete: () => onDeleteModule(i),
407
514
  })),
@@ -468,14 +575,14 @@ window.__ModuleLoader__.load({
468
575
  }, state.confirmLabel || '确定'))))
469
576
  }
470
577
 
471
- function BrowseModal({ state, onClose, onNavigate, onUp, onPick, onToggleHidden }) {
578
+ function BrowseModal({ state, onClose, onNavigate, onUp, onPick, onSelectFile, onToggleHidden }) {
472
579
  return h('div', { className: 'dad-mask', role: 'presentation', onClick: onClose },
473
580
  h('div', {
474
- className: 'dad-modal', role: 'dialog', 'aria-modal': 'true', 'aria-label': '选择目录',
581
+ className: 'dad-modal', role: 'dialog', 'aria-modal': 'true', 'aria-label': state.file ? '选择文件' : '选择目录',
475
582
  onClick: (ev) => ev.stopPropagation(),
476
583
  },
477
584
  h('div', { className: 'dad-modal-head' },
478
- h('strong', null, '选择目录'),
585
+ h('strong', null, state.file ? '选择文件' : '选择目录'),
479
586
  h('label', { className: 'dad-chk dad-autoflex' },
480
587
  h('input', {
481
588
  type: 'checkbox', checked: !!state.hidden,
@@ -504,16 +611,29 @@ window.__ModuleLoader__.load({
504
611
  (state.dirs || []).map((dir) => h('button', {
505
612
  key: dir, type: 'button', className: 'dad-dir',
506
613
  onClick: () => onNavigate(dir),
507
- }, dir)))),
614
+ }, dir))),
615
+ state.file
616
+ ? h('div', { className: 'dad-dirs' },
617
+ (state.files || []).length ? null : h('div', { className: 'dad-hint' }, '(无文件)'),
618
+ (state.files || []).map((f) => h('button', {
619
+ key: f, type: 'button', className: 'dad-dir', 'data-sel': state.sel === f ? '1' : '0',
620
+ onClick: () => onSelectFile(f),
621
+ }, f)))
622
+ : null),
508
623
  h('div', { className: 'dad-modal-foot' },
509
624
  h('button', {
510
625
  type: 'button', className: 'dad-btn', onClick: onUp,
511
626
  disabled: state.loading || !state.path || state.path === '/',
512
627
  }, '上级'),
513
- h('button', {
514
- type: 'button', className: 'dad-btn dad-btn-primary', onClick: onPick,
515
- disabled: state.loading || !state.path,
516
- }, '选择当前目录'))))
628
+ state.file
629
+ ? h('button', {
630
+ type: 'button', className: 'dad-btn dad-btn-primary', onClick: onPick,
631
+ disabled: state.loading || !state.path || !state.sel,
632
+ }, '选择此文件')
633
+ : h('button', {
634
+ type: 'button', className: 'dad-btn dad-btn-primary', onClick: onPick,
635
+ disabled: state.loading || !state.path,
636
+ }, '选择当前目录'))))
517
637
  }
518
638
 
519
639
  // ---------- 主面板 ----------
@@ -533,6 +653,17 @@ window.__ModuleLoader__.load({
533
653
  const [jdkOpen, setJdkOpen] = useState(false)
534
654
  const jdkRef = useRef(null)
535
655
 
656
+ const [nodes, setNodes] = useState([])
657
+ const [nodeErr, setNodeErr] = useState('')
658
+
659
+ const [pms, setPms] = useState([])
660
+ const [pmErr, setPmErr] = useState('')
661
+ const [pmOpen, setPmOpen] = useState(false)
662
+ const pmRef = useRef(null)
663
+ const [sshTest, setSshTest] = useState({}) // { [目标index]: { state: 'run'|'ok'|'err', text } }
664
+ const [nodeOpen, setNodeOpen] = useState(false)
665
+ const nodeRef = useRef(null)
666
+
536
667
  const [browse, setBrowse] = useState(null)
537
668
 
538
669
  const [selected, setSelected] = useState([])
@@ -550,6 +681,7 @@ window.__ModuleLoader__.load({
550
681
  const logRef = useRef(null)
551
682
  const esRef = useRef(null)
552
683
  const importRef = useRef(null)
684
+ const keyPickRef = useRef(null) // 文件浏览弹窗的选中回调(私钥选择用)
553
685
  const dragIdRef = useRef(null)
554
686
  const [draggingId, setDraggingId] = useState('')
555
687
  const [dragOverId, setDragOverId] = useState('')
@@ -679,16 +811,17 @@ window.__ModuleLoader__.load({
679
811
  if (el) el.scrollTop = el.scrollHeight
680
812
  }, [logText])
681
813
 
682
- // JDK 下拉:点击空白处关闭
814
+ // JDK/Node/包管理器 下拉:点击空白处关闭
683
815
  useEffect(() => {
684
- if (!jdkOpen) return undefined
816
+ if (!jdkOpen && !nodeOpen && !pmOpen) return undefined
685
817
  function onDocDown(ev) {
686
- const el = jdkRef.current
687
- if (el && !el.contains(ev.target)) setJdkOpen(false)
818
+ if (jdkOpen && jdkRef.current && !jdkRef.current.contains(ev.target)) setJdkOpen(false)
819
+ if (nodeOpen && nodeRef.current && !nodeRef.current.contains(ev.target)) setNodeOpen(false)
820
+ if (pmOpen && pmRef.current && !pmRef.current.contains(ev.target)) setPmOpen(false)
688
821
  }
689
822
  document.addEventListener('mousedown', onDocDown)
690
823
  return () => document.removeEventListener('mousedown', onDocDown)
691
- }, [jdkOpen])
824
+ }, [jdkOpen, nodeOpen, pmOpen])
692
825
 
693
826
  // ---------- 环境操作 ----------
694
827
 
@@ -700,8 +833,8 @@ window.__ModuleLoader__.load({
700
833
  function addEnv() {
701
834
  const env = {
702
835
  id: newEnvId(envs), name: '新环境', note: '', group: '',
703
- projectPath: defProject, jdkHome: '', expTime: '',
704
- ssh: { host: '', port: 22, user: 'root', password: '', options: '' },
836
+ projectPath: defProject, jdkHome: '', nodeHome: '', nodeOptions: '', pmPath: '', buildEnv: '', expTime: '',
837
+ sshs: [{ id: 'default', name: '默认', host: '', port: 22, user: 'root', password: '', keyFile: '', options: '' }],
705
838
  modules: [],
706
839
  }
707
840
  setDraft(env)
@@ -834,8 +967,43 @@ window.__ModuleLoader__.load({
834
967
  function patchEnv(patch) {
835
968
  setDraft((prev) => (prev ? { ...prev, ...patch } : prev))
836
969
  }
837
- function patchSsh(patch) {
838
- setDraft((prev) => (prev ? { ...prev, ssh: { ...prev.ssh, ...patch } } : prev))
970
+ function patchSsh(index, patch) {
971
+ setDraft((prev) => (prev
972
+ ? { ...prev, sshs: (prev.sshs || []).map((s, i) => (i === index ? { ...s, ...patch } : s)) }
973
+ : prev))
974
+ }
975
+ function addSsh() {
976
+ setDraft((prev) => {
977
+ if (!prev) return prev
978
+ const n = (prev.sshs || []).length + 1
979
+ return {
980
+ ...prev,
981
+ sshs: [...(prev.sshs || []), { id: 'ssh-' + Date.now().toString(36), name: 'SSH ' + n, host: '', port: 22, user: 'root', password: '', keyFile: '', options: '' }],
982
+ }
983
+ })
984
+ }
985
+ function deleteSsh(index) {
986
+ setDraft((prev) => (prev && (prev.sshs || []).length > 1
987
+ ? { ...prev, sshs: prev.sshs.filter((_, i) => i !== index) }
988
+ : prev))
989
+ }
990
+ // 测试连接:直接用表单当前值(无需先保存环境)
991
+ function runSshTest(index) {
992
+ const t = draft && draft.sshs && draft.sshs[index]
993
+ if (!t) return
994
+ setSshTest((p) => ({ ...p, [index]: { state: 'run', text: '测试中…' } }))
995
+ rpc('sshTest', {
996
+ target: {
997
+ host: (t.host || '').trim(), port: Number(t.port) || 22,
998
+ user: (t.user || '').trim() || 'root', password: t.password || '',
999
+ keyFile: (t.keyFile || '').trim(), options: (t.options || '').trim(),
1000
+ },
1001
+ })
1002
+ .then((r) => setSshTest((p) => ({
1003
+ ...p,
1004
+ [index]: r && r.ok ? { state: 'ok', text: '连接成功(' + r.ms + ' ms)' } : { state: 'err', text: '失败:' + ((r && r.error) || '未知错误') },
1005
+ })))
1006
+ .catch((e) => setSshTest((p) => ({ ...p, [index]: { state: 'err', text: '失败:' + String(e?.message || e) } })))
839
1007
  }
840
1008
  function patchModule(index, field, value) {
841
1009
  setDraft((prev) => {
@@ -877,22 +1045,28 @@ window.__ModuleLoader__.load({
877
1045
  group: (draft.group || '').trim(),
878
1046
  projectPath: (draft.projectPath || '').trim() || defProject,
879
1047
  jdkHome: (draft.jdkHome || '').trim(),
1048
+ nodeHome: (draft.nodeHome || '').trim(),
1049
+ nodeOptions: (draft.nodeOptions || '').trim(),
1050
+ pmPath: (draft.pmPath || '').trim(),
1051
+ buildEnv: draft.buildEnv || '',
880
1052
  expTime: draft.expTime || '',
881
- ssh: {
882
- host: (draft.ssh.host || '').trim(),
883
- port: Number(draft.ssh.port) || 22,
884
- user: (draft.ssh.user || '').trim() || 'root',
885
- password: draft.ssh.password || '',
886
- options: (draft.ssh.options || '').trim(),
887
- },
1053
+ sshs: (draft.sshs || []).map((s) => ({
1054
+ id: (s.id || '').trim(),
1055
+ name: (s.name || '').trim(),
1056
+ host: (s.host || '').trim(),
1057
+ port: Number(s.port) || 22,
1058
+ user: (s.user || '').trim() || 'root',
1059
+ password: s.password || '',
1060
+ keyFile: (s.keyFile || '').trim(),
1061
+ options: (s.options || '').trim(),
1062
+ })),
888
1063
  modules: draft.modules.map((m) => ({
889
1064
  id: (m.id || '').trim(),
890
1065
  name: (m.name || '').trim() || '新模块',
891
1066
  kind: m.kind === 'server' ? 'server' : 'front',
892
1067
  enabled: !!m.enabled,
893
- gradleTask: (m.gradleTask || '').trim(),
894
- key: (m.key || '').trim(),
895
- extraArgs: (m.extraArgs || '').trim(),
1068
+ buildCmd: (m.buildCmd || '').trim(),
1069
+ sshId: (m.sshId || '').trim(),
896
1070
  localPath: (m.localPath || '').trim(),
897
1071
  remotePath: (m.remotePath || '').trim(),
898
1072
  postCommand: (m.postCommand || '').trim(),
@@ -914,30 +1088,36 @@ window.__ModuleLoader__.load({
914
1088
 
915
1089
  // ---------- 路径浏览 ----------
916
1090
 
917
- function loadBrowse(path, hidden) {
1091
+ function loadBrowse(path, hidden, file) {
918
1092
  setBrowse((prev) => ({
919
1093
  path: prev ? prev.path : '',
920
1094
  parent: prev ? prev.parent : '',
921
1095
  dirs: prev ? prev.dirs : [],
922
1096
  worktrees: prev ? prev.worktrees : [],
1097
+ files: prev && prev.file === !!file ? prev.files : [],
1098
+ file: !!file, sel: '',
923
1099
  hidden: !!hidden, loading: true, error: '',
924
1100
  }))
925
1101
  const args = {}
926
1102
  if (path) args.path = path
927
1103
  if (hidden) args.hidden = true
1104
+ if (file) args.file = true
928
1105
  rpc('browse', args).then((d) => {
929
1106
  setBrowse({
930
1107
  path: (d && d.path) || '',
931
1108
  parent: (d && d.parent) || parentPath((d && d.path) || ''),
932
1109
  dirs: Array.isArray(d && d.dirs) ? d.dirs : [],
1110
+ files: Array.isArray(d && d.files) ? d.files : [],
933
1111
  worktrees: Array.isArray(d && d.worktrees) ? d.worktrees : [],
1112
+ file: !!file, sel: '',
934
1113
  hidden: !!hidden, loading: false, error: '',
935
1114
  })
936
1115
  }).catch((error) => {
937
1116
  setBrowse((prev) => ({
938
1117
  path: prev ? prev.path : '',
939
1118
  parent: prev ? prev.parent : '',
940
- dirs: [], worktrees: [],
1119
+ dirs: [], worktrees: [], files: [],
1120
+ file: !!file, sel: '',
941
1121
  hidden: !!hidden, loading: false,
942
1122
  error: error.message || String(error),
943
1123
  }))
@@ -945,24 +1125,39 @@ window.__ModuleLoader__.load({
945
1125
  }
946
1126
  function openBrowse() {
947
1127
  if (!draft) return
948
- loadBrowse((draft.projectPath || '').trim(), false)
1128
+ keyPickRef.current = null
1129
+ loadBrowse((draft.projectPath || '').trim(), false, false)
1130
+ }
1131
+ // 选择私钥文件:从当前 keyFile 所在目录(或 ~/.ssh)起步,默认显示隐藏文件(密钥多为点开头无,但 .ssh 是隐藏目录)
1132
+ function openKeyBrowse(index) {
1133
+ const t = draft && draft.sshs && draft.sshs[index]
1134
+ keyPickRef.current = (p) => patchSsh(index, { keyFile: p })
1135
+ loadBrowse(t && t.keyFile ? parentPath(t.keyFile) : '~/.ssh', true, true)
949
1136
  }
950
1137
  function navigateBrowse(target) {
951
1138
  if (!browse) return
952
- loadBrowse(joinPath(browse.path, target), browse.hidden)
1139
+ loadBrowse(joinPath(browse.path, target), browse.hidden, browse.file)
953
1140
  }
954
1141
  function browseUp() {
955
1142
  if (!browse) return
956
- loadBrowse(browse.parent || parentPath(browse.path), browse.hidden)
1143
+ loadBrowse(browse.parent || parentPath(browse.path), browse.hidden, browse.file)
957
1144
  }
958
1145
  function browsePick() {
959
1146
  if (!browse || !browse.path) return
960
- patchEnv({ projectPath: browse.path })
1147
+ if (browse.file) {
1148
+ if (!browse.sel) return
1149
+ if (keyPickRef.current) keyPickRef.current(joinPath(browse.path, browse.sel))
1150
+ } else {
1151
+ patchEnv({ projectPath: browse.path })
1152
+ }
961
1153
  setBrowse(null)
962
1154
  }
1155
+ function browseSelectFile(name) {
1156
+ setBrowse((prev) => (prev ? { ...prev, sel: name } : prev))
1157
+ }
963
1158
  function browseToggleHidden(hidden) {
964
1159
  if (!browse) return
965
- loadBrowse(browse.path, hidden)
1160
+ loadBrowse(browse.path, hidden, browse.file)
966
1161
  }
967
1162
 
968
1163
  // ---------- JDK ----------
@@ -978,6 +1173,32 @@ window.__ModuleLoader__.load({
978
1173
  setJdkOpen(false)
979
1174
  }
980
1175
 
1176
+ // ---------- Node ----------
1177
+
1178
+ function openNode() {
1179
+ setNodeOpen(true)
1180
+ if (!nodes.length) {
1181
+ rpc('nodes').then((list) => setNodes(Array.isArray(list) ? list : [])).catch((e) => setNodeErr(String(e?.message || e)))
1182
+ }
1183
+ }
1184
+ function pickNode(path) {
1185
+ patchEnv({ nodeHome: path })
1186
+ setNodeOpen(false)
1187
+ }
1188
+
1189
+ // ---------- 包管理器 ----------
1190
+
1191
+ function openPm() {
1192
+ setPmOpen(true)
1193
+ if (!pms.length) {
1194
+ rpc('packageManagers').then((list) => setPms(Array.isArray(list) ? list : [])).catch((e) => setPmErr(String(e?.message || e)))
1195
+ }
1196
+ }
1197
+ function pickPm(path) {
1198
+ patchEnv({ pmPath: path })
1199
+ setPmOpen(false)
1200
+ }
1201
+
981
1202
  // ---------- 部署 ----------
982
1203
 
983
1204
  function toggleSel(id) {
@@ -1013,11 +1234,13 @@ window.__ModuleLoader__.load({
1013
1234
  lines.push('')
1014
1235
  lines.push('将依次执行:')
1015
1236
  for (const m of mods) {
1016
- const exp = m.kind === 'server' && env.expTime ? ' -PexpTime=' + env.expTime : ''
1237
+ // expTime 追加规则与服务端一致:仅首 token 是 gradle 的 server 模块
1238
+ const exp = m.kind === 'server' && env.expTime && ((m.buildCmd || '').trim().split(/\s+/)[0] || '').includes('gradle') ? ' -PexpTime=' + env.expTime : ''
1017
1239
  lines.push('')
1018
1240
  lines.push('[' + (m.name || m.id) + ']')
1019
- lines.push(' 构建: gradlew ' + m.gradleTask + (m.key ? ' -Pkey=' + m.key : '') + (m.extraArgs ? ' ' + m.extraArgs : '') + exp)
1020
- lines.push(' rsync: ' + m.localPath + ' → ' + env.ssh.user + '@' + env.ssh.host + ':' + m.remotePath)
1241
+ const target = targetOf(env, m)
1242
+ lines.push(' 构建: ' + m.buildCmd + exp)
1243
+ lines.push(' rsync: ' + m.localPath + ' → ' + (target.user || 'root') + '@' + (target.host || '?') + ':' + m.remotePath + (env.sshs && env.sshs.length > 1 ? '(目标: ' + (target.name || target.id) + ')' : ''))
1021
1244
  if (m.postCommand) lines.push(' ssh: ' + m.postCommand)
1022
1245
  }
1023
1246
  return lines.join('\n')
@@ -1026,8 +1249,12 @@ window.__ModuleLoader__.load({
1026
1249
  function requestDeploy() {
1027
1250
  if (!draft || !selected.length) return
1028
1251
  if (isNew) { setDeployState({ running: false, text: '请先保存环境再执行部署', kind: 'err' }); return }
1029
- if (!draft.ssh.host || !draft.ssh.user) {
1030
- setDeployState({ running: false, text: '该环境 SSH 配置不完整,请先保存 SSH 配置', kind: 'err' })
1252
+ const deployTargets = [...new Set(selected.map((id) => {
1253
+ const m = moduleById[id]
1254
+ return m && targetOf(draft, m)
1255
+ }).filter(Boolean))]
1256
+ if (!deployTargets.length || deployTargets.some((t) => !t.host || !t.user)) {
1257
+ setDeployState({ running: false, text: '所选模块的 SSH 目标配置不完整,请先保存 SSH 配置', kind: 'err' })
1031
1258
  return
1032
1259
  }
1033
1260
  const mods = selected.map((id) => moduleById[id]).filter((m) => m && m.enabled)
@@ -1124,7 +1351,7 @@ window.__ModuleLoader__.load({
1124
1351
  const canRun = !!draft && !isNew && selected.length > 0 && !deployState.running
1125
1352
 
1126
1353
  // 标题行:对象名 + 一句「现状」(ID / 最近部署结果 / SSH 目标),全部来自已有数据
1127
- const selSsh = draft && draft.ssh ? draft.ssh : {}
1354
+ const selSsh = draft && draft.sshs && draft.sshs[0] ? draft.sshs[0] : {}
1128
1355
  const selSshText = selSsh.host ? (selSsh.user || 'root') + '@' + selSsh.host + ':' + (selSsh.port || 22) : ''
1129
1356
  const selBuild = draft ? lastBuild[draft.id] : null
1130
1357
  let titleSub = '选择左侧环境,或新增一个环境'
@@ -1283,13 +1510,16 @@ window.__ModuleLoader__.load({
1283
1510
  onToggle: () => setFormOpen((v) => !v),
1284
1511
  onPatch: patchEnv, onPatchSsh: patchSsh, onPatchModule: patchModule,
1285
1512
  onAddModule: addModule, onDeleteModule: askDeleteModule,
1286
- onSave: saveEnv, onDelete: deleteCurrentEnv, onBrowse: openBrowse,
1513
+ onAddSsh: addSsh, onDeleteSsh: deleteSsh, onTestSsh: runSshTest, sshTest,
1514
+ onSave: saveEnv, onDelete: deleteCurrentEnv, onBrowse: openBrowse, onBrowseKey: openKeyBrowse,
1287
1515
  jdks, jdkOpen, jdkRef, onJdkFocus: openJdk, onJdkPick: pickJdk,
1516
+ nodes, nodeOpen, nodeRef, nodeErr, onNodeFocus: openNode, onNodePick: pickNode,
1517
+ pms, pmOpen, pmRef, pmErr, onPmFocus: openPm, onPmPick: pickPm,
1288
1518
  })),
1289
1519
  confirmState ? h(ConfirmDialog, { state: confirmState, onClose: () => setConfirmState(null) }) : null,
1290
1520
  browse ? h(BrowseModal, {
1291
1521
  state: browse, onClose: () => setBrowse(null),
1292
- onNavigate: navigateBrowse, onUp: browseUp, onPick: browsePick,
1522
+ onNavigate: navigateBrowse, onUp: browseUp, onPick: browsePick, onSelectFile: browseSelectFile,
1293
1523
  onToggleHidden: browseToggleHidden,
1294
1524
  }) : null)
1295
1525
  }
@@ -1376,6 +1606,7 @@ window.__ModuleLoader__.load({
1376
1606
  '.dad-btn-danger-solid:hover:not(:disabled){background:var(--dsw-alias-state-error-primary)}',
1377
1607
  '.dad-formfoot{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-top:4px}',
1378
1608
  '.dad-mcard{position:relative;min-width:0;padding:10px 12px;margin-bottom:10px;border:.5px solid var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-lg);background:var(--dsw-alias-bg-layer-2)}',
1609
+ '.dad-sshtarget{position:relative;min-width:0;padding:8px 10px 10px;margin-bottom:8px;border:.5px solid var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-lg);background:var(--dsw-alias-bg-layer-1)}',
1379
1610
  '.dad-mcard[data-off="1"]{opacity:.6}',
1380
1611
  '.dad-btndel{position:absolute;top:4px;right:6px;border:0;background:none;color:var(--dsw-alias-label-tertiary);font-size:14px;line-height:1;padding:2px 4px;cursor:pointer}',
1381
1612
  '.dad-btndel:hover{color:var(--dsw-alias-state-error-primary)}',