课程日历

dev
lion 3 months ago
parent 42eb2ca683
commit 19628c5efd

@ -19,10 +19,10 @@ let apiApp = {
courseUpdateSign: "/api/mobile/course/update-sign",
courseContent: '/api/mobile/course/contents',
courseUserList: "/api/mobile/course/user-list",
courseGetSign: "/api/mobile/course/get-sign",
myCourseContent:'/api/mobile/course/my-course-content',
courseContentDetail:'/api/mobile/course/course-content-detail',
courseGetSign: "/api/mobile/course/get-sign",
myCourseContent:'/api/mobile/course/my-course-content',
courseContentDetail:'/api/mobile/course/course-content-detail',
courseContentForm:'/api/mobile/course/course-content-form',
// 资讯
@ -54,6 +54,14 @@ let apiApp = {
bookIndex: '/api/mobile/book/index',
bookDetail: '/api/mobile/book/detail',
bookOther: '/api/mobile/book/other',
// 日历
calendarsGet:'/api/mobile/course/calendars',
// 签到
signGet:'/api/mobile/course/content-check-list',
signCheck:'/api/mobile/course/content-check',
signDistance:'/api/mobile/course/distance',
}
// 此处第二个参数vm就是我们在页面使用的this你可以通过vm获取vuex等操作
@ -77,10 +85,10 @@ const install = (Vue, vm) => {
let courseUpdateSign = (params = {}) => vm.$u.post(apiApp.courseUpdateSign, params);
let courseContent = (params = {}) => vm.$u.get(apiApp.courseContent, params);
let courseUserList = (params = {}) => vm.$u.get(apiApp.courseUserList, params);
let courseGetSign = (params = {}) => vm.$u.get(apiApp.courseGetSign, params);
let myCourseContent = (params = {}) => vm.$u.get(apiApp.myCourseContent, params);
let courseContentDetail = (params = {}) => vm.$u.get(apiApp.courseContentDetail, params);
let courseContentForm = (params = {}) => vm.$u.post(apiApp.courseContentForm, params);
let courseGetSign = (params = {}) => vm.$u.get(apiApp.courseGetSign, params);
let myCourseContent = (params = {}) => vm.$u.get(apiApp.myCourseContent, params);
let courseContentDetail = (params = {}) => vm.$u.get(apiApp.courseContentDetail, params);
let courseContentForm = (params = {}) => vm.$u.post(apiApp.courseContentForm, params);
@ -118,6 +126,13 @@ const install = (Vue, vm) => {
let bookDetail = (params = {}) => vm.$u.get(apiApp.bookDetail, params);
let bookOther = (params = {}) => vm.$u.get(apiApp.bookOther, params);
// 日历
let calendarsGet = (params = {}) => vm.$u.get(apiApp.calendarsGet, params);
// 签到
let signGet = (params = {}) => vm.$u.get(apiApp.signGet, params);
let signCheck = (params = {}) => vm.$u.get(apiApp.signCheck, params);
let signDistance = (params = {}) => vm.$u.get(apiApp.signDistance, params);
// 将各个定义的接口名称统一放进对象挂载到vm.$u.api(因为vm就是this也即this.$u.api)下
vm.$u.api = {
// 用户
@ -139,9 +154,9 @@ const install = (Vue, vm) => {
courseUpdateSign,
courseContent,
courseUserList,
courseGetSign,
myCourseContent,
courseContentDetail,
courseGetSign,
myCourseContent,
courseContentDetail,
courseContentForm,
// 预约
scheduleIndex,
@ -171,6 +186,12 @@ const install = (Vue, vm) => {
bookIndex,
bookDetail,
bookOther,
// 日历
calendarsGet,
// 签到
signGet,
signCheck,
signDistance,
};
}

@ -37,7 +37,7 @@ async function getToken() {
resolve(result.data.token);
},
fail(err) {
console.log("login-error",err)
console.log("login-error",err)
reject(err);
}
@ -61,9 +61,13 @@ async function getUserInfo(tokenResult) {
'vuex_token': tokenResult,
"vuex_user": result1.data.user
})
uni.reLaunch({
url: '/pages/index/index'
});
// 登录成功后跳回之前的页面(带参),若无则回首页
const redirect = uni.getStorageSync('redirect_after_login')
uni.removeStorageSync('redirect_after_login')
const target = redirect || '/pages/index/index'
uni.reLaunch({
url: target
});
},
fail(err) {
console.log("uesr-error",err)
@ -104,7 +108,23 @@ const install = (Vue, vm) => {
console.log('res-http', res)
if (res.statusCode === 200) {
if (res.data.hasOwnProperty("errcode")) {
if (res.data?.errcode === 40001) {
if (res.data?.errcode === 40001) {
// 记录当前页面完整路径,登录成功后跳回
try {
const pages = getCurrentPages()
if (pages && pages.length) {
const current = pages[pages.length - 1]
const route = current.route || current.$page?.fullPath || ''
let url = '/' + route
if (current.options) {
const qs = Object.keys(current.options)
.map(k => `${k}=${encodeURIComponent(current.options[k])}`)
.join('&')
if (qs) url += '?' + qs
}
uni.setStorageSync('redirect_after_login', url)
}
} catch (e) {}
uni.showModal({
title: '用户信息已失效',
confirmText: '重新获取',

@ -0,0 +1,497 @@
<template>
<view class="calendar-grid">
<view class="calendar-header">
<view class="nav-btn" @tap="prevMonth"></view>
<text class="month-text">{{ displayYear }}{{ displayMonthText }}</text>
<view class="nav-btn" @tap="nextMonth"></view>
<text class="back-today" @tap="backToday"></text>
</view>
<view class="weekdays">
<text v-for="(d, wi) in weekNames" :key="wi" class="weekday">{{ d }}</text>
</view>
<view ref="grid" class="grid" :style="'height:' + gridHeightPx + 'rpx'">
<!-- 日期格子 -->
<view class="row" v-for="(row, rIdx) in weeks" :key="rIdx">
<view class="cell" v-for="cell in row" :key="cell.fullDate" @tap="onDayClick(cell.fullDate)">
<text class="date-num" :class="{ dim: !cell.inMonth }">{{ cell.date }}</text>
<view class="cell-events">
<view v-for="ev in eventsForDate(cell.fullDate)" :key="ev.id" class="event-chip" :class="'event-type-' + (ev.type || 'default')">
{{ ev.title }}
</view>
</view>
</view>
</view>
<!-- 跨天覆盖层 -->
<view class="overlay">
<view v-for="(seg, si) in continuousSegments" :key="si" class="continuous-bar" :style="'left:'+seg._style.left+';width:'+seg._style.width+';top:'+seg._style.top+';height:'+seg._style.height" :class="'event-type-' + (seg.type || 'default')" @tap="onEdit(seg.id)">
{{ seg.title }}
</view>
</view>
</view>
</view>
</template>
<script>
export default {
name: 'CalendarGrid',
props: {
month: { // YYYY-MM
type: String,
required: true
},
events: { // [{ id, title, type, start_time, end_time }]
type: Array,
default: () => []
},
// rpx
rowHeightRpx: {
type: Number,
default: 140
},
headerHeightRpx: {
type: Number,
default: 80
},
weekHeaderHeightRpx: {
type: Number,
default: 56
},
dateNumberHeightRpx: {
type: Number,
default: 48
},
barHeightRpx: {
type: Number,
default: 28
},
barSpacingRpx: {
type: Number,
default: 6
}
},
data() {
return {
FIRST_DOW: 0, // 0=Sunday
// rpx 使 rpx
cellHeight: this.rowHeightRpx,
headerHeight: this.headerHeightRpx,
weekHeaderHeight: this.weekHeaderHeightRpx,
dateNumberHeight: this.dateNumberHeightRpx,
barHeight: this.barHeightRpx,
barSpacing: this.barSpacingRpx
}
},
watch: {
rowHeightRpx(val) {
this.cellHeight = Number(val) || this.cellHeight
},
headerHeightRpx(val) {
this.headerHeight = Number(val) || this.headerHeight
},
weekHeaderHeightRpx(val) {
this.weekHeaderHeight = Number(val) || this.weekHeaderHeight
},
dateNumberHeightRpx(val) {
this.dateNumberHeight = Number(val) || this.dateNumberHeight
},
barHeightRpx(val) {
this.barHeight = Number(val) || this.barHeight
},
barSpacingRpx(val) {
this.barSpacing = Number(val) || this.barSpacing
}
},
computed: {
displayMonthText() {
const m = this.displayMonth
return (m < 10 ? ('0' + m) : '' + m)
},
displayYear() {
const [y] = this.month.split('-').map(Number)
return y
},
displayMonth() {
const [, m] = this.month.split('-').map(Number)
return m
},
baseDate() {
// month
if (!this.month || typeof this.month !== 'string') {
const t = new Date()
return new Date(t.getFullYear(), t.getMonth(), 1)
}
const [yStr, mStr] = this.month.split('-')
const y = parseInt(yStr, 10)
const m = parseInt(mStr, 10)
if (!y || !m) {
const t = new Date()
return new Date(t.getFullYear(), t.getMonth(), 1)
}
return new Date(y, m - 1, 1)
},
weekNames() {
return ['日','一','二','三','四','五','六']
},
weeks() {
// 6x7
const firstDay = new Date(this.baseDate)
if (!(firstDay instanceof Date) || isNaN(firstDay.getTime())) return []
const startDow = firstDay.getDay()
const offset = (startDow - this.FIRST_DOW + 7) % 7
const gridStart = new Date(firstDay)
gridStart.setDate(1 - offset)
const weeks = []
for (let w = 0; w < 6; w += 1) {
const row = []
for (let d = 0; d < 7; d += 1) {
const cur = new Date(gridStart)
cur.setDate(gridStart.getDate() + (w * 7 + d))
row.push({
date: cur.getDate(),
month: cur.getMonth() + 1,
year: cur.getFullYear(),
inMonth: cur.getMonth() === this.baseDate.getMonth(),
fullDate: `${cur.getFullYear()}-${this.pad2(cur.getMonth()+1)}-${this.pad2(cur.getDate())}`
})
}
weeks.push(row)
}
return weeks
},
gridHeightPx() {
// rpx
return this.headerHeight + this.weekHeaderHeight + this.cellHeight * 6
},
continuousSegments() {
//
const events = (this.events || []).filter(ev => ev && ev.start_time)
const y = this.displayYear
const m = this.displayMonth - 1
const monthStart = new Date(y, m, 1)
const monthEnd = new Date(y, m + 1, 0)
const multiDay = events.filter(ev => {
if (!ev.end_time) return false
const s = this.parseDateTime(ev.start_time)
const e = this.parseDateTime(ev.end_time)
return s.toDateString() !== e.toDateString()
})
const segs = []
const getWeekStart = (date) => {
const d = new Date(date)
const jsDow = d.getDay()
const off = (jsDow - this.FIRST_DOW + 7) % 7
d.setDate(d.getDate() - off)
d.setHours(0,0,0,0)
return d
}
multiDay.forEach(ev => {
const s = this.parseDateTime(ev.start_time)
const e = this.parseDateTime(ev.end_time)
if (e < monthStart || s > monthEnd) return
const clampedStart = s < monthStart ? monthStart : s
const clampedEnd = e > monthEnd ? monthEnd : e
let cursor = getWeekStart(clampedStart)
while (cursor <= clampedEnd) {
const weekStart = new Date(cursor)
const weekEnd = new Date(cursor)
weekEnd.setDate(weekEnd.getDate() + 6)
const segStart = clampedStart > weekStart ? clampedStart : weekStart
const segEnd = clampedEnd < weekEnd ? clampedEnd : weekEnd
if (segStart <= segEnd) {
const startCol = (segStart.getDay() - this.FIRST_DOW + 7) % 7
const endCol = (segEnd.getDay() - this.FIRST_DOW + 7) % 7
const spanCols = endCol - startCol + 1
segs.push({
...ev,
weekStartISO: weekStart.toISOString(),
// ISO/
segStartMs: segStart.getTime(),
segEndMs: segEnd.getTime(),
startCol,
endCol,
spanCols
})
}
cursor.setDate(cursor.getDate() + 7)
}
})
// lane
const byWeek = {}
segs.forEach(s => {
const key = s.weekStartISO
if (!byWeek[key]) byWeek[key] = []
byWeek[key].push(s)
})
Object.values(byWeek).forEach(arr => {
arr.sort((a,b) => (a.startCol - b.startCol) || (b.spanCols - a.spanCols))
const laneEnd = []
arr.forEach(seg => {
let placed = false
for (let i=0;i<laneEnd.length;i+=1) {
if (laneEnd[i] < seg.startCol) {
seg.laneIndex = i
laneEnd[i] = seg.endCol
placed = true
break
}
}
if (!placed) {
seg.laneIndex = laneEnd.length
laneEnd.push(seg.endCol)
}
})
})
//
const y2 = this.displayYear
const m2 = this.displayMonth - 1
const firstDay = new Date(y2, m2, 1)
const startDow = firstDay.getDay()
const offset = (startDow - this.FIRST_DOW + 7) % 7
const gridStart = new Date(firstDay)
gridStart.setDate(1 - offset)
gridStart.setHours(0,0,0,0)
const msPerDay = 86400000
const cellWidthPct = 100 / 7
const heightRpx = this.barHeight
segs.forEach(seg => {
const segStart = new Date(seg.segStartMs)
// weeks
const startStr = `${segStart.getFullYear()}-${this.pad2(segStart.getMonth()+1)}-${this.pad2(segStart.getDate())}`
let row = 0
let col = seg.startCol
outer: for (let r = 0; r < this.weeks.length; r += 1) {
const rowArr = this.weeks[r]
for (let c = 0; c < rowArr.length; c += 1) {
if (rowArr[c].fullDate === startStr) {
row = r
col = c
break outer
}
}
}
const leftPct = col * cellWidthPct
const widthPct = seg.spanCols * cellWidthPct
const vOffset = (seg.laneIndex || 0) * (this.barHeight + this.barSpacing)
const topRpx = this.headerHeight + this.weekHeaderHeight + (row * this.cellHeight) + this.dateNumberHeight + vOffset
seg._style = {
left: leftPct + '%',
width: widthPct + '%',
top: topRpx + 'rpx',
height: heightRpx + 'rpx'
}
})
return segs
}
},
methods: {
prevMonth() {
const d = new Date(this.baseDate)
d.setMonth(d.getMonth() - 1)
this.$emit('monthChange', { year: d.getFullYear(), month: d.getMonth() + 1 })
},
nextMonth() {
const d = new Date(this.baseDate)
d.setMonth(d.getMonth() + 1)
this.$emit('monthChange', { year: d.getFullYear(), month: d.getMonth() + 1 })
},
backToday() {
const t = new Date()
const isSameMonth = t.getFullYear() === this.displayYear && (t.getMonth()+1) === this.displayMonth
if (!isSameMonth) {
this.$emit('monthChange', { year: t.getFullYear(), month: t.getMonth() + 1 })
}
//
const full = `${t.getFullYear()}-${String(t.getMonth()+1).padStart(2,'0')}-${String(t.getDate()).padStart(2,'0')}`
this.onDayClick(full)
},
onDayClick(fullDate) {
this.$emit('dayClick', { fulldate: fullDate })
},
onEdit(id) {
//
this.$emit('edit', id)
},
eventsForDate(fullDate) {
const d0 = new Date(fullDate)
d0.setHours(0,0,0,0)
return (this.events || []).filter(ev => {
if (!ev || !ev.start_time) return false
const s = this.parseDateTime(ev.start_time)
const e = ev.end_time ? this.parseDateTime(ev.end_time) : this.parseDateTime(ev.start_time)
s.setHours(0,0,0,0); e.setHours(0,0,0,0)
//
const isMulti = ev.end_time && (s.getTime() !== e.getTime())
if (isMulti) return false
return d0.getTime() === s.getTime()
})
},
pad2(n) {
n = Number(n) || 0
return n < 10 ? ('0' + n) : String(n)
},
parseDateTime(dateTimeStr) {
if (!dateTimeStr) return null
const [datePart, timePart = '00:00:00'] = String(dateTimeStr).trim().split(/[T\s]+/)
const [y, m, d] = datePart.split('-').map(n => parseInt(n, 10))
const [hh = 0, mm = 0, ss = 0] = timePart.split(':').map(n => parseInt(n, 10))
// /
return new Date(y, (m || 1) - 1, d || 1, hh || 0, mm || 0, ss || 0)
}
}
}
</script>
<style scoped>
.calendar-grid {
background: #fff;
border-radius: 18rpx;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
margin: 20rpx;
padding: 10rpx 0 20rpx 0;
}
.calendar-header {
position: relative;
display: flex;
justify-content: center;
align-items: center;
height: 46px;
border-bottom: 1px solid #ededed;
}
.nav-btn {
width: 40px;
text-align: center;
font-size: 20px;
color: #666;
}
.month-text {
width: 140px;
text-align: center;
font-size: 15px;
color: #333;
}
.back-today {
position: absolute;
right: 8px;
top: 8px;
padding: 4px 8px;
border-radius: 12px;
font-size: 12px;
color: #333;
background: #f1f1f1;
}
.weekdays {
display: flex;
flex-direction: row;
justify-content: space-between;
padding: 6px 10px;
height: 32px;
box-sizing: border-box;
}
.weekday {
width: 14.2857%;
text-align: center;
font-size: 12px;
color: #666;
}
.grid {
position: relative;
}
.row {
display: flex;
flex-direction: row;
}
.cell {
width: 14.2857%;
height: 120rpx;
border-bottom: 1px solid #f5f5f5;
border-top: 1px solid #f5f5f5;
border-right: 1px solid #f5f5f5;
position: relative;
padding: 2px 2px 2px 2px;
}
.row .cell:first-child {
border-left: 1px solid #f5f5f5;
}
.date-num {
font-size: 12px;
font-weight: 600;
color: #333;
position: relative;
z-index: 3;
}
.date-num.dim {
color: #bfbfbf;
}
.cell-events {
position: relative;
z-index: 3;
}
.event-chip {
font-size: 11px;
line-height: 14px;
padding: 1px 3px;
margin: 1px 0;
color: #fff;
border-radius: 3px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.event-chip.event-type-1 { background: #67C23A; }
.event-chip.event-type-2 { background: #409EFF; }
.event-chip.event-type-3 { background: #E6A23C; }
.event-chip.event-type-4 { background: #F56C6C; }
.event-chip.event-type-5 { background: #909399; }
.event-chip.event-type-default { background: #409EFF; }
.overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
z-index: 2;
}
.continuous-bar {
position: absolute;
pointer-events: auto;
z-index: 2;
color: #fff;
font-size: 11px;
line-height: 18px;
padding: 0 4px;
border-radius: 3px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
background: #409EFF;
}
.continuous-bar.event-type-1 { background: linear-gradient(90deg, #67C23A 0%, #5CB85C 100%); }
.continuous-bar.event-type-2 { background: linear-gradient(90deg, #409EFF 0%, #337ecc 100%); }
.continuous-bar.event-type-3 { background: linear-gradient(90deg, #E6A23C 0%, #D4952B 100%); }
.continuous-bar.event-type-4 { background: linear-gradient(90deg, #F56C6C 0%, #E85555 100%); }
.continuous-bar.event-type-5 { background: linear-gradient(90deg, #909399 0%, #73767A 100%); }
.continuous-bar.event-type-default { background: linear-gradient(90deg, #409EFF 0%, #337ecc 100%); }
</style>

@ -85,7 +85,7 @@
"disableHostCheck" : true,
"https" : false
},
"title" : "蟹太太",
"title" : "苏州科技商学院",
"sdkConfigs" : {
"maps" : {}
}

@ -0,0 +1,709 @@
<template>
<div class="admin-calendar">
<!-- 顶部操作区 -->
<div class="admin-header">
<el-button type="success" icon="el-icon-plus" @click="openCreateModal('add')"></el-button>
</div>
<!-- 日历预览区 -->
<div class="admin-main">
<div class="calendar-panel">
<div class="calendar-wrapper">
<el-calendar v-model="calendarDate" :first-day-of-week="7">
<template slot="dateCell" slot-scope="{date}">
<div class="cell-content">
<span class="date-number">{{ date.getDate() }}</span>
<div class="event-list">
<div
v-for="ev in eventsForDate(date)"
:key="ev._id"
:class="['event-item', getEventClass(ev, date)]"
:title="getEventTooltip(ev, date)"
@click.stop="openCreateModal('editor', ev.id)"
>
<span class="event-title">{{ ev.title }}</span>
</div>
</div>
</div>
</template>
</el-calendar>
<!-- 连续事件覆盖层 -->
<div class="continuous-events-overlay">
<div
v-for="event in getContinuousEvents()"
:key="`continuous-${event.id}-${event.weekStart}`"
:class="['continuous-event', `event-type-${event.type || 'default'}`]"
:style="getContinuousEventStyle(event)"
:title="getEventTooltip(event, new Date(event.weekStart))"
@click.stop="openCreateModal('editor', event.id)"
>
{{ event.title }}
</div>
</div>
</div>
</div>
</div>
<addCalendar ref="addCalendar" @refresh="getList"></addCalendar>
</div>
</template>
<script>
import addCalendar from './components/addCalendar.vue'
import {
index
} from '@/api/calendars/index.js'
export default {
components: {
addCalendar
},
data() {
return {
list: [],
calendarDate: new Date()
}
},
computed: {
selectMonth() {
const now = this.calendarDate instanceof Date ? this.calendarDate : new Date(this.calendarDate)
const month = now.getMonth() + 1 < 10 ? '0' + (now.getMonth() + 1) : now.getMonth() + 1
const year = now.getFullYear()
return year + '-' + month
}
},
watch: {
calendarDate: {
handler() {
this.getList()
},
deep: true
}
},
created() {
this.getList()
},
methods: {
async getList() {
const res = await index({
month: this.selectMonth
})
this.list = res
},
openCreateModal(type, id) {
if (type === 'editor') {
this.$refs.addCalendar.id = id
}
this.$refs.addCalendar.type = type
this.$refs.addCalendar.isShow = true
},
eventsForDate(date) {
const d = new Date(date)
return this.list.filter(ev => {
const startDate = this.parseDateTime(ev.start_time)
// end_timestart_time
if (!ev.end_time) {
const currentDate = new Date(d.getFullYear(), d.getMonth(), d.getDate())
const eventStartDate = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate())
return currentDate.getTime() === eventStartDate.getTime()
}
const endDate = this.parseDateTime(ev.end_time)
//
const isMultiDay = startDate.getDate() !== endDate.getDate() ||
startDate.getMonth() !== endDate.getMonth() ||
startDate.getFullYear() !== endDate.getFullYear()
//
if (isMultiDay) {
return false
}
//
const currentDate = new Date(d.getFullYear(), d.getMonth(), d.getDate())
const eventStartDate = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate())
return currentDate.getTime() === eventStartDate.getTime()
})
},
getEventClass(event, date) {
const startDate = new Date(event.start_time)
const currentDate = new Date(date)
// end_time
if (!event.end_time) {
return `single-day event-type-${event.type || 'default'}`
}
const endDate = new Date(event.end_time)
//
const isMultiDay = startDate.getDate() !== endDate.getDate() ||
startDate.getMonth() !== endDate.getMonth() ||
startDate.getFullYear() !== endDate.getFullYear()
if (!isMultiDay) {
return `single-day event-type-${event.type || 'default'}`
}
//
const eventStartDate = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate())
const eventEndDate = new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate())
const currentDateOnly = new Date(currentDate.getFullYear(), currentDate.getMonth(), currentDate.getDate())
if (currentDateOnly.getTime() === eventStartDate.getTime()) {
return `multi-day-start event-type-${event.type || 'default'}`
} else if (currentDateOnly.getTime() === eventEndDate.getTime()) {
return `multi-day-end event-type-${event.type || 'default'}`
} else {
return `multi-day-middle event-type-${event.type || 'default'}`
}
},
getEventTooltip(event, date) {
const startDate = new Date(event.start_time)
// end_time
if (!event.end_time) {
return `${event.title}\n时间${this.formatDateTime(event.start_time)}`
}
const endDate = new Date(event.end_time)
const isMultiDay = startDate.getDate() !== endDate.getDate() ||
startDate.getMonth() !== endDate.getMonth() ||
startDate.getFullYear() !== endDate.getFullYear()
if (isMultiDay) {
return `${event.title}\n时间${this.formatDateTime(event.start_time)} ~ ${this.formatDateTime(event.end_time)}`
} else {
return `${event.title}\n时间${this.formatDateTime(event.start_time)} ~ ${this.formatDateTime(event.end_time)}`
}
},
getContinuousEvents() {
const FIRST_DOW = 0 // 0=Sunday to match :first-day-of-week="7" on el-calendar
const continuousEvents = []
//
const multiDayEvents = this.list.filter(ev => {
if (!ev.end_time) return false
const s = this.parseDateTime(ev.start_time)
const e = this.parseDateTime(ev.end_time)
return s.toDateString() !== e.toDateString()
})
const currentMonth = this.calendarDate.getMonth()
const currentYear = this.calendarDate.getFullYear()
const monthStart = new Date(currentYear, currentMonth, 1)
const monthEnd = new Date(currentYear, currentMonth + 1, 0)
function getWeekStart(date) {
const d = new Date(date)
const jsDow = d.getDay() // 0..6 (Sun..Sat)
const offset = (jsDow - FIRST_DOW + 7) % 7
d.setDate(d.getDate() - offset)
return d
}
multiDayEvents.forEach(ev => {
const eventStart = this.parseDateTime(ev.start_time)
const eventEnd = this.parseDateTime(ev.end_time)
if (eventEnd < monthStart || eventStart > monthEnd) return
// Clamp to month range so we only render in current month viewport
const clampedStart = eventStart < monthStart ? monthStart : eventStart
const clampedEnd = eventEnd > monthEnd ? monthEnd : eventEnd
let cursor = getWeekStart(clampedStart)
while (cursor <= clampedEnd) {
const weekStart = new Date(cursor)
const weekEnd = new Date(cursor)
weekEnd.setDate(weekEnd.getDate() + 6)
// Segment inside this week
const segStart = clampedStart > weekStart ? clampedStart : weekStart
const segEnd = clampedEnd < weekEnd ? clampedEnd : weekEnd
if (segStart <= segEnd) {
const startCol = (segStart.getDay() - FIRST_DOW + 7) % 7
const endCol = (segEnd.getDay() - FIRST_DOW + 7) % 7
const spanCols = endCol - startCol + 1
continuousEvents.push({
...ev,
weekStartISO: weekStart.toISOString(),
segStartISO: segStart.toISOString(),
segEndISO: segEnd.toISOString(),
startCol,
spanCols,
endCol
})
}
cursor.setDate(cursor.getDate() + 7)
}
})
// lane
const byWeek = {}
continuousEvents.forEach(seg => {
const key = seg.weekStartISO
if (!byWeek[key]) byWeek[key] = []
byWeek[key].push(seg)
})
Object.values(byWeek).forEach(segs => {
//
segs.sort((a, b) => (a.startCol - b.startCol) || (b.spanCols - a.spanCols))
const laneEndCols = [] //
segs.forEach(seg => {
let placed = false
for (let i = 0; i < laneEndCols.length; i += 1) {
if (laneEndCols[i] < seg.startCol) {
seg.laneIndex = i
laneEndCols[i] = seg.endCol
placed = true
break
}
}
if (!placed) {
seg.laneIndex = laneEndCols.length
laneEndCols.push(seg.endCol)
}
})
})
return continuousEvents
},
getContinuousEventStyle(event) {
const FIRST_DOW = 0
const currentMonth = this.calendarDate.getMonth()
const currentYear = this.calendarDate.getFullYear()
const firstDay = new Date(currentYear, currentMonth, 1)
// 7
const adjStart = new Date(event.segStartISO)
adjStart.setDate(adjStart.getDate() + 7)
const msPerDay = 1000 * 60 * 60 * 24
const daysFromFirstOfMonth = Math.floor((adjStart - firstDay) / msPerDay)
const firstDayOffset = (firstDay.getDay() - FIRST_DOW + 7) % 7
const totalDaysFromCalendarStart = daysFromFirstOfMonth + firstDayOffset
const weekRow = Math.floor(totalDaysFromCalendarStart / 7)
// 使
const startColAdjusted = (adjStart.getDay() - FIRST_DOW + 7) % 7
const cellWidth = 100 / 7
const cellHeight = 100
const headerHeight = 50
const dateNumberHeight = 25
const eventHeight = 16
const eventSpacing = 2
const verticalOffset = (event.laneIndex || 0) * (eventHeight + eventSpacing)
return {
position: 'absolute',
left: `calc(${startColAdjusted * cellWidth}% + 2px)`,
top: `${headerHeight + weekRow * cellHeight + dateNumberHeight + verticalOffset}px`,
width: `calc(${event.spanCols * cellWidth}% - 4px)`,
height: `${eventHeight}px`,
zIndex: 1000,
background: `linear-gradient(90deg, ${this.getEventTypeColor(event.type)} 0%, ${this.darkenColor(this.getEventTypeColor(event.type))} 100%)`,
borderRadius: '3px',
fontSize: '11px',
lineHeight: `${eventHeight}px`,
color: 'white',
padding: '0 4px',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
cursor: 'pointer',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.3)'
}
},
getEventStyle(event, date) {
const startDate = new Date(event.start_time)
const currentDate = new Date(date)
// end_time使
if (!event.end_time) {
return {}
}
const endDate = new Date(event.end_time)
//
const isMultiDay = startDate.getDate() !== endDate.getDate() ||
startDate.getMonth() !== endDate.getMonth() ||
startDate.getFullYear() !== endDate.getFullYear()
if (!isMultiDay) {
return {}
}
//
const currentWeekStart = new Date(currentDate)
const dayOfWeek = currentDate.getDay()
currentWeekStart.setDate(currentDate.getDate() - dayOfWeek)
//
const eventStartDate = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate())
const eventEndDate = new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate())
const currentDateOnly = new Date(currentDate.getFullYear(), currentDate.getMonth(), currentDate.getDate())
//
const weekStart = new Date(currentWeekStart)
const weekEnd = new Date(currentWeekStart)
weekEnd.setDate(weekEnd.getDate() + 6)
//
const eventWeekStart = eventStartDate < weekStart ? weekStart : eventStartDate
const eventWeekEnd = eventEndDate > weekEnd ? weekEnd : eventEndDate
//
if (currentDateOnly < eventWeekStart || currentDateOnly > eventWeekEnd) {
return {}
}
//
if (currentDateOnly.getTime() === eventWeekStart.getTime()) {
const startDayOfWeek = eventWeekStart.getDay()
const endDayOfWeek = eventWeekEnd.getDay()
const spanDays = endDayOfWeek - startDayOfWeek + 1
//
const bgColor = this.getEventTypeColor(event.type)
return {
position: 'absolute',
left: '0',
top: '1px',
width: `calc(${spanDays * 100}% - 2px)`,
zIndex: 10,
background: `linear-gradient(90deg, ${bgColor} 0%, ${this.darkenColor(bgColor)} 100%)`,
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.2)',
border: '1px solid rgba(255, 255, 255, 0.3)',
borderRadius: '3px'
}
} else if (currentDateOnly > eventWeekStart && currentDateOnly <= eventWeekEnd) {
//
return {
opacity: '0',
pointerEvents: 'none'
}
}
return {}
},
getEventDisplayTitle(event, date) {
const startDate = new Date(event.start_time)
const currentDate = new Date(date)
// end_time
if (!event.end_time) {
return event.title
}
const endDate = new Date(event.end_time)
//
const isMultiDay = startDate.getDate() !== endDate.getDate() ||
startDate.getMonth() !== endDate.getMonth() ||
startDate.getFullYear() !== endDate.getFullYear()
if (!isMultiDay) {
return event.title
}
//
const eventStartDate = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate())
const currentDateOnly = new Date(currentDate.getFullYear(), currentDate.getMonth(), currentDate.getDate())
//
const currentWeekStart = new Date(currentDate)
const dayOfWeek = currentDate.getDay()
currentWeekStart.setDate(currentDate.getDate() - dayOfWeek)
const weekStart = new Date(currentWeekStart)
const eventWeekStart = eventStartDate < weekStart ? weekStart : eventStartDate
if (currentDateOnly.getTime() === eventWeekStart.getTime()) {
return event.title
}
return '' //
},
getEventTypeColor(type) {
//
const colorMap = {
1: '#67C23A', // - 绿
2: '#409EFF', // -
3: '#E6A23C', // -
4: '#F56C6C', // -
5: '#909399', // -
default: '#409EFF' //
}
return colorMap[type] || colorMap.default
},
// Safari/
parseDateTime(dateTimeStr) {
if (!dateTimeStr) return null
// "YYYY-MM-DD HH:mm:ss" "YYYY-MM-DD"
const [datePart, timePart = '00:00:00'] = dateTimeStr.trim().split(/\s+/)
const [y, m, d] = datePart.split('-').map(n => parseInt(n, 10))
const [hh = 0, mm = 0, ss = 0] = timePart.split(':').map(n => parseInt(n, 10))
return new Date(y, (m || 1) - 1, d || 1, hh || 0, mm || 0, ss || 0)
},
darkenColor(color) {
//
const colorMap = {
'#67C23A': '#5CB85C', // 绿
'#409EFF': '#337ecc', //
'#E6A23C': '#D4952B', //
'#F56C6C': '#E85555', //
'#909399': '#73767A' //
}
return colorMap[color] || '#337ecc'
},
formatDateTime(val) {
if (!val) return ''
return val.replace('T', ' ')
},
typeText(className) {
if (!className) return ''
if (className === 1) return '课程'
if (className === 3) return '自定义事件'
if (className === 4) return '资讯'
return ''
}
},
filters: {
formatDateTime(val) {
if (!val) return ''
return val.replace('T', ' ')
}
}
}
</script>
<style scoped lang="scss">
::v-deep .el-calendar__body{
padding-left:0;
padding-right:0;
}
.admin-calendar {
background: #f4f6fa;
min-height: 100vh;
display: flex;
flex-direction: column;
}
.admin-header {
background: transparent;
padding: 20px 30px 10px 30px;
border-bottom: none;
box-shadow: none;
}
.admin-main {
flex: 1;
padding: 20px 30px;
overflow: hidden;
}
.calendar-panel {
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
padding: 24px;
height: 100%;
overflow: auto;
}
.calendar-wrapper {
position: relative;
}
.continuous-events-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
z-index: 100;
}
.continuous-event {
pointer-events: auto;
transition: all 0.2s ease;
}
.continuous-event:hover {
transform: translateY(-1px);
filter: brightness(1.1);
z-index: 101;
}
/* Element UI 日历样式覆盖 */
.calendar-panel ::v-deep .el-calendar-table {
overflow: visible;
}
.calendar-panel ::v-deep .el-calendar-table .el-calendar-day {
position: relative;
overflow: visible;
height: auto;
min-height: 100px;
padding: 4px;
}
.calendar-panel ::v-deep .el-calendar-table td {
position: relative;
overflow: visible;
border: 1px solid #ebeef5;
}
.calendar-panel ::v-deep .el-calendar-table tbody tr {
overflow: visible;
}
.calendar-panel ::v-deep .el-calendar-table tbody {
overflow: visible;
}
.cell-content {
position: relative;
min-height: 100px;
display: flex;
flex-direction: column;
padding: 2px;
overflow: visible;
}
.date-number {
font-weight: bold;
color: #333;
margin-bottom: 2px;
position: relative;
z-index: 1;
}
.event-list {
flex: 1;
overflow: visible;
position: relative;
z-index: 5;
}
.event-item {
font-size: 11px;
line-height: 14px;
padding: 1px 3px;
margin: 1px 0;
background: #409EFF;
color: white;
border-radius: 3px;
cursor: pointer;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
transition: background-color 0.2s;
position: relative;
}
.event-title {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.event-item:hover {
background: #337ecc;
}
/* 不同事件类型的颜色 */
.event-item.event-type-1 {
background: #67C23A; /* 课程 - 绿色 */
}
.event-item.event-type-2 {
background: #409EFF; /* 会议 - 蓝色 */
}
.event-item.event-type-3 {
background: #E6A23C; /* 自定义事件 - 橙色 */
}
.event-item.event-type-4 {
background: #F56C6C; /* 资讯 - 红色 */
}
.event-item.event-type-5 {
background: #909399; /* 其他 - 灰色 */
}
.event-item.event-type-default {
background: #409EFF; /* 默认 - 蓝色 */
}
/* 悬停效果 */
.event-item.event-type-1:hover {
background: #5CB85C;
}
.event-item.event-type-2:hover {
background: #337ecc;
}
.event-item.event-type-3:hover {
background: #D4952B;
}
.event-item.event-type-4:hover {
background: #E85555;
}
.event-item.event-type-5:hover {
background: #73767A;
}
.event-item.event-type-default:hover {
background: #337ecc;
}
/* 连续事件的特殊样式 */
.event-item[style*="position: absolute"] {
border-radius: 3px !important;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.event-item[style*="position: absolute"]:hover {
transform: translateY(-1px);
filter: brightness(1.1);
}
.mt-4 {
margin-top: 24px;
}
.mb-2 {
margin-bottom: 8px;
}
@media (max-width: 1200px) {
.admin-header {
padding: 15px 20px;
}
.admin-main {
padding: 15px 20px;
}
.calendar-panel {
padding: 16px;
}
}
</style>

@ -12,6 +12,23 @@
</view>
</view> -->
<!-- 自定义日历支持跨天横条显示 -->
<view class="calendar-container">
<CalendarGrid
:month="calendarDate"
:events="courses"
:rowHeightRpx="110"
:headerHeightRpx="72"
:weekHeaderHeightRpx="52"
:dateNumberHeightRpx="46"
:barHeightRpx="26"
:barSpacingRpx="6"
@dayClick="onDateChange"
@monthChange="onMonthSwitch"
@edit="onEditEvent"
/>
</view>
<!-- 课程类型筛选 -->
<view class="calendar-filters">
<view
@ -23,22 +40,10 @@
{{ item.label }}
</view>
</view>
<!-- 日历 -->
<view class="calendar-container">
<uni-calendar
:insert="true"
:lunar="true"
:selected="selectedDates"
@change="onDateChange"
@monthSwitch="onMonthSwitch"
/>
</view>
<!-- 当月日程列表 -->
<view class="month-events-container">
<view class="events-title">当月日程</view>
<scroll-view v-if="monthEvents.length" class="events-list" scroll-y>
<view v-if="monthEvents.length" class="events-list no-scroll">
<view
v-for="ev in monthEvents"
:key="ev.id"
@ -51,7 +56,7 @@
<view class="event-title">{{ ev.title }}</view>
<view class="event-meta">
<text>{{ getCourseTypeName(ev.type) }}</text>
<text> | {{ formatDateTimeRange(ev.start, ev.end) }}</text>
<text> | {{ formatDateTimeRange(ev.start_time, ev.end_time) }}</text>
</view>
</view>
</view>
@ -59,7 +64,7 @@
<u-icon name="arrow-right" color="#999" size="28"></u-icon>
</view>
</view>
</scroll-view>
</view>
<view v-else class="empty-events">
<text>本月暂无日程</text>
</view>
@ -74,37 +79,22 @@
<view class="modal-body">
<view class="course-info">
<view class="info-item">
<text class="info-label">程类型</text>
<text class="info-label">程类型</text>
<text class="info-value">{{ getCourseTypeName(detailData.type) }}</text>
</view>
<view class="info-item">
<text class="info-label">开课时间</text>
<text class="info-value">{{ formatDateTime(detailData.start) }}</text>
</view>
<view class="info-item">
<text class="info-label">课程地点</text>
<text class="info-value">{{ detailData.location }}</text>
<text class="info-label">开始时间</text>
<text class="info-value">{{ formatDateTime(detailData.start_time) }}</text>
</view>
<view class="info-item">
<text class="info-label">主讲老师</text>
<text class="info-value">{{ detailData.teacher }}</text>
<text class="info-label">结束时间</text>
<text class="info-value">{{ formatDateTime(detailData.end_time) }}</text>
</view>
</view>
<view class="course-description">
<text class="desc-title">课程简介</text>
<text class="desc-content">{{ detailData.description }}</text>
</view>
<view v-if="detailData.vipContent" class="course-description vip-content">
<text class="desc-title">VIP专享内容</text>
<view class="desc-content">
<text>课程大纲</text>
<view>模块一人工智能基础理论</view>
<view>模块二具身智能技术应用</view>
<view>模块三产业落地实践</view>
<text>课程资料</text>
<view>课程讲义下载</view>
<view>案例分析材料</view>
<view>实践项目指导</view>
<text class="desc-title">日程详情</text>
<!-- <u-parse :content="detailData.content || ''"></u-parse> -->
<view v-html="detailData.content || ''">
</view>
</view>
<view class="course-actions">
@ -118,10 +108,10 @@
</template>
<script>
import uniCalendar from '@/uni_modules/uni-calendar/components/uni-calendar/uni-calendar.vue'
import CalendarGrid from '@/components/calendar-grid/calendar-grid.vue'
export default {
components:{
uniCalendar
CalendarGrid
},
data() {
const now = new Date();
@ -129,205 +119,172 @@ export default {
const month = String(now.getMonth() + 1).padStart(2, '0');
return {
filterTabs: [
{ label: '全部', value: 'all' },
{ label: '课程', value: 'course' },
{ label: '日程', value: 'activity' },
{ label: '资讯', value: 'workshop' }
{ label: '全部', value: '' },
{ label: '课程', value: 1 },
{ label: '自定义事件', value: 3 },
{ label: '资讯', value: 4 }
],
filterType: 'all',
filterType: '',
calendarDate: `${year}-${month}`,
showDetail: false,
detailData: {},
courses: [
{
id: 1,
title: '2025产业加速营 | 智能制造专题',
type: 'course',
start: '2025-07-04T09:00:00',
end: '2025-07-04T17:00:00',
location: '商学院A101',
teacher: '王教授',
description: '聚焦智能制造领域的最新发展与应用,邀请行业专家深度解析。',
vipContent: true,
enrollmentDeadline: '2025-06-01'
},
{
id: 2,
title: '校友企业参访 | 新能源企业',
type: 'activity',
start: '2025-07-10T13:30:00',
end: '2025-07-10T17:00:00',
location: '苏州高新区',
teacher: '李总监',
description: '走进新能源龙头企业,了解绿色科技创新。',
vipContent: false,
enrollmentDeadline: '2025-06-07'
},
{
id: 3,
title: '移动课堂 | AI商业落地',
type: 'workshop',
start: '2025-07-15T09:00:00',
end: '2025-07-15T16:00:00',
location: '创新实验室B202',
teacher: '张教授',
description: '实战演练AI项目商业化流程提升创业能力。',
vipContent: true,
enrollmentDeadline: '2025-06-12'
},
{
id: 4,
title: '2025校友论坛 | 数字经济新机遇',
type: 'activity',
start: '2025-07-20T14:00:00',
end: '2025-07-20T17:30:00',
location: '报告厅',
teacher: '特邀嘉宾',
description: '聚焦数字经济发展趋势,促进校友交流合作。',
vipContent: false,
enrollmentDeadline: '2025-06-17'
},
{
id: 5,
title: '2025暑期创新营 | 项目路演',
type: 'course',
start: '2025-07-27T09:00:00',
end: '2025-07-27T12:00:00',
location: '多功能厅',
teacher: '创业导师团',
description: '学员项目成果展示与专家点评。',
vipContent: true,
enrollmentDeadline: '2025-06-24'
},
{
id: 6,
title: '2025创新创业移动课堂跨天',
type: 'workshop',
start: '2025-06-08T09:00:00',
end: '2025-06-10T17:00:00',
location: '创新基地C301',
teacher: '创业导师团',
description: '三天两夜创新创业实战训练,团队协作与路演。',
vipContent: true,
enrollmentDeadline: '2025-06-05'
},
{
id: 7,
title: '2025校友企业家沙龙',
type: 'activity',
start: '2025-06-13T18:00:00',
end: '2025-06-13T21:00:00',
location: '校友会馆',
teacher: '企业家代表',
description: '校友企业家分享创业经验与行业洞察。',
vipContent: false,
enrollmentDeadline: '2025-06-10'
},
{
id: 8,
title: '2025人工智能专题课程跨天',
type: 'course',
start: '2025-06-17T09:00:00',
end: '2025-06-19T17:00:00',
location: '商学院A201',
teacher: 'AI专家组',
description: '为期三天的AI理论与实操课程含项目实践。',
vipContent: true,
enrollmentDeadline: '2025-06-14'
},
{
id: 9,
title: '2025校友羽毛球友谊赛',
type: 'activity',
start: '2025-06-23T14:00:00',
end: '2025-06-23T18:00:00',
location: '体育馆',
teacher: '体育部',
description: '校友间的体育交流与友谊赛。',
vipContent: false,
enrollmentDeadline: '2025-06-20'
},
{
id: 10,
title: '2025创新项目孵化移动课堂跨天',
type: 'workshop',
start: '2025-06-25T09:00:00',
end: '2025-06-28T17:00:00',
location: '孵化基地',
teacher: '孵化导师团',
description: '连续四天的创新项目孵化与辅导,含结营路演。',
vipContent: true,
enrollmentDeadline: '2025-06-22'
}
]
courses: [] // web [{ id, title, type, start_time, end_time, ... }]
}
},
computed: {
selectedDates() {
const dates = new Set();
const dates = new Set()
this.courses.forEach(ev => {
if (this.filterType === 'all' || ev.type === this.filterType) {
const start = new Date(ev.start);
const end = new Date(ev.end);
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
dates.add(d.toISOString().slice(0, 10));
}
if (!this.matchesFilter(ev.type)) return
const start = this.parseDateTime(ev.start_time)
const end = ev.end_time ? this.parseDateTime(ev.end_time) : this.parseDateTime(ev.start_time)
if (!start || !end) return
const cursor = new Date(start)
while (cursor <= end) {
dates.add(this.toDateString(cursor))
cursor.setDate(cursor.getDate() + 1)
}
});
return Array.from(dates).map(date => ({ date, info: '' }));
})
return Array.from(dates).map(date => ({ date, info: '' }))
},
monthEvents() {
if (!this.calendarDate) return [];
const [year, month] = this.calendarDate.split('-').map(Number);
if (!this.calendarDate) return []
const [yearStr, monthStr] = (this.calendarDate || '').split('-')
const year = parseInt(yearStr, 10)
const month = parseInt(monthStr, 10)
if (!year || !month) return []
const monthStart = new Date(year, month - 1, 1)
const monthEnd = new Date(year, month, 0)
const filtered = this.courses.filter(ev => {
if (this.filterType !== 'all' && ev.type !== this.filterType) return false;
const evDate = new Date(ev.start);
return evDate.getFullYear() === year && (evDate.getMonth() + 1) === month;
});
if (!this.matchesFilter(ev.type)) return false
const start = this.parseDateTime(ev.start_time)
const end = ev.end_time ? this.parseDateTime(ev.end_time) : this.parseDateTime(ev.start_time)
if (!start || !end) return false
//
return !(end < monthStart || start > monthEnd)
})
return filtered.sort((a, b) => new Date(a.start) - new Date(b.start));
return filtered.sort((a, b) => this.parseDateTime(a.start_time) - this.parseDateTime(b.start_time))
}
},
created(){
this.getCalendar()
},
methods: {
matchesFilter(type) {
// filterType
if (this.filterType === '' || this.filterType === null || this.filterType === undefined) return true
return type === this.filterType
},
parseDateTime(dateTimeStr) {
if (!dateTimeStr) return null
const [datePart, timePart = '00:00:00'] = dateTimeStr.trim().split(/[T\s]+/)
const [y, m, d] = datePart.split('-').map(n => parseInt(n, 10))
const [hh = 0, mm = 0, ss = 0] = timePart.split(':').map(n => parseInt(n, 10))
return new Date(y, (m || 1) - 1, d || 1, hh || 0, mm || 0, ss || 0)
},
toDateString(d) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
},
async getCalendar(){
try {
const res = await this.$u.api.calendarsGet({
month: this.calendarDate
})
const rows = (res && res.data) ? res.data : (Array.isArray(res) ? res : [])
this.courses = Array.isArray(rows) ? rows : []
} catch (error) {
console.error('getCalendar failed:', error)
}
},
onFilterChange(type) {
this.filterType = type;
},
onDateChange({ fulldate }) {
const evs = this.getEventsForDate(fulldate);
const evs = this.getEventsForDate(fulldate)
if (evs.length) {
//
evs.sort((a,b) => {
const order = { course: 1, workshop: 2, activity: 3 };
return (order[a.type] || 9) - (order[b.type] || 9);
});
this.showCourseDetail(evs[0]);
//
evs.sort((a,b) => this.parseDateTime(a.start_time) - this.parseDateTime(b.start_time))
this.showCourseDetail(evs[0])
}
},
onMonthSwitch({ year, month }) {
this.calendarDate = `${year}-${String(month).padStart(2, '0')}`;
this.calendarDate = `${year}-${String(month).padStart(2, '0')}`
this.getCalendar()
},
onEditEvent(id){
//
// console.log('edit', id)
},
getEventsForDate(dateStr) {
//
const targetDate = new Date(dateStr);
targetDate.setHours(0, 0, 0, 0);
const targetDate = new Date(dateStr)
targetDate.setHours(0, 0, 0, 0)
return this.courses.filter(ev => {
if (this.filterType !== 'all' && ev.type !== this.filterType) return false;
const startDate = new Date(ev.start);
startDate.setHours(0, 0, 0, 0);
const endDate = new Date(ev.end);
endDate.setHours(0, 0, 0, 0);
return targetDate >= startDate && targetDate <= endDate;
});
if (this.filterType !== 'all' && ev.type !== this.filterType) return false
const startDate = this.parseDateTime(ev.start_time)
const endDate = ev.end_time ? this.parseDateTime(ev.end_time) : this.parseDateTime(ev.start_time)
if (!startDate || !endDate) return false
startDate.setHours(0,0,0,0)
endDate.setHours(0,0,0,0)
return targetDate >= startDate && targetDate <= endDate
})
},
showCourseDetail(ev) {
this.detailData = ev;
this.showDetail = true;
this.checkEnrollmentDeadline(ev);
//
// type=1
// - end_time <= end_time packages/course/detail?id=course_id
// - url webview
// type=3 使 v-html content
// type=4 webview
const type = ev.type
if (type === 1) {
const now = new Date()
const end = ev.end_time ? this.parseDateTime(ev.end_time) : null
const inTime = end ? (now.getTime() <= end.getTime()) : true
if (inTime) {
if (ev.course_id) {
uni.navigateTo({ url: `/packages/course/detail?id=${ev.course_id}` })
return
}
}
// end_time url
if (ev.url) {
const encoded = ev.url
uni.navigateTo({ url: `/packages/webview/index?type=3&url=${encoded}` })
return
}
if (ev.course_id) {
uni.navigateTo({ url: `/packages/course/detail?id=${ev.course_id}` })
return
}
//
this.detailData = ev
this.showDetail = true
return
}
if (type === 3) {
this.detailData = ev
this.showDetail = true
return
}
if (type === 4) {
if (ev.url) {
const encoded = ev.url
uni.navigateTo({ url: `/packages/webview/index?type=3&url=${encoded}` })
return
}
// url
this.detailData = ev
this.showDetail = true
return
}
//
this.detailData = ev
this.showDetail = true
},
enrollCourse() {
uni.showModal({
@ -355,20 +312,24 @@ export default {
},
getCourseTypeName(type) {
const types = {
course: '课程',
activity: '日程',
workshop: '资讯'
};
return types[type] || type;
1: '课程',
2: '会议',
3: '自定义事件',
4: '资讯',
5: '其他'
}
return types[type] || '其他'
},
formatDateTime(dateStr) {
if (!dateStr) return '';
const d = new Date(dateStr);
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`;
if (!dateStr) return ''
const d = this.parseDateTime(dateStr)
if (!d || isNaN(d.getTime())) return ''
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`
},
formatDateTimeRange(startStr, endStr) {
const start = new Date(startStr);
const end = new Date(endStr);
const start = this.parseDateTime(startStr)
const end = endStr ? this.parseDateTime(endStr) : this.parseDateTime(startStr)
if (!start || !end) return ''
const startDay = `${String(start.getMonth() + 1).padStart(2,'0')}-${String(start.getDate()).padStart(2,'0')}`;
const startTime = `${String(start.getHours()).padStart(2,'0')}:${String(start.getMinutes()).padStart(2,'0')}`;
const endDay = `${String(end.getMonth() + 1).padStart(2,'0')}-${String(end.getDate()).padStart(2,'0')}`;
@ -475,7 +436,10 @@ export default {
margin-bottom: 20rpx;
}
.events-list {
max-height: 400rpx;
max-height: none;
}
.events-list.no-scroll {
overflow: visible;
}
.event-item {
display: flex;
@ -546,18 +510,19 @@ export default {
}
.course-info {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-columns: 1fr;
gap: 10rpx;
margin-bottom: 18rpx;
}
.info-item {
background: #f8f9fa;
padding: 10rpx;
padding: 20rpx;
border-radius: 8rpx;
}
.info-label {
font-size: 28rpx;
color: #6c757d;
margin-right:20rpx;
}
.info-value {
font-size: 28rpx;

@ -3,19 +3,19 @@
<view class="checkin-container">
<!-- 课程信息卡片 -->
<view class="course-card">
<view class="course-title">{{ course.title }}</view>
<view class="course-title">{{ course.theme }}</view>
<view class="course-info">
<view class="info-item">
<u-icon name="calendar-fill" class="info-icon"></u-icon>
<text>{{ course.time }}</text>
<text>{{ course.date }} - {{ course.period }}</text>
</view>
<view class="info-item">
<u-icon name="map-fill" class="info-icon"></u-icon>
<text>{{ course.locationName }}</text>
<text>{{ course.address }}</text>
</view>
<view class="info-item">
<u-icon name="account-fill" class="info-icon"></u-icon>
<text>{{ course.teacher }}</text>
<text>{{ course.teacher?course.teacher.name:'' }}</text>
</view>
</view>
</view>
@ -32,6 +32,7 @@
<u-icon :name="getStatusIcon(locationStatus.type)"></u-icon>
<text>{{ locationStatus.text }}</text>
</view>
<view v-if="showOpenLocationSetting" class="open-permission" @tap="requestLocationPermission"></view>
</view>
<view class="status-item">
<text class="status-label">打卡范围</text>
@ -44,7 +45,7 @@
<!-- 距离信息 -->
<view v-if="distance !== null" class="distance-info">
<view class="distance-value">{{ distance }} m</view>
<view class="distance-value">{{ formattedDistance }}</view>
<view class="distance-label">距离课程地点</view>
</view>
@ -86,8 +87,7 @@
</view>
<view v-else class="history-list">
<view v-for="(record, index) in checkinHistory" :key="index" class="history-item">
<text>时间: {{ record.time }}</text>
<text>距离: {{ record.distance }}m</text>
<text>签到时间: {{ record.created_at }}</text>
</view>
</view>
</view>
@ -99,14 +99,8 @@
export default {
data() {
return {
course: {
title: '2025产业加速营 | 智能制造专题',
time: '2025-06-04 09:00-17:00',
teacher: '王教授',
locationName: '商学院A101',
location: { lat: 31.2741, lng: 120.7853 },
allowedDistance: 100 //
},
course_content_id:'',
course: {},
userLocation: null,
distance: null,
canCheckin: false,
@ -114,53 +108,199 @@ export default {
locationStatus: { type: 'default', text: '未定位' },
rangeStatus: { type: 'default', text: '未计算' },
alertInfo: { type: '', message: '' },
checkinHistory: []
checkinHistory: [],
showOpenLocationSetting: false
};
},
onLoad() {
this.loadCheckinHistory();
this.initializeCheckin();
computed: {
formattedDistance() {
const d = this.distance
if (d == null) return ''
// >= 1km km< 1km
if (d >= 1000) return `${Math.round(d / 1000)}km`
return `${d}m`
}
},
onLoad(options) {
console.log("options?.course_content_id",options?.course_content_id)
this.course_content_id = options?.course_content_id
// this.loadCheckinHistory();
//
// this.checkPermissionAndInit();
this.initializeCheckin()
},
methods: {
initializeCheckin() {
this.locationStatus = { type: 'warning', text: '定位中...' };
this.rangeStatus = { type: 'default', text: '未计算' };
this.canCheckin = false;
this.distance = null;
this.alertInfo = { type: '', message: '' };
uni.getLocation({
type: 'gcj02',
success: (res) => {
this.userLocation = { lat: res.latitude, lng: res.longitude };
this.locationStatus = { type: 'success', text: '定位成功' };
this.calculateDistance();
async checkPermissionAndInit() {
this.showOpenLocationSetting = false
let granted = false
try {
const setting = await new Promise((resolve) => {
uni.getSetting({ success: resolve, fail: () => resolve({}) })
})
const auth = setting?.authSetting || {}
granted = !!auth['scope.getLocation']
} catch (e) {}
if (!granted) {
this.locationStatus = { type: 'warning', text: '未获取定位权限' }
this.rangeStatus = { type: 'default', text: '未计算' }
this.canCheckin = false
this.showOpenLocationSetting = true
return
}
await this.initializeCheckin()
},
requestLocationPermission() {
uni.authorize({
scope: 'scope.getLocation',
success: () => {
this.showOpenLocationSetting = false
this.initializeCheckin()
},
fail: () => {
this.locationStatus = { type: 'error', text: '定位失败' };
this.rangeStatus = { type: 'error', text: '无法计算' };
this.alertInfo = { type: 'error', message: '获取位置失败,请检查定位权限和网络。' };
uni.openSetting({
success: (res) => {
const ok = res?.authSetting && res.authSetting['scope.getLocation']
if (ok) {
this.showOpenLocationSetting = false
this.initializeCheckin()
}
}
})
}
})
},
// Promise
// uni.getLocation
getLocation() {
return new Promise((resolve, reject) => {
const candidates = [
{ lat: 31.401992, lng: 120.686876 },
{ lat: 32.899321, lng: 120.682145 },
{ lat: 31.272715, lng: 120.670337 }
]
const idx = Math.floor(Math.random() * candidates.length)
resolve(candidates[idx])
//
// uni.getLocation({
// type: 'gcj02',
// success: (res) => resolve({ lat: res.latitude, lng: res.longitude }),
// fail: (err) => reject(err)
// })
})
},
//
async getCourse() {
const res = await this.$u.api.courseContentDetail({
course_content_id: this.course_content_id
});
const data = res || {}
// latitude/longitude lat/lng
const lat = data.latitude
const lng = data.longitude
this.course = {
...this.course,
...data,
location: { lat, lng },
allowedDistance: 500
}
},
//
async signGet() {
const res = await this.$u.api.signGet({
course_content_id: this.course_content_id
});
const rows = res?.list || []
this.checkinHistory = Array.isArray(rows) ? rows : []
if (this.checkinHistory.length > 0) {
this.hasCheckedIn = true
this.canCheckin = false
this.alertInfo = { type: 'warning', message: '已存在签到记录,不能重复打卡' }
}
},
calculateDistance() {
if (!this.userLocation) return;
const dist = this.getDistanceFromLatLonInM(
this.userLocation.lat, this.userLocation.lng,
this.course.location.lat, this.course.location.lng
);
this.distance = Math.round(dist);
if (this.distance <= this.course.allowedDistance) {
// signDistance + calculateDistance
async signDistance() {
let distance = null
let allowed = this.course.allowedDistance || 500
try {
const res = await this.$u.api.signDistance({
course_content_id: this.course_content_id,
latitude: this.userLocation.lat,
longitude: this.userLocation.lng
})
const data = res || {}
// km m
if (data && data.distance != null && !isNaN(parseFloat(data.distance))) {
const km = parseFloat(data.distance)
distance = Math.round(km * 1000)
}
if (data && data.content_check_range != null && !isNaN(parseFloat(data.content_check_range))) {
const kmRange = parseFloat(data.content_check_range)
allowed = Math.round(kmRange * 1000)
}
} catch (e) {}
if (distance === null) {
const distLocal = this.getDistanceFromLatLonInM(
this.userLocation.lat, this.userLocation.lng,
this.course.location.lat, this.course.location.lng
)
distance = Math.round(distLocal)
}
this.distance = distance
this.course.allowedDistance = allowed
if (this.distance <= allowed) {
this.rangeStatus = { type: 'success', text: '在打卡范围内' };
this.canCheckin = !this.hasCheckedIn;
this.alertInfo = { type: 'success', message: `您已进入打卡范围,距离${this.distance}米。` };
this.alertInfo = this.hasCheckedIn
? { type: 'warning', message: '已存在签到记录,不能重复打卡' }
: { type: 'success', message: `您已进入打卡范围,距离${this.formatDistanceVal(this.distance)}` };
} else {
this.rangeStatus = { type: 'error', text: `超出范围约${this.distance - this.course.allowedDistance}` };
const over = Math.max(0, this.distance - allowed)
this.rangeStatus = { type: 'error', text: `超出范围约${this.formatDistanceVal(over)}` };
this.canCheckin = false;
this.alertInfo = { type: 'warning', message: `您需要进入${this.course.allowedDistance}米范围内才能签到。` };
this.alertInfo = { type: 'warning', message: `您需要进入${this.formatDistanceVal(allowed)}范围内才能签到。` };
}
},
//
async signCheck() {
const res = await this.$u.api.signCheck({
course_content_id: this.course_content_id,
latitude: this.userLocation.lat,
longitude: this.userLocation.lng
});
//
this.hasCheckedIn = true
this.canCheckin = false
this.alertInfo = { type: 'success', message: '签到成功' }
//
try { await this.signGet() } catch (e) {}
},
async initializeCheckin() {
this.locationStatus = { type: 'warning', text: '定位中...' };
this.rangeStatus = { type: 'default', text: '未计算' };
this.canCheckin = false;
this.distance = null;
this.alertInfo = { type: '', message: '' };
try {
// 1.
await this.getCourse()
await this.signGet()
// 2.
const loc = await this.getLocation()
console.log(loc)
this.userLocation = loc
this.locationStatus = { type: 'success', text: '定位成功' };
// 3.
await this.signDistance()
} catch (e) {
this.locationStatus = { type: 'error', text: '定位失败' };
this.rangeStatus = { type: 'error', text: '无法计算' };
this.alertInfo = { type: 'error', message: '获取信息或位置失败,请检查权限和网络。' };
}
},
// calculateDistance signDistance
getDistanceFromLatLonInM(lat1, lon1, lat2, lon2) {
const R = 6371;
const dLat = this.deg2rad(lat2 - lat1);
@ -174,6 +314,13 @@ export default {
deg2rad(deg) {
return deg * (Math.PI / 180);
},
formatDistanceVal(m) {
if (m == null) return ''
const n = Number(m)
if (isNaN(n)) return ''
if (n >= 1000) return `${Math.round(n / 1000)}km`
return `${Math.round(n)}m`
},
getStatusIcon(type) {
const icons = {
success: 'checkmark-circle-fill',
@ -183,25 +330,18 @@ export default {
};
return icons[type] || 'question-circle-fill';
},
performCheckin() {
async performCheckin() {
if (this.hasCheckedIn) {
uni.showToast({ title: '您今天已经签过到了', icon: 'none' });
return;
}
const now = new Date();
const checkinData = {
type: '位置签到',
time: now.toLocaleString('zh-CN'),
distance: this.distance
};
this.checkinHistory.unshift(checkinData);
uni.setStorageSync('checkinHistory', this.checkinHistory);
this.hasCheckedIn = true;
this.canCheckin = false;
this.alertInfo = { type: 'success', message: `签到成功!时间:${checkinData.time}` };
uni.showToast({ title: '签到成功!', icon: 'success' });
if (!this.canCheckin) return
try {
await this.signCheck()
uni.showToast({ title: '签到成功!', icon: 'success' });
} catch (e) {
uni.showToast({ title: '签到失败', icon: 'none' });
}
},
loadCheckinHistory() {
const records = uni.getStorageSync('checkinHistory') || [];

Loading…
Cancel
Save