dsh-app-deploy 0.2.0 → 0.3.1

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
@@ -74,7 +74,7 @@ window.__ModuleLoader__.load({
74
74
  while (modules.some((m) => m.id === 'mod' + n)) n++
75
75
  return {
76
76
  id: 'mod' + n, name: '新模块', kind: 'front', enabled: true,
77
- gradleTask: '', key: '', extraArgs: '', localPath: '', remotePath: '',
77
+ buildCmd: '', sshId: '', localPath: '', remotePath: '',
78
78
  postCommand: '', healthCheckUrl: '', frontEnv: '',
79
79
  }
80
80
  }
@@ -82,14 +82,21 @@ window.__ModuleLoader__.load({
82
82
  return {
83
83
  id: m.id || '', name: m.name || '', kind: m.kind === 'server' ? 'server' : 'front',
84
84
  enabled: m.enabled !== false,
85
- gradleTask: m.gradleTask || '', key: m.key || '', extraArgs: m.extraArgs || '',
85
+ buildCmd: m.buildCmd || '', sshId: m.sshId || '',
86
86
  localPath: m.localPath || '', remotePath: m.remotePath || '', postCommand: m.postCommand || '',
87
87
  healthCheckUrl: m.healthCheckUrl || '', frontEnv: m.frontEnv || '',
88
88
  }
89
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
+
90
96
  function normalizeEnv(env) {
91
97
  const e = clone(env || {})
92
- const ssh = e.ssh || {}
98
+ // 旧格式 env.ssh 单对象自动迁移为 sshs 数组
99
+ const rawSshs = Array.isArray(e.sshs) && e.sshs.length ? e.sshs : (e.ssh ? [e.ssh] : [])
93
100
  return {
94
101
  id: e.id || '',
95
102
  name: e.name || '',
@@ -97,11 +104,17 @@ window.__ModuleLoader__.load({
97
104
  group: e.group || '',
98
105
  projectPath: e.projectPath || '',
99
106
  jdkHome: e.jdkHome || '',
107
+ nodeHome: e.nodeHome || '',
108
+ nodeOptions: e.nodeOptions || '',
109
+ pmPath: e.pmPath || '',
110
+ buildEnv: e.buildEnv || '',
100
111
  expTime: e.expTime || '',
101
- ssh: {
102
- host: ssh.host || '', port: Number(ssh.port) || 22, user: ssh.user || 'root',
103
- password: ssh.password || '', options: ssh.options || '',
104
- },
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
+ })),
105
118
  modules: (e.modules || []).map(normalizeModule),
106
119
  }
107
120
  }
@@ -127,9 +140,40 @@ window.__ModuleLoader__.load({
127
140
  children)
128
141
  }
129
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
+
130
170
  function EnvCard({ env, selected, lastBuild, onSelect, onCopy, onDelete }) {
131
171
  const lb = lastBuild[env.id]
132
- 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 未配置'
133
177
  return h('div', {
134
178
  className: 'dad-env',
135
179
  'data-sel': selected ? '1' : '0',
@@ -142,7 +186,7 @@ window.__ModuleLoader__.load({
142
186
  h('i', { className: 'dad-dot' + (lb ? ' st-' + lb.status : ''), 'aria-hidden': true }),
143
187
  env.name || env.id),
144
188
  h('span', { className: 'dad-sub' }, env.id + (env.note ? ' · ' + env.note : '')),
145
- h('span', { className: 'dad-sub' }, ssh.host ? (ssh.user || 'root') + '@' + ssh.host + ':' + (ssh.port || 22) : 'SSH 未配置'),
189
+ h('span', { className: 'dad-sub' }, sshText),
146
190
  h('span', { className: 'dad-sub dad-build' + (lb ? ' ' + lb.status : '') },
147
191
  h('i', { className: 'dad-dot', 'aria-hidden': true }),
148
192
  lb ? '最后构建: ' + fmtTime(lb.time) + '(' + (HSTATUS[lb.status] || lb.status) + ')' : '最后构建: 从未')),
@@ -205,11 +249,11 @@ window.__ModuleLoader__.load({
205
249
  nodes))
206
250
  }
207
251
 
208
- function ModuleCard({ mod, onChange, onDelete }) {
252
+ function ModuleCard({ mod, onChange, onDelete, sshs }) {
209
253
  const envFile = mod.id === 'bsFront' ? 'client/client-front/.env'
210
254
  : mod.id === 'adminFront' ? 'admin/admin-front/.env' : ''
211
255
  const feLabel = 'frontEnv(KEY=VALUE 多行,构建时注入'
212
- + (envFile ? ' ' + envFile + (mod.key ? '.' + mod.key : '') : '') + ')'
256
+ + (envFile ? ' ' + envFile : '') + ')'
213
257
  return h('div', { className: 'dad-mcard', 'data-off': mod.enabled ? '0' : '1' },
214
258
  h('button', {
215
259
  type: 'button', className: 'dad-btndel', title: '删除模块', 'aria-label': '删除模块',
@@ -239,21 +283,19 @@ window.__ModuleLoader__.load({
239
283
  onChange: (ev) => onChange('enabled', ev.target.checked),
240
284
  }), '启用')),
241
285
  h('div', { className: 'dad-row' },
242
- h(Field, { label: 'gradle 任务' },
243
- h('input', {
244
- className: 'dad-input', type: 'text', value: mod.gradleTask, 'aria-label': 'gradle 任务',
245
- onChange: (ev) => onChange('gradleTask', ev.target.value),
246
- })),
247
- h(Field, { label: '-Pkey(可空)' },
248
- h('input', {
249
- className: 'dad-input', type: 'text', value: mod.key, 'aria-label': '-Pkey',
250
- onChange: (ev) => onChange('key', ev.target.value),
251
- })),
252
- h(Field, { label: '额外参数(如 -PexpTime=…)' },
253
- h('input', {
254
- className: 'dad-input', type: 'text', value: mod.extraArgs, 'aria-label': '额外参数',
255
- onChange: (ev) => onChange('extraArgs', ev.target.value),
256
- }))),
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
+ })))),
257
299
  h('div', { className: 'dad-row' },
258
300
  h(Field, { label: '本地产物路径' },
259
301
  h('input', {
@@ -264,7 +306,13 @@ window.__ModuleLoader__.load({
264
306
  h('input', {
265
307
  className: 'dad-input', type: 'text', value: mod.remotePath, 'aria-label': '远程目标路径',
266
308
  onChange: (ev) => onChange('remotePath', ev.target.value),
267
- }))),
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))))),
268
316
  h('div', { className: 'dad-row' },
269
317
  h(Field, { label: '部署后远程命令(可空)' },
270
318
  h('input', {
@@ -292,12 +340,15 @@ window.__ModuleLoader__.load({
292
340
  function EnvForm({
293
341
  env, isNew, groups, message, open, onToggle,
294
342
  onPatch, onPatchSsh, onPatchModule, onAddModule, onDeleteModule,
295
- onSave, onDelete, onBrowse,
343
+ onSave, onDelete, onBrowse, onBrowseKey,
344
+ onAddSsh, onDeleteSsh, onTestSsh, sshTest,
296
345
  jdks, jdkOpen, jdkRef, onJdkFocus, onJdkPick,
346
+ nodes, nodeOpen, nodeRef, nodeErr, onNodeFocus, onNodePick,
347
+ pms, pmOpen, pmRef, pmErr, onPmFocus, onPmPick,
297
348
  }) {
298
349
  const title = env ? '环境配置' : '选择左侧环境进行编辑'
299
350
  const groupOptions = groups.map((g) => h('option', { key: g, value: g }))
300
- const ssh = env ? env.ssh : { host: '', port: 22, user: '', password: '', options: '' }
351
+ const sshList = env ? (env.sshs || []) : []
301
352
  return h('div', { className: 'dad-card' },
302
353
  h('div', { className: 'dad-card-head' },
303
354
  h('span', { className: 'dad-card-title', title }, title),
@@ -345,20 +396,37 @@ window.__ModuleLoader__.load({
345
396
  }),
346
397
  h('button', { type: 'button', className: 'dad-btn', onClick: onBrowse }, '浏览…'))),
347
398
  h(Field, { label: 'JDK 路径(JAVA_HOME,可留空)', wide: true },
348
- h('div', { className: 'dad-jdkwrap', ref: jdkRef },
349
- h('input', {
350
- className: 'dad-input', type: 'text', value: env.jdkHome, 'aria-label': 'JDK 路径',
351
- onFocus: onJdkFocus, onChange: (ev) => onPatch({ jdkHome: ev.target.value }),
352
- }),
353
- jdkOpen ? h('div', { className: 'dad-jdkdd' },
354
- jdks.length
355
- ? jdks.map((j) => h('button', {
356
- key: j.path, type: 'button', className: 'dad-jitem',
357
- onClick: () => onJdkPick(j.path),
358
- },
359
- h('span', { className: 'dad-jlbl' }, j.label),
360
- h('span', { className: 'dad-jfull' }, j.path)))
361
- : 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
+ }))),
362
430
  h('div', { className: 'dad-row' },
363
431
  h(Field, { label: '过期时间 expTime(YYYY-MM-DD,可留空)' },
364
432
  h('input', {
@@ -366,44 +434,81 @@ window.__ModuleLoader__.load({
366
434
  onChange: (ev) => onPatch({ expTime: ev.target.value }),
367
435
  })))),
368
436
  h('div', { className: 'dad-sec' },
369
- h('div', { className: 'dad-sec-title' }, 'SSH'),
370
- h('div', { className: 'dad-row' },
371
- h(Field, { label: 'Host' },
372
- h('input', {
373
- className: 'dad-input', type: 'text', value: ssh.host, 'aria-label': 'SSH Host',
374
- onChange: (ev) => onPatchSsh({ host: ev.target.value }),
375
- })),
376
- h(Field, { label: 'Port' },
377
- h('input', {
378
- className: 'dad-input', type: 'number', value: ssh.port, 'aria-label': 'SSH Port',
379
- onChange: (ev) => onPatchSsh({ port: ev.target.value }),
380
- })),
381
- h(Field, { label: 'User' },
382
- h('input', {
383
- className: 'dad-input', type: 'text', value: ssh.user, 'aria-label': 'SSH User',
384
- onChange: (ev) => onPatchSsh({ user: ev.target.value }),
385
- }))),
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
+ }),
386
503
  h('div', { className: 'dad-row' },
387
- h(Field, { label: '密码(留空=ssh-agent/密钥)' },
388
- h('input', {
389
- className: 'dad-input', type: 'password', value: ssh.password, 'aria-label': 'SSH 密码',
390
- autoComplete: 'new-password',
391
- onChange: (ev) => onPatchSsh({ password: ev.target.value }),
392
- })),
393
- h(Field, { label: '自定义 ssh 选项(如 -o StrictHostKeyChecking=no)', wide: true },
394
- h('input', {
395
- className: 'dad-input', type: 'text', value: ssh.options, 'aria-label': '自定义 ssh 选项',
396
- onChange: (ev) => onPatchSsh({ options: ev.target.value }),
397
- }))),
504
+ h('button', { type: 'button', className: 'dad-btn', onClick: onAddSsh }, '+ 添加 SSH 目标')),
398
505
  h('div', { className: 'dad-hint' },
399
- ssh.password
400
- ? '已配置密码,部署时将使用 sshpass(密码不会写入日志)'
401
- : '密码留空时使用本机 ssh-agent / 密钥认证')),
506
+ '密码留空时使用本机 ssh-agent / 密钥认证;密码只经 sshpass 环境变量传递,不会写入日志')),
402
507
  h('div', { className: 'dad-sec' },
403
508
  h('div', { className: 'dad-sec-title' }, '构建 / 发布模块'),
404
509
  env.modules.length === 0 ? h('div', { className: 'dad-hint' }, '(暂无模块)') : null,
405
510
  env.modules.map((m, i) => h(ModuleCard, {
406
- key: i, mod: m,
511
+ key: i, mod: m, sshs: sshList,
407
512
  onChange: (field, value) => onPatchModule(i, field, value),
408
513
  onDelete: () => onDeleteModule(i),
409
514
  })),
@@ -470,14 +575,14 @@ window.__ModuleLoader__.load({
470
575
  }, state.confirmLabel || '确定'))))
471
576
  }
