fix: 修复重新导入的模块数缺失、名称被覆盖与 URL 抓取报错
- 项目详情接口 _count 漏 select modules,重新导入弹框因此显示「undefined 个模块」 - 重新导入不再用文档中的 name/description 覆盖用户自行设置的项目名称与描述 - URL 抓取失败原因此前被完全吞掉,只剩无信息量的 502:前端改为预判混合内容、 跳过必然超时的内网服务端代理、直连加超时,并抛出带错误码的 SpecFetchError - 服务端代理加 15s 超时并始终返回 JSON,不再挂到 nginx 超时返回 HTML 错误页 - 新增 SpecFetchErrorNotice 组件,跨域类失败时可展开具体的 CORS 配置引导 - nginx /api/ 补 client_max_body_size 12m,避免大文档在 nginx 层被 413 挡掉 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,26 +4,47 @@ import { requireAuth } from '../middleware/auth.js';
|
|||||||
const router: RouterType = Router();
|
const router: RouterType = Router();
|
||||||
router.use(requireAuth);
|
router.use(requireAuth);
|
||||||
|
|
||||||
// CORS proxy: frontend calls this when direct fetch is blocked by CORS
|
// 服务端兜底抓取:只有当浏览器直连被 CORS / 混合内容拦截时前端才会调用这里。
|
||||||
|
// 目标地址若是内网 / VPN 资源,服务器通常访问不到,必须快速失败并返回 JSON,
|
||||||
|
// 否则请求会挂到 nginx 超时,前端只能看到一个没有信息量的 502/504 HTML 页面。
|
||||||
|
const FETCH_TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
const specUrl = req.query.url as string;
|
const specUrl = req.query.url as string;
|
||||||
if (!specUrl || !specUrl.startsWith('http')) {
|
let target: URL;
|
||||||
res.status(400).json({ success: false, error: { code: 'VALIDATION', message: 'Provide a valid URL' } });
|
try {
|
||||||
|
target = new URL(specUrl);
|
||||||
|
if (target.protocol !== 'http:' && target.protocol !== 'https:') throw new Error('bad protocol');
|
||||||
|
} catch {
|
||||||
|
res.status(400).json({ success: false, error: { code: 'VALIDATION', message: 'Provide a valid http(s) URL' } });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(specUrl, {
|
const response = await fetch(target.toString(), {
|
||||||
headers: { Accept: 'application/json, application/yaml, text/yaml, text/plain, */*' },
|
headers: { Accept: 'application/json, application/yaml, text/yaml, text/plain, */*' },
|
||||||
|
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
res.status(502).json({ success: false, error: { code: 'FETCH_FAILED', message: `Remote server returned ${response.status}` } });
|
res.status(502).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: 'FETCH_FAILED', message: `Remote server returned ${response.status}` },
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
res.json({ success: true, data: { content: text, contentType: response.headers.get('content-type') || '' } });
|
res.json({ success: true, data: { content: text, contentType: response.headers.get('content-type') || '' } });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(502).json({ success: false, error: { code: 'FETCH_FAILED', message: err instanceof Error ? err.message : 'Failed to fetch URL' } });
|
const timedOut = err instanceof Error && (err.name === 'TimeoutError' || err.name === 'AbortError');
|
||||||
|
res.status(502).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: timedOut ? 'FETCH_TIMEOUT' : 'FETCH_FAILED',
|
||||||
|
message: timedOut
|
||||||
|
? `Timed out after ${FETCH_TIMEOUT_MS / 1000}s — the server cannot reach ${target.host}`
|
||||||
|
: `${err instanceof Error ? err.message : 'Failed to fetch URL'} (${target.host})`,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -29,10 +29,11 @@ router.post('/:id/reimport', async (req, res) => {
|
|||||||
await tx.endpoint.deleteMany({ where: { projectId: project.id } });
|
await tx.endpoint.deleteMany({ where: { projectId: project.id } });
|
||||||
await tx.module.deleteMany({ where: { projectId: project.id } });
|
await tx.module.deleteMany({ where: { projectId: project.id } });
|
||||||
|
|
||||||
|
// 只更新文档本体,项目名称与描述由用户自行维护,重新导入不覆盖
|
||||||
await tx.project.update({
|
await tx.project.update({
|
||||||
where: { id: project.id },
|
where: { id: project.id },
|
||||||
data: {
|
data: {
|
||||||
name: parsed.name, description: parsed.description, baseUrl: parsed.baseUrl,
|
baseUrl: parsed.baseUrl,
|
||||||
openApiSpec: parsed.spec as any, openApiVersion: parsed.openApiVersion,
|
openApiSpec: parsed.spec as any, openApiVersion: parsed.openApiVersion,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ router.get('/:id', async (req, res) => {
|
|||||||
include: { _count: { select: { endpoints: true } } },
|
include: { _count: { select: { endpoints: true } } },
|
||||||
orderBy: { sortOrder: 'asc' },
|
orderBy: { sortOrder: 'asc' },
|
||||||
},
|
},
|
||||||
_count: { select: { endpoints: true } },
|
_count: { select: { endpoints: true, modules: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!project) {
|
if (!project) {
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ server {
|
|||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
|
# OpenAPI 文档整份提交,默认 1m 上限会被大文档打爆(server 侧允许 10mb)
|
||||||
|
client_max_body_size 12m;
|
||||||
|
proxy_read_timeout 120s;
|
||||||
proxy_pass http://server:3000;
|
proxy_pass http://server:3000;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
|||||||
68
packages/web/src/components/SpecFetchErrorNotice.tsx
Normal file
68
packages/web/src/components/SpecFetchErrorNotice.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { SpecFetchError, describeSpecFetchError } from '../lib/fetch-spec';
|
||||||
|
import { useI18n } from '../lib/i18n';
|
||||||
|
|
||||||
|
/** 这些失败都卡在浏览器的跨域策略上,光看报错用户无从下手,需要给出具体配置 */
|
||||||
|
const FIXABLE_BY_CONFIG: ReadonlySet<string> = new Set(['blocked', 'mixedContent', 'privateNetwork']);
|
||||||
|
|
||||||
|
function corsSnippet(origin: string): string {
|
||||||
|
return [
|
||||||
|
'# Response headers',
|
||||||
|
`Access-Control-Allow-Origin: ${origin}`,
|
||||||
|
'Access-Control-Allow-Private-Network: true',
|
||||||
|
'',
|
||||||
|
'# Nginx',
|
||||||
|
`add_header Access-Control-Allow-Origin "${origin}" always;`,
|
||||||
|
'add_header Access-Control-Allow-Private-Network "true" always;',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 导入失败提示:错误原因 + 可展开的跨域配置引导 */
|
||||||
|
export default function SpecFetchErrorNotice({ error }: { error: unknown }) {
|
||||||
|
const [showHelp, setShowHelp] = useState(false);
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
if (!error) return null;
|
||||||
|
|
||||||
|
const code = error instanceof SpecFetchError ? error.code : '';
|
||||||
|
const helpful = FIXABLE_BY_CONFIG.has(code);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-3 rounded-lg bg-danger-muted space-y-2">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<svg className="w-4 h-4 text-danger shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<path d="M15 9l-6 6m0-6l6 6" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-danger text-[13px]">{describeSpecFetchError(error, t)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{helpful && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowHelp((v) => !v)}
|
||||||
|
className="text-[12px] text-danger underline underline-offset-2 hover:opacity-80"
|
||||||
|
>
|
||||||
|
{showHelp ? t('common.specFetch.helpHide') : t('common.specFetch.helpShow')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showHelp && (
|
||||||
|
<div className="text-[12px] text-text-secondary space-y-2 pt-1">
|
||||||
|
<p>{t('common.specFetch.helpIntro')}</p>
|
||||||
|
<ol className="list-decimal pl-4 space-y-2">
|
||||||
|
<li>
|
||||||
|
<p>{t('common.specFetch.helpCors')}</p>
|
||||||
|
<pre className="mt-1.5 p-2 rounded-md bg-bg-tertiary text-[11px] leading-relaxed text-text-primary overflow-x-auto select-all">
|
||||||
|
{corsSnippet(window.location.origin)}
|
||||||
|
</pre>
|
||||||
|
</li>
|
||||||
|
<li>{t('common.specFetch.helpMixed')}</li>
|
||||||
|
<li>{t('common.specFetch.helpFallback')}</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,32 +1,129 @@
|
|||||||
import yaml from 'js-yaml';
|
import yaml from 'js-yaml';
|
||||||
import { apiFetch } from './api';
|
import { apiFetch } from './api';
|
||||||
|
import type { TFunction } from './i18n';
|
||||||
|
|
||||||
|
const DIRECT_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
|
export type SpecFetchCode = 'invalidUrl' | 'mixedContent' | 'privateNetwork' | 'blocked' | 'httpStatus' | 'parse';
|
||||||
|
|
||||||
|
/** 带错误码的抓取失败,供界面翻译成可操作的提示 */
|
||||||
|
export class SpecFetchError extends Error {
|
||||||
|
code: SpecFetchCode;
|
||||||
|
detail: string;
|
||||||
|
proxyDetail: string;
|
||||||
|
|
||||||
|
constructor(code: SpecFetchCode, detail = '', proxyDetail = '') {
|
||||||
|
super(`spec fetch failed: ${code}${detail ? ` (${detail})` : ''}`);
|
||||||
|
this.name = 'SpecFetchError';
|
||||||
|
this.code = code;
|
||||||
|
this.detail = detail;
|
||||||
|
this.proxyDetail = proxyDetail;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function parseSpecText(text: string): object {
|
function parseSpecText(text: string): object {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed) throw new SpecFetchError('parse', 'empty response');
|
||||||
try {
|
try {
|
||||||
return JSON.parse(text);
|
return JSON.parse(trimmed);
|
||||||
} catch {
|
} catch {
|
||||||
return yaml.load(text) as object;
|
// 不是 JSON,继续按 YAML 解析
|
||||||
}
|
}
|
||||||
|
let loaded: unknown;
|
||||||
|
try {
|
||||||
|
loaded = yaml.load(trimmed);
|
||||||
|
} catch (err) {
|
||||||
|
throw new SpecFetchError('parse', err instanceof Error ? err.message.split('\n')[0] : 'invalid YAML');
|
||||||
|
}
|
||||||
|
if (!loaded || typeof loaded !== 'object') {
|
||||||
|
throw new SpecFetchError('parse', trimmed.slice(0, 60));
|
||||||
|
}
|
||||||
|
return loaded as object;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLoopback(hostname: string): boolean {
|
||||||
|
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname === '::1';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 私有网段 / 本机 / 无域名后缀的主机名——只有用户的浏览器所在网络能访问到,服务端代理跑一趟必然超时 */
|
||||||
|
function isPrivateHost(hostname: string): boolean {
|
||||||
|
const host = hostname.replace(/^\[|\]$/g, '').toLowerCase();
|
||||||
|
if (isLoopback(host)) return true;
|
||||||
|
if (host.endsWith('.local') || host.endsWith('.internal') || host.endsWith('.lan')) return true;
|
||||||
|
if (host.startsWith('fc') || host.startsWith('fd') || host.startsWith('fe80:')) return true; // IPv6 ULA / link-local
|
||||||
|
const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
||||||
|
if (v4) {
|
||||||
|
const [a, b] = [Number(v4[1]), Number(v4[2])];
|
||||||
|
return a === 10 || a === 127 || (a === 192 && b === 168) || (a === 172 && b >= 16 && b <= 31) || (a === 169 && b === 254);
|
||||||
|
}
|
||||||
|
// 单段主机名(如 http://gateway/openapi.json)只在内网 DNS 里能解析
|
||||||
|
return !host.includes('.');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch an OpenAPI spec from a URL and parse it.
|
* 从 URL 抓取 OpenAPI 文档。
|
||||||
* 1. Try direct fetch from browser (works for localhost/intranet)
|
*
|
||||||
* 2. If CORS blocks it, fall back to server-side proxy
|
* 1. 优先由浏览器直连抓取——用户的文档常位于内网或需要 VPN,只有浏览器所在的网络环境能访问到。
|
||||||
* Returns a parsed spec object (JSON or YAML).
|
* 2. 浏览器被拦截(CORS / 混合内容)时才退回服务端代理,服务端通常访问不到内网地址。
|
||||||
|
* 3. 两条路都失败时抛出带错误码的 SpecFetchError,界面据此告诉用户到底卡在哪一步。
|
||||||
*/
|
*/
|
||||||
export async function fetchSpecFromUrl(url: string): Promise<object> {
|
export async function fetchSpecFromUrl(url: string): Promise<object> {
|
||||||
// Try direct fetch first (handles localhost, intranet, CORS-friendly APIs)
|
let target: URL;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, {
|
target = new URL(url.trim());
|
||||||
headers: { Accept: 'application/json, application/yaml, text/yaml, */*' },
|
|
||||||
});
|
|
||||||
if (res.ok) return parseSpecText(await res.text());
|
|
||||||
} catch {
|
} catch {
|
||||||
// CORS or network error — fall through to server proxy
|
throw new SpecFetchError('invalidUrl');
|
||||||
|
}
|
||||||
|
if (target.protocol !== 'http:' && target.protocol !== 'https:') {
|
||||||
|
throw new SpecFetchError('invalidUrl');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to server-side proxy for CORS-restricted URLs
|
// HTTPS 页面读取 http:// 资源会被浏览器的混合内容策略直接拦截,连请求都发不出去
|
||||||
const data = await apiFetch<{ content: string }>(`/fetch-spec?url=${encodeURIComponent(url)}`);
|
const mixedContent =
|
||||||
return parseSpecText(data.content);
|
window.location.protocol === 'https:' && target.protocol === 'http:' && !isLoopback(target.hostname);
|
||||||
|
|
||||||
|
let directDetail = '';
|
||||||
|
if (mixedContent) {
|
||||||
|
directDetail = 'mixed content blocked by the browser';
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const res = await fetch(target.toString(), {
|
||||||
|
headers: { Accept: 'application/json, application/yaml, text/yaml, */*' },
|
||||||
|
signal: AbortSignal.timeout(DIRECT_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
// 拿到了真实状态码说明 CORS 已通过,结果是确定的,无需再走服务端代理
|
||||||
|
if (!res.ok) throw new SpecFetchError('httpStatus', `${res.status} ${res.statusText}`.trim());
|
||||||
|
return parseSpecText(await res.text());
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof SpecFetchError) throw err;
|
||||||
|
directDetail = err instanceof Error ? err.message : String(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内网地址服务端必然访问不到,不必白等一次超时
|
||||||
|
if (isPrivateHost(target.hostname)) {
|
||||||
|
throw new SpecFetchError('privateNetwork', directDetail);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 浏览器直连失败,退回服务端代理(对公网可达但未开 CORS 的地址有效)
|
||||||
|
try {
|
||||||
|
const data = await apiFetch<{ content: string }>(`/fetch-spec?url=${encodeURIComponent(target.toString())}`);
|
||||||
|
return parseSpecText(data.content);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof SpecFetchError) throw err;
|
||||||
|
const proxyDetail = err instanceof Error ? err.message : String(err);
|
||||||
|
throw new SpecFetchError(mixedContent ? 'mixedContent' : 'blocked', directDetail, proxyDetail);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把抓取错误翻译成用户可读、可操作的提示 */
|
||||||
|
export function describeSpecFetchError(err: unknown, t: TFunction): string {
|
||||||
|
if (err instanceof SpecFetchError) {
|
||||||
|
return t(`common.specFetch.${err.code}` as 'common.specFetch.blocked', {
|
||||||
|
detail: err.detail,
|
||||||
|
proxyDetail: err.proxyDetail,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (err instanceof Error) return err.message;
|
||||||
|
return t('common.specFetch.blocked', { detail: '', proxyDetail: '' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,6 +166,20 @@ const en = {
|
|||||||
'common.dropFile': 'Drop your OpenAPI file here',
|
'common.dropFile': 'Drop your OpenAPI file here',
|
||||||
'common.jsonOrYaml': 'JSON or YAML',
|
'common.jsonOrYaml': 'JSON or YAML',
|
||||||
|
|
||||||
|
// ===== Spec fetching =====
|
||||||
|
'common.specFetch.invalidUrl': 'Invalid URL. Enter a full address starting with http:// or https://.',
|
||||||
|
'common.specFetch.mixedContent': 'This page is served over HTTPS, so the browser blocks reading an http:// address (mixed content). The server-side fallback failed too ({proxyDetail}).',
|
||||||
|
'common.specFetch.privateNetwork': 'This address lives on a private network, so only your browser can reach it — but the browser was blocked from reading it ({detail}).',
|
||||||
|
'common.specFetch.blocked': 'The browser could not read this URL directly — the target service most likely does not allow cross-origin requests ({detail}). The server-side fallback failed too ({proxyDetail}).',
|
||||||
|
'common.specFetch.httpStatus': 'The document server returned {detail}.',
|
||||||
|
'common.specFetch.parse': 'The fetched content is not a valid JSON or YAML document ({detail}).',
|
||||||
|
'common.specFetch.helpShow': 'How do I make URL import work?',
|
||||||
|
'common.specFetch.helpHide': 'Hide',
|
||||||
|
'common.specFetch.helpIntro': 'Browsers only read cross-origin URLs that explicitly allow it. URL import works once all of the following are true:',
|
||||||
|
'common.specFetch.helpCors': 'The document service returns CORS headers allowing this site. Private network addresses need the Private Network header too:',
|
||||||
|
'common.specFetch.helpMixed': 'If the document URL is http:// while this site is HTTPS, also set "Insecure content" to "Allow" under the lock icon → Site settings in your browser.',
|
||||||
|
'common.specFetch.helpFallback': 'If changing the document service is not an option, open the URL in your browser, save it as a .json or .yaml file, and import it via "Upload File" — that path is not subject to cross-origin rules and gives the same result.',
|
||||||
|
|
||||||
// ===== Theme =====
|
// ===== Theme =====
|
||||||
'theme.light': 'Light',
|
'theme.light': 'Light',
|
||||||
'theme.dark': 'Dark',
|
'theme.dark': 'Dark',
|
||||||
|
|||||||
@@ -168,6 +168,20 @@ const zh: Record<TranslationKey, string> = {
|
|||||||
'common.dropFile': '将 OpenAPI 文件拖放到这里',
|
'common.dropFile': '将 OpenAPI 文件拖放到这里',
|
||||||
'common.jsonOrYaml': 'JSON 或 YAML',
|
'common.jsonOrYaml': 'JSON 或 YAML',
|
||||||
|
|
||||||
|
// ===== Spec fetching =====
|
||||||
|
'common.specFetch.invalidUrl': 'URL 无效,请填写以 http:// 或 https:// 开头的完整地址。',
|
||||||
|
'common.specFetch.mixedContent': '当前页面是 HTTPS,浏览器禁止读取 http:// 地址(混合内容拦截);服务端代抓取同样失败({proxyDetail})。',
|
||||||
|
'common.specFetch.privateNetwork': '该地址位于内网或私有网段,只有你的浏览器能访问到,但浏览器直连被拦截({detail})。',
|
||||||
|
'common.specFetch.blocked': '浏览器无法直接读取该地址,通常是目标服务未允许跨域访问({detail});服务端代抓取同样失败({proxyDetail})。',
|
||||||
|
'common.specFetch.httpStatus': '文档服务返回 {detail}。',
|
||||||
|
'common.specFetch.parse': '抓取到的内容不是有效的 JSON 或 YAML 文档({detail})。',
|
||||||
|
'common.specFetch.helpShow': '如何让 URL 导入可用?',
|
||||||
|
'common.specFetch.helpHide': '收起',
|
||||||
|
'common.specFetch.helpIntro': '浏览器只允许读取明确放行的跨域地址。要让 URL 导入生效,需要同时满足下面几点:',
|
||||||
|
'common.specFetch.helpCors': '文档服务在响应中带上跨域头,放行本站;内网地址还需要 Private Network 头:',
|
||||||
|
'common.specFetch.helpMixed': '文档地址是 http:// 而本站是 HTTPS 时,还需在浏览器地址栏的锁图标 →「网站设置」→「不安全内容」中选择「允许」。',
|
||||||
|
'common.specFetch.helpFallback': '不方便改动文档服务时,在浏览器里打开该地址、另存为 .json 或 .yaml 文件,再用「上传文件」导入——这条路不受跨域限制,效果完全相同。',
|
||||||
|
|
||||||
// ===== Theme =====
|
// ===== Theme =====
|
||||||
'theme.light': '浅色',
|
'theme.light': '浅色',
|
||||||
'theme.dark': '深色',
|
'theme.dark': '深色',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { apiFetch } from '../lib/api';
|
|||||||
import { fetchSpecFromUrl } from '../lib/fetch-spec';
|
import { fetchSpecFromUrl } from '../lib/fetch-spec';
|
||||||
import { useI18n } from '../lib/i18n';
|
import { useI18n } from '../lib/i18n';
|
||||||
import Modal from '../components/Modal';
|
import Modal from '../components/Modal';
|
||||||
|
import SpecFetchErrorNotice from '../components/SpecFetchErrorNotice';
|
||||||
|
|
||||||
type ImportResult = {
|
type ImportResult = {
|
||||||
project: { id: string; name: string };
|
project: { id: string; name: string };
|
||||||
@@ -17,7 +18,7 @@ export default function ImportDialog({ onClose }: { onClose: () => void }) {
|
|||||||
const [fileContent, setFileContent] = useState<string>('');
|
const [fileContent, setFileContent] = useState<string>('');
|
||||||
const [fileName, setFileName] = useState('');
|
const [fileName, setFileName] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState<unknown>(null);
|
||||||
const [result, setResult] = useState<ImportResult | null>(null);
|
const [result, setResult] = useState<ImportResult | null>(null);
|
||||||
const [dragging, setDragging] = useState(false);
|
const [dragging, setDragging] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -41,7 +42,7 @@ export default function ImportDialog({ onClose }: { onClose: () => void }) {
|
|||||||
|
|
||||||
const handleImport = async () => {
|
const handleImport = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError(null);
|
||||||
try {
|
try {
|
||||||
let body: Record<string, unknown>;
|
let body: Record<string, unknown>;
|
||||||
if (mode === 'url') {
|
if (mode === 'url') {
|
||||||
@@ -56,7 +57,7 @@ export default function ImportDialog({ onClose }: { onClose: () => void }) {
|
|||||||
setResult(data);
|
setResult(data);
|
||||||
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Import failed');
|
setError(err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -104,12 +105,7 @@ export default function ImportDialog({ onClose }: { onClose: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
<SpecFetchErrorNotice error={error} />
|
||||||
<div className="p-3 rounded-lg bg-danger-muted flex items-center gap-2">
|
|
||||||
<svg className="w-4 h-4 text-danger shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><circle cx="12" cy="12" r="10" /><path d="M15 9l-6 6m0-6l6 6" /></svg>
|
|
||||||
<span className="text-danger text-[13px]">{error}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-2.5">
|
<div className="flex justify-end gap-2.5">
|
||||||
<button onClick={onClose} className="btn-ghost">{t('common.cancel')}</button>
|
<button onClick={onClose} className="btn-ghost">{t('common.cancel')}</button>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { apiFetch } from '../lib/api';
|
|||||||
import { fetchSpecFromUrl } from '../lib/fetch-spec';
|
import { fetchSpecFromUrl } from '../lib/fetch-spec';
|
||||||
import { useI18n } from '../lib/i18n';
|
import { useI18n } from '../lib/i18n';
|
||||||
import Modal from '../components/Modal';
|
import Modal from '../components/Modal';
|
||||||
|
import SpecFetchErrorNotice from '../components/SpecFetchErrorNotice';
|
||||||
|
|
||||||
type ReimportResult = {
|
type ReimportResult = {
|
||||||
stats: { modules: number; endpoints: number };
|
stats: { modules: number; endpoints: number };
|
||||||
@@ -24,7 +25,7 @@ export default function ReimportDialog({ projectId, currentStats, onClose, onSuc
|
|||||||
const [fileContent, setFileContent] = useState('');
|
const [fileContent, setFileContent] = useState('');
|
||||||
const [fileName, setFileName] = useState('');
|
const [fileName, setFileName] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState<unknown>(null);
|
||||||
const [result, setResult] = useState<ReimportResult | null>(null);
|
const [result, setResult] = useState<ReimportResult | null>(null);
|
||||||
const [dragging, setDragging] = useState(false);
|
const [dragging, setDragging] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -46,7 +47,7 @@ export default function ReimportDialog({ projectId, currentStats, onClose, onSuc
|
|||||||
|
|
||||||
const handleReimport = async () => {
|
const handleReimport = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError(null);
|
||||||
try {
|
try {
|
||||||
let body: Record<string, unknown>;
|
let body: Record<string, unknown>;
|
||||||
if (mode === 'url') {
|
if (mode === 'url') {
|
||||||
@@ -61,7 +62,7 @@ export default function ReimportDialog({ projectId, currentStats, onClose, onSuc
|
|||||||
setResult(data);
|
setResult(data);
|
||||||
setStep('success');
|
setStep('success');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Re-import failed');
|
setError(err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -84,8 +85,8 @@ export default function ReimportDialog({ projectId, currentStats, onClose, onSuc
|
|||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<p className="font-medium text-warning mb-1">{t('dashboard.reimport.warningTitle')}</p>
|
<p className="font-medium text-warning mb-1">{t('dashboard.reimport.warningTitle')}</p>
|
||||||
<ul className="text-text-secondary space-y-1">
|
<ul className="text-text-secondary space-y-1">
|
||||||
<li>{t('dashboard.reimport.warningModules', { count: currentStats.modules })}</li>
|
<li>{t('dashboard.reimport.warningModules', { count: currentStats.modules ?? 0 })}</li>
|
||||||
<li>{t('dashboard.reimport.warningEndpoints', { count: currentStats.endpoints })}</li>
|
<li>{t('dashboard.reimport.warningEndpoints', { count: currentStats.endpoints ?? 0 })}</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p className="text-text-muted mt-2">{t('dashboard.reimport.warningNote')}</p>
|
<p className="text-text-muted mt-2">{t('dashboard.reimport.warningNote')}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -138,7 +139,7 @@ export default function ReimportDialog({ projectId, currentStats, onClose, onSuc
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && <div className="p-3 rounded-lg bg-danger-muted text-danger text-sm">{error}</div>}
|
<SpecFetchErrorNotice error={error} />
|
||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
<div className="flex justify-end gap-3">
|
||||||
<button onClick={() => setStep('confirm')} className="btn-ghost">{t('common.back')}</button>
|
<button onClick={() => setStep('confirm')} className="btn-ghost">{t('common.back')}</button>
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export default function ProjectSettings({ project }: { project: Project }) {
|
|||||||
<section className="border-t border-border-default pt-8">
|
<section className="border-t border-border-default pt-8">
|
||||||
<p className="section-title">{t('dashboard.projectSettings.reimportTitle')}</p>
|
<p className="section-title">{t('dashboard.projectSettings.reimportTitle')}</p>
|
||||||
<p className="section-desc mb-4">
|
<p className="section-desc mb-4">
|
||||||
{t('dashboard.projectSettings.reimportDesc', { modules: project._count.modules, endpoints: project._count.endpoints })}
|
{t('dashboard.projectSettings.reimportDesc', { modules: project._count.modules ?? 0, endpoints: project._count.endpoints ?? 0 })}
|
||||||
</p>
|
</p>
|
||||||
<button onClick={() => setShowReimport(true)} className="btn-outline">
|
<button onClick={() => setShowReimport(true)} className="btn-outline">
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
|||||||
Reference in New Issue
Block a user