将翻译文件拆分为独立的 en.ts/zh.ts,为 t() 函数添加插值支持, 国际化 Dashboard 全部页面和组件(登录、注册、项目管理、设置、 MCP 集成等),修复 ThemeToggle 仅中文标签的 bug, 在 Dashboard header 中添加 LanguageToggle 组件。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
377 lines
14 KiB
TypeScript
377 lines
14 KiB
TypeScript
/**
|
|
* Structured renderers for OpenAPI parameters, request bodies, and responses.
|
|
* Replaces raw JSON.stringify output with readable tables and schema trees.
|
|
*/
|
|
|
|
import { useI18n } from '../lib/i18n';
|
|
|
|
/* ===== Helpers ===== */
|
|
|
|
type SchemaObj = {
|
|
type?: string;
|
|
format?: string;
|
|
description?: string;
|
|
enum?: unknown[];
|
|
items?: SchemaObj;
|
|
properties?: Record<string, SchemaObj>;
|
|
required?: string[];
|
|
additionalProperties?: boolean | SchemaObj;
|
|
oneOf?: SchemaObj[];
|
|
anyOf?: SchemaObj[];
|
|
allOf?: SchemaObj[];
|
|
default?: unknown;
|
|
example?: unknown;
|
|
nullable?: boolean;
|
|
minimum?: number;
|
|
maximum?: number;
|
|
minLength?: number;
|
|
maxLength?: number;
|
|
pattern?: string;
|
|
[key: string]: unknown;
|
|
};
|
|
|
|
type Parameter = {
|
|
name: string;
|
|
in: string;
|
|
required?: boolean;
|
|
description?: string;
|
|
schema?: SchemaObj;
|
|
type?: string;
|
|
format?: string;
|
|
enum?: unknown[];
|
|
[key: string]: unknown;
|
|
};
|
|
|
|
function resolveType(schema?: SchemaObj): string {
|
|
if (!schema) return '—';
|
|
if (schema.type === 'array' && schema.items) {
|
|
return `${resolveType(schema.items)}[]`;
|
|
}
|
|
if (schema.oneOf) return schema.oneOf.map(resolveType).join(' | ');
|
|
if (schema.anyOf) return schema.anyOf.map(resolveType).join(' | ');
|
|
return schema.type || '—';
|
|
}
|
|
|
|
function TypeBadge({ type }: { type: string }) {
|
|
const colorMap: Record<string, string> = {
|
|
string: 'text-[#30a46c] bg-[rgba(48,164,108,0.08)]',
|
|
integer: 'text-[#3b82f6] bg-[rgba(59,130,246,0.08)]',
|
|
number: 'text-[#3b82f6] bg-[rgba(59,130,246,0.08)]',
|
|
boolean: 'text-[#e5a000] bg-[rgba(229,160,0,0.08)]',
|
|
object: 'text-[#8b5cf6] bg-[rgba(139,92,246,0.08)]',
|
|
array: 'text-[#e5484d] bg-[rgba(229,72,77,0.08)]',
|
|
};
|
|
const base = type.replace('[]', '');
|
|
const cls = colorMap[base] || 'text-text-muted bg-bg-tertiary';
|
|
return (
|
|
<span className={`inline-block px-1.5 py-0.5 rounded text-[11px] font-mono font-medium ${cls}`}>
|
|
{type}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function InBadge({ location }: { location: string }) {
|
|
return (
|
|
<span className="inline-block px-1.5 py-0.5 rounded text-[11px] font-mono text-text-muted bg-bg-tertiary">
|
|
{location}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
/* ===== Parameters Table ===== */
|
|
|
|
export function ParametersView({ parameters }: { parameters: unknown }) {
|
|
const { t } = useI18n();
|
|
if (!Array.isArray(parameters) || parameters.length === 0) return null;
|
|
const params = parameters as Parameter[];
|
|
|
|
return (
|
|
<div>
|
|
<p className="section-label mb-2">{t('dashboard.schema.parameters')}</p>
|
|
<div className="border border-border-default rounded-lg overflow-hidden">
|
|
<table className="w-full text-[13px]">
|
|
<thead>
|
|
<tr className="bg-bg-tertiary/50 text-text-muted text-[11px] uppercase tracking-wider">
|
|
<th className="text-left px-3 py-2 font-medium">{t('dashboard.schema.name')}</th>
|
|
<th className="text-left px-3 py-2 font-medium">{t('dashboard.schema.in')}</th>
|
|
<th className="text-left px-3 py-2 font-medium">{t('dashboard.schema.type')}</th>
|
|
<th className="text-left px-3 py-2 font-medium">{t('dashboard.schema.required')}</th>
|
|
<th className="text-left px-3 py-2 font-medium">{t('dashboard.schema.descriptionCol')}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border-muted">
|
|
{params.map((p, i) => {
|
|
const type = resolveType(p.schema) || p.type || '—';
|
|
const format = p.schema?.format || p.format;
|
|
const enumVals = p.schema?.enum || p.enum;
|
|
return (
|
|
<tr key={i} className="hover:bg-bg-tertiary/30 transition-colors">
|
|
<td className="px-3 py-2.5 font-mono text-text-primary font-medium">
|
|
{p.name}
|
|
</td>
|
|
<td className="px-3 py-2.5">
|
|
<InBadge location={p.in} />
|
|
</td>
|
|
<td className="px-3 py-2.5">
|
|
<div className="flex items-center gap-1.5 flex-wrap">
|
|
<TypeBadge type={type} />
|
|
{format && (
|
|
<span className="text-[11px] text-text-muted">({format})</span>
|
|
)}
|
|
</div>
|
|
</td>
|
|
<td className="px-3 py-2.5">
|
|
{p.required ? (
|
|
<span className="text-[11px] font-medium text-danger">{t('dashboard.schema.required')}</span>
|
|
) : (
|
|
<span className="text-[11px] text-text-muted">{t('dashboard.schema.optional')}</span>
|
|
)}
|
|
</td>
|
|
<td className="px-3 py-2.5 text-text-secondary max-w-xs">
|
|
<div>
|
|
{p.description && <span>{p.description}</span>}
|
|
{enumVals && enumVals.length > 0 && (
|
|
<div className="mt-1 flex items-center gap-1 flex-wrap">
|
|
<span className="text-[11px] text-text-muted">{t('dashboard.schema.enum')}</span>
|
|
{enumVals.map((v, j) => (
|
|
<code key={j} className="text-[11px] font-mono bg-bg-tertiary px-1 py-0.5 rounded text-text-secondary">
|
|
{String(v)}
|
|
</code>
|
|
))}
|
|
</div>
|
|
)}
|
|
{p.schema?.default !== undefined && (
|
|
<div className="mt-0.5 text-[11px] text-text-muted">
|
|
{t('dashboard.schema.default')} <code className="font-mono">{JSON.stringify(p.schema.default)}</code>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ===== Schema Properties Tree ===== */
|
|
|
|
function SchemaProperties({ schema, depth = 0 }: { schema: SchemaObj; depth?: number }) {
|
|
const { t } = useI18n();
|
|
const properties = schema.properties;
|
|
const requiredSet = new Set(schema.required || []);
|
|
|
|
if (!properties || Object.keys(properties).length === 0) {
|
|
// Just show the type if no properties
|
|
if (schema.type) {
|
|
return (
|
|
<div className="px-3 py-2 text-[13px] text-text-muted">
|
|
<TypeBadge type={resolveType(schema)} />
|
|
{schema.description && <span className="ml-2">{schema.description}</span>}
|
|
</div>
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div className={depth > 0 ? 'ml-4 border-l border-border-muted pl-3 mt-1' : ''}>
|
|
{Object.entries(properties).map(([name, prop]) => {
|
|
const type = resolveType(prop);
|
|
const hasChildren = prop.type === 'object' && prop.properties;
|
|
const isArray = prop.type === 'array' && prop.items?.properties;
|
|
|
|
return (
|
|
<div key={name} className="py-1.5 first:pt-0">
|
|
<div className="flex items-start gap-2 text-[13px]">
|
|
<code className="font-mono text-text-primary font-medium shrink-0">{name}</code>
|
|
<TypeBadge type={type} />
|
|
{prop.format && (
|
|
<span className="text-[11px] text-text-muted">({prop.format})</span>
|
|
)}
|
|
{requiredSet.has(name) && (
|
|
<span className="text-[11px] font-medium text-danger">{t('dashboard.schema.required')}</span>
|
|
)}
|
|
{prop.nullable && (
|
|
<span className="text-[11px] text-text-muted">{t('dashboard.schema.nullable')}</span>
|
|
)}
|
|
{prop.description && (
|
|
<span className="text-text-secondary text-[12px] leading-snug">{prop.description}</span>
|
|
)}
|
|
</div>
|
|
{prop.enum && prop.enum.length > 0 && (
|
|
<div className="ml-0 mt-0.5 flex items-center gap-1 flex-wrap">
|
|
<span className="text-[11px] text-text-muted">{t('dashboard.schema.enum')}</span>
|
|
{prop.enum.map((v, j) => (
|
|
<code key={j} className="text-[11px] font-mono bg-bg-tertiary px-1 py-0.5 rounded text-text-secondary">
|
|
{String(v)}
|
|
</code>
|
|
))}
|
|
</div>
|
|
)}
|
|
{prop.default !== undefined && (
|
|
<div className="text-[11px] text-text-muted mt-0.5">
|
|
{t('dashboard.schema.default')} <code className="font-mono">{JSON.stringify(prop.default)}</code>
|
|
</div>
|
|
)}
|
|
{hasChildren && <SchemaProperties schema={prop} depth={depth + 1} />}
|
|
{isArray && prop.items && <SchemaProperties schema={prop.items} depth={depth + 1} />}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ===== Request Body ===== */
|
|
|
|
export function RequestBodyView({ requestBody }: { requestBody: unknown }) {
|
|
const { t } = useI18n();
|
|
if (!requestBody || typeof requestBody !== 'object') return null;
|
|
const body = requestBody as {
|
|
required?: boolean;
|
|
description?: string;
|
|
content?: Record<string, { schema?: SchemaObj }>;
|
|
schema?: SchemaObj; // Swagger 2.0 converted format
|
|
};
|
|
|
|
// Swagger 2.0 format: { schema: {...} }
|
|
if (body.schema && !body.content) {
|
|
return (
|
|
<div>
|
|
<p className="section-label mb-2">
|
|
{t('dashboard.schema.requestBody')}
|
|
{body.required && <span className="text-danger ml-2 normal-case tracking-normal text-[11px]">{t('dashboard.schema.required')}</span>}
|
|
</p>
|
|
<div className="border border-border-default rounded-lg p-3">
|
|
<SchemaProperties schema={body.schema} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// OpenAPI 3.x format: { content: { "application/json": { schema: {...} } } }
|
|
if (!body.content) return null;
|
|
const contentTypes = Object.entries(body.content);
|
|
|
|
return (
|
|
<div>
|
|
<p className="section-label mb-2">
|
|
{t('dashboard.schema.requestBody')}
|
|
{body.required && <span className="text-danger ml-2 normal-case tracking-normal text-[11px]">{t('dashboard.schema.required')}</span>}
|
|
</p>
|
|
{body.description && (
|
|
<p className="text-[13px] text-text-secondary mb-2">{body.description}</p>
|
|
)}
|
|
{contentTypes.map(([contentType, media]) => (
|
|
<div key={contentType} className="border border-border-default rounded-lg overflow-hidden mb-2 last:mb-0">
|
|
<div className="px-3 py-1.5 bg-bg-tertiary/50 border-b border-border-muted">
|
|
<code className="text-[11px] font-mono text-text-muted">{contentType}</code>
|
|
</div>
|
|
<div className="p-3">
|
|
{media.schema ? (
|
|
media.schema.properties ? (
|
|
<SchemaProperties schema={media.schema} />
|
|
) : (
|
|
<div className="flex items-center gap-2 text-[13px]">
|
|
<TypeBadge type={resolveType(media.schema)} />
|
|
{media.schema.description && <span className="text-text-secondary">{media.schema.description}</span>}
|
|
</div>
|
|
)
|
|
) : (
|
|
<span className="text-[13px] text-text-muted">{t('dashboard.schema.noSchema')}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ===== Responses ===== */
|
|
|
|
function StatusBadge({ code }: { code: string }) {
|
|
const n = parseInt(code, 10);
|
|
let cls = 'text-text-muted bg-bg-tertiary';
|
|
if (n >= 200 && n < 300) cls = 'text-[#30a46c] bg-[rgba(48,164,108,0.08)]';
|
|
else if (n >= 300 && n < 400) cls = 'text-[#3b82f6] bg-[rgba(59,130,246,0.08)]';
|
|
else if (n >= 400 && n < 500) cls = 'text-[#e5a000] bg-[rgba(229,160,0,0.08)]';
|
|
else if (n >= 500) cls = 'text-[#e5484d] bg-[rgba(229,72,77,0.08)]';
|
|
return (
|
|
<span className={`inline-block px-2 py-0.5 rounded text-[12px] font-mono font-semibold ${cls}`}>
|
|
{code}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export function ResponsesView({ responses }: { responses: unknown }) {
|
|
const { t } = useI18n();
|
|
if (!responses || typeof responses !== 'object') return null;
|
|
const entries = Object.entries(responses as Record<string, unknown>);
|
|
if (entries.length === 0) return null;
|
|
|
|
return (
|
|
<div>
|
|
<p className="section-label mb-2">{t('dashboard.schema.responses')}</p>
|
|
<div className="space-y-2">
|
|
{entries.map(([code, resp]) => {
|
|
const response = resp as {
|
|
description?: string;
|
|
content?: Record<string, { schema?: SchemaObj }>;
|
|
schema?: SchemaObj; // Swagger 2.0
|
|
};
|
|
|
|
// Find schema from content or direct schema (Swagger 2)
|
|
let schema: SchemaObj | undefined;
|
|
let contentType: string | undefined;
|
|
if (response.content) {
|
|
const firstEntry = Object.entries(response.content)[0];
|
|
if (firstEntry) {
|
|
contentType = firstEntry[0];
|
|
schema = firstEntry[1].schema;
|
|
}
|
|
} else if (response.schema) {
|
|
schema = response.schema;
|
|
}
|
|
|
|
return (
|
|
<div key={code} className="border border-border-default rounded-lg overflow-hidden">
|
|
<div className="px-3 py-2 bg-bg-tertiary/50 border-b border-border-muted flex items-center gap-2.5">
|
|
<StatusBadge code={code} />
|
|
{response.description && (
|
|
<span className="text-[13px] text-text-secondary">{response.description}</span>
|
|
)}
|
|
{contentType && (
|
|
<code className="text-[11px] font-mono text-text-muted ml-auto">{contentType}</code>
|
|
)}
|
|
</div>
|
|
{schema && (schema.properties || schema.items?.properties || schema.type) && (
|
|
<div className="p-3">
|
|
{schema.properties ? (
|
|
<SchemaProperties schema={schema} />
|
|
) : schema.type === 'array' && schema.items?.properties ? (
|
|
<div>
|
|
<div className="text-[11px] text-text-muted mb-1">
|
|
<TypeBadge type="array" /> {t('dashboard.schema.ofObjects')}
|
|
</div>
|
|
<SchemaProperties schema={schema.items} />
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center gap-2 text-[13px]">
|
|
<TypeBadge type={resolveType(schema)} />
|
|
{schema.description && <span className="text-text-secondary">{schema.description}</span>}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|