apiskill 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/MCP.md +12 -0
- package/README.ja.md +108 -0
- package/README.ko.md +108 -0
- package/README.md +119 -0
- package/README.zh.md +119 -0
- package/dist/assets/index-DH0wsJCI.js +299 -0
- package/dist/assets/index-vocUDpcf.css +1 -0
- package/dist/index.html +13 -0
- package/docs/cli.ja.md +90 -0
- package/docs/cli.ko.md +90 -0
- package/docs/cli.md +117 -0
- package/docs/cli.zh.md +117 -0
- package/docs/mcp.ja.md +79 -0
- package/docs/mcp.ko.md +79 -0
- package/docs/mcp.md +79 -0
- package/docs/mcp.zh.md +79 -0
- package/docs/web.ja.md +44 -0
- package/docs/web.ko.md +44 -0
- package/docs/web.md +57 -0
- package/docs/web.zh.md +57 -0
- package/index.html +12 -0
- package/mcp-config.example.json +13 -0
- package/package.json +44 -0
- package/scripts/apiskill-cli.mjs +372 -0
- package/scripts/lib/apiskill-core.mjs +520 -0
- package/scripts/lib/mock-server.mjs +262 -0
- package/scripts/lib/openapi-importer.mjs +169 -0
- package/scripts/lib/openapi-store.mjs +542 -0
- package/scripts/mcp-server.mjs +408 -0
- package/skills/apiskill/SKILL.md +71 -0
- package/skills/apiskill/agents/openai.yaml +4 -0
- package/src/AddApiDialog.tsx +590 -0
- package/src/App.tsx +2570 -0
- package/src/DocumentVersionManager.tsx +264 -0
- package/src/main.tsx +10 -0
- package/src/manualApiConfig.ts +401 -0
- package/src/styles.css +2101 -0
- package/src/swagger.ts +664 -0
- package/src/types.ts +115 -0
- package/tsconfig.json +21 -0
- package/vite.config.ts +1380 -0
package/src/App.tsx
ADDED
|
@@ -0,0 +1,2570 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
AlertCircle,
|
|
4
|
+
Braces,
|
|
5
|
+
Check,
|
|
6
|
+
ClipboardCopy,
|
|
7
|
+
Database,
|
|
8
|
+
FileCode2,
|
|
9
|
+
Filter,
|
|
10
|
+
Globe2,
|
|
11
|
+
KeyRound,
|
|
12
|
+
Link2,
|
|
13
|
+
LockKeyhole,
|
|
14
|
+
Pencil,
|
|
15
|
+
Plus,
|
|
16
|
+
RefreshCcw,
|
|
17
|
+
Search,
|
|
18
|
+
Send,
|
|
19
|
+
Server,
|
|
20
|
+
Trash2,
|
|
21
|
+
X,
|
|
22
|
+
} from 'lucide-react';
|
|
23
|
+
import {
|
|
24
|
+
AUTH_CHECK_API_URL,
|
|
25
|
+
buildApiCliText,
|
|
26
|
+
buildRawSchemaJson,
|
|
27
|
+
CACHE_API_URL,
|
|
28
|
+
CUSTOM_OPERATION_API_URL,
|
|
29
|
+
DELETE_OPERATION_API_URL,
|
|
30
|
+
DOCUMENT_CREATE_API_URL,
|
|
31
|
+
endpointToManualApiOperationConfig,
|
|
32
|
+
IMPORT_API_URL,
|
|
33
|
+
listEndpoints,
|
|
34
|
+
listTags,
|
|
35
|
+
MOCK_START_API_URL,
|
|
36
|
+
MOCK_STATUS_API_URL,
|
|
37
|
+
OPERATION_LINKS_API_URL,
|
|
38
|
+
parameterRows,
|
|
39
|
+
requestBodyRows,
|
|
40
|
+
resolveSchema,
|
|
41
|
+
responseRows,
|
|
42
|
+
STORAGE_SOURCE_KEY,
|
|
43
|
+
STORAGE_SYNC_KEY,
|
|
44
|
+
VERSION_DELETE_API_URL,
|
|
45
|
+
VERSION_META_API_URL,
|
|
46
|
+
VERSION_SELECT_API_URL,
|
|
47
|
+
VERSIONS_API_URL,
|
|
48
|
+
} from './swagger';
|
|
49
|
+
import { AddApiDialog } from './AddApiDialog';
|
|
50
|
+
import { DocumentVersionManager } from './DocumentVersionManager';
|
|
51
|
+
import type { DocumentVersionMeta } from './DocumentVersionManager';
|
|
52
|
+
import type { ManualApiOperationConfig } from './manualApiConfig';
|
|
53
|
+
import type { Endpoint, EndpointAssociationLinks, FieldRow, HttpMethod, SwaggerDocument, SwaggerParameter, SwaggerSchema } from './types';
|
|
54
|
+
|
|
55
|
+
const methodOptions: Array<'all' | HttpMethod> = ['all', 'get', 'post', 'put', 'delete', 'patch'];
|
|
56
|
+
const OPEN_ENDPOINT_TABS_STORAGE_KEY = 'apiskill-open-endpoint-tabs';
|
|
57
|
+
|
|
58
|
+
type LoadState = 'loading' | 'ready' | 'error';
|
|
59
|
+
type DetailTab = 'ai' | 'json' | 'test' | 'links';
|
|
60
|
+
type ImportMode = 'file' | 'crawl' | 'curl' | 'upload';
|
|
61
|
+
type ImportDialogMode = ImportMode | 'blank';
|
|
62
|
+
type CacheMode = ImportMode | 'manual' | 'document';
|
|
63
|
+
type AuthType = 'none' | 'bearer' | 'jwt' | 'apiKey' | 'basic';
|
|
64
|
+
type ApiKeyLocation = 'header' | 'query';
|
|
65
|
+
type DocumentAuthConfig = {
|
|
66
|
+
type: 'none' | 'basic';
|
|
67
|
+
username?: string;
|
|
68
|
+
password?: string;
|
|
69
|
+
};
|
|
70
|
+
type AuthPromptState = {
|
|
71
|
+
open: boolean;
|
|
72
|
+
mode: ImportMode;
|
|
73
|
+
inputUrl: string;
|
|
74
|
+
message: string;
|
|
75
|
+
updateVersionId?: string;
|
|
76
|
+
};
|
|
77
|
+
type ImportDialogState = {
|
|
78
|
+
open: boolean;
|
|
79
|
+
mode: ImportDialogMode;
|
|
80
|
+
status: 'running' | 'success' | 'error';
|
|
81
|
+
progress: number;
|
|
82
|
+
inputUrl: string;
|
|
83
|
+
message: string;
|
|
84
|
+
error?: string;
|
|
85
|
+
meta?: ImportMeta;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
type ImportMeta = {
|
|
89
|
+
versionId?: string;
|
|
90
|
+
mode?: CacheMode;
|
|
91
|
+
inputUrl?: string;
|
|
92
|
+
resolvedUrl?: string;
|
|
93
|
+
title?: string;
|
|
94
|
+
version?: string;
|
|
95
|
+
savedFile?: string;
|
|
96
|
+
savedPath?: string;
|
|
97
|
+
savedAt?: string;
|
|
98
|
+
paths?: number;
|
|
99
|
+
schemas?: number;
|
|
100
|
+
environmentName?: string;
|
|
101
|
+
environmentBaseUrl?: string;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
type ImportResponse = {
|
|
105
|
+
document: SwaggerDocument;
|
|
106
|
+
meta?: ImportMeta;
|
|
107
|
+
versions?: ImportMeta[];
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
type VersionSelectResponse = ImportResponse & {
|
|
111
|
+
versions?: ImportMeta[];
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
type VersionListResponse = {
|
|
115
|
+
versions: ImportMeta[];
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
type VersionMetaResponse = {
|
|
119
|
+
meta: ImportMeta;
|
|
120
|
+
versions: ImportMeta[];
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
type VersionDeleteResponse = {
|
|
124
|
+
versions: ImportMeta[];
|
|
125
|
+
nextVersionId?: string;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
type AuthCheckResponse = {
|
|
129
|
+
requiresAuth: boolean;
|
|
130
|
+
status?: number;
|
|
131
|
+
message?: string;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
type RequestTestResponse = {
|
|
135
|
+
status: number;
|
|
136
|
+
statusText: string;
|
|
137
|
+
headers: Record<string, string | string[]>;
|
|
138
|
+
body: string;
|
|
139
|
+
elapsedMs: number;
|
|
140
|
+
url: string;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
type MockStatusResponse = {
|
|
144
|
+
running: boolean;
|
|
145
|
+
mock?: {
|
|
146
|
+
url: string;
|
|
147
|
+
host: string;
|
|
148
|
+
port: number;
|
|
149
|
+
versionId: string;
|
|
150
|
+
title: string;
|
|
151
|
+
routesCount: number;
|
|
152
|
+
};
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
type OpenEndpointTab = {
|
|
156
|
+
id: string;
|
|
157
|
+
method: HttpMethod;
|
|
158
|
+
path: string;
|
|
159
|
+
summary: string;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
type OpenEndpointTabsCache = {
|
|
163
|
+
versionId: string;
|
|
164
|
+
tabIds: string[];
|
|
165
|
+
selectedId: string;
|
|
166
|
+
allowEmptyDetail: boolean;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
function endpointToOpenTab(endpoint: Endpoint): OpenEndpointTab {
|
|
170
|
+
return {
|
|
171
|
+
id: endpoint.id,
|
|
172
|
+
method: endpoint.method,
|
|
173
|
+
path: endpoint.path,
|
|
174
|
+
summary: endpoint.summary || endpoint.operation.operationId || endpoint.path,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function getOperationLinkDraftKey(versionId: string, endpoint: Endpoint) {
|
|
179
|
+
return `${versionId || 'local'}::${endpoint.id}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function App() {
|
|
183
|
+
const [doc, setDoc] = useState<SwaggerDocument | null>(null);
|
|
184
|
+
const [loadState, setLoadState] = useState<LoadState>('loading');
|
|
185
|
+
const [error, setError] = useState('');
|
|
186
|
+
const [query, setQuery] = useState('');
|
|
187
|
+
const [tag, setTag] = useState('all');
|
|
188
|
+
const [method, setMethod] = useState<'all' | HttpMethod>('all');
|
|
189
|
+
const [bodyOnly, setBodyOnly] = useState(false);
|
|
190
|
+
const [selectedId, setSelectedId] = useState('');
|
|
191
|
+
const [allowEmptyDetail, setAllowEmptyDetail] = useState(false);
|
|
192
|
+
const [openedEndpointTabs, setOpenedEndpointTabs] = useState<OpenEndpointTab[]>([]);
|
|
193
|
+
const [tabsCacheReady, setTabsCacheReady] = useState(false);
|
|
194
|
+
const [syncingMode, setSyncingMode] = useState<ImportDialogMode | ''>('');
|
|
195
|
+
const [activeSourceTab, setActiveSourceTab] = useState<ImportMode>('file');
|
|
196
|
+
const [versions, setVersions] = useState<ImportMeta[]>([]);
|
|
197
|
+
const [selectedVersionId, setSelectedVersionId] = useState('');
|
|
198
|
+
const [selectedVersionMeta, setSelectedVersionMeta] = useState<ImportMeta | undefined>(undefined);
|
|
199
|
+
const [lastSync, setLastSync] = useState('');
|
|
200
|
+
const [fileSourceUrl, setFileSourceUrl] = useState('');
|
|
201
|
+
const [crawlSourceUrl, setCrawlSourceUrl] = useState('');
|
|
202
|
+
const [curlSourceText, setCurlSourceText] = useState('');
|
|
203
|
+
const [uploadFile, setUploadFile] = useState<File | null>(null);
|
|
204
|
+
const [blankDocTitle, setBlankDocTitle] = useState('API Skill Document');
|
|
205
|
+
const [blankDocVersion, setBlankDocVersion] = useState('1.0.0');
|
|
206
|
+
const [blankDocDescription, setBlankDocDescription] = useState('Created from API Skill blank document.');
|
|
207
|
+
const [blankDocDialogOpen, setBlankDocDialogOpen] = useState(false);
|
|
208
|
+
const [blankUpdateVersionId, setBlankUpdateVersionId] = useState('');
|
|
209
|
+
const [resolvedSourceUrl, setResolvedSourceUrl] = useState('');
|
|
210
|
+
const [docAuthUsername, setDocAuthUsername] = useState('');
|
|
211
|
+
const [docAuthPassword, setDocAuthPassword] = useState('');
|
|
212
|
+
const [copied, setCopied] = useState<'ai' | 'json' | ''>('');
|
|
213
|
+
const [importDialog, setImportDialog] = useState<ImportDialogState | null>(null);
|
|
214
|
+
const [authPrompt, setAuthPrompt] = useState<AuthPromptState | null>(null);
|
|
215
|
+
const [addApiOpen, setAddApiOpen] = useState(false);
|
|
216
|
+
const [mockStatus, setMockStatus] = useState<MockStatusResponse | null>(null);
|
|
217
|
+
const [mockMessage, setMockMessage] = useState('');
|
|
218
|
+
const [startingMock, setStartingMock] = useState(false);
|
|
219
|
+
const [editingApiConfig, setEditingApiConfig] = useState<ManualApiOperationConfig | undefined>(undefined);
|
|
220
|
+
const [editingApiTarget, setEditingApiTarget] = useState<{ method: HttpMethod; path: string } | undefined>(undefined);
|
|
221
|
+
const [operationLinkDrafts, setOperationLinkDrafts] = useState<Record<string, EndpointAssociationLinks>>({});
|
|
222
|
+
const [savingApi, setSavingApi] = useState(false);
|
|
223
|
+
const uploadInputRef = useRef<HTMLInputElement | null>(null);
|
|
224
|
+
|
|
225
|
+
useEffect(() => {
|
|
226
|
+
async function loadInitialCache() {
|
|
227
|
+
try {
|
|
228
|
+
await loadCache();
|
|
229
|
+
} catch (err) {
|
|
230
|
+
setError(err instanceof Error ? err.message : '读取最新缓存失败');
|
|
231
|
+
setLoadState('ready');
|
|
232
|
+
} finally {
|
|
233
|
+
void loadVersionList();
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
loadInitialCache();
|
|
238
|
+
void loadMockStatus();
|
|
239
|
+
}, []);
|
|
240
|
+
|
|
241
|
+
const endpoints = useMemo(() => (doc ? listEndpoints(doc) : []), [doc]);
|
|
242
|
+
const endpointById = useMemo(() => new Map(endpoints.map((endpoint) => [endpoint.id, endpoint])), [endpoints]);
|
|
243
|
+
const tags = useMemo(() => listTags(endpoints), [endpoints]);
|
|
244
|
+
|
|
245
|
+
const filteredEndpoints = useMemo(() => {
|
|
246
|
+
const normalized = query.trim().toLowerCase();
|
|
247
|
+
return endpoints.filter((endpoint) => {
|
|
248
|
+
const matchQuery = !normalized || endpoint.searchable.includes(normalized);
|
|
249
|
+
const matchTag = tag === 'all' || endpoint.tags.includes(tag);
|
|
250
|
+
const matchMethod = method === 'all' || endpoint.method === method;
|
|
251
|
+
const matchBody = !bodyOnly || endpoint.hasBody;
|
|
252
|
+
return matchQuery && matchTag && matchMethod && matchBody;
|
|
253
|
+
});
|
|
254
|
+
}, [bodyOnly, endpoints, method, query, tag]);
|
|
255
|
+
|
|
256
|
+
const selectedEndpoint = useMemo(() => {
|
|
257
|
+
if (!selectedId && allowEmptyDetail) return undefined;
|
|
258
|
+
return endpointById.get(selectedId) ?? filteredEndpoints[0];
|
|
259
|
+
}, [allowEmptyDetail, endpointById, filteredEndpoints, selectedId]);
|
|
260
|
+
const selectedVersion = useMemo(
|
|
261
|
+
() => versions.find((version) => version.versionId === selectedVersionId) ?? selectedVersionMeta,
|
|
262
|
+
[selectedVersionId, selectedVersionMeta, versions],
|
|
263
|
+
);
|
|
264
|
+
const selectedOperationLinkDraftKey = selectedEndpoint ? getOperationLinkDraftKey(selectedVersionId, selectedEndpoint) : '';
|
|
265
|
+
const selectedOperationLinkDraft = selectedEndpoint
|
|
266
|
+
? operationLinkDrafts[selectedOperationLinkDraftKey] ?? normalizeEndpointLinks(selectedEndpoint.operation['x-apiskill-links'])
|
|
267
|
+
: undefined;
|
|
268
|
+
|
|
269
|
+
useEffect(() => {
|
|
270
|
+
if (selectedEndpoint && selectedEndpoint.id !== selectedId) {
|
|
271
|
+
setSelectedId(selectedEndpoint.id);
|
|
272
|
+
}
|
|
273
|
+
}, [selectedEndpoint, selectedId]);
|
|
274
|
+
|
|
275
|
+
useEffect(() => {
|
|
276
|
+
if (!selectedEndpoint) return;
|
|
277
|
+
setOpenedEndpointTabs((current) => {
|
|
278
|
+
if (current.some((tab) => tab.id === selectedEndpoint.id)) return current;
|
|
279
|
+
return [...current, endpointToOpenTab(selectedEndpoint)];
|
|
280
|
+
});
|
|
281
|
+
}, [selectedEndpoint]);
|
|
282
|
+
|
|
283
|
+
useEffect(() => {
|
|
284
|
+
setOpenedEndpointTabs((current) => current.filter((tab) => endpointById.has(tab.id)));
|
|
285
|
+
}, [endpointById]);
|
|
286
|
+
|
|
287
|
+
useEffect(() => {
|
|
288
|
+
if (!selectedVersionId || !endpointById.size) return;
|
|
289
|
+
|
|
290
|
+
try {
|
|
291
|
+
const raw = localStorage.getItem(OPEN_ENDPOINT_TABS_STORAGE_KEY);
|
|
292
|
+
if (!raw) {
|
|
293
|
+
setTabsCacheReady(true);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const cache = JSON.parse(raw) as OpenEndpointTabsCache;
|
|
298
|
+
if (cache.versionId !== selectedVersionId) {
|
|
299
|
+
setTabsCacheReady(true);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const restoredTabs = cache.tabIds
|
|
304
|
+
.map((id) => endpointById.get(id))
|
|
305
|
+
.filter((endpoint): endpoint is Endpoint => Boolean(endpoint))
|
|
306
|
+
.map(endpointToOpenTab);
|
|
307
|
+
setOpenedEndpointTabs(restoredTabs);
|
|
308
|
+
|
|
309
|
+
const nextSelectedId = cache.selectedId && endpointById.has(cache.selectedId) ? cache.selectedId : restoredTabs[0]?.id ?? '';
|
|
310
|
+
setSelectedId(nextSelectedId);
|
|
311
|
+
setAllowEmptyDetail(cache.allowEmptyDetail && !nextSelectedId);
|
|
312
|
+
} catch {
|
|
313
|
+
localStorage.removeItem(OPEN_ENDPOINT_TABS_STORAGE_KEY);
|
|
314
|
+
} finally {
|
|
315
|
+
setTabsCacheReady(true);
|
|
316
|
+
}
|
|
317
|
+
}, [endpointById, selectedVersionId]);
|
|
318
|
+
|
|
319
|
+
useEffect(() => {
|
|
320
|
+
if (!selectedVersionId || !tabsCacheReady) return;
|
|
321
|
+
|
|
322
|
+
const cache: OpenEndpointTabsCache = {
|
|
323
|
+
versionId: selectedVersionId,
|
|
324
|
+
tabIds: openedEndpointTabs.map((tab) => tab.id),
|
|
325
|
+
selectedId,
|
|
326
|
+
allowEmptyDetail,
|
|
327
|
+
};
|
|
328
|
+
localStorage.setItem(OPEN_ENDPOINT_TABS_STORAGE_KEY, JSON.stringify(cache));
|
|
329
|
+
}, [allowEmptyDetail, openedEndpointTabs, selectedId, selectedVersionId, tabsCacheReady]);
|
|
330
|
+
|
|
331
|
+
useEffect(() => {
|
|
332
|
+
if (importDialog?.status !== 'running') return;
|
|
333
|
+
|
|
334
|
+
const timer = window.setInterval(() => {
|
|
335
|
+
setImportDialog((current) => {
|
|
336
|
+
if (!current || current.status !== 'running') return current;
|
|
337
|
+
return {
|
|
338
|
+
...current,
|
|
339
|
+
progress: Math.min(88, current.progress + (current.mode === 'crawl' || current.mode === 'curl' ? 4 : 7)),
|
|
340
|
+
};
|
|
341
|
+
});
|
|
342
|
+
}, 600);
|
|
343
|
+
|
|
344
|
+
return () => window.clearInterval(timer);
|
|
345
|
+
}, [importDialog?.status, importDialog?.mode]);
|
|
346
|
+
|
|
347
|
+
async function loadCache(versionId?: string) {
|
|
348
|
+
setTabsCacheReady(false);
|
|
349
|
+
const response = versionId
|
|
350
|
+
? await fetch(VERSION_SELECT_API_URL, {
|
|
351
|
+
method: 'POST',
|
|
352
|
+
headers: {
|
|
353
|
+
'content-type': 'application/json',
|
|
354
|
+
},
|
|
355
|
+
body: JSON.stringify({ versionId }),
|
|
356
|
+
})
|
|
357
|
+
: await fetch(CACHE_API_URL, { cache: 'no-store' });
|
|
358
|
+
if (response.status === 404) {
|
|
359
|
+
setDoc(null);
|
|
360
|
+
setSelectedVersionId('');
|
|
361
|
+
setSelectedVersionMeta(undefined);
|
|
362
|
+
setOpenedEndpointTabs([]);
|
|
363
|
+
setLoadState('ready');
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
if (!response.ok) {
|
|
367
|
+
throw new Error(`读取缓存失败: ${response.status}`);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const payload = (await response.json()) as VersionSelectResponse;
|
|
371
|
+
setDoc(payload.document);
|
|
372
|
+
setAllowEmptyDetail(false);
|
|
373
|
+
setOpenedEndpointTabs([]);
|
|
374
|
+
setLastSync(payload.meta?.savedAt || '');
|
|
375
|
+
setSelectedVersionId(payload.meta?.versionId || '');
|
|
376
|
+
setSelectedVersionMeta(payload.meta);
|
|
377
|
+
applySourceDefaults(payload.meta, payload.document);
|
|
378
|
+
setResolvedSourceUrl(payload.meta?.resolvedUrl || payload.meta?.inputUrl || '');
|
|
379
|
+
if (payload.versions) {
|
|
380
|
+
setVersions(payload.versions);
|
|
381
|
+
}
|
|
382
|
+
setLoadState('ready');
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async function loadVersionList() {
|
|
386
|
+
const response = await fetch(VERSIONS_API_URL, { cache: 'no-store' });
|
|
387
|
+
if (!response.ok) return;
|
|
388
|
+
const payload = (await response.json()) as VersionListResponse;
|
|
389
|
+
setVersions(payload.versions ?? []);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function isImportMode(value?: string): value is ImportMode {
|
|
393
|
+
return value === 'file' || value === 'crawl' || value === 'curl' || value === 'upload';
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function sourceTabFromCacheMode(value?: string): ImportMode | undefined {
|
|
397
|
+
if (isImportMode(value)) return value;
|
|
398
|
+
return undefined;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function applySourceDefaults(meta?: DocumentVersionMeta, document?: SwaggerDocument | null) {
|
|
402
|
+
const sourceTab = sourceTabFromCacheMode(meta?.mode);
|
|
403
|
+
if (sourceTab) {
|
|
404
|
+
setActiveSourceTab(sourceTab);
|
|
405
|
+
}
|
|
406
|
+
if (isImportMode(meta?.mode)) {
|
|
407
|
+
setSourceInput(meta.mode, meta.inputUrl || '');
|
|
408
|
+
}
|
|
409
|
+
if ((meta?.mode === 'document' || meta?.mode === 'manual') && document?.info) {
|
|
410
|
+
setBlankDocTitle(document.info.title || 'API Skill Document');
|
|
411
|
+
setBlankDocVersion(document.info.version || '1.0.0');
|
|
412
|
+
setBlankDocDescription(document.info.description || '');
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function getImportUpdateVersionId(mode: ImportMode) {
|
|
417
|
+
return selectedVersionId && selectedVersion?.mode === mode ? selectedVersionId : '';
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function setSourceInput(mode: ImportMode, value: string) {
|
|
421
|
+
if (mode === 'crawl') setCrawlSourceUrl(value);
|
|
422
|
+
else if (mode === 'curl') setCurlSourceText(value);
|
|
423
|
+
else if (mode === 'file') setFileSourceUrl(value);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function getActiveSourceValue() {
|
|
427
|
+
if (activeSourceTab === 'crawl') return crawlSourceUrl.trim();
|
|
428
|
+
if (activeSourceTab === 'curl') return curlSourceText.trim();
|
|
429
|
+
if (activeSourceTab === 'upload') return uploadFile?.name || '';
|
|
430
|
+
return fileSourceUrl.trim();
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function emptySourceMessage(mode: ImportMode) {
|
|
434
|
+
if (mode === 'curl') return '请输入 CURL 命令';
|
|
435
|
+
if (mode === 'upload') return '请选择 JSON 或 YAML 文件';
|
|
436
|
+
return mode === 'crawl' ? '请输入在线文档页地址' : '请输入 Swagger/OpenAPI 文件地址';
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function clearActiveSource() {
|
|
440
|
+
if (activeSourceTab === 'crawl') setCrawlSourceUrl('');
|
|
441
|
+
else if (activeSourceTab === 'curl') setCurlSourceText('');
|
|
442
|
+
else if (activeSourceTab === 'upload') {
|
|
443
|
+
setUploadFile(null);
|
|
444
|
+
if (uploadInputRef.current) uploadInputRef.current.value = '';
|
|
445
|
+
} else {
|
|
446
|
+
setFileSourceUrl('');
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
async function getImportInput(mode: ImportMode) {
|
|
451
|
+
if (mode === 'upload') {
|
|
452
|
+
if (!uploadFile) throw new Error(emptySourceMessage(mode));
|
|
453
|
+
if (!/\.(json|ya?ml)$/i.test(uploadFile.name)) {
|
|
454
|
+
throw new Error('仅支持 JSON、YAML 或 YML 文件');
|
|
455
|
+
}
|
|
456
|
+
return {
|
|
457
|
+
input: uploadFile.name,
|
|
458
|
+
content: await uploadFile.text(),
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const input = mode === 'crawl' ? crawlSourceUrl.trim() : mode === 'curl' ? curlSourceText.trim() : fileSourceUrl.trim();
|
|
463
|
+
if (!input) throw new Error(emptySourceMessage(mode));
|
|
464
|
+
return { input };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async function importSpec(mode: ImportMode, auth: DocumentAuthConfig = { type: 'none' }, skipAuthCheck = false, updateVersionId?: string) {
|
|
468
|
+
let importInput: { input: string; content?: string };
|
|
469
|
+
try {
|
|
470
|
+
importInput = await getImportInput(mode);
|
|
471
|
+
} catch (err) {
|
|
472
|
+
const message = err instanceof Error ? err.message : emptySourceMessage(mode);
|
|
473
|
+
setError(message);
|
|
474
|
+
setImportDialog({
|
|
475
|
+
open: true,
|
|
476
|
+
mode,
|
|
477
|
+
status: 'error',
|
|
478
|
+
progress: 100,
|
|
479
|
+
inputUrl: '',
|
|
480
|
+
message: '未开始',
|
|
481
|
+
error: message,
|
|
482
|
+
});
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
const nextSourceUrl = importInput.input;
|
|
486
|
+
const matchingVersionId = getImportUpdateVersionId(mode);
|
|
487
|
+
const targetVersionId = updateVersionId !== undefined ? updateVersionId || undefined : matchingVersionId || undefined;
|
|
488
|
+
const updating = Boolean(targetVersionId);
|
|
489
|
+
|
|
490
|
+
setSyncingMode(mode);
|
|
491
|
+
setError('');
|
|
492
|
+
setImportDialog({
|
|
493
|
+
open: true,
|
|
494
|
+
mode,
|
|
495
|
+
status: 'running',
|
|
496
|
+
progress: mode === 'crawl' && !skipAuthCheck ? 6 : mode === 'curl' ? 14 : mode === 'crawl' ? 12 : 18,
|
|
497
|
+
inputUrl: nextSourceUrl,
|
|
498
|
+
message:
|
|
499
|
+
mode === 'crawl' && !skipAuthCheck
|
|
500
|
+
? '正在检测文档访问权限'
|
|
501
|
+
: mode === 'crawl'
|
|
502
|
+
? updating
|
|
503
|
+
? '正在爬取并更新当前版本文档'
|
|
504
|
+
: '正在由 Node 爬取在线文档并识别 OpenAPI JSON 地址'
|
|
505
|
+
: mode === 'curl'
|
|
506
|
+
? updating
|
|
507
|
+
? '正在执行 CURL 并更新当前版本文档'
|
|
508
|
+
: '正在解析并执行 CURL 请求'
|
|
509
|
+
: mode === 'upload'
|
|
510
|
+
? updating
|
|
511
|
+
? '正在解析上传文件并更新当前版本文档'
|
|
512
|
+
: '正在读取并解析上传的 JSON / YAML 文件'
|
|
513
|
+
: updating
|
|
514
|
+
? '正在拉取并更新当前版本文档'
|
|
515
|
+
: '正在由 Node 拉取并解析 OpenAPI JSON 文件',
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
try {
|
|
519
|
+
if (mode !== 'curl' && mode !== 'upload' && !skipAuthCheck && auth.type === 'none') {
|
|
520
|
+
const authCheck = await checkDocumentAuth(nextSourceUrl, mode);
|
|
521
|
+
if (authCheck.requiresAuth) {
|
|
522
|
+
setAuthPrompt({
|
|
523
|
+
open: true,
|
|
524
|
+
mode,
|
|
525
|
+
inputUrl: nextSourceUrl,
|
|
526
|
+
message: authCheck.message || '该文档地址需要用户名和密码',
|
|
527
|
+
updateVersionId: targetVersionId,
|
|
528
|
+
});
|
|
529
|
+
setImportDialog(null);
|
|
530
|
+
setSyncingMode('');
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
setImportDialog((current) =>
|
|
535
|
+
current?.status === 'running'
|
|
536
|
+
? {
|
|
537
|
+
...current,
|
|
538
|
+
progress: mode === 'crawl' ? 12 : 18,
|
|
539
|
+
message:
|
|
540
|
+
mode === 'crawl'
|
|
541
|
+
? updating
|
|
542
|
+
? '正在爬取并更新当前版本文档'
|
|
543
|
+
: '正在由 Node 爬取在线文档并识别 OpenAPI JSON 地址'
|
|
544
|
+
: updating
|
|
545
|
+
? '正在拉取并更新当前版本文档'
|
|
546
|
+
: '正在由 Node 拉取并解析 OpenAPI JSON 文件',
|
|
547
|
+
}
|
|
548
|
+
: current,
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const payload = await importOpenApi(nextSourceUrl, mode, auth, importInput.content, targetVersionId);
|
|
553
|
+
const nextDoc = payload.document;
|
|
554
|
+
const syncedAt = new Date().toISOString();
|
|
555
|
+
localStorage.setItem(STORAGE_SYNC_KEY, syncedAt);
|
|
556
|
+
localStorage.setItem(STORAGE_SOURCE_KEY, nextSourceUrl);
|
|
557
|
+
if (auth.type === 'basic') setDocAuthPassword('');
|
|
558
|
+
setTabsCacheReady(false);
|
|
559
|
+
setDoc(nextDoc);
|
|
560
|
+
setAllowEmptyDetail(false);
|
|
561
|
+
setOpenedEndpointTabs([]);
|
|
562
|
+
setLastSync(syncedAt);
|
|
563
|
+
setSelectedVersionId(payload.meta?.versionId || '');
|
|
564
|
+
setSelectedVersionMeta(payload.meta);
|
|
565
|
+
setResolvedSourceUrl(payload.meta?.resolvedUrl || nextSourceUrl);
|
|
566
|
+
applySourceDefaults(payload.meta, nextDoc);
|
|
567
|
+
setLoadState('ready');
|
|
568
|
+
if (payload.versions) setVersions(payload.versions);
|
|
569
|
+
else await loadVersionList();
|
|
570
|
+
setImportDialog({
|
|
571
|
+
open: true,
|
|
572
|
+
mode,
|
|
573
|
+
status: 'success',
|
|
574
|
+
progress: 100,
|
|
575
|
+
inputUrl: nextSourceUrl,
|
|
576
|
+
message: updating ? '文档已更新并覆盖原版本' : '文档已解析并保存为独立版本',
|
|
577
|
+
meta: payload.meta,
|
|
578
|
+
});
|
|
579
|
+
} catch (err) {
|
|
580
|
+
const message = err instanceof Error ? err.message : '导入失败,请检查地址或文档格式';
|
|
581
|
+
setError(message);
|
|
582
|
+
setImportDialog({
|
|
583
|
+
open: true,
|
|
584
|
+
mode,
|
|
585
|
+
status: 'error',
|
|
586
|
+
progress: 100,
|
|
587
|
+
inputUrl: nextSourceUrl,
|
|
588
|
+
message: '处理失败',
|
|
589
|
+
error: message,
|
|
590
|
+
});
|
|
591
|
+
} finally {
|
|
592
|
+
setSyncingMode('');
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
async function createBlankApiDocument() {
|
|
597
|
+
const title = blankDocTitle.trim();
|
|
598
|
+
if (!title) {
|
|
599
|
+
setError('请输入文档名称');
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const targetVersionId = blankUpdateVersionId || undefined;
|
|
604
|
+
const updating = Boolean(targetVersionId);
|
|
605
|
+
setSyncingMode('blank');
|
|
606
|
+
setError('');
|
|
607
|
+
setImportDialog({
|
|
608
|
+
open: true,
|
|
609
|
+
mode: 'blank',
|
|
610
|
+
status: 'running',
|
|
611
|
+
progress: 45,
|
|
612
|
+
inputUrl: title,
|
|
613
|
+
message: updating ? '正在更新当前 OpenAPI 文档' : '正在创建空白 OpenAPI 文档',
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
try {
|
|
617
|
+
const payload = await createOpenApiDocument({
|
|
618
|
+
title,
|
|
619
|
+
version: blankDocVersion.trim() || '1.0.0',
|
|
620
|
+
description: blankDocDescription.trim(),
|
|
621
|
+
versionId: targetVersionId,
|
|
622
|
+
});
|
|
623
|
+
const syncedAt = payload.meta?.savedAt || new Date().toISOString();
|
|
624
|
+
setTabsCacheReady(false);
|
|
625
|
+
setDoc(payload.document);
|
|
626
|
+
setAllowEmptyDetail(true);
|
|
627
|
+
setOpenedEndpointTabs([]);
|
|
628
|
+
setSelectedId('');
|
|
629
|
+
setLastSync(syncedAt);
|
|
630
|
+
setSelectedVersionId(payload.meta?.versionId || '');
|
|
631
|
+
setSelectedVersionMeta(payload.meta);
|
|
632
|
+
setResolvedSourceUrl(payload.meta?.resolvedUrl || payload.meta?.inputUrl || 'manual-document');
|
|
633
|
+
applySourceDefaults(payload.meta, payload.document);
|
|
634
|
+
setLoadState('ready');
|
|
635
|
+
if (payload.versions) setVersions(payload.versions);
|
|
636
|
+
else await loadVersionList();
|
|
637
|
+
setImportDialog({
|
|
638
|
+
open: true,
|
|
639
|
+
mode: 'blank',
|
|
640
|
+
status: 'success',
|
|
641
|
+
progress: 100,
|
|
642
|
+
inputUrl: title,
|
|
643
|
+
message: updating ? '文档已更新并覆盖原版本' : '空白文档已创建,可以继续新增 API 配置',
|
|
644
|
+
meta: payload.meta,
|
|
645
|
+
});
|
|
646
|
+
setBlankDocDialogOpen(false);
|
|
647
|
+
setBlankUpdateVersionId('');
|
|
648
|
+
} catch (err) {
|
|
649
|
+
const message = err instanceof Error ? err.message : '创建文档失败';
|
|
650
|
+
setError(message);
|
|
651
|
+
setImportDialog({
|
|
652
|
+
open: true,
|
|
653
|
+
mode: 'blank',
|
|
654
|
+
status: 'error',
|
|
655
|
+
progress: 100,
|
|
656
|
+
inputUrl: title,
|
|
657
|
+
message: '创建失败',
|
|
658
|
+
error: message,
|
|
659
|
+
});
|
|
660
|
+
} finally {
|
|
661
|
+
setSyncingMode('');
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
async function copyText(kind: 'ai' | 'json', text: string) {
|
|
666
|
+
await navigator.clipboard.writeText(text);
|
|
667
|
+
setCopied(kind);
|
|
668
|
+
window.setTimeout(() => setCopied(''), 1600);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function submitDocumentAuth() {
|
|
672
|
+
const targetVersionId = authPrompt?.updateVersionId ?? '';
|
|
673
|
+
setAuthPrompt(null);
|
|
674
|
+
void importSpec(authPrompt?.mode ?? 'crawl', {
|
|
675
|
+
type: 'basic',
|
|
676
|
+
username: docAuthUsername,
|
|
677
|
+
password: docAuthPassword,
|
|
678
|
+
}, true, targetVersionId);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function closeEndpointTab(tabId: string) {
|
|
682
|
+
setOpenedEndpointTabs((current) => {
|
|
683
|
+
const index = current.findIndex((tab) => tab.id === tabId);
|
|
684
|
+
const next = current.filter((tab) => tab.id !== tabId);
|
|
685
|
+
if (tabId === selectedId) {
|
|
686
|
+
const fallback = next[index] ?? next[index - 1];
|
|
687
|
+
setAllowEmptyDetail(!fallback);
|
|
688
|
+
setSelectedId(fallback?.id ?? '');
|
|
689
|
+
}
|
|
690
|
+
return next;
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function selectEndpoint(endpointId: string) {
|
|
695
|
+
setAllowEmptyDetail(false);
|
|
696
|
+
setSelectedId(endpointId);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function updateOperationLinkDraft(endpoint: Endpoint, links: EndpointAssociationLinks) {
|
|
700
|
+
const draftKey = getOperationLinkDraftKey(selectedVersionId, endpoint);
|
|
701
|
+
setOperationLinkDrafts((current) => ({ ...current, [draftKey]: normalizeEndpointLinks(links) }));
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
async function updateVersionEnvironment(versionId: string, environmentName: string, environmentBaseUrl: string) {
|
|
705
|
+
const response = await fetch(VERSION_META_API_URL, {
|
|
706
|
+
method: 'POST',
|
|
707
|
+
headers: {
|
|
708
|
+
'content-type': 'application/json',
|
|
709
|
+
},
|
|
710
|
+
body: JSON.stringify({ versionId, environmentName, environmentBaseUrl }),
|
|
711
|
+
});
|
|
712
|
+
const payload = await response.json();
|
|
713
|
+
if (!response.ok) throw new Error(payload.error || '环境保存失败');
|
|
714
|
+
const data = payload as VersionMetaResponse;
|
|
715
|
+
setVersions(data.versions ?? []);
|
|
716
|
+
if (versionId === selectedVersionId) {
|
|
717
|
+
setSelectedVersionMeta(data.meta);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
async function deleteVersion(versionId: string) {
|
|
722
|
+
const response = await fetch(VERSION_DELETE_API_URL, {
|
|
723
|
+
method: 'POST',
|
|
724
|
+
headers: {
|
|
725
|
+
'content-type': 'application/json',
|
|
726
|
+
},
|
|
727
|
+
body: JSON.stringify({ versionId }),
|
|
728
|
+
});
|
|
729
|
+
const payload = await response.json();
|
|
730
|
+
if (!response.ok) throw new Error(payload.error || '版本删除失败');
|
|
731
|
+
const data = payload as VersionDeleteResponse;
|
|
732
|
+
setVersions(data.versions ?? []);
|
|
733
|
+
|
|
734
|
+
if (versionId !== selectedVersionId) return;
|
|
735
|
+
if (data.nextVersionId) {
|
|
736
|
+
await loadCache(data.nextVersionId);
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
setDoc(null);
|
|
741
|
+
setSelectedVersionId('');
|
|
742
|
+
setSelectedVersionMeta(undefined);
|
|
743
|
+
setOpenedEndpointTabs([]);
|
|
744
|
+
setLastSync('');
|
|
745
|
+
setResolvedSourceUrl('');
|
|
746
|
+
setLoadState('ready');
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
async function loadMockStatus() {
|
|
750
|
+
try {
|
|
751
|
+
const response = await fetch(MOCK_STATUS_API_URL, { cache: 'no-store' });
|
|
752
|
+
if (!response.ok) return;
|
|
753
|
+
setMockStatus((await response.json()) as MockStatusResponse);
|
|
754
|
+
} catch {
|
|
755
|
+
// Mock status is best-effort; the web app still works when unavailable.
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
async function startMockService() {
|
|
760
|
+
if (!doc || !stats.endpoints) {
|
|
761
|
+
setMockMessage('请先导入、爬取或新建 API 文档,并至少包含一个接口后再启动 MOCK 服务');
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
setStartingMock(true);
|
|
766
|
+
setMockMessage('');
|
|
767
|
+
setError('');
|
|
768
|
+
try {
|
|
769
|
+
const response = await fetch(MOCK_START_API_URL, {
|
|
770
|
+
method: 'POST',
|
|
771
|
+
headers: {
|
|
772
|
+
'content-type': 'application/json',
|
|
773
|
+
},
|
|
774
|
+
body: JSON.stringify({ versionId: selectedVersionId || undefined }),
|
|
775
|
+
});
|
|
776
|
+
const payload = await response.json();
|
|
777
|
+
if (!response.ok) throw new Error(payload.error || 'MOCK 服务启动失败');
|
|
778
|
+
const status = payload as MockStatusResponse;
|
|
779
|
+
setMockStatus(status);
|
|
780
|
+
setMockMessage(status.mock?.url ? `MOCK 服务已启动:${status.mock.url}` : 'MOCK 服务已启动');
|
|
781
|
+
} catch (err) {
|
|
782
|
+
setMockMessage(err instanceof Error ? err.message : 'MOCK 服务启动失败');
|
|
783
|
+
} finally {
|
|
784
|
+
setStartingMock(false);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
async function prepareVersionUpdate(version: DocumentVersionMeta) {
|
|
789
|
+
const versionId = version.versionId || '';
|
|
790
|
+
if (!versionId) throw new Error('请选择要更新的文档版本');
|
|
791
|
+
const versionMode = version.mode || '';
|
|
792
|
+
if (versionId !== selectedVersionId) {
|
|
793
|
+
await loadCache(versionId);
|
|
794
|
+
if (versionMode === 'document' || versionMode === 'manual') {
|
|
795
|
+
setBlankUpdateVersionId(versionId);
|
|
796
|
+
setBlankDocDialogOpen(true);
|
|
797
|
+
}
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
applySourceDefaults(version, doc);
|
|
801
|
+
if (versionMode === 'document' || versionMode === 'manual') {
|
|
802
|
+
setBlankUpdateVersionId(versionId);
|
|
803
|
+
setBlankDocDialogOpen(true);
|
|
804
|
+
}
|
|
805
|
+
setError('');
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
async function saveManualApi(config: ManualApiOperationConfig) {
|
|
809
|
+
setSavingApi(true);
|
|
810
|
+
setError('');
|
|
811
|
+
try {
|
|
812
|
+
const payload = await saveCustomOperation(selectedVersionId || undefined, config, editingApiTarget);
|
|
813
|
+
const syncedAt = payload.meta?.savedAt || new Date().toISOString();
|
|
814
|
+
setDoc(payload.document);
|
|
815
|
+
setLastSync(syncedAt);
|
|
816
|
+
setSelectedVersionId(payload.meta?.versionId || '');
|
|
817
|
+
setSelectedVersionMeta(payload.meta);
|
|
818
|
+
setResolvedSourceUrl(payload.meta?.resolvedUrl || payload.meta?.inputUrl || 'manual-api-config');
|
|
819
|
+
setLoadState('ready');
|
|
820
|
+
setAddApiOpen(false);
|
|
821
|
+
setEditingApiConfig(undefined);
|
|
822
|
+
setEditingApiTarget(undefined);
|
|
823
|
+
await loadVersionList();
|
|
824
|
+
} catch (err) {
|
|
825
|
+
setError(err instanceof Error ? err.message : '新增接口保存失败');
|
|
826
|
+
} finally {
|
|
827
|
+
setSavingApi(false);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
async function deleteManualApi(endpoint: Endpoint) {
|
|
832
|
+
if (!selectedVersionId) {
|
|
833
|
+
setError('请先选择一个本地缓存版本');
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
if (!window.confirm(`确认删除 ${endpoint.method.toUpperCase()} ${endpoint.path}?`)) return;
|
|
837
|
+
|
|
838
|
+
setSavingApi(true);
|
|
839
|
+
setError('');
|
|
840
|
+
try {
|
|
841
|
+
const payload = await deleteCustomOperation(selectedVersionId, endpoint.method, endpoint.path);
|
|
842
|
+
setDoc(payload.document);
|
|
843
|
+
setLastSync(payload.meta?.savedAt || new Date().toISOString());
|
|
844
|
+
setSelectedVersionId(payload.meta?.versionId || selectedVersionId);
|
|
845
|
+
setSelectedVersionMeta(payload.meta);
|
|
846
|
+
setResolvedSourceUrl(payload.meta?.resolvedUrl || payload.meta?.inputUrl || 'manual-api-config');
|
|
847
|
+
setLoadState('ready');
|
|
848
|
+
setSelectedId('');
|
|
849
|
+
setOpenedEndpointTabs((current) => current.filter((tab) => tab.id !== endpoint.id));
|
|
850
|
+
await loadVersionList();
|
|
851
|
+
} catch (err) {
|
|
852
|
+
setError(err instanceof Error ? err.message : '删除接口失败');
|
|
853
|
+
} finally {
|
|
854
|
+
setSavingApi(false);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
async function saveOperationLinks(endpoint: Endpoint, links: EndpointAssociationLinks) {
|
|
859
|
+
if (!selectedVersionId) {
|
|
860
|
+
throw new Error('请先选择一个本地缓存版本');
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
const response = await fetch(OPERATION_LINKS_API_URL, {
|
|
864
|
+
method: 'POST',
|
|
865
|
+
headers: {
|
|
866
|
+
'content-type': 'application/json',
|
|
867
|
+
},
|
|
868
|
+
body: JSON.stringify({
|
|
869
|
+
versionId: selectedVersionId,
|
|
870
|
+
method: endpoint.method,
|
|
871
|
+
path: endpoint.path,
|
|
872
|
+
links,
|
|
873
|
+
}),
|
|
874
|
+
});
|
|
875
|
+
const payload = await response.json();
|
|
876
|
+
if (!response.ok) throw new Error(payload.error || '关联配置保存失败');
|
|
877
|
+
const data = payload as VersionSelectResponse;
|
|
878
|
+
setDoc(data.document);
|
|
879
|
+
setLastSync(data.meta?.savedAt || new Date().toISOString());
|
|
880
|
+
setSelectedVersionId(data.meta?.versionId || selectedVersionId);
|
|
881
|
+
setSelectedVersionMeta(data.meta);
|
|
882
|
+
setOperationLinkDrafts((current) => ({
|
|
883
|
+
...current,
|
|
884
|
+
[getOperationLinkDraftKey(data.meta?.versionId || selectedVersionId, endpoint)]: normalizeEndpointLinks(links),
|
|
885
|
+
}));
|
|
886
|
+
if (data.versions) setVersions(data.versions);
|
|
887
|
+
else await loadVersionList();
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
const stats = useMemo(() => {
|
|
891
|
+
const paths = doc?.paths ? Object.keys(doc.paths).length : 0;
|
|
892
|
+
const schemaSource = doc?.components?.schemas ?? doc?.definitions ?? {};
|
|
893
|
+
const schemas = Object.keys(schemaSource).length;
|
|
894
|
+
return { paths, schemas, endpoints: endpoints.length, tags: tags.length };
|
|
895
|
+
}, [doc, endpoints.length, tags.length]);
|
|
896
|
+
const activeSourceCanUpdate = Boolean(getImportUpdateVersionId(activeSourceTab));
|
|
897
|
+
|
|
898
|
+
if (loadState === 'loading') {
|
|
899
|
+
return (
|
|
900
|
+
<main className="state-screen">
|
|
901
|
+
<Database className="state-icon" />
|
|
902
|
+
<h1>正在读取最新缓存</h1>
|
|
903
|
+
<p>如果没有缓存版本,将进入空文档状态。</p>
|
|
904
|
+
</main>
|
|
905
|
+
);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
if (loadState === 'error') {
|
|
909
|
+
return (
|
|
910
|
+
<main className="state-screen">
|
|
911
|
+
<AlertCircle className="state-icon error" />
|
|
912
|
+
<h1>接口文档加载失败</h1>
|
|
913
|
+
<p>{error}</p>
|
|
914
|
+
</main>
|
|
915
|
+
);
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
return (
|
|
919
|
+
<div className="app-shell">
|
|
920
|
+
<header className="topbar">
|
|
921
|
+
<div>
|
|
922
|
+
<p className="eyebrow">OpenAPI 3.0 Console</p>
|
|
923
|
+
<h1>API Skill Console</h1>
|
|
924
|
+
</div>
|
|
925
|
+
<div className="topbar-actions">
|
|
926
|
+
<DocumentVersionManager
|
|
927
|
+
versions={versions}
|
|
928
|
+
selectedVersionId={selectedVersionId}
|
|
929
|
+
currentVersion={selectedVersion}
|
|
930
|
+
disabled={Boolean(syncingMode)}
|
|
931
|
+
onSelect={(versionId) => {
|
|
932
|
+
if (!versionId || versionId === selectedVersionId) return;
|
|
933
|
+
void loadCache(versionId);
|
|
934
|
+
}}
|
|
935
|
+
onPrepareUpdate={prepareVersionUpdate}
|
|
936
|
+
onUpdateEnvironment={updateVersionEnvironment}
|
|
937
|
+
onDelete={deleteVersion}
|
|
938
|
+
/>
|
|
939
|
+
<button
|
|
940
|
+
className="primary-button"
|
|
941
|
+
onClick={() => {
|
|
942
|
+
setEditingApiConfig(undefined);
|
|
943
|
+
setEditingApiTarget(undefined);
|
|
944
|
+
setAddApiOpen(true);
|
|
945
|
+
}}
|
|
946
|
+
>
|
|
947
|
+
<Plus size={16} />
|
|
948
|
+
新增API配置
|
|
949
|
+
</button>
|
|
950
|
+
<div className="sync-meta">
|
|
951
|
+
<span>接口 {stats.endpoints}</span>
|
|
952
|
+
<span>Schema {stats.schemas}</span>
|
|
953
|
+
<span>{lastSync ? `同步 ${formatDate(lastSync)}` : '未加载文档'}</span>
|
|
954
|
+
</div>
|
|
955
|
+
</div>
|
|
956
|
+
</header>
|
|
957
|
+
|
|
958
|
+
<section className="source-panel">
|
|
959
|
+
<div className="source-panel-header">
|
|
960
|
+
<div className="source-tabs" role="tablist" aria-label="文档同步方式">
|
|
961
|
+
<button
|
|
962
|
+
className={activeSourceTab === 'file' ? 'active' : ''}
|
|
963
|
+
onClick={() => setActiveSourceTab('file')}
|
|
964
|
+
role="tab"
|
|
965
|
+
aria-selected={activeSourceTab === 'file'}
|
|
966
|
+
>
|
|
967
|
+
<RefreshCcw size={15} />
|
|
968
|
+
在线文档
|
|
969
|
+
</button>
|
|
970
|
+
<button
|
|
971
|
+
className={activeSourceTab === 'crawl' ? 'active' : ''}
|
|
972
|
+
onClick={() => setActiveSourceTab('crawl')}
|
|
973
|
+
role="tab"
|
|
974
|
+
aria-selected={activeSourceTab === 'crawl'}
|
|
975
|
+
>
|
|
976
|
+
<Globe2 size={15} />
|
|
977
|
+
爬取网页
|
|
978
|
+
</button>
|
|
979
|
+
<button
|
|
980
|
+
className={activeSourceTab === 'curl' ? 'active' : ''}
|
|
981
|
+
onClick={() => setActiveSourceTab('curl')}
|
|
982
|
+
role="tab"
|
|
983
|
+
aria-selected={activeSourceTab === 'curl'}
|
|
984
|
+
>
|
|
985
|
+
<Send size={15} />
|
|
986
|
+
执行CURL
|
|
987
|
+
</button>
|
|
988
|
+
<button
|
|
989
|
+
className={activeSourceTab === 'upload' ? 'active' : ''}
|
|
990
|
+
onClick={() => setActiveSourceTab('upload')}
|
|
991
|
+
role="tab"
|
|
992
|
+
aria-selected={activeSourceTab === 'upload'}
|
|
993
|
+
>
|
|
994
|
+
<FileCode2 size={15} />
|
|
995
|
+
导入文件
|
|
996
|
+
</button>
|
|
997
|
+
</div>
|
|
998
|
+
<div className="source-panel-actions">
|
|
999
|
+
<button className="secondary-button" onClick={startMockService} disabled={startingMock}>
|
|
1000
|
+
<Server size={16} className={startingMock ? 'spin' : ''} />
|
|
1001
|
+
{startingMock ? '启动中' : '启动MOCK服务'}
|
|
1002
|
+
</button>
|
|
1003
|
+
<button
|
|
1004
|
+
className="secondary-button"
|
|
1005
|
+
onClick={() => {
|
|
1006
|
+
setBlankUpdateVersionId('');
|
|
1007
|
+
setBlankDocDialogOpen(true);
|
|
1008
|
+
}}
|
|
1009
|
+
disabled={Boolean(syncingMode)}
|
|
1010
|
+
>
|
|
1011
|
+
<Braces size={16} />
|
|
1012
|
+
新建文档
|
|
1013
|
+
</button>
|
|
1014
|
+
</div>
|
|
1015
|
+
</div>
|
|
1016
|
+
<div className="source-form">
|
|
1017
|
+
{activeSourceTab === 'upload' ? (
|
|
1018
|
+
<label key="source-upload-field" className="source-file-picker">
|
|
1019
|
+
<FileCode2 size={16} />
|
|
1020
|
+
<input
|
|
1021
|
+
key="source-upload-input"
|
|
1022
|
+
ref={uploadInputRef}
|
|
1023
|
+
type="file"
|
|
1024
|
+
accept=".json,.yaml,.yml,application/json,application/yaml,text/yaml,text/x-yaml"
|
|
1025
|
+
onChange={(event) => setUploadFile(event.target.files?.[0] ?? null)}
|
|
1026
|
+
/>
|
|
1027
|
+
<span>{uploadFile ? uploadFile.name : '选择 JSON / YAML 文件'}</span>
|
|
1028
|
+
</label>
|
|
1029
|
+
) : (
|
|
1030
|
+
<label key="source-text-field" className={`source-input ${activeSourceTab === 'curl' ? 'source-input-multiline' : ''}`}>
|
|
1031
|
+
<Link2 size={16} />
|
|
1032
|
+
{activeSourceTab === 'curl' ? (
|
|
1033
|
+
<textarea
|
|
1034
|
+
key="source-curl-input"
|
|
1035
|
+
value={curlSourceText}
|
|
1036
|
+
onChange={(event) => setCurlSourceText(event.target.value)}
|
|
1037
|
+
placeholder="粘贴 curl 命令,例如 curl 'https://example.com/openapi.yaml' -H 'accept: application/yaml'"
|
|
1038
|
+
spellCheck={false}
|
|
1039
|
+
/>
|
|
1040
|
+
) : (
|
|
1041
|
+
<input
|
|
1042
|
+
key="source-url-input"
|
|
1043
|
+
value={activeSourceTab === 'file' ? fileSourceUrl : crawlSourceUrl}
|
|
1044
|
+
onChange={(event) => {
|
|
1045
|
+
if (activeSourceTab === 'file') setFileSourceUrl(event.target.value);
|
|
1046
|
+
else setCrawlSourceUrl(event.target.value);
|
|
1047
|
+
}}
|
|
1048
|
+
placeholder={
|
|
1049
|
+
activeSourceTab === 'file'
|
|
1050
|
+
? '输入 Swagger/OpenAPI JSON 或 YAML 文件地址'
|
|
1051
|
+
: '输入 Swagger UI / Knife4j / Redoc 在线文档页地址'
|
|
1052
|
+
}
|
|
1053
|
+
/>
|
|
1054
|
+
)}
|
|
1055
|
+
</label>
|
|
1056
|
+
)}
|
|
1057
|
+
<button
|
|
1058
|
+
className="primary-button"
|
|
1059
|
+
onClick={() => {
|
|
1060
|
+
void importSpec(activeSourceTab);
|
|
1061
|
+
}}
|
|
1062
|
+
disabled={Boolean(syncingMode) || !getActiveSourceValue()}
|
|
1063
|
+
>
|
|
1064
|
+
{activeSourceTab === 'crawl' ? (
|
|
1065
|
+
<Globe2 size={16} className={syncingMode === 'crawl' ? 'spin' : ''} />
|
|
1066
|
+
) : activeSourceTab === 'curl' ? (
|
|
1067
|
+
<Send size={16} className={syncingMode === 'curl' ? 'spin' : ''} />
|
|
1068
|
+
) : activeSourceTab === 'upload' ? (
|
|
1069
|
+
<FileCode2 size={16} className={syncingMode === 'upload' ? 'spin' : ''} />
|
|
1070
|
+
) : (
|
|
1071
|
+
<RefreshCcw size={16} className={syncingMode === 'file' ? 'spin' : ''} />
|
|
1072
|
+
)}
|
|
1073
|
+
{syncingMode === activeSourceTab ? '处理中' : activeSourceCanUpdate ? '更新' : '确认'}
|
|
1074
|
+
</button>
|
|
1075
|
+
<button className="secondary-button" onClick={clearActiveSource} disabled={Boolean(syncingMode) || !getActiveSourceValue()}>
|
|
1076
|
+
清空
|
|
1077
|
+
</button>
|
|
1078
|
+
</div>
|
|
1079
|
+
<div className="source-meta">
|
|
1080
|
+
当前文档源:<span>{resolvedSourceUrl || '未加载'}</span>
|
|
1081
|
+
</div>
|
|
1082
|
+
{mockStatus?.running && mockStatus.mock ? (
|
|
1083
|
+
<div className="source-meta">
|
|
1084
|
+
MOCK服务:<span>{mockStatus.mock.url}</span>
|
|
1085
|
+
<small>{mockStatus.mock.routesCount} routes</small>
|
|
1086
|
+
</div>
|
|
1087
|
+
) : null}
|
|
1088
|
+
{mockMessage ? <div className={`version-message ${mockMessage.includes('失败') || mockMessage.includes('请先') ? 'error' : ''}`}>{mockMessage}</div> : null}
|
|
1089
|
+
</section>
|
|
1090
|
+
|
|
1091
|
+
{error ? (
|
|
1092
|
+
<div className="inline-error">
|
|
1093
|
+
<AlertCircle size={16} />
|
|
1094
|
+
{error}
|
|
1095
|
+
</div>
|
|
1096
|
+
) : null}
|
|
1097
|
+
|
|
1098
|
+
{importDialog?.open ? <ImportStatusDialog state={importDialog} onClose={() => setImportDialog(null)} /> : null}
|
|
1099
|
+
{authPrompt?.open ? (
|
|
1100
|
+
<DocumentAuthDialog
|
|
1101
|
+
state={authPrompt}
|
|
1102
|
+
username={docAuthUsername}
|
|
1103
|
+
password={docAuthPassword}
|
|
1104
|
+
onUsernameChange={setDocAuthUsername}
|
|
1105
|
+
onPasswordChange={setDocAuthPassword}
|
|
1106
|
+
onClose={() => setAuthPrompt(null)}
|
|
1107
|
+
onSubmit={submitDocumentAuth}
|
|
1108
|
+
/>
|
|
1109
|
+
) : null}
|
|
1110
|
+
{blankDocDialogOpen ? (
|
|
1111
|
+
<BlankDocumentDialog
|
|
1112
|
+
updating={Boolean(blankUpdateVersionId)}
|
|
1113
|
+
title={blankDocTitle}
|
|
1114
|
+
version={blankDocVersion}
|
|
1115
|
+
description={blankDocDescription}
|
|
1116
|
+
saving={syncingMode === 'blank'}
|
|
1117
|
+
onTitleChange={setBlankDocTitle}
|
|
1118
|
+
onVersionChange={setBlankDocVersion}
|
|
1119
|
+
onDescriptionChange={setBlankDocDescription}
|
|
1120
|
+
onClose={() => {
|
|
1121
|
+
if (syncingMode === 'blank') return;
|
|
1122
|
+
setBlankDocDialogOpen(false);
|
|
1123
|
+
setBlankUpdateVersionId('');
|
|
1124
|
+
}}
|
|
1125
|
+
onSubmit={createBlankApiDocument}
|
|
1126
|
+
/>
|
|
1127
|
+
) : null}
|
|
1128
|
+
{addApiOpen ? (
|
|
1129
|
+
<AddApiDialog
|
|
1130
|
+
selectedVersionId={selectedVersionId}
|
|
1131
|
+
saving={savingApi}
|
|
1132
|
+
initialConfig={editingApiConfig}
|
|
1133
|
+
onClose={() => {
|
|
1134
|
+
setAddApiOpen(false);
|
|
1135
|
+
setEditingApiConfig(undefined);
|
|
1136
|
+
setEditingApiTarget(undefined);
|
|
1137
|
+
}}
|
|
1138
|
+
onSave={saveManualApi}
|
|
1139
|
+
/>
|
|
1140
|
+
) : null}
|
|
1141
|
+
|
|
1142
|
+
<section className="workspace">
|
|
1143
|
+
<aside className="sidebar">
|
|
1144
|
+
<div className="search-box">
|
|
1145
|
+
<Search size={17} />
|
|
1146
|
+
<input
|
|
1147
|
+
value={query}
|
|
1148
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
1149
|
+
placeholder="搜索路径、摘要、参数、tag"
|
|
1150
|
+
/>
|
|
1151
|
+
{query ? (
|
|
1152
|
+
<button type="button" className="search-clear-button" onClick={() => setQuery('')} aria-label="清空搜索">
|
|
1153
|
+
<X size={15} />
|
|
1154
|
+
</button>
|
|
1155
|
+
) : null}
|
|
1156
|
+
</div>
|
|
1157
|
+
|
|
1158
|
+
<div className="filters">
|
|
1159
|
+
<label>
|
|
1160
|
+
<Filter size={15} />
|
|
1161
|
+
<select value={tag} onChange={(event) => setTag(event.target.value)}>
|
|
1162
|
+
<option value="all">全部分组 ({stats.tags})</option>
|
|
1163
|
+
{tags.map((item) => (
|
|
1164
|
+
<option key={item.name} value={item.name}>
|
|
1165
|
+
{item.name} ({item.count})
|
|
1166
|
+
</option>
|
|
1167
|
+
))}
|
|
1168
|
+
</select>
|
|
1169
|
+
</label>
|
|
1170
|
+
<label className="checkbox-line">
|
|
1171
|
+
<input type="checkbox" checked={bodyOnly} onChange={(event) => setBodyOnly(event.target.checked)} />
|
|
1172
|
+
仅看有请求体
|
|
1173
|
+
</label>
|
|
1174
|
+
</div>
|
|
1175
|
+
|
|
1176
|
+
<div className="method-tabs" aria-label="method filters">
|
|
1177
|
+
{methodOptions.map((item) => (
|
|
1178
|
+
<button
|
|
1179
|
+
key={item}
|
|
1180
|
+
className={method === item ? 'active' : ''}
|
|
1181
|
+
onClick={() => setMethod(item)}
|
|
1182
|
+
title={item === 'all' ? '全部方法' : item.toUpperCase()}
|
|
1183
|
+
>
|
|
1184
|
+
{item === 'all' ? 'ALL' : item.toUpperCase()}
|
|
1185
|
+
</button>
|
|
1186
|
+
))}
|
|
1187
|
+
</div>
|
|
1188
|
+
|
|
1189
|
+
<div className="endpoint-count">
|
|
1190
|
+
显示 {filteredEndpoints.length} / {endpoints.length}
|
|
1191
|
+
</div>
|
|
1192
|
+
|
|
1193
|
+
<div className="endpoint-list">
|
|
1194
|
+
{filteredEndpoints.map((endpoint) => (
|
|
1195
|
+
<button
|
|
1196
|
+
key={endpoint.id}
|
|
1197
|
+
className={`endpoint-item ${endpoint.id === selectedEndpoint?.id ? 'selected' : ''}`}
|
|
1198
|
+
onClick={() => selectEndpoint(endpoint.id)}
|
|
1199
|
+
>
|
|
1200
|
+
<span className={`method method-${endpoint.method}`}>{endpoint.method.toUpperCase()}</span>
|
|
1201
|
+
<span className="endpoint-main">
|
|
1202
|
+
<strong>{endpoint.summary}</strong>
|
|
1203
|
+
<small>{endpoint.path}</small>
|
|
1204
|
+
</span>
|
|
1205
|
+
</button>
|
|
1206
|
+
))}
|
|
1207
|
+
</div>
|
|
1208
|
+
</aside>
|
|
1209
|
+
|
|
1210
|
+
<main className="detail-pane">
|
|
1211
|
+
{openedEndpointTabs.length ? (
|
|
1212
|
+
<div className="opened-api-tabs" aria-label="已打开 API 页签">
|
|
1213
|
+
{openedEndpointTabs.map((tab) => (
|
|
1214
|
+
<div
|
|
1215
|
+
key={tab.id}
|
|
1216
|
+
className={`opened-api-tab opened-api-tab-${tab.method} ${tab.id === selectedEndpoint?.id ? 'active' : ''}`}
|
|
1217
|
+
role="button"
|
|
1218
|
+
tabIndex={0}
|
|
1219
|
+
onClick={() => selectEndpoint(tab.id)}
|
|
1220
|
+
onKeyDown={(event) => {
|
|
1221
|
+
if (event.key !== 'Enter' && event.key !== ' ') return;
|
|
1222
|
+
event.preventDefault();
|
|
1223
|
+
selectEndpoint(tab.id);
|
|
1224
|
+
}}
|
|
1225
|
+
title={`${tab.method.toUpperCase()} ${tab.path}`}
|
|
1226
|
+
>
|
|
1227
|
+
<span className={`opened-api-tab-method method-${tab.method}`}>{tab.method.toUpperCase()}</span>
|
|
1228
|
+
<span className="opened-api-tab-text">
|
|
1229
|
+
<strong>{tab.summary}</strong>
|
|
1230
|
+
</span>
|
|
1231
|
+
<button
|
|
1232
|
+
type="button"
|
|
1233
|
+
className="opened-api-tab-close"
|
|
1234
|
+
aria-label="关闭页签"
|
|
1235
|
+
onClick={(event) => {
|
|
1236
|
+
event.stopPropagation();
|
|
1237
|
+
closeEndpointTab(tab.id);
|
|
1238
|
+
}}
|
|
1239
|
+
>
|
|
1240
|
+
<X size={14} />
|
|
1241
|
+
</button>
|
|
1242
|
+
</div>
|
|
1243
|
+
))}
|
|
1244
|
+
</div>
|
|
1245
|
+
) : null}
|
|
1246
|
+
{doc && selectedEndpoint ? (
|
|
1247
|
+
<EndpointDetail
|
|
1248
|
+
doc={doc}
|
|
1249
|
+
endpoint={selectedEndpoint}
|
|
1250
|
+
copied={copied}
|
|
1251
|
+
onCopy={(kind, text) => copyText(kind, text)}
|
|
1252
|
+
onEdit={() => {
|
|
1253
|
+
setEditingApiConfig(endpointToManualApiOperationConfig(doc, selectedEndpoint));
|
|
1254
|
+
setEditingApiTarget({ method: selectedEndpoint.method, path: selectedEndpoint.path });
|
|
1255
|
+
setAddApiOpen(true);
|
|
1256
|
+
}}
|
|
1257
|
+
onDelete={() => void deleteManualApi(selectedEndpoint)}
|
|
1258
|
+
onSaveLinks={saveOperationLinks}
|
|
1259
|
+
linksDraft={selectedOperationLinkDraft ?? normalizeEndpointLinks(selectedEndpoint.operation['x-apiskill-links'])}
|
|
1260
|
+
onLinksDraftChange={(links) => updateOperationLinkDraft(selectedEndpoint, links)}
|
|
1261
|
+
environmentBaseUrl={selectedVersion?.environmentBaseUrl || ''}
|
|
1262
|
+
/>
|
|
1263
|
+
) : !doc ? (
|
|
1264
|
+
<div className="empty-state">
|
|
1265
|
+
<FileCode2 size={32} />
|
|
1266
|
+
<h2>暂无 API 文档数据</h2>
|
|
1267
|
+
<p>导入现有 Swagger/OpenAPI 文档,或新建空白文档后从零维护接口。</p>
|
|
1268
|
+
</div>
|
|
1269
|
+
) : filteredEndpoints.length ? (
|
|
1270
|
+
<div className="empty-state">
|
|
1271
|
+
<FileCode2 size={32} />
|
|
1272
|
+
<h2>未打开接口</h2>
|
|
1273
|
+
<p>从左侧列表选择接口后会在上方生成可关闭页签。</p>
|
|
1274
|
+
</div>
|
|
1275
|
+
) : (
|
|
1276
|
+
<div className="empty-state">
|
|
1277
|
+
<FileCode2 size={32} />
|
|
1278
|
+
<h2>没有匹配的接口</h2>
|
|
1279
|
+
<p>调整搜索词、分组或方法过滤条件。</p>
|
|
1280
|
+
</div>
|
|
1281
|
+
)}
|
|
1282
|
+
</main>
|
|
1283
|
+
</section>
|
|
1284
|
+
</div>
|
|
1285
|
+
);
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
function DocumentAuthDialog({
|
|
1289
|
+
state,
|
|
1290
|
+
username,
|
|
1291
|
+
password,
|
|
1292
|
+
onUsernameChange,
|
|
1293
|
+
onPasswordChange,
|
|
1294
|
+
onClose,
|
|
1295
|
+
onSubmit,
|
|
1296
|
+
}: {
|
|
1297
|
+
state: AuthPromptState;
|
|
1298
|
+
username: string;
|
|
1299
|
+
password: string;
|
|
1300
|
+
onUsernameChange: (value: string) => void;
|
|
1301
|
+
onPasswordChange: (value: string) => void;
|
|
1302
|
+
onClose: () => void;
|
|
1303
|
+
onSubmit: () => void;
|
|
1304
|
+
}) {
|
|
1305
|
+
return (
|
|
1306
|
+
<div className="modal-backdrop" role="dialog" aria-modal="true" aria-labelledby="document-auth-title">
|
|
1307
|
+
<div className="auth-dialog">
|
|
1308
|
+
<div className="import-dialog-header">
|
|
1309
|
+
<div>
|
|
1310
|
+
<p className="eyebrow">Document Auth</p>
|
|
1311
|
+
<h2 id="document-auth-title">输入文档访问凭据</h2>
|
|
1312
|
+
</div>
|
|
1313
|
+
<span className="import-badge running">需要登录</span>
|
|
1314
|
+
</div>
|
|
1315
|
+
|
|
1316
|
+
<div className="auth-dialog-body">
|
|
1317
|
+
<div className="auth-message">
|
|
1318
|
+
<LockKeyhole size={18} />
|
|
1319
|
+
<span>{state.message}</span>
|
|
1320
|
+
</div>
|
|
1321
|
+
<div className="auth-target">
|
|
1322
|
+
<span>文档地址</span>
|
|
1323
|
+
<strong>{state.inputUrl}</strong>
|
|
1324
|
+
</div>
|
|
1325
|
+
<div className="auth-fields">
|
|
1326
|
+
<label>
|
|
1327
|
+
用户名
|
|
1328
|
+
<input value={username} onChange={(event) => onUsernameChange(event.target.value)} autoComplete="username" autoFocus />
|
|
1329
|
+
</label>
|
|
1330
|
+
<label>
|
|
1331
|
+
密码
|
|
1332
|
+
<input
|
|
1333
|
+
value={password}
|
|
1334
|
+
onChange={(event) => onPasswordChange(event.target.value)}
|
|
1335
|
+
type="password"
|
|
1336
|
+
autoComplete="current-password"
|
|
1337
|
+
onKeyDown={(event) => {
|
|
1338
|
+
if (event.key === 'Enter') onSubmit();
|
|
1339
|
+
}}
|
|
1340
|
+
/>
|
|
1341
|
+
</label>
|
|
1342
|
+
</div>
|
|
1343
|
+
</div>
|
|
1344
|
+
|
|
1345
|
+
<div className="import-dialog-actions">
|
|
1346
|
+
<button className="secondary-button" onClick={onClose}>
|
|
1347
|
+
取消
|
|
1348
|
+
</button>
|
|
1349
|
+
<button className="primary-button" onClick={onSubmit}>
|
|
1350
|
+
<KeyRound size={16} />
|
|
1351
|
+
使用凭据继续
|
|
1352
|
+
</button>
|
|
1353
|
+
</div>
|
|
1354
|
+
</div>
|
|
1355
|
+
</div>
|
|
1356
|
+
);
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
function BlankDocumentDialog({
|
|
1360
|
+
updating,
|
|
1361
|
+
title,
|
|
1362
|
+
version,
|
|
1363
|
+
description,
|
|
1364
|
+
saving,
|
|
1365
|
+
onTitleChange,
|
|
1366
|
+
onVersionChange,
|
|
1367
|
+
onDescriptionChange,
|
|
1368
|
+
onClose,
|
|
1369
|
+
onSubmit,
|
|
1370
|
+
}: {
|
|
1371
|
+
updating: boolean;
|
|
1372
|
+
title: string;
|
|
1373
|
+
version: string;
|
|
1374
|
+
description: string;
|
|
1375
|
+
saving: boolean;
|
|
1376
|
+
onTitleChange: (value: string) => void;
|
|
1377
|
+
onVersionChange: (value: string) => void;
|
|
1378
|
+
onDescriptionChange: (value: string) => void;
|
|
1379
|
+
onClose: () => void;
|
|
1380
|
+
onSubmit: () => void;
|
|
1381
|
+
}) {
|
|
1382
|
+
return (
|
|
1383
|
+
<div className="modal-backdrop" role="dialog" aria-modal="true" aria-labelledby="blank-document-title">
|
|
1384
|
+
<div className="auth-dialog">
|
|
1385
|
+
<div className="import-dialog-header">
|
|
1386
|
+
<div>
|
|
1387
|
+
<p className="eyebrow">OpenAPI Document</p>
|
|
1388
|
+
<h2 id="blank-document-title">{updating ? '更新文档' : '新建文档'}</h2>
|
|
1389
|
+
</div>
|
|
1390
|
+
<button className="icon-secondary-button" onClick={onClose} aria-label="关闭" disabled={saving}>
|
|
1391
|
+
<X size={17} />
|
|
1392
|
+
</button>
|
|
1393
|
+
</div>
|
|
1394
|
+
|
|
1395
|
+
<div className="blank-document-dialog-body">
|
|
1396
|
+
<label className="source-input">
|
|
1397
|
+
<FileCode2 size={16} />
|
|
1398
|
+
<input
|
|
1399
|
+
value={title}
|
|
1400
|
+
onChange={(event) => onTitleChange(event.target.value)}
|
|
1401
|
+
placeholder="文档名称,例如 Customer Service API"
|
|
1402
|
+
autoFocus
|
|
1403
|
+
/>
|
|
1404
|
+
</label>
|
|
1405
|
+
<label className="source-input">
|
|
1406
|
+
<Braces size={16} />
|
|
1407
|
+
<input value={version} onChange={(event) => onVersionChange(event.target.value)} placeholder="文档版本,例如 1.0.0" />
|
|
1408
|
+
</label>
|
|
1409
|
+
<label className="source-input">
|
|
1410
|
+
<Pencil size={16} />
|
|
1411
|
+
<input value={description} onChange={(event) => onDescriptionChange(event.target.value)} placeholder="文档描述,可选" />
|
|
1412
|
+
</label>
|
|
1413
|
+
</div>
|
|
1414
|
+
|
|
1415
|
+
<div className="import-dialog-actions">
|
|
1416
|
+
<button className="secondary-button" onClick={onClose} disabled={saving}>
|
|
1417
|
+
取消
|
|
1418
|
+
</button>
|
|
1419
|
+
<button className="primary-button" onClick={onSubmit} disabled={saving || !title.trim()}>
|
|
1420
|
+
<Braces size={16} className={saving ? 'spin' : ''} />
|
|
1421
|
+
{saving ? '处理中' : updating ? '更新' : '创建'}
|
|
1422
|
+
</button>
|
|
1423
|
+
</div>
|
|
1424
|
+
</div>
|
|
1425
|
+
</div>
|
|
1426
|
+
);
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
function ImportStatusDialog({ state, onClose }: { state: ImportDialogState; onClose: () => void }) {
|
|
1430
|
+
const title =
|
|
1431
|
+
state.mode === 'blank'
|
|
1432
|
+
? '新建空白文档'
|
|
1433
|
+
: state.mode === 'crawl'
|
|
1434
|
+
? '爬取在线文档'
|
|
1435
|
+
: state.mode === 'curl'
|
|
1436
|
+
? '执行 CURL'
|
|
1437
|
+
: state.mode === 'upload'
|
|
1438
|
+
? '上传 JSON / YAML 文件'
|
|
1439
|
+
: '导入 JSON / YAML 文件';
|
|
1440
|
+
const canClose = state.status !== 'running';
|
|
1441
|
+
|
|
1442
|
+
return (
|
|
1443
|
+
<div className="modal-backdrop" role="dialog" aria-modal="true" aria-labelledby="import-status-title">
|
|
1444
|
+
<div className="import-dialog">
|
|
1445
|
+
<div className="import-dialog-header">
|
|
1446
|
+
<div>
|
|
1447
|
+
<p className="eyebrow">OpenAPI Import</p>
|
|
1448
|
+
<h2 id="import-status-title">{title}</h2>
|
|
1449
|
+
</div>
|
|
1450
|
+
<span className={`import-badge ${state.status}`}>
|
|
1451
|
+
{state.status === 'running' ? '处理中' : state.status === 'success' ? '已完成' : '失败'}
|
|
1452
|
+
</span>
|
|
1453
|
+
</div>
|
|
1454
|
+
|
|
1455
|
+
<div className="progress-track" aria-label="导入进度">
|
|
1456
|
+
<div className={`progress-bar ${state.status}`} style={{ width: `${state.progress}%` }} />
|
|
1457
|
+
</div>
|
|
1458
|
+
|
|
1459
|
+
<div className="import-summary">
|
|
1460
|
+
<div>
|
|
1461
|
+
<span>输入地址</span>
|
|
1462
|
+
<strong>{state.inputUrl || '-'}</strong>
|
|
1463
|
+
</div>
|
|
1464
|
+
<div>
|
|
1465
|
+
<span>当前状态</span>
|
|
1466
|
+
<strong>{state.message}</strong>
|
|
1467
|
+
</div>
|
|
1468
|
+
{state.meta?.resolvedUrl ? (
|
|
1469
|
+
<div>
|
|
1470
|
+
<span>解析地址</span>
|
|
1471
|
+
<strong>{state.meta.resolvedUrl}</strong>
|
|
1472
|
+
</div>
|
|
1473
|
+
) : null}
|
|
1474
|
+
{state.meta?.versionId ? (
|
|
1475
|
+
<div>
|
|
1476
|
+
<span>版本</span>
|
|
1477
|
+
<strong>{state.meta.versionId}</strong>
|
|
1478
|
+
</div>
|
|
1479
|
+
) : null}
|
|
1480
|
+
{state.meta?.savedPath ? (
|
|
1481
|
+
<div>
|
|
1482
|
+
<span>保存位置</span>
|
|
1483
|
+
<strong>{state.meta.savedPath}</strong>
|
|
1484
|
+
</div>
|
|
1485
|
+
) : null}
|
|
1486
|
+
{state.status === 'success' ? (
|
|
1487
|
+
<div className="import-counts">
|
|
1488
|
+
<span>接口路径 {state.meta?.paths ?? 0}</span>
|
|
1489
|
+
<span>Schema {state.meta?.schemas ?? 0}</span>
|
|
1490
|
+
</div>
|
|
1491
|
+
) : null}
|
|
1492
|
+
{state.error ? (
|
|
1493
|
+
<div className="import-error">
|
|
1494
|
+
<AlertCircle size={16} />
|
|
1495
|
+
{state.error}
|
|
1496
|
+
</div>
|
|
1497
|
+
) : null}
|
|
1498
|
+
</div>
|
|
1499
|
+
|
|
1500
|
+
<div className="import-dialog-actions">
|
|
1501
|
+
<button className="secondary-button" onClick={onClose} disabled={!canClose}>
|
|
1502
|
+
{canClose ? '关闭' : '处理中'}
|
|
1503
|
+
</button>
|
|
1504
|
+
</div>
|
|
1505
|
+
</div>
|
|
1506
|
+
</div>
|
|
1507
|
+
);
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
async function importOpenApi(url: string, mode: ImportMode, auth: DocumentAuthConfig, content?: string, versionId?: string) {
|
|
1511
|
+
const response = await fetch(IMPORT_API_URL, {
|
|
1512
|
+
method: 'POST',
|
|
1513
|
+
headers: {
|
|
1514
|
+
'content-type': 'application/json',
|
|
1515
|
+
},
|
|
1516
|
+
body: JSON.stringify({ url, mode, auth, content, versionId }),
|
|
1517
|
+
});
|
|
1518
|
+
const payload = await response.json();
|
|
1519
|
+
if (!response.ok) {
|
|
1520
|
+
throw new Error(payload.error || '导入失败');
|
|
1521
|
+
}
|
|
1522
|
+
return payload as ImportResponse;
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
async function createOpenApiDocument(input: { title: string; version: string; description?: string; versionId?: string }) {
|
|
1526
|
+
const response = await fetch(DOCUMENT_CREATE_API_URL, {
|
|
1527
|
+
method: 'POST',
|
|
1528
|
+
headers: {
|
|
1529
|
+
'content-type': 'application/json',
|
|
1530
|
+
},
|
|
1531
|
+
body: JSON.stringify(input),
|
|
1532
|
+
});
|
|
1533
|
+
const payload = await response.json();
|
|
1534
|
+
if (!response.ok) {
|
|
1535
|
+
throw new Error(payload.error || '创建文档失败');
|
|
1536
|
+
}
|
|
1537
|
+
return payload as ImportResponse;
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
async function checkDocumentAuth(url: string, mode: ImportMode) {
|
|
1541
|
+
const response = await fetch(AUTH_CHECK_API_URL, {
|
|
1542
|
+
method: 'POST',
|
|
1543
|
+
headers: {
|
|
1544
|
+
'content-type': 'application/json',
|
|
1545
|
+
},
|
|
1546
|
+
body: JSON.stringify({ url, mode }),
|
|
1547
|
+
});
|
|
1548
|
+
const payload = await response.json();
|
|
1549
|
+
if (!response.ok) {
|
|
1550
|
+
throw new Error(payload.error || '检测文档访问权限失败');
|
|
1551
|
+
}
|
|
1552
|
+
return payload as AuthCheckResponse;
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
async function saveCustomOperation(versionId: string | undefined, config: ManualApiOperationConfig, replaceTarget?: { method: HttpMethod; path: string }) {
|
|
1556
|
+
const response = await fetch(CUSTOM_OPERATION_API_URL, {
|
|
1557
|
+
method: 'POST',
|
|
1558
|
+
headers: {
|
|
1559
|
+
'content-type': 'application/json',
|
|
1560
|
+
},
|
|
1561
|
+
body: JSON.stringify({ versionId, config, replaceTarget }),
|
|
1562
|
+
});
|
|
1563
|
+
const payload = await response.json();
|
|
1564
|
+
if (!response.ok) {
|
|
1565
|
+
throw new Error(payload.error || '新增接口保存失败');
|
|
1566
|
+
}
|
|
1567
|
+
return payload as ImportResponse;
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
async function deleteCustomOperation(versionId: string, method: HttpMethod, path: string) {
|
|
1571
|
+
const response = await fetch(DELETE_OPERATION_API_URL, {
|
|
1572
|
+
method: 'POST',
|
|
1573
|
+
headers: {
|
|
1574
|
+
'content-type': 'application/json',
|
|
1575
|
+
},
|
|
1576
|
+
body: JSON.stringify({ versionId, method, path }),
|
|
1577
|
+
});
|
|
1578
|
+
const payload = await response.json();
|
|
1579
|
+
if (!response.ok) {
|
|
1580
|
+
throw new Error(payload.error || '删除接口失败');
|
|
1581
|
+
}
|
|
1582
|
+
return payload as ImportResponse;
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
function EndpointDetail({
|
|
1586
|
+
doc,
|
|
1587
|
+
endpoint,
|
|
1588
|
+
copied,
|
|
1589
|
+
onCopy,
|
|
1590
|
+
onEdit,
|
|
1591
|
+
onDelete,
|
|
1592
|
+
onSaveLinks,
|
|
1593
|
+
linksDraft,
|
|
1594
|
+
onLinksDraftChange,
|
|
1595
|
+
environmentBaseUrl,
|
|
1596
|
+
}: {
|
|
1597
|
+
doc: SwaggerDocument;
|
|
1598
|
+
endpoint: Endpoint;
|
|
1599
|
+
copied: 'ai' | 'json' | '';
|
|
1600
|
+
onCopy: (kind: 'ai' | 'json', text: string) => void;
|
|
1601
|
+
onEdit: () => void;
|
|
1602
|
+
onDelete: () => void;
|
|
1603
|
+
onSaveLinks: (endpoint: Endpoint, links: EndpointAssociationLinks) => Promise<void>;
|
|
1604
|
+
linksDraft: EndpointAssociationLinks;
|
|
1605
|
+
onLinksDraftChange: (links: EndpointAssociationLinks) => void;
|
|
1606
|
+
environmentBaseUrl: string;
|
|
1607
|
+
}) {
|
|
1608
|
+
const params = useMemo(() => parameterRows(doc, endpoint.operation.parameters), [doc, endpoint]);
|
|
1609
|
+
const requestBodies = useMemo(() => requestBodyRows(doc, endpoint.operation), [doc, endpoint]);
|
|
1610
|
+
const responses = useMemo(() => responseRows(doc, endpoint.operation), [doc, endpoint]);
|
|
1611
|
+
const aiText = useMemo(() => buildApiCliText(doc, endpoint), [doc, endpoint]);
|
|
1612
|
+
const rawText = useMemo(() => buildRawSchemaJson(endpoint), [endpoint]);
|
|
1613
|
+
const [activeDetailTab, setActiveDetailTab] = useState<DetailTab>('ai');
|
|
1614
|
+
const [pathCopied, setPathCopied] = useState(false);
|
|
1615
|
+
const activeCopyText = activeDetailTab === 'json' ? rawText : aiText;
|
|
1616
|
+
const activeCopyLabel = activeDetailTab === 'json' ? '复制原始 Operation' : '复制 API CLI';
|
|
1617
|
+
|
|
1618
|
+
function copyEndpointPath() {
|
|
1619
|
+
copyTextWithSelection(endpoint.path);
|
|
1620
|
+
void navigator.clipboard.writeText(endpoint.path).catch(() => undefined);
|
|
1621
|
+
setPathCopied(true);
|
|
1622
|
+
window.setTimeout(() => setPathCopied(false), 1600);
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
return (
|
|
1626
|
+
<article className="endpoint-detail">
|
|
1627
|
+
<div className="detail-header">
|
|
1628
|
+
<div>
|
|
1629
|
+
<div className="endpoint-title-line">
|
|
1630
|
+
<span className={`method method-${endpoint.method}`}>{endpoint.method.toUpperCase()}</span>
|
|
1631
|
+
<h2>{endpoint.summary}</h2>
|
|
1632
|
+
<div className="tag-row">
|
|
1633
|
+
{endpoint.tags.map((item) => (
|
|
1634
|
+
<span key={item}>{item}</span>
|
|
1635
|
+
))}
|
|
1636
|
+
</div>
|
|
1637
|
+
</div>
|
|
1638
|
+
<div className="path-row">
|
|
1639
|
+
|
|
1640
|
+
<code className="path-line">{endpoint.path}</code>
|
|
1641
|
+
<button type="button" className="path-copy-button" onClick={copyEndpointPath} title="复制 API 路由地址">
|
|
1642
|
+
{pathCopied ? <Check size={14} /> : <ClipboardCopy size={14} />}
|
|
1643
|
+
<span>{pathCopied ? '已复制' : '复制'}</span>
|
|
1644
|
+
</button>
|
|
1645
|
+
</div>
|
|
1646
|
+
|
|
1647
|
+
</div>
|
|
1648
|
+
<div className="detail-actions">
|
|
1649
|
+
<button className="secondary-button" onClick={onEdit}>
|
|
1650
|
+
<Pencil size={15} />
|
|
1651
|
+
编辑
|
|
1652
|
+
</button>
|
|
1653
|
+
<button className="danger-button" onClick={onDelete}>
|
|
1654
|
+
<Trash2 size={15} />
|
|
1655
|
+
删除
|
|
1656
|
+
</button>
|
|
1657
|
+
</div>
|
|
1658
|
+
</div>
|
|
1659
|
+
|
|
1660
|
+
{endpoint.operation.description ? <p className="description">{endpoint.operation.description}</p> : null}
|
|
1661
|
+
|
|
1662
|
+
<section className="copy-preview">
|
|
1663
|
+
<div className="copy-preview-toolbar">
|
|
1664
|
+
<div className="copy-tabs" role="tablist" aria-label="可复制内容">
|
|
1665
|
+
<button
|
|
1666
|
+
className={activeDetailTab === 'ai' ? 'active' : ''}
|
|
1667
|
+
onClick={() => setActiveDetailTab('ai')}
|
|
1668
|
+
role="tab"
|
|
1669
|
+
aria-selected={activeDetailTab === 'ai'}
|
|
1670
|
+
>
|
|
1671
|
+
<ClipboardCopy size={15} />
|
|
1672
|
+
API Cli
|
|
1673
|
+
</button>
|
|
1674
|
+
<button
|
|
1675
|
+
className={activeDetailTab === 'json' ? 'active' : ''}
|
|
1676
|
+
onClick={() => setActiveDetailTab('json')}
|
|
1677
|
+
role="tab"
|
|
1678
|
+
aria-selected={activeDetailTab === 'json'}
|
|
1679
|
+
>
|
|
1680
|
+
<Braces size={15} />
|
|
1681
|
+
原始 Operation
|
|
1682
|
+
</button>
|
|
1683
|
+
<button
|
|
1684
|
+
className={activeDetailTab === 'test' ? 'active' : ''}
|
|
1685
|
+
onClick={() => setActiveDetailTab('test')}
|
|
1686
|
+
role="tab"
|
|
1687
|
+
aria-selected={activeDetailTab === 'test'}
|
|
1688
|
+
>
|
|
1689
|
+
<Send size={15} />
|
|
1690
|
+
测试请求
|
|
1691
|
+
</button>
|
|
1692
|
+
<button
|
|
1693
|
+
className={activeDetailTab === 'links' ? 'active' : ''}
|
|
1694
|
+
onClick={() => setActiveDetailTab('links')}
|
|
1695
|
+
role="tab"
|
|
1696
|
+
aria-selected={activeDetailTab === 'links'}
|
|
1697
|
+
>
|
|
1698
|
+
<Link2 size={15} />
|
|
1699
|
+
关联地址
|
|
1700
|
+
</button>
|
|
1701
|
+
</div>
|
|
1702
|
+
{activeDetailTab === 'test' || activeDetailTab === 'links' ? null : (
|
|
1703
|
+
<button className="copy-inline-button" onClick={() => onCopy(activeDetailTab, activeCopyText)}>
|
|
1704
|
+
{copied === activeDetailTab ? <Check size={16} /> : <ClipboardCopy size={16} />}
|
|
1705
|
+
{copied === activeDetailTab ? '已复制' : activeCopyLabel}
|
|
1706
|
+
</button>
|
|
1707
|
+
)}
|
|
1708
|
+
</div>
|
|
1709
|
+
{activeDetailTab === 'test' ? (
|
|
1710
|
+
<RequestTester doc={doc} endpoint={endpoint} environmentBaseUrl={environmentBaseUrl} />
|
|
1711
|
+
) : activeDetailTab === 'links' ? (
|
|
1712
|
+
<EndpointLinksConfig endpoint={endpoint} links={linksDraft} onChange={onLinksDraftChange} onSave={(links) => onSaveLinks(endpoint, links)} />
|
|
1713
|
+
) : (
|
|
1714
|
+
<pre className="copy-preview-content">{activeCopyText}</pre>
|
|
1715
|
+
)}
|
|
1716
|
+
</section>
|
|
1717
|
+
|
|
1718
|
+
<Section title="请求参数" count={params.length}>
|
|
1719
|
+
<FieldTable rows={params} empty="无 query / path / header 参数" />
|
|
1720
|
+
</Section>
|
|
1721
|
+
|
|
1722
|
+
<Section title="请求体" count={Object.values(requestBodies).reduce((sum, rows) => sum + rows.length, 0)}>
|
|
1723
|
+
{Object.keys(requestBodies).length ? (
|
|
1724
|
+
Object.entries(requestBodies).map(([mediaType, rows]) => (
|
|
1725
|
+
<div key={mediaType} className="media-block">
|
|
1726
|
+
<h4>{mediaType}</h4>
|
|
1727
|
+
<FieldTable rows={rows} empty="空对象或无字段说明" />
|
|
1728
|
+
</div>
|
|
1729
|
+
))
|
|
1730
|
+
) : (
|
|
1731
|
+
<p className="muted">无请求体</p>
|
|
1732
|
+
)}
|
|
1733
|
+
</Section>
|
|
1734
|
+
|
|
1735
|
+
<Section title="响应字段" count={Object.values(responses).reduce((sum, media) => sum + Object.values(media).flat().length, 0)}>
|
|
1736
|
+
{Object.keys(endpoint.operation.responses ?? {}).length ? (
|
|
1737
|
+
Object.entries(endpoint.operation.responses ?? {}).map(([status, response]) => {
|
|
1738
|
+
const mediaRows = responses[status] ?? {};
|
|
1739
|
+
return (
|
|
1740
|
+
<div key={status} className="response-block">
|
|
1741
|
+
<div className="response-heading">
|
|
1742
|
+
<strong>{status}</strong>
|
|
1743
|
+
<span>{response.description || '无描述'}</span>
|
|
1744
|
+
</div>
|
|
1745
|
+
{Object.keys(mediaRows).length ? (
|
|
1746
|
+
Object.entries(mediaRows).map(([mediaType, rows]) => (
|
|
1747
|
+
<div key={mediaType} className="media-block">
|
|
1748
|
+
<h4>{mediaType}</h4>
|
|
1749
|
+
<FieldTable rows={rows} empty="空对象或无字段说明" />
|
|
1750
|
+
</div>
|
|
1751
|
+
))
|
|
1752
|
+
) : (
|
|
1753
|
+
<p className="muted">无字段说明</p>
|
|
1754
|
+
)}
|
|
1755
|
+
</div>
|
|
1756
|
+
);
|
|
1757
|
+
})
|
|
1758
|
+
) : (
|
|
1759
|
+
<p className="muted">无响应说明</p>
|
|
1760
|
+
)}
|
|
1761
|
+
</Section>
|
|
1762
|
+
</article>
|
|
1763
|
+
);
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
function EndpointLinksConfig({
|
|
1767
|
+
endpoint,
|
|
1768
|
+
links,
|
|
1769
|
+
onChange,
|
|
1770
|
+
onSave,
|
|
1771
|
+
}: {
|
|
1772
|
+
endpoint: Endpoint;
|
|
1773
|
+
links: EndpointAssociationLinks;
|
|
1774
|
+
onChange: (links: EndpointAssociationLinks) => void;
|
|
1775
|
+
onSave: (links: EndpointAssociationLinks) => Promise<void>;
|
|
1776
|
+
}) {
|
|
1777
|
+
const [saving, setSaving] = useState(false);
|
|
1778
|
+
const [message, setMessage] = useState('');
|
|
1779
|
+
|
|
1780
|
+
useEffect(() => {
|
|
1781
|
+
setMessage('');
|
|
1782
|
+
}, [endpoint.id]);
|
|
1783
|
+
|
|
1784
|
+
async function save() {
|
|
1785
|
+
setSaving(true);
|
|
1786
|
+
setMessage('');
|
|
1787
|
+
try {
|
|
1788
|
+
await onSave(links);
|
|
1789
|
+
setMessage('关联配置已保存');
|
|
1790
|
+
} catch (err) {
|
|
1791
|
+
setMessage(err instanceof Error ? err.message : '关联配置保存失败');
|
|
1792
|
+
} finally {
|
|
1793
|
+
setSaving(false);
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
return (
|
|
1798
|
+
<div className="endpoint-links-config">
|
|
1799
|
+
{message ? <div className={`version-message ${message.includes('失败') ? 'error' : ''}`}>{message}</div> : null}
|
|
1800
|
+
<div className="links-grid">
|
|
1801
|
+
<AssociatedLinkEditor
|
|
1802
|
+
title="原型地址"
|
|
1803
|
+
value={links.prototype ?? defaultAssociatedLink()}
|
|
1804
|
+
onChange={(value) => onChange({ ...links, prototype: value })}
|
|
1805
|
+
/>
|
|
1806
|
+
<AssociatedLinkEditor
|
|
1807
|
+
title="UI地址"
|
|
1808
|
+
value={links.ui ?? defaultAssociatedLink()}
|
|
1809
|
+
onChange={(value) => onChange({ ...links, ui: value })}
|
|
1810
|
+
/>
|
|
1811
|
+
</div>
|
|
1812
|
+
<div className="links-actions">
|
|
1813
|
+
<button className="primary-button" onClick={save} disabled={saving}>
|
|
1814
|
+
<Check size={16} />
|
|
1815
|
+
{saving ? '保存中' : '保存关联配置'}
|
|
1816
|
+
</button>
|
|
1817
|
+
</div>
|
|
1818
|
+
</div>
|
|
1819
|
+
);
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
function AssociatedLinkEditor({
|
|
1823
|
+
title,
|
|
1824
|
+
value,
|
|
1825
|
+
onChange,
|
|
1826
|
+
}: {
|
|
1827
|
+
title: string;
|
|
1828
|
+
value: NonNullable<EndpointAssociationLinks['prototype']>;
|
|
1829
|
+
onChange: (value: NonNullable<EndpointAssociationLinks['prototype']>) => void;
|
|
1830
|
+
}) {
|
|
1831
|
+
const isOnline = value.sourceType === 'online';
|
|
1832
|
+
const isCurl = value.sourceType === 'curl';
|
|
1833
|
+
|
|
1834
|
+
function patch(next: Partial<NonNullable<EndpointAssociationLinks['prototype']>>) {
|
|
1835
|
+
onChange({ ...value, ...next });
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
function copyLinkValue() {
|
|
1839
|
+
const text = isCurl ? value.curl : value.url;
|
|
1840
|
+
if (!text) return;
|
|
1841
|
+
copyTextWithSelection(text);
|
|
1842
|
+
void navigator.clipboard.writeText(text).catch(() => undefined);
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
return (
|
|
1846
|
+
<section className="tester-panel link-editor-panel">
|
|
1847
|
+
<div className="tester-panel-title">
|
|
1848
|
+
<Link2 size={15} />
|
|
1849
|
+
{title}
|
|
1850
|
+
</div>
|
|
1851
|
+
<div className="tester-form-grid">
|
|
1852
|
+
<label>
|
|
1853
|
+
类型
|
|
1854
|
+
<select value={value.sourceType || 'local'} onChange={(event) => patch({ sourceType: event.target.value as 'local' | 'online' | 'curl' })}>
|
|
1855
|
+
<option value="local">本地文件地址</option>
|
|
1856
|
+
<option value="online">在线地址</option>
|
|
1857
|
+
<option value="curl">CURL</option>
|
|
1858
|
+
</select>
|
|
1859
|
+
</label>
|
|
1860
|
+
<label>
|
|
1861
|
+
标题
|
|
1862
|
+
<input value={value.title || ''} onChange={(event) => patch({ title: event.target.value })} placeholder="例如 Figma 原型 / 用户列表页面" />
|
|
1863
|
+
</label>
|
|
1864
|
+
{isCurl ? (
|
|
1865
|
+
<label className="span-all">
|
|
1866
|
+
CURL 命令
|
|
1867
|
+
<textarea
|
|
1868
|
+
value={value.curl || ''}
|
|
1869
|
+
onChange={(event) => patch({ curl: event.target.value })}
|
|
1870
|
+
placeholder="curl 'https://example.com/prototype' -H 'Authorization: Bearer ...'"
|
|
1871
|
+
spellCheck={false}
|
|
1872
|
+
/>
|
|
1873
|
+
</label>
|
|
1874
|
+
) : (
|
|
1875
|
+
<label className="span-all">
|
|
1876
|
+
地址
|
|
1877
|
+
<input
|
|
1878
|
+
value={value.url || ''}
|
|
1879
|
+
onChange={(event) => patch({ url: event.target.value })}
|
|
1880
|
+
placeholder={isOnline ? 'https://example.com/prototype' : '/Users/name/project/design/user-list.fig'}
|
|
1881
|
+
/>
|
|
1882
|
+
</label>
|
|
1883
|
+
)}
|
|
1884
|
+
{isOnline ? (
|
|
1885
|
+
<>
|
|
1886
|
+
<label>
|
|
1887
|
+
访问方式
|
|
1888
|
+
<select value={value.authType || 'none'} onChange={(event) => patch({ authType: event.target.value as NonNullable<typeof value.authType> })}>
|
|
1889
|
+
<option value="none">无需登录</option>
|
|
1890
|
+
<option value="browser">浏览器登录 / Cookie</option>
|
|
1891
|
+
<option value="bearer">Bearer Token</option>
|
|
1892
|
+
<option value="header">自定义 Header</option>
|
|
1893
|
+
</select>
|
|
1894
|
+
</label>
|
|
1895
|
+
{value.authType === 'header' ? (
|
|
1896
|
+
<label>
|
|
1897
|
+
Header 名称
|
|
1898
|
+
<input value={value.headerName || ''} onChange={(event) => patch({ headerName: event.target.value })} placeholder="X-Auth-Token" />
|
|
1899
|
+
</label>
|
|
1900
|
+
) : null}
|
|
1901
|
+
{value.authType === 'bearer' || value.authType === 'header' ? (
|
|
1902
|
+
<label className="span-all">
|
|
1903
|
+
Token / Header 值
|
|
1904
|
+
<input
|
|
1905
|
+
value={value.token || ''}
|
|
1906
|
+
onChange={(event) => patch({ token: event.target.value })}
|
|
1907
|
+
placeholder="仅保存在本地文档缓存中,用于记录访问凭据"
|
|
1908
|
+
type="password"
|
|
1909
|
+
/>
|
|
1910
|
+
</label>
|
|
1911
|
+
) : null}
|
|
1912
|
+
</>
|
|
1913
|
+
) : null}
|
|
1914
|
+
<label className="span-all">
|
|
1915
|
+
备注
|
|
1916
|
+
<input
|
|
1917
|
+
value={value.note || ''}
|
|
1918
|
+
onChange={(event) => patch({ note: event.target.value })}
|
|
1919
|
+
placeholder={
|
|
1920
|
+
isCurl
|
|
1921
|
+
? '例如 CURL 里的 token 来源、过期方式或请求前置条件'
|
|
1922
|
+
: isOnline
|
|
1923
|
+
? '例如需要先登录公司 SSO,或 token 过期时找谁更新'
|
|
1924
|
+
: '例如本地原型文件位置、分支或打开方式'
|
|
1925
|
+
}
|
|
1926
|
+
/>
|
|
1927
|
+
</label>
|
|
1928
|
+
</div>
|
|
1929
|
+
<div className="link-editor-actions">
|
|
1930
|
+
<button className="secondary-button" onClick={copyLinkValue} disabled={!(isCurl ? value.curl : value.url)}>
|
|
1931
|
+
<ClipboardCopy size={15} />
|
|
1932
|
+
{isCurl ? '复制CURL' : '复制地址'}
|
|
1933
|
+
</button>
|
|
1934
|
+
{isOnline && value.url ? (
|
|
1935
|
+
<a className="secondary-button link-button" href={value.url} target="_blank" rel="noreferrer">
|
|
1936
|
+
<Link2 size={15} />
|
|
1937
|
+
打开地址
|
|
1938
|
+
</a>
|
|
1939
|
+
) : null}
|
|
1940
|
+
</div>
|
|
1941
|
+
</section>
|
|
1942
|
+
);
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
function normalizeEndpointLinks(value?: EndpointAssociationLinks): EndpointAssociationLinks {
|
|
1946
|
+
return {
|
|
1947
|
+
prototype: normalizeAssociatedLink(value?.prototype),
|
|
1948
|
+
ui: normalizeAssociatedLink(value?.ui),
|
|
1949
|
+
};
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
function normalizeAssociatedLink(value?: EndpointAssociationLinks['prototype']): NonNullable<EndpointAssociationLinks['prototype']> {
|
|
1953
|
+
const sourceType = value?.sourceType === 'online' || value?.sourceType === 'curl' ? value.sourceType : 'local';
|
|
1954
|
+
return {
|
|
1955
|
+
...defaultAssociatedLink(),
|
|
1956
|
+
...(value ?? {}),
|
|
1957
|
+
sourceType,
|
|
1958
|
+
authType: value?.authType || 'none',
|
|
1959
|
+
};
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
function defaultAssociatedLink(): NonNullable<EndpointAssociationLinks['prototype']> {
|
|
1963
|
+
return {
|
|
1964
|
+
sourceType: 'local',
|
|
1965
|
+
title: '',
|
|
1966
|
+
url: '',
|
|
1967
|
+
curl: '',
|
|
1968
|
+
authType: 'none',
|
|
1969
|
+
token: '',
|
|
1970
|
+
headerName: '',
|
|
1971
|
+
note: '',
|
|
1972
|
+
};
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
function RequestTester({ doc, endpoint, environmentBaseUrl }: { doc: SwaggerDocument; endpoint: Endpoint; environmentBaseUrl: string }) {
|
|
1976
|
+
const parameters = endpoint.operation.parameters ?? [];
|
|
1977
|
+
const pathParams = parameters.filter((item) => item.in === 'path');
|
|
1978
|
+
const queryParams = parameters.filter((item) => item.in === 'query');
|
|
1979
|
+
const headerParams = parameters.filter((item) => item.in === 'header');
|
|
1980
|
+
const bodyMedia = useMemo(() => pickRequestBodyMedia(endpoint), [endpoint]);
|
|
1981
|
+
const previousAutoBaseUrlRef = useRef('');
|
|
1982
|
+
const [baseUrl, setBaseUrl] = useState('');
|
|
1983
|
+
const [authType, setAuthType] = useState<AuthType>('none');
|
|
1984
|
+
const [authValue, setAuthValue] = useState('');
|
|
1985
|
+
const [apiKeyName, setApiKeyName] = useState('Authorization');
|
|
1986
|
+
const [apiKeyLocation, setApiKeyLocation] = useState<ApiKeyLocation>('header');
|
|
1987
|
+
const [basicUsername, setBasicUsername] = useState('');
|
|
1988
|
+
const [basicPassword, setBasicPassword] = useState('');
|
|
1989
|
+
const [paramValues, setParamValues] = useState<Record<string, string>>({});
|
|
1990
|
+
const [headerValues, setHeaderValues] = useState<Record<string, string>>({});
|
|
1991
|
+
const [bodyText, setBodyText] = useState('');
|
|
1992
|
+
const [customHeadersText, setCustomHeadersText] = useState('{\n "Content-Type": "application/json"\n}');
|
|
1993
|
+
const [sending, setSending] = useState(false);
|
|
1994
|
+
const [requestError, setRequestError] = useState('');
|
|
1995
|
+
const [response, setResponse] = useState<RequestTestResponse | null>(null);
|
|
1996
|
+
|
|
1997
|
+
useEffect(() => {
|
|
1998
|
+
setParamValues({});
|
|
1999
|
+
setHeaderValues({});
|
|
2000
|
+
setResponse(null);
|
|
2001
|
+
setRequestError('');
|
|
2002
|
+
setBodyText(bodyMedia?.schema ? JSON.stringify(createExampleFromSchema(doc, bodyMedia.schema), null, 2) : '');
|
|
2003
|
+
setCustomHeadersText(bodyMedia ? '{\n "Content-Type": "application/json"\n}' : '{}');
|
|
2004
|
+
}, [doc, endpoint.id, bodyMedia]);
|
|
2005
|
+
|
|
2006
|
+
useEffect(() => {
|
|
2007
|
+
const nextBaseUrl = environmentBaseUrl.trim();
|
|
2008
|
+
setBaseUrl((current) => {
|
|
2009
|
+
const previousAutoBaseUrl = previousAutoBaseUrlRef.current;
|
|
2010
|
+
previousAutoBaseUrlRef.current = nextBaseUrl;
|
|
2011
|
+
if (!current || current === previousAutoBaseUrl) return nextBaseUrl;
|
|
2012
|
+
return current;
|
|
2013
|
+
});
|
|
2014
|
+
}, [environmentBaseUrl]);
|
|
2015
|
+
|
|
2016
|
+
const previewUrl = useMemo(() => {
|
|
2017
|
+
try {
|
|
2018
|
+
return buildRequestUrl(baseUrl, endpoint.path, parameters, paramValues, apiKeyLocation === 'query' ? { [apiKeyName]: authValue } : {});
|
|
2019
|
+
} catch {
|
|
2020
|
+
return endpoint.path;
|
|
2021
|
+
}
|
|
2022
|
+
}, [apiKeyLocation, apiKeyName, authValue, baseUrl, endpoint.path, parameters, paramValues]);
|
|
2023
|
+
|
|
2024
|
+
function mockData() {
|
|
2025
|
+
const nextParamValues: Record<string, string> = {};
|
|
2026
|
+
const nextHeaderValues: Record<string, string> = {};
|
|
2027
|
+
|
|
2028
|
+
parameters.forEach((parameter, index) => {
|
|
2029
|
+
const key = parameterKey(parameter, index);
|
|
2030
|
+
const value = stringifyMockValue(createParameterMockValue(doc, parameter));
|
|
2031
|
+
if (parameter.in === 'path' || parameter.in === 'query') {
|
|
2032
|
+
nextParamValues[key] = value;
|
|
2033
|
+
}
|
|
2034
|
+
if (parameter.in === 'header') {
|
|
2035
|
+
nextHeaderValues[key] = value;
|
|
2036
|
+
}
|
|
2037
|
+
});
|
|
2038
|
+
|
|
2039
|
+
setParamValues(nextParamValues);
|
|
2040
|
+
setHeaderValues(nextHeaderValues);
|
|
2041
|
+
if (bodyMedia?.schema && !['get', 'head'].includes(endpoint.method)) {
|
|
2042
|
+
setBodyText(JSON.stringify(createMockFromSchema(doc, bodyMedia.schema), null, 2));
|
|
2043
|
+
setCustomHeadersText(JSON.stringify({ ...safeParseHeaderJson(customHeadersText), 'Content-Type': bodyMedia.mediaType }, null, 2));
|
|
2044
|
+
} else {
|
|
2045
|
+
setBodyText('');
|
|
2046
|
+
}
|
|
2047
|
+
setResponse(null);
|
|
2048
|
+
setRequestError('');
|
|
2049
|
+
}
|
|
2050
|
+
async function sendRequest() {
|
|
2051
|
+
setSending(true);
|
|
2052
|
+
setRequestError('');
|
|
2053
|
+
setResponse(null);
|
|
2054
|
+
|
|
2055
|
+
try {
|
|
2056
|
+
const customHeaders = parseHeaderJson(customHeadersText);
|
|
2057
|
+
const headers = buildRequestHeaders({
|
|
2058
|
+
customHeaders,
|
|
2059
|
+
headerParams,
|
|
2060
|
+
headerValues,
|
|
2061
|
+
authType,
|
|
2062
|
+
authValue,
|
|
2063
|
+
apiKeyName,
|
|
2064
|
+
apiKeyLocation,
|
|
2065
|
+
basicUsername,
|
|
2066
|
+
basicPassword,
|
|
2067
|
+
});
|
|
2068
|
+
const url = buildRequestUrl(baseUrl, endpoint.path, parameters, paramValues, apiKeyLocation === 'query' ? { [apiKeyName]: authValue } : {});
|
|
2069
|
+
const requestBody = ['get', 'head'].includes(endpoint.method) ? undefined : bodyText.trim() || undefined;
|
|
2070
|
+
const result = await sendTestRequest({
|
|
2071
|
+
url,
|
|
2072
|
+
method: endpoint.method.toUpperCase(),
|
|
2073
|
+
headers,
|
|
2074
|
+
body: requestBody,
|
|
2075
|
+
});
|
|
2076
|
+
setResponse(result);
|
|
2077
|
+
} catch (err) {
|
|
2078
|
+
setRequestError(err instanceof Error ? err.message : '请求失败');
|
|
2079
|
+
} finally {
|
|
2080
|
+
setSending(false);
|
|
2081
|
+
}
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
return (
|
|
2085
|
+
<div className="request-tester">
|
|
2086
|
+
<div className="request-line">
|
|
2087
|
+
<span className={`method method-${endpoint.method}`}>{endpoint.method.toUpperCase()}</span>
|
|
2088
|
+
<label>
|
|
2089
|
+
Base URL
|
|
2090
|
+
<input value={baseUrl} onChange={(event) => setBaseUrl(event.target.value)} placeholder="https://api.example.com" />
|
|
2091
|
+
</label>
|
|
2092
|
+
<div className="request-actions">
|
|
2093
|
+
<button className="primary-button" onClick={sendRequest} disabled={sending}>
|
|
2094
|
+
<Send size={16} />
|
|
2095
|
+
{sending ? '请求中' : '发送请求'}
|
|
2096
|
+
</button>
|
|
2097
|
+
<button className="secondary-button" onClick={mockData} disabled={sending}>
|
|
2098
|
+
<Braces size={16} />
|
|
2099
|
+
MOCK数据
|
|
2100
|
+
</button>
|
|
2101
|
+
</div>
|
|
2102
|
+
</div>
|
|
2103
|
+
|
|
2104
|
+
<code className="request-preview">{previewUrl}</code>
|
|
2105
|
+
|
|
2106
|
+
<div className="tester-grid">
|
|
2107
|
+
<section className="tester-panel">
|
|
2108
|
+
<div className="tester-panel-title">
|
|
2109
|
+
<KeyRound size={15} />
|
|
2110
|
+
鉴权
|
|
2111
|
+
</div>
|
|
2112
|
+
<div className="tester-form-grid">
|
|
2113
|
+
<label>
|
|
2114
|
+
类型
|
|
2115
|
+
<select value={authType} onChange={(event) => setAuthType(event.target.value as AuthType)}>
|
|
2116
|
+
<option value="none">None</option>
|
|
2117
|
+
<option value="bearer">Bearer Token</option>
|
|
2118
|
+
<option value="jwt">JWT Token</option>
|
|
2119
|
+
<option value="apiKey">API Key</option>
|
|
2120
|
+
<option value="basic">Basic Auth</option>
|
|
2121
|
+
</select>
|
|
2122
|
+
</label>
|
|
2123
|
+
{authType === 'apiKey' ? (
|
|
2124
|
+
<>
|
|
2125
|
+
<label>
|
|
2126
|
+
Key 名称
|
|
2127
|
+
<input value={apiKeyName} onChange={(event) => setApiKeyName(event.target.value)} placeholder="X-API-Key" />
|
|
2128
|
+
</label>
|
|
2129
|
+
<label>
|
|
2130
|
+
位置
|
|
2131
|
+
<select value={apiKeyLocation} onChange={(event) => setApiKeyLocation(event.target.value as ApiKeyLocation)}>
|
|
2132
|
+
<option value="header">Header</option>
|
|
2133
|
+
<option value="query">Query</option>
|
|
2134
|
+
</select>
|
|
2135
|
+
</label>
|
|
2136
|
+
<label>
|
|
2137
|
+
Value
|
|
2138
|
+
<input value={authValue} onChange={(event) => setAuthValue(event.target.value)} placeholder="api key value" />
|
|
2139
|
+
</label>
|
|
2140
|
+
</>
|
|
2141
|
+
) : authType === 'basic' ? (
|
|
2142
|
+
<>
|
|
2143
|
+
<label>
|
|
2144
|
+
Username
|
|
2145
|
+
<input value={basicUsername} onChange={(event) => setBasicUsername(event.target.value)} />
|
|
2146
|
+
</label>
|
|
2147
|
+
<label>
|
|
2148
|
+
Password
|
|
2149
|
+
<input value={basicPassword} onChange={(event) => setBasicPassword(event.target.value)} type="password" />
|
|
2150
|
+
</label>
|
|
2151
|
+
</>
|
|
2152
|
+
) : authType !== 'none' ? (
|
|
2153
|
+
<label>
|
|
2154
|
+
Token
|
|
2155
|
+
<input value={authValue} onChange={(event) => setAuthValue(event.target.value)} placeholder="token" />
|
|
2156
|
+
</label>
|
|
2157
|
+
) : null}
|
|
2158
|
+
</div>
|
|
2159
|
+
</section>
|
|
2160
|
+
|
|
2161
|
+
<section className="tester-panel">
|
|
2162
|
+
<div className="tester-panel-title">参数</div>
|
|
2163
|
+
<ParameterEditor title="Path" parameters={pathParams} values={paramValues} onChange={setParamValues} />
|
|
2164
|
+
<ParameterEditor title="Query" parameters={queryParams} values={paramValues} onChange={setParamValues} />
|
|
2165
|
+
<ParameterEditor title="Header" parameters={headerParams} values={headerValues} onChange={setHeaderValues} />
|
|
2166
|
+
</section>
|
|
2167
|
+
</div>
|
|
2168
|
+
|
|
2169
|
+
<div className="tester-grid">
|
|
2170
|
+
<section className="tester-panel">
|
|
2171
|
+
<div className="tester-panel-title">请求头 JSON</div>
|
|
2172
|
+
<textarea value={customHeadersText} onChange={(event) => setCustomHeadersText(event.target.value)} spellCheck={false} />
|
|
2173
|
+
</section>
|
|
2174
|
+
<section className="tester-panel">
|
|
2175
|
+
<div className="tester-panel-title">请求体 {bodyMedia ? `(${bodyMedia.mediaType})` : ''}</div>
|
|
2176
|
+
<textarea
|
|
2177
|
+
value={bodyText}
|
|
2178
|
+
onChange={(event) => setBodyText(event.target.value)}
|
|
2179
|
+
disabled={!bodyMedia || ['get', 'head'].includes(endpoint.method)}
|
|
2180
|
+
spellCheck={false}
|
|
2181
|
+
placeholder={bodyMedia ? 'JSON / raw body' : '该接口没有请求体'}
|
|
2182
|
+
/>
|
|
2183
|
+
</section>
|
|
2184
|
+
</div>
|
|
2185
|
+
|
|
2186
|
+
{requestError ? (
|
|
2187
|
+
<div className="import-error">
|
|
2188
|
+
<AlertCircle size={16} />
|
|
2189
|
+
{requestError}
|
|
2190
|
+
</div>
|
|
2191
|
+
) : null}
|
|
2192
|
+
|
|
2193
|
+
<section className="tester-panel response-panel">
|
|
2194
|
+
<div className="tester-panel-title">响应</div>
|
|
2195
|
+
{response ? (
|
|
2196
|
+
<>
|
|
2197
|
+
<div className="response-meta-line">
|
|
2198
|
+
<span className={response.status >= 200 && response.status < 300 ? 'status-ok' : 'status-error'}>
|
|
2199
|
+
{response.status} {response.statusText}
|
|
2200
|
+
</span>
|
|
2201
|
+
<span>{response.elapsedMs}ms</span>
|
|
2202
|
+
<span>{response.url}</span>
|
|
2203
|
+
</div>
|
|
2204
|
+
<pre>{formatResponseBody(response.body)}</pre>
|
|
2205
|
+
</>
|
|
2206
|
+
) : (
|
|
2207
|
+
<p className="muted">发送请求后显示响应内容。</p>
|
|
2208
|
+
)}
|
|
2209
|
+
</section>
|
|
2210
|
+
</div>
|
|
2211
|
+
);
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
function ParameterEditor({
|
|
2215
|
+
title,
|
|
2216
|
+
parameters,
|
|
2217
|
+
values,
|
|
2218
|
+
onChange,
|
|
2219
|
+
}: {
|
|
2220
|
+
title: string;
|
|
2221
|
+
parameters: SwaggerParameter[];
|
|
2222
|
+
values: Record<string, string>;
|
|
2223
|
+
onChange: (value: Record<string, string>) => void;
|
|
2224
|
+
}) {
|
|
2225
|
+
if (!parameters.length) return null;
|
|
2226
|
+
|
|
2227
|
+
return (
|
|
2228
|
+
<div className="param-editor">
|
|
2229
|
+
<h4>{title}</h4>
|
|
2230
|
+
{parameters.map((parameter, index) => {
|
|
2231
|
+
const key = parameterKey(parameter, index);
|
|
2232
|
+
return (
|
|
2233
|
+
<label key={key}>
|
|
2234
|
+
<span>
|
|
2235
|
+
{parameter.name}
|
|
2236
|
+
{parameter.required ? <b>*</b> : null}
|
|
2237
|
+
</span>
|
|
2238
|
+
<input
|
|
2239
|
+
value={values[key] ?? ''}
|
|
2240
|
+
onChange={(event) => onChange({ ...values, [key]: event.target.value })}
|
|
2241
|
+
placeholder={parameter.description || parameter.schema?.description || ''}
|
|
2242
|
+
/>
|
|
2243
|
+
</label>
|
|
2244
|
+
);
|
|
2245
|
+
})}
|
|
2246
|
+
</div>
|
|
2247
|
+
);
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
function pickRequestBodyMedia(endpoint: Endpoint) {
|
|
2251
|
+
const content = endpoint.operation.requestBody?.content ?? {};
|
|
2252
|
+
const entries = Object.entries(content);
|
|
2253
|
+
const jsonEntry = entries.find(([mediaType]) => mediaType.includes('json'));
|
|
2254
|
+
const entry = jsonEntry ?? entries[0];
|
|
2255
|
+
const bodyParameter = endpoint.operation.parameters?.find((parameter) => parameter.in === 'body');
|
|
2256
|
+
if (!entry && bodyParameter?.schema) {
|
|
2257
|
+
return {
|
|
2258
|
+
mediaType: endpoint.operation.consumes?.find((mediaType) => mediaType.includes('json')) ?? endpoint.operation.consumes?.[0] ?? 'application/json',
|
|
2259
|
+
schema: bodyParameter.schema,
|
|
2260
|
+
};
|
|
2261
|
+
}
|
|
2262
|
+
if (!entry) return undefined;
|
|
2263
|
+
return { mediaType: entry[0], schema: entry[1].schema };
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2266
|
+
function createExampleFromSchema(doc: SwaggerDocument, schema?: SwaggerSchema, depth = 0, seen = new Set<SwaggerSchema>()): unknown {
|
|
2267
|
+
if (!schema || depth > 8) return {};
|
|
2268
|
+
const resolved = resolveSchema(doc, schema) ?? schema;
|
|
2269
|
+
if (seen.has(resolved)) return {};
|
|
2270
|
+
seen.add(resolved);
|
|
2271
|
+
|
|
2272
|
+
if (resolved.default !== undefined) return resolved.default;
|
|
2273
|
+
if (resolved.enum?.length) return resolved.enum[0];
|
|
2274
|
+
if (resolved.type === 'array') return [createExampleFromSchema(doc, resolved.items, depth + 1, seen)];
|
|
2275
|
+
if (resolved.type === 'integer' || resolved.type === 'number') return 0;
|
|
2276
|
+
if (resolved.type === 'boolean') return false;
|
|
2277
|
+
if (resolved.type === 'string') return '';
|
|
2278
|
+
|
|
2279
|
+
const properties = resolved.properties ?? {};
|
|
2280
|
+
return Object.fromEntries(
|
|
2281
|
+
Object.entries(properties).map(([name, property]) => [name, createExampleFromSchema(doc, property, depth + 1, new Set(seen))]),
|
|
2282
|
+
);
|
|
2283
|
+
}
|
|
2284
|
+
|
|
2285
|
+
function createParameterMockValue(doc: SwaggerDocument, parameter: SwaggerParameter) {
|
|
2286
|
+
if (parameter.default !== undefined) return parameter.default;
|
|
2287
|
+
if (parameter.enum?.length) return parameter.enum[0];
|
|
2288
|
+
const schema = schemaFromParameter(parameter);
|
|
2289
|
+
return createMockFromSchema(doc, schema, parameter.name);
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
function createMockFromSchema(
|
|
2293
|
+
doc: SwaggerDocument,
|
|
2294
|
+
schema?: SwaggerSchema,
|
|
2295
|
+
fieldName = '',
|
|
2296
|
+
depth = 0,
|
|
2297
|
+
seen = new Set<SwaggerSchema>(),
|
|
2298
|
+
): unknown {
|
|
2299
|
+
if (!schema || depth > 8) return mockValueByName(fieldName) ?? 'mock-value';
|
|
2300
|
+
const resolved = resolveSchema(doc, schema) ?? schema;
|
|
2301
|
+
if (seen.has(resolved)) return {};
|
|
2302
|
+
const nextSeen = new Set(seen);
|
|
2303
|
+
nextSeen.add(resolved);
|
|
2304
|
+
|
|
2305
|
+
if (resolved.default !== undefined) return resolved.default;
|
|
2306
|
+
if (schema.default !== undefined) return schema.default;
|
|
2307
|
+
if (resolved.enum?.length) return resolved.enum[0];
|
|
2308
|
+
if (schema.enum?.length) return schema.enum[0];
|
|
2309
|
+
|
|
2310
|
+
const semanticValue = mockValueByName(fieldName, resolved.format || schema.format);
|
|
2311
|
+
const normalizedType = resolved.type || schema.type || (resolved.properties ? 'object' : '');
|
|
2312
|
+
if (semanticValue !== undefined && (!normalizedType || normalizedType === 'string' || normalizedType === 'integer' || normalizedType === 'number')) {
|
|
2313
|
+
return semanticValue;
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
if (resolved.oneOf?.length) return createMockFromSchema(doc, resolved.oneOf[0], fieldName, depth + 1, nextSeen);
|
|
2317
|
+
if (resolved.anyOf?.length) return createMockFromSchema(doc, resolved.anyOf[0], fieldName, depth + 1, nextSeen);
|
|
2318
|
+
if (normalizedType === 'array') return [createMockFromSchema(doc, resolved.items, fieldName, depth + 1, nextSeen)];
|
|
2319
|
+
if (normalizedType === 'integer' || normalizedType === 'number') return mockNumberByName(fieldName);
|
|
2320
|
+
if (normalizedType === 'boolean') return true;
|
|
2321
|
+
if (normalizedType === 'string') return semanticValue ?? 'mock-value';
|
|
2322
|
+
|
|
2323
|
+
const properties = resolved.properties ?? {};
|
|
2324
|
+
if (Object.keys(properties).length) {
|
|
2325
|
+
return Object.fromEntries(
|
|
2326
|
+
Object.entries(properties).map(([name, property]) => [name, createMockFromSchema(doc, property, name, depth + 1, nextSeen)]),
|
|
2327
|
+
);
|
|
2328
|
+
}
|
|
2329
|
+
|
|
2330
|
+
if (resolved.additionalProperties && typeof resolved.additionalProperties === 'object') {
|
|
2331
|
+
return { key: createMockFromSchema(doc, resolved.additionalProperties, 'value', depth + 1, nextSeen) };
|
|
2332
|
+
}
|
|
2333
|
+
|
|
2334
|
+
return semanticValue ?? {};
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
function schemaFromParameter(parameter: SwaggerParameter): SwaggerSchema | undefined {
|
|
2338
|
+
if (parameter.schema) return parameter.schema;
|
|
2339
|
+
if (!parameter.type && !parameter.format && !parameter.items && !parameter.enum?.length && parameter.default === undefined) return undefined;
|
|
2340
|
+
return {
|
|
2341
|
+
type: parameter.type,
|
|
2342
|
+
format: parameter.format,
|
|
2343
|
+
items: parameter.items,
|
|
2344
|
+
enum: parameter.enum,
|
|
2345
|
+
default: parameter.default,
|
|
2346
|
+
description: parameter.description,
|
|
2347
|
+
};
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
function mockValueByName(fieldName: string, format = '') {
|
|
2351
|
+
const normalized = fieldName.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
2352
|
+
if (format === 'date-time') return new Date().toISOString();
|
|
2353
|
+
if (format === 'date') return new Date().toISOString().slice(0, 10);
|
|
2354
|
+
if (format === 'email' || normalized.includes('email')) return 'mock@example.com';
|
|
2355
|
+
if (format === 'uuid' || normalized.includes('uuid')) return '00000000-0000-4000-8000-000000000000';
|
|
2356
|
+
if (normalized === 'id' || normalized.endsWith('id')) return 1;
|
|
2357
|
+
if (normalized === 'page' || normalized === 'pagenum' || normalized === 'pageindex') return 1;
|
|
2358
|
+
if (normalized === 'pagesize' || normalized === 'limit' || normalized === 'size') return 10;
|
|
2359
|
+
if (normalized.includes('phone') || normalized.includes('mobile')) return '13800000000';
|
|
2360
|
+
if (normalized.includes('name') || normalized.includes('title') || normalized.includes('keyword')) return 'mock-name';
|
|
2361
|
+
if (normalized.includes('url')) return 'https://example.com';
|
|
2362
|
+
if (normalized.includes('date')) return new Date().toISOString().slice(0, 10);
|
|
2363
|
+
if (normalized.includes('time')) return new Date().toISOString();
|
|
2364
|
+
return undefined;
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2367
|
+
function mockNumberByName(fieldName: string) {
|
|
2368
|
+
const value = mockValueByName(fieldName);
|
|
2369
|
+
return typeof value === 'number' ? value : 1;
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
function stringifyMockValue(value: unknown) {
|
|
2373
|
+
if (value === undefined || value === null) return '';
|
|
2374
|
+
if (typeof value === 'string') return value;
|
|
2375
|
+
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
|
2376
|
+
return JSON.stringify(value);
|
|
2377
|
+
}
|
|
2378
|
+
|
|
2379
|
+
function parameterKey(parameter: SwaggerParameter, index: number) {
|
|
2380
|
+
return `${parameter.in}-${parameter.name}-${index}`;
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2383
|
+
function buildRequestUrl(
|
|
2384
|
+
baseUrl: string,
|
|
2385
|
+
path: string,
|
|
2386
|
+
parameters: SwaggerParameter[],
|
|
2387
|
+
values: Record<string, string>,
|
|
2388
|
+
extraQuery: Record<string, string>,
|
|
2389
|
+
) {
|
|
2390
|
+
if (!baseUrl.trim() && !/^https?:\/\//i.test(path)) {
|
|
2391
|
+
throw new Error('请输入 Base URL,例如 https://api.example.com');
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
const base = baseUrl.trim() || undefined;
|
|
2395
|
+
let requestPath = path;
|
|
2396
|
+
parameters.forEach((parameter, index) => {
|
|
2397
|
+
if (parameter.in !== 'path') return;
|
|
2398
|
+
const value = values[parameterKey(parameter, index)] ?? '';
|
|
2399
|
+
requestPath = requestPath.replace(new RegExp(`\\{${escapeRegExp(parameter.name)}\\}`, 'g'), encodeURIComponent(value));
|
|
2400
|
+
});
|
|
2401
|
+
|
|
2402
|
+
const url = new URL(requestPath, base);
|
|
2403
|
+
parameters.forEach((parameter, index) => {
|
|
2404
|
+
if (parameter.in !== 'query') return;
|
|
2405
|
+
const value = values[parameterKey(parameter, index)] ?? '';
|
|
2406
|
+
if (value) url.searchParams.append(parameter.name, value);
|
|
2407
|
+
});
|
|
2408
|
+
Object.entries(extraQuery).forEach(([key, value]) => {
|
|
2409
|
+
if (key.trim() && value.trim()) url.searchParams.set(key.trim(), value.trim());
|
|
2410
|
+
});
|
|
2411
|
+
|
|
2412
|
+
return url.toString();
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2415
|
+
function buildRequestHeaders({
|
|
2416
|
+
customHeaders,
|
|
2417
|
+
headerParams,
|
|
2418
|
+
headerValues,
|
|
2419
|
+
authType,
|
|
2420
|
+
authValue,
|
|
2421
|
+
apiKeyName,
|
|
2422
|
+
apiKeyLocation,
|
|
2423
|
+
basicUsername,
|
|
2424
|
+
basicPassword,
|
|
2425
|
+
}: {
|
|
2426
|
+
customHeaders: Record<string, string>;
|
|
2427
|
+
headerParams: SwaggerParameter[];
|
|
2428
|
+
headerValues: Record<string, string>;
|
|
2429
|
+
authType: AuthType;
|
|
2430
|
+
authValue: string;
|
|
2431
|
+
apiKeyName: string;
|
|
2432
|
+
apiKeyLocation: ApiKeyLocation;
|
|
2433
|
+
basicUsername: string;
|
|
2434
|
+
basicPassword: string;
|
|
2435
|
+
}) {
|
|
2436
|
+
const headers: Record<string, string> = { ...customHeaders };
|
|
2437
|
+
headerParams.forEach((parameter, index) => {
|
|
2438
|
+
const value = headerValues[parameterKey(parameter, index)];
|
|
2439
|
+
if (value) headers[parameter.name] = value;
|
|
2440
|
+
});
|
|
2441
|
+
|
|
2442
|
+
if ((authType === 'bearer' || authType === 'jwt') && authValue.trim()) {
|
|
2443
|
+
headers.Authorization = `Bearer ${authValue.trim()}`;
|
|
2444
|
+
}
|
|
2445
|
+
if (authType === 'apiKey' && apiKeyLocation === 'header' && apiKeyName.trim() && authValue.trim()) {
|
|
2446
|
+
headers[apiKeyName.trim()] = authValue.trim();
|
|
2447
|
+
}
|
|
2448
|
+
if (authType === 'basic' && (basicUsername || basicPassword)) {
|
|
2449
|
+
headers.Authorization = `Basic ${btoa(`${basicUsername}:${basicPassword}`)}`;
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2452
|
+
return headers;
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
function parseHeaderJson(value: string) {
|
|
2456
|
+
if (!value.trim()) return {};
|
|
2457
|
+
const parsed = JSON.parse(value) as Record<string, unknown>;
|
|
2458
|
+
return Object.fromEntries(Object.entries(parsed).map(([key, item]) => [key, String(item)]));
|
|
2459
|
+
}
|
|
2460
|
+
|
|
2461
|
+
function safeParseHeaderJson(value: string) {
|
|
2462
|
+
try {
|
|
2463
|
+
return parseHeaderJson(value);
|
|
2464
|
+
} catch {
|
|
2465
|
+
return {};
|
|
2466
|
+
}
|
|
2467
|
+
}
|
|
2468
|
+
|
|
2469
|
+
async function sendTestRequest(payload: { url: string; method: string; headers: Record<string, string>; body?: string }) {
|
|
2470
|
+
const response = await fetch('/api/request/send', {
|
|
2471
|
+
method: 'POST',
|
|
2472
|
+
headers: {
|
|
2473
|
+
'content-type': 'application/json',
|
|
2474
|
+
},
|
|
2475
|
+
body: JSON.stringify(payload),
|
|
2476
|
+
});
|
|
2477
|
+
const data = await response.json();
|
|
2478
|
+
if (!response.ok) throw new Error(data.error || '请求失败');
|
|
2479
|
+
return data as RequestTestResponse;
|
|
2480
|
+
}
|
|
2481
|
+
|
|
2482
|
+
function formatResponseBody(value: string) {
|
|
2483
|
+
try {
|
|
2484
|
+
return JSON.stringify(JSON.parse(value), null, 2);
|
|
2485
|
+
} catch {
|
|
2486
|
+
return value;
|
|
2487
|
+
}
|
|
2488
|
+
}
|
|
2489
|
+
|
|
2490
|
+
function copyTextWithSelection(value: string) {
|
|
2491
|
+
const textarea = document.createElement('textarea');
|
|
2492
|
+
textarea.value = value;
|
|
2493
|
+
textarea.setAttribute('readonly', 'true');
|
|
2494
|
+
textarea.style.position = 'fixed';
|
|
2495
|
+
textarea.style.left = '-9999px';
|
|
2496
|
+
textarea.style.top = '0';
|
|
2497
|
+
document.body.appendChild(textarea);
|
|
2498
|
+
textarea.focus();
|
|
2499
|
+
textarea.select();
|
|
2500
|
+
|
|
2501
|
+
try {
|
|
2502
|
+
return document.execCommand('copy');
|
|
2503
|
+
} finally {
|
|
2504
|
+
document.body.removeChild(textarea);
|
|
2505
|
+
}
|
|
2506
|
+
}
|
|
2507
|
+
|
|
2508
|
+
function escapeRegExp(value: string) {
|
|
2509
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
function Section({ title, count, children }: { title: string; count: number; children: React.ReactNode }) {
|
|
2513
|
+
return (
|
|
2514
|
+
<section className="detail-section">
|
|
2515
|
+
<div className="section-title">
|
|
2516
|
+
<h3>{title}</h3>
|
|
2517
|
+
<span>{count}</span>
|
|
2518
|
+
</div>
|
|
2519
|
+
{children}
|
|
2520
|
+
</section>
|
|
2521
|
+
);
|
|
2522
|
+
}
|
|
2523
|
+
|
|
2524
|
+
function FieldTable({ rows, empty }: { rows: FieldRow[]; empty: string }) {
|
|
2525
|
+
if (!rows.length) return <p className="muted">{empty}</p>;
|
|
2526
|
+
|
|
2527
|
+
return (
|
|
2528
|
+
<div className="table-wrap">
|
|
2529
|
+
<table>
|
|
2530
|
+
<thead>
|
|
2531
|
+
<tr>
|
|
2532
|
+
<th>字段</th>
|
|
2533
|
+
<th>位置</th>
|
|
2534
|
+
<th>必填</th>
|
|
2535
|
+
<th>类型</th>
|
|
2536
|
+
<th>说明</th>
|
|
2537
|
+
<th>默认值</th>
|
|
2538
|
+
<th>枚举</th>
|
|
2539
|
+
</tr>
|
|
2540
|
+
</thead>
|
|
2541
|
+
<tbody>
|
|
2542
|
+
{rows.map((row, index) => (
|
|
2543
|
+
<tr key={`${row.location}-${row.path}-${row.depth}-${index}`}>
|
|
2544
|
+
<td>
|
|
2545
|
+
<code style={{ paddingLeft: row.depth * 16 }}>{row.name}</code>
|
|
2546
|
+
</td>
|
|
2547
|
+
<td>{row.location || '-'}</td>
|
|
2548
|
+
<td>{row.required ? '是' : '否'}</td>
|
|
2549
|
+
<td>{row.type}</td>
|
|
2550
|
+
<td>{row.description || '-'}</td>
|
|
2551
|
+
<td>{row.defaultValue || '-'}</td>
|
|
2552
|
+
<td>{row.enumValue || '-'}</td>
|
|
2553
|
+
</tr>
|
|
2554
|
+
))}
|
|
2555
|
+
</tbody>
|
|
2556
|
+
</table>
|
|
2557
|
+
</div>
|
|
2558
|
+
);
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2561
|
+
function formatDate(value: string) {
|
|
2562
|
+
const date = new Date(value);
|
|
2563
|
+
if (Number.isNaN(date.getTime())) return value;
|
|
2564
|
+
return date.toLocaleString('zh-CN', {
|
|
2565
|
+
month: '2-digit',
|
|
2566
|
+
day: '2-digit',
|
|
2567
|
+
hour: '2-digit',
|
|
2568
|
+
minute: '2-digit',
|
|
2569
|
+
});
|
|
2570
|
+
}
|