Commit 8f1cac35 authored by Administrator's avatar Administrator
Browse files

mobile: responsive app shell + lists/calendar; student slider_icon for TaskView completion thumb

parent fd870ca4
......@@ -40,4 +40,5 @@ clipboard*
*.yaml
暑假日程表.txt
import-assignments.cjs
to-do-feature.md
\ No newline at end of file
to-do-feature.md
debug
......@@ -100,10 +100,21 @@ export const updateStudent = async (studentId, studentData) => {
apiData.avatar_file_name = studentData.avatar_file_name || ''
}
// If slider_icon data is included, add slider_icon-related fields
if (studentData.slider_icon && studentData.slider_icon_mime_type) {
apiData.slider_icon_base64 = studentData.slider_icon
apiData.slider_icon_mime_type = studentData.slider_icon_mime_type
apiData.slider_icon_file_name = studentData.slider_icon_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
data: {
...apiData,
avatar_base64: apiData.avatar_base64 ? '[base64 data]' : undefined,
slider_icon_base64: apiData.slider_icon_base64 ? '[base64 data]' : undefined
} // Don't print full base64
})
const response = await apiClient.patch(apiEndpoints.STUDENTS.UPDATE(studentId), apiData)
......@@ -144,9 +155,20 @@ export const createStudent = async (studentData) => {
apiData.avatar_file_name = studentData.avatar_file_name || ''
}
// If slider_icon data is included, add slider_icon-related fields
if (studentData.slider_icon && studentData.slider_icon_mime_type) {
apiData.slider_icon_base64 = studentData.slider_icon
apiData.slider_icon_mime_type = studentData.slider_icon_mime_type
apiData.slider_icon_file_name = studentData.slider_icon_file_name || ''
}
console.log('Sending create request:', {
url: apiEndpoints.STUDENTS.CREATE,
data: { ...apiData, avatar_base64: apiData.avatar_base64 ? '[base64 data]' : undefined }
data: {
...apiData,
avatar_base64: apiData.avatar_base64 ? '[base64 data]' : undefined,
slider_icon_base64: apiData.slider_icon_base64 ? '[base64 data]' : undefined
}
})
const response = await apiClient.post(apiEndpoints.STUDENTS.CREATE, apiData)
......
......@@ -4,6 +4,9 @@
@update:model-value="emit('update:modelValue', $event)"
app
width="250"
:mobile-breakpoint="960"
:temporary="!lgAndUp"
:scrim="true"
>
<v-list-item :title="$t('navigation.welcome.title')" :subtitle="$t('navigation.welcome.subtitle')"></v-list-item>
<v-divider></v-divider>
......@@ -65,6 +68,7 @@
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
import { GLOBAL_MENU_ITEMS } from './constants/menuItems'
// 使用i18n的全局实例
......@@ -75,6 +79,8 @@ defineProps({
})
const emit = defineEmits(['update:modelValue'])
const { lgAndUp } = useDisplay()
const route = useRoute()
// 预留权限/可见性过滤钩子(当前直接返回所有项)
......
......@@ -4,16 +4,36 @@
<v-app-bar-nav-icon @click="$emit('toggle-drawer')"></v-app-bar-nav-icon>
</template>
<template v-slot:append>
<v-btn icon>
<v-icon>mdi-magnify</v-icon>
</v-btn>
<v-btn icon>
<v-icon>mdi-bell</v-icon>
</v-btn>
<LanguageSwitcher class="mr-2" />
<v-btn icon @click="$emit('logout')">
<v-icon>mdi-logout</v-icon>
</v-btn>
<!-- ≥600px 显示完整按钮 -->
<v-btn icon class="d-none d-sm-flex" :title="$t('components.appHeader.search.button')">
<v-icon>mdi-magnify</v-icon>
</v-btn>
<v-btn icon class="d-none d-sm-flex" :title="$t('components.appHeader.notifications.button')">
<v-icon>mdi-bell</v-icon>
</v-btn>
<LanguageSwitcher class="mr-2 d-none d-sm-inline-flex" />
<v-btn icon @click="$emit('logout')" class="d-none d-sm-flex" :title="$t('components.appHeader.user.logout')">
<v-icon>mdi-logout</v-icon>
</v-btn>
<!-- <600px overflow 菜单 -->
<v-menu class="d-flex d-sm-none">
<template v-slot:activator="{ props }">
<v-btn icon v-bind="props" :title="$t('components.appHeader.moreMenu')">
<v-icon>mdi-dots-vertical</v-icon>
</v-btn>
</template>
<v-list>
<v-list-item prepend-icon="mdi-magnify" :title="$t('components.appHeader.search.button')" />
<v-list-item prepend-icon="mdi-bell" :title="$t('components.appHeader.notifications.button')" />
<v-list-item>
<LanguageSwitcher />
</v-list-item>
<v-list-item prepend-icon="mdi-logout"
:title="$t('components.appHeader.user.logout')"
@click="$emit('logout')" />
</v-list>
</v-menu>
</template>
<v-app-bar-title>{{ $t('components.appHeader.title') }}</v-app-bar-title>
......
......@@ -3,10 +3,10 @@
<v-img
src="/src/assets/images/login.jpg"
cover
height="100vh"
min-height="100dvh"
style="object-position: center top;"
>
<v-layout height="100vh" style="background: rgba(255, 255, 255, 0.85);">
<v-layout min-height="100dvh" style="background: rgba(255, 255, 255, 0.85);">
<AppHeader :drawer-active="drawerActive" @toggle-drawer="toggleDrawer" @logout="showLogoutDialog = true" />
<AppFooter />
<AppDrawer v-model="drawerActive" />
......@@ -45,8 +45,9 @@
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { ref, watchEffect, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
import AppHeader from './AppHeader.vue'
import AppDrawer from './AppDrawer.vue'
import AppFooter from './AppFooter.vue'
......@@ -56,6 +57,7 @@ import { useRouter, useRoute } from 'vue-router'
import { GLOBAL_MENU_ITEMS } from './constants/menuItems'
const { t } = useI18n()
const { lgAndUp } = useDisplay()
const auth = useAuthStore()
const router = useRouter()
const route = useRoute()
......@@ -63,6 +65,11 @@ const route = useRoute()
const drawerActive = ref(false)
const toggleDrawer = () => { drawerActive.value = !drawerActive.value }
// 桌面 (≥1280px) 默认展开 Drawer; 移动端由 hamburger 触发
watchEffect(() => {
drawerActive.value = lgAndUp.value
})
const showLogoutDialog = ref(false)
const confirmLogout = () => {
showLogoutDialog.value = false
......@@ -80,11 +87,6 @@ const handleLogout = () => {
router.push('/login')
}
// 登录后自动展开 Drawer
onMounted(() => {
drawerActive.value = true
})
// 从菜单配置中查找图标的辅助函数
const findIconByPath = (items, path) => {
for (const item of items) {
......
......@@ -7,8 +7,8 @@
prepend-icon="mdi-translate"
class="text-none"
>
{{ getCurrentLanguageName() }}
<v-icon class="ml-1">mdi-chevron-down</v-icon>
<span class="d-none d-sm-inline">{{ getCurrentLanguageName() }}</span>
<v-icon class="ml-1 d-none d-sm-inline">mdi-chevron-down</v-icon>
</v-btn>
</template>
......
......@@ -9,6 +9,7 @@ export default {
empty: 'No notifications',
title: 'Notifications'
},
moreMenu: 'More',
user: {
menu: 'User menu',
profile: 'Profile',
......
......@@ -88,6 +88,20 @@ export default {
}
},
// 滑块图标:替换 TaskView 编辑对话框里完成度 v-slider 的拖动按钮
sliderIcon: {
upload: {
title: 'Slider icon',
button: 'Upload icon',
change: 'Change icon',
hint: 'Replaces the drag handle on the completion slider inside the task edit dialog. Use a transparent or high-contrast image, up to 2 MB.',
selectImage: 'Please select an image file',
sizeExceeded: 'File size cannot exceed 2MB',
readError: 'File reading failed, please try again'
},
previewAlt: 'slider icon preview'
},
// 验证消息
validation: {
nameRequired: 'Name is required',
......
......@@ -9,6 +9,7 @@ export default {
empty: '暂无通知',
title: '通知'
},
moreMenu: '更多',
user: {
menu: '用户菜单',
profile: '个人资料',
......
......@@ -88,6 +88,20 @@ export default {
}
},
// 滑块图标:替换 TaskView 编辑对话框里完成度 v-slider 的拖动按钮
sliderIcon: {
upload: {
title: '滑块图标',
button: '上传图标',
change: '更换图标',
hint: '用于替换任务编辑对话框里完成度滑块的拖动按钮。建议使用透明背景或对比度高的图片,最大 2 MB。',
selectImage: '请选择图片文件',
sizeExceeded: '文件大小不能超过 2MB',
readError: '文件读取失败,请重试'
},
previewAlt: '滑块图标预览'
},
// 验证消息
validation: {
nameRequired: '姓名为必填项',
......
......@@ -4,6 +4,9 @@ import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
// 全局 safe-area 适配 (iOS / Home Indicator)
import './styles/safe-area.css'
// 引入Vuetify
import vuetify from './plugins/vuetify'
......
<script setup>
import { onMounted, ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
import { useAuthStore } from '@/stores/auth'
import { getStories, updateStory, createStory, deleteStory } from '@/api/storyService.js'
import { getStudents } from '@/api/studentService.js'
import { getSubjects } from '@/api/subjectService.js'
import { getTerms } from '@/api/termService.js'
const { xs } = useDisplay()
const { t } = useI18n()
const authStore = useAuthStore()
......@@ -426,7 +429,7 @@ const getTrackedStatus = (flag) => {
:items="stories"
item-key="story_id"
:items-per-page="10"
class="elevation-1"
class="elevation-1 text-no-wrap"
:sort-by="[{ key: 'story_name', order: 'asc' }]"
multi-sort
:items-per-page-text="t('common.table.pagination.itemsPerPage')"
......@@ -440,8 +443,9 @@ const getTrackedStatus = (flag) => {
{ value: -1, title: t('common.table.pagination.itemsPerPageAll') }
]"
:hide-default-footer="false"
height="calc(100vh - 360px)"
fixed-header
:height="xs ? undefined : 'calc(100dvh - 360px)'"
:fixed-header="!xs"
:density="xs ? 'compact' : 'comfortable'"
>
<template v-slot:[`item.student_id`]="{ item }">
{{ getStudentName(item.student_id) }}
......
......@@ -22,7 +22,10 @@ const editForm = ref({
enabled: true,
avatar: '',
avatar_mime_type: '',
avatar_file_name: ''
avatar_file_name: '',
slider_icon: '',
slider_icon_mime_type: '',
slider_icon_file_name: ''
})
const isSaving = ref(false)
const successMessage = ref('')
......@@ -37,7 +40,10 @@ const createForm = ref({
enabled: true,
avatar: '',
avatar_mime_type: '',
avatar_file_name: ''
avatar_file_name: '',
slider_icon: '',
slider_icon_mime_type: '',
slider_icon_file_name: ''
})
const isCreating = ref(false)
const createErrorMessage = ref('')
......@@ -109,6 +115,12 @@ const getAvatarDataUrl = (avatar, mimeType) => {
return `data:${mimeType};base64,${avatar}`
}
// 生成滑块图标的数据URL(TaskView 日历上的进度标记)
const getSliderIconDataUrl = (slider_icon, mimeType) => {
if (!slider_icon || !mimeType) return null
return `data:${mimeType};base64,${slider_icon}`
}
// 头像上传处理
const handleAvatarUpload = (event) => {
console.log('File upload event:', event)
......@@ -175,6 +187,50 @@ const removeAvatar = () => {
editForm.value.avatar_file_name = ''
}
// 滑块图标上传处理(编辑对话框)
const handleSliderIconUpload = (event) => {
const files = event.target?.files || event
const file = Array.isArray(files) ? files[0] : files?.[0] || files
if (!file) return
if (!file.type.startsWith('image/')) {
saveErrorMessage.value = t('student.sliderIcon.upload.selectImage')
setTimeout(() => { saveErrorMessage.value = '' }, 3000)
return
}
if (file.size > 2 * 1024 * 1024) {
saveErrorMessage.value = t('student.sliderIcon.upload.sizeExceeded')
setTimeout(() => { saveErrorMessage.value = '' }, 3000)
return
}
const reader = new FileReader()
reader.onload = (e) => {
const base64 = e.target.result.split(',')[1]
editForm.value.slider_icon = base64
editForm.value.slider_icon_mime_type = file.type
editForm.value.slider_icon_file_name = file.name
saveErrorMessage.value = ''
console.log('Slider icon data set:', {
slider_icon_length: base64?.length,
mime_type: file.type,
file_name: file.name
})
}
reader.onerror = () => {
saveErrorMessage.value = t('student.sliderIcon.upload.readError')
setTimeout(() => { saveErrorMessage.value = '' }, 3000)
}
reader.readAsDataURL(file)
}
// 移除滑块图标(编辑对话框)
const removeSliderIcon = () => {
editForm.value.slider_icon = ''
editForm.value.slider_icon_mime_type = ''
editForm.value.slider_icon_file_name = ''
}
// 新增功能的头像上传处理
const handleCreateAvatarUpload = (event) => {
console.log('Create file upload event:', event)
......@@ -237,6 +293,50 @@ const removeCreateAvatar = () => {
createForm.value.avatar_file_name = ''
}
// 新增滑块图标上传处理(新增对话框)
const handleCreateSliderIconUpload = (event) => {
const files = event.target?.files || event
const file = Array.isArray(files) ? files[0] : files?.[0] || files
if (!file) return
if (!file.type.startsWith('image/')) {
createErrorMessage.value = t('student.sliderIcon.upload.selectImage')
setTimeout(() => { createErrorMessage.value = '' }, 3000)
return
}
if (file.size > 2 * 1024 * 1024) {
createErrorMessage.value = t('student.sliderIcon.upload.sizeExceeded')
setTimeout(() => { createErrorMessage.value = '' }, 3000)
return
}
const reader = new FileReader()
reader.onload = (e) => {
const base64 = e.target.result.split(',')[1]
createForm.value.slider_icon = base64
createForm.value.slider_icon_mime_type = file.type
createForm.value.slider_icon_file_name = file.name
createErrorMessage.value = ''
console.log('Create slider icon data set:', {
slider_icon_length: base64?.length,
mime_type: file.type,
file_name: file.name
})
}
reader.onerror = () => {
createErrorMessage.value = t('student.sliderIcon.upload.readError')
setTimeout(() => { createErrorMessage.value = '' }, 3000)
}
reader.readAsDataURL(file)
}
// 移除新增滑块图标
const removeCreateSliderIcon = () => {
createForm.value.slider_icon = ''
createForm.value.slider_icon_mime_type = ''
createForm.value.slider_icon_file_name = ''
}
// 编辑功能方法
const openEditDialog = (student) => {
editingStudent.value = student
......@@ -248,7 +348,10 @@ const openEditDialog = (student) => {
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 || ''
avatar_file_name: student.avatar_file_name || '',
slider_icon: student.slider_icon || '',
slider_icon_mime_type: student.slider_icon_mime_type || '',
slider_icon_file_name: student.slider_icon_file_name || ''
}
console.log('Editing student data:', student)
console.log('Converted form data:', editForm.value)
......@@ -265,7 +368,10 @@ const closeEditDialog = () => {
enabled: true,
avatar: '',
avatar_mime_type: '',
avatar_file_name: ''
avatar_file_name: '',
slider_icon: '',
slider_icon_mime_type: '',
slider_icon_file_name: ''
}
}
......@@ -278,7 +384,10 @@ const openCreateDialog = () => {
enabled: true,
avatar: '',
avatar_mime_type: '',
avatar_file_name: ''
avatar_file_name: '',
slider_icon: '',
slider_icon_mime_type: '',
slider_icon_file_name: ''
}
createErrorMessage.value = ''
createDialog.value = true
......@@ -293,7 +402,10 @@ const closeCreateDialog = () => {
enabled: true,
avatar: '',
avatar_mime_type: '',
avatar_file_name: ''
avatar_file_name: '',
slider_icon: '',
slider_icon_mime_type: '',
slider_icon_file_name: ''
}
createErrorMessage.value = ''
}
......@@ -328,7 +440,23 @@ const createNewStudent = async () => {
console.log('Not including avatar data')
}
console.log('Sending data:', { ...createData, avatar_base64: createData.avatar ? '[base64 data]' : undefined })
// 如果有滑块图标数据,则包含滑块图标字段
if (createForm.value.slider_icon && createForm.value.slider_icon_mime_type) {
createData.slider_icon = createForm.value.slider_icon
createData.slider_icon_mime_type = createForm.value.slider_icon_mime_type
createData.slider_icon_file_name = createForm.value.slider_icon_file_name
console.log('Including slider icon data:', {
slider_icon_length: createData.slider_icon?.length,
mime_type: createData.slider_icon_mime_type,
file_name: createData.slider_icon_file_name
})
}
console.log('Sending data:', {
...createData,
avatar_base64: createData.avatar ? '[base64 data]' : undefined,
slider_icon_base64: createData.slider_icon ? '[base64 data]' : undefined
})
// 1. 调用API创建学生
const newStudent = await createStudent(createData)
......@@ -346,6 +474,13 @@ const createNewStudent = async () => {
studentToAdd.avatar_file_name = createForm.value.avatar_file_name
}
// 如果有滑块图标数据,则添加滑块图标信息
if (createForm.value.slider_icon && createForm.value.slider_icon_mime_type) {
studentToAdd.slider_icon = createForm.value.slider_icon
studentToAdd.slider_icon_mime_type = createForm.value.slider_icon_mime_type
studentToAdd.slider_icon_file_name = createForm.value.slider_icon_file_name
}
students.value.unshift(studentToAdd) // 在列表顶部添加新学生
// 3. 显示成功消息
......@@ -460,7 +595,23 @@ const saveStudent = async () => {
})
}
console.log('Sending data:', { ...updateData, avatar_base64: updateData.avatar ? '[base64 data]' : undefined })
// 如果有滑块图标数据,则包含滑块图标字段
if (editForm.value.slider_icon && editForm.value.slider_icon_mime_type) {
updateData.slider_icon = editForm.value.slider_icon
updateData.slider_icon_mime_type = editForm.value.slider_icon_mime_type
updateData.slider_icon_file_name = editForm.value.slider_icon_file_name
console.log('Including slider icon data:', {
slider_icon_length: updateData.slider_icon?.length,
mime_type: updateData.slider_icon_mime_type,
file_name: updateData.slider_icon_file_name
})
}
console.log('Sending data:', {
...updateData,
avatar_base64: updateData.avatar ? '[base64 data]' : undefined,
slider_icon_base64: updateData.slider_icon ? '[base64 data]' : undefined
})
// 1. 调用API保存到服务器
await updateStudent(editingStudent.value.student_id, updateData)
......@@ -483,6 +634,13 @@ const saveStudent = async () => {
updatedStudent.avatar_file_name = editForm.value.avatar_file_name
}
// 如果有滑块图标数据,则更新滑块图标信息
if (editForm.value.slider_icon && editForm.value.slider_icon_mime_type) {
updatedStudent.slider_icon = editForm.value.slider_icon
updatedStudent.slider_icon_mime_type = editForm.value.slider_icon_mime_type
updatedStudent.slider_icon_file_name = editForm.value.slider_icon_file_name
}
students.value[index] = updatedStudent
}
......@@ -681,6 +839,57 @@ const saveStudent = async () => {
</div>
</div>
<!-- Slider icon upload area -->
<div class="mb-4">
<h4 class="text-subtitle-1 mb-3 font-weight-medium">
{{ $t('student.sliderIcon.upload.title') }}
</h4>
<div class="d-flex align-center">
<div class="position-relative me-4">
<v-avatar size="64" rounded="md" class="elevation-2">
<v-img
v-if="editForm.slider_icon && editForm.slider_icon_mime_type"
:src="getSliderIconDataUrl(editForm.slider_icon, editForm.slider_icon_mime_type)"
:alt="editForm.slider_icon_file_name || $t('student.sliderIcon.previewAlt')"
/>
<v-icon v-else icon="mdi-gauge" size="32" color="grey-darken-1" />
</v-avatar>
<v-btn
v-if="editForm.slider_icon"
icon="mdi-close"
size="x-small"
color="error"
variant="elevated"
class="position-absolute"
style="top: -6px; right: -6px;"
@click="removeSliderIcon"
/>
</div>
<div class="flex-grow-1">
<input
ref="sliderIconInput"
type="file"
accept="image/*"
style="display: none;"
@change="handleSliderIconUpload"
/>
<v-btn
color="primary"
variant="outlined"
size="small"
prepend-icon="mdi-image-plus"
@click="$refs.sliderIconInput.click()"
:disabled="isSaving"
>
{{ editForm.slider_icon ? $t('student.sliderIcon.upload.change') : $t('student.sliderIcon.upload.button') }}
</v-btn>
<div class="text-caption text-medium-emphasis mt-2">
{{ $t('student.sliderIcon.upload.hint') }}
</div>
</div>
</div>
</div>
<!-- Divider -->
<v-divider class="mb-4" />
</v-col>
......@@ -841,6 +1050,57 @@ const saveStudent = async () => {
</div>
</div>
<!-- Slider icon upload area -->
<div class="mb-4">
<h4 class="text-subtitle-1 mb-3 font-weight-medium">
{{ $t('student.sliderIcon.upload.title') }}
</h4>
<div class="d-flex align-center">
<div class="position-relative me-4">
<v-avatar size="64" rounded="md" class="elevation-2">
<v-img
v-if="createForm.slider_icon && createForm.slider_icon_mime_type"
:src="getSliderIconDataUrl(createForm.slider_icon, createForm.slider_icon_mime_type)"
:alt="createForm.slider_icon_file_name || $t('student.sliderIcon.previewAlt')"
/>
<v-icon v-else icon="mdi-gauge" size="32" color="grey-darken-1" />
</v-avatar>
<v-btn
v-if="createForm.slider_icon"
icon="mdi-close"
size="x-small"
color="error"
variant="elevated"
class="position-absolute"
style="top: -6px; right: -6px;"
@click="removeCreateSliderIcon"
/>
</div>
<div class="flex-grow-1">
<input
ref="createSliderIconInput"
type="file"
accept="image/*"
style="display: none;"
@change="handleCreateSliderIconUpload"
/>
<v-btn
color="primary"
variant="outlined"
size="small"
prepend-icon="mdi-image-plus"
@click="$refs.createSliderIconInput.click()"
:disabled="isCreating"
>
{{ createForm.slider_icon ? $t('student.sliderIcon.upload.change') : $t('student.sliderIcon.upload.button') }}
</v-btn>
<div class="text-caption text-medium-emphasis mt-2">
{{ $t('student.sliderIcon.upload.hint') }}
</div>
</div>
</div>
</div>
<!-- Divider -->
<v-divider class="mb-4" />
</v-col>
......@@ -970,6 +1230,12 @@ const saveStudent = async () => {
</template>
<style scoped>
@media (max-width: 600px) {
.v-data-table :deep(.v-avatar) {
width: 36px !important;
height: 36px !important;
}
}
</style>
<script setup>
import { onMounted, ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
import { useAuthStore } from '@/stores/auth'
import { getSubjects, updateSubject, createSubject, deleteSubject } from '@/api/subjectService.js'
import { COLOR_MAPPING, COLOR_KEYS } from '@/components/constants/colors'
const { xs } = useDisplay()
const { t } = useI18n()
const authStore = useAuthStore()
......@@ -372,7 +374,7 @@ const getCalendarColorValue = (colorCode) => {
:items="subjects"
item-key="subject_id"
:items-per-page="10"
class="elevation-1"
class="elevation-1 text-no-wrap"
:sort-by="[{ key: 'sort_sequence', order: 'asc' }]"
multi-sort
:items-per-page-text="$t('subject.table.pagination.itemsPerPage')"
......@@ -386,8 +388,9 @@ const getCalendarColorValue = (colorCode) => {
{ value: -1, title: $t('subject.table.pagination.itemsPerPageAll') }
]"
:hide-default-footer="false"
height="calc(100vh - 360px)"
fixed-header
:height="xs ? undefined : 'calc(100dvh - 360px)'"
:fixed-header="!xs"
:density="xs ? 'compact' : 'comfortable'"
>
<template v-slot:[`item.enabled_flag`]="{ item }">
<v-chip :color="item.enabled_flag === 'Y' ? 'success' : 'error'" text-color="white" size="small">
......
......@@ -9,17 +9,6 @@
<!-- 筛选条件和日历的左右布局 -->
<div class="d-flex task-layout">
<!-- 浮动折叠按钮:贴在筛选面板右边缘中段, 折叠时贴左边缘 -->
<v-btn
class="filter-panel-toggle"
:class="{ 'filter-panel-toggle-collapsed': !isFilterExpanded }"
:icon="isFilterExpanded ? 'mdi-chevron-left' : 'mdi-chevron-right'"
variant="elevated"
color="primary"
size="small"
@click="toggleFilterPanel"
:title="isFilterExpanded ? $t('task.filters.collapse') : $t('task.filters.expand')"
/>
<!-- 左侧:筛选条件面板 -->
<div
class="filter-panel-container"
......@@ -115,8 +104,21 @@
<div
class="calendar-container"
:class="{ 'calendar-full-width': !isFilterExpanded }"
style="height: calc(100vh - 270px); overflow-y: auto;"
:style="xs ? { height: '520px', minHeight: '520px' } : { height: 'calc(100dvh - 270px)', minHeight: '480px' }"
>
<!-- 浮动折叠按钮:桌面(左右布局)贴筛选面板右边缘中段, 移动端(上下布局)贴日历上方中央 -->
<v-btn
class="filter-panel-toggle"
:class="{ 'filter-panel-toggle-collapsed': !isFilterExpanded }"
:icon="xs
? (isFilterExpanded ? 'mdi-chevron-up' : 'mdi-chevron-down')
: (isFilterExpanded ? 'mdi-chevron-left' : 'mdi-chevron-right')"
variant="elevated"
color="primary"
size="small"
@click="toggleFilterPanel"
:title="isFilterExpanded ? $t('task.filters.collapse') : $t('task.filters.expand')"
/>
<!-- 错误提示已移至编辑对话框内部 -->
<!-- 日历组件 -->
......@@ -130,7 +132,7 @@
top: isFullscreen ? '0' : 'auto',
left: isFullscreen ? '0' : 'auto',
width: isFullscreen ? '100vw' : 'auto',
height: isFullscreen ? '100vh' : '100%',
height: isFullscreen ? '100vh' : (xs ? '520px' : '100%'),
'z-index': isFullscreen ? '9999' : 'auto',
overflow: isFullscreen ? 'auto' : 'visible'
}"
......@@ -154,7 +156,7 @@
display: 'flex',
'flex-direction': 'column',
position: 'relative',
height: isFullscreen ? '0' : 'auto',
height: isFullscreen ? '0' : '100%',
'min-height': '0',
padding: isFullscreen ? '0' : '16px'
}">
......@@ -310,6 +312,8 @@
:step="1"
thumb-label="always"
persistent-hint
:style="completionSliderStyle"
:class="['completion-slider', { 'completion-slider--custom': isCompletionSliderCustom }]"
:hint="`${editForm.completion_percent}%`"
>
<template #append>
......@@ -476,7 +480,7 @@
</v-dialog>
<!-- 图像放大查看对话框 -->
<v-dialog v-model="imageDialog" max-width="800">
<v-dialog v-model="imageDialog" :max-width="$vuetify.display.smAndDown ? '100%' : 800">
<v-card>
<v-card-title>
<span>{{ $t('task.task.image') }} {{ currentImageIndex }}</span>
......@@ -529,6 +533,7 @@
<script setup>
import { onMounted, ref, computed, watch, onUnmounted, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
import { useAuthStore } from '@/stores/auth'
import { getTasks, getTaskImage, updateTask as updateTaskAPI, deleteTask } from '@/api/taskService.js'
import { getStudents } from '@/api/studentService.js'
......@@ -549,6 +554,7 @@ defineOptions({
const { t, locale: i18nLocale } = useI18n()
const authStore = useAuthStore()
const { xs } = useDisplay()
// FullCalendar 使用 'en' / 'zh-cn'(小写),与项目内部 'zh-CN' 略有差异
const fcLocale = computed(() => (i18nLocale.value === 'zh-CN' ? 'zh-cn' : 'en'))
......@@ -650,9 +656,9 @@ const calendarOptions = computed(() => ({
firstDay: 0,
locale: fcLocale.value,
headerToolbar: {
left: 'prev,next today',
left: xs.value ? 'prev,next' : 'prev,next today',
center: 'title',
right: 'dayGridMonth,dayGridWeek,dayGridDay'
right: xs.value ? 'dayGridMonth,dayGridWeek' : 'dayGridMonth,dayGridWeek,dayGridDay'
},
buttonText: {
today: t('task.calendar.buttonText.today'),
......@@ -660,9 +666,11 @@ const calendarOptions = computed(() => ({
week: t('task.calendar.buttonText.week'),
day: t('task.calendar.buttonText.day')
},
editable: true,
eventDurationEditable: true,
editable: !xs.value,
selectable: !xs.value,
eventDurationEditable: !xs.value,
displayEventTime: false,
dayMaxEvents: xs.value ? 3 : 5,
// 原生跨天事件(FullCalendar 原生支持 allDay 跨天条)
events: (() => {
const result = []
......@@ -852,7 +860,7 @@ const getAvatarFallback = (name) => {
// 为每个学生生成唯一的颜色
const getStudentColor = (studentId) => {
if (!studentId) return '#9E9E9E' // 默认灰色
// 使用学生ID作为种子生成一个伪随机颜色
// 从COLOR_MAPPING中选择一个颜色
const colorKeys = Object.keys(COLOR_MAPPING)
......@@ -861,6 +869,15 @@ const getStudentColor = (studentId) => {
return COLOR_MAPPING[colorKeys[colorIndex]] || '#9E9E9E'
}
// 查表:学生主数据里的 slider_icon → data URL。
// 编辑对话框里 v-slider 的 thumb 读取这个值,未设置时调用方不应用图片样式。
const getStudentSliderIcon = (studentId) => {
if (!studentId) return null
const student = students.value.find(s => s.student_id === studentId)
if (!student || !student.slider_icon || !student.slider_icon_mime_type) return null
return `data:${student.slider_icon_mime_type};base64,${student.slider_icon}`
}
// 获取学科简称(取学科名称的第一个字符)
const getSubjectShortName = (subjectId) => {
const subject = subjects.value.find(s => s.subject_id === subjectId)
......@@ -1142,6 +1159,17 @@ const editStoryOptions = computed(() => {
}))
})
// 编辑对话框:completion 滑块的 thumb 自定义样式。
// 学生没选 / 没设 slider_icon 时返回空对象 → 不加 --custom class → 默认 Vuetify thumb。
// 学生设了 slider_icon 时返回 CSS 变量 + 加 class → 深选择器改写 .v-slider-thumb__surface。
const completionSliderStyle = computed(() => {
const url = getStudentSliderIcon(editForm.value.student_id)
if (!url) return {}
return { '--v-slider-thumb-image': `url("${url}")` }
})
const isCompletionSliderCustom = computed(() => Boolean(completionSliderStyle.value['--v-slider-thumb-image']))
// 图像放大查看相关状态
const imageDialog = ref(false)
const currentImageIndex = ref(null)
......@@ -1905,6 +1933,19 @@ watch(() => editForm.value.subject_id, (newSubjectId, oldSubjectId) => {
flex: 0 0 auto;
}
/* 完成度滑块:当学生在 Student 主数据里设置了 slider_icon 时,
用 CSS 变量把 .v-slider-thumb__surface 的默认纯色背景替换为该图。
--v-slider-thumb-image 由 :style 上的 completionSliderStyle 计算属性在 v-slider 根上注入。
学生未设置 → 没有 --custom class → 深选择器不命中 → 默认 Vuetify thumb 视觉不动。 */
.completion-slider--custom :deep(.v-slider-thumb__surface) {
background-color: rgb(var(--v-theme-primary));
background-image: var(--v-slider-thumb-image);
background-repeat: no-repeat;
background-position: center;
background-size: contain;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25);
}
/* 图像容器样式 */
.image-container {
display: flex;
......@@ -2115,7 +2156,7 @@ watch(() => editForm.value.subject_id, (newSubjectId, oldSubjectId) => {
.filter-panel-toggle {
position: absolute;
top: 50%;
left: 240px; /* panel 宽 240 + margin-right 16, 减半按钮宽约等于贴在右边 */
left: -16px; /* panel 宽 240 + margin-right 16, 按钮在 calendar 左边缘外 16px */
transform: translate(-50%, -50%);
z-index: 10;
transition: left 0.4s cubic-bezier(0.4, 0.0, 0.2, 1);
......@@ -2151,6 +2192,7 @@ watch(() => editForm.value.subject_id, (newSubjectId, oldSubjectId) => {
flex: 1;
transition: all 0.4s cubic-bezier(0.4, 0.0, 0.2, 1);
min-width: 0;
position: relative;
}
.calendar-container.calendar-full-width {
......@@ -2225,7 +2267,27 @@ watch(() => editForm.value.subject_id, (newSubjectId, oldSubjectId) => {
}
.calendar-container {
height: calc(100vh - 350px);
height: auto;
min-height: 480px;
}
}
/* xs: .calendar-container 显式 height不是 min-height),让内部 <v-card height="100%"> 能正确级联 */
@media (max-width: 600px) {
.calendar-container {
height: 520px !important;
min-height: 520px;
}
/* 折叠按钮:从「面板右侧中段」改成「日历上方中央」(半个按钮高探出日历顶部) */
.filter-panel-toggle {
top: -16px;
left: 50%;
transform: translateX(-50%);
}
/* 折叠态位置不变(已经在日历上方中央) */
.filter-panel-toggle.filter-panel-toggle-collapsed {
left: 50%;
}
}
......@@ -2332,4 +2394,18 @@ watch(() => editForm.value.subject_id, (newSubjectId, oldSubjectId) => {
.task-calendar :deep(.fc-daygrid-block-event .fc-event-main) {
overflow: visible;
}
/* 移动端 xs:日历字号 / 按钮 padding / 事件字号压缩 */
@media (max-width: 600px) {
.task-calendar :deep(.fc) { font-size: 12px; }
.task-calendar :deep(.fc-toolbar-title) { font-size: 14px; }
.task-calendar :deep(.fc-button) { padding: 4px 6px; font-size: 12px; }
.task-calendar :deep(.fc-toolbar.fc-header-toolbar) {
flex-wrap: wrap;
gap: 4px;
}
.task-calendar :deep(.fc-event-content) { font-size: 11px; padding: 0 2px; }
.task-calendar :deep(.fc-event-avatar) { width: 16px; height: 16px; font-size: 10px; }
.task-calendar :deep(.fc-daygrid-day-frame) { min-height: 48px; }
}
</style>
<script setup>
import { onMounted, ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
import { useAuthStore } from '@/stores/auth'
import { getTerms, updateTerm, createTerm, deleteTerm } from '@/api/termService.js'
import { getStudents } from '@/api/studentService.js'
const { xs } = useDisplay()
const { t } = useI18n()
const authStore = useAuthStore()
......@@ -368,7 +370,7 @@ const saveTerm = async () => {
:items="terms"
item-key="term_id"
:items-per-page="10"
class="elevation-1"
class="elevation-1 text-no-wrap"
:sort-by="[{ key: 'term_start_date', order: 'desc' }]"
multi-sort
:items-per-page-text="$t('term.table.pagination.itemsPerPage')"
......@@ -382,8 +384,9 @@ const saveTerm = async () => {
{ value: -1, title: $t('term.table.pagination.itemsPerPageAll') }
]"
:hide-default-footer="false"
height="calc(100vh - 360px)"
fixed-header
:height="xs ? undefined : 'calc(100dvh - 360px)'"
:fixed-header="!xs"
:density="xs ? 'compact' : 'comfortable'"
>
<template v-slot:[`item.term_start_date`]="{ item }">
{{ formatDate(item.term_start_date) }}
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment