ashboard dans le webapp qui liste dynamiquement toutes les APIs OpenAPI disponibles dans PMOMusic

This commit is contained in:
2025-10-17 13:12:28 +02:00
parent 8b3cb719a9
commit f94a600ff8
18 changed files with 580 additions and 823 deletions

View File

@@ -13,7 +13,7 @@
<router-link to="/logs" @click="showDebugMenu = false">📋 Logs</router-link>
<router-link to="/upnp" @click="showDebugMenu = false">🎵 UPnP Explorer</router-link>
<router-link to="/covers-cache" @click="showDebugMenu = false">🎨 Cover Cache</router-link>
<router-link to="/api-explorer" @click="showDebugMenu = false">🌐 API Explorer</router-link>
<router-link to="/api-dashboard" @click="showDebugMenu = false">🚀 API Dashboard</router-link>
</div>
</div>
</nav>
@@ -31,7 +31,7 @@ const showDebugMenu = ref(false)
const route = useRoute()
const isDebugRoute = computed(() => {
return ['/logs', '/upnp', '/covers-cache', '/api-explorer'].includes(route.path)
return ['/logs', '/upnp', '/covers-cache', '/api-dashboard'].includes(route.path)
})
</script>

View File

@@ -0,0 +1,449 @@
<template>
<div class="api-dashboard">
<div class="header">
<h1>API Dashboard</h1>
<p class="subtitle">Vue d'ensemble des APIs disponibles dans PMOMusic</p>
</div>
<div v-if="loading" class="loading">Chargement des APIs...</div>
<div v-else-if="error" class="error">{{ error }}</div>
<div v-else class="dashboard-content">
<!-- Stats globales -->
<div class="stats-section">
<div class="stat-card">
<div class="stat-icon">🚀</div>
<div class="stat-info">
<div class="stat-value">{{ registry?.apis.length || 0 }}</div>
<div class="stat-label">APIs disponibles</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">🔌</div>
<div class="stat-info">
<div class="stat-value">{{ registry?.total_endpoints || 0 }}</div>
<div class="stat-label">Endpoints totaux</div>
</div>
</div>
</div>
<!-- Liste des APIs -->
<div class="apis-grid">
<div
v-for="api in registry?.apis"
:key="api.name"
class="api-card"
>
<div class="api-header">
<div class="api-icon">{{ getApiIcon(api.name) }}</div>
<div class="api-title-section">
<h3>{{ api.title }}</h3>
<p class="api-name">{{ api.name }}</p>
</div>
<div class="api-version">v{{ api.version }}</div>
</div>
<div class="api-body">
<p v-if="api.description" class="api-description">
{{ api.description }}
</p>
<p v-else class="api-description empty">Aucune description disponible</p>
<div class="api-stats">
<div class="api-stat">
<span class="stat-icon">📍</span>
<span class="stat-text">{{ api.endpoint_count }} endpoints</span>
</div>
<div class="api-stat">
<span class="stat-icon">🔗</span>
<span class="stat-text">{{ api.path }}</span>
</div>
</div>
</div>
<div class="api-footer">
<a :href="api.swagger_ui_path" target="_blank" class="btn-swagger">
<span class="btn-icon">📖</span>
<span>Documentation Swagger</span>
</a>
<a :href="api.openapi_json_path" target="_blank" class="btn-json">
<span class="btn-icon">📄</span>
<span>Spec OpenAPI</span>
</a>
</div>
</div>
</div>
<!-- Message si aucune API -->
<div v-if="!registry?.apis || registry.apis.length === 0" class="empty-state">
<div class="empty-icon">🔍</div>
<h3>Aucune API enregistrée</h3>
<p>Les APIs seront affichées ici au fur et à mesure de leur enregistrement.</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
interface ApiRegistryEntry {
name: string;
path: string;
swagger_ui_path: string;
openapi_json_path: string;
endpoint_count: number;
version: string;
description?: string;
title: string;
}
interface ApiRegistry {
apis: ApiRegistryEntry[];
total_endpoints: number;
}
const loading = ref(true);
const error = ref<string | null>(null);
const registry = ref<ApiRegistry | null>(null);
async function fetchRegistry() {
try {
loading.value = true;
error.value = null;
const response = await fetch('/api/registry');
if (!response.ok) {
throw new Error(`Failed to fetch API registry: ${response.statusText}`);
}
registry.value = await response.json();
} catch (e: any) {
error.value = e.message || 'Failed to load API registry';
console.error('Error fetching API registry:', e);
} finally {
loading.value = false;
}
}
function getApiIcon(name: string): string {
const icons: Record<string, string> = {
covers: '🎨',
audio: '🎵',
sources: '📡',
upnp: '🔌',
devices: '📱',
cache: '💾',
mediaserver: '🎬',
renderer: '🎭',
};
return icons[name.toLowerCase()] || '🔧';
}
onMounted(() => {
fetchRegistry();
});
</script>
<style scoped>
.api-dashboard {
padding: 2rem;
max-width: 1400px;
margin: 0 auto;
min-height: 100vh;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
}
.header {
text-align: center;
margin-bottom: 3rem;
}
.header h1 {
font-size: 2.5rem;
margin-bottom: 0.5rem;
color: #2c3e50;
font-weight: 700;
}
.subtitle {
font-size: 1.1rem;
color: #7f8c8d;
margin: 0;
}
.loading,
.error {
padding: 3rem;
text-align: center;
font-size: 1.2rem;
background: white;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.error {
color: #e74c3c;
background: #fff5f5;
border: 2px solid #fc8181;
}
.dashboard-content {
animation: fadeIn 0.5s ease-in;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Stats Section */
.stats-section {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 2rem;
border-radius: 12px;
display: flex;
align-items: center;
gap: 1.5rem;
box-shadow: 0 8px 16px rgba(102, 126, 234, 0.3);
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.stat-card:hover {
transform: translateY(-5px);
box-shadow: 0 12px 24px rgba(102, 126, 234, 0.4);
}
.stat-icon {
font-size: 3rem;
}
.stat-info {
flex: 1;
}
.stat-value {
font-size: 2.5rem;
font-weight: 700;
line-height: 1;
margin-bottom: 0.5rem;
}
.stat-label {
font-size: 1rem;
opacity: 0.9;
font-weight: 500;
}
/* APIs Grid */
.apis-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.api-card {
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: transform 0.3s ease, box-shadow 0.3s ease;
display: flex;
flex-direction: column;
}
.api-card:hover {
transform: translateY(-5px);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15);
}
.api-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 1.5rem;
display: flex;
align-items: center;
gap: 1rem;
}
.api-icon {
font-size: 2.5rem;
line-height: 1;
}
.api-title-section {
flex: 1;
}
.api-title-section h3 {
margin: 0 0 0.25rem 0;
font-size: 1.3rem;
font-weight: 600;
}
.api-name {
margin: 0;
font-size: 0.9rem;
opacity: 0.9;
font-family: 'Courier New', monospace;
}
.api-version {
background: rgba(255, 255, 255, 0.2);
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 600;
}
.api-body {
padding: 1.5rem;
flex: 1;
display: flex;
flex-direction: column;
gap: 1rem;
}
.api-description {
color: #4a5568;
line-height: 1.6;
margin: 0;
}
.api-description.empty {
color: #a0aec0;
font-style: italic;
}
.api-stats {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-top: auto;
}
.api-stat {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.95rem;
color: #718096;
}
.api-stat .stat-icon {
font-size: 1.2rem;
}
.api-stat .stat-text {
font-family: 'Courier New', monospace;
font-size: 0.9rem;
}
.api-footer {
display: grid;
grid-template-columns: 1fr 1fr;
border-top: 1px solid #e2e8f0;
}
.btn-swagger,
.btn-json {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 1rem;
text-decoration: none;
font-weight: 600;
transition: background 0.2s ease;
color: #667eea;
font-size: 0.9rem;
}
.btn-swagger {
border-right: 1px solid #e2e8f0;
}
.btn-swagger:hover {
background: #f7fafc;
color: #5a67d8;
}
.btn-json {
color: #48bb78;
}
.btn-json:hover {
background: #f7fafc;
color: #38a169;
}
.btn-icon {
font-size: 1.2rem;
}
/* Empty State */
.empty-state {
text-align: center;
padding: 4rem 2rem;
background: white;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.empty-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
.empty-state h3 {
color: #2c3e50;
font-size: 1.5rem;
margin: 0 0 0.5rem 0;
}
.empty-state p {
color: #7f8c8d;
margin: 0;
}
/* Responsive */
@media (max-width: 768px) {
.api-dashboard {
padding: 1rem;
}
.header h1 {
font-size: 2rem;
}
.apis-grid {
grid-template-columns: 1fr;
}
.api-footer {
grid-template-columns: 1fr;
}
.btn-swagger {
border-right: none;
border-bottom: 1px solid #e2e8f0;
}
}
</style>

View File

@@ -1,802 +0,0 @@
<template>
<div class="api-explorer">
<h1>API Explorer</h1>
<div v-if="loading" class="loading">Chargement de la documentation API...</div>
<div v-else-if="error" class="error">{{ error }}</div>
<div v-else class="explorer-content">
<!-- API Info Section -->
<section class="api-info" v-if="apiDoc">
<h2>{{ apiDoc.info?.title || 'API Documentation' }}</h2>
<p v-if="apiDoc.info?.description" class="description">
{{ apiDoc.info.description }}
</p>
<div class="metadata">
<span v-if="apiDoc.info?.version" class="version">Version: {{ apiDoc.info.version }}</span>
</div>
</section>
<!-- Endpoints List -->
<section class="endpoints" v-if="endpoints.length > 0">
<h2>Endpoints ({{ endpoints.length }})</h2>
<div
v-for="endpoint in endpoints"
:key="endpoint.path + endpoint.method"
class="endpoint-card"
:class="{ expanded: expandedEndpoint === endpoint.path + endpoint.method }"
>
<div
class="endpoint-header"
@click="toggleEndpoint(endpoint)"
>
<span class="method" :class="endpoint.method.toLowerCase()">
{{ endpoint.method.toUpperCase() }}
</span>
<span class="path">{{ endpoint.path }}</span>
<span class="summary" v-if="endpoint.summary">{{ endpoint.summary }}</span>
<span class="arrow">{{ expandedEndpoint === endpoint.path + endpoint.method ? '▼' : '▶' }}</span>
</div>
<div v-show="expandedEndpoint === endpoint.path + endpoint.method" class="endpoint-details">
<!-- Description -->
<div v-if="endpoint.description" class="description">
{{ endpoint.description }}
</div>
<!-- Tags -->
<div v-if="endpoint.tags && endpoint.tags.length > 0" class="tags">
<span v-for="tag in endpoint.tags" :key="tag" class="tag">{{ tag }}</span>
</div>
<!-- Parameters -->
<div v-if="endpoint.parameters && endpoint.parameters.length > 0" class="parameters">
<h4>Paramètres</h4>
<table>
<thead>
<tr>
<th>Nom</th>
<th>Type</th>
<th>Localisation</th>
<th>Requis</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr v-for="param in endpoint.parameters" :key="param.name">
<td><code>{{ param.name }}</code></td>
<td>{{ param.schema?.type || 'unknown' }}</td>
<td><span class="param-in">{{ param.in }}</span></td>
<td>{{ param.required ? '✓' : '' }}</td>
<td>{{ param.description || '-' }}</td>
</tr>
</tbody>
</table>
</div>
<!-- Request Body -->
<div v-if="endpoint.requestBody" class="request-body">
<h4>Corps de la requête</h4>
<div class="schema-viewer">
<pre>{{ formatSchema(endpoint.requestBody) }}</pre>
</div>
</div>
<!-- Responses -->
<div v-if="endpoint.responses" class="responses">
<h4>Réponses</h4>
<div v-for="(response, status) in endpoint.responses" :key="status" class="response-item">
<div class="response-status" :class="getStatusClass(status)">
{{ status }} - {{ response.description }}
</div>
<div v-if="response.content" class="response-schema">
<pre>{{ formatResponseSchema(response) }}</pre>
</div>
</div>
</div>
<!-- Test Section -->
<div class="test-section">
<h4>Tester cet endpoint</h4>
<!-- Path Parameters -->
<div v-if="endpoint.parameters && endpoint.parameters.some(p => p.in === 'path')" class="test-params">
<label>Paramètres de chemin:</label>
<div v-for="param in endpoint.parameters.filter(p => p.in === 'path')" :key="param.name" class="param-input">
<label>{{ param.name }}:</label>
<input
v-model="testParams[endpoint.path + endpoint.method]![param.name]"
type="text"
:placeholder="param.description || param.name"
/>
</div>
</div>
<!-- Query Parameters -->
<div v-if="endpoint.parameters && endpoint.parameters.some(p => p.in === 'query')" class="test-params">
<label>Paramètres de requête:</label>
<div v-for="param in endpoint.parameters.filter(p => p.in === 'query')" :key="param.name" class="param-input">
<label>{{ param.name }}:</label>
<input
v-model="testParams[endpoint.path + endpoint.method]![param.name]"
type="text"
:placeholder="param.description || param.name"
/>
</div>
</div>
<!-- Request Body -->
<div v-if="endpoint.requestBody" class="test-body">
<label>Corps de la requête (JSON):</label>
<textarea
v-model="testBodies[endpoint.path + endpoint.method]"
rows="6"
placeholder='{"key": "value"}'
></textarea>
</div>
<div class="test-actions">
<button @click="executeRequest(endpoint)" class="btn-test" :disabled="testing">
{{ testing ? 'Envoi...' : 'Envoyer la requête' }}
</button>
<button @click="copyCurl(endpoint)" class="btn-copy">
Copier cURL
</button>
</div>
<!-- Test Result -->
<div v-if="testResults[endpoint.path + endpoint.method]" class="test-result">
<div class="result-header">
<strong>Résultat:</strong>
<span
class="result-status"
:class="getStatusClass(testResults[endpoint.path + endpoint.method]!.status)"
>
{{ testResults[endpoint.path + endpoint.method]!.status }}
</span>
</div>
<pre class="result-body">{{ testResults[endpoint.path + endpoint.method]!.body }}</pre>
</div>
</div>
</div>
</div>
</section>
<!-- Schemas Section -->
<section class="schemas" v-if="schemas && Object.keys(schemas).length > 0">
<h2>Schémas ({{ Object.keys(schemas).length }})</h2>
<div
v-for="(schema, name) in schemas"
:key="name"
class="schema-card"
:class="{ expanded: expandedSchema === name }"
>
<div
class="schema-header"
@click="toggleSchema(name)"
>
<span class="schema-name">{{ name }}</span>
<span class="arrow">{{ expandedSchema === name ? '▼' : '▶' }}</span>
</div>
<div v-show="expandedSchema === name" class="schema-details">
<pre>{{ JSON.stringify(schema, null, 2) }}</pre>
</div>
</div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
interface OpenAPIDoc {
openapi?: string;
info?: {
title?: string;
description?: string;
version?: string;
};
paths?: Record<string, any>;
components?: {
schemas?: Record<string, any>;
};
}
interface Endpoint {
path: string;
method: string;
summary?: string;
description?: string;
tags?: string[];
parameters?: any[];
requestBody?: any;
responses?: Record<string, any>;
}
const loading = ref(true);
const error = ref<string | null>(null);
const apiDoc = ref<OpenAPIDoc | null>(null);
const expandedEndpoint = ref<string | null>(null);
const expandedSchema = ref<string | null>(null);
const testing = ref(false);
const testParams = ref<Record<string, Record<string, string>>>({});
const testBodies = ref<Record<string, string>>({});
const testResults = ref<Record<string, { status: number; body: string }>>({});
const endpoints = computed<Endpoint[]>(() => {
if (!apiDoc.value?.paths) return [];
const result: Endpoint[] = [];
for (const [path, pathItem] of Object.entries(apiDoc.value.paths)) {
for (const [method, operation] of Object.entries(pathItem)) {
if (['get', 'post', 'put', 'delete', 'patch'].includes(method)) {
const endpointKey = path + method;
if (!testParams.value[endpointKey]) {
testParams.value[endpointKey] = {};
}
if (!testBodies.value[endpointKey]) {
testBodies.value[endpointKey] = '';
}
result.push({
path,
method,
...(operation as any),
});
}
}
}
return result;
});
const schemas = computed(() => {
return apiDoc.value?.components?.schemas || {};
});
async function fetchAPIDoc() {
try {
loading.value = true;
error.value = null;
// Fetch the OpenAPI documentation from the server
const response = await fetch('/api-docs/sources.json');
if (!response.ok) {
throw new Error(`Failed to fetch API documentation: ${response.statusText}`);
}
apiDoc.value = await response.json();
} catch (e: any) {
error.value = e.message || 'Failed to load API documentation';
console.error('Error fetching API doc:', e);
} finally {
loading.value = false;
}
}
function toggleEndpoint(endpoint: Endpoint) {
const key = endpoint.path + endpoint.method;
expandedEndpoint.value = expandedEndpoint.value === key ? null : key;
}
function toggleSchema(name: string) {
expandedSchema.value = expandedSchema.value === name ? null : name;
}
function formatSchema(requestBody: any): string {
try {
if (requestBody.content) {
const content = requestBody.content['application/json'];
if (content?.schema) {
return JSON.stringify(content.schema, null, 2);
}
}
return JSON.stringify(requestBody, null, 2);
} catch {
return String(requestBody);
}
}
function formatResponseSchema(response: any): string {
try {
if (response.content) {
const content = response.content['application/json'];
if (content?.schema) {
return JSON.stringify(content.schema, null, 2);
}
}
return JSON.stringify(response, null, 2);
} catch {
return String(response);
}
}
function getStatusClass(status: string | number): string {
const code = typeof status === 'string' ? parseInt(status) : status;
if (code >= 200 && code < 300) return 'success';
if (code >= 300 && code < 400) return 'redirect';
if (code >= 400 && code < 500) return 'client-error';
if (code >= 500) return 'server-error';
return '';
}
function buildRequestUrl(endpoint: Endpoint): string {
let url = endpoint.path;
const key = endpoint.path + endpoint.method;
const params = testParams.value[key] || {};
// Replace path parameters
for (const [name, value] of Object.entries(params)) {
url = url.replace(`{${name}}`, encodeURIComponent(value));
}
// Add query parameters
const queryParams = endpoint.parameters
?.filter(p => p.in === 'query' && params?.[p.name])
.map(p => `${encodeURIComponent(p.name)}=${encodeURIComponent(params?.[p.name] || '')}`)
.join('&');
if (queryParams) {
url += '?' + queryParams;
}
return url;
}
async function executeRequest(endpoint: Endpoint) {
const key = endpoint.path + endpoint.method;
testing.value = true;
try {
const url = buildRequestUrl(endpoint);
const options: RequestInit = {
method: endpoint.method.toUpperCase(),
headers: {
'Content-Type': 'application/json',
},
};
if (endpoint.requestBody && testBodies.value[key]) {
options.body = testBodies.value[key];
}
const response = await fetch(url, options);
const contentType = response.headers.get('content-type');
let body: any;
if (contentType?.includes('application/json')) {
body = JSON.stringify(await response.json(), null, 2);
} else {
body = await response.text();
}
testResults.value[key] = {
status: response.status,
body,
};
} catch (e: any) {
testResults.value[key] = {
status: 0,
body: `Error: ${e.message}`,
};
} finally {
testing.value = false;
}
}
function copyCurl(endpoint: Endpoint) {
const key = endpoint.path + endpoint.method;
const url = window.location.origin + buildRequestUrl(endpoint);
let curl = `curl -X ${endpoint.method.toUpperCase()} '${url}'`;
if (endpoint.requestBody && testBodies.value[key]) {
curl += ` -H 'Content-Type: application/json'`;
curl += ` -d '${testBodies.value[key]}'`;
}
navigator.clipboard.writeText(curl).then(() => {
alert('Commande cURL copiée dans le presse-papiers !');
});
}
onMounted(() => {
fetchAPIDoc();
});
</script>
<style scoped>
.api-explorer {
padding: 2rem;
max-width: 1400px;
margin: 0 auto;
}
h1 {
font-size: 2rem;
margin-bottom: 1.5rem;
color: #333;
}
h2 {
font-size: 1.5rem;
margin-bottom: 1rem;
color: #444;
border-bottom: 2px solid #ddd;
padding-bottom: 0.5rem;
}
h4 {
font-size: 1.1rem;
margin: 1rem 0 0.5rem;
color: #555;
}
.loading, .error {
padding: 2rem;
text-align: center;
font-size: 1.1rem;
}
.error {
color: #d32f2f;
background: #ffebee;
border-radius: 4px;
}
/* API Info Section */
.api-info {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 2rem;
border-radius: 8px;
margin-bottom: 2rem;
}
.api-info h2 {
color: white;
border: none;
margin-bottom: 0.5rem;
}
.api-info .description {
margin: 1rem 0;
opacity: 0.95;
}
.api-info .metadata {
display: flex;
gap: 1rem;
font-size: 0.9rem;
opacity: 0.9;
}
.api-info .version {
background: rgba(255, 255, 255, 0.2);
padding: 0.25rem 0.75rem;
border-radius: 4px;
}
/* Endpoints Section */
.endpoints {
margin-bottom: 2rem;
}
.endpoint-card {
background: white;
border: 1px solid #ddd;
border-radius: 8px;
margin-bottom: 1rem;
overflow: hidden;
transition: box-shadow 0.2s;
}
.endpoint-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.endpoint-card.expanded {
border-color: #667eea;
}
.endpoint-header {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
cursor: pointer;
background: #f8f9fa;
transition: background 0.2s;
}
.endpoint-header:hover {
background: #e9ecef;
}
.method {
font-weight: bold;
padding: 0.25rem 0.75rem;
border-radius: 4px;
font-size: 0.85rem;
min-width: 70px;
text-align: center;
}
.method.get { background: #4caf50; color: white; }
.method.post { background: #2196f3; color: white; }
.method.put { background: #ff9800; color: white; }
.method.delete { background: #f44336; color: white; }
.method.patch { background: #9c27b0; color: white; }
.path {
font-family: 'Courier New', monospace;
font-weight: 600;
color: #333;
}
.summary {
color: #666;
flex: 1;
}
.arrow {
margin-left: auto;
color: #999;
}
.endpoint-details {
padding: 1.5rem;
background: white;
border-top: 1px solid #eee;
}
.description {
color: #666;
margin-bottom: 1rem;
line-height: 1.6;
}
.tags {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.tag {
background: #e3f2fd;
color: #1976d2;
padding: 0.25rem 0.75rem;
border-radius: 4px;
font-size: 0.85rem;
}
/* Parameters Table */
.parameters table {
width: 100%;
border-collapse: collapse;
margin-top: 0.5rem;
}
.parameters th,
.parameters td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid #eee;
}
.parameters th {
background: #f8f9fa;
font-weight: 600;
color: #555;
}
.parameters code {
background: #f8f9fa;
padding: 0.2rem 0.4rem;
border-radius: 3px;
font-family: 'Courier New', monospace;
font-size: 0.9rem;
}
.param-in {
display: inline-block;
background: #fff3e0;
color: #e65100;
padding: 0.2rem 0.5rem;
border-radius: 3px;
font-size: 0.85rem;
}
/* Schema Viewer */
.schema-viewer pre,
.response-schema pre,
.result-body {
background: #f8f9fa;
padding: 1rem;
border-radius: 4px;
overflow-x: auto;
font-family: 'Courier New', monospace;
font-size: 0.9rem;
line-height: 1.5;
}
/* Responses */
.responses {
margin-top: 1rem;
}
.response-item {
margin-bottom: 1rem;
}
.response-status {
padding: 0.5rem 1rem;
border-radius: 4px;
font-weight: 600;
margin-bottom: 0.5rem;
}
.response-status.success { background: #e8f5e9; color: #2e7d32; }
.response-status.redirect { background: #fff3e0; color: #e65100; }
.response-status.client-error { background: #ffebee; color: #c62828; }
.response-status.server-error { background: #fce4ec; color: #880e4f; }
/* Test Section */
.test-section {
margin-top: 2rem;
padding-top: 2rem;
border-top: 2px solid #eee;
}
.test-params,
.test-body {
margin-bottom: 1rem;
}
.test-params label,
.test-body label {
display: block;
font-weight: 600;
margin-bottom: 0.5rem;
color: #555;
}
.param-input {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 0.5rem;
}
.param-input label {
min-width: 150px;
margin: 0;
font-weight: normal;
}
.param-input input,
.test-body textarea {
flex: 1;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
font-family: 'Courier New', monospace;
}
.test-body textarea {
width: 100%;
font-size: 0.9rem;
resize: vertical;
}
.test-actions {
display: flex;
gap: 1rem;
margin-top: 1rem;
}
.btn-test,
.btn-copy {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-test {
background: #667eea;
color: white;
}
.btn-test:hover:not(:disabled) {
background: #5568d3;
}
.btn-test:disabled {
background: #ccc;
cursor: not-allowed;
}
.btn-copy {
background: #f8f9fa;
color: #333;
border: 1px solid #ddd;
}
.btn-copy:hover {
background: #e9ecef;
}
/* Test Result */
.test-result {
margin-top: 1rem;
padding: 1rem;
background: #f8f9fa;
border-radius: 4px;
}
.result-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
.result-status {
padding: 0.25rem 0.75rem;
border-radius: 4px;
font-weight: 600;
font-size: 0.9rem;
}
/* Schemas Section */
.schemas {
margin-top: 2rem;
}
.schema-card {
background: white;
border: 1px solid #ddd;
border-radius: 8px;
margin-bottom: 1rem;
overflow: hidden;
}
.schema-card.expanded {
border-color: #667eea;
}
.schema-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
cursor: pointer;
background: #f8f9fa;
transition: background 0.2s;
}
.schema-header:hover {
background: #e9ecef;
}
.schema-name {
font-family: 'Courier New', monospace;
font-weight: 600;
color: #333;
}
.schema-details {
padding: 1.5rem;
background: white;
border-top: 1px solid #eee;
}
.schema-details pre {
margin: 0;
}
</style>

View File

@@ -3,14 +3,14 @@ import HelloWorld from "../components/HelloWorld.vue";
import LogView from "../components/LogView.vue";
import CoverCacheManager from "../components/CoverCacheManager.vue";
import UpnpExplorer from "../components/UpnpExplorer.vue";
import APIExplorer from "../components/APIExplorer.vue";
import APIDashboard from "../components/APIDashboard.vue";
const routes = [
{ path: "/", name: "home", component: HelloWorld },
{ path: "/logs", name: "logs", component: LogView },
{ path: "/covers-cache", name: "covers-cache", component: CoverCacheManager },
{ path: "/upnp", name: "upnp", component: UpnpExplorer },
{ path: "/api-explorer", name: "api-explorer", component: APIExplorer },
{ path: "/api-dashboard", name: "api-dashboard", component: APIDashboard },
];
const router = createRouter({