e563f17283
- 添加JWT认证中间件保护API端点 - 在登录流程中使用bcrypt哈希密码和JWT令牌 - 配置HTTPS服务器使用自签名证书 - 更新前端API调用以包含认证令牌
308 lines
8.0 KiB
Vue
308 lines
8.0 KiB
Vue
<template>
|
|
<div class="personnel-container">
|
|
<!-- Add Worker Section -->
|
|
<section class="card">
|
|
<h2 class="card-header">Add New Worker</h2>
|
|
<div class="add-worker-form">
|
|
<div class="form-group">
|
|
<label for="fullName">Full Name</label>
|
|
<input
|
|
type="text"
|
|
id="fullName"
|
|
v-model="newWorker.fullName"
|
|
class="form-input"
|
|
placeholder="e.g., John Smith"
|
|
/>
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="username">Username</label>
|
|
<input
|
|
type="text"
|
|
id="username"
|
|
v-model="newWorker.username"
|
|
class="form-input"
|
|
placeholder="e.g., jsmith"
|
|
/>
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="password">Password</label>
|
|
<input
|
|
type="password"
|
|
id="password"
|
|
v-model="newWorker.password"
|
|
class="form-input"
|
|
placeholder="e.g., 123456"
|
|
/>
|
|
</div>
|
|
<button @click="addWorker" :disabled="!isFormValid || loading" class="button-primary">
|
|
{{ loading ? 'Adding...' : 'Add Worker' }}
|
|
</button>
|
|
</div>
|
|
<p v-if="errorMessage" class="error-message">{{ errorMessage }}</p>
|
|
</section>
|
|
|
|
<!-- Roster Section -->
|
|
<section class="card">
|
|
<h2 class="card-header">Worker Roster</h2>
|
|
<div class="roster-controls">
|
|
<input
|
|
type="text"
|
|
v-model="searchQuery"
|
|
placeholder="Search by name or username..."
|
|
class="form-input search-input"
|
|
@keyup.enter="fetchWorkers(1)"
|
|
/>
|
|
</div>
|
|
<div class="table-wrapper">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Full Name</th>
|
|
<th>Username</th>
|
|
<th>Date Joined</th>
|
|
<th class="actions-header">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-if="loading && workers.length === 0">
|
|
<td colspan="4" class="loading-state">Loading workers...</td>
|
|
</tr>
|
|
<tr v-if="!loading && workers.length === 0">
|
|
<td colspan="4" class="empty-state">No workers found.</td>
|
|
</tr>
|
|
<tr v-for="worker in workers" :key="worker.id">
|
|
<td>{{ worker.full_name }}</td>
|
|
<td>{{ worker.username }}</td>
|
|
<td>{{ new Date(worker.created_at).toLocaleDateString() }}</td>
|
|
<td class="actions-cell">
|
|
<button @click="viewRecords(worker.id)" class="button-secondary">
|
|
View Records
|
|
</button>
|
|
<button @click="deleteWorker(worker.id)" class="button-danger">Delete</button>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="pagination-controls" v-if="totalPages > 1">
|
|
<button @click="changePage(currentPage - 1)" :disabled="currentPage <= 1">Previous</button>
|
|
<span>Page {{ currentPage }} of {{ totalPages }}</span>
|
|
<button @click="changePage(currentPage + 1)" :disabled="currentPage >= totalPages">
|
|
Next
|
|
</button>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted, computed, watch } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
|
|
import { apiFetch } from '@/api.js'
|
|
|
|
const router = useRouter()
|
|
const workers = ref([])
|
|
const loading = ref(false)
|
|
const errorMessage = ref('')
|
|
|
|
// Form State
|
|
const newWorker = ref({ fullName: '', username: '', password: '' })
|
|
|
|
// Search & Pagination State
|
|
const searchQuery = ref('')
|
|
const currentPage = ref(1)
|
|
const pageSize = ref(20) // Or whatever default you prefer
|
|
const totalWorkers = ref(0)
|
|
|
|
// --- COMPUTED ---
|
|
const isFormValid = computed(
|
|
() => newWorker.value.fullName && newWorker.value.username && newWorker.value.password,
|
|
)
|
|
const totalPages = computed(() => Math.ceil(totalWorkers.value / pageSize.value))
|
|
|
|
// --- METHODS ---
|
|
let searchDebounce = null
|
|
watch(searchQuery, () => {
|
|
clearTimeout(searchDebounce)
|
|
searchDebounce = setTimeout(() => {
|
|
fetchWorkers(1) // Reset to page 1 on new search
|
|
}, 500) // Debounce search for 500ms
|
|
})
|
|
|
|
const fetchWorkers = async (page = currentPage.value) => {
|
|
loading.value = true
|
|
try {
|
|
const data = await apiFetch(
|
|
`/api/managers/workers?search=${searchQuery.value}&page=${page}&limit=${pageSize.value}`,
|
|
)
|
|
workers.value = data.workers
|
|
totalWorkers.value = data.totalCount
|
|
currentPage.value = page
|
|
} catch (err) {
|
|
errorMessage.value = 'Failed to fetch workers.'
|
|
console.error(err)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
const changePage = (page) => {
|
|
if (page > 0 && page <= totalPages.value) {
|
|
fetchWorkers(page)
|
|
}
|
|
}
|
|
|
|
const addWorker = async () => {
|
|
if (!isFormValid.value) return
|
|
loading.value = true
|
|
errorMessage.value = ''
|
|
try {
|
|
const data = await apiFetch('/api/managers/workers', {
|
|
method: 'POST',
|
|
body: JSON.stringify(newWorker.value),
|
|
})
|
|
await fetchWorkers(1) // Refresh list to the first page
|
|
newWorker.value = { fullName: '', username: '', password: '' } // Clear form
|
|
} catch (err) {
|
|
errorMessage.value = 'An error occurred while adding the worker.'
|
|
console.error(err)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
const deleteWorker = async (id) => {
|
|
if (!confirm('Are you sure you want to delete this worker account?')) return
|
|
try {
|
|
await apiFetch(`/api/managers/workers/${id}`, {
|
|
method: 'DELETE',
|
|
})
|
|
// If the deleted worker was the last on the page, go to the previous page
|
|
if (workers.value.length === 1 && currentPage.value > 1) {
|
|
await fetchWorkers(currentPage.value - 1)
|
|
} else {
|
|
await fetchWorkers(currentPage.value)
|
|
}
|
|
} catch (err) {
|
|
errorMessage.value = 'Failed to delete worker.'
|
|
console.error(err)
|
|
}
|
|
}
|
|
|
|
const viewRecords = (workerIds) => {
|
|
console.log(
|
|
`[DEBUG] 1. fetchWorkerDetails called with ID: '${workerIds}' (Type: ${typeof workerIds})`,
|
|
)
|
|
router.push(`/manager/attendance/${workerIds}`)
|
|
}
|
|
|
|
onMounted(() => {
|
|
const userRole = sessionStorage.getItem('userRole')
|
|
if (userRole !== 'manager') {
|
|
router.push('/')
|
|
return
|
|
}
|
|
fetchWorkers()
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.personnel-container {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2rem;
|
|
}
|
|
.card-header {
|
|
margin-top: 0;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
.add-worker-form {
|
|
display: grid;
|
|
grid-template-columns: 2fr 1fr 1fr auto;
|
|
gap: 1rem;
|
|
align-items: flex-end;
|
|
}
|
|
.form-group {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
}
|
|
.form-group label {
|
|
font-size: 0.9rem;
|
|
font-weight: 500;
|
|
}
|
|
.error-message {
|
|
color: var(--c-danger);
|
|
margin-top: 1rem;
|
|
}
|
|
|
|
.roster-controls {
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
.search-input {
|
|
width: 100%;
|
|
max-width: 400px;
|
|
}
|
|
|
|
.actions-header {
|
|
text-align: right;
|
|
}
|
|
.actions-cell {
|
|
display: flex;
|
|
gap: 0.75rem;
|
|
justify-content: flex-end;
|
|
}
|
|
.empty-state,
|
|
.loading-state {
|
|
text-align: center;
|
|
padding: 2rem;
|
|
color: var(--c-text-secondary);
|
|
}
|
|
|
|
.pagination-controls {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
margin-top: 1.5rem;
|
|
padding-top: 1.5rem;
|
|
border-top: 1px solid var(--c-border);
|
|
}
|
|
.table-wrapper {
|
|
overflow-x: auto;
|
|
-webkit-overflow-scrolling: touch;
|
|
}
|
|
|
|
table {
|
|
min-width: 600px; /* Force horizontal scroll on mobile */
|
|
}
|
|
|
|
/* For tablet-sized screens */
|
|
@media (max-width: 992px) {
|
|
.add-worker-form {
|
|
grid-template-columns: 1fr 1fr; /* 2-column layout for the form */
|
|
}
|
|
.add-worker-form button {
|
|
grid-column: span 2; /* Make button span full width */
|
|
}
|
|
}
|
|
|
|
/* For mobile-sized screens */
|
|
@media (max-width: 768px) {
|
|
.add-worker-form {
|
|
grid-template-columns: 1fr; /* 1-column layout for the form */
|
|
}
|
|
.add-worker-form button {
|
|
grid-column: span 1; /* Reset span */
|
|
}
|
|
.actions-cell {
|
|
flex-wrap: wrap; /* Allow action buttons to wrap onto the next line */
|
|
}
|
|
.pagination-controls {
|
|
flex-direction: column; /* Stack pagination controls */
|
|
gap: 0.75rem;
|
|
}
|
|
}
|
|
</style>
|