hookehuyr

feat: 新增扫码打卡流程与展位图画廊功能

- 新增扫码打卡列表页、详情页及展位图画廊页,包含完整样式与配置
- 新增扫码打卡点模拟数据工具函数
- 升级活动详情页以适配扫码打卡类型流程
- 将新页面注册至应用配置列表
- 更新地图活动API接口,新增打卡类型参数
- 修复应用配置及API文件的格式问题
- 新增项目开发指导文档AGENTS.md
# AGENTS.md
本文件为 Codex (Codex.ai/code) 在此代码库中工作时提供指导。
## 项目概述
**lls_program** 是一个基于 Taro 4 + Vue 3 + NutUI 的微信小程序,名为"老来赛"。这是一个家庭活动和积分奖励管理系统。
## 技术栈
- **框架**: Taro 4.1.7 - 跨平台小程序框架
- **UI**: Vue 3.3 + Composition API (`<script setup>`)
- **UI 组件库**: NutUI Taro 4.3.13 (自动导入,无需手动引入)
- **样式**: TailwindCSS 3.4 + Less (组件特定样式)
- **状态管理**: Pinia 3.0 + taro-plugin-pinia
- **HTTP 请求**: axios-miniprogram 2.7.2
- **构建工具**: Webpack 5
## 开发命令
```bash
# 安装依赖
pnpm install
# 开发(微信小程序)
pnpm run dev:weapp
# 生产构建
pnpm run build:weapp
# 其他平台
pnpm run dev:h5 # H5 开发
pnpm run dev:alipay # 支付宝小程序
pnpm run dev:tt # 抖音小程序
```
## 架构设计
### 核心目录结构
```
src/
├── api/ # 按业务领域组织的 API 接口
├── assets/ # 静态资源(图片、样式)
├── components/ # 可复用的 Vue 组件
├── composables/ # Vue 3 组合式函数 (useXxx)
├── pages/ # Taro 页面(每个页面包含 index.vue + index.config.js)
├── stores/ # Pinia 状态管理
├── utils/ # 工具函数
├── app.config.js # Taro 应用配置(页面列表、窗口、权限)
└── app.less # 全局样式
```
### 路径别名 (config/index.js:30-38)
```javascript
@/utils src/utils
@/components src/components
@/images src/assets/images
@/assets src/assets
@/composables src/composables
@/api src/api
@/stores src/stores
@/hooks src/hooks
```
### 设计宽度配置
- **NutUI 组件**: 375px (自动处理)
- **其他所有内容**: 750px (Taro 标准)
- `config/index.js` 中的 `designWidth` 函数根据文件路径自动切换
## 核心 API 模式
### API 响应格式
所有 API 响应遵循以下结构:
```javascript
{
code: 1, // 1 = 成功,其他值 = 失败
data: {...}, // 响应数据
msg: "message" // 错误/成功消息
}
```
**始终检查** `res.code === 1`(而不是 `res.code`)来判断成功。
### 认证机制 (sessionid)
**关键**: 项目使用 `sessionid` 进行认证(存储在 `wx.storage` 中):
1. **获取**: `src/utils/request.js:23-30` - `getSessionId()``wx.getStorageSync("sessionid")` 读取
2. **设置**: 在 `miniProgramAuthAPI``loginAPI` 成功后设置
3. **使用**: 请求拦截器 (`request.js:75-78`) 设置 `config.headers.cookie = sessionid`
4. **清除**: 收到 401 响应或用户登出时
⚠️ **重要**: sessionid **不**由前端用于判断登录状态(后端通过 401 响应来判断)。它只是传递给服务器的凭证。
### 请求拦截器 (src/utils/request.js:66-80)
```javascript
service.interceptors.request.use(config => {
// 动态获取 sessionid 并设置到请求头
const sessionid = getSessionId();
if (sessionid) {
config.headers.cookie = sessionid;
}
return config;
})
```
### API 模块模式 (src/api/)
每个 API 文件导出调用中央 `fn()` 辅助函数的函数:
```javascript
// src/api/common.js
export const smsAPI = (params) => fn(fetch.post(Api.SMS, params));
```
关键 API 模块:
- `common.js` - 短信验证码、上传凭证
- `user.js` - 用户认证和个人信息
- `family.js` - 家庭管理
- `points.js` - 积分/奖励系统
- `photo.js` - 照片/媒体处理
- `organization.js` - 组织管理
## Taro 小程序限制
### ❌ 禁止使用 Web API
```javascript
// 禁止 - 在小程序中会崩溃
window.document.getElementById()
localStorage
window.location.href
fetch()
```
### ✅ 必须使用 Taro API
```javascript
// 正确 - 使用 Taro 等价 API
Taro.createSelectorQuery()
Taro.getStorage() / Taro.setStorage()
Taro.navigateTo()
Taro.request()
```
### 页面生命周期(使用 Taro Hooks)
```javascript
import { useLoad, useShow, useReady } from '@tarojs/taro'
useLoad((options) => {
// 页面加载(仅触发一次)- 适合获取路由参数
})
useShow(() => {
// 页面显示(每次显示都触发)- 适合刷新数据
})
useReady(() => {
// 页面首次渲染完成
})
```
### ❌ 页面中避免使用 Vue 生命周期
```javascript
// 不要使用 - 可能无法正常工作
onMounted(() => { ... })
onUnmounted(() => { ... })
```
## 组件指南
### 页面结构
每个页面目录包含:
- `index.vue` - 页面组件(必须使用 `<script setup>`
- `index.config.js` - 页面特定配置(navigationBarTitleText 等)
- `index.less` - 页面特定样式(scoped)
### 组件命名规范
- **页面**: 目录名(如 `pages/Dashboard/`
- **组件**: PascalCase 多单词命名(如 `PointsCollector.vue``FamilyAlbum.vue`
- **API 文件**: camelCase(如 `miniProgramAuthAPI`
### NutUI 自动导入
NutUI 组件通过 `unplugin-vue-components` 自动导入。**不要**手动导入:
```vue
<!-- ✅ 正确 - 自动导入 -->
<template>
<nut-button type="primary">点击</nut-button>
</template>
<!-- ❌ 错误 - 不要导入 -->
<script setup>
import { Button } from '@nutui/nutui-taro'
</script>
```
## 样式
### TailwindCSS + Less 混合使用
- **TailwindCSS**: 用于布局、间距、颜色、排版(80% 的样式)
- **Less**: 用于组件特定样式、动画、深度选择器(20%)
### Tailwind 配置
- **Content**: `./src/**/*.{html,js,ts,jsx,tsx,vue}` (tailwind.config.js:13)
- **Preflight**: 禁用(小程序不需要)
- **rem → rpx**: 由 `weapp-tailwindcss` 插件处理 (rem2rpx: true)
### 样式指南
```vue
<style lang="less" scoped>
/* ✅ 组件必须使用 scoped */
.page-container {
padding: 30px;
}
/* ✅ 使用 Less 处理深度选择器 */
.custom-element :deep(.nut-popup) {
background-color: #fff;
}
</style>
```
## 状态管理 (Pinia)
### Store 模式
```javascript
// src/stores/host.js
import { defineStore } from 'pinia'
export const hostStore = defineStore('host', {
state: () => ({
id: '',
join_id: ''
}),
actions: {
add(id) {
this.id = id
}
}
})
```
### 在组件中使用
```vue
<script setup>
import { hostStore } from '@/stores/host'
const host = hostStore()
host.add('123')
</script>
```
## 常用模式
### 页面导航
```javascript
import Taro from '@tarojs/taro'
// 跳转到页面
Taro.navigateTo({
url: '/pages/Detail/index?id=123'
})
// 重定向(无返回)
Taro.redirectTo({
url: '/pages/Login/index'
})
// 切换 Tab
Taro.switchTab({
url: '/pages/Dashboard/index'
})
// 获取路由参数
useLoad((options) => {
const { id } = options
})
```
### 本地存储
```javascript
// 异步(推荐)
await Taro.setStorage({ key: 'user', data: userInfo })
const { data } = await Taro.getStorage({ key: 'user' })
// 同步(谨慎使用)
Taro.setStorageSync('token', 'xxxx')
const token = Taro.getStorageSync('token')
```
### 提示/弹窗
```javascript
// Toast 提示
Taro.showToast({
title: '操作成功',
icon: 'success',
duration: 2000
})
// Modal 弹窗
Taro.showModal({
title: '提示',
content: '确定删除吗?',
success: (res) => {
if (res.confirm) {
// 用户点击了确定
}
}
})
```
## 页面注册
页面在 `src/app.config.js` 中注册:
```javascript
export default {
pages: [
'pages/Dashboard/index',
'pages/MyFamily/index',
'pages/Activities/index',
// ... 更多页面
]
}
```
**创建新页面时**: 必须将其添加到此数组中。
## 构建输出
- **开发环境**: `dist/` 目录
- **微信开发者工具**: 打开 `dist/` 作为项目根目录
## 重要文件说明
### `src/utils/request.js`
核心 HTTP 客户端,包含:
- SessionID 注入
- 401 响应处理
- 401 时静默授权重定向
- 错误处理
### `src/utils/authRedirect.js`
处理小程序登录流程的静默授权。
### `src/utils/tools.js`
通用工具函数:
- `formatDate()` - 使用 moment.js 格式化日期
- `wxInfo()` - 平台检测(Android/iOS/微信)
- `hasEllipsis()` - 文本溢出检测
## 开发注意事项
1. **始终使用 Taro API** 而非 Web API
2. **检查 `res.code === 1`** 判断 API 成功(不是 `res.code`
3. **NutUI 组件已自动导入** - 不要手动导入
4. **页面中使用 Taro 生命周期钩子**`useLoad``useShow`
5. **SessionID 动态获取** - 每次请求从存储中读取
6. **已配置路径别名** - 使用 `@/components` 代替相对路径
7. **设计宽度双模式**: NutUI 使用 375px,其他使用 750px
## 平台差异
项目通过 Taro 支持多平台:
- **微信 (weapp)**: 主要目标平台
- **H5**: Web 浏览器版本
- **支付宝 (alipay)**: 支付宝小程序
- **抖音 (tt)**: 字节跳动小程序
平台特定代码可使用:
```javascript
if (process.env.TARO_ENV === 'weapp') {
// 微信特定代码
}
```
......@@ -35,6 +35,7 @@ export const checkinAPI = params => fn(fetch.post(Api.Checkin, params))
* data: {
url: string; // 地图网址
id: integer; // 活动ID
type: string; // 打卡类型,MAP=地图打卡,QR_CODE=扫码打卡
cover: string; // 封面图
begin_date: string; // 开始时间
end_date: string; // 结束时间
......
......@@ -37,6 +37,9 @@ export default {
'pages/FamilyRank/index',
'pages/PosterCheckin/index',
'pages/PosterCheckinDetail/index',
'pages/ScanCheckinList/index',
'pages/ScanCheckinDetail/index',
'pages/BoothMapGallery/index',
'pages/CheckinList/index',
'pages/CheckinMap/index',
'pages/JoinOrganization/index',
......
......@@ -158,6 +158,11 @@ import { mockMapActivityDetailAPI } from '@/utils/mockData'
// const USE_MOCK_DATA = process.env.NODE_ENV === 'development'
const USE_MOCK_DATA = false
const CHECKIN_TYPES = {
MAP: 'MAP',
QR_CODE: 'QR_CODE',
}
// 默认海报图
const defaultPoster = ref(
'https://cdn.ipadbiz.cn/lls_prog/images/welcome_8.jpg?imageMogr2/strip/quality/60'
......@@ -272,6 +277,7 @@ const activityData = ref({
'有机会获得商户优惠券',
'参与月度积分排行榜',
],
checkinType: CHECKIN_TYPES.MAP,
})
// 分享配置(动态生成,包含当前页面参数)
......@@ -398,6 +404,8 @@ const getUserLocation = async (skipAuthCheck = false) => {
* 获取按钮显示文本
*/
const getButtonText = () => {
const currentCheckinType = getCurrentCheckinType()
// 如果活动已结束,显示"活动已结束"
if (activityStatus.value.is_ended) {
return '活动已结束'
......@@ -413,6 +421,10 @@ const getButtonText = () => {
return '立即参加'
}
if (currentCheckinType === CHECKIN_TYPES.QR_CODE) {
return '进入打卡点'
}
// 如果位置获取失败,显示"重新定位"
if (locationError.value) {
return '重新定位'
......@@ -433,6 +445,33 @@ const getButtonText = () => {
}
/**
* 获取当前生效的打卡类型
* 接口未返回 type 时,默认仍走原来的地图打卡流程。
*/
const getCurrentCheckinType = () => {
return activityData.value.checkinType || CHECKIN_TYPES.MAP
}
/**
* 跳转到扫码打卡点列表页
*/
const navigateToQrCodeCheckin = async () => {
const params = new URLSearchParams()
if (activityId.value) {
params.append('activityId', activityId.value)
}
if (activityData.value.title) {
params.append('title', activityData.value.title)
}
await Taro.navigateTo({
url: `/pages/ScanCheckinList/index?${params.toString()}`,
})
}
/**
* 检查用户是否加入家庭并处理参加活动按钮点击
*/
const checkFamilyStatusAndJoinActivity = async () => {
......@@ -565,6 +604,11 @@ const handleJoinActivity = async () => {
isJoining.value = true
try {
if (getCurrentCheckinType() === CHECKIN_TYPES.QR_CODE) {
await navigateToQrCodeCheckin()
return
}
// 检查定位授权状态
const authSetting = await Taro.getSetting()
const hasLocationPermission = authSetting.authSetting['scope.userLocation']
......@@ -1005,6 +1049,7 @@ const transformApiDataToActivityData = apiData => {
discount_title: apiData.discount_title || '打卡点专属优惠',
activityId: apiData.id || '',
mapUrl: apiData.url || '', // 保留地图 URL
checkinType: apiData.type || CHECKIN_TYPES.MAP,
}
}
......
export default {
navigationBarTitleText: '展位图',
}
.booth-map-gallery-page {
min-height: 100vh;
padding: 24rpx;
background: #f4f6f8;
box-sizing: border-box;
}
.booth-map-gallery-grid {
column-count: 2;
column-gap: 20rpx;
}
.booth-map-gallery-item {
break-inside: avoid;
margin-bottom: 20rpx;
border-radius: 24rpx;
overflow: hidden;
background: #ffffff;
box-shadow: 0 12rpx 32rpx rgba(15, 23, 42, 0.08);
}
.booth-map-gallery-image {
width: 100%;
display: block;
background: #e5e7eb;
}
<template>
<view class="booth-map-gallery-page">
<view class="booth-map-gallery-grid">
<view
v-for="(item, index) in imageList"
:key="item.id"
class="booth-map-gallery-item"
@click="previewImage(index)"
>
<image class="booth-map-gallery-image" :src="item.url" :mode="item.mode || 'widthFix'" />
</view>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import Taro from '@tarojs/taro'
import './index.less'
const imageList = ref([
{
id: 'booth-01',
url: 'https://cdn.ipadbiz.cn/lls_prog/images/check_detail_img.png?imageMogr2/strip/quality/60',
mode: 'widthFix',
},
{
id: 'booth-02',
url: 'https://cdn.ipadbiz.cn/lls_prog/images/welcome_8.jpg?imageMogr2/strip/quality/60',
mode: 'widthFix',
},
{
id: 'booth-03',
url: 'https://cdn.ipadbiz.cn/lls_prog/images/check_detail_img.png?imageMogr2/strip/quality/60',
mode: 'widthFix',
},
])
const previewImage = index => {
Taro.previewImage({
current: imageList.value[index].url,
urls: imageList.value.map(item => item.url),
})
}
</script>
<script>
export default {
name: 'BoothMapGallery',
}
</script>
export default {
navigationBarTitleText: '打卡详情',
}
.scan-checkin-detail-page {
min-height: 100vh;
padding: 0 0 60rpx;
background: #f2f4f7;
box-sizing: border-box;
}
.scan-checkin-detail-cover {
width: 100%;
height: 520rpx;
}
.scan-checkin-detail-cover-image {
width: 100%;
height: 100%;
display: block;
}
.scan-checkin-detail-card {
margin: 28rpx 24rpx 0;
padding: 36rpx 32rpx 40rpx;
border-radius: 32rpx;
background: #ffffff;
box-shadow: 0 16rpx 50rpx rgba(15, 23, 42, 0.08);
}
.scan-checkin-detail-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24rpx;
}
.scan-checkin-detail-title {
flex: 1;
font-size: 36rpx;
line-height: 1.35;
color: #1f2937;
font-weight: 600;
word-break: break-all;
}
.scan-checkin-detail-status {
padding: 10rpx 24rpx;
border-radius: 999rpx;
font-size: 24rpx;
line-height: 1;
white-space: nowrap;
}
.scan-checkin-detail-status.pending {
color: #f97316;
border: 1rpx solid rgba(249, 115, 22, 0.5);
background: rgba(255, 247, 237, 0.9);
}
.scan-checkin-detail-status.done {
color: #16a34a;
border: 1rpx solid rgba(22, 163, 74, 0.45);
background: rgba(240, 253, 244, 0.95);
}
.scan-checkin-detail-subtitle {
display: block;
margin-top: 18rpx;
font-size: 28rpx;
line-height: 1.5;
color: #c3cad5;
}
.scan-checkin-detail-section {
margin-top: 34rpx;
}
.scan-checkin-detail-section-header {
padding-bottom: 2rpx;
border-bottom: 1rpx solid #e5e7eb;
}
.scan-checkin-detail-section-title {
position: relative;
display: inline-block;
padding-bottom: 18rpx;
font-size: 30rpx;
font-weight: 600;
line-height: 1.2;
color: #df7750;
}
.scan-checkin-detail-section-title::after {
content: '';
position: absolute;
left: 50%;
bottom: 0;
width: 80rpx;
height: 8rpx;
border-radius: 999rpx;
background: #df7750;
transform: translateX(-50%);
}
.scan-checkin-detail-content {
margin-top: 24rpx;
padding-top: 26rpx;
}
.scan-checkin-detail-rich-text {
display: block;
color: #4b5563;
font-size: 30rpx;
line-height: 1.8;
}
.scan-checkin-detail-button-wrap {
display: flex;
justify-content: center;
margin-top: 48rpx;
}
.scan-checkin-detail-button {
width: 420rpx;
height: 96rpx;
border-radius: 24rpx;
font-size: 38rpx;
font-weight: 600;
box-shadow: 0 18rpx 36rpx rgba(239, 123, 69, 0.28);
}
<template>
<view class="scan-checkin-detail-page">
<view class="scan-checkin-detail-cover">
<image class="scan-checkin-detail-cover-image" :src="detail.cover" mode="aspectFill" />
</view>
<view class="scan-checkin-detail-card">
<view class="scan-checkin-detail-heading">
<text class="scan-checkin-detail-title">{{ detail.code }} {{ detail.title }}</text>
<view class="scan-checkin-detail-status" :class="detail.isChecked ? 'done' : 'pending'">
{{ detail.isChecked ? '已打卡' : '未打卡' }}
</view>
</view>
<text class="scan-checkin-detail-subtitle">{{ detail.guideText }}</text>
<view class="scan-checkin-detail-section">
<view class="scan-checkin-detail-section-header">
<text class="scan-checkin-detail-section-title">{{ detail.discountTitle }}</text>
</view>
<view class="scan-checkin-detail-content">
<rich-text class="scan-checkin-detail-rich-text" :nodes="formattedDiscountContent" />
</view>
</view>
</view>
<view class="scan-checkin-detail-button-wrap">
<nut-button
type="primary"
class="scan-checkin-detail-button"
color="#DF7750"
:loading="scanSubmitting"
@click="handleScanCheckin"
>
扫码打卡
</nut-button>
</view>
</view>
</template>
<script setup>
import { reactive, computed } from 'vue'
import Taro, { useLoad } from '@tarojs/taro'
import './index.less'
import { getMockScanCheckinDetail } from '@/utils/mockQrCheckin'
const detail = reactive({
id: '',
code: 'W2D01',
title: '泰康之家经营管理有限公司上海分公司',
guideText: '在点位现场扫码打卡并推荐好物',
discountTitle: '打卡点专属优惠',
discountContentRaw: '',
cover: 'https://cdn.ipadbiz.cn/lls_prog/images/check_detail_img.png?imageMogr2/strip/quality/60',
isChecked: false,
lastScanCode: '',
scanSubmitting: false,
})
const scanSubmitting = computed(() => detail.scanSubmitting === true)
const formattedDiscountContent = computed(() => {
const content = detail.discountContentRaw
if (!content) {
return ''
}
if (Array.isArray(content)) {
return content
.map(
item =>
`<p style="margin: 0 0 20rpx; line-height: 1.8; color: #4b5563; font-size: 30rpx;">${item}</p>`
)
.join('')
}
let formattedContent = content
formattedContent = formattedContent.replace(/\n/g, '<br>')
formattedContent = formattedContent.replace(
/<img/g,
'<img style="max-width:100%;height:auto;display:block;border-radius:16rpx;margin:24rpx 0;"'
)
return formattedContent
})
const mockSubmitScanCode = async code => {
await new Promise(resolve => {
setTimeout(resolve, 500)
})
return {
code: 1,
msg: '打卡成功',
data: {
scan_code: code,
},
}
}
const handleScanCheckin = async () => {
detail.scanSubmitting = true
try {
const scanResult = await Taro.scanCode({
onlyFromCamera: false,
scanType: ['qrCode', 'barCode'],
})
const scannedCode = scanResult.result || ''
if (!scannedCode) {
Taro.showToast({
title: '未识别到扫码结果',
icon: 'none',
})
return
}
const submitResult = await mockSubmitScanCode(scannedCode)
if (submitResult.code === 1) {
detail.isChecked = true
detail.lastScanCode = scannedCode
await Taro.showModal({
title: '模拟提交成功',
content: `扫码结果:${scannedCode}`,
showCancel: false,
confirmText: '知道了',
})
return
}
Taro.showToast({
title: submitResult.msg || '提交失败',
icon: 'none',
})
} catch (error) {
if (error?.errMsg && error.errMsg.includes('cancel')) {
return
}
console.error('扫码打卡失败:', error)
Taro.showToast({
title: '扫码失败,请重试',
icon: 'none',
})
} finally {
detail.scanSubmitting = false
}
}
const handleMockDataLoaded = mockDetail => {
detail.scanSubmitting = false
Object.assign(detail, {
...mockDetail,
discountContentRaw: mockDetail.discountContent,
isChecked: mockDetail.status === '已打卡',
})
}
useLoad(options => {
const detailId = options.detailId || options.id || ''
const mockDetail = getMockScanCheckinDetail(detailId)
if (!mockDetail) {
Taro.showToast({
title: '未找到打卡点',
icon: 'none',
})
return
}
handleMockDataLoaded(mockDetail)
})
</script>
<script>
export default {
name: 'ScanCheckinDetail',
}
</script>
export default {
navigationBarTitleText: '打卡点',
}
.scan-checkin-list-page {
min-height: 100vh;
padding: 32rpx 24rpx 40rpx;
background: linear-gradient(180deg, #f6f8fb 0%, #eef2f5 100%);
box-sizing: border-box;
position: relative;
}
.scan-checkin-list-header {
margin-bottom: 24rpx;
}
.scan-checkin-list-title {
display: block;
font-size: 40rpx;
font-weight: 600;
color: #1f2937;
line-height: 1.4;
}
.scan-checkin-list-subtitle {
display: block;
margin-top: 8rpx;
font-size: 26rpx;
color: #7b8794;
}
.scan-checkin-list-card {
display: flex;
align-items: center;
gap: 20rpx;
padding: 24rpx 28rpx;
margin-bottom: 20rpx;
border-radius: 32rpx;
background: #ffffff;
box-shadow: 0 12rpx 40rpx rgba(15, 23, 42, 0.08);
}
.scan-checkin-list-leading {
width: 68rpx;
height: 68rpx;
border-radius: 50%;
background: rgba(84, 171, 174, 0.12);
display: flex;
align-items: center;
justify-content: center;
color: #3aa9ad;
flex-shrink: 0;
}
.scan-checkin-list-content {
flex: 1;
min-width: 0;
}
.scan-checkin-list-name {
display: block;
font-size: 32rpx;
line-height: 1.45;
color: #2f3a4a;
word-break: break-all;
}
.scan-checkin-list-note {
display: block;
margin-top: 6rpx;
font-size: 24rpx;
color: #95a0ad;
}
.scan-checkin-list-action {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: #36b5bb;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
flex-shrink: 0;
}
.scan-checkin-list-floating-button {
position: fixed;
right: 24rpx;
bottom: 120rpx;
width: 120rpx;
height: 120rpx;
border-radius: 50%;
background: #88c055;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
box-shadow: 0 12rpx 30rpx rgba(136, 192, 85, 0.35);
z-index: 20;
}
.scan-checkin-list-floating-icon {
width: 40rpx;
height: 40rpx;
}
.scan-checkin-list-floating-text {
margin-top: 10rpx;
font-size: 24rpx;
line-height: 1;
color: #ffffff;
}
<!--
* @Date: 2026-05-19 14:40:21
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2026-05-19 15:03:23
* @FilePath: /lls_program/src/pages/ScanCheckinList/index.vue
* @Description: 文件描述
-->
<template>
<view class="scan-checkin-list-page">
<view class="scan-checkin-list-header">
<text class="scan-checkin-list-subtitle">请选择一个打卡点,进入详情后完成扫码打卡</text>
</view>
<view v-for="point in pointList" :key="point.id" class="scan-checkin-list-card">
<view class="scan-checkin-list-leading">
<IconFont size="30" name="https://cdn.ipadbiz.cn/lls_prog/icon/check_list_logo.png" />
</view>
<view class="scan-checkin-list-content">
<text class="scan-checkin-list-name">{{ point.code }} {{ point.title }}</text>
</view>
<view class="scan-checkin-list-action" @click="goToDetail(point)">
<Scan2 size="20" />
</view>
</view>
<view class="scan-checkin-list-floating-button" @click="handleShowBoothMap">
<IconFont
class="scan-checkin-list-floating-icon"
size="20"
name="https://cdn.ipadbiz.cn/lls_prog/icon/%E5%B1%95%E4%BD%8D%E5%9B%BE@2x.png"
/>
<text class="scan-checkin-list-floating-text">展位图</text>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import Taro, { useLoad } from '@tarojs/taro'
import { IconFont, Scan2 } from '@nutui/icons-vue-taro'
import './index.less'
import { getMockScanCheckinPoints } from '@/utils/mockQrCheckin'
const pointList = ref([])
const activityId = ref('')
const goToDetail = point => {
const params = new URLSearchParams({
activityId: activityId.value,
detailId: point.id,
title: point.title,
})
Taro.navigateTo({
url: `/pages/ScanCheckinDetail/index?${params.toString()}`,
})
}
const handleShowBoothMap = () => {
const params = new URLSearchParams({
activityId: activityId.value,
})
Taro.navigateTo({
url: `/pages/BoothMapGallery/index?${params.toString()}`,
})
}
useLoad(options => {
activityId.value = options.activityId || options.id || ''
pointList.value = getMockScanCheckinPoints(activityId.value)
})
</script>
<script>
export default {
name: 'ScanCheckinList',
}
</script>
/*
* @Date: 2026-05-19 14:40:21
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2026-05-19 15:25:07
* @FilePath: /lls_program/src/utils/mockQrCheckin.js
* @Description: 文件描述
*/
const mockScanCheckinPoints = [
{
id: 'point-01',
code: 'W2D01',
title: '泰康之家经营管理有限公司上海分公司',
description: '在点位现场扫码,完成拍照打卡并推荐好物。',
status: '未打卡',
cover:
'https://cdn.ipadbiz.cn/lls_prog/images/check_detail_img.png?imageMogr2/strip/quality/60',
discountTitle: '打卡点专属优惠',
discountContent:
'<p style="margin: 0 0 20rpx; line-height: 1.8; color: #4b5563; font-size: 30rpx;">朋友圈发圈即可获得申园种子纸,10个赞即送扇子,30个赞送冰箱贴。</p><p style="margin: 0 0 20rpx; line-height: 1.8; color: #4b5563; font-size: 30rpx;">到店或线上购买产品可送优惠券(满100减20,满200减50)。</p>',
},
{
id: 'point-02',
code: 'W2D02',
title: '泰康之家申园体验区',
description: '完成现场扫码后可查看展区亮点,并领取到店权益。',
status: '未打卡',
cover:
'https://cdn.ipadbiz.cn/lls_prog/images/check_detail_img.png?imageMogr2/strip/quality/60',
discountTitle: '打卡点专属优惠',
discountContent:
'<p style="margin: 0 0 20rpx; line-height: 1.8; color: #4b5563; font-size: 30rpx;">完成打卡后可领取体验区纪念贴纸 1 份。</p><p style="margin: 0 0 20rpx; line-height: 1.8; color: #4b5563; font-size: 30rpx;">现场咨询指定产品可额外领取优惠券包。</p>',
},
{
id: 'point-03',
code: 'W2D03',
title: '泰康之家乐龄生活馆',
description: '扫码进入生活馆详情页,完成互动任务后即可点亮本点位。',
status: '已打卡',
cover:
'https://cdn.ipadbiz.cn/lls_prog/images/check_detail_img.png?imageMogr2/strip/quality/60',
discountTitle: '打卡点专属优惠',
discountContent:
'<p style="margin: 0 0 20rpx; line-height: 1.8; color: #4b5563; font-size: 30rpx;">现场互动完成后可领取康养手册 1 份。</p><p style="margin: 0 0 20rpx; line-height: 1.8; color: #4b5563; font-size: 30rpx;">指定商品现场下单可享专属立减优惠。</p>',
},
]
/**
* 获取扫码打卡点列表 mock 数据
* @param {string} activityId
* @returns {Array}
*/
export const getMockScanCheckinPoints = activityId => {
return mockScanCheckinPoints.map(item => ({
...item,
activityId: activityId || '',
}))
}
/**
* 获取扫码打卡点详情 mock 数据
* @param {string} detailId
* @returns {Object|null}
*/
export const getMockScanCheckinDetail = detailId => {
const detail = mockScanCheckinPoints.find(item => item.id === detailId)
if (!detail) {
return null
}
return {
...detail,
guideText: detail.description,
}
}