选课调整

This commit is contained in:
hebo 2025-09-19 09:47:19 +08:00
parent f51548a299
commit bf543d3929
24 changed files with 3913 additions and 863 deletions

View File

@ -158,3 +158,9 @@ const setDefaultValue = () => {

View File

@ -16,17 +16,17 @@
<view class="selector-popup">
<!-- 头部 -->
<view class="popup-header">
<text class="cancel-btn" @click="closeSelector">取消</text>
<text class="popup-title">{{ title }}</text>
<view class="popup-actions">
<text class="action-btn cancel-btn" @click="closeSelector">取消</text>
<text class="action-btn confirm-btn" @click="confirmSelection">确定</text>
</view>
<text class="confirm-btn" @click="confirmSelection">确定</text>
</view>
<!-- 树形选择器内容 -->
<view class="popup-content">
<scroll-view scroll-y class="tree-scroll" :style="{ height: 'calc(80vh - 80px)' }">
<checkbox-group v-if="multiple" @change="onGradeCheckboxChange">
<scroll-view scroll-y class="tree-scroll">
<view v-if="isLoading" class="loading-indicator">加载中...</view>
<checkbox-group v-else-if="multiple" @change="onGradeCheckboxChange">
<view class="tree-container">
<view
v-for="(nj, njIndex) in njList"
@ -49,17 +49,10 @@
</view>
<text class="grade-text">{{ nj.title }}</text>
<checkbox
v-if="multiple"
:value="nj.key"
:checked="isGradeSelected(nj)"
class="grade-checkbox"
/>
<radio
v-else
:value="nj.key"
:checked="isGradeSelected(nj)"
class="grade-radio"
/>
</view>
</view>
@ -68,7 +61,7 @@
v-if="nj.expanded"
class="class-children"
>
<checkbox-group v-if="multiple" @change="(event: any) => onClassCheckboxChange(nj, event)">
<checkbox-group @change="(event: any) => onClassCheckboxChange(nj, event)">
<view
v-for="(bj, bjIndex) in nj.children"
:key="bj.key"
@ -146,6 +139,8 @@
</view>
</view>
</radio-group>
<view v-if="!isLoading && njList.length === 0" class="empty-state">暂无数据</view>
</scroll-view>
</view>
</view>
@ -258,6 +253,14 @@ const displayText = computed(() => {
return ''
})
//
const getSelectedCount = () => {
if (!props.modelValue) return 0
const gradeCount = props.modelValue.selectedGrades?.length || 0
const classCount = props.modelValue.selectedClasses?.length || 0
return gradeCount + classCount
}
// modelValue
watch(() => props.modelValue, (newVal) => {
if (newVal) {
@ -522,36 +525,31 @@ onMounted(() => {
flex-direction: column;
.popup-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 15px 20px;
padding: 10px 15px;
background-color: #fff;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
.cancel-btn {
font-size: 14px;
color: #666;
padding: 4px 8px;
}
.popup-title {
font-size: 16px;
font-weight: 600;
font-weight: bold;
color: #333;
}
.popup-actions {
display: flex;
gap: 20px;
.action-btn {
font-size: 14px;
padding: 4px 8px;
&.cancel-btn {
color: #999;
}
&.confirm-btn {
color: #1890ff;
font-weight: 500;
}
}
.confirm-btn {
font-size: 14px;
color: #409eff;
font-weight: 500;
padding: 4px 8px;
}
}
@ -564,7 +562,8 @@ onMounted(() => {
.tree-scroll {
flex: 1;
background-color: #fff;
min-height: 0;
box-sizing: border-box;
overflow-y: auto;
.tree-container {
padding: 0;
@ -572,6 +571,10 @@ onMounted(() => {
.tree-node {
.tree-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 15px;
border-bottom: 1px solid #f0f0f0;
&:last-child {
@ -587,13 +590,13 @@ onMounted(() => {
border-bottom: 1px solid #e0e0e0;
.tree-item-content {
padding: 5px 12px;
display: flex;
align-items: center;
min-height: 30px;
flex-grow: 1;
margin-right: 15px;
.expand-icon {
margin-right: 5px;
margin-right: 10px;
display: flex;
align-items: center;
justify-content: center;
@ -603,13 +606,13 @@ onMounted(() => {
.grade-text {
flex: 1;
font-size: 14px;
font-weight: 500;
font-size: 15px;
color: #333;
}
.grade-checkbox {
margin-left: 5px;
.grade-checkbox,
.grade-radio {
flex-shrink: 0;
}
}
}
@ -618,26 +621,27 @@ onMounted(() => {
background-color: #fff;
.tree-item-content {
padding: 3px 12px 3px 26px;
display: flex;
align-items: center;
min-height: 26px;
flex-grow: 1;
margin-right: 15px;
.class-indent {
width: 10px;
height: 1px;
background-color: #e0e0e0;
margin-right: 5px;
margin-right: 10px;
}
.class-text {
flex: 1;
font-size: 13px;
color: #666;
font-size: 15px;
color: #333;
}
.class-checkbox {
margin-left: 5px;
.class-checkbox,
.class-radio {
flex-shrink: 0;
}
}
}
@ -659,8 +663,7 @@ onMounted(() => {
}
.tree-item-content {
padding: 2px 12px 2px 26px;
min-height: 24px;
padding: 8px 15px 8px 26px;
}
}
}
@ -668,6 +671,15 @@ onMounted(() => {
}
}
}
}
.loading-indicator,
.empty-state {
text-align: center;
color: #999;
padding: 30px 15px;
font-size: 14px;
}
}
</style>

View File

@ -0,0 +1,845 @@
<template>
<view class="image-video-upload">
<!-- 图片上传区域 -->
<view class="upload-section" v-if="enableImage">
<view class="section-title">
<text class="title-text">图片</text>
<text class="count-text">({{ imageList.length }}/{{ maxImageCount }})</text>
</view>
<view class="image-list">
<view
class="image-item"
v-for="(image, index) in imageList"
:key="index"
>
<image
:src="image.url || image.tempPath"
class="image-preview"
@click="previewImage(index)"
mode="aspectFill"
/>
<view class="image-actions">
<view class="delete-btn" @click="removeImage(index)">
<uni-icons type="close" size="16" color="#fff"></uni-icons>
</view>
</view>
<view class="image-status" v-if="image.isCompressed">
<text class="compressed-tag">已压缩</text>
</view>
</view>
<view
class="add-btn"
v-if="imageList.length < maxImageCount"
@click="chooseImage"
>
<uni-icons type="camera" size="24" color="#999"></uni-icons>
<text class="add-text">添加图片</text>
</view>
</view>
</view>
<!-- 视频上传区域 -->
<view class="upload-section" v-if="enableVideo">
<view class="section-title">
<text class="title-text">视频</text>
<text class="count-text">({{ videoList.length }}/{{ maxVideoCount }})</text>
</view>
<view class="video-list">
<view
class="video-item"
v-for="(video, index) in videoList"
:key="index"
>
<view class="video-preview" @click="previewVideo(index)">
<image
:src="video.thumbnail || '/static/icons/video-placeholder.png'"
class="video-thumbnail"
mode="aspectFill"
/>
<view class="play-icon">
<uni-icons type="play-filled" size="24" color="#fff"></uni-icons>
</view>
<view class="video-duration" v-if="video.duration">
{{ formatDuration(video.duration) }}
</view>
</view>
<view class="video-actions">
<view class="delete-btn" @click="removeVideo(index)">
<uni-icons type="close" size="16" color="#fff"></uni-icons>
</view>
</view>
<view class="video-info">
<text class="video-name">{{ video.name }}</text>
<text class="video-size" v-if="video.size">{{ formatFileSize(video.size) }}</text>
</view>
</view>
<view
class="add-btn"
v-if="videoList.length < maxVideoCount"
@click="chooseVideo"
>
<uni-icons type="videocam" size="24" color="#999"></uni-icons>
<text class="add-text">添加视频</text>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
//
interface ImageItem {
tempPath?: string
url?: string
name?: string
originalPath?: string
isCompressed?: boolean
}
interface VideoItem {
tempPath?: string
url?: string
name?: string
duration?: number
size?: number
thumbnail?: string
}
// Props
interface Props {
//
enableImage?: boolean
maxImageCount?: number
imageList?: ImageItem[]
//
enableVideo?: boolean
maxVideoCount?: number
videoList?: VideoItem[]
//
compressConfig?: {
image: {
quality: number
maxWidth: number
maxHeight: number
maxSize: number
minQuality: number
}
video: {
maxDuration: number
maxSize: number
quality: string
}
}
//
autoUpload?: boolean
uploadApi?: (file: any) => Promise<any>
}
const props = withDefaults(defineProps<Props>(), {
enableImage: true,
maxImageCount: 5,
imageList: () => [],
enableVideo: true,
maxVideoCount: 3,
videoList: () => [],
compressConfig: () => ({
image: {
quality: 60,
maxWidth: 1280,
maxHeight: 720,
maxSize: 200 * 1024, // 200KB
minQuality: 20
},
video: {
maxDuration: 60,
maxSize: 10 * 1024 * 1024, // 10MB
quality: 'medium'
}
}),
autoUpload: true,
uploadApi: undefined
})
// Emits
const emit = defineEmits<{
'update:imageList': [images: ImageItem[]]
'update:videoList': [videos: VideoItem[]]
'image-upload-success': [image: ImageItem, index: number]
'image-upload-error': [error: any, index: number]
'video-upload-success': [video: VideoItem, index: number]
'video-upload-error': [error: any, index: number]
'upload-progress': [type: 'image' | 'video', current: number, total: number]
}>()
//
const imageList = ref<ImageItem[]>([...props.imageList])
const videoList = ref<VideoItem[]>([...props.videoList])
// props
watch(() => props.imageList, (newList) => {
imageList.value = [...newList]
}, { deep: true })
watch(() => props.videoList, (newList) => {
videoList.value = [...newList]
}, { deep: true })
//
const COMPRESS_CONFIG = computed(() => props.compressConfig)
//
const getFileInfo = (filePath: string): Promise<any> => {
return new Promise((resolve, reject) => {
uni.getFileInfo({
filePath: filePath,
success: resolve,
fail: reject
})
})
}
// - H5APP
const compressImage = (src: string, quality: number = COMPRESS_CONFIG.value.image.quality): Promise<string> => {
return new Promise((resolve, reject) => {
// H5
// @ts-ignore
if (typeof window !== 'undefined' && window.uni && window.uni.compressImage) {
// APP使uni.compressImage
uni.compressImage({
src: src,
quality: quality,
success: (res) => {
resolve(res.tempFilePath)
},
fail: (error) => {
reject(error)
}
})
} else {
// H5使Canvas
compressImageWithCanvas(src, quality).then(resolve).catch(reject)
}
})
}
// H5Canvas
const compressImageWithCanvas = (src: string, quality: number): Promise<string> => {
return new Promise((resolve, reject) => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => {
try {
//
let { width, height } = calculateCompressedSize(img.width, img.height)
// Canvas
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) {
reject(new Error('无法获取Canvas上下文'))
return
}
canvas.width = width
canvas.height = height
//
ctx.drawImage(img, 0, 0, width, height)
// Blob
canvas.toBlob((blob) => {
if (blob) {
// URL
const compressedUrl = URL.createObjectURL(blob)
resolve(compressedUrl)
} else {
reject(new Error('Canvas压缩失败'))
}
}, 'image/jpeg', quality / 100)
} catch (error) {
reject(error)
}
}
img.onerror = () => {
reject(new Error('图片加载失败'))
}
img.src = src
})
}
//
const calculateCompressedSize = (originalWidth: number, originalHeight: number): { width: number, height: number } => {
const maxWidth = COMPRESS_CONFIG.value.image.maxWidth
const maxHeight = COMPRESS_CONFIG.value.image.maxHeight
let width = originalWidth
let height = originalHeight
//
if (width > maxWidth || height > maxHeight) {
const ratio = Math.min(maxWidth / width, maxHeight / height)
width = Math.floor(width * ratio)
height = Math.floor(height * ratio)
}
return { width, height }
}
//
const smartCompressImage = async (filePath: string): Promise<string> => {
try {
const fileInfo = await getFileInfo(filePath)
const originalSize = fileInfo.size
//
if (originalSize <= COMPRESS_CONFIG.value.image.maxSize) {
return filePath
}
//
let quality = COMPRESS_CONFIG.value.image.quality
if (originalSize > COMPRESS_CONFIG.value.image.maxSize * 10) {
quality = 15
} else if (originalSize > COMPRESS_CONFIG.value.image.maxSize * 5) {
quality = 25
} else if (originalSize > COMPRESS_CONFIG.value.image.maxSize * 3) {
quality = 35
} else if (originalSize > COMPRESS_CONFIG.value.image.maxSize * 2) {
quality = 45
} else if (originalSize > COMPRESS_CONFIG.value.image.maxSize) {
quality = 55
}
//
const compressedPath = await compressImage(filePath, quality)
//
const compressedInfo = await getFileInfo(compressedPath)
//
if (compressedInfo.size > COMPRESS_CONFIG.value.image.maxSize && quality > COMPRESS_CONFIG.value.image.minQuality) {
const newQuality = Math.max(quality - 10, COMPRESS_CONFIG.value.image.minQuality)
return await smartCompressImage(compressedPath)
}
return compressedPath
} catch (error) {
console.error('智能压缩失败:', error)
throw error
}
}
//
const checkVideoSize = async (filePath: string): Promise<boolean> => {
try {
const fileInfo = await getFileInfo(filePath)
return fileInfo.size <= COMPRESS_CONFIG.value.video.maxSize
} catch (error) {
console.error('获取视频文件信息失败:', error)
return false
}
}
//
const chooseImage = () => {
uni.chooseImage({
count: props.maxImageCount - imageList.value.length,
sizeType: ['original'],
sourceType: ['album', 'camera'],
success: async (res) => {
const tempFilePaths = res.tempFilePaths as string[]
showLoading('压缩图片中...')
try {
const compressedImages = []
//
const compressPromises = tempFilePaths.map(async (path, index) => {
try {
showLoading(`压缩图片中... (${index + 1}/${tempFilePaths.length})`)
const compressedPath = await smartCompressImage(path)
return {
tempPath: compressedPath,
name: path.split('/').pop() || 'image.jpg',
originalPath: path,
isCompressed: true
}
} catch (error) {
console.error(`图片 ${path} 压缩失败:`, error)
return {
tempPath: path,
name: path.split('/').pop() || 'image.jpg',
isCompressed: false
}
}
})
const results = await Promise.all(compressPromises)
compressedImages.push(...results)
//
imageList.value = [...imageList.value, ...compressedImages]
emit('update:imageList', imageList.value)
hideLoading()
//
const compressedCount = results.filter(r => r.isCompressed).length
if (compressedCount > 0) {
showToast({
title: `已压缩 ${compressedCount} 张图片`,
icon: 'success',
duration: 2000
})
}
//
if (props.autoUpload && props.uploadApi) {
await uploadImages(compressedImages)
}
} catch (error) {
hideLoading()
showToast({ title: '图片处理失败', icon: 'none' })
console.error('图片处理失败:', error)
}
},
fail: (error) => {
console.error('选择图片失败:', error)
showToast({ title: '选择图片失败', icon: 'none' })
}
})
}
//
const chooseVideo = () => {
uni.chooseVideo({
sourceType: ['album', 'camera'],
maxDuration: COMPRESS_CONFIG.value.video.maxDuration,
camera: 'back',
success: async (res) => {
const tempFilePath = res.tempFilePath
try {
//
const isValidSize = await checkVideoSize(tempFilePath)
if (!isValidSize) {
const fileInfo = await getFileInfo(tempFilePath)
const fileSizeMB = (fileInfo.size / 1024 / 1024).toFixed(1)
uni.showModal({
title: '视频文件过大',
content: `视频文件大小为 ${fileSizeMB}MB超过限制的 ${(COMPRESS_CONFIG.value.video.maxSize / 1024 / 1024).toFixed(1)}MB请选择较小的视频文件。`,
showCancel: false,
confirmText: '知道了'
})
return
}
//
const newVideo = {
tempPath: tempFilePath,
name: tempFilePath.split('/').pop() || 'video.mp4',
duration: res.duration,
size: res.size
}
videoList.value = [...videoList.value, newVideo]
emit('update:videoList', videoList.value)
//
if (props.autoUpload && props.uploadApi) {
await uploadVideos([newVideo])
}
} catch (error) {
console.error('视频处理失败:', error)
showToast({ title: '视频处理失败', icon: 'none' })
}
},
fail: (error) => {
console.error('选择视频失败:', error)
showToast({ title: '选择视频失败', icon: 'none' })
}
})
}
//
const uploadImages = async (images: ImageItem[]) => {
if (!props.uploadApi) return
try {
showLoading('上传图片中...')
let successCount = 0
let failCount = 0
for (let i = 0; i < images.length; i++) {
const image = images[i]
if (image.tempPath) {
try {
showLoading(`上传图片中... (${i + 1}/${images.length})`)
emit('upload-progress', 'image', i + 1, images.length)
const uploadResult = await props.uploadApi(image.tempPath)
if (uploadResult && uploadResult.resultCode === 1 && uploadResult.result && uploadResult.result.length > 0) {
const serverPath = uploadResult.result[0].filePath
//
const index = imageList.value.findIndex(img => img.tempPath === image.tempPath)
if (index !== -1) {
imageList.value[index].url = serverPath
delete imageList.value[index].tempPath
delete imageList.value[index].originalPath
}
emit('image-upload-success', imageList.value[index], index)
successCount++
} else {
throw new Error('上传响应格式异常')
}
} catch (error) {
console.error('图片上传失败:', error)
emit('image-upload-error', error, i)
const index = imageList.value.findIndex(img => img.tempPath === image.tempPath)
if (index !== -1) {
imageList.value.splice(index, 1)
}
failCount++
}
}
}
emit('update:imageList', imageList.value)
hideLoading()
if (failCount === 0) {
showToast({ title: `成功上传 ${successCount} 张图片`, icon: 'success' })
} else {
showToast({
title: `上传完成:成功 ${successCount} 张,失败 ${failCount}`,
icon: 'none',
duration: 3000
})
}
} catch (error) {
hideLoading()
console.error('批量上传图片失败:', error)
showToast({ title: '图片上传失败,请重试', icon: 'none' })
}
}
//
const uploadVideos = async (videos: VideoItem[]) => {
if (!props.uploadApi) return
try {
showLoading('上传视频中...')
let successCount = 0
let failCount = 0
for (let i = 0; i < videos.length; i++) {
const video = videos[i]
if (video.tempPath) {
try {
showLoading(`上传视频中... (${i + 1}/${videos.length})`)
emit('upload-progress', 'video', i + 1, videos.length)
const uploadResult = await props.uploadApi(video.tempPath)
if (uploadResult && uploadResult.resultCode === 1 && uploadResult.result && uploadResult.result.length > 0) {
const serverPath = uploadResult.result[0].filePath
//
const index = videoList.value.findIndex(v => v.tempPath === video.tempPath)
if (index !== -1) {
videoList.value[index].url = serverPath
delete videoList.value[index].tempPath
}
emit('video-upload-success', videoList.value[index], index)
successCount++
} else {
throw new Error('上传响应格式异常')
}
} catch (error) {
console.error('视频上传失败:', error)
emit('video-upload-error', error, i)
const index = videoList.value.findIndex(v => v.tempPath === video.tempPath)
if (index !== -1) {
videoList.value.splice(index, 1)
}
failCount++
}
}
}
emit('update:videoList', videoList.value)
hideLoading()
if (failCount === 0) {
showToast({ title: `成功上传 ${successCount} 个视频`, icon: 'success' })
} else {
showToast({
title: `上传完成:成功 ${successCount} 个,失败 ${failCount}`,
icon: 'none',
duration: 3000
})
}
} catch (error) {
hideLoading()
console.error('批量上传视频失败:', error)
showToast({ title: '视频上传失败,请重试', icon: 'none' })
}
}
//
const previewImage = (index: number) => {
const urls = imageList.value.map(img => img.url || img.tempPath).filter(Boolean)
uni.previewImage({
urls: urls,
current: index
})
}
//
const previewVideo = (index: number) => {
const video = videoList.value[index]
if (video.url || video.tempPath) {
uni.previewVideo({
sources: [{
src: video.url || video.tempPath,
type: 'mp4'
}]
})
}
}
//
const removeImage = (index: number) => {
imageList.value.splice(index, 1)
emit('update:imageList', imageList.value)
}
//
const removeVideo = (index: number) => {
videoList.value.splice(index, 1)
emit('update:videoList', videoList.value)
}
//
const formatDuration = (seconds: number): string => {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${secs.toString().padStart(2, '0')}`
}
//
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return bytes + 'B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + 'KB'
return (bytes / 1024 / 1024).toFixed(1) + 'MB'
}
//
const showLoading = (title: string) => {
uni.showLoading({ title })
}
const hideLoading = () => {
uni.hideLoading()
}
const showToast = (options: { title: string; icon?: string; duration?: number }) => {
uni.showToast(options)
}
//
defineExpose({
chooseImage,
chooseVideo,
uploadImages,
uploadVideos,
removeImage,
removeVideo
})
</script>
<style scoped>
.image-video-upload {
width: 100%;
}
.upload-section {
margin-bottom: 20rpx;
}
.section-title {
display: flex;
align-items: center;
margin-bottom: 16rpx;
}
.title-text {
font-size: 28rpx;
font-weight: 500;
color: #333;
}
.count-text {
font-size: 24rpx;
color: #999;
margin-left: 8rpx;
}
.image-list, .video-list {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
}
.image-item, .video-item {
position: relative;
width: 160rpx;
height: 160rpx;
border-radius: 8rpx;
overflow: hidden;
}
.image-preview {
width: 100%;
height: 100%;
}
.video-preview {
position: relative;
width: 100%;
height: 100%;
}
.video-thumbnail {
width: 100%;
height: 100%;
}
.play-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0, 0, 0, 0.6);
border-radius: 50%;
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
}
.video-duration {
position: absolute;
bottom: 4rpx;
right: 4rpx;
background: rgba(0, 0, 0, 0.6);
color: #fff;
font-size: 20rpx;
padding: 2rpx 6rpx;
border-radius: 4rpx;
}
.image-actions, .video-actions {
position: absolute;
top: 4rpx;
right: 4rpx;
}
.delete-btn {
width: 32rpx;
height: 32rpx;
background: rgba(0, 0, 0, 0.6);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.image-status {
position: absolute;
bottom: 4rpx;
left: 4rpx;
}
.compressed-tag {
background: #4CAF50;
color: #fff;
font-size: 20rpx;
padding: 2rpx 6rpx;
border-radius: 4rpx;
}
.video-info {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
padding: 8rpx;
}
.video-name, .video-size {
color: #fff;
font-size: 20rpx;
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.video-size {
margin-top: 2rpx;
opacity: 0.8;
}
.add-btn {
width: 160rpx;
height: 160rpx;
border: 2rpx dashed #ddd;
border-radius: 8rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #fafafa;
}
.add-text {
font-size: 24rpx;
color: #999;
margin-top: 8rpx;
}
</style>

View File

@ -0,0 +1,223 @@
# ImageVideoUpload 图片视频上传组件
一个功能完整的图片和视频上传组件,支持压缩、预览、删除等功能。
## 功能特性
- ✅ 图片和视频上传
- ✅ 智能图片压缩支持H5和APP环境
- ✅ 文件大小和时长限制
- ✅ 预览功能
- ✅ 删除功能
- ✅ 上传进度显示
- ✅ 自定义压缩配置
- ✅ 自动上传或手动上传
- ✅ 完整的事件回调
## 基本使用
```vue
<template>
<view class="container">
<ImageVideoUpload
v-model:image-list="imageList"
v-model:video-list="videoList"
:upload-api="uploadApi"
:compress-config="compressConfig"
@image-upload-success="onImageUploadSuccess"
@video-upload-success="onVideoUploadSuccess"
/>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ImageVideoUpload, type ImageItem, type VideoItem, COMPRESS_PRESETS } from '@/components/ImageVideoUpload'
import { attachmentUpload } from '@/api/system/upload'
// 数据
const imageList = ref<ImageItem[]>([])
const videoList = ref<VideoItem[]>([])
// 压缩配置
const compressConfig = ref(COMPRESS_PRESETS.medium)
// 上传API
const uploadApi = attachmentUpload
// 事件处理
const onImageUploadSuccess = (image: ImageItem, index: number) => {
console.log('图片上传成功:', image, index)
}
const onVideoUploadSuccess = (video: VideoItem, index: number) => {
console.log('视频上传成功:', video, index)
}
</script>
```
## Props 属性
| 属性名 | 类型 | 默认值 | 说明 |
|--------|------|--------|------|
| enableImage | boolean | true | 是否启用图片上传 |
| enableVideo | boolean | true | 是否启用视频上传 |
| maxImageCount | number | 5 | 最大图片数量 |
| maxVideoCount | number | 3 | 最大视频数量 |
| imageList | ImageItem[] | [] | 图片列表 |
| videoList | VideoItem[] | [] | 视频列表 |
| compressConfig | CompressConfig | 默认配置 | 压缩配置 |
| autoUpload | boolean | true | 是否自动上传 |
| uploadApi | Function | - | 上传API函数 |
## Events 事件
| 事件名 | 参数 | 说明 |
|--------|------|------|
| update:imageList | images: ImageItem[] | 图片列表更新 |
| update:videoList | videos: VideoItem[] | 视频列表更新 |
| image-upload-success | image: ImageItem, index: number | 图片上传成功 |
| image-upload-error | error: any, index: number | 图片上传失败 |
| video-upload-success | video: VideoItem, index: number | 视频上传成功 |
| video-upload-error | error: any, index: number | 视频上传失败 |
| upload-progress | type: 'image' \| 'video', current: number, total: number | 上传进度 |
## 压缩配置
### 使用预设配置
```javascript
import { COMPRESS_PRESETS } from '@/components/ImageVideoUpload'
// 高质量配置
const highQualityConfig = COMPRESS_PRESETS.high
// 平衡配置(默认)
const mediumConfig = COMPRESS_PRESETS.medium
// 高压缩配置
const lowQualityConfig = COMPRESS_PRESETS.low
```
### 自定义配置
```javascript
const customConfig = {
image: {
quality: 70, // 压缩质量 (1-100)
maxWidth: 1600, // 最大宽度
maxHeight: 900, // 最大高度
maxSize: 300 * 1024, // 最大文件大小 300KB
minQuality: 25 // 最低压缩质量
},
video: {
maxDuration: 45, // 最大时长45秒
maxSize: 8 * 1024 * 1024, // 最大文件大小 8MB
quality: 'medium' // 视频质量
}
}
```
## 高级用法
### 手动上传
```vue
<template>
<ImageVideoUpload
ref="uploadRef"
v-model:image-list="imageList"
v-model:video-list="videoList"
:auto-upload="false"
:upload-api="uploadApi"
/>
<button @click="handleUpload">手动上传</button>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const uploadRef = ref()
const handleUpload = async () => {
// 上传所有图片
await uploadRef.value.uploadImages(imageList.value)
// 上传所有视频
await uploadRef.value.uploadVideos(videoList.value)
}
</script>
```
### 监听上传进度
```vue
<template>
<ImageVideoUpload
v-model:image-list="imageList"
v-model:video-list="videoList"
:upload-api="uploadApi"
@upload-progress="onUploadProgress"
/>
<view v-if="uploading" class="progress">
上传进度: {{ currentProgress }}/{{ totalProgress }}
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const uploading = ref(false)
const currentProgress = ref(0)
const totalProgress = ref(0)
const onUploadProgress = (type: 'image' | 'video', current: number, total: number) => {
uploading.value = true
currentProgress.value = current
totalProgress.value = total
if (current === total) {
uploading.value = false
}
}
</script>
```
## 样式自定义
组件使用 scoped 样式,如需自定义样式,可以通过以下方式:
```vue
<style>
/* 全局样式覆盖 */
.image-video-upload .add-btn {
border-color: #007aff;
}
.image-video-upload .add-btn .add-text {
color: #007aff;
}
</style>
```
## 注意事项
1. **H5环境**使用Canvas进行图片压缩
2. **APP环境**使用uni.compressImage进行压缩
3. **文件大小**:建议根据实际需求调整压缩配置
4. **上传API**:需要返回标准的响应格式
5. **内存管理**:上传成功后会自动清理临时文件
## 响应格式要求
上传API需要返回以下格式的响应
```javascript
{
resultCode: 1, // 1表示成功
result: [
{
filePath: "服务器文件路径"
}
]
}
```

View File

@ -0,0 +1,65 @@
<template>
<view class="example-page">
<text class="title">图片视频上传组件示例</text>
<ImageVideoUpload
v-model:image-list="imageList"
v-model:video-list="videoList"
:max-image-count="5"
:max-video-count="3"
:compress-config="compressConfig"
:upload-api="uploadApi"
@image-upload-success="onImageUploadSuccess"
@video-upload-success="onVideoUploadSuccess"
/>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ImageVideoUpload, type ImageItem, type VideoItem, COMPRESS_PRESETS } from './index'
//
const imageList = ref<ImageItem[]>([])
const videoList = ref<VideoItem[]>([])
//
const compressConfig = ref(COMPRESS_PRESETS.medium)
// API
const uploadApi = async (file: any) => {
console.log('上传文件:', file)
//
await new Promise(resolve => setTimeout(resolve, 1000))
//
return {
resultCode: 1,
result: [{
filePath: `https://example.com/uploads/${Date.now()}.jpg`
}]
}
}
//
const onImageUploadSuccess = (image: ImageItem, index: number) => {
console.log('图片上传成功:', image, index)
}
const onVideoUploadSuccess = (video: VideoItem, index: number) => {
console.log('视频上传成功:', video, index)
}
</script>
<style scoped>
.example-page {
padding: 20rpx;
}
.title {
font-size: 32rpx;
font-weight: bold;
margin-bottom: 20rpx;
display: block;
}
</style>

View File

@ -0,0 +1,6 @@
// 导出组件和类型
export { default as ImageVideoUpload } from './ImageVideoUpload.vue'
export * from './types'
// 默认导出
export { default } from './ImageVideoUpload.vue'

View File

@ -0,0 +1,132 @@
// 图片项接口
export interface ImageItem {
tempPath?: string // 临时路径(用于预览)
url?: string // 服务器路径(上传成功后)
name?: string // 文件名
originalPath?: string // 原始路径(用于调试)
isCompressed?: boolean // 是否已压缩
}
// 视频项接口
export interface VideoItem {
tempPath?: string // 临时路径(用于预览)
url?: string // 服务器路径(上传成功后)
name?: string // 文件名
duration?: number // 视频时长(秒)
size?: number // 文件大小(字节)
thumbnail?: string // 缩略图路径
}
// 压缩配置接口
export interface CompressConfig {
image: {
quality: number // 压缩质量 (1-100)
maxWidth: number // 最大宽度
maxHeight: number // 最大高度
maxSize: number // 最大文件大小(字节)
minQuality: number // 最低压缩质量
}
video: {
maxDuration: number // 最大时长(秒)
maxSize: number // 最大文件大小(字节)
quality: string // 视频质量
}
}
// 组件Props接口
export interface ImageVideoUploadProps {
// 图片相关
enableImage?: boolean
maxImageCount?: number
imageList?: ImageItem[]
// 视频相关
enableVideo?: boolean
maxVideoCount?: number
videoList?: VideoItem[]
// 压缩配置
compressConfig?: CompressConfig
// 上传配置
autoUpload?: boolean
uploadApi?: (file: any) => Promise<any>
}
// 组件Emits接口
export interface ImageVideoUploadEmits {
'update:imageList': [images: ImageItem[]]
'update:videoList': [videos: VideoItem[]]
'image-upload-success': [image: ImageItem, index: number]
'image-upload-error': [error: any, index: number]
'video-upload-success': [video: VideoItem, index: number]
'video-upload-error': [error: any, index: number]
'upload-progress': [type: 'image' | 'video', current: number, total: number]
}
// 默认压缩配置
export const DEFAULT_COMPRESS_CONFIG: CompressConfig = {
image: {
quality: 60,
maxWidth: 1280,
maxHeight: 720,
maxSize: 200 * 1024, // 200KB
minQuality: 20
},
video: {
maxDuration: 60,
maxSize: 10 * 1024 * 1024, // 10MB
quality: 'medium'
}
}
// 预设压缩配置
export const COMPRESS_PRESETS = {
// 高质量配置
high: {
image: {
quality: 80,
maxWidth: 1920,
maxHeight: 1080,
maxSize: 500 * 1024, // 500KB
minQuality: 40
},
video: {
maxDuration: 60,
maxSize: 20 * 1024 * 1024, // 20MB
quality: 'high'
}
},
// 平衡配置
medium: {
image: {
quality: 60,
maxWidth: 1280,
maxHeight: 720,
maxSize: 200 * 1024, // 200KB
minQuality: 20
},
video: {
maxDuration: 60,
maxSize: 10 * 1024 * 1024, // 10MB
quality: 'medium'
}
},
// 高压缩配置
low: {
image: {
quality: 40,
maxWidth: 960,
maxHeight: 540,
maxSize: 100 * 1024, // 100KB
minQuality: 15
},
video: {
maxDuration: 30,
maxSize: 5 * 1024 * 1024, // 5MB
quality: 'low'
}
}
}

View File

@ -0,0 +1,204 @@
# ImageVideoUpload 图片视频上传组件
## 组件位置
`D:\code\zhxy-jsd\src\components\ImageVideoUpload\`
## 文件结构
```
ImageVideoUpload/
├── ImageVideoUpload.vue # 主组件文件
├── types.ts # 类型定义
├── index.ts # 导出文件
├── example.vue # 使用示例
├── README.md # 详细文档
└── 使用说明.md # 中文说明
```
## 快速使用
### 1. 基本用法
```vue
<template>
<ImageVideoUpload
v-model:image-list="imageList"
v-model:video-list="videoList"
:upload-api="uploadApi"
/>
</template>
<script setup>
import { ref } from 'vue'
import { ImageVideoUpload } from '@/components/ImageVideoUpload'
const imageList = ref([])
const videoList = ref([])
const uploadApi = async (file) => {
// 你的上传逻辑
return { resultCode: 1, result: [{ filePath: '服务器路径' }] }
}
</script>
```
### 2. 完整配置
```vue
<template>
<ImageVideoUpload
v-model:image-list="imageList"
v-model:video-list="videoList"
:max-image-count="5"
:max-video-count="3"
:compress-config="compressConfig"
:upload-api="uploadApi"
:auto-upload="true"
@image-upload-success="onImageUploadSuccess"
@video-upload-success="onVideoUploadSuccess"
/>
</template>
<script setup>
import { ref } from 'vue'
import { ImageVideoUpload, COMPRESS_PRESETS } from '@/components/ImageVideoUpload'
const imageList = ref([])
const videoList = ref([])
const compressConfig = ref(COMPRESS_PRESETS.medium)
const uploadApi = async (file) => {
// 上传逻辑
}
const onImageUploadSuccess = (image, index) => {
console.log('图片上传成功', image)
}
</script>
```
## 主要功能
### ✅ 图片功能
- 拍照/相册选择
- 智能压缩H5/APP自适应
- 预览功能
- 删除功能
- 压缩状态显示
### ✅ 视频功能
- 拍摄/相册选择
- 文件大小检查
- 时长限制
- 预览播放
- 删除功能
### ✅ 压缩功能
- 尺寸压缩(降低分辨率)
- 质量压缩调整JPEG质量
- 智能压缩(根据文件大小自动调整)
- 多级压缩(确保达到目标大小)
## 压缩配置
### 预设配置
```javascript
import { COMPRESS_PRESETS } from '@/components/ImageVideoUpload'
// 高质量(文件较大)
const highConfig = COMPRESS_PRESETS.high
// 平衡(推荐)
const mediumConfig = COMPRESS_PRESETS.medium
// 高压缩(文件很小)
const lowConfig = COMPRESS_PRESETS.low
```
### 自定义配置
```javascript
const customConfig = {
image: {
quality: 70, // 压缩质量 1-100
maxWidth: 1600, // 最大宽度
maxHeight: 900, // 最大高度
maxSize: 300 * 1024, // 最大文件大小(字节)
minQuality: 25 // 最低压缩质量
},
video: {
maxDuration: 45, // 最大时长(秒)
maxSize: 8 * 1024 * 1024, // 最大文件大小(字节)
quality: 'medium' // 视频质量
}
}
```
## 事件回调
```javascript
// 图片上传成功
@image-upload-success="(image, index) => {}"
// 图片上传失败
@image-upload-error="(error, index) => {}"
// 视频上传成功
@video-upload-success="(video, index) => {}"
// 视频上传失败
@video-upload-error="(error, index) => {}"
// 上传进度
@upload-progress="(type, current, total) => {}"
```
## 在 xcXkkcDetail.vue 中的使用
原来的代码已经替换为:
```vue
<ImageVideoUpload
v-model:image-list="imageList"
v-model:video-list="videoList"
:max-image-count="5"
:max-video-count="3"
:compress-config="compressConfig"
:upload-api="attachmentUpload"
@image-upload-success="onImageUploadSuccess"
@video-upload-success="onVideoUploadSuccess"
/>
```
## 注意事项
1. **环境兼容**自动检测H5/APP环境使用不同的压缩方案
2. **文件大小**:建议根据实际需求调整压缩配置
3. **上传API**:需要返回标准格式的响应
4. **内存管理**:上传成功后自动清理临时文件
5. **错误处理**:完善的错误处理和用户提示
## 响应格式要求
上传API需要返回
```javascript
{
resultCode: 1, // 1表示成功
result: [
{
filePath: "服务器文件路径"
}
]
}
```
## 样式自定义
组件使用scoped样式如需自定义
```css
/* 全局样式覆盖 */
.image-video-upload .add-btn {
border-color: #007aff;
}
```
## 测试
可以运行 `example.vue` 来测试组件功能。

View File

@ -1,7 +1,9 @@
const ip: string = "127.0.0.1:8897";
// const ip: string = "yufangzc.com";
// const ip: string = "lzcxsx.cn";
const fwqip: string = "lzcxsx.cn";
const fwqip: string = "127.0.0.1:8897";
//const ip: string = "lzcxsx.cn";
//const fwqip: string = "lzcxsx.cn";
//const ip: string = "zhxy.yufangzc.com";//
//const fwqip: string = "zhxy.yufangzc.com";
//打包服务器接口代理标识
const SERVERAGENT: string = "/jsd-api";
//本地代理url地址,配置了就启动代理,没配置就不启动代理
@ -15,7 +17,7 @@ export const BASE_WS_URL: string = `wss://${ip}`;
// export const BASE_IMAGE_URL: string = process.env.NODE_ENV == "development" ? `https://${ip}` : `https://${fwqip}`;
export const BASE_IMAGE_URL = `https://${fwqip}`;
// kkFileView预览服务地址
export const KK_FILE_VIEW_URL: string = `http://${fwqip}:8891`;
export const KK_FILE_VIEW_URL: string = `https://${fwqip}/kkpro`;
//存token的key
export const AUTH_KEY: string = "satoken";
//token过期返回状态码

View File

@ -241,6 +241,13 @@
"enablePullDownRefresh": false
}
},
{
"path": "pages/view/routine/ShiTangXunCha/edit",
"style": {
"navigationBarTitleText": "食堂巡查修改",
"enablePullDownRefresh": false
}
},
{
"path": "pages/view/routine/JiFenPingJia/detail",
"style": {

View File

@ -242,7 +242,7 @@ const sections = reactive<Section[]>([
{
id: "r6",
icon: "kfxc",
text: "课服巡查",
text: "教学巡查",
show: true,
permissionKey: "routine-kfxc", //
path: "/pages/view/routine/kefuxuncha/xcPbList",

View File

@ -85,8 +85,12 @@ let bjId = '';
//
const changeNjBj = (nj: any, bj: any) => {
bjId = bj.key;
selectDay(curRqIndex.value);
console.log(nj, bj);
console.log("班级选择变更:", { nj, bj, bjId });
//
if (rqList.value.length > 0) {
selectDay(curRqIndex.value);
}
};
//
@ -153,7 +157,8 @@ const selectWeek = (zc: any) => {
//
closeWeekPicker();
//
//
curRqIndex.value = 0;
selectDay(0);
};
@ -163,9 +168,18 @@ const selectDay = (index: number) => {
if (!rqList.value.length) {
return;
}
console.log("查询课表参数:", {
jsId: "",
bjId: bjId || "",
xqId: xqId,
rq: rqList.value[index].rq,
zj: rqList.value[index].zj
});
drpkkbApi({
jsId: "", // getJs.id,
bjId: bjId,
bjId: bjId || "", //
xqId: xqId,
rq: rqList.value[index].rq, //
zj: rqList.value[index].zj
@ -173,14 +187,17 @@ const selectDay = (index: number) => {
// result
if (res && res.resultCode === 1) {
sjList.value = res.result;
console.log("课表数据加载成功:", res.result);
} else {
//
console.warn("检查报名状态接口返回错误:", res);
console.warn("课表接口返回错误:", res);
sjList.value = [];
}
})
.catch((error) => {
//
console.error("调用检查报名状态接口失败:", error);
console.error("调用课表接口失败:", error);
sjList.value = [];
});
};
@ -194,17 +211,38 @@ const getCourseColorClass = (subject: string | undefined): string => {
};
onMounted(async () => {
const res = await dqpkApi();
const result = res.result;
dqZc = res.result.dqZc;
xqId = res.result.dqXq.id;
zcList.value = result.zcList;
sjList.value = result.sjList;
let zc = zcList.value.find((item:any) => item.djz === dqZc);
if (!zc) {
zc = zcList.value[0];
try {
const res = await dqpkApi();
const result = res.result;
dqZc = res.result.dqZc;
xqId = res.result.dqXq.id;
zcList.value = result.zcList;
sjList.value = result.sjList;
// 1.
let zc = zcList.value.find((item:any) => item.djz === dqZc);
if (!zc) {
zc = zcList.value[0];
}
//
Object.assign(curZc.value, zc);
Object.assign(rqList.value, curZc.value.drList);
// 2.
const currentDayOfWeek = dayjs().day(); // 0=, 1=, ..., 6=
const targetDayIndex = currentDayOfWeek === 0 ? 6 : currentDayOfWeek - 1; // 1=, 7=
//
const validDayIndex = Math.min(Math.max(targetDayIndex, 0), rqList.value.length - 1);
curRqIndex.value = validDayIndex;
//
selectDay(validDayIndex);
} catch (error) {
console.error("初始化失败:", error);
}
selectWeek(zc);
});
</script>
@ -380,6 +418,7 @@ onMounted(async () => {
}
}
.loading-overlay {
position: absolute;
top: 0;

View File

@ -62,7 +62,7 @@
</template>
<script lang="ts" setup>
import { hcFindByIdApi } from "@/api/base/hcApi";
import { hcFindPageApi } from "@/api/base/hcApi";
import { imagUrl } from "@/utils";
import { ref, onMounted } from "vue";
import { onLoad } from "@dcloudio/uni-app";
@ -101,9 +101,13 @@ onLoad((options) => {
//
const loadDetail = async (id: string) => {
try {
const res = await hcFindByIdApi({ id });
if (res && res.result) {
detailData.value = res.result;
const res = await hcFindPageApi({
id: id,
page: 1,
rows: 1
});
if (res && res.rows && res.rows.length > 0) {
detailData.value = res.rows[0];
//
if (detailData.value.tjfj) {

View File

@ -0,0 +1,552 @@
<!-- src/pages/view/routine/ShiTangXunCha/edit.vue -->
<template>
<view class="edit-page">
<view class="form-container">
<view class="form-title">修改食堂巡查</view>
<!-- 工作名称 -->
<view class="form-item">
<text class="form-label">工作名称 *</text>
<input
v-model="formData.gzmc"
class="form-input"
placeholder="请输入工作名称"
maxlength="100"
/>
</view>
<!-- 工作描述 -->
<view class="form-item">
<text class="form-label">工作描述</text>
<textarea
v-model="formData.gzms"
class="form-textarea"
placeholder="请输入工作描述"
maxlength="500"
:show-count="true"
/>
</view>
<!-- 上传照片 -->
<view class="form-item">
<text class="form-label">上传照片</text>
<view class="upload-section">
<view class="upload-list">
<view
v-for="(image, index) in imageList"
:key="index"
class="upload-item"
>
<image
:src="image.url ? imagUrl(image.url) : image.tempPath"
mode="aspectFill"
class="upload-image"
@click="previewImage(index)"
/>
<view class="upload-delete" @click="deleteImage(index)">
<text class="delete-icon">×</text>
</view>
</view>
<view
v-if="imageList.length < 9"
class="upload-add"
@click="chooseImage"
>
<text class="add-icon">+</text>
<text class="add-text">添加图片</text>
</view>
</view>
</view>
</view>
<!-- 提交人 -->
<view class="form-item">
<text class="form-label">提交人</text>
<input
:value="formData.jsxm"
class="form-input"
disabled
placeholder="自动获取当前用户"
/>
</view>
<!-- 提交时间 -->
<view class="form-item">
<text class="form-label">提交时间</text>
<picker
mode="date"
:value="formData.tjtime"
@change="onTimeChange"
class="form-picker"
>
<view class="picker-display">
{{ formData.tjtime || '请选择提交时间' }}
</view>
</picker>
</view>
</view>
<!-- 提交按钮 -->
<view class="submit-section">
<u-button
text="提交"
type="primary"
:loading="submitting"
@click="handleSubmit"
class="submit-btn"
/>
</view>
</view>
</template>
<script lang="ts" setup>
import { hcSaveApi, hcFindPageApi } from "@/api/base/hcApi";
import { attachmentUpload } from "@/api/system/upload";
import { useUserStore } from "@/store/modules/user";
import { showLoading, hideLoading, showToast } from "@/utils/uniapp";
import { imagUrl } from "@/utils";
import { ref, onMounted } from "vue";
import { onLoad } from "@dcloudio/uni-app";
const { getJs } = useUserStore();
//
let hcId = '';
//
const formData = ref({
id: '', // ID
gzmc: '', //
gzms: '', //
jsId: '', // ID
jsxm: '', //
tjtime: '', //
tjfj: '' //
});
// -
interface ImageItem {
tempPath?: string; //
url?: string; //
name?: string; //
}
const imageList = ref<ImageItem[]>([]);
//
const submitting = ref(false);
//
onLoad((options: any) => {
if (options.id) {
hcId = options.id;
loadData();
} else {
showToast({ title: '参数错误', icon: 'none' });
setTimeout(() => {
uni.navigateBack();
}, 1500);
}
});
//
const loadData = async () => {
try {
showLoading('加载中...');
console.log('正在加载数据ID:', hcId);
const res = await hcFindPageApi({
id: hcId,
page: 1,
rows: 1
});
console.log('API响应:', res);
if (res && res.rows && res.rows.length > 0) {
const data = res.rows[0];
//
formData.value = {
id: data.id,
gzmc: data.gzmc || '',
gzms: data.gzms || '',
jsId: data.jsId || '',
jsxm: data.jsxm || '',
tjtime: data.tjtime ? data.tjtime.split(' ')[0] : '', //
tjfj: data.tjfj || ''
};
//
if (data.tjfj) {
const imageUrls = data.tjfj.split(',').filter((url: string) => url.trim());
imageList.value = imageUrls.map((url: string) => ({
url: url.trim(),
name: url.split('/').pop() || 'image.jpg'
}));
}
} else {
throw new Error('获取数据失败');
}
} catch (error) {
console.error('加载数据失败:', error);
showToast({ title: '加载数据失败', icon: 'none' });
setTimeout(() => {
uni.navigateBack();
}, 1500);
} finally {
hideLoading();
}
};
//
onMounted(() => {
const js = getJs;
formData.value.jsId = js.id;
formData.value.jsxm = js.jsxm;
});
//
const chooseImage = () => {
uni.chooseImage({
count: 9 - imageList.value.length,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: async (res) => {
//
const tempFilePaths = res.tempFilePaths as string[];
const newImages = tempFilePaths.map((path: string) => ({
tempPath: path,
name: path.split('/').pop() || 'image.jpg'
}));
imageList.value = [...imageList.value, ...newImages];
//
await uploadImages(newImages);
}
});
};
//
const uploadImages = async (images: ImageItem[]) => {
try {
showLoading('上传图片中...');
for (let i = 0; i < images.length; i++) {
const image = images[i];
if (image.tempPath) {
try {
//
const uploadResult: any = await attachmentUpload(image.tempPath as any);
if (uploadResult && uploadResult.resultCode === 1 && uploadResult.result && uploadResult.result.length > 0) {
//
const serverPath = uploadResult.result[0].filePath;
//
const index = imageList.value.findIndex(img => img.tempPath === image.tempPath);
if (index !== -1) {
imageList.value[index].url = serverPath;
//
delete imageList.value[index].tempPath;
}
} else {
throw new Error('上传响应格式异常');
}
} catch (error) {
console.error('图片上传失败:', error);
showToast({ title: `${image.name || '图片'}上传失败`, icon: 'none' });
//
const index = imageList.value.findIndex(img => img.tempPath === image.tempPath);
if (index !== -1) {
imageList.value.splice(index, 1);
}
}
}
}
hideLoading();
showToast({ title: '图片上传完成', icon: 'success' });
} catch (error) {
hideLoading();
console.error('批量上传图片失败:', error);
showToast({ title: '图片上传失败,请重试', icon: 'none' });
}
};
//
const previewImage = (index: number) => {
const urls = imageList.value.map(img =>
img.url ? imagUrl(img.url) : img.tempPath
).filter((url): url is string => !!url);
uni.previewImage({
urls: urls,
current: index
});
};
//
const deleteImage = (index: number) => {
imageList.value.splice(index, 1);
};
//
const onTimeChange = (e: any) => {
formData.value.tjtime = e.detail.value;
};
//
const handleSubmit = async () => {
//
if (!formData.value.gzmc.trim()) {
showToast({ title: '请输入工作名称', icon: 'none' });
return;
}
if (!formData.value.tjtime) {
showToast({ title: '请选择提交时间', icon: 'none' });
return;
}
//
const hasUploadingImages = imageList.value.some(img => img.tempPath && !img.url);
if (hasUploadingImages) {
showToast({ title: '请等待图片上传完成', icon: 'none' });
return;
}
submitting.value = true;
showLoading('提交中...');
try {
//
let tjfj = '';
const uploadedImages = imageList.value.filter(img => img.url);
if (uploadedImages.length > 0) {
// 使 undefined
const imageUrls = uploadedImages.map(img => img.url).filter((url): url is string => !!url);
tjfj = imageUrls.join(',');
}
const submitData = {
...formData.value,
tjfj
};
await hcSaveApi(submitData);
hideLoading();
showToast({ title: '提交成功', icon: 'success' });
//
setTimeout(() => {
uni.navigateBack();
}, 1500);
} catch (error) {
hideLoading();
showToast({ title: '提交失败,请重试', icon: 'none' });
console.error('提交失败:', error);
} finally {
submitting.value = false;
}
};
</script>
<style scoped lang="scss">
.edit-page {
min-height: 100vh;
background-color: #f5f7fa;
padding: 16px;
box-sizing: border-box;
}
.form-container {
background-color: #ffffff;
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
margin-bottom: 20px;
}
.form-title {
font-size: 18px;
font-weight: 600;
color: #2c3e50;
text-align: center;
margin-bottom: 24px;
padding-bottom: 12px;
border-bottom: 1px solid #f0f0f0;
}
.form-item {
margin-bottom: 20px;
.form-label {
display: block;
font-size: 14px;
color: #2c3e50;
font-weight: 500;
margin-bottom: 8px;
}
.form-input {
width: 100%;
height: 44px;
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 0 12px;
font-size: 14px;
color: #495057;
background-color: #ffffff;
box-sizing: border-box;
&:focus {
border-color: #28a745;
outline: none;
}
&:disabled {
background-color: #f8f9fa;
color: #6c757d;
}
}
.form-textarea {
width: 100%;
min-height: 100px;
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 12px;
font-size: 14px;
color: #495057;
background-color: #ffffff;
box-sizing: border-box;
resize: none;
&:focus {
border-color: #28a745;
outline: none;
}
}
.form-picker {
.picker-display {
width: 100%;
height: 44px;
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 0 12px;
font-size: 14px;
color: #495057;
background-color: #ffffff;
display: flex;
align-items: center;
box-sizing: border-box;
}
}
}
.upload-section {
.upload-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.upload-item {
position: relative;
width: 80px;
height: 80px;
border-radius: 8px;
overflow: hidden;
border: 1px solid #e9ecef;
.upload-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.upload-delete {
position: absolute;
top: 4px;
right: 4px;
width: 20px;
height: 20px;
background-color: rgba(0, 0, 0, 0.6);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
.delete-icon {
color: #ffffff;
font-size: 14px;
font-weight: bold;
}
}
}
.upload-add {
width: 80px;
height: 80px;
border: 2px dashed #e9ecef;
border-radius: 8px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: #f8f9fa;
cursor: pointer;
.add-icon {
font-size: 24px;
color: #6c757d;
margin-bottom: 4px;
}
.add-text {
font-size: 12px;
color: #6c757d;
}
}
}
.submit-section {
padding: 0 16px;
.submit-btn {
width: 100%;
height: 48px;
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
border: none;
border-radius: 12px;
font-size: 16px;
font-weight: 600;
color: #ffffff;
box-shadow: 0 4px 16px rgba(40, 167, 69, 0.3);
transition: all 0.3s ease;
&:active {
transform: translateY(1px);
box-shadow: 0 2px 8px rgba(40, 167, 69, 0.4);
}
}
}
//
@media (max-width: 375px) {
.edit-page {
padding: 12px;
}
.form-container {
padding: 16px;
}
.form-title {
font-size: 16px;
}
}
</style>

View File

@ -55,16 +55,30 @@
<view class="card-body">
<rich-text class="hc-excerpt" :nodes="data.gzms"></rich-text>
<view v-if="data.tjfj" class="image-preview">
<image
:src="getImageUrl(data.tjfj)"
mode="aspectFill"
class="preview-image"
></image>
<view
v-for="(imageUrl, index) in getImageList(data.tjfj)"
:key="index"
class="preview-image-wrapper"
>
<image
:src="imageUrl"
mode="aspectFill"
class="preview-image"
></image>
</view>
</view>
</view>
<view class="card-footer">
<text class="footer-item">提交人: {{ data.jsxm || '未知' }}</text>
<text class="footer-item">提交时间: {{ formatTime(data.tjtime) }}</text>
<view class="footer-actions">
<view
@click.stop="goToEdit(data.id)"
class="edit-btn"
>
修改
</view>
</view>
</view>
</view>
</template>
@ -170,6 +184,7 @@ const getHcList = async () => {
//
const goToDetail = (hcId: string) => {
console.log('点击卡片跳转详情页ID:', hcId);
uni.navigateTo({
url: `/pages/view/routine/ShiTangXunCha/detail?id=${hcId}`,
});
@ -182,6 +197,14 @@ const goToAdd = () => {
});
};
//
const goToEdit = (hcId: string) => {
console.log('点击修改按钮ID:', hcId);
uni.navigateTo({
url: `/pages/view/routine/ShiTangXunCha/edit?id=${hcId}`,
});
};
//
const formatTime = (timeStr: string) => {
if (!timeStr) return "";
@ -200,6 +223,14 @@ const getImageUrl = (imagePath: string) => {
return imagUrl(firstImage);
};
// 3
const getImageList = (imagePath: string) => {
if (!imagePath) return [];
// 3
const imagePaths = imagePath.split(',').slice(0, 3);
return imagePaths.map(path => imagUrl(path.trim()));
};
onShow(() => {
getHcList();
});
@ -371,12 +402,23 @@ onMounted(() => {
}
.image-preview {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 8px;
.preview-image-wrapper {
position: relative;
flex-shrink: 0;
}
.preview-image {
width: 80px;
height: 60px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
object-fit: cover;
display: block;
}
}
}
@ -409,6 +451,36 @@ onMounted(() => {
flex-shrink: 0;
}
}
.footer-actions {
margin-left: auto;
flex-shrink: 0;
.edit-btn {
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
border: none;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
padding: 4px 12px;
min-width: 50px;
height: 28px;
box-shadow: 0 2px 6px rgba(40, 167, 69, 0.3);
position: relative;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
cursor: pointer;
transition: all 0.2s ease;
&:active {
transform: translateY(1px);
box-shadow: 0 1px 3px rgba(40, 167, 69, 0.4);
}
}
}
}
// -

View File

@ -3,22 +3,10 @@
<!-- 班级选择器 -->
<view class="section">
<text class="section-title">选择班级</text>
<BasicNjBjSelect
:custom-style="{
borderRadius: '12rpx',
background: 'background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%)',
padding: '0rpx 30rpx',
border: '1rpx solid #e9ecef',
transition: 'all 0.3s ease',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
}"
v-model="selectedNjBj"
placeholder="请选择年级班级"
:only-confirm-change-flag="true"
:auto-selected-first="false"
:bj-required="true"
@change="changeNjBj"
/>
<view class="class-selector" @click="showClassTree">
<text :class="{ placeholder: !selectedClassText }">{{ selectedClassText || "请选择班级" }}</text>
<uni-icons type="right" size="16" color="#999"></uni-icons>
</view>
<!-- 班级选择提示 -->
<view v-if="!curBj" class="class-tip">
<text class="tip-icon"></text>
@ -38,16 +26,18 @@
ref="dmXsRef"
/>
<!-- 拍照视频组件 -->
<!-- 图片视频上传组件 -->
<view class="section" v-if="curBj">
<DmPsComponent
v-model="mediaData"
:photo-title="'就餐现场拍照'"
:video-title="'就餐现场视频'"
:max-photo-count="9"
<ImageVideoUpload
v-model:image-list="imageList"
v-model:video-list="videoList"
:max-image-count="9"
:max-video-count="3"
:max-video-duration="60"
ref="dmPsRef"
:compress-config="compressConfig"
:upload-api="attachmentUpload"
@image-upload-success="onImageUploadSuccess"
@video-upload-success="onVideoUploadSuccess"
ref="imageVideoUploadRef"
/>
</view>
@ -69,15 +59,30 @@
提交点名
</button>
</view>
<!-- 班级选择树 -->
<BasicTree
ref="treeRef"
:range="treeData"
idKey="key"
rangeKey="title"
title="选择班级"
:multiple="false"
:selectParent="false"
@confirm="onTreeConfirm"
@cancel="onTreeCancel"
/>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import BasicNjBjSelect from '@/components/BasicNjBjSelect/index.vue'
import { ref, computed, onMounted } from 'vue'
import BasicTree from '@/components/BasicTree/Tree.vue'
import DmJs from './dmJs.vue'
import DmXs from './dmXs.vue'
import DmPsComponent from '@/pages/components/dmPs/index.vue'
import { ImageVideoUpload, type ImageItem, type VideoItem, COMPRESS_PRESETS } from '@/components/ImageVideoUpload'
import { submitJcDmDataApi } from '@/api/base/jcApi'
import { findAllNjBjTree } from '@/api/base/server'
import { attachmentUpload } from '@/api/system/upload'
import { useUserStore } from '@/store/modules/user'
import { useDataStore } from '@/store/modules/data'
import { useDebounce } from "@/utils/debounce";
@ -87,15 +92,9 @@ const { getJcBz } = useDataStore();
// isSubmitting useDebounce
const { isProcessing: isSubmitting, debounce } = useDebounce(2000);
const selectedNjBj = ref<any>(null);
/**
* 就餐点名组件
* 功能
* 1. 选择班级获取学生列表
* 2. 将学生分为两类已缴费可切换正常/请假/缺勤和未缴费/未报名状态为未缴费/未报名
* 3. 选择陪餐教师
* 4. 批量提交点名记录包含jsId和dmPcId字段
*/
//
const treeData = ref<any[]>([]);
const treeRef = ref();
//
const props = withDefaults(defineProps<{
@ -110,24 +109,86 @@ const isLoading = ref(false);
const curNj = ref<any>(null);
const curBj = ref<any>(null);
//
const selectedClassText = computed(() => {
if (curBj.value && curNj.value) {
return `${curNj.value.title} ${curBj.value.title}`;
}
return '';
});
const dmXsRef = ref<any>(null);
const dmJsRef = ref<any>(null);
//
const mediaData = ref({
photoList: [],
videoList: []
});
const dmPsRef = ref<any>(null);
//
const compressConfig = ref(COMPRESS_PRESETS.medium)
//
const changeNjBj = (val: any) => {
const njList = val.selectedGrades || [];
const nj = njList[0] || {};
const bjList = val.selectedClasses || [];
const bj = bjList[0] || {};
curNj.value = nj
curBj.value = bj
//
const imageList = ref<ImageItem[]>([]);
const videoList = ref<VideoItem[]>([]);
const imageVideoUploadRef = ref<any>(null);
//
const onImageUploadSuccess = (image: ImageItem, index: number) => {
console.log('图片上传成功:', image, index);
};
const onVideoUploadSuccess = (video: VideoItem, index: number) => {
console.log('视频上传成功:', video, index);
};
//
const loadTreeData = async () => {
try {
const res = await findAllNjBjTree();
if (res.resultCode === 1 && res.result) {
// BasicTree
const convertTreeData = (items: any[]): any[] => {
return items.map((item: any) => ({
key: item.key,
title: item.title,
njmcId: item.njmcId,
children: item.children ? convertTreeData(item.children) : [],
}));
};
treeData.value = convertTreeData(res.result);
}
} catch (error) {
uni.showToast({ title: "加载班级数据失败", icon: "error" });
}
};
//
const showClassTree = () => {
if (treeRef.value) {
treeRef.value._show();
}
};
//
const onTreeConfirm = (selectedItems: any[]) => {
if (selectedItems.length > 0) {
const selectedItem = selectedItems[0]; //
// parents
if (selectedItem.parents && selectedItem.parents.length > 0) {
const parent = selectedItem.parents[0]; //
const nj = parent; //
const bj = selectedItem; //
curNj.value = nj;
curBj.value = bj;
} else {
//
curNj.value = selectedItem;
curBj.value = null;
}
}
};
//
const onTreeCancel = () => {
//
};
const tjDm = debounce(async () => {
@ -141,26 +202,16 @@ const tjDm = debounce(async () => {
try {
const dmXsList = dmXsRef.value.getDmXsList();
const dmJsList = dmJsRef.value.getDmJsList();
//
let photoUrls = '';
let videoUrls = '';
// URL
const photoUrls = imageList.value
.filter(img => img.url)
.map(img => img.url)
.join(',');
if (mediaData.value.photoList.length > 0 || mediaData.value.videoList.length > 0) {
try {
if (dmPsRef.value) {
const uploadResult = await dmPsRef.value.uploadMedia();
photoUrls = uploadResult.photoUrls;
videoUrls = uploadResult.videoUrls;
}
} catch (uploadError) {
console.error('媒体文件上传失败:', uploadError);
uni.showToast({
title: '媒体文件上传失败,请重试',
icon: 'none'
});
return;
}
}
const videoUrls = videoList.value
.filter(video => video.url)
.map(video => video.url)
.join(',');
let dmData: any = {
jcTime: new Date(),
bjId: curBj.value.key,
@ -191,10 +242,8 @@ const tjDm = debounce(async () => {
//
curNj.value = null
curBj.value = null
mediaData.value = {
photoList: [],
videoList: []
};
imageList.value = []
videoList.value = []
//
uni.navigateBack()
} else {
@ -209,6 +258,11 @@ const tjDm = debounce(async () => {
}
});
//
onMounted(() => {
loadTreeData();
});
</script>
<style lang="scss" scoped>
@ -235,6 +289,32 @@ const tjDm = debounce(async () => {
}
}
.class-selector {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 30rpx;
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
border: 1rpx solid #e9ecef;
border-radius: 12rpx;
margin-top: 20rpx;
transition: all 0.3s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
text {
font-size: 28rpx;
color: #333;
&.placeholder {
color: #999;
}
}
&:active {
background: linear-gradient(135deg, #e9ecef 0%, #dee2e6 100%);
}
}
.class-tip {
display: flex;
align-items: center;

View File

@ -7,27 +7,27 @@
<!-- 统计信息 -->
<view class="stats-container">
<view class="stat-item">
<view class="stat-item clickable" @click="showStudentDrawer('all')">
<text class="stat-number">{{ rsData.zrs }}</text>
<text class="stat-label">总人数</text>
</view>
<view class="stat-item">
<view class="stat-item clickable" @click="showStudentDrawer('registered')">
<text class="stat-number unregistered">{{ rsData.bmRs }}</text>
<text class="stat-label">报名就餐</text>
</view>
<view class="stat-item">
<view class="stat-item clickable" @click="showStudentDrawer('unregistered')">
<text class="stat-number unregistered">{{ rsData.unBmRs }}</text>
<text class="stat-label">未报名就餐</text>
</view>
<view class="stat-item">
<view class="stat-item clickable" @click="showStudentDrawer('normal')">
<text class="stat-number normal">{{ hqZtSl('A') }}</text>
<text class="stat-label">正常</text>
</view>
<view class="stat-item">
<view class="stat-item clickable" @click="showStudentDrawer('leave')">
<text class="stat-number leave">{{ hqZtSl('B') }}</text>
<text class="stat-label">请假</text>
</view>
<view class="stat-item">
<view class="stat-item clickable" @click="showStudentDrawer('absent')">
<text class="stat-number absent">{{ hqZtSl('C') }}</text>
<text class="stat-label">缺勤</text>
</view>
@ -122,6 +122,42 @@
@confirm="onChangeZt"
@cancel="ztVisible = false"
></u-picker>
<!-- 学生列表抽屉 -->
<uni-popup ref="studentPopup" type="bottom" @change="popupChange">
<view class="student-drawer">
<view class="drawer-header">
<text class="drawer-title">{{ drawerTitle }}</text>
<text class="drawer-close" @click="closeStudentDrawer"></text>
</view>
<view class="drawer-content">
<view v-if="filteredStudentList.length > 0" class="student-list">
<view
v-for="xs in filteredStudentList"
:key="xs.id"
class="student-item"
>
<view class="student-info">
<image
class="student-avatar"
:src="imagUrl(xs.xstx) || '/static/images/default-avatar.png'"
mode="aspectFill"
></image>
<view class="student-details">
<text class="student-name">{{ xs.xm }}</text>
</view>
</view>
<view class="student-tag" :class="getStatusClass(xs.jcZt)">
{{ hqZtWz(xs.jcZt) }}
</view>
</view>
</view>
<view v-else class="empty-drawer">
<text class="empty-text">{{ emptyText }}</text>
</view>
</view>
</view>
</uni-popup>
</view>
</template>
@ -165,6 +201,69 @@ const ztList = ref<Array<{ text: string, value: string }>>([
const dqXs = ref<any>(null) //
const mqXz = ref<any>([0]) //
//
const studentPopup = ref<any>(null)
const drawerTitle = ref('')
const emptyText = ref('')
const filteredStudentList = ref<any>([])
const showStudentDrawer = (type: string) => {
let students: any[] = []
let title = ''
let empty = ''
switch (type) {
case 'all':
students = [...bmXsList.value, ...unBmXsList.value]
title = '全部学生'
empty = '暂无学生数据'
break
case 'registered':
students = bmXsList.value
title = '报名就餐学生'
empty = '暂无报名学生'
break
case 'unregistered':
students = unBmXsList.value
title = '未报名就餐学生'
empty = '暂无未报名学生'
break
case 'normal':
students = bmXsList.value.filter((s: any) => s.jcZt === 'A')
title = '正常学生'
empty = '暂无正常学生'
break
case 'leave':
students = bmXsList.value.filter((s: any) => s.jcZt === 'B')
title = '请假学生'
empty = '暂无请假学生'
break
case 'absent':
students = bmXsList.value.filter((s: any) => s.jcZt === 'C')
title = '缺勤学生'
empty = '暂无缺勤学生'
break
}
drawerTitle.value = title
emptyText.value = empty
filteredStudentList.value = students
if (studentPopup.value) {
studentPopup.value.open('bottom')
}
}
const closeStudentDrawer = () => {
if (studentPopup.value) {
studentPopup.value.close()
}
}
const popupChange = (e: { show: boolean }) => {
//
}
//
const loadXsList = async () => {
if (!props.bzId || !props.bjId) return
@ -364,6 +463,45 @@ defineExpose({
align-items: center;
min-width: 120rpx;
&.clickable {
cursor: pointer;
transition: all 0.2s ease;
position: relative;
&:hover {
transform: translateY(-2rpx);
box-shadow: 0 4rpx 12rpx rgba(235, 47, 150, 0.2);
background-color: rgba(235, 47, 150, 0.05);
border-radius: 12rpx;
padding: 8rpx;
margin: -8rpx;
}
&:active {
transform: translateY(0);
box-shadow: 0 2rpx 8rpx rgba(235, 47, 150, 0.3);
}
&::after {
content: '';
position: absolute;
top: 50%;
right: -10rpx;
transform: translateY(-50%);
width: 6rpx;
height: 6rpx;
background-color: #eb2f96;
border-radius: 50%;
opacity: 0.6;
transition: all 0.2s ease;
}
&:hover::after {
opacity: 1;
transform: translateY(-50%) scale(1.2);
}
}
.stat-number {
font-size: 36rpx;
font-weight: bold;
@ -395,6 +533,7 @@ defineExpose({
font-size: 24rpx;
color: #666;
}
}
}
@ -585,4 +724,128 @@ defineExpose({
font-size: 28rpx;
padding: 60rpx 0;
}
/* 学生列表抽屉样式 */
.student-drawer {
background-color: #fff;
border-radius: 20rpx 20rpx 0 0;
max-height: 80vh;
overflow: hidden;
.drawer-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 30rpx 40rpx 20rpx;
border-bottom: 1rpx solid #f0f0f0;
position: sticky;
top: 0;
background-color: #fff;
z-index: 10;
.drawer-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
}
.drawer-close {
font-size: 36rpx;
color: #999;
padding: 10rpx;
cursor: pointer;
&:hover {
color: #666;
}
}
}
.drawer-content {
padding: 20rpx 40rpx 40rpx;
max-height: calc(80vh - 100rpx);
overflow-y: auto;
}
.student-list {
.student-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24rpx 0;
border-bottom: 1rpx solid #f5f5f5;
&:last-child {
border-bottom: none;
}
.student-info {
display: flex;
align-items: center;
flex: 1;
.student-avatar {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
margin-right: 24rpx;
background-color: #f5f5f5;
}
.student-details {
display: flex;
flex-direction: column;
.student-name {
font-size: 28rpx;
font-weight: 500;
color: #333;
}
}
}
.student-tag {
font-size: 20rpx;
padding: 8rpx 16rpx;
border-radius: 12rpx;
font-weight: 500;
&.status-normal {
background-color: #e6f7ff;
color: #52c41a;
}
&.status-leave {
background-color: #fff7e6;
color: #faad14;
}
&.status-absent {
background-color: #fff2f0;
color: #ff4d4f;
}
&.status-unpaid {
background-color: #f9f0ff;
color: #722ed1;
}
&.status-unregistered {
background-color: #fff0f6;
color: #eb2f96;
}
}
}
}
.empty-drawer {
text-align: center;
padding: 80rpx 0;
.empty-text {
font-size: 28rpx;
color: #999;
}
}
}
</style>

View File

@ -7,10 +7,18 @@
style="position: absolute"
>
<template v-slot="{ data, index }">
<view class="inspection-record bg-white r-md p-15 mb-15">
<view class="inspection-record bg-white r-md p-15 mb-15 timeline-item">
<!-- 时间轴连接线 -->
<view class="timeline-line" v-if="index < 10"></view>
<!-- 时间轴节点 -->
<view class="timeline-dot">
<view class="dot-inner"></view>
</view>
<view class="record-header">
<view class="record-time">
<u-icon name="clock" color="#666" size="14"></u-icon>
<u-icon name="clock" color="#4080ff" size="16"></u-icon>
<text class="time-text">{{ formatTime(data.xctime) }}</text>
</view>
<view class="record-status">
@ -18,10 +26,18 @@
</view>
</view>
<view class="record-content">
<view class="content-item" v-if="data.njbj">
<text class="item-label">年级班级</text>
<text class="item-value">{{ data.njbj }}</text>
</view>
<view class="content-item">
<text class="item-label">巡查教师</text>
<text class="item-value">{{ data.jsxm }}</text>
</view>
<view class="content-item" v-if="data.rkjsxm">
<text class="item-label">任课老师</text>
<text class="item-value">{{ data.rkjsxm }}</text>
</view>
<view class="content-item flex-col">
<text class="item-label" style="flex: 0 0 25px"
>巡查项目</text
@ -36,20 +52,25 @@
style="margin-bottom: 4px"
>
<view>
<text>{{ idx + 1 }}{{ xm.xcMc }}</text>
<view
style="
display: flex;
justify-content: space-between;
margin: 4px 0;
"
>
<text>分值{{ xm.xmFz }}</text>
<text
>巡查结果{{
xm.xcJg === "A" ? "有" : "无"
}}</text
>
<view style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px;">
<view style="display: flex; align-items: center; flex: 1;">
<text style="margin-right: 8px;">{{ idx + 1 }}{{ xm.xcMc }}</text>
<view style="display: flex; align-items: center;">
<u-icon
:name="xm.xcJg === 'B' ? 'checkmark-circle-fill' : 'close-circle-fill'"
:color="xm.xcJg === 'B' ? '#67c23a' : '#f56c6c'"
size="18"
></u-icon>
</view>
</view>
<view style="font-size: 12px; color: #666;">
<text v-if="xm.xcJg === 'A'" style="color: #f56c6c;">
扣分-{{ xm.xmFz }}
</text>
<text v-else style="color: #67c23a;">
不扣分
</text>
</view>
</view>
</view>
</view>
@ -173,12 +194,15 @@ const xkkc = computed(() => getData);
let inspectionParams = ref({
rows: 10,
kyXcId: xkkc.value.id,
jsId: js.value.id,
njId: xkkc.value.njId,
bjId: xkkc.value.bjId,
njmcId: xkkc.value.njmcId,
//
});
//
console.log('课业辅导巡查记录查询参数:', inspectionParams.value);
//
const [registerInspection, { reload }] = useLayout({
api: getKyXcPageApi,
@ -189,9 +213,10 @@ const [registerInspection, { reload }] = useLayout({
//
const handlePreviewImage = (img: string, images: string[]) => {
// uni-appAPI
const processedImages = images.map(image => imagUrl(image));
uni.previewImage({
current: img,
urls: images,
current: imagUrl(img),
urls: processedImages,
});
};
@ -202,7 +227,7 @@ const handlePreviewVideo = (videos: string[], index: number) => {
uni.previewMedia({
current: index,
sources: videos.map((url) => ({
url,
url: imagUrl(url),
type: "video",
})),
});
@ -210,7 +235,10 @@ const handlePreviewVideo = (videos: string[], index: number) => {
//
const formatTime = (timestamp: string) => {
return dayjs(timestamp).format("YYYY-MM-DD HH:mm");
const date = dayjs(timestamp);
const weekDays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
const weekDay = weekDays[date.day()];
return `${weekDay} ${date.format("YYYY-MM-DD HH:mm")}`;
};
//
@ -235,52 +263,170 @@ onMounted(() => {
.inspection-list {
position: relative;
height: calc(100vh - 50px);
padding-left: 12px; //
.inspection-record {
position: relative;
margin-left: 0; //
background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
border: 1px solid #e8f4fd;
transition: all 0.3s ease;
overflow: hidden;
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12);
border-color: #4080ff;
}
// 线
.timeline-line {
position: absolute;
left: -12px;
top: 0;
width: 2px;
height: 100%;
background: linear-gradient(180deg, #4080ff 0%, #e8f4fd 100%);
z-index: 1;
}
//
.timeline-dot {
position: absolute;
left: -18px;
top: 20px;
width: 12px;
height: 12px;
background: #4080ff;
border-radius: 50%;
z-index: 2;
box-shadow: 0 0 0 4px #ffffff, 0 0 0 6px #e8f4fd;
.dot-inner {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #4080ff 0%, #66b3ff 100%);
border-radius: 50%;
animation: pulse 2s infinite;
}
}
.record-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
padding-bottom: 10px;
border-bottom: 1px solid #eee;
margin-bottom: 15px;
padding: 12px 8px;
background: linear-gradient(135deg, #f0f7ff 0%, #e8f4fd 100%);
border-radius: 8px 8px 0 0;
border-bottom: 1px solid #d1e7ff;
.record-time {
display: flex;
align-items: center;
font-size: 14px;
color: #666;
font-size: 15px;
color: #2c5aa0;
font-weight: 600;
.time-text {
margin-left: 5px;
margin-left: 8px;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
}
.record-status {
padding: 3px 8px;
border-radius: 4px;
border: 1px solid #4080ff;
display: inline-flex;
padding: 6px 12px;
border-radius: 20px;
background: linear-gradient(135deg, #4080ff 0%, #66b3ff 100%);
color: #ffffff;
font-size: 12px;
color: #4080ff;
font-weight: 600;
box-shadow: 0 2px 8px rgba(64, 128, 255, 0.3);
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
}
.record-content {
padding: 0 8px 12px 8px;
.content-item {
display: flex;
margin-bottom: 5px;
margin-bottom: 6px;
font-size: 14px;
color: #333;
padding: 6px 8px;
background: #fafbfc;
border-radius: 6px;
border-left: 3px solid #e8f4fd;
transition: all 0.2s ease;
&:hover {
background: #f0f7ff;
border-left-color: #4080ff;
}
.item-label {
font-weight: bold;
flex: 0 0 80px;
font-weight: 600;
flex: 0 0 70px;
color: #2c5aa0;
}
.item-value {
color: #4a5568;
}
}
}
}
//
.inspection-record:first-child {
.timeline-dot {
background: linear-gradient(135deg, #67c23a 0%, #85ce61 100%);
box-shadow: 0 0 0 4px #ffffff, 0 0 0 6px #f0f9ff;
}
}
//
.inspection-record:last-child {
.timeline-line {
display: none;
}
}
//
@keyframes pulse {
0% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.1);
opacity: 0.8;
}
100% {
transform: scale(1);
opacity: 1;
}
}
//
.timeline-item {
animation: fadeInUp 0.6s ease-out;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
::v-deep .zp-loading-fixed {
position: absolute;
}

View File

@ -5,7 +5,7 @@
<view class="header-content">
<view class="title-section" @click="clickShowXkSelector">
<view class="title">
<text v-if="xkData && xkData.xkmc">{{ xkData.xkmc }}</text>
<text v-if="pbData && pbData.xcbt">{{ pbData.xcbt }}</text>
<text v-else>巡查选课</text>
</view>
<view class="switch-btn" v-if="xkList.length > 1">切换</view>
@ -22,14 +22,14 @@
:key="xkkc.id || index"
class="course-item"
>
<view class="course-name">{{ xkkc.kcmc }}</view>
<view class="course-info-item">
<view class="info-label">年级</view>
<view class="info-data">{{ xkkc.gradeName || '暂无' }}</view>
<!-- 巡查状态标识 -->
<view class="course-status" :class="xkkc.sfxc === '是' ? 'status-done' : 'status-pending'">
{{ xkkc.sfxc === '是' ? '已巡查' : '待巡查' }}
</view>
<view class="course-info-item" v-if="xkkc.bjmc">
<view class="info-label">班级</view>
<view class="info-data">{{ xkkc.bjmc || '暂无' }}</view>
<view class="course-name">{{ getCourseDisplayName(xkkc) }}</view>
<view class="course-info-item">
<view class="info-label">巡查时间</view>
<view class="info-data">{{ getInspectionTime(xkkc) }}</view>
</view>
<view class="separator-line"></view>
<view class="course-btn-group">
@ -95,6 +95,7 @@ import { getKyXcCourseListApi } from "@/api/base/pbApi";
import { useDataStore } from "@/store/modules/data";
import { useUserStore } from "@/store/modules/user";
import { onBeforeUnmount, onMounted, ref } from "vue";
import dayjs from "dayjs";
const { getJs } = useUserStore();
const dataStore = useDataStore();
@ -108,25 +109,105 @@ const xkData = ref();
//
const xkkcList = ref<any[]>([]);
//
const pbData = ref<any>(null);
const xcBeforeMinute = ref<number>(0);
//
const wdNameList = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"];
//
const isCurrentTimeMatch = (xkkc: any) => {
const now = dayjs();
let wDay = now.day();
if (wDay === 0) {
wDay = 7; // 07
}
let mDay = now.date(); // 1-31
//
switch (xkkc.skzqlx) {
case '每天':
return true; //
case '每周':
if (!xkkc.skzq) return false;
const daysOfWeek = xkkc.skzq.split(',').map(Number);
return daysOfWeek.includes(wDay);
case '每月':
if (!xkkc.skzq) return false;
const daysOfMonth = xkkc.skzq.split(',').map(Number);
return daysOfMonth.includes(mDay);
default:
return false;
}
};
//
const getCourseDisplayName = (xkkc: any) => {
//
let gradeClassInfo = '';
if (xkkc.gradeName) {
gradeClassInfo = xkkc.gradeName;
}
if (xkkc.bjmc && xkkc.bjmc !== '暂无') {
if (gradeClassInfo) {
gradeClassInfo += `-${xkkc.bjmc}`;
} else {
gradeClassInfo = xkkc.bjmc;
}
}
//
return gradeClassInfo || '暂无信息';
};
//
const getInspectionTime = (xkkc: any) => {
if (!xkkc.skzqlx || !xkkc.skzq) {
return '暂无';
}
//
switch (xkkc.skzqlx) {
case '每天':
return '每天';
case '每周':
if (!xkkc.skzq) return '每周';
const daysOfWeek = xkkc.skzq.split(',').map(Number);
const weekDays = daysOfWeek.map((day: number) => wdNameList[day - 1]).join('、');
return `每周 ${weekDays}`;
case '每月':
if (!xkkc.skzq) return '每月';
const daysOfMonth = xkkc.skzq.split(',').map(Number);
const monthDays = daysOfMonth.map((day: number) => day + '号').join('、');
return `每月 ${monthDays}`;
default:
return '暂无';
}
};
onMounted(async () => {
uni.showLoading({
title: "加载中...",
});
// globaldata
let pbData = dataStore.getGlobal;
// dataxcPbList.vue
let tempPbData = dataStore.getData;
if (!pbData || !pbData.xcbt ) {
pbData = dataStore.getData;
//
console.log('初始获取的data数据:', tempPbData);
// dataglobal
if (!tempPbData || !tempPbData.xcbt) {
tempPbData = dataStore.getGlobal;
console.log('从global获取的数据:', tempPbData);
}
//
if (!pbData || !pbData.xcbt ) {
if (!tempPbData || !tempPbData.xcbt) {
uni.showToast({
title: '数据异常,请重新选择排班',
icon: 'none'
@ -134,41 +215,154 @@ onMounted(async () => {
uni.navigateBack();
return;
}
// kcmc
if (pbData.kcmc) {
//
//
const reconstructedPbData = {
id: pbData.pbId, // 使pbIdID
status: 'A',
njIds: pbData.njIds || '',
bjIds: pbData.bjIds || '',
xcbt: pbData.xcbt,
xclx: pbData.xclx,
xqId: pbData.xqId,
xqmc: pbData.xqmc,
xcstime: pbData.xcstime,
xcjstime: pbData.xcjstime,
pk: pbData.pk
};
dataStore.setGlobal(reconstructedPbData);
pbData = reconstructedPbData;
} else {
//
dataStore.setGlobal(pbData);
//
if (tempPbData.kcmc) {
console.warn('检测到课程数据尝试从global获取排班数据');
tempPbData = dataStore.getGlobal;
if (!tempPbData || !tempPbData.xcbt) {
uni.showToast({
title: '排班数据丢失,请重新选择排班',
icon: 'none'
});
uni.navigateBack();
return;
}
}
await loadXcCourseList(pbData);
// kcmc
if (tempPbData.kcmc) {
//
//
// 使ID使pbId使id
const pbId = tempPbData.pbId || tempPbData.id;
const reconstructedPbData = {
pbId: pbId, // ID使pbId
status: 'A',
njIds: tempPbData.njIds || '',
bjIds: tempPbData.bjIds || '',
xcbt: tempPbData.xcbt,
xclx: tempPbData.xclx,
xqId: tempPbData.xqId,
xqmc: tempPbData.xqmc,
xcstime: tempPbData.xcstime,
xcjstime: tempPbData.xcjstime,
pk: tempPbData.pk,
pbLxId: tempPbData.pbLxId || '1165059210334048256' // ID使
};
dataStore.setGlobal(reconstructedPbData);
pbData.value = reconstructedPbData;
tempPbData = reconstructedPbData;
} else {
// pbIdAPI
// pbIdid
const pbId = tempPbData.pbId || tempPbData.id;
const pbDataWithPbId = {
...tempPbData,
pbId: pbId, // 使pbId
pbLxId: tempPbData.pbLxId || '1165059210334048256' // ID使
};
// idpbId
delete pbDataWithPbId.id;
dataStore.setGlobal(pbDataWithPbId);
pbData.value = pbDataWithPbId;
tempPbData = pbDataWithPbId;
}
//
console.log('排班数据检查:', {
tempPbData,
pbId: tempPbData.pbId,
pbLxId: tempPbData.pbLxId,
hasKcmc: !!tempPbData.kcmc,
allKeys: Object.keys(tempPbData),
pbIdField: tempPbData.pbId,
isFromXcPbList: !tempPbData.kcmc && tempPbData.xcbt //
});
await loadXcCourseList(tempPbData);
//
uni.$on('refreshCourseList', async () => {
console.log('收到刷新事件,重新加载课业辅导巡查课程列表');
await refreshCourseList();
});
//
uni.$on('silentRefreshCourseList', async () => {
console.log('收到静默刷新事件,重新加载课业辅导巡查课程列表');
await silentRefreshCourseList();
});
uni.hideLoading();
});
//
const refreshCourseList = async () => {
try {
uni.showLoading({
title: "刷新中...",
});
//
const pbData = dataStore.getGlobal;
//
console.log('刷新时的排班数据:', {
pbData,
pbId: pbData?.pbId,
pbIdField: pbData?.pbId
});
if (pbData && pbData.pbId) {
//
await loadXcCourseList(pbData);
}
uni.hideLoading();
uni.showToast({
title: "刷新成功",
icon: "success",
duration: 1000
});
} catch (error) {
console.error('刷新课程列表失败:', error);
uni.hideLoading();
uni.showToast({
title: "刷新失败",
icon: "none"
});
}
};
//
const silentRefreshCourseList = async () => {
try {
//
const pbData = dataStore.getGlobal;
//
console.log('静默刷新时的排班数据:', {
pbData,
pbId: pbData?.pbId,
pbIdField: pbData?.pbId
});
if (pbData && pbData.pbId) {
//
await loadXcCourseList(pbData);
}
} catch (error) {
console.error('静默刷新课程列表失败:', error);
//
}
};
//
const loadXcCourseList = async (pbData: any) => {
try {
// pbData
if (!pbData || !pbData.id) {
if (!pbData || !pbData.pbId) {
uni.showToast({
title: '排班数据无效,请重新选择',
icon: 'none'
@ -176,24 +370,47 @@ const loadXcCourseList = async (pbData: any) => {
return;
}
// ID使pbId
const pbId = pbData.pbId;
//
console.log('API调用参数:', {
jsId: getJs.id,
pbId: pbId,
pbData: pbData
});
const res = await getKyXcCourseListApi({
jsId: getJs.id,
pbId: pbData.id
pbId: pbId
});
if (res && res.resultCode == 1) {
const list = res.result || [];
xkkcList.value = list.map((item: any) => ({
//
let mappedList = list.map((item: any) => ({
id: item.id, // ID
pbId: item.pbId || pbData.id, // ID
pbId: pbId, // ID
kcmc: item.kmmc || '课业辅导',
gradeName: item.bc && item.njmc ? `${item.bc}(${item.njmc})` : (item.njmc || '暂无'),
bjmc: item.bjmc || '暂无',
// ID
njId: item.njId || '',
njmcId: item.njmcId || '',
bjId: item.bjId || ''
bjId: item.bjId || '',
//
skzqlx: item.skzqlx || '',
skzq: item.skzq || '',
//
sfxc: item.sfxc || '否',
//
xcstime: item.xcstime || '',
xcjstime: item.xcjstime || ''
}));
//
xkkcList.value = mappedList.filter((xkkc: any) => isCurrentTimeMatch(xkkc));
} else {
xkkcList.value = [];
uni.showToast({
@ -252,8 +469,12 @@ function switchXk(xk: any) {
//
const goXc = (xkkc: any) => {
// global
const pbData = dataStore.getGlobal;
// globaldata
let pbData = dataStore.getGlobal;
if (!pbData || !pbData.xcbt ) {
pbData = dataStore.getData;
}
//
if (!pbData || !pbData.xcbt ) {
@ -267,17 +488,29 @@ const goXc = (xkkc: any) => {
//
const combinedData = {
...xkkc,
id: xkkc.id, // ID
pbId: pbData.id, // ID - 使ID
pbJsId: xkkc.id, // IDpbJsId
pbId: pbData.pbId, // ID - 使pb_id
pbNjBjId: xkkc.id, // IDpbNjBjId
pbLxId: pbData.pbLxId || '', // ID
xclx: pbData.xclx,
xcbt: pbData.xcbt,
xqmc: pbData.xqmc,
// ID
njId: xkkc.njId || '',
njmcId: xkkc.njmcId || '',
bjId: xkkc.bjId || ''
bjId: xkkc.bjId || '',
//
xcstime: xkkc.xcstime || '',
xcjstime: xkkc.xcjstime || ''
};
//
console.log('点击巡查,传递数据:', {
pbLxId: combinedData.pbLxId,
pbId: combinedData.pbId,
pbNjBjId: combinedData.pbNjBjId,
pbData: pbData,
xkkc: xkkc
});
// 使
dataStore.setData(combinedData); //
@ -285,7 +518,7 @@ const goXc = (xkkc: any) => {
// global
// combinedData
const originalPbData = {
id: pbData.id, // ID
pbId: pbData.pbId, // ID使pbId
status: pbData.status,
njIds: pbData.njIds,
bjIds: pbData.bjIds,
@ -295,7 +528,8 @@ const goXc = (xkkc: any) => {
xqmc: pbData.xqmc,
xcstime: pbData.xcstime,
xcjstime: pbData.xcjstime,
pk: pbData.pk
pk: pbData.pk,
pbLxId: pbData.pbLxId || '' // ID
};
dataStore.setGlobal(originalPbData); // global
@ -306,8 +540,12 @@ const goXc = (xkkc: any) => {
//
const goRecord = (xkkc: any) => {
// global
const pbData = dataStore.getGlobal;
// globaldata
let pbData = dataStore.getGlobal;
if (!pbData || !pbData.xcbt ) {
pbData = dataStore.getData;
}
//
if (!pbData || !pbData.xcbt ) {
@ -321,8 +559,8 @@ const goRecord = (xkkc: any) => {
//
const combinedData = {
...xkkc,
id: xkkc.id, // ID
pbId: pbData.id, // ID - 使ID
pbId: pbData.pbId, // ID - 使pb_id
pbLxId: pbData.pbLxId || '', // ID
xclx: pbData.xclx,
xcbt: pbData.xcbt,
xqmc: pbData.xqmc,
@ -331,6 +569,24 @@ const goRecord = (xkkc: any) => {
njmcId: xkkc.njmcId || '',
bjId: xkkc.bjId || ''
};
// global
const originalPbData = {
pbId: pbData.pbId, // ID使pbId
status: pbData.status,
njIds: pbData.njIds,
bjIds: pbData.bjIds,
xcbt: pbData.xcbt,
xclx: pbData.xclx,
xqId: pbData.xqId,
xqmc: pbData.xqmc,
xcstime: pbData.xcstime,
xcjstime: pbData.xcjstime,
pk: pbData.pk,
pbLxId: pbData.pbLxId || '' // ID
};
dataStore.setGlobal(originalPbData); // global
dataStore.setData(combinedData);
uni.navigateTo({
url: `/pages/view/routine/kefuxuncha/kyRecord`,
@ -338,8 +594,12 @@ const goRecord = (xkkc: any) => {
};
//
onBeforeUnmount(() => {});
//
onBeforeUnmount(() => {
//
uni.$off('refreshCourseList');
uni.$off('silentRefreshCourseList');
});
</script>
<style lang="scss" scoped>
@ -450,6 +710,30 @@ onBeforeUnmount(() => {});
box-shadow: 0 4px 20px rgba(63, 191, 114, 0.15);
}
.course-status {
position: absolute;
top: 15px;
right: 15px;
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
z-index: 2;
animation: fadeInRight 0.5s ease-out 0.2s both;
&.status-done {
background: linear-gradient(135deg, #67c23a, #85ce61);
color: #fff;
box-shadow: 0 2px 8px rgba(103, 194, 58, 0.3);
}
&.status-pending {
background: linear-gradient(135deg, #e6a23c, #f0c78a);
color: #fff;
box-shadow: 0 2px 8px rgba(230, 162, 60, 0.3);
}
}
.course-name {
font-size: 17px;
font-weight: 600;
@ -457,6 +741,7 @@ onBeforeUnmount(() => {});
margin-bottom: 15px;
line-height: 1.4;
animation: fadeInLeft 0.5s ease-out 0.1s both;
padding-right: 80px; //
}
.course-btn-group {
@ -521,19 +806,21 @@ onBeforeUnmount(() => {});
display: flex;
margin-bottom: 14px;
font-size: 13px;
align-items: center;
align-items: flex-start;
animation: fadeInUp 0.5s ease-out 0.15s both;
.info-label {
color: #666;
flex: 0 0 100px;
flex: 0 0 auto;
font-weight: 500;
margin-right: 0;
}
.info-data {
flex: 1 0 1px;
color: #333;
font-weight: 400;
margin-left: 0;
}
}

View File

@ -8,36 +8,37 @@
<view class="course-icon flex-center mr-10">
<u-icon name="calendar" color="#4080ff" size="20"></u-icon>
</view>
<text class="font-16 font-bold">{{ xkkc.kcmc }}</text>
<text class="font-16 font-bold">{{ getGradeClassDisplay() }}</text>
<text class="font-14 cor-999 ml-10"
>{{ todayInfo.date }} ({{ todayInfo.weekName }})</text
>
</view>
<!-- 课业辅导信息 -->
<view class="course-time-info">
<view class="time-item">
<view class="time-label">年级</view>
<view class="time-value">{{ xkkc.gradeName || '暂无' }}</view>
</view>
<view class="time-item" v-if="xkkc.bjmc">
<view class="time-label">班级</view>
<view class="time-value">{{ xkkc.bjmc || '暂无' }}</view>
</view>
<view class="time-item">
<view class="time-label">巡查类型</view>
<view class="time-value">{{ xkkc.xclx === 'B' ? '课业辅导巡查' : '课程巡查' }}</view>
</view>
<view class="time-item">
<view class="time-label">排班标题</view>
<view class="time-value">{{ xkkc.xcbt || '暂无' }}</view>
</view>
<view class="time-item">
<view class="time-label">学期</view>
<view class="time-value">{{ xkkc.xqmc || '暂无' }}</view>
<!-- 任课老师选择 -->
<view class="teacher-selection-info">
<view class="teacher-item">
<view class="teacher-label">任课老师</view>
<view class="teacher-picker-container">
<BasicJsPicker
:defaultValue="selectedTeacherId"
:multiple="false"
@change="onTeacherChange"
placeholder="请选择任课老师"
ref="teacherPickerRef"
/>
</view>
</view>
</view>
<!-- 巡查时间信息 -->
<view class="inspection-time-info" v-if="xkkc.xcstime && xkkc.xcjstime">
<view class="time-item">
<u-icon name="clock" color="#4080ff" size="16"></u-icon>
<text class="time-label">巡查时间</text>
<text class="time-value">{{ xkkc.xcstime }} - {{ xkkc.xcjstime }}</text>
</view>
</view>
<!-- 巡查时间状态 -->
<view class="inspection-status" v-if="!canInspect">
<u-icon name="clock" color="#ff9900" size="16"></u-icon>
@ -112,94 +113,29 @@
</view>
</view>
<!-- 拍照上传 -->
<view class="section mx-15 mb-15">
<view class="section-title-bar">
<view class="decorator"></view>
<text class="title-text">拍照上传</text>
</view>
<view class="upload-card bg-white r-md p-15">
<view class="upload-section">
<view class="upload-list">
<view
v-for="(image, index) in imageList"
:key="index"
class="upload-item"
>
<image
:src="image.url ? imagUrl(image.url) : image.tempPath"
mode="aspectFill"
class="upload-image"
@click="previewImage(index)"
/>
<view class="upload-delete" @click="deleteImage(index)">
<text class="delete-icon">×</text>
</view>
</view>
<view
v-if="imageList.length < 5"
class="upload-add"
@click="chooseImage"
>
<text class="add-icon">+</text>
<text class="add-text">添加图片</text>
</view>
</view>
</view>
</view>
</view>
<!-- 拍视频上传 -->
<!-- 图片视频上传组件 -->
<view class="section mx-15 mb-30">
<view class="section-title-bar">
<view class="decorator"></view>
<text class="title-text">视频上传</text>
<text class="title-text">图片视频上传</text>
</view>
<view class="upload-card bg-white r-md p-15">
<view class="upload-section">
<view class="upload-list">
<view
v-for="(video, index) in videoList"
:key="index"
class="upload-item"
>
<video
:src="video.url ? video.url : video.tempPath"
class="upload-video"
:controls="false"
:show-center-play-btn="false"
:show-play-btn="false"
:show-fullscreen-btn="false"
:show-progress="false"
:show-mute-btn="false"
:enable-progress-gesture="false"
:enable-play-gesture="false"
:loop="false"
:muted="true"
:poster="''"
></video>
<view class="video-play-icon">
<u-icon name="play-right-fill" color="#fff" size="20"></u-icon>
</view>
<view class="upload-delete" @click="deleteVideo(index)">
<text class="delete-icon">×</text>
</view>
</view>
<view
v-if="videoList.length < 3"
class="upload-add"
@click="chooseVideo"
>
<text class="add-icon">+</text>
<text class="add-text">添加视频</text>
</view>
</view>
</view>
</view>
</view>
<ImageVideoUpload
v-model:image-list="imageList"
v-model:video-list="videoList"
:max-image-count="5"
:max-video-count="3"
:compress-config="compressConfig"
:upload-api="attachmentUpload"
@image-upload-success="onImageUploadSuccess"
@video-upload-success="onVideoUploadSuccess"
/>
</view>
</view>
</view>
</view>
<template #bottom>
<view
v-if="canInspect"
@ -223,6 +159,8 @@ import { xcXmFindByXcLxApi } from "@/api/base/xcXmApi";
import { kyXcSaveApi } from "@/api/base/kyXcApi";
import { attachmentUpload } from "@/api/system/upload";
import BasicLayout from "@/components/BasicLayout/Layout.vue";
import BasicJsPicker from "@/components/BasicJsPicker/Picker.vue";
import { ImageVideoUpload } from "@/components/ImageVideoUpload";
import { useDataStore } from "@/store/modules/data";
import { useUserStore } from "@/store/modules/user";
import { computed, onMounted, ref } from "vue";
@ -236,6 +174,30 @@ const { getData } = useDataStore();
const js = computed(() => getJs);
const xkkc = computed(() => getData);
//
const getGradeClassDisplay = () => {
const gradeName = xkkc.value.gradeName || '';
const bjmc = xkkc.value.bjmc || '';
//
let displayText = '';
if (gradeName) {
displayText = gradeName;
}
if (bjmc && bjmc !== '暂无') {
if (displayText) {
displayText += bjmc;
} else {
displayText = bjmc;
}
}
//
return displayText || '暂无信息';
};
const now = dayjs();
let wDay = now.day();
if (wDay === 0) {
@ -250,19 +212,11 @@ const todayInfo = ref({
//
const checkItems = ref<any[]>([]);
// -
interface ImageItem {
tempPath?: string; //
url?: string; //
name?: string; //
}
//
import { type ImageItem, type VideoItem, COMPRESS_PRESETS } from '@/components/ImageVideoUpload'
// -
interface VideoItem {
tempPath?: string; //
url?: string; //
name?: string; //
}
//
const compressConfig = ref(COMPRESS_PRESETS.medium)
const imageList = ref<ImageItem[]>([]);
const videoList = ref<VideoItem[]>([]);
@ -274,6 +228,11 @@ const canInspect = ref(true); // 是否可以巡查
//
const isSubmitting = ref(false);
//
const selectedTeacher = ref<any>(null);
const selectedTeacherId = ref<string>('');
const teacherPickerRef = ref();
//
const loadCheckItems = async () => {
try {
@ -302,140 +261,13 @@ const onCheckItemChange = (e: any, item: any) => {
item.checked = e.detail.value === "A";
};
//
const chooseImage = () => {
uni.chooseImage({
count: 5 - imageList.value.length,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: async (res) => {
//
const tempFilePaths = res.tempFilePaths as string[];
const newImages = tempFilePaths.map((path: string) => ({
tempPath: path,
name: path.split('/').pop() || 'image.jpg'
}));
imageList.value = [...imageList.value, ...newImages];
//
await uploadImages(newImages);
}
});
//
const onImageUploadSuccess = (image: ImageItem, index: number) => {
console.log('图片上传成功:', image, index);
};
//
const chooseVideo = () => {
uni.chooseVideo({
sourceType: ['album', 'camera'],
maxDuration: 60,
camera: 'back',
success: async (res) => {
//
const tempFilePath = res.tempFilePath;
const newVideo = {
tempPath: tempFilePath,
name: tempFilePath.split('/').pop() || 'video.mp4'
};
videoList.value = [...videoList.value, newVideo];
//
await uploadVideos([newVideo]);
}
});
};
//
const uploadImages = async (images: ImageItem[]) => {
try {
showLoading('上传图片中...');
for (let i = 0; i < images.length; i++) {
const image = images[i];
if (image.tempPath) {
try {
//
const uploadResult: any = await attachmentUpload(image.tempPath as any);
if (uploadResult && uploadResult.resultCode === 1 && uploadResult.result && uploadResult.result.length > 0) {
//
const serverPath = uploadResult.result[0].filePath;
//
const index = imageList.value.findIndex(img => img.tempPath === image.tempPath);
if (index !== -1) {
imageList.value[index].url = serverPath;
//
delete imageList.value[index].tempPath;
}
} else {
throw new Error('上传响应格式异常');
}
} catch (error) {
console.error('图片上传失败:', error);
showToast({ title: `${image.name || '图片'}上传失败`, icon: 'none' });
//
const index = imageList.value.findIndex(img => img.tempPath === image.tempPath);
if (index !== -1) {
imageList.value.splice(index, 1);
}
}
}
}
hideLoading();
showToast({ title: '图片上传完成', icon: 'success' });
} catch (error) {
hideLoading();
console.error('批量上传图片失败:', error);
showToast({ title: '图片上传失败,请重试', icon: 'none' });
}
};
//
const uploadVideos = async (videos: VideoItem[]) => {
try {
showLoading('上传视频中...');
for (let i = 0; i < videos.length; i++) {
const video = videos[i];
if (video.tempPath) {
try {
//
const uploadResult: any = await attachmentUpload(video.tempPath as any);
if (uploadResult && uploadResult.resultCode === 1 && uploadResult.result && uploadResult.result.length > 0) {
//
const serverPath = uploadResult.result[0].filePath;
//
const index = videoList.value.findIndex(v => v.tempPath === video.tempPath);
if (index !== -1) {
videoList.value[index].url = serverPath;
//
delete videoList.value[index].tempPath;
}
} else {
throw new Error('上传响应格式异常');
}
} catch (error) {
console.error('视频上传失败:', error);
showToast({ title: `${video.name || '视频'}上传失败`, icon: 'none' });
//
const index = videoList.value.findIndex(v => v.tempPath === video.tempPath);
if (index !== -1) {
videoList.value.splice(index, 1);
}
}
}
}
hideLoading();
showToast({ title: '视频上传完成', icon: 'success' });
} catch (error) {
hideLoading();
console.error('批量上传视频失败:', error);
showToast({ title: '视频上传失败,请重试', icon: 'none' });
}
const onVideoUploadSuccess = (video: VideoItem, index: number) => {
console.log('视频上传成功:', video, index);
};
//
@ -450,21 +282,71 @@ const previewImage = (index: number) => {
});
};
//
const deleteImage = (index: number) => {
imageList.value.splice(index, 1);
};
//
const deleteVideo = (index: number) => {
videoList.value.splice(index, 1);
};
// -
// -
const checkInspectionTime = () => {
//
canInspect.value = true;
inspectionStatusText.value = "可以巡查";
const xcstime = xkkc.value.xcstime;
const xcjstime = xkkc.value.xcjstime;
//
if (!xcstime || !xcjstime) {
canInspect.value = true;
inspectionStatusText.value = "可以巡查";
return;
}
//
const now = dayjs();
const currentTime = now.format("HH:mm:ss");
//
const currentTimeMinutes = timeToMinutes(currentTime);
const startTimeMinutes = timeToMinutes(xcstime);
const endTimeMinutes = timeToMinutes(xcjstime);
//
if (currentTimeMinutes < startTimeMinutes) {
canInspect.value = false;
inspectionStatusText.value = `还未到巡查时间,无法巡查(巡查时间:${xcstime} - ${xcjstime}`;
} else if (currentTimeMinutes > endTimeMinutes) {
canInspect.value = false;
inspectionStatusText.value = `巡查时间已结束,无法巡查(巡查时间:${xcstime} - ${xcjstime}`;
} else {
canInspect.value = true;
inspectionStatusText.value = `可以巡查(巡查时间:${xcstime} - ${xcjstime}`;
}
};
// 便
const timeToMinutes = (timeStr: string) => {
if (!timeStr || !timeStr.includes(':')) {
return 0;
}
const parts = timeStr.split(':');
if (parts.length !== 3) {
return 0;
}
const hours = parseInt(parts[0], 10) || 0;
const minutes = parseInt(parts[1], 10) || 0;
const seconds = parseInt(parts[2], 10) || 0;
return hours * 60 + minutes + seconds / 60;
};
//
const onTeacherChange = (teacher: any) => {
console.log('任课老师选择变化:', teacher);
if (teacher) {
selectedTeacher.value = teacher;
selectedTeacherId.value = teacher.id || teacher.value;
console.log('选中的老师:', selectedTeacher.value);
console.log('选中的老师ID:', selectedTeacherId.value);
} else {
selectedTeacher.value = null;
selectedTeacherId.value = '';
}
};
//
@ -479,6 +361,9 @@ const submit = async () => {
return;
}
//
checkInspectionTime();
if (!canInspect.value) {
uni.showToast({
title: inspectionStatusText.value,
@ -499,6 +384,20 @@ const submit = async () => {
return;
}
//
console.log('提交时检查任课老师:', {
selectedTeacher: selectedTeacher.value,
selectedTeacherId: selectedTeacherId.value
});
if (!selectedTeacher.value && !selectedTeacherId.value) {
uni.showToast({
title: "请选择任课老师",
icon: "none",
duration: 2000,
});
return;
}
//
isSubmitting.value = true;
@ -521,13 +420,22 @@ const submit = async () => {
njId: xkkc.value.njId || '', // ID
njmcId: xkkc.value.njmcId || '', // ID
bjId: xkkc.value.bjId || '', // ID
pbJsId: xkkc.value.pbJsId || xkkc.value.id, // 使pbJsIdID
pbNjBjId: xkkc.value.pbNjBjId || xkkc.value.id, // 使pbNjBjIdID
pbLxId: xkkc.value.pbLxId || '', // ID
rkjsId: selectedTeacherId.value, // ID
xctime: now.format("YYYY-MM-DD HH:mm:ss"),
zp: getImageUrls(),
sp: getVideoUrls(),
kyXcXmList: xkXcXmList, //
};
//
console.log('提交巡查数据:', {
pbLxId: submitData.pbLxId,
pbNjBjId: submitData.pbNjBjId,
xkkcData: xkkc.value
});
const res = await kyXcSaveApi(submitData);
if (res && res.resultCode === 1) {
@ -536,7 +444,12 @@ const submit = async () => {
icon: "success",
});
setTimeout(() => {
uni.navigateBack();
uni.navigateBack({
success: () => {
// kyXkList
uni.$emit('silentRefreshCourseList');
}
});
}, 1500);
} else {
uni.showToast({
@ -611,27 +524,53 @@ onMounted(async () => {
background-color: rgba(64, 128, 255, 0.1);
}
.course-time-info {
.teacher-selection-info {
margin-top: 15px;
padding: 10px 15px;
background-color: #f9f9f9;
border-radius: 4px;
border: 1px solid #eee;
.time-item {
.teacher-item {
display: flex;
justify-content: space-between;
margin-bottom: 5px;
align-items: center;
.teacher-label {
font-size: 14px;
color: #666;
flex-shrink: 0;
}
.teacher-picker-container {
flex: 1;
margin-left: 10px;
}
}
}
.inspection-time-info {
margin-top: 15px;
padding: 10px 15px;
background-color: #f0f8ff;
border-radius: 4px;
border: 1px solid #d6e4ff;
.time-item {
display: flex;
align-items: center;
.time-label {
font-size: 14px;
color: #666;
margin-left: 5px;
margin-right: 5px;
}
.time-value {
font-size: 14px;
font-weight: bold;
color: #333;
color: #4080ff;
font-weight: 500;
}
}
}

View File

@ -6,7 +6,7 @@
<view class="title-section">
<view class="title">
<text v-if="pbList && pbList.length > 0">
{{ getCurrentSemesterName() }} - 选课排班
{{ getCurrentSemesterName() }} - 教学巡查
</text>
<text v-else>暂无排班数据</text>
</view>
@ -15,7 +15,15 @@
</view>
<!-- 可滚动的内容区域 -->
<view class="scrollable-content">
<scroll-view
class="scrollable-content"
scroll-y
@scrolltolower="onScrollToLower"
:refresher-enabled="true"
:refresher-triggered="refreshing"
@refresherrefresh="onRefresh"
refresher-background="#f8fafc"
>
<!-- 排班列表 -->
<view class="course-list" v-if="pbList && pbList.length > 0">
<view v-for="(pb, index) in pbList" :key="pb.id || index" class="course-item">
@ -49,13 +57,18 @@
</view>
<view class="empty-text">暂无排班数据</view>
</view>
</view>
<!-- 加载更多 -->
<view class="load-more" v-if="hasMore && pbList.length > 0">
<u-loading-icon v-if="loading" mode="spinner" size="20"></u-loading-icon>
<text v-else @click="loadMore">加载更多</text>
</view>
<!-- 加载更多 -->
<view class="load-more" v-if="hasMore && pbList.length > 0">
<u-loading-icon v-if="loading" mode="spinner" size="20"></u-loading-icon>
<text v-else>加载更多</text>
</view>
<!-- 没有更多数据提示 -->
<view class="no-more" v-if="!hasMore && pbList.length > 0">
<text>没有更多数据了</text>
</view>
</scroll-view>
</view>
</template>
@ -79,6 +92,7 @@ const { getData, setData } = useDataStore();
const pbList = ref<any[]>([]);
const loading = ref(false);
const hasMore = ref(true);
const refreshing = ref(false);
//
const pagination = reactive({
@ -150,6 +164,7 @@ const loadPbList = async (isRefresh = false) => {
});
} finally {
loading.value = false;
refreshing.value = false;
}
};
@ -161,8 +176,17 @@ const loadMore = () => {
}
};
//
const onScrollToLower = () => {
if (hasMore.value && !loading.value) {
pagination.page++;
loadPbList();
}
};
//
const onRefresh = () => {
refreshing.value = true;
loadPbList(true);
};
@ -222,7 +246,14 @@ const goXc = (pb: any) => {
console.log('pb.id:', pb?.id);
console.log('pb的所有属性:', Object.keys(pb || {}));
setData(pb);
// 使pbId
const pbData = {
...pb,
pbId: pb.id, // idpbId
};
delete pbData.id; // id
setData(pbData);
if (pb.xclx === 'A') {
//
uni.navigateTo({
@ -448,6 +479,20 @@ defineExpose({
}
}
//
.no-more {
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
color: #999;
font-size: 12px;
text {
color: #999;
}
}
//
@keyframes fadeInUp {
from {

View File

@ -7,10 +7,18 @@
style="position: absolute"
>
<template v-slot="{ data, index }">
<view class="inspection-record bg-white r-md p-15 mb-15">
<view class="inspection-record bg-white r-md p-15 mb-15 timeline-item">
<!-- 时间轴连接线 -->
<view class="timeline-line" v-if="index < 10"></view>
<!-- 时间轴节点 -->
<view class="timeline-dot">
<view class="dot-inner"></view>
</view>
<view class="record-header">
<view class="record-time">
<u-icon name="clock" color="#666" size="14"></u-icon>
<u-icon name="clock" color="#4080ff" size="16"></u-icon>
<text class="time-text">{{ formatTime(data.xctime) }}</text>
</view>
<view class="record-status">
@ -21,6 +29,12 @@
<view class="content-item">
<text class="item-label">巡查教师</text>
<text class="item-value">{{ data.jsxm }}</text>
<text class="item-label" style="margin-left: 20px;">代课教师</text>
<text class="item-value">{{ data.dkjsxm || '无' }}</text>
</view>
<view class="content-item">
<text class="item-label">点名时间</text>
<text class="item-value">{{ data.dmTime ? formatTime(data.dmTime) : '无' }}</text>
</view>
<view class="content-item flex-col">
<text class="item-label" style="flex: 0 0 25px"
@ -36,20 +50,25 @@
style="margin-bottom: 4px"
>
<view>
<text>{{ idx + 1 }}{{ xm.xcMc }}</text>
<view
style="
display: flex;
justify-content: space-between;
margin: 4px 0;
"
>
<text>分值{{ xm.xmFz }}</text>
<text
>巡查结果{{
xm.xcJg === "A" ? "有" : "无"
}}</text
>
<view style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px;">
<view style="display: flex; align-items: center; flex: 1;">
<text style="margin-right: 8px;">{{ idx + 1 }}{{ xm.xcMc }}</text>
<view style="display: flex; align-items: center;">
<u-icon
:name="xm.xcJg === 'B' ? 'checkmark-circle-fill' : 'close-circle-fill'"
:color="xm.xcJg === 'B' ? '#67c23a' : '#f56c6c'"
size="18"
></u-icon>
</view>
</view>
<view style="font-size: 12px; color: #666;">
<text v-if="xm.xcJg === 'A'" style="color: #f56c6c;">
扣分-{{ xm.xmFz }}
</text>
<text v-else style="color: #67c23a;">
不扣分
</text>
</view>
</view>
</view>
</view>
@ -173,9 +192,11 @@ const xkkc = computed(() => getData);
let inspectionParams = ref({
rows: 10,
xkkcId: xkkc.value.id,
jsId: js.value.id,
});
//
console.log('巡查记录查询参数:', inspectionParams.value);
//
const [registerInspection, { reload }] = useLayout({
api: xkXcFindPageApi,
@ -186,9 +207,10 @@ const [registerInspection, { reload }] = useLayout({
//
const handlePreviewImage = (img: string, images: string[]) => {
// uni-appAPI
const processedImages = images.map(image => imagUrl(image));
uni.previewImage({
current: img,
urls: images,
current: imagUrl(img),
urls: processedImages,
});
};
@ -199,7 +221,7 @@ const handlePreviewVideo = (videos: string[], index: number) => {
uni.previewMedia({
current: index,
sources: videos.map((url) => ({
url,
url: imagUrl(url),
type: "video",
})),
});
@ -207,7 +229,10 @@ const handlePreviewVideo = (videos: string[], index: number) => {
//
const formatTime = (timestamp: string) => {
return dayjs(timestamp).format("YYYY-MM-DD HH:mm");
const date = dayjs(timestamp);
const weekDays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
const weekDay = weekDays[date.day()];
return `${weekDay} ${date.format("YYYY-MM-DD HH:mm")}`;
};
//
@ -232,52 +257,170 @@ onMounted(() => {
.inspection-list {
position: relative;
height: calc(100vh - 50px);
padding-left: 12px; //
.inspection-record {
position: relative;
margin-left: 0; //
background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
border: 1px solid #e8f4fd;
transition: all 0.3s ease;
overflow: hidden;
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12);
border-color: #4080ff;
}
// 线
.timeline-line {
position: absolute;
left: -12px;
top: 0;
width: 2px;
height: 100%;
background: linear-gradient(180deg, #4080ff 0%, #e8f4fd 100%);
z-index: 1;
}
//
.timeline-dot {
position: absolute;
left: -18px;
top: 20px;
width: 12px;
height: 12px;
background: #4080ff;
border-radius: 50%;
z-index: 2;
box-shadow: 0 0 0 4px #ffffff, 0 0 0 6px #e8f4fd;
.dot-inner {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #4080ff 0%, #66b3ff 100%);
border-radius: 50%;
animation: pulse 2s infinite;
}
}
.record-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
padding-bottom: 10px;
border-bottom: 1px solid #eee;
margin-bottom: 15px;
padding: 12px 8px;
background: linear-gradient(135deg, #f0f7ff 0%, #e8f4fd 100%);
border-radius: 8px 8px 0 0;
border-bottom: 1px solid #d1e7ff;
.record-time {
display: flex;
align-items: center;
font-size: 14px;
color: #666;
font-size: 15px;
color: #2c5aa0;
font-weight: 600;
.time-text {
margin-left: 5px;
margin-left: 8px;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
}
.record-status {
padding: 3px 8px;
border-radius: 4px;
border: 1px solid #4080ff;
display: inline-flex;
padding: 6px 12px;
border-radius: 20px;
background: linear-gradient(135deg, #4080ff 0%, #66b3ff 100%);
color: #ffffff;
font-size: 12px;
color: #4080ff;
font-weight: 600;
box-shadow: 0 2px 8px rgba(64, 128, 255, 0.3);
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
}
.record-content {
padding: 0 8px 12px 8px;
.content-item {
display: flex;
margin-bottom: 5px;
margin-bottom: 6px;
font-size: 14px;
color: #333;
padding: 6px 8px;
background: #fafbfc;
border-radius: 6px;
border-left: 3px solid #e8f4fd;
transition: all 0.2s ease;
&:hover {
background: #f0f7ff;
border-left-color: #4080ff;
}
.item-label {
font-weight: bold;
flex: 0 0 80px;
font-weight: 600;
flex: 0 0 70px;
color: #2c5aa0;
}
.item-value {
color: #4a5568;
}
}
}
}
//
.inspection-record:first-child {
.timeline-dot {
background: linear-gradient(135deg, #67c23a 0%, #85ce61 100%);
box-shadow: 0 0 0 4px #ffffff, 0 0 0 6px #f0f9ff;
}
}
//
.inspection-record:last-child {
.timeline-line {
display: none;
}
}
//
@keyframes pulse {
0% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.1);
opacity: 0.8;
}
100% {
transform: scale(1);
opacity: 1;
}
}
//
.timeline-item {
animation: fadeInUp 0.6s ease-out;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
::v-deep .zp-loading-fixed {
position: absolute;
}

View File

@ -22,26 +22,33 @@
:key="xkkc.id || index"
class="course-item"
>
<!-- 巡查状态标识 -->
<view class="course-status" :class="xkkc.sfxc === '是' ? 'status-done' : 'status-pending'">
{{ xkkc.sfxc === '是' ? '已巡查' : '待巡查' }}
</view>
<view class="course-name">{{ xkkc.kcmc }}</view>
<view class="course-info-item">
<view class="info-label">上课周期</view>
<view class="info-data">{{ xkkc.skzqmc }}</view>
<!-- 第一行上课周期开课年级 -->
<view class="course-info-row">
<view class="course-info-item">
<view class="info-label">上课周期</view>
<view class="info-data">{{ xkkc.skzqmc }}</view>
</view>
<view class="course-info-item">
<view class="info-label">开课年级</view>
<view class="info-data">{{ xkkc.njname || '暂无' }}</view>
</view>
</view>
<view class="course-info-item">
<view class="info-label">上课时间</view>
<view class="info-data">{{ formatClassTime(xkkc.skkstime, xkkc.skjstime) }}</view>
</view>
<view class="course-info-item">
<view class="info-label">开课地点</view>
<view class="info-data">{{ xkkc.kcdd }}</view>
</view>
<view class="course-info-item">
<view class="info-label">授课教师</view>
<view class="info-data">{{ xkkc.jsName || '暂无' }}</view>
</view>
<view class="course-info-item">
<view class="info-label">开课年级</view>
<view class="info-data">{{ xkkc.njname || '暂无' }}</view>
<!-- 第二行授课教师开课地点 -->
<view class="course-info-row">
<view class="course-info-item">
<view class="info-label">授课教师</view>
<view class="info-data">{{ xkkc.jsName || '暂无' }}</view>
</view>
<view class="course-info-item">
<view class="info-label">开课地点</view>
<view class="info-data">{{ xkkc.kcdd }}</view>
</view>
</view>
<view class="separator-line"></view>
<view class="course-btn-group">
@ -114,6 +121,32 @@ const dataStore = useDataStore();
const wdNameList = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"];
//
const isCurrentTimeMatch = (xkkc: any) => {
const now = dayjs();
let wDay = now.day();
if (wDay === 0) {
wDay = 7; // 07
}
let mDay = now.date(); // 1-31
//
switch (xkkc.skzqlx) {
case '每天':
return true; //
case '每周':
if (!xkkc.skzq) return false;
const daysOfWeek = xkkc.skzq.split(',').map(Number);
return daysOfWeek.includes(wDay);
case '每月':
if (!xkkc.skzq) return false;
const daysOfMonth = xkkc.skzq.split(',').map(Number);
return daysOfMonth.includes(mDay);
default:
return false;
}
};
//
const showXkFlag = ref(false);
@ -133,12 +166,12 @@ onMounted(async () => {
//
const pbData = dataStore.getData;
if (pbData && pbData.id) {
if (pbData && pbData.pbId) {
// kcmc
if (pbData.kcmc) {
//
const reconstructedPbData = {
id: pbData.pbId, // 使pbIdID
pbId: pbData.pbId, // 使pbIdID
status: 'A',
njIds: pbData.njIds || '',
bjIds: pbData.bjIds || '',
@ -153,15 +186,26 @@ onMounted(async () => {
dataStore.setGlobal(reconstructedPbData);
await loadXcCourseList(reconstructedPbData);
} else {
// 使
dataStore.setGlobal(pbData);
await loadXcCourseList(pbData);
// pbId
const pbDataWithPbId = {
...pbData,
pbId: pbData.pbId || pbData.id
};
delete pbDataWithPbId.id; // id
dataStore.setGlobal(pbDataWithPbId);
await loadXcCourseList(pbDataWithPbId);
}
} else {
//
await loadCourseList();
}
//
uni.$on('refreshCourseList', async () => {
console.log('收到刷新事件,重新加载课程列表');
await refreshCourseList();
});
uni.hideLoading();
});
@ -182,21 +226,58 @@ const loadCourseList = async () => {
}
};
//
const refreshCourseList = async () => {
try {
uni.showLoading({
title: "刷新中...",
});
//
const pbData = dataStore.getGlobal;
if (pbData && pbData.pbId) {
//
await loadXcCourseList(pbData);
} else {
//
await loadCourseList();
}
uni.hideLoading();
uni.showToast({
title: "刷新成功",
icon: "success",
duration: 1000
});
} catch (error) {
console.error('刷新课程列表失败:', error);
uni.hideLoading();
uni.showToast({
title: "刷新失败",
icon: "none"
});
}
};
//
const loadXcCourseList = async (pbData: any) => {
try {
const res = await getXcCourseListApi({
jsId: getJs.id,
pbId: pbData.id,
pbId: pbData.pbId,
xclx: pbData.xclx
});
if (res && res.resultCode == 1) {
const list = res.result || [];
//
let mappedList = [];
if (pbData.xclx === 'A') {
// A使
xkkcList.value = list.map((item: any) => ({
mappedList = list.map((item: any) => ({
id: item.id,
kcmc: item.kcmc,
skzqmc: item.skzqmc || '每周',
@ -209,12 +290,15 @@ const loadXcCourseList = async (pbData: any) => {
skzqlx: item.skzqlx,
skzq: item.skzq,
jsName: item.jsName,
kcjsId: item.kcjsId, // ID
jxjh: item.jxjh, //
jxll: item.jxll //
jxll: item.jxll, //
pbLxId: item.pbLxId, // ID
sfxc: item.sfxc || '否' //
}));
} else if (pbData.xclx === 'B') {
// B使
xkkcList.value = list.map((item: any) => ({
mappedList = list.map((item: any) => ({
id: item.id,
kcmc: item.kcmc,
skzqmc: item.skzqmc || '每周',
@ -227,11 +311,17 @@ const loadXcCourseList = async (pbData: any) => {
skzqlx: item.skzqlx,
skzq: item.skzq,
jsName: item.jsName,
kcjsId: item.kcjsId || item.jsId, // ID
jxjh: item.jxjh, //
jxll: item.jxll //
jxll: item.jxll, //
pbLxId: item.pbLxId, // ID
sfxc: item.sfxc || '否' //
}));
}
//
xkkcList.value = mappedList.filter((xkkc: any) => isCurrentTimeMatch(xkkc));
//
for (let i = 0; i < xkkcList.value.length; i++) {
let xkkc = xkkcList.value[i];
@ -313,7 +403,7 @@ const goXc = (xkkc: any) => {
const pbData = dataStore.getGlobal;
//
if (!pbData || !pbData.id || !pbData.xcbt) {
if (!pbData || !pbData.pbId || !pbData.xcbt) {
console.log('排班数据检查失败:', pbData);
uni.showToast({
title: '数据异常,请重新选择排班',
@ -326,12 +416,15 @@ const goXc = (xkkc: any) => {
const combinedData = {
...xkkc,
id: xkkc.id, // ID
pbId: pbData.id, // ID - 使ID
pbId: pbData.pbId, // ID - 使ID
pbLxId: xkkc.pbLxId || pbData.pbLxId, // pbLxId使 pbLxId
kcjsId: xkkc.kcjsId , // ID
xclx: pbData.xclx,
xcbt: pbData.xcbt,
xqmc: pbData.xqmc
};
dataStore.setData(combinedData);
uni.navigateTo({
url: `/pages/view/routine/kefuxuncha/xcXkkcDetail`,
@ -344,7 +437,7 @@ const goRecord = (xkkc: any) => {
const pbData = dataStore.getGlobal;
//
if (!pbData || !pbData.id || !pbData.xcbt) {
if (!pbData || !pbData.pbId || !pbData.xcbt) {
console.log('排班数据检查失败:', pbData);
uni.showToast({
title: '数据异常,请重新选择排班',
@ -357,7 +450,7 @@ const goRecord = (xkkc: any) => {
const combinedData = {
...xkkc,
id: xkkc.id, // ID
pbId: pbData.id, // ID - 使ID
pbId: pbData.pbId, // ID - 使ID
xclx: pbData.xclx,
xcbt: pbData.xcbt,
xqmc: pbData.xqmc
@ -405,8 +498,11 @@ const formatClassTime = (startTime: string, endTime: string) => {
}
};
//
onBeforeUnmount(() => {});
//
onBeforeUnmount(() => {
//
uni.$off('refreshCourseList');
});
</script>
<style lang="scss" scoped>
@ -517,6 +613,30 @@ onBeforeUnmount(() => {});
box-shadow: 0 4px 20px rgba(63, 191, 114, 0.15);
}
.course-status {
position: absolute;
top: 15px;
right: 15px;
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
z-index: 2;
animation: fadeInRight 0.5s ease-out 0.2s both;
&.status-done {
background: linear-gradient(135deg, #67c23a, #85ce61);
color: #fff;
box-shadow: 0 2px 8px rgba(103, 194, 58, 0.3);
}
&.status-pending {
background: linear-gradient(135deg, #e6a23c, #f0c78a);
color: #fff;
box-shadow: 0 2px 8px rgba(230, 162, 60, 0.3);
}
}
.course-name {
font-size: 17px;
font-weight: 600;
@ -524,6 +644,7 @@ onBeforeUnmount(() => {});
margin-bottom: 15px;
line-height: 1.4;
animation: fadeInLeft 0.5s ease-out 0.1s both;
padding-right: 80px; //
}
.course-btn-group {
@ -584,23 +705,31 @@ onBeforeUnmount(() => {});
}
}
.course-info-item {
.course-info-row {
display: flex;
margin-bottom: 14px;
gap: 20px;
animation: fadeInUp 0.5s ease-out 0.15s both;
}
.course-info-item {
display: flex;
flex: 1;
font-size: 13px;
align-items: center;
animation: fadeInUp 0.5s ease-out 0.15s both;
.info-label {
color: #666;
flex: 0 0 100px;
flex: 0 0 70px;
font-weight: 500;
margin-right: 5px;
}
.info-data {
flex: 1 0 1px;
flex: 1;
color: #333;
font-weight: 400;
word-break: break-all;
}
}

View File

@ -9,38 +9,34 @@
<u-icon name="calendar" color="#4080ff" size="20"></u-icon>
</view>
<text class="font-16 font-bold">{{ xkkc.kcmc }}</text>
<text class="font-14 cor-999 ml-10"
>{{ todayInfo.date }} ({{ todayInfo.weekName }})</text
>
<text class="font-14 cor-999 ml-10">{{ todayInfo.weekName }}</text>
</view>
<!-- 上课时间信息 -->
<!-- 课程详细信息 -->
<view class="course-time-info">
<view class="time-item">
<view class="time-label">上课周期类型</view>
<view class="time-value">{{ xkkc.skzqlx }}</view>
</view>
<view class="time-item">
<view class="time-label">上课周期</view>
<view class="time-value">{{ xkkc.skzqmc }}</view>
</view>
<view class="time-item">
<view class="time-label">上课时间</view>
<view class="time-value"
>{{ xkkc.skkstime }} - {{ xkkc.skjstime }}</view
>
<view class="time-label">授课老师</view>
<view class="time-value">{{ xkkc.jsName || '暂无' }}</view>
</view>
<view class="time-item">
<view class="time-label">上课地点</view>
<view class="time-value">{{ xkkc.kcdd }}</view>
</view>
<view class="time-item">
<view class="time-label">开课年级</view>
<view class="time-value">{{ xkkc.njname || '暂无' }}</view>
</view>
<view class="time-item">
<view class="time-label">上课人数</view>
<view class="time-value">{{ xkkc.hasNum || 0 }} | {{ xkkc.maxNum || 0 }}</view>
</view>
<!-- 代课老师选择 -->
<view class="teacher-selection-info">
<view class="teacher-item">
<view class="teacher-label">代课老师</view>
<view class="teacher-picker-container">
<BasicJsPicker
:defaultValue="selectedSubstituteTeacherId"
:multiple="false"
@change="onSubstituteTeacherChange"
placeholder="请选择代课老师"
ref="substituteTeacherPickerRef"
/>
</view>
</view>
</view>
@ -195,91 +191,25 @@
</view>
</view>
<!-- 拍照上传 -->
<view class="section mx-15 mb-15">
<view class="section-title-bar">
<view class="decorator"></view>
<text class="title-text">拍照上传</text>
</view>
<view class="upload-card bg-white r-md p-15">
<view class="upload-section">
<view class="upload-list">
<view
v-for="(image, index) in imageList"
:key="index"
class="upload-item"
>
<image
:src="image.url ? imagUrl(image.url) : image.tempPath"
mode="aspectFill"
class="upload-image"
@click="previewImage(index)"
/>
<view class="upload-delete" @click="deleteImage(index)">
<text class="delete-icon">×</text>
</view>
</view>
<view
v-if="imageList.length < 5"
class="upload-add"
@click="chooseImage"
>
<text class="add-icon">+</text>
<text class="add-text">添加图片</text>
</view>
</view>
</view>
</view>
</view>
<!-- 拍视频上传 -->
<!-- 图片视频上传组件 -->
<view class="section mx-15 mb-30">
<view class="section-title-bar">
<view class="decorator"></view>
<text class="title-text">视频上传</text>
<text class="title-text">图片视频上传</text>
</view>
<view class="upload-card bg-white r-md p-15">
<view class="upload-section">
<view class="upload-list">
<view
v-for="(video, index) in videoList"
:key="index"
class="upload-item"
>
<video
:src="video.url ? video.url : video.tempPath"
class="upload-video"
:controls="false"
:show-center-play-btn="false"
:show-play-btn="false"
:show-fullscreen-btn="false"
:show-progress="false"
:show-mute-btn="false"
:enable-progress-gesture="false"
:enable-play-gesture="false"
:loop="false"
:muted="true"
:poster="''"
></video>
<view class="video-play-icon">
<u-icon name="play-right-fill" color="#fff" size="20"></u-icon>
</view>
<view class="upload-delete" @click="deleteVideo(index)">
<text class="delete-icon">×</text>
</view>
</view>
<view
v-if="videoList.length < 3"
class="upload-add"
@click="chooseVideo"
>
<text class="add-icon">+</text>
<text class="add-text">添加视频</text>
</view>
</view>
</view>
</view>
</view>
<ImageVideoUpload
v-model:image-list="imageList"
v-model:video-list="videoList"
:max-image-count="5"
:max-video-count="3"
:compress-config="compressConfig"
:upload-api="attachmentUpload"
@image-upload-success="onImageUploadSuccess"
@video-upload-success="onVideoUploadSuccess"
/>
</view>
</view>
</view>
</view>
@ -304,6 +234,8 @@ import { xkXcSaveApi } from "@/api/base/xkXcApi";
import { getXkDmPageApi } from "@/api/base/xkApi";
import { attachmentUpload } from "@/api/system/upload";
import BasicLayout from "@/components/BasicLayout/Layout.vue";
import BasicJsPicker from "@/components/BasicJsPicker/Picker.vue";
import { ImageVideoUpload } from "@/components/ImageVideoUpload";
import { useDataStore } from "@/store/modules/data";
import { useUserStore } from "@/store/modules/user";
import { computed, onMounted, ref } from "vue";
@ -317,6 +249,7 @@ const { getData } = useDataStore();
const js = computed(() => getJs);
const xkkc = computed(() => getData);
//
const parsedTeachingPlan = computed(() => {
console.log('教学计划原始数据:', xkkc.value.jxjh);
@ -386,19 +319,11 @@ const todayInfo = ref({
//
const checkItems = ref<any[]>([]);
// -
interface ImageItem {
tempPath?: string; //
url?: string; //
name?: string; //
}
//
import { type ImageItem, type VideoItem, COMPRESS_PRESETS } from '@/components/ImageVideoUpload'
// -
interface VideoItem {
tempPath?: string; //
url?: string; //
name?: string; //
}
//
const compressConfig = ref(COMPRESS_PRESETS.medium)
const imageList = ref<ImageItem[]>([]);
const videoList = ref<VideoItem[]>([]);
@ -426,9 +351,28 @@ const toggleTeachingPlan = () => {
teachingPlanExpanded.value = !teachingPlanExpanded.value;
};
//
const onSubstituteTeacherChange = (teacher: any) => {
console.log('代课老师选择变化:', teacher);
if (teacher) {
selectedSubstituteTeacher.value = teacher;
selectedSubstituteTeacherId.value = teacher.id || teacher.value;
console.log('选中的代课老师:', selectedSubstituteTeacher.value);
console.log('选中的代课老师ID:', selectedSubstituteTeacherId.value);
} else {
selectedSubstituteTeacher.value = null;
selectedSubstituteTeacherId.value = '';
}
};
//
const isSubmitting = ref(false);
//
const selectedSubstituteTeacher = ref<any>(null);
const selectedSubstituteTeacherId = ref<string>('');
const substituteTeacherPickerRef = ref();
//
const loadRollCallData = async () => {
try {
@ -436,6 +380,14 @@ const loadRollCallData = async () => {
const startTime = `${today} 00:00:00`;
const endTime = `${today} 23:59:59`;
//
console.log('点名数据加载参数:', {
xkkcId: xkkc.value.id,
kcjsId: xkkc.value.kcjsId,
jsId: js.value.id,
courseData: xkkc.value
});
const res = await getXkDmPageApi({
rows: 10,
page: 1,
@ -443,7 +395,7 @@ const loadRollCallData = async () => {
endTime,
pageNo: 1,
xkkcId: xkkc.value.id,
jsId: js.value.id,
jsId: xkkc.value.kcjsId, // 使ID使ID
sidx: 'dmTime',
sord: 'desc'
});
@ -514,140 +466,13 @@ const onCheckItemChange = (e: any, item: any) => {
item.checked = e.detail.value === "A";
};
//
const chooseImage = () => {
uni.chooseImage({
count: 5 - imageList.value.length,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: async (res) => {
//
const tempFilePaths = res.tempFilePaths as string[];
const newImages = tempFilePaths.map((path: string) => ({
tempPath: path,
name: path.split('/').pop() || 'image.jpg'
}));
imageList.value = [...imageList.value, ...newImages];
//
await uploadImages(newImages);
}
});
//
const onImageUploadSuccess = (image: ImageItem, index: number) => {
console.log('图片上传成功:', image, index);
};
//
const chooseVideo = () => {
uni.chooseVideo({
sourceType: ['album', 'camera'],
maxDuration: 60,
camera: 'back',
success: async (res) => {
//
const tempFilePath = res.tempFilePath;
const newVideo = {
tempPath: tempFilePath,
name: tempFilePath.split('/').pop() || 'video.mp4'
};
videoList.value = [...videoList.value, newVideo];
//
await uploadVideos([newVideo]);
}
});
};
//
const uploadImages = async (images: ImageItem[]) => {
try {
showLoading('上传图片中...');
for (let i = 0; i < images.length; i++) {
const image = images[i];
if (image.tempPath) {
try {
//
const uploadResult: any = await attachmentUpload(image.tempPath as any);
if (uploadResult && uploadResult.resultCode === 1 && uploadResult.result && uploadResult.result.length > 0) {
//
const serverPath = uploadResult.result[0].filePath;
//
const index = imageList.value.findIndex(img => img.tempPath === image.tempPath);
if (index !== -1) {
imageList.value[index].url = serverPath;
//
delete imageList.value[index].tempPath;
}
} else {
throw new Error('上传响应格式异常');
}
} catch (error) {
console.error('图片上传失败:', error);
showToast({ title: `${image.name || '图片'}上传失败`, icon: 'none' });
//
const index = imageList.value.findIndex(img => img.tempPath === image.tempPath);
if (index !== -1) {
imageList.value.splice(index, 1);
}
}
}
}
hideLoading();
showToast({ title: '图片上传完成', icon: 'success' });
} catch (error) {
hideLoading();
console.error('批量上传图片失败:', error);
showToast({ title: '图片上传失败,请重试', icon: 'none' });
}
};
//
const uploadVideos = async (videos: VideoItem[]) => {
try {
showLoading('上传视频中...');
for (let i = 0; i < videos.length; i++) {
const video = videos[i];
if (video.tempPath) {
try {
//
const uploadResult: any = await attachmentUpload(video.tempPath as any);
if (uploadResult && uploadResult.resultCode === 1 && uploadResult.result && uploadResult.result.length > 0) {
//
const serverPath = uploadResult.result[0].filePath;
//
const index = videoList.value.findIndex(v => v.tempPath === video.tempPath);
if (index !== -1) {
videoList.value[index].url = serverPath;
//
delete videoList.value[index].tempPath;
}
} else {
throw new Error('上传响应格式异常');
}
} catch (error) {
console.error('视频上传失败:', error);
showToast({ title: `${video.name || '视频'}上传失败`, icon: 'none' });
//
const index = videoList.value.findIndex(v => v.tempPath === video.tempPath);
if (index !== -1) {
videoList.value.splice(index, 1);
}
}
}
}
hideLoading();
showToast({ title: '视频上传完成', icon: 'success' });
} catch (error) {
hideLoading();
console.error('批量上传视频失败:', error);
showToast({ title: '视频上传失败,请重试', icon: 'none' });
}
const onVideoUploadSuccess = (video: VideoItem, index: number) => {
console.log('视频上传成功:', video, index);
};
//
@ -662,16 +487,6 @@ const previewImage = (index: number) => {
});
};
//
const deleteImage = (index: number) => {
imageList.value.splice(index, 1);
};
//
const deleteVideo = (index: number) => {
videoList.value.splice(index, 1);
};
//
const checkInspectionTime = () => {
const currentTime = now;
@ -758,11 +573,14 @@ const submit = async () => {
jsId: js.value.id,
jsxm: js.value.xm || js.value.jsxm, //
xkkcId: xkkc.value.id,
pbLxId: xkkc.value.pbLxId, // ID
dkjsId: selectedSubstituteTeacherId.value, // ID
xctime: now.format("YYYY-MM-DD HH:mm:ss"),
zp: getImageUrls(),
sp: getVideoUrls(),
xkXcXmList: xkXcXmList, //
};
const res = await xkXcSaveApi(submitData);
@ -772,7 +590,13 @@ const submit = async () => {
icon: "success",
});
setTimeout(() => {
uni.navigateBack();
//
uni.navigateBack({
success: () => {
// 线
uni.$emit('refreshCourseList');
}
});
}, 1500);
} else {
uni.showToast({
@ -872,6 +696,31 @@ onMounted(async () => {
}
}
.teacher-selection-info {
margin-top: 15px;
padding: 10px 15px;
background-color: #f9f9f9;
border-radius: 4px;
border: 1px solid #eee;
.teacher-item {
display: flex;
justify-content: space-between;
align-items: center;
.teacher-label {
font-size: 14px;
color: #666;
flex-shrink: 0;
}
.teacher-picker-container {
flex: 1;
margin-left: 10px;
}
}
}
.inspection-status {
display: flex;
align-items: center;