hookehuyr

feat(打卡): 添加感恩对象管理功能

- 新增CheckinTargetList组件用于展示和管理感恩对象
- 实现感恩对象的添加、编辑、删除和选择功能
- 在打卡详情页集成感恩对象管理组件
- 更新API接口支持感恩对象相关操作
- 优化动态表单字段处理逻辑
/*
* @Date: 2025-06-06 09:26:16
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-12-13 21:13:42
* @LastEditTime: 2025-12-16 11:52:53
* @FilePath: /mlaj/src/api/checkin.js
* @Description: 签到模块相关接口
*/
......@@ -45,7 +45,18 @@ export const getTaskDetailAPI = (params) => fn(fetch.get(Api.GET_TASK_DETAIL, p
* @description: 小作业列表
* @param task_id 大作业ID
* @param date 日期(用来判断是否可以补卡)
* @returns data: [{id 作业id,title 作业名称 ,cycle 作业周期 [0=本周期 | 30=每月 | 7=每周 | 1=每日],frequency 交作业的频次,attachment_type 上传附件的类型 [text=文本 image=图片 video=视频 audio=音频],begin_date 开始时间,end_date 结束时间,is_finish 作业在当前周期是否已经达标, is_gray 作业是否应该置灰, is_makeup 是否可以补卡}]
* @returns data: [{
* id 作业id,
* title 作业名称 ,
* cycle 作业周期 [0=本周期 | 30=每月 | 7=每周 | 1=每日],
* frequency 交作业的频次,attachment_type 上传附件的类型 [text=文本 image=图片 video=视频 audio=音频],
* begin_date 开始时间,
* end_date 结束时间,
* is_finish 作业在当前周期是否已经达标,
* is_gray 作业是否应该置灰,
* is_makeup 是否可以补卡
* field_list 动态表单字段列表 [{field_name,label,type}]
* }]
*/
export const getSubtaskListAPI = (params) => fn(fetch.get(Api.GET_SUBTASK_LIST, params))
......@@ -63,6 +74,8 @@ export const checkinTaskAPI = (params) => fn(fetch.post(Api.TASK_CHECKIN, param
* @param meta_id[] 附件ID列表
* @param file_type 上传附件的类型 image=上传图片,video=视频,audio=音频
* @param makeup_time 补卡时间
* @param gratitude_count 感恩次数
* @param gratitude_people_ids 感恩对象ID数组 [id1,id2,id3]
* @returns
*/
export const addUploadTaskAPI = (params) => fn(fetch.post(Api.TASK_UPLOAD_ADD, params))
......@@ -79,7 +92,10 @@ export const addUploadTaskAPI = (params) => fn(fetch.post(Api.TASK_UPLOAD_ADD,
* @returns data: [{id 打卡动态ID, status 审批状态 3=待审批,5=审批通过,7=审批不通过, created_by 打卡人ID, username 打卡人昵称
* avatar 打卡人头像, created_time 打卡时间, created_time_desc 打卡时间描述, note 打卡内容, files[{meta_id,name,value,extension}] 附件列表,
* file_type 上传附件的类型 image=上传图片,video=视频,audio=音频, like_count 点赞数, is_my 是不是我的打卡, is_like 我是否已经点赞, is_makeup 是否补卡
* subtask_title 小作业标题}]
* subtask_title 小作业标题
* gratitude_count 感恩次数
* gratitude_people 感恩对象列表 [{id,name,city,unit}]
* }]
*/
export const getUploadTaskListAPI = (params) => fn(fetch.post(Api.TASK_UPLOAD_LIST, params))
......@@ -88,7 +104,10 @@ export const getUploadTaskListAPI = (params) => fn(fetch.post(Api.TASK_UPLOAD_L
* @param i 打卡动态ID
* @returns data: {id 打卡动态ID, subtask_id 小作业ID, status 审批状态 3=待审批,5=审批通过,7=审批不通过, created_by 打卡人ID, username 打卡人昵称
* avatar 打卡人头像, created_time 打卡时间, created_time_desc 打卡时间描述, note 打卡内容, files[{meta_id,name,value,extension}] 附件列表,
* file_type 上传附件的类型 image=上传图片,video=视频,audio=音频, like_count 点赞数, is_my 是不是我的打卡, is_like 我是否已经点赞, is_makeup 是否补卡}
* file_type 上传附件的类型 image=上传图片,video=视频,audio=音频, like_count 点赞数, is_my 是不是我的打卡, is_like 我是否已经点赞, is_makeup 是否补卡
* gratitude_count 感恩次数
* gratitude_people 感恩对象列表 [{id,name,city,unit}]
* }
*/
export const getUploadTaskInfoAPI = (params) => fn(fetch.get(Api.TASK_UPLOAD_INFO, params))
......@@ -98,6 +117,8 @@ export const getUploadTaskInfoAPI = (params) => fn(fetch.get(Api.TASK_UPLOAD_IN
* @param note 打卡文字
* @param meta_id[] 附件ID列表
* @param file_type 上传附件的类型 image=上传图片,video=视频,audio=音频
* @param gratitude_count 感恩次数
* @param gratitude_people_ids 感恩对象ID数组 [id1,id2,id3]
* @returns
*/
export const editUploadTaskInfoAPI = (params) => fn(fetch.get(Api.TASK_UPLOAD_EDIT, params))
......
/*
* @Date: 2025-06-06 09:26:16
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-12-16 13:59:55
* @FilePath: /mlaj/src/api/gratitude.js
* @Description: 计数模块相关接口
*/
import { fn, fetch } from './fn'
const Api = {
GET_GRATITUDE_LIST: '/srv/?a=gratitude_people&t=list',
POST_GRATITUDE_ADD: '/srv/?a=gratitude_people&t=add',
POST_GRATITUDE_EDIT: '/srv/?a=gratitude_people&t=edit',
POST_GRATITUDE_DEL: '/srv/?a=gratitude_people&t=del',
}
/**
* @description: 计数对象列表
* @param: person_type 计数对象类型 teacher=老师, doctor=医生, parent=父母, sage=圣贤
* @return: data.gratitude_people [{ id 计数对象ID, name 计数对象名称, city 地址, unit 单位 }]
*/
export const getGratitudeListAPI = (params) => fn(fetch.get(Api.GET_GRATITUDE_LIST, params))
/**
* @description: 添加感恩对象
* @param person_type 计数对象类型 teacher=老师, doctor=医生, parent=父母, sage=圣贤
* 下面的值应该是动态字段绑定的值, 通过field_list获取里面的field_name, 再根据field_name绑定值
* @param name 感恩对象名称
* @param city 地址
* @param unit 单位
* @returns
*/
export const gratitudeAddAPI = (params) => fn(fetch.post(Api.POST_GRATITUDE_ADD, params))
/**
* @description: 编辑感恩对象
* @param id 感恩对象ID
* 下面的值应该是动态字段绑定的值, 通过field_list获取里面的field_name, 再根据field_name绑定值
* @param name 感恩对象名称
* @param city 地址
* @param unit 单位
* @returns
*/
export const gratitudeEditAPI = (params) => fn(fetch.post(Api.POST_GRATITUDE_EDIT, params))
/**
* @description: 删除感恩对象
* @param id 感恩对象ID
* @returns
*/
export const gratitudeDelAPI = (params) => fn(fetch.post(Api.POST_GRATITUDE_DEL, params))
......@@ -16,6 +16,7 @@ declare module 'vue' {
CheckinCard: typeof import('./components/checkin/CheckinCard.vue')['default']
CheckInDialog: typeof import('./components/ui/CheckInDialog.vue')['default']
CheckInList: typeof import('./components/ui/CheckInList.vue')['default']
CheckinTargetList: typeof import('./components/count/CheckinTargetList.vue')['default']
CollapsibleCalendar: typeof import('./components/ui/CollapsibleCalendar.vue')['default']
ConfirmDialog: typeof import('./components/ui/ConfirmDialog.vue')['default']
CourseCard: typeof import('./components/ui/CourseCard.vue')['default']
......
......@@ -52,6 +52,13 @@ const props = defineProps({
fields: {
type: Array,
required: true
},
/**
* 初始数据(用于编辑回显)
*/
initialValues: {
type: Object,
default: () => ({})
}
})
......@@ -60,16 +67,16 @@ const emit = defineEmits(['update:show', 'confirm'])
// 本地表单字段状态
const localFields = ref([])
// 监听弹窗显示状态,初始化表单
watch(() => props.show, (val) => {
if (val) {
// 监听弹窗显示状态和字段配置变化,初始化表单
watch([() => props.show, () => props.fields], ([showVal, fieldsVal]) => {
if (showVal) {
// 初始化字段,添加 value 属性
localFields.value = props.fields.map(field => ({
localFields.value = fieldsVal.map(field => ({
...field,
value: ''
value: (props.initialValues && props.initialValues[field.id]) || ''
}))
}
})
}, { immediate: true, deep: true })
/**
* 更新弹窗显示状态
......
<!--
* @Date: 2025-12-16 11:44:27
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-12-16 13:53:45
* @FilePath: /mlaj/src/components/count/CheckinTargetList.vue
* @Description: 打卡动态对象列表组件
-->
<template>
<div class="mb-4">
<div class="flex justify-between items-center mb-2 mx-2">
<div class="flex items-center gap-2">
<div class="text-sm font-bold text-gray-700">{{ dynamicFieldText }}对象</div>
<div class="text-xs text-gray-400 font-normal scale-90 origin-left">(长按可编辑/删除)</div>
</div>
<van-button size="small" type="primary" plain icon="plus" @click="onAdd" class="!h-7">添加</van-button>
</div>
<div class="bg-gray-50 rounded-lg p-2">
<div class="flex flex-wrap gap-2">
<template v-if="targetList.length > 0">
<div v-for="(item, index) in targetList" :key="index"
class="px-4 py-1.5 rounded-full text-sm transition-colors duration-200 border cursor-pointer select-none relative"
:style="selectedTargets.some(t => t.id === item.id) ? {
backgroundColor: '#4caf50',
color: '#ffffff',
borderColor: '#4caf50'
} : {
backgroundColor: '#ffffff',
color: '#4b5563',
borderColor: '#e5e7eb'
}"
@click="onClick(item)"
@touchstart="onTouchStart(item)"
@touchend="onTouchEnd"
@touchmove="onTouchMove"
@mousedown="onMouseDown(item)"
@mouseup="onMouseUp"
@mouseleave="onMouseUp"
>
{{ item.name }}
</div>
</template>
<div v-else class="w-full text-center py-4 text-gray-400 text-sm">
暂无{{ dynamicFieldText }}对象,请点击上方添加按钮
</div>
</div>
</div>
<!-- 操作菜单 -->
<van-action-sheet
v-model:show="showActionSheet"
:actions="actions"
cancel-text="取消"
close-on-click-action
@select="onSelectAction"
/>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { showConfirmDialog } from 'vant'
/**
* 计数对象列表组件
* @description 展示可供选择的计数对象列表,支持选择和添加
*/
const props = defineProps({
/**
* 动态字段文本 (e.g. "感恩", "念佛")
*/
dynamicFieldText: {
type: String,
default: '计数'
},
/**
* 所有可用的对象列表
*/
targetList: {
type: Array,
default: () => []
},
/**
* 当前选中的对象列表
*/
selectedTargets: {
type: Array,
default: () => []
}
})
const emit = defineEmits(['add', 'toggle', 'edit', 'delete'])
/**
* 点击添加按钮
*/
const onAdd = () => {
emit('add')
}
// 长按相关逻辑
const longPressTimer = ref(null)
const isLongPress = ref(false)
const showActionSheet = ref(false)
const currentItem = ref(null)
const actions = [
{ name: '编辑', color: '#1989fa', action: 'edit' },
{ name: '删除', color: '#ee0a24', action: 'delete' }
]
const startLongPress = (item) => {
isLongPress.value = false
longPressTimer.value = setTimeout(() => {
isLongPress.value = true
currentItem.value = item
showActionSheet.value = true
// 震动反馈 (如果设备支持)
if (navigator.vibrate) {
navigator.vibrate(50)
}
}, 500)
}
const clearLongPress = () => {
if (longPressTimer.value) {
clearTimeout(longPressTimer.value)
longPressTimer.value = null
}
}
// Touch events
const onTouchStart = (item) => {
startLongPress(item)
}
const onTouchEnd = () => {
clearLongPress()
}
const onTouchMove = () => {
clearLongPress()
}
// Mouse events (for PC debugging)
const onMouseDown = (item) => {
startLongPress(item)
}
const onMouseUp = () => {
clearLongPress()
}
/**
* 点击项
* @param {Object} item
*/
const onClick = (item) => {
// 如果是长按触发的结束,不执行点击
if (isLongPress.value) {
// 重置状态
setTimeout(() => {
isLongPress.value = false
}, 0)
return
}
emit('toggle', item)
}
/**
* 选中操作
*/
const onSelectAction = (action) => {
if (action.action === 'edit') {
emit('edit', currentItem.value)
} else if (action.action === 'delete') {
confirmDelete()
}
}
// 删除相关
const confirmDelete = () => {
if (!currentItem.value) return
showConfirmDialog({
title: '确认删除',
message: `确定要删除"${currentItem.value.name}"吗?`,
})
.then(() => {
emit('delete', currentItem.value)
})
.catch(() => {
// on cancel
})
}
</script>
......@@ -37,36 +37,16 @@
</template>
</div>
<!-- 计数对象 -->
<div v-if="taskType === 'count' && selectedTaskValue && selectedTaskValue.length > 0" class="mb-4">
<div class="flex justify-between items-center mb-2 mx-2">
<div class="text-sm font-bold text-gray-700">{{ dynamicFieldText }}对象</div>
<van-button size="small" type="primary" plain icon="plus"
@click="openAddTargetDialog" class="!h-7">添加</van-button>
</div>
<div class="bg-gray-50 rounded-lg p-2">
<div class="flex flex-wrap gap-2">
<template v-if="targetList.length > 0">
<div v-for="(item, index) in targetList" :key="index"
class="px-4 py-1.5 rounded-full text-sm transition-colors duration-200 border cursor-pointer select-none"
:style="selectedTargets.some(t => t.name === item.name) ? {
backgroundColor: '#4caf50',
color: '#ffffff',
borderColor: '#4caf50'
} : {
backgroundColor: '#ffffff',
color: '#4b5563',
borderColor: '#e5e7eb'
}" @click="toggleTarget(item)">
{{ item.name }}
</div>
</template>
<div v-else class="w-full text-center py-4 text-gray-400 text-sm">
暂无{{ dynamicFieldText }}对象,请点击上方添加按钮
</div>
</div>
</div>
</div>
<CheckinTargetList
v-if="taskType === 'count' && selectedTaskValue && selectedTaskValue.length > 0"
:dynamic-field-text="dynamicFieldText"
:target-list="targetList"
:selected-targets="selectedTargets"
@add="openAddTargetDialog"
@toggle="toggleTarget"
@edit="handleTargetEdit"
@delete="handleTargetDelete"
/>
<!-- 计数次数 -->
<div v-if="taskType === 'count'"
......@@ -78,8 +58,9 @@
<!-- 新增计数对象弹框 -->
<AddTargetDialog
v-model:show="showAddTargetDialog"
:title="`添加${dynamicFieldText}对象`"
:title="editingTarget ? `编辑${dynamicFieldText}对象` : `添加${dynamicFieldText}对象`"
:fields="dynamicFormFields"
:initial-values="editingTarget"
@confirm="confirmAddTarget"
/>
......@@ -195,11 +176,13 @@ import { ref, computed, onMounted, nextTick, reactive, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getTaskDetailAPI, getUploadTaskInfoAPI, getSubtaskListAPI } from "@/api/checkin"
import { getTeacherFindSettingsAPI } from '@/api/teacher'
import { getGratitudeListAPI, gratitudeAddAPI, gratitudeEditAPI, gratitudeDelAPI } from '@/api/gratitude'
import { useTitle } from '@vueuse/core'
import { useCheckin } from '@/composables/useCheckin'
import AudioPlayer from '@/components/ui/AudioPlayer.vue'
import VideoPlayer from '@/components/ui/VideoPlayer.vue'
import AddTargetDialog from '@/components/count/AddTargetDialog.vue'
import CheckinTargetList from '@/components/count/CheckinTargetList.vue'
import { showToast, showLoadingToast } from 'vant'
import dayjs from 'dayjs'
......@@ -242,33 +225,30 @@ const taskType = computed(() => route.query.task_type)
const showTaskPicker = ref(false)
const taskOptions = ref([])
// TODO: 模拟任务选项数据, 不同作业有不同的计数对象, 这里根据作业ID模拟不同的计数对象
const mockData = {
'task1': [
{ name: '张老师', city: '北京', school: '北京大学' },
{ name: '李老师', city: '上海', school: '复旦大学' }
],
'task2': [
{ name: '王老师', city: '广州', school: '中山大学' },
{ name: '赵老师', city: '深圳', school: '深圳大学' }
],
'task3': [
{ name: '孙老师', city: '杭州', school: '浙江大学' }
]
}
const fetchTargetList = async (person_type) => {
const { code, data } = await getGratitudeListAPI({ person_type })
if (code) {
targetList.value = data.gratitude_people || []
}
const fetchTargetList = async (subTaskId) => {
// 模拟接口调用延迟
setTimeout(() => {
targetList.value = mockData[subTaskId] || []
showLoadingToast({
message: '加载成功',
type: 'success',
duration: 1000
})
}, 500)
// TODO: 暂时mock数据
targetList.value = [{
id: '1',
name: '张三',
city: '北京',
unit: '公司'
}, {
id: '2',
name: '李四',
city: '上海',
unit: '公司'
}]
}
// 动态表单字段 (默认值,实际会根据选择的作业动态更新)
const dynamicFormFields = ref([])
const personType = ref('') // 动态表单字段中的person_type
// 确认作业选择
const onConfirmTask = ({ selectedOptions }) => {
const option = selectedOptions[0]
......@@ -277,9 +257,44 @@ const onConfirmTask = ({ selectedOptions }) => {
isMakeup.value = !!option.is_makeup
showTaskPicker.value = false
// 动态表单字段映射
if (option.field_list && Array.isArray(option.field_list)) {
dynamicFormFields.value = option.field_list.map(field => ({
id: field.field_name,
label: field.label,
type: 'text', // 默认类型,如果后端有类型字段可替换
required: true // 默认必填,如果后端有必填字段可替换
}))
// 确保如果有city字段,类型为textarea
const cityField = dynamicFormFields.value.find(f => f.id === 'city')
if (cityField) {
cityField.type = 'textarea'
}
// 确保如果有unit字段,类型为textarea
const unitField = dynamicFormFields.value.find(f => f.id === 'unit')
if (unitField) {
unitField.type = 'textarea'
}
} else {
// 如果没有配置字段,使用默认字段 (兼容旧数据或Mock)
dynamicFormFields.value = [
{ id: 'name', label: '姓名', type: 'text', required: true },
{ id: 'city', label: '城市', type: 'textarea', required: true },
{ id: 'unit', label: '单位', type: 'textarea', required: true },
]
}
// TODO: 暂时mock数据
dynamicFormFields.value = [
{ id: 'name', label: '姓名', type: 'text', required: true },
{ id: 'city', label: '城市', type: 'textarea', required: true },
{ id: 'unit', label: '单位', type: 'textarea', required: true },
]
// 如果是计数打卡,根据选中的作业ID查询计数对象
if (taskType.value === 'count') {
fetchTargetList(option.value)
fetchTargetList(option.person_type)
personType.value = option.person_type
}
}
......@@ -291,22 +306,13 @@ const onConfirmTask = ({ selectedOptions }) => {
// }
// })
/********* TODO: *******/
// 计数打卡相关逻辑
const countValue = ref(1)
const selectedTargets = ref([])
// Mock 老师数据
const targetList = ref([])
const showAddTargetDialog = ref(false)
// TODO: 动态表单字段 Mock 数据
const dynamicFormFields = ref([
{ id: 'name', label: '姓名', type: 'text', required: true },
{ id: 'city', label: '城市', type: 'text', required: true },
{ id: 'school', label: '单位', type: 'text', required: true },
{ id: 'remark', label: '备注备注备注', type: 'textarea', required: true } // 新增字段
])
const editingTarget = ref(null)
const toggleTarget = (item) => {
const index = selectedTargets.value.findIndex(t => t.name === item.name)
......@@ -318,25 +324,84 @@ const toggleTarget = (item) => {
}
const openAddTargetDialog = () => {
editingTarget.value = null; // 重置编辑对象
showAddTargetDialog.value = true;
}
/**
* 确认添加对象
* 确认添加/编辑对象
* @param {Object} formData - 表单数据
*/
const confirmAddTarget = (formData) => {
console.log(`新增${dynamicFieldText.value}对象信息:`, formData)
// TODO: 这里根据实际情况调整动态字段的处理逻辑, 暂时没有正式数据, 要等一等.
// 添加到列表(适配原有的数据结构)
targetList.value.push({
name: formData.name,
city: formData.city,
school: formData.school
// 其他字段...
})
const confirmAddTarget = async (formData) => {
console.log(`${editingTarget.value ? '编辑' : '新增'}${dynamicFieldText.value}对象信息:`, formData)
if (editingTarget.value) {
// 编辑模式
const index = targetList.value.findIndex(t => t === editingTarget.value)
if (index > -1) {
const { code } = await gratitudeEditAPI({ ...editingTarget.value })
if (code) {
// 更新对象
targetList.value[index] = { ...targetList.value[index], ...formData }
// 如果在选中列表中,也需要更新
const selectedIndex = selectedTargets.value.findIndex(t => t.id === editingTarget.value.id)
if (selectedIndex > -1) {
selectedTargets.value[selectedIndex] = { ...selectedTargets.value[selectedIndex], ...formData }
}
}
}
showToast('修改成功')
} else {
// 新增模式
try {
const res = await gratitudeAddAPI({
...formData,
person_type: personType.value
})
if (res.code) {
// 新增成功,更新本地列表
targetList.value.push({
...formData,
})
showToast('新增成功')
}
} catch (error) {
showToast(`新增失败:${error.message || '未知错误'}`)
}
}
showAddTargetDialog.value = false;
}
/**
* 处理对象编辑
*/
const handleTargetEdit = (item) => {
editingTarget.value = item
showAddTargetDialog.value = true
}
/**
* 处理对象删除
*/
const handleTargetDelete = async (item) => {
const { code } = await gratitudeDeleteAPI({ id: item.id })
if (code) {
// 删除成功,更新本地列表
const targetIndex = targetList.value.findIndex(t => t.id === item.id)
if (targetIndex > -1) {
targetList.value.splice(targetIndex, 1)
}
// 从选中列表中也删除
const selectedIndex = selectedTargets.value.findIndex(t => t.id === item.id)
if (selectedIndex > -1) {
selectedTargets.value.splice(selectedIndex, 1)
}
showToast('删除成功')
}
}
/**
......@@ -388,7 +453,7 @@ const handleSubmit = async () => {
}
// 传递额外数据
extraData.targets = selectedTargets.value.map(t => t.name) // 假设传 name
extraData.targets = selectedTargets.value.map(t => t.id)
extraData.count = countValue.value
}
......@@ -825,6 +890,8 @@ onMounted(async () => {
text: item.is_makeup ? '补卡:' + item.title : item.title,
value: item.id,
is_makeup: item.is_makeup, // 是否为补录
field_list: item.field_list || [], // 动态字段列表
person_type: item.person_type || '', // 打卡对象类型
}))
]
}
......@@ -839,7 +906,8 @@ onMounted(async () => {
// 如果是计数打卡,根据选中的作业ID查询计数对象
if (taskType.value === 'count') {
fetchTargetList(option.value)
fetchTargetList(option.person_type)
personType.value = option.person_type
}
}
......
<!--
* @Date: 2025-05-29 15:34:17
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-12-15 14:46:49
* @LastEditTime: 2025-12-16 13:54:46
* @FilePath: /mlaj/src/views/checkin/IndexCheckInPage.vue
* @Description: 文件描述
-->
......@@ -398,6 +398,7 @@ const goToCheckinDetailPage = () => {
path: '/checkin/detail',
query: {
post_id: route.query.id,
task_id: route.query.id,
subtask_id: selectedSubtaskId.value,
date: current_date,
is_patch: isPatchCheckin.value ? '1' : '0',
......