Browse Source

feat(visitor): 新增访客登记功能模块

- 添加访客登记页面 (visitor-register.vue) 及相关表单验证逻辑
- 实现访客信息提交接口对接及二维码扫码识别功能
- 新增访客管理相关 API 接口文件 (api/visitor.js)
- 在 pages.json 中注册访客登记页面路径及导航栏配置
- 添加 README 文档说明访客登记功能使用及技术细节
master
科技小王子 6 days ago
parent
commit
aa43006807
  1. 135
      README.md
  2. 22
      api/visitor.js
  3. 15
      pages.json
  4. 7
      pages/index/index.vue
  5. 526
      pages/user/visitor-register.vue

135
README.md

@ -0,0 +1,135 @@
# 祥安e家 - 社区管理系统
## 项目介绍
祥安e家是一个基于uni-app开发的社区管理小程序,为社区居民提供便捷的物业服务、生活缴费、访客管理等功能。
## 功能模块
### 1. 用户认证
- 业主认证
- 家属认证
- 租客认证
### 2. 物业服务
- 物业缴费
- 报修服务
- 投诉建议
- 问卷调查
### 3. 访客管理
- 访客邀请(业主邀请)
- **访客登记(外卖/快递人员登记)**
- 二维码扫描登记
- 访客信息填写(身份证、姓名、电话、车牌号等)
- 访问房号和目的选择
- 访客记录查看
### 4. 小区二维码管理
- 为每个小区生成专属访客登记二维码
- 二维码保存和打印功能
### 5. 生活服务
- 生活缴费
- 餐饮服务
- 快递服务
- 远程开门
## 新增访客登记功能说明
### 功能描述
为满足小区安全管理需求,系统新增访客登记功能,主要服务于外卖员、快递员等配送人员。通过在小区保安亭等位置张贴专属二维码,访客扫码后需完成注册登录并填写相关信息,认证成功后方可进入小区送货。
### 实现流程
1. **物业管理人员操作**
- 进入"二维码管理"页面
- 选择对应小区生成专属二维码
- 打印并张贴到保安亭等位置
2. **访客操作流程**
- 扫描小区专属二维码
- 系统自动识别小区信息
- 如未登录,跳转至登录页面
- 填写访客信息(身份证、姓名、电话、车牌号、身份)
- 选择访问房号和访问目的
- 提交登记信息
- 系统记录访客信息并通知对应业主
### 技术实现
- 前端:uni-app + uView UI
- 页面:
- [pages/user/visitor-register.vue](pages/user/visitor-register.vue) - 访客登记页面
- [pages/user/visitor-record.vue](pages/user/visitor-record.vue) - 访客记录查看页面
- [pages/user/visitor-detail.vue](pages/user/visitor-detail.vue) - 访客详情页面
- [pages/user/qr-scanner.vue](pages/user/qr-scanner.vue) - 二维码扫描页面
- [pages/user/village-qr-manage.vue](pages/user/village-qr-manage.vue) - 小区二维码管理页面
- API接口:
- [api/visitor.js](api/visitor.js) - 访客相关接口
### 页面功能详情
#### 访客登记页面
- 自动识别扫码进入的小区信息
- 访客基本信息填写(姓名、手机号、身份证号、车牌号)
- 身份选择(外卖员、快递员、跑腿员等)
- 访问信息填写(房号选择、访问目的)
- 表单验证和数据提交
#### 访客记录查看页面
- 展示访客登记记录列表
- 显示访客基本信息和访问信息
- 查看记录详情
#### 访客详情页面
- 展示访客详细信息
- 显示登记时间、状态等信息
#### 二维码扫描页面
- 调用摄像头扫描二维码
- 支持从相册选取二维码图片
- 闪光灯控制功能
#### 小区二维码管理页面
- 展示所有小区列表
- 为每个小区生成专属访客登记二维码
- 二维码保存功能
## 开发说明
### 目录结构
```
.
├── api # 接口请求
├── components # 公共组件
├── config # 配置文件
├── js_sdk # 第三方SDK
├── pages # 页面文件
│ ├── user # 用户相关页面
│ ├── service # 服务相关页面
│ └── ... # 其他页面
├── static # 静态资源
├── util # 工具函数
└── ...
```
### 新增页面
- [pages/user/visitor-register.vue](pages/user/visitor-register.vue)
- [pages/user/visitor-record.vue](pages/user/visitor-record.vue)
- [pages/user/visitor-detail.vue](pages/user/visitor-detail.vue)
- [pages/user/qr-scanner.vue](pages/user/qr-scanner.vue)
- [pages/user/village-qr-manage.vue](pages/user/village-qr-manage.vue)
### 新增API
- [api/visitor.js](api/visitor.js)
## 部署说明
1. 安装依赖:`npm install`
2. 运行开发环境:`npm run dev:%PLATFORM%`
3. 构建生产环境:`npm run build:%PLATFORM%`
## 注意事项
1. 访客登记功能需要后端接口支持
2. 二维码生成需要后端提供生成服务
3. 访客信息提交需要进行数据验证和安全处理

