Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
Clark Lin
student-app-frontend
Commits
8f1cac35
Commit
8f1cac35
authored
Jul 06, 2026
by
Administrator
Browse files
mobile: responsive app shell + lists/calendar; student slider_icon for TaskView completion thumb
parent
fd870ca4
Changes
16
Hide whitespace changes
Inline
Side-by-side
.gitignore
View file @
8f1cac35
...
...
@@ -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
src/api/studentService.js
View file @
8f1cac35
...
...
@@ -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
)
...
...
src/components/AppDrawer.vue
View file @
8f1cac35
...
...
@@ -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
()
// 预留权限/可见性过滤钩子(当前直接返回所有项)
...
...
src/components/AppHeader.vue
View file @
8f1cac35
...
...
@@ -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>
<!--
<
600
px
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>
...
...
src/components/AppLayout.vue
View file @
8f1cac35
...
...
@@ -3,10 +3,10 @@
<v-img
src=
"/src/assets/images/login.jpg"
cover
height=
"100vh"
min-
height=
"100
d
vh"
style=
"object-position: center top;"
>
<v-layout
height=
"100vh"
style=
"background: rgba(255, 255, 255, 0.85);"
>
<v-layout
min-
height=
"100
d
vh"
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
)
{
...
...
src/components/LanguageSwitcher.vue
View file @
8f1cac35
...
...
@@ -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
>
...
...
src/locales/en/components/app-header.js
View file @
8f1cac35
...
...
@@ -9,6 +9,7 @@ export default {
empty
:
'
No notifications
'
,
title
:
'
Notifications
'
},
moreMenu
:
'
More
'
,
user
:
{
menu
:
'
User menu
'
,
profile
:
'
Profile
'
,
...
...
src/locales/en/modules/student.js
View file @
8f1cac35
...
...
@@ -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
'
,
...
...
src/locales/zh-CN/components/app-header.js
View file @
8f1cac35
...
...
@@ -9,6 +9,7 @@ export default {
empty
:
'
暂无通知
'
,
title
:
'
通知
'
},
moreMenu
:
'
更多
'
,
user
:
{
menu
:
'
用户菜单
'
,
profile
:
'
个人资料
'
,
...
...
src/locales/zh-CN/modules/student.js
View file @
8f1cac35
...
...
@@ -88,6 +88,20 @@ export default {
}
},
// 滑块图标:替换 TaskView 编辑对话框里完成度 v-slider 的拖动按钮
sliderIcon
:
{
upload
:
{
title
:
'
滑块图标
'
,
button
:
'
上传图标
'
,
change
:
'
更换图标
'
,
hint
:
'
用于替换任务编辑对话框里完成度滑块的拖动按钮。建议使用透明背景或对比度高的图片,最大 2 MB。
'
,
selectImage
:
'
请选择图片文件
'
,
sizeExceeded
:
'
文件大小不能超过 2MB
'
,
readError
:
'
文件读取失败,请重试
'
},
previewAlt
:
'
滑块图标预览
'
},
// 验证消息
validation
:
{
nameRequired
:
'
姓名为必填项
'
,
...
...
src/main.js
View file @
8f1cac35
...
...
@@ -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
'
...
...
src/views/StoryView.vue
View file @
8f1cac35
<
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
)
}}
...
...
src/views/StudentView.vue
View file @
8f1cac35
...
...
@@ -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
>
src/views/SubjectView.vue
View file @
8f1cac35
<
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"
>
...
...
src/views/TaskView.vue
View file @
8f1cac35
...
...
@@ -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(100
d
vh - 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
:
240
px
;
/* panel 宽 240 + margin-right 16,
减半按钮宽约等于贴在右边
*/
left
:
-16
px
;
/* 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
>
src/views/TermView.vue
View file @
8f1cac35
<
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
)
}}
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment