CheckInList.vue 10.8 KB
<template>
    <!-- 列表主体 -->
    <div :class="wrapper_class" :style="scroll_style">
        <button
            v-for="item in items"
            :key="item.id"
            class="CheckInListItem flex flex-col items-center p-2 rounded-lg border transition-colors bg-white/70 border-gray-100 hover:bg-white"
            :class="{ 'is-active': selected_item?.id === item.id }"
            @click="handle_select(item)"
        >
            <div class="Icon w-12 h-12 rounded-full flex items-center justify-center mb-1 transition-colors bg-gray-100"
                :class="{ 'is-active': selected_item?.id === item.id }"
            >
                <van-icon v-if="item.task_type === 'checkin'" name="edit" size="1.5rem" :color="item.is_gray ? 'gray' : ''" />
                <van-icon v-if="item.task_type === 'upload'" name="tosend" size="1.5rem" :color="item.is_gray ? 'gray' : ''" />
            </div>
            <span :class="['text-xs', item.is_gray ? 'text-gray-500' : '']">{{ item.name }}</span>
        </button>
    </div>

    <!-- 提交按钮 -->
    <div v-if="selected_item" class="mt-3">
        <button
            class="SubmitBtn mt-2 w-full bg-gradient-to-r from-green-500 to-green-600 text-white py-2 rounded-lg flex items-center justify-center"
            @click="handle_submit"
            :disabled="submitting"
        >
            <template v-if="submitting">
                <div class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-2"></div>
                提交中...
            </template>
            <template v-else>提交打卡</template>
        </button>
    </div>

    <!-- 二级弹框(课程列表,使用mock数据) -->
    <van-popup
        :show="inner_popup_show"
        @update:show="(v) => inner_popup_show = v"
        round
        position="bottom"
        teleport="body"
        :close-on-click-overlay="false"
        :style="{ minHeight: '40%', maxHeight: '80%', width: '100%' }"
    >
        <div class="p-4">
            <div class="flex justify-between items-center mb-3">
                <h3 class="font-medium">课程列表</h3>
                <van-icon name="cross" @click="close_inner_popup" />
            </div>
            <!-- 简单文本列表:仅显示标题与开始/结束日期 -->
            <div class="rounded-lg bg-white/80 divide-y">
                <div v-for="task in inner_courses" :key="task.id" class="taskItem bg-white rounded-lg shadow px-4 py-3 mb-3" @click="handle_click(task)">
                    <div class="flex items-center justify-between">
                        <!-- 左侧图标:垂直居中,占用较小空间 -->
                        <div class="iconWrapper flex items-center justify-center w-8 mr-3 text-gray-500">
                            <van-icon name="notes-o" size="1.2rem" />
                        </div>
                        <!-- 中间内容:占据剩余空间 -->
                        <div class="left flex-1">
                            <div class="taskTitle text-sm font-semibold text-gray-800">{{ task.title }}</div>
                            <div class="taskDates text-xs text-gray-600 mt-1">开始时间:{{ dayjs(task.begin_date).format('YYYY-MM-DD') }}</div>
                            <div v-if="task.end_date" class="taskDates text-xs text-gray-600 mt-1">截止时间:{{ dayjs(task.end_date).format('YYYY-MM-DD') }}</div>
                        </div>
                        <!-- 右侧按钮:占用较小空间,右对齐 -->
                        <div class="right flex items-center justify-end w-20 ml-3">
                            <van-button type="success" size="small" round class="w-full" @click="go_task_home(task.id)">查看</van-button>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </van-popup>
</template>

<script setup>
import { ref, computed, inject } from 'vue'
import { useRouter } from 'vue-router'
import { checkinTaskAPI } from '@/api/checkin'
import { showToast } from 'vant'
import dayjs from 'dayjs'

/**
 * @typedef {Object} CheckInItem
 * @property {number|string} id - 任务ID。
 * @property {string} name - 任务名称。
 * @property {string} task_type - 任务类型,`checkin` 或 `upload`。
 * @property {boolean} [is_gray] - 是否置灰,表示今日已完成。
 */

/**
 * @function props
 * @description 组件接收的属性定义。
 */
const props = defineProps({
    items: { type: Array, default: () => [] },
    dense: { type: Boolean, default: false },
    scroll: { type: Boolean, default: false },
    plain: { type: Boolean, default: false },
})

/**
 * @function emits
 * @description 组件对外抛出的事件。
 */
const emit = defineEmits(['submit-success'])

const router = useRouter()
const selected_item = ref(null)
const submitting = ref(false)
// 父弹框联动(仅弹框模式下有效)
const parent_popup = inject('parent_popup_control', null)
// 二级弹框与数据
const inner_popup_show = ref(false)
const inner_courses = ref([])

/**
 * @function wrapper_class
 * @description 计算列表容器类名。
 * @returns {string[]}
 */
const wrapper_class = computed(() => [
    'CheckInListWrapper',
    props.dense ? 'grid grid-cols-2 gap-2 py-2' : 'grid grid-cols-2 gap-4 py-2',
])

/**
 * @function scroll_style
 * @description 当 `scroll` 为真时启用滚动区域样式。
 * @returns {Object}
 */
const scroll_style = computed(() => {
    if (!props.scroll) return {}
    return { maxHeight: '13rem', overflow: 'auto' }
})

/**
 * @function handle_select
 * @description 处理打卡类型选择:已完成提示;上传型跳转;否则选中。
 * @param {CheckInItem} item - 当前点击的打卡项。
 * @returns {void}
 */
