Commit 1e2ed63c authored by Administrator's avatar Administrator
Browse files

Checkpoint 2026-07-02 before using Minmax

parent b8cdc054
......@@ -30,3 +30,13 @@ coverage
*.tsbuildinfo
.qoder*
CLAUDE.md
reasonix.toml
swagger*
desktop*.json
clipboard*
*.xlsx
*.yaml
暑假日程表.txt
import-assignments.cjs
\ No newline at end of file
......@@ -8,6 +8,9 @@
"name": "student-app-frontend",
"version": "0.0.0",
"dependencies": {
"@fullcalendar/daygrid": "^6.1.21",
"@fullcalendar/interaction": "^6.1.21",
"@fullcalendar/vue3": "^6.1.21",
"@mdi/font": "^7.4.47",
"axios": "^1.11.0",
"pinia": "^3.0.3",
......@@ -23,6 +26,7 @@
"eslint": "^9.31.0",
"eslint-plugin-vue": "~10.3.0",
"globals": "^16.3.0",
"js-yaml": "^5.2.0",
"prettier": "3.6.2",
"vite": "^7.0.6",
"vite-plugin-vue-devtools": "^8.0.0"
......@@ -1089,6 +1093,29 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@eslint/eslintrc/node_modules/js-yaml": {
"version": "4.3.0",
"resolved": "https://repo.huaweicloud.com/repository/npm/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/@eslint/js": {
"version": "9.33.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.33.0.tgz",
......@@ -1126,6 +1153,44 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@fullcalendar/core": {
"version": "6.1.21",
"resolved": "https://repo.huaweicloud.com/repository/npm/@fullcalendar/core/-/core-6.1.21.tgz",
"integrity": "sha512-t3u/+sqh3Iq7TWtUnVLcGDUE6OWZh0UD3c04bI/l7lSLAgAKr3kngBmhHiQD1QXpwC8ZN5iNqG7a7gOVixhSKQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"preact": "~10.12.1"
}
},
"node_modules/@fullcalendar/daygrid": {
"version": "6.1.21",
"resolved": "https://repo.huaweicloud.com/repository/npm/@fullcalendar/daygrid/-/daygrid-6.1.21.tgz",
"integrity": "sha512-QYb1y40RGYLlOxKpYWg8O+7njEnKnFG8Tt7qjnubJGR35s1phQg67E+81y2TyAbbm59p2JFOCXGDk9t6KDujIA==",
"license": "MIT",
"peerDependencies": {
"@fullcalendar/core": "~6.1.21"
}
},
"node_modules/@fullcalendar/interaction": {
"version": "6.1.21",
"resolved": "https://repo.huaweicloud.com/repository/npm/@fullcalendar/interaction/-/interaction-6.1.21.tgz",
"integrity": "sha512-WPYpqtljDWmU0Xm2cOtFrLlocgxv7cgkOppj34Q6OUUat8a6Cnd6kYo2JR+irP223PE5lBYHFNp1qh7SIpJc0w==",
"license": "MIT",
"peerDependencies": {
"@fullcalendar/core": "~6.1.21"
}
},
"node_modules/@fullcalendar/vue3": {
"version": "6.1.21",
"resolved": "https://repo.huaweicloud.com/repository/npm/@fullcalendar/vue3/-/vue3-6.1.21.tgz",
"integrity": "sha512-OGt6WSC+/zz/ej6a0KfIBNl7BYuGchpZU49SsedYyv3WZWbghAE+D8YD6nhH1ia/I4p5Gcsv/nEXgEkT/I8aYQ==",
"license": "MIT",
"peerDependencies": {
"@fullcalendar/core": "~6.1.21",
"vue": "^3.0.11"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
......@@ -3238,16 +3303,26 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"version": "5.2.0",
"resolved": "https://repo.huaweicloud.com/repository/npm/js-yaml/-/js-yaml-5.2.0.tgz",
"integrity": "sha512-YeLUMlvR4Ou1B119LIaM0r65JvbOBooJDc9yEu0dClb/uSC5P4FrLU8OCCz/HXWvtPoIrR0dRzABTjo1sTN9Bw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
"js-yaml": "bin/js-yaml.mjs"
}
},
"node_modules/jsesc": {
......@@ -3728,6 +3803,17 @@
"node": ">=4"
}
},
"node_modules/preact": {
"version": "10.12.1",
"resolved": "https://repo.huaweicloud.com/repository/npm/preact/-/preact-10.12.1.tgz",
"integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
......
......@@ -14,6 +14,9 @@
"format": "prettier --write src/"
},
"dependencies": {
"@fullcalendar/daygrid": "^6.1.21",
"@fullcalendar/interaction": "^6.1.21",
"@fullcalendar/vue3": "^6.1.21",
"@mdi/font": "^7.4.47",
"axios": "^1.11.0",
"pinia": "^3.0.3",
......@@ -29,6 +32,7 @@
"eslint": "^9.31.0",
"eslint-plugin-vue": "~10.3.0",
"globals": "^16.3.0",
"js-yaml": "^5.2.0",
"prettier": "3.6.2",
"vite": "^7.0.6",
"vite-plugin-vue-devtools": "^8.0.0"
......
......@@ -6,7 +6,8 @@ export const login = async (credentials) => {
const response = await apiClient.post(apiEndpoints.AUTH.LOGIN, credentials)
return response.data
} catch (error) {
throw new Error(error?.response?.data?.detail || error?.response?.data?.message || error.message || 'Login failed')
// 保留原始 error.response,以便调用方(如 auth store)能访问 error.response.data.detail
throw error
}
}
......@@ -16,6 +17,7 @@ export const refreshAccessToken = async (refreshToken) => {
const response = await apiClient.post(apiEndpoints.AUTH.REFRESH, { refresh: refreshToken })
return response.data
} catch (error) {
throw new Error(error?.response?.data?.detail || error?.message || 'Failed to refresh token')
// 保留原始 error.response,以便调用方能访问 error.response.data.detail
throw error
}
}
......@@ -77,6 +77,7 @@ export const getTasks = async (params = {}) => {
if (params.student_id) queryParams.student_id = params.student_id
if (params.subject_id) queryParams.subject_id = params.subject_id
if (params.term_id) queryParams.term_id = params.term_id
if (params.story_id) queryParams.story_id = params.story_id
console.log('Tasks API request params:', queryParams)
......
<template>
<v-footer app color="primary" dark :elevation="2">
<v-col class="text-center white--text">
<v-footer app color="primary" :elevation="2">
<v-col class="text-center text-white">
&copy; 2025 Student Management App
</v-col>
</v-footer>
......
......@@ -22,6 +22,7 @@ export default {
subject: 'Subject',
student: 'Student',
term: 'Term',
story: 'Story',
startDate: 'Start Date',
endDate: 'End Date',
completionPercent: 'Completion',
......@@ -43,9 +44,11 @@ export default {
allStudents: 'All Students',
allSubjects: 'All Subjects',
allTerms: 'All Terms',
allStories: 'No Story',
apply: 'Apply Filters',
clear: 'Clear Filters',
selectStudentFirst: 'Please select a student first',
selectTermFirst: 'Please select a term first',
expand: 'Expand Filter Panel',
collapse: 'Collapse Filter Panel',
expandToReselect: 'Click to Expand and Reselect'
......
......@@ -22,6 +22,7 @@ export default {
subject: '学科',
student: '学生',
term: '学期',
story: '系列任务',
startDate: '开始日期',
endDate: '截止日期',
completionPercent: '完成度',
......@@ -43,9 +44,11 @@ export default {
allStudents: '所有学生',
allSubjects: '所有学科',
allTerms: '所有学期',
allStories: '无系列',
apply: '应用筛选',
clear: '清除筛选',
selectStudentFirst: '请先选择学生',
selectTermFirst: '请先选择学期',
expand: '展开筛选面板',
collapse: '收起筛选面板',
expandToReselect: '点击展开重新选择'
......
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { isLanAccess } from '@/utils/lanAccess'
import HomeView from '../views/HomeView.vue'
import LoginView from '../views/LoginView.vue'
import ProfileView from '../views/ProfileView.vue'
......@@ -146,6 +147,12 @@ const router = createRouter({
// 路由守卫
router.beforeEach(async (to, from, next) => {
// 内网访问跳过认证
if (isLanAccess()) {
next()
return
}
const authStore = useAuthStore()
const isAuthed = authStore.isAuthenticated
......
......@@ -3,6 +3,7 @@ import { defineStore } from 'pinia'
import { useRouter } from 'vue-router'
import apiClient from '@/api/index.js'
import { login as authLogin, refreshAccessToken as authRefreshToken } from '@/api/authService.js'
import { isLanAccess } from '@/utils/lanAccess'
export const useAuthStore = defineStore('auth', () => {
const router = useRouter()
......@@ -120,7 +121,11 @@ export const useAuthStore = defineStore('auth', () => {
}
if (originalRequest._retry) {
// 已重试过,仍然 401,直接登出
// 已重试过,仍然 401
// 内网访问时:读操作(GET)跳过,写操作(POST/PATCH/DELETE)要求登录
if (isLanAccess() && originalRequest.method?.toUpperCase() === 'GET') {
return Promise.reject(error)
}
logout()
return Promise.reject(error)
}
......@@ -132,6 +137,10 @@ export const useAuthStore = defineStore('auth', () => {
originalRequest.headers.Authorization = `Bearer ${accessToken.value}`
return apiClient(originalRequest)
} catch (e) {
// 内网访问时:读操作(GET)跳过,写操作要求登录
if (isLanAccess() && originalRequest.method?.toUpperCase() === 'GET') {
return Promise.reject(error)
}
logout()
return Promise.reject(e)
}
......
/**
* LAN 内网访问配置
*
* 在这些 CIDR 范围内访问时,跳过前端认证检查。
* 需要后端配合去掉 API 认证。
*/
const ALLOWED_CIDRS = [
'192.168.1.0/24',
'192.168.2.0/24',
]
// 将 IP 地址转换为 32 位整数
const ipToInt = (ip) => {
const parts = ip.split('.').map(Number)
return ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0
}
// 检查 IP 是否在 CIDR 范围内
const ipInCidr = (ip, cidr) => {
const [rangeIp, bits] = cidr.split('/')
const mask = ~(2 ** (32 - parseInt(bits)) - 1) >>> 0
return (ipToInt(ip) & mask) === (ipToInt(rangeIp) & mask)
}
/**
* 判断当前访问是否来自允许的内网地址
*/
export const isLanAccess = () => {
if (typeof window === 'undefined') return false
const hostname = window.location.hostname
// localhost 始终放行
if (hostname === 'localhost' || hostname === '127.0.0.1') return true
// 检查是否在允许的 CIDR 范围内
return ALLOWED_CIDRS.some(cidr => ipInCidr(hostname, cidr))
}
......@@ -15,14 +15,14 @@
:class="{ 'filter-panel-collapsed': !isFilterExpanded }"
>
<v-card elevation="2" class="h-100 filter-panel-card">
<v-card-title class="pa-2">
<v-card-title class="pa-1">
<div class="d-flex align-center">
<v-icon class="me-2">mdi-filter</v-icon>
<span>{{ $t('task.filters.title') }}</span>
</div>
</v-card-title>
<v-card-text>
<v-row>
<v-card-text class="pa-1 py-2">
<v-row dense>
<v-col cols="12">
<v-select
v-model="filters.studentId"
......@@ -31,6 +31,7 @@
clearable
density="compact"
variant="outlined"
hide-details
@update:model-value="onStudentChange"
/>
</v-col>
......@@ -42,6 +43,7 @@
clearable
density="compact"
variant="outlined"
hide-details
@update:model-value="onSubjectChange"
/>
</v-col>
......@@ -54,11 +56,24 @@
clearable
density="compact"
variant="outlined"
:hint="!filters.studentId ? $t('task.filters.selectStudentFirst') : ''"
persistent-hint
hide-details
@update:model-value="onTermChange"
/>
</v-col>
<v-col cols="12">
<v-select
v-model="filters.storyId"
:items="filteredStoryOptions"
:label="$t('task.task.story')"
:disabled="!filters.studentId || !filters.termId"
clearable
density="compact"
variant="outlined"
hide-details
@update:model-value="onStoryChange"
/>
</v-col>
<v-col cols="12">
<v-btn
@click="applyFilters"
......@@ -66,6 +81,7 @@
variant="flat"
:loading="isLoading"
block
size="small"
class="mb-2"
>
{{ $t('task.filters.apply') }}
......@@ -74,6 +90,7 @@
@click="clearFilters"
variant="outlined"
block
size="small"
>
{{ $t('task.filters.clear') }}
</v-btn>
......@@ -103,7 +120,8 @@
left: isFullscreen ? '0' : 'auto',
width: isFullscreen ? '100vw' : 'auto',
height: isFullscreen ? '100vh' : '100%',
'z-index': isFullscreen ? '9999' : 'auto'
'z-index': isFullscreen ? '9999' : 'auto',
overflow: isFullscreen ? 'auto' : 'visible'
}"
>
<v-card-title class="d-flex justify-space-between align-center">
......@@ -132,49 +150,23 @@
</v-card-title>
<v-card-text :style="{
flex: '1',
'overflow-y': 'auto',
padding: isFullscreen ? '24px' : '16px'
display: 'flex',
'flex-direction': 'column',
position: 'relative',
height: isFullscreen ? '0' : 'auto',
'min-height': '0',
padding: isFullscreen ? '0' : '16px'
}">
<!-- Vuetify v-calendar 组件 -->
<div class="mb-4" :style="{ height: isFullscreen ? 'calc(100vh - 200px)' : 'auto', position: 'relative' }">
<v-calendar
ref="calendar"
v-model="selectedDate"
:events="calendarEvents"
@click:event="onEventClick"
@click:date="onDateClick"
<div :style="{ overflow: isFullscreen ? 'auto' : 'visible', flex: '1', 'min-height': '0', height: '100%' }">
<FullCalendar
v-if="tasks.length > 0"
:options="calendarOptions"
:key="'fc-' + isFullscreen"
class="task-calendar"
:first-interval="0"
:interval-count="24"
:interval-height="isFullscreen ? 80 : 60"
type="month"
:disabled="isLoading"
>
<!-- 自定义日历事件显示 -->
<template #day-event="{ event }">
<div class="custom-event" @click="onEventClick(event)">
<div class="event-content">
<span
class="subject-tag"
:style="{ color: event.color, fontWeight: 'bold' }"
>
[{{ getSubjectShortName(event.taskData?.subject_id) }}]
</span>
{{ event.title }}
</div>
<!-- 完成度进度条 -->
<div class="completion-bar">
<div
class="completion-fill"
:style="{
width: `${event.taskData?.completion_percent || 0}%`,
backgroundColor: '#4CAF50' // 绿色
}"
></div>
</div>
:class="{ 'is-fullscreen': isFullscreen }"
/>
</div>
</template>
</v-calendar>
<!-- 加载遮罩层 -->
<div v-if="isLoading" class="loading-overlay">
......@@ -185,12 +177,8 @@
width="6"
/>
</div>
</div>
<div v-if="calendarEvents.length === 0 && !isLoading" class="text-center py-8">
<v-icon size="64" color="grey-lighten-1">mdi-calendar-blank</v-icon>
<div class="text-h6 mt-2 text-grey-lighten-1">{{ $t('task.calendar.noEvents') }}</div>
</div>
<!-- FullCalendar 自带空状态 -->
</v-card-text>
</v-card>
</div>
......@@ -264,31 +252,32 @@
<!-- 任务编辑对话框 -->
<v-dialog v-model="editDialog" max-width="600">
<v-card>
<!-- 固定在对话框顶部的错误消息 -->
<v-card class="edit-task-card">
<v-card-title class="d-flex align-center pe-2 edit-task-header">
<span>{{ isCreateMode ? $t('task.edit.createTitle') : $t('task.edit.title') }}</span>
<v-spacer />
<v-btn
icon="mdi-close"
variant="text"
density="compact"
@click="closeEditDialog"
/>
</v-card-title>
<v-divider />
<v-card-text class="edit-task-body">
<!-- 错误消息(sticky 固定在滚动区顶部) -->
<div class="error-message-container" v-if="isError">
<v-alert
type="error"
variant="tonal"
closable
class="mb-0"
class="mb-2"
style="width: 100%;"
@click:close="isError = false"
>
{{ errorMessage }}
</v-alert>
</div>
<v-card-title>
<span>{{ isCreateMode ? $t('task.edit.createTitle') : $t('task.edit.title') }}</span>
<v-spacer />
<v-btn
icon="mdi-close"
variant="text"
@click="closeEditDialog"
/>
</v-card-title>
<v-card-text>
<v-form ref="editFormRef">
<v-row>
<v-col cols="12">
......@@ -345,17 +334,39 @@
/>
</v-col>
<v-col cols="12" md="6">
<!-- <v-text-field
v-model="editForm.completion_percent"
:label="$t('task.task.completionPercent') + ' *'"
:placeholder="$t('task.task.completionPercent')"
<v-select
v-model="editForm.story_id"
:items="editStoryOptions"
:label="$t('task.task.story')"
:disabled="!editForm.student_id || !editForm.term_id"
variant="outlined"
density="compact"
type="number"
min="0"
max="100"
clearable
:hint="!editForm.student_id ? $t('task.filters.selectStudentFirst') : (!editForm.term_id ? $t('task.filters.selectTermFirst') : '')"
persistent-hint
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="editForm.start_date"
:label="$t('task.task.startDate') + ' *'"
variant="outlined"
density="compact"
type="date"
required
/> -->
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="editForm.end_date"
:label="$t('task.task.endDate') + ' *'"
variant="outlined"
density="compact"
type="date"
required
/>
</v-col>
<v-col cols="12">
<v-slider
v-model="editForm.completion_percent"
:label="$t('task.task.completionPercent') + ' *'"
......@@ -379,26 +390,6 @@
</template>
</v-slider>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="editForm.start_date"
:label="$t('task.task.startDate') + ' *'"
variant="outlined"
density="compact"
type="date"
required
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="editForm.end_date"
:label="$t('task.task.endDate') + ' *'"
variant="outlined"
density="compact"
type="date"
required
/>
</v-col>
<!-- 图像显示区域 -->
<v-col cols="12">
......@@ -453,8 +444,6 @@
v-else
class="image-preview empty"
>
<v-icon size="large" color="grey">mdi-image-off</v-icon>
<div class="upload-placeholder">{{ $t('task.task.noImage') }}</div>
</div>
<div v-if="!imageCache[index] && !imageLoading[index]" class="image-actions-bottom">
<v-btn
......@@ -471,7 +460,8 @@
</v-row>
</v-form>
</v-card-text>
<v-card-actions>
<v-divider />
<v-card-actions class="edit-task-actions">
<v-btn
v-if="!isCreateMode"
color="error"
......@@ -661,21 +651,23 @@
</template>
<script setup>
import { onMounted, ref, computed, watch, onUnmounted } from 'vue'
import { onMounted, ref, computed, watch, onUnmounted, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
import { getTasks, getTaskById, getTaskImage, updateTask as updateTaskAPI, deleteTask } from '@/api/taskService.js'
import { getStudents } from '@/api/studentService.js'
import { getSubjects } from '@/api/subjectService.js'
import { getTerms } from '@/api/termService.js'
import { getStories } from '@/api/storyService.js'
import { COLOR_MAPPING } from '@/components/constants/colors'
// 从 Labs 正确导入 VCalendar 组件
import { VCalendar } from 'vuetify/labs/VCalendar'
import FullCalendar from '@fullcalendar/vue3'
import dayGridPlugin from '@fullcalendar/daygrid'
import interactionPlugin from '@fullcalendar/interaction'
// 在 Composition API 中注册 Labs 组件
// 注册 FullCalendar 组件
defineOptions({
components: {
VCalendar
FullCalendar
}
})
......@@ -705,7 +697,8 @@ const isFullscreen = ref(false)
const filters = ref({
studentId: null,
subjectId: null,
termId: null
termId: null,
storyId: null
})
const isFilterExpanded = ref(true)
......@@ -713,99 +706,191 @@ const isFilterExpanded = ref(true)
const students = ref([])
const subjects = ref([])
const terms = ref([])
const stories = ref([])
// 计算属性
const studentOptions = computed(() => [
{ title: t('task.filters.allStudents'), value: null },
...students.value.map(student => ({
// "全部"不再作为选项,clear 按钮本身就是"全部"的意思,避免 value 碰撞
const studentOptions = computed(() =>
students.value.map(student => ({
title: student.student_name,
value: student.student_id
}))
])
)
const subjectOptions = computed(() => [
{ title: t('task.filters.allSubjects'), value: null },
...subjects.value.map(subject => ({
const subjectOptions = computed(() =>
subjects.value.map(subject => ({
title: subject.subject_name,
value: subject.subject_id
}))
])
)
// 学期选项根据选择的学生进行筛选
// 学期选项根据选择的学生进行筛选,按开始日期倒序
const termOptions = computed(() => {
// 如果没有选择学生或选择了"所有学生",只显示"所有学期"选项
if (!filters.value.studentId) {
return [{ title: t('task.filters.allTerms'), value: null }]
return [] // 没选学生时无学期可选
}
// 筛选属于选定学生的学期
const filteredTerms = terms.value.filter(term =>
term.student_id === filters.value.studentId
)
return [
{ title: t('task.filters.allTerms'), value: null },
...filteredTerms.map(term => ({
const filteredTerms = [...terms.value]
.filter(term => term.student_id === filters.value.studentId)
.sort((a, b) => new Date(b.term_start_date) - new Date(a.term_start_date))
return filteredTerms.map(term => ({
title: term.term_name,
value: term.term_id
}))
]
})
// 监听学生选择变化,重置学期选择
// 筛选面板:系列任务选项(cascade:未选学生或学期时无选项,story 依赖 student + term)
const filteredStoryOptions = computed(() => {
if (!filters.value.studentId || !filters.value.termId) {
return [] // 未选学生或学期时无系列任务可选
}
let filtered = [...stories.value]
filtered = filtered.filter(s => s.student_id === filters.value.studentId)
filtered = filtered.filter(s => s.term_id === filters.value.termId)
if (filters.value.subjectId) {
filtered = filtered.filter(s => s.subject_id === filters.value.subjectId)
}
return filtered.map(story => ({
title: story.story_name,
value: story.story_id
}))
})
// 监听学生选择变化,重置学期和系列任务选择
watch(() => filters.value.studentId, (newStudentId, oldStudentId) => {
// 当学生选择发生变化时,重置学期选择
if (newStudentId !== oldStudentId) {
filters.value.termId = null
console.log('Student selection changed, term selection reset')
filters.value.storyId = null
console.log('Student selection changed, term & story selection reset')
}
})
// 将任务数据转换为日历事件格式
const calendarEvents = computed(() => {
if (!tasks.value || tasks.value.length === 0) {
console.log('No tasks available for calendar')
return []
}
const events = tasks.value.map((task, index) => {
// 解析日期
const startDate = new Date(task.start_date)
const endDate = new Date(task.end_date)
console.log(`Task ${index}:`, {
original: task,
startDate: startDate,
endDate: endDate,
isValidStart: !isNaN(startDate.getTime()),
isValidEnd: !isNaN(endDate.getTime())
})
// Vuetify v-calendar 事件标准格式
return {
name: task.task_name, // Vuetify v-calendar 使用 name 属性
start: startDate,
end: endDate,
color: getSubjectColor(task.subject_id),
timed: false, // 设置为全天事件
// 额外数据用于调试和点击处理
id: task.task_id,
// FullCalendar 响应式配置
const calendarOptions = computed(() => ({
plugins: [dayGridPlugin, interactionPlugin],
initialView: 'dayGridMonth',
height: 'parent',
expandRows: true,
firstDay: 0,
locale: 'zh-cn',
buttonText: { today: '今天' },
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,dayGridWeek,dayGridDay'
},
buttonText: {
today: '今天',
month: '月',
week: '周',
day: '日'
},
editable: true,
eventDurationEditable: true,
displayEventTime: false,
// 原生跨天事件(FullCalendar 原生支持 allDay 跨天条)
events: (() => {
const result = []
for (const task of (tasks.value || [])) {
if (!task.start_date) continue
const start = task.start_date
const end = task.end_date
if (!end) continue
// end 加一天(FullCalendar 的 end 是 exclusive)
const endDate = new Date(end)
endDate.setDate(endDate.getDate() + 1)
result.push({
id: String(task.task_id),
title: task.task_name,
taskData: {
id: task.task_id,
completion_percent: task.completion_percent,
subject_id: task.subject_id,
start: start.split('T')[0],
end: endDate.toISOString().split('T')[0],
allDay: true,
color: getSubjectColor(task.subject_id),
backgroundColor: getSubjectColor(task.subject_id),
display: 'auto',
extendedProps: {
task_id: task.task_id,
student_id: task.student_id,
subject_id: task.subject_id,
completion_percent: task.completion_percent,
description: task.task_description
}
})
}
return result
})(),
// 自定义事件渲染
eventContent: (arg) => {
const taskData = arg.event.extendedProps
const studentName = getStudentName(taskData.student_id) || ''
const initial = getAvatarFallback(studentName)
const subjectShort = getSubjectShortName(taskData.subject_id) || ''
const compPct = taskData.completion_percent || 0
return {
html: `
<div class="fc-event-custom" data-task-id="${arg.event.id}">
<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-subject">[${subjectShort}]</span>
<span class="fc-event-title">${arg.event.title}</span>
</div>
<div class="fc-event-bar">
<div class="fc-event-fill" style="width:${compPct}%;background:#4CAF50"></div>
</div>
</div>
`
}
},
// 事件点击
eventClick: (info) => {
const taskId = info.event.extendedProps.task_id
if (taskId) onEventClick({ taskData: { id: taskId } })
},
// 日期点击
dateClick: (info) => {
onDateClick(new Date(info.dateStr + 'T12:00:00'))
},
// 拖拽事件
eventDrop: async (info) => {
const taskId = info.event.extendedProps.task_id
if (!taskId) return
const oldStart = info.oldEvent.start
const newStart = info.event.start
if (!oldStart || !newStart) return
const diffDays = Math.round((newStart - oldStart) / 86400000)
if (diffDays === 0) return
const task = tasks.value.find(t => t.task_id === taskId)
if (!task) return
const oldTaskStart = new Date(task.start_date)
const oldTaskEnd = new Date(task.end_date)
const newTaskStart = new Date(oldTaskStart.getTime() + diffDays * 86400000)
const newTaskEnd = new Date(oldTaskEnd.getTime() + diffDays * 86400000)
isLoading.value = true
try {
await updateTaskAPI(taskId, {
start_date: newTaskStart.toISOString(),
end_date: newTaskEnd.toISOString()
})
await fetchTasks()
} catch (err) {
console.error('Failed to move task:', err)
info.revert()
errorMessage.value = err?.message || t('task.messages.updateError')
isError.value = true
setTimeout(() => { isError.value = false; errorMessage.value = '' }, 3000)
} finally {
isLoading.value = false
}
}
}))
console.log('Calendar events formatted for Vuetify:', events)
return events
})
// 获取学科颜色
const getSubjectColor = (subjectId) => {
const subject = subjects.value.find(s => s.subject_id === subjectId)
if (subject && subject.calendar_color) {
......@@ -817,8 +902,7 @@ const getSubjectColor = (subjectId) => {
}
// 根据背景色计算文字颜色(黑色或白色)
// 注释掉未使用的方法
/* const getTextColor = (backgroundColor) => {
const getTextColor = (backgroundColor) => {
// 如果背景色是十六进制颜色值
if (backgroundColor && backgroundColor.startsWith('#')) {
// 移除 # 符号并解析 RGB 值
......@@ -836,7 +920,7 @@ const getSubjectColor = (subjectId) => {
// 对于其他颜色值,使用白色文字
return '#FFFFFF'
} */
}
// 获取完成度对应的颜色
const getCompletionColor = (percent) => {
......@@ -868,6 +952,26 @@ const getStudentName = (studentId) => {
return student ? student.student_name : `Student ${studentId}`
}
// 学生头像相关函数已移除,改为使用学生姓名最后一个字符作为头像
// 获取头像占位文本(取名字最后一个字)
const getAvatarFallback = (name) => {
if (!name) return '?'
return name.charAt(name.length - 1).toUpperCase()
}
// 为每个学生生成唯一的颜色
const getStudentColor = (studentId) => {
if (!studentId) return '#9E9E9E' // 默认灰色
// 使用学生ID作为种子生成一个伪随机颜色
// 从COLOR_MAPPING中选择一个颜色
const colorKeys = Object.keys(COLOR_MAPPING)
// 使用学生ID的数值来确定颜色索引,确保同一学生始终获得相同颜色
const colorIndex = parseInt(studentId) % colorKeys.length
return COLOR_MAPPING[colorKeys[colorIndex]] || '#9E9E9E'
}
// 获取学科名称
const getSubjectName = (subjectId) => {
const subject = subjects.value.find(s => s.subject_id === subjectId)
......@@ -902,7 +1006,8 @@ const fetchTasks = async (filterParams = null) => {
const params = filterParams || {
student_id: filters.value.studentId,
subject_id: filters.value.subjectId,
term_id: filters.value.termId
term_id: filters.value.termId,
story_id: filters.value.storyId
}
console.log('Fetching tasks with filters:', params)
......@@ -985,6 +1090,15 @@ const fetchTerms = async () => {
}
}
const fetchStories = async () => {
try {
const stories_data = await getStories()
stories.value = Array.isArray(stories_data) ? stories_data : []
} catch (error) {
console.error('Failed to fetch stories:', error)
}
}
// 事件处理函数
const onEventClick = async (event) => {
console.log('Calendar event clicked:', event)
......@@ -1046,6 +1160,7 @@ const onDateClick = (date) => {
}
}
// ── 拖拽事件处理 ──
const toggleFullscreen = () => {
isFullscreen.value = !isFullscreen.value
}
......@@ -1054,9 +1169,12 @@ const toggleFilterPanel = () => {
isFilterExpanded.value = !isFilterExpanded.value
}
// 监听键盘事件,用ESC退出全屏
// 监听键盘事件:有对话框打开时 Esc 只关对话框,不退出全屏;
// 无对话框时 Esc 才退出全屏
const handleKeydown = (event) => {
if (event.key === 'Escape' && isFullscreen.value) {
if (event.key !== 'Escape') return
if (editDialog.value || deleteDialog.value || imageDialog.value || taskDetailDialog.value) return
if (isFullscreen.value) {
isFullscreen.value = false
}
}
......@@ -1071,7 +1189,8 @@ const clearFilters = async () => {
filters.value = {
studentId: null,
subjectId: null,
termId: null
termId: null,
storyId: null
}
console.log('All filters cleared')
// 清除筛选后重新获取所有数据
......@@ -1081,9 +1200,10 @@ const clearFilters = async () => {
// 学生选择变化处理函数
const onStudentChange = async (studentId) => {
filters.value.studentId = studentId
// 当学生选择变化时,自动重置学期选择
// cascade: 学生变了 → 重置学期和系列任务
filters.value.termId = null
console.log('Student changed to:', studentId, 'Term reset to null')
filters.value.storyId = null
console.log('Student changed to:', studentId, 'Term & Story reset')
// 自动应用筛选
if (studentId || filters.value.subjectId) {
......@@ -1094,7 +1214,9 @@ const onStudentChange = async (studentId) => {
// 学科选择变化处理函数
const onSubjectChange = async (subjectId) => {
filters.value.subjectId = subjectId
console.log('Subject changed to:', subjectId)
// cascade: 学科变了 → 重置系列任务
filters.value.storyId = null
console.log('Subject changed to:', subjectId, 'Story reset')
// 自动应用筛选
if (subjectId || filters.value.studentId || filters.value.termId) {
......@@ -1105,7 +1227,9 @@ const onSubjectChange = async (subjectId) => {
// 学期选择变化处理函数
const onTermChange = async (termId) => {
filters.value.termId = termId
console.log('Term changed to:', termId)
// cascade: 学期变了 → 重置系列任务
filters.value.storyId = null
console.log('Term changed to:', termId, 'Story reset')
// 自动应用筛选
if (termId || filters.value.studentId || filters.value.subjectId) {
......@@ -1113,6 +1237,19 @@ const onTermChange = async (termId) => {
}
}
// 系列任务选择变化处理函数
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()
}
}
// 编辑功能相关状态
const editDialog = ref(false)
const isCreateMode = ref(false) // 标记是否为创建模式
......@@ -1140,32 +1277,46 @@ const editForm = ref({
})
const isSaving = ref(false)
const editFormRef = ref(null)
// 加载任务到表单时抑制 cascade 重置,避免覆盖已加载的 term/story
const suppressCascade = ref(false)
// 为编辑对话框创建独立的学期选项计算属性
const editTermOptions = computed(() => {
// 如果没有选择学生,显示所有学期
if (!editForm.value.student_id) {
return [
{ title: t('task.filters.allTerms'), value: null },
...terms.value.map(term => ({
const sortedTerms = [...terms.value].sort((a, b) => new Date(b.term_start_date) - new Date(a.term_start_date))
return sortedTerms.map(term => ({
title: term.term_name,
value: term.term_id
}))
]
}
// 筛选属于选定学生的学期
const filteredTerms = terms.value.filter(term =>
term.student_id === editForm.value.student_id
)
// 筛选属于选定学生的学期,按开始日期倒序排列
const filteredTerms = [...terms.value]
.filter(term => term.student_id === editForm.value.student_id)
.sort((a, b) => new Date(b.term_start_date) - new Date(a.term_start_date))
return [
{ title: t('task.filters.allTerms'), value: null },
...filteredTerms.map(term => ({
return filteredTerms.map(term => ({
title: term.term_name,
value: term.term_id
}))
]
})
// 系列任务选项(Story)- 编辑对话框用,带 cascade 过滤(依赖 student + term)
const editStoryOptions = computed(() => {
if (!editForm.value.student_id || !editForm.value.term_id) {
return []
}
let filtered = [...stories.value]
filtered = filtered.filter(s => s.student_id === editForm.value.student_id)
filtered = filtered.filter(s => s.term_id === editForm.value.term_id)
if (editForm.value.subject_id) {
filtered = filtered.filter(s => s.subject_id === editForm.value.subject_id)
}
return filtered.map(story => ({
title: story.story_name,
value: story.story_id
}))
})
// 图像放大查看相关状态
......@@ -1220,12 +1371,15 @@ const openEditDialog = async (task) => {
});
editingTask.value = task
// 抑制 cascade watcher,避免加载时把 term/story 重置为 null
suppressCascade.value = true
editForm.value = {
task_name: task.task_name || '',
task_description: task.task_description || '',
student_id: task.student_id || null,
subject_id: task.subject_id || null,
term_id: task.term_id || null,
story_id: task.story_id || null,
start_date: task.start_date ? task.start_date.split('T')[0] : '',
end_date: task.end_date ? task.end_date.split('T')[0] : '',
completion_percent: task.completion_percent || 0,
......@@ -1243,6 +1397,8 @@ const openEditDialog = async (task) => {
}
console.log('Editing task data:', task)
console.log('Converted form data:', editForm.value)
await nextTick()
suppressCascade.value = false
editDialog.value = true
// 对话框打开后,预加载所有图片
......@@ -1283,7 +1439,7 @@ const openEditDialog = async (task) => {
}
// 打开创建对话框
const openCreateDialog = (date) => {
const openCreateDialog = async (date) => {
// 添加调试信息,记录传入的date参数
console.log('openCreateDialog called with date:', date);
console.log('date type:', typeof date);
......@@ -1354,12 +1510,15 @@ const openCreateDialog = (date) => {
formattedDate = new Date().toISOString().split('T')[0];
}
// 抑制 cascade watcher,避免加载时把预填的 term 重置为 null
suppressCascade.value = true;
editForm.value = {
task_name: '',
task_description: '',
student_id: filters.value.studentId || null,
subject_id: filters.value.subjectId || null,
term_id: filters.value.termId || null,
story_id: null,
start_date: formattedDate,
end_date: formattedDate,
completion_percent: 0,
......@@ -1377,6 +1536,8 @@ const openCreateDialog = (date) => {
};
console.log('Creating new task with date:', formattedDate);
await nextTick();
suppressCascade.value = false;
editDialog.value = true;
}
......@@ -1395,6 +1556,7 @@ const closeEditDialog = () => {
student_id: null,
subject_id: null,
term_id: null,
story_id: null,
start_date: '',
end_date: '',
completion_percent: 0,
......@@ -1768,6 +1930,7 @@ const updateTask = async () => {
student_id: parseInt(editForm.value.student_id),
subject_id: parseInt(editForm.value.subject_id),
term_id: editForm.value.term_id ? parseInt(editForm.value.term_id) : null,
story_id: editForm.value.story_id || null,
start_date: new Date(editForm.value.start_date).toISOString(),
end_date: new Date(editForm.value.end_date).toISOString(),
completion_percent: parseInt(editForm.value.completion_percent)
......@@ -1854,7 +2017,8 @@ onMounted(() => {
fetchTasks({}), // 传入空对象获取所有任务
fetchStudents(),
fetchSubjects(),
fetchTerms()
fetchTerms(),
fetchStories()
])
// 添加键盘事件监听
......@@ -1866,38 +2030,39 @@ onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
})
// 监听编辑表单中学生选择的变化,用于更新学期选项
// 监听编辑表单中学生选择的变化,重置学期和系列任务选择
watch(() => editForm.value.student_id, (newStudentId, oldStudentId) => {
// 当学生选择发生变化时,如果当前选择的学期不属于新选择的学生,则重置学期选择
if (newStudentId !== oldStudentId && editForm.value.term_id) {
const termBelongsToStudent = terms.value.some(
term => term.term_id === editForm.value.term_id && term.student_id === newStudentId
)
if (!termBelongsToStudent) {
if (suppressCascade.value) return
if (newStudentId !== oldStudentId) {
// cascade: 学生变了 → 重置学期和系列任务
editForm.value.term_id = null
editForm.value.story_id = null
console.log('Edit: student changed, term & story reset')
}
})
// 学期变了 → 重置系列任务
watch(() => editForm.value.term_id, (newTermId, oldTermId) => {
if (suppressCascade.value) return
if (newTermId !== oldTermId) {
editForm.value.story_id = null
console.log('Edit: term changed, story reset')
}
})
// 学科变了 → 重置系列任务
watch(() => editForm.value.subject_id, (newSubjectId, oldSubjectId) => {
if (suppressCascade.value) return
if (newSubjectId !== oldSubjectId) {
editForm.value.story_id = null
console.log('Edit: subject changed, story reset')
}
})
</script>
<style scoped>
.task-calendar {
min-height: 600px;
}
.task-calendar :deep(.v-calendar-daily__day-interval) {
cursor: pointer;
}
.task-calendar :deep(.v-calendar-event) {
cursor: pointer;
transition: all 0.2s;
}
.task-calendar :deep(.v-calendar-event:hover) {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
height: 100%;
}
/* 错误消息容器样式 */
......@@ -1908,89 +2073,26 @@ watch(() => editForm.value.student_id, (newStudentId, oldStudentId) => {
width: 100%;
}
/* 自定义事件样式 */
.custom-event {
position: relative;
width: 100%;
height: 100%;
border-radius: 4px;
overflow: hidden;
box-sizing: border-box;
background-color: #E0E0E0; /* 未完成部分使用灰色背景 */
border: 1px solid transparent; /* 默认透明边框 */
cursor: pointer;
transform: scale(1);
transition: all 0.2s;
}
/* 图片操作按钮样式 */
.image-actions {
position: absolute;
top: 5px;
right: 5px;
display: flex;
gap: 5px;
opacity: 0;
transition: opacity 0.2s;
background-color: rgba(255, 255, 255, 0.7);
border-radius: 4px;
padding: 2px;
}
.image-preview:hover .image-actions {
opacity: 1;
}
/* 图片下方操作按钮样式 */
.image-actions-bottom {
/* 编辑对话框:头/尾固定,中间内容滚动 */
.edit-task-card {
display: flex;
justify-content: center;
gap: 8px;
margin-top: 8px;
}
.upload-placeholder {
margin-top: 5px;
font-size: 0.8rem;
color: #666;
text-align: center;
}
.custom-event:hover {
transform: scale(1.1); /* 悬停时放大10% */
border-color: #000000; /* 悬停时显示黑色边框 */
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
z-index: 10; /* 确保放大时在其他元素之上 */
}
.event-content {
position: relative;
z-index: 2;
padding: 2px 4px;
white-space: nowrap;
flex-direction: column;
max-height: 90vh;
overflow: hidden;
text-overflow: ellipsis;
font-size: 0.8rem;
font-weight: 500;
color: #000000; /* 默认黑色文字 */
}
.subject-tag {
margin-right: 4px;
.edit-task-header {
flex: 0 0 auto;
}
.completion-bar {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
.edit-task-body {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
}
.completion-fill {
height: 100%;
transition: width 0.3s ease;
.edit-task-actions {
flex: 0 0 auto;
}
/* 图像容器样式 */
......@@ -2088,6 +2190,91 @@ watch(() => editForm.value.student_id, (newStudentId, oldStudentId) => {
user-select: none;
}
/* 悬浮提示 */
.event-hover-tip {
display: none;
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: rgba(30, 30, 30, 0.95);
color: #fff;
padding: 8px 12px;
border-radius: 6px;
font-size: 12px;
line-height: 1.5;
white-space: normal;
min-width: 200px;
max-width: 320px;
z-index: 9999;
pointer-events: none;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}
.event-hover-tip .tip-title {
font-weight: 600;
font-size: 13px;
margin-bottom: 2px;
}
.event-hover-tip .tip-desc {
color: #ccc;
font-size: 11px;
margin-top: 4px;
border-top: 1px solid rgba(255,255,255,0.15);
padding-top: 4px;
word-break: break-word;
}
.event-hover-tip .tip-meta {
display: flex;
gap: 10px;
margin-top: 6px;
font-size: 11px;
opacity: 0.8;
}
.event-hover-tip::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: rgba(30, 30, 30, 0.95);
}
.custom-event.tip-active .event-hover-tip {
display: block;
}
/* 拖拽放置区域 */
.calendar-drop-zone {
position: relative;
}
/* 日格拖拽悬停高亮 */
.v-calendar-month__day.drag-over {
background-color: rgba(33, 150, 243, 0.12) !important;
outline: 2px dashed #1976D2;
outline-offset: -2px;
border-radius: 4px;
}
/* 拖拽中的卡片 */
.custom-event[draggable="true"] {
cursor: grab;
}
.custom-event[draggable="true"]:active {
cursor: grabbing;
}
/* 拖拽时的样式 */
.custom-event.dragging {
opacity: 0.4;
}
/* 任务布局容器 */
.task-layout {
height: calc(100vh - 270px);
......@@ -2096,9 +2283,9 @@ watch(() => editForm.value.student_id, (newStudentId, oldStudentId) => {
/* 筛选面板容器 */
.filter-panel-container {
width: 300px;
min-width: 300px;
max-width: 300px;
width: 240px;
min-width: 240px;
max-width: 240px;
transition: all 0.4s cubic-bezier(0.4, 0.0, 0.2, 1);
overflow: hidden;
margin-right: 16px;
......@@ -2113,8 +2300,24 @@ watch(() => editForm.value.student_id, (newStudentId, oldStudentId) => {
}
.filter-panel-card {
width: 300px;
min-width: 300px;
width: 240px;
min-width: 240px;
}
/* 筛选面板标题与控件统一缩小 */
.filter-panel-card .v-card-title {
font-size: 0.9375rem;
padding: 6px 8px;
}
.filter-panel-card .v-card-title .v-icon {
font-size: 18px;
}
/* 筛选面板内的标签和输入框缩小 */
.filter-panel-card :deep(.v-label),
.filter-panel-card :deep(.v-field__input) {
font-size: 0.8125rem;
}
/* 日历容器 */
......@@ -2199,4 +2402,111 @@ watch(() => editForm.value.student_id, (newStudentId, oldStudentId) => {
height: calc(100vh - 350px);
}
}
/* ── FullCalendar 自定义样式 ── */
.task-calendar :deep(.fc-event) {
border: none;
background: transparent;
padding: 0;
cursor: pointer;
}
.task-calendar :deep(.fc-event-main) {
padding: 0;
}
.fc-event-custom {
display: flex;
flex-direction: column;
width: 100%;
border-radius: 3px;
overflow: hidden;
background: #E0E0E0;
border: 1px solid transparent;
transition: all 0.15s;
cursor: grab;
position: relative;
}
.fc-event-custom:hover {
border-color: #000;
box-shadow: 0 1px 4px rgba(0,0,0,0.15);
z-index: 5;
}
.fc-event-content {
display: flex;
align-items: center;
gap: 3px;
padding: 1px 3px;
font-size: 11px;
font-weight: 500;
color: #000;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
position: relative;
z-index: 2;
}
.fc-event-avatar {
width: 18px;
height: 18px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 700;
flex-shrink: 0;
}
.fc-event-subject {
font-weight: 700;
flex-shrink: 0;
color: #000 !important;
}
.fc-event-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fc-event-bar {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
pointer-events: none;
}
.fc-event-fill {
height: 100%;
transition: width 0.3s;
}
.task-calendar :deep(.fc) {
min-height: 300px;
}
.task-calendar.is-fullscreen :deep(.fc) {
min-height: 300px;
height: auto !important;
}
.task-calendar :deep(.fc-daygrid-day-frame) {
min-height: 60px;
}
/* 跨天事件条加强可见性 */
.task-calendar :deep(.fc-h-event) {
border-left: 3px solid rgba(0,0,0,0.3) !important;
}
.task-calendar :deep(.fc-daygrid-block-event .fc-event-main) {
overflow: visible;
}
</style>
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