chore: initialize mini program frontend

This commit is contained in:
souplearn
2026-06-24 15:19:14 +08:00
commit fa73a3c184
96 changed files with 20561 additions and 0 deletions
+479
View File
@@ -0,0 +1,479 @@
const api = require('../../utils/api')
const DEFAULT_POINT_NAME = '宁波嘉恒智能科技有限公司'
const defaultForm = {
dayStart: '08:00',
dayEnd: '17:20',
lunchStart: '11:40',
lunchEnd: '12:40',
dinnerStart: '17:20',
dinnerEnd: '18:00',
overtimeStart: '18:00',
overtimeEnd: '20:00',
nightStart: '20:00',
nightEnd: '06:00',
attendanceLatitude: '',
attendanceLongitude: '',
attendanceRadiusMeters: 500,
autoSubmitHours: 15,
attendancePoints: [],
}
const scheduleFields = [
'dayStart',
'dayEnd',
'lunchStart',
'lunchEnd',
'dinnerStart',
'dinnerEnd',
'overtimeStart',
'overtimeEnd',
'nightStart',
'nightEnd',
]
const defaultScheduleValues = () => scheduleFields.reduce((result, field) => ({
...result,
[field]: defaultForm[field],
}), {})
const emptyPoint = () => ({
localKey: `new_${Date.now()}`,
name: '',
originalName: '',
latitude: '',
longitude: '',
radiusMeters: 500,
...defaultScheduleValues(),
remark: '',
isActive: true,
})
Page({
data: {
form: { ...defaultForm },
updatedAtText: '',
refreshing: false,
saving: false,
locating: false,
locationAccuracyText: '',
expandedPointKey: '',
},
onShow() {
this.load()
},
async load() {
try {
const schedule = await api.getWorkSchedule()
this.setData({
form: this.buildForm(schedule),
updatedAtText: schedule.updatedAtText || '',
expandedPointKey: '',
})
} catch (error) {
wx.showToast({ title: error.message || '加载失败', icon: 'none' })
}
},
async onPullDownRefresh() {
this.setData({ refreshing: true })
try {
await this.load()
} finally {
this.setData({ refreshing: false })
if (wx.stopPullDownRefresh) {
wx.stopPullDownRefresh()
}
}
},
onTimeChange(e) {
const field = e.currentTarget.dataset.field
const pointIndex = e.currentTarget.dataset.index
if (pointIndex !== undefined && pointIndex !== '') {
this.setData({
[`form.attendancePoints[${pointIndex}].${field}`]: e.detail.value,
})
return
}
this.setData({ [`form.${field}`]: e.detail.value })
},
buildForm(schedule = {}) {
return {
dayStart: schedule.dayStart || defaultForm.dayStart,
dayEnd: schedule.dayEnd || defaultForm.dayEnd,
lunchStart: schedule.lunchStart || defaultForm.lunchStart,
lunchEnd: schedule.lunchEnd || defaultForm.lunchEnd,
dinnerStart: schedule.dinnerStart || defaultForm.dinnerStart,
dinnerEnd: schedule.dinnerEnd || defaultForm.dinnerEnd,
overtimeStart: schedule.overtimeStart || defaultForm.overtimeStart,
overtimeEnd: schedule.overtimeEnd || defaultForm.overtimeEnd,
nightStart: schedule.nightStart || defaultForm.nightStart,
nightEnd: schedule.nightEnd || defaultForm.nightEnd,
attendanceLatitude: schedule.attendanceLatitude === '' || schedule.attendanceLatitude === undefined || schedule.attendanceLatitude === null
? ''
: this.formatCoordinate(schedule.attendanceLatitude),
attendanceLongitude: schedule.attendanceLongitude === '' || schedule.attendanceLongitude === undefined || schedule.attendanceLongitude === null
? ''
: this.formatCoordinate(schedule.attendanceLongitude),
attendanceRadiusMeters: schedule.attendanceRadiusMeters || defaultForm.attendanceRadiusMeters,
autoSubmitHours: schedule.autoSubmitHours || defaultForm.autoSubmitHours,
attendancePoints: this.buildAttendancePoints(schedule),
}
},
buildPointSchedule(point = {}, schedule = {}) {
return scheduleFields.reduce((result, field) => ({
...result,
[field]: point[field] || schedule[field] || defaultForm[field],
}), {})
},
buildAttendancePoints(schedule = {}) {
const source = (schedule.attendancePoints || []).length
? schedule.attendancePoints
: [{
name: DEFAULT_POINT_NAME,
latitude: schedule.attendanceLatitude,
longitude: schedule.attendanceLongitude,
radiusMeters: schedule.attendanceRadiusMeters || defaultForm.attendanceRadiusMeters,
remark: '',
isActive: true,
}]
return source.map((point, index) => ({
localKey: point.name || `point_${index}`,
name: point.name || '',
originalName: point.originalName || point.name || '',
latitude: point.latitude === '' || point.latitude === undefined || point.latitude === null ? '' : this.formatCoordinate(point.latitude),
longitude: point.longitude === '' || point.longitude === undefined || point.longitude === null ? '' : this.formatCoordinate(point.longitude),
radiusMeters: point.radiusMeters || defaultForm.attendanceRadiusMeters,
...this.buildPointSchedule(point, schedule),
remark: point.remark || '',
isActive: point.isActive !== false,
}))
},
onRadiusInput(e) {
this.setData({
'form.attendanceRadiusMeters': e.detail.value,
})
},
onAutoSubmitHoursInput(e) {
this.setData({
'form.autoSubmitHours': e.detail.value,
})
},
onCoordinateInput(e) {
const field = e.currentTarget.dataset.field
this.setData({
[`form.${field}`]: e.detail.value,
})
},
onPointInput(e) {
const index = e.currentTarget.dataset.index
const field = e.currentTarget.dataset.field
this.setData({
[`form.attendancePoints[${index}].${field}`]: e.detail.value,
})
},
addAttendancePoint() {
const point = emptyPoint()
this.setData({
'form.attendancePoints': this.data.form.attendancePoints.concat(point),
expandedPointKey: point.localKey,
})
},
toggleAttendancePoint(e) {
const key = e.currentTarget.dataset.key
this.setData({
expandedPointKey: this.data.expandedPointKey === key ? '' : key,
})
},
formatCoordinate(value) {
const number = Number(value)
return Number.isFinite(number) ? number.toFixed(6) : ''
},
formatAccuracy(value) {
const number = Number(value)
if (!Number.isFinite(number)) {
return ''
}
return number < 10 ? `${number.toFixed(1)}米` : `${Math.round(number)}米`
},
sleep(ms) {
return new Promise(resolve => {
setTimeout(resolve, ms)
})
},
requestHighAccuracyLocation() {
return new Promise((resolve, reject) => {
wx.getLocation({
type: 'gcj02',
isHighAccuracy: true,
highAccuracyExpireTime: 8000,
success: resolve,
fail: reject,
})
})
},
normalizeLocation(res) {
const latitude = Number(res && res.latitude)
const longitude = Number(res && res.longitude)
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
return null
}
return {
latitude,
longitude,
accuracy: Number(res && res.accuracy),
}
},
pickBetterLocation(current, next) {
if (!next) {
return current
}
if (!current) {
return next
}
const currentAccuracy = Number(current.accuracy)
const nextAccuracy = Number(next.accuracy)
if (!Number.isFinite(currentAccuracy)) {
return next
}
if (!Number.isFinite(nextAccuracy)) {
return current
}
return nextAccuracy < currentAccuracy ? next : current
},
async getBestCurrentLocation() {
let best = null
let lastError = null
for (let index = 0; index < 3; index += 1) {
try {
const sample = this.normalizeLocation(await this.requestHighAccuracyLocation())
best = this.pickBetterLocation(best, sample)
if (sample && Number.isFinite(sample.accuracy) && sample.accuracy <= 30) {
break
}
} catch (error) {
lastError = error
}
if (index < 2) {
await this.sleep(700)
}
}
if (best) {
return best
}
throw lastError || new Error('获取位置失败')
},
async useCurrentLocation(e) {
const pointIndex = e && e.currentTarget ? e.currentTarget.dataset.index : undefined
this.setData({ locating: true })
try {
const location = await this.getBestCurrentLocation()
const accuracyText = this.formatAccuracy(location.accuracy)
const updates = {
locationAccuracyText: accuracyText ? `定位精度约 ${accuracyText}` : '',
}
if (pointIndex !== undefined && pointIndex !== '') {
updates[`form.attendancePoints[${pointIndex}].latitude`] = this.formatCoordinate(location.latitude)
updates[`form.attendancePoints[${pointIndex}].longitude`] = this.formatCoordinate(location.longitude)
} else {
updates['form.attendanceLatitude'] = this.formatCoordinate(location.latitude)
updates['form.attendanceLongitude'] = this.formatCoordinate(location.longitude)
}
this.setData(updates)
if (Number.isFinite(location.accuracy) && location.accuracy > 100) {
wx.showModal({
title: '定位精度偏低',
content: '当前只拿到模糊定位,请在系统和微信里开启精确位置,或手动修正经纬度后保存。',
showCancel: false,
})
} else {
wx.showToast({ title: '已获取当前位置', icon: 'success' })
}
} catch (error) {
const message = error && error.errMsg ? error.errMsg : ''
wx.showToast({
title: message.indexOf('auth deny') >= 0 ? '请允许获取位置' : '获取位置失败',
icon: 'none',
})
} finally {
this.setData({ locating: false })
}
},
resetDefault() {
this.setData({
form: {
...this.data.form,
...defaultScheduleValues(),
attendanceLatitude: this.data.form.attendanceLatitude,
attendanceLongitude: this.data.form.attendanceLongitude,
attendanceRadiusMeters: this.data.form.attendanceRadiusMeters || defaultForm.attendanceRadiusMeters,
autoSubmitHours: this.data.form.autoSubmitHours || defaultForm.autoSubmitHours,
attendancePoints: (this.data.form.attendancePoints || []).map(point => ({
...point,
...defaultScheduleValues(),
})),
},
})
},
validateSchedule(point, name) {
for (let index = 0; index < scheduleFields.length; index += 1) {
const field = scheduleFields[index]
const value = String(point[field] || '').trim()
if (!/^\d{1,2}:\d{2}$/.test(value)) {
wx.showToast({ title: `${name} 作息时间格式错误`, icon: 'none' })
return false
}
const parts = value.split(':')
const hour = Number(parts[0])
const minute = Number(parts[1])
if (!Number.isInteger(hour) || !Number.isInteger(minute) || hour < 0 || hour > 23 || minute < 0 || minute > 59) {
wx.showToast({ title: `${name} 作息时间范围错误`, icon: 'none' })
return false
}
}
return true
},
validateAttendancePoints(points) {
const names = new Set()
for (let index = 0; index < points.length; index += 1) {
const point = points[index]
const name = String(point.name || '').trim()
if (!name) {
wx.showToast({ title: `第${index + 1}个考勤点缺少名称`, icon: 'none' })
return false
}
if (names.has(name)) {
wx.showToast({ title: `考勤点名称重复:${name}`, icon: 'none' })
return false
}
names.add(name)
const latitudeText = String(point.latitude || '').trim()
const longitudeText = String(point.longitude || '').trim()
if ((latitudeText && !longitudeText) || (!latitudeText && longitudeText)) {
wx.showToast({ title: `${name} 经纬度需要同时填写`, icon: 'none' })
return false
}
if (latitudeText || longitudeText) {
const latitude = Number(latitudeText)
const longitude = Number(longitudeText)
if (!Number.isFinite(latitude) || latitude < -90 || latitude > 90) {
wx.showToast({ title: `${name} 纬度范围为-90到90`, icon: 'none' })
return false
}
if (!Number.isFinite(longitude) || longitude < -180 || longitude > 180) {
wx.showToast({ title: `${name} 经度范围为-180到180`, icon: 'none' })
return false
}
}
const radius = Number(point.radiusMeters)
if (!Number.isFinite(radius) || radius <= 0) {
wx.showToast({ title: `${name} 请输入有效考勤半径`, icon: 'none' })
return false
}
if (!this.validateSchedule(point, name)) {
return false
}
}
return true
},
async removePoint(e) {
const index = e.currentTarget.dataset.index
const point = this.data.form.attendancePoints[index]
if (!point) {
return
}
if (!point.originalName) {
const nextPoints = this.data.form.attendancePoints.filter((_, itemIndex) => itemIndex !== Number(index))
this.setData({
'form.attendancePoints': nextPoints,
expandedPointKey: this.data.expandedPointKey === point.localKey ? '' : this.data.expandedPointKey,
})
return
}
wx.showModal({
title: '停用考勤点',
content: `确认停用 ${point.name}?已有历史业务数据会保留。`,
confirmText: '停用',
confirmColor: '#d92d20',
success: async res => {
if (!res.confirm) {
return
}
try {
await api.deleteAttendancePoint(point.originalName)
wx.showToast({ title: '已停用', icon: 'success' })
await this.load()
} catch (error) {
wx.showToast({ title: error.message || '停用失败', icon: 'none' })
}
},
})
},
async save() {
const radius = Number(this.data.form.attendanceRadiusMeters)
if (!Number.isFinite(radius) || radius <= 0) {
wx.showToast({ title: '请输入有效考勤半径', icon: 'none' })
return
}
const latitudeText = String(this.data.form.attendanceLatitude || '').trim()
const longitudeText = String(this.data.form.attendanceLongitude || '').trim()
const autoSubmitHours = Number(this.data.form.autoSubmitHours)
if (!Number.isFinite(autoSubmitHours) || autoSubmitHours < 1 || autoSubmitHours > 168) {
wx.showToast({ title: '系统自动提交时长需在1到168小时之间', icon: 'none' })
return
}
if ((latitudeText && !longitudeText) || (!latitudeText && longitudeText)) {
wx.showToast({ title: '经纬度需要同时填写', icon: 'none' })
return
}
if (latitudeText || longitudeText) {
const latitude = Number(latitudeText)
const longitude = Number(longitudeText)
if (!Number.isFinite(latitude) || latitude < -90 || latitude > 90) {
wx.showToast({ title: '纬度范围为-90到90', icon: 'none' })
return
}
if (!Number.isFinite(longitude) || longitude < -180 || longitude > 180) {
wx.showToast({ title: '经度范围为-180到180', icon: 'none' })
return
}
}
this.setData({ saving: true })
wx.showLoading({ title: '保存中' })
try {
const points = this.data.form.attendancePoints || []
if (!points.length) {
wx.showToast({ title: '请至少维护一个考勤点', icon: 'none' })
return
}
if (!this.validateAttendancePoints(points)) {
return
}
const primaryPoint = points[0] || {}
const schedulePayload = {
...this.data.form,
...this.buildPointSchedule(primaryPoint, this.data.form),
attendanceLatitude: primaryPoint.latitude || this.data.form.attendanceLatitude,
attendanceLongitude: primaryPoint.longitude || this.data.form.attendanceLongitude,
attendanceRadiusMeters: primaryPoint.radiusMeters || this.data.form.attendanceRadiusMeters,
attendancePoints: points.map(point => ({
...point,
name: String(point.name || '').trim(),
originalName: point.originalName || point.name,
})),
}
const schedule = await api.saveWorkScheduleSettings(schedulePayload)
wx.showToast({ title: '已保存', icon: 'success' })
this.setData({
form: this.buildForm(schedule),
updatedAtText: schedule.updatedAtText || '',
})
await this.load()
} catch (error) {
wx.showToast({ title: error.message || '保存失败', icon: 'none' })
} finally {
this.setData({ saving: false })
wx.hideLoading()
}
},
})
+3
View File
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "考勤设置"
}
+199
View File
@@ -0,0 +1,199 @@
<scroll-view
class="page safe-bottom"
scroll-y
type="list"
refresher-enabled
refresher-triggered="{{refreshing}}"
bindrefresherrefresh="onPullDownRefresh"
>
<view class="header">
<text class="title">考勤设置</text>
<text class="subtitle">按考勤点维护作息时间、就餐时间和扫码考勤范围</text>
</view>
<view class="card auto-submit-card">
<view class="form-item">
<text class="label">系统自动提交时长(小时)</text>
<input
class="input"
type="digit"
value="{{form.autoSubmitHours}}"
placeholder="默认15小时"
bindinput="onAutoSubmitHoursInput"
/>
<text class="subtitle">超过该时长未下班报工,系统将自动提交待审核报工。</text>
</view>
</view>
<view class="section-title section-title-action">
<text>考勤范围</text>
<button class="btn mini primary add-point-btn" bindtap="addAttendancePoint">+</button>
</view>
<view
wx:for="{{form.attendancePoints}}"
wx:key="localKey"
wx:for-index="pointIndex"
wx:for-item="point"
class="card location-card {{expandedPointKey === point.localKey ? 'expanded' : 'collapsed'}}"
>
<view class="location-head" data-key="{{point.localKey}}" bindtap="toggleAttendancePoint">
<view class="location-text">
<text class="schedule-name">{{point.name || '新考勤点'}}</text>
<block wx:if="{{expandedPointKey === point.localKey}}">
<text class="subtitle">一个考勤点对应一个分公司,可独立配置作息和扫码范围</text>
<text wx:if="{{locationAccuracyText}}" class="location-accuracy">{{locationAccuracyText}}</text>
</block>
</view>
<button
wx:if="{{expandedPointKey === point.localKey}}"
class="btn primary locate-btn"
loading="{{locating}}"
data-index="{{pointIndex}}"
catchtap="useCurrentLocation"
>使用当前位置</button>
</view>
<block wx:if="{{expandedPointKey === point.localKey}}">
<view class="form-item">
<text class="label">考勤点名称</text>
<input
class="input"
value="{{point.name}}"
placeholder="请输入考勤点名称"
data-index="{{pointIndex}}"
data-field="name"
bindinput="onPointInput"
/>
</view>
<view class="point-section-title">作息时间</view>
<view class="schedule-panel">
<view class="schedule-row">
<text class="schedule-name">白班时间段</text>
<view class="time-range">
<picker mode="time" value="{{point.dayStart}}" data-index="{{pointIndex}}" data-field="dayStart" bindchange="onTimeChange">
<view class="picker time-picker">{{point.dayStart}}</view>
</picker>
<text class="range-sep">至</text>
<picker mode="time" value="{{point.dayEnd}}" data-index="{{pointIndex}}" data-field="dayEnd" bindchange="onTimeChange">
<view class="picker time-picker">{{point.dayEnd}}</view>
</picker>
</view>
</view>
<view class="schedule-row">
<text class="schedule-name">夜班时间段</text>
<view class="time-range">
<picker mode="time" value="{{point.nightStart}}" data-index="{{pointIndex}}" data-field="nightStart" bindchange="onTimeChange">
<view class="picker time-picker">{{point.nightStart}}</view>
</picker>
<text class="range-sep">至</text>
<picker mode="time" value="{{point.nightEnd}}" data-index="{{pointIndex}}" data-field="nightEnd" bindchange="onTimeChange">
<view class="picker time-picker">{{point.nightEnd}}</view>
</picker>
</view>
</view>
<view class="schedule-row">
<text class="schedule-name">中饭就餐</text>
<view class="time-range">
<picker mode="time" value="{{point.lunchStart}}" data-index="{{pointIndex}}" data-field="lunchStart" bindchange="onTimeChange">
<view class="picker time-picker">{{point.lunchStart}}</view>
</picker>
<text class="range-sep">至</text>
<picker mode="time" value="{{point.lunchEnd}}" data-index="{{pointIndex}}" data-field="lunchEnd" bindchange="onTimeChange">
<view class="picker time-picker">{{point.lunchEnd}}</view>
</picker>
</view>
</view>
<view class="schedule-row">
<text class="schedule-name">晚饭就餐</text>
<view class="time-range">
<picker mode="time" value="{{point.dinnerStart}}" data-index="{{pointIndex}}" data-field="dinnerStart" bindchange="onTimeChange">
<view class="picker time-picker">{{point.dinnerStart}}</view>
</picker>
<text class="range-sep">至</text>
<picker mode="time" value="{{point.dinnerEnd}}" data-index="{{pointIndex}}" data-field="dinnerEnd" bindchange="onTimeChange">
<view class="picker time-picker">{{point.dinnerEnd}}</view>
</picker>
</view>
</view>
<view class="schedule-row last">
<text class="schedule-name">加班时间段</text>
<view class="time-range">
<picker mode="time" value="{{point.overtimeStart}}" data-index="{{pointIndex}}" data-field="overtimeStart" bindchange="onTimeChange">
<view class="picker time-picker">{{point.overtimeStart}}</view>
</picker>
<text class="range-sep">至</text>
<picker mode="time" value="{{point.overtimeEnd}}" data-index="{{pointIndex}}" data-field="overtimeEnd" bindchange="onTimeChange">
<view class="picker time-picker">{{point.overtimeEnd}}</view>
</picker>
</view>
</view>
</view>
<view class="point-section-title">考勤范围</view>
<view class="location-grid">
<view class="location-cell">
<text class="label">纬度</text>
<input
class="input coordinate-input mono"
type="digit"
value="{{point.latitude}}"
placeholder="请输入纬度"
data-index="{{pointIndex}}"
data-field="latitude"
bindinput="onPointInput"
/>
</view>
<view class="location-cell">
<text class="label">经度</text>
<input
class="input coordinate-input mono"
type="digit"
value="{{point.longitude}}"
placeholder="请输入经度"
data-index="{{pointIndex}}"
data-field="longitude"
bindinput="onPointInput"
/>
</view>
</view>
<view class="form-item radius-item">
<text class="label">考勤范围半径(米)</text>
<input
class="input"
type="number"
value="{{point.radiusMeters}}"
placeholder="请输入米数"
data-index="{{pointIndex}}"
data-field="radiusMeters"
bindinput="onPointInput"
/>
</view>
<view class="form-item">
<text class="label">备注</text>
<textarea
class="textarea"
value="{{point.remark}}"
placeholder="可填写分公司或厂区说明"
data-index="{{pointIndex}}"
data-field="remark"
bindinput="onPointInput"
></textarea>
</view>
<button class="btn danger full" data-index="{{pointIndex}}" catchtap="removePoint">停用/移除考勤点</button>
</block>
</view>
<view class="card save-card">
<text wx:if="{{updatedAtText}}" class="subtitle">最近更新 {{updatedAtText}}</text>
<view class="button-row">
<button class="btn secondary full" bindtap="resetDefault">恢复默认</button>
<button class="btn primary full" loading="{{saving}}" bindtap="save">保存设置</button>
</view>
</view>
</scroll-view>
+186
View File
@@ -0,0 +1,186 @@
.schedule-panel {
margin-bottom: 26rpx;
padding: 6rpx 20rpx;
border: 2rpx solid #d7e1ef;
border-radius: 16rpx;
background: #f8fbff;
}
.schedule-row {
display: grid;
grid-template-columns: 178rpx minmax(0, 1fr);
align-items: center;
gap: 18rpx;
padding: 22rpx 0;
border-bottom: 1rpx solid #e6edf6;
}
.schedule-row.last {
border-bottom: 0;
}
.schedule-name {
color: #162232;
font-size: 27rpx;
font-weight: 700;
line-height: 1.35;
}
.time-range {
display: grid;
grid-template-columns: minmax(0, 1fr) 42rpx minmax(0, 1fr);
align-items: center;
gap: 12rpx;
}
.time-picker {
min-height: 74rpx;
justify-content: center;
padding: 0 10rpx;
color: #162232;
font-size: 27rpx;
font-weight: 700;
}
.range-sep {
color: #6b778c;
text-align: center;
font-size: 24rpx;
font-weight: 700;
}
.save-card {
padding-top: 22rpx;
}
.auto-submit-card {
padding: 24rpx;
}
.point-section-title {
margin: 28rpx 0 16rpx;
color: #1f64d8;
font-size: 25rpx;
font-weight: 900;
line-height: 1.35;
}
.location-head + .point-section-title {
margin-top: 0;
}
.save-card .subtitle {
margin-top: 0;
margin-bottom: 18rpx;
}
.section-title-action {
justify-content: space-between;
}
.section-title-action text {
flex: 1;
}
.section-title-action::before {
margin-right: 0;
}
.add-point-btn {
width: 64rpx;
min-width: 64rpx;
padding: 0;
font-size: 40rpx;
font-weight: 900;
}
.location-card {
padding: 24rpx;
}
.location-card.collapsed {
padding: 0;
}
.location-card.expanded .location-head {
margin-bottom: 22rpx;
}
.location-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18rpx;
}
.location-card.collapsed .location-head {
min-height: 92rpx;
padding: 0 24rpx;
}
.location-text {
min-width: 0;
flex: 1;
}
.location-text .schedule-name {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.location-accuracy {
display: block;
margin-top: 8rpx;
color: #1f64d8;
font-size: 22rpx;
font-weight: 700;
line-height: 1.35;
}
.locate-btn {
flex: 0 0 220rpx;
margin: 0;
padding: 0 14rpx;
}
.location-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16rpx;
margin-top: 22rpx;
}
.location-cell {
min-width: 0;
padding: 18rpx;
border: 2rpx solid #d1dced;
border-radius: 14rpx;
background: linear-gradient(180deg, #f8fbff 0%, #eef4fb 100%);
box-shadow:
0 8rpx 0 rgba(181, 194, 213, 0.68),
inset 0 2rpx 0 rgba(255, 255, 255, 0.92);
box-sizing: border-box;
}
.mono {
font-family: DIN Alternate, Arial, sans-serif;
font-size: 26rpx;
word-break: break-all;
}
.coordinate-input {
width: 100%;
margin-top: 8rpx;
padding: 0 14rpx;
color: #162232;
font-weight: 800;
text-align: left;
box-sizing: border-box;
}
.radius-item {
margin-top: 28rpx;
}