dsh-api-runner 0.1.0 → 0.2.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/client.js CHANGED
@@ -1,1604 +1,1840 @@
1
- /**
2
- * dsh-api-runner 侧边栏面板(Client half)。
3
- *
4
- * 这是给 DSH Web 前端加载的**经典脚本**(不是 ESM):由 client-modules 注入
5
- * window.__ModuleLoader__,我们把自己的 factory 注册进去,由它提供 require。
6
- * 导出 inject / apply,与 host half 的 ESM 形态不同,不要混用。
7
- *
8
- * 视觉:与 dsh-db-tool 一致的「inset grouped / iOS 分段控件」风格——
9
- * · 顶部整条分段控件(接口 / 服务 / 设置)
10
- * · 卡片用半透明灰膜 + 大圆角,不用描边
11
- * · 输入框无边框、填充式;按钮为胶囊填充
12
- * · 列表一张卡片多行,行间 hairline
13
- * 颜色:中性色用 rgba 灰膜(明暗主题都成立),强调色/状态色优先取
14
- * --dsw-alias-* 主题 token,取不到时回落到固定色;不写死正文文字色,直接继承。
15
- */
16
- window.__ModuleLoader__.load({
17
- id: 'dsh-api-runner',
18
- factory: (require) => {
19
- var module = { exports: {} };
20
- var exports = module.exports;
21
-
22
- const React = require('react');
23
- const h = React.createElement;
24
-
25
- const TAB_ID = 'dsh-api-runner';
26
- const API = '/dsh-api-runner/api';
27
- const STYLE_ID = 'dsh-api-runner-style';
28
- const METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'];
29
-
30
- // ---------------------------------------------------------------- 样式
31
- const CSS = `
32
- .ar-root { display: flex; flex-direction: column; height: 100%; min-height: 0; min-width: 0; box-sizing: border-box;
33
- font-size: 13px; line-height: 1.45; -webkit-font-smoothing: antialiased;
34
- font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif;
35
- --ar-accent: var(--dsw-alias-brand-primary, #0a84ff);
36
- --ar-surface: rgba(120, 120, 128, .12);
37
- --ar-surface-strong: rgba(120, 120, 128, .18);
38
- --ar-sep: rgba(120, 120, 128, .22);
39
- --ar-danger: var(--dsw-alias-state-error-primary, #ff453a);
40
- --ar-success: var(--dsw-alias-state-success-primary, #30d158);
41
- --ar-warn: var(--dsw-alias-state-warn-primary, #ffd60a);
42
- --ar-r-card: 12px;
43
- --ar-r-ctrl: 8px;
44
- --ar-r-pill: 6px;
45
- --ar-mono: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
46
- --ar-ease: cubic-bezier(.25, .1, .25, 1);
47
- }
48
- @media (prefers-color-scheme: light) {
49
- .ar-root { --ar-surface: rgba(120, 120, 128, .09); --ar-surface-strong: rgba(120, 120, 128, .15);
50
- --ar-sep: rgba(60, 60, 67, .16);
51
- --ar-danger: var(--dsw-alias-state-error-primary, #ff3b30);
52
- --ar-success: var(--dsw-alias-state-success-primary, #34c759);
53
- --ar-warn: var(--dsw-alias-state-warn-primary, #ff9f0a); }
54
- }
55
-
56
- /* ---- 顶部:整条分段控件 ---- */
57
- .ar-top { flex: 0 0 auto; display: flex; align-items: center; gap: 6px; padding: 10px 12px 8px; }
58
- .ar-tabs { flex: 1 1 auto; display: flex; gap: 2px; padding: 2px; min-width: 0;
59
- background: var(--ar-surface); border-radius: var(--ar-r-ctrl); }
60
- .ar-tab { flex: 1 1 0; min-width: 0; display: inline-flex; align-items: center; justify-content: center; gap: 5px;
61
- border: 0; background: transparent; color: inherit; cursor: pointer; padding: 5px 8px; font: inherit; font-size: 12.5px;
62
- border-radius: var(--ar-r-pill); white-space: nowrap; opacity: .68;
63
- transition: background-color .18s var(--ar-ease), opacity .18s var(--ar-ease), box-shadow .18s var(--ar-ease); }
64
- .ar-tab:hover { opacity: .9; }
65
- .ar-tab.on { opacity: 1; font-weight: 600; background: var(--ar-surface-strong); box-shadow: 0 1px 3px rgba(0, 0, 0, .12); }
66
- .ar-count { font-size: 10px; font-weight: 600; line-height: 1; padding: 2px 4px; border-radius: 4px;
67
- background: var(--ar-surface-strong); opacity: .85; }
68
- .ar-tab.on .ar-count { background: var(--ar-accent); color: #fff; opacity: 1; }
69
-
70
- .ar-body { flex: 1 1 auto; min-height: 0; min-width: 0; overflow-y: auto; padding: 2px 12px 24px;
71
- display: flex; flex-direction: column; gap: 10px; }
72
- .ar-pane { display: flex; flex-direction: column; gap: 10px; min-width: 0; }
73
-
74
- /* ---- 排版 ---- */
75
- .ar-mono { font-family: var(--ar-mono); }
76
- .ar-muted { opacity: .6; }
77
- .ar-faint { opacity: .45; }
78
- .ar-cut { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
79
- .ar-spacer { flex: 1 1 auto; min-width: 0; }
80
- .ar-row { display: flex; align-items: center; gap: 6px; }
81
-
82
- /* 分组标题:一行小标题 + 右延细线 */
83
- .ar-sec { display: flex; align-items: center; gap: 8px; margin-top: 4px; }
84
- .ar-sec .t { font-size: 12.5px; font-weight: 600; letter-spacing: .01em; opacity: .72; white-space: nowrap; }
85
- .ar-sec::after { content: ''; flex: 1 1 auto; height: 1px; background: var(--ar-sep); }
86
-
87
- /* 二级页签:和顶部同一套分段控件,但用颜色区分层级——
88
- 顶部(接口/服务/设置)是灰底 + 灰白选中块,二级(已接管/运行中/…、项目分类)去掉灰底容器、
89
- 选中态用品牌色实心块。这样一眼能看出「当前在哪一层」,不会和顶部混成同一排。
90
- 注意:选中态用实心 accent 而不是半透明混合,避免依赖 color-mix,浅色/深色主题都稳。 */
91
- .ar-tabs.sub { flex: none; background: transparent; padding: 0; gap: 4px; }
92
- .ar-tabs.sub .ar-tab { padding: 4px 8px; font-size: 12px; background: var(--ar-surface); opacity: .72; }
93
- .ar-tabs.sub .ar-tab:hover { opacity: .95; }
94
- .ar-tabs.sub .ar-tab.on {
95
- background: var(--ar-accent); color: #fff; opacity: 1; font-weight: 600;
96
- box-shadow: 0 1px 3px rgba(0, 0, 0, .18);
97
- }
98
- .ar-tabs.sub .ar-count { font-size: 9.5px; padding: 1px 4px; background: var(--ar-surface-strong); opacity: .9; }
99
- .ar-tabs.sub .ar-tab.on .ar-count { background: rgba(255, 255, 255, .26); color: #fff; opacity: 1; }
100
- /* 项目多的时候横向滚动,不换行、不压扁页签 */
101
- .ar-tabs.ar-projects { overflow-x: auto; scrollbar-width: none; }
102
- .ar-tabs.ar-projects::-webkit-scrollbar { display: none; }
103
- .ar-tabs.ar-projects .ar-tab { flex: 0 0 auto; max-width: 132px; }
104
- .ar-tabs.ar-projects .ar-tab .l { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
105
-
106
- /* ---- inset grouped 卡片 ---- */
107
- .ar-card { display: flex; flex-direction: column; gap: 9px; padding: 12px;
108
- background: var(--ar-surface); border: 0; border-radius: var(--ar-r-card); }
109
- .ar-group { display: flex; flex-direction: column; background: var(--ar-surface);
110
- border-radius: var(--ar-r-card); overflow: hidden; }
111
- .ar-item { display: flex; flex-direction: column; gap: 6px; padding: 11px 12px;
112
- transition: background-color .15s var(--ar-ease); }
113
- .ar-item + .ar-item { border-top: 1px solid var(--ar-sep); }
114
- .ar-head { display: flex; align-items: flex-start; gap: 8px; }
115
- .ar-main { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
116
- .ar-name { font-size: 13px; font-weight: 600; }
117
-
118
- /* ---- 徽章 / 方法标 ---- */
119
- .ar-badge { display: inline-flex; align-items: center; height: 18px; padding: 0 6px; border-radius: var(--ar-r-pill);
120
- font-size: 11px; font-weight: 500; white-space: nowrap; line-height: 1;
121
- background: var(--ar-surface-strong); opacity: .9; }
122
- .ar-badge.ok { color: var(--ar-success); background: rgba(48, 209, 88, .14); opacity: 1; }
123
- .ar-badge.warn { color: var(--ar-warn); background: rgba(255, 214, 10, .14); opacity: 1; }
124
- .ar-badge.err { color: var(--ar-danger); background: rgba(255, 69, 58, .14); opacity: 1; }
125
- .ar-badge.brand { color: var(--ar-accent); background: rgba(10, 132, 255, .14); opacity: 1; }
126
- .ar-m { display: inline-flex; align-items: center; justify-content: center; flex: none;
127
- min-width: 46px; height: 19px; padding: 0 5px; border-radius: 5px;
128
- font-family: var(--ar-mono); font-size: 10.5px; font-weight: 700; letter-spacing: .02em; }
129
- .ar-m.get { color: #0a84ff; background: rgba(10, 132, 255, .14); }
130
- .ar-m.post { color: #34c759; background: rgba(52, 199, 89, .16); }
131
- .ar-m.put, .ar-m.patch { color: #ff9f0a; background: rgba(255, 159, 10, .16); }
132
- .ar-m.del { color: #ff3b30; background: rgba(255, 59, 48, .14); }
133
-
134
- /* ---- 地址:host 弱、path 强 ---- */
135
- .ar-url { font-family: var(--ar-mono); font-size: 11.5px; overflow-wrap: anywhere; }
136
- .ar-url .h { opacity: .5; }
137
- .ar-url .p { opacity: .95; }
138
-
139
- /* ---- 按钮:胶囊填充 ---- */
140
- .ar-btn { display: inline-flex; align-items: center; justify-content: center; gap: 4px;
141
- border: 0; border-radius: var(--ar-r-ctrl); padding: 5px 12px; font: inherit; font-size: 12.5px; font-weight: 500;
142
- cursor: pointer; white-space: nowrap; color: inherit; background: var(--ar-surface);
143
- transition: background-color .18s var(--ar-ease), filter .18s var(--ar-ease), transform .18s var(--ar-ease), opacity .18s var(--ar-ease); }
144
- .ar-btn:active:not(:disabled) { transform: scale(.97); }
145
- .ar-btn:disabled { opacity: .38; cursor: default; transform: none; }
146
- .ar-btn.primary { background: var(--ar-accent); color: #fff; }
147
- .ar-btn.tonal { background: var(--ar-surface-strong); }
148
- .ar-btn.ghost { background: transparent; opacity: .62; padding: 5px 9px; }
149
- .ar-btn.danger { color: var(--ar-danger); }
150
- .ar-btn.icon { padding: 5px; width: 28px; }
151
- @media (hover: hover) and (pointer: fine) {
152
- .ar-btn:hover:not(:disabled) { background: var(--ar-surface-strong); }
153
- .ar-btn.primary:hover:not(:disabled) { filter: brightness(1.08); background: var(--ar-accent); }
154
- .ar-btn.ghost:hover:not(:disabled) { background: var(--ar-surface); opacity: .95; }
155
- .ar-btn.danger:hover:not(:disabled) { background: rgba(255, 69, 58, .12); }
156
- .ar-item:hover { background: var(--ar-surface-strong); }
157
- }
158
-
159
- /* ---- 表单:无边框填充 ---- */
160
- .ar-field { display: grid; grid-template-columns: 58px minmax(0, 1fr); gap: 8px; align-items: center; }
161
- .ar-field.top { align-items: start; }
162
- .ar-field > label { font-size: 12px; opacity: .62; }
163
- .ar-field.top > label { padding-top: 7px; }
164
- .ar-in, .ar-sel, .ar-ta { width: 100%; box-sizing: border-box; font: inherit; font-size: 13px;
165
- color: inherit; background: var(--ar-surface-strong); border: 0; border-radius: var(--ar-r-ctrl);
166
- padding: 6px 10px; outline: 2px solid transparent; outline-offset: -1px;
167
- transition: outline-color .18s var(--ar-ease); }
168
- .ar-in, .ar-sel { height: 30px; padding-top: 0; padding-bottom: 0; }
169
- .ar-sel { cursor: pointer; }
170
- .ar-ta { min-height: 60px; resize: vertical; font-family: var(--ar-mono); font-size: 12px; line-height: 1.5; }
171
- .ar-in:focus, .ar-sel:focus, .ar-ta:focus { outline-color: var(--ar-accent); }
172
- .ar-in::placeholder, .ar-ta::placeholder { color: inherit; opacity: .38; }
173
- .ar-check { display: flex; align-items: center; gap: 7px; cursor: pointer; font-size: 13px; }
174
- .ar-check input { width: 15px; height: 15px; margin: 0; accent-color: var(--ar-accent); cursor: pointer; flex: none; }
175
- .ar-hint { font-size: 11.5px; opacity: .55; line-height: 1.55; }
176
-
177
- /* ---- 提示条 ---- */
178
- .ar-banner { display: flex; align-items: flex-start; gap: 7px; padding: 8px 11px;
179
- border-radius: var(--ar-r-ctrl); font-size: 12.5px; line-height: 1.5;
180
- background: var(--ar-surface); }
181
- .ar-banner > span { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; }
182
- /* 关闭按钮做成「纯文字 ×」:无边框无底色,颜色跟着提示条走,位置钉在最后一行右侧 */
183
- .ar-banner > .ar-x { flex: none; margin-left: auto; align-self: center;
184
- appearance: none; -webkit-appearance: none; border: 0 none; background: none; box-shadow: none;
185
- color: inherit; cursor: pointer; font-family: inherit; font-size: 15px; line-height: 1;
186
- padding: 0 1px 2px; border-radius: 4px; opacity: .55;
187
- transition: opacity .16s var(--ar-ease); }
188
- .ar-banner > .ar-x:hover { opacity: .95; }
189
- .ar-banner > .ar-x:focus-visible { outline: 1px solid currentColor; outline-offset: 1px; opacity: .95; }
190
- .ar-banner.err { color: var(--ar-danger); background: rgba(255, 69, 58, .12); }
191
- .ar-banner.ok { color: var(--ar-success); background: rgba(52, 199, 89, .12); }
192
-
193
- /* ---- 响应查看 ---- */
194
- .ar-pre { margin: 0; padding: 10px 11px; border-radius: var(--ar-r-ctrl);
195
- background: var(--ar-surface-strong); font-family: var(--ar-mono); font-size: 12px; line-height: 1.55;
196
- white-space: pre-wrap; overflow-wrap: anywhere; max-height: 320px; overflow: auto; }
197
- .ar-seg { display: flex; gap: 2px; padding: 2px; background: var(--ar-surface); border-radius: var(--ar-r-pill); }
198
- .ar-seg > button { border: 0; background: transparent; color: inherit; cursor: pointer; font: inherit; font-size: 11.5px;
199
- padding: 3px 9px; border-radius: 5px; opacity: .62; transition: background-color .18s var(--ar-ease), opacity .18s var(--ar-ease); }
200
- .ar-seg > button:hover { opacity: .9; }
201
- .ar-seg > button.on { opacity: 1; font-weight: 600; background: var(--ar-surface-strong); }
202
-
203
- /* ---- 空态 ---- */
204
- .ar-empty { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 30px 16px; text-align: center; }
205
- .ar-empty-icon { font-size: 22px; opacity: .3; line-height: 1; }
206
- .ar-empty-title { font-size: 13px; font-weight: 600; }
207
- .ar-empty-hint { font-size: 12px; opacity: .55; line-height: 1.6; max-width: 230px; }
208
-
209
- /* ---- 键值行 ---- */
210
- .ar-kv { display: flex; align-items: baseline; gap: 8px; font-size: 12px; }
211
- .ar-kv > .k { flex: 0 0 55px; opacity: .55; }
212
- .ar-kv > .v { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; }
213
-
214
- /* ---- 服务详情 / 实时日志 ---- */
215
- .ar-detail { display: flex; flex-direction: column; gap: 9px; min-width: 0; }
216
- .ar-detail-head { display: flex; align-items: center; gap: 7px; min-width: 0; }
217
- .ar-dot { width: 7px; height: 7px; border-radius: 50%; flex: none; background: var(--ar-success); }
218
- .ar-dot.off { background: var(--ar-danger); }
219
- /* 已忽略的服务整体压暗,但仍可操作(取消忽略) */
220
- .ar-ignored { opacity: .55; }
221
- /* 日志容器:宽度必须被父级约束住,否则 flex 的 min-width:auto 会让它按内容撑开,
222
- 看起来就像「不换行」(踩过:只设 white-space:pre-wrap 但父级没 min-width:0 时无效)。 */
223
- .ar-log { margin: 0; padding: 10px 11px; border-radius: var(--ar-r-ctrl); width: 100%; box-sizing: border-box;
224
- background: var(--ar-surface-strong); font-family: var(--ar-mono); font-size: 11.5px; line-height: 1.62;
225
- white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word;
226
- max-height: 58vh; min-height: 140px; overflow-y: auto; overflow-x: hidden; tab-size: 4; }
227
- .ar-log.nowrap { white-space: pre; overflow-wrap: normal; word-break: normal; overflow-x: auto; }
228
- .ar-log:empty::before { content: '(暂无日志输出)'; opacity: .45; }
229
-
230
- @media (prefers-reduced-motion: reduce) {
231
- .ar-root * { transition: none !important; animation: none !important; }
232
- }
233
- `;
234
-
235
- /**
236
- * 插入样式表。
237
- * 优先用宿主给动态 Client half 的 styles.insert(由 DSH 管理生命周期、
238
- * 保证插到正确的文档根);取不到才退回 document.head,与 dsh-db-tool 同法。
239
- */
240
- function installStyles(ctx) {
241
- let api;
242
- if (typeof styles !== 'undefined' && styles && typeof styles.insert === 'function') api = styles;
243
- else if (ctx && typeof ctx.get === 'function') api = ctx.get('styles');
244
- if (api && typeof api.insert === 'function') {
245
- api.insert(CSS);
246
- return;
247
- }
248
- if (document.getElementById(STYLE_ID)) return;
249
- const el = document.createElement('style');
250
- el.id = STYLE_ID;
251
- el.textContent = CSS;
252
- (document.head || document.documentElement).appendChild(el);
253
- }
254
-
255
- // ---------------------------------------------------------------- 工具
256
- async function request(path, init) {
257
- const res = await fetch(`${API}/${path}`, init);
258
- let data;
259
- try {
260
- data = await res.json();
261
- } catch {
262
- data = undefined;
263
- }
264
- if (res.status === 401) throw new Error('需要先登录 DSH 网页端(请求未通过鉴权)');
265
- if (!data) throw new Error(`接口无 JSON 响应(HTTP ${res.status})`);
266
- return data;
267
- }
268
-
269
- async function copyText(text) {
270
- try {
271
- await navigator.clipboard.writeText(text);
272
- return true;
273
- } catch {
274
- return false;
275
- }
276
- }
277
-
278
- function pretty(value) {
279
- try {
280
- return JSON.stringify(value, null, 2);
281
- } catch {
282
- return String(value);
283
- }
284
- }
285
-
286
- function byteSize(value) {
287
- try {
288
- const text = typeof value === 'string' ? value : JSON.stringify(value);
289
- const bytes = new TextEncoder().encode(text || '').length;
290
- return bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(1)} KB`;
291
- } catch {
292
- return '';
293
- }
294
- }
295
-
296
- function splitUrl(url) {
297
- const text = String(url || '');
298
- const match = /^([a-z]+:\/\/[^/]+)([\s\S]*)$/i.exec(text);
299
- if (!match) return { host: '', path: text };
300
- return { host: match[1], path: match[2] || '/' };
301
- }
302
-
303
- function methodClass(method) {
304
- const m = String(method || 'GET').toLowerCase();
305
- if (m === 'get' || m === 'head') return 'ar-m get';
306
- if (m === 'post') return 'ar-m post';
307
- if (m === 'put' || m === 'patch') return 'ar-m put';
308
- if (m === 'delete') return 'ar-m del';
309
- return 'ar-m';
310
- }
311
-
312
- function emptyForm(baseUrl) {
313
- return {
314
- id: '', name: '', method: 'GET', url: '', baseUrl: baseUrl || '', description: '',
315
- headersText: '', bodyText: '', sampleText: '', useToken: '',
316
- isLogin: false, tokenPath: 'token', authHeader: 'Authorization', prefix: 'Bearer ',
317
- timeoutMs: 30000,
318
- };
319
- }
320
-
321
- function formFromApi(api) {
322
- return {
323
- id: api.id,
324
- name: api.name || api.id,
325
- method: api.method || 'GET',
326
- url: api.url || '',
327
- baseUrl: api.baseUrl || '',
328
- description: api.description || '',
329
- headersText: api.headersText || '',
330
- bodyText: api.bodyText || '',
331
- sampleText: api.sampleText || '',
332
- useToken: api.useToken || '',
333
- isLogin: Boolean(api.isLogin),
334
- tokenPath: api.tokenPath || 'token',
335
- authHeader: api.header || 'Authorization',
336
- prefix: api.prefix === undefined ? 'Bearer ' : api.prefix,
337
- timeoutMs: api.timeoutMs || 30000,
338
- source: api.source,
339
- };
340
- }
341
-
342
- function parseJsonField(text, label) {
343
- const trimmed = String(text || '').trim();
344
- if (trimmed === '') return undefined;
345
- try {
346
- return JSON.parse(trimmed);
347
- } catch (error) {
348
- throw new Error(`${label} 不是合法 JSON:${error.message}`);
349
- }
350
- }
351
-
352
- function placeholderNames(api) {
353
- const out = new Set(Array.isArray(api.params) ? api.params : []);
354
- const scan = (value) => {
355
- if (typeof value === 'string') {
356
- for (const match of value.matchAll(/\{([A-Za-z_][A-Za-z0-9_.-]*)\}/g)) out.add(match[1]);
357
- } else if (Array.isArray(value)) value.forEach(scan);
358
- else if (value && typeof value === 'object') Object.values(value).forEach(scan);
359
- };
360
- scan(api.url);
361
- scan(api.bodyText);
362
- return [...out];
363
- }
364
-
365
- // ---------------------------------------------------------------- 组件
366
- function Field(props) {
367
- return h('div', { className: props.top ? 'ar-field top' : 'ar-field' },
368
- h('label', {}, props.label), props.children);
369
- }
370
-
371
- /** 提示条:err/ok 用色区分,点 × 可关掉(提示不该一直占着版面) */
372
- function Banner(props) {
373
- const { kind, text, onClose } = props;
374
- if (!text) return null;
375
- // 用 span 而不是 button:button 会带上浏览器/主题的默认边框底色,跟提示条融不进去
376
- const close = onClose
377
- ? h('span', {
378
- className: 'ar-x',
379
- role: 'button',
380
- tabIndex: 0,
381
- title: '关闭',
382
- 'aria-label': '关闭',
383
- onClick: onClose,
384
- onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClose(); } },
385
- }, '×')
386
- : null;
387
- return h('div', { className: kind ? `ar-banner ${kind}` : 'ar-banner' }, h('span', {}, text), close);
388
- }
389
-
390
- function Empty(props) {
391
- return h('div', { className: 'ar-empty' },
392
- props.icon ? h('div', { className: 'ar-empty-icon' }, props.icon) : null,
393
- h('div', { className: 'ar-empty-title' }, props.title),
394
- props.hint ? h('div', { className: 'ar-empty-hint' }, props.hint) : null,
395
- props.action || null);
396
- }
397
-
398
- function Url(props) {
399
- const parts = splitUrl(props.value);
400
- return h('div', { className: 'ar-url', title: props.value },
401
- parts.host ? h('span', { className: 'h' }, parts.host) : null,
402
- h('span', { className: 'p' }, parts.path));
403
- }
404
-
405
- /**
406
- * 项目 key 是归一化后的绝对路径,页签上只给末段目录名,够区分又不占地方。
407
- * 末段撞车(两个都叫 api)时退回上一级,避免两个页签长一样。
408
- */
409
- function projectLabel(key, allKeys) {
410
- const parts = String(key || '').split('/').filter(Boolean);
411
- if (parts.length === 0) return key || 'default';
412
- const last = parts[parts.length - 1];
413
- const dup = (allKeys || []).filter((item) => {
414
- const other = String(item || '').split('/').filter(Boolean);
415
- return other.length > 0 && other[other.length - 1] === last;
416
- });
417
- return dup.length > 1 && parts.length > 1 ? `${parts[parts.length - 2]}/${last}` : last;
418
- }
419
-
420
- function ApiRow(props) {
421
- const { api, tokens, busy, onTest, onLogin, onEdit, onDelete, onClearToken } = props;
422
- const tokenId = api.tokenFrom || '';
423
- const token = tokenId ? tokens[tokenId] : undefined;
424
- const params = Array.isArray(api.params) ? api.params : [];
425
- return h('div', { className: 'ar-item' },
426
- h('div', { className: 'ar-head' },
427
- h('span', { className: methodClass(api.method) }, api.method),
428
- h('div', { className: 'ar-main' },
429
- h('div', { className: 'ar-row', style: { flexWrap: 'wrap', gap: '5px' } },
430
- h('span', { className: 'ar-name ar-cut' }, api.name || api.id),
431
- api.source === 'file' ? h('span', { className: 'ar-badge', title: '来自 .dsh/api-runner.yml,请在文件里修改' }, 'YAML') : null,
432
- api.isLogin ? h('span', { className: 'ar-badge warn' }, '登录') : null,
433
- token ? h('span', { className: 'ar-badge ok', title: '调用时会自动带上这个 token' }, token.masked) : null),
434
- h(Url, { value: api.url }),
435
- api.description ? h('div', { className: 'ar-hint ar-cut', title: api.description }, api.description) : null,
436
- params.length > 0 ? h('div', { className: 'ar-hint ar-mono' }, `参数 ${params.join(' · ')}`) : null)),
437
- // 项目页签切过去之后,行内的测试/登录/编辑/删除都作用于那个项目自己的配置
438
- // (请求带 project,后端按项目桶读写),所以这里不再有只读分支。
439
- h('div', { className: 'ar-row' },
440
- h('button', { className: 'ar-btn primary', disabled: Boolean(busy), title: '用「示例参数」调用一次', onClick: () => onTest(api) }, '测试'),
441
- api.isLogin ? h('button', { className: 'ar-btn tonal', disabled: Boolean(busy), title: '调登录接口并把 token 存起来', onClick: () => onLogin(api) }, '登录并保存') : null,
442
- h('span', { className: 'ar-spacer' }),
443
- h('button', { className: 'ar-btn ghost icon', title: '复制完整地址', onClick: () => copyText(api.url) }, '⧉'),
444
- token ? h('button', { className: 'ar-btn ghost', title: `忘掉 ${tokenId} 的 token`, onClick: () => onClearToken(tokenId) }, '清除 token') : null,
445
- h('button', { className: 'ar-btn ghost', onClick: () => onEdit(api) }, '编辑'),
446
- api.source === 'file' ? null : h('button', { className: 'ar-btn ghost danger', onClick: () => onDelete(api) }, '删除')),
447
- );
448
- }
449
-
450
- function EditForm(props) {
451
- const { value, onChange, onSave, onCancel, tokens, busy } = props;
452
- const set = (key, next) => onChange({ ...value, [key]: next });
453
- const placeholders = placeholderNames(value);
454
- return h('div', { className: 'ar-card' },
455
- h('div', { className: 'ar-name' }, value.id ? `编辑 ${value.id}` : '新增接口'),
456
- h(Field, { label: 'id' }, h('input', { className: 'ar-in', value: value.id, placeholder: '唯一标识,如 login', onChange: (e) => set('id', e.target.value) })),
457
- h(Field, { label: '名称' }, h('input', { className: 'ar-in', value: value.name, placeholder: '显示名,留空则用 id', onChange: (e) => set('name', e.target.value) })),
458
- h(Field, { label: '方法' }, h('select', { className: 'ar-sel', value: value.method, onChange: (e) => set('method', e.target.value) },
459
- METHODS.map((m) => h('option', { key: m, value: m }, m)))),
460
- h(Field, { label: '路径' }, h('input', { className: 'ar-in', value: value.url, placeholder: '/auth/login 或完整地址,支持 {id}', onChange: (e) => set('url', e.target.value) })),
461
- h(Field, { label: 'baseUrl' }, h('input', { className: 'ar-in', value: value.baseUrl, placeholder: '留空则用「设置」里的 baseUrl', onChange: (e) => set('baseUrl', e.target.value) })),
462
- h(Field, { label: '说明' }, h('input', { className: 'ar-in', value: value.description, onChange: (e) => set('description', e.target.value) })),
463
- h(Field, { label: '请求头', top: true }, h('textarea', { className: 'ar-ta', value: value.headersText, placeholder: '{"X-Token":"${DEMO_TOKEN}"}', onChange: (e) => set('headersText', e.target.value) })),
464
- h(Field, { label: 'body', top: true }, h('textarea', { className: 'ar-ta', value: value.bodyText, placeholder: '{"username":"{user}","password":"{pwd}"}', onChange: (e) => set('bodyText', e.target.value) })),
465
- h(Field, { label: '示例参数', top: true }, h('textarea', { className: 'ar-ta', value: value.sampleText, placeholder: '「测试」按钮用它填 {占位},如 {"user":"alice"}', onChange: (e) => set('sampleText', e.target.value) })),
466
- placeholders.length > 0 ? h('div', { className: 'ar-hint ar-mono' }, `占位参数:${placeholders.join(' · ')}`) : null,
467
- h(Field, { label: '用 token' }, h('select', { className: 'ar-sel', value: value.useToken, onChange: (e) => set('useToken', e.target.value) },
468
- h('option', { value: '' }, '自动(项目里只有一个 token 时用它)'),
469
- Object.keys(tokens || {}).map((id) => h('option', { key: id, value: id }, `${id}(${tokens[id].masked})`)))),
470
- h('label', { className: 'ar-check' },
471
- h('input', { type: 'checkbox', checked: value.isLogin, onChange: (e) => set('isLogin', e.target.checked) }),
472
- h('span', {}, '这是登录接口,调用成功后保存 token')),
473
- value.isLogin
474
- ? h('div', { className: 'ar-card', style: { background: 'var(--ar-surface-strong)' } },
475
- h(Field, { label: 'token 路径' }, h('input', { className: 'ar-in', value: value.tokenPath, placeholder: '响应 JSON 路径,如 data.token', onChange: (e) => set('tokenPath', e.target.value) })),
476
- h(Field, { label: '注入头' }, h('input', { className: 'ar-in', value: value.authHeader, placeholder: 'Authorization', onChange: (e) => set('authHeader', e.target.value) })),
477
- h(Field, { label: '前缀' }, h('input', { className: 'ar-in', value: value.prefix, placeholder: 'Bearer (不需要就留空)', onChange: (e) => set('prefix', e.target.value) })),
478
- h('div', { className: 'ar-hint' }, '留空 token 路径时,会在响应里找形如 *token* 的字段,只有一个就直接用。'))
479
- : null,
480
- h('div', { className: 'ar-row' },
481
- h('button', { className: 'ar-btn primary', disabled: Boolean(busy), onClick: () => onSave(value) }, '保存'),
482
- h('button', { className: 'ar-btn ghost', onClick: onCancel }, '取消')));
483
- }
484
-
485
- /**
486
- * 端口只有一个数字时用户会问「为什么这个服务显示监听两个端口」——
487
- * 所以每个端口都标上角色:HTTP 业务端口 / 私有协议端口 / 调试端口。
488
- */
489
- function Ports(props) {
490
- const { item } = props;
491
- const list = Array.isArray(item.portList) ? item.portList : [];
492
- if (list.length === 0) {
493
- if (!item.primaryPort) return h('span', { className: 'ar-hint' }, ' · 未监听端口');
494
- return h('span', { className: 'ar-hint' }, ` · 监听 ${item.primaryPort}`);
495
- }
496
- return h('span', { className: 'ar-hint' },
497
- ' · ',
498
- list.map((port, index) => h('span', { key: port.port },
499
- index > 0 ? ' / ' : '',
500
- h('span', {
501
- className: port.role === 'http' ? 'ar-mono' : 'ar-mono ar-muted',
502
- title: port.reason ? `${port.label}(${port.reason})` : port.label,
503
- style: { fontWeight: port.role === 'http' ? 600 : 400 },
504
- }, port.port),
505
- h('span', { className: 'ar-muted' }, ` ${port.role === 'http' ? 'HTTP' : port.role === 'sentinel' ? '私有协议' : port.role === 'debug' ? 'debug' : ''}`))));
506
- }
507
-
508
- function JavaRow(props) {
509
- const { item, onUseBaseUrl, onTakeOver, onStop, onRestart, onDetail, onIgnore, onUnignore, onSave, busy, muted } = props;
510
- const profiles = Array.isArray(item.profiles) ? item.profiles : [];
511
- const managed = item.owner === 'plugin';
512
- const running = managed ? Boolean(item.alive) : true;
513
- /** 只有探测到「真的讲 HTTP」的端口才能当接口地址,私有协议/调试端口不能点 */
514
- const httpPort = item.httpPort || (managed ? item.primaryPort : 0);
515
- const ignored = Boolean(item.ignored);
516
- return h('div', { className: muted ? 'ar-item ar-ignored' : 'ar-item' },
517
- h('div', { className: 'ar-row', style: { flexWrap: 'wrap', gap: '5px' } },
518
- httpPort ? h('span', { className: 'ar-badge brand ar-mono' }, `:${httpPort}`) : null,
519
- h('span', { className: 'ar-name ar-cut', style: { flex: '0 1 auto' } }, item.name),
520
- managed
521
- ? h('span', { className: `ar-badge ${running ? 'ok' : ''}`, title: '由插件启动(可停止、可看日志)' }, running ? '插件启动' : '已停止')
522
- : item.owner === 'idea'
523
- ? h('span', { className: 'ar-badge warn', title: '由 IDEA 启动;插件不能直接重启它,只能「接管」' }, 'IDEA 启动')
524
- : h('span', { className: 'ar-badge', title: '不是 IDEA 起的,也不是插件起的' }, '其它'),
525
- ignored ? h('span', { className: 'ar-badge', title: '已忽略:在「正在运行」里不显示' }, '已忽略') : null,
526
- item.debugPort ? h('span', { className: 'ar-badge', title: '调试端口(jdwp)' }, `debug ${item.debugPort}`) : null,
527
- profiles.map((p) => h('span', { key: p, className: 'ar-badge' }, p))),
528
- h('div', { className: 'ar-hint ar-mono ar-cut', title: item.mainClass || '' }, item.mainClass || ''),
529
- h('div', { style: { fontSize: '12px' } },
530
- h('span', { className: 'ar-hint ar-mono' }, `pid ${item.pid}`),
531
- h(Ports, { item }),
532
- managed && item.startedAt ? h('span', { className: 'ar-hint' }, ` · 启动于 ${String(item.startedAt).slice(11, 19)}`) : null),
533
- h('div', { className: 'ar-row', style: { flexWrap: 'wrap' } },
534
- httpPort ? h('button', { className: 'ar-btn ghost icon', title: `把项目 baseUrl 设为 http://127.0.0.1:${httpPort}`, onClick: () => onUseBaseUrl(item) }, '⌁') : null,
535
- // 「查询详情」= 日志页:托管进程读插件输出,IDEA 进程读它自己的 logback 文件
536
- h('button', { className: 'ar-btn tonal', disabled: busy !== '', title: '查看进程信息与日志', onClick: () => onDetail(item) }, '查询详情'),
537
- // 被忽略的服务只留「取消忽略」:它不在正常列表里,给启动/停止按钮容易误操作
538
- ignored
539
- ? null
540
- : (managed
541
- ? (running
542
- ? h('button', { className: 'ar-btn', disabled: busy !== '', onClick: () => onRestart(item) }, '重启')
543
- : h('button', { className: 'ar-btn', disabled: busy !== '', onClick: () => onRestart(item) }, '启动'))
544
- : h('button', { className: 'ar-btn primary', disabled: busy !== '', title: '结束 IDEA 起的进程,改用插件启动(会先编译,再拉起);之后就能在插件里停止/重启/看日志', onClick: () => onTakeOver(item) }, '接管启动')),
545
- !ignored && managed && running ? h('button', { className: 'ar-btn ghost danger', disabled: busy !== '', onClick: () => onStop(item) }, '停止') : null,
546
- // 保存配方:之后即使 IDEA 里没跑,也能直接从这里启动
547
- !ignored && onSave && running ? h('button', { className: 'ar-btn ghost', disabled: busy !== '', title: '把这套启动参数(classpath/主类/JVM 参数)存下来,以后不用先在 IDEA 里跑也能启动', onClick: () => onSave(item) }, '保存') : null,
548
- h('span', { className: 'ar-spacer' }),
549
- ignored
550
- ? h('button', { className: 'ar-btn ghost', title: '重新显示在「正在运行」里', onClick: () => onUnignore(item) }, '取消忽略')
551
- : h('button', { className: 'ar-btn ghost', title: '不再显示在「正在运行」里(不影响进程本身)', onClick: () => onIgnore(item) }, '忽略')));
552
- }
553
-
554
- /** 已保存的服务:不需要现有进程,按配方直接启动 */
555
- function SavedRow(props) {
556
- const { item, busy, onStart, onDelete } = props;
557
- return h('div', { className: 'ar-item' },
558
- h('div', { className: 'ar-row', style: { flexWrap: 'wrap', gap: '5px' } },
559
- item.port ? h('span', { className: 'ar-badge brand ar-mono' }, `:${item.port}`) : null,
560
- h('span', { className: 'ar-name ar-cut', style: { flex: '0 1 auto' } }, item.name || item.id),
561
- h('span', { className: 'ar-badge', title: '启动参数已保存' }, '已保存')),
562
- h('div', { className: 'ar-hint ar-mono ar-cut', title: item.mainClass || '' }, item.mainClass || ''),
563
- h('div', { className: 'ar-hint ar-mono ar-cut', title: item.module?.rootDir || '' },
564
- `${item.module?.artifactId || '未识别模块'}${item.savedAt ? ` · 保存于 ${String(item.savedAt).slice(5, 16).replace('T', ' ')}` : ''}`),
565
- h('div', { className: 'ar-row', style: { flexWrap: 'wrap' } },
566
- h('button', { className: 'ar-btn primary', disabled: busy !== '', title: '编译后按保存的参数拉起(不需要 IDEA 里先有进程)', onClick: () => onStart(item) }, '启动'),
567
- h('span', { className: 'ar-spacer' }),
568
- h('button', { className: 'ar-btn ghost danger', disabled: busy !== '', onClick: () => onDelete(item) }, '删除')));
569
- }
570
-
571
- /**
572
- * 服务详情 = 日志页。
573
- *
574
- * 日志来源:
575
- * 插件托管的进程 → `<数据目录>/java-logs/<服务名>/<时间戳>.log`(插件写的 stdout,
576
- * 每次启动一个文件,最接近 IDEA 控制台,也能翻历史几次启动)
577
- * IDEA 起的进程 → 它自己的 logback 文件 `<user.dir>/logs/<服务名>/info.log`
578
- * (IDEA 控制台不是文件,拿不到;应用日志内容一样)
579
- * 按字节偏移增量拉,只在可见时轮询,切走就停。
580
- */
581
- function ServiceDetail(props) {
582
- const { item, visible, get, onClose, onStop, onRestart, onTakeOver, onIgnore, onUnignore, busy } = props;
583
- const [detail, setDetail] = React.useState(null);
584
- const [text, setText] = React.useState('');
585
- const [source, setSource] = React.useState('');
586
- const [auto, setAuto] = React.useState(true);
587
- const [wrap, setWrap] = React.useState(true);
588
- /** 首屏行数:和 IDEA 控制台一样先给最近一段,不够再加 */
589
- const [lines, setLines] = React.useState(200);
590
- /** 自定义行数输入框的草稿(回车/失焦生效) */
591
- const [linesText, setLinesText] = React.useState('');
592
- const [error, setError] = React.useState('');
593
- const [loading, setLoading] = React.useState(false);
594
- const boxRef = React.useRef(null);
595
- const offsetRef = React.useRef(0);
596
- const sourceRef = React.useRef('');
597
-
598
- const target = item.runId || item.pid;
599
- const alive = detail ? detail.alive : Boolean(item.alive) || item.owner !== 'plugin';
600
-
601
- /** 首屏:拿详情(进程信息 + 日志文件清单)并从文件末尾读一段 */
602
- const load = React.useCallback(async (nextSource, nextLines) => {
603
- try {
604
- setLoading(true);
605
- const key = nextSource !== undefined ? nextSource : sourceRef.current || '';
606
- const size = Number(nextLines) || lines;
607
- const query = `java/detail?id=${encodeURIComponent(target)}&lines=${size}&max_bytes=65536${key ? `&source=${encodeURIComponent(key)}` : ''}`;
608
- const data = await get(query);
609
- if (!data.ok) {
610
- setError(data.message || '读取详情失败');
611
- return;
612
- }
613
- setError('');
614
- const log = data.log || {};
615
- sourceRef.current = data.source || '';
616
- offsetRef.current = log.nextOffset || 0;
617
- setSource(data.source || '');
618
- setDetail(data);
619
- setText(log.text || '');
620
- } catch (e) {
621
- setError(e.message);
622
- } finally {
623
- setLoading(false);
624
- }
625
- }, [get, target, lines]);
626
-
627
- /** 增量:只取 offset 之后的新内容,追加到已有文本 */
628
- const follow = React.useCallback(async () => {
629
- if (!sourceRef.current) return;
630
- try {
631
- const data = await get(`java/detail?id=${encodeURIComponent(target)}&from=${offsetRef.current}&source=${encodeURIComponent(sourceRef.current)}&max_bytes=262144`);
632
- if (!data.ok) return;
633
- const log = data.log || {};
634
- if (typeof log.nextOffset === 'number') {
635
- // 变小说明文件被轮转/截断了,这时整段重来
636
- if (log.nextOffset < offsetRef.current) {
637
- setText(log.text || '');
638
- } else if (log.text) {
639
- setText((prev) => (prev + log.text).slice(-400_000));
640
- }
641
- offsetRef.current = log.nextOffset;
642
- }
643
- setDetail((prev) => (prev ? { ...data, log: undefined } : data));
644
- } catch {
645
- // 轮询失败不打断阅读,下一轮再试
646
- }
647
- }, [get, target]);
648
-
649
- /** 自定义行数:1–5000,回车或失焦时生效 */
650
- const applyLines = React.useCallback(() => {
651
- const wanted = Number(String(linesText || '').trim());
652
- if (!Number.isFinite(wanted) || wanted <= 0) return;
653
- const size = Math.min(5000, Math.max(1, Math.round(wanted)));
654
- setLines(size);
655
- setLinesText('');
656
- load(sourceRef.current, size);
657
- }, [linesText, load]);
658
-
659
- React.useEffect(() => {
660
- offsetRef.current = 0;
661
- sourceRef.current = '';
662
- setText('');
663
- setDetail(null);
664
- setSource('');
665
- load('');
666
- }, [target]);
667
- React.useEffect(() => {
668
- if (!visible || !auto || !sourceRef.current) return undefined;
669
- const timer = setInterval(() => { follow(); }, 1500);
670
- return () => clearInterval(timer);
671
- }, [visible, auto, follow, source]);
672
-
673
- // 新内容进来自动滚到底,除非用户往上翻了
674
- React.useEffect(() => {
675
- const box = boxRef.current;
676
- if (!box) return;
677
- const nearBottom = box.scrollHeight - box.scrollTop - box.clientHeight < 80;
678
- if (nearBottom) box.scrollTop = box.scrollHeight;
679
- }, [text]);
680
-
681
- const shown = text.length > 400_000 ? text.slice(-400_000) : text;
682
- const sources = (detail && Array.isArray(detail.sources) && detail.sources) || [];
683
- const ports = [detail && detail.portList, detail && detail.ports, item.portList].find((value) => Array.isArray(value)) || [];
684
-
685
- return h('div', { className: 'ar-detail' },
686
- h('div', { className: 'ar-row' },
687
- h('button', { className: 'ar-btn ghost icon', title: '返回列表', onClick: onClose }, '‹'),
688
- h('div', { className: 'ar-detail-head', style: { flex: '1 1 auto', minWidth: 0 } },
689
- h('span', { className: `ar-dot ${alive ? '' : 'off'}` }),
690
- h('span', { className: 'ar-name ar-cut' }, item.name),
691
- h('span', { className: 'ar-hint ar-mono' }, alive ? `pid ${detail ? detail.pid : item.pid}` : '已停止')),
692
- h('button', { className: 'ar-btn ghost', disabled: loading, onClick: () => load(source) }, '刷新')),
693
- h('div', { className: 'ar-row', style: { flexWrap: 'wrap' } },
694
- (detail && detail.port ? h('span', { className: 'ar-badge brand ar-mono' }, `:${detail.port}`) : null),
695
- ports.map((p) => h('span', {
696
- key: p.port,
697
- className: `ar-badge ${p.role === 'http' ? 'brand' : ''}`,
698
- title: p.reason ? `${p.label}(${p.reason})` : p.label,
699
- }, `${p.port} ${p.role === 'http' ? 'HTTP' : p.role === 'sentinel' ? '私有协议' : p.role === 'debug' ? 'debug' : ''}`)),
700
- (detail && detail.debugPort ? h('span', { className: 'ar-badge' }, `debug ${detail.debugPort}`) : null),
701
- (Array.isArray(detail && detail.profiles) ? detail.profiles : []).map((p) => h('span', { key: p, className: 'ar-badge' }, p)),
702
- h('span', { className: 'ar-spacer' }),
703
- h('button', { className: `ar-btn ${auto ? 'tonal' : 'ghost'}`, title: '每 1.5 秒增量拉取新日志', onClick: () => setAuto(!auto) }, auto ? '自动刷新 开' : '自动刷新 关')),
704
- // 有多个日志文件(info.log / error.log / 插件输出)时让用户切换
705
- sources.length > 1
706
- ? h('div', { className: 'ar-row', style: { flexWrap: 'wrap' } },
707
- sources.map((s) => h('button', {
708
- key: s.key,
709
- className: `ar-btn ${s.key === source ? 'tonal' : 'ghost'}`,
710
- title: s.path,
711
- onClick: () => { setText(''); offsetRef.current = 0; sourceRef.current = s.key; load(s.key); },
712
- }, s.label)))
713
- : null,
714
- h('div', { className: 'ar-row', style: { flexWrap: 'wrap' } },
715
- item.owner === 'plugin'
716
- ? (alive
717
- ? h('span', null,
718
- h('button', { className: 'ar-btn', disabled: busy !== '', onClick: () => onRestart(item) }, '重启'),
719
- h('button', { className: 'ar-btn ghost danger', disabled: busy !== '', onClick: () => onStop(item) }, '停止'))
720
- : h('button', { className: 'ar-btn primary', disabled: busy !== '', onClick: () => onRestart(item) }, '启动'))
721
- : h('span', null,
722
- // 已忽略的服务只提供「取消忽略」:它不在正常列表里,直接给「接管启动」容易误点
723
- item.ignored
724
- ? h('button', { className: 'ar-btn ghost', onClick: () => onUnignore(item) }, '取消忽略')
725
- : h('span', null,
726
- h('button', { className: 'ar-btn primary', disabled: busy !== '', onClick: () => onTakeOver(item) }, '接管启动'),
727
- h('button', { className: 'ar-btn ghost', onClick: () => onIgnore(item) }, '忽略'))),
728
- h('span', { className: 'ar-spacer' }),
729
- detail && detail.cwd ? h('span', { className: 'ar-hint ar-mono ar-cut', title: detail.cwd }, detail.cwd) : null),
730
- // 日志视图开关:行数(首屏读多少)与自动换行,尽量贴近 IDEA 控制台
731
- h('div', { className: 'ar-row', style: { flexWrap: 'wrap' } },
732
- h('span', { className: 'ar-hint' }, '行数'),
733
- [100, 200, 500, 1000].map((n) => h('button', {
734
- key: n,
735
- className: `ar-btn ${lines === n ? 'tonal' : 'ghost'}`,
736
- title: `从文件末尾读最近 ${n} 行`,
737
- onClick: () => { setLines(n); load(sourceRef.current, n); },
738
- }, String(n))),
739
- h('input', {
740
- className: 'ar-in ar-mono',
741
- style: { width: '64px', flex: 'none' },
742
- value: linesText,
743
- placeholder: '自定义',
744
- title: '自己填行数,回车生效(1–5000)',
745
- onChange: (e) => setLinesText(e.target.value.replace(/[^\d]/g, '')),
746
- onKeyDown: (e) => { if (e.key === 'Enter') applyLines(); },
747
- onBlur: applyLines,
748
- }),
749
- h('span', { className: 'ar-spacer' }),
750
- h('button', {
751
- className: `ar-btn ${wrap ? 'tonal' : 'ghost'}`,
752
- title: '长日志行是否折行显示(关闭后横向滚动,更接近 IDEA 的竖排对齐)',
753
- onClick: () => setWrap(!wrap),
754
- }, wrap ? '自动换行 开' : '自动换行 关')),
755
- h(Banner, { kind: error ? 'err' : '', text: error, onClose: () => setError('') }),
756
- h(Banner, { text: detail && detail.note ? detail.note : '' }),
757
- loading && !detail ? h('div', { className: 'ar-hint' }, '读取中…') : null,
758
- h('pre', { className: wrap ? 'ar-log' : 'ar-log nowrap', ref: boxRef }, shown),
759
- h('div', { className: 'ar-hint ar-mono ar-cut', title: (detail && detail.log && detail.log.logPath) || '' },
760
- detail && detail.log && detail.log.logPath
761
- ? `${detail.log.logPath}${detail.log.size ? ` · ${(detail.log.size / 1024).toFixed(1)} KB` : ''}`
762
- : '没有可读的日志文件'));
763
- }
764
-
765
- function ResultCard(props) {
766
- const { result, onClose } = props;
767
- const payload = result.payload;
768
- const [view, setView] = React.useState('body');
769
- const ok = Boolean(payload.ok);
770
- const json = payload.json !== undefined ? payload.json : payload.body;
771
- const views = [
772
- { id: 'body', label: '响应' },
773
- { id: 'headers', label: '响应头' },
774
- { id: 'request', label: '请求' },
775
- ];
776
- const content = view === 'headers'
777
- ? pretty(payload.headers || {})
778
- : view === 'request'
779
- ? pretty({ method: payload.request?.method || '', url: payload.request?.url || payload.url || '', headers: payload.request?.headers || {} })
780
- : payload.error
781
- ? String(payload.error)
782
- : json !== undefined ? pretty(json) : String(payload.body || '');
783
- return h('div', { className: 'ar-card' },
784
- h('div', { className: 'ar-row', style: { flexWrap: 'wrap' } },
785
- h('span', { className: `ar-badge ${ok ? 'ok' : 'err'}` }, ok ? `HTTP ${payload.status}` : '失败'),
786
- typeof payload.durationMs === 'number' ? h('span', { className: 'ar-hint ar-mono' }, `${payload.durationMs} ms`) : null,
787
- h('span', { className: 'ar-hint ar-mono' }, byteSize(json !== undefined ? json : payload.body)),
788
- h('span', { className: 'ar-spacer' }),
789
- h('div', { className: 'ar-seg' }, views.map((v) => h('button', { key: v.id, className: view === v.id ? 'on' : '', onClick: () => setView(v.id) }, v.label))),
790
- h('button', { className: 'ar-btn ghost icon', title: '复制这段内容', onClick: () => copyText(content) }, '⧉'),
791
- h('button', { className: 'ar-btn ghost icon', title: '关闭', onClick: onClose }, '×')),
792
- h('div', { className: 'ar-row', style: { minWidth: 0 } },
793
- h('span', { className: methodClass(payload.method) }, payload.method || 'GET'),
794
- h('div', { style: { flex: '1 1 auto', minWidth: 0 } }, h(Url, { value: payload.url || payload.request?.url || '' })),
795
- h('span', { className: 'ar-hint' }, result.apiId)),
796
- payload.message ? h('div', { className: `ar-banner ${payload.ok ? 'ok' : 'err'}` }, payload.message) : null,
797
- payload.token ? h('div', { className: 'ar-hint ar-mono' }, `token ${payload.token.masked} · 来自 ${payload.token.from} · 注入 ${payload.token.header}`) : null,
798
- h('pre', { className: 'ar-pre' }, content));
799
- }
800
-
801
- // ---------------------------------------------------------------- 面板
802
- function Panel(props) {
803
- const scope = props.scope || {};
804
- const visible = props.visible !== false;
805
- const cwd = scope.cwd || scope.workspacePath || '';
806
- const sessionId = scope.sessionId || '';
807
-
808
- const [tab, setTab] = React.useState('apis');
809
- const [state, setState] = React.useState(null);
810
- const [java, setJava] = React.useState(null);
811
- const [error, setError] = React.useState('');
812
- const [notice, setNotice] = React.useState('');
813
- const [busy, setBusy] = React.useState('');
814
- const [form, setForm] = React.useState(null);
815
- const [result, setResult] = React.useState(null);
816
- const [baseUrl, setBaseUrl] = React.useState('');
817
- const [draft, setDraft] = React.useState('');
818
- const [filter, setFilter] = React.useState('');
819
- const [detail, setDetail] = React.useState(null);
820
- /** 接口页二级页签选中的项目:'' 表示跟着会话工作区走 */
821
- const [projectTab, setProjectTab] = React.useState('');
822
- /** 服务页二级页签:已接管 / 运行中 / 已保存 / 已忽略 */
823
- const [group, setGroup] = React.useState('managed');
824
- /** 用户点过页签之后就不要自动跳了 */
825
- const groupTouched = React.useRef(false);
826
- const [settings, setSettings] = React.useState(null);
827
- const [dataDirDraft, setDataDirDraft] = React.useState('');
828
- const [workspaceDraft, setWorkspaceDraft] = React.useState('');
829
- const [mavenHomeDraft, setMavenHomeDraft] = React.useState('');
830
-
831
- const headers = React.useMemo(() => ({ 'Content-Type': 'application/json', 'x-dsh-session-id': sessionId }), [sessionId]);
832
- const get = React.useCallback(async (path) => {
833
- const sep = path.includes('?') ? '&' : '?';
834
- return request(`${path}${sep}cwd=${encodeURIComponent(cwd)}`, { headers });
835
- }, [cwd, headers]);
836
- const post = React.useCallback(async (path, body) => {
837
- return request(path, { method: 'POST', headers, body: JSON.stringify({ ...(body || {}), cwd }) });
838
- }, [cwd, headers]);
839
-
840
- /**
841
- * 接口相关的请求带上「接口页」选中的项目。
842
- *
843
- * 只有接口这套(ui / call / login)跟项目走;服务与设置永远是会话工作区的事,
844
- * 否则切到别的项目时「服务」页会突然变空、「设置」也会读到别的项目的值。
845
- */
846
- const withProject = (path) => {
847
- if (!projectTab) return path;
848
- const sep = path.includes('?') ? '&' : '?';
849
- return `${path}${sep}project=${encodeURIComponent(projectTab)}`;
850
- };
851
- const getApi = React.useCallback(async (path) => {
852
- const sep = path.includes('?') ? '&' : '?';
853
- return request(withProject(`${path}${sep}cwd=${encodeURIComponent(cwd)}`), { headers });
854
- }, [cwd, headers, projectTab]);
855
- const postApi = React.useCallback(async (path, body) => {
856
- const extra = projectTab ? { project: projectTab } : {};
857
- return request(path, { method: 'POST', headers, body: JSON.stringify({ ...(body || {}), ...extra, cwd }) });
858
- }, [cwd, headers, projectTab]);
859
-
860
- const refresh = React.useCallback(async () => {
861
- try {
862
- const data = await getApi('ui');
863
- setState(data);
864
- setBaseUrl(data.baseUrl || '');
865
- setDraft(data.baseUrl || '');
866
- setError('');
867
- } catch (e) {
868
- setError(e.message);
869
- }
870
- }, [getApi]);
871
-
872
- const refreshJava = React.useCallback(async () => {
873
- try {
874
- const data = await get('java');
875
- setJava(data);
876
- return data;
877
- } catch (e) {
878
- setJava({ ok: false, message: e.message, processes: [] });
879
- return null;
880
- }
881
- }, [get]);
882
-
883
- const refreshSettings = React.useCallback(async () => {
884
- try {
885
- const data = await get('settings');
886
- setSettings(data);
887
- setDataDirDraft(data.dataDir || '');
888
- setWorkspaceDraft(data.workspace || '');
889
- setMavenHomeDraft(data.mavenHome || '');
890
- } catch (e) {
891
- setSettings({ ok: false, message: e.message });
892
- }
893
- }, [get]);
894
-
895
- React.useEffect(() => {
896
- if (!visible) return undefined;
897
- refresh();
898
- refreshJava();
899
- refreshSettings();
900
- const timer = setInterval(() => { refreshJava(); }, 5000);
901
- return () => clearInterval(timer);
902
- }, [visible, refresh, refreshJava, refreshSettings]);
903
-
904
- // 成功提示过几秒自己消失,别一直挂在顶上;出错提示留着,等用户处理或手动关掉
905
- React.useEffect(() => {
906
- if (!notice) return undefined;
907
- const timer = setTimeout(() => setNotice(''), 6000);
908
- return () => clearTimeout(timer);
909
- }, [notice]);
910
-
911
- const apis = (state && state.apis) || [];
912
- const tokens = (state && state.tokens) || {};
913
- const processes = (java && java.processes) || [];
914
- /** 已保存的配方:没有进程也能启动 */
915
- const savedList = (java && java.saved) || [];
916
- const keyword = filter.trim().toLowerCase();
917
- const shown = keyword === '' ? apis : apis.filter((api) => `${api.id} ${api.name || ''} ${api.url}`.toLowerCase().includes(keyword));
918
- /**
919
- * 接口页按项目分类:页签列的是所有已知项目(会话工作区、设置里的默认工作区、
920
- * 配过接口的项目),切过去就是真的切过去——接口、token、baseUrl 都换,
921
- * 在那边点测试/登录/新增/编辑/删除也作用于那个项目自己的配置。
922
- */
923
- const allProjects = (state && Array.isArray(state.projects)) ? state.projects : [];
924
- const cwdProjectKey = (state && state.cwdProjectKey) || '';
925
- /** '' 表示跟随会话工作区;有选中就以选中为准 */
926
- const activeKey = projectTab || cwdProjectKey;
927
- /** 选中的项目从清单里消失(目录删了、配置清了)就退回跟随工作区 */
928
- React.useEffect(() => {
929
- if (projectTab !== '' && !allProjects.some((item) => item.key === projectTab)) setProjectTab('');
930
- }, [projectTab, allProjects.length]);
931
- /** 服务分三组:插件接管过的 / 正在运行的(IDEA 或其它)/ 已忽略的 */
932
- const managedList = processes.filter((item) => item.owner === 'plugin');
933
- const ignoredList = processes.filter((item) => item.ignored);
934
- const runningList = processes.filter((item) => item.owner !== 'plugin' && !item.ignored);
935
-
936
- // 一进来「已接管」是空的、但确实有进程在跑,就直接落在「运行中」,省一次点击;
937
- // 用户自己点过页签之后就不再干预。
938
- React.useEffect(() => {
939
- if (groupTouched.current) return;
940
- if (managedList.length === 0 && runningList.length > 0) setGroup('running');
941
- }, [managedList.length, runningList.length]);
942
-
943
- const saveApi = async (value) => {
944
- setBusy('save');
945
- setError('');
946
- try {
947
- const api = {
948
- id: value.id.trim(),
949
- name: value.name.trim() || value.id.trim(),
950
- method: value.method,
951
- url: value.url.trim(),
952
- baseUrl: value.baseUrl.trim(),
953
- description: value.description.trim(),
954
- headers: parseJsonField(value.headersText, '请求头') || {},
955
- body: parseJsonField(value.bodyText, 'body'),
956
- sampleParams: parseJsonField(value.sampleText, '示例参数') || {},
957
- useToken: value.useToken,
958
- timeoutMs: Number(value.timeoutMs) || 30000,
959
- login: value.isLogin
960
- ? { tokenPath: value.tokenPath.trim() || 'token', header: value.authHeader.trim() || 'Authorization', prefix: value.prefix }
961
- : undefined,
962
- };
963
- if (!api.id) throw new Error('id 不能为空');
964
- if (!api.url) throw new Error('url 不能为空');
965
- const res = await postApi('ui/api', { api });
966
- if (res.ok === false) throw new Error(res.message || '保存失败');
967
- setForm(null);
968
- setNotice(res.message || '已保存');
969
- await refresh();
970
- } catch (e) {
971
- setError(e.message);
972
- } finally {
973
- setBusy('');
974
- }
975
- };
976
-
977
- const deleteApi = async (api) => {
978
- if (!window.confirm(`删除接口 ${api.id}?`)) return;
979
- setBusy('delete');
980
- try {
981
- const res = await postApi('ui/api/delete', { id: api.id });
982
- if (res.ok === false) throw new Error(res.message || '删除失败');
983
- if (result && result.apiId === api.id) setResult(null);
984
- setNotice(`已删除 ${api.id}`);
985
- await refresh();
986
- } catch (e) {
987
- setError(e.message);
988
- } finally {
989
- setBusy('');
990
- }
991
- };
992
-
993
- const doCall = async (api, mode) => {
994
- setBusy(mode === 'login' ? 'login' : 'call');
995
- setError('');
996
- setNotice('');
997
- try {
998
- const params = parseJsonField(api.sampleText, '示例参数') || {};
999
- const payload = mode === 'login'
1000
- ? await postApi('login', { api_id: api.id, params })
1001
- : await postApi('call', { api_id: api.id, params });
1002
- setResult({ apiId: api.id, mode, payload });
1003
- if (payload.ok && mode === 'login') {
1004
- setNotice(payload.message || '已保存 token');
1005
- await refresh();
1006
- }
1007
- } catch (e) {
1008
- setError(e.message);
1009
- } finally {
1010
- setBusy('');
1011
- }
1012
- };
1013
-
1014
- const clearToken = async (loginId) => {
1015
- try {
1016
- await postApi('ui/token/clear', { login_id: loginId });
1017
- setNotice(`已清除 ${loginId} 的 token`);
1018
- await refresh();
1019
- } catch (e) {
1020
- setError(e.message);
1021
- }
1022
- };
1023
-
1024
- const saveBaseUrl = async () => {
1025
- try {
1026
- await postApi('ui/base-url', { baseUrl: draft });
1027
- setNotice('baseUrl 已保存');
1028
- await refresh();
1029
- } catch (e) {
1030
- setError(e.message);
1031
- }
1032
- };
1033
-
1034
- const useBaseUrl = async (item) => {
1035
- try {
1036
- await postApi('ui/base-url', { baseUrl: item.baseUrl });
1037
- setNotice(`baseUrl 已设为 ${item.baseUrl}`);
1038
- await refresh();
1039
- } catch (e) {
1040
- setError(e.message);
1041
- }
1042
- };
1043
-
1044
- /** 接管:结束 IDEA 的进程,编译后由插件拉起;之后这个服务就归插件管 */
1045
- const takeOver = async (item) => {
1046
- const ok = window.confirm(
1047
- `接管 ${item.name}?\n\n` +
1048
- `1. 先编译(mvn compile,可能要几十秒;失败则不动原进程)\n` +
1049
- `2. 结束 IDEA 起的进程 pid ${item.pid}\n` +
1050
- `3. 由插件用同样的启动参数重新拉起,日志写到 ${item.managedLogPath || '本地文件'}\n\n` +
1051
- `之后这个服务在 IDEA 里会显示为已退出,停止/重启请在插件里操作。`,
1052
- );
1053
- if (!ok) return;
1054
- setBusy('takeover');
1055
- setError('');
1056
- setNotice('');
1057
- try {
1058
- const payload = await post('java/start', { pid: item.pid, port: item.httpPort || item.primaryPort || 0, compile: true });
1059
- if (payload.ok) {
1060
- setNotice(payload.note || `已接管 ${item.name}`);
1061
- await refreshJava();
1062
- setDetail({ runId: payload.id || item.name, name: item.name, pid: payload.pid, owner: 'plugin', alive: true });
1063
- } else {
1064
- setError(payload.message || '接管失败');
1065
- }
1066
- await refresh();
1067
- } catch (e) {
1068
- setError(e.message);
1069
- } finally {
1070
- setBusy('');
1071
- }
1072
- };
1073
-
1074
- const stopRun = async (item) => {
1075
- if (!window.confirm(`停止 ${item.name}(pid ${item.pid})?之后再启动请回这里点「启动」。`)) return;
1076
- setBusy('stop');
1077
- try {
1078
- const payload = await post('java/stop', { id: item.runId || item.pid });
1079
- if (payload.ok) setNotice(payload.message || '已停止');
1080
- else setError(payload.message || '停止失败');
1081
- await refreshJava();
1082
- } catch (e) {
1083
- setError(e.message);
1084
- } finally {
1085
- setBusy('');
1086
- }
1087
- };
1088
-
1089
- const restartRun = async (item) => {
1090
- setBusy('restart');
1091
- setError('');
1092
- try {
1093
- const payload = await post('java/restart', { id: item.runId || item.pid, compile: true });
1094
- if (payload.ok) setNotice(`已重启 ${item.name}(pid ${payload.pid})`);
1095
- else setError(payload.message || '重启失败');
1096
- await refreshJava();
1097
- } catch (e) {
1098
- setError(e.message);
1099
- } finally {
1100
- setBusy('');
1101
- }
1102
- };
1103
-
1104
- /** 忽略:只记进项目配置,不动进程本身 */
1105
- const ignoreJava = async (item) => {
1106
- try {
1107
- const payload = await post('java/ignore', { item: { mainClass: item.mainClass, name: item.name, kind: item.kind } });
1108
- if (payload.ok) setNotice(payload.message || `已忽略 ${item.name}`);
1109
- else setError(payload.message || '忽略失败');
1110
- await refreshJava();
1111
- await refresh();
1112
- } catch (e) {
1113
- setError(e.message);
1114
- }
1115
- };
1116
-
1117
- const unignoreJava = async (item) => {
1118
- try {
1119
- const payload = await post('java/unignore', { item: { mainClass: item.mainClass, name: item.name, kind: item.kind } });
1120
- if (payload.ok) setNotice(payload.message || `已取消忽略 ${item.name}`);
1121
- else setError(payload.message || '取消忽略失败');
1122
- await refreshJava();
1123
- await refresh();
1124
- } catch (e) {
1125
- setError(e.message);
1126
- }
1127
- };
1128
-
1129
- /** 保存配方:把这套启动参数记下来,以后不用先在 IDEA 里跑 */
1130
- const saveJava = async (item) => {
1131
- setBusy('save');
1132
- setError('');
1133
- try {
1134
- const payload = await post('java/save', { id: item.runId || item.pid, pid: item.pid });
1135
- if (payload.ok) setNotice(payload.message || '已保存');
1136
- else setError(payload.message || '保存失败');
1137
- await refreshJava();
1138
- } catch (e) {
1139
- setError(e.message);
1140
- } finally {
1141
- setBusy('');
1142
- }
1143
- };
1144
-
1145
- const startSaved = async (item) => {
1146
- setBusy('start-saved');
1147
- setError('');
1148
- setNotice('');
1149
- try {
1150
- const payload = await post('java/start-saved', { id: item.id });
1151
- if (payload.ok) {
1152
- setNotice(payload.note || `已启动 ${item.id}(pid ${payload.pid})`);
1153
- await refreshJava();
1154
- } else {
1155
- setError(payload.message || '启动失败');
1156
- }
1157
- } catch (e) {
1158
- setError(e.message);
1159
- } finally {
1160
- setBusy('');
1161
- }
1162
- };
1163
-
1164
- const deleteSaved = async (item) => {
1165
- if (!window.confirm(`删除已保存的服务 ${item.id}?(只删除启动配方,不影响正在运行的进程)`)) return;
1166
- setBusy('delete-saved');
1167
- try {
1168
- const payload = await post('java/unsave', { id: item.id });
1169
- if (payload.ok) setNotice(`已删除 ${item.id}`);
1170
- else setError(payload.message || '删除失败');
1171
- await refreshJava();
1172
- } catch (e) {
1173
- setError(e.message);
1174
- } finally {
1175
- setBusy('');
1176
- }
1177
- };
1178
-
1179
- const saveSettings = async (patch, okText) => {
1180
- setBusy('settings');
1181
- setError('');
1182
- try {
1183
- const payload = await post('settings', patch);
1184
- if (!payload.ok) throw new Error(payload.message || '保存失败');
1185
- setNotice(okText);
1186
- await refreshSettings();
1187
- await refreshJava();
1188
- } catch (e) {
1189
- setError(e.message);
1190
- } finally {
1191
- setBusy('');
1192
- }
1193
- };
1194
-
1195
- const tabs = [
1196
- { id: 'apis', label: '接口', count: apis.length },
1197
- { id: 'services', label: '服务', count: runningList.length },
1198
- { id: 'settings', label: '设置', count: 0 },
1199
- ];
1200
-
1201
- const apisPane = h('div', { className: 'ar-pane' },
1202
- // 项目分段控件:只有一个项目时没有分类的意义,整排省掉,别白占一行
1203
- allProjects.length > 1
1204
- ? h('div', { className: 'ar-tabs sub ar-projects' },
1205
- allProjects.map((item) => {
1206
- const isCurrent = item.key === activeKey;
1207
- const label = projectLabel(item.key, allProjects.map((p) => p.key));
1208
- const title = `${item.key}${item.key === cwdProjectKey ? '(会话当前工作区)' : ''}`;
1209
- return h('button', {
1210
- key: item.key,
1211
- className: isCurrent ? 'ar-tab on' : 'ar-tab',
1212
- title,
1213
- onClick: () => { setForm(null); setProjectTab(item.key === cwdProjectKey ? '' : item.key); },
1214
- }, h('span', { className: 'l' }, label), item.apiCount > 0 ? h('span', { className: 'ar-count' }, item.apiCount) : null);
1215
- }))
1216
- : null,
1217
- h('div', { className: 'ar-row' },
1218
- h('input', { className: 'ar-in', value: filter, placeholder: '筛选接口…', onChange: (e) => setFilter(e.target.value) }),
1219
- form ? null : h('button', { className: 'ar-btn tonal', onClick: () => setForm(emptyForm(baseUrl)) }, '新增')),
1220
- form ? h(EditForm, { value: form, onChange: setForm, onSave: saveApi, onCancel: () => setForm(null), tokens, busy }) : null,
1221
- apis.length === 0 && !form
1222
- ? h(Empty, {
1223
- icon: '⌁',
1224
- title: '还没有接口',
1225
- hint: '点「新增」在面板里配一个,或在 .dsh/api-runner.yml 里写(YAML 里的接口只能改文件)。',
1226
- action: h('button', { className: 'ar-btn primary', style: { marginTop: '2px' }, onClick: () => setForm(emptyForm(baseUrl)) }, '新增接口'),
1227
- })
1228
- : shown.length === 0
1229
- ? h('div', { className: 'ar-hint', style: { padding: '8px 2px' } }, `没有匹配「${filter}」的接口。`)
1230
- : h('div', { className: 'ar-group' }, shown.map((api) => h(ApiRow, {
1231
- key: api.id, api, tokens, busy,
1232
- onTest: (item) => doCall(item, 'call'),
1233
- onLogin: (item) => doCall(item, 'login'),
1234
- onEdit: (item) => setForm(formFromApi(item)),
1235
- onDelete: deleteApi,
1236
- onClearToken: clearToken,
1237
- }))),
1238
- result ? h(ResultCard, { result, onClose: () => setResult(null) }) : null);
1239
-
1240
- const servicesPane = detail
1241
- ? h(ServiceDetail, {
1242
- item: detail,
1243
- visible,
1244
- get,
1245
- busy,
1246
- onClose: () => { setDetail(null); refreshJava(); },
1247
- onStop: async (item) => { await stopRun(item); await refreshJava(); },
1248
- onRestart: async (item) => { await restartRun(item); },
1249
- onTakeOver: takeOver,
1250
- onIgnore: ignoreJava,
1251
- onUnignore: unignoreJava,
1252
- })
1253
- : h('div', { className: 'ar-pane' },
1254
- h('div', { className: 'ar-row' },
1255
- // 二级页签:和顶部「接口/服务/设置」同一套分段控件,只是小一号
1256
- h('div', { className: 'ar-tabs sub' }, [
1257
- { id: 'managed', label: '已接管', count: managedList.length },
1258
- { id: 'running', label: '运行中', count: runningList.length },
1259
- { id: 'saved', label: '已保存', count: savedList.length },
1260
- { id: 'ignored', label: '已忽略', count: ignoredList.length },
1261
- ].map((item) => h('button', {
1262
- key: item.id,
1263
- className: item.id === group ? 'ar-tab on' : 'ar-tab',
1264
- onClick: () => { groupTouched.current = true; setGroup(item.id); },
1265
- }, item.label, item.count > 0 ? h('span', { className: 'ar-count' }, item.count) : null))),
1266
- h('button', { className: 'ar-btn ghost icon', disabled: busy !== '', title: '重新扫描 Java 进程', onClick: refreshJava }, '↻')),
1267
- h(Banner, { kind: 'err', text: java && java.ok === false ? (java.message || '无法读取 Java 进程') : '' }),
1268
-
1269
- group === 'managed'
1270
- ? (managedList.length === 0
1271
- ? h(Empty, {
1272
- icon: '☕',
1273
- title: '还没有接管任何服务',
1274
- hint: '在「正在运行」里选一个 IDEA 起的服务,点「接管启动」,之后就能在这里停止、重启、看日志。',
1275
- action: h('button', { className: 'ar-btn tonal', style: { marginTop: '2px' }, onClick: () => setGroup('running') }, '去看正在运行的服务'),
1276
- })
1277
- : h('div', { className: 'ar-group' }, managedList.map((item) => h(JavaRow, {
1278
- key: `plugin-${item.runId || item.pid}`,
1279
- item,
1280
- busy,
1281
- onUseBaseUrl: useBaseUrl,
1282
- onTakeOver: takeOver,
1283
- onStop: stopRun,
1284
- onRestart: restartRun,
1285
- onDetail: (target) => setDetail(target),
1286
- onIgnore: ignoreJava,
1287
- onUnignore: unignoreJava,
1288
- onSave: saveJava,
1289
- }))))
1290
- : null,
1291
-
1292
- group === 'running'
1293
- ? (runningList.length === 0
1294
- ? h(Empty, {
1295
- icon: '☕',
1296
- title: '没有运行中的 Java 服务',
1297
- hint: '在 IDEA 里启动一个 Spring Boot 应用,再回来看;已经保存过启动参数的服务可以在「已保存」里直接启动。',
1298
- })
1299
- : h('div', { className: 'ar-group' }, runningList.map((item) => h(JavaRow, {
1300
- key: `run-${item.owner}-${item.runId || item.pid}`,
1301
- item,
1302
- busy,
1303
- onUseBaseUrl: useBaseUrl,
1304
- onTakeOver: takeOver,
1305
- onStop: stopRun,
1306
- onRestart: restartRun,
1307
- onDetail: (target) => setDetail(target),
1308
- onIgnore: ignoreJava,
1309
- onUnignore: unignoreJava,
1310
- onSave: saveJava,
1311
- }))))
1312
- : null,
1313
-
1314
- group === 'saved'
1315
- ? (savedList.length === 0
1316
- ? h(Empty, {
1317
- icon: '☕',
1318
- title: '还没有保存的服务',
1319
- hint: '在「正在运行」里选一个服务点「保存」,把它那套启动参数(classpath/主类/JVM 参数)记下来。以后不用先在 IDEA 里跑,直接在这里启动。',
1320
- action: h('button', { className: 'ar-btn tonal', style: { marginTop: '2px' }, onClick: () => setGroup('running') }, '去看正在运行的服务'),
1321
- })
1322
- : h('div', { className: 'ar-group' }, savedList.map((item) => h(SavedRow, {
1323
- key: `saved-${item.id}`,
1324
- item,
1325
- busy,
1326
- onStart: startSaved,
1327
- onDelete: deleteSaved,
1328
- }))))
1329
- : null,
1330
-
1331
- group === 'ignored'
1332
- ? (ignoredList.length === 0
1333
- ? h(Empty, { icon: '•', title: '没有已忽略的服务', hint: '不想在「正在运行」里看到的服务,点它右边的「忽略」就会挪到这里,不影响进程本身。' })
1334
- : h('div', { className: 'ar-group' }, ignoredList.map((item) => h(JavaRow, {
1335
- key: `ignored-${item.owner}-${item.runId || item.pid}`,
1336
- item,
1337
- muted: true,
1338
- busy,
1339
- onUseBaseUrl: useBaseUrl,
1340
- onTakeOver: takeOver,
1341
- onStop: stopRun,
1342
- onRestart: restartRun,
1343
- onDetail: (target) => setDetail(target),
1344
- onIgnore: ignoreJava,
1345
- onUnignore: unignoreJava,
1346
- onSave: saveJava,
1347
- }))))
1348
- : null);
1349
-
1350
- const settingsPane = h('div', { className: 'ar-pane' },
1351
- h('div', null,
1352
- h('div', { className: 'ar-sec' }, h('span', {}, '数据目录')),
1353
- h('div', { className: 'ar-card' },
1354
- h('div', { className: 'ar-hint' },
1355
- '插件自己的东西(服务日志、状态、面板配置)放这里。默认在 DSH_HOME 下,想换盘就填个绝对路径,保存后新日志立刻写过去。'),
1356
- h('div', { className: 'ar-row' },
1357
- h('input', {
1358
- className: 'ar-in ar-mono',
1359
- value: dataDirDraft,
1360
- placeholder: settings?.effectiveDataDir || '绝对路径,例如 <你的目录>/dsh-api-runner-data',
1361
- onChange: (e) => setDataDirDraft(e.target.value),
1362
- }),
1363
- h('button', {
1364
- className: 'ar-btn primary',
1365
- disabled: busy !== '' || dataDirDraft === (settings?.dataDir || ''),
1366
- onClick: () => saveSettings({ dataDir: dataDirDraft }, '数据目录已保存'),
1367
- }, '保存'),
1368
- dataDirDraft ? h('button', {
1369
- className: 'ar-btn ghost',
1370
- title: '改回默认(DSH_HOME 下)',
1371
- onClick: () => { setDataDirDraft(''); saveSettings({ dataDir: '' }, '已改回默认数据目录'); },
1372
- }, '默认') : null),
1373
- settings && settings.effectiveDataDir
1374
- ? h('div', { className: 'ar-kv' }, h('span', { className: 'k' }, '当前'), h('span', { className: 'v ar-mono ar-muted ar-cut', title: settings.effectiveDataDir }, settings.effectiveDataDir))
1375
- : null,
1376
- settings && settings.settingsFile
1377
- ? h('div', { className: 'ar-kv' }, h('span', { className: 'k' }, '设置文件'), h('span', { className: 'v ar-mono ar-muted ar-cut', title: settings.settingsFile }, settings.settingsFile))
1378
- : null)),
1379
- h('div', null,
1380
- h('div', { className: 'ar-sec' }, h('span', {}, '默认工作区')),
1381
- h('div', { className: 'ar-card' },
1382
- h('div', { className: 'ar-hint' },
1383
- '在没有会话上下文时(比如直接调接口、从工具里启动服务)用它当项目目录。填你的项目根目录,日志和面板配置就会按它归档。'),
1384
- h('div', { className: 'ar-row' },
1385
- h('input', {
1386
- className: 'ar-in ar-mono',
1387
- value: workspaceDraft,
1388
- placeholder: '你的项目根目录的绝对路径',
1389
- onChange: (e) => setWorkspaceDraft(e.target.value),
1390
- }),
1391
- h('button', {
1392
- className: 'ar-btn primary',
1393
- disabled: busy !== '' || workspaceDraft === (settings?.workspace || ''),
1394
- onClick: () => saveSettings({ workspace: workspaceDraft }, '默认工作区已保存'),
1395
- }, '保存')))),
1396
- h('div', null,
1397
- h('div', { className: 'ar-sec' }, h('span', {}, 'Maven 主路径')),
1398
- h('div', { className: 'ar-card' },
1399
- h('div', { className: 'ar-hint' },
1400
- '接管启动前编译 Java 服务用。留空会自动探测:MAVEN_HOME → PATH → IDEA 自带 Maven(并读取 .idea 里的 settings.xml 和本地仓库)。'),
1401
- h('div', { className: 'ar-row' },
1402
- h('input', {
1403
- className: 'ar-in ar-mono',
1404
- value: mavenHomeDraft,
1405
- placeholder: 'Maven 安装目录(到根为止,不含 bin)',
1406
- onChange: (e) => setMavenHomeDraft(e.target.value),
1407
- }),
1408
- h('button', {
1409
- className: 'ar-btn primary',
1410
- disabled: busy !== '' || mavenHomeDraft === (settings?.mavenHome || ''),
1411
- onClick: () => saveSettings({ mavenHome: mavenHomeDraft }, 'Maven 主路径已保存'),
1412
- }, '保存'),
1413
- mavenHomeDraft ? h('button', {
1414
- className: 'ar-btn ghost',
1415
- title: '清空后恢复自动探测',
1416
- onClick: () => { setMavenHomeDraft(''); saveSettings({ mavenHome: '' }, '已改回自动探测'); },
1417
- }, '自动') : null))),
1418
- h('div', null,
1419
- h('div', { className: 'ar-sec' }, h('span', {}, '接口基地址')),
1420
- h('div', { className: 'ar-card' },
1421
- h('div', { className: 'ar-hint' }, '接口自己没填 baseUrl 时用它。在「服务」页签点一下,就能直接设成某个 Java 服务的端口。'),
1422
- h('div', { className: 'ar-row' },
1423
- h('input', { className: 'ar-in ar-mono', value: draft, placeholder: 'http://127.0.0.1:8080', onChange: (e) => setDraft(e.target.value) }),
1424
- h('button', { className: 'ar-btn primary', disabled: draft === baseUrl, onClick: saveBaseUrl }, '保存')))),
1425
- h('div', null,
1426
- h('div', { className: 'ar-sec' }, h('span', {}, `已保存 token · ${Object.keys(tokens).length}`)),
1427
- h('div', { className: 'ar-card' },
1428
- Object.keys(tokens).length === 0
1429
- ? h('div', { className: 'ar-hint' }, '还没有 token。把登录接口勾成「登录接口」,再点「登录并保存」。')
1430
- : Object.entries(tokens).map(([id, item]) => h('div', { key: id, className: 'ar-row' },
1431
- h('span', { className: 'ar-name', style: { flex: '0 0 auto' } }, id),
1432
- h('span', { className: 'ar-mono ar-muted ar-cut', style: { flex: '1 1 auto', fontSize: '12px' } }, item.masked),
1433
- h('button', { className: 'ar-btn ghost danger', onClick: () => clearToken(id) }, '清除'))))),
1434
- h('div', null,
1435
- h('div', { className: 'ar-sec' }, h('span', {}, '配置来源')),
1436
- h('div', { className: 'ar-card' },
1437
- h('div', { className: 'ar-kv' }, h('span', { className: 'k' }, '项目'), h('span', { className: 'v ar-mono ar-muted ar-cut', title: cwd }, cwd || '(未取到项目路径,回落到 default)')),
1438
- state && state.projectKey ? h('div', { className: 'ar-kv' }, h('span', { className: 'k' }, 'key'), h('span', { className: 'v ar-mono ar-muted ar-cut' }, state.projectKey)) : null,
1439
- state && state.sources && state.sources.length > 0
1440
- ? state.sources.map((s) => h('div', { key: s, className: 'ar-kv' }, h('span', { className: 'k' }, '来源'), h('span', { className: 'v ar-mono ar-muted ar-cut', title: s }, s)))
1441
- : h('div', { className: 'ar-kv' }, h('span', { className: 'k' }, '来源'), h('span', { className: 'v ar-muted' }, '暂无 YAML 文件,接口全部来自面板')))));
1442
-
1443
- return h('div', { className: 'ar-root' },
1444
- h('div', { className: 'ar-top' },
1445
- h('div', { className: 'ar-tabs' }, tabs.map((item) => h('button', {
1446
- key: item.id,
1447
- className: item.id === tab ? 'ar-tab on' : 'ar-tab',
1448
- onClick: () => setTab(item.id),
1449
- }, item.label, item.count > 0 ? h('span', { className: 'ar-count' }, item.count) : null))),
1450
- h('button', { className: 'ar-btn ghost icon', disabled: busy !== '', title: '重新读取配置与进程列表', onClick: () => { refresh(); refreshJava(); } }, '↻')),
1451
- h('div', { className: 'ar-body' },
1452
- h(Banner, { kind: 'err', text: error, onClose: () => setError('') }),
1453
- h(Banner, { kind: 'ok', text: error ? '' : notice, onClose: () => setNotice('') }),
1454
- tab === 'apis' ? apisPane : null,
1455
- tab === 'services' ? servicesPane : null,
1456
- tab === 'settings' ? settingsPane : null));
1457
- }
1458
-
1459
- /**
1460
- * 设置里的插件主页面。
1461
- *
1462
- * 侧边栏那个页签是「干活的地方」,这里只放全局项:默认工作区与数据目录。
1463
- * 没有会话上下文,cwd 传空串,host 会回落到设置里的默认工作区。
1464
- */
1465
- function SettingsPage() {
1466
- const [settings, setSettings] = React.useState(null);
1467
- const [error, setError] = React.useState('');
1468
- const [notice, setNotice] = React.useState('');
1469
- const [busy, setBusy] = React.useState('');
1470
- const [workspace, setWorkspace] = React.useState('');
1471
- const [dataDir, setDataDir] = React.useState('');
1472
-
1473
- const load = React.useCallback(async () => {
1474
- try {
1475
- const data = await request('settings', { headers: { 'Content-Type': 'application/json' } });
1476
- if (data.ok === false) throw new Error(data.message || '读取设置失败');
1477
- setSettings(data);
1478
- setWorkspace(data.workspace || '');
1479
- setDataDir(data.dataDir || '');
1480
- setError('');
1481
- } catch (e) {
1482
- setError(e.message);
1483
- }
1484
- }, []);
1485
-
1486
- React.useEffect(() => { load(); }, [load]);
1487
-
1488
- const save = async (patch, message) => {
1489
- setBusy('save');
1490
- setError('');
1491
- setNotice('');
1492
- try {
1493
- const data = await request('settings', {
1494
- method: 'POST',
1495
- headers: { 'Content-Type': 'application/json' },
1496
- body: JSON.stringify({ settings: patch }),
1497
- });
1498
- if (data.ok === false) throw new Error(data.message || '保存失败');
1499
- setSettings(data);
1500
- setWorkspace(data.workspace || '');
1501
- setDataDir(data.dataDir || '');
1502
- setNotice(message);
1503
- } catch (e) {
1504
- setError(e.message);
1505
- } finally {
1506
- setBusy('');
1507
- }
1508
- };
1509
-
1510
- return h('div', { className: 'ar-root' },
1511
- h('div', { className: 'ar-body' },
1512
- h(Banner, { kind: 'err', text: error, onClose: () => setError('') }),
1513
- h(Banner, { kind: 'ok', text: error ? '' : notice, onClose: () => setNotice('') }),
1514
- h('div', null,
1515
- h('div', { className: 'ar-sec' }, h('span', { className: 't' }, '默认工作区')),
1516
- h('div', { className: 'ar-card' },
1517
- h('div', { className: 'ar-hint' },
1518
- '没有会话上下文时(直接调接口、从工具里启动服务)用它当项目目录。填你的项目根目录的绝对路径。'),
1519
- h('div', { className: 'ar-row' },
1520
- h('input', {
1521
- className: 'ar-in ar-mono',
1522
- value: workspace,
1523
- placeholder: '你的项目根目录的绝对路径',
1524
- onChange: (e) => setWorkspace(e.target.value),
1525
- }),
1526
- h('button', {
1527
- className: 'ar-btn primary',
1528
- disabled: busy !== '' || workspace === (settings?.workspace || ''),
1529
- onClick: () => save({ workspace }, '默认工作区已保存'),
1530
- }, '保存')))),
1531
- h('div', null,
1532
- h('div', { className: 'ar-sec' }, h('span', { className: 't' }, '数据目录')),
1533
- h('div', { className: 'ar-card' },
1534
- h('div', { className: 'ar-hint' },
1535
- '插件自己产生的东西(服务日志、状态文件、面板配置)放这里。默认在 DSH_HOME 下;想换盘就填绝对路径,保存后新的日志立刻写过去。'),
1536
- h('div', { className: 'ar-row' },
1537
- h('input', {
1538
- className: 'ar-in ar-mono',
1539
- value: dataDir,
1540
- placeholder: settings?.effectiveDataDir || '绝对路径,例如 <你的目录>/dsh-api-runner-data',
1541
- onChange: (e) => setDataDir(e.target.value),
1542
- }),
1543
- h('button', {
1544
- className: 'ar-btn primary',
1545
- disabled: busy !== '' || dataDir === (settings?.dataDir || ''),
1546
- onClick: () => save({ dataDir }, '数据目录已保存'),
1547
- }, '保存'),
1548
- h('button', {
1549
- className: 'ar-btn ghost',
1550
- disabled: busy !== '' || (settings?.dataDir || '') === '',
1551
- onClick: () => save({ dataDir: '' }, '已改回默认数据目录'),
1552
- }, '恢复默认')),
1553
- settings && settings.effectiveDataDir
1554
- ? h('div', { className: 'ar-kv' }, h('span', { className: 'k' }, '当前生效'), h('span', { className: 'v ar-mono ar-muted ar-cut', title: settings.effectiveDataDir }, settings.effectiveDataDir))
1555
- : null,
1556
- settings && settings.settingsFile
1557
- ? h('div', { className: 'ar-kv' }, h('span', { className: 'k' }, '设置文件'), h('span', { className: 'v ar-mono ar-muted ar-cut', title: settings.settingsFile }, settings.settingsFile))
1558
- : null)),
1559
- h('div', null,
1560
- h('div', { className: 'ar-sec' }, h('span', { className: 't' }, '说明')),
1561
- h('div', { className: 'ar-card' },
1562
- h('div', { className: 'ar-hint' },
1563
- '接口、token、已保存的服务都按项目归档,在侧边栏「接口调试」页签里配。这里只放跨项目的全局项。')))));
1564
- }
1565
-
1566
- // ---------------------------------------------------------------- 注册
1567
- //
1568
- // inject 只写 betterSidebar:侧边栏页签是本插件的主入口。
1569
- // 设置页(settings.section)属于可选通道,用 ctx.get('slots') 取——
1570
- // cordis 的 ctx 是 Proxy,直接读 ctx.slots 在没 inject 时会抛错。
1571
- const inject = ['betterSidebar'];
1572
-
1573
- function apply(ctx) {
1574
- installStyles(ctx);
1575
- if (!ctx.betterSidebar) return;
1576
- ctx.effect(() => ctx.betterSidebar.registerTab({
1577
- id: TAB_ID,
1578
- title: () => '接口调试',
1579
- description: '配置接口、登录保存 token、调用测试、查看本机 Java 服务',
1580
- icon: h('span', { style: { fontSize: '13px' } }, '⚡'),
1581
- order: 42,
1582
- single: true,
1583
- component: (props) => h(Panel, { ctx, visible: props.visible, scope: props.scope }),
1584
- }), 'dsh-api-runner: register tab');
1585
-
1586
- ctx.effect(() => {
1587
- const slots = typeof ctx.get === 'function' ? ctx.get('slots') : undefined;
1588
- if (!slots || typeof slots.register !== 'function') return () => {};
1589
- const register = () => slots.register({
1590
- name: 'settings.section',
1591
- id: TAB_ID,
1592
- order: 80,
1593
- label: () => '接口调试',
1594
- }, () => h(SettingsPage, {}));
1595
- const dispose = typeof slots.inject === 'function' ? slots.inject('settings.section', register) : register();
1596
- return typeof dispose === 'function' ? dispose : () => {};
1597
- }, 'dsh-api-runner: settings section');
1598
- }
1599
-
1600
- exports.inject = inject;
1601
- exports.apply = apply;
1602
- return module.exports;
1603
- },
1604
- });
1
+ /**
2
+ * dsh-api-runner 侧边栏面板(Client half)。
3
+ *
4
+ * 这是给 DSH Web 前端加载的**经典脚本**(不是 ESM):由 client-modules 注入
5
+ * window.__ModuleLoader__,我们把自己的 factory 注册进去,由它提供 require。
6
+ * 导出 inject / apply,与 host half 的 ESM 形态不同,不要混用。
7
+ *
8
+ * 视觉:与 dsh-db-tool 一致的「inset grouped / iOS 分段控件」风格——
9
+ * · 顶部整条分段控件(接口 / 服务 / 设置)
10
+ * · 卡片用半透明灰膜 + 大圆角,不用描边
11
+ * · 输入框无边框、填充式;按钮为胶囊填充
12
+ * · 列表一张卡片多行,行间 hairline
13
+ * 颜色:中性色用 rgba 灰膜(明暗主题都成立),强调色/状态色优先取
14
+ * --dsw-alias-* 主题 token,取不到时回落到固定色;不写死正文文字色,直接继承。
15
+ */
16
+ window.__ModuleLoader__.load({
17
+ id: 'dsh-api-runner',
18
+ factory: (require) => {
19
+ var module = { exports: {} };
20
+ var exports = module.exports;
21
+
22
+ const React = require('react');
23
+ const h = React.createElement;
24
+
25
+ /** 面板 id:既是左栏全局页在 `main` 里的 key,也是 `sidebar.panellist` 的 id */
26
+ const TAB_ID = 'dsh-api-runner';
27
+ const API = '/dsh-api-runner/api';
28
+ const STYLE_ID = 'dsh-api-runner-style';
29
+ const METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'];
30
+
31
+ // ---------------------------------------------------------------- 样式
32
+ const CSS = `
33
+ .ar-root { display: flex; flex-direction: column; height: 100%; min-height: 0; min-width: 0; box-sizing: border-box;
34
+ font-size: 13px; line-height: 1.45; -webkit-font-smoothing: antialiased;
35
+ font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif;
36
+ --ar-accent: var(--dsw-alias-brand-primary, #0a84ff);
37
+ --ar-surface: rgba(120, 120, 128, .12);
38
+ --ar-surface-strong: rgba(120, 120, 128, .18);
39
+ --ar-sep: rgba(120, 120, 128, .22);
40
+ --ar-danger: var(--dsw-alias-state-error-primary, #ff453a);
41
+ --ar-success: var(--dsw-alias-state-success-primary, #30d158);
42
+ --ar-warn: var(--dsw-alias-state-warn-primary, #ffd60a);
43
+ --ar-r-card: 12px;
44
+ --ar-r-ctrl: 8px;
45
+ --ar-r-pill: 6px;
46
+ --ar-mono: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
47
+ --ar-ease: cubic-bezier(.25, .1, .25, 1);
48
+ }
49
+ @media (prefers-color-scheme: light) {
50
+ .ar-root { --ar-surface: rgba(120, 120, 128, .09); --ar-surface-strong: rgba(120, 120, 128, .15);
51
+ --ar-sep: rgba(60, 60, 67, .16);
52
+ --ar-danger: var(--dsw-alias-state-error-primary, #ff3b30);
53
+ --ar-success: var(--dsw-alias-state-success-primary, #34c759);
54
+ --ar-warn: var(--dsw-alias-state-warn-primary, #ff9f0a); }
55
+ }
56
+
57
+ /* ---- 顶部:整条分段控件 ---- */
58
+ .ar-top { flex: 0 0 auto; display: flex; align-items: center; gap: 6px; padding: 10px 12px 8px; }
59
+ .ar-tabs { flex: 1 1 auto; display: flex; gap: 2px; padding: 2px; min-width: 0;
60
+ background: var(--ar-surface); border-radius: var(--ar-r-ctrl); }
61
+ .ar-tab { flex: 1 1 0; min-width: 0; display: inline-flex; align-items: center; justify-content: center; gap: 5px;
62
+ border: 0; background: transparent; color: inherit; cursor: pointer; padding: 5px 8px; font: inherit; font-size: 12.5px;
63
+ border-radius: var(--ar-r-pill); white-space: nowrap; opacity: .68;
64
+ transition: background-color .18s var(--ar-ease), opacity .18s var(--ar-ease), box-shadow .18s var(--ar-ease); }
65
+ .ar-tab:hover { opacity: .9; }
66
+ .ar-tab.on { opacity: 1; font-weight: 600; background: var(--ar-surface-strong); box-shadow: 0 1px 3px rgba(0, 0, 0, .12); }
67
+ .ar-count { font-size: 10px; font-weight: 600; line-height: 1; padding: 2px 4px; border-radius: 4px;
68
+ background: var(--ar-surface-strong); opacity: .85; }
69
+ .ar-tab.on .ar-count { background: var(--ar-accent); color: #fff; opacity: 1; }
70
+
71
+ .ar-body { flex: 1 1 auto; min-height: 0; min-width: 0; overflow-y: auto; padding: 2px 12px 24px;
72
+ display: flex; flex-direction: column; gap: 10px; }
73
+ .ar-pane { display: flex; flex-direction: column; gap: 10px; min-width: 0; }
74
+
75
+ /* ---- 排版 ---- */
76
+ .ar-mono { font-family: var(--ar-mono); }
77
+ .ar-muted { opacity: .6; }
78
+ .ar-faint { opacity: .45; }
79
+ .ar-cut { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
80
+ .ar-spacer { flex: 1 1 auto; min-width: 0; }
81
+ .ar-row { display: flex; align-items: center; gap: 6px; }
82
+
83
+ /* 分组标题:一行小标题 + 右延细线 */
84
+ .ar-sec { display: flex; align-items: center; gap: 8px; margin-top: 4px; }
85
+ .ar-sec .t { font-size: 12.5px; font-weight: 600; letter-spacing: .01em; opacity: .72; white-space: nowrap; }
86
+ .ar-sec::after { content: ''; flex: 1 1 auto; height: 1px; background: var(--ar-sep); }
87
+
88
+ /* 二级页签:和顶部同一套分段控件,但用颜色区分层级——
89
+ 顶部(接口/服务/设置)是灰底 + 灰白选中块,二级(已接管/运行中/…、项目分类)去掉灰底容器、
90
+ 选中态用品牌色实心块。这样一眼能看出「当前在哪一层」,不会和顶部混成同一排。
91
+ 注意:选中态用实心 accent 而不是半透明混合,避免依赖 color-mix,浅色/深色主题都稳。 */
92
+ .ar-tabs.sub { flex: none; background: transparent; padding: 0; gap: 4px; }
93
+ .ar-tabs.sub .ar-tab { padding: 4px 8px; font-size: 12px; background: var(--ar-surface); opacity: .72; }
94
+ .ar-tabs.sub .ar-tab:hover { opacity: .95; }
95
+ .ar-tabs.sub .ar-tab.on {
96
+ background: var(--ar-accent); color: #fff; opacity: 1; font-weight: 600;
97
+ box-shadow: 0 1px 3px rgba(0, 0, 0, .18);
98
+ }
99
+ .ar-tabs.sub .ar-count { font-size: 9.5px; padding: 1px 4px; background: var(--ar-surface-strong); opacity: .9; }
100
+ .ar-tabs.sub .ar-tab.on .ar-count { background: rgba(255, 255, 255, .26); color: #fff; opacity: 1; }
101
+ /* 项目多的时候横向滚动,不换行、不压扁页签 */
102
+ .ar-tabs.ar-projects { overflow-x: auto; scrollbar-width: none; }
103
+ .ar-tabs.ar-projects::-webkit-scrollbar { display: none; }
104
+ .ar-tabs.ar-projects .ar-tab { flex: 0 0 auto; max-width: 132px; }
105
+ .ar-tabs.ar-projects .ar-tab .l { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
106
+
107
+ /* ---- inset grouped 卡片 ---- */
108
+ .ar-card { display: flex; flex-direction: column; gap: 9px; padding: 12px;
109
+ background: var(--ar-surface); border: 0; border-radius: var(--ar-r-card); }
110
+ .ar-group { display: flex; flex-direction: column; background: var(--ar-surface);
111
+ border-radius: var(--ar-r-card); overflow: hidden; }
112
+ .ar-item { display: flex; flex-direction: column; gap: 6px; padding: 11px 12px;
113
+ transition: background-color .15s var(--ar-ease); }
114
+ .ar-item + .ar-item { border-top: 1px solid var(--ar-sep); }
115
+ .ar-head { display: flex; align-items: flex-start; gap: 8px; }
116
+ .ar-main { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
117
+ .ar-name { font-size: 13px; font-weight: 600; }
118
+
119
+ /* ---- 徽章 / 方法标 ---- */
120
+ .ar-badge { display: inline-flex; align-items: center; height: 18px; padding: 0 6px; border-radius: var(--ar-r-pill);
121
+ font-size: 11px; font-weight: 500; white-space: nowrap; line-height: 1;
122
+ background: var(--ar-surface-strong); opacity: .9; }
123
+ .ar-badge.ok { color: var(--ar-success); background: rgba(48, 209, 88, .14); opacity: 1; }
124
+ .ar-badge.warn { color: var(--ar-warn); background: rgba(255, 214, 10, .14); opacity: 1; }
125
+ .ar-badge.err { color: var(--ar-danger); background: rgba(255, 69, 58, .14); opacity: 1; }
126
+ .ar-badge.brand { color: var(--ar-accent); background: rgba(10, 132, 255, .14); opacity: 1; }
127
+ .ar-m { display: inline-flex; align-items: center; justify-content: center; flex: none;
128
+ min-width: 46px; height: 19px; padding: 0 5px; border-radius: 5px;
129
+ font-family: var(--ar-mono); font-size: 10.5px; font-weight: 700; letter-spacing: .02em; }
130
+ .ar-m.get { color: #0a84ff; background: rgba(10, 132, 255, .14); }
131
+ .ar-m.post { color: #34c759; background: rgba(52, 199, 89, .16); }
132
+ .ar-m.put, .ar-m.patch { color: #ff9f0a; background: rgba(255, 159, 10, .16); }
133
+ .ar-m.del { color: #ff3b30; background: rgba(255, 59, 48, .14); }
134
+
135
+ /* ---- 地址:host 弱、path 强 ---- */
136
+ .ar-url { font-family: var(--ar-mono); font-size: 11.5px; overflow-wrap: anywhere; }
137
+ .ar-url .h { opacity: .5; }
138
+ .ar-url .p { opacity: .95; }
139
+
140
+ /* ---- 按钮:胶囊填充 ---- */
141
+ .ar-btn { display: inline-flex; align-items: center; justify-content: center; gap: 4px;
142
+ border: 0; border-radius: var(--ar-r-ctrl); padding: 5px 12px; font: inherit; font-size: 12.5px; font-weight: 500;
143
+ cursor: pointer; white-space: nowrap; color: inherit; background: var(--ar-surface);
144
+ transition: background-color .18s var(--ar-ease), filter .18s var(--ar-ease), transform .18s var(--ar-ease), opacity .18s var(--ar-ease); }
145
+ .ar-btn:active:not(:disabled) { transform: scale(.97); }
146
+ .ar-btn:disabled { opacity: .38; cursor: default; transform: none; }
147
+ .ar-btn.primary { background: var(--ar-accent); color: #fff; }
148
+ .ar-btn.tonal { background: var(--ar-surface-strong); }
149
+ .ar-btn.ghost { background: transparent; opacity: .62; padding: 5px 9px; }
150
+ .ar-btn.danger { color: var(--ar-danger); }
151
+ .ar-btn.icon { padding: 5px; width: 28px; }
152
+ @media (hover: hover) and (pointer: fine) {
153
+ .ar-btn:hover:not(:disabled) { background: var(--ar-surface-strong); }
154
+ .ar-btn.primary:hover:not(:disabled) { filter: brightness(1.08); background: var(--ar-accent); }
155
+ .ar-btn.ghost:hover:not(:disabled) { background: var(--ar-surface); opacity: .95; }
156
+ .ar-btn.danger:hover:not(:disabled) { background: rgba(255, 69, 58, .12); }
157
+ .ar-item:hover { background: var(--ar-surface-strong); }
158
+ }
159
+
160
+ /* ---- 表单:无边框填充 ---- */
161
+ .ar-field { display: grid; grid-template-columns: 58px minmax(0, 1fr); gap: 8px; align-items: center; }
162
+ .ar-field.top { align-items: start; }
163
+ .ar-field > label { font-size: 12px; opacity: .62; }
164
+ .ar-field.top > label { padding-top: 7px; }
165
+ .ar-in, .ar-sel, .ar-ta { width: 100%; box-sizing: border-box; font: inherit; font-size: 13px;
166
+ color: inherit; background: var(--ar-surface-strong); border: 0; border-radius: var(--ar-r-ctrl);
167
+ padding: 6px 10px; outline: 2px solid transparent; outline-offset: -1px;
168
+ transition: outline-color .18s var(--ar-ease); }
169
+ .ar-in, .ar-sel { height: 30px; padding-top: 0; padding-bottom: 0; }
170
+ .ar-sel { cursor: pointer; }
171
+ .ar-ta { min-height: 60px; resize: vertical; font-family: var(--ar-mono); font-size: 12px; line-height: 1.5; }
172
+ .ar-in:focus, .ar-sel:focus, .ar-ta:focus { outline-color: var(--ar-accent); }
173
+ .ar-in::placeholder, .ar-ta::placeholder { color: inherit; opacity: .38; }
174
+ .ar-check { display: flex; align-items: center; gap: 7px; cursor: pointer; font-size: 13px; }
175
+ .ar-check input { width: 15px; height: 15px; margin: 0; accent-color: var(--ar-accent); cursor: pointer; flex: none; }
176
+ .ar-hint { font-size: 11.5px; opacity: .55; line-height: 1.55; }
177
+
178
+ /* ---- 提示条 ---- */
179
+ .ar-banner { display: flex; align-items: flex-start; gap: 7px; padding: 8px 11px;
180
+ border-radius: var(--ar-r-ctrl); font-size: 12.5px; line-height: 1.5;
181
+ background: var(--ar-surface); }
182
+ .ar-banner > span { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; }
183
+ /* 关闭按钮做成「纯文字 ×」:无边框无底色,颜色跟着提示条走,位置钉在最后一行右侧 */
184
+ .ar-banner > .ar-x { flex: none; margin-left: auto; align-self: center;
185
+ appearance: none; -webkit-appearance: none; border: 0 none; background: none; box-shadow: none;
186
+ color: inherit; cursor: pointer; font-family: inherit; font-size: 15px; line-height: 1;
187
+ padding: 0 1px 2px; border-radius: 4px; opacity: .55;
188
+ transition: opacity .16s var(--ar-ease); }
189
+ .ar-banner > .ar-x:hover { opacity: .95; }
190
+ .ar-banner > .ar-x:focus-visible { outline: 1px solid currentColor; outline-offset: 1px; opacity: .95; }
191
+ .ar-banner.err { color: var(--ar-danger); background: rgba(255, 69, 58, .12); }
192
+ .ar-banner.ok { color: var(--ar-success); background: rgba(52, 199, 89, .12); }
193
+
194
+ /* ---- 响应查看 ---- */
195
+ .ar-pre { margin: 0; padding: 10px 11px; border-radius: var(--ar-r-ctrl);
196
+ background: var(--ar-surface-strong); font-family: var(--ar-mono); font-size: 12px; line-height: 1.55;
197
+ white-space: pre-wrap; overflow-wrap: anywhere; max-height: 320px; overflow: auto; }
198
+ .ar-seg { display: flex; gap: 2px; padding: 2px; background: var(--ar-surface); border-radius: var(--ar-r-pill); }
199
+ .ar-seg > button { border: 0; background: transparent; color: inherit; cursor: pointer; font: inherit; font-size: 11.5px;
200
+ padding: 3px 9px; border-radius: 5px; opacity: .62; transition: background-color .18s var(--ar-ease), opacity .18s var(--ar-ease); }
201
+ .ar-seg > button:hover { opacity: .9; }
202
+ .ar-seg > button.on { opacity: 1; font-weight: 600; background: var(--ar-surface-strong); }
203
+
204
+ /* ---- 空态 ---- */
205
+ .ar-empty { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 30px 16px; text-align: center; }
206
+ .ar-empty-icon { font-size: 22px; opacity: .3; line-height: 1; }
207
+ .ar-empty-title { font-size: 13px; font-weight: 600; }
208
+ .ar-empty-hint { font-size: 12px; opacity: .55; line-height: 1.6; max-width: 230px; }
209
+
210
+ /* ---- 一键启动进度 ---- */
211
+ .ar-batch { display: flex; flex-direction: column; gap: 6px; padding: 9px 11px; border-radius: var(--ar-r-card);
212
+ background: var(--ar-surface); }
213
+ .ar-batch-top { display: flex; align-items: center; gap: 7px; font-size: 12.5px; font-weight: 600; }
214
+ .ar-batch-bar { height: 4px; border-radius: 2px; background: var(--ar-surface-strong); overflow: hidden; }
215
+ .ar-batch-bar > i { display: block; height: 100%; border-radius: 2px; background: var(--ar-accent);
216
+ transition: width .25s var(--ar-ease); }
217
+ .ar-batch-line { display: flex; align-items: baseline; gap: 6px; font-size: 12px; }
218
+ .ar-batch-line > .n { flex: 0 0 auto; font-family: var(--ar-mono); }
219
+ .ar-batch-line.ok > .n { color: var(--ar-success); }
220
+ .ar-batch-line.err > .n { color: var(--ar-danger); }
221
+ .ar-batch-line.skip > .n { color: var(--ar-warn); }
222
+
223
+ /* ---- 键值行 ---- */
224
+ .ar-kv { display: flex; align-items: baseline; gap: 8px; font-size: 12px; }
225
+ .ar-kv > .k { flex: 0 0 55px; opacity: .55; }
226
+ .ar-kv > .v { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; }
227
+
228
+ /* ---- 服务详情 / 实时日志 ---- */
229
+ .ar-detail { display: flex; flex-direction: column; gap: 9px; min-width: 0; }
230
+ .ar-detail-head { display: flex; align-items: center; gap: 7px; min-width: 0; }
231
+ .ar-dot { width: 7px; height: 7px; border-radius: 50%; flex: none; background: var(--ar-success); }
232
+ .ar-dot.off { background: var(--ar-danger); }
233
+ /* 已忽略的服务整体压暗,但仍可操作(取消忽略) */
234
+ .ar-ignored { opacity: .55; }
235
+ /* 日志容器:宽度必须被父级约束住,否则 flex 的 min-width:auto 会让它按内容撑开,
236
+ 看起来就像「不换行」(踩过:只设 white-space:pre-wrap 但父级没 min-width:0 时无效)。 */
237
+ .ar-log { margin: 0; padding: 10px 11px; border-radius: var(--ar-r-ctrl); width: 100%; box-sizing: border-box;
238
+ background: var(--ar-surface-strong); font-family: var(--ar-mono); font-size: 11.5px; line-height: 1.62;
239
+ white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word;
240
+ max-height: 58vh; min-height: 140px; overflow-y: auto; overflow-x: hidden; tab-size: 4; }
241
+ .ar-log.nowrap { white-space: pre; overflow-wrap: normal; word-break: normal; overflow-x: auto; }
242
+ .ar-log:empty::before { content: '(暂无日志输出)'; opacity: .45; }
243
+
244
+ @media (prefers-reduced-motion: reduce) {
245
+ .ar-root * { transition: none !important; animation: none !important; }
246
+ }
247
+ `;
248
+
249
+ /**
250
+ * 插入样式表。
251
+ * 优先用宿主给动态 Client half 的 styles.insert(由 DSH 管理生命周期、
252
+ * 保证插到正确的文档根);取不到才退回 document.head,与 dsh-db-tool 同法。
253
+ */
254
+ function installStyles(ctx) {
255
+ let api;
256
+ if (typeof styles !== 'undefined' && styles && typeof styles.insert === 'function') api = styles;
257
+ else if (ctx && typeof ctx.get === 'function') api = ctx.get('styles');
258
+ if (api && typeof api.insert === 'function') {
259
+ api.insert(CSS);
260
+ return;
261
+ }
262
+ if (document.getElementById(STYLE_ID)) return;
263
+ const el = document.createElement('style');
264
+ el.id = STYLE_ID;
265
+ el.textContent = CSS;
266
+ (document.head || document.documentElement).appendChild(el);
267
+ }
268
+
269
+ // ---------------------------------------------------------------- 工具
270
+ async function request(path, init) {
271
+ const res = await fetch(`${API}/${path}`, init);
272
+ let data;
273
+ try {
274
+ data = await res.json();
275
+ } catch {
276
+ data = undefined;
277
+ }
278
+ if (res.status === 401) throw new Error('需要先登录 DSH 网页端(请求未通过鉴权)');
279
+ if (!data) throw new Error(`接口无 JSON 响应(HTTP ${res.status})`);
280
+ return data;
281
+ }
282
+
283
+ async function copyText(text) {
284
+ try {
285
+ await navigator.clipboard.writeText(text);
286
+ return true;
287
+ } catch {
288
+ return false;
289
+ }
290
+ }
291
+
292
+ function pretty(value) {
293
+ try {
294
+ return JSON.stringify(value, null, 2);
295
+ } catch {
296
+ return String(value);
297
+ }
298
+ }
299
+
300
+ function byteSize(value) {
301
+ try {
302
+ const text = typeof value === 'string' ? value : JSON.stringify(value);
303
+ const bytes = new TextEncoder().encode(text || '').length;
304
+ return bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(1)} KB`;
305
+ } catch {
306
+ return '';
307
+ }
308
+ }
309
+
310
+ function splitUrl(url) {
311
+ const text = String(url || '');
312
+ const match = /^([a-z]+:\/\/[^/]+)([\s\S]*)$/i.exec(text);
313
+ if (!match) return { host: '', path: text };
314
+ return { host: match[1], path: match[2] || '/' };
315
+ }
316
+
317
+ function methodClass(method) {
318
+ const m = String(method || 'GET').toLowerCase();
319
+ if (m === 'get' || m === 'head') return 'ar-m get';
320
+ if (m === 'post') return 'ar-m post';
321
+ if (m === 'put' || m === 'patch') return 'ar-m put';
322
+ if (m === 'delete') return 'ar-m del';
323
+ return 'ar-m';
324
+ }
325
+
326
+ function emptyForm(baseUrl) {
327
+ return {
328
+ id: '', name: '', method: 'GET', url: '', baseUrl: baseUrl || '', description: '',
329
+ headersText: '', queryText: '', bodyText: '', sampleText: '', useToken: '',
330
+ isLogin: false, tokenPath: 'token', authHeader: 'Authorization', prefix: 'Bearer ',
331
+ timeoutMs: 30000,
332
+ };
333
+ }
334
+
335
+ function formFromApi(api) {
336
+ return {
337
+ id: api.id,
338
+ name: api.name || api.id,
339
+ method: api.method || 'GET',
340
+ url: api.url || '',
341
+ baseUrl: api.baseUrl || '',
342
+ description: api.description || '',
343
+ headersText: api.headersText || '',
344
+ queryText: api.queryText || '',
345
+ bodyText: api.bodyText || '',
346
+ sampleText: api.sampleText || '',
347
+ useToken: api.useToken || '',
348
+ isLogin: Boolean(api.isLogin),
349
+ tokenPath: api.tokenPath || 'token',
350
+ authHeader: api.header || 'Authorization',
351
+ prefix: api.prefix === undefined ? 'Bearer ' : api.prefix,
352
+ timeoutMs: api.timeoutMs || 30000,
353
+ source: api.source,
354
+ };
355
+ }
356
+
357
+ function parseJsonField(text, label) {
358
+ const trimmed = String(text || '').trim();
359
+ if (trimmed === '') return undefined;
360
+ try {
361
+ return JSON.parse(trimmed);
362
+ } catch (error) {
363
+ throw new Error(`${label} 不是合法 JSON:${error.message}`);
364
+ }
365
+ }
366
+
367
+ function placeholderNames(api) {
368
+ const out = new Set(Array.isArray(api.params) ? api.params : []);
369
+ const scan = (value) => {
370
+ if (typeof value === 'string') {
371
+ for (const match of value.matchAll(/\{([A-Za-z_][A-Za-z0-9_.-]*)\}/g)) out.add(match[1]);
372
+ } else if (Array.isArray(value)) value.forEach(scan);
373
+ else if (value && typeof value === 'object') Object.values(value).forEach(scan);
374
+ };
375
+ scan(api.url);
376
+ // query 里的占位符也要算:只写在 query 里的键,提示行不显示的话用户会以为漏了
377
+ scan(api.queryText);
378
+ if (api.query && typeof api.query === 'object') scan(api.query);
379
+ scan(api.bodyText);
380
+ return [...out];
381
+ }
382
+
383
+ // ---------------------------------------------------------------- 组件
384
+ function Field(props) {
385
+ return h('div', { className: props.top ? 'ar-field top' : 'ar-field' },
386
+ h('label', {}, props.label), props.children);
387
+ }
388
+
389
+ /** 提示条:err/ok 用色区分,点 × 可关掉(提示不该一直占着版面) */
390
+ function Banner(props) {
391
+ const { kind, text, onClose } = props;
392
+ if (!text) return null;
393
+ // 用 span 而不是 button:button 会带上浏览器/主题的默认边框底色,跟提示条融不进去
394
+ const close = onClose
395
+ ? h('span', {
396
+ className: 'ar-x',
397
+ role: 'button',
398
+ tabIndex: 0,
399
+ title: '关闭',
400
+ 'aria-label': '关闭',
401
+ onClick: onClose,
402
+ onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClose(); } },
403
+ }, '×')
404
+ : null;
405
+ return h('div', { className: kind ? `ar-banner ${kind}` : 'ar-banner' }, h('span', {}, text), close);
406
+ }
407
+
408
+ function Empty(props) {
409
+ return h('div', { className: 'ar-empty' },
410
+ props.icon ? h('div', { className: 'ar-empty-icon' }, props.icon) : null,
411
+ h('div', { className: 'ar-empty-title' }, props.title),
412
+ props.hint ? h('div', { className: 'ar-empty-hint' }, props.hint) : null,
413
+ props.action || null);
414
+ }
415
+
416
+ function Url(props) {
417
+ const parts = splitUrl(props.value);
418
+ return h('div', { className: 'ar-url', title: props.value },
419
+ parts.host ? h('span', { className: 'h' }, parts.host) : null,
420
+ h('span', { className: 'p' }, parts.path));
421
+ }
422
+
423
+ /**
424
+ * 项目 key 是归一化后的绝对路径,页签上只给末段目录名,够区分又不占地方。
425
+ * 末段撞车(两个都叫 api)时退回上一级,避免两个页签长一样。
426
+ */
427
+ function projectLabel(key, allKeys) {
428
+ const parts = String(key || '').split('/').filter(Boolean);
429
+ if (parts.length === 0) return key || 'default';
430
+ const last = parts[parts.length - 1];
431
+ const dup = (allKeys || []).filter((item) => {
432
+ const other = String(item || '').split('/').filter(Boolean);
433
+ return other.length > 0 && other[other.length - 1] === last;
434
+ });
435
+ return dup.length > 1 && parts.length > 1 ? `${parts[parts.length - 2]}/${last}` : last;
436
+ }
437
+
438
+ function ApiRow(props) {
439
+ const { api, tokens, busy, onTest, onLogin, onEdit, onDelete, onClearToken } = props;
440
+ const tokenId = api.tokenFrom || '';
441
+ const token = tokenId ? tokens[tokenId] : undefined;
442
+ const params = Array.isArray(api.params) ? api.params : [];
443
+ return h('div', { className: 'ar-item' },
444
+ h('div', { className: 'ar-head' },
445
+ h('span', { className: methodClass(api.method) }, api.method),
446
+ h('div', { className: 'ar-main' },
447
+ h('div', { className: 'ar-row', style: { flexWrap: 'wrap', gap: '5px' } },
448
+ h('span', { className: 'ar-name ar-cut' }, api.name || api.id),
449
+ api.source === 'file' ? h('span', { className: 'ar-badge', title: '来自 .dsh/api-runner.yml,请在文件里修改' }, 'YAML') : null,
450
+ api.isLogin ? h('span', { className: 'ar-badge warn' }, '登录') : null,
451
+ token ? h('span', { className: 'ar-badge ok', title: '调用时会自动带上这个 token' }, token.masked) : null),
452
+ h(Url, { value: api.url }),
453
+ api.description ? h('div', { className: 'ar-hint ar-cut', title: api.description }, api.description) : null,
454
+ params.length > 0 ? h('div', { className: 'ar-hint ar-mono' }, `参数 ${params.join(' · ')}`) : null)),
455
+ // 项目页签切过去之后,行内的测试/登录/编辑/删除都作用于那个项目自己的配置
456
+ // (请求带 project,后端按项目桶读写),所以这里不再有只读分支。
457
+ h('div', { className: 'ar-row' },
458
+ h('button', { className: 'ar-btn primary', disabled: Boolean(busy), title: '用「示例参数」调用一次', onClick: () => onTest(api) }, '测试'),
459
+ api.isLogin ? h('button', { className: 'ar-btn tonal', disabled: Boolean(busy), title: '调登录接口并把 token 存起来', onClick: () => onLogin(api) }, '登录并保存') : null,
460
+ h('span', { className: 'ar-spacer' }),
461
+ h('button', { className: 'ar-btn ghost icon', title: '复制完整地址', onClick: () => copyText(api.url) }, '⧉'),
462
+ token ? h('button', { className: 'ar-btn ghost', title: `忘掉 ${tokenId} 的 token`, onClick: () => onClearToken(tokenId) }, '清除 token') : null,
463
+ h('button', { className: 'ar-btn ghost', onClick: () => onEdit(api) }, '编辑'),
464
+ api.source === 'file' ? null : h('button', { className: 'ar-btn ghost danger', onClick: () => onDelete(api) }, '删除')),
465
+ );
466
+ }
467
+
468
+ function EditForm(props) {
469
+ const { value, onChange, onSave, onCancel, tokens, busy } = props;
470
+ const set = (key, next) => onChange({ ...value, [key]: next });
471
+ const placeholders = placeholderNames(value);
472
+ return h('div', { className: 'ar-card' },
473
+ h('div', { className: 'ar-name' }, value.id ? `编辑 ${value.id}` : '新增接口'),
474
+ h(Field, { label: 'id' }, h('input', { className: 'ar-in', value: value.id, placeholder: '唯一标识,如 login', onChange: (e) => set('id', e.target.value) })),
475
+ h(Field, { label: '名称' }, h('input', { className: 'ar-in', value: value.name, placeholder: '显示名,留空则用 id', onChange: (e) => set('name', e.target.value) })),
476
+ h(Field, { label: '方法' }, h('select', { className: 'ar-sel', value: value.method, onChange: (e) => set('method', e.target.value) },
477
+ METHODS.map((m) => h('option', { key: m, value: m }, m)))),
478
+ h(Field, { label: '路径' }, h('input', { className: 'ar-in', value: value.url, placeholder: '/system/library/matter/billList,支持 {id}', onChange: (e) => set('url', e.target.value) })),
479
+ h(Field, { label: 'baseUrl' }, h('input', { className: 'ar-in', value: value.baseUrl, placeholder: '留空则用「设置」里的 baseUrl', onChange: (e) => set('baseUrl', e.target.value) })),
480
+ h(Field, { label: '说明' }, h('input', { className: 'ar-in', value: value.description, onChange: (e) => set('description', e.target.value) })),
481
+ h(Field, { label: 'query', top: true }, h('textarea', {
482
+ className: 'ar-ta',
483
+ value: value.queryText,
484
+ placeholder: '查询参数(JSON),如 {"matterCd":"{matterCd}"};不用把 ?a=b 写进「路径」',
485
+ onChange: (e) => set('queryText', e.target.value),
486
+ })),
487
+ h(Field, { label: '请求头', top: true }, h('textarea', { className: 'ar-ta', value: value.headersText, placeholder: '{"X-Token":"${DEMO_TOKEN}"}', onChange: (e) => set('headersText', e.target.value) })),
488
+ h(Field, { label: 'body', top: true }, h('textarea', { className: 'ar-ta', value: value.bodyText, placeholder: '{"username":"{user}","password":"{pwd}"}', onChange: (e) => set('bodyText', e.target.value) })),
489
+ h(Field, { label: '示例参数', top: true }, h('textarea', { className: 'ar-ta', value: value.sampleText, placeholder: '「测试」按钮用它填 {占位},如 {"user":"alice"}', onChange: (e) => set('sampleText', e.target.value) })),
490
+ placeholders.length > 0 ? h('div', { className: 'ar-hint ar-mono' }, `占位参数:${placeholders.join(' · ')}`) : null,
491
+ h(Field, { label: '用 token' }, h('select', { className: 'ar-sel', value: value.useToken, onChange: (e) => set('useToken', e.target.value) },
492
+ h('option', { value: '' }, '自动(项目里只有一个 token 时用它)'),
493
+ Object.keys(tokens || {}).map((id) => h('option', { key: id, value: id }, `${id}(${tokens[id].masked})`)))),
494
+ h('label', { className: 'ar-check' },
495
+ h('input', { type: 'checkbox', checked: value.isLogin, onChange: (e) => set('isLogin', e.target.checked) }),
496
+ h('span', {}, '这是登录接口,调用成功后保存 token')),
497
+ value.isLogin
498
+ ? h('div', { className: 'ar-card', style: { background: 'var(--ar-surface-strong)' } },
499
+ h(Field, { label: 'token 路径' }, h('input', { className: 'ar-in', value: value.tokenPath, placeholder: '响应 JSON 路径,如 data.token', onChange: (e) => set('tokenPath', e.target.value) })),
500
+ h(Field, { label: '注入头' }, h('input', { className: 'ar-in', value: value.authHeader, placeholder: 'Authorization', onChange: (e) => set('authHeader', e.target.value) })),
501
+ h(Field, { label: '前缀' }, h('input', { className: 'ar-in', value: value.prefix, placeholder: 'Bearer (不需要就留空)', onChange: (e) => set('prefix', e.target.value) })),
502
+ h('div', { className: 'ar-hint' }, '留空 token 路径时,会在响应里找形如 *token* 的字段,只有一个就直接用。'))
503
+ : null,
504
+ h('div', { className: 'ar-row' },
505
+ h('button', { className: 'ar-btn primary', disabled: Boolean(busy), onClick: () => onSave(value) }, '保存'),
506
+ h('button', { className: 'ar-btn ghost', onClick: onCancel }, '取消')));
507
+ }
508
+
509
+ /**
510
+ * 端口只有一个数字时用户会问「为什么这个服务显示监听两个端口」——
511
+ * 所以每个端口都标上角色:HTTP 业务端口 / 私有协议端口 / 调试端口。
512
+ */
513
+ function Ports(props) {
514
+ const { item } = props;
515
+ const list = Array.isArray(item.portList) ? item.portList : [];
516
+ if (list.length === 0) {
517
+ if (!item.primaryPort) return h('span', { className: 'ar-hint' }, ' · 未监听端口');
518
+ return h('span', { className: 'ar-hint' }, ` · 监听 ${item.primaryPort}`);
519
+ }
520
+ return h('span', { className: 'ar-hint' },
521
+ ' · ',
522
+ list.map((port, index) => h('span', { key: port.port },
523
+ index > 0 ? ' / ' : '',
524
+ h('span', {
525
+ className: port.role === 'http' ? 'ar-mono' : 'ar-mono ar-muted',
526
+ title: port.reason ? `${port.label}(${port.reason})` : port.label,
527
+ style: { fontWeight: port.role === 'http' ? 600 : 400 },
528
+ }, port.port),
529
+ h('span', { className: 'ar-muted' }, ` ${port.role === 'http' ? 'HTTP' : port.role === 'sentinel' ? '私有协议' : port.role === 'debug' ? 'debug' : ''}`))));
530
+ }
531
+
532
+ function JavaRow(props) {
533
+ const { item, onUseBaseUrl, onTakeOver, onStop, onRestart, onDetail, onIgnore, onUnignore, onSave, busy, muted } = props;
534
+ const profiles = Array.isArray(item.profiles) ? item.profiles : [];
535
+ const managed = item.owner === 'plugin';
536
+ const running = managed ? Boolean(item.alive) : true;
537
+ /** 只有探测到「真的讲 HTTP」的端口才能当接口地址,私有协议/调试端口不能点 */
538
+ const httpPort = item.httpPort || (managed ? item.primaryPort : 0);
539
+ const ignored = Boolean(item.ignored);
540
+ return h('div', { className: muted ? 'ar-item ar-ignored' : 'ar-item' },
541
+ h('div', { className: 'ar-row', style: { flexWrap: 'wrap', gap: '5px' } },
542
+ httpPort ? h('span', { className: 'ar-badge brand ar-mono' }, `:${httpPort}`) : null,
543
+ h('span', { className: 'ar-name ar-cut', style: { flex: '0 1 auto' } }, item.name),
544
+ managed
545
+ ? h('span', { className: `ar-badge ${running ? 'ok' : ''}`, title: '由插件启动(可停止、可看日志)' }, running ? '插件启动' : '已停止')
546
+ : item.owner === 'idea'
547
+ ? h('span', { className: 'ar-badge warn', title: '由 IDEA 启动;插件不能直接重启它,只能「接管」' }, 'IDEA 启动')
548
+ : h('span', { className: 'ar-badge', title: '不是 IDEA 起的,也不是插件起的' }, '其它'),
549
+ ignored ? h('span', { className: 'ar-badge', title: '已忽略:在「正在运行」里不显示' }, '已忽略') : null,
550
+ item.debugPort ? h('span', { className: 'ar-badge', title: '调试端口(jdwp)' }, `debug ${item.debugPort}`) : null,
551
+ profiles.map((p) => h('span', { key: p, className: 'ar-badge' }, p))),
552
+ h('div', { className: 'ar-hint ar-mono ar-cut', title: item.mainClass || '' }, item.mainClass || ''),
553
+ h('div', { style: { fontSize: '12px' } },
554
+ h('span', { className: 'ar-hint ar-mono' }, `pid ${item.pid}`),
555
+ h(Ports, { item }),
556
+ managed && item.startedAt ? h('span', { className: 'ar-hint' }, ` · 启动于 ${String(item.startedAt).slice(11, 19)}`) : null),
557
+ h('div', { className: 'ar-row', style: { flexWrap: 'wrap' } },
558
+ httpPort ? h('button', { className: 'ar-btn ghost icon', title: `把项目 baseUrl 设为 http://127.0.0.1:${httpPort}`, onClick: () => onUseBaseUrl(item) }, '⌁') : null,
559
+ // 「查询详情」= 日志页:托管进程读插件输出,IDEA 进程读它自己的 logback 文件
560
+ h('button', { className: 'ar-btn tonal', disabled: busy !== '', title: '查看进程信息与日志', onClick: () => onDetail(item) }, '查询详情'),
561
+ // 被忽略的服务只留「取消忽略」:它不在正常列表里,给启动/停止按钮容易误操作
562
+ ignored
563
+ ? null
564
+ : (managed
565
+ ? (running
566
+ ? h('button', { className: 'ar-btn', disabled: busy !== '', onClick: () => onRestart(item) }, '重启')
567
+ : h('button', { className: 'ar-btn', disabled: busy !== '', onClick: () => onRestart(item) }, '启动'))
568
+ : h('button', { className: 'ar-btn primary', disabled: busy !== '', title: '结束 IDEA 起的进程,改用插件启动(会先编译,再拉起);之后就能在插件里停止/重启/看日志', onClick: () => onTakeOver(item) }, '接管启动')),
569
+ !ignored && managed && running ? h('button', { className: 'ar-btn ghost danger', disabled: busy !== '', onClick: () => onStop(item) }, '停止') : null,
570
+ // 保存配方:之后即使 IDEA 里没跑,也能直接从这里启动
571
+ !ignored && onSave && running ? h('button', { className: 'ar-btn ghost', disabled: busy !== '', title: '把这套启动参数(classpath/主类/JVM 参数)存下来,以后不用先在 IDEA 里跑也能启动', onClick: () => onSave(item) }, '保存') : null,
572
+ h('span', { className: 'ar-spacer' }),
573
+ ignored
574
+ ? h('button', { className: 'ar-btn ghost', title: '重新显示在「正在运行」里', onClick: () => onUnignore(item) }, '取消忽略')
575
+ : h('button', { className: 'ar-btn ghost', title: '不再显示在「正在运行」里(不影响进程本身)', onClick: () => onIgnore(item) }, '忽略')));
576
+ }
577
+
578
+ /** 已保存的服务:不需要现有进程,按配方直接启动 */
579
+ /**
580
+ * 一键启动的进度卡片。
581
+ *
582
+ * 编译 + 拉起一批服务要几分钟,期间必须让用户看到「到哪了」,
583
+ * 否则点完按钮页面没反应,会以为卡死。逐条显示每个服务的结局。
584
+ */
585
+ function BatchProgress(props) {
586
+ const { batch, onDismiss } = props;
587
+ const total = Number(batch.total) || 0;
588
+ const done = Number(batch.done) || 0;
589
+ const pct = total > 0 ? Math.min(100, Math.round((done / total) * 100)) : 0;
590
+ const items = Array.isArray(batch.items) ? batch.items : [];
591
+ return h('div', { className: 'ar-batch' },
592
+ h('div', { className: 'ar-batch-top' },
593
+ h('span', null, batch.finished ? '一键启动完成' : '正在启动服务…'),
594
+ h('span', { className: 'ar-spacer' }),
595
+ h('span', { className: 'ar-mono ar-muted', style: { fontSize: '11.5px', fontWeight: '400' } }, `${done}/${total}`),
596
+ batch.finished ? h('button', { className: 'ar-btn ghost icon', title: '关闭', onClick: onDismiss }, '×') : null),
597
+ h('div', { className: 'ar-batch-bar' }, h('i', { style: { width: `${pct}%` } })),
598
+ !batch.finished && batch.current
599
+ ? h('div', { className: 'ar-hint ar-mono' }, `正在处理 ${batch.current}`)
600
+ : null,
601
+ items.length > 0
602
+ ? h('div', { style: { display: 'flex', flexDirection: 'column', gap: '3px' } },
603
+ items.map((item) => h('div', {
604
+ key: item.id,
605
+ className: `ar-batch-line ${item.skipped ? 'skip' : item.ok ? 'ok' : 'err'}`,
606
+ },
607
+ h('span', { className: 'n' }, item.skipped ? '·' : item.ok ? '✓' : '✗'),
608
+ h('span', { className: 'ar-cut', style: { flex: '0 1 auto' } }, item.id),
609
+ h('span', { className: 'ar-hint ar-cut', style: { flex: '1 1 auto', minWidth: 0 } },
610
+ item.skipped ? '已在运行,跳过' : item.ok ? (item.port ? `已启动 · 端口 ${item.port}` : '已启动') : (item.message || item.code || '失败')))))
611
+ : null);
612
+ }
613
+
614
+ function SavedRow(props) {
615
+ const { item, busy, onStart, onDelete } = props;
616
+ return h('div', { className: 'ar-item' },
617
+ h('div', { className: 'ar-row', style: { flexWrap: 'wrap', gap: '5px' } },
618
+ item.port ? h('span', { className: 'ar-badge brand ar-mono' }, `:${item.port}`) : null,
619
+ h('span', { className: 'ar-name ar-cut', style: { flex: '0 1 auto' } }, item.name || item.id),
620
+ h('span', { className: 'ar-badge', title: '启动参数已保存' }, '已保存')),
621
+ h('div', { className: 'ar-hint ar-mono ar-cut', title: item.mainClass || '' }, item.mainClass || ''),
622
+ h('div', { className: 'ar-hint ar-mono ar-cut', title: item.module?.rootDir || '' },
623
+ `${item.module?.artifactId || '未识别模块'}${item.savedAt ? ` · 保存于 ${String(item.savedAt).slice(5, 16).replace('T', ' ')}` : ''}`),
624
+ h('div', { className: 'ar-row', style: { flexWrap: 'wrap' } },
625
+ h('button', { className: 'ar-btn primary', disabled: busy !== '', title: '编译后按保存的参数拉起(不需要 IDEA 里先有进程)', onClick: () => onStart(item) }, '启动'),
626
+ h('span', { className: 'ar-spacer' }),
627
+ h('button', { className: 'ar-btn ghost danger', disabled: busy !== '', onClick: () => onDelete(item) }, '删除')));
628
+ }
629
+
630
+ /**
631
+ * 服务详情 = 日志页。
632
+ *
633
+ * 日志来源:
634
+ * 插件托管的进程 → `<数据目录>/java-logs/<服务名>/<时间戳>.log`(插件写的 stdout,
635
+ * 每次启动一个文件,最接近 IDEA 控制台,也能翻历史几次启动)
636
+ * IDEA 起的进程 → 它自己的 logback 文件 `<user.dir>/logs/<服务名>/info.log`
637
+ * (IDEA 控制台不是文件,拿不到;应用日志内容一样)
638
+ * 按字节偏移增量拉,只在可见时轮询,切走就停。
639
+ */
640
+ function ServiceDetail(props) {
641
+ const { item, visible, get, onClose, onStop, onRestart, onTakeOver, onIgnore, onUnignore, busy } = props;
642
+ const [detail, setDetail] = React.useState(null);
643
+ const [text, setText] = React.useState('');
644
+ const [source, setSource] = React.useState('');
645
+ const [auto, setAuto] = React.useState(true);
646
+ const [wrap, setWrap] = React.useState(true);
647
+ /** 首屏行数:和 IDEA 控制台一样先给最近一段,不够再加 */
648
+ const [lines, setLines] = React.useState(200);
649
+ /** 自定义行数输入框的草稿(回车/失焦生效) */
650
+ const [linesText, setLinesText] = React.useState('');
651
+ const [error, setError] = React.useState('');
652
+ const [loading, setLoading] = React.useState(false);
653
+ const boxRef = React.useRef(null);
654
+ const offsetRef = React.useRef(0);
655
+ const sourceRef = React.useRef('');
656
+
657
+ const target = item.runId || item.pid;
658
+ const alive = detail ? detail.alive : Boolean(item.alive) || item.owner !== 'plugin';
659
+
660
+ /** 首屏:拿详情(进程信息 + 日志文件清单)并从文件末尾读一段 */
661
+ const load = React.useCallback(async (nextSource, nextLines) => {
662
+ try {
663
+ setLoading(true);
664
+ const key = nextSource !== undefined ? nextSource : sourceRef.current || '';
665
+ const size = Number(nextLines) || lines;
666
+ const query = `java/detail?id=${encodeURIComponent(target)}&lines=${size}&max_bytes=65536${key ? `&source=${encodeURIComponent(key)}` : ''}`;
667
+ const data = await get(query);
668
+ if (!data.ok) {
669
+ setError(data.message || '读取详情失败');
670
+ return;
671
+ }
672
+ setError('');
673
+ const log = data.log || {};
674
+ sourceRef.current = data.source || '';
675
+ offsetRef.current = log.nextOffset || 0;
676
+ setSource(data.source || '');
677
+ setDetail(data);
678
+ setText(log.text || '');
679
+ } catch (e) {
680
+ setError(e.message);
681
+ } finally {
682
+ setLoading(false);
683
+ }
684
+ }, [get, target, lines]);
685
+
686
+ /** 增量:只取 offset 之后的新内容,追加到已有文本 */
687
+ const follow = React.useCallback(async () => {
688
+ if (!sourceRef.current) return;
689
+ try {
690
+ const data = await get(`java/detail?id=${encodeURIComponent(target)}&from=${offsetRef.current}&source=${encodeURIComponent(sourceRef.current)}&max_bytes=262144`);
691
+ if (!data.ok) return;
692
+ const log = data.log || {};
693
+ if (typeof log.nextOffset === 'number') {
694
+ // 变小说明文件被轮转/截断了,这时整段重来
695
+ if (log.nextOffset < offsetRef.current) {
696
+ setText(log.text || '');
697
+ } else if (log.text) {
698
+ setText((prev) => (prev + log.text).slice(-400_000));
699
+ }
700
+ offsetRef.current = log.nextOffset;
701
+ }
702
+ setDetail((prev) => (prev ? { ...data, log: undefined } : data));
703
+ } catch {
704
+ // 轮询失败不打断阅读,下一轮再试
705
+ }
706
+ }, [get, target]);
707
+
708
+ /** 自定义行数:1–5000,回车或失焦时生效 */
709
+ const applyLines = React.useCallback(() => {
710
+ const wanted = Number(String(linesText || '').trim());
711
+ if (!Number.isFinite(wanted) || wanted <= 0) return;
712
+ const size = Math.min(5000, Math.max(1, Math.round(wanted)));
713
+ setLines(size);
714
+ setLinesText('');
715
+ load(sourceRef.current, size);
716
+ }, [linesText, load]);
717
+
718
+ React.useEffect(() => {
719
+ offsetRef.current = 0;
720
+ sourceRef.current = '';
721
+ setText('');
722
+ setDetail(null);
723
+ setSource('');
724
+ load('');
725
+ }, [target]);
726
+ React.useEffect(() => {
727
+ if (!visible || !auto || !sourceRef.current) return undefined;
728
+ const timer = setInterval(() => { follow(); }, 1500);
729
+ return () => clearInterval(timer);
730
+ }, [visible, auto, follow, source]);
731
+
732
+ // 新内容进来自动滚到底,除非用户往上翻了
733
+ React.useEffect(() => {
734
+ const box = boxRef.current;
735
+ if (!box) return;
736
+ const nearBottom = box.scrollHeight - box.scrollTop - box.clientHeight < 80;
737
+ if (nearBottom) box.scrollTop = box.scrollHeight;
738
+ }, [text]);
739
+
740
+ const shown = text.length > 400_000 ? text.slice(-400_000) : text;
741
+ const sources = (detail && Array.isArray(detail.sources) && detail.sources) || [];
742
+ const ports = [detail && detail.portList, detail && detail.ports, item.portList].find((value) => Array.isArray(value)) || [];
743
+
744
+ return h('div', { className: 'ar-detail' },
745
+ h('div', { className: 'ar-row' },
746
+ h('button', { className: 'ar-btn ghost icon', title: '返回列表', onClick: onClose }, '‹'),
747
+ h('div', { className: 'ar-detail-head', style: { flex: '1 1 auto', minWidth: 0 } },
748
+ h('span', { className: `ar-dot ${alive ? '' : 'off'}` }),
749
+ h('span', { className: 'ar-name ar-cut' }, item.name),
750
+ h('span', { className: 'ar-hint ar-mono' }, alive ? `pid ${detail ? detail.pid : item.pid}` : '已停止')),
751
+ h('button', { className: 'ar-btn ghost', disabled: loading, onClick: () => load(source) }, '刷新')),
752
+ h('div', { className: 'ar-row', style: { flexWrap: 'wrap' } },
753
+ (detail && detail.port ? h('span', { className: 'ar-badge brand ar-mono' }, `:${detail.port}`) : null),
754
+ ports.map((p) => h('span', {
755
+ key: p.port,
756
+ className: `ar-badge ${p.role === 'http' ? 'brand' : ''}`,
757
+ title: p.reason ? `${p.label}(${p.reason})` : p.label,
758
+ }, `${p.port} ${p.role === 'http' ? 'HTTP' : p.role === 'sentinel' ? '私有协议' : p.role === 'debug' ? 'debug' : ''}`)),
759
+ (detail && detail.debugPort ? h('span', { className: 'ar-badge' }, `debug ${detail.debugPort}`) : null),
760
+ (Array.isArray(detail && detail.profiles) ? detail.profiles : []).map((p) => h('span', { key: p, className: 'ar-badge' }, p)),
761
+ h('span', { className: 'ar-spacer' }),
762
+ h('button', { className: `ar-btn ${auto ? 'tonal' : 'ghost'}`, title: '每 1.5 秒增量拉取新日志', onClick: () => setAuto(!auto) }, auto ? '自动刷新 开' : '自动刷新 关')),
763
+ // 有多个日志文件(info.log / error.log / 插件输出)时让用户切换
764
+ sources.length > 1
765
+ ? h('div', { className: 'ar-row', style: { flexWrap: 'wrap' } },
766
+ sources.map((s) => h('button', {
767
+ key: s.key,
768
+ className: `ar-btn ${s.key === source ? 'tonal' : 'ghost'}`,
769
+ title: s.path,
770
+ onClick: () => { setText(''); offsetRef.current = 0; sourceRef.current = s.key; load(s.key); },
771
+ }, s.label)))
772
+ : null,
773
+ h('div', { className: 'ar-row', style: { flexWrap: 'wrap' } },
774
+ item.owner === 'plugin'
775
+ ? (alive
776
+ ? h('span', null,
777
+ h('button', { className: 'ar-btn', disabled: busy !== '', onClick: () => onRestart(item) }, '重启'),
778
+ h('button', { className: 'ar-btn ghost danger', disabled: busy !== '', onClick: () => onStop(item) }, '停止'))
779
+ : h('button', { className: 'ar-btn primary', disabled: busy !== '', onClick: () => onRestart(item) }, '启动'))
780
+ : h('span', null,
781
+ // 已忽略的服务只提供「取消忽略」:它不在正常列表里,直接给「接管启动」容易误点
782
+ item.ignored
783
+ ? h('button', { className: 'ar-btn ghost', onClick: () => onUnignore(item) }, '取消忽略')
784
+ : h('span', null,
785
+ h('button', { className: 'ar-btn primary', disabled: busy !== '', onClick: () => onTakeOver(item) }, '接管启动'),
786
+ h('button', { className: 'ar-btn ghost', onClick: () => onIgnore(item) }, '忽略'))),
787
+ h('span', { className: 'ar-spacer' }),
788
+ detail && detail.cwd ? h('span', { className: 'ar-hint ar-mono ar-cut', title: detail.cwd }, detail.cwd) : null),
789
+ // 日志视图开关:行数(首屏读多少)与自动换行,尽量贴近 IDEA 控制台
790
+ h('div', { className: 'ar-row', style: { flexWrap: 'wrap' } },
791
+ h('span', { className: 'ar-hint' }, '行数'),
792
+ [100, 200, 500, 1000].map((n) => h('button', {
793
+ key: n,
794
+ className: `ar-btn ${lines === n ? 'tonal' : 'ghost'}`,
795
+ title: `从文件末尾读最近 ${n} 行`,
796
+ onClick: () => { setLines(n); load(sourceRef.current, n); },
797
+ }, String(n))),
798
+ h('input', {
799
+ className: 'ar-in ar-mono',
800
+ style: { width: '64px', flex: 'none' },
801
+ value: linesText,
802
+ placeholder: '自定义',
803
+ title: '自己填行数,回车生效(1–5000)',
804
+ onChange: (e) => setLinesText(e.target.value.replace(/[^\d]/g, '')),
805
+ onKeyDown: (e) => { if (e.key === 'Enter') applyLines(); },
806
+ onBlur: applyLines,
807
+ }),
808
+ h('span', { className: 'ar-spacer' }),
809
+ h('button', {
810
+ className: `ar-btn ${wrap ? 'tonal' : 'ghost'}`,
811
+ title: '长日志行是否折行显示(关闭后横向滚动,更接近 IDEA 的竖排对齐)',
812
+ onClick: () => setWrap(!wrap),
813
+ }, wrap ? '自动换行 开' : '自动换行 关')),
814
+ h(Banner, { kind: error ? 'err' : '', text: error, onClose: () => setError('') }),
815
+ h(Banner, { text: detail && detail.note ? detail.note : '' }),
816
+ loading && !detail ? h('div', { className: 'ar-hint' }, '读取中…') : null,
817
+ h('pre', { className: wrap ? 'ar-log' : 'ar-log nowrap', ref: boxRef }, shown),
818
+ h('div', { className: 'ar-hint ar-mono ar-cut', title: (detail && detail.log && detail.log.logPath) || '' },
819
+ detail && detail.log && detail.log.logPath
820
+ ? `${detail.log.logPath}${detail.log.size ? ` · ${(detail.log.size / 1024).toFixed(1)} KB` : ''}`
821
+ : '没有可读的日志文件'));
822
+ }
823
+
824
+ function ResultCard(props) {
825
+ const { result, onClose } = props;
826
+ const payload = result.payload;
827
+ const [view, setView] = React.useState('body');
828
+ const ok = Boolean(payload.ok);
829
+ const json = payload.json !== undefined ? payload.json : payload.body;
830
+ const views = [
831
+ { id: 'body', label: '响应' },
832
+ { id: 'headers', label: '响应头' },
833
+ { id: 'request', label: '请求' },
834
+ ];
835
+ const content = view === 'headers'
836
+ ? pretty(payload.headers || {})
837
+ : view === 'request'
838
+ ? pretty({ method: payload.request?.method || '', url: payload.request?.url || payload.url || '', headers: payload.request?.headers || {} })
839
+ : payload.error
840
+ ? String(payload.error)
841
+ : json !== undefined ? pretty(json) : String(payload.body || '');
842
+ return h('div', { className: 'ar-card' },
843
+ h('div', { className: 'ar-row', style: { flexWrap: 'wrap' } },
844
+ h('span', { className: `ar-badge ${ok ? 'ok' : 'err'}` }, ok ? `HTTP ${payload.status}` : '失败'),
845
+ typeof payload.durationMs === 'number' ? h('span', { className: 'ar-hint ar-mono' }, `${payload.durationMs} ms`) : null,
846
+ h('span', { className: 'ar-hint ar-mono' }, byteSize(json !== undefined ? json : payload.body)),
847
+ h('span', { className: 'ar-spacer' }),
848
+ h('div', { className: 'ar-seg' }, views.map((v) => h('button', { key: v.id, className: view === v.id ? 'on' : '', onClick: () => setView(v.id) }, v.label))),
849
+ h('button', { className: 'ar-btn ghost icon', title: '复制这段内容', onClick: () => copyText(content) }, '⧉'),
850
+ h('button', { className: 'ar-btn ghost icon', title: '关闭', onClick: onClose }, '×')),
851
+ h('div', { className: 'ar-row', style: { minWidth: 0 } },
852
+ h('span', { className: methodClass(payload.method) }, payload.method || 'GET'),
853
+ h('div', { style: { flex: '1 1 auto', minWidth: 0 } }, h(Url, { value: payload.url || payload.request?.url || '' })),
854
+ h('span', { className: 'ar-hint' }, result.apiId)),
855
+ payload.message ? h('div', { className: `ar-banner ${payload.ok ? 'ok' : 'err'}` }, payload.message) : null,
856
+ payload.token ? h('div', { className: 'ar-hint ar-mono' }, `token ${payload.token.masked} · 来自 ${payload.token.from} · 注入 ${payload.token.header}`) : null,
857
+ h('pre', { className: 'ar-pre' }, content));
858
+ }
859
+
860
+ // ---------------------------------------------------------------- 面板
861
+ function Panel(props) {
862
+ const scope = props.scope || {};
863
+ const visible = props.visible !== false;
864
+ const cwd = scope.cwd || scope.workspacePath || '';
865
+ const sessionId = scope.sessionId || '';
866
+
867
+ const [tab, setTab] = React.useState('apis');
868
+ const [state, setState] = React.useState(null);
869
+ const [java, setJava] = React.useState(null);
870
+ const [error, setError] = React.useState('');
871
+ const [notice, setNotice] = React.useState('');
872
+ const [busy, setBusy] = React.useState('');
873
+ const [form, setForm] = React.useState(null);
874
+ const [result, setResult] = React.useState(null);
875
+ const [baseUrl, setBaseUrl] = React.useState('');
876
+ const [draft, setDraft] = React.useState('');
877
+ const [filter, setFilter] = React.useState('');
878
+ const [detail, setDetail] = React.useState(null);
879
+ /** 接口页二级页签选中的项目:'' 表示跟着会话工作区走 */
880
+ const [projectTab, setProjectTab] = React.useState('');
881
+ /** 服务页二级页签:已接管 / 运行中 / 已保存 / 已忽略 */
882
+ const [group, setGroup] = React.useState('managed');
883
+ /** 用户点过页签之后就不要自动跳了 */
884
+ const groupTouched = React.useRef(false);
885
+ const [settings, setSettings] = React.useState(null);
886
+ const [dataDirDraft, setDataDirDraft] = React.useState('');
887
+ const [workspaceDraft, setWorkspaceDraft] = React.useState('');
888
+ const [mavenHomeDraft, setMavenHomeDraft] = React.useState('');
889
+ /** 一键启动的后台批次进度(null = 没有正在进行的批次) */
890
+ const [batch, setBatch] = React.useState(null);
891
+
892
+ const headers = React.useMemo(() => ({ 'Content-Type': 'application/json', 'x-dsh-session-id': sessionId }), [sessionId]);
893
+ const get = React.useCallback(async (path) => {
894
+ const sep = path.includes('?') ? '&' : '?';
895
+ return request(`${path}${sep}cwd=${encodeURIComponent(cwd)}`, { headers });
896
+ }, [cwd, headers]);
897
+ const post = React.useCallback(async (path, body) => {
898
+ return request(path, { method: 'POST', headers, body: JSON.stringify({ ...(body || {}), cwd }) });
899
+ }, [cwd, headers]);
900
+
901
+ /**
902
+ * 接口相关的请求带上「接口页」选中的项目。
903
+ *
904
+ * 只有接口这套(ui / call / login)跟项目走;服务与设置永远是会话工作区的事,
905
+ * 否则切到别的项目时「服务」页会突然变空、「设置」也会读到别的项目的值。
906
+ */
907
+ const withProject = (path) => {
908
+ if (!projectTab) return path;
909
+ const sep = path.includes('?') ? '&' : '?';
910
+ return `${path}${sep}project=${encodeURIComponent(projectTab)}`;
911
+ };
912
+ const getApi = React.useCallback(async (path) => {
913
+ const sep = path.includes('?') ? '&' : '?';
914
+ return request(withProject(`${path}${sep}cwd=${encodeURIComponent(cwd)}`), { headers });
915
+ }, [cwd, headers, projectTab]);
916
+ const postApi = React.useCallback(async (path, body) => {
917
+ const extra = projectTab ? { project: projectTab } : {};
918
+ return request(path, { method: 'POST', headers, body: JSON.stringify({ ...(body || {}), ...extra, cwd }) });
919
+ }, [cwd, headers, projectTab]);
920
+
921
+ const refresh = React.useCallback(async () => {
922
+ try {
923
+ const data = await getApi('ui');
924
+ setState(data);
925
+ setBaseUrl(data.baseUrl || '');
926
+ setDraft(data.baseUrl || '');
927
+ setError('');
928
+ } catch (e) {
929
+ setError(e.message);
930
+ }
931
+ }, [getApi]);
932
+
933
+ const refreshJava = React.useCallback(async () => {
934
+ try {
935
+ const data = await get('java');
936
+ setJava(data);
937
+ return data;
938
+ } catch (e) {
939
+ setJava({ ok: false, message: e.message, processes: [] });
940
+ return null;
941
+ }
942
+ }, [get]);
943
+
944
+ const refreshSettings = React.useCallback(async () => {
945
+ try {
946
+ const data = await get('settings');
947
+ setSettings(data);
948
+ setDataDirDraft(data.dataDir || '');
949
+ setWorkspaceDraft(data.workspace || '');
950
+ setMavenHomeDraft(data.mavenHome || '');
951
+ } catch (e) {
952
+ setSettings({ ok: false, message: e.message });
953
+ }
954
+ }, [get]);
955
+
956
+ React.useEffect(() => {
957
+ if (!visible) return undefined;
958
+ refresh();
959
+ refreshJava();
960
+ refreshSettings();
961
+ const timer = setInterval(() => { refreshJava(); }, 5000);
962
+ return () => clearInterval(timer);
963
+ }, [visible, refresh, refreshJava, refreshSettings]);
964
+
965
+ // 成功提示过几秒自己消失,别一直挂在顶上;出错提示留着,等用户处理或手动关掉
966
+ React.useEffect(() => {
967
+ if (!notice) return undefined;
968
+ const timer = setTimeout(() => setNotice(''), 6000);
969
+ return () => clearTimeout(timer);
970
+ }, [notice]);
971
+
972
+ const apis = (state && state.apis) || [];
973
+ const tokens = (state && state.tokens) || {};
974
+ const processes = (java && java.processes) || [];
975
+ /** 已保存的配方:没有进程也能启动 */
976
+ const savedList = (java && java.saved) || [];
977
+ const keyword = filter.trim().toLowerCase();
978
+ const shown = keyword === '' ? apis : apis.filter((api) => `${api.id} ${api.name || ''} ${api.url}`.toLowerCase().includes(keyword));
979
+ /**
980
+ * 接口页按项目分类:页签列的是所有已知项目(会话工作区、设置里的默认工作区、
981
+ * 配过接口的项目),切过去就是真的切过去——接口、token、baseUrl 都换,
982
+ * 在那边点测试/登录/新增/编辑/删除也作用于那个项目自己的配置。
983
+ */
984
+ const allProjects = (state && Array.isArray(state.projects)) ? state.projects : [];
985
+ const cwdProjectKey = (state && state.cwdProjectKey) || '';
986
+ /** '' 表示跟随会话工作区;有选中就以选中为准 */
987
+ const activeKey = projectTab || cwdProjectKey;
988
+ /** 选中的项目从清单里消失(目录删了、配置清了)就退回跟随工作区 */
989
+ React.useEffect(() => {
990
+ if (projectTab !== '' && !allProjects.some((item) => item.key === projectTab)) setProjectTab('');
991
+ }, [projectTab, allProjects.length]);
992
+ /** 服务分三组:插件接管过的 / 正在运行的(IDEA 或其它)/ 已忽略的 */
993
+ const managedList = processes.filter((item) => item.owner === 'plugin');
994
+ const ignoredList = processes.filter((item) => item.ignored);
995
+ const runningList = processes.filter((item) => item.owner !== 'plugin' && !item.ignored);
996
+
997
+ // 一进来「已接管」是空的、但确实有进程在跑,就直接落在「运行中」,省一次点击;
998
+ // 用户自己点过页签之后就不再干预。
999
+ React.useEffect(() => {
1000
+ if (groupTouched.current) return;
1001
+ if (managedList.length === 0 && runningList.length > 0) setGroup('running');
1002
+ }, [managedList.length, runningList.length]);
1003
+
1004
+ const saveApi = async (value) => {
1005
+ setBusy('save');
1006
+ setError('');
1007
+ try {
1008
+ const api = {
1009
+ id: value.id.trim(),
1010
+ name: value.name.trim() || value.id.trim(),
1011
+ method: value.method,
1012
+ url: value.url.trim(),
1013
+ baseUrl: value.baseUrl.trim(),
1014
+ description: value.description.trim(),
1015
+ headers: parseJsonField(value.headersText, '请求头') || {},
1016
+ /**
1017
+ * query / sampleParams 用 `|| {}` 兜底:表单里清空时 parseJsonField 返回
1018
+ * undefined,这里显式给 {},表示「用户明确清空了」,后端才会覆盖旧值;
1019
+ * 若传 undefined 会被当成「本次没提交」,旧值又会被沿用回来。
1020
+ */
1021
+ query: parseJsonField(value.queryText, 'query') || {},
1022
+ body: parseJsonField(value.bodyText, 'body'),
1023
+ sampleParams: parseJsonField(value.sampleText, '示例参数') || {},
1024
+ useToken: value.useToken,
1025
+ timeoutMs: Number(value.timeoutMs) || 30000,
1026
+ login: value.isLogin
1027
+ ? { tokenPath: value.tokenPath.trim() || 'token', header: value.authHeader.trim() || 'Authorization', prefix: value.prefix }
1028
+ : undefined,
1029
+ };
1030
+ if (!api.id) throw new Error('id 不能为空');
1031
+ if (!api.url) throw new Error('url 不能为空');
1032
+ const res = await postApi('ui/api', { api });
1033
+ if (res.ok === false) throw new Error(res.message || '保存失败');
1034
+ setForm(null);
1035
+ setNotice(res.message || '已保存');
1036
+ await refresh();
1037
+ } catch (e) {
1038
+ setError(e.message);
1039
+ } finally {
1040
+ setBusy('');
1041
+ }
1042
+ };
1043
+
1044
+ const deleteApi = async (api) => {
1045
+ if (!window.confirm(`删除接口 ${api.id}?`)) return;
1046
+ setBusy('delete');
1047
+ try {
1048
+ const res = await postApi('ui/api/delete', { id: api.id });
1049
+ if (res.ok === false) throw new Error(res.message || '删除失败');
1050
+ if (result && result.apiId === api.id) setResult(null);
1051
+ setNotice(`已删除 ${api.id}`);
1052
+ await refresh();
1053
+ } catch (e) {
1054
+ setError(e.message);
1055
+ } finally {
1056
+ setBusy('');
1057
+ }
1058
+ };
1059
+
1060
+ const doCall = async (api, mode) => {
1061
+ setBusy(mode === 'login' ? 'login' : 'call');
1062
+ setError('');
1063
+ setNotice('');
1064
+ try {
1065
+ const params = parseJsonField(api.sampleText, '示例参数') || {};
1066
+ const payload = mode === 'login'
1067
+ ? await postApi('login', { api_id: api.id, params })
1068
+ : await postApi('call', { api_id: api.id, params });
1069
+ setResult({ apiId: api.id, mode, payload });
1070
+ if (payload.ok && mode === 'login') {
1071
+ setNotice(payload.message || '已保存 token');
1072
+ await refresh();
1073
+ }
1074
+ } catch (e) {
1075
+ setError(e.message);
1076
+ } finally {
1077
+ setBusy('');
1078
+ }
1079
+ };
1080
+
1081
+ const clearToken = async (loginId) => {
1082
+ try {
1083
+ await postApi('ui/token/clear', { login_id: loginId });
1084
+ setNotice(`已清除 ${loginId} 的 token`);
1085
+ await refresh();
1086
+ } catch (e) {
1087
+ setError(e.message);
1088
+ }
1089
+ };
1090
+
1091
+ const saveBaseUrl = async () => {
1092
+ try {
1093
+ await postApi('ui/base-url', { baseUrl: draft });
1094
+ setNotice('baseUrl 已保存');
1095
+ await refresh();
1096
+ } catch (e) {
1097
+ setError(e.message);
1098
+ }
1099
+ };
1100
+
1101
+ const useBaseUrl = async (item) => {
1102
+ try {
1103
+ await postApi('ui/base-url', { baseUrl: item.baseUrl });
1104
+ setNotice(`baseUrl 已设为 ${item.baseUrl}`);
1105
+ await refresh();
1106
+ } catch (e) {
1107
+ setError(e.message);
1108
+ }
1109
+ };
1110
+
1111
+ /** 接管:结束 IDEA 的进程,编译后由插件拉起;之后这个服务就归插件管 */
1112
+ const takeOver = async (item) => {
1113
+ const ok = window.confirm(
1114
+ `接管 ${item.name}?\n\n` +
1115
+ `1. 先编译(mvn compile,可能要几十秒;失败则不动原进程)\n` +
1116
+ `2. 结束 IDEA 起的进程 pid ${item.pid}\n` +
1117
+ `3. 由插件用同样的启动参数重新拉起,日志写到 ${item.managedLogPath || '本地文件'}\n\n` +
1118
+ `之后这个服务在 IDEA 里会显示为已退出,停止/重启请在插件里操作。`,
1119
+ );
1120
+ if (!ok) return;
1121
+ setBusy('takeover');
1122
+ setError('');
1123
+ setNotice('');
1124
+ try {
1125
+ const payload = await post('java/start', { pid: item.pid, port: item.httpPort || item.primaryPort || 0, compile: true });
1126
+ if (payload.ok) {
1127
+ setNotice(payload.note || `已接管 ${item.name}`);
1128
+ await refreshJava();
1129
+ setDetail({ runId: payload.id || item.name, name: item.name, pid: payload.pid, owner: 'plugin', alive: true });
1130
+ } else {
1131
+ setError(payload.message || '接管失败');
1132
+ }
1133
+ await refresh();
1134
+ } catch (e) {
1135
+ setError(e.message);
1136
+ } finally {
1137
+ setBusy('');
1138
+ }
1139
+ };
1140
+
1141
+ const stopRun = async (item) => {
1142
+ if (!window.confirm(`停止 ${item.name}(pid ${item.pid})?之后再启动请回这里点「启动」。`)) return;
1143
+ setBusy('stop');
1144
+ try {
1145
+ const payload = await post('java/stop', { id: item.runId || item.pid });
1146
+ if (payload.ok) setNotice(payload.message || '已停止');
1147
+ else setError(payload.message || '停止失败');
1148
+ await refreshJava();
1149
+ } catch (e) {
1150
+ setError(e.message);
1151
+ } finally {
1152
+ setBusy('');
1153
+ }
1154
+ };
1155
+
1156
+ const restartRun = async (item) => {
1157
+ setBusy('restart');
1158
+ setError('');
1159
+ try {
1160
+ const payload = await post('java/restart', { id: item.runId || item.pid, compile: true });
1161
+ if (payload.ok) setNotice(`已重启 ${item.name}(pid ${payload.pid})`);
1162
+ else setError(payload.message || '重启失败');
1163
+ await refreshJava();
1164
+ } catch (e) {
1165
+ setError(e.message);
1166
+ } finally {
1167
+ setBusy('');
1168
+ }
1169
+ };
1170
+
1171
+ /** 忽略:只记进项目配置,不动进程本身 */
1172
+ const ignoreJava = async (item) => {
1173
+ try {
1174
+ const payload = await post('java/ignore', { item: { mainClass: item.mainClass, name: item.name, kind: item.kind } });
1175
+ if (payload.ok) setNotice(payload.message || `已忽略 ${item.name}`);
1176
+ else setError(payload.message || '忽略失败');
1177
+ await refreshJava();
1178
+ await refresh();
1179
+ } catch (e) {
1180
+ setError(e.message);
1181
+ }
1182
+ };
1183
+
1184
+ const unignoreJava = async (item) => {
1185
+ try {
1186
+ const payload = await post('java/unignore', { item: { mainClass: item.mainClass, name: item.name, kind: item.kind } });
1187
+ if (payload.ok) setNotice(payload.message || `已取消忽略 ${item.name}`);
1188
+ else setError(payload.message || '取消忽略失败');
1189
+ await refreshJava();
1190
+ await refresh();
1191
+ } catch (e) {
1192
+ setError(e.message);
1193
+ }
1194
+ };
1195
+
1196
+ /** 保存配方:把这套启动参数记下来,以后不用先在 IDEA 里跑 */
1197
+ const saveJava = async (item) => {
1198
+ setBusy('save');
1199
+ setError('');
1200
+ try {
1201
+ const payload = await post('java/save', { id: item.runId || item.pid, pid: item.pid });
1202
+ if (payload.ok) setNotice(payload.message || '已保存');
1203
+ else setError(payload.message || '保存失败');
1204
+ await refreshJava();
1205
+ } catch (e) {
1206
+ setError(e.message);
1207
+ } finally {
1208
+ setBusy('');
1209
+ }
1210
+ };
1211
+
1212
+ const startSaved = async (item) => {
1213
+ setBusy('start-saved');
1214
+ setError('');
1215
+ setNotice('');
1216
+ try {
1217
+ const payload = await post('java/start-saved', { id: item.id });
1218
+ if (payload.ok) {
1219
+ setNotice(payload.note || `已启动 ${item.id}(pid ${payload.pid})`);
1220
+ await refreshJava();
1221
+ } else {
1222
+ setError(payload.message || '启动失败');
1223
+ }
1224
+ } catch (e) {
1225
+ setError(e.message);
1226
+ } finally {
1227
+ setBusy('');
1228
+ }
1229
+ };
1230
+
1231
+ /**
1232
+ * 一键启动:把「已保存」里还没跑的服务全拉起来。
1233
+ *
1234
+ * 后台跑(host 逐个编译要几分钟),这里只负责起批次 + 轮询进度;
1235
+ * 轮询到 finished 就停,并刷一次服务列表把新进程显示出来。
1236
+ */
1237
+ const startAllSaved = async () => {
1238
+ setBusy('start-all');
1239
+ setError('');
1240
+ setNotice('');
1241
+ try {
1242
+ const payload = await post('java/start-all', {});
1243
+ if (!payload.ok) {
1244
+ setError(payload.message || '一键启动失败');
1245
+ setBusy('');
1246
+ return;
1247
+ }
1248
+ setBatch({
1249
+ batchId: payload.batchId,
1250
+ total: payload.total || 0,
1251
+ done: 0,
1252
+ current: '',
1253
+ items: [],
1254
+ finished: false,
1255
+ result: null,
1256
+ });
1257
+ setNotice(payload.message || '已开始一键启动');
1258
+ } catch (e) {
1259
+ setError(e.message);
1260
+ setBusy('');
1261
+ }
1262
+ };
1263
+
1264
+ // 批次进度轮询:只在有未完成批次时跑,1 秒一次,完成即停并刷新服务列表
1265
+ React.useEffect(() => {
1266
+ if (!batch || batch.finished || !batch.batchId) return undefined;
1267
+ let cancelled = false;
1268
+ const timer = setInterval(async () => {
1269
+ try {
1270
+ const data = await request(`java/start-all/status?batch=${encodeURIComponent(batch.batchId)}`, { headers });
1271
+ if (cancelled) return;
1272
+ if (!data.ok) return;
1273
+ setBatch(data);
1274
+ if (data.finished) {
1275
+ setBusy('');
1276
+ await refreshJava();
1277
+ if (data.result && data.result.ok === false) setError(data.result.message || '部分服务启动失败');
1278
+ else if (data.result) setNotice(data.result.message || '一键启动完成');
1279
+ }
1280
+ } catch {
1281
+ /* 轮询失败下次再来,不打断用户操作 */
1282
+ }
1283
+ }, 1000);
1284
+ return () => {
1285
+ cancelled = true;
1286
+ clearInterval(timer);
1287
+ };
1288
+ }, [batch && batch.batchId, batch && batch.finished, headers, refreshJava]);
1289
+
1290
+ const deleteSaved = async (item) => {
1291
+ if (!window.confirm(`删除已保存的服务 ${item.id}?(只删除启动配方,不影响正在运行的进程)`)) return;
1292
+ setBusy('delete-saved');
1293
+ try {
1294
+ const payload = await post('java/unsave', { id: item.id });
1295
+ if (payload.ok) setNotice(`已删除 ${item.id}`);
1296
+ else setError(payload.message || '删除失败');
1297
+ await refreshJava();
1298
+ } catch (e) {
1299
+ setError(e.message);
1300
+ } finally {
1301
+ setBusy('');
1302
+ }
1303
+ };
1304
+
1305
+ const saveSettings = async (patch, okText) => {
1306
+ setBusy('settings');
1307
+ setError('');
1308
+ try {
1309
+ const payload = await post('settings', patch);
1310
+ if (!payload.ok) throw new Error(payload.message || '保存失败');
1311
+ setNotice(okText);
1312
+ await refreshSettings();
1313
+ await refreshJava();
1314
+ } catch (e) {
1315
+ setError(e.message);
1316
+ } finally {
1317
+ setBusy('');
1318
+ }
1319
+ };
1320
+
1321
+ const tabs = [
1322
+ { id: 'apis', label: '接口', count: apis.length },
1323
+ { id: 'services', label: '服务', count: runningList.length },
1324
+ { id: 'settings', label: '设置', count: 0 },
1325
+ ];
1326
+
1327
+ const apisPane = h('div', { className: 'ar-pane' },
1328
+ // 项目分段控件:只有一个项目时没有分类的意义,整排省掉,别白占一行
1329
+ allProjects.length > 1
1330
+ ? h('div', { className: 'ar-tabs sub ar-projects' },
1331
+ allProjects.map((item) => {
1332
+ const isCurrent = item.key === activeKey;
1333
+ const label = projectLabel(item.key, allProjects.map((p) => p.key));
1334
+ const title = `${item.key}${item.key === cwdProjectKey ? '(会话当前工作区)' : ''}`;
1335
+ return h('button', {
1336
+ key: item.key,
1337
+ className: isCurrent ? 'ar-tab on' : 'ar-tab',
1338
+ title,
1339
+ onClick: () => { setForm(null); setProjectTab(item.key === cwdProjectKey ? '' : item.key); },
1340
+ }, h('span', { className: 'l' }, label), item.apiCount > 0 ? h('span', { className: 'ar-count' }, item.apiCount) : null);
1341
+ }))
1342
+ : null,
1343
+ h('div', { className: 'ar-row' },
1344
+ h('input', { className: 'ar-in', value: filter, placeholder: '筛选接口…', onChange: (e) => setFilter(e.target.value) }),
1345
+ form ? null : h('button', { className: 'ar-btn tonal', onClick: () => setForm(emptyForm(baseUrl)) }, '新增')),
1346
+ form ? h(EditForm, { value: form, onChange: setForm, onSave: saveApi, onCancel: () => setForm(null), tokens, busy }) : null,
1347
+ apis.length === 0 && !form
1348
+ ? h(Empty, {
1349
+ icon: '⌁',
1350
+ title: '还没有接口',
1351
+ hint: '点「新增」在面板里配一个,或在 .dsh/api-runner.yml 里写(YAML 里的接口只能改文件)。',
1352
+ action: h('button', { className: 'ar-btn primary', style: { marginTop: '2px' }, onClick: () => setForm(emptyForm(baseUrl)) }, '新增接口'),
1353
+ })
1354
+ : shown.length === 0
1355
+ ? h('div', { className: 'ar-hint', style: { padding: '8px 2px' } }, `没有匹配「${filter}」的接口。`)
1356
+ : h('div', { className: 'ar-group' }, shown.map((api) => h(ApiRow, {
1357
+ key: api.id, api, tokens, busy,
1358
+ onTest: (item) => doCall(item, 'call'),
1359
+ onLogin: (item) => doCall(item, 'login'),
1360
+ onEdit: (item) => setForm(formFromApi(item)),
1361
+ onDelete: deleteApi,
1362
+ onClearToken: clearToken,
1363
+ }))),
1364
+ result ? h(ResultCard, { result, onClose: () => setResult(null) }) : null);
1365
+
1366
+ const servicesPane = detail
1367
+ ? h(ServiceDetail, {
1368
+ item: detail,
1369
+ visible,
1370
+ get,
1371
+ busy,
1372
+ onClose: () => { setDetail(null); refreshJava(); },
1373
+ onStop: async (item) => { await stopRun(item); await refreshJava(); },
1374
+ onRestart: async (item) => { await restartRun(item); },
1375
+ onTakeOver: takeOver,
1376
+ onIgnore: ignoreJava,
1377
+ onUnignore: unignoreJava,
1378
+ })
1379
+ : h('div', { className: 'ar-pane' },
1380
+ h('div', { className: 'ar-row' },
1381
+ // 二级页签:和顶部「接口/服务/设置」同一套分段控件,只是小一号
1382
+ h('div', { className: 'ar-tabs sub' }, [
1383
+ { id: 'managed', label: '已接管', count: managedList.length },
1384
+ { id: 'running', label: '运行中', count: runningList.length },
1385
+ { id: 'saved', label: '已保存', count: savedList.length },
1386
+ { id: 'ignored', label: '已忽略', count: ignoredList.length },
1387
+ ].map((item) => h('button', {
1388
+ key: item.id,
1389
+ className: item.id === group ? 'ar-tab on' : 'ar-tab',
1390
+ onClick: () => { groupTouched.current = true; setGroup(item.id); },
1391
+ }, item.label, item.count > 0 ? h('span', { className: 'ar-count' }, item.count) : null))),
1392
+ h('button', { className: 'ar-btn ghost icon', disabled: busy !== '', title: '重新扫描 Java 进程', onClick: refreshJava }, '↻')),
1393
+ h(Banner, { kind: 'err', text: java && java.ok === false ? (java.message || '无法读取 Java 进程') : '' }),
1394
+
1395
+ group === 'managed'
1396
+ ? (managedList.length === 0
1397
+ ? h(Empty, {
1398
+ icon: '☕',
1399
+ title: '还没有接管任何服务',
1400
+ hint: '在「正在运行」里选一个 IDEA 起的服务,点「接管启动」,之后就能在这里停止、重启、看日志。',
1401
+ action: h('button', { className: 'ar-btn tonal', style: { marginTop: '2px' }, onClick: () => setGroup('running') }, '去看正在运行的服务'),
1402
+ })
1403
+ : h('div', { className: 'ar-group' }, managedList.map((item) => h(JavaRow, {
1404
+ key: `plugin-${item.runId || item.pid}`,
1405
+ item,
1406
+ busy,
1407
+ onUseBaseUrl: useBaseUrl,
1408
+ onTakeOver: takeOver,
1409
+ onStop: stopRun,
1410
+ onRestart: restartRun,
1411
+ onDetail: (target) => setDetail(target),
1412
+ onIgnore: ignoreJava,
1413
+ onUnignore: unignoreJava,
1414
+ onSave: saveJava,
1415
+ }))))
1416
+ : null,
1417
+
1418
+ group === 'running'
1419
+ ? (runningList.length === 0
1420
+ ? h(Empty, {
1421
+ icon: '☕',
1422
+ title: '没有运行中的 Java 服务',
1423
+ hint: '在 IDEA 里启动一个 Spring Boot 应用,再回来看;已经保存过启动参数的服务可以在「已保存」里直接启动。',
1424
+ })
1425
+ : h('div', { className: 'ar-group' }, runningList.map((item) => h(JavaRow, {
1426
+ key: `run-${item.owner}-${item.runId || item.pid}`,
1427
+ item,
1428
+ busy,
1429
+ onUseBaseUrl: useBaseUrl,
1430
+ onTakeOver: takeOver,
1431
+ onStop: stopRun,
1432
+ onRestart: restartRun,
1433
+ onDetail: (target) => setDetail(target),
1434
+ onIgnore: ignoreJava,
1435
+ onUnignore: unignoreJava,
1436
+ onSave: saveJava,
1437
+ }))))
1438
+ : null,
1439
+
1440
+ group === 'saved'
1441
+ ? (savedList.length === 0
1442
+ ? h(Empty, {
1443
+ icon: '☕',
1444
+ title: '还没有保存的服务',
1445
+ hint: '在「正在运行」里选一个服务点「保存」,把它那套启动参数(classpath/主类/JVM 参数)记下来。以后不用先在 IDEA 里跑,直接在这里启动。',
1446
+ action: h('button', { className: 'ar-btn tonal', style: { marginTop: '2px' }, onClick: () => setGroup('running') }, '去看正在运行的服务'),
1447
+ })
1448
+ : h('div', { className: 'ar-group' },
1449
+ // 一键启动:逐个编译+拉起,已运行的跳过,单个失败不影响其余
1450
+ h('div', { className: 'ar-row', style: { marginBottom: '2px' } },
1451
+ h('button', {
1452
+ className: 'ar-btn primary',
1453
+ disabled: busy !== '',
1454
+ title: '把「已保存」里的服务全部拉起来(已在运行的跳过;逐个编译,可能要几分钟)',
1455
+ onClick: startAllSaved,
1456
+ }, busy === 'start-all' ? '启动中…' : '一键启动全部'),
1457
+ h('span', { className: 'ar-hint', style: { flex: '1 1 auto', minWidth: 0 } },
1458
+ `${savedList.length} 个已保存`)),
1459
+ batch ? h(BatchProgress, { batch, onDismiss: () => setBatch(null) }) : null,
1460
+ savedList.map((item) => h(SavedRow, {
1461
+ key: `saved-${item.id}`,
1462
+ item,
1463
+ busy,
1464
+ onStart: startSaved,
1465
+ onDelete: deleteSaved,
1466
+ }))))
1467
+ : null,
1468
+
1469
+ group === 'ignored'
1470
+ ? (ignoredList.length === 0
1471
+ ? h(Empty, { icon: '•', title: '没有已忽略的服务', hint: '不想在「正在运行」里看到的服务,点它右边的「忽略」就会挪到这里,不影响进程本身。' })
1472
+ : h('div', { className: 'ar-group' }, ignoredList.map((item) => h(JavaRow, {
1473
+ key: `ignored-${item.owner}-${item.runId || item.pid}`,
1474
+ item,
1475
+ muted: true,
1476
+ busy,
1477
+ onUseBaseUrl: useBaseUrl,
1478
+ onTakeOver: takeOver,
1479
+ onStop: stopRun,
1480
+ onRestart: restartRun,
1481
+ onDetail: (target) => setDetail(target),
1482
+ onIgnore: ignoreJava,
1483
+ onUnignore: unignoreJava,
1484
+ onSave: saveJava,
1485
+ }))))
1486
+ : null);
1487
+
1488
+ const settingsPane = h('div', { className: 'ar-pane' },
1489
+ h('div', null,
1490
+ h('div', { className: 'ar-sec' }, h('span', {}, '数据目录')),
1491
+ h('div', { className: 'ar-card' },
1492
+ h('div', { className: 'ar-hint' },
1493
+ '插件自己的东西(服务日志、状态、面板配置)放这里。默认在 DSH_HOME 下,想换盘就填个绝对路径,保存后新日志立刻写过去。'),
1494
+ h('div', { className: 'ar-row' },
1495
+ h('input', {
1496
+ className: 'ar-in ar-mono',
1497
+ value: dataDirDraft,
1498
+ placeholder: settings?.effectiveDataDir || '绝对路径,例如 <你的目录>/dsh-api-runner-data',
1499
+ onChange: (e) => setDataDirDraft(e.target.value),
1500
+ }),
1501
+ h('button', {
1502
+ className: 'ar-btn primary',
1503
+ disabled: busy !== '' || dataDirDraft === (settings?.dataDir || ''),
1504
+ onClick: () => saveSettings({ dataDir: dataDirDraft }, '数据目录已保存'),
1505
+ }, '保存'),
1506
+ dataDirDraft ? h('button', {
1507
+ className: 'ar-btn ghost',
1508
+ title: '改回默认(DSH_HOME 下)',
1509
+ onClick: () => { setDataDirDraft(''); saveSettings({ dataDir: '' }, '已改回默认数据目录'); },
1510
+ }, '默认') : null),
1511
+ settings && settings.effectiveDataDir
1512
+ ? h('div', { className: 'ar-kv' }, h('span', { className: 'k' }, '当前'), h('span', { className: 'v ar-mono ar-muted ar-cut', title: settings.effectiveDataDir }, settings.effectiveDataDir))
1513
+ : null,
1514
+ settings && settings.settingsFile
1515
+ ? h('div', { className: 'ar-kv' }, h('span', { className: 'k' }, '设置文件'), h('span', { className: 'v ar-mono ar-muted ar-cut', title: settings.settingsFile }, settings.settingsFile))
1516
+ : null)),
1517
+ h('div', null,
1518
+ h('div', { className: 'ar-sec' }, h('span', {}, '默认工作区')),
1519
+ h('div', { className: 'ar-card' },
1520
+ h('div', { className: 'ar-hint' },
1521
+ '在没有会话上下文时(比如直接调接口、从工具里启动服务)用它当项目目录。填你的项目根目录,日志和面板配置就会按它归档。'),
1522
+ h('div', { className: 'ar-row' },
1523
+ h('input', {
1524
+ className: 'ar-in ar-mono',
1525
+ value: workspaceDraft,
1526
+ placeholder: '你的项目根目录的绝对路径',
1527
+ onChange: (e) => setWorkspaceDraft(e.target.value),
1528
+ }),
1529
+ h('button', {
1530
+ className: 'ar-btn primary',
1531
+ disabled: busy !== '' || workspaceDraft === (settings?.workspace || ''),
1532
+ onClick: () => saveSettings({ workspace: workspaceDraft }, '默认工作区已保存'),
1533
+ }, '保存')))),
1534
+ h('div', null,
1535
+ h('div', { className: 'ar-sec' }, h('span', {}, 'Maven 主路径')),
1536
+ h('div', { className: 'ar-card' },
1537
+ h('div', { className: 'ar-hint' },
1538
+ '接管启动前编译 Java 服务用。留空会自动探测:MAVEN_HOME → PATH → IDEA 自带 Maven(并读取 .idea 里的 settings.xml 和本地仓库)。'),
1539
+ h('div', { className: 'ar-row' },
1540
+ h('input', {
1541
+ className: 'ar-in ar-mono',
1542
+ value: mavenHomeDraft,
1543
+ placeholder: 'Maven 安装目录(到根为止,不含 bin)',
1544
+ onChange: (e) => setMavenHomeDraft(e.target.value),
1545
+ }),
1546
+ h('button', {
1547
+ className: 'ar-btn primary',
1548
+ disabled: busy !== '' || mavenHomeDraft === (settings?.mavenHome || ''),
1549
+ onClick: () => saveSettings({ mavenHome: mavenHomeDraft }, 'Maven 主路径已保存'),
1550
+ }, '保存'),
1551
+ mavenHomeDraft ? h('button', {
1552
+ className: 'ar-btn ghost',
1553
+ title: '清空后恢复自动探测',
1554
+ onClick: () => { setMavenHomeDraft(''); saveSettings({ mavenHome: '' }, '已改回自动探测'); },
1555
+ }, '自动') : null))),
1556
+ h('div', null,
1557
+ h('div', { className: 'ar-sec' }, h('span', {}, '接口基地址')),
1558
+ h('div', { className: 'ar-card' },
1559
+ h('div', { className: 'ar-hint' }, '接口自己没填 baseUrl 时用它。在「服务」页签点一下,就能直接设成某个 Java 服务的端口。'),
1560
+ h('div', { className: 'ar-row' },
1561
+ h('input', { className: 'ar-in ar-mono', value: draft, placeholder: 'http://127.0.0.1:8080', onChange: (e) => setDraft(e.target.value) }),
1562
+ h('button', { className: 'ar-btn primary', disabled: draft === baseUrl, onClick: saveBaseUrl }, '保存')))),
1563
+ h('div', null,
1564
+ h('div', { className: 'ar-sec' }, h('span', {}, `已保存 token · ${Object.keys(tokens).length}`)),
1565
+ h('div', { className: 'ar-card' },
1566
+ Object.keys(tokens).length === 0
1567
+ ? h('div', { className: 'ar-hint' }, '还没有 token。把登录接口勾成「登录接口」,再点「登录并保存」。')
1568
+ : Object.entries(tokens).map(([id, item]) => h('div', { key: id, className: 'ar-row' },
1569
+ h('span', { className: 'ar-name', style: { flex: '0 0 auto' } }, id),
1570
+ h('span', { className: 'ar-mono ar-muted ar-cut', style: { flex: '1 1 auto', fontSize: '12px' } }, item.masked),
1571
+ h('button', { className: 'ar-btn ghost danger', onClick: () => clearToken(id) }, '清除'))))),
1572
+ h('div', null,
1573
+ h('div', { className: 'ar-sec' }, h('span', {}, '配置来源')),
1574
+ h('div', { className: 'ar-card' },
1575
+ h('div', { className: 'ar-kv' }, h('span', { className: 'k' }, '项目'), h('span', { className: 'v ar-mono ar-muted ar-cut', title: cwd }, cwd || '(未取到项目路径,回落到 default)')),
1576
+ state && state.projectKey ? h('div', { className: 'ar-kv' }, h('span', { className: 'k' }, 'key'), h('span', { className: 'v ar-mono ar-muted ar-cut' }, state.projectKey)) : null,
1577
+ state && state.sources && state.sources.length > 0
1578
+ ? state.sources.map((s) => h('div', { key: s, className: 'ar-kv' }, h('span', { className: 'k' }, '来源'), h('span', { className: 'v ar-mono ar-muted ar-cut', title: s }, s)))
1579
+ : h('div', { className: 'ar-kv' }, h('span', { className: 'k' }, '来源'), h('span', { className: 'v ar-muted' }, '暂无 YAML 文件,接口全部来自面板')))));
1580
+
1581
+ return h('div', { className: 'ar-root' },
1582
+ h('div', { className: 'ar-top' },
1583
+ h('div', { className: 'ar-tabs' }, tabs.map((item) => h('button', {
1584
+ key: item.id,
1585
+ className: item.id === tab ? 'ar-tab on' : 'ar-tab',
1586
+ onClick: () => setTab(item.id),
1587
+ }, item.label, item.count > 0 ? h('span', { className: 'ar-count' }, item.count) : null))),
1588
+ h('button', { className: 'ar-btn ghost icon', disabled: busy !== '', title: '重新读取配置与进程列表', onClick: () => { refresh(); refreshJava(); } }, '↻')),
1589
+ h('div', { className: 'ar-body' },
1590
+ h(Banner, { kind: 'err', text: error, onClose: () => setError('') }),
1591
+ h(Banner, { kind: 'ok', text: error ? '' : notice, onClose: () => setNotice('') }),
1592
+ tab === 'apis' ? apisPane : null,
1593
+ tab === 'services' ? servicesPane : null,
1594
+ tab === 'settings' ? settingsPane : null));
1595
+ }
1596
+
1597
+ /**
1598
+ * 设置里的插件主页面。
1599
+ *
1600
+ * 侧边栏那个页签是「干活的地方」,这里只放全局项:默认工作区与数据目录。
1601
+ * 没有会话上下文,cwd 传空串,host 会回落到设置里的默认工作区。
1602
+ */
1603
+ function SettingsPage() {
1604
+ const [settings, setSettings] = React.useState(null);
1605
+ const [error, setError] = React.useState('');
1606
+ const [notice, setNotice] = React.useState('');
1607
+ const [busy, setBusy] = React.useState('');
1608
+ const [workspace, setWorkspace] = React.useState('');
1609
+ const [dataDir, setDataDir] = React.useState('');
1610
+
1611
+ const load = React.useCallback(async () => {
1612
+ try {
1613
+ const data = await request('settings', { headers: { 'Content-Type': 'application/json' } });
1614
+ if (data.ok === false) throw new Error(data.message || '读取设置失败');
1615
+ setSettings(data);
1616
+ setWorkspace(data.workspace || '');
1617
+ setDataDir(data.dataDir || '');
1618
+ setError('');
1619
+ } catch (e) {
1620
+ setError(e.message);
1621
+ }
1622
+ }, []);
1623
+
1624
+ React.useEffect(() => { load(); }, [load]);
1625
+
1626
+ const save = async (patch, message) => {
1627
+ setBusy('save');
1628
+ setError('');
1629
+ setNotice('');
1630
+ try {
1631
+ const data = await request('settings', {
1632
+ method: 'POST',
1633
+ headers: { 'Content-Type': 'application/json' },
1634
+ body: JSON.stringify({ settings: patch }),
1635
+ });
1636
+ if (data.ok === false) throw new Error(data.message || '保存失败');
1637
+ setSettings(data);
1638
+ setWorkspace(data.workspace || '');
1639
+ setDataDir(data.dataDir || '');
1640
+ setNotice(message);
1641
+ } catch (e) {
1642
+ setError(e.message);
1643
+ } finally {
1644
+ setBusy('');
1645
+ }
1646
+ };
1647
+
1648
+ return h('div', { className: 'ar-root' },
1649
+ h('div', { className: 'ar-body' },
1650
+ h(Banner, { kind: 'err', text: error, onClose: () => setError('') }),
1651
+ h(Banner, { kind: 'ok', text: error ? '' : notice, onClose: () => setNotice('') }),
1652
+ h('div', null,
1653
+ h('div', { className: 'ar-sec' }, h('span', { className: 't' }, '默认工作区')),
1654
+ h('div', { className: 'ar-card' },
1655
+ h('div', { className: 'ar-hint' },
1656
+ '没有会话上下文时(直接调接口、从工具里启动服务)用它当项目目录。填你的项目根目录的绝对路径。'),
1657
+ h('div', { className: 'ar-row' },
1658
+ h('input', {
1659
+ className: 'ar-in ar-mono',
1660
+ value: workspace,
1661
+ placeholder: '你的项目根目录的绝对路径',
1662
+ onChange: (e) => setWorkspace(e.target.value),
1663
+ }),
1664
+ h('button', {
1665
+ className: 'ar-btn primary',
1666
+ disabled: busy !== '' || workspace === (settings?.workspace || ''),
1667
+ onClick: () => save({ workspace }, '默认工作区已保存'),
1668
+ }, '保存')))),
1669
+ h('div', null,
1670
+ h('div', { className: 'ar-sec' }, h('span', { className: 't' }, '数据目录')),
1671
+ h('div', { className: 'ar-card' },
1672
+ h('div', { className: 'ar-hint' },
1673
+ '插件自己产生的东西(服务日志、状态文件、面板配置)放这里。默认在 DSH_HOME 下;想换盘就填绝对路径,保存后新的日志立刻写过去。'),
1674
+ h('div', { className: 'ar-row' },
1675
+ h('input', {
1676
+ className: 'ar-in ar-mono',
1677
+ value: dataDir,
1678
+ placeholder: settings?.effectiveDataDir || '绝对路径,例如 <你的目录>/dsh-api-runner-data',
1679
+ onChange: (e) => setDataDir(e.target.value),
1680
+ }),
1681
+ h('button', {
1682
+ className: 'ar-btn primary',
1683
+ disabled: busy !== '' || dataDir === (settings?.dataDir || ''),
1684
+ onClick: () => save({ dataDir }, '数据目录已保存'),
1685
+ }, '保存'),
1686
+ h('button', {
1687
+ className: 'ar-btn ghost',
1688
+ disabled: busy !== '' || (settings?.dataDir || '') === '',
1689
+ onClick: () => save({ dataDir: '' }, '已改回默认数据目录'),
1690
+ }, '恢复默认')),
1691
+ settings && settings.effectiveDataDir
1692
+ ? h('div', { className: 'ar-kv' }, h('span', { className: 'k' }, '当前生效'), h('span', { className: 'v ar-mono ar-muted ar-cut', title: settings.effectiveDataDir }, settings.effectiveDataDir))
1693
+ : null,
1694
+ settings && settings.settingsFile
1695
+ ? h('div', { className: 'ar-kv' }, h('span', { className: 'k' }, '设置文件'), h('span', { className: 'v ar-mono ar-muted ar-cut', title: settings.settingsFile }, settings.settingsFile))
1696
+ : null)),
1697
+ h('div', null,
1698
+ h('div', { className: 'ar-sec' }, h('span', { className: 't' }, '说明')),
1699
+ h('div', { className: 'ar-card' },
1700
+ h('div', { className: 'ar-hint' },
1701
+ '接口、token、已保存的服务都按项目归档,在侧边栏「接口调试」页签里配。这里只放跨项目的全局项。')))));
1702
+ }
1703
+
1704
+ // ---------------------------------------------------------------- 注册
1705
+ //
1706
+ // 全部注册到 DSH **自带**的 slot 席位,不依赖任何第三方侧边栏插件:
1707
+ //
1708
+ // sidebar.panellist 左栏图标(id 即 main 的 key)
1709
+ // main 中栏整页(keyed,按面板 id 分发)
1710
+ // sidebar.right.pane.tab / .title
1711
+ // 右侧栏页签(会话级,与终端/文件/任务并列)
1712
+ // settings.section 设置页里的插件主页(可选通道)
1713
+ //
1714
+ // 每个席位都用 ctx.slots.inject 包一层:席位由宿主声明,声明还没出现时
1715
+ // 先等着,声明撤掉时自动卸载贡献,之后重新声明再装回来。
1716
+ // 不写死 inject 里的服务名——cordis 的 ctx 是 Proxy,读未 inject 的服务会抛
1717
+ // `cannot get property "slots" without inject`,所以统一用 ctx.get('slots')
1718
+ // 判空,缺席位时安静跳过而不是让整个 client half 不激活。
1719
+ const inject = ['slots'];
1720
+
1721
+ /**
1722
+ * 左栏/中栏面板(全局页)。
1723
+ *
1724
+ * `main` 是 root 级 keyed 席位,本身不带会话绑定,但 root 标准 props 里有
1725
+ * `useSessions`:按「被主视图 retain 的那个会话」取 id(和宿主 DocumentTitle
1726
+ * 同一判据),再把 id 交给 host 解析 cwd。这样在全局页里打开的也是当前会话
1727
+ * 的工作区,而不是永远落到设置里的默认工作区。
1728
+ */
1729
+ function GlobalPanel(props) {
1730
+ const sessionId = typeof props.useSessions === 'function'
1731
+ ? props.useSessions((state) => {
1732
+ try {
1733
+ const rows = state && state.byId ? Object.values(state.byId) : [];
1734
+ const current = rows.find((row) => row && row.retainedBy && (row.retainedBy.mainView ?? 0) > 0);
1735
+ return current && current.id ? current.id : '';
1736
+ } catch {
1737
+ return '';
1738
+ }
1739
+ })
1740
+ : '';
1741
+ return h(Panel, { visible: true, scope: { sessionId: sessionId || '' } });
1742
+ }
1743
+
1744
+ /**
1745
+ * 右侧栏页签正文(会话级席位)。
1746
+ *
1747
+ * 会话 id 从 standard props 拿,请求头带上它,host 就能解析出该会话的 cwd;
1748
+ * 可见性跟着页签走,页签没被选中时不轮询(省掉后台会话的定时刷新)。
1749
+ */
1750
+ function RightTabPanel(props) {
1751
+ const info = typeof props.useTabInfo === 'function' ? props.useTabInfo() : undefined;
1752
+ return h(Panel, {
1753
+ visible: info ? info.tab.visible !== false : true,
1754
+ scope: { sessionId: props.sessionId || '' },
1755
+ });
1756
+ }
1757
+
1758
+ /**
1759
+ * 面板图标:宿主给 size/active,只画一个 ⚡ 字形,不引宿主 primitives。
1760
+ * 左栏 `sidebar.panellist` 和右侧栏 guide 卡都用它。
1761
+ */
1762
+ function PanelIcon(props) {
1763
+ const size = Number(props.size) || 16;
1764
+ return h('span', {
1765
+ style: {
1766
+ display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
1767
+ width: `${size}px`, height: `${size}px`,
1768
+ fontSize: `${Math.round(size * 0.82)}px`, lineHeight: 1,
1769
+ opacity: props.active ? 1 : 0.7,
1770
+ },
1771
+ 'aria-hidden': 'true',
1772
+ }, '⚡');
1773
+ }
1774
+
1775
+ function apply(ctx) {
1776
+ installStyles(ctx);
1777
+ const slots = typeof ctx.get === 'function' ? ctx.get('slots') : undefined;
1778
+ if (!slots || typeof slots.register !== 'function') return;
1779
+
1780
+ const wait = (name, register) => {
1781
+ if (typeof slots.inject !== 'function') return register();
1782
+ const dispose = slots.inject(name, register);
1783
+ return typeof dispose === 'function' ? dispose : () => {};
1784
+ };
1785
+
1786
+ // 左栏图标 + 中栏整页:一对,id 必须一致,点图标才切得过去
1787
+ ctx.effect(() => wait('sidebar.panellist', () => slots.register({
1788
+ name: 'sidebar.panellist',
1789
+ id: TAB_ID,
1790
+ order: 42,
1791
+ label: () => '接口调试',
1792
+ }, PanelIcon)), 'dsh-api-runner: panellist');
1793
+
1794
+ ctx.effect(() => wait('main', () => slots.register({
1795
+ name: 'main',
1796
+ key: TAB_ID,
1797
+ }, GlobalPanel)), 'dsh-api-runner: main panel');
1798
+
1799
+ // 右侧栏页签:定义注册进 sidebarRightTabs,正文和标题按同一个 id 落到两个 keyed 席位
1800
+ ctx.effect(() => {
1801
+ const tabs = typeof ctx.get === 'function' ? ctx.get('sidebarRightTabs') : undefined;
1802
+ if (!tabs || typeof tabs.register !== 'function') return () => {};
1803
+ return tabs.register({
1804
+ id: TAB_ID,
1805
+ kind: TAB_ID,
1806
+ title: () => '接口调试',
1807
+ guide: [{
1808
+ id: TAB_ID,
1809
+ order: 42,
1810
+ title: () => '接口调试',
1811
+ description: () => '配置接口、登录保存 token、调用测试、查看本机 Java 服务',
1812
+ icon: PanelIcon,
1813
+ }],
1814
+ }) ?? (() => {});
1815
+ }, 'dsh-api-runner: right tab type');
1816
+
1817
+ ctx.effect(() => wait('sidebar.right.pane.tab', () => slots.register({
1818
+ name: 'sidebar.right.pane.tab',
1819
+ key: TAB_ID,
1820
+ }, RightTabPanel)), 'dsh-api-runner: right tab body');
1821
+
1822
+ ctx.effect(() => wait('sidebar.right.pane.tab.title', () => slots.register({
1823
+ name: 'sidebar.right.pane.tab.title',
1824
+ key: TAB_ID,
1825
+ }, () => h('span', { style: { fontSize: '12.5px' } }, '接口调试'))), 'dsh-api-runner: right tab title');
1826
+
1827
+ // 设置页里的插件主页:全局项(默认工作区、数据目录、Maven 主路径)
1828
+ ctx.effect(() => wait('settings.section', () => slots.register({
1829
+ name: 'settings.section',
1830
+ id: TAB_ID,
1831
+ order: 80,
1832
+ label: () => '接口调试',
1833
+ }, () => h(SettingsPage, {}))), 'dsh-api-runner: settings section');
1834
+ }
1835
+
1836
+ exports.inject = inject;
1837
+ exports.apply = apply;
1838
+ return module.exports;
1839
+ },
1840
+ });