mock-service-cli 4.9.3 → 4.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2099 @@
1
+ /* API Client deliberately keeps credentials in memory, outside persisted UI state. */
2
+ (() => {
3
+ const $ = selector => document.querySelector(selector);
4
+ const id = () =>
5
+ typeof crypto.randomUUID === 'function'
6
+ ? crypto.randomUUID()
7
+ : '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, c =>
8
+ (Number(c) ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (Number(c) / 4)))).toString(16)
9
+ );
10
+ const clone = value => JSON.parse(JSON.stringify(value));
11
+ const sensitive = /authorization|cookie|password|token|secret|api[-_]?key/i;
12
+ const state = {
13
+ collections: [],
14
+ environments: [],
15
+ tabs: [],
16
+ active: '',
17
+ env: '',
18
+ panel: 'query',
19
+ responseView: 'body',
20
+ history: [],
21
+ sidebar: 'collections',
22
+ collapsed: new Set(),
23
+ secrets: {},
24
+ environmentSecrets: {},
25
+ collectionSecrets: {},
26
+ token: '',
27
+ currentResult: null,
28
+ job: null,
29
+ runner: null
30
+ };
31
+ let db;
32
+ let config;
33
+ let editor;
34
+ let editorPromise;
35
+ let toastTimer;
36
+ let persistTimer;
37
+ let rows = [];
38
+ let responseGeneration = 0;
39
+ const pending = new Map();
40
+ let workerId = 0;
41
+ const worker = new Worker('/worker.js');
42
+ worker.onmessage = event => {
43
+ const item = pending.get(event.data.id);
44
+ if (!item) return;
45
+ pending.delete(event.data.id);
46
+ if (event.data.error) item.reject(new Error(event.data.error));
47
+ else item.resolve(event.data.result);
48
+ };
49
+ const work = (action, data = {}) =>
50
+ new Promise((resolve, reject) => {
51
+ const key = ++workerId;
52
+ pending.set(key, { resolve, reject });
53
+ worker.postMessage({ id: key, action, ...data });
54
+ });
55
+ function el(tag, props = {}, ...children) {
56
+ const node = document.createElement(tag);
57
+ for (const [key, value] of Object.entries(props)) {
58
+ if (key.startsWith('on')) node.addEventListener(key.slice(2).toLowerCase(), value);
59
+ else if (key === 'className') node.className = value;
60
+ else if (key === 'text') node.textContent = value;
61
+ else if (key === 'checked' || key === 'disabled' || key === 'value') node[key] = value;
62
+ else node.setAttribute(key, value);
63
+ }
64
+ node.append(...children.filter(child => child !== null && child !== undefined));
65
+ return node;
66
+ }
67
+ function button(text, action, className = '') {
68
+ return el('button', { text, className, onclick: () => guard(action) });
69
+ }
70
+ function select(options, value, change, label) {
71
+ const node = el('select', { 'aria-label': label || '选择', onchange: event => change(event.target.value) });
72
+ for (const option of options)
73
+ node.append(
74
+ el('option', {
75
+ value: Array.isArray(option) ? option[0] : option,
76
+ text: Array.isArray(option) ? option[1] : option
77
+ })
78
+ );
79
+ node.value = value;
80
+ return node;
81
+ }
82
+ function input(value, change, placeholder = '', type = 'text') {
83
+ return el('input', {
84
+ value: value ?? '',
85
+ type,
86
+ placeholder,
87
+ 'aria-label': placeholder || '值',
88
+ oninput: event => change(event.target.value)
89
+ });
90
+ }
91
+ function notice(text, error = false) {
92
+ return el('div', { className: `notice ${error ? 'error-notice' : ''}`, text });
93
+ }
94
+ function toast(text, error = false) {
95
+ const node = $('#toast');
96
+ node.textContent = text;
97
+ node.className = error ? 'error' : '';
98
+ node.hidden = false;
99
+ clearTimeout(toastTimer);
100
+ toastTimer = setTimeout(() => {
101
+ node.hidden = true;
102
+ }, 4500);
103
+ }
104
+ async function guard(action) {
105
+ try {
106
+ return await action();
107
+ } catch (error) {
108
+ toast(error.message, true);
109
+ }
110
+ }
111
+ async function api(path, method = 'GET', data, extra = {}) {
112
+ const response = await fetch(`/__api-client/api${path}`, {
113
+ method,
114
+ headers: {
115
+ 'X-Api-Client-Token': state.token,
116
+ ...(data === undefined ? {} : { 'Content-Type': 'application/json' }),
117
+ ...extra
118
+ },
119
+ ...(data === undefined ? {} : { body: JSON.stringify(data) })
120
+ });
121
+ if (response.status === 401) {
122
+ location.assign('/login');
123
+ throw new Error('请重新登录');
124
+ }
125
+ const result = await response.json();
126
+ if (!response.ok) throw new Error(result.error || '操作失败');
127
+ return result;
128
+ }
129
+ function active() {
130
+ return state.tabs.find(tab => tab.id === state.active);
131
+ }
132
+ function fresh() {
133
+ return {
134
+ id: id(),
135
+ name: '新请求',
136
+ method: 'GET',
137
+ url: '',
138
+ query: [],
139
+ headers: [],
140
+ body: { type: 'none', text: '', fields: [] },
141
+ auth: { type: 'inherit' },
142
+ assertions: [],
143
+ extractors: [],
144
+ settings: { timeout: 30000, redirects: true, verifyTls: true, cookies: true }
145
+ };
146
+ }
147
+ function flatten(items, parent = null) {
148
+ return items.flatMap(item => [{ item, parent }, ...(item.type === 'folder' ? flatten(item.items, item) : [])]);
149
+ }
150
+ function persistable(value) {
151
+ if (Array.isArray(value)) return value.map(persistable);
152
+ if (!value || typeof value !== 'object') return value;
153
+ const result = {};
154
+ for (const [key, val] of Object.entries(value)) {
155
+ if (key === 'uploadId') continue;
156
+ const secret =
157
+ ['password', 'token', 'username'].includes(key) ||
158
+ (key === 'value' && (value.secret || sensitive.test(value.key || '') || value.type === 'apiKey'));
159
+ result[key] = secret && typeof val === 'string' && !/\{\{[^{}]+\}\}/.test(val) ? '' : persistable(val);
160
+ }
161
+ return result;
162
+ }
163
+ function secureRequest(value) {
164
+ const result = clone(value);
165
+ const keep = (obj, key, name) => {
166
+ if (!obj[key] || /\{\{[^{}]+\}\}/.test(obj[key])) return;
167
+ const variable = `secret_${result.id.replace(/-/g, '')}_${name.replace(/[^\w]/g, '_')}`;
168
+ state.secrets[variable] = obj[key];
169
+ obj[key] = `{{${variable}}}`;
170
+ };
171
+ for (const key of ['token', 'password', 'username']) keep(result.auth, key, key);
172
+ if (result.auth.type === 'apiKey') keep(result.auth, 'value', 'apiKey');
173
+ for (const group of [result.headers, result.query, result.body.fields || []])
174
+ for (const row of group) if (row.secret || sensitive.test(row.key)) keep(row, 'value', row.key);
175
+ return result;
176
+ }
177
+ function persist() {
178
+ clearTimeout(persistTimer);
179
+ persistTimer = setTimeout(() => {
180
+ if (!db || !config) return;
181
+ const data = {
182
+ tabs: state.tabs.map(tab => ({ ...tab, request: persistable(tab.request), result: undefined, job: undefined })),
183
+ active: state.active,
184
+ env: state.env,
185
+ history: state.history.slice(0, 500),
186
+ theme: $('#theme').value,
187
+ left: document.documentElement.style.getPropertyValue('--left'),
188
+ requestWidth: document.documentElement.style.getPropertyValue('--request')
189
+ };
190
+ try {
191
+ db.transaction('ui', 'readwrite').objectStore('ui').put(data, config.workspaceId);
192
+ } catch {
193
+ toast('浏览器存储不可用,项目文件仍可保存', true);
194
+ }
195
+ }, 180);
196
+ }
197
+ async function loadUI() {
198
+ db = await new Promise((resolve, reject) => {
199
+ const opening = indexedDB.open('mock-service-api-client', 1);
200
+ opening.onupgradeneeded = () => opening.result.createObjectStore('ui');
201
+ opening.onsuccess = () => resolve(opening.result);
202
+ opening.onerror = () => reject(opening.error);
203
+ });
204
+ const saved = await new Promise(resolve => {
205
+ const read = db.transaction('ui').objectStore('ui').get(config.workspaceId);
206
+ read.onsuccess = () => resolve(read.result);
207
+ read.onerror = () => resolve(null);
208
+ });
209
+ if (!saved) return;
210
+ state.tabs = saved.tabs || [];
211
+ state.active = saved.active;
212
+ state.env = saved.env;
213
+ state.history = saved.history || [];
214
+ $('#theme').value = saved.theme || 'system';
215
+ applyTheme();
216
+ if (saved.left) document.documentElement.style.setProperty('--left', saved.left);
217
+ if (saved.requestWidth) document.documentElement.style.setProperty('--request', saved.requestWidth);
218
+ }
219
+ function applyTheme() {
220
+ document.documentElement.dataset.theme =
221
+ $('#theme').value === 'system'
222
+ ? matchMedia('(prefers-color-scheme: dark)').matches
223
+ ? 'dark'
224
+ : 'light'
225
+ : $('#theme').value;
226
+ }
227
+ function mark() {
228
+ const tab = active();
229
+ if (!tab) return;
230
+ tab.dirty = true;
231
+ $('#save-state').textContent = '有未保存更改';
232
+ renderTabs();
233
+ urlPreview();
234
+ persist();
235
+ }
236
+ function openRequest(value, collectionId = '') {
237
+ let tab = state.tabs.find(row => row.request.id === value.id && row.collectionId === collectionId);
238
+ if (!tab) {
239
+ tab = { id: id(), collectionId, request: clone(value), dirty: !collectionId };
240
+ const parsed = splitQuery(tab.request.url);
241
+ if (parsed.query.length) {
242
+ tab.request.url = parsed.url;
243
+ tab.request.query = [...parsed.query, ...tab.request.query];
244
+ }
245
+ state.tabs.push(tab);
246
+ }
247
+ state.active = tab.id;
248
+ renderRequest();
249
+ renderTree();
250
+ persist();
251
+ }
252
+ function renderTabs() {
253
+ $('#tabs').replaceChildren(
254
+ ...state.tabs.map(tab => {
255
+ const node = el(
256
+ 'button',
257
+ {
258
+ className: tab.id === state.active ? 'active' : '',
259
+ role: 'tab',
260
+ title: `${tab.request.method} ${tab.request.name}${tab.dirty ? '(未保存)' : ''}`,
261
+ 'aria-selected': String(tab.id === state.active),
262
+ onclick: () => {
263
+ state.active = tab.id;
264
+ renderRequest();
265
+ renderTree();
266
+ persist();
267
+ }
268
+ },
269
+ el('span', { className: `method-tag ${tab.request.method}`, text: tab.request.method }),
270
+ el('span', { className: 'tab-title', text: `${tab.dirty ? '• ' : ''}${tab.request.name}` })
271
+ );
272
+ node.append(
273
+ el('span', {
274
+ className: 'tab-close',
275
+ role: 'button',
276
+ 'aria-label': `关闭 ${tab.request.name}`,
277
+ title: `关闭 ${tab.request.name}`,
278
+ tabindex: '0',
279
+ text: '×',
280
+ onclick: event => {
281
+ event.stopPropagation();
282
+ closeTab(tab);
283
+ },
284
+ onkeydown: event => {
285
+ if (event.key === 'Enter') {
286
+ event.stopPropagation();
287
+ closeTab(tab);
288
+ }
289
+ }
290
+ })
291
+ );
292
+ return node;
293
+ })
294
+ );
295
+ $('#tabs .active')?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
296
+ }
297
+ function closeTab(tab) {
298
+ if (tab.dirty && !confirm('此请求尚未保存,确定关闭?')) return;
299
+ state.tabs = state.tabs.filter(row => row !== tab);
300
+ if (state.active === tab.id) state.active = state.tabs[0]?.id;
301
+ if (!state.tabs.length) openRequest(fresh());
302
+ else renderRequest();
303
+ persist();
304
+ }
305
+ function buildRows() {
306
+ const search = $('#search').value.toLowerCase().trim();
307
+ rows = [];
308
+ if (state.sidebar === 'history') {
309
+ rows = state.history
310
+ .filter(row => `${row.request.name} ${row.request.url}`.toLowerCase().includes(search))
311
+ .map(row => ({
312
+ history: row,
313
+ name: `${row.status || 'ERR'} · ${row.request.name}`,
314
+ method: row.request.method,
315
+ depth: 0
316
+ }));
317
+ return;
318
+ }
319
+ const visit = (items, collection, depth) => {
320
+ for (const item of items) {
321
+ if (item.type === 'folder') {
322
+ if (
323
+ !search ||
324
+ flatten(item.items).some(row => `${row.item.name} ${row.item.url || ''}`.toLowerCase().includes(search))
325
+ )
326
+ rows.push({ item, collection, name: item.name, depth, folder: true });
327
+ if (search || !state.collapsed.has(item.id)) visit(item.items, collection, depth + 1);
328
+ } else if (!search || `${item.name} ${item.url}`.toLowerCase().includes(search))
329
+ rows.push({ item, collection, name: item.name, method: item.method, depth });
330
+ }
331
+ };
332
+ for (const collection of state.collections) {
333
+ if (
334
+ search &&
335
+ !flatten(collection.items).some(row => `${row.item.name} ${row.item.url || ''}`.toLowerCase().includes(search))
336
+ )
337
+ continue;
338
+ rows.push({ collection, name: collection.name, depth: 0, folder: true });
339
+ if (search || !state.collapsed.has(collection.id)) visit(collection.items, collection, 1);
340
+ }
341
+ }
342
+ function renderTree() {
343
+ buildRows();
344
+ drawTree();
345
+ }
346
+ function drawTree() {
347
+ const tree = $('#tree');
348
+ const scroll = tree.scrollTop;
349
+ const start = Math.max(0, Math.floor(scroll / 36) - 5);
350
+ const end = Math.min(rows.length, start + Math.ceil(tree.clientHeight / 36) + 12);
351
+ const spacer = el('div', { style: `height:${rows.length * 36}px;position:relative` });
352
+ if (!rows.length)
353
+ spacer.append(
354
+ el('p', {
355
+ className: 'hint',
356
+ style: 'padding:16px',
357
+ text:
358
+ state.sidebar === 'history' ? '发送请求后,这里会显示历史记录。' : '还没有集合。创建一个,或导入已有接口。'
359
+ })
360
+ );
361
+ for (let i = start; i < end; i++) {
362
+ const row = rows[i];
363
+ const selected = row.item?.id === active()?.request.id;
364
+ const node = el('div', {
365
+ className: `tree-row${selected ? ' selected' : ''}`,
366
+ role: 'treeitem',
367
+ tabindex: '0',
368
+ style: `position:absolute;top:${i * 36}px;left:0;right:0;padding-left:${12 + row.depth * 13}px`,
369
+ 'aria-label': row.name
370
+ });
371
+ const go = () => {
372
+ if (row.history) openRequest({ ...clone(row.history.request), id: id() });
373
+ else if (row.folder) {
374
+ const key = row.item?.id || row.collection.id;
375
+ if (state.collapsed.has(key)) state.collapsed.delete(key);
376
+ else state.collapsed.add(key);
377
+ renderTree();
378
+ } else openRequest(row.item, row.collection.id);
379
+ };
380
+ node.addEventListener('click', go);
381
+ node.addEventListener('keydown', event => {
382
+ if (event.key === 'Enter') go();
383
+ });
384
+ node.append(
385
+ el('span', {
386
+ className: row.method ? `method-tag ${row.method}` : 'muted',
387
+ text: row.method || (state.collapsed.has(row.item?.id || row.collection?.id) ? '▸' : '▾')
388
+ }),
389
+ el('span', { className: 'row-name', text: row.name })
390
+ );
391
+ if (!row.history)
392
+ node.append(
393
+ el('button', {
394
+ className: 'row-menu',
395
+ text: '···',
396
+ 'aria-label': `管理 ${row.name}`,
397
+ onclick: event => {
398
+ event.stopPropagation();
399
+ manageRow(row);
400
+ }
401
+ })
402
+ );
403
+ spacer.append(node);
404
+ }
405
+ tree.replaceChildren(spacer);
406
+ tree.scrollTop = scroll;
407
+ }
408
+ function modal(title, content, actions = []) {
409
+ $('#modal-title').textContent = title;
410
+ $('#modal-body').replaceChildren(...(Array.isArray(content) ? content : [content]));
411
+ $('#modal-actions').replaceChildren(...actions);
412
+ if (!$('#modal').open) $('#modal').showModal();
413
+ }
414
+ const closeModal = () => $('#modal').close();
415
+ function sealVariables(rows, scope) {
416
+ for (const row of rows || []) {
417
+ if ((row.secret || sensitive.test(row.key)) && row.value && !/\{\{[^{}]+\}\}/.test(row.value)) {
418
+ scope[row.key] = row.value;
419
+ row.value = '';
420
+ row.secret = true;
421
+ }
422
+ }
423
+ }
424
+ async function saveCollection(collection) {
425
+ sealVariables(collection.variables, (state.collectionSecrets[collection.id] ||= {}));
426
+ const saved = await api(
427
+ `/collections/${collection.id}`,
428
+ 'PUT',
429
+ collection,
430
+ collection.revision ? { 'If-Match': collection.revision } : {}
431
+ );
432
+ const index = state.collections.findIndex(row => row.id === saved.id);
433
+ if (index < 0) state.collections.push(saved);
434
+ else state.collections[index] = saved;
435
+ renderTree();
436
+ return saved;
437
+ }
438
+ function newCollection() {
439
+ let name = '新集合';
440
+ const field = input(
441
+ name,
442
+ value => {
443
+ name = value;
444
+ },
445
+ '集合名称'
446
+ );
447
+ modal('新建集合', field, [
448
+ button(
449
+ '创建集合',
450
+ async () => {
451
+ const collection = await saveCollection({
452
+ id: id(),
453
+ schemaVersion: 1,
454
+ name,
455
+ items: [],
456
+ variables: [],
457
+ auth: { type: 'none' }
458
+ });
459
+ closeModal();
460
+ openRequest(fresh(), collection.id);
461
+ toast('集合已创建');
462
+ },
463
+ 'primary'
464
+ )
465
+ ]);
466
+ }
467
+ function folderOptions(collection, excluded = new Set()) {
468
+ return [
469
+ ['', '集合根目录'],
470
+ ...flatten(collection.items)
471
+ .filter(row => row.item.type === 'folder' && !excluded.has(row.item.id))
472
+ .map(row => [row.item.id, row.item.name])
473
+ ];
474
+ }
475
+ function manageRow(row) {
476
+ const collection = clone(row.collection);
477
+ const original = row.item && flatten(collection.items).find(entry => entry.item.id === row.item.id);
478
+ const item = original?.item || collection;
479
+ const content = el('div');
480
+ content.append(
481
+ el(
482
+ 'label',
483
+ { text: '名称' },
484
+ input(
485
+ item.name,
486
+ value => {
487
+ item.name = value;
488
+ },
489
+ '名称'
490
+ )
491
+ )
492
+ );
493
+ let destination = original?.parent?.id || '';
494
+ if (original)
495
+ content.append(
496
+ el(
497
+ 'label',
498
+ { text: '移动到目录' },
499
+ select(
500
+ folderOptions(collection, new Set([item.id, ...flatten(item.items || []).map(entry => entry.item.id)])),
501
+ destination,
502
+ value => {
503
+ destination = value;
504
+ },
505
+ '目标目录'
506
+ )
507
+ )
508
+ );
509
+ else {
510
+ content.append(el('h3', { text: '集合变量' }));
511
+ content.append(
512
+ variablesTable(
513
+ collection.variables || (collection.variables = []),
514
+ (state.collectionSecrets[collection.id] ||= {})
515
+ )
516
+ );
517
+ content.append(
518
+ el('h3', { text: '集合认证' }),
519
+ authForm(collection.auth || (collection.auth = { type: 'none' }), () => {}, false)
520
+ );
521
+ }
522
+ const persistRow = async () => {
523
+ if (original && destination !== (original.parent?.id || '')) {
524
+ const source = original.parent?.items || collection.items;
525
+ source.splice(source.indexOf(item), 1);
526
+ const target = destination
527
+ ? flatten(collection.items).find(entry => entry.item.id === destination).item.items
528
+ : collection.items;
529
+ target.push(item);
530
+ }
531
+ if (!original) {
532
+ const wrapper = secureRequest({ ...fresh(), id: collection.id, auth: collection.auth });
533
+ collection.auth = wrapper.auth;
534
+ }
535
+ await saveCollection(collection);
536
+ closeModal();
537
+ toast('已保存');
538
+ };
539
+ const actions = [
540
+ button(
541
+ '删除',
542
+ async () => {
543
+ if (!confirm(`确定删除「${item.name}」?`)) return;
544
+ if (original) {
545
+ const source = original.parent?.items || collection.items;
546
+ source.splice(source.indexOf(item), 1);
547
+ await saveCollection(collection);
548
+ } else {
549
+ await api(`/collections/${collection.id}`, 'DELETE', undefined, { 'If-Match': collection.revision });
550
+ state.collections = state.collections.filter(entry => entry.id !== collection.id);
551
+ }
552
+ closeModal();
553
+ renderTree();
554
+ },
555
+ 'danger'
556
+ ),
557
+ button('复制', async () => {
558
+ const copy = clone(item);
559
+ const reid = value => {
560
+ value.id = id();
561
+ if (value.items) value.items.forEach(reid);
562
+ };
563
+ reid(copy);
564
+ copy.name += ' 副本';
565
+ if (original) {
566
+ (original.parent?.items || collection.items).push(copy);
567
+ await saveCollection(collection);
568
+ } else {
569
+ delete copy.revision;
570
+ await saveCollection(copy);
571
+ }
572
+ closeModal();
573
+ toast('已复制');
574
+ })
575
+ ];
576
+ if (original)
577
+ for (const [label, offset] of [
578
+ ['上移', -1],
579
+ ['下移', 1]
580
+ ])
581
+ actions.push(
582
+ button(label, async () => {
583
+ const source = original.parent?.items || collection.items;
584
+ const index = source.indexOf(item);
585
+ const target = Math.max(0, Math.min(source.length - 1, index + offset));
586
+ source.splice(index, 1);
587
+ source.splice(target, 0, item);
588
+ await saveCollection(collection);
589
+ closeModal();
590
+ })
591
+ );
592
+ if (!original || item.type === 'folder') {
593
+ actions.push(
594
+ button('+ 请求', () => {
595
+ closeModal();
596
+ const req = fresh();
597
+ req._folder = original ? item.id : '';
598
+ openRequest(req, collection.id);
599
+ })
600
+ );
601
+ actions.push(
602
+ button('+ 文件夹', async () => {
603
+ const name = prompt('文件夹名称', '新文件夹');
604
+ if (!name) return;
605
+ (original ? item.items : collection.items).push({ id: id(), type: 'folder', name, items: [] });
606
+ await saveCollection(collection);
607
+ closeModal();
608
+ })
609
+ );
610
+ }
611
+ actions.push(button('保存', persistRow, 'primary'));
612
+ modal(original ? '管理条目' : '集合设置', content, actions);
613
+ }
614
+ async function saveRequest() {
615
+ const tab = active();
616
+ if (!tab) return;
617
+ const commit = async collectionId => {
618
+ const source = state.collections.find(row => row.id === collectionId);
619
+ if (!source) throw new Error('集合已删除,请选择其他集合');
620
+ const collection = clone(source);
621
+ const value = secureRequest(tab.request);
622
+ const found = flatten(collection.items).find(row => row.item.id === value.id);
623
+ if (found) {
624
+ const items = found.parent?.items || collection.items;
625
+ items[items.indexOf(found.item)] = value;
626
+ } else {
627
+ const folder = value._folder && flatten(collection.items).find(row => row.item.id === value._folder)?.item;
628
+ delete value._folder;
629
+ (folder?.items || collection.items).push(value);
630
+ }
631
+ await saveCollection(collection);
632
+ tab.collectionId = collectionId;
633
+ tab.request = value;
634
+ tab.dirty = false;
635
+ closeModal();
636
+ renderRequest();
637
+ persist();
638
+ toast('请求已保存到项目');
639
+ };
640
+ if (tab.collectionId && state.collections.some(row => row.id === tab.collectionId)) return commit(tab.collectionId);
641
+ let collectionId = state.collections[0]?.id;
642
+ if (!collectionId) {
643
+ const collection = await saveCollection({
644
+ id: id(),
645
+ schemaVersion: 1,
646
+ name: '我的接口',
647
+ items: [],
648
+ variables: [],
649
+ auth: { type: 'none' }
650
+ });
651
+ return commit(collection.id);
652
+ }
653
+ modal(
654
+ '保存到集合',
655
+ select(
656
+ state.collections.map(row => [row.id, row.name]),
657
+ collectionId,
658
+ value => {
659
+ collectionId = value;
660
+ },
661
+ '保存到集合'
662
+ ),
663
+ [button('保存', () => commit(collectionId), 'primary')]
664
+ );
665
+ }
666
+ function splitQuery(value) {
667
+ const index = value.indexOf('?');
668
+ if (index < 0) return { url: value, query: [] };
669
+ return {
670
+ url: value.slice(0, index),
671
+ query: [...new URLSearchParams(value.slice(index + 1))].map(([key, val]) => ({ key, value: val, enabled: true }))
672
+ };
673
+ }
674
+ function requestUrl(req) {
675
+ const query = (req.query || [])
676
+ .filter(row => row.enabled !== false)
677
+ .map(
678
+ row =>
679
+ `${encodeURIComponent(row.key)}=${encodeURIComponent(row.value).replace(/%7B/gi, '{').replace(/%7D/gi, '}')}`
680
+ )
681
+ .join('&');
682
+ return req.url + (query ? (req.url.includes('?') ? '&' : '?') + query : '');
683
+ }
684
+ function urlPreview() {
685
+ const req = active()?.request;
686
+ if (!req) return;
687
+ $('#url-preview').textContent = requestUrl(req).replace(/\{\{([^{}]+)\}\}/g, (original, key) => {
688
+ if (Object.hasOwn(state.secrets, key)) return '••••';
689
+ const env = state.environments.find(row => row.id === state.env);
690
+ const collection = state.collections.find(row => row.id === active().collectionId);
691
+ const value = [...(collection?.variables || []), ...(env?.variables || [])]
692
+ .filter(row => row.enabled !== false && row.key === key)
693
+ .at(-1);
694
+ return value?.secret ? '••••' : value?.value || original;
695
+ });
696
+ }
697
+ function renderRequest() {
698
+ const tab = active();
699
+ if (!tab) return;
700
+ $('#request-name').value = tab.request.name;
701
+ $('#method').value = tab.request.method;
702
+ $('#url').value = requestUrl(tab.request);
703
+ $('#save-state').textContent = tab.dirty ? '有未保存更改' : '已保存到项目';
704
+ renderTabs();
705
+ renderPanel();
706
+ urlPreview();
707
+ showResult(tab.result || null);
708
+ syncSend();
709
+ }
710
+ function kvTable(values, change, options = {}) {
711
+ const wrapper = el('div');
712
+ const render = () => {
713
+ const table = el('table', { className: 'kv-table' });
714
+ const head = el('tr');
715
+ for (const name of ['', 'KEY', 'VALUE', ...(options.secret ? ['SECRET'] : []), ''])
716
+ head.append(el('th', { text: name }));
717
+ table.append(el('thead', {}, head));
718
+ const body = el('tbody');
719
+ for (const row of values) {
720
+ const tr = el('tr');
721
+ tr.append(
722
+ el(
723
+ 'td',
724
+ {},
725
+ el('input', {
726
+ type: 'checkbox',
727
+ checked: row.enabled !== false,
728
+ 'aria-label': '启用参数',
729
+ onchange: event => {
730
+ row.enabled = event.target.checked;
731
+ change();
732
+ }
733
+ })
734
+ )
735
+ );
736
+ tr.append(
737
+ el(
738
+ 'td',
739
+ {},
740
+ input(
741
+ row.key,
742
+ value => {
743
+ row.key = value;
744
+ change();
745
+ },
746
+ 'Key'
747
+ )
748
+ )
749
+ );
750
+ const field = input(
751
+ row.secret ? (options.secretValues || state.secrets)[row.key] || '' : row.value,
752
+ value => {
753
+ if (row.secret) (options.secretValues || state.secrets)[row.key] = value;
754
+ else row.value = value;
755
+ change();
756
+ },
757
+ row.file ? '选择文件 →' : 'Value',
758
+ row.secret ? 'password' : 'text'
759
+ );
760
+ const td = el('td', {}, field);
761
+ if (options.files) {
762
+ td.append(
763
+ button(row.file ? row.fileName || '选择文件' : '文本 → 文件', () => {
764
+ if (!row.file) {
765
+ row.file = true;
766
+ render();
767
+ change();
768
+ return;
769
+ }
770
+ chooseFile(async file => {
771
+ const uploaded = await upload(file);
772
+ row.uploadId = uploaded.uploadId;
773
+ row.fileName = file.name;
774
+ change();
775
+ render();
776
+ });
777
+ })
778
+ );
779
+ if (row.file)
780
+ td.append(
781
+ button('改为文本', () => {
782
+ row.file = false;
783
+ delete row.uploadId;
784
+ change();
785
+ render();
786
+ })
787
+ );
788
+ }
789
+ tr.append(td);
790
+ if (options.secret)
791
+ tr.append(
792
+ el(
793
+ 'td',
794
+ {},
795
+ el('input', {
796
+ type: 'checkbox',
797
+ checked: Boolean(row.secret),
798
+ 'aria-label': 'Secret 变量',
799
+ onchange: event => {
800
+ row.secret = event.target.checked;
801
+ if (row.secret) {
802
+ (options.secretValues || state.secrets)[row.key] = row.value;
803
+ row.value = '';
804
+ }
805
+ change();
806
+ render();
807
+ }
808
+ })
809
+ )
810
+ );
811
+ tr.append(
812
+ el(
813
+ 'td',
814
+ {},
815
+ button('×', () => {
816
+ values.splice(values.indexOf(row), 1);
817
+ change();
818
+ render();
819
+ })
820
+ )
821
+ );
822
+ body.append(tr);
823
+ }
824
+ table.append(body);
825
+ wrapper.replaceChildren(
826
+ table,
827
+ el(
828
+ 'div',
829
+ { className: 'toolbar' },
830
+ button('+ 添加', () => {
831
+ values.push({ key: '', value: '', enabled: true });
832
+ change();
833
+ render();
834
+ }),
835
+ button('批量粘贴', () => {
836
+ const text = prompt('每行一个 key: value 或制表符分隔的键值');
837
+ if (!text) return;
838
+ for (const line of text.split('\n')) {
839
+ const separator = line.includes('\t') ? '\t' : ':';
840
+ const index = line.indexOf(separator);
841
+ if (index > 0)
842
+ values.push({ key: line.slice(0, index).trim(), value: line.slice(index + 1).trim(), enabled: true });
843
+ }
844
+ change();
845
+ render();
846
+ })
847
+ )
848
+ );
849
+ };
850
+ render();
851
+ return wrapper;
852
+ }
853
+ function variablesTable(values, secretValues = state.secrets) {
854
+ return kvTable(values, () => {}, { secret: true, secretValues });
855
+ }
856
+ function authForm(auth, change, inherit = true) {
857
+ const wrapper = el('div');
858
+ const render = () => {
859
+ const types = [
860
+ ...(inherit ? [['inherit', '继承集合认证']] : []),
861
+ ['none', '无认证'],
862
+ ['basic', 'Basic Auth'],
863
+ ['bearer', 'Bearer Token'],
864
+ ['apiKey', 'API Key']
865
+ ];
866
+ const grid = el('div', { className: 'form-grid' });
867
+ grid.append(
868
+ el('label', { text: '认证方式' }),
869
+ select(
870
+ types,
871
+ auth.type,
872
+ value => {
873
+ auth.type = value;
874
+ change();
875
+ render();
876
+ },
877
+ '认证方式'
878
+ )
879
+ );
880
+ const field = (key, name, secret = false) => {
881
+ grid.append(
882
+ el('label', { text: name }),
883
+ input(
884
+ auth[key] || '',
885
+ value => {
886
+ auth[key] = value;
887
+ change();
888
+ },
889
+ secret ? `{{${key}}} 或会话凭据` : name,
890
+ secret ? 'password' : 'text'
891
+ )
892
+ );
893
+ };
894
+ if (auth.type === 'basic') {
895
+ field('username', '用户名', true);
896
+ field('password', '密码', true);
897
+ }
898
+ if (auth.type === 'bearer') field('token', 'Token', true);
899
+ if (auth.type === 'apiKey') {
900
+ field('key', 'Key 名称');
901
+ field('value', 'Key 值', true);
902
+ grid.append(
903
+ el('label', { text: '添加位置' }),
904
+ select(
905
+ [
906
+ ['header', 'Header'],
907
+ ['query', 'Query']
908
+ ],
909
+ auth.in || 'header',
910
+ value => {
911
+ auth.in = value;
912
+ change();
913
+ },
914
+ 'API Key 位置'
915
+ )
916
+ );
917
+ }
918
+ wrapper.replaceChildren(
919
+ grid,
920
+ el('p', {
921
+ className: 'hint',
922
+ text: '凭据仅在当前会话使用。保存请求时转换为 Secret 变量引用;重开页面后请在环境管理中重新填写。OAuth Token 可手动粘贴到 Bearer。'
923
+ })
924
+ );
925
+ };
926
+ render();
927
+ return wrapper;
928
+ }
929
+ function renderPanel() {
930
+ editor?.destroy();
931
+ editor = null;
932
+ const req = active().request;
933
+ const content = $('#request-content');
934
+ content.replaceChildren();
935
+ document
936
+ .querySelectorAll('#request-panels button')
937
+ .forEach(node => node.classList.toggle('active', node.dataset.panel === state.panel));
938
+ if (['query', 'headers'].includes(state.panel)) {
939
+ content.append(
940
+ el('div', {
941
+ className: 'section-label',
942
+ text: state.panel === 'query' ? 'QUERY PARAMETERS' : 'REQUEST HEADERS'
943
+ }),
944
+ kvTable(req[state.panel], () => {
945
+ mark();
946
+ if (state.panel === 'query') $('#url').value = requestUrl(req);
947
+ }),
948
+ el('p', { className: 'hint', text: '支持 {{变量名}}。取消勾选可暂时停用;同名参数将按顺序保留。' })
949
+ );
950
+ } else if (state.panel === 'body') {
951
+ content.append(
952
+ select(
953
+ [
954
+ ['none', '无正文'],
955
+ ['json', 'JSON'],
956
+ ['text', 'Text'],
957
+ ['xml', 'XML'],
958
+ ['form', 'x-www-form-urlencoded'],
959
+ ['multipart', 'multipart/form-data'],
960
+ ['binary', '二进制文件']
961
+ ],
962
+ req.body.type,
963
+ value => {
964
+ req.body.type = value;
965
+ req.body.fields ||= [];
966
+ mark();
967
+ renderPanel();
968
+ },
969
+ '正文类型'
970
+ )
971
+ );
972
+ if (['form', 'multipart'].includes(req.body.type))
973
+ content.append(
974
+ kvTable(req.body.fields || (req.body.fields = []), mark, { files: req.body.type === 'multipart' })
975
+ );
976
+ else if (req.body.type === 'binary')
977
+ content.append(
978
+ el(
979
+ 'div',
980
+ { className: 'toolbar' },
981
+ button(req.body.fileName || '选择文件(最大 20 MiB)', () =>
982
+ chooseFile(async file => {
983
+ const data = await upload(file);
984
+ req.body.uploadId = data.uploadId;
985
+ req.body.fileName = file.name;
986
+ mark();
987
+ renderPanel();
988
+ })
989
+ )
990
+ )
991
+ );
992
+ else if (req.body.type !== 'none') {
993
+ const host = el('div', { className: 'editor-host', style: 'margin-top:14px' });
994
+ const textarea = el('textarea', {
995
+ 'aria-label': '请求正文',
996
+ value: req.body.text || '',
997
+ spellcheck: 'false',
998
+ oninput: event => {
999
+ req.body.text = event.target.value;
1000
+ mark();
1001
+ }
1002
+ });
1003
+ host.append(textarea);
1004
+ const tools = el('div', { className: 'toolbar' });
1005
+ if (req.body.type === 'json')
1006
+ tools.append(
1007
+ button('格式化 JSON', () =>
1008
+ guard(async () => {
1009
+ const before = req.body.text || '';
1010
+ const formatted = await work('format', { value: before });
1011
+ if (!host.isConnected || req.body.text !== before) return;
1012
+ if (editor) editor.replaceText(formatted);
1013
+ else {
1014
+ textarea.value = formatted;
1015
+ req.body.text = formatted;
1016
+ mark();
1017
+ }
1018
+ })
1019
+ )
1020
+ );
1021
+ tools.append(button('查找 / 替换', () => editor?.search()));
1022
+ const hint = el('p', { className: 'hint', text: '正在加载正文编辑器…' });
1023
+ content.append(tools, host, hint);
1024
+ loadEditor()
1025
+ .then(() => {
1026
+ if (!host.isConnected || !window.createApiEditor) return;
1027
+ host.replaceChildren();
1028
+ editor = window.createApiEditor(
1029
+ host,
1030
+ req.body.text || '',
1031
+ value => {
1032
+ req.body.text = value;
1033
+ mark();
1034
+ },
1035
+ req.body.type
1036
+ );
1037
+ hint.textContent = `${req.body.type === 'text' ? '纯文本' : req.body.type.toUpperCase() + ' 语法高亮'} · 行号与折叠 · Tab 缩进 · Ctrl / ⌘ + F 查找`;
1038
+ })
1039
+ .catch(() => {
1040
+ hint.textContent = '编辑器加载失败,当前使用普通文本框;请重新构建服务并刷新页面。';
1041
+ editorPromise = null;
1042
+ });
1043
+ }
1044
+ } else if (state.panel === 'auth') content.append(authForm(req.auth, mark));
1045
+ else if (state.panel === 'tests') renderRules(content, req);
1046
+ else {
1047
+ const grid = el('div', { className: 'form-grid' });
1048
+ grid.append(
1049
+ el('label', { text: '超时(毫秒)' }),
1050
+ input(
1051
+ req.settings.timeout,
1052
+ value => {
1053
+ req.settings.timeout = Number(value);
1054
+ mark();
1055
+ },
1056
+ '1000–300000',
1057
+ 'number'
1058
+ )
1059
+ );
1060
+ for (const [key, name] of [
1061
+ ['redirects', '自动跟随重定向'],
1062
+ ['verifyTls', '验证 TLS 证书'],
1063
+ ['cookies', '自动携带 Cookie']
1064
+ ])
1065
+ grid.append(
1066
+ el('label', { text: name }),
1067
+ el('input', {
1068
+ type: 'checkbox',
1069
+ checked: req.settings[key],
1070
+ 'aria-label': name,
1071
+ onchange: event => {
1072
+ req.settings[key] = event.target.checked;
1073
+ mark();
1074
+ renderPanel();
1075
+ }
1076
+ })
1077
+ );
1078
+ content.append(grid);
1079
+ if (!req.settings.verifyTls) content.append(notice('此请求已关闭 TLS 证书验证。', true));
1080
+ content.append(
1081
+ el('p', { className: 'hint', text: '最多 10 次重定向;跨源跳转不携带原始凭据。请求失败不会自动重试。' })
1082
+ );
1083
+ }
1084
+ }
1085
+ function loadEditor() {
1086
+ if (!editorPromise)
1087
+ editorPromise = new Promise((resolve, reject) => {
1088
+ const script = el('script', { src: '/editor.js' });
1089
+ script.onload = resolve;
1090
+ script.onerror = reject;
1091
+ document.head.append(script);
1092
+ });
1093
+ return editorPromise;
1094
+ }
1095
+ function renderRules(content, req) {
1096
+ const assertions = el('div');
1097
+ const extractors = el('div');
1098
+ function draw() {
1099
+ assertions.replaceChildren(
1100
+ ...req.assertions.map(rule =>
1101
+ el(
1102
+ 'div',
1103
+ { className: 'rule-row' },
1104
+ select(
1105
+ [
1106
+ ['status', '状态码'],
1107
+ ['header', '响应头'],
1108
+ ['json', 'JSON 字段'],
1109
+ ['time', '耗时']
1110
+ ],
1111
+ rule.source,
1112
+ value => {
1113
+ rule.source = value;
1114
+ mark();
1115
+ draw();
1116
+ },
1117
+ '断言来源'
1118
+ ),
1119
+ ...(['header', 'json'].includes(rule.source)
1120
+ ? [
1121
+ input(
1122
+ rule.path || '',
1123
+ value => {
1124
+ rule.path = value;
1125
+ mark();
1126
+ },
1127
+ rule.source === 'json' ? '/data/id' : 'Header 名称'
1128
+ )
1129
+ ]
1130
+ : []),
1131
+ select(
1132
+ [
1133
+ ['equals', '等于'],
1134
+ ['exists', '存在'],
1135
+ ['contains', '包含'],
1136
+ ['type', '类型'],
1137
+ ['lessThan', '小于']
1138
+ ],
1139
+ rule.op,
1140
+ value => {
1141
+ rule.op = value;
1142
+ mark();
1143
+ },
1144
+ '断言操作'
1145
+ ),
1146
+ input(
1147
+ typeof rule.value === 'string' ? rule.value : JSON.stringify(rule.value),
1148
+ value => {
1149
+ try {
1150
+ rule.value = JSON.parse(value);
1151
+ } catch {
1152
+ rule.value = value;
1153
+ }
1154
+ mark();
1155
+ },
1156
+ '期望值'
1157
+ ),
1158
+ button('×', () => {
1159
+ req.assertions.splice(req.assertions.indexOf(rule), 1);
1160
+ mark();
1161
+ draw();
1162
+ })
1163
+ )
1164
+ )
1165
+ );
1166
+ extractors.replaceChildren(
1167
+ ...req.extractors.map(rule =>
1168
+ el(
1169
+ 'div',
1170
+ { className: 'rule-row' },
1171
+ input(
1172
+ rule.name,
1173
+ value => {
1174
+ rule.name = value;
1175
+ mark();
1176
+ },
1177
+ '变量名'
1178
+ ),
1179
+ select(
1180
+ [
1181
+ ['json', 'JSON Pointer'],
1182
+ ['header', '响应头']
1183
+ ],
1184
+ rule.source,
1185
+ value => {
1186
+ rule.source = value;
1187
+ mark();
1188
+ },
1189
+ '提取来源'
1190
+ ),
1191
+ input(
1192
+ rule.path,
1193
+ value => {
1194
+ rule.path = value;
1195
+ mark();
1196
+ },
1197
+ '/data/token'
1198
+ ),
1199
+ button('×', () => {
1200
+ req.extractors.splice(req.extractors.indexOf(rule), 1);
1201
+ mark();
1202
+ draw();
1203
+ })
1204
+ )
1205
+ )
1206
+ );
1207
+ }
1208
+ content.append(
1209
+ el('h3', { text: '响应断言' }),
1210
+ assertions,
1211
+ button('+ 添加断言', () => {
1212
+ req.assertions.push({ source: 'status', op: 'equals', value: 200 });
1213
+ mark();
1214
+ draw();
1215
+ }),
1216
+ el('h3', { text: '提取运行时变量', style: 'margin-top:28px' }),
1217
+ extractors,
1218
+ button('+ 提取变量', () => {
1219
+ req.extractors.push({ name: 'token', source: 'json', path: '/data/token' });
1220
+ mark();
1221
+ draw();
1222
+ }),
1223
+ el('p', {
1224
+ className: 'hint',
1225
+ text: '使用 JSON Pointer,例如 /data/0/id。提取值仅在本轮集合执行中生效;后续请求可引用 {{token}}。'
1226
+ })
1227
+ );
1228
+ draw();
1229
+ }
1230
+ function chooseFile(callback, accept = '') {
1231
+ const picker = el('input', { type: 'file', accept });
1232
+ picker.onchange = () => {
1233
+ if (picker.files[0]) guard(() => callback(picker.files[0]));
1234
+ };
1235
+ picker.click();
1236
+ }
1237
+ async function upload(file) {
1238
+ if (file.size > config.limits.request) throw new Error('文件超过 20 MiB');
1239
+ const response = await fetch('/__api-client/api/uploads', {
1240
+ method: 'POST',
1241
+ headers: {
1242
+ 'X-Api-Client-Token': state.token,
1243
+ 'Content-Type': 'application/octet-stream',
1244
+ 'X-File-Name': encodeURIComponent(file.name)
1245
+ },
1246
+ body: file
1247
+ });
1248
+ const value = await response.json();
1249
+ if (!response.ok) throw new Error(value.error);
1250
+ toast('文件已上传,仅在本次会话可用');
1251
+ return value;
1252
+ }
1253
+ function executionContext(collectionId) {
1254
+ const collection = state.collections.find(row => row.id === collectionId);
1255
+ const environment = state.environments.find(row => row.id === state.env);
1256
+ const resolveScope = (rows, secrets = {}) =>
1257
+ (rows || [])
1258
+ .filter(row => row.enabled !== false)
1259
+ .map(row => ({ ...row, value: row.secret ? secrets[row.key] : row.value }));
1260
+ return {
1261
+ collectionVariables: resolveScope(collection?.variables, state.collectionSecrets[collectionId]),
1262
+ environmentVariables: resolveScope(environment?.variables, state.environmentSecrets[state.env]),
1263
+ secrets: state.secrets,
1264
+ auth: collection?.auth || { type: 'none' }
1265
+ };
1266
+ }
1267
+ function syncSend() {
1268
+ const sending = Boolean(active()?.job);
1269
+ $('#send').disabled = sending;
1270
+ $('#send').textContent = sending ? '发送中…' : '发送 ↗';
1271
+ $('#cancel').hidden = !sending;
1272
+ }
1273
+ async function send() {
1274
+ const tab = active();
1275
+ if (tab.job) return;
1276
+ tab.request = secureRequest(tab.request);
1277
+ const data = await api('/executions', 'POST', { request: tab.request, ...executionContext(tab.collectionId) });
1278
+ tab.job = data.executionId;
1279
+ if (active() === tab) {
1280
+ syncSend();
1281
+ $('#response-content').replaceChildren(
1282
+ el(
1283
+ 'div',
1284
+ { className: 'empty-state' },
1285
+ el('div', { className: 'loading-ring' }),
1286
+ el('h3', { text: '正在等待响应…' }),
1287
+ el('p', { text: '请求由本地服务发送,可随时取消。' })
1288
+ )
1289
+ );
1290
+ }
1291
+ try {
1292
+ const job = await poll(data.executionId);
1293
+ tab.result = job.results[0] || {
1294
+ error: job.error || (job.state === 'cancelled' ? '已取消' : '请求失败'),
1295
+ assertions: []
1296
+ };
1297
+ state.history.unshift({
1298
+ id: id(),
1299
+ time: Date.now(),
1300
+ request: persistable(tab.request),
1301
+ status: tab.result.status,
1302
+ duration: tab.result.duration
1303
+ });
1304
+ state.history = state.history.slice(0, 500);
1305
+ if (active() === tab) await showResult(tab.result);
1306
+ renderTree();
1307
+ persist();
1308
+ } finally {
1309
+ tab.job = null;
1310
+ syncSend();
1311
+ }
1312
+ }
1313
+ async function poll(executionId, progress) {
1314
+ let interval = 200;
1315
+ const results = [];
1316
+ for (;;) {
1317
+ const job = await api(`/executions/${executionId}?after=${results.length}`);
1318
+ results.push(...job.results);
1319
+ job.results = results;
1320
+ if (progress) progress(job);
1321
+ if (!['queued', 'running'].includes(job.state)) return job;
1322
+ await new Promise(resolve => setTimeout(resolve, interval));
1323
+ interval = Math.min(1000, interval + 100);
1324
+ }
1325
+ }
1326
+ async function showResult(result) {
1327
+ const generation = ++responseGeneration;
1328
+ state.currentResult = result;
1329
+ const metrics = $('#metrics');
1330
+ const content = $('#response-content');
1331
+ content.onscroll = null;
1332
+ if (!result) {
1333
+ metrics.replaceChildren(el('span', { className: 'muted', text: '准备就绪' }));
1334
+ content.replaceChildren(
1335
+ el(
1336
+ 'div',
1337
+ { className: 'empty-state' },
1338
+ el('div', { className: 'empty-symbol', text: '↗' }),
1339
+ el('h3', { text: '从一个请求开始' }),
1340
+ el('p', { text: '输入接口地址或选择一个已保存请求。' })
1341
+ )
1342
+ );
1343
+ return;
1344
+ }
1345
+ if (result.error) {
1346
+ metrics.replaceChildren(el('span', { className: 'failure', text: result.error }));
1347
+ content.replaceChildren(notice(result.error, true));
1348
+ return;
1349
+ }
1350
+ metrics.replaceChildren(
1351
+ el('span', {
1352
+ className: `metric ${result.status < 400 ? 'success' : 'failure'}`,
1353
+ text: `${result.status} ${result.statusText || ''}`
1354
+ }),
1355
+ el('span', { className: 'metric', text: `${result.duration} ms` }),
1356
+ el('span', { className: 'metric', text: `${(result.size / 1024).toFixed(1)} KiB` })
1357
+ );
1358
+ $('#response-footer').textContent = result.truncated
1359
+ ? '预览已截断为 1 MiB;下载查看完整响应。'
1360
+ : result.tlsVerified === false
1361
+ ? '此请求未验证 TLS 证书'
1362
+ : `${(result.assertions || []).filter(row => row.passed).length}/${(result.assertions || []).length} 断言通过`;
1363
+ if (state.responseView === 'body') {
1364
+ await work('load', { value: result.body || '' });
1365
+ if (generation !== responseGeneration) return;
1366
+ if ($('#response-format').value === 'tree') {
1367
+ content.replaceChildren();
1368
+ await treeNode(content, [], generation);
1369
+ } else {
1370
+ content.scrollTop = 0;
1371
+ await renderResponseLines(generation);
1372
+ content.onscroll = () => guard(() => renderResponseLines(generation));
1373
+ }
1374
+ } else if (state.responseView === 'headers') {
1375
+ content.replaceChildren(
1376
+ ...result.headers.map(row =>
1377
+ el('div', { className: 'result-card' }, el('strong', { text: row.key }), el('pre', { text: row.value }))
1378
+ )
1379
+ );
1380
+ } else if (state.responseView === 'request') {
1381
+ content.replaceChildren(
1382
+ el(
1383
+ 'div',
1384
+ { className: 'result-card' },
1385
+ el('strong', { text: `${result.request.method} ${result.url}` }),
1386
+ el('pre', {
1387
+ text: JSON.stringify(
1388
+ { headers: result.request.headers, redirects: result.redirects, tlsVerified: result.tlsVerified },
1389
+ null,
1390
+ 2
1391
+ )
1392
+ })
1393
+ )
1394
+ );
1395
+ } else
1396
+ content.replaceChildren(
1397
+ ...(result.assertions || []).map(row =>
1398
+ el('div', {
1399
+ className: `result-card ${row.passed ? 'success' : 'failure'}`,
1400
+ text: `${row.passed ? '✓' : '×'} ${row.name}${row.error ? ` · ${row.error}` : ''}`
1401
+ })
1402
+ ),
1403
+ ...(result.extractionErrors || []).map(text => notice(text, true)),
1404
+ ...((result.assertions || []).length
1405
+ ? []
1406
+ : [el('p', { className: 'hint', text: '在请求 Tests 面板中添加断言。' })])
1407
+ );
1408
+ }
1409
+ async function renderResponseLines(generation) {
1410
+ const content = $('#response-content');
1411
+ const scroll = content.scrollTop;
1412
+ const start = Math.max(0, Math.floor(scroll / 21) - 5);
1413
+ const data = await work('lines', {
1414
+ value: $('#response-format').value,
1415
+ query: $('#response-search').value,
1416
+ start,
1417
+ count: Math.ceil(content.clientHeight / 21) + 15
1418
+ });
1419
+ if (generation !== responseGeneration) return;
1420
+ const wrap = el('div', { style: `height:${data.total * 21}px;position:relative;min-width:100%` });
1421
+ data.rows.forEach((row, i) =>
1422
+ wrap.append(
1423
+ el(
1424
+ 'div',
1425
+ { className: 'response-line', style: `position:absolute;top:${(start + i) * 21}px` },
1426
+ el('span', { className: 'line-number', text: row.number }),
1427
+ ...(row.parts
1428
+ ? row.parts.map(part => el('span', { className: `syntax-${part.kind}`, text: part.text }))
1429
+ : [row.text])
1430
+ )
1431
+ )
1432
+ );
1433
+ content.replaceChildren(wrap);
1434
+ content.scrollTop = scroll;
1435
+ }
1436
+ async function treeNode(parent, path, generation, start = 0) {
1437
+ const data = await work('tree', { path, start, count: 100 });
1438
+ if (generation !== responseGeneration) return;
1439
+ if (data.leaf !== undefined) {
1440
+ parent.append(el('div', { className: 'json-leaf', text: data.leaf || '此响应不是 JSON' }));
1441
+ return;
1442
+ }
1443
+ for (const row of data.entries) {
1444
+ if (!row.branch) parent.append(el('div', { className: 'json-leaf', text: `${row.key}: ${row.summary}` }));
1445
+ else {
1446
+ const detail = el('details', { className: 'json-tree' }, el('summary', { text: `${row.key}: ${row.summary}` }));
1447
+ let loaded = false;
1448
+ detail.addEventListener('toggle', () => {
1449
+ if (detail.open && !loaded) {
1450
+ loaded = true;
1451
+ guard(() => treeNode(detail, [...path, row.key], generation));
1452
+ }
1453
+ });
1454
+ parent.append(detail);
1455
+ }
1456
+ }
1457
+ if (start + data.entries.length < data.total) {
1458
+ const more = button('加载更多节点', async () => {
1459
+ more.remove();
1460
+ await treeNode(parent, path, generation, start + 100);
1461
+ });
1462
+ parent.append(more);
1463
+ }
1464
+ }
1465
+ async function download(path, filename) {
1466
+ const response = await fetch(`/__api-client/api${path}`, { headers: { 'X-Api-Client-Token': state.token } });
1467
+ if (!response.ok) throw new Error('下载失败或结果已过期');
1468
+ saveBlob(await response.blob(), filename);
1469
+ }
1470
+ async function copyText(text) {
1471
+ if (navigator.clipboard) return navigator.clipboard.writeText(text);
1472
+ const area = el('textarea', { value: text, style: 'position:fixed;opacity:0' });
1473
+ document.body.append(area);
1474
+ area.select();
1475
+ const copied = document.execCommand('copy');
1476
+ area.remove();
1477
+ if (!copied) throw new Error('浏览器不允许复制,请使用导出或下载');
1478
+ }
1479
+ function saveBlob(blob, name) {
1480
+ const url = URL.createObjectURL(blob);
1481
+ const link = el('a', { href: url, download: name });
1482
+ link.click();
1483
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
1484
+ }
1485
+ async function exportRequest() {
1486
+ const tab = active();
1487
+ const collection = state.collections.find(row => row.id === tab.collectionId);
1488
+ modal('导出', el('p', { className: 'hint', text: '默认保留变量引用,敏感值不会写入导出文件。' }), [
1489
+ button('复制 cURL', async () => {
1490
+ const value = await api('/exports/curl', 'POST', secureRequest(tab.request));
1491
+ await copyText(value.text);
1492
+ toast('cURL 已复制');
1493
+ closeModal();
1494
+ }),
1495
+ button(
1496
+ '导出集合 JSON',
1497
+ () => {
1498
+ const data = collection || {
1499
+ schemaVersion: 1,
1500
+ name: tab.request.name,
1501
+ variables: [],
1502
+ items: [secureRequest(tab.request)]
1503
+ };
1504
+ saveBlob(
1505
+ new Blob([JSON.stringify(persistable(data), null, 2)], { type: 'application/json' }),
1506
+ `${data.name}.json`
1507
+ );
1508
+ closeModal();
1509
+ },
1510
+ 'primary'
1511
+ )
1512
+ ]);
1513
+ }
1514
+ function importDialog() {
1515
+ let format = 'curl';
1516
+ let text = '';
1517
+ let report;
1518
+ const source = el('textarea', {
1519
+ placeholder: '粘贴 cURL、Postman Collection、OpenAPI 或原生 JSON…',
1520
+ 'aria-label': '导入内容',
1521
+ oninput: event => {
1522
+ text = event.target.value;
1523
+ report = null;
1524
+ }
1525
+ });
1526
+ const result = el('div');
1527
+ const content = el(
1528
+ 'div',
1529
+ {},
1530
+ el(
1531
+ 'div',
1532
+ { className: 'toolbar' },
1533
+ select(
1534
+ [
1535
+ ['curl', 'cURL'],
1536
+ ['postman', 'Postman v2.1'],
1537
+ ['openapi', 'OpenAPI 3.0 / 3.1'],
1538
+ ['native', '原生 JSON']
1539
+ ],
1540
+ format,
1541
+ value => {
1542
+ format = value;
1543
+ report = null;
1544
+ },
1545
+ '导入格式'
1546
+ ),
1547
+ button('选择文件', () =>
1548
+ chooseFile(async file => {
1549
+ if (file.size > config.limits.request) throw new Error('文件超过 20 MiB');
1550
+ text = await file.text();
1551
+ source.value = text;
1552
+ report = null;
1553
+ }, '.json,.yaml,.yml,.txt')
1554
+ )
1555
+ ),
1556
+ source,
1557
+ result
1558
+ );
1559
+ const commit = button(
1560
+ '确认导入',
1561
+ async () => {
1562
+ if (!report) throw new Error('请先预览当前内容');
1563
+ await saveCollection(report.collection);
1564
+ closeModal();
1565
+ toast(`已导入 ${report.count} 个请求`);
1566
+ },
1567
+ 'primary'
1568
+ );
1569
+ modal('导入接口', content, [
1570
+ button('预览兼容性', async () => {
1571
+ report = await api('/imports/preview', 'POST', { format, text });
1572
+ result.replaceChildren(
1573
+ notice(`「${report.collection.name}」包含 ${report.count} 个请求,将保存为新集合。`),
1574
+ ...report.warnings.map(message => notice(message))
1575
+ );
1576
+ }),
1577
+ commit
1578
+ ]);
1579
+ }
1580
+ function environmentsDialog() {
1581
+ let selected = state.env || state.environments[0]?.id || '';
1582
+ let draft;
1583
+ const content = el('div');
1584
+ const draw = () => {
1585
+ draft = clone(
1586
+ state.environments.find(row => row.id === selected) || {
1587
+ id: id(),
1588
+ name: '新环境',
1589
+ schemaVersion: 1,
1590
+ variables: []
1591
+ }
1592
+ );
1593
+ content.replaceChildren(
1594
+ select(
1595
+ [['', '+ 新环境'], ...state.environments.map(row => [row.id, row.name])],
1596
+ selected,
1597
+ value => {
1598
+ selected = value;
1599
+ draw();
1600
+ },
1601
+ '编辑环境'
1602
+ ),
1603
+ el(
1604
+ 'label',
1605
+ { text: '环境名称' },
1606
+ input(
1607
+ draft.name,
1608
+ value => {
1609
+ draft.name = value;
1610
+ },
1611
+ '环境名称'
1612
+ )
1613
+ ),
1614
+ variablesTable(draft.variables, (state.environmentSecrets[draft.id] ||= {})),
1615
+ el('p', {
1616
+ className: 'hint',
1617
+ text: 'Secret 值只保留在当前页面内存中。普通变量保存到项目文件,切勿填写真实密码。'
1618
+ })
1619
+ );
1620
+ const missing = new Set(Object.keys(state.secrets));
1621
+ const scan = value => {
1622
+ for (const match of JSON.stringify(value).matchAll(/\{\{([^{}]+)\}\}/g))
1623
+ if (match[1].startsWith('secret_')) missing.add(match[1]);
1624
+ };
1625
+ state.collections.forEach(scan);
1626
+ state.tabs.forEach(tab => scan(tab.request));
1627
+ if (missing.size) {
1628
+ content.append(el('h3', { text: '请求凭据(仅当前会话)' }));
1629
+ for (const key of missing)
1630
+ content.append(
1631
+ el(
1632
+ 'label',
1633
+ { text: key },
1634
+ input(
1635
+ state.secrets[key] || '',
1636
+ value => {
1637
+ state.secrets[key] = value;
1638
+ },
1639
+ '重新填写凭据',
1640
+ 'password'
1641
+ )
1642
+ )
1643
+ );
1644
+ }
1645
+ };
1646
+ draw();
1647
+ modal('环境与会话凭据', content, [
1648
+ button('删除环境', async () => {
1649
+ if (!selected || !confirm('确定删除此环境?')) return;
1650
+ await api(`/environments/${draft.id}`, 'DELETE', undefined, { 'If-Match': draft.revision });
1651
+ state.environments = state.environments.filter(row => row.id !== draft.id);
1652
+ if (state.env === draft.id) state.env = '';
1653
+ renderEnvironments();
1654
+ closeModal();
1655
+ }),
1656
+ button('仅应用会话凭据', () => {
1657
+ closeModal();
1658
+ urlPreview();
1659
+ toast('会话凭据已更新');
1660
+ }),
1661
+ button(
1662
+ '保存并启用环境',
1663
+ async () => {
1664
+ sealVariables(draft.variables, (state.environmentSecrets[draft.id] ||= {}));
1665
+ const saved = await api(
1666
+ `/environments/${draft.id}`,
1667
+ 'PUT',
1668
+ draft,
1669
+ draft.revision ? { 'If-Match': draft.revision } : {}
1670
+ );
1671
+ const index = state.environments.findIndex(row => row.id === saved.id);
1672
+ if (index < 0) state.environments.push(saved);
1673
+ else state.environments[index] = saved;
1674
+ state.env = saved.id;
1675
+ renderEnvironments();
1676
+ closeModal();
1677
+ persist();
1678
+ toast('环境已保存');
1679
+ },
1680
+ 'primary'
1681
+ )
1682
+ ]);
1683
+ }
1684
+ function renderEnvironments() {
1685
+ $('#environment').replaceChildren(
1686
+ el('option', { value: '', text: '无环境' }),
1687
+ ...state.environments.map(row => el('option', { value: row.id, text: row.name }))
1688
+ );
1689
+ $('#environment').value = state.env;
1690
+ urlPreview();
1691
+ }
1692
+ async function cookiesDialog() {
1693
+ const cookies = await api('/cookies');
1694
+ let url = '';
1695
+ let value = '';
1696
+ const content = el(
1697
+ 'div',
1698
+ {},
1699
+ el('p', {
1700
+ className: 'hint',
1701
+ text: 'Cookie 按域名、路径、Secure 和到期时间管理,仅用于目标接口。不会读取浏览器自身 Cookie。'
1702
+ }),
1703
+ ...cookies.map(cookie =>
1704
+ el(
1705
+ 'div',
1706
+ { className: 'result-card' },
1707
+ el('strong', { text: `${cookie.domain}${cookie.path} · ${cookie.key}` }),
1708
+ el('pre', { text: `${cookie.value}\n${cookie.secure ? 'Secure · ' : ''}${cookie.expires || 'Session'}` })
1709
+ )
1710
+ ),
1711
+ el(
1712
+ 'label',
1713
+ { text: '目标 URL' },
1714
+ input(
1715
+ url,
1716
+ text => {
1717
+ url = text;
1718
+ },
1719
+ 'https://api.example.com'
1720
+ )
1721
+ ),
1722
+ el(
1723
+ 'label',
1724
+ { text: '新增或更新 Cookie' },
1725
+ input(
1726
+ value,
1727
+ text => {
1728
+ value = text;
1729
+ },
1730
+ 'session=abc; Path=/; Secure'
1731
+ )
1732
+ )
1733
+ );
1734
+ modal('Cookie 管理', content, [
1735
+ button('清除全部', async () => {
1736
+ await api('/cookies', 'DELETE');
1737
+ await cookiesDialog();
1738
+ }),
1739
+ button(
1740
+ '保存 Cookie',
1741
+ async () => {
1742
+ await api('/cookies', 'POST', { url, cookie: value });
1743
+ await cookiesDialog();
1744
+ },
1745
+ 'primary'
1746
+ )
1747
+ ]);
1748
+ }
1749
+ function runnerDialog() {
1750
+ if (state.runner) {
1751
+ showRunner();
1752
+ return;
1753
+ }
1754
+ let collectionId = active()?.collectionId || state.collections[0]?.id;
1755
+ if (!collectionId) {
1756
+ toast('先创建并保存一个集合', true);
1757
+ return;
1758
+ }
1759
+ let iterations = 1;
1760
+ let delay = 0;
1761
+ let stopOnFailure = false;
1762
+ const selection = new Set();
1763
+ const requests = el('div');
1764
+ const draw = () => {
1765
+ selection.clear();
1766
+ const collection = state.collections.find(row => row.id === collectionId);
1767
+ requests.replaceChildren(
1768
+ ...flatten(collection.items)
1769
+ .filter(row => row.item.type !== 'folder')
1770
+ .map(({ item }) => {
1771
+ selection.add(item.id);
1772
+ return el(
1773
+ 'label',
1774
+ { className: 'check-label' },
1775
+ el('input', {
1776
+ type: 'checkbox',
1777
+ checked: true,
1778
+ onchange: event => {
1779
+ if (event.target.checked) selection.add(item.id);
1780
+ else selection.delete(item.id);
1781
+ }
1782
+ }),
1783
+ `${item.method} ${item.name}`
1784
+ );
1785
+ })
1786
+ );
1787
+ };
1788
+ draw();
1789
+ const content = el(
1790
+ 'div',
1791
+ {},
1792
+ select(
1793
+ state.collections.map(row => [row.id, row.name]),
1794
+ collectionId,
1795
+ value => {
1796
+ collectionId = value;
1797
+ draw();
1798
+ },
1799
+ '运行集合'
1800
+ ),
1801
+ el(
1802
+ 'div',
1803
+ { className: 'toolbar' },
1804
+ el(
1805
+ 'label',
1806
+ { text: '迭代次数' },
1807
+ input(
1808
+ iterations,
1809
+ value => {
1810
+ iterations = Number(value);
1811
+ },
1812
+ '1–100',
1813
+ 'number'
1814
+ )
1815
+ ),
1816
+ el(
1817
+ 'label',
1818
+ { text: '请求间隔(毫秒)' },
1819
+ input(
1820
+ delay,
1821
+ value => {
1822
+ delay = Number(value);
1823
+ },
1824
+ '0–30000',
1825
+ 'number'
1826
+ )
1827
+ ),
1828
+ el(
1829
+ 'label',
1830
+ { className: 'check-label' },
1831
+ el('input', {
1832
+ type: 'checkbox',
1833
+ onchange: event => {
1834
+ stopOnFailure = event.target.checked;
1835
+ }
1836
+ }),
1837
+ '失败后停止'
1838
+ )
1839
+ ),
1840
+ requests
1841
+ );
1842
+ modal('运行集合', content, [
1843
+ button(
1844
+ '开始运行',
1845
+ async () => {
1846
+ const collection = state.collections.find(row => row.id === collectionId);
1847
+ const selected = flatten(collection.items)
1848
+ .filter(row => selection.has(row.item.id))
1849
+ .map(row => row.item);
1850
+ if (!selected.length) throw new Error('至少选择一个请求');
1851
+ state.runner = await api('/executions', 'POST', {
1852
+ requests: selected,
1853
+ iterations,
1854
+ delay,
1855
+ stopOnFailure,
1856
+ ...executionContext(collectionId)
1857
+ });
1858
+ showRunner();
1859
+ const job = await poll(state.runner.executionId, progress => {
1860
+ state.runner = progress;
1861
+ if ($('#modal').open && $('#modal-title').textContent === '集合执行结果') showRunner();
1862
+ });
1863
+ state.runner = job;
1864
+ showRunner();
1865
+ },
1866
+ 'primary'
1867
+ )
1868
+ ]);
1869
+ }
1870
+ function showRunner() {
1871
+ const job = state.runner;
1872
+ const content = el(
1873
+ 'div',
1874
+ {},
1875
+ notice(`${job.state || 'queued'} · ${job.progress?.completed || 0} / ${job.progress?.total || '…'}`)
1876
+ );
1877
+ for (const result of job.results || [])
1878
+ content.append(
1879
+ el(
1880
+ 'div',
1881
+ { className: 'result-card' },
1882
+ el('strong', { text: `${result.name} · 第 ${result.iteration} 轮 · ${result.status || result.error}` }),
1883
+ el('p', {
1884
+ className: 'hint',
1885
+ text: `${result.duration || 0} ms · ${(result.assertions || []).filter(row => row.passed).length}/${(result.assertions || []).length} 断言通过`
1886
+ }),
1887
+ button('查看响应', async () => {
1888
+ closeModal();
1889
+ await showResult(result);
1890
+ })
1891
+ )
1892
+ );
1893
+ if (job.error) content.append(notice(job.error, true));
1894
+ modal('集合执行结果', content, [
1895
+ button('取消执行', () => api(`/executions/${job.executionId}/cancel`, 'POST', {})),
1896
+ button('导出报告', () => download(`/executions/${job.executionId}/report`, 'api-client-report.json')),
1897
+ button('清理结果', async () => {
1898
+ await api(`/executions/${job.executionId}`, 'DELETE');
1899
+ state.runner = null;
1900
+ closeModal();
1901
+ })
1902
+ ]);
1903
+ }
1904
+ function setupSplitter(node, property, min, max, reversed = false) {
1905
+ node.addEventListener('pointerdown', event => {
1906
+ const start = event.clientX;
1907
+ const initial = reversed
1908
+ ? $('.request-pane').getBoundingClientRect().width
1909
+ : $('.sidebar').getBoundingClientRect().width;
1910
+ node.setPointerCapture(event.pointerId);
1911
+ const move = moveEvent =>
1912
+ document.documentElement.style.setProperty(
1913
+ property,
1914
+ `${Math.min(max, Math.max(min, initial + moveEvent.clientX - start))}px`
1915
+ );
1916
+ const up = () => {
1917
+ node.removeEventListener('pointermove', move);
1918
+ node.removeEventListener('pointerup', up);
1919
+ persist();
1920
+ };
1921
+ node.addEventListener('pointermove', move);
1922
+ node.addEventListener('pointerup', up);
1923
+ });
1924
+ node.addEventListener('keydown', event => {
1925
+ if (!['ArrowLeft', 'ArrowRight'].includes(event.key)) return;
1926
+ event.preventDefault();
1927
+ const initial = reversed
1928
+ ? $('.request-pane').getBoundingClientRect().width
1929
+ : $('.sidebar').getBoundingClientRect().width;
1930
+ document.documentElement.style.setProperty(
1931
+ property,
1932
+ `${Math.min(max, Math.max(min, initial + (event.key === 'ArrowRight' ? 20 : -20)))}px`
1933
+ );
1934
+ persist();
1935
+ });
1936
+ }
1937
+ async function boot() {
1938
+ config = await api('/config');
1939
+ state.token = config.token;
1940
+ $('#workspace').textContent = config.workspaceName;
1941
+ [state.collections, state.environments] = await Promise.all([api('/collections'), api('/environments')]);
1942
+ try {
1943
+ await loadUI();
1944
+ } catch {
1945
+ toast('浏览器存储不可用,临时界面状态不会保存', true);
1946
+ }
1947
+ $('#search').addEventListener('input', renderTree);
1948
+ $('#tree').addEventListener('scroll', drawTree);
1949
+ $('#show-collections').onclick = () => {
1950
+ state.sidebar = 'collections';
1951
+ $('#show-collections').classList.add('active');
1952
+ $('#show-history').classList.remove('active');
1953
+ renderTree();
1954
+ };
1955
+ $('#show-history').onclick = () => {
1956
+ state.sidebar = 'history';
1957
+ $('#show-history').classList.add('active');
1958
+ $('#show-collections').classList.remove('active');
1959
+ renderTree();
1960
+ };
1961
+ $('#new-collection').onclick = newCollection;
1962
+ $('#new-request').onclick = () => openRequest(fresh());
1963
+ $('#request-name').oninput = event => {
1964
+ active().request.name = event.target.value;
1965
+ mark();
1966
+ };
1967
+ $('#method').onchange = event => {
1968
+ active().request.method = event.target.value;
1969
+ mark();
1970
+ };
1971
+ $('#url').oninput = event => {
1972
+ const parsed = splitQuery(event.target.value);
1973
+ active().request.url = parsed.url;
1974
+ active().request.query = parsed.query;
1975
+ mark();
1976
+ if (state.panel === 'query') renderPanel();
1977
+ };
1978
+ $('#request-panels').onclick = event => {
1979
+ if (!event.target.dataset.panel) return;
1980
+ state.panel = event.target.dataset.panel;
1981
+ renderPanel();
1982
+ };
1983
+ $('#response-panels').onclick = event => {
1984
+ if (!event.target.dataset.view) return;
1985
+ state.responseView = event.target.dataset.view;
1986
+ document
1987
+ .querySelectorAll('#response-panels button')
1988
+ .forEach(node => node.classList.toggle('active', node === event.target));
1989
+ guard(() => showResult(state.currentResult));
1990
+ };
1991
+ $('#response-format').onchange = () => guard(() => showResult(state.currentResult));
1992
+ let searchTimer;
1993
+ $('#response-search').oninput = () => {
1994
+ clearTimeout(searchTimer);
1995
+ searchTimer = setTimeout(() => guard(() => showResult(state.currentResult)), 150);
1996
+ };
1997
+ $('#response-copy').onclick = () =>
1998
+ guard(async () => {
1999
+ if (!state.currentResult || state.currentResult.error) throw new Error('还没有可复制的响应');
2000
+ await copyText(await work('copy', { value: $('#response-format').value }));
2001
+ toast('响应已复制');
2002
+ });
2003
+ $('#response-download').onclick = () =>
2004
+ guard(async () => {
2005
+ if (!state.currentResult?.downloadId) throw new Error('还没有可下载的响应');
2006
+ await download(
2007
+ `/downloads/${state.currentResult.downloadId}`,
2008
+ state.currentResult.downloadName || 'response.bin'
2009
+ );
2010
+ });
2011
+ $('#send').onclick = () => guard(send);
2012
+ $('#cancel').onclick = () => guard(() => api(`/executions/${active().job}/cancel`, 'POST', {}));
2013
+ $('#save').onclick = () => guard(saveRequest);
2014
+ $('#export').onclick = () => guard(exportRequest);
2015
+ $('#import').onclick = importDialog;
2016
+ $('#environments').onclick = environmentsDialog;
2017
+ $('#environment').onchange = event => {
2018
+ state.env = event.target.value;
2019
+ urlPreview();
2020
+ persist();
2021
+ };
2022
+ $('#cookies').onclick = () => guard(cookiesDialog);
2023
+ $('#run').onclick = runnerDialog;
2024
+ $('#theme').onchange = () => {
2025
+ applyTheme();
2026
+ persist();
2027
+ };
2028
+ matchMedia('(prefers-color-scheme: dark)').addEventListener('change', applyTheme);
2029
+ $('#logout').onclick = () =>
2030
+ guard(async () => {
2031
+ if (!confirm('退出并清除当前会话凭据和执行结果?')) return;
2032
+ await api('/session', 'DELETE');
2033
+ state.secrets = {};
2034
+ state.environmentSecrets = {};
2035
+ state.collectionSecrets = {};
2036
+ location.assign('/');
2037
+ });
2038
+ $('#modal-close').onclick = closeModal;
2039
+ const modifier = /Mac|iPhone|iPad/.test(navigator.platform) ? '⌘' : 'Ctrl';
2040
+ $('#save kbd').textContent = `${modifier} S`;
2041
+ $('#search').placeholder = `搜索请求… ${modifier} K`;
2042
+ $('#shortcuts').onclick = () =>
2043
+ modal('快捷键', [
2044
+ el('p', {
2045
+ className: 'hint',
2046
+ text: '页面获得焦点后可用;Mac 使用 ⌘,Windows / Linux 使用 Ctrl。打开弹窗时暂停工作台快捷键。'
2047
+ }),
2048
+ ...[
2049
+ ['发送当前请求', `${modifier} + Enter`],
2050
+ ['保存当前请求到项目', `${modifier} + S`],
2051
+ ['聚焦左侧请求搜索', `${modifier} + K`],
2052
+ ['正文内查找 / 替换(先点击编辑器)', `${modifier} + F`],
2053
+ ['正文撤销 / 重做', `${modifier} + Z / ${modifier} + Shift + Z`],
2054
+ ['正文缩进 / 取消缩进', 'Tab / Shift + Tab'],
2055
+ ['离开正文编辑器', '先按 Escape,再按 Tab'],
2056
+ ['关闭弹窗或正文查找栏', 'Escape']
2057
+ ].map(([label, keys]) =>
2058
+ el(
2059
+ 'div',
2060
+ { className: 'result-card' },
2061
+ el('strong', { text: label }),
2062
+ el('div', {}, el('kbd', { text: keys }))
2063
+ )
2064
+ )
2065
+ ]);
2066
+ document.addEventListener(
2067
+ 'keydown',
2068
+ event => {
2069
+ if (!(event.ctrlKey || event.metaKey) || $('#modal').open) return;
2070
+ const key = event.key.toLowerCase();
2071
+ if (!['enter', 's', 'k'].includes(key)) return;
2072
+ event.preventDefault();
2073
+ event.stopPropagation();
2074
+ if (key === 'enter') guard(send);
2075
+ else if (key === 's') guard(saveRequest);
2076
+ else $('#search').focus();
2077
+ },
2078
+ { capture: true }
2079
+ );
2080
+ window.addEventListener('beforeunload', event => {
2081
+ if (state.tabs.some(tab => tab.dirty)) {
2082
+ event.preventDefault();
2083
+ event.returnValue = '';
2084
+ }
2085
+ });
2086
+ setupSplitter($('#left-split'), '--left', 180, 420);
2087
+ setupSplitter($('#right-split'), '--request', 320, 1000, true);
2088
+ if (!state.tabs.length) openRequest(fresh());
2089
+ else {
2090
+ if (!active()) state.active = state.tabs[0].id;
2091
+ renderRequest();
2092
+ }
2093
+ renderEnvironments();
2094
+ renderTree();
2095
+ applyTheme();
2096
+ document.body.dataset.ready = 'true';
2097
+ }
2098
+ guard(boot);
2099
+ })();