use api in front

This commit is contained in:
2025-10-29 02:57:10 +01:00
parent 07dd1bc0ff
commit 52723b200a
14 changed files with 900 additions and 263 deletions
@@ -0,0 +1,268 @@
<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>
<!-- 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>Cost: {{ response.cost || '-' }}</div>
<div>Cycles: {{ response.cycles || '-' }}</div>
<div>Area: {{ response.area || '-' }}</div>
</div>
</td>
<td>
<div class="badge badge-warning badge-sm">
{{ response.ocr_confidence_score ? Math.round(response.ocr_confidence_score * 100) + '%' : 'Low' }}
</div>
</td>
<td>
<button
@click="openValidationModal(response)"
class="btn btn-sm btn-primary"
>
<i class="mdi mdi-check-circle mr-1"></i>
Validate
</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">
<h3 class="font-bold text-lg mb-4">Validate Response</h3>
<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-3 gap-4">
<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 || '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 || '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 || '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>
</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'
// 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: {
validated_cost: '',
validated_cycles: '',
validated_area: ''
}
})
// Methods
const loadData = async () => {
try {
isLoading.value = true
// Load stats
const statsResponse = await apiService.getStats()
if (statsResponse.data) {
stats.value = statsResponse.data
}
// 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 openValidationModal = (response: PuzzleResponse) => {
validationModal.value.response = response
validationModal.value.data = {
validated_cost: response.cost || '',
validated_cycles: response.cycles || '',
validated_area: response.area || ''
}
validationModal.value.show = true
}
const closeValidationModal = () => {
validationModal.value.show = false
validationModal.value.response = null
validationModal.value.data = {
validated_cost: '',
validated_cycles: '',
validated_area: ''
}
}
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()
})
// Expose refresh method
defineExpose({
refresh: loadData
})
</script>
+10 -1
View File
@@ -143,10 +143,11 @@
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
import { ocrService, type OpusMagnumData } from '../services/ocrService'
import type { SubmissionFile } from '@/types'
import type { SubmissionFile, SteamCollectionItem } from '@/types'
interface Props {
modelValue: SubmissionFile[]
puzzles?: SteamCollectionItem[]
}
interface Emits {
@@ -171,6 +172,14 @@ watch(files, (newFiles) => {
emit('update:modelValue', newFiles)
}, { deep: true })
// Watch for puzzle changes and update OCR service
watch(() => props.puzzles, (newPuzzles) => {
if (newPuzzles && newPuzzles.length > 0) {
const puzzleNames = newPuzzles.map(puzzle => puzzle.title)
ocrService.setAvailablePuzzleNames(puzzleNames)
}
}, { immediate: true })
const handleFileSelect = (event: Event) => {
const target = event.target as HTMLInputElement
if (target.files) {
+11 -8
View File
@@ -61,29 +61,32 @@
<tbody>
<tr v-for="response in responses" :key="response.id" class="hover">
<td>
<span v-if="response.cost" class="badge badge-success badge-xs">
{{ response.cost }}
<span v-if="response.final_cost || response.cost" class="badge badge-success badge-xs">
{{ response.final_cost || response.cost }}
</span>
<span v-else class="text-base-content/50">-</span>
</td>
<td>
<span v-if="response.cycles" class="badge badge-info badge-xs">
{{ response.cycles }}
<span v-if="response.final_cycles || response.cycles" class="badge badge-info badge-xs">
{{ response.final_cycles || response.cycles }}
</span>
<span v-else class="text-base-content/50">-</span>
</td>
<td>
<span v-if="response.area" class="badge badge-warning badge-xs">
{{ response.area }}
<span v-if="response.final_area || response.area" class="badge badge-warning badge-xs">
{{ response.final_area || response.area }}
</span>
<span v-else class="text-base-content/50">-</span>
</td>
<td>
<div class="flex items-center gap-1">
<span class="badge badge-ghost badge-xs">{{ response.files.length }}</span>
<div class="tooltip" :data-tip="response.files.map(f => f.file.name).join(', ')">
<span class="badge badge-ghost badge-xs">{{ response.files?.length || 0 }}</span>
<div v-if="response.files?.length" class="tooltip" :data-tip="response.files.map(f => f.original_filename || f.file?.name).join(', ')">
<i class="mdi mdi-information-outline text-xs"></i>
</div>
<div v-if="response.needs_manual_validation" class="tooltip" data-tip="Needs manual validation">
<i class="mdi mdi-alert-circle text-xs text-warning"></i>
</div>
</div>
</td>
</tr>
@@ -22,7 +22,7 @@
</div>
<!-- File Upload -->
<FileUpload v-model="submissionFiles" />
<FileUpload v-model="submissionFiles" :puzzles="puzzles" />
<!-- Notes -->
<div class="form-control">
@@ -57,7 +57,7 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import FileUpload from './FileUpload.vue'
import type { SteamCollectionItem, SubmissionFile, Submission, PuzzleResponse } from '@/types'
import type { SteamCollectionItem, SubmissionFile } from '@/types'
interface Props {
puzzles: SteamCollectionItem[]
@@ -65,7 +65,7 @@ interface Props {
}
interface Emits {
submit: [submission: Submission]
submit: [submissionData: { files: SubmissionFile[], notes?: string }]
}
const props = defineProps<Props>()
@@ -108,31 +108,11 @@ const handleSubmit = async () => {
isSubmitting.value = true
try {
const responses: PuzzleResponse[] = []
// Create responses for each detected puzzle
Object.entries(responsesByPuzzle.value).forEach(([puzzleName, data]) => {
if (data.puzzle) {
// Get OCR data from the first file with complete data
const fileWithOCR = data.files.find(f => f.ocrData?.cost || f.ocrData?.cycles || f.ocrData?.area)
responses.push({
puzzle_id: data.puzzle.id,
puzzle_name: puzzleName,
cost: fileWithOCR?.ocrData?.cost,
cycles: fileWithOCR?.ocrData?.cycles,
area: fileWithOCR?.ocrData?.area,
files: data.files
})
}
})
const submission: Submission = {
responses,
// Emit the files and notes for the parent to handle API submission
emit('submit', {
files: submissionFiles.value,
notes: notes.value.trim() || undefined
}
emit('submit', submission)
})
// Reset form
submissionFiles.value = []