Compare commits

...

10 Commits

Author SHA1 Message Date
chenglijuan 12c2f25797 拜访区域支持多选,被访人改为输入框,取消拜访区域和被访人的联动 2026-05-27 15:44:23 +08:00
chenglijuan 643f37b06e 增加线上配置分享功能+用户后台登录,不影响首页渲染 2026-05-20 14:24:54 +08:00
chenglijuan dbfc8011c4 增加线上配置 2026-05-20 10:05:47 +08:00
chenglijuan f26d767cca 访客预约小程序切换到公司主体的小程序 2026-05-11 17:10:18 +08:00
chenglijuan e6d61585dd 光标离开时校验必填字段 2026-05-07 18:36:37 +08:00
chenglijuan 1b5f3811b6 增加被访区域下拉框值api接口 2026-05-07 10:44:33 +08:00
chenglijuan 7c59d18596 来访时段选择时间区间 2026-05-07 09:29:24 +08:00
chenglijuan 57243dfdf3 访客信息增加车牌号 2026-05-06 10:12:36 +08:00
chenglijuan 85dca5c7a1 更新访客预约 2026-04-30 17:08:30 +08:00
ws c6bef45940 fix: 修复记录页登录状态检测和重试逻辑 2026-04-30 12:17:14 +08:00
19 changed files with 854 additions and 244 deletions
+31 -7
View File
@@ -4,11 +4,19 @@ const { request } = require('./utils/api')
App({
onLaunch() {
// 自动静默登录:wx.login 获取 code,再请求后端接口换取 openid
// 优先从本地缓存恢复登录态,避免阻塞页面
const cached = wx.getStorageSync('userInfo')
if (cached && cached.openid) {
this.globalData.userInfo = cached
this.globalData.isLoggedIn = true
}
// 静默登录获取最新 session,不阻塞页面
this.silentLogin()
},
silentLogin() {
this._loginPromise = new Promise((resolve) => {
this._loginResolve = resolve
wx.login({
success: (loginRes) => {
if (loginRes.code) {
@@ -23,6 +31,19 @@ App({
this.handleLoginFail()
}
})
})
},
/**
* 返回登录完成的 Promise,供页面 await 使用
* @param {boolean} useCache - 是否在已有缓存时立即 resolve
* @returns {Promise<object|null>}
*/
waitLogin(useCache = false) {
if (useCache && this.globalData.isLoggedIn) {
return Promise.resolve(this.globalData.userInfo)
}
return this._loginPromise || Promise.resolve(this.globalData.userInfo || null)
},
async loginWithCode(code) {
@@ -39,22 +60,25 @@ App({
}
this.globalData.userInfo = userInfo
this.globalData.isLoggedIn = true
this.globalData.loginFailed = false
wx.setStorageSync('userInfo', userInfo)
if (this.loginReadyCallback) {
this.loginReadyCallback(userInfo)
}
if (this._loginResolve) this._loginResolve(userInfo)
} catch (err) {
console.error('后端登录失败', err)
// 如果有缓存登录态,登录失败不立即清除,让页面继续使用缓存
if (!this.globalData.isLoggedIn) {
this.handleLoginFail()
} else {
this.globalData.loginFailed = true
if (this._loginResolve) this._loginResolve(this.globalData.userInfo)
}
}
},
handleLoginFail() {
this.globalData.isLoggedIn = false
this.globalData.loginFailed = true
if (this.loginReadyCallback) {
this.loginReadyCallback(null)
}
if (this._loginResolve) this._loginResolve(null)
},
globalData: {
+134
View File
@@ -0,0 +1,134 @@
// plate-input.js
// 车牌号输入组件:先选省份简称 → 再选城市代码 → 最后键盘输入号码
Component({
properties: {
value: { type: String, value: '' }
},
data: {
plateChars: [],
numValue: '',
inputFocus: false,
showProvince: false,
showCity: false,
hasValue: false,
provinces: [
'京', '津', '沪', '渝', '冀', '豫', '云', '辽', '黑', '湘',
'皖', '鲁', '新', '苏', '浙', '赣', '鄂', '桂', '甘', '晋',
'蒙', '陕', '吉', '闽', '贵', '粤', '川', '青', '藏', '琼', '宁'
],
cityLetters: [
'A', 'B', 'C', 'D', 'E', 'F', 'G',
'H', 'J', 'K', 'L', 'M', 'N', 'P',
'Q', 'R', 'S', 'T', 'U', 'V', 'W',
'X', 'Y', 'Z'
],
numSlots: [0, 1, 2, 3, 4, 5]
},
observers: {
'value': function (val) {
if (val && val !== this._lastEmitted) {
this._parseValue(val)
}
}
},
lifetimes: {
attached() {
if (this.data.value) {
this._parseValue(this.data.value)
}
}
},
methods: {
_parseValue(val) {
const chars = val.split('')
const numValue = chars.slice(2).join('')
this.setData({
plateChars: chars,
numValue: numValue,
hasValue: chars.length > 0
})
},
_emit(chars) {
const value = chars.filter(Boolean).join('')
this._lastEmitted = value
this.triggerEvent('change', { value })
},
// 点击省份格
onProvinceTap() {
this.setData({ showProvince: true, showCity: false, inputFocus: false })
},
// 选择省份
selectProvince(e) {
const code = e.currentTarget.dataset.value
const chars = [code, this.data.plateChars[1]].filter(Boolean)
this.setData({ plateChars: chars, showProvince: false, showCity: true, hasValue: true })
this._emit(chars)
},
// 点击城市格
onCityTap() {
if (!this.data.plateChars[0]) {
this.setData({ showProvince: true })
return
}
this.setData({ showCity: true, showProvince: false, inputFocus: false })
},
// 选择城市代码
selectCity(e) {
const letter = e.currentTarget.dataset.value
const numPart = this.data.plateChars.slice(2).join('')
const chars = [this.data.plateChars[0], letter].concat(numPart.split(''))
this.setData({
plateChars: chars,
showCity: false,
numValue: numPart,
inputFocus: true,
hasValue: true
})
this._emit(chars)
},
// 点击号码格 → 弹出键盘
onNumTap() {
if (!this.data.plateChars[0] || !this.data.plateChars[1]) {
this.setData({ showProvince: true })
return
}
this.setData({ inputFocus: true, showProvince: false, showCity: false })
},
// 键盘输入号码
onNumInput(e) {
const raw = e.detail.value.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 6)
const chars = [this.data.plateChars[0], this.data.plateChars[1]].concat(raw.split(''))
this.setData({ plateChars: chars, numValue: raw, hasValue: true })
this._emit(chars)
},
// 关闭弹窗
hidePicker() {
this.setData({ showProvince: false, showCity: false })
},
noop() {},
// 清除车牌号
clearPlate() {
this.setData({
plateChars: [],
numValue: '',
inputFocus: false,
hasValue: false
})
this._emit([])
}
}
})
+3
View File
@@ -0,0 +1,3 @@
{
"component": true
}
+69
View File
@@ -0,0 +1,69 @@
<!--plate-input.wxml-->
<view class="plate-container">
<!-- 车牌号格子显示 -->
<view class="plate-body">
<view class="plate-cell {{plateChars[0] ? 'filled' : 'empty'}}" bindtap="onProvinceTap">
{{plateChars[0] || '省'}}
</view>
<view class="plate-cell {{plateChars[1] ? 'filled' : 'empty'}}" bindtap="onCityTap">
{{plateChars[1] || '市'}}
</view>
<view class="plate-sep">·</view>
<view
wx:for="{{numSlots}}" wx:key="*this"
class="plate-cell num {{plateChars[item + 2] ? 'filled' : 'empty'}} {{item >= 5 ? 'extra' : ''}}"
bindtap="onNumTap"
>
{{plateChars[item + 2] || (item < 5 ? '' : '新')}}
</view>
</view>
<!-- 隐藏输入框(唤起键盘) -->
<input
class="plate-hidden-input"
type="text"
focus="{{inputFocus}}"
value="{{numValue}}"
maxlength="6"
bindinput="onNumInput"
adjust-position="{{true}}"
confirm-type="done"
/>
<!-- 清除按钮 -->
<view class="plate-clear" wx:if="{{hasValue}}" bindtap="clearPlate">清除</view>
<!-- 省份选择弹窗 -->
<view class="popup-mask" wx:if="{{showProvince}}" bindtap="hidePicker">
<view class="popup-panel" catchtap="noop">
<view class="popup-title">选择省份简称</view>
<scroll-view scroll-y class="popup-scroll">
<view class="popup-grid">
<view
class="popup-item"
wx:for="{{provinces}}" wx:key="*this"
data-value="{{item}}" bindtap="selectProvince"
>{{item}}</view>
</view>
</scroll-view>
<view class="popup-cancel" bindtap="hidePicker">取消</view>
</view>
</view>
<!-- 城市选择弹窗 -->
<view class="popup-mask" wx:if="{{showCity}}" bindtap="hidePicker">
<view class="popup-panel" catchtap="noop">
<view class="popup-title">选择城市代码</view>
<scroll-view scroll-y class="popup-scroll">
<view class="popup-grid">
<view
class="popup-item"
wx:for="{{cityLetters}}" wx:key="*this"
data-value="{{item}}" bindtap="selectCity"
>{{item}}</view>
</view>
</scroll-view>
<view class="popup-cancel" bindtap="hidePicker">取消</view>
</view>
</view>
</view>
+154
View File
@@ -0,0 +1,154 @@
/* plate-input.wxss */
.plate-container {
flex: 1;
display: flex;
align-items: center;
overflow: hidden;
}
/* 车牌格子区域 */
.plate-body {
display: flex;
align-items: center;
background: #f5f7fa;
border: 2rpx solid #dce3ec;
border-radius: 12rpx;
padding: 8rpx 16rpx;
height: 72rpx;
flex: 1;
min-width: 0;
overflow: hidden;
}
.plate-cell {
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 30rpx;
border-radius: 8rpx;
margin: 0 4rpx;
flex-shrink: 0;
transition: all 0.2s;
}
.plate-cell.empty {
color: #b8c9db;
background: #eaf0f7;
font-size: 24rpx;
}
.plate-cell.filled {
color: #2c3e50;
background: #ffffff;
border: 1rpx solid #dce3ec;
font-weight: 600;
}
.plate-cell.num {
width: 44rpx;
}
.plate-cell.num.extra {
border-style: dashed;
border-color: #c8d6e5;
}
.plate-sep {
font-size: 32rpx;
color: #b8c9db;
margin: 0 6rpx;
font-weight: bold;
}
/* 隐藏输入框(用于唤起键盘) */
.plate-hidden-input {
position: absolute;
left: -9999rpx;
width: 0;
height: 0;
opacity: 0;
}
/* 清除按钮 */
.plate-clear {
flex-shrink: 0;
font-size: 24rpx;
color: #ff4d4f;
padding: 8rpx 12rpx;
margin-left: 12rpx;
}
/* 弹窗遮罩 */
.popup-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
display: flex;
align-items: flex-end;
}
/* 弹窗面板 */
.popup-panel {
width: 100%;
background: #fff;
border-radius: 24rpx 24rpx 0 0;
padding: 32rpx;
padding-bottom: 0;
}
.popup-title {
font-size: 30rpx;
font-weight: 600;
color: #2c3e50;
margin-bottom: 24rpx;
text-align: center;
}
.popup-scroll {
max-height: 400rpx;
padding-bottom: 16rpx;
}
.popup-grid {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
padding: 0 8rpx;
}
.popup-item {
width: calc((100% - 96rpx) / 7);
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
background: #f5f7fa;
border-radius: 12rpx;
font-size: 30rpx;
color: #2c3e50;
border: 1rpx solid #dce3ec;
transition: all 0.15s;
}
.popup-item:active {
background: #5b9bd5;
color: #fff;
border-color: #5b9bd5;
}
.popup-cancel {
text-align: center;
font-size: 28rpx;
color: #999;
padding: 24rpx 0;
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
border-top: 1rpx solid #f0f0f0;
margin-top: 16rpx;
}
+103 -69
View File
@@ -3,7 +3,7 @@ const { formatDate, appointmentDB } = require('../../utils/api')
const app = getApp()
// 订阅消息模板ID
const SUBSCRIBE_TEMPLATE_ID = 'Csf_dJU7DhvVFt_03sphPPBCGlnmcWQSPhgqfxHZ5RQ'
const SUBSCRIBE_TEMPLATE_ID = 'EF5CDtuZwrGbt8iyOoi-sY7J6hZamX0AbWPLoK-qnEw'
Page({
data: {
@@ -15,41 +15,48 @@ Page({
date: '',
time: '',
hostName: '',
hostId: '',
area: ''
area: '',
plateNumber: ''
},
areas: ['A区-生产车间', 'B区-办公楼', 'C区-仓储区', 'D区-研发中心', 'E区-综合区'],
areaMap: { 'A区-生产车间': 'A', 'B区-办公楼': 'B', 'C区-仓储区': 'C', 'D区-研发中心': 'D', 'E区-综合区': 'E' },
areaIndex: -1,
persons: [],
personNames: [],
personIndex: -1,
areaOptions: [],
selectedAreas: {},
selectedAreasDisplay: '',
showAreaDropdown: false,
today: '',
submitting: false
timeStart: '',
timeEnd: '',
submitting: false,
fieldErrors: {}
},
onLoad() {
if (app.globalData.isLoggedIn) {
this.initPage()
} else if (app.globalData.loginFailed) {
this.showLoginTipAndGoBack()
} else {
// 登录异步未完成,等待回调
app.loginReadyCallback = (userInfo) => {
app.loginReadyCallback = null
this._awaitLoginAndInit()
},
async _awaitLoginAndInit() {
const userInfo = await app.waitLogin(true)
if (userInfo) {
this.initPage()
} else {
this.showLoginTipAndGoBack()
}
}
}
},
initPage() {
// 使用本地时区获取当天日期,用于 picker 最小日期限制
const today = formatDate(new Date())
this.setData({ today })
this.loadAreaOptions()
},
async loadAreaOptions() {
try {
const list = await appointmentDB.getDepartmentSelector()
const areaOptions = list.map(item => item.departmentName)
this.setData({ areaOptions })
} catch (err) {
console.error('获取拜访区域列表失败', err)
wx.showToast({ title: '获取拜访区域失败', icon: 'none' })
}
},
showLoginTipAndGoBack() {
@@ -65,81 +72,99 @@ Page({
onNameInput(e) {
this.setData({ 'form.name': e.detail.value })
this._clearFieldError('name')
},
onPhoneInput(e) {
this.setData({ 'form.phone': e.detail.value })
this._clearFieldError('phone')
},
onCompanyInput(e) {
this.setData({ 'form.company': e.detail.value })
this._clearFieldError('company')
},
onReasonInput(e) {
this.setData({ 'form.reason': e.detail.value })
this._clearFieldError('reason')
},
onHostNameInput(e) {
this.setData({ 'form.hostName': e.detail.value })
},
onPlateNumberChange(e) {
this.setData({ 'form.plateNumber': e.detail.value })
},
onAreaChange(e) {
const index = Number(e.detail.value)
const areaName = this.data.areas[index]
const department = this.data.areaMap[areaName]
this.setData({
areaIndex: index,
'form.area': areaName,
'form.hostId': '',
'form.hostName': '',
persons: [],
personNames: [],
personIndex: -1
})
if (department) {
this.loadPersons(department)
onNameBlur() {
if (!this.data.form.name.trim()) {
this.setData({ 'fieldErrors.name': '姓名不能为空' })
}
},
onPhoneBlur() {
if (!this.data.form.phone.trim()) {
this.setData({ 'fieldErrors.phone': '手机号不能为空' })
}
},
onCompanyBlur() {
if (!this.data.form.company.trim()) {
this.setData({ 'fieldErrors.company': '公司不能为空' })
}
},
onReasonBlur() {
if (!this.data.form.reason.trim()) {
this.setData({ 'fieldErrors.reason': '来访事由不能为空' })
}
},
async loadPersons(department) {
wx.showLoading({ title: '加载被访人中...' })
try {
const list = await appointmentDB.getPersonSelector(department)
const persons = list.map(item => ({
personId: item.personId,
personName: item.personName
}))
this.setData({
persons,
personNames: persons.map(p => p.personName),
personIndex: -1
})
} catch (err) {
console.error('获取被访人列表失败', err)
wx.showToast({ title: '获取被访人列表失败', icon: 'none' })
} finally {
wx.hideLoading()
_clearFieldError(field) {
if (this.data.fieldErrors[field]) {
this.setData({ ['fieldErrors.' + field]: '' })
}
},
onPersonChange(e) {
const index = Number(e.detail.value)
const person = this.data.persons[index]
if (person && person.personId) {
toggleAreaDropdown() {
this.setData({ showAreaDropdown: !this.data.showAreaDropdown })
},
toggleArea(e) {
const value = e.currentTarget.dataset.value
const selectedAreas = { ...this.data.selectedAreas }
selectedAreas[value] = !selectedAreas[value]
const display = Object.keys(selectedAreas).filter(k => selectedAreas[k]).join('、')
this.setData({
personIndex: index,
'form.hostId': person.personId,
'form.hostName': person.personName
selectedAreas,
selectedAreasDisplay: display,
'form.area': display
})
}
this._clearFieldError('area')
},
onDateChange(e) {
this.setData({ 'form.date': e.detail.value })
},
onTimeChange(e) {
this.setData({ 'form.time': e.detail.value })
onTimeStartChange(e) {
this.setData({ timeStart: e.detail.value }, this._updateTimeRange)
},
onTimeEndChange(e) {
this.setData({ timeEnd: e.detail.value }, this._updateTimeRange)
},
_updateTimeRange() {
const { timeStart, timeEnd } = this.data
if (timeStart && timeEnd) {
if (timeEnd <= timeStart) {
this.setData({ 'form.time': '', 'fieldErrors.timeRange': '结束时间必须晚于开始时间' })
wx.showToast({ title: '结束时间必须晚于开始时间', icon: 'none' })
return
}
this.setData({ 'form.time': timeStart + '-' + timeEnd, 'fieldErrors.timeRange': '' })
} else {
this.setData({ 'form.time': '' })
}
},
validateForm() {
const { name, phone, company, reason, date, time, hostName, area } = this.data.form
const { name, phone, company, reason, date, time, area } = this.data.form
if (!name.trim()) {
wx.showToast({ title: '请输入访客姓名', icon: 'none' })
return false
@@ -164,10 +189,6 @@ Page({
wx.showToast({ title: '请选择来访时间', icon: 'none' })
return false
}
if (!hostName.trim()) {
wx.showToast({ title: '请选择被访人', icon: 'none' })
return false
}
if (!area) {
wx.showToast({ title: '请选择拜访区域', icon: 'none' })
return false
@@ -214,5 +235,18 @@ Page({
console.error('提交预约失败', err)
wx.showToast({ title: '提交失败,请重试', icon: 'none' })
}
},
onShareAppMessage(res) {
return {
title: '访客预约',
path: '/pages/index/index'
}
},
onShareTimeline() {
return {
title: '访客预约'
}
}
})
+3 -1
View File
@@ -1,4 +1,6 @@
{
"usingComponents": {},
"usingComponents": {
"plate-input": "/components/plate-input/plate-input"
},
"navigationBarTitleText": "访客预约"
}
+71 -35
View File
@@ -1,31 +1,47 @@
<!--appointment.wxml-->
<view class="page">
<!-- 预约人信息 -->
<!-- 来访人信息 -->
<view class="section">
<view class="section-title">预约人信息</view>
<view class="section-title">来访人信息</view>
<view class="form-group">
<text class="form-label">姓名</text>
<input class="form-input" placeholder="请输入访客姓名" value="{{form.name}}" bindinput="onNameInput" />
<text class="form-label">姓名<text class="required">*</text></text>
<view class="form-field-wrap">
<input class="form-input {{fieldErrors.name ? 'form-input-error' : ''}}" placeholder="请输入访客姓名" value="{{form.name}}" bindinput="onNameInput" bindblur="onNameBlur" />
<text wx:if="{{fieldErrors.name}}" class="form-error">{{fieldErrors.name}}</text>
</view>
</view>
<view class="form-group">
<text class="form-label">手机号</text>
<input class="form-input" type="number" maxlength="11" placeholder="请输入手机号" value="{{form.phone}}" bindinput="onPhoneInput" />
<text class="form-label">手机号<text class="required">*</text></text>
<view class="form-field-wrap">
<input class="form-input {{fieldErrors.phone ? 'form-input-error' : ''}}" type="number" maxlength="11" placeholder="请输入手机号" value="{{form.phone}}" bindinput="onPhoneInput" bindblur="onPhoneBlur" />
<text wx:if="{{fieldErrors.phone}}" class="form-error">{{fieldErrors.phone}}</text>
</view>
</view>
<view class="form-group">
<text class="form-label">公司</text>
<input class="form-input" placeholder="请输入所属公司" value="{{form.company}}" bindinput="onCompanyInput" />
<text class="form-label">公司<text class="required">*</text></text>
<view class="form-field-wrap">
<input class="form-input {{fieldErrors.company ? 'form-input-error' : ''}}" placeholder="请输入所属公司" value="{{form.company}}" bindinput="onCompanyInput" bindblur="onCompanyBlur" />
<text wx:if="{{fieldErrors.company}}" class="form-error">{{fieldErrors.company}}</text>
</view>
</view>
<view class="form-group">
<text class="form-label">来访事由</text>
<input class="form-input" placeholder="请输入来访事由" value="{{form.reason}}" bindinput="onReasonInput" />
<text class="form-label">来访事由<text class="required">*</text></text>
<view class="form-field-wrap">
<input class="form-input {{fieldErrors.reason ? 'form-input-error' : ''}}" placeholder="请输入来访事由" value="{{form.reason}}" bindinput="onReasonInput" bindblur="onReasonBlur" />
<text wx:if="{{fieldErrors.reason}}" class="form-error">{{fieldErrors.reason}}</text>
</view>
</view>
<view class="form-group">
<text class="form-label">车牌号</text>
<plate-input value="{{form.plateNumber}}" bindchange="onPlateNumberChange" />
</view>
</view>
<!-- 预约时间 -->
<!-- 来访时间 -->
<view class="section">
<view class="section-title">预约时间</view>
<view class="section-title">来访时间</view>
<view class="form-group">
<text class="form-label">来访日期</text>
<text class="form-label">来访日期<text class="required">*</text></text>
<picker class="form-picker-wrap" mode="date" value="{{form.date}}" start="{{today}}" bindchange="onDateChange">
<view class="form-picker">
<text class="{{form.date ? 'picker-value' : 'picker-placeholder'}}">{{form.date || '请选择日期'}}</text>
@@ -33,42 +49,62 @@
</view>
</picker>
</view>
<view class="form-group">
<text class="form-label">来访时</text>
<picker class="form-picker-wrap" mode="time" value="{{form.time}}" bindchange="onTimeChange">
<view class="form-picker">
<text class="{{form.time ? 'picker-value' : 'picker-placeholder'}}">{{form.time || '请选择时间'}}</text>
<text class="picker-arrow"></text>
<view class="form-group form-group-time">
<text class="form-label">来访时段<text class="required">*</text></text>
<view class="form-field-wrap">
<view class="time-range">
<picker class="time-picker-wrap" mode="time" value="{{timeStart}}" bindchange="onTimeStartChange">
<view class="time-picker">
<text class="{{timeStart ? 'picker-value' : 'picker-placeholder'}}">{{timeStart || '开始时间'}}</text>
</view>
</picker>
<text class="time-range-sep">至</text>
<picker class="time-picker-wrap" mode="time" value="{{timeEnd}}" bindchange="onTimeEndChange">
<view class="time-picker">
<text class="{{timeEnd ? 'picker-value' : 'picker-placeholder'}}">{{timeEnd || '结束时间'}}</text>
</view>
</picker>
</view>
<text wx:if="{{fieldErrors.timeRange}}" class="form-error">{{fieldErrors.timeRange}}</text>
</view>
</view>
</view>
<!-- 被访人信息 -->
<view class="section">
<view class="section-title">被访人信息</view>
<view class="form-group">
<text class="form-label">拜访区域</text>
<picker class="form-picker-wrap" range="{{areas}}" value="{{areaIndex}}" bindchange="onAreaChange">
<view class="form-picker">
<text class="{{areaIndex >= 0 ? 'picker-value' : 'picker-placeholder'}}">{{areaIndex >= 0 ? areas[areaIndex] : '请选择拜访区域'}}</text>
<text class="picker-arrow"></text>
<view class="form-group form-group-area">
<text class="form-label">拜访区域<text class="required">*</text></text>
<view class="form-field-wrap">
<view class="multi-select-wrap">
<view class="multi-select-trigger" bindtap="toggleAreaDropdown">
<text class="{{selectedAreasDisplay || 'picker-placeholder'}}">{{selectedAreasDisplay || '请选择拜访区域'}}</text>
<text class="picker-arrow {{showAreaDropdown ? 'arrow-up' : ''}}"></text>
</view>
<view wx:if="{{showAreaDropdown}}" class="multi-select-dropdown">
<view wx:for="{{areaOptions}}" wx:key="*this" class="checkbox-item" bindtap="toggleArea" data-value="{{item}}">
<view class="checkbox {{selectedAreas[item] ? 'checkbox-checked' : ''}}">
<text wx:if="{{selectedAreas[item]}}" class="checkbox-icon">✓</text>
</view>
<text class="checkbox-label">{{item}}</text>
</view>
</view>
</view>
<text wx:if="{{fieldErrors.area}}" class="form-error">{{fieldErrors.area}}</text>
</view>
</picker>
</view>
<view class="form-group">
<text class="form-label">被访人</text>
<picker wx:if="{{personNames.length > 0}}" class="form-picker-wrap" range="{{personNames}}" value="{{personIndex}}" bindchange="onPersonChange">
<view class="form-picker">
<text class="{{personIndex >= 0 ? 'picker-value' : 'picker-placeholder'}}">{{personIndex >= 0 ? personNames[personIndex] : '请选择被访人'}}</text>
<text class="picker-arrow"></text>
</view>
</picker>
<view wx:else class="form-picker">
<text class="picker-placeholder">请先选择拜访区域</text>
<view class="form-field-wrap">
<input class="form-input" placeholder="请输入被访人" value="{{form.hostName}}" bindinput="onHostNameInput" />
</view>
</view>
<view class="form-group">
<text class="form-label">接待人</text>
<view class="form-field-wrap">
<input class="form-input form-input-disabled" value="金梦婷" disabled />
</view>
</view>
</view>
<!-- 提交按钮 -->
+126
View File
@@ -37,6 +37,47 @@ page {
border-bottom: none;
}
.form-group-time {
flex-wrap: wrap;
}
.form-field-wrap {
flex: 1;
min-width: 0;
}
.time-range {
flex: 1;
display: flex;
align-items: center;
}
.time-picker-wrap {
flex: 1;
}
.time-picker {
display: flex;
align-items: center;
justify-content: center;
height: 88rpx;
position: relative;
}
.time-picker .picker-arrow {
position: absolute;
right: 0;
font-size: 32rpx;
color: #b8c9db;
}
.time-range-sep {
font-size: 28rpx;
color: #b8c9db;
padding: 0 12rpx;
flex-shrink: 0;
}
.form-label {
width: 160rpx;
font-size: 28rpx;
@@ -44,6 +85,11 @@ page {
flex-shrink: 0;
}
.required {
color: #ff4d4f;
margin-left: 4rpx;
}
.form-input {
flex: 1;
font-size: 28rpx;
@@ -77,6 +123,76 @@ page {
color: #b8c9db;
}
.form-input-disabled {
color: #999;
}
.form-group-area {
flex-wrap: wrap;
}
.multi-select-wrap {
position: relative;
flex: 1;
}
.multi-select-trigger {
display: flex;
align-items: center;
justify-content: space-between;
height: 88rpx;
}
.multi-select-dropdown {
position: absolute;
top: 88rpx;
left: 0;
right: 0;
background: #fff;
border: 1rpx solid #e8eef5;
border-radius: 12rpx;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.1);
z-index: 100;
padding: 8rpx 0;
}
.checkbox-item {
display: flex;
align-items: center;
padding: 20rpx 24rpx;
}
.checkbox {
width: 40rpx;
height: 40rpx;
border: 2rpx solid #d0d8e4;
border-radius: 8rpx;
display: flex;
align-items: center;
justify-content: center;
margin-right: 16rpx;
flex-shrink: 0;
}
.checkbox-checked {
background: #5b9bd5;
border-color: #5b9bd5;
}
.checkbox-icon {
color: #fff;
font-size: 24rpx;
}
.checkbox-label {
font-size: 28rpx;
color: #2c3e50;
}
.arrow-up {
transform: rotate(90deg);
}
.submit-wrap {
position: fixed;
bottom: 0;
@@ -117,3 +233,13 @@ page {
color: #94aec5;
box-shadow: none;
}
.form-input-error {
color: #ff4d4f;
}
.form-error {
font-size: 22rpx;
color: #ff4d4f;
padding-top: 4rpx;
}
+26 -25
View File
@@ -11,45 +11,33 @@ Page({
},
onLoad() {
// 首页不阻塞,直接渲染;有缓存登录态时立即加载数据
if (app.globalData.isLoggedIn) {
this.onLoginReady()
} else if (app.globalData.loginFailed) {
this.onLoginFailed()
} else {
app.loginReadyCallback = (userInfo) => {
if (userInfo) {
this.onLoginReady()
} else {
this.onLoginFailed()
}
}
}
},
onShow() {
if (app.globalData.isLoggedIn) {
this.setData({ isLoggedIn: true })
this.loadLatestRecord()
}
},
onLoginReady() {
async onShow() {
// 每次显示时等待登录完成,再加载最新数据
const userInfo = await app.waitLogin(true)
if (userInfo) {
this.setData({ isLoggedIn: true, loginFailed: false })
this.loadLatestRecord()
},
onLoginFailed() {
} else {
this.setData({ isLoggedIn: false, loginFailed: true })
}
},
onRetry() {
async onRetry() {
this.setData({ loginFailed: false })
app.silentLogin()
app.loginReadyCallback = (userInfo) => {
const userInfo = await app.waitLogin()
if (userInfo) {
this.onLoginReady()
this.setData({ isLoggedIn: true, loginFailed: false })
this.loadLatestRecord()
} else {
this.onLoginFailed()
}
this.setData({ loginFailed: true })
}
},
@@ -85,5 +73,18 @@ Page({
showQrcode(e) {
this.selectComponent('#qrcodeModal').show(e.currentTarget.dataset.id)
},
onShareAppMessage(res) {
return {
title: '访客预约',
path: '/pages/index/index'
}
},
onShareTimeline() {
return {
title: '访客预约'
}
}
})
+5 -12
View File
@@ -1,16 +1,9 @@
<!--index.wxml-->
<view class="page">
<!-- loading 遮罩 -->
<view class="loading-mask" wx:if="{{!isLoggedIn && !loginFailed}}">
<view class="loading-spinner"></view>
<text class="loading-text">正在获取身份信息...</text>
</view>
<!-- 登录失败 -->
<view class="loading-mask" wx:if="{{loginFailed}}">
<text class="fail-icon">⚠️</text>
<text class="fail-text">网络异常,请重试</text>
<view class="retry-btn" bindtap="onRetry">重新加载</view>
<!-- 登录失败提示条(不遮挡页面) -->
<view class="login-fail-bar" wx:if="{{loginFailed && !isLoggedIn}}">
<text>网络异常,无法获取身份信息</text>
<text class="retry-link" bindtap="onRetry">重试</text>
</view>
<view class="header">
@@ -26,7 +19,7 @@
</view>
<view class="action-info">
<text class="action-title">访客预约</text>
<text class="action-desc">选择预约时间和预约人,提交预约信息</text>
<text class="action-desc">选择来访时间和预约人,提交预约信息</text>
</view>
<text class="action-arrow"></text>
</view>
+12 -55
View File
@@ -21,67 +21,24 @@ page {
line-height: 1;
}
/* loading 遮罩 */
.loading-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #f0f5fa;
/* 登录失败提示条(不遮挡页面) */
.login-fail-bar {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 999;
}
.loading-spinner {
width: 64rpx;
height: 64rpx;
border: 6rpx solid #dbeafe;
border-top: 6rpx solid #5b9bd5;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-bottom: 24rpx;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.loading-text {
font-size: 28rpx;
color: #7f8fa6;
letter-spacing: 2rpx;
}
/* 登录失败 */
.fail-icon {
font-size: 80rpx;
justify-content: space-between;
background: #fff3e0;
color: #e6a23c;
font-size: 24rpx;
padding: 16rpx 24rpx;
border-radius: 12rpx;
margin-bottom: 20rpx;
}
.fail-text {
font-size: 28rpx;
color: #7f8fa6;
margin-bottom: 32rpx;
}
.retry-btn {
font-size: 28rpx;
color: #fff;
background: linear-gradient(135deg, #5b9bd5, #4a8bc2);
padding: 16rpx 56rpx;
border-radius: 40rpx;
.login-fail-bar .retry-link {
color: #5b9bd5;
font-weight: 600;
letter-spacing: 2rpx;
box-shadow: 0 4rpx 16rpx rgba(91, 155, 213, 0.3);
}
.retry-btn:active {
opacity: 0.85;
flex-shrink: 0;
margin-left: 20rpx;
}
.header-title {
+40 -3
View File
@@ -7,20 +7,44 @@ Page({
records: [],
filteredRecords: [],
currentTab: 'all',
loading: true
loading: true,
isLoggedIn: false,
loginFailed: false
},
onLoad() {
this._awaitLoginAndLoad()
},
async _awaitLoginAndLoad() {
const userInfo = await app.waitLogin(true)
if (userInfo) {
this.setData({ isLoggedIn: true, loginFailed: false })
this.loadRecords()
} else {
this.setData({ isLoggedIn: false, loginFailed: true, loading: false })
}
},
onShow() {
// 仅从预约页返回时刷新,避免 onLoad + onShow 双重加载
if (this.data._loaded) {
// 已加载过且已登录时刷新(从预约页返回等场景)
if (this.data._loaded && app.globalData.isLoggedIn) {
this.loadRecords()
}
},
async onRetry() {
this.setData({ loginFailed: false, loading: true })
app.silentLogin()
const userInfo = await app.waitLogin()
if (userInfo) {
this.setData({ isLoggedIn: true, loginFailed: false })
this.loadRecords()
} else {
this.setData({ loginFailed: true })
}
},
async loadRecords() {
this.setData({ loading: true })
try {
@@ -85,5 +109,18 @@ Page({
showQrcode(e) {
this.selectComponent('#qrcodeModal').show(e.currentTarget.dataset.id)
},
onShareAppMessage(res) {
return {
title: '访客预约',
path: '/pages/index/index'
}
},
onShareTimeline() {
return {
title: '访客预约'
}
}
})
+10 -3
View File
@@ -20,7 +20,7 @@
</view>
<!-- 记录列表 -->
<view class="record-list" wx:if="{{!loading && filteredRecords.length > 0}}">
<view class="record-list" wx:if="{{!loading && !loginFailed && filteredRecords.length > 0}}">
<view class="record-card" wx:for="{{filteredRecords}}" wx:key="_id">
<view class="record-header">
<view wx:if="{{item.status === 'approved'}}" class="qrcode-btn" bindtap="showQrcode" data-id="{{item._id}}">
@@ -45,7 +45,7 @@
<text class="record-value">{{item.reason}}</text>
</view>
<view class="record-row">
<text class="record-label">预约时间</text>
<text class="record-label">来访时间</text>
<text class="record-value">{{item.date}} {{item.time}}</text>
</view>
<view class="record-row">
@@ -70,8 +70,15 @@
<text class="empty-text">加载中...</text>
</view>
<!-- 登录失败 -->
<view class="empty" wx:if="{{loginFailed}}">
<text class="empty-icon">⚠️</text>
<text class="empty-text">登录失败,请重试</text>
<view class="empty-btn" bindtap="onRetry">重新登录</view>
</view>
<!-- 空状态 -->
<view class="empty" wx:if="{{!loading && filteredRecords.length === 0}}">
<view class="empty" wx:if="{{!loading && !loginFailed && filteredRecords.length === 0}}">
<text class="empty-icon">📭</text>
<text class="empty-text">暂无预约记录</text>
<view class="empty-btn" bindtap="goAppointment">去预约</view>
+13
View File
@@ -94,5 +94,18 @@ Page({
this.setData({ verifying: false })
wx.showToast({ title: err.message || '核销失败,请稍后重试', icon: 'none' })
}
},
onShareAppMessage(res) {
return {
title: '访客预约',
path: '/pages/index/index'
}
},
onShareTimeline() {
return {
title: '访客预约'
}
}
})
+4
View File
@@ -36,6 +36,10 @@
<text class="detail-label">来访事由</text>
<text class="detail-value">{{record.reason}}</text>
</view>
<view class="detail-row" wx:if="{{record.plateNumber}}">
<text class="detail-label">车牌号</text>
<text class="detail-value">{{record.plateNumber}}</text>
</view>
</view>
<view class="detail-section">
+1 -1
View File
@@ -36,6 +36,6 @@
"tabIndent": "auto",
"tabSize": 2
},
"appid": "wx50fe0c5c28dd3060",
"appid": "wx4286144359eeafe5",
"simulatorPluginLibVersion": {}
}
+14
View File
@@ -122,7 +122,9 @@ const appointmentDB = {
visitDate: data.date,
visitTime: data.time,
hostName: data.hostName,
personId: data.personId,
area: data.area,
plateNumber: data.plateNumber,
openid: data.openid
}
})
@@ -185,6 +187,18 @@ const appointmentDB = {
return mapApiRecord(data) || null
},
/**
* 获取被访部门/区域列表
* @returns {Promise<Array>} 部门列表
*/
async getDepartmentSelector() {
const data = await request({
url: BASE_URL + API.DEPARTMENT_SELECTOR,
method: 'GET'
})
return data || []
},
/**
* 获取被访人列表
* @param {string} department - 部门/区域编码
+8 -6
View File
@@ -2,12 +2,10 @@
// 环境地址配置
const ENV_CONFIG = {
// 正式版
// release: 'https://xcx.yun.588580.xyz',
trial: 'https://qywx.yun.588580.xyz',
// 开发版 & 体验版
develop: 'http://172.16.60.235:8080'
// develop: 'http://10.50.13.191:8080'
release: 'https://smartguest.bmser.com:8091',
trial: 'https://smartguest.bmser.com:8091',
// develop: 'https://192.168.123.76:8080'
develop: 'https://10.50.13.185:8091'
}
// 自动判断当前运行环境
@@ -15,6 +13,9 @@ function getBaseUrl() {
const accountInfo = wx.getAccountInfoSync()
const envVersion = accountInfo.miniProgram.envVersion
// release = 正式版, develop = 开发版, trial = 体验版
if (envVersion === 'release') {
return ENV_CONFIG.release
}
return envVersion === 'trial' ? ENV_CONFIG.trial : ENV_CONFIG.develop
}
@@ -30,6 +31,7 @@ const API = {
APPOINTMENT_DETAIL: '/api/wx-mini/appointment/detail',
WXACODE: '/api/wx-mini/wxacode',
PERSON_SELECTOR: '/api/wx-mini/appointment/person/selector',
DEPARTMENT_SELECTOR: '/api/wx-mini/appointment/department/selector',
NOTIFY_HOST: '/visitor/notify-host'
}