You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

495 lines
15 KiB

5 months ago
<template>
<view class="checkin-page">
<view class="checkin-container">
<!-- 课程信息卡片 -->
<view class="course-card">
4 months ago
<view class="course-title">{{ course.theme }}</view>
5 months ago
<view class="course-info">
<view class="info-item">
<u-icon name="calendar-fill" class="info-icon"></u-icon>
4 months ago
<text>{{ course.date }} - {{ course.period }}</text>
5 months ago
</view>
<view class="info-item">
<u-icon name="map-fill" class="info-icon"></u-icon>
4 months ago
<text>{{ course.address }}</text>
5 months ago
</view>
<view class="info-item">
<u-icon name="account-fill" class="info-icon"></u-icon>
4 months ago
<text>{{ course.teacher?course.teacher.name:'' }}</text>
5 months ago
</view>
</view>
</view>
<!-- 状态检测卡片 -->
<view class="status-card">
<h6 class="card-title">
<u-icon name="shield-checkmark" class="title-icon"></u-icon>
签到状态检测
</h6>
<view class="status-item">
<text class="status-label">定位状态</text>
<view :class="['status-value', 'status-' + locationStatus.type]">
<u-icon :name="getStatusIcon(locationStatus.type)"></u-icon>
<text>{{ locationStatus.text }}</text>
</view>
4 months ago
<view v-if="showOpenLocationSetting" class="open-permission" @tap="requestLocationPermission"></view>
5 months ago
</view>
<view class="status-item">
<text class="status-label">打卡范围</text>
<view :class="['status-value', 'status-' + rangeStatus.type]">
<u-icon :name="getStatusIcon(rangeStatus.type)"></u-icon>
<text>{{ rangeStatus.text }}</text>
</view>
</view>
</view>
<!-- 距离信息 -->
<view v-if="distance !== null" class="distance-info">
4 months ago
<view class="distance-value">{{ formattedDistance }}</view>
5 months ago
<view class="distance-label">距离课程地点</view>
</view>
<!-- 提示信息 -->
<view v-if="alertInfo.message" :class="['alert-custom', 'alert-' + alertInfo.type]">
<u-icon :name="getStatusIcon(alertInfo.type)" class="alert-icon"></u-icon>
<text>{{ alertInfo.message }}</text>
</view>
<!-- 签到操作 -->
<view class="checkin-actions">
<u-button
type="primary"
class="checkin-btn"
:disabled="!canCheckin"
@click="performCheckin"
>
<u-icon name="map" class="btn-icon"></u-icon>
{{ hasCheckedIn ? '已签到' : '位置签到' }}
</u-button>
<u-button
type="success"
class="recheck-btn"
@click="initializeCheckin"
>
<u-icon name="reload" class="btn-icon"></u-icon>
重新定位
</u-button>
</view>
<!-- 签到记录 -->
<view class="status-card">
<h6 class="card-title">
<u-icon name="order" class="title-icon"></u-icon>
签到记录
</h6>
<view v-if="checkinHistory.length === 0" class="history-empty">
暂无签到记录
</view>
<view v-else class="history-list">
<view v-for="(record, index) in checkinHistory" :key="index" class="history-item">
4 months ago
<text>签到时间: {{ record.created_at }}</text>
5 months ago
</view>
</view>
</view>
</view>
</view>
</template>
<script>
export default {
data() {
return {
4 months ago
course_content_id:'',
course: {},
5 months ago
userLocation: null,
distance: null,
canCheckin: false,
hasCheckedIn: false,
locationStatus: { type: 'default', text: '未定位' },
rangeStatus: { type: 'default', text: '未计算' },
alertInfo: { type: '', message: '' },
4 months ago
checkinHistory: [],
showOpenLocationSetting: false
5 months ago
};
},
4 months ago
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()
5 months ago
},
methods: {
4 months ago
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()
5 months ago
},
fail: () => {
4 months ago
uni.openSetting({
success: (res) => {
const ok = res?.authSetting && res.authSetting['scope.getLocation']
if (ok) {
this.showOpenLocationSetting = false
this.initializeCheckin()
}
}
})
5 months ago
}
4 months ago
})
},
// 获取当前位置经纬度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
5 months ago
});
4 months ago
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: '已存在签到记录,不能重复打卡' }
}
5 months ago
},
4 months ago
// 获取距离并更新状态(合并 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) {
5 months ago
this.rangeStatus = { type: 'success', text: '在打卡范围内' };
this.canCheckin = !this.hasCheckedIn;
4 months ago
this.alertInfo = this.hasCheckedIn
? { type: 'warning', message: '已存在签到记录,不能重复打卡' }
: { type: 'success', message: `您已进入打卡范围,距离${this.formatDistanceVal(this.distance)}` };
5 months ago
} else {
4 months ago
const over = Math.max(0, this.distance - allowed)
this.rangeStatus = { type: 'error', text: `超出范围约${this.formatDistanceVal(over)}` };
5 months ago
this.canCheckin = false;
4 months ago
this.alertInfo = { type: 'warning', message: `您需要进入${this.formatDistanceVal(allowed)}范围内才能签到。` };
5 months ago
}
},
4 months ago
// 签到
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
5 months ago
getDistanceFromLatLonInM(lat1, lon1, lat2, lon2) {
const R = 6371;
const dLat = this.deg2rad(lat2 - lat1);
const dLon = this.deg2rad(lon2 - lon1);
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(this.deg2rad(lat1)) * Math.cos(this.deg2rad(lat2)) *
Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c * 1000;
},
deg2rad(deg) {
return deg * (Math.PI / 180);
},
4 months ago
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`
},
5 months ago
getStatusIcon(type) {
const icons = {
success: 'checkmark-circle-fill',
error: 'close-circle-fill',
warning: 'error-circle-fill',
default: 'question-circle-fill'
};
return icons[type] || 'question-circle-fill';
},
4 months ago
async performCheckin() {
5 months ago
if (this.hasCheckedIn) {
uni.showToast({ title: '您今天已经签过到了', icon: 'none' });
return;
}
4 months ago
if (!this.canCheckin) return
try {
await this.signCheck()
uni.showToast({ title: '签到成功!', icon: 'success' });
} catch (e) {
uni.showToast({ title: '签到失败', icon: 'none' });
}
5 months ago
},
loadCheckinHistory() {
const records = uni.getStorageSync('checkinHistory') || [];
this.checkinHistory = records;
if (records.length > 0) {
const lastRecordTime = new Date(records[0].time);
if (lastRecordTime.toDateString() === new Date().toDateString()) {
this.hasCheckedIn = true;
}
}
}
}
};
</script>
<style scoped>
.checkin-page {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20rpx;
}
.checkin-container {
max-width: 400px;
margin: 0 auto;
}
.course-card, .status-card {
background: white;
border-radius: 32rpx;
box-shadow: 0 8rpx 32rpx rgba(0,0,0,0.1);
padding: 32rpx;
margin-bottom: 30rpx;
}
.course-title {
font-size: 36rpx;
font-weight: 600;
text-align: center;
margin-bottom: 24rpx;
}
.course-info {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.info-item {
display: flex;
align-items: center;
gap: 16rpx;
padding: 12rpx;
background: #f8f9fa;
border-radius: 16rpx;
font-size: 26rpx;
}
.info-icon {
color: #3498db;
}
.card-title {
display: flex;
align-items: center;
gap: 12rpx;
font-size: 30rpx;
font-weight: 600;
margin-bottom: 16rpx;
}
.status-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16rpx 0;
border-bottom: 1rpx solid #e9ecef;
font-size: 28rpx;
}
.status-item:last-child {
border-bottom: none;
}
.status-label {
font-weight: 500;
}
.status-value {
display: flex;
align-items: center;
gap: 8rpx;
}
.status-success { color: #2ecc71; }
.status-error { color: #e74c3c; }
.status-warning { color: #f1c40f; }
.status-default { color: #909399; }
.distance-info {
text-align: center;
padding: 24rpx;
background: #fff;
border-radius: 32rpx;
margin-bottom: 24rpx;
}
.distance-value {
font-size: 48rpx;
font-weight: 700;
color: #3498db;
}
.distance-label {
font-size: 26rpx;
color: #6c757d;
margin-top: 8rpx;
}
.alert-custom {
border-radius: 24rpx;
padding: 24rpx;
margin-bottom: 24rpx;
display: flex;
align-items: center;
gap: 16rpx;
font-size: 26rpx;
color: #fff;
}
.alert-success { background: #2ecc71; }
.alert-error { background: #e74c3c; }
.alert-warning { background: #f1c40f; }
.checkin-actions {
display: flex;
flex-direction: column;
gap: 20rpx;
margin-bottom: 30rpx;
}
.checkin-btn /deep/ .u-btn, .recheck-btn /deep/ .u-btn {
height: 90rpx;
}
.btn-icon {
margin-right: 12rpx;
}
.history-empty {
text-align: center;
color: #999;
padding: 30rpx 0;
font-size: 26rpx;
}
.history-list {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.history-item {
display: flex;
justify-content: space-between;
font-size: 24rpx;
color: #666;
background: #f8f9fa;
padding: 12rpx;
border-radius: 12rpx;
}
</style>