Commits (2)
...@@ -28,3 +28,5 @@ coverage ...@@ -28,3 +28,5 @@ coverage
*.sw? *.sw?
*.tsbuildinfo *.tsbuildinfo
.qoder*
\ No newline at end of file
...@@ -18,4 +18,4 @@ export const refreshAccessToken = async (refreshToken) => { ...@@ -18,4 +18,4 @@ export const refreshAccessToken = async (refreshToken) => {
} catch (error) { } catch (error) {
throw new Error(error?.response?.data?.detail || error?.message || 'Failed to refresh token') throw new Error(error?.response?.data?.detail || error?.message || 'Failed to refresh token')
} }
} }
\ No newline at end of file
...@@ -22,7 +22,10 @@ export const apiEndpoints = { ...@@ -22,7 +22,10 @@ export const apiEndpoints = {
// 学生相关 // 学生相关
STUDENTS: { STUDENTS: {
LIST: '/api/student/', LIST: '/api/student/',
CREATE: '/api/student/',
UPDATE: (studentId) => `/api/student/${studentId}/`,
DELETE: (studentId) => `/api/student/${studentId}/`,
}, },
} }
export default apiClient export default apiClient
\ No newline at end of file
import apiClient, { apiEndpoints } from './index.js' import apiClient, { apiEndpoints } from './index.js'
// 获取学生列表 // Get student list
export const getStudents = async () => { export const getStudents = async () => {
try { try {
const response = await apiClient.get(apiEndpoints.STUDENTS.LIST) const response = await apiClient.get(apiEndpoints.STUDENTS.LIST)
...@@ -8,4 +8,119 @@ export const getStudents = async () => { ...@@ -8,4 +8,119 @@ export const getStudents = async () => {
} catch (error) { } catch (error) {
throw new Error(error?.response?.data?.detail || error?.message || 'Failed to fetch students') throw new Error(error?.response?.data?.detail || error?.message || 'Failed to fetch students')
} }
} }
\ No newline at end of file
// Update student information
export const updateStudent = async (studentId, studentData) => {
try {
// Convert data format to match API requirements
const apiData = {
student_name: studentData.student_name || '',
enabled: studentData.enabled ? 'Y' : 'N',
grade: studentData.grade || '',
age: parseInt(studentData.age) || 0,
}
// If avatar data is included, add avatar-related fields
if (studentData.avatar && studentData.avatar_mime_type) {
apiData.avatar_base64 = studentData.avatar
apiData.avatar_mime_type = studentData.avatar_mime_type
apiData.avatar_file_name = studentData.avatar_file_name || ''
}
console.log('Sending update request:', {
studentId,
url: apiEndpoints.STUDENTS.UPDATE(studentId),
data: { ...apiData, avatar_base64: apiData.avatar_base64 ? '[base64 data]' : undefined } // Don't print full base64
})
const response = await apiClient.patch(apiEndpoints.STUDENTS.UPDATE(studentId), apiData)
return response.data
} catch (error) {
console.error('API error details:', {
status: error?.response?.status,
statusText: error?.response?.statusText,
data: error?.response?.data,
headers: error?.response?.headers
})
const errorMessage = error?.response?.data?.detail ||
error?.response?.data?.message ||
error?.response?.data?.error ||
`HTTP ${error?.response?.status}: ${error?.response?.statusText}` ||
error.message ||
'Failed to update student'
throw new Error(errorMessage)
}
}
// Create student
export const createStudent = async (studentData) => {
try {
// Convert data format to match API requirements
const apiData = {
student_name: studentData.student_name || '',
enabled: studentData.enabled ? 'Y' : 'N',
grade: studentData.grade || '',
age: parseInt(studentData.age) || 0,
}
// If avatar data is included, add avatar-related fields
if (studentData.avatar && studentData.avatar_mime_type) {
apiData.avatar_base64 = studentData.avatar
apiData.avatar_mime_type = studentData.avatar_mime_type
apiData.avatar_file_name = studentData.avatar_file_name || ''
}
console.log('Sending create request:', {
url: apiEndpoints.STUDENTS.CREATE,
data: { ...apiData, avatar_base64: apiData.avatar_base64 ? '[base64 data]' : undefined }
})
const response = await apiClient.post(apiEndpoints.STUDENTS.CREATE, apiData)
return response.data
} catch (error) {
console.error('API error details:', {
status: error?.response?.status,
statusText: error?.response?.statusText,
data: error?.response?.data,
headers: error?.response?.headers
})
const errorMessage = error?.response?.data?.detail ||
error?.response?.data?.message ||
error?.response?.data?.error ||
`HTTP ${error?.response?.status}: ${error?.response?.statusText}` ||
error.message ||
'Failed to create student'
throw new Error(errorMessage)
}
}
// Delete student
export const deleteStudent = async (studentId) => {
try {
console.log('Sending delete request:', {
studentId,
url: apiEndpoints.STUDENTS.DELETE(studentId)
})
const response = await apiClient.delete(apiEndpoints.STUDENTS.DELETE(studentId))
return response.data
} catch (error) {
console.error('API error details:', {
status: error?.response?.status,
statusText: error?.response?.statusText,
data: error?.response?.data,
headers: error?.response?.headers
})
const errorMessage = error?.response?.data?.detail ||
error?.response?.data?.message ||
error?.response?.data?.error ||
`HTTP ${error?.response?.status}: ${error?.response?.statusText}` ||
error.message ||
'Failed to delete student'
throw new Error(errorMessage)
}
}
...@@ -8,6 +8,7 @@ ...@@ -8,6 +8,7 @@
> >
<v-layout height="100vh" style="background: rgba(255, 255, 255, 0.85);"> <v-layout height="100vh" style="background: rgba(255, 255, 255, 0.85);">
<AppHeader :drawer-active="drawerActive" @toggle-drawer="toggleDrawer" @logout="showLogoutDialog = true" /> <AppHeader :drawer-active="drawerActive" @toggle-drawer="toggleDrawer" @logout="showLogoutDialog = true" />
<AppFooter />
<AppDrawer v-model="drawerActive" /> <AppDrawer v-model="drawerActive" />
<v-main> <v-main>
<v-container fluid class="pt-4 pb-0"> <v-container fluid class="pt-4 pb-0">
...@@ -28,7 +29,6 @@ ...@@ -28,7 +29,6 @@
</v-container> </v-container>
<slot></slot> <slot></slot>
</v-main> </v-main>
<AppFooter />
</v-layout> </v-layout>
</v-img> </v-img>
<!-- 登出确认对话框 --> <!-- 登出确认对话框 -->
......
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import axios from 'axios' import apiClient from '@/api/index.js'
import { login as authLogin, refreshAccessToken as authRefreshToken } from '@/api/authService.js' import { login as authLogin, refreshAccessToken as authRefreshToken } from '@/api/authService.js'
export const useAuthStore = defineStore('auth', () => { export const useAuthStore = defineStore('auth', () => {
...@@ -58,9 +58,10 @@ export const useAuthStore = defineStore('auth', () => { ...@@ -58,9 +58,10 @@ export const useAuthStore = defineStore('auth', () => {
const setAxiosAuthHeader = (token) => { const setAxiosAuthHeader = (token) => {
if (token) { if (token) {
axios.defaults.headers.common.Authorization = `Bearer ${token}` // 设置apiClient默认headers
apiClient.defaults.headers.common.Authorization = `Bearer ${token}`
} else { } else {
delete axios.defaults.headers.common.Authorization delete apiClient.defaults.headers.common.Authorization
} }
} }
...@@ -92,8 +93,8 @@ export const useAuthStore = defineStore('auth', () => { ...@@ -92,8 +93,8 @@ export const useAuthStore = defineStore('auth', () => {
const setupAxiosInterceptors = () => { const setupAxiosInterceptors = () => {
if (interceptorsInitialized.value) return if (interceptorsInitialized.value) return
// 请求拦截:总是携带最新的访问令牌 // 请求拦截:总是携带最新的访问令牌
axios.interceptors.request.use((config) => { apiClient.interceptors.request.use((config) => {
if (accessToken.value) { if (accessToken.value) {
config.headers = config.headers || {} config.headers = config.headers || {}
config.headers.Authorization = `Bearer ${accessToken.value}` config.headers.Authorization = `Bearer ${accessToken.value}`
...@@ -101,8 +102,8 @@ export const useAuthStore = defineStore('auth', () => { ...@@ -101,8 +102,8 @@ export const useAuthStore = defineStore('auth', () => {
return config return config
}) })
// 响应拦截:遇到 401 尝试刷新一次并重试原请求 // 响应拦截:遇到 401 尝试刷新一次并重试原请求
axios.interceptors.response.use( apiClient.interceptors.response.use(
(response) => response, (response) => response,
async (error) => { async (error) => {
const originalRequest = error.config || {} const originalRequest = error.config || {}
...@@ -121,7 +122,7 @@ export const useAuthStore = defineStore('auth', () => { ...@@ -121,7 +122,7 @@ export const useAuthStore = defineStore('auth', () => {
await refreshAccessToken() await refreshAccessToken()
originalRequest.headers = originalRequest.headers || {} originalRequest.headers = originalRequest.headers || {}
originalRequest.headers.Authorization = `Bearer ${accessToken.value}` originalRequest.headers.Authorization = `Bearer ${accessToken.value}`
return axios(originalRequest) return apiClient(originalRequest)
} catch (e) { } catch (e) {
logout() logout()
return Promise.reject(e) return Promise.reject(e)
......
<script setup> <script setup>
import { ref } from 'vue' import { ref } from 'vue'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import loginImage from '@/assets/images/login.jpg'
const authStore = useAuthStore() const authStore = useAuthStore()
const username = ref('') const username = ref('')
...@@ -84,6 +83,14 @@ const handleLogin = async () => { ...@@ -84,6 +83,14 @@ const handleLogin = async () => {
variant="outlined" variant="outlined"
density="comfortable" density="comfortable"
/> />
<v-alert
v-if="error"
type="error"
variant="tonal"
class="mb-4"
>
{{ error }}
</v-alert>
<v-btn <v-btn
color="primary" color="primary"
block block
......
<script setup> <script setup>
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { getStudents } from '@/api/studentService.js' import { getStudents, updateStudent, createStudent, deleteStudent } from '@/api/studentService.js'
const authStore = useAuthStore() const authStore = useAuthStore()
...@@ -10,12 +10,58 @@ const isError = ref(false) ...@@ -10,12 +10,58 @@ const isError = ref(false)
const errorMessage = ref('') const errorMessage = ref('')
const students = ref([]) const students = ref([])
// 编辑功能相关状态
const editDialog = ref(false)
const editingStudent = ref(null)
const editForm = ref({
student_name: '',
age: '',
grade: '',
enabled: true,
avatar: '',
avatar_mime_type: '',
avatar_file_name: ''
})
const isSaving = ref(false)
const successMessage = ref('')
const saveErrorMessage = ref('')
// 新增功能相关状态
const createDialog = ref(false)
const createForm = ref({
student_name: '',
age: '',
grade: '',
enabled: true,
avatar: '',
avatar_mime_type: '',
avatar_file_name: ''
})
const isCreating = ref(false)
const createErrorMessage = ref('')
// 删除功能相关状态
const deleteDialog = ref(false)
const studentToDelete = ref(null)
const isDeleting = ref(false)
const headers = [ const headers = [
{ title: 'Avatar', value: 'avatar', align: 'start' }, { title: 'Avatar', value: 'avatar', align: 'start', sortable: false },
{ title: 'Name', value: 'student_name' }, { title: 'Name', value: 'student_name', sortable: true },
{ title: 'Enabled', value: 'enabled_flag' }, {
{ title: 'Age', value: 'age' }, title: 'Enabled',
{ title: 'Grade', value: 'grade' }, value: 'enabled',
sortable: true,
sort: (a, b) => {
// Y (启用) 排在前面,N (禁用) 排在后面
if (a === 'Y' && b === 'N') return -1
if (a === 'N' && b === 'Y') return 1
return 0
}
},
{ title: 'Age', value: 'age', sortable: true },
{ title: 'Grade', value: 'grade', sortable: true },
{ title: 'Actions', value: 'actions', sortable: false, align: 'end' },
] ]
const fetchStudents = async () => { const fetchStudents = async () => {
...@@ -28,6 +74,11 @@ const fetchStudents = async () => { ...@@ -28,6 +74,11 @@ const fetchStudents = async () => {
} catch (e) { } catch (e) {
isError.value = true isError.value = true
errorMessage.value = e?.response?.data?.detail || e?.message || 'Failed to load students' errorMessage.value = e?.response?.data?.detail || e?.message || 'Failed to load students'
// 3秒后自动隐藏错误消息
setTimeout(() => {
isError.value = false
errorMessage.value = ''
}, 3000)
} finally { } finally {
isLoading.value = false isLoading.value = false
} }
...@@ -46,6 +97,410 @@ const getAvatarFallback = (name) => { ...@@ -46,6 +97,410 @@ const getAvatarFallback = (name) => {
const t = name.trim() const t = name.trim()
return t ? t.charAt(0).toUpperCase() : '?' return t ? t.charAt(0).toUpperCase() : '?'
} }
// 生成头像的数据URL
const getAvatarDataUrl = (avatar, mimeType) => {
if (!avatar || !mimeType) return null
return `data:${mimeType};base64,${avatar}`
}
// 头像上传处理
const handleAvatarUpload = (event) => {
console.log('File upload event:', event)
// 获取文件 - Vuetify 3 的 v-file-input 事件处理
const files = event.target?.files || event
const file = Array.isArray(files) ? files[0] : files?.[0] || files
console.log('Selected file:', file)
if (!file) {
console.log('No file selected')
return
}
// 检查文件类型
if (!file.type.startsWith('image/')) {
saveErrorMessage.value = 'Please select an image file'
// 3秒后自动隐藏错误消息
setTimeout(() => {
saveErrorMessage.value = ''
}, 3000)
return
}
// 检查文件大小 (限制为2MB)
if (file.size > 2 * 1024 * 1024) {
saveErrorMessage.value = 'File size cannot exceed 2MB'
// 3秒后自动隐藏错误消息
setTimeout(() => {
saveErrorMessage.value = ''
}, 3000)
return
}
const reader = new FileReader()
reader.onload = (e) => {
const base64 = e.target.result.split(',')[1] // 移除数据URL前缀
editForm.value.avatar = base64
editForm.value.avatar_mime_type = file.type
editForm.value.avatar_file_name = file.name
saveErrorMessage.value = '' // 清除错误消息
console.log('Avatar data set:', {
avatar_length: base64?.length,
mime_type: file.type,
file_name: file.name
})
}
reader.onerror = () => {
saveErrorMessage.value = 'File reading failed, please try again'
// 3秒后自动隐藏错误消息
setTimeout(() => {
saveErrorMessage.value = ''
}, 3000)
}
reader.readAsDataURL(file)
}
// 移除头像
const removeAvatar = () => {
editForm.value.avatar = ''
editForm.value.avatar_mime_type = ''
editForm.value.avatar_file_name = ''
}
// 新增功能的头像上传处理
const handleCreateAvatarUpload = (event) => {
console.log('Create file upload event:', event)
const files = event.target?.files || event
const file = Array.isArray(files) ? files[0] : files?.[0] || files
console.log('Selected file:', file)
if (!file) {
console.log('No file selected')
return
}
// 检查文件类型
if (!file.type.startsWith('image/')) {
createErrorMessage.value = 'Please select an image file'
setTimeout(() => {
createErrorMessage.value = ''
}, 3000)
return
}
// 检查文件大小 (限制为2MB)
if (file.size > 2 * 1024 * 1024) {
createErrorMessage.value = 'File size cannot exceed 2MB'
setTimeout(() => {
createErrorMessage.value = ''
}, 3000)
return
}
const reader = new FileReader()
reader.onload = (e) => {
const base64 = e.target.result.split(',')[1]
createForm.value.avatar = base64
createForm.value.avatar_mime_type = file.type
createForm.value.avatar_file_name = file.name
createErrorMessage.value = ''
console.log('Create avatar data set:', {
avatar_length: base64?.length,
mime_type: file.type,
file_name: file.name
})
}
reader.onerror = () => {
createErrorMessage.value = 'File reading failed, please try again'
setTimeout(() => {
createErrorMessage.value = ''
}, 3000)
}
reader.readAsDataURL(file)
}
// 移除新增头像
const removeCreateAvatar = () => {
createForm.value.avatar = ''
createForm.value.avatar_mime_type = ''
createForm.value.avatar_file_name = ''
}
// 编辑功能方法
const openEditDialog = (student) => {
editingStudent.value = student
editForm.value = {
student_name: student.student_name || '',
age: student.age || '',
grade: student.grade || '',
// 处理不同的enabled数据格式
enabled: student.enabled === true || student.enabled === 'Y' || student.enabled === 1,
avatar: student.avatar || '',
avatar_mime_type: student.avatar_mime_type || '',
avatar_file_name: student.avatar_file_name || ''
}
console.log('Editing student data:', student)
console.log('Converted form data:', editForm.value)
editDialog.value = true
}
const closeEditDialog = () => {
editDialog.value = false
editingStudent.value = null
editForm.value = {
student_name: '',
age: '',
grade: '',
enabled: true,
avatar: '',
avatar_mime_type: '',
avatar_file_name: ''
}
}
// 新增功能方法
const openCreateDialog = () => {
createForm.value = {
student_name: '',
age: '',
grade: '',
enabled: true,
avatar: '',
avatar_mime_type: '',
avatar_file_name: ''
}
createErrorMessage.value = ''
createDialog.value = true
}
const closeCreateDialog = () => {
createDialog.value = false
createForm.value = {
student_name: '',
age: '',
grade: '',
enabled: true,
avatar: '',
avatar_mime_type: '',
avatar_file_name: ''
}
createErrorMessage.value = ''
}
const createNewStudent = async () => {
isCreating.value = true
createErrorMessage.value = ''
successMessage.value = ''
try {
console.log('Form data before creation:', createForm.value)
// 构建要发送的数据
const createData = {
student_name: createForm.value.student_name,
grade: createForm.value.grade,
age: createForm.value.age,
enabled: createForm.value.enabled
}
// 如果有头像数据,则包含头像字段
if (createForm.value.avatar && createForm.value.avatar_mime_type) {
createData.avatar = createForm.value.avatar
createData.avatar_mime_type = createForm.value.avatar_mime_type
createData.avatar_file_name = createForm.value.avatar_file_name
console.log('Including avatar data:', {
avatar_length: createData.avatar?.length,
mime_type: createData.avatar_mime_type,
file_name: createData.avatar_file_name
})
} else {
console.log('Not including avatar data')
}
console.log('Sending data:', { ...createData, avatar_base64: createData.avatar ? '[base64 data]' : undefined })
// 1. 调用API创建学生
const newStudent = await createStudent(createData)
// 2. 创建成功后,将新学生添加到本地列表
const studentToAdd = {
...newStudent,
enabled: createForm.value.enabled ? 'Y' : 'N'
}
// 如果有头像数据,则添加头像信息
if (createForm.value.avatar && createForm.value.avatar_mime_type) {
studentToAdd.avatar = createForm.value.avatar
studentToAdd.avatar_mime_type = createForm.value.avatar_mime_type
studentToAdd.avatar_file_name = createForm.value.avatar_file_name
}
students.value.unshift(studentToAdd) // 在列表顶部添加新学生
// 3. 显示成功消息
successMessage.value = 'Student created successfully'
setTimeout(() => {
successMessage.value = ''
}, 3000)
closeCreateDialog()
} catch (error) {
console.error('Creation failed:', error)
createErrorMessage.value = error.message || 'Creation failed, please try again'
setTimeout(() => {
createErrorMessage.value = ''
}, 3000)
} finally {
isCreating.value = false
}
}
// 删除功能方法
const openDeleteDialog = (student) => {
studentToDelete.value = student
deleteDialog.value = true
}
const closeDeleteDialog = () => {
deleteDialog.value = false
studentToDelete.value = null
}
const confirmDeleteStudent = async () => {
if (!studentToDelete.value?.student_id) {
console.error('Unable to get student ID')
return
}
isDeleting.value = true
try {
console.log('Deleting student:', studentToDelete.value)
// 1. 调用API删除学生
await deleteStudent(studentToDelete.value.student_id)
// 2. 从本地列表中移除学生
const index = students.value.findIndex(s => s.student_id === studentToDelete.value.student_id)
if (index !== -1) {
students.value.splice(index, 1)
}
// 3. 显示成功消息
successMessage.value = `Student "${studentToDelete.value.student_name}" deleted successfully`
setTimeout(() => {
successMessage.value = ''
}, 3000)
closeDeleteDialog()
closeEditDialog() // 自动关闭编辑对话框
} catch (error) {
console.error('Deletion failed:', error)
// 在删除对话框中显示错误,但不关闭对话框
saveErrorMessage.value = error.message || 'Deletion failed, please try again'
setTimeout(() => {
saveErrorMessage.value = ''
}, 3000)
} finally {
isDeleting.value = false
}
}
const saveStudent = async () => {
if (!editingStudent.value?.student_id) {
saveErrorMessage.value = 'Unable to get student ID'
// 3秒后自动隐藏错误消息
setTimeout(() => {
saveErrorMessage.value = ''
}, 3000)
return
}
isSaving.value = true
saveErrorMessage.value = ''
successMessage.value = ''
try {
console.log('Form data before saving:', editForm.value)
// 构建要发送的数据
const updateData = {
student_name: editForm.value.student_name,
grade: editForm.value.grade,
age: editForm.value.age,
enabled: editForm.value.enabled
}
// 如果有头像数据,则包含头像字段
if (editForm.value.avatar && editForm.value.avatar_mime_type) {
updateData.avatar = editForm.value.avatar
updateData.avatar_mime_type = editForm.value.avatar_mime_type
updateData.avatar_file_name = editForm.value.avatar_file_name
console.log('Including avatar data:', {
avatar_length: updateData.avatar?.length,
mime_type: updateData.avatar_mime_type,
file_name: updateData.avatar_file_name
})
} else {
console.log('Not including avatar data:', {
avatar: editForm.value.avatar,
avatar_mime_type: editForm.value.avatar_mime_type
})
}
console.log('Sending data:', { ...updateData, avatar_base64: updateData.avatar ? '[base64 data]' : undefined })
// 1. 调用API保存到服务器
await updateStudent(editingStudent.value.student_id, updateData)
// 2. API调用成功后,更新本地数据
const index = students.value.findIndex(s => s.student_id === editingStudent.value.student_id)
if (index !== -1) {
const updatedStudent = {
...students.value[index],
student_name: editForm.value.student_name,
grade: editForm.value.grade,
age: editForm.value.age,
enabled: editForm.value.enabled ? 'Y' : 'N' // 转换为API格式
}
// 如果有头像数据,则更新头像信息
if (editForm.value.avatar && editForm.value.avatar_mime_type) {
updatedStudent.avatar = editForm.value.avatar
updatedStudent.avatar_mime_type = editForm.value.avatar_mime_type
updatedStudent.avatar_file_name = editForm.value.avatar_file_name
}
students.value[index] = updatedStudent
}
// 3. 显示成功消息
successMessage.value = 'Student information updated successfully'
// 3秒后自动隐藏成功消息
setTimeout(() => {
successMessage.value = ''
}, 3000)
closeEditDialog()
} catch (error) {
// 4. API失败时不更新本地数据,显示错误消息
console.error('Save failed:', error)
saveErrorMessage.value = error.message || 'Save failed, please try again'
// 3秒后自动隐藏错误消息
setTimeout(() => {
saveErrorMessage.value = ''
}, 3000)
} finally {
isSaving.value = false
}
}
</script> </script>
<template> <template>
...@@ -55,6 +510,14 @@ const getAvatarFallback = (name) => { ...@@ -55,6 +510,14 @@ const getAvatarFallback = (name) => {
<h2 class="text-h5">Students</h2> <h2 class="text-h5">Students</h2>
</v-col> </v-col>
<v-col cols="12" sm="6" class="text-sm-right text-right"> <v-col cols="12" sm="6" class="text-sm-right text-right">
<v-btn
color="success"
prepend-icon="mdi-plus"
class="mr-2"
@click="openCreateDialog"
>
Add Student
</v-btn>
<v-btn color="primary" prepend-icon="mdi-refresh" :loading="isLoading" @click="fetchStudents"> <v-btn color="primary" prepend-icon="mdi-refresh" :loading="isLoading" @click="fetchStudents">
Refresh Refresh
</v-btn> </v-btn>
...@@ -65,25 +528,422 @@ const getAvatarFallback = (name) => { ...@@ -65,25 +528,422 @@ const getAvatarFallback = (name) => {
{{ errorMessage }} {{ errorMessage }}
</v-alert> </v-alert>
<!-- 成功消息 -->
<v-alert v-if="successMessage" type="success" class="mb-4" closable @click:close="successMessage = ''">
{{ successMessage }}
</v-alert>
<v-progress-linear v-if="isLoading" indeterminate color="primary" class="mb-4"></v-progress-linear> <v-progress-linear v-if="isLoading" indeterminate color="primary" class="mb-4"></v-progress-linear>
<v-data-table :headers="headers" :items="students" item-key="id" :items-per-page="-1" class="elevation-1"> <v-data-table
:headers="headers"
:items="students"
item-key="student_id"
:items-per-page="-1"
class="elevation-1"
:sort-by="[{ key: 'student_name', order: 'asc' }]"
multi-sort
>
<template v-slot:[`item.avatar`]="{ item }"> <template v-slot:[`item.avatar`]="{ item }">
<v-avatar size="36"> <div class="d-flex align-center py-2">
<template v-if="item.avatar"> <v-avatar size="64">
<v-img :src="item.avatar" alt="avatar" cover></v-img> <template v-if="item.avatar && item.avatar_mime_type">
</template> <v-img
<template v-else> :src="getAvatarDataUrl(item.avatar, item.avatar_mime_type)"
<span>{{ getAvatarFallback(item.student_name) }}</span> :alt="item.avatar_file_name || 'avatar'"
</template> cover
</v-avatar> @error="console.warn('Avatar loading failed:', item.avatar_file_name)"
/>
</template>
<template v-else>
<span class="text-body-2">{{ getAvatarFallback(item.student_name) }}</span>
</template>
</v-avatar>
</div>
</template> </template>
<template v-slot:[`item.enabled_flag`]="{ item }"> <template v-slot:[`item.enabled`]="{ item }">
<v-chip :color="item.enabled_flag ? 'success' : 'error'" size="small" variant="flat"> <v-chip :color="item.enabled === 'Y' ? 'success' : 'error'" size="small" variant="flat">
{{ item.enabled_flag ? 'Enabled' : 'Disabled' }} {{ item.enabled === 'Y' ? 'Enabled' : 'Disabled' }}
</v-chip> </v-chip>
</template> </template>
<template v-slot:[`item.actions`]="{ item }">
<v-btn
icon="mdi-pencil"
size="small"
color="primary"
variant="text"
@click="openEditDialog(item)"
>
</v-btn>
</template>
</v-data-table> </v-data-table>
<!-- 编辑对话框 -->
<v-dialog v-model="editDialog" max-width="500px">
<v-card>
<v-card-title class="text-h5">
Edit Student Information
</v-card-title>
<v-card-text>
<v-container>
<!-- 保存错误消息 -->
<v-alert v-if="saveErrorMessage" type="error" class="mb-4" closable @click:close="saveErrorMessage = ''">
{{ saveErrorMessage }}
</v-alert>
<v-row>
<!-- Avatar upload area -->
<v-col cols="12">
<div class="mb-6">
<h4 class="text-subtitle-1 mb-4 font-weight-medium">Avatar</h4>
<!-- Avatar preview and upload area -->
<div class="d-flex flex-column align-center">
<!-- Avatar preview area -->
<div class="position-relative mb-4">
<v-avatar size="120" class="elevation-4">
<template v-if="editForm.avatar && editForm.avatar_mime_type">
<v-img
:src="getAvatarDataUrl(editForm.avatar, editForm.avatar_mime_type)"
:alt="editForm.avatar_file_name || 'avatar'"
cover
/>
</template>
<template v-else>
<div class="d-flex flex-column align-center justify-center h-100 bg-grey-lighten-3">
<v-icon size="48" color="grey-darken-1">mdi-account-circle</v-icon>
<span class="text-caption text-grey-darken-1 mt-1">{{ getAvatarFallback(editForm.student_name) }}</span>
</div>
</template>
</v-avatar>
<!-- Remove avatar button -->
<v-btn
v-if="editForm.avatar"
icon="mdi-close"
size="small"
color="error"
variant="elevated"
class="position-absolute"
style="top: -8px; right: -8px;"
@click="removeAvatar"
/>
</div>
<!-- Upload button area -->
<div class="d-flex flex-column align-center gap-3">
<!-- Hidden file input -->
<input
ref="fileInput"
type="file"
accept="image/*"
style="display: none;"
@change="handleAvatarUpload"
/>
<!-- Upload button -->
<v-btn
color="primary"
variant="outlined"
size="large"
prepend-icon="mdi-camera-plus"
@click="$refs.fileInput.click()"
:disabled="isSaving"
>
{{ editForm.avatar ? 'Change Avatar' : 'Upload Avatar' }}
</v-btn>
<!-- File format description -->
<div class="text-center">
<div class="text-caption text-medium-emphasis">
Supports PNG, JPG, GIF formats
</div>
<div class="text-caption text-medium-emphasis">
File size up to 2MB
</div>
</div>
</div>
</div>
</div>
<!-- Divider -->
<v-divider class="mb-4" />
</v-col>
<!-- Form fields -->
<v-col cols="12">
<v-text-field
v-model="editForm.student_name"
label="Name"
required
variant="outlined"
/>
</v-col>
<v-col cols="6">
<v-text-field
v-model="editForm.age"
label="Age"
type="number"
variant="outlined"
/>
</v-col>
<v-col cols="6">
<v-text-field
v-model="editForm.grade"
label="Grade"
variant="outlined"
/>
</v-col>
<v-col cols="12">
<v-switch
v-model="editForm.enabled"
label="Enabled Status"
color="primary"
/>
</v-col>
</v-row>
</v-container>
</v-card-text>
<v-card-actions>
<v-btn
color="error"
variant="outlined"
prepend-icon="mdi-delete"
@click="openDeleteDialog(editingStudent)"
:disabled="isSaving"
>
Delete
</v-btn>
<v-spacer />
<v-btn
color="grey-darken-1"
variant="text"
@click="closeEditDialog"
>
Cancel
</v-btn>
<v-btn
color="primary"
variant="text"
:loading="isSaving"
:disabled="isSaving"
@click="saveStudent"
>
Save
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Add Student Dialog -->
<v-dialog v-model="createDialog" max-width="500px">
<v-card>
<v-card-title class="text-h5">
Add Student
</v-card-title>
<v-card-text>
<v-container>
<!-- Error messages -->
<v-alert v-if="createErrorMessage" type="error" class="mb-4" closable @click:close="createErrorMessage = ''">
{{ createErrorMessage }}
</v-alert>
<v-row>
<!-- Avatar upload area -->
<v-col cols="12">
<div class="mb-6">
<h4 class="text-subtitle-1 mb-4 font-weight-medium">Avatar</h4>
<!-- Avatar preview and upload area -->
<div class="d-flex flex-column align-center">
<!-- Avatar preview area -->
<div class="position-relative mb-4">
<v-avatar size="120" class="elevation-4">
<template v-if="createForm.avatar && createForm.avatar_mime_type">
<v-img
:src="getAvatarDataUrl(createForm.avatar, createForm.avatar_mime_type)"
:alt="createForm.avatar_file_name || 'avatar'"
cover
/>
</template>
<template v-else>
<div class="d-flex flex-column align-center justify-center h-100 bg-grey-lighten-3">
<v-icon size="48" color="grey-darken-1">mdi-account-circle</v-icon>
<span class="text-caption text-grey-darken-1 mt-1">{{ getAvatarFallback(createForm.student_name) }}</span>
</div>
</template>
</v-avatar>
<!-- Remove avatar button -->
<v-btn
v-if="createForm.avatar"
icon="mdi-close"
size="small"
color="error"
variant="elevated"
class="position-absolute"
style="top: -8px; right: -8px;"
@click="removeCreateAvatar"
/>
</div>
<!-- Upload button area -->
<div class="d-flex flex-column align-center gap-3">
<!-- Hidden file input -->
<input
ref="createFileInput"
type="file"
accept="image/*"
style="display: none;"
@change="handleCreateAvatarUpload"
/>
<!-- Upload button -->
<v-btn
color="primary"
variant="outlined"
size="large"
prepend-icon="mdi-camera-plus"
@click="$refs.createFileInput.click()"
:disabled="isCreating"
>
{{ createForm.avatar ? 'Change Avatar' : 'Upload Avatar' }}
</v-btn>
<!-- File format description -->
<div class="text-center">
<div class="text-caption text-medium-emphasis">
Supports PNG, JPG, GIF formats
</div>
<div class="text-caption text-medium-emphasis">
File size up to 2MB
</div>
</div>
</div>
</div>
</div>
<!-- Divider -->
<v-divider class="mb-4" />
</v-col>
<!-- Form fields -->
<v-col cols="12">
<v-text-field
v-model="createForm.student_name"
label="Name *"
required
variant="outlined"
/>
</v-col>
<v-col cols="6">
<v-text-field
v-model="createForm.age"
label="Age"
type="number"
variant="outlined"
/>
</v-col>
<v-col cols="6">
<v-text-field
v-model="createForm.grade"
label="Grade"
variant="outlined"
/>
</v-col>
<v-col cols="12">
<v-switch
v-model="createForm.enabled"
label="Enabled Status *"
color="primary"
/>
</v-col>
</v-row>
</v-container>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
color="grey-darken-1"
variant="text"
@click="closeCreateDialog"
>
Cancel
</v-btn>
<v-btn
color="success"
variant="text"
:loading="isCreating"
:disabled="isCreating"
@click="createNewStudent"
>
Create
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Delete Confirmation Dialog -->
<v-dialog v-model="deleteDialog" max-width="400px">
<v-card>
<v-card-title class="text-h5 text-error">
<v-icon icon="mdi-alert" class="mr-2" />
Confirm Delete
</v-card-title>
<v-card-text>
<div class="text-body-1 mb-4">
Are you sure you want to delete this student?
</div>
<div v-if="studentToDelete" class="bg-grey-lighten-4 pa-3 rounded">
<div class="d-flex align-center mb-2">
<v-avatar size="40" class="mr-3">
<template v-if="studentToDelete.avatar && studentToDelete.avatar_mime_type">
<v-img
:src="getAvatarDataUrl(studentToDelete.avatar, studentToDelete.avatar_mime_type)"
:alt="studentToDelete.avatar_file_name || 'avatar'"
cover
/>
</template>
<template v-else>
<span class="text-body-2">{{ getAvatarFallback(studentToDelete.student_name) }}</span>
</template>
</v-avatar>
<div>
<div class="font-weight-medium">{{ studentToDelete.student_name }}</div>
<div class="text-caption text-medium-emphasis">
{{ studentToDelete.age }} years old | {{ studentToDelete.grade }}
</div>
</div>
</div>
</div>
<div class="text-body-2 text-error mt-4">
<v-icon icon="mdi-alert-circle" size="small" class="mr-1" />
This operation cannot be undone. Please proceed with caution.
</div>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
color="grey-darken-1"
variant="text"
@click="closeDeleteDialog"
:disabled="isDeleting"
>
Cancel
</v-btn>
<v-btn
color="error"
variant="elevated"
prepend-icon="mdi-delete"
:loading="isDeleting"
:disabled="isDeleting"
@click="confirmDeleteStudent"
>
Confirm Delete
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container> </v-container>
</template> </template>
......