Commit fd870ca4 authored by Administrator's avatar Administrator
Browse files

added multiple feat

UI: add Settings skeleton page with v-tabs + 6 categories
UI: enlarge TaskView event fonts and replace progress bar with SVG ring
UI: add native title tooltip on TaskView events
UI: convert filter panel toggle to floating icon button with slide animation
UI: set browser tab title to "学生管理系统"
parent 1e2ed63c
...@@ -39,4 +39,5 @@ clipboard* ...@@ -39,4 +39,5 @@ clipboard*
*.xlsx *.xlsx
*.yaml *.yaml
暑假日程表.txt 暑假日程表.txt
import-assignments.cjs import-assignments.cjs
\ No newline at end of file to-do-feature.md
\ No newline at end of file
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<link rel="icon" href="/favicon.ico"> <link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title> <title>学生管理系统</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
......
...@@ -49,5 +49,11 @@ export const GLOBAL_MENU_ITEMS = [ ...@@ -49,5 +49,11 @@ export const GLOBAL_MENU_ITEMS = [
mdiIcon: 'mdi-clipboard-check-multiple-outline', mdiIcon: 'mdi-clipboard-check-multiple-outline',
hasRouter: true hasRouter: true
}, },
{
nameKey: 'navigation.menu.settings',
path: '/settings',
mdiIcon: 'mdi-cog-outline',
hasRouter: true
},
// 可以继续添加更多菜单项 // 可以继续添加更多菜单项
]; ];
...@@ -11,6 +11,7 @@ import task from './modules/task' ...@@ -11,6 +11,7 @@ import task from './modules/task'
import story from './modules/story' import story from './modules/story'
import auth from './modules/auth' import auth from './modules/auth'
import navigation from './modules/navigation' import navigation from './modules/navigation'
import settings from './modules/settings'
import appHeader from './components/app-header' import appHeader from './components/app-header'
// 组合所有翻译 // 组合所有翻译
...@@ -29,6 +30,7 @@ export default { ...@@ -29,6 +30,7 @@ export default {
story, story,
auth, auth,
navigation, navigation,
settings,
components: { components: {
appHeader appHeader
} }
......
...@@ -27,7 +27,8 @@ export default { ...@@ -27,7 +27,8 @@ export default {
terms: 'Terms', terms: 'Terms',
stories: 'Story', stories: 'Story',
tasks: 'Task Management', tasks: 'Task Management',
profile: 'Profile' profile: 'Profile',
settings: 'Settings'
}, },
drawer: { drawer: {
......
export default {
title: 'Settings',
subtitle: 'Manage your preferences and account',
// 分类 tab 标签
categories: {
general: { label: 'General', description: 'Language, region, basic preferences' },
appearance: { label: 'Appearance', description: 'Theme, density, color scheme' },
notifications: { label: 'Notifications', description: 'Reminders, alerts, sound' },
data: { label: 'Data', description: 'Import, export, cache, reset' },
account: { label: 'Account', description: 'Profile, password, devices' },
about: { label: 'About', description: 'Version, licenses, acknowledgements' }
},
// 顶部动作
actions: {
reset: 'Reset to defaults',
save: 'Save changes'
},
// 占位说明(各 tab 卡片复用)
placeholders: {
comingSoon: 'This section is not implemented yet (phase-1 skeleton).'
},
// 各分类的字段(phase-1 仅占位)
fields: {
language: 'Display language',
timezone: 'Time zone',
theme: 'Theme',
density: 'Interface density',
weekStart: 'Week starts on',
reminderLeadTime: 'Default reminder lead time',
exportFormat: 'Default export format',
autoBackup: 'Auto-backup enabled'
},
messages: {
phase1Hint: 'Skeleton only — values are not persisted yet.',
resetConfirm: 'Reset all settings to defaults?'
}
}
\ No newline at end of file
...@@ -14,7 +14,16 @@ export default { ...@@ -14,7 +14,16 @@ export default {
}, },
noEvents: 'No tasks this month', noEvents: 'No tasks this month',
enterFullscreen: 'Enter Fullscreen', enterFullscreen: 'Enter Fullscreen',
exitFullscreen: 'Exit Fullscreen' exitFullscreen: 'Exit Fullscreen',
tooltip: {
progress: 'Progress: {percent}%'
},
buttonText: {
today: 'Today',
month: 'Month',
week: 'Week',
day: 'Day'
}
}, },
task: { task: {
name: 'Task Name', name: 'Task Name',
...@@ -57,6 +66,13 @@ export default { ...@@ -57,6 +66,13 @@ export default {
title: 'Edit Task', title: 'Edit Task',
createTitle: 'Create New Task' createTitle: 'Create New Task'
}, },
dialog: {
delete: 'Delete Confirmation'
},
deleteConfirmation: {
message: 'Are you sure you want to delete this task? This action cannot be undone.',
warning: 'This action is irreversible.'
},
messages: { messages: {
loadError: 'Failed to load task data', loadError: 'Failed to load task data',
noData: 'No task data available', noData: 'No task data available',
......
...@@ -11,6 +11,7 @@ import task from './modules/task' ...@@ -11,6 +11,7 @@ import task from './modules/task'
import story from './modules/story' import story from './modules/story'
import auth from './modules/auth' import auth from './modules/auth'
import navigation from './modules/navigation' import navigation from './modules/navigation'
import settings from './modules/settings'
import appHeader from './components/app-header' import appHeader from './components/app-header'
// 组合所有翻译 // 组合所有翻译
...@@ -29,6 +30,7 @@ export default { ...@@ -29,6 +30,7 @@ export default {
story, story,
auth, auth,
navigation, navigation,
settings,
components: { components: {
appHeader appHeader
} }
......
...@@ -27,7 +27,8 @@ export default { ...@@ -27,7 +27,8 @@ export default {
terms: '学期', terms: '学期',
stories: '系列任务', stories: '系列任务',
tasks: '作业管理', tasks: '作业管理',
profile: '个人资料' profile: '个人资料',
settings: '设置'
}, },
drawer: { drawer: {
......
export default {
title: '设置',
subtitle: '管理您的偏好与账户',
// 分类 tab 标签
categories: {
general: { label: '通用', description: '语言、区域、基本偏好' },
appearance: { label: '外观', description: '主题、密度、配色' },
notifications: { label: '通知', description: '提醒、弹窗、声音' },
data: { label: '数据', description: '导入、导出、缓存、重置' },
account: { label: '账户', description: '资料、密码、设备' },
about: { label: '关于', description: '版本、许可、致谢' }
},
// 顶部动作
actions: {
reset: '恢复默认',
save: '保存修改'
},
// 占位说明
placeholders: {
comingSoon: '本分区尚未实现(phase-1 骨架)。'
},
// 各分类的字段
fields: {
language: '显示语言',
timezone: '时区',
theme: '主题',
density: '界面密度',
weekStart: '一周起始日',
reminderLeadTime: '默认提醒提前时间',
exportFormat: '默认导出格式',
autoBackup: '启用自动备份'
},
messages: {
phase1Hint: '当前仅为骨架,暂不会持久化。',
resetConfirm: '确定要把所有设置恢复为默认值吗?'
}
}
\ No newline at end of file
...@@ -14,7 +14,16 @@ export default { ...@@ -14,7 +14,16 @@ export default {
}, },
noEvents: '本月没有作业', noEvents: '本月没有作业',
enterFullscreen: '全屏显示', enterFullscreen: '全屏显示',
exitFullscreen: '退出全屏' exitFullscreen: '退出全屏',
tooltip: {
progress: '进度: {percent}%'
},
buttonText: {
today: '今天',
month: '',
week: '',
day: ''
}
}, },
task: { task: {
name: '作业名称', name: '作业名称',
...@@ -57,6 +66,13 @@ export default { ...@@ -57,6 +66,13 @@ export default {
title: '编辑作业', title: '编辑作业',
createTitle: '新增作业' createTitle: '新增作业'
}, },
dialog: {
delete: '删除确认'
},
deleteConfirmation: {
message: '您确定要删除此作业吗?此操作无法撤销。',
warning: '此操作不可撤销,请谨慎。'
},
messages: { messages: {
loadError: '加载作业数据失败', loadError: '加载作业数据失败',
noData: '暂无作业数据', noData: '暂无作业数据',
......
...@@ -9,6 +9,7 @@ import SubjectView from '../views/SubjectView.vue' ...@@ -9,6 +9,7 @@ import SubjectView from '../views/SubjectView.vue'
import TermView from '../views/TermView.vue' import TermView from '../views/TermView.vue'
import StoryView from '../views/StoryView.vue' import StoryView from '../views/StoryView.vue'
import TaskView from '../views/TaskView.vue' import TaskView from '../views/TaskView.vue'
import SettingsView from '../views/SettingsView.vue'
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
...@@ -138,6 +139,23 @@ const router = createRouter({ ...@@ -138,6 +139,23 @@ const router = createRouter({
] ]
}, },
}, },
{
path: '/settings',
name: 'settings',
component: SettingsView,
meta: {
requiresAuth: true,
layout: 'default',
title: 'Settings',
breadcrumb: {
key: 'navigation.breadcrumb.settings'
},
breadcrumbPath: [
{ key: 'navigation.breadcrumb.home', to: '/', disabled: false },
{ key: 'navigation.breadcrumb.settings', to: '/settings', disabled: true }
]
},
},
{ {
path: '/:pathMatch(.*)*', path: '/:pathMatch(.*)*',
redirect: '/', redirect: '/',
......
<script setup>
import { ref } from 'vue'
// 分类配置(phase-1: 数组驱动 tabs; phase-2: 改为 { key, icon, component })
const categories = [
{ key: 'general', icon: 'mdi-tune-variant' },
{ key: 'appearance', icon: 'mdi-palette-outline' },
{ key: 'notifications', icon: 'mdi-bell-outline' },
{ key: 'data', icon: 'mdi-database-outline' },
{ key: 'account', icon: 'mdi-account-cog-outline' },
{ key: 'about', icon: 'mdi-information-outline' }
]
const activeTab = ref('general')
// phase-1 占位状态; phase-2 替换为 usePreferencesStore()
const general = ref({ language: 'zh-CN', timezone: 'Asia/Shanghai' })
const appearance = ref({ theme: 'system', density: 'comfortable' })
const notifications = ref({ weekStart: 1, reminderLeadTime: 15 })
const data = ref({ exportFormat: 'xlsx', autoBackup: true })
</script>
<template>
<v-container fluid>
<!-- 页面标题区(与 HomeView/TaskView 一致) -->
<v-row class="mb-4">
<v-col cols="12">
<h2>{{ $t('settings.title') }}</h2>
<p class="text-body-2 text-medium-emphasis mt-1">
{{ $t('settings.subtitle') }}
</p>
</v-col>
</v-row>
<v-card>
<!-- 分类 tabs -->
<v-tabs v-model="activeTab" show-arrows density="comfortable">
<v-tab v-for="cat in categories" :key="cat.key" :value="cat.key">
<v-icon class="me-2" :icon="cat.icon" />
{{ $t(`settings.categories.${cat.key}.label`) }}
</v-tab>
</v-tabs>
<v-divider />
<!-- 分类内容 -->
<v-window v-model="activeTab">
<v-window-item v-for="cat in categories" :key="cat.key" :value="cat.key">
<v-card flat>
<v-card-title>
{{ $t(`settings.categories.${cat.key}.label`) }}
</v-card-title>
<v-card-subtitle>
{{ $t(`settings.categories.${cat.key}.description`) }}
</v-card-subtitle>
<v-card-text>
<p class="text-body-2 text-medium-emphasis mb-4">
{{ $t('settings.placeholders.comingSoon') }}
</p>
<v-row dense>
<!-- general -->
<template v-if="cat.key === 'general'">
<v-col cols="12" md="6">
<v-select
v-model="general.language"
:items="[
{ title: '中文', value: 'zh-CN' },
{ title: 'English', value: 'en' }
]"
:label="$t('settings.fields.language')"
variant="outlined"
density="comfortable"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="general.timezone"
:label="$t('settings.fields.timezone')"
variant="outlined"
density="comfortable"
/>
</v-col>
</template>
<!-- appearance -->
<template v-else-if="cat.key === 'appearance'">
<v-col cols="12" md="6">
<v-select
v-model="appearance.theme"
:items="['system', 'light', 'dark']"
:label="$t('settings.fields.theme')"
variant="outlined"
density="comfortable"
/>
</v-col>
<v-col cols="12" md="6">
<v-select
v-model="appearance.density"
:items="['comfortable', 'compact', 'default']"
:label="$t('settings.fields.density')"
variant="outlined"
density="comfortable"
/>
</v-col>
</template>
<!-- notifications -->
<template v-else-if="cat.key === 'notifications'">
<v-col cols="12" md="6">
<v-select
v-model="notifications.weekStart"
:items="[0, 1, 6]"
:label="$t('settings.fields.weekStart')"
variant="outlined"
density="comfortable"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model.number="notifications.reminderLeadTime"
type="number"
:label="$t('settings.fields.reminderLeadTime')"
variant="outlined"
density="comfortable"
/>
</v-col>
</template>
<!-- data -->
<template v-else-if="cat.key === 'data'">
<v-col cols="12" md="6">
<v-select
v-model="data.exportFormat"
:items="['xlsx', 'csv', 'json']"
:label="$t('settings.fields.exportFormat')"
variant="outlined"
density="comfortable"
/>
</v-col>
<v-col cols="12" md="6" class="d-flex align-center">
<v-switch
v-model="data.autoBackup"
:label="$t('settings.fields.autoBackup')"
color="primary"
hide-details
/>
</v-col>
</template>
<!-- account: 占位 -->
<template v-else-if="cat.key === 'account'">
<v-col cols="12">
<v-alert type="info" variant="tonal">
{{ $t('settings.placeholders.comingSoon') }}
</v-alert>
</v-col>
</template>
<!-- about -->
<template v-else-if="cat.key === 'about'">
<v-col cols="12">
<v-list>
<v-list-item title="Version" subtitle="0.1.0 (phase-1)" />
<v-list-item title="Vue" subtitle="3.x" />
<v-list-item title="Vuetify" subtitle="3.x" />
</v-list>
</v-col>
</template>
</v-row>
</v-card-text>
</v-card>
</v-window-item>
</v-window>
<!-- 底部共享按钮: 跨所有 tab, 只显示一对 Reset / Save -->
<v-divider />
<v-card-actions>
<v-spacer />
<v-btn variant="text" color="error">
{{ $t('settings.actions.reset') }}
</v-btn>
<v-btn color="primary" variant="elevated">
{{ $t('settings.actions.save') }}
</v-btn>
</v-card-actions>
</v-card>
</v-container>
</template>
\ No newline at end of file
...@@ -9,6 +9,17 @@ ...@@ -9,6 +9,17 @@
<!-- 筛选条件和日历的左右布局 --> <!-- 筛选条件和日历的左右布局 -->
<div class="d-flex task-layout"> <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 <div
class="filter-panel-container" class="filter-panel-container"
...@@ -127,16 +138,6 @@ ...@@ -127,16 +138,6 @@
<v-card-title class="d-flex justify-space-between align-center"> <v-card-title class="d-flex justify-space-between align-center">
<span>{{ $t('task.calendar.title') }}</span> <span>{{ $t('task.calendar.title') }}</span>
<div class="d-flex gap-1"> <div class="d-flex gap-1">
<!-- 筛选面板切换按钮 -->
<v-btn
:icon="isFilterExpanded ? 'mdi-filter-minus' : 'mdi-filter-plus'"
variant="outlined"
color="primary"
size="small"
@click="toggleFilterPanel"
:title="isFilterExpanded ? $t('task.filters.collapse') : $t('task.filters.expand')"
:disabled="isLoading"
/>
<!-- 全屏切换按钮 --> <!-- 全屏切换按钮 -->
<v-btn <v-btn
:icon="isFullscreen ? 'mdi-fullscreen-exit' : 'mdi-fullscreen'" :icon="isFullscreen ? 'mdi-fullscreen-exit' : 'mdi-fullscreen'"
...@@ -184,72 +185,6 @@ ...@@ -184,72 +185,6 @@
</div> </div>
</div> </div>
<!-- 任务详情对话框 -->
<v-dialog v-model="taskDetailDialog" max-width="600">
<v-card v-if="selectedTask">
<v-card-title>
<span>{{ selectedTask.task_name }}</span>
<v-spacer />
<v-btn
icon="mdi-close"
variant="text"
@click="taskDetailDialog = false"
/>
</v-card-title>
<v-card-text>
<v-row>
<v-col cols="6">
<v-list-item>
<v-list-item-title>{{ $t('task.task.subject') }}</v-list-item-title>
<v-list-item-subtitle>{{ getSubjectName(selectedTask.subject_id) }}</v-list-item-subtitle>
</v-list-item>
</v-col>
<v-col cols="6">
<v-list-item>
<v-list-item-title>{{ $t('task.task.student') }}</v-list-item-title>
<v-list-item-subtitle>{{ getStudentName(selectedTask.student_id) }}</v-list-item-subtitle>
</v-list-item>
</v-col>
<v-col cols="6">
<v-list-item>
<v-list-item-title>{{ $t('task.task.startDate') }}</v-list-item-title>
<v-list-item-subtitle>{{ formatDate(selectedTask.start_date) }}</v-list-item-subtitle>
</v-list-item>
</v-col>
<v-col cols="6">
<v-list-item>
<v-list-item-title>{{ $t('task.task.endDate') }}</v-list-item-title>
<v-list-item-subtitle>{{ formatDate(selectedTask.end_date) }}</v-list-item-subtitle>
</v-list-item>
</v-col>
<v-col cols="12">
<v-list-item>
<v-list-item-title>{{ $t('task.task.completionPercent') }}</v-list-item-title>
<v-list-item-subtitle>
<v-progress-linear
:model-value="selectedTask.completion_percent"
:color="getCompletionColor(selectedTask.completion_percent)"
height="20"
rounded
>
<template #default="{ value }">
<strong>{{ Math.ceil(value) }}%</strong>
</template>
</v-progress-linear>
</v-list-item-subtitle>
</v-list-item>
</v-col>
<v-col cols="12" v-if="selectedTask.task_description">
<v-list-item>
<v-list-item-title>{{ $t('task.task.description') }}</v-list-item-title>
<v-list-item-subtitle>{{ selectedTask.task_description }}</v-list-item-subtitle>
</v-list-item>
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-dialog>
<!-- 任务编辑对话框 --> <!-- 任务编辑对话框 -->
<v-dialog v-model="editDialog" max-width="600"> <v-dialog v-model="editDialog" max-width="600">
<v-card class="edit-task-card"> <v-card class="edit-task-card">
...@@ -588,65 +523,6 @@ ...@@ -588,65 +523,6 @@
</v-card-text> </v-card-text>
</v-card> </v-card>
</v-dialog> </v-dialog>
<!-- 删除确认对话框 -->
<v-dialog
v-model="deleteDialog"
max-width="500px"
persistent
>
<v-card>
<v-card-title class="text-h5 bg-error text-white">
{{ $t('common.messages.dialogs.deleteConfirmation') }}
</v-card-title>
<v-card-text class="pt-4">
<div v-if="isError" class="mb-4">
<v-alert
type="error"
variant="tonal"
closable
class="mb-2"
>
{{ errorMessage }}
</v-alert>
</div>
<p class="text-body-1 mb-4">{{ $t('task.messages.deleteConfirmation') }}</p>
<v-card variant="outlined" class="mb-4">
<v-card-item>
<v-card-title>{{ taskToDelete?.task_name }}</v-card-title>
</v-card-item>
</v-card>
<v-alert
type="warning"
variant="tonal"
icon="mdi-alert-circle"
class="mb-0"
>
{{ $t('common.messages.deleteWarning') }}
</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
@click="closeDeleteDialog"
variant="outlined"
>
{{ $t('common.buttons.cancel') }}
</v-btn>
<v-btn
color="error"
variant="flat"
:loading="isDeleting"
@click="confirmDeleteTask"
>
{{ $t('common.buttons.delete') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container> </v-container>
</template> </template>
...@@ -654,7 +530,7 @@ ...@@ -654,7 +530,7 @@
import { onMounted, ref, computed, watch, onUnmounted, nextTick } from 'vue' import { onMounted, ref, computed, watch, onUnmounted, nextTick } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { getTasks, getTaskById, getTaskImage, updateTask as updateTaskAPI, deleteTask } from '@/api/taskService.js' import { getTasks, getTaskImage, updateTask as updateTaskAPI, deleteTask } from '@/api/taskService.js'
import { getStudents } from '@/api/studentService.js' import { getStudents } from '@/api/studentService.js'
import { getSubjects } from '@/api/subjectService.js' import { getSubjects } from '@/api/subjectService.js'
import { getTerms } from '@/api/termService.js' import { getTerms } from '@/api/termService.js'
...@@ -671,14 +547,16 @@ defineOptions({ ...@@ -671,14 +547,16 @@ defineOptions({
} }
}) })
const { t } = useI18n() const { t, locale: i18nLocale } = useI18n()
const authStore = useAuthStore() const authStore = useAuthStore()
// FullCalendar 使用 'en' / 'zh-cn'(小写),与项目内部 'zh-CN' 略有差异
const fcLocale = computed(() => (i18nLocale.value === 'zh-CN' ? 'zh-cn' : 'en'))
// 基础状态 // 基础状态
const isLoading = ref(false) const isLoading = ref(false)
const isError = ref(false) const isError = ref(false)
const errorMessage = ref('') const errorMessage = ref('')
const successMessage = ref('')
// 删除功能相关状态 // 删除功能相关状态
const deleteDialog = ref(false) const deleteDialog = ref(false)
...@@ -687,10 +565,6 @@ const isDeleting = ref(false) ...@@ -687,10 +565,6 @@ const isDeleting = ref(false)
const tasks = ref([]) const tasks = ref([])
// 日历相关状态 // 日历相关状态
const calendar = ref(null)
const selectedDate = ref(new Date())
const taskDetailDialog = ref(false)
const selectedTask = ref(null)
const isFullscreen = ref(false) const isFullscreen = ref(false)
// 筛选相关状态 // 筛选相关状态
...@@ -774,18 +648,17 @@ const calendarOptions = computed(() => ({ ...@@ -774,18 +648,17 @@ const calendarOptions = computed(() => ({
height: 'parent', height: 'parent',
expandRows: true, expandRows: true,
firstDay: 0, firstDay: 0,
locale: 'zh-cn', locale: fcLocale.value,
buttonText: { today: '今天' },
headerToolbar: { headerToolbar: {
left: 'prev,next today', left: 'prev,next today',
center: 'title', center: 'title',
right: 'dayGridMonth,dayGridWeek,dayGridDay' right: 'dayGridMonth,dayGridWeek,dayGridDay'
}, },
buttonText: { buttonText: {
today: '今天', today: t('task.calendar.buttonText.today'),
month: '', month: t('task.calendar.buttonText.month'),
week: '', week: t('task.calendar.buttonText.week'),
day: '' day: t('task.calendar.buttonText.day')
}, },
editable: true, editable: true,
eventDurationEditable: true, eventDurationEditable: true,
...@@ -822,6 +695,15 @@ const calendarOptions = computed(() => ({ ...@@ -822,6 +695,15 @@ const calendarOptions = computed(() => ({
} }
return result return result
})(), })(),
// hover tooltip: 作业名 + 描述 + 进度。原生 title 属性, 浏览器自动渲染气泡。
eventDidMount: (arg) => {
const taskData = arg.event.extendedProps
const lines = [arg.event.title]
if (taskData.description) lines.push(taskData.description)
const pct = taskData.completion_percent || 0
if (pct > 0) lines.push(t('task.calendar.tooltip.progress', { percent: pct }))
arg.el.title = lines.join('\n')
},
// 自定义事件渲染 // 自定义事件渲染
eventContent: (arg) => { eventContent: (arg) => {
const taskData = arg.event.extendedProps const taskData = arg.event.extendedProps
...@@ -830,16 +712,31 @@ const calendarOptions = computed(() => ({ ...@@ -830,16 +712,31 @@ const calendarOptions = computed(() => ({
const subjectShort = getSubjectShortName(taskData.subject_id) || '' const subjectShort = getSubjectShortName(taskData.subject_id) || ''
const compPct = taskData.completion_percent || 0 const compPct = taskData.completion_percent || 0
// 学科背景色由后端给(深浅不定),根据亮度算文字色:暗底配白字、亮底配黑字。
// 在 .fc-event-custom 上设 color,所有文字子元素继承。
const subjectBg = getSubjectColor(taskData.subject_id)
const textColor = getTextColor(subjectBg)
// 进度环:1-99% 画弧,100% 用对勾。0% 不显示。
// 弧长 = 2πr,stroke-dashoffset 控制已绘制比例;rotate(-90) 让起点在 12 点钟方向。
let pctIcon = ''
if (compPct >= 100) {
pctIcon = `<svg class="fc-event-ring" width="11" height="11" viewBox="0 0 24 24" fill="currentColor" aria-label="completed"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>`
} else if (compPct > 0) {
const r = 5
const c = 2 * Math.PI * r
const offset = c * (1 - compPct / 100)
pctIcon = `<svg class="fc-event-ring" width="11" height="11" viewBox="0 0 12 12" aria-label="${compPct}%"><circle cx="6" cy="6" r="${r}" fill="none" stroke="currentColor" stroke-opacity="0.3" stroke-width="1.5"/><circle cx="6" cy="6" r="${r}" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-dasharray="${c.toFixed(2)}" stroke-dashoffset="${offset.toFixed(2)}" transform="rotate(-90 6 6)"/></svg>`
}
return { return {
html: ` html: `
<div class="fc-event-custom" data-task-id="${arg.event.id}"> <div class="fc-event-custom" data-task-id="${arg.event.id}" style="color:${textColor}">
<div class="fc-event-content"> <div class="fc-event-content">
<span class="fc-event-avatar" style="background:${getStudentColor(taskData.student_id)};color:${getTextColor(getStudentColor(taskData.student_id))}">${initial}</span> <span class="fc-event-avatar" style="background:${getStudentColor(taskData.student_id)};color:${getTextColor(getStudentColor(taskData.student_id))}">${initial}</span>
<span class="fc-event-subject">[${subjectShort}]</span> <span class="fc-event-subject">[${subjectShort}]</span>
<span class="fc-event-title">${arg.event.title}</span> <span class="fc-event-title">${arg.event.title}</span>
</div> <span class="fc-event-pct">${pctIcon}</span>
<div class="fc-event-bar">
<div class="fc-event-fill" style="width:${compPct}%;background:#4CAF50"></div>
</div> </div>
</div> </div>
` `
...@@ -922,14 +819,6 @@ const getTextColor = (backgroundColor) => { ...@@ -922,14 +819,6 @@ const getTextColor = (backgroundColor) => {
return '#FFFFFF' return '#FFFFFF'
} }
// 获取完成度对应的颜色
const getCompletionColor = (percent) => {
if (percent >= 100) return 'success'
if (percent >= 70) return 'info'
if (percent >= 30) return 'warning'
return 'error'
}
// 格式化日期 // 格式化日期
const formatDate = (date) => { const formatDate = (date) => {
if (!date) return '' if (!date) return ''
...@@ -972,12 +861,6 @@ const getStudentColor = (studentId) => { ...@@ -972,12 +861,6 @@ const getStudentColor = (studentId) => {
return COLOR_MAPPING[colorKeys[colorIndex]] || '#9E9E9E' return COLOR_MAPPING[colorKeys[colorIndex]] || '#9E9E9E'
} }
// 获取学科名称
const getSubjectName = (subjectId) => {
const subject = subjects.value.find(s => s.subject_id === subjectId)
return subject ? subject.subject_name : `Subject ${subjectId}`
}
// 获取学科简称(取学科名称的第一个字符) // 获取学科简称(取学科名称的第一个字符)
const getSubjectShortName = (subjectId) => { const getSubjectShortName = (subjectId) => {
const subject = subjects.value.find(s => s.subject_id === subjectId) const subject = subjects.value.find(s => s.subject_id === subjectId)
...@@ -1100,34 +983,26 @@ const fetchStories = async () => { ...@@ -1100,34 +983,26 @@ const fetchStories = async () => {
} }
// 事件处理函数 // 事件处理函数
const onEventClick = async (event) => { const onEventClick = (event) => {
console.log('Calendar event clicked:', event) console.log('Calendar event clicked:', event)
// 从事件中获取任务ID // 从事件中获取任务ID
const taskId = event.taskData?.id || event.id const taskId = event.taskData?.id || event.id
if (taskId) { if (taskId) {
try { // 列表接口已经返回编辑对话框所需的全量字段,直接复用缓存避免一次额外的请求。
// 显示加载状态 // 之前这里会 await getTaskById 并 toggle isLoading,导致日历上的 loading-overlay 闪一下。
isLoading.value = true const cachedTask = tasks.value.find((t) => t.task_id === taskId)
if (!cachedTask) {
// 从后端获取完整的任务详情 console.error('Task not found in local cache:', taskId)
const taskDetail = await getTaskById(taskId) errorMessage.value = t('task.messages.loadError')
// 打开编辑对话框
openEditDialog(taskDetail)
} catch (error) {
console.error('Failed to fetch task detail:', error)
// 显示错误消息
errorMessage.value = error?.message || t('task.messages.loadError')
isError.value = true isError.value = true
setTimeout(() => { setTimeout(() => {
isError.value = false isError.value = false
errorMessage.value = '' errorMessage.value = ''
}, 3000) }, 3000)
} finally { return
// 隐藏加载状态
isLoading.value = false
} }
openEditDialog(cachedTask)
} else { } else {
console.error('Task ID not found for event:', event) console.error('Task ID not found for event:', event)
} }
...@@ -1136,26 +1011,13 @@ const onEventClick = async (event) => { ...@@ -1136,26 +1011,13 @@ const onEventClick = async (event) => {
const onDateClick = (date) => { const onDateClick = (date) => {
console.log('onDateClick called with date:', date); console.log('onDateClick called with date:', date);
// v-calendar的click:date事件应该传递日期对象,而不是点击事件 // FullCalendar 的 dateClick 始终传入日期字符串
// 但如果收到的是点击事件,我们需要处理这种情况 // 若收到 DOM 事件或无效参数,使用当前日期
if (date) { if (date && !date.target) {
if (date.target) { console.log('Received valid date object');
// 这是一个DOM事件对象,而不是日期 openCreateDialog(date);
console.log('Received DOM event instead of date, using current date');
selectedDate.value = new Date();
// 传递null给openCreateDialog,它会使用当前日期
openCreateDialog(null);
} else {
// 这是一个正常的日期对象
console.log('Received valid date object');
selectedDate.value = date;
// 点击日历空白处,打开新增对话框
openCreateDialog(date);
}
} else { } else {
// 如果没有收到日期参数,使用当前日期 console.log('No/invalid date received, using current date');
console.log('No date received, using current date');
selectedDate.value = new Date();
openCreateDialog(null); openCreateDialog(null);
} }
} }
...@@ -1173,7 +1035,7 @@ const toggleFilterPanel = () => { ...@@ -1173,7 +1035,7 @@ const toggleFilterPanel = () => {
// 无对话框时 Esc 才退出全屏 // 无对话框时 Esc 才退出全屏
const handleKeydown = (event) => { const handleKeydown = (event) => {
if (event.key !== 'Escape') return if (event.key !== 'Escape') return
if (editDialog.value || deleteDialog.value || imageDialog.value || taskDetailDialog.value) return if (editDialog.value || deleteDialog.value || imageDialog.value) return
if (isFullscreen.value) { if (isFullscreen.value) {
isFullscreen.value = false isFullscreen.value = false
} }
...@@ -1197,56 +1059,12 @@ const clearFilters = async () => { ...@@ -1197,56 +1059,12 @@ const clearFilters = async () => {
await fetchTasks({}) await fetchTasks({})
} }
// 学生选择变化处理函数 // 筛选面板处理器:只负责自动应用筛选;
const onStudentChange = async (studentId) => { // 子项重置由 watcher(line 634+)统一处理,避免重复
filters.value.studentId = studentId const onStudentChange = () => fetchTasks()
// cascade: 学生变了 → 重置学期和系列任务 const onSubjectChange = () => fetchTasks()
filters.value.termId = null const onTermChange = () => fetchTasks()
filters.value.storyId = null const onStoryChange = () => fetchTasks()
console.log('Student changed to:', studentId, 'Term & Story reset')
// 自动应用筛选
if (studentId || filters.value.subjectId) {
await fetchTasks()
}
}
// 学科选择变化处理函数
const onSubjectChange = async (subjectId) => {
filters.value.subjectId = subjectId
// cascade: 学科变了 → 重置系列任务
filters.value.storyId = null
console.log('Subject changed to:', subjectId, 'Story reset')
// 自动应用筛选
if (subjectId || filters.value.studentId || filters.value.termId) {
await fetchTasks()
}
}
// 学期选择变化处理函数
const onTermChange = async (termId) => {
filters.value.termId = termId
// cascade: 学期变了 → 重置系列任务
filters.value.storyId = null
console.log('Term changed to:', termId, 'Story reset')
// 自动应用筛选
if (termId || filters.value.studentId || filters.value.subjectId) {
await fetchTasks()
}
}
// 系列任务选择变化处理函数
const onStoryChange = async (storyId) => {
filters.value.storyId = storyId
console.log('Story changed to:', storyId)
// 自动应用筛选
if (storyId || filters.value.studentId || filters.value.subjectId || filters.value.termId) {
await fetchTasks()
}
}
...@@ -1273,7 +1091,12 @@ const editForm = ref({ ...@@ -1273,7 +1091,12 @@ const editForm = ref({
image_04: null, image_04: null,
image_04_mime_type: null, image_04_mime_type: null,
image_05: null, image_05: null,
image_05_mime_type: null image_05_mime_type: null,
delete_image_01: false,
delete_image_02: false,
delete_image_03: false,
delete_image_04: false,
delete_image_05: false
}) })
const isSaving = ref(false) const isSaving = ref(false)
const editFormRef = ref(null) const editFormRef = ref(null)
...@@ -1405,34 +1228,16 @@ const openEditDialog = async (task) => { ...@@ -1405,34 +1228,16 @@ const openEditDialog = async (task) => {
if (task && task.task_id) { if (task && task.task_id) {
// 使用setTimeout让对话框先显示出来,然后再加载图片 // 使用setTimeout让对话框先显示出来,然后再加载图片
setTimeout(() => { setTimeout(() => {
// 尝试预加载5个图片位置的图片 // 仅当该槽位确实有图时才去拉,否则会触发一堆 404 + 5 个 spinner 一起转的视觉噪音。
for (let i = 1; i <= 5; i++) { for (let i = 1; i <= 5; i++) {
// 检查是否有key或etag信息,如果有则尝试加载图片 const slotKey = `image_0${i}`
const imageKey = `image_0${i}`; if (!editForm.value[slotKey]) continue
const imageKeyField = `${imageKey}_key`;
const imageEtagField = `${imageKey}_etag`; loadTaskImage(task.task_id, i).catch((error) => {
// 404 表示该槽位没有图,正常情况
// 添加调试日志 if (error?.response?.status === 404) return
console.log(`Checking image ${i} for preloading:`, { console.error(`Failed to preload image ${i}:`, error)
hasKey: !!task[imageKeyField], })
hasEtag: !!task[imageEtagField],
key: task[imageKeyField],
etag: task[imageEtagField]
});
// 尝试加载图片,无论是否有key或etag信息
loadTaskImage(task.task_id, i)
.then(() => {
console.log(`Image ${i} preloaded successfully`);
})
.catch(error => {
// 如果是404错误,说明该索引位置没有图片,这是正常的
if (error.response && error.response.status === 404) {
console.log(`No image found at index ${i} - this is normal`);
} else {
console.error(`Failed to preload image ${i}:`, error);
}
});
} }
}, 100); }, 100);
} }
...@@ -1532,7 +1337,12 @@ const openCreateDialog = async (date) => { ...@@ -1532,7 +1337,12 @@ const openCreateDialog = async (date) => {
image_04: null, image_04: null,
image_04_mime_type: null, image_04_mime_type: null,
image_05: null, image_05: null,
image_05_mime_type: null image_05_mime_type: null,
delete_image_01: false,
delete_image_02: false,
delete_image_03: false,
delete_image_04: false,
delete_image_05: false
}; };
console.log('Creating new task with date:', formattedDate); console.log('Creating new task with date:', formattedDate);
...@@ -1570,7 +1380,12 @@ const closeEditDialog = () => { ...@@ -1570,7 +1380,12 @@ const closeEditDialog = () => {
image_04: null, image_04: null,
image_04_mime_type: null, image_04_mime_type: null,
image_05: null, image_05: null,
image_05_mime_type: null image_05_mime_type: null,
delete_image_01: false,
delete_image_02: false,
delete_image_03: false,
delete_image_04: false,
delete_image_05: false
} }
// 清理图像缓存 // 清理图像缓存
...@@ -1626,12 +1441,7 @@ const confirmDeleteTask = async () => { ...@@ -1626,12 +1441,7 @@ const confirmDeleteTask = async () => {
tasks.value.splice(index, 1) tasks.value.splice(index, 1)
} }
// 3. 显示成功消息 // 3. 关闭对话框
successMessage.value = t('task.messages.deleteSuccess', { name: taskToDelete.value.task_name })
setTimeout(() => {
successMessage.value = ''
}, 3000)
closeDeleteDialog() closeDeleteDialog()
closeEditDialog() // 如果正在编辑,也关闭编辑对话框 closeEditDialog() // 如果正在编辑,也关闭编辑对话框
...@@ -2277,12 +2087,14 @@ watch(() => editForm.value.subject_id, (newSubjectId, oldSubjectId) => { ...@@ -2277,12 +2087,14 @@ watch(() => editForm.value.subject_id, (newSubjectId, oldSubjectId) => {
/* 任务布局容器 */ /* 任务布局容器 */
.task-layout { .task-layout {
position: relative;
height: calc(100vh - 270px); height: calc(100vh - 270px);
overflow: hidden; overflow: visible;
} }
/* 筛选面板容器 */ /* 筛选面板容器 */
.filter-panel-container { .filter-panel-container {
position: relative;
width: 240px; width: 240px;
min-width: 240px; min-width: 240px;
max-width: 240px; max-width: 240px;
...@@ -2299,6 +2111,20 @@ watch(() => editForm.value.subject_id, (newSubjectId, oldSubjectId) => { ...@@ -2299,6 +2111,20 @@ watch(() => editForm.value.subject_id, (newSubjectId, oldSubjectId) => {
transform: translateX(-100%); transform: translateX(-100%);
} }
/* 浮动折叠按钮:展开时贴在 panel 右侧边缘, 折叠时移至最左侧 */
.filter-panel-toggle {
position: absolute;
top: 50%;
left: 240px; /* panel 宽 240 + margin-right 16, 减半按钮宽约等于贴在右边 */
transform: translate(-50%, -50%);
z-index: 10;
transition: left 0.4s cubic-bezier(0.4, 0.0, 0.2, 1);
}
.filter-panel-toggle.filter-panel-toggle-collapsed {
left: 16px;
}
.filter-panel-card { .filter-panel-card {
width: 240px; width: 240px;
min-width: 240px; min-width: 240px;
...@@ -2415,33 +2241,31 @@ watch(() => editForm.value.subject_id, (newSubjectId, oldSubjectId) => { ...@@ -2415,33 +2241,31 @@ watch(() => editForm.value.subject_id, (newSubjectId, oldSubjectId) => {
padding: 0; padding: 0;
} }
.fc-event-custom { .task-calendar :deep(.fc-event-custom) {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 100%; width: 100%;
border-radius: 3px; border-radius: 3px;
overflow: hidden; overflow: hidden;
background: #E0E0E0;
border: 1px solid transparent; border: 1px solid transparent;
transition: all 0.15s; transition: all 0.15s;
cursor: grab; cursor: grab;
position: relative; position: relative;
} }
.fc-event-custom:hover { .task-calendar :deep(.fc-event-custom:hover) {
border-color: #000; border-color: #000;
box-shadow: 0 1px 4px rgba(0,0,0,0.15); box-shadow: 0 1px 4px rgba(0,0,0,0.15);
z-index: 5; z-index: 5;
} }
.fc-event-content { .task-calendar :deep(.fc-event-content) {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 3px; gap: 3px;
padding: 1px 3px; padding: 1px 3px;
font-size: 11px; font-size: 14px;
font-weight: 500; font-weight: 500;
color: #000;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
...@@ -2449,43 +2273,42 @@ watch(() => editForm.value.subject_id, (newSubjectId, oldSubjectId) => { ...@@ -2449,43 +2273,42 @@ watch(() => editForm.value.subject_id, (newSubjectId, oldSubjectId) => {
z-index: 2; z-index: 2;
} }
.fc-event-avatar { .task-calendar :deep(.fc-event-avatar) {
width: 18px; width: 22px;
height: 18px; height: 22px;
border-radius: 50%; border-radius: 50%;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 10px; font-size: 12px;
font-weight: 700; font-weight: 700;
flex-shrink: 0; flex-shrink: 0;
} }
.fc-event-subject { .task-calendar :deep(.fc-event-subject) {
font-weight: 700; font-weight: 700;
flex-shrink: 0; flex-shrink: 0;
color: #000 !important;
} }
.fc-event-title { .task-calendar :deep(.fc-event-title) {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
font-size: 14px;
font-weight: 600;
} }
.fc-event-bar { .task-calendar :deep(.fc-event-pct) {
position: absolute; margin-left: auto;
bottom: 0; padding-left: 4px;
left: 0; flex-shrink: 0;
width: 100%; display: inline-flex;
height: 100%; align-items: center;
z-index: 1; line-height: 0;
pointer-events: none;
} }
.fc-event-fill { .task-calendar :deep(.fc-event-ring) {
height: 100%; display: block;
transition: width 0.3s;
} }
.task-calendar :deep(.fc) { .task-calendar :deep(.fc) {
......
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