22
api/visitor.js

@ -0,0 +1,22 @@
import post, { get, del, put } from "@/api/request";
// 访客登记
export const visitorRegisterReq = data => post('/zhsq/zhsq-visitor/register', data)
// 获取访客记录列表
export const visitorListReq = params => get('/zhsq/zhsq-visitor/list', { params })
// 获取访客记录详情
export const visitorInfoReq = id => get(`/zhsq/zhsq-visitor/${id}`)
// 更新访客记录
export const visitorUpdateReq = (id, data) => put(`/zhsq/zhsq-visitor/${id}`, data)
// 删除访客记录
export const visitorDeleteReq = id => del(`/zhsq/zhsq-visitor/${id}`)
// 生成小区访客二维码
export const makeVillageVisitorQrReq = data => post('/wechat/make-village-visitor-qr', data)
// 获取访客统计数据
export const visitorStatisticsReq = params => get('/zhsq/zhsq-visitor/statistics', { params })

15
pages.json

@ -95,17 +95,26 @@
},
{
"path" : "pages/community/index",
"style" :
"style" :
{
"navigationBarTitleText" : "小区概览"
}
},
{
"path" : "pages/community/detail",
"style" :
"style" :
{
"navigationBarTitleText" : "详情"
}
},
{
"path" : "pages/user/visitor-register",
"style" :
{
"navigationBarTitleText" : "外卖员登记",
"navigationBarBackgroundColor": "#98C147",
"navigationBarTextStyle": "white"
}
}
],
"subPackages": [
@ -313,4 +322,4 @@
"backgroundColor": "#f5f5f5"
},
"uniIdRouter": {}
}
}

7
pages/index/index.vue

@ -48,6 +48,11 @@
style="width: 80rpx; height: 80rpx"/>
<text class="text-24 mt-15">生活缴费</text>
</view>
<!-- <view class="icon-item" @click="navToWithLogin('/pages/user/village-qr-manage')">-->
<!-- <image src="https://oss.wsdns.cn/20250626/qr-code-icon.png"-->
<!-- style="width: 80rpx; height: 80rpx"/>-->
<!-- <text class="text-24 mt-15">二维码管理</text>-->
<!-- </view>-->
</view>
<!-- 广告图 -->
<view class="ad-section">
@ -254,4 +259,4 @@ export default {
bottom: 200rpx;
z-index: 999;
}
</style>
</style>

526
pages/user/visitor-register.vue

