refactor(api): 统一前端API调用使用apiFetch并优化错误处理

refactor: 替换直接fetch调用为apiFetch以统一处理错误和响应
fix(server): 改进QR码验证的错误消息和密码哈希处理
This commit is contained in:
sudomarcma
2025-06-26 11:45:14 +08:00
parent e563f17283
commit 5e3015ba4f
8 changed files with 184 additions and 198 deletions
+14 -4
View File
@@ -95,11 +95,21 @@ async function startServer() {
app.post('/api/clock', authenticateJWT, async (req, res) => {
try {
const { userId, eventType, qrCodeValue, latitude, longitude } = req.body
const [qrRows] = await db.execute('SELECT name, is_active FROM qr_codes WHERE id = ?', [
qrCodeValue,
])
if (qrRows.length === 0 || !qrRows[0].is_active) {
return res.status(400).json({ message: 'Invalid or inactive QR Code.' })
if (qrRows.length === 0) {
// This code is not in the database at all.
return res.status(400).json({ message: 'Invalid QR Code scanned.' })
}
if (!qrRows[0].is_active) {
// This code exists but has been deactivated.
return res
.status(400)
.json({ message: 'This QR Code has expired and is no longer active.' })
}
const [lastEventRows] = await db.execute(
'SELECT event_type FROM clock_records WHERE worker_id = ? ORDER BY timestamp DESC LIMIT 1',
@@ -204,8 +214,8 @@ async function startServer() {
if (!username || !password || !fullName) {
return res.status(400).json({ message: 'Username, password, and full name are required.' })
}
const saltRounds = 10;
const hashedPassword = await bcrypt.hash(password, saltRounds);
const saltRounds = 10
const hashedPassword = await bcrypt.hash(password, saltRounds)
const [result] = await db.execute(
"INSERT INTO workers (username, password_hash, full_name, role) VALUES (?, ?, ?, 'worker')",
[username, hashedPassword, fullName],
+6 -1
View File
@@ -19,7 +19,12 @@ export async function apiFetch(endpoint, options = {}) {
})
if (!response.ok) {
throw new Error(`API call failed with status: ${response.status}`)
// Try to parse the error response body from the server
const errorData = await response.json()
throw new Error(errorData.message || `API call failed with status: ${response.status}`)
}
if (response.status === 204) {
return null
}
return response.json()
+31 -51
View File
@@ -223,6 +223,8 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
// CORRECT: Import the apiFetch wrapper
import { apiFetch } from '@/api.js'
// --- STATE ---
const searchQuery = ref('')
@@ -305,42 +307,30 @@ const calendarGrid = computed(() => {
})
const handleSearch = async () => {
// SCENARIO 1: User presses Enter on a highlighted item in the list
if (highlightedIndex.value >= 0 && searchResults.value[highlightedIndex.value]) {
selectWorker(searchResults.value[highlightedIndex.value])
}
// SCENARIO 2: User presses Enter in an empty search box
else if (searchQuery.value === '') {
} else if (searchQuery.value === '') {
await handleSelectAll()
searchResults.value = []
highlightedIndex.value = -1
}
// SCENARIO 3: User types a query and presses Enter for the first time
else {
// This fetches the list of workers so you can navigate it
} else {
await fetchWorkers()
}
}
const fetchWorkers = async (selectAll = false) => {
// ... this function's internal logic remains the same
try {
const res = await fetch(
`${import.meta.env.VITE_API_BASE_URL}/api/managers/workers?search=${searchQuery.value}&limit=1000`,
)
if (res.ok) {
const data = await res.json()
if (selectAll) {
data.workers.forEach((worker) => {
if (!selectedWorkers.value.some((w) => w.id === worker.id)) {
selectedWorkers.value.push(worker)
}
})
} else {
searchResults.value = data.workers
// Set highlighted index to the first item, or -1 if no results
highlightedIndex.value = data.workers.length > 0 ? 0 : -1
}
// CORRECT: Use apiFetch and get data directly
const data = await apiFetch(`/api/managers/workers?search=${searchQuery.value}&limit=1000`)
if (selectAll) {
data.workers.forEach((worker) => {
if (!selectedWorkers.value.some((w) => w.id === worker.id)) {
selectedWorkers.value.push(worker)
}
})
} else {
searchResults.value = data.workers
highlightedIndex.value = data.workers.length > 0 ? 0 : -1
}
} catch (err) {
console.error('Failed to search workers', err)
@@ -361,11 +351,10 @@ const navigateResults = (direction) => {
}
}
const handleSelectAll = async () => {
await fetchWorkers(true) // This function already fetches and selects all workers
isSelectAllActive.value = true // Activate the summary view
await fetchWorkers(true)
isSelectAllActive.value = true
}
// vvv ADD THIS NEW METHOD vvv
const clearAllSelection = () => {
selectedWorkers.value = []
isSelectAllActive.value = false
@@ -375,14 +364,13 @@ const selectWorker = (worker) => {
if (!selectedWorkers.value.some((w) => w.id === worker.id)) {
selectedWorkers.value.push(worker)
}
isSelectAllActive.value = false // Turn off "Select All" mode
clearSearch() // Clear the search box and results after selection
isSelectAllActive.value = false
clearSearch()
}
// vvv MODIFY THIS METHOD vvv
const removeWorker = (workerId) => {
selectedWorkers.value = selectedWorkers.value.filter((w) => w.id !== workerId)
isSelectAllActive.value = false // Deactivate summary view when removing individually
isSelectAllActive.value = false
}
const generateReport = async () => {
@@ -396,16 +384,14 @@ const generateReport = async () => {
overtimeReport.value = null
const workerIds = selectedWorkers.value.map((w) => w.id).join(',')
const url = `${import.meta.env.VITE_API_BASE_URL}/api/managers/attendance-records?workerIds=${workerIds}&startDate=${filters.value.startDate}&endDate=${filters.value.endDate}`
const url = `/api/managers/attendance-records?workerIds=${workerIds}&startDate=${filters.value.startDate}&endDate=${filters.value.endDate}`
try {
const res = await fetch(url)
if (!res.ok) throw new Error('Failed to fetch attendance records')
const fetchedRecords = await res.json()
// CORRECT: Use apiFetch to get records directly
const fetchedRecords = await apiFetch(url)
reportData.value = fetchedRecords
// --- OT CALCULATION FOR UI SUMMARY ---
// OT CALCULATION LOGIC REMAINS THE SAME
const otResults = {}
const hourlyRate = monthlySalary.value / 26 / 8
@@ -433,7 +419,7 @@ const generateReport = async () => {
const otPay = hours * hourlyRate * factor
dailyBreakdown.push({ hours, otPay })
}
clockInTime = null // Reset for the next session
clockInTime = null
}
}
@@ -451,7 +437,7 @@ const generateReport = async () => {
}
}
// --- UPDATED CSV EXPORT METHOD ---
// CSV EXPORT LOGIC REMAINS THE SAME
const exportOtSummaryCsv = () => {
if (reportData.value.length === 0) return
@@ -488,7 +474,7 @@ const exportOtSummaryCsv = () => {
if (!clockInRecord) {
clockInRecord = record
}
row.push('', '') // Push empty cells for hours and wage on clock-in
row.push('', '')
allRows.push(row.join(','))
} else if (record.event_type === 'clock_out') {
if (clockInRecord) {
@@ -503,7 +489,7 @@ const exportOtSummaryCsv = () => {
? overtimeSettings.value.publicHolidayFactor
: overtimeSettings.value.restDayFactor
const otPay = hours * hourlyRate * factor
workerTotalWage += otPay // Accumulate wage for summary
workerTotalWage += otPay
row.push(hours.toFixed(2), otPay.toFixed(2))
} else {
@@ -517,25 +503,19 @@ const exportOtSummaryCsv = () => {
}
}
// Add summary row for the worker
const summaryRow = [`"${worker.full_name} Total"`, '', '', '', '', workerTotalWage.toFixed(2)]
allRows.push(summaryRow.join(','))
// Add empty row for separation
allRows.push('')
}
// Remove the last empty row
if (allRows.length > 0) {
allRows.pop()
}
// --- CHANGED SECTION FOR FILENAME ---
// Create a formatted timestamp for the filename
const now = new Date()
const timestamp = now.toISOString().slice(0, 19).replace('T', '_').replace(/:/g, '-')
const fileName = `${timestamp}_report.csv` // e.g., 2025-06-16_16-46-25_report.csv
// --- END OF CHANGED SECTION ---
const fileName = `${timestamp}_report.csv`
let csvContent = headers.join(',') + '\n' + allRows.join('\n')
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
@@ -549,7 +529,7 @@ const exportOtSummaryCsv = () => {
document.body.removeChild(link)
}
// --- CALENDAR METHODS ---
// CALENDAR METHODS REMAIN THE SAME
const changeMonth = (offset) => {
const newDate = new Date(calendarDate.value)
newDate.setMonth(newDate.getMonth() + offset)
@@ -576,7 +556,7 @@ onMounted(() => {
</script>
<style scoped>
/* Add these media queries */
/* STYLES REMAIN UNCHANGED */
@media (max-width: 768px) {
.attendance-reporting-layout {
flex-direction: column;
+32 -36
View File
@@ -44,7 +44,6 @@
<tr v-for="qr in qrCodes" :key="qr.id">
<td>{{ qr.name }}</td>
<td>
<!-- FIX #1: Use qr.is_active instead of qr.isActive -->
<span class="status-badge" :class="qr.is_active ? 'active' : 'inactive'">
{{ qr.is_active ? 'Active' : 'Inactive' }}
</span>
@@ -57,7 +56,6 @@
>
<span></span> Download
</button>
<!-- FIX #1: Use qr.is_active instead of qr.isActive -->
<button @click="toggleQrStatus(qr)" class="button-secondary">
{{ qr.is_active ? 'Deactivate' : 'Activate' }}
</button>
@@ -75,6 +73,7 @@
import { ref, onMounted, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import QRCode from 'qrcode'
import { apiFetch } from '@/api.js'
const router = useRouter()
const qrCodes = ref([])
@@ -93,8 +92,9 @@ onMounted(() => {
const fetchQrCodes = async () => {
try {
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/managers/qr-codes`)
qrCodes.value = await res.json()
// CORRECT: Get the data directly from apiFetch
const data = await apiFetch('/api/managers/qr-codes')
qrCodes.value = data
} catch (err) {
console.error('Failed to fetch QR codes:', err)
}
@@ -103,26 +103,25 @@ const fetchQrCodes = async () => {
const addQrCode = async () => {
if (!newQrName.value) return
try {
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/managers/qr-codes`, {
// CORRECT: Get the new QR object directly
const newQr = await apiFetch('/api/managers/qr-codes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newQrName.value }),
})
if (res.ok) {
const newQr = await res.json()
qrCodes.value.unshift(newQr)
newlyGeneratedQr.value = newQr
newQrName.value = ''
await nextTick()
QRCode.toCanvas(
newQrCanvas.value,
newQr.id,
{ width: 220, margin: 2, color: { dark: '#050505', light: '#FFFFFF' } },
(error) => {
if (error) console.error(error)
},
)
}
qrCodes.value.unshift(newQr)
newlyGeneratedQr.value = newQr
newQrName.value = ''
await nextTick()
QRCode.toCanvas(
newQrCanvas.value,
newQr.id,
{ width: 220, margin: 2, color: { dark: '#050505', light: '#FFFFFF' } },
(error) => {
if (error) console.error(error)
},
)
} catch (err) {
console.error('Failed to add QR code:', err)
}
@@ -130,19 +129,16 @@ const addQrCode = async () => {
const toggleQrStatus = async (qr) => {
try {
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/managers/qr-codes/${qr.id}`, {
// CORRECT: No need to check response, catch block will handle errors
await apiFetch(`/api/managers/qr-codes/${qr.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
// Send the opposite of the current status
body: JSON.stringify({ isActive: !qr.is_active }),
})
if (res.ok) {
// FIX #2: Instead of replacing the object, just update the property.
// This preserves the 'name' and other properties of the object.
const index = qrCodes.value.findIndex((q) => q.id === qr.id)
if (index !== -1) {
qrCodes.value[index].is_active = !qrCodes.value[index].is_active
}
// Update status locally on success
const index = qrCodes.value.findIndex((q) => q.id === qr.id)
if (index !== -1) {
qrCodes.value[index].is_active = !qrCodes.value[index].is_active
}
} catch (err) {
console.error('Failed to update QR status:', err)
@@ -154,12 +150,12 @@ const deleteQrCode = async (id) => {
return
}
try {
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/managers/qr-codes/${id}`, {
// CORRECT: No need to check response, just await the call
await apiFetch(`/api/managers/qr-codes/${id}`, {
method: 'DELETE',
})
if (res.ok) {
qrCodes.value = qrCodes.value.filter((qr) => qr.id !== id)
}
// Filter out the deleted QR code on success
qrCodes.value = qrCodes.value.filter((qr) => qr.id !== id)
} catch (err) {
console.error('Failed to delete QR code:', err)
}
@@ -187,7 +183,7 @@ const downloadQrCode = async (qr) => {
</script>
<style scoped>
/* Styles remain the same */
/* STYLES REMAIN UNCHANGED */
.qr-management-container {
display: flex;
flex-direction: column;
+23 -38
View File
@@ -101,6 +101,7 @@ const route = useRoute()
const records = ref([])
const workerName = ref('')
const workerId = route.params.workerId
import { apiFetch } from '@/api.js'
// Get the current date and time in the required format for datetime-local input
const toLocalISOString = (date) => {
@@ -126,42 +127,30 @@ const filters = ref({
})
const fetchRecords = async () => {
// Ensure we have a worker name, fetch if not
if (!workerName.value) {
try {
const workerRes = await fetch(
`${import.meta.env.VITE_API_BASE_URL}/api/managers/worker/${workerId}`,
)
if (workerRes.ok) {
const workerData = await workerRes.json()
workerName.value = workerData.full_name
}
} catch (err) {
console.error('Failed to fetch worker name:', err)
workerName.value = `Worker #${workerId}` // Fallback name
}
}
// Fetch attendance records
let url = `${import.meta.env.VITE_API_BASE_URL}/api/managers/attendance-records?workerIds=${workerId}`
let url = `/api/managers/attendance-records?workerIds=${workerId}`
if (filters.value.startDate && filters.value.endDate) {
url += `&startDate=${filters.value.startDate}&endDate=${filters.value.endDate}`
}
try {
const res = await fetch(url)
if (res.ok) {
records.value = await res.json()
// If the worker name hasn't been set yet and records are found, set it.
if (!workerName.value && records.value.length > 0) {
workerName.value = records.value[0].full_name
// Correct: 'data' is the JSON array returned directly from apiFetch.
const data = await apiFetch(url)
if (data && Array.isArray(data)) {
records.value = data
if (!workerName.value && data.length > 0) {
workerName.value = data[0].full_name
}
} else {
records.value = []
}
} catch (err) {
console.error('Failed to fetch attendance records:', err)
alert(err.message) // The error thrown by apiFetch will be caught here.
records.value = []
}
}
// New method to add a manual clock-out record
const addManualClockOut = async () => {
if (!manualClockOut.value.timestamp) {
alert('Please select a timestamp for the clock-out.')
@@ -173,7 +162,9 @@ const addManualClockOut = async () => {
}
try {
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/managers/add-record`, {
// FIX: Removed "const resData =" as the variable is not used.
// We just need to wait for the API call to complete successfully.
await apiFetch('/api/managers/add-record', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -186,20 +177,14 @@ const addManualClockOut = async () => {
}),
})
const data = await res.json()
if (res.ok) {
alert('Manual clock-out recorded successfully!')
// Reset form and refresh the records
manualClockOut.value.notes = ''
manualClockOut.value.timestamp = toLocalISOString(new Date())
fetchRecords()
} else {
alert(`Failed to add record: ${data.message}`)
}
// If the line above completes without an error, the call was successful.
alert('Manual clock-out recorded successfully!')
manualClockOut.value.notes = ''
manualClockOut.value.timestamp = toLocalISOString(new Date())
fetchRecords() // Refresh the records list
} catch (err) {
console.error('Failed to submit manual clock-out:', err)
alert('An error occurred while submitting the record.')
alert(`An error occurred: ${err.message}`)
}
}
+1 -1
View File
@@ -56,7 +56,7 @@ const handleLogin = async () => {
} else if (decodedToken.role === 'manager') {
router.push('/manager/dashboard')
}
} catch (e) {
} catch {
error.value = 'Invalid token received from server.'
}
} else {
+69 -61
View File
@@ -38,6 +38,7 @@
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
import { Html5Qrcode } from 'html5-qrcode'
import { useRouter } from 'vue-router'
import { apiFetch } from '@/api.js'
let html5QrCode = null
const fileInput = ref(null)
@@ -58,23 +59,51 @@ const clockStatus = computed(() => (isClockedIn.value ? 'Clocked In' : 'Clocked
//fetch worker name
const fetchWorkerDetails = async () => {
try {
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/workers/${userId}`)
if (!response.ok) return
const data = await response.json()
workerName.value = data.full_name
} catch {
errorMessage.value = 'Could not load worker information.'
// CORRECT: 'data' is the JSON object returned directly by apiFetch
const data = await apiFetch(`/api/workers/${userId}`)
if (data) {
workerName.value = data.full_name
}
} catch (err) {
errorMessage.value = `Could not load worker information: ${err.message}`
}
}
const fetchCurrentStatus = async () => {
try {
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/worker/status/${userId}`)
if (!response.ok) return
const lastEvent = await response.json()
isClockedIn.value = lastEvent.eventType === 'clock_in'
} catch {
errorMessage.value = 'Could not verify current status from server.'
// CORRECT: 'lastEvent' is the JSON object
const lastEvent = await apiFetch(`/api/worker/status/${userId}`)
if (lastEvent) {
isClockedIn.value = lastEvent.eventType === 'clock_in'
}
} catch (err) {
errorMessage.value = `Could not verify current status from server: ${err.message}`
}
}
const sendClockEvent = async (qrCodeValue, latitude, longitude) => {
const eventType = isClockedIn.value ? 'clock_out' : 'clock_in'
try {
// CORRECT: 'data' is the JSON response from the server
const data = await apiFetch('/api/clock', {
method: 'POST',
body: JSON.stringify({
userId: userId,
eventType,
qrCodeValue,
latitude,
longitude,
}),
})
// If the call succeeds, the code proceeds here. If it fails, the catch block is executed.
isClockedIn.value = !isClockedIn.value
successMessage.value = `Successfully clocked ${eventType.replace('_', ' ')} at ${
data.location || 'site' // Assuming the response might contain location info
}.`
} catch (err) {
// The error message from apiFetch or the server is caught here
errorMessage.value = `Error: ${err.message}`
}
}
@@ -94,33 +123,6 @@ onBeforeUnmount(() => {
}
})
const sendClockEvent = async (qrCodeValue, latitude, longitude) => {
const eventType = isClockedIn.value ? 'clock_out' : 'clock_in'
try {
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/clock`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId: userId,
eventType,
qrCodeValue,
latitude,
longitude,
}),
})
const data = await response.json()
if (response.ok) {
isClockedIn.value = !isClockedIn.value
successMessage.value = `Successfully clocked ${eventType.replace('_', ' ')} at ${new Date().toLocaleTimeString()}`
} else {
errorMessage.value = `Error: ${data.message}`
}
} catch {
errorMessage.value = 'Failed to connect to the server.'
}
}
// Other functions (startScanner, stopScanner, etc.) remain unchanged
const clearMessages = () => {
errorMessage.value = ''
successMessage.value = ''
@@ -128,21 +130,22 @@ const clearMessages = () => {
const startScanner = () => {
isScannerActive.value = true
clearMessages()
// A small delay helps ensure the DOM is ready for the QR reader to attach
setTimeout(() => {
html5QrCode = new Html5Qrcode('qr-reader')
const config = { fps: 10, qrbox: { width: 250, height: 250 } }
html5QrCode
.start({ facingMode: 'environment' }, config, onScanSuccess, onScanFailure)
.catch(() => {
errorMessage.value = `Unable to start camera. Try uploading an image instead.`
isScannerActive.value = false
})
}, 2000)
try {
html5QrCode = new Html5Qrcode('qr-reader')
const config = { fps: 10, qrbox: { width: 250, height: 250 } }
html5QrCode.start({ facingMode: 'environment' }, config, onScanSuccess, onScanFailure)
} catch (err) {
errorMessage.value = `Unable to start camera. Try uploading an image instead. Error: ${err.message}`
isScannerActive.value = false
}
}, 300)
}
const stopScanner = () => {
if (html5QrCode && html5QrCode.isScanning) {
html5QrCode.stop().catch((err) => console.error('Failed to stop scanner', err))
html5QrCode.stop().catch((err) => console.error('Failed to stop scanner cleanly.', err))
}
isScannerActive.value = false
}
@@ -151,14 +154,17 @@ const handleFileUpload = (event) => {
const file = event.target.files[0]
if (!file) return
clearMessages()
isScannerActive.value = true
setTimeout(() => {
html5QrCode = new Html5Qrcode('qr-reader')
html5QrCode
.scanFile(file, true)
.then(onScanSuccess)
.catch((err) => onScanFailure(`Error scanning file: ${err}`))
}, 100)
// No need to set isScannerActive to true for file uploads unless you want the UI overlay
// html5QrCode does not need to be attached to the DOM for file scanning
if (!html5QrCode) {
html5QrCode = new Html5Qrcode('qr-reader', false)
}
html5QrCode
.scanFile(file, true)
.then(onScanSuccess)
.catch((err) => {
onScanFailure(`Error scanning file: ${err}`)
})
}
const onScanSuccess = (decodedText) => {
successMessage.value = `QR Code detected. Getting location...`
@@ -169,16 +175,18 @@ const onScanSuccess = (decodedText) => {
}
navigator.geolocation.getCurrentPosition(
(position) => sendClockEvent(decodedText, position.coords.latitude, position.coords.longitude),
() =>
(errorMessage.value = 'Unable to retrieve your location. Please enable location services.'),
(geoError) =>
(errorMessage.value = `Unable to retrieve your location: ${geoError.message}. Please enable location services.`),
)
}
const onScanFailure = () => {
errorMessage.value = 'Please Try Again'
const onScanFailure = (message = 'Could not detect a QR code. Please try again.') => {
errorMessage.value = message
stopScanner()
}
</script>
<style scoped>
/* STYLES REMAIN UNCHANGED */
.worker-dashboard {
max-width: 500px;
margin: auto;
+8 -6
View File
@@ -22,11 +22,11 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
// CORRECT: Import the centralized apiFetch function
import { apiFetch } from '@/api.js'
const router = useRouter()
const clockHistory = ref([])
// --- ALIGNMENT CHANGE ---
const userId = sessionStorage.getItem('userId')
onMounted(async () => {
@@ -35,18 +35,20 @@ onMounted(async () => {
return
}
try {
const response = await fetch(`
${import.meta.env.VITE_API_BASE_URL}/api/worker/clock-history/${userId}`)
if (response.ok) {
clockHistory.value = await response.json()
// CORRECT: Use apiFetch to automatically handle headers and response parsing
const data = await apiFetch(`/api/worker/clock-history/${userId}`)
if (data) {
clockHistory.value = data
}
} catch (error) {
// The error from apiFetch is caught here
console.error('Failed to fetch clock history:', error)
}
})
</script>
<style scoped>
/* STYLES REMAIN UNCHANGED */
.history-container {
max-width: 800px;
margin: auto;