Add files via upload

This commit is contained in:
公明
2026-03-09 22:19:22 +08:00
committed by GitHub
parent 379486d36c
commit f26ee8e6e7
11 changed files with 990 additions and 200 deletions
+146 -61
View File
@@ -3,13 +3,74 @@
let apiSpec = null;
let currentToken = null;
function _t(key, opts) {
return typeof window.t === 'function' ? window.t(key, opts) : key;
}
function waitForI18n() {
return new Promise(function (resolve) {
if (window.t) return resolve();
var n = 0;
var iv = setInterval(function () {
if (window.t) { clearInterval(iv); resolve(); return; }
n++;
if (n >= 100) { clearInterval(iv); resolve(); }
}, 50);
});
}
// 从 OpenAPI spec 的 x-i18n-tags 构建 tag -> i18n key 映射(方案 A:后端提供键)
var apiSpecTagToKey = {};
function buildApiSpecTagToKey() {
apiSpecTagToKey = {};
if (!apiSpec || !apiSpec.paths) return;
Object.keys(apiSpec.paths).forEach(function (path) {
var pathItem = apiSpec.paths[path];
if (!pathItem || typeof pathItem !== 'object') return;
['get', 'post', 'put', 'delete', 'patch'].forEach(function (method) {
var op = pathItem[method];
if (!op || !op.tags || !op['x-i18n-tags']) return;
var tags = op.tags;
var keys = op['x-i18n-tags'];
for (var i = 0; i < tags.length && i < keys.length; i++) {
apiSpecTagToKey[tags[i]] = typeof keys[i] === 'string' ? keys[i] : keys[i];
}
});
});
}
function translateApiDocTag(tag) {
if (!tag) return tag;
var key = apiSpecTagToKey[tag];
return key ? _t('apiDocs.tags.' + key) : tag;
}
function translateApiDocSummaryFromOp(op) {
var key = op && op['x-i18n-summary'];
if (key) return _t('apiDocs.summary.' + key);
return op && op.summary ? op.summary : '';
}
function translateApiDocResponseDescFromResp(resp) {
if (!resp) return '';
var key = resp['x-i18n-description'];
if (key) return _t('apiDocs.response.' + key);
return resp.description || '';
}
// 初始化
document.addEventListener('DOMContentLoaded', async () => {
await waitForI18n();
await loadToken();
await loadAPISpec();
if (apiSpec) {
renderAPIDocs();
}
document.addEventListener('languagechange', function () {
if (typeof window.applyTranslations === 'function') {
window.applyTranslations(document);
}
if (apiSpec) {
renderAPIDocs();
}
});
});
// 加载token
@@ -43,22 +104,25 @@ async function loadAPISpec() {
const response = await fetch(url);
if (!response.ok) {
if (response.status === 401) {
showError('需要登录才能查看API文档。请先在前端页面登录,然后刷新此页面。');
showError(_t('apiDocs.errorLoginRequired'));
return;
}
throw new Error('加载API规范失败: ' + response.status);
throw new Error(_t('apiDocs.errorLoadSpec') + response.status);
}
apiSpec = await response.json();
buildApiSpecTagToKey();
} catch (error) {
console.error('加载API规范失败:', error);
showError('加载API文档失败: ' + error.message);
showError(_t('apiDocs.errorLoadFailed') + error.message);
}
}
// 显示错误
function showError(message) {
const main = document.getElementById('api-docs-main');
const loadFailed = _t('apiDocs.loadFailed');
const backToLogin = _t('apiDocs.backToLogin');
main.innerHTML = `
<div class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -66,10 +130,10 @@ function showError(message) {
<line x1="15" y1="9" x2="9" y2="15"/>
<line x1="9" y1="9" x2="15" y2="15"/>
</svg>
<h3>加载失败</h3>
<p>${message}</p>
<h3>${escapeHtml(loadFailed)}</h3>
<p>${escapeHtml(message)}</p>
<div style="margin-top: 16px;">
<a href="/" style="color: var(--accent-color); text-decoration: none;">返回首页登录</a>
<a href="/" style="color: var(--accent-color); text-decoration: none;">${escapeHtml(backToLogin)}</a>
</div>
</div>
`;
@@ -78,7 +142,7 @@ function showError(message) {
// 渲染API文档
function renderAPIDocs() {
if (!apiSpec || !apiSpec.paths) {
showError('API规范格式错误');
showError(_t('apiDocs.errorSpecInvalid'));
return;
}
@@ -109,7 +173,7 @@ function renderAuthInfo() {
tokenStatus.style.display = 'block';
tokenStatus.style.background = 'rgba(255, 152, 0, 0.1)';
tokenStatus.style.borderLeftColor = '#ff9800';
tokenStatus.innerHTML = '<p style="margin: 0; font-size: 0.8125rem; color: #ff9800;"><strong>⚠ 未检测到 Token</strong> - 请先在前端页面登录,然后刷新此页面。测试接口时需要在请求头中添加 Authorization: Bearer token</p>';
tokenStatus.innerHTML = '<p style="margin: 0; font-size: 0.8125rem; color: #ff9800;">' + escapeHtml(_t('apiDocs.tokenNotDetected')) + '</p>';
}
}
@@ -127,11 +191,14 @@ function renderSidebar() {
const groupList = document.getElementById('api-group-list');
const allGroups = Array.from(groups).sort();
while (groupList.children.length > 1) {
groupList.removeChild(groupList.lastChild);
}
allGroups.forEach(group => {
const li = document.createElement('li');
li.className = 'api-group-item';
li.innerHTML = `<a href="#" class="api-group-link" data-group="${group}">${group}</a>`;
const groupLabel = translateApiDocTag(group);
li.innerHTML = `<a href="#" class="api-group-link" data-group="${escapeHtml(group)}">${escapeHtml(groupLabel)}</a>`;
groupList.appendChild(li);
});
@@ -176,7 +243,7 @@ function renderEndpoints(filterGroup = null) {
});
if (endpoints.length === 0) {
main.innerHTML = '<div class="empty-state"><h3>暂无API</h3><p>该分组下没有API端点</p></div>';
main.innerHTML = '<div class="empty-state"><h3>' + escapeHtml(_t('apiDocs.noApis')) + '</h3><p>' + escapeHtml(_t('apiDocs.noEndpointsInGroup')) + '</p></div>';
return;
}
@@ -192,8 +259,8 @@ function createEndpointCard(endpoint) {
const methodClass = endpoint.method.toLowerCase();
const tags = endpoint.tags || [];
const tagHtml = tags.map(tag => `<span class="api-tag">${tag}</span>`).join('');
const tagHtml = tags.map(tag => `<span class="api-tag">${escapeHtml(translateApiDocTag(tag))}</span>`).join('');
const summaryText = translateApiDocSummaryFromOp(endpoint);
card.innerHTML = `
<div class="api-endpoint-header">
<div class="api-endpoint-title">
@@ -204,21 +271,21 @@ function createEndpointCard(endpoint) {
</div>
<div class="api-endpoint-body">
<div class="api-section">
<div class="api-section-title">描述</div>
${endpoint.summary ? `<div class="api-description" style="font-weight: 500; margin-bottom: 8px; color: var(--text-primary);">${escapeHtml(endpoint.summary)}</div>` : ''}
<div class="api-section-title">${escapeHtml(_t('apiDocs.sectionDescription'))}</div>
${summaryText ? `<div class="api-description" style="font-weight: 500; margin-bottom: 8px; color: var(--text-primary);">${escapeHtml(summaryText)}</div>` : ''}
${endpoint.description ? `
<div class="api-description-toggle">
<button class="description-toggle-btn" onclick="toggleDescription(this)">
<svg class="description-toggle-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"/>
</svg>
<span>查看详细说明</span>
<span>${escapeHtml(_t('apiDocs.viewDetailDesc'))}</span>
</button>
<div class="api-description-detail" style="display: none;">
${formatDescription(endpoint.description)}
</div>
</div>
` : endpoint.summary ? '' : '<div class="api-description">无描述</div>'}
` : endpoint.summary ? '' : '<div class="api-description">' + escapeHtml(_t('apiDocs.noDescription')) + '</div>'}
</div>
${renderParameters(endpoint)}
@@ -236,8 +303,10 @@ function renderParameters(endpoint) {
const params = endpoint.parameters || [];
if (params.length === 0) return '';
const requiredLabel = escapeHtml(_t('apiDocs.required'));
const optionalLabel = escapeHtml(_t('apiDocs.optional'));
const rows = params.map(param => {
const required = param.required ? '<span class="api-param-required">必需</span>' : '<span class="api-param-optional">可选</span>';
const required = param.required ? '<span class="api-param-required">' + requiredLabel + '</span>' : '<span class="api-param-optional">' + optionalLabel + '</span>';
// 处理描述文本,将换行符转换为<br>
let descriptionHtml = '-';
if (param.description) {
@@ -255,17 +324,20 @@ function renderParameters(endpoint) {
`;
}).join('');
const paramName = escapeHtml(_t('apiDocs.paramName'));
const typeLabel = escapeHtml(_t('apiDocs.type'));
const descLabel = escapeHtml(_t('apiDocs.description'));
return `
<div class="api-section">
<div class="api-section-title">参数</div>
<div class="api-section-title">${escapeHtml(_t('apiDocs.sectionParams'))}</div>
<div class="api-table-wrapper">
<table class="api-params-table">
<thead>
<tr>
<th>参数名</th>
<th>类型</th>
<th>描述</th>
<th>必需</th>
<th>${paramName}</th>
<th>${typeLabel}</th>
<th>${descLabel}</th>
<th>${requiredLabel}</th>
</tr>
</thead>
<tbody>
@@ -297,11 +369,13 @@ function renderRequestBody(endpoint) {
let paramsTable = '';
if (schema.properties) {
const requiredFields = schema.required || [];
const reqLabel = escapeHtml(_t('apiDocs.required'));
const optLabel = escapeHtml(_t('apiDocs.optional'));
const rows = Object.keys(schema.properties).map(key => {
const prop = schema.properties[key];
const required = requiredFields.includes(key)
? '<span class="api-param-required">必需</span>'
: '<span class="api-param-optional">可选</span>';
? '<span class="api-param-required">' + reqLabel + '</span>'
: '<span class="api-param-optional">' + optLabel + '</span>';
// 处理嵌套类型
let typeDisplay = prop.type || 'object';
@@ -338,16 +412,20 @@ function renderRequestBody(endpoint) {
}).join('');
if (rows) {
const pName = escapeHtml(_t('apiDocs.paramName'));
const tLabel = escapeHtml(_t('apiDocs.type'));
const dLabel = escapeHtml(_t('apiDocs.description'));
const exLabel = escapeHtml(_t('apiDocs.example'));
paramsTable = `
<div class="api-table-wrapper" style="margin-top: 12px;">
<table class="api-params-table">
<thead>
<tr>
<th>参数名</th>
<th>类型</th>
<th>描述</th>
<th>必需</th>
<th>示例</th>
<th>${pName}</th>
<th>${tLabel}</th>
<th>${dLabel}</th>
<th>${reqLabel}</th>
<th>${exLabel}</th>
</tr>
</thead>
<tbody>
@@ -389,12 +467,12 @@ function renderRequestBody(endpoint) {
return `
<div class="api-section">
<div class="api-section-title">请求体</div>
<div class="api-section-title">${escapeHtml(_t('apiDocs.sectionRequestBody'))}</div>
${endpoint.requestBody.description ? `<div class="api-description">${endpoint.requestBody.description}</div>` : ''}
${paramsTable}
${example ? `
<div style="margin-top: 16px;">
<div style="font-weight: 500; margin-bottom: 8px; color: var(--text-primary);">示例JSON:</div>
<div style="font-weight: 500; margin-bottom: 8px; color: var(--text-primary);">${escapeHtml(_t('apiDocs.exampleJson'))}</div>
<div class="api-response-example">
<pre>${escapeHtml(example)}</pre>
</div>
@@ -414,11 +492,11 @@ function renderResponses(endpoint) {
if (schema.example) {
example = JSON.stringify(schema.example, null, 2);
}
const descText = translateApiDocResponseDescFromResp(response);
return `
<div style="margin-bottom: 16px;">
<strong style="color: ${status.startsWith('2') ? 'var(--success-color)' : status.startsWith('4') ? 'var(--error-color)' : 'var(--warning-color)'}">${status}</strong>
${response.description ? `<span style="color: var(--text-secondary); margin-left: 8px;">${response.description}</span>` : ''}
${descText ? `<span style="color: var(--text-secondary); margin-left: 8px;">${escapeHtml(descText)}</span>` : ''}
${example ? `
<div class="api-response-example" style="margin-top: 8px;">
<pre>${escapeHtml(example)}</pre>
@@ -432,7 +510,7 @@ function renderResponses(endpoint) {
return `
<div class="api-section">
<div class="api-section-title">响应</div>
<div class="api-section-title">${escapeHtml(_t('apiDocs.sectionResponse'))}</div>
${responseItems}
</div>
`;
@@ -462,8 +540,8 @@ function renderTestSection(endpoint) {
const bodyInputId = `test-body-${escapeId(path)}-${method}`;
bodyInput = `
<div class="api-test-input-group">
<label>请求体 (JSON)</label>
<textarea id="${bodyInputId}" class="test-body-input" placeholder='请输入JSON格式的请求体'>${defaultBody}</textarea>
<label>${escapeHtml(_t('apiDocs.requestBodyJson'))}</label>
<textarea id="${bodyInputId}" class="test-body-input" placeholder='${escapeHtml(_t('apiDocs.requestBodyPlaceholder'))}'>${defaultBody}</textarea>
</div>
`;
}
@@ -491,7 +569,7 @@ function renderTestSection(endpoint) {
const inputId = `test-query-${param.name}-${escapeId(path)}-${method}`;
const defaultValue = param.schema?.default !== undefined ? param.schema.default : '';
const placeholder = param.description || param.name;
const required = param.required ? '<span style="color: var(--error-color);">*</span>' : '<span style="color: var(--text-muted);">可选</span>';
const required = param.required ? '<span style="color: var(--error-color);">*</span>' : '<span style="color: var(--text-muted);">' + escapeHtml(_t('apiDocs.optional')) + '</span>';
return `
<div class="api-test-input-group">
<label>${param.name} ${required}</label>
@@ -505,33 +583,40 @@ function renderTestSection(endpoint) {
}).join('');
}
const testSectionTitle = escapeHtml(_t('apiDocs.testSection'));
const queryParamsTitle = escapeHtml(_t('apiDocs.queryParams'));
const sendRequestLabel = escapeHtml(_t('apiDocs.sendRequest'));
const copyCurlLabel = escapeHtml(_t('apiDocs.copyCurl'));
const clearResultLabel = escapeHtml(_t('apiDocs.clearResult'));
const copyCurlTitle = escapeHtml(_t('apiDocs.copyCurlTitle'));
const clearResultTitle = escapeHtml(_t('apiDocs.clearResultTitle'));
return `
<div class="api-test-section">
<div class="api-section-title">测试接口</div>
<div class="api-section-title">${testSectionTitle}</div>
<div class="api-test-form">
${pathParamsInput}
${queryParamsInput ? `<div style="margin-top: 16px;"><div style="font-weight: 500; margin-bottom: 8px; color: var(--text-primary);">查询参数:</div>${queryParamsInput}</div>` : ''}
${queryParamsInput ? `<div style="margin-top: 16px;"><div style="font-weight: 500; margin-bottom: 8px; color: var(--text-primary);">${queryParamsTitle}</div>${queryParamsInput}</div>` : ''}
${bodyInput}
<div class="api-test-buttons">
<button class="api-test-btn primary" onclick="testAPI('${method}', '${escapeHtml(path)}', '${endpoint.operationId || ''}')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="5 3 19 12 5 21 5 3"/>
</svg>
发送请求
${sendRequestLabel}
</button>
<button class="api-test-btn copy-curl" onclick="copyCurlCommand(event, '${method}', '${escapeHtml(path)}')" title="复制curl命令">
<button class="api-test-btn copy-curl" onclick="copyCurlCommand(event, '${method}', '${escapeHtml(path)}')" title="${copyCurlTitle}">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" stroke="currentColor" stroke-width="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" stroke="currentColor" stroke-width="2"/>
</svg>
复制curl
${copyCurlLabel}
</button>
<button class="api-test-btn clear-result" onclick="clearTestResult('${escapeId(path)}-${method}')" title="清除测试结果">
<button class="api-test-btn clear-result" onclick="clearTestResult('${escapeId(path)}-${method}')" title="${clearResultTitle}">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
清除结果
${clearResultLabel}
</button>
</div>
<div id="test-result-${escapeId(path)}-${method}" class="api-test-result" style="display: none;"></div>
@@ -548,7 +633,7 @@ async function testAPI(method, path, operationId) {
resultDiv.style.display = 'block';
resultDiv.className = 'api-test-result loading';
resultDiv.textContent = '发送请求中...';
resultDiv.textContent = _t('apiDocs.sendingRequest');
try {
// 替换路径参数
@@ -561,7 +646,7 @@ async function testAPI(method, path, operationId) {
if (input && input.value) {
actualPath = actualPath.replace(param, encodeURIComponent(input.value));
} else {
throw new Error(`路径参数 ${paramName} 不能为空`);
throw new Error(_t('apiDocs.errorPathParamRequired', { name: paramName }));
}
});
@@ -580,7 +665,7 @@ async function testAPI(method, path, operationId) {
if (input && input.value !== '' && input.value !== null && input.value !== undefined) {
queryParams.push(`${encodeURIComponent(param.name)}=${encodeURIComponent(input.value)}`);
} else if (param.required) {
throw new Error(`查询参数 ${param.name} 不能为空`);
throw new Error(_t('apiDocs.errorQueryParamRequired', { name: param.name }));
}
});
}
@@ -602,8 +687,7 @@ async function testAPI(method, path, operationId) {
if (currentToken) {
options.headers['Authorization'] = 'Bearer ' + currentToken;
} else {
// 如果没有token,提示用户
throw new Error('未检测到 Token。请先在前端页面登录,然后刷新此页面。或者手动在请求头中添加 Authorization: Bearer your_token');
throw new Error(_t('apiDocs.errorTokenRequired'));
}
// 添加请求体
@@ -614,7 +698,7 @@ async function testAPI(method, path, operationId) {
try {
options.body = JSON.stringify(JSON.parse(bodyInput.value.trim()));
} catch (e) {
throw new Error('请求体JSON格式错误: ' + e.message);
throw new Error(_t('apiDocs.errorJsonInvalid') + e.message);
}
}
}
@@ -636,7 +720,7 @@ async function testAPI(method, path, operationId) {
} catch (error) {
resultDiv.className = 'api-test-result error';
resultDiv.textContent = '请求失败: ' + error.message;
resultDiv.textContent = _t('apiDocs.requestFailed') + error.message;
}
}
@@ -727,17 +811,17 @@ function copyCurlCommand(event, method, path) {
// 复制到剪贴板
const button = event ? event.target.closest('button') : null;
navigator.clipboard.writeText(curlCommand).then(() => {
// 显示成功提示
if (button) {
const originalText = button.innerHTML;
button.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>已复制';
const copiedLabel = escapeHtml(_t('apiDocs.copied'));
button.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>' + copiedLabel;
button.style.color = 'var(--success-color)';
setTimeout(() => {
button.innerHTML = originalText;
button.style.color = '';
}, 2000);
} else {
alert('curl命令已复制到剪贴板!');
alert(_t('apiDocs.curlCopied'));
}
}).catch(err => {
console.error('复制失败:', err);
@@ -752,24 +836,25 @@ function copyCurlCommand(event, method, path) {
document.execCommand('copy');
if (button) {
const originalText = button.innerHTML;
button.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>已复制';
const copiedLabel = escapeHtml(_t('apiDocs.copied'));
button.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>' + copiedLabel;
button.style.color = 'var(--success-color)';
setTimeout(() => {
button.innerHTML = originalText;
button.style.color = '';
}, 2000);
} else {
alert('curl命令已复制到剪贴板!');
alert(_t('apiDocs.curlCopied'));
}
} catch (e) {
alert('复制失败,请手动复制:\n\n' + curlCommand);
alert(_t('apiDocs.copyFailedManual') + curlCommand);
}
document.body.removeChild(textarea);
});
} catch (error) {
console.error('生成curl命令失败:', error);
alert('生成curl命令失败: ' + error.message);
alert(_t('apiDocs.curlGenFailed') + error.message);
}
}
@@ -935,10 +1020,10 @@ function toggleDescription(button) {
if (detail.style.display === 'none') {
detail.style.display = 'block';
icon.style.transform = 'rotate(180deg)';
span.textContent = '隐藏详细说明';
span.textContent = typeof window.t === 'function' ? window.t('apiDocs.hideDetailDesc') : '隐藏详细说明';
} else {
detail.style.display = 'none';
icon.style.transform = 'rotate(0deg)';
span.textContent = '查看详细说明';
span.textContent = typeof window.t === 'function' ? window.t('apiDocs.viewDetailDesc') : '查看详细说明';
}
}