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.

684 lines
22 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.name?course.name:'' }}</view>
<view class="course-info">
<view class="info-item">
<u-icon name="calendar-fill" class="info-icon"></u-icon>
<text>开始日期{{ course.start_date?course.start_date:'' }}</text>
</view>
<view class="info-item">
<u-icon name="calendar-fill" class="info-icon"></u-icon>
<text>结束日期{{ course.end_date?course.end_date:'' }}</text>
</view>
<view class="info-item">
<u-icon name="map-fill" class="info-icon"></u-icon>
<text>{{ course.address_detail?course.address_detail:'' }}</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>
<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="handleRecheckClick"
>
<u-icon :name="hasLocationPermission ? 'reload' : 'location'" class="btn-icon"></u-icon>
{{ hasLocationPermission ? '重新定位' : '获取定位权限' }}
</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_id:'',
course: {},
userLocation: null,
distance: null,
canCheckin: false,
hasCheckedIn: false,
locationStatus: { type: 'default', text: '未定位' },
rangeStatus: { type: 'default', text: '未计算' },
alertInfo: { type: '', message: '' },
checkinHistory: [],
hasLocationPermission: false,
// 坐标系统配置
coordinateConfig: {
// 后端返回的坐标系统:'wgs84' 或 'gcj02'
backendSystem: 'wgs84',
// 是否启用坐标转换
enableConversion: true
}
};
},
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:", options)
this.course_id = options?.course_id
// 测试坐标转换功能
this.testCoordinateConversion()
// 进入页面:先检查定位权限
console.log("开始检查权限...")
this.checkPermissionAndInit();
},
methods: {
async checkPermissionAndInit() {
console.log('开始检查权限...')
let granted = false
try {
const setting = await new Promise((resolve) => {
uni.getSetting({ success: resolve, fail: () => resolve({}) })
})
console.log('获取设置结果:', setting)
const auth = setting?.authSetting || {}
console.log('权限设置:', auth)
granted = !!auth['scope.userLocation']
this.hasLocationPermission = granted
console.log('权限检测结果:', granted, 'hasLocationPermission:', this.hasLocationPermission)
} catch (e) {
console.log('权限检测异常:', e)
this.hasLocationPermission = false
}
// 无论权限状态如何,都先获取课程信息
console.log('开始获取课程信息...')
await this.getCourse()
await this.signGet()
if (!granted) {
console.log('无权限,设置状态为未获取定位权限')
this.locationStatus = { type: 'warning', text: '未获取定位权限' }
this.rangeStatus = { type: 'default', text: '未计算' }
this.canCheckin = false
// 即使没有权限,也显示课程信息,但不进行定位相关操作
return
}
// 有权限则继续完整的签到流程
console.log('有权限,开始完整签到流程')
await this.initializeCheckin()
},
handleRecheckClick() {
console.log('按钮被点击,当前权限状态:', this.hasLocationPermission)
if (this.hasLocationPermission) {
console.log('有权限,执行重新定位')
this.initializeCheckin()
} else {
console.log('无权限,请求定位权限')
this.requestLocationPermission()
}
},
requestLocationPermission() {
console.log('开始请求定位权限...')
uni.authorize({
scope: 'scope.userLocation',
success: () => {
console.log('定位权限授权成功')
this.hasLocationPermission = true
this.locationStatus = { type: 'warning', text: '定位中...' }
this.initializeCheckin()
},
fail: (err) => {
console.log('定位权限授权失败:', err)
uni.openSetting({
success: (res) => {
const ok = res?.authSetting && res.authSetting['scope.userLocation']
if (ok) {
console.log('通过设置页面获取定位权限成功')
this.hasLocationPermission = true
this.locationStatus = { type: 'warning', text: '定位中...' }
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.courseDetail({
course_id: this.course_id
});
const data = res || {}
// 兼容后端字段latitude/longitude 或 lat/lng
let lat = data.latitude || data.lat
let lng = data.longitude || data.lng
// 根据配置进行坐标转换
let convertedLng = lng
let convertedLat = lat
if (this.coordinateConfig.enableConversion && this.coordinateConfig.backendSystem !== 'gcj02') {
const [converted] = this.convertCoordinates(lng, lat, this.coordinateConfig.backendSystem, 'gcj02')
convertedLng = converted[0]
convertedLat = converted[1]
}
console.log('原始坐标:', { lng, lat, system: this.coordinateConfig.backendSystem })
console.log('转换后坐标:', { lng: convertedLng, lat: convertedLat, system: 'gcj02' })
this.course = {
...this.course,
...data,
location: {
lat: convertedLat,
lng: convertedLng,
originalLat: lat,
originalLng: lng,
coordinateSystem: 'gcj02'
},
allowedDistance: 500
}
},
// 获取签到记录
async signGet() {
const res = await this.$u.api.signGet({
course_id: this.course_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 {
// 用户位置是GCJ-02坐标系uni.getLocation返回的
const userLat = this.userLocation.lat
const userLng = this.userLocation.lng
console.log('用户位置坐标:', { lat: userLat, lng: userLng, system: 'gcj02' })
console.log('课程位置坐标:', {
lat: this.course.location.lat,
lng: this.course.location.lng,
system: this.course.location.coordinateSystem
})
const res = await this.$u.api.signDistance({
course_id: this.course_id,
latitude: userLat,
longitude: userLng
})
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() {
try {
const res = await this.$u.api.courseCheck({
course_id: this.course_id,
latitude: this.userLocation.lat,
longitude: this.userLocation.lng
});
// 根据返回结果更新本地状态
this.hasCheckedIn = true
this.canCheckin = false
this.alertInfo = { type: 'success', message: '签到成功' }
uni.showToast({ title: '签到成功!', icon: 'success' });
// 可选:重新拉取签到记录
try { await this.signGet() } catch (e) {}
} catch (e) {
console.error('签到失败:', e);
uni.showToast({ title: '签到失败', icon: 'none' });
}
},
async initializeCheckin() {
this.locationStatus = { type: 'warning', text: '定位中...' };
this.rangeStatus = { type: 'default', text: '未计算' };
this.canCheckin = false;
this.distance = null;
this.alertInfo = { type: '', message: '' };
try {
// 1. 定位
const loc = await this.getLocation()
console.log(loc)
this.userLocation = loc
this.locationStatus = { type: 'success', text: '定位成功' };
// 2. 距离与状态
await this.signDistance()
} catch (e) {
this.locationStatus = { type: 'error', text: '定位失败' };
this.rangeStatus = { type: 'error', text: '无法计算' };
this.alertInfo = { type: 'error', message: '获取信息或位置失败,请检查权限和网络。' };
}
},
// 坐标转换工具方法
// WGS84 转 GCJ-02 (火星坐标系)
wgs84ToGcj02(lng, lat) {
const a = 6378245.0;
const ee = 0.00669342162296594323;
if (this.outOfChina(lng, lat)) {
return [lng, lat];
}
let dLat = this.transformLat(lng - 105.0, lat - 35.0);
let dLng = this.transformLng(lng - 105.0, lat - 35.0);
const radLat = lat / 180.0 * Math.PI;
let magic = Math.sin(radLat);
magic = 1 - ee * magic * magic;
const sqrtMagic = Math.sqrt(magic);
dLat = (dLat * 180.0) / ((a * (1 - ee)) / (magic * sqrtMagic) * Math.PI);
dLng = (dLng * 180.0) / (a / sqrtMagic * Math.cos(radLat) * Math.PI);
return [lng + dLng, lat + dLat];
},
// GCJ-02 转 WGS84
gcj02ToWgs84(lng, lat) {
if (this.outOfChina(lng, lat)) {
return [lng, lat];
}
let dLat = this.transformLat(lng - 105.0, lat - 35.0);
let dLng = this.transformLng(lng - 105.0, lat - 35.0);
const radLat = lat / 180.0 * Math.PI;
let magic = Math.sin(radLat);
magic = 1 - 0.00669342162296594323 * magic * magic;
const sqrtMagic = Math.sqrt(magic);
dLat = (dLat * 180.0) / ((6378245.0 * (1 - 0.00669342162296594323)) / (magic * sqrtMagic) * Math.PI);
dLng = (dLng * 180.0) / (6378245.0 / sqrtMagic * Math.cos(radLat) * Math.PI);
return [lng - dLng, lat - dLat];
},
// 判断是否在中国境内
outOfChina(lng, lat) {
return !(lng > 73.66 && lng < 135.05 && lat > 3.86 && lat < 53.55);
},
// 转换纬度
transformLat(lng, lat) {
let ret = -100.0 + 2.0 * lng + 3.0 * lat + 0.2 * lat * lat + 0.1 * lng * lat + 0.2 * Math.sqrt(Math.abs(lng));
ret += (20.0 * Math.sin(6.0 * lng * Math.PI) + 20.0 * Math.sin(2.0 * lng * Math.PI)) * 2.0 / 3.0;
ret += (20.0 * Math.sin(lat * Math.PI) + 40.0 * Math.sin(lat / 3.0 * Math.PI)) * 2.0 / 3.0;
ret += (160.0 * Math.sin(lat / 12.0 * Math.PI) + 320 * Math.sin(lat * Math.PI / 30.0)) * 2.0 / 3.0;
return ret;
},
// 转换经度
transformLng(lng, lat) {
let ret = 300.0 + lng + 2.0 * lat + 0.1 * lng * lng + 0.1 * lng * lat + 0.1 * Math.sqrt(Math.abs(lat));
ret += (20.0 * Math.sin(6.0 * lng * Math.PI) + 20.0 * Math.sin(2.0 * lng * Math.PI)) * 2.0 / 3.0;
ret += (20.0 * Math.sin(lng * Math.PI) + 40.0 * Math.sin(lng / 3.0 * Math.PI)) * 2.0 / 3.0;
ret += (150.0 * Math.sin(lng / 12.0 * Math.PI) + 300.0 * Math.sin(lng / 30.0 * Math.PI)) * 2.0 / 3.0;
return ret;
},
// 统一坐标转换方法
convertCoordinates(lng, lat, fromSystem = 'gcj02', toSystem = 'gcj02') {
if (fromSystem === toSystem) {
return [lng, lat];
}
if (fromSystem === 'wgs84' && toSystem === 'gcj02') {
return this.wgs84ToGcj02(lng, lat);
}
if (fromSystem === 'gcj02' && toSystem === 'wgs84') {
return this.gcj02ToWgs84(lng, lat);
}
// 如果是从WGS84到其他坐标系先转到GCJ-02
if (fromSystem === 'wgs84') {
const gcj02 = this.wgs84ToGcj02(lng, lat);
return this.convertCoordinates(gcj02[0], gcj02[1], 'gcj02', toSystem);
}
return [lng, lat];
},
// 测试坐标转换(开发调试用)
testCoordinateConversion() {
// 北京天安门坐标示例
const wgs84Lng = 116.397128
const wgs84Lat = 39.916527
console.log('=== 坐标转换测试 ===')
console.log('WGS84坐标:', { lng: wgs84Lng, lat: wgs84Lat })
const gcj02 = this.wgs84ToGcj02(wgs84Lng, wgs84Lat)
console.log('转换为GCJ-02:', { lng: gcj02[0], lat: gcj02[1] })
const backToWgs84 = this.gcj02ToWgs84(gcj02[0], gcj02[1])
console.log('转换回WGS84:', { lng: backToWgs84[0], lat: backToWgs84[1] })
const diff = {
lng: Math.abs(backToWgs84[0] - wgs84Lng),
lat: Math.abs(backToWgs84[1] - wgs84Lat)
}
console.log('转换精度误差:', diff)
console.log('=== 测试结束 ===')
},
// 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
// 直接签到当前课程
await this.signCheck();
},
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>