const handle_select = (item) => {
    // TODO: 想要判断是否有二级菜单
    // const has_submenu = item.children && item.children.length > 0;
    // // 如果有二级菜单需要特殊处理
    // if (has_submenu) {
    //     // 不同模式下弹框的显示逻辑是不一样的
    //     if (props.plain) {
    //         // 普通模式:直接弹出本组件的popup
    //         open_inner_popup()
    //     } else {
    //         // 弹框模式:先隐藏父级弹框,再弹出本组件的popup,关闭后重新打开父级弹框
    //         if (parent_popup && typeof parent_popup.hideParent === 'function') {
    //             parent_popup.hideParent()
    //         }
    //         // 略微延迟以确保父弹框状态切换完成
    //         setTimeout(() => open_inner_popup(), 50)
    //     }
    //     return
    // }
    // 直接处理点击事件
    handle_click(item)
}

/**
 * @function handle_click
 * @description 点击列表项时触发:
 * 1) 若为内弹框任务项(含 begin_date/end_date),提示并关闭弹框;
 * 2) 否则按打卡项逻辑处理选择/跳转。
 * @param {Object} item - 列表项对象。
 * @returns {void}
 */
const handle_click = (item) => {
    // 打卡项:已完成提示
    if (item.is_gray && item.task_type === 'checkin') {
        showToast('您已经完成了今天的打卡')
        return
    }
    // 打卡项:上传型跳转
    if (item.task_type === 'upload') {
        router.push({
            path: '/checkin/index',
            query: { id: item.id },
        })
        return
    }
    // 打卡项:选中后展示提交按钮
    selected_item.value = item
}

/**
 * @function handle_submit
 * @description 提交打卡调用接口,成功后抛出事件并复位。
 * @returns {Promise<void>}
 */
const handle_submit = async () => {
    if (!selected_item.value) {
        showToast('请选择打卡项目')
        return
    }
    submitting.value = true
    try {
        const { code } = await checkinTaskAPI({ task_id: selected_item.value.id })
        if (code) {
            emit('submit-success')
            showToast('打卡成功')
            selected_item.value = null
        }
    } catch (e) {
        // showToast('打卡失败,请重试')
    } finally {
        submitting.value = false
    }
}

/**
 * @function open_inner_popup
 * @description 打开二级弹框并填充示例任务列表(本地构造)。
 * @returns {void}
 */
const open_inner_popup = () => {
    inner_courses.value = build_course_list()
    inner_popup_show.value = true
}

/**
 * @function close_inner_popup
 * @description 关闭二级弹框;若处于弹框模式则重新打开父级弹框。
 * @returns {void}
 */
const close_inner_popup = () => {
    inner_popup_show.value = false
    if (!props.plain && parent_popup && typeof parent_popup.reopenParent === 'function') {
        // 略微延迟,避免与二级弹框关闭动画冲突
        setTimeout(() => parent_popup.reopenParent(), 150)
    }
}

/**
 * @function build_course_list
 * @description 构造示例任务数据(仅标题与开始/结束日期)。
 * @returns {Array<{id:string,title:string,begin_date:string,end_date?:string}>}
 */
const build_course_list = () => {
    const now = new Date()
    /**
     * @function add_days
     * @description 在当前日期基础上增加指定天数。
     * @param {number} days - 增加的天数(可为负数)。
     * @returns {Date}
     */
    const add_days = (days) => {
        const d = new Date(now)
        d.setDate(d.getDate() + days)
        return d
    }
    /**
     * @function to_yyyy_mm_dd
     * @description 格式化日期为 YYYY-MM-DD 字符串。
     * @param {Date} date - 日期对象。
     * @returns {string}
     */
    const to_yyyy_mm_dd = (date) => {
        const y = date.getFullYear()
        const m = String(date.getMonth() + 1).padStart(2, '0')
        const d = String(date.getDate()).padStart(2, '0')
        return `${y}-${m}-${d}`
    }
    return [
        { id: 'task-1', title: '课程打卡任务一', begin_date: to_yyyy_mm_dd(add_days(-2)), end_date: to_yyyy_mm_dd(add_days(5)) },
        { id: 'task-2', title: '课程打卡任务二', begin_date: to_yyyy_mm_dd(add_days(-1)), end_date: to_yyyy_mm_dd(add_days(6)) },
        { id: 'task-3', title: '课程打卡任务三', begin_date: to_yyyy_mm_dd(add_days(0)), end_date: to_yyyy_mm_dd(add_days(7)) },
        { id: 'task-4', title: '课程打卡任务四', begin_date: to_yyyy_mm_dd(add_days(1)) },
    ]
}

// 该组件当前不展示封面图片,移除不再使用的图片处理方法
</script>

<style lang="less" scoped>
.CheckInListWrapper {
    // 列表项样式
    .CheckInListItem {
        // 选中态样式
        &.is-active {
            border-color: #bbf7d0; // 绿色边框
            background-color: rgba(16, 185, 129, 0.1); // 轻微绿色背景
        }

        // 图标样式
        .Icon {
            &.is-active {
                background-color: #10b981; // 绿色激活背景
                color: #ffffff; // 白色图标
            }
        }
    }

    // 提交按钮样式
    .SubmitBtn {
        &:disabled {
            opacity: 0.7; // 禁用态透明度
        }
    }
}
</style>