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

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<template>
<view class="checkin-page">
<view class="checkin-container">
<!-- 课程信息卡片 -->
<view class="course-card">
<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.date }} - {{ course.period }}</text>
</view>
<view class="info-item">
<u-icon name="map-fill" class="info-icon"></u-icon>
<text>{{ course.address }}</text>
</view>
<view class="info-item">
<u-icon name="account-fill" class="info-icon"></u-icon>
<text>{{ course.teacher?course.teacher.name:'' }}</text>
</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>
<view v-if="showOpenLocationSetting" class="open-permission" @tap="requestLocationPermission">打开定位权限</view>
</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">
<view class="distance-value">{{ formattedDistance }}</view>
<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">
<text>签到时间: {{ record.created_at }}</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
export default {
data() {
return {
course_content_id:'',
course: {},
userLocation: null,
distance: null,
canCheckin: false,
hasCheckedIn: false,
locationStatus: { type: 'default', text: '未定位' },
rangeStatus: { type: 'default', text: '未计算' },
alertInfo: { type: '', message: '' },
checkinHistory: [],
showOpenLocationSetting: false
};
},
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: {
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: () => {
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: '已存在签到记录,不能重复打卡' }
}
},
// 获取距离并更新状态(合并 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 = this.hasCheckedIn
? { type: 'warning', message: '已存在签到记录,不能重复打卡' }
: { type: 'success', message: `您已进入打卡范围,距离${this.formatDistanceVal(this.distance)}` };
} else {
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.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);
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);
},
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',
error: 'close-circle-fill',
warning: 'error-circle-fill',
default: 'question-circle-fill'
};
return icons[type] || 'question-circle-fill';
},
async performCheckin() {
if (this.hasCheckedIn) {
uni.showToast({ title: '您今天已经签过到了', icon: 'none' });
return;
}
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') || [];
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>