hookehuyr

feat(teacher): 新增教师端作业主页功能

- 添加作业主页路由及页面组件
- 实现作业信息展示、统计数据和日历功能
- 添加学生完成情况展示组件
- 更新任务管理页跳转逻辑
- 添加相关SVG图标和组件类型声明
......@@ -7,3 +7,8 @@ https://oa-dev.onwall.cn/f/mlaj
- 教师端新增作业管理页面:路径 `/teacher/tasks`,标题“作业管理”。
- 列表展示:作业名称、开始时间、截止时间。
- 当前数据来源为Mock,后续可替换为真实接口数据。
- 教师端新增作业主页:路径 `/teacher/tasks/:id`,标题“作业主页”。
- 头部:作业名称、介绍文案、细项信息(周期、频次、时间段、附件类型)。
- 统计:出勤率与任务完成率(参考 `myClassPage.vue` 统计样式,数据Mock)。
- 日历:使用 `van-calendar` 单选模式,选择日期后展示当日学生完成情况。
- 学生完成情况:参考图片2样式,勾选代表已完成,未勾选代表未完成(数据Mock)。
......
<svg width="36" height="36" viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#10b981"/>
<stop offset="100%" stop-color="#16a34a"/>
</linearGradient>
</defs>
<!-- 右上角圆弧形底色(四分之一圆),更贴近示例图 -->
<path d="M36 0 A36 36 0 0 1 0 36 L36 36 Z" fill="url(#g)" />
<!-- 白色对勾 -->
<path d="M22 8 L28 14 L16 26" fill="none" stroke="#ffffff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" />
</svg>
\ No newline at end of file
......@@ -32,6 +32,7 @@ declare module 'vue' {
RouterView: typeof import('vue-router')['RouterView']
SearchBar: typeof import('./components/ui/SearchBar.vue')['default']
SummerCampCard: typeof import('./components/ui/SummerCampCard.vue')['default']
TaskCalendar: typeof import('./components/ui/TaskCalendar.vue')['default']
TermsPopup: typeof import('./components/ui/TermsPopup.vue')['default']
UploadVideoPopup: typeof import('./components/ui/UploadVideoPopup.vue')['default']
UserAgreement: typeof import('./components/ui/UserAgreement.vue')['default']
......
<!--
* @Date: 2025-11-19 21:20:00
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-11-19 21:22:29
* @FilePath: /mlaj/src/components/ui/TaskCalendar.vue
* @Description: 自定义轻量日历组件(单月视图,7列栅格,点击选择日期;样式参考示例图)
-->
<template>
<div class="TaskCalendar bg-white rounded-lg shadow px-4 py-4 relative">
<!-- 顶部:左右切月 + 月份标题 -->
<div class="header flex items-center justify-between mb-3">
<van-icon name="arrow-left" class="text-gray-500" @click="go_prev_month" />
<div class="monthTitle text-xl font-bold text-gray-900 cursor-pointer" @click="open_month_picker">{{ month_title }}</div>
<van-icon name="arrow" class="text-gray-500 rotate-180" @click="go_next_month" />
</div>
<!-- 年月选择弹窗(使用 Vant DatePicker) -->
<van-popup v-model:show="show_date_picker" position="bottom">
<van-picker-group
title="选择年月"
:tabs="['选择年月']"
@confirm="on_confirm_year_month"
@cancel="on_cancel_year_month"
>
<van-date-picker
v-model="year_month_value"
:min-date="min_date"
:max-date="max_date"
:columns-type="columns_type"
/>
</van-picker-group>
</van-popup>
<!-- 星期标题 -->
<div class="weekRow grid grid-cols-7 gap-3 mb-2 text-center text-gray-500 text-sm">
<div v-for="w in weeks" :key="w">{{ w }}</div>
</div>
<!-- 日期网格:圆形按钮,选中高亮;无日期不显示圆但保留格子位置 -->
<div class="daysGrid grid grid-cols-7 gap-3">
<div v-for="day in grid_days" :key="day.key"
class="dayItem flex items-center justify-center rounded-full" :class="[
(!day.date || day.type !== 'current') ? 'invisible' : 'bg-green-100 text-gray-700',
is_selected(day) ? 'bg-green-500 text-white' : ''
]" @click="on_click_day(day)">
<span v-if="day.date">{{ day.date.getDate() }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
/**
* 组件对外暴露的 v-model 值:选中日期(YYYY-MM-DD)
*/
const props = defineProps({
modelValue: { type: String, default: '' }
})
const emit = defineEmits(['update:modelValue', 'select'])
//
// 状态:当前面板年月与选中日期
//
const selected_date = ref(props.modelValue || format_date(new Date()))
const panel_year = ref(Number(selected_date.value.slice(0, 4)))
const panel_month = ref(Number(selected_date.value.slice(5, 7)))
watch(() => props.modelValue, (val) => {
if (val) selected_date.value = val
})
/**
* 月份标题文案
* @returns {string}
*/
const month_title = computed(() => `${panel_year.value}年${panel_month.value}月`)
/**
* 星期标题
*/
const weeks = ref(['日', '一', '二', '三', '四', '五', '六'])
/**
* 生成日历网格(含上月/下月占位)
* @returns {Array<{key:string,type:string,date:Date|null}>}
*/
const grid_days = computed(() => {
const first = new Date(panel_year.value, panel_month.value - 1, 1)
const first_weekday = first.getDay() // 0..6
const days_in_month = new Date(panel_year.value, panel_month.value, 0).getDate()
const days = []
// 上月占位
for (let i = 0; i < first_weekday; i++) {
days.push({ key: `p-${i}`, type: 'prev', date: null })
}
// 当月日期
for (let d = 1; d <= days_in_month; d++) {
const dt = new Date(panel_year.value, panel_month.value - 1, d)
days.push({ key: `c-${d}`, type: 'current', date: dt })
}
// 末尾占位:补至整周
const tail = (7 - (days.length % 7)) % 7
for (let j = 0; j < tail; j++) {
days.push({ key: `n-${j}`, type: 'next', date: null })
}
return days
})
/**
* 判断是否选中该日期
* @param {{type:string,date:Date|null}} day
* @returns {boolean}
*/
function is_selected(day) {
if (!day.date) return false
return format_date(day.date) === selected_date.value
}
/**
* 点击某日期:更新选中,并向外派发事件
* @param {{type:string,date:Date|null}} day
* @returns {void}
*/
function on_click_day(day) {
if (!day.date) return
const val = format_date(day.date)
selected_date.value = val
emit('update:modelValue', val)
emit('select', val)
}
/**
* 切换至上/下月
*/
function go_prev_month() {
const d = new Date(panel_year.value, panel_month.value - 2, 1)
panel_year.value = d.getFullYear()
panel_month.value = d.getMonth() + 1
}
function go_next_month() {
const d = new Date(panel_year.value, panel_month.value, 1)
panel_year.value = d.getFullYear()
panel_month.value = d.getMonth() + 1
}
/**
* 日期格式化为 YYYY-MM-DD
* @param {Date} d - 日期对象
* @returns {string}
*/
function format_date(d) {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
/**
* 年月选择弹窗相关状态
*/
const show_date_picker = ref(false)
// DatePicker 的 v-model 值(仅年/月),与 Vant 用法保持一致
const year_month_value = ref([String(panel_year.value), String(panel_month.value).padStart(2, '0')])
/**
* DatePicker 列类型(仅年份与月份)
* @type {string[]}
*/
const columns_type = ['year', 'month']
// 选择范围(可按需调整)
const min_date = new Date(2020, 0, 1)
const max_date = new Date(2035, 11, 31)
/**
* 打开年月选择弹窗
* @returns {void}
*/
function open_month_picker() {
// 同步当前面板年月到选择器
year_month_value.value = [String(panel_year.value), String(panel_month.value).padStart(2, '0')]
show_date_picker.value = true
}
/**
* 取消选择年月
* @returns {void}
*/
function on_cancel_year_month() {
show_date_picker.value = false
}
/**
* 确认选择年月:更新面板年月并关闭弹窗
* @returns {void}
*/
function on_confirm_year_month() {
const [y, m] = year_month_value.value
panel_year.value = Number(y)
panel_month.value = Number(m)
show_date_picker.value = false
}
</script>
<style lang="less" scoped>
.TaskCalendar {
// 防止内部内容溢出容器尺寸
width: 100%;
max-width: 100%;
overflow: hidden;
.header {
.monthTitle {
line-height: 1.3;
}
}
.weekRow {
// 兜底栅格:确保7列布局,避免类未编译导致竖排
display: grid;
grid-template-columns: repeat(7, 1fr);
}
.daysGrid {
// 兜底栅格:确保7列布局,避免类未编译导致竖排
display: grid;
grid-template-columns: repeat(7, 1fr);
.dayItem {
// 自适应圆点尺寸:不超过容器列宽与2.5rem,保持正圆
width: 100%;
max-width: 2.5rem;
aspect-ratio: 1 / 1;
border-radius: 9999px;
justify-self: center;
transition: all 0.2s ease-in-out;
}
}
}
</style>
......@@ -43,6 +43,15 @@ export default [
},
},
{
path: '/teacher/tasks/:id',
name: 'TeacherTaskHome',
component: () => import('../views/teacher/taskHomePage.vue'),
meta: {
title: '作业主页',
requiresAuth: true
},
},
{
path: '/teacher/student/:id',
name: 'Student',
component: () => import('../views/teacher/studentPage.vue'),
......
<!--
* @Date: 2025-11-19 21:00:00
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-11-19 21:43:15
* @FilePath: /mlaj/src/views/teacher/taskHomePage.vue
* @Description: 教师端作业主页(头部介绍、统计、日历与学生完成情况;数据Mock)
-->
<template>
<div class="TaskHomePage">
<!-- 头部卡片:名称、介绍、细项(参考图片1结构) -->
<div class="headCard bg-white rounded-lg shadow px-4 py-4">
<div class="title text-2xl font-bold text-gray-900 mb-2">{{ task_title }}</div>
<div class="intro text-base text-gray-700 leading-relaxed mb-3">{{ task_intro }}</div>
<!-- 三图展示(可选),使用CDN示例地址并加压缩参数 -->
<!-- <div class="images grid grid-cols-3 gap-3 mb-3">
<img v-for="(img, idx) in task_images" :key="idx" :src="img"
class="rounded-md object-cover w-full h-24" />
</div> -->
<div class="details text-sm text-gray-600">
<div class="detailItem">周期:{{ task_details.cycle }}</div>
<div class="detailItem">频次:{{ task_details.frequency }}</div>
<div class="detailItem">时间段:{{ task_details.time_range }}</div>
<div class="detailItem">附件类型:{{ task_details.attachment_type }}</div>
</div>
</div>
<!-- 统计数据(参考 myClassPage.vue 出勤率/任务完成) -->
<div class="statsCard bg-white rounded-lg shadow px-4 py-4 mt-4">
<van-row gutter="16">
<!-- 出勤率 -->
<van-col span="12">
<div class="text-center">
<div class="relative w-16 h-16 mx-auto mb-2">
<van-circle v-model:current-rate="checkin_count" :rate="checkin_count" :text="checkin_text"
stroke-width="70" color="#10b981" size="64" layer-color="#eee" />
</div>
<div class="text-sm text-gray-600">出勤率</div>
</div>
</van-col>
<!-- 任务完成 -->
<van-col span="12">
<div class="text-center">
<div class="relative w-16 h-16 mx-auto mb-2">
<van-circle v-model:current-rate="upload_count" :rate="upload_count" :text="upload_text"
stroke-width="70" color="#3b82f6" size="64" layer-color="#eee" />
</div>
<div class="text-sm text-gray-600">任务完成</div>
</div>
</van-col>
</van-row>
</div>
<!-- 日历:选择日期后展示该日期的学生完成情况 -->
<div class="calendarCard bg-white rounded-lg shadow px-4 py-4 mt-4">
<div class="text-base font-semibold text-gray-800 mb-2">选择日期查看完成情况</div>
<TaskCalendar v-model="selected_date" @select="on_date_select" />
</div>
<!-- 学生完成情况(参考图片2样式) -->
<div class="studentsCard bg-white rounded-lg shadow px-4 py-4 mt-4">
<div class="flex items-center justify-between mb-3">
<div class="text-base font-semibold text-gray-800">完成情况({{ completed_count }}/{{ students.length }})
</div>
<div class="text-xs text-gray-500">当前日期:{{ current_date_text }}</div>
</div>
<div class="grid grid-cols-5 gap-3 StudentsGrid">
<div v-for="(stu, idx) in students_status" :key="stu.id"
class="studentItem relative rounded-md h-16 flex flex-col items-center justify-center text-center border overflow-hidden"
:class="stu.completed ? 'bg-white border-green-500 text-green-600' : 'bg-gray-100 border-gray-300 text-gray-500'">
<div class="text-sm font-semibold">{{ idx + 1 }}</div>
<div class="text-sm mt-1">{{ stu.name }}</div>
<img v-if="stu.completed" :src="checkCorner" alt="checked" class="cornerIcon" />
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useRoute } from 'vue-router'
import { useTitle } from '@vueuse/core'
import TaskCalendar from '@/components/ui/TaskCalendar.vue'
import checkCorner from '@/assets/check_corner.svg'
const $route = useRoute()
useTitle('作业主页')
//
// Mock:作业基础信息
//
const task_id = $route.params.id || '0'
const task_title = ref('组长每日共修打卡内容')
const task_intro = ref('组长与副组长以上每日语音打卡内容:《义工宣言》《万事如意祈请文》《吉祥圆满感恩文》')
const task_images = ref([
'https://cdn.ipadbiz.cn/mlaj/demo-task-1.png?imageMogr2/thumbnail/200x/strip/quality/70',
'https://cdn.ipadbiz.cn/mlaj/demo-task-2.png?imageMogr2/thumbnail/200x/strip/quality/70',
'https://cdn.ipadbiz.cn/mlaj/demo-task-3.png?imageMogr2/thumbnail/200x/strip/quality/70'
])
const task_details = ref({
cycle: '每天',
frequency: '每日一次',
time_range: '00:00 ~ 23:59',
attachment_type: '语音/文本'
})
//
// Mock:统计数据
//
const checkin_count = ref(56)
const upload_count = ref(62)
const checkin_text = computed(() => `${checkin_count.value}%`)
const upload_text = computed(() => `${upload_count.value}%`)
//
// Mock:学生与完成记录
// 注:每个学生给出若干已完成的日期字符串(YYYY-MM-DD)
//
const today = new Date()
const selected_date = ref(format_date(today))
const students = ref([
{ id: '1', name: '王菲', completed_dates: ['2025-11-18', selected_date.value] },
{ id: '2', name: '朱明献', completed_dates: ['2025-11-18'] },
{ id: '3', name: '陈小云', completed_dates: [] },
{ id: '4', name: '冯新虎', completed_dates: [selected_date.value] },
{ id: '5', name: '罗睿', completed_dates: ['2025-11-17', '2025-11-18'] },
{ id: '6', name: '吴绍婷', completed_dates: [] },
{ id: '7', name: '焦淑敏', completed_dates: [selected_date.value] },
{ id: '8', name: '李言斐', completed_dates: [] },
{ id: '9', name: '陈正统', completed_dates: [] },
{ id: '10', name: '杨子娟', completed_dates: [] },
{ id: '11', name: '方萍', completed_dates: [selected_date.value] },
{ id: '12', name: '冯静', completed_dates: [selected_date.value] },
{ id: '13', name: '尤瑞', completed_dates: [] },
{ id: '14', name: '鲁镇伟', completed_dates: [] },
{ id: '15', name: '黄润', completed_dates: [selected_date.value] },
{ id: '16', name: '王亚琼', completed_dates: [selected_date.value] },
{ id: '17', name: '高晓云', completed_dates: [selected_date.value] },
{ id: '18', name: '张朗', completed_dates: [] },
{ id: '19', name: '姚娟', completed_dates: [selected_date.value] },
{ id: '20', name: '李凯', completed_dates: [selected_date.value] },
{ id: '21', name: '李鑫', completed_dates: [] },
{ id: '22', name: '礼忠斌', completed_dates: [] },
{ id: '23', name: '谭小梅', completed_dates: [selected_date.value] },
{ id: '24', name: '赵红梅', completed_dates: [] }
])
/**
* 将日期对象格式化为 YYYY-MM-DD
* @param {Date} d - 日期对象
* @returns {string} 格式化后的日期字符串
* 注释:补零并返回标准格式,便于与完成记录匹配。
*/
function format_date(d) {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
/**
* 处理日历选择事件
* @param {any} date - 选中的日期对象
* @returns {void}
* 注释:更新选中日期,并联动学生完成情况。
*/
function on_date_select(val) {
// 自定义日历组件返回 YYYY-MM-DD 字符串
selected_date.value = val
}
/**
* 计算某日期下学生完成情况列表
* @returns {{id:string,name:string,completed:boolean}[]} 学生状态列表
* 注释:根据 selected_date 在每个学生的 completed_dates 中判断是否完成。
*/
const students_status = computed(() => {
return students.value.map(stu => ({
id: stu.id,
name: stu.name,
completed: stu.completed_dates.includes(selected_date.value)
}))
})
/**
* 完成人数统计文案
* @returns {number} 完成人数
*/
const completed_count = computed(() => students_status.value.filter(s => s.completed).length)
/**
* 当前日期的展示文本
* @returns {string} 文本
*/
const current_date_text = computed(() => selected_date.value)
</script>
<style lang="less" scoped>
.TaskHomePage {
min-height: 100vh;
background: linear-gradient(to bottom right, #f0fdf4, #f0fdfa, #eff6ff);
padding: 1rem;
padding-bottom: 6rem;
.headCard {
.title {
line-height: 1.3;
}
.intro {
color: #374151;
}
.details {
.detailItem {
margin-bottom: 0.25rem;
}
}
}
.studentsCard {
// 兜底:强制学生列表为5列栅格,避免一行仅1个的问题
.StudentsGrid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 0.75rem; // 等同于 tailwind 的 gap-3
}
.grid>div {
transition: all 0.2s ease-in-out;
}
.studentItem {
min-height: 4rem;
// 卡片圆角裁切,配合右上角图片效果
overflow: hidden;
}
.cornerIcon {
// 右上角图片样式(对勾角标),贴边显示
position: absolute;
top: -1px;
right: -1px;
width: 28px;
height: 28px;
}
}
}
</style>
......@@ -29,7 +29,7 @@
</div>
<!-- 右侧按钮:占用较小空间,右对齐 -->
<div class="right flex items-center justify-end w-20 ml-3">
<van-button type="primary" size="small" round class="w-full">查看</van-button>
<van-button type="primary" size="small" round class="w-full" @click="go_task_home(task.id)">查看</van-button>
</div>
</div>
</div>
......@@ -45,10 +45,11 @@
<script setup>
import { ref } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { useTitle } from '@vueuse/core'
const $route = useRoute()
const $router = useRouter()
useTitle($route.meta.title)
//
......@@ -72,6 +73,16 @@ const format_date_range = (begin_date, end_date) => {
const end = end_date || '-'
return `${start} 至 ${end}`
}
/**
* 跳转到作业主页
* @param {string} id - 作业ID
* @returns {void}
* 注释:点击列表项右侧“查看”按钮,导航到教师端作业主页。
*/
const go_task_home = (id) => {
$router.push({ name: 'TeacherTaskHome', params: { id } })
}
</script>
<style lang="less" scoped>
......