442 lines
13 KiB
Vue
442 lines
13 KiB
Vue
<template>
|
|
<div class="card bg-base-100 shadow-xl">
|
|
<div class="card-body">
|
|
<h2 class="card-title">
|
|
<i class="mdi mdi-shield-account text-2xl text-warning"></i>
|
|
Admin Panel
|
|
</h2>
|
|
|
|
<!-- Stats -->
|
|
<div class="stats stats-vertical lg:stats-horizontal shadow mb-6">
|
|
<div class="stat">
|
|
<div class="stat-title">Total Submissions</div>
|
|
<div class="stat-value text-primary">
|
|
{{ stats.total_submissions }}
|
|
</div>
|
|
</div>
|
|
<div class="stat">
|
|
<div class="stat-title">Total Responses</div>
|
|
<div class="stat-value text-secondary">
|
|
{{ stats.total_responses }}
|
|
</div>
|
|
</div>
|
|
<div class="stat">
|
|
<div class="stat-title">Need Validation</div>
|
|
<div class="stat-value text-warning">
|
|
{{ stats.needs_validation }}
|
|
</div>
|
|
</div>
|
|
<div class="stat">
|
|
<div class="stat-title">Validation Rate</div>
|
|
<div class="stat-value text-success">
|
|
{{ Math.round(stats.validation_rate * 100) }}%
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<button class="btn btn-sm btn-primary" @click="autoValidationResponse">
|
|
<i class="mdi mdi-check-circle mr-1"></i>
|
|
Auto validation for all responses
|
|
</button>
|
|
|
|
<!-- Responses Needing Validation -->
|
|
<div v-if="responsesNeedingValidation.length > 0">
|
|
<h3 class="text-lg font-bold mb-4">Responses Needing Validation</h3>
|
|
|
|
<div class="overflow-x-auto">
|
|
<table class="table table-zebra">
|
|
<thead>
|
|
<tr>
|
|
<th>Puzzle</th>
|
|
<th>OCR Data</th>
|
|
<th>Confidence</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr
|
|
v-for="response in responsesNeedingValidation"
|
|
:key="response.id"
|
|
>
|
|
<td>
|
|
<div class="font-bold">{{ response.puzzle_name }}</div>
|
|
<div class="text-sm opacity-50">ID: {{ response.id }}</div>
|
|
</td>
|
|
<td>
|
|
<div class="text-sm space-y-1">
|
|
<div class="flex justify-between items-center">
|
|
<span>Cost: {{ response.cost || "-" }}</span>
|
|
<span
|
|
v-if="response.ocr_confidence_cost"
|
|
class="badge badge-xs"
|
|
:class="
|
|
getConfidenceBadgeClass(response.ocr_confidence_cost)
|
|
"
|
|
>
|
|
{{ Math.round(response.ocr_confidence_cost * 100) }}%
|
|
</span>
|
|
</div>
|
|
<div class="flex justify-between items-center">
|
|
<span>Cycles: {{ response.cycles || "-" }}</span>
|
|
<span
|
|
v-if="response.ocr_confidence_cycles"
|
|
class="badge badge-xs"
|
|
:class="
|
|
getConfidenceBadgeClass(
|
|
response.ocr_confidence_cycles,
|
|
)
|
|
"
|
|
>
|
|
{{ Math.round(response.ocr_confidence_cycles * 100) }}%
|
|
</span>
|
|
</div>
|
|
<div class="flex justify-between items-center">
|
|
<span>Area: {{ response.area || "-" }}</span>
|
|
<span
|
|
v-if="response.ocr_confidence_area"
|
|
class="badge badge-xs"
|
|
:class="
|
|
getConfidenceBadgeClass(response.ocr_confidence_area)
|
|
"
|
|
>
|
|
{{ Math.round(response.ocr_confidence_area * 100) }}%
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<div class="badge badge-warning badge-sm">
|
|
{{ getOverallConfidence(response) }}%
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<button
|
|
@click="openValidationModal(response)"
|
|
class="btn btn-sm btn-primary mr-2"
|
|
>
|
|
<i class="mdi mdi-check-circle mr-1"></i>
|
|
Validate
|
|
</button>
|
|
<button
|
|
v-if="response.id"
|
|
@click="autoValidation(response.id)"
|
|
class="btn btn-sm btn-warning"
|
|
>
|
|
<i class="mdi mdi-check-circle mr-1"></i>
|
|
Auto Validation
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else class="text-center py-8">
|
|
<i class="mdi mdi-check-all text-6xl text-success opacity-50"></i>
|
|
<p class="text-lg font-medium mt-2">All responses validated!</p>
|
|
<p class="text-sm opacity-70">
|
|
No responses currently need manual validation.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Validation Modal -->
|
|
<div v-if="validationModal.show" class="modal modal-open">
|
|
<div class="modal-box w-11/12 max-w-5xl">
|
|
<h3 class="font-bold text-lg mb-4">Validate Response</h3>
|
|
|
|
<div v-for="file in validationModal.response?.files ?? []">
|
|
<img :src="file.file_url" />
|
|
</div>
|
|
|
|
<div v-if="validationModal.response" class="space-y-4">
|
|
<div class="alert alert-info">
|
|
<i class="mdi mdi-information-outline"></i>
|
|
<div>
|
|
<div class="font-bold">
|
|
{{ validationModal.response.puzzle_name }}
|
|
</div>
|
|
<div class="text-sm">Review and correct the OCR data below</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-4 gap-4">
|
|
<div class="form-control">
|
|
<label class="label">
|
|
<span class="label-text">Puzzle</span>
|
|
</label>
|
|
<select
|
|
v-model="validationModal.data.puzzle"
|
|
class="select select-bordered select-sm w-full"
|
|
>
|
|
<option value="">Select puzzle...</option>
|
|
<option
|
|
v-for="puzzle in puzzlesStore.puzzles"
|
|
:key="puzzle.id"
|
|
:value="puzzle.id"
|
|
>
|
|
{{ puzzle.title }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="form-control">
|
|
<label class="label">
|
|
<span class="label-text">Cost</span>
|
|
</label>
|
|
<input
|
|
v-model="validationModal.data.validated_cost"
|
|
type="text"
|
|
class="input input-bordered input-sm"
|
|
:placeholder="
|
|
validationModal.response.cost?.toString() || 'Enter cost'
|
|
"
|
|
/>
|
|
</div>
|
|
|
|
<div class="form-control">
|
|
<label class="label">
|
|
<span class="label-text">Cycles</span>
|
|
</label>
|
|
<input
|
|
v-model="validationModal.data.validated_cycles"
|
|
type="text"
|
|
class="input input-bordered input-sm"
|
|
:placeholder="
|
|
validationModal.response.cycles?.toString() || 'Enter cycles'
|
|
"
|
|
/>
|
|
</div>
|
|
|
|
<div class="form-control">
|
|
<label class="label">
|
|
<span class="label-text">Area</span>
|
|
</label>
|
|
<input
|
|
v-model="validationModal.data.validated_area"
|
|
type="text"
|
|
class="input input-bordered input-sm"
|
|
:placeholder="
|
|
validationModal.response.area?.toString() || 'Enter area'
|
|
"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal-action">
|
|
<button @click="closeValidationModal" class="btn btn-ghost">
|
|
Cancel
|
|
</button>
|
|
<button
|
|
@click="submitValidation"
|
|
class="btn btn-primary"
|
|
:disabled="isValidating"
|
|
>
|
|
<span
|
|
v-if="isValidating"
|
|
class="loading loading-spinner loading-sm"
|
|
></span>
|
|
{{ isValidating ? "Validating..." : "Validate" }}
|
|
</button>
|
|
</div>
|
|
|
|
<div class="mockup-code w-full">
|
|
<pre><code>{{ validationModal}}</code></pre>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="modal-backdrop" @click="closeValidationModal"></div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, onMounted } from "vue";
|
|
import { apiService } from "@/services/apiService";
|
|
import type { PuzzleResponse } from "@/types";
|
|
import { usePuzzlesStore } from "@/stores/puzzles";
|
|
const puzzlesStore = usePuzzlesStore();
|
|
|
|
// Reactive data
|
|
const stats = ref({
|
|
total_submissions: 0,
|
|
total_responses: 0,
|
|
needs_validation: 0,
|
|
validated_submissions: 0,
|
|
validation_rate: 0,
|
|
});
|
|
|
|
const responsesNeedingValidation = ref<PuzzleResponse[]>([]);
|
|
const isLoading = ref(false);
|
|
const isValidating = ref(false);
|
|
|
|
const validationModal = ref({
|
|
show: false,
|
|
response: null as PuzzleResponse | null,
|
|
data: {
|
|
puzzle: -1,
|
|
validated_cost: 0,
|
|
validated_cycles: 0,
|
|
validated_area: 0,
|
|
},
|
|
});
|
|
|
|
// Methods
|
|
const loadData = async () => {
|
|
try {
|
|
isLoading.value = true;
|
|
|
|
// Load stats (skip if endpoint doesn't exist)
|
|
try {
|
|
const statsResponse = await apiService.getStats();
|
|
if (statsResponse.data) {
|
|
stats.value = statsResponse.data;
|
|
}
|
|
} catch (error) {
|
|
console.warn("Stats endpoint not available:", error);
|
|
// Set default stats
|
|
stats.value = {
|
|
total_submissions: 0,
|
|
total_responses: 0,
|
|
needs_validation: 0,
|
|
validated_submissions: 0,
|
|
validation_rate: 0,
|
|
};
|
|
}
|
|
|
|
// Load responses needing validation
|
|
const responsesResponse = await apiService.getResponsesNeedingValidation();
|
|
if (responsesResponse.data) {
|
|
responsesNeedingValidation.value = responsesResponse.data;
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to load admin data:", error);
|
|
} finally {
|
|
isLoading.value = false;
|
|
}
|
|
};
|
|
|
|
const autoValidationResponse = async () => {
|
|
for (const response of Array.from(responsesNeedingValidation.value)) {
|
|
if (!response.id) {
|
|
continue;
|
|
}
|
|
const { data, error } = await apiService.autoValidateResponses(response.id);
|
|
|
|
if (data && !data.needs_manual_validation) {
|
|
// Remove from validation list
|
|
responsesNeedingValidation.value =
|
|
responsesNeedingValidation.value.filter((r) => r.id !== response.id);
|
|
stats.value.needs_validation -= 1;
|
|
} else if (error) {
|
|
break;
|
|
}
|
|
}
|
|
};
|
|
|
|
const openValidationModal = (response: PuzzleResponse) => {
|
|
validationModal.value.response = response;
|
|
validationModal.value.data = {
|
|
puzzle: response.puzzle_id || -1,
|
|
validated_cost: response.cost || 0,
|
|
validated_cycles: response.cycles || 0,
|
|
validated_area: response.area || 0,
|
|
};
|
|
validationModal.value.show = true;
|
|
};
|
|
|
|
const closeValidationModal = () => {
|
|
validationModal.value.show = false;
|
|
validationModal.value.response = null;
|
|
validationModal.value.data = {
|
|
puzzle: -1,
|
|
validated_cost: 0,
|
|
validated_cycles: 0,
|
|
validated_area: 0,
|
|
};
|
|
};
|
|
|
|
const autoValidation = async (id: number) => {
|
|
const { data } = await apiService.autoValidateResponses(id);
|
|
console.log(data);
|
|
|
|
if (data && !data.needs_manual_validation) {
|
|
// Remove from validation list
|
|
responsesNeedingValidation.value = responsesNeedingValidation.value.filter(
|
|
(r) => r.id !== id,
|
|
);
|
|
console.log(stats.value);
|
|
stats.value.needs_validation -= 1;
|
|
console.log(stats.value);
|
|
}
|
|
};
|
|
|
|
const submitValidation = async () => {
|
|
if (!validationModal.value.response?.id) return;
|
|
|
|
try {
|
|
isValidating.value = true;
|
|
|
|
const response = await apiService.validateResponse(
|
|
validationModal.value.response.id,
|
|
validationModal.value.data,
|
|
);
|
|
|
|
if (response.error) {
|
|
alert(`Validation failed: ${response.error}`);
|
|
return;
|
|
}
|
|
|
|
// Remove from validation list
|
|
responsesNeedingValidation.value = responsesNeedingValidation.value.filter(
|
|
(r) => r.id !== validationModal.value.response?.id,
|
|
);
|
|
|
|
// Update stats
|
|
stats.value.needs_validation = Math.max(
|
|
0,
|
|
stats.value.needs_validation - 1,
|
|
);
|
|
|
|
closeValidationModal();
|
|
} catch (error) {
|
|
console.error("Validation error:", error);
|
|
alert("Validation failed");
|
|
} finally {
|
|
isValidating.value = false;
|
|
}
|
|
};
|
|
|
|
// Lifecycle
|
|
onMounted(() => {
|
|
loadData();
|
|
});
|
|
|
|
// Helper functions for confidence display
|
|
const getConfidenceBadgeClass = (confidence: number): string => {
|
|
if (confidence >= 0.8) return "badge-success";
|
|
if (confidence >= 0.6) return "badge-warning";
|
|
return "badge-error";
|
|
};
|
|
|
|
const getOverallConfidence = (response: PuzzleResponse): number => {
|
|
const confidences = [
|
|
response.ocr_confidence_cost,
|
|
response.ocr_confidence_cycles,
|
|
response.ocr_confidence_area,
|
|
].filter((conf) => conf !== undefined && conf !== null) as number[];
|
|
|
|
if (confidences.length === 0) return 0;
|
|
|
|
const average =
|
|
confidences.reduce((sum, conf) => sum + conf, 0) / confidences.length;
|
|
return Math.round(average * 100);
|
|
};
|
|
|
|
// Expose refresh method
|
|
defineExpose({
|
|
refresh: loadData,
|
|
});
|
|
</script>
|