472
577
 
473
- function BrowseModal({ state, onClose, onNavigate, onUp, onPick, onToggleHidden }) {
578
+ function BrowseModal({ state, onClose, onNavigate, onUp, onPick, onSelectFile, onToggleHidden }) {
474
579
  return h('div', { className: 'dad-mask', role: 'presentation', onClick: onClose },
475
580
  h('div', {
476
- className: 'dad-modal', role: 'dialog', 'aria-modal': 'true', 'aria-label': '选择目录',
581
+ className: 'dad-modal', role: 'dialog', 'aria-modal': 'true', 'aria-label': state.file ? '选择文件' : '选择目录',
477
582
  onClick: (ev) => ev.stopPropagation(),
478
583
  },
479
584
  h('div', { className: 'dad-modal-head' },
480
- h('strong', null, '选择目录'),
585
+ h('strong', null, state.file ? '选择文件' : '选择目录'),
481
586
  h('label', { className: 'dad-chk dad-autoflex' },
482
587
  h('input', {
483
588
  type: 'checkbox', checked: !!state.hidden,
@@ -506,16 +611,29 @@ window.__ModuleLoader__.load({
506
611
  (state.dirs || []).map((dir) => h('button', {
507
612
  key: dir, type: 'button', className: 'dad-dir',
508
613
  onClick: () => onNavigate(dir),
509
- }, 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),
510
623
  h('div', { className: 'dad-modal-foot' },
511
624
  h('button', {
512
625
  type: 'button', className: 'dad-btn', onClick: onUp,
513
626
  disabled: state.loading || !state.path || state.path === '/',
514
627
  }, '上级'),
515
- h('button', {
516
- type: 'button', className: 'dad-btn dad-btn-primary', onClick: onPick,
517
- disabled: state.loading || !state.path,
518
- }, '选择当前目录'))))
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
+ }, '选择当前目录'))))
519
637
  }
520
638
 
521
639
  // ---------- 主面板 ----------
@@ -535,6 +653,17 @@ window.__ModuleLoader__.load({
535
653
  const [jdkOpen, setJdkOpen] = useState(false)
536
654
  const jdkRef = useRef(null)
537
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
+
538
667
  const [browse, setBrowse] = useState(null)
539
668
 
540
669
  const [selected, setSelected] = useState([])
@@ -552,6 +681,7 @@ window.__ModuleLoader__.load({
552
681
  const logRef = useRef(null)
553
682
  const esRef = useRef(null)
554
683
  const importRef = useRef(null)
684
+ const keyPickRef = useRef(null) // 文件浏览弹窗的选中回调(私钥选择用)
555
685
  const dragIdRef = useRef(null)
556
686
  const [draggingId, setDraggingId] = useState('')
557
687
  const [dragOverId, setDragOverId] = useState('')
@@ -681,16 +811,17 @@ window.__ModuleLoader__.load({
681
811
  if (el) el.scrollTop = el.scrollHeight
682
812
  }, [logText])
683
813
 
684
- // JDK 下拉:点击空白处关闭
814
+ // JDK/Node/包管理器 下拉:点击空白处关闭
685
815
  useEffect(() => {
686
- if (!jdkOpen) return undefined
816
+ if (!jdkOpen && !nodeOpen && !pmOpen) return undefined
687
817
  function onDocDown(ev) {
688
- const el = jdkRef.current
689
- 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)
690
821
  }
691
822
  document.addEventListener('mousedown', onDocDown)
692
823
  return () => document.removeEventListener('mousedown', onDocDown)
693
- }, [jdkOpen])
824
+ }, [jdkOpen, nodeOpen, pmOpen])
694
825
 
695
826
  // ---------- 环境操作 ----------
696
827
 
@@ -702,8 +833,8 @@ window.__ModuleLoader__.load({
702
833
  function addEnv() {
703
834
  const env = {
704
835
  id: newEnvId(envs), name: '新环境', note: '', group: '',
705
- projectPath: defProject, jdkHome: '', expTime: '',
706
- 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: '' }],
707
838
  modules: [],
708
839
  }
709
840
  setDraft(env)
@@ -836,8 +967,43 @@ window.__ModuleLoader__.load({
836
967
  function patchEnv(patch) {
837
968
  setDraft((prev) => (prev ? { ...prev, ...patch } : prev))
838
969
  }
839
- function patchSsh(patch) {
840
- 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) } })))
841
1007
  }
842
1008
  function patchModule(index, field, value) {
843
1009
  setDraft((prev) => {
@@ -879,22 +1045,28 @@ window.__ModuleLoader__.load({
879
1045
  group: (draft.group || '').trim(),
880
1046
  projectPath: (draft.projectPath || '').trim() || defProject,
881
1047
  jdkHome: (draft.jdkHome || '').trim(),
1048
+ nodeHome: (draft.nodeHome || '').trim(),
1049
+ nodeOptions: (draft.nodeOptions || '').trim(),
1050
+ pmPath: (draft.pmPath || '').trim(),
1051
+ buildEnv: draft.buildEnv || '',
882
1052
  expTime: draft.expTime || '',
883
- ssh: {
884
- host: (draft.ssh.host || '').trim(),
885
- port: Number(draft.ssh.port) || 22,
886
- user: (draft.ssh.user || '').trim() || 'root',
887
- password: draft.ssh.password || '',
888
- options: (draft.ssh.options || '').trim(),
889
- },
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
+ })),
890
1063
  modules: draft.modules.map((m) => ({
891
1064
  id: (m.id || '').trim(),
892
1065
  name: (m.name || '').trim() || '新模块',
893
1066
  kind: m.kind === 'server' ? 'server' : 'front',
894
1067
  enabled: !!m.enabled,
895
- gradleTask: (m.gradleTask || '').trim(),
896
- key: (m.key || '').trim(),
897
- extraArgs: (m.extraArgs || '').trim(),
1068
+ buildCmd: (m.buildCmd || '').trim(),
1069
+ sshId: (m.sshId || '').trim(),
898
1070
  localPath: (m.localPath || '').trim(),
899
1071
  remotePath: (m.remotePath || '').trim(),
900
1072
  postCommand: (m.postCommand || '').trim(),
@@ -916,30 +1088,36 @@ window.__ModuleLoader__.load({
916
1088
 
917
1089
  // ---------- 路径浏览 ----------
918
1090
 
919
- function loadBrowse(path, hidden) {
1091
+ function loadBrowse(path, hidden, file) {
920
1092
  setBrowse((prev) => ({
921
1093
  path: prev ? prev.path : '',
922
1094
  parent: prev ? prev.parent : '',
923
1095
  dirs: prev ? prev.dirs : [],
924
1096
  worktrees: prev ? prev.worktrees : [],
1097
+ files: prev && prev.file === !!file ? prev.files : [],
1098
+ file: !!file, sel: '',
925
1099
  hidden: !!hidden, loading: true, error: '',
926
1100
  }))
927
1101
  const args = {}
928
1102
  if (path) args.path = path
929
1103
  if (hidden) args.hidden = true
1104
+ if (file) args.file = true
930
1105
  rpc('browse', args).then((d) => {
931
1106
  setBrowse({
932
1107
  path: (d && d.path) || '',
933
1108
  parent: (d && d.parent) || parentPath((d && d.path) || ''),
934
1109
  dirs: Array.isArray(d && d.dirs) ? d.dirs : [],
1110
+ files: Array.isArray(d && d.files) ? d.files : [],
935
1111
  worktrees: Array.isArray(d && d.worktrees) ? d.worktrees : [],
1112
+ file: !!file, sel: '',
936
1113
  hidden: !!hidden, loading: false, error: '',
937
1114
  })
938
1115
  }).catch((error) => {
939
1116
  setBrowse((prev) => ({
940
1117
  path: prev ? prev.path : '',
941
1118
  parent: prev ? prev.parent : '',
942
- dirs: [], worktrees: [],
1119
+ dirs: [], worktrees: [], files: [],
1120
+ file: !!file, sel: '',
943
1121
  hidden: !!hidden, loading: false,
944
1122
  error: error.message || String(error),
945
1123
  }))
@@ -947,24 +1125,39 @@ window.__ModuleLoader__.load({
947
1125
  }
948
1126
  function openBrowse() {
949
1127
  if (!draft) return
950
- 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)
951
1136
  }
952
1137
  function navigateBrowse(target) {
953
1138
  if (!browse) return
954
- loadBrowse(joinPath(browse.path, target), browse.hidden)
1139
+ loadBrowse(joinPath(browse.path, target), browse.hidden, browse.file)
955
1140
  }
956
1141
  function browseUp() {
957
1142
  if (!browse) return
958
- loadBrowse(browse.parent || parentPath(browse.path), browse.hidden)
1143
+ loadBrowse(browse.parent || parentPath(browse.path), browse.hidden, browse.file)
959
1144
  }
960
1145
  function browsePick() {
961
1146
  if (!browse || !browse.path) return
962
- 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
+ }
963
1153
  setBrowse(null)
964
1154
  }
1155
+ function browseSelectFile(name) {
1156
+ setBrowse((prev) => (prev ? { ...prev, sel: name } : prev))
1157
+ }
965
1158
  function browseToggleHidden(hidden) {
966
1159
  if (!browse) return
967
- loadBrowse(browse.path, hidden)
1160
+ loadBrowse(browse.path, hidden, browse.file)
968
1161
  }
969
1162
 
970
1163
  // ---------- JDK ----------
@@ -980,6 +1173,32 @@ window.__ModuleLoader__.load({
980
1173
  setJdkOpen(false)
981
1174
  }
982
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
+
983
1202
  // ---------- 部署 ----------
984
1203
 
985
1204
  function toggleSel(id) {
@@ -1015,11 +1234,13 @@ window.__ModuleLoader__.load({
1015
1234
  lines.push('')
1016
1235
  lines.push('将依次执行:')
1017
1236
  for (const m of mods) {
1018
- 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 : ''
1019
1239
  lines.push('')
1020
1240
  lines.push('[' + (m.name || m.id) + ']')
1021
- lines.push(' 构建: gradlew ' + m.gradleTask + (m.key ? ' -Pkey=' + m.key : '') + (m.extraArgs ? ' ' + m.extraArgs : '') + exp)
1022
- 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) + ')' : ''))
1023
1244
  if (m.postCommand) lines.push(' ssh: ' + m.postCommand)
1024
1245
  }
1025
1246
  return lines.join('\n')
@@ -1028,8 +1249,12 @@ window.__ModuleLoader__.load({
1028
1249
  function requestDeploy() {
1029
1250
  if (!draft || !selected.length) return
1030
1251
  if (isNew) { setDeployState({ running: false, text: '请先保存环境再执行部署', kind: 'err' }); return }
1031
- if (!draft.ssh.host || !draft.ssh.user) {
1032
- 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' })
1033
1258
  return
1034
1259
  }
1035
1260
  const mods = selected.map((id) => moduleById[id]).filter((m) => m && m.enabled)
@@ -1126,7 +1351,7 @@ window.__ModuleLoader__.load({
1126
1351
  const canRun = !!draft && !isNew && selected.length > 0 && !deployState.running
1127
1352
 
1128
1353
  // 标题行:对象名 + 一句「现状」(ID / 最近部署结果 / SSH 目标),全部来自已有数据
1129
- const selSsh = draft && draft.ssh ? draft.ssh : {}
1354
+ const selSsh = draft && draft.sshs && draft.sshs[0] ? draft.sshs[0] : {}
1130
1355
  const selSshText = selSsh.host ? (selSsh.user || 'root') + '@' + selSsh.host + ':' + (selSsh.port || 22) : ''
1131
1356
  const selBuild = draft ? lastBuild[draft.id] : null
1132
1357
  let titleSub = '选择左侧环境,或新增一个环境'
@@ -1285,13 +1510,16 @@ window.__ModuleLoader__.load({
1285
1510
  onToggle: () => setFormOpen((v) => !v),
1286
1511
  onPatch: patchEnv, onPatchSsh: patchSsh, onPatchModule: patchModule,
1287
1512
  onAddModule: addModule, onDeleteModule: askDeleteModule,
1288
- onSave: saveEnv, onDelete: deleteCurrentEnv, onBrowse: openBrowse,
1513
+ onAddSsh: addSsh, onDeleteSsh: deleteSsh, onTestSsh: runSshTest, sshTest,
1514
+ onSave: saveEnv, onDelete: deleteCurrentEnv, onBrowse: openBrowse, onBrowseKey: openKeyBrowse,
1289
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,
1290
1518
  })),
1291
1519
  confirmState ? h(ConfirmDialog, { state: confirmState, onClose: () => setConfirmState(null) }) : null,
1292
1520
  browse ? h(BrowseModal, {
1293
1521
  state: browse, onClose: () => setBrowse(null),
1294
- onNavigate: navigateBrowse, onUp: browseUp, onPick: browsePick,
1522
+ onNavigate: navigateBrowse, onUp: browseUp, onPick: browsePick, onSelectFile: browseSelectFile,
1295
1523
  onToggleHidden: browseToggleHidden,
1296
1524
  }) : null)
1297
1525
  }
@@ -1378,6 +1606,7 @@ window.__ModuleLoader__.load({
1378
1606
  '.dad-btn-danger-solid:hover:not(:disabled){background:var(--dsw-alias-state-error-primary)}',
1379
1607
  '.dad-formfoot{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-top:4px}',
1380
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)}',
1381
1610
  '.dad-mcard[data-off="1"]{opacity:.6}',
1382
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}',
1383
1612
  '.dad-btndel:hover{color:var(--dsw-alias-state-error-primary)}',