1
0
mirror of https://github.com/ZeroCatDev/Classworks.git synced 2026-02-04 07:53:11 +00:00
Classworks/src/components/home/HomeworkGrid.vue
chenziang fca33c1b95
feat(settings): 添加迟到学生是否计入出勤人数的显示设置
- 在设置工具中新增 display.lateStudentsArePresent 配置项
- 修改考勤侧边栏计算逻辑以支持迟到人数统计选项
- 更新作业网格组件中的出勤人数计算方式
- 添加设置导入功能到相关组件中
- 在显示设置卡片中增加新的配置选项界面
2026-01-11 07:34:34 +08:00

431 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div ref="gridContainer" class="grid-masonry">
<TransitionGroup name="grid">
<div
v-for="item in sortedItems"
:key="item.key"
ref="items"
:data-key="item.key"
:style="{
order: item.order,
}"
class="grid-item"
>
<!-- 一言卡片 -->
<div v-if="item.type === 'hitokoto'" style="height: 100%">
<hitokoto-card />
</div>
<!-- 考试卡片 -->
<div v-else-if="item.type === 'exam'" style="height: 100%">
<concise-exam-card
:exam-id="item.data.examId"
:content-style="contentStyle"
@click="$emit('open-exam-detail', item.data.examId)"
/>
</div>
<!-- 出勤卡片 -->
<v-card
v-else-if="item.type === 'attendance'"
:class="{ 'glow-highlight': highlightedCards[item.key], 'cursor-not-allowed': isEditingDisabled, 'cursor-pointer': !isEditingDisabled }"
border
class="glow-track"
height="100%"
@click="handleCardClick('attendance', null)"
@mousemove="handleMouseMove"
@touchmove="handleTouchMove"
>
<v-card-title class="d-flex align-center">
<v-icon class="mr-2" color="primary" icon="mdi-account-group" />
出勤统计
</v-card-title>
<v-card-text>
<div class="d-flex justify-space-between align-center mb-2">
<span>应到/实到</span>
<span class="text-h6">
{{ item.data.total - item.data.exclude.length }}/{{
item.data.total -
item.data.absent.length -
(!getSetting("display.lateStudentsArePresent")) * item.data.late.length -
item.data.exclude.length
}}
</span>
</div>
<v-divider class="mb-2" />
<div v-if="item.data.absent.length > 0" class="mb-2">
<div class="text-error text-caption mb-1">请假 ({{ item.data.absent.length }})</div>
<div class="d-flex flex-wrap" style="gap: 4px">
<v-chip v-for="name in item.data.absent" :key="name" color="error" size="x-small" variant="flat">
{{ name }}
</v-chip>
</div>
</div>
<div v-if="item.data.late.length > 0" class="mb-2">
<div class="text-warning text-caption mb-1">迟到 ({{ item.data.late.length }})</div>
<div class="d-flex flex-wrap" style="gap: 4px">
<v-chip v-for="name in item.data.late" :key="name" color="warning" size="x-small" variant="flat">
{{ name }}
</v-chip>
</div>
</div>
<div v-if="item.data.exclude.length > 0" class="mb-2">
<div class="text-grey text-caption mb-1">不参与 ({{ item.data.exclude.length }})</div>
<div class="d-flex flex-wrap" style="gap: 4px">
<v-chip v-for="name in item.data.exclude" :key="name" color="grey" size="x-small" variant="flat">
{{ name }}
</v-chip>
</div>
</div>
<div
v-if="
item.data.absent.length === 0 &&
item.data.late.length === 0 &&
item.data.exclude.length === 0
"
class="text-success text-center mt-2"
>
全勤
</div>
</v-card-text>
</v-card>
<!-- 自定义/测试卡片 -->
<v-card
v-else-if="item.type === 'custom'"
:class="{ 'glow-highlight': highlightedCards[item.key], 'cursor-not-allowed': isEditingDisabled, 'cursor-pointer': !isEditingDisabled }"
border
class="glow-track"
height="100%"
@click="handleCardClick('dialog', item.key)"
@mousemove="handleMouseMove"
@touchmove="handleTouchMove"
>
<v-card-title class="text-primary">
<v-icon class="mr-2" icon="mdi-card-text-outline" size="small" />
{{ item.name }}
</v-card-title>
<v-card-text :style="contentStyle">
{{ item.content }}
</v-card-text>
</v-card>
<!-- 普通作业卡片 -->
<v-card
v-else
:class="{ 'glow-highlight': highlightedCards[item.key], 'cursor-not-allowed': isEditingDisabled, 'cursor-pointer': !isEditingDisabled }"
border
class="glow-track"
height="100%"
@click="handleCardClick('dialog', item.key)"
@mousemove="handleMouseMove"
@touchmove="handleTouchMove"
>
<v-card-title>{{ item.name }}</v-card-title>
<v-card-text :style="contentStyle">
<v-list>
<v-list-item
v-for="text in splitPoint(item.content)"
:key="text"
>
{{ text }}
</v-list-item>
</v-list>
</v-card-text>
</v-card>
</div>
</TransitionGroup>
</div>
<!-- 单独显示空科目 -->
<div class="empty-subjects mt-4">
<!-- 移动端优化视图 -->
<div v-if="isMobile" class="d-flex flex-wrap justify-center">
<v-chip
v-for="subject in unusedSubjects"
:key="subject.name"
class="ma-1"
color="primary"
variant="tonal"
@click="handleCardClick('dialog', subject.name)"
>
<v-icon start size="small">
{{ isReadOnlyToken ? 'mdi-cancel' : 'mdi-plus' }}
</v-icon>
{{ subject.name }}
</v-chip>
</div>
<template v-else-if="emptySubjectDisplay === 'button'">
<v-btn-group divided variant="tonal">
<v-btn
v-for="subject in unusedSubjects"
:key="subject.name"
@click="handleCardClick('dialog', subject.name)"
>
<v-icon start>
{{ isReadOnlyToken ? 'mdi-cancel' : 'mdi-plus' }}
</v-icon>
{{ subject.name }}
</v-btn>
</v-btn-group>
</template>
<div v-else class="empty-subjects-grid">
<TransitionGroup name="v-list">
<v-card
v-for="subject in unusedSubjects"
:key="subject.name"
border
class="empty-subject-card"
@click="handleCardClick('dialog', subject.name)"
>
<v-card-title class="text-subtitle-1">
{{ subject.name }}
</v-card-title>
<v-card-text class="text-center">
<template v-if="isReadOnlyToken">
<v-icon color="grey" size="small"> mdi-cancel </v-icon>
<div class="text-caption text-grey"> 当日无作业 </div>
</template>
<template v-else>
<v-icon color="grey" size="small"> mdi-plus </v-icon>
<div class="text-caption text-grey"> 点击添加作业 </div>
</template>
</v-card-text>
</v-card>
</TransitionGroup>
</div>
</div>
</template>
<script>
import HitokotoCard from "@/components/HitokotoCard.vue";
import ConciseExamCard from "@/components/home/ConciseExamCard.vue";
import {getSetting} from "@/utils/settings.js";
export default {
name: "HomeworkGrid",
computed: {
settings() {
return settings
}
},
components: {
HitokotoCard,
ConciseExamCard,
},
props: {
sortedItems: {
type: Array,
required: true,
},
unusedSubjects: {
type: Array,
required: true,
},
emptySubjectDisplay: {
type: String,
default: "button",
},
isMobile: {
type: Boolean,
default: false,
},
isEditingDisabled: {
type: Boolean,
default: false,
},
contentStyle: {
type: Object,
default: () => ({}),
},
highlightedCards: {
type: Object,
default: () => ({}),
},
},
emits: ["open-dialog", "open-attendance", "disabled-click"],
data() {
return {
isReadOnlyToken: false,
}
},
async mounted() {
/* eslint-disable no-undef */
this.resizeObserver = new ResizeObserver(() => {
this.resizeAllGridItems();
});
/* eslint-enable no-undef */
// Observe the grid container for width changes
if (this.$refs.gridContainer) {
this.resizeObserver.observe(this.$refs.gridContainer);
}
// Initial resize
this.$nextTick(() => {
this.resizeAllGridItems();
// Observe all items
if (this.$refs.items) {
this.$refs.items.forEach(item => {
// Observe the content inside the grid item
if (item.firstElementChild) {
this.resizeObserver.observe(item.firstElementChild);
}
});
}
});
// 检查只读状态
await this.checkReadOnlyStatus();
},
updated() {
// When items change, re-observe new items
this.$nextTick(() => {
this.resizeAllGridItems();
if (this.$refs.items) {
this.$refs.items.forEach(item => {
if (item.firstElementChild) {
this.resizeObserver.observe(item.firstElementChild);
}
});
}
});
},
beforeUnmount() {
if (this.resizeObserver) {
this.resizeObserver.disconnect();
}
},
methods: {
getSetting,
async checkReadOnlyStatus() {
// 尝试获取父组件中的StudentNameManager引用
try {
// 在Vue 2中通过$parent或$root访问父组件
let manager = null;
// 首先尝试直接访问父组件的引用
if (this.$parent && this.$parent.$refs && this.$parent.$refs.studentNameManager) {
manager = this.$parent.$refs.studentNameManager;
} else if (this.$root && this.$root.$refs && this.$root.$refs.studentNameManager) {
manager = this.$root.$refs.studentNameManager;
}
if (manager && typeof manager.isReadOnly !== 'undefined') {
this.isReadOnlyToken = manager.isReadOnly;
} else {
// 如果无法直接访问manager尝试通过全局设置获取token信息
// 这里需要使用utils/settings中的函数
const { getSetting } = await import('@/utils/settings');
const token = getSetting('server.kvToken');
if (token) {
// 通过API获取token信息来判断是否只读
const { default: axios } = await import('@/axios/axios');
const serverUrl = getSetting('server.domain');
if (serverUrl) {
try {
const tokenResponse = await axios.get(`${serverUrl}/kv/_token`, {
headers: {
Authorization: `Bearer ${token}`
}
});
if (tokenResponse.data && typeof tokenResponse.data.isReadOnly !== 'undefined') {
this.isReadOnlyToken = tokenResponse.data.isReadOnly;
}
} catch (err) {
console.error('获取Token信息失败:', err);
}
}
}
}
} catch (error) {
console.error('检查只读状态失败:', error);
}
},
resizeGridItem(item) {
const grid = this.$refs.gridContainer;
if (!grid) return;
const rowHeight = parseInt(window.getComputedStyle(grid).getPropertyValue('grid-auto-rows'));
const rowGap = parseInt(window.getComputedStyle(grid).getPropertyValue('gap'));
// Find the content element (v-card or div)
const content = item.firstElementChild;
if (!content) return;
// Calculate required span
// We use scrollHeight to get the full height of content
// Add a small buffer to prevent scrollbars
const contentHeight = content.getBoundingClientRect().height;
// Formula: span = ceil((contentHeight + gap) / (rowHeight + gap))
const rowSpan = Math.ceil((contentHeight + rowGap) / (rowHeight + rowGap));
item.style.gridRowEnd = `span ${rowSpan}`;
},
resizeAllGridItems() {
const items = this.$refs.items;
if (items) {
items.forEach(item => this.resizeGridItem(item));
}
},
handleCardClick(type, key) {
if (this.isEditingDisabled) {
this.$emit('disabled-click');
return;
}
if (type === 'attendance') {
this.$emit('open-attendance');
} else if (type === 'dialog') {
this.$emit('open-dialog', key);
}
},
splitPoint(content) {
return content.split("\n").filter((text) => text.trim());
},
handleMouseMove(e) {
const card = e.currentTarget;
const rect = card.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * 100;
const y = ((e.clientY - rect.top) / rect.height) * 100;
card.style.setProperty("--x", `${x}%`);
card.style.setProperty("--y", `${y}%`);
},
handleTouchMove(e) {
if (e.touches.length === 1) {
const touch = e.touches[0];
const card = e.currentTarget;
const rect = card.getBoundingClientRect();
const x = ((touch.clientX - rect.left) / rect.width) * 100;
const y = ((touch.clientY - rect.top) / rect.height) * 100;
card.style.setProperty("--x", `${x}%`);
card.style.setProperty("--y", `${y}%`);
}
},
},
};
</script>
<style scoped>
.cursor-not-allowed {
cursor: not-allowed !important;
}
.cursor-pointer {
cursor: pointer;
}
.v-card.cursor-not-allowed:hover {
transform: none !important;
}
</style>