@ -0,0 +1,526 @@
<template>
<view class="visitor-register">
<!-- 自定义导航栏 -->
<!-- <view class="custom-navbar">-->
<!-- <view class="status-bar" :style="{height: statusBarHeight + 'px'}"></view>-->
<!-- <view class="nav-content">-->
<!-- <view class="nav-left" @click="goBack">-->
<!-- <text class="nav-back"></text>-->
<!-- </view>-->
<!-- <view class="nav-title">访客登记</view>-->
<!-- </view>-->
<!-- </view>-->
<!-- 页面内容 -->
<view class="content">
<view class="form-container">
<!-- 小区信息 -->
<view class="section">
<view class="section-title">小区信息</view>
<view class="form-item">
<text class="label">小区名称</text>
<text class="value">{{ villageInfo.name || '未选择' }}</text>
</view>
</view>
<!-- 访客基本信息 -->
<view class="section">
<view class="section-title">访客信息</view>
<view class="form-item">
<text class="label required">姓名</text>
<u-input
v-model="visitorInfo.name"
placeholder="请输入真实姓名"
border="none"
class="input"
/>
</view>
<view class="form-item">
<text class="label required">手机号</text>
<u-input
v-model="visitorInfo.phone"
placeholder="请输入手机号"
border="none"
type="number"
class="input"
/>
</view>
<view class="form-item">
<text class="label required">身份证号</text>
<u-input
v-model="visitorInfo.idCard"
placeholder="请输入身份证号"
border="none"
class="input"
/>
</view>
<view class="form-item">
<text class="label">车牌号</text>
<u-input
v-model="visitorInfo.carNumber"
placeholder="请输入车牌号(选填)"
border="none"
class="input"
/>
</view>
<view class="form-item">
<text class="label required">身份</text>
<view class="picker-wrapper" @click="showIdentityPicker = true">
<text class="value">{{ visitorInfo.identity || '请选择身份' }}</text>
<text class="arrow"></text>
</view>
</view>
</view>
<!-- 访问信息 -->
<view class="section">
<view class="section-title">访问信息</view>
<view class="form-item" @click="selectRoom">
<text class="label required">访问房号</text>
<view class="picker-wrapper">
<text class="value">{{ selectedRoom ? selectedRoom.title : '请选择房号' }}</text>
<text class="arrow"></text>
</view>
</view>
<view class="form-item">
<text class="label required">访问目的</text>
<view class="picker-wrapper" @click="showPurposePicker = true">
<text class="value">{{ visitorInfo.purpose || '请选择访问目的' }}</text>
<text class="arrow"></text>
</view>
</view>
<view class="form-item">
<text class="label">备注</text>
<u-input
v-model="visitorInfo.remark"
placeholder="请输入备注信息(选填)"
border="none"
class="input textarea"
type="textarea"
:auto-height="true"
/>
</view>
</view>
</view>
<!-- 提交按钮 -->
<view class="submit-container">
<button class="submit-btn" @click="submitForm">提交登记</button>
</view>
</view>
<!-- 身份选择弹窗 -->
<u-picker
:show="showIdentityPicker"
:columns="[identityOptions]"
@cancel="showIdentityPicker = false"
@confirm="selectIdentity"
closeOnClickOverlay
/>
<!-- 访问目的选择弹窗 -->
<u-picker
:show="showPurposePicker"
:columns="[purposeOptions]"
@cancel="showPurposePicker = false"
@confirm="selectPurpose"
closeOnClickOverlay
/>
</view>
</template>
<script>
import { getUserInfo } from '@/util/user.js'
import { visitorRegisterReq } from '@/api/visitor.js'
export default {
data() {
return {
statusBarHeight: 0,
isLogin: false, //
villageInfo: {}, //
visitorInfo: {
name: '', //
phone: '', //
idCard: '', //
carNumber: '', //
identity: '', //
purpose: '', // 访
remark: '' //
},
selectedRoom: null, //
showIdentityPicker: false, //
showPurposePicker: false, // 访
identityOptions: ['外卖员', '快递员', '跑腿员', '其他'], //
purposeOptions: ['送餐', '送货', '跑腿', '其他'], // 访
qrCodeData: null //
}
},
onLoad(options) {
//
const systemInfo = uni.getSystemInfoSync()
this.statusBarHeight = systemInfo.statusBarHeight
//
this.checkLoginStatus()
//
if (options.q) {
try {
//
const decoded = decodeURIComponent(options.q)
//
//
this.villageInfo = {
id: 'village_001',
name: '祥安小区',
code: 'XA001'
}
} catch (e) {
console.error('解析二维码参数失败', e)
}
}
},
methods: {
goBack() {
uni.navigateBack()
},
//
checkLoginStatus() {
const userInfo = getUserInfo()
if (userInfo && userInfo.token) {
this.isLogin = true
//
this.visitorInfo.phone = userInfo.phone || ''
} else {
//
this.navigateToLogin()
}
},
//
navigateToLogin() {
uni.navigateTo({
url: '/components/Login',
events: {
done: () => {
//
this.checkLoginStatus()
}
}
})
},
//
selectIdentity({ value }) {
this.visitorInfo.identity = value[0]
this.showIdentityPicker = false
},
// 访
selectPurpose({ value }) {
this.visitorInfo.purpose = value[0]
this.showPurposePicker = false
},
//
selectRoom() {
if (!this.villageInfo.code) {
uni.showToast({
title: '请先选择小区',
icon: 'none'
})
return
}
//
uni.navigateTo({
url: `/pages/user/select-room?villageCode=${this.villageInfo.code}`,
events: {
select: (room) => {
this.selectedRoom = room
}
}
})
},
//
async submitForm() {
//
if (!this.validateForm()) {
return
}
//
const submitData = {
name: this.visitorInfo.name,
phone: this.visitorInfo.phone,
idCard: this.visitorInfo.idCard,
carNumber: this.visitorInfo.carNumber,
identity: this.visitorInfo.identity,
purpose: this.visitorInfo.purpose,
remark: this.visitorInfo.remark,
villageCode: this.villageInfo.code,
roomCode: this.selectedRoom.roomCode,
buildingCode: this.selectedRoom.baseBuildingBuildCode,
unitCode: this.selectedRoom.baseUnitUnitCode
}
//
uni.showLoading({
title: '提交中...'
})
try {
const res = await visitorRegisterReq(submitData)
if (res.code === 0) {
uni.hideLoading()
uni.showToast({
title: '登记成功',
icon: 'success'
})
//
setTimeout(() => {
uni.navigateBack()
}, 1500)
} else {
throw new Error(res.msg || '登记失败')
}
} catch (error) {
uni.hideLoading()
uni.showToast({
title: error.message || '登记失败,请重试',
icon: 'none'
})
console.error('访客登记失败', error)
}
},
//
validateForm() {
if (!this.visitorInfo.name) {
uni.showToast({
title: '请输入姓名',
icon: 'none'
})
return false
}
if (!this.visitorInfo.phone) {
uni.showToast({
title: '请输入手机号',
icon: 'none'
})
return false
}
//
if (!/^1[3-9]\d{9}$/.test(this.visitorInfo.phone)) {
uni.showToast({
title: '请输入正确的手机号',
icon: 'none'
})
return false
}
if (!this.visitorInfo.idCard) {
uni.showToast({
title: '请输入身份证号',
icon: 'none'
})
return false
}
//
if (!/(^\d{15}$)|(^\d{18}$)|(^\d{17}(\d|X|x)$)/.test(this.visitorInfo.idCard)) {
uni.showToast({
title: '请输入正确的身份证号',
icon: 'none'
})
return false
}
if (!this.visitorInfo.identity) {
uni.showToast({
title: '请选择身份',
icon: 'none'
})
return false
}
if (!this.selectedRoom) {
uni.showToast({
title: '请选择访问房号',
icon: 'none'
})
return false
}
if (!this.visitorInfo.purpose) {
uni.showToast({
title: '请选择访问目的',
icon: 'none'
})
return false
}
return true
}
}
}
</script>
<style scoped lang="scss">
.visitor-register {
min-height: 100vh;
background-color: #f5f5f5;
}
.custom-navbar {
background: linear-gradient(180deg, #98C147 0%, #A6D057 100%);
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 999;
.nav-content {
height: 88rpx;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 32rpx;
position: relative;
}
.nav-left {
width: 80rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: flex-start;
}
.nav-back {
font-size: 48rpx;
color: #fff;
font-weight: 300;
}
.nav-title {
position: absolute;
left: 50%;
transform: translateX(-50%);
font-size: 36rpx;
color: #fff;
font-weight: 500;
}
}
.content {
padding-top: calc(88rpx + var(--status-bar-height));
padding-bottom: 120rpx;
}
.form-container {
margin: 20rpx;
}
.section {
background: #fff;
border-radius: 16rpx;
margin-bottom: 20rpx;
overflow: hidden;
}
.section-title {
padding: 20rpx 32rpx;
font-size: 32rpx;
font-weight: 500;
color: #333;
border-bottom: 1rpx solid #f5f5f5;
}
.form-item {
display: flex;
align-items: center;
padding: 32rpx;
border-bottom: 1rpx solid #f5f5f5;
&:last-child {
border-bottom: none;
}
}
.label {
width: 180rpx;
font-size: 28rpx;
color: #333;
&.required::before {
content: '*';
color: #ff6b35;
margin-right: 8rpx;
}
}
.value {
flex: 1;
font-size: 28rpx;
color: #333;
text-align: right;
}
.input {
flex: 1;
text-align: right;
&.textarea {
padding: 10rpx 0;
}
}
.picker-wrapper {
flex: 1;
display: flex;
justify-content: space-between;
align-items: center;
}
.arrow {
font-size: 36rpx;
color: #ccc;
font-weight: 300;
margin-left: 20rpx;
}
.submit-container {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 20rpx;
background: #fff;
box-shadow: 0 -2rpx 10rpx rgba(0, 0, 0, 0.1);
}
.submit-btn {
width: 100%;
height: 96rpx;
background: linear-gradient(90deg, #98C147 0%, #A6D057 100%);
border-radius: 48rpx;
border: none;
font-size: 36rpx;
color: #fff;
font-weight: 500;
}
</style>
Loading…
Cancel
Save