apiskill 0.1.4 → 0.1.6
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/README.md +11 -1
- package/README.zh.md +13 -1
- package/dist/assets/index-B_wAJLu_.js +324 -0
- package/dist/assets/index-CHOQ6xhP.css +1 -0
- package/dist/index.html +2 -2
- package/docs/cli.md +16 -2
- package/docs/cli.zh.md +16 -2
- package/docs/mcp.ja.md +2 -4
- package/docs/mcp.ko.md +2 -4
- package/docs/mcp.md +2 -4
- package/docs/mcp.zh.md +2 -4
- package/docs/web.md +3 -1
- package/docs/web.zh.md +3 -1
- package/mcp-config.example.json +1 -5
- package/package.json +4 -3
- package/scripts/apiskill-cli.mjs +48 -9
- package/scripts/lib/apiskill-core.mjs +6 -0
- package/scripts/lib/cache-paths.mjs +94 -4
- package/scripts/test-shared-cache.mjs +175 -0
- package/src/App.tsx +29 -0
- package/src/CacheSettingsDialog.tsx +166 -0
- package/src/DocumentVersionManager.tsx +47 -1
- package/src/styles.css +119 -0
- package/src/swagger.ts +2 -0
- package/vite.config.ts +92 -1
- package/dist/assets/index-CV6e3HAP.js +0 -299
- package/dist/assets/index-slFOragU.css +0 -1
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { createServer } from 'node:net';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
|
|
5
|
+
import { homedir, tmpdir } from 'node:os';
|
|
6
|
+
import { dirname, join, resolve } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
import { getDefaultCacheDir } from './lib/cache-paths.mjs';
|
|
10
|
+
|
|
11
|
+
const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
12
|
+
const cliPath = resolve(projectRoot, 'scripts/apiskill-cli.mjs');
|
|
13
|
+
const testRoot = await mkdtemp(join(tmpdir(), 'apiskill-shared-cache-'));
|
|
14
|
+
const cacheDir = resolve(testRoot, 'active-cache');
|
|
15
|
+
const defaultCacheDir = resolve(testRoot, 'default-cache');
|
|
16
|
+
const configDir = resolve(testRoot, 'config');
|
|
17
|
+
const port = await findFreePort();
|
|
18
|
+
let webProcess;
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
await mkdir(cacheDir, { recursive: true });
|
|
22
|
+
assert.equal(getDefaultCacheDir({ APISKILL_ROOT: '/tmp/another-project' }), resolve(homedir(), '.apiskill', 'cache'));
|
|
23
|
+
|
|
24
|
+
const created = JSON.parse(
|
|
25
|
+
await runCli(['document', 'create', '--title', 'Shared Cache Test', '--doc-version', '1.0.0', '--json']),
|
|
26
|
+
);
|
|
27
|
+
const versionId = created.meta.versionId;
|
|
28
|
+
await runCli([
|
|
29
|
+
'api',
|
|
30
|
+
'create',
|
|
31
|
+
'--version',
|
|
32
|
+
versionId,
|
|
33
|
+
'--config',
|
|
34
|
+
JSON.stringify({
|
|
35
|
+
api: {
|
|
36
|
+
method: 'get',
|
|
37
|
+
path: '/api/v1/from-cli',
|
|
38
|
+
summary: 'Created from CLI',
|
|
39
|
+
responses: [{ status: '200', description: 'Success' }],
|
|
40
|
+
},
|
|
41
|
+
}),
|
|
42
|
+
'--json',
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
webProcess = spawn(
|
|
46
|
+
process.execPath,
|
|
47
|
+
[cliPath, 'run', 'web', '--host', '127.0.0.1', '--port', String(port), '--strict-port', '--cwd', projectRoot],
|
|
48
|
+
{
|
|
49
|
+
cwd: projectRoot,
|
|
50
|
+
env: { ...process.env, APISKILL_CACHE_DIR: cacheDir, APISKILL_CONFIG_DIR: configDir },
|
|
51
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
52
|
+
},
|
|
53
|
+
);
|
|
54
|
+
const webOutput = collectProcessOutput(webProcess);
|
|
55
|
+
await waitForWeb(port, webProcess, webOutput);
|
|
56
|
+
|
|
57
|
+
const webCacheResponse = await fetch(`http://127.0.0.1:${port}/api/openapi/cache`);
|
|
58
|
+
assert.equal(webCacheResponse.status, 200);
|
|
59
|
+
const webCache = await webCacheResponse.json();
|
|
60
|
+
assert.equal(webCache.meta.versionId, versionId);
|
|
61
|
+
assert.ok(webCache.document.paths['/api/v1/from-cli']?.get, 'Web did not read the API created by CLI');
|
|
62
|
+
|
|
63
|
+
const webWriteResponse = await fetch(`http://127.0.0.1:${port}/api/openapi/custom-operation`, {
|
|
64
|
+
method: 'POST',
|
|
65
|
+
headers: { 'content-type': 'application/json' },
|
|
66
|
+
body: JSON.stringify({
|
|
67
|
+
versionId,
|
|
68
|
+
config: {
|
|
69
|
+
method: 'post',
|
|
70
|
+
path: '/api/v1/from-web',
|
|
71
|
+
summary: 'Created from Web',
|
|
72
|
+
responses: [{ status: '201', description: 'Created' }],
|
|
73
|
+
},
|
|
74
|
+
}),
|
|
75
|
+
});
|
|
76
|
+
assert.equal(webWriteResponse.status, 200, await webWriteResponse.text());
|
|
77
|
+
|
|
78
|
+
const cliRead = JSON.parse(await runCli(['api', 'query', 'POST', '/api/v1/from-web', '--version', versionId]));
|
|
79
|
+
assert.equal(cliRead.path, '/api/v1/from-web');
|
|
80
|
+
assert.equal(cliRead.method, 'post');
|
|
81
|
+
|
|
82
|
+
const check = JSON.parse(await runCli(['check', '--json']));
|
|
83
|
+
assert.equal(check.cacheDir, cacheDir);
|
|
84
|
+
|
|
85
|
+
const settingsResponse = await fetch(`http://127.0.0.1:${port}/api/cache-settings`, {
|
|
86
|
+
method: 'POST',
|
|
87
|
+
headers: { 'content-type': 'application/json' },
|
|
88
|
+
body: JSON.stringify({ cacheDir: defaultCacheDir }),
|
|
89
|
+
});
|
|
90
|
+
const settings = await settingsResponse.json();
|
|
91
|
+
assert.equal(settingsResponse.status, 200, JSON.stringify(settings));
|
|
92
|
+
assert.equal(settings.activeCacheDir, cacheDir);
|
|
93
|
+
assert.equal(settings.defaultCacheDir, defaultCacheDir);
|
|
94
|
+
assert.equal(settings.environmentOverride, true);
|
|
95
|
+
|
|
96
|
+
const syncResponse = await fetch(`http://127.0.0.1:${port}/api/openapi/version-sync-cache`, {
|
|
97
|
+
method: 'POST',
|
|
98
|
+
headers: { 'content-type': 'application/json' },
|
|
99
|
+
body: JSON.stringify({ versionId }),
|
|
100
|
+
});
|
|
101
|
+
const synced = await syncResponse.json();
|
|
102
|
+
assert.equal(syncResponse.status, 200, JSON.stringify(synced));
|
|
103
|
+
assert.equal(synced.targetCacheDir, defaultCacheDir);
|
|
104
|
+
assert.ok(existsSync(resolve(cacheDir, 'versions', `${versionId}.json`)), 'Sync removed the source document');
|
|
105
|
+
assert.ok(existsSync(resolve(defaultCacheDir, 'versions', `${versionId}.json`)), 'Sync did not create the target document');
|
|
106
|
+
|
|
107
|
+
const cliFromNewDefault = JSON.parse(
|
|
108
|
+
await runCli(['api', 'query', 'POST', '/api/v1/from-web'], { cacheOverride: false }),
|
|
109
|
+
);
|
|
110
|
+
assert.equal(cliFromNewDefault.path, '/api/v1/from-web');
|
|
111
|
+
console.log(`Shared cache test passed: Web and CLI synchronized ${versionId} from ${cacheDir} to ${defaultCacheDir}.`);
|
|
112
|
+
} finally {
|
|
113
|
+
if (webProcess && webProcess.exitCode === null) {
|
|
114
|
+
webProcess.kill('SIGTERM');
|
|
115
|
+
await new Promise((resolvePromise) => webProcess.once('close', resolvePromise));
|
|
116
|
+
}
|
|
117
|
+
await rm(testRoot, { recursive: true, force: true });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function runCli(args, { cacheOverride = true } = {}) {
|
|
121
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
122
|
+
const env = { ...process.env, APISKILL_CONFIG_DIR: configDir };
|
|
123
|
+
if (cacheOverride) env.APISKILL_CACHE_DIR = cacheDir;
|
|
124
|
+
else delete env.APISKILL_CACHE_DIR;
|
|
125
|
+
const child = spawn(process.execPath, [cliPath, ...args], {
|
|
126
|
+
cwd: projectRoot,
|
|
127
|
+
env,
|
|
128
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
129
|
+
});
|
|
130
|
+
const output = collectProcessOutput(child);
|
|
131
|
+
child.on('error', rejectPromise);
|
|
132
|
+
child.on('close', (code) => {
|
|
133
|
+
if (code === 0) resolvePromise(output.stdout);
|
|
134
|
+
else rejectPromise(new Error(`apiskill ${args.join(' ')} failed (${code}): ${output.stderr || output.stdout}`));
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function collectProcessOutput(child) {
|
|
140
|
+
const output = { stdout: '', stderr: '' };
|
|
141
|
+
child.stdout.setEncoding('utf8');
|
|
142
|
+
child.stderr.setEncoding('utf8');
|
|
143
|
+
child.stdout.on('data', (chunk) => {
|
|
144
|
+
output.stdout += chunk;
|
|
145
|
+
});
|
|
146
|
+
child.stderr.on('data', (chunk) => {
|
|
147
|
+
output.stderr += chunk;
|
|
148
|
+
});
|
|
149
|
+
return output;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function waitForWeb(webPort, child, output) {
|
|
153
|
+
const deadline = Date.now() + 15_000;
|
|
154
|
+
while (Date.now() < deadline) {
|
|
155
|
+
if (child.exitCode !== null) throw new Error(`Web process exited early (${child.exitCode}): ${output.stderr || output.stdout}`);
|
|
156
|
+
try {
|
|
157
|
+
const response = await fetch(`http://127.0.0.1:${webPort}/api/openapi/versions`);
|
|
158
|
+
if (response.ok) return;
|
|
159
|
+
} catch {}
|
|
160
|
+
await new Promise((resolvePromise) => setTimeout(resolvePromise, 100));
|
|
161
|
+
}
|
|
162
|
+
throw new Error(`Timed out waiting for Web: ${output.stderr || output.stdout}`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function findFreePort() {
|
|
166
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
167
|
+
const server = createServer();
|
|
168
|
+
server.once('error', rejectPromise);
|
|
169
|
+
server.listen(0, '127.0.0.1', () => {
|
|
170
|
+
const address = server.address();
|
|
171
|
+
const freePort = typeof address === 'object' && address ? address.port : 0;
|
|
172
|
+
server.close((error) => (error ? rejectPromise(error) : resolvePromise(freePort)));
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
}
|
package/src/App.tsx
CHANGED
|
@@ -44,9 +44,12 @@ import {
|
|
|
44
44
|
VERSION_DELETE_API_URL,
|
|
45
45
|
VERSION_META_API_URL,
|
|
46
46
|
VERSION_SELECT_API_URL,
|
|
47
|
+
VERSION_SYNC_CACHE_API_URL,
|
|
47
48
|
VERSIONS_API_URL,
|
|
48
49
|
} from './swagger';
|
|
49
50
|
import { AddApiDialog } from './AddApiDialog';
|
|
51
|
+
import { CacheSettingsDialog, loadCacheSettings } from './CacheSettingsDialog';
|
|
52
|
+
import type { CacheSettings } from './CacheSettingsDialog';
|
|
50
53
|
import { DocumentVersionManager } from './DocumentVersionManager';
|
|
51
54
|
import type { DocumentVersionMeta } from './DocumentVersionManager';
|
|
52
55
|
import type { ManualApiOperationConfig } from './manualApiConfig';
|
|
@@ -125,6 +128,16 @@ type VersionDeleteResponse = {
|
|
|
125
128
|
nextVersionId?: string;
|
|
126
129
|
};
|
|
127
130
|
|
|
131
|
+
type VersionCacheSyncResponse = {
|
|
132
|
+
status: 'synced' | 'already-current';
|
|
133
|
+
versionId: string;
|
|
134
|
+
sourceCacheDir: string;
|
|
135
|
+
sourceDocumentPath: string;
|
|
136
|
+
targetCacheDir: string;
|
|
137
|
+
targetDocumentPath: string;
|
|
138
|
+
syncedAt?: string;
|
|
139
|
+
};
|
|
140
|
+
|
|
128
141
|
type AuthCheckResponse = {
|
|
129
142
|
requiresAuth: boolean;
|
|
130
143
|
status?: number;
|
|
@@ -220,6 +233,7 @@ export function App() {
|
|
|
220
233
|
const [editingApiTarget, setEditingApiTarget] = useState<{ method: HttpMethod; path: string } | undefined>(undefined);
|
|
221
234
|
const [operationLinkDrafts, setOperationLinkDrafts] = useState<Record<string, EndpointAssociationLinks>>({});
|
|
222
235
|
const [savingApi, setSavingApi] = useState(false);
|
|
236
|
+
const [cacheSettings, setCacheSettings] = useState<CacheSettings | undefined>(undefined);
|
|
223
237
|
const uploadInputRef = useRef<HTMLInputElement | null>(null);
|
|
224
238
|
|
|
225
239
|
useEffect(() => {
|
|
@@ -236,6 +250,7 @@ export function App() {
|
|
|
236
250
|
|
|
237
251
|
loadInitialCache();
|
|
238
252
|
void loadMockStatus();
|
|
253
|
+
void loadCacheSettings().then(setCacheSettings).catch(() => {});
|
|
239
254
|
}, []);
|
|
240
255
|
|
|
241
256
|
const endpoints = useMemo(() => (doc ? listEndpoints(doc) : []), [doc]);
|
|
@@ -756,6 +771,17 @@ export function App() {
|
|
|
756
771
|
setLoadState('ready');
|
|
757
772
|
}
|
|
758
773
|
|
|
774
|
+
async function syncVersionToDefaultCache(versionId: string) {
|
|
775
|
+
const response = await fetch(VERSION_SYNC_CACHE_API_URL, {
|
|
776
|
+
method: 'POST',
|
|
777
|
+
headers: { 'content-type': 'application/json' },
|
|
778
|
+
body: JSON.stringify({ versionId }),
|
|
779
|
+
});
|
|
780
|
+
const payload = await response.json();
|
|
781
|
+
if (!response.ok) throw new Error(payload.error || '文档同步到默认缓存地址失败');
|
|
782
|
+
return payload as VersionCacheSyncResponse;
|
|
783
|
+
}
|
|
784
|
+
|
|
759
785
|
async function loadMockStatus() {
|
|
760
786
|
try {
|
|
761
787
|
const response = await fetch(MOCK_STATUS_API_URL, { cache: 'no-store' });
|
|
@@ -940,6 +966,8 @@ export function App() {
|
|
|
940
966
|
onPrepareUpdate={prepareVersionUpdate}
|
|
941
967
|
onUpdateEnvironment={updateVersionEnvironment}
|
|
942
968
|
onDelete={deleteVersion}
|
|
969
|
+
cacheSettings={cacheSettings}
|
|
970
|
+
onSyncCache={syncVersionToDefaultCache}
|
|
943
971
|
/>
|
|
944
972
|
<button
|
|
945
973
|
className="primary-button"
|
|
@@ -957,6 +985,7 @@ export function App() {
|
|
|
957
985
|
<span>Schema {stats.schemas}</span>
|
|
958
986
|
<span>{lastSync ? `同步 ${formatDate(lastSync)}` : '未加载文档'}</span>
|
|
959
987
|
</div>
|
|
988
|
+
<CacheSettingsDialog settings={cacheSettings} onSettingsChange={setCacheSettings} />
|
|
960
989
|
</div>
|
|
961
990
|
</header>
|
|
962
991
|
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { AlertCircle, FolderCog, RotateCcw, Save, X } from 'lucide-react';
|
|
3
|
+
import { CACHE_SETTINGS_API_URL } from './swagger';
|
|
4
|
+
|
|
5
|
+
export type CacheSettings = {
|
|
6
|
+
activeCacheDir: string;
|
|
7
|
+
defaultCacheDir: string;
|
|
8
|
+
configuredCacheDir: string;
|
|
9
|
+
builtInCacheDir: string;
|
|
10
|
+
environmentCacheDir: string;
|
|
11
|
+
environmentOverride: boolean;
|
|
12
|
+
effectiveNextCacheDir: string;
|
|
13
|
+
source: 'environment' | 'config' | 'default';
|
|
14
|
+
configPath: string;
|
|
15
|
+
restartRequired: boolean;
|
|
16
|
+
activeMatchesDefault: boolean;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function CacheSettingsDialog({
|
|
20
|
+
settings,
|
|
21
|
+
onSettingsChange,
|
|
22
|
+
}: {
|
|
23
|
+
settings?: CacheSettings;
|
|
24
|
+
onSettingsChange: (settings: CacheSettings) => void;
|
|
25
|
+
}) {
|
|
26
|
+
const [open, setOpen] = useState(false);
|
|
27
|
+
const [cacheDir, setCacheDir] = useState('');
|
|
28
|
+
const [loading, setLoading] = useState(false);
|
|
29
|
+
const [message, setMessage] = useState('');
|
|
30
|
+
const [error, setError] = useState('');
|
|
31
|
+
|
|
32
|
+
async function openDialog() {
|
|
33
|
+
setOpen(true);
|
|
34
|
+
setMessage('');
|
|
35
|
+
setError('');
|
|
36
|
+
setLoading(true);
|
|
37
|
+
try {
|
|
38
|
+
const next = await loadCacheSettings();
|
|
39
|
+
onSettingsChange(next);
|
|
40
|
+
setCacheDir(next.defaultCacheDir);
|
|
41
|
+
} catch (loadError) {
|
|
42
|
+
setError(loadError instanceof Error ? loadError.message : '缓存设置读取失败');
|
|
43
|
+
} finally {
|
|
44
|
+
setLoading(false);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function saveSettings(reset = false) {
|
|
49
|
+
setLoading(true);
|
|
50
|
+
setMessage('');
|
|
51
|
+
setError('');
|
|
52
|
+
try {
|
|
53
|
+
const response = await fetch(CACHE_SETTINGS_API_URL, {
|
|
54
|
+
method: 'POST',
|
|
55
|
+
headers: { 'content-type': 'application/json' },
|
|
56
|
+
body: JSON.stringify(reset ? { reset: true } : { cacheDir }),
|
|
57
|
+
});
|
|
58
|
+
const payload = await response.json();
|
|
59
|
+
if (!response.ok) throw new Error(payload.error || '缓存设置保存失败');
|
|
60
|
+
const next = payload as CacheSettings;
|
|
61
|
+
onSettingsChange(next);
|
|
62
|
+
setCacheDir(next.defaultCacheDir);
|
|
63
|
+
setMessage(reset ? '已恢复内置默认地址,现有缓存文件未改动' : '默认缓存地址已保存,现有缓存文件未改动');
|
|
64
|
+
} catch (saveError) {
|
|
65
|
+
setError(saveError instanceof Error ? saveError.message : '缓存设置保存失败');
|
|
66
|
+
} finally {
|
|
67
|
+
setLoading(false);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<>
|
|
73
|
+
<button className="secondary-button cache-settings-trigger" onClick={openDialog} title="设置接口文档默认缓存地址">
|
|
74
|
+
<FolderCog size={16} />
|
|
75
|
+
缓存设置
|
|
76
|
+
</button>
|
|
77
|
+
|
|
78
|
+
{open ? (
|
|
79
|
+
<div className="modal-backdrop" role="dialog" aria-modal="true" aria-labelledby="cache-settings-title">
|
|
80
|
+
<div className="cache-settings-dialog">
|
|
81
|
+
<div className="import-dialog-header">
|
|
82
|
+
<div>
|
|
83
|
+
<p className="eyebrow">Cache Settings</p>
|
|
84
|
+
<h2 id="cache-settings-title">接口文档缓存设置</h2>
|
|
85
|
+
</div>
|
|
86
|
+
<button className="icon-secondary-button" onClick={() => setOpen(false)} aria-label="关闭">
|
|
87
|
+
<X size={17} />
|
|
88
|
+
</button>
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
<div className="cache-settings-body">
|
|
92
|
+
{error ? <div className="version-message error">{error}</div> : null}
|
|
93
|
+
{message ? <div className="version-message">{message}</div> : null}
|
|
94
|
+
|
|
95
|
+
<div className="cache-location-row">
|
|
96
|
+
<span>当前会话缓存位置</span>
|
|
97
|
+
<code>{settings?.activeCacheDir || (loading ? '读取中...' : '-')}</code>
|
|
98
|
+
</div>
|
|
99
|
+
|
|
100
|
+
<label className="cache-settings-field">
|
|
101
|
+
默认缓存地址
|
|
102
|
+
<input
|
|
103
|
+
value={cacheDir}
|
|
104
|
+
onChange={(event) => setCacheDir(event.target.value)}
|
|
105
|
+
placeholder="例如 ~/.apiskill/cache"
|
|
106
|
+
autoComplete="off"
|
|
107
|
+
disabled={loading}
|
|
108
|
+
/>
|
|
109
|
+
</label>
|
|
110
|
+
|
|
111
|
+
<div className="cache-settings-notice">
|
|
112
|
+
<AlertCircle size={18} />
|
|
113
|
+
<div>
|
|
114
|
+
<strong>修改地址不会移动或删除已有缓存文件</strong>
|
|
115
|
+
<p>新的 CLI、MCP 进程会读取该默认地址;Web 端重启后切换。需要保留的版本可在版本管理中使用“更新缓存地址”复制过去。</p>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
{settings?.environmentOverride ? (
|
|
120
|
+
<div className="cache-settings-notice warning">
|
|
121
|
+
<AlertCircle size={18} />
|
|
122
|
+
<div>
|
|
123
|
+
<strong>环境变量正在覆盖默认设置</strong>
|
|
124
|
+
<p>当前 `APISKILL_CACHE_DIR` 为 {settings.environmentCacheDir}。只要该变量存在,重启后仍优先使用它。</p>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
) : null}
|
|
128
|
+
|
|
129
|
+
<dl className="cache-settings-details">
|
|
130
|
+
<div>
|
|
131
|
+
<dt>内置默认地址</dt>
|
|
132
|
+
<dd>{settings?.builtInCacheDir || '-'}</dd>
|
|
133
|
+
</div>
|
|
134
|
+
<div>
|
|
135
|
+
<dt>配置文件</dt>
|
|
136
|
+
<dd>{settings?.configPath || '-'}</dd>
|
|
137
|
+
</div>
|
|
138
|
+
</dl>
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
<div className="import-dialog-actions cache-settings-actions">
|
|
142
|
+
<button className="secondary-button" onClick={() => void saveSettings(true)} disabled={loading}>
|
|
143
|
+
<RotateCcw size={16} />
|
|
144
|
+
恢复默认
|
|
145
|
+
</button>
|
|
146
|
+
<div>
|
|
147
|
+
<button className="secondary-button" onClick={() => setOpen(false)} disabled={loading}>取消</button>
|
|
148
|
+
<button className="primary-button" onClick={() => void saveSettings(false)} disabled={loading || !cacheDir.trim()}>
|
|
149
|
+
<Save size={16} />
|
|
150
|
+
{loading ? '保存中' : '保存设置'}
|
|
151
|
+
</button>
|
|
152
|
+
</div>
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
) : null}
|
|
157
|
+
</>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function loadCacheSettings(): Promise<CacheSettings> {
|
|
162
|
+
const response = await fetch(CACHE_SETTINGS_API_URL, { cache: 'no-store' });
|
|
163
|
+
const payload = await response.json();
|
|
164
|
+
if (!response.ok) throw new Error(payload.error || '缓存设置读取失败');
|
|
165
|
+
return payload as CacheSettings;
|
|
166
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useEffect, useMemo, useState } from 'react';
|
|
2
|
-
import { Check, Download, Pencil, RefreshCcw, Server, Trash2, X } from 'lucide-react';
|
|
2
|
+
import { Check, Download, FolderSync, Pencil, RefreshCcw, Server, Trash2, X } from 'lucide-react';
|
|
3
|
+
import type { CacheSettings } from './CacheSettingsDialog';
|
|
3
4
|
import { VERSION_EXPORT_API_URL } from './swagger';
|
|
4
5
|
|
|
5
6
|
export type DocumentVersionMeta = {
|
|
@@ -14,6 +15,7 @@ export type DocumentVersionMeta = {
|
|
|
14
15
|
schemas?: number;
|
|
15
16
|
environmentName?: string;
|
|
16
17
|
environmentBaseUrl?: string;
|
|
18
|
+
savedPath?: string;
|
|
17
19
|
};
|
|
18
20
|
|
|
19
21
|
export function DocumentVersionManager({
|
|
@@ -25,6 +27,8 @@ export function DocumentVersionManager({
|
|
|
25
27
|
onPrepareUpdate,
|
|
26
28
|
onUpdateEnvironment,
|
|
27
29
|
onDelete,
|
|
30
|
+
cacheSettings,
|
|
31
|
+
onSyncCache,
|
|
28
32
|
}: {
|
|
29
33
|
versions: DocumentVersionMeta[];
|
|
30
34
|
selectedVersionId: string;
|
|
@@ -34,6 +38,12 @@ export function DocumentVersionManager({
|
|
|
34
38
|
onPrepareUpdate: (version: DocumentVersionMeta) => Promise<void> | void;
|
|
35
39
|
onUpdateEnvironment: (versionId: string, environmentName: string, environmentBaseUrl: string) => Promise<void>;
|
|
36
40
|
onDelete: (versionId: string) => Promise<void>;
|
|
41
|
+
cacheSettings?: CacheSettings;
|
|
42
|
+
onSyncCache: (versionId: string) => Promise<{
|
|
43
|
+
status: 'synced' | 'already-current';
|
|
44
|
+
targetCacheDir: string;
|
|
45
|
+
targetDocumentPath: string;
|
|
46
|
+
}>;
|
|
37
47
|
}) {
|
|
38
48
|
const [open, setOpen] = useState(false);
|
|
39
49
|
const [editingVersionId, setEditingVersionId] = useState('');
|
|
@@ -118,6 +128,38 @@ export function DocumentVersionManager({
|
|
|
118
128
|
document.body.removeChild(anchor);
|
|
119
129
|
}
|
|
120
130
|
|
|
131
|
+
async function syncVersionCache(version: DocumentVersionMeta) {
|
|
132
|
+
const versionId = version.versionId;
|
|
133
|
+
if (!versionId) return;
|
|
134
|
+
if (!cacheSettings) {
|
|
135
|
+
setMessage('缓存设置尚未读取,请稍后重试');
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const sourceLocation = version.savedPath || cacheSettings.activeCacheDir;
|
|
140
|
+
const targetLocation = cacheSettings.defaultCacheDir;
|
|
141
|
+
if (cacheSettings.activeMatchesDefault) {
|
|
142
|
+
window.alert(`当前缓存位置:\n${sourceLocation}\n\n该版本已位于默认缓存地址:\n${targetLocation}`);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const confirmed = window.confirm(
|
|
147
|
+
`当前缓存位置:\n${sourceLocation}\n\n默认缓存地址:\n${targetLocation}\n\n确认将该文档版本复制到默认缓存地址吗?源文件不会被移动或删除;目标中同版本内容将被更新。`,
|
|
148
|
+
);
|
|
149
|
+
if (!confirmed) return;
|
|
150
|
+
|
|
151
|
+
setBusyVersionId(versionId);
|
|
152
|
+
setMessage('');
|
|
153
|
+
try {
|
|
154
|
+
const result = await onSyncCache(versionId);
|
|
155
|
+
setMessage(result.status === 'already-current' ? '该版本已位于默认缓存地址' : `已同步到 ${result.targetDocumentPath}`);
|
|
156
|
+
} catch (error) {
|
|
157
|
+
setMessage(error instanceof Error ? error.message : '更新缓存地址失败');
|
|
158
|
+
} finally {
|
|
159
|
+
setBusyVersionId('');
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
121
163
|
return (
|
|
122
164
|
<div className="version-manager">
|
|
123
165
|
<button className="version-manager-trigger" onClick={() => setOpen(true)} disabled={disabled || !versions.length}>
|
|
@@ -231,6 +273,10 @@ export function DocumentVersionManager({
|
|
|
231
273
|
<Download size={15} />
|
|
232
274
|
导出
|
|
233
275
|
</button>
|
|
276
|
+
<button className="secondary-button" onClick={() => void syncVersionCache(version)} disabled={busy || !versionId}>
|
|
277
|
+
<FolderSync size={15} />
|
|
278
|
+
更新缓存地址
|
|
279
|
+
</button>
|
|
234
280
|
<button className="danger-button" onClick={() => deleteVersion(version)} disabled={busy || !versionId}>
|
|
235
281
|
<Trash2 size={15} />
|
|
236
282
|
删除
|
package/src/styles.css
CHANGED
|
@@ -75,7 +75,10 @@ p {
|
|
|
75
75
|
.topbar-actions {
|
|
76
76
|
display: flex;
|
|
77
77
|
align-items: center;
|
|
78
|
+
justify-content: flex-end;
|
|
79
|
+
flex-wrap: wrap;
|
|
78
80
|
gap: 14px;
|
|
81
|
+
min-width: 0;
|
|
79
82
|
}
|
|
80
83
|
|
|
81
84
|
.version-switcher {
|
|
@@ -1542,6 +1545,110 @@ td code {
|
|
|
1542
1545
|
box-shadow: 0 18px 60px rgba(18, 25, 35, 0.22);
|
|
1543
1546
|
}
|
|
1544
1547
|
|
|
1548
|
+
.cache-settings-dialog {
|
|
1549
|
+
width: min(680px, 100%);
|
|
1550
|
+
max-height: calc(100vh - 40px);
|
|
1551
|
+
overflow: auto;
|
|
1552
|
+
border: 1px solid #d7dde4;
|
|
1553
|
+
border-radius: 8px;
|
|
1554
|
+
background: #fff;
|
|
1555
|
+
box-shadow: 0 18px 60px rgba(18, 25, 35, 0.22);
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
.cache-settings-body {
|
|
1559
|
+
display: grid;
|
|
1560
|
+
gap: 16px;
|
|
1561
|
+
padding: 18px 20px;
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
.cache-location-row,
|
|
1565
|
+
.cache-settings-details > div {
|
|
1566
|
+
display: grid;
|
|
1567
|
+
grid-template-columns: 150px minmax(0, 1fr);
|
|
1568
|
+
align-items: start;
|
|
1569
|
+
gap: 14px;
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
.cache-location-row > span,
|
|
1573
|
+
.cache-settings-details dt {
|
|
1574
|
+
color: #657181;
|
|
1575
|
+
font-size: 12px;
|
|
1576
|
+
font-weight: 800;
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
.cache-location-row code,
|
|
1580
|
+
.cache-settings-details dd {
|
|
1581
|
+
min-width: 0;
|
|
1582
|
+
margin: 0;
|
|
1583
|
+
overflow-wrap: anywhere;
|
|
1584
|
+
color: #1d2a36;
|
|
1585
|
+
font-size: 12px;
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
.cache-settings-field {
|
|
1589
|
+
display: grid;
|
|
1590
|
+
gap: 7px;
|
|
1591
|
+
color: #334152;
|
|
1592
|
+
font-size: 12px;
|
|
1593
|
+
font-weight: 800;
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
.cache-settings-field input {
|
|
1597
|
+
width: 100%;
|
|
1598
|
+
min-height: 40px;
|
|
1599
|
+
padding: 0 11px;
|
|
1600
|
+
border: 1px solid #bdc8d4;
|
|
1601
|
+
border-radius: 6px;
|
|
1602
|
+
outline: 0;
|
|
1603
|
+
color: #15202c;
|
|
1604
|
+
background: #fff;
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
.cache-settings-field input:focus {
|
|
1608
|
+
border-color: #1b6b65;
|
|
1609
|
+
box-shadow: 0 0 0 3px rgba(27, 107, 101, 0.12);
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
.cache-settings-notice {
|
|
1613
|
+
display: grid;
|
|
1614
|
+
grid-template-columns: auto minmax(0, 1fr);
|
|
1615
|
+
gap: 10px;
|
|
1616
|
+
padding: 12px 0;
|
|
1617
|
+
border-block: 1px solid #dbe7e4;
|
|
1618
|
+
color: #285c55;
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
.cache-settings-notice.warning {
|
|
1622
|
+
border-color: #ead9ad;
|
|
1623
|
+
color: #765b16;
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
.cache-settings-notice strong {
|
|
1627
|
+
font-size: 13px;
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
.cache-settings-notice p {
|
|
1631
|
+
margin: 4px 0 0;
|
|
1632
|
+
color: #657181;
|
|
1633
|
+
font-size: 12px;
|
|
1634
|
+
line-height: 1.55;
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
.cache-settings-details {
|
|
1638
|
+
display: grid;
|
|
1639
|
+
gap: 9px;
|
|
1640
|
+
margin: 0;
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
.cache-settings-actions {
|
|
1644
|
+
justify-content: space-between;
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
.cache-settings-actions > div {
|
|
1648
|
+
display: flex;
|
|
1649
|
+
gap: 8px;
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1545
1652
|
.api-config-dialog {
|
|
1546
1653
|
width: 80vw;
|
|
1547
1654
|
min-width: 1000px;
|
|
@@ -2260,6 +2367,18 @@ td code {
|
|
|
2260
2367
|
gap: 8px;
|
|
2261
2368
|
}
|
|
2262
2369
|
|
|
2370
|
+
.cache-location-row,
|
|
2371
|
+
.cache-settings-details > div {
|
|
2372
|
+
grid-template-columns: 1fr;
|
|
2373
|
+
gap: 5px;
|
|
2374
|
+
}
|
|
2375
|
+
|
|
2376
|
+
.cache-settings-actions,
|
|
2377
|
+
.cache-settings-actions > div {
|
|
2378
|
+
display: grid;
|
|
2379
|
+
width: 100%;
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2263
2382
|
.endpoint-title-line {
|
|
2264
2383
|
align-items: flex-start;
|
|
2265
2384
|
flex-direction: column;
|
package/src/swagger.ts
CHANGED
|
@@ -19,6 +19,8 @@ export const VERSION_SELECT_API_URL = '/api/openapi/version-select';
|
|
|
19
19
|
export const VERSION_META_API_URL = '/api/openapi/version-meta';
|
|
20
20
|
export const VERSION_DELETE_API_URL = '/api/openapi/version-delete';
|
|
21
21
|
export const VERSION_EXPORT_API_URL = '/api/openapi/version-export';
|
|
22
|
+
export const VERSION_SYNC_CACHE_API_URL = '/api/openapi/version-sync-cache';
|
|
23
|
+
export const CACHE_SETTINGS_API_URL = '/api/cache-settings';
|
|
22
24
|
export const MOCK_START_API_URL = '/api/mock/start';
|
|
23
25
|
export const MOCK_STATUS_API_URL = '/api/mock/status';
|
|
24
26
|
export const MOCK_STOP_API_URL = '/api/mock/stop';
|