- Layout canonique px-6 py-8 + sections border-t (suppression container mx-auto) - Toolbar : label titre + bouton retour (ScrapperEdit) + boutons actions (ScrapperConfigurations) - Bouton submit déplacé dans la toolbar droite via defineExpose/ref - ContentSourceForm aplati (suppression du wrapper carte et du header) - Séparation des sections du formulaire par border-t - Suppression de tous les rounded-* sur les 4 composants - Suppression du bloc debug "aucune source" et du h1 volant
334 lines
16 KiB
Vue
334 lines
16 KiB
Vue
<template>
|
|
<div class="flex flex-col h-full">
|
|
<Toolbar :config="toolbarConfig" />
|
|
|
|
<div class="overflow-y-auto flex-1">
|
|
<div class="px-6 py-8">
|
|
<section class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
|
<!-- Loading State -->
|
|
<div v-if="loadingCurrentSource" class="flex justify-center py-12">
|
|
<div class="animate-spin h-12 w-12 border-b-2 border-blue-500"></div>
|
|
</div>
|
|
|
|
<!-- Error State -->
|
|
<div v-else-if="currentSourceError" class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 p-4 mb-6">
|
|
<div class="flex items-center">
|
|
<ExclamationTriangleIcon class="w-5 h-5 text-red-400 mr-2" />
|
|
<p class="text-red-800 dark:text-red-200">{{ currentSourceError }}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Form -->
|
|
<div v-else>
|
|
<ContentSourceForm
|
|
ref="formRef"
|
|
:source="currentSource"
|
|
:saving="saving"
|
|
:error="saveError"
|
|
@submit="handleSubmit"
|
|
@test="handleTest" />
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Test Results Modal -->
|
|
<div v-if="showTestResults" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
|
<div class="bg-white dark:bg-gray-800 shadow-xl w-full max-w-4xl max-h-[80vh] overflow-hidden">
|
|
<div class="p-6 border-b border-gray-200 dark:border-gray-600">
|
|
<div class="flex justify-between items-center">
|
|
<h3 class="text-lg font-semibold">Résultats du test</h3>
|
|
<button
|
|
@click="showTestResults = false"
|
|
class="text-gray-400 hover:text-gray-600">
|
|
<XMarkIcon class="w-6 h-6" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="p-6 overflow-y-auto">
|
|
<!-- Loading state during test -->
|
|
<div v-if="testingConfiguration" class="flex items-center justify-center py-8">
|
|
<div class="animate-spin h-8 w-8 border-b-2 border-blue-500 mr-3"></div>
|
|
<span class="text-gray-600">Test en cours...</span>
|
|
</div>
|
|
|
|
<!-- Success Results -->
|
|
<div v-else-if="testResults.success" class="space-y-4">
|
|
<div class="flex items-center text-green-600 mb-4">
|
|
<CheckCircleIcon class="w-5 h-5 mr-2" />
|
|
<span class="font-medium">Test réussi !</span>
|
|
</div>
|
|
|
|
<div class="bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700 p-4">
|
|
<div class="grid grid-cols-2 gap-4 text-sm">
|
|
<div>
|
|
<span class="font-medium text-green-800 dark:text-green-200">URL testée:</span>
|
|
<div class="text-green-700 dark:text-green-300 break-all">{{ testResults.testedUrl }}</div>
|
|
</div>
|
|
<div>
|
|
<span class="font-medium text-green-800 dark:text-green-200">Type de scraping:</span>
|
|
<div class="text-green-700 dark:text-green-300">{{ testResults.scrapingType }}</div>
|
|
</div>
|
|
<div>
|
|
<span class="font-medium text-green-800 dark:text-green-200">Images trouvées:</span>
|
|
<div class="text-green-700 dark:text-green-300">{{ testResults.totalImages || testResults.imageUrls?.length || 0 }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="testResults.imageUrls && testResults.imageUrls.length > 0">
|
|
<h4 class="font-medium mb-3">Aperçu des images trouvées :</h4>
|
|
<div class="grid grid-cols-3 gap-3 max-h-96 overflow-y-auto">
|
|
<div
|
|
v-for="(imageUrl, index) in testResults.imageUrls.slice(0, 12)"
|
|
:key="index"
|
|
class="relative group">
|
|
<img
|
|
:src="imageUrl"
|
|
:alt="`Image ${index + 1}`"
|
|
class="w-full h-32 object-cover border border-gray-200 dark:border-gray-600"
|
|
@error="handleImageError"
|
|
@load="handleImageLoad" />
|
|
<div class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-30 transition-opacity flex items-center justify-center">
|
|
<span class="text-white opacity-0 group-hover:opacity-100 text-sm font-medium">
|
|
Page {{ index + 1 }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<p v-if="testResults.imageUrls.length > 12" class="text-sm text-gray-500 mt-3 text-center">
|
|
Et {{ testResults.imageUrls.length - 12 }} autres images...
|
|
</p>
|
|
</div>
|
|
|
|
<div v-else class="bg-yellow-50 dark:bg-yellow-900 border border-yellow-200 dark:border-yellow-700 p-4">
|
|
<div class="flex items-center">
|
|
<ExclamationTriangleIcon class="w-5 h-5 text-yellow-400 mr-2" />
|
|
<p class="text-yellow-800 dark:text-yellow-200">
|
|
Le test s'est déroulé sans erreur mais aucune image n'a été trouvée.
|
|
Vérifiez vos sélecteurs CSS.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Error Results -->
|
|
<div v-else class="space-y-4">
|
|
<div class="flex items-center text-red-600 mb-4">
|
|
<XCircleIcon class="w-5 h-5 mr-2" />
|
|
<span class="font-medium">Test échoué</span>
|
|
</div>
|
|
|
|
<div class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 p-4 mb-4">
|
|
<div class="text-sm text-red-800 dark:text-red-200">
|
|
<div><strong>URL testée:</strong> {{ testResults.testedUrl || 'N/A' }}</div>
|
|
<div><strong>Type de scraping:</strong> {{ testResults.scrapingType || 'N/A' }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Detailed Errors -->
|
|
<div v-if="testResults.errors && testResults.errors.length > 0" class="space-y-3">
|
|
<h4 class="font-medium text-red-800 dark:text-red-200">Erreurs détaillées :</h4>
|
|
<div
|
|
v-for="(error, index) in testResults.errors"
|
|
:key="index"
|
|
class="bg-red-100 dark:bg-red-800 border-l-4 border-red-400 p-4">
|
|
<div class="flex items-start">
|
|
<div class="flex-shrink-0">
|
|
<ExclamationTriangleIcon class="w-5 h-5 text-red-400" />
|
|
</div>
|
|
<div class="ml-3 flex-1">
|
|
<div class="flex items-center mb-1">
|
|
<span class="inline-flex items-center px-2 py-1 text-xs font-medium bg-red-200 text-red-800 dark:bg-red-700 dark:text-red-200 mr-2">
|
|
{{ formatErrorType(error.type) }}
|
|
</span>
|
|
<span class="text-sm font-medium text-red-800 dark:text-red-200">
|
|
{{ error.field }}
|
|
</span>
|
|
</div>
|
|
<p class="text-sm text-red-700 dark:text-red-300 mb-2">
|
|
{{ error.message }}
|
|
</p>
|
|
<div class="bg-red-50 dark:bg-red-900 p-2">
|
|
<p class="text-xs text-red-600 dark:text-red-400">
|
|
<strong>Suggestion :</strong> {{ error.suggestion }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Generic Error -->
|
|
<div v-else-if="testResults.error" class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 p-3">
|
|
<code class="text-sm text-red-800 dark:text-red-200">
|
|
{{ testResults.error }}
|
|
</code>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Success Message -->
|
|
<div v-if="showSuccessMessage" class="fixed bottom-4 right-4 bg-green-500 text-white px-4 py-2 shadow-lg">
|
|
Configuration {{ isEditing ? 'mise à jour' : 'créée' }} avec succès !
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import {
|
|
ArrowLeftIcon,
|
|
CheckCircleIcon,
|
|
ExclamationTriangleIcon,
|
|
PencilSquareIcon,
|
|
XCircleIcon,
|
|
XMarkIcon
|
|
} from '@heroicons/vue/24/outline';
|
|
import { storeToRefs } from 'pinia';
|
|
import { computed, onMounted, ref } from 'vue';
|
|
import { useRoute, useRouter } from 'vue-router';
|
|
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
|
import { useContentSourceStore } from '../../application/store/contentSourceStore';
|
|
import { ApiContentSourceRepository } from '../../infrastructure/api/apiContentSourceRepository';
|
|
import ContentSourceForm from '../components/ContentSourceForm.vue';
|
|
|
|
const route = useRoute();
|
|
const router = useRouter();
|
|
const contentSourceStore = useContentSourceStore();
|
|
const contentSourceRepository = new ApiContentSourceRepository();
|
|
|
|
const {
|
|
currentSource,
|
|
loadingCurrentSource,
|
|
currentSourceError,
|
|
saving,
|
|
saveError
|
|
} = storeToRefs(contentSourceStore);
|
|
|
|
// Form ref
|
|
const formRef = ref(null);
|
|
|
|
// Local state
|
|
const showTestResults = ref(false);
|
|
const showSuccessMessage = ref(false);
|
|
const testResults = ref({});
|
|
const testingConfiguration = ref(false);
|
|
|
|
const isEditing = computed(() => !!route.params.id);
|
|
|
|
// Load source if editing, clear if creating new
|
|
onMounted(async () => {
|
|
if (isEditing.value) {
|
|
await contentSourceStore.loadSource(route.params.id);
|
|
} else {
|
|
// Clear current source immediately when creating new
|
|
contentSourceStore.clearCurrentSource();
|
|
}
|
|
});
|
|
|
|
// Toolbar configuration
|
|
const toolbarConfig = computed(() => ({
|
|
leftSection: [
|
|
{ type: 'button', icon: ArrowLeftIcon, label: 'Retour', onClick: () => router.push({ name: 'scrapper-configurations' }) },
|
|
{ type: 'divider' },
|
|
{ type: 'label', text: isEditing.value ? 'Modifier la configuration' : 'Nouvelle configuration', class: 'text-sm font-medium' },
|
|
],
|
|
rightSection: [
|
|
{ type: 'button', icon: PencilSquareIcon, label: isEditing.value ? 'Mettre à jour' : 'Créer', onClick: () => formRef.value?.submitForm(), disabled: saving.value },
|
|
],
|
|
}));
|
|
|
|
// Actions
|
|
const handleSubmit = async (formData) => {
|
|
try {
|
|
if (isEditing.value) {
|
|
await contentSourceStore.updateSource(route.params.id, formData);
|
|
} else {
|
|
await contentSourceStore.createSource(formData);
|
|
}
|
|
|
|
// Clear current source and errors before redirecting
|
|
contentSourceStore.clearCurrentSource();
|
|
contentSourceStore.clearErrors();
|
|
|
|
// Show success message briefly then redirect
|
|
showSuccessMessage.value = true;
|
|
|
|
// Use nextTick to ensure the DOM is updated before redirecting
|
|
await new Promise(resolve => setTimeout(resolve, 1500));
|
|
|
|
// Navigate back to list
|
|
await router.push({ name: 'scrapper-configurations' });
|
|
|
|
// Hide success message after navigation
|
|
showSuccessMessage.value = false;
|
|
|
|
} catch (error) {
|
|
console.error('Erreur lors de la sauvegarde:', error);
|
|
// Don't redirect if there's an error
|
|
}
|
|
};
|
|
|
|
const handleTest = async ({ configuration, testData }) => {
|
|
testingConfiguration.value = true;
|
|
showTestResults.value = true;
|
|
testResults.value = {};
|
|
|
|
try {
|
|
// Préparer les données selon le format de l'API
|
|
const testConfiguration = {
|
|
baseUrl: configuration.baseUrl,
|
|
chapterUrlFormat: configuration.chapterUrlFormat,
|
|
scrapingType: configuration.scrapingType?.toLowerCase() || 'html',
|
|
testUrl: testData.testUrl,
|
|
mangaSlug: testData.mangaSlug,
|
|
chapterNumber: parseFloat(testData.chapterNumber),
|
|
imageSelector: configuration.imageSelector || null,
|
|
nextPageSelector: configuration.nextPageSelector || null,
|
|
chapterSelector: configuration.chapterSelector || null
|
|
};
|
|
|
|
console.log('Envoi de la configuration de test:', testConfiguration);
|
|
|
|
const response = await contentSourceRepository.testConfiguration(testConfiguration);
|
|
|
|
testResults.value = response;
|
|
console.log('Résultats du test:', response);
|
|
|
|
} catch (error) {
|
|
console.error('Erreur lors du test:', error);
|
|
testResults.value = {
|
|
success: false,
|
|
error: error.message,
|
|
testedUrl: testData.testUrl,
|
|
scrapingType: configuration.scrapingType?.toLowerCase() || 'html',
|
|
errors: []
|
|
};
|
|
} finally {
|
|
testingConfiguration.value = false;
|
|
}
|
|
};
|
|
|
|
const handleImageError = (event) => {
|
|
// Hide broken images
|
|
event.target.style.display = 'none';
|
|
};
|
|
|
|
const handleImageLoad = (event) => {
|
|
// Ensure loaded images are visible
|
|
event.target.style.display = 'block';
|
|
};
|
|
|
|
const formatErrorType = (type) => {
|
|
const typeMap = {
|
|
'selector_error': 'Erreur sélecteur',
|
|
'url_error': 'Erreur URL',
|
|
'general_error': 'Erreur générale'
|
|
};
|
|
return typeMap[type] || type;
|
|
};
|
|
</script>
|