hookehuyr

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

- 新增扫码打卡列表页、详情页及展位图画廊页,包含完整样式与配置
- 新增扫码打卡点模拟数据工具函数
- 升级活动详情页以适配扫码打卡类型流程
- 将新页面注册至应用配置列表
- 更新地图活动API接口,新增打卡类型参数
- 修复应用配置及API文件的格式问题
- 新增项目开发指导文档AGENTS.md
1 +# AGENTS.md
2 +
3 +本文件为 Codex (Codex.ai/code) 在此代码库中工作时提供指导。
4 +
5 +## 项目概述
6 +
7 +**lls_program** 是一个基于 Taro 4 + Vue 3 + NutUI 的微信小程序,名为"老来赛"。这是一个家庭活动和积分奖励管理系统。
8 +
9 +## 技术栈
10 +
11 +- **框架**: Taro 4.1.7 - 跨平台小程序框架
12 +- **UI**: Vue 3.3 + Composition API (`<script setup>`)
13 +- **UI 组件库**: NutUI Taro 4.3.13 (自动导入,无需手动引入)
14 +- **样式**: TailwindCSS 3.4 + Less (组件特定样式)
15 +- **状态管理**: Pinia 3.0 + taro-plugin-pinia
16 +- **HTTP 请求**: axios-miniprogram 2.7.2
17 +- **构建工具**: Webpack 5
18 +
19 +## 开发命令
20 +
21 +```bash
22 +# 安装依赖
23 +pnpm install
24 +
25 +# 开发(微信小程序)
26 +pnpm run dev:weapp
27 +
28 +# 生产构建
29 +pnpm run build:weapp
30 +
31 +# 其他平台
32 +pnpm run dev:h5 # H5 开发
33 +pnpm run dev:alipay # 支付宝小程序
34 +pnpm run dev:tt # 抖音小程序
35 +```
36 +
37 +## 架构设计
38 +
39 +### 核心目录结构
40 +
41 +```
42 +src/
43 +├── api/ # 按业务领域组织的 API 接口
44 +├── assets/ # 静态资源(图片、样式)
45 +├── components/ # 可复用的 Vue 组件
46 +├── composables/ # Vue 3 组合式函数 (useXxx)
47 +├── pages/ # Taro 页面(每个页面包含 index.vue + index.config.js)
48 +├── stores/ # Pinia 状态管理
49 +├── utils/ # 工具函数
50 +├── app.config.js # Taro 应用配置(页面列表、窗口、权限)
51 +└── app.less # 全局样式
52 +```
53 +
54 +### 路径别名 (config/index.js:30-38)
55 +
56 +```javascript
57 +@/utils src/utils
58 +@/components src/components
59 +@/images src/assets/images
60 +@/assets src/assets
61 +@/composables src/composables
62 +@/api src/api
63 +@/stores src/stores
64 +@/hooks src/hooks
65 +```
66 +
67 +### 设计宽度配置
68 +
69 +- **NutUI 组件**: 375px (自动处理)
70 +- **其他所有内容**: 750px (Taro 标准)
71 +- `config/index.js` 中的 `designWidth` 函数根据文件路径自动切换
72 +
73 +## 核心 API 模式
74 +
75 +### API 响应格式
76 +
77 +所有 API 响应遵循以下结构:
78 +```javascript
79 +{
80 + code: 1, // 1 = 成功,其他值 = 失败
81 + data: {...}, // 响应数据
82 + msg: "message" // 错误/成功消息
83 +}
84 +```
85 +
86 +**始终检查** `res.code === 1`(而不是 `res.code`)来判断成功。
87 +
88 +### 认证机制 (sessionid)
89 +
90 +**关键**: 项目使用 `sessionid` 进行认证(存储在 `wx.storage` 中):
91 +
92 +1. **获取**: `src/utils/request.js:23-30` - `getSessionId()``wx.getStorageSync("sessionid")` 读取
93 +2. **设置**: 在 `miniProgramAuthAPI``loginAPI` 成功后设置
94 +3. **使用**: 请求拦截器 (`request.js:75-78`) 设置 `config.headers.cookie = sessionid`
95 +4. **清除**: 收到 401 响应或用户登出时
96 +
97 +⚠️ **重要**: sessionid **不**由前端用于判断登录状态(后端通过 401 响应来判断)。它只是传递给服务器的凭证。
98 +
99 +### 请求拦截器 (src/utils/request.js:66-80)
100 +
101 +```javascript
102 +service.interceptors.request.use(config => {
103 + // 动态获取 sessionid 并设置到请求头
104 + const sessionid = getSessionId();
105 + if (sessionid) {
106 + config.headers.cookie = sessionid;
107 + }
108 + return config;
109 +})
110 +```
111 +
112 +### API 模块模式 (src/api/)
113 +
114 +每个 API 文件导出调用中央 `fn()` 辅助函数的函数:
115 +
116 +```javascript
117 +// src/api/common.js
118 +export const smsAPI = (params) => fn(fetch.post(Api.SMS, params));
119 +```
120 +
121 +关键 API 模块:
122 +- `common.js` - 短信验证码、上传凭证
123 +- `user.js` - 用户认证和个人信息
124 +- `family.js` - 家庭管理
125 +- `points.js` - 积分/奖励系统
126 +- `photo.js` - 照片/媒体处理
127 +- `organization.js` - 组织管理
128 +
129 +## Taro 小程序限制
130 +
131 +### ❌ 禁止使用 Web API
132 +
133 +```javascript
134 +// 禁止 - 在小程序中会崩溃
135 +window.document.getElementById()
136 +localStorage
137 +window.location.href
138 +fetch()
139 +```
140 +
141 +### ✅ 必须使用 Taro API
142 +
143 +```javascript
144 +// 正确 - 使用 Taro 等价 API
145 +Taro.createSelectorQuery()
146 +Taro.getStorage() / Taro.setStorage()
147 +Taro.navigateTo()
148 +Taro.request()
149 +```
150 +
151 +### 页面生命周期(使用 Taro Hooks)
152 +
153 +```javascript
154 +import { useLoad, useShow, useReady } from '@tarojs/taro'
155 +
156 +useLoad((options) => {
157 + // 页面加载(仅触发一次)- 适合获取路由参数
158 +})
159 +
160 +useShow(() => {
161 + // 页面显示(每次显示都触发)- 适合刷新数据
162 +})
163 +
164 +useReady(() => {
165 + // 页面首次渲染完成
166 +})
167 +```
168 +
169 +### ❌ 页面中避免使用 Vue 生命周期
170 +
171 +```javascript
172 +// 不要使用 - 可能无法正常工作
173 +onMounted(() => { ... })
174 +onUnmounted(() => { ... })
175 +```
176 +
177 +## 组件指南
178 +
179 +### 页面结构
180 +
181 +每个页面目录包含:
182 +- `index.vue` - 页面组件(必须使用 `<script setup>`
183 +- `index.config.js` - 页面特定配置(navigationBarTitleText 等)
184 +- `index.less` - 页面特定样式(scoped)
185 +
186 +### 组件命名规范
187 +
188 +- **页面**: 目录名(如 `pages/Dashboard/`
189 +- **组件**: PascalCase 多单词命名(如 `PointsCollector.vue``FamilyAlbum.vue`
190 +- **API 文件**: camelCase(如 `miniProgramAuthAPI`
191 +
192 +### NutUI 自动导入
193 +
194 +NutUI 组件通过 `unplugin-vue-components` 自动导入。**不要**手动导入:
195 +
196 +```vue
197 +<!-- ✅ 正确 - 自动导入 -->
198 +<template>
199 + <nut-button type="primary">点击</nut-button>
200 +</template>
201 +
202 +<!-- ❌ 错误 - 不要导入 -->
203 +<script setup>
204 +import { Button } from '@nutui/nutui-taro'
205 +</script>
206 +```
207 +
208 +## 样式
209 +
210 +### TailwindCSS + Less 混合使用
211 +
212 +- **TailwindCSS**: 用于布局、间距、颜色、排版(80% 的样式)
213 +- **Less**: 用于组件特定样式、动画、深度选择器(20%)
214 +
215 +### Tailwind 配置
216 +
217 +- **Content**: `./src/**/*.{html,js,ts,jsx,tsx,vue}` (tailwind.config.js:13)
218 +- **Preflight**: 禁用(小程序不需要)
219 +- **rem → rpx**: 由 `weapp-tailwindcss` 插件处理 (rem2rpx: true)
220 +
221 +### 样式指南
222 +
223 +```vue
224 +<style lang="less" scoped>
225 +/* ✅ 组件必须使用 scoped */
226 +.page-container {
227 + padding: 30px;
228 +}
229 +
230 +/* ✅ 使用 Less 处理深度选择器 */
231 +.custom-element :deep(.nut-popup) {
232 + background-color: #fff;
233 +}
234 +</style>
235 +```
236 +
237 +## 状态管理 (Pinia)
238 +
239 +### Store 模式
240 +
241 +```javascript
242 +// src/stores/host.js
243 +import { defineStore } from 'pinia'
244 +
245 +export const hostStore = defineStore('host', {
246 + state: () => ({
247 + id: '',
248 + join_id: ''
249 + }),
250 + actions: {
251 + add(id) {
252 + this.id = id
253 + }
254 + }
255 +})
256 +```
257 +
258 +### 在组件中使用
259 +
260 +```vue
261 +<script setup>
262 +import { hostStore } from '@/stores/host'
263 +
264 +const host = hostStore()
265 +host.add('123')
266 +</script>
267 +```
268 +
269 +## 常用模式
270 +
271 +### 页面导航
272 +
273 +```javascript
274 +import Taro from '@tarojs/taro'
275 +
276 +// 跳转到页面
277 +Taro.navigateTo({
278 + url: '/pages/Detail/index?id=123'
279 +})
280 +
281 +// 重定向(无返回)
282 +Taro.redirectTo({
283 + url: '/pages/Login/index'
284 +})
285 +
286 +// 切换 Tab
287 +Taro.switchTab({
288 + url: '/pages/Dashboard/index'
289 +})
290 +
291 +// 获取路由参数
292 +useLoad((options) => {
293 + const { id } = options
294 +})
295 +```
296 +
297 +### 本地存储
298 +
299 +```javascript
300 +// 异步(推荐)
301 +await Taro.setStorage({ key: 'user', data: userInfo })
302 +const { data } = await Taro.getStorage({ key: 'user' })
303 +
304 +// 同步(谨慎使用)
305 +Taro.setStorageSync('token', 'xxxx')
306 +const token = Taro.getStorageSync('token')
307 +```
308 +
309 +### 提示/弹窗
310 +
311 +```javascript
312 +// Toast 提示
313 +Taro.showToast({
314 + title: '操作成功',
315 + icon: 'success',
316 + duration: 2000
317 +})
318 +
319 +// Modal 弹窗
320 +Taro.showModal({
321 + title: '提示',
322 + content: '确定删除吗?',
323 + success: (res) => {
324 + if (res.confirm) {
325 + // 用户点击了确定
326 + }
327 + }
328 +})
329 +```
330 +
331 +## 页面注册
332 +
333 +页面在 `src/app.config.js` 中注册:
334 +
335 +```javascript
336 +export default {
337 + pages: [
338 + 'pages/Dashboard/index',
339 + 'pages/MyFamily/index',
340 + 'pages/Activities/index',
341 + // ... 更多页面
342 + ]
343 +}
344 +```
345 +
346 +**创建新页面时**: 必须将其添加到此数组中。
347 +
348 +## 构建输出
349 +
350 +- **开发环境**: `dist/` 目录
351 +- **微信开发者工具**: 打开 `dist/` 作为项目根目录
352 +
353 +## 重要文件说明
354 +
355 +### `src/utils/request.js`
356 +
357 +核心 HTTP 客户端,包含:
358 +- SessionID 注入
359 +- 401 响应处理
360 +- 401 时静默授权重定向
361 +- 错误处理
362 +
363 +### `src/utils/authRedirect.js`
364 +
365 +处理小程序登录流程的静默授权。
366 +
367 +### `src/utils/tools.js`
368 +
369 +通用工具函数:
370 +- `formatDate()` - 使用 moment.js 格式化日期
371 +- `wxInfo()` - 平台检测(Android/iOS/微信)
372 +- `hasEllipsis()` - 文本溢出检测
373 +
374 +## 开发注意事项
375 +
376 +1. **始终使用 Taro API** 而非 Web API
377 +2. **检查 `res.code === 1`** 判断 API 成功(不是 `res.code`
378 +3. **NutUI 组件已自动导入** - 不要手动导入
379 +4. **页面中使用 Taro 生命周期钩子**`useLoad``useShow`
380 +5. **SessionID 动态获取** - 每次请求从存储中读取
381 +6. **已配置路径别名** - 使用 `@/components` 代替相对路径
382 +7. **设计宽度双模式**: NutUI 使用 375px,其他使用 750px
383 +
384 +## 平台差异
385 +
386 +项目通过 Taro 支持多平台:
387 +- **微信 (weapp)**: 主要目标平台
388 +- **H5**: Web 浏览器版本
389 +- **支付宝 (alipay)**: 支付宝小程序
390 +- **抖音 (tt)**: 字节跳动小程序
391 +
392 +平台特定代码可使用:
393 +```javascript
394 +if (process.env.TARO_ENV === 'weapp') {
395 + // 微信特定代码
396 +}
397 +```
...@@ -35,6 +35,7 @@ export const checkinAPI = params => fn(fetch.post(Api.Checkin, params)) ...@@ -35,6 +35,7 @@ export const checkinAPI = params => fn(fetch.post(Api.Checkin, params))
35 * data: { 35 * data: {
36 url: string; // 地图网址 36 url: string; // 地图网址
37 id: integer; // 活动ID 37 id: integer; // 活动ID
38 + type: string; // 打卡类型,MAP=地图打卡,QR_CODE=扫码打卡
38 cover: string; // 封面图 39 cover: string; // 封面图
39 begin_date: string; // 开始时间 40 begin_date: string; // 开始时间
40 end_date: string; // 结束时间 41 end_date: string; // 结束时间
......
...@@ -37,6 +37,9 @@ export default { ...@@ -37,6 +37,9 @@ export default {
37 'pages/FamilyRank/index', 37 'pages/FamilyRank/index',
38 'pages/PosterCheckin/index', 38 'pages/PosterCheckin/index',
39 'pages/PosterCheckinDetail/index', 39 'pages/PosterCheckinDetail/index',
40 + 'pages/ScanCheckinList/index',
41 + 'pages/ScanCheckinDetail/index',
42 + 'pages/BoothMapGallery/index',
40 'pages/CheckinList/index', 43 'pages/CheckinList/index',
41 'pages/CheckinMap/index', 44 'pages/CheckinMap/index',
42 'pages/JoinOrganization/index', 45 'pages/JoinOrganization/index',
......
...@@ -158,6 +158,11 @@ import { mockMapActivityDetailAPI } from '@/utils/mockData' ...@@ -158,6 +158,11 @@ import { mockMapActivityDetailAPI } from '@/utils/mockData'
158 // const USE_MOCK_DATA = process.env.NODE_ENV === 'development' 158 // const USE_MOCK_DATA = process.env.NODE_ENV === 'development'
159 const USE_MOCK_DATA = false 159 const USE_MOCK_DATA = false
160 160
161 +const CHECKIN_TYPES = {
162 + MAP: 'MAP',
163 + QR_CODE: 'QR_CODE',
164 +}
165 +
161 // 默认海报图 166 // 默认海报图
162 const defaultPoster = ref( 167 const defaultPoster = ref(
163 'https://cdn.ipadbiz.cn/lls_prog/images/welcome_8.jpg?imageMogr2/strip/quality/60' 168 'https://cdn.ipadbiz.cn/lls_prog/images/welcome_8.jpg?imageMogr2/strip/quality/60'
...@@ -272,6 +277,7 @@ const activityData = ref({ ...@@ -272,6 +277,7 @@ const activityData = ref({
272 '有机会获得商户优惠券', 277 '有机会获得商户优惠券',
273 '参与月度积分排行榜', 278 '参与月度积分排行榜',
274 ], 279 ],
280 + checkinType: CHECKIN_TYPES.MAP,
275 }) 281 })
276 282
277 // 分享配置(动态生成,包含当前页面参数) 283 // 分享配置(动态生成,包含当前页面参数)
...@@ -398,6 +404,8 @@ const getUserLocation = async (skipAuthCheck = false) => { ...@@ -398,6 +404,8 @@ const getUserLocation = async (skipAuthCheck = false) => {
398 * 获取按钮显示文本 404 * 获取按钮显示文本
399 */ 405 */
400 const getButtonText = () => { 406 const getButtonText = () => {
407 + const currentCheckinType = getCurrentCheckinType()
408 +
401 // 如果活动已结束,显示"活动已结束" 409 // 如果活动已结束,显示"活动已结束"
402 if (activityStatus.value.is_ended) { 410 if (activityStatus.value.is_ended) {
403 return '活动已结束' 411 return '活动已结束'
...@@ -413,6 +421,10 @@ const getButtonText = () => { ...@@ -413,6 +421,10 @@ const getButtonText = () => {
413 return '立即参加' 421 return '立即参加'
414 } 422 }
415 423
424 + if (currentCheckinType === CHECKIN_TYPES.QR_CODE) {
425 + return '进入打卡点'
426 + }
427 +
416 // 如果位置获取失败,显示"重新定位" 428 // 如果位置获取失败,显示"重新定位"
417 if (locationError.value) { 429 if (locationError.value) {
418 return '重新定位' 430 return '重新定位'
...@@ -433,6 +445,33 @@ const getButtonText = () => { ...@@ -433,6 +445,33 @@ const getButtonText = () => {
433 } 445 }
434 446
435 /** 447 /**
448 + * 获取当前生效的打卡类型
449 + * 接口未返回 type 时,默认仍走原来的地图打卡流程。
450 + */
451 +const getCurrentCheckinType = () => {
452 + return activityData.value.checkinType || CHECKIN_TYPES.MAP
453 +}
454 +
455 +/**
456 + * 跳转到扫码打卡点列表页
457 + */
458 +const navigateToQrCodeCheckin = async () => {
459 + const params = new URLSearchParams()
460 +
461 + if (activityId.value) {
462 + params.append('activityId', activityId.value)
463 + }
464 +
465 + if (activityData.value.title) {
466 + params.append('title', activityData.value.title)
467 + }
468 +
469 + await Taro.navigateTo({
470 + url: `/pages/ScanCheckinList/index?${params.toString()}`,
471 + })
472 +}
473 +
474 +/**
436 * 检查用户是否加入家庭并处理参加活动按钮点击 475 * 检查用户是否加入家庭并处理参加活动按钮点击
437 */ 476 */
438 const checkFamilyStatusAndJoinActivity = async () => { 477 const checkFamilyStatusAndJoinActivity = async () => {
...@@ -565,6 +604,11 @@ const handleJoinActivity = async () => { ...@@ -565,6 +604,11 @@ const handleJoinActivity = async () => {
565 isJoining.value = true 604 isJoining.value = true
566 605
567 try { 606 try {
607 + if (getCurrentCheckinType() === CHECKIN_TYPES.QR_CODE) {
608 + await navigateToQrCodeCheckin()
609 + return
610 + }
611 +
568 // 检查定位授权状态 612 // 检查定位授权状态
569 const authSetting = await Taro.getSetting() 613 const authSetting = await Taro.getSetting()
570 const hasLocationPermission = authSetting.authSetting['scope.userLocation'] 614 const hasLocationPermission = authSetting.authSetting['scope.userLocation']
...@@ -1005,6 +1049,7 @@ const transformApiDataToActivityData = apiData => { ...@@ -1005,6 +1049,7 @@ const transformApiDataToActivityData = apiData => {
1005 discount_title: apiData.discount_title || '打卡点专属优惠', 1049 discount_title: apiData.discount_title || '打卡点专属优惠',
1006 activityId: apiData.id || '', 1050 activityId: apiData.id || '',
1007 mapUrl: apiData.url || '', // 保留地图 URL 1051 mapUrl: apiData.url || '', // 保留地图 URL
1052 + checkinType: apiData.type || CHECKIN_TYPES.MAP,
1008 } 1053 }
1009 } 1054 }
1010 1055
......
1 +export default {
2 + navigationBarTitleText: '展位图',
3 +}
1 +.booth-map-gallery-page {
2 + min-height: 100vh;
3 + padding: 24rpx;
4 + background: #f4f6f8;
5 + box-sizing: border-box;
6 +}
7 +
8 +.booth-map-gallery-grid {
9 + column-count: 2;
10 + column-gap: 20rpx;
11 +}
12 +
13 +.booth-map-gallery-item {
14 + break-inside: avoid;
15 + margin-bottom: 20rpx;
16 + border-radius: 24rpx;
17 + overflow: hidden;
18 + background: #ffffff;
19 + box-shadow: 0 12rpx 32rpx rgba(15, 23, 42, 0.08);
20 +}
21 +
22 +.booth-map-gallery-image {
23 + width: 100%;
24 + display: block;
25 + background: #e5e7eb;
26 +}
1 +<template>
2 + <view class="booth-map-gallery-page">
3 + <view class="booth-map-gallery-grid">
4 + <view
5 + v-for="(item, index) in imageList"
6 + :key="item.id"
7 + class="booth-map-gallery-item"
8 + @click="previewImage(index)"
9 + >
10 + <image class="booth-map-gallery-image" :src="item.url" :mode="item.mode || 'widthFix'" />
11 + </view>
12 + </view>
13 + </view>
14 +</template>
15 +
16 +<script setup>
17 +import { ref } from 'vue'
18 +import Taro from '@tarojs/taro'
19 +import './index.less'
20 +
21 +const imageList = ref([
22 + {
23 + id: 'booth-01',
24 + url: 'https://cdn.ipadbiz.cn/lls_prog/images/check_detail_img.png?imageMogr2/strip/quality/60',
25 + mode: 'widthFix',
26 + },
27 + {
28 + id: 'booth-02',
29 + url: 'https://cdn.ipadbiz.cn/lls_prog/images/welcome_8.jpg?imageMogr2/strip/quality/60',
30 + mode: 'widthFix',
31 + },
32 + {
33 + id: 'booth-03',
34 + url: 'https://cdn.ipadbiz.cn/lls_prog/images/check_detail_img.png?imageMogr2/strip/quality/60',
35 + mode: 'widthFix',
36 + },
37 +])
38 +
39 +const previewImage = index => {
40 + Taro.previewImage({
41 + current: imageList.value[index].url,
42 + urls: imageList.value.map(item => item.url),
43 + })
44 +}
45 +</script>
46 +
47 +<script>
48 +export default {
49 + name: 'BoothMapGallery',
50 +}
51 +</script>
1 +export default {
2 + navigationBarTitleText: '打卡详情',
3 +}
1 +.scan-checkin-detail-page {
2 + min-height: 100vh;
3 + padding: 0 0 60rpx;
4 + background: #f2f4f7;
5 + box-sizing: border-box;
6 +}
7 +
8 +.scan-checkin-detail-cover {
9 + width: 100%;
10 + height: 520rpx;
11 +}
12 +
13 +.scan-checkin-detail-cover-image {
14 + width: 100%;
15 + height: 100%;
16 + display: block;
17 +}
18 +
19 +.scan-checkin-detail-card {
20 + margin: 28rpx 24rpx 0;
21 + padding: 36rpx 32rpx 40rpx;
22 + border-radius: 32rpx;
23 + background: #ffffff;
24 + box-shadow: 0 16rpx 50rpx rgba(15, 23, 42, 0.08);
25 +}
26 +
27 +.scan-checkin-detail-heading {
28 + display: flex;
29 + align-items: flex-start;
30 + justify-content: space-between;
31 + gap: 24rpx;
32 +}
33 +
34 +.scan-checkin-detail-title {
35 + flex: 1;
36 + font-size: 36rpx;
37 + line-height: 1.35;
38 + color: #1f2937;
39 + font-weight: 600;
40 + word-break: break-all;
41 +}
42 +
43 +.scan-checkin-detail-status {
44 + padding: 10rpx 24rpx;
45 + border-radius: 999rpx;
46 + font-size: 24rpx;
47 + line-height: 1;
48 + white-space: nowrap;
49 +}
50 +
51 +.scan-checkin-detail-status.pending {
52 + color: #f97316;
53 + border: 1rpx solid rgba(249, 115, 22, 0.5);
54 + background: rgba(255, 247, 237, 0.9);
55 +}
56 +
57 +.scan-checkin-detail-status.done {
58 + color: #16a34a;
59 + border: 1rpx solid rgba(22, 163, 74, 0.45);
60 + background: rgba(240, 253, 244, 0.95);
61 +}
62 +
63 +.scan-checkin-detail-subtitle {
64 + display: block;
65 + margin-top: 18rpx;
66 + font-size: 28rpx;
67 + line-height: 1.5;
68 + color: #c3cad5;
69 +}
70 +
71 +.scan-checkin-detail-section {
72 + margin-top: 34rpx;
73 +}
74 +
75 +.scan-checkin-detail-section-header {
76 + padding-bottom: 2rpx;
77 + border-bottom: 1rpx solid #e5e7eb;
78 +}
79 +
80 +.scan-checkin-detail-section-title {
81 + position: relative;
82 + display: inline-block;
83 + padding-bottom: 18rpx;
84 + font-size: 30rpx;
85 + font-weight: 600;
86 + line-height: 1.2;
87 + color: #df7750;
88 +}
89 +
90 +.scan-checkin-detail-section-title::after {
91 + content: '';
92 + position: absolute;
93 + left: 50%;
94 + bottom: 0;
95 + width: 80rpx;
96 + height: 8rpx;
97 + border-radius: 999rpx;
98 + background: #df7750;
99 + transform: translateX(-50%);
100 +}
101 +
102 +.scan-checkin-detail-content {
103 + margin-top: 24rpx;
104 + padding-top: 26rpx;
105 +}
106 +
107 +.scan-checkin-detail-rich-text {
108 + display: block;
109 + color: #4b5563;
110 + font-size: 30rpx;
111 + line-height: 1.8;
112 +}
113 +
114 +.scan-checkin-detail-button-wrap {
115 + display: flex;
116 + justify-content: center;
117 + margin-top: 48rpx;
118 +}
119 +
120 +.scan-checkin-detail-button {
121 + width: 420rpx;
122 + height: 96rpx;
123 + border-radius: 24rpx;
124 + font-size: 38rpx;
125 + font-weight: 600;
126 + box-shadow: 0 18rpx 36rpx rgba(239, 123, 69, 0.28);
127 +}
1 +<template>
2 + <view class="scan-checkin-detail-page">
3 + <view class="scan-checkin-detail-cover">
4 + <image class="scan-checkin-detail-cover-image" :src="detail.cover" mode="aspectFill" />
5 + </view>
6 +
7 + <view class="scan-checkin-detail-card">
8 + <view class="scan-checkin-detail-heading">
9 + <text class="scan-checkin-detail-title">{{ detail.code }} {{ detail.title }}</text>
10 + <view class="scan-checkin-detail-status" :class="detail.isChecked ? 'done' : 'pending'">
11 + {{ detail.isChecked ? '已打卡' : '未打卡' }}
12 + </view>
13 + </view>
14 +
15 + <text class="scan-checkin-detail-subtitle">{{ detail.guideText }}</text>
16 +
17 + <view class="scan-checkin-detail-section">
18 + <view class="scan-checkin-detail-section-header">
19 + <text class="scan-checkin-detail-section-title">{{ detail.discountTitle }}</text>
20 + </view>
21 + <view class="scan-checkin-detail-content">
22 + <rich-text class="scan-checkin-detail-rich-text" :nodes="formattedDiscountContent" />
23 + </view>
24 + </view>
25 + </view>
26 +
27 + <view class="scan-checkin-detail-button-wrap">
28 + <nut-button
29 + type="primary"
30 + class="scan-checkin-detail-button"
31 + color="#DF7750"
32 + :loading="scanSubmitting"
33 + @click="handleScanCheckin"
34 + >
35 + 扫码打卡
36 + </nut-button>
37 + </view>
38 + </view>
39 +</template>
40 +
41 +<script setup>
42 +import { reactive, computed } from 'vue'
43 +import Taro, { useLoad } from '@tarojs/taro'
44 +import './index.less'
45 +import { getMockScanCheckinDetail } from '@/utils/mockQrCheckin'
46 +
47 +const detail = reactive({
48 + id: '',
49 + code: 'W2D01',
50 + title: '泰康之家经营管理有限公司上海分公司',
51 + guideText: '在点位现场扫码打卡并推荐好物',
52 + discountTitle: '打卡点专属优惠',
53 + discountContentRaw: '',
54 + cover: 'https://cdn.ipadbiz.cn/lls_prog/images/check_detail_img.png?imageMogr2/strip/quality/60',
55 + isChecked: false,
56 + lastScanCode: '',
57 + scanSubmitting: false,
58 +})
59 +
60 +const scanSubmitting = computed(() => detail.scanSubmitting === true)
61 +
62 +const formattedDiscountContent = computed(() => {
63 + const content = detail.discountContentRaw
64 +
65 + if (!content) {
66 + return ''
67 + }
68 +
69 + if (Array.isArray(content)) {
70 + return content
71 + .map(
72 + item =>
73 + `<p style="margin: 0 0 20rpx; line-height: 1.8; color: #4b5563; font-size: 30rpx;">${item}</p>`
74 + )
75 + .join('')
76 + }
77 +
78 + let formattedContent = content
79 +
80 + formattedContent = formattedContent.replace(/\n/g, '<br>')
81 + formattedContent = formattedContent.replace(
82 + /<img/g,
83 + '<img style="max-width:100%;height:auto;display:block;border-radius:16rpx;margin:24rpx 0;"'
84 + )
85 +
86 + return formattedContent
87 +})
88 +
89 +const mockSubmitScanCode = async code => {
90 + await new Promise(resolve => {
91 + setTimeout(resolve, 500)
92 + })
93 +
94 + return {
95 + code: 1,
96 + msg: '打卡成功',
97 + data: {
98 + scan_code: code,
99 + },
100 + }
101 +}
102 +
103 +const handleScanCheckin = async () => {
104 + detail.scanSubmitting = true
105 +
106 + try {
107 + const scanResult = await Taro.scanCode({
108 + onlyFromCamera: false,
109 + scanType: ['qrCode', 'barCode'],
110 + })
111 +
112 + const scannedCode = scanResult.result || ''
113 +
114 + if (!scannedCode) {
115 + Taro.showToast({
116 + title: '未识别到扫码结果',
117 + icon: 'none',
118 + })
119 + return
120 + }
121 +
122 + const submitResult = await mockSubmitScanCode(scannedCode)
123 +
124 + if (submitResult.code === 1) {
125 + detail.isChecked = true
126 + detail.lastScanCode = scannedCode
127 +
128 + await Taro.showModal({
129 + title: '模拟提交成功',
130 + content: `扫码结果:${scannedCode}`,
131 + showCancel: false,
132 + confirmText: '知道了',
133 + })
134 + return
135 + }
136 +
137 + Taro.showToast({
138 + title: submitResult.msg || '提交失败',
139 + icon: 'none',
140 + })
141 + } catch (error) {
142 + if (error?.errMsg && error.errMsg.includes('cancel')) {
143 + return
144 + }
145 +
146 + console.error('扫码打卡失败:', error)
147 + Taro.showToast({
148 + title: '扫码失败,请重试',
149 + icon: 'none',
150 + })
151 + } finally {
152 + detail.scanSubmitting = false
153 + }
154 +}
155 +
156 +const handleMockDataLoaded = mockDetail => {
157 + detail.scanSubmitting = false
158 +
159 + Object.assign(detail, {
160 + ...mockDetail,
161 + discountContentRaw: mockDetail.discountContent,
162 + isChecked: mockDetail.status === '已打卡',
163 + })
164 +}
165 +
166 +useLoad(options => {
167 + const detailId = options.detailId || options.id || ''
168 + const mockDetail = getMockScanCheckinDetail(detailId)
169 +
170 + if (!mockDetail) {
171 + Taro.showToast({
172 + title: '未找到打卡点',
173 + icon: 'none',
174 + })
175 + return
176 + }
177 +
178 + handleMockDataLoaded(mockDetail)
179 +})
180 +</script>
181 +
182 +<script>
183 +export default {
184 + name: 'ScanCheckinDetail',
185 +}
186 +</script>
1 +export default {
2 + navigationBarTitleText: '打卡点',
3 +}
1 +.scan-checkin-list-page {
2 + min-height: 100vh;
3 + padding: 32rpx 24rpx 40rpx;
4 + background: linear-gradient(180deg, #f6f8fb 0%, #eef2f5 100%);
5 + box-sizing: border-box;
6 + position: relative;
7 +}
8 +
9 +.scan-checkin-list-header {
10 + margin-bottom: 24rpx;
11 +}
12 +
13 +.scan-checkin-list-title {
14 + display: block;
15 + font-size: 40rpx;
16 + font-weight: 600;
17 + color: #1f2937;
18 + line-height: 1.4;
19 +}
20 +
21 +.scan-checkin-list-subtitle {
22 + display: block;
23 + margin-top: 8rpx;
24 + font-size: 26rpx;
25 + color: #7b8794;
26 +}
27 +
28 +.scan-checkin-list-card {
29 + display: flex;
30 + align-items: center;
31 + gap: 20rpx;
32 + padding: 24rpx 28rpx;
33 + margin-bottom: 20rpx;
34 + border-radius: 32rpx;
35 + background: #ffffff;
36 + box-shadow: 0 12rpx 40rpx rgba(15, 23, 42, 0.08);
37 +}
38 +
39 +.scan-checkin-list-leading {
40 + width: 68rpx;
41 + height: 68rpx;
42 + border-radius: 50%;
43 + background: rgba(84, 171, 174, 0.12);
44 + display: flex;
45 + align-items: center;
46 + justify-content: center;
47 + color: #3aa9ad;
48 + flex-shrink: 0;
49 +}
50 +
51 +.scan-checkin-list-content {
52 + flex: 1;
53 + min-width: 0;
54 +}
55 +
56 +.scan-checkin-list-name {
57 + display: block;
58 + font-size: 32rpx;
59 + line-height: 1.45;
60 + color: #2f3a4a;
61 + word-break: break-all;
62 +}
63 +
64 +.scan-checkin-list-note {
65 + display: block;
66 + margin-top: 6rpx;
67 + font-size: 24rpx;
68 + color: #95a0ad;
69 +}
70 +
71 +.scan-checkin-list-action {
72 + width: 72rpx;
73 + height: 72rpx;
74 + border-radius: 50%;
75 + background: #36b5bb;
76 + display: flex;
77 + align-items: center;
78 + justify-content: center;
79 + color: #ffffff;
80 + flex-shrink: 0;
81 +}
82 +
83 +.scan-checkin-list-floating-button {
84 + position: fixed;
85 + right: 24rpx;
86 + bottom: 120rpx;
87 + width: 120rpx;
88 + height: 120rpx;
89 + border-radius: 50%;
90 + background: #88c055;
91 + display: flex;
92 + flex-direction: column;
93 + align-items: center;
94 + justify-content: center;
95 + box-shadow: 0 12rpx 30rpx rgba(136, 192, 85, 0.35);
96 + z-index: 20;
97 +}
98 +
99 +.scan-checkin-list-floating-icon {
100 + width: 40rpx;
101 + height: 40rpx;
102 +}
103 +
104 +.scan-checkin-list-floating-text {
105 + margin-top: 10rpx;
106 + font-size: 24rpx;
107 + line-height: 1;
108 + color: #ffffff;
109 +}
1 +<!--
2 + * @Date: 2026-05-19 14:40:21
3 + * @LastEditors: hookehuyr hookehuyr@gmail.com
4 + * @LastEditTime: 2026-05-19 15:03:23
5 + * @FilePath: /lls_program/src/pages/ScanCheckinList/index.vue
6 + * @Description: 文件描述
7 +-->
8 +<template>
9 + <view class="scan-checkin-list-page">
10 + <view class="scan-checkin-list-header">
11 + <text class="scan-checkin-list-subtitle">请选择一个打卡点,进入详情后完成扫码打卡</text>
12 + </view>
13 +
14 + <view v-for="point in pointList" :key="point.id" class="scan-checkin-list-card">
15 + <view class="scan-checkin-list-leading">
16 + <IconFont size="30" name="https://cdn.ipadbiz.cn/lls_prog/icon/check_list_logo.png" />
17 + </view>
18 +
19 + <view class="scan-checkin-list-content">
20 + <text class="scan-checkin-list-name">{{ point.code }} {{ point.title }}</text>
21 + </view>
22 +
23 + <view class="scan-checkin-list-action" @click="goToDetail(point)">
24 + <Scan2 size="20" />
25 + </view>
26 + </view>
27 +
28 + <view class="scan-checkin-list-floating-button" @click="handleShowBoothMap">
29 + <IconFont
30 + class="scan-checkin-list-floating-icon"
31 + size="20"
32 + name="https://cdn.ipadbiz.cn/lls_prog/icon/%E5%B1%95%E4%BD%8D%E5%9B%BE@2x.png"
33 + />
34 + <text class="scan-checkin-list-floating-text">展位图</text>
35 + </view>
36 + </view>
37 +</template>
38 +
39 +<script setup>
40 +import { ref } from 'vue'
41 +import Taro, { useLoad } from '@tarojs/taro'
42 +import { IconFont, Scan2 } from '@nutui/icons-vue-taro'
43 +import './index.less'
44 +import { getMockScanCheckinPoints } from '@/utils/mockQrCheckin'
45 +
46 +const pointList = ref([])
47 +const activityId = ref('')
48 +
49 +const goToDetail = point => {
50 + const params = new URLSearchParams({
51 + activityId: activityId.value,
52 + detailId: point.id,
53 + title: point.title,
54 + })
55 +
56 + Taro.navigateTo({
57 + url: `/pages/ScanCheckinDetail/index?${params.toString()}`,
58 + })
59 +}
60 +
61 +const handleShowBoothMap = () => {
62 + const params = new URLSearchParams({
63 + activityId: activityId.value,
64 + })
65 +
66 + Taro.navigateTo({
67 + url: `/pages/BoothMapGallery/index?${params.toString()}`,
68 + })
69 +}
70 +
71 +useLoad(options => {
72 + activityId.value = options.activityId || options.id || ''
73 + pointList.value = getMockScanCheckinPoints(activityId.value)
74 +})
75 +</script>
76 +
77 +<script>
78 +export default {
79 + name: 'ScanCheckinList',
80 +}
81 +</script>
1 +/*
2 + * @Date: 2026-05-19 14:40:21
3 + * @LastEditors: hookehuyr hookehuyr@gmail.com
4 + * @LastEditTime: 2026-05-19 15:25:07
5 + * @FilePath: /lls_program/src/utils/mockQrCheckin.js
6 + * @Description: 文件描述
7 + */
8 +const mockScanCheckinPoints = [
9 + {
10 + id: 'point-01',
11 + code: 'W2D01',
12 + title: '泰康之家经营管理有限公司上海分公司',
13 + description: '在点位现场扫码,完成拍照打卡并推荐好物。',
14 + status: '未打卡',
15 + cover:
16 + 'https://cdn.ipadbiz.cn/lls_prog/images/check_detail_img.png?imageMogr2/strip/quality/60',
17 + discountTitle: '打卡点专属优惠',
18 + discountContent:
19 + '<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>',
20 + },
21 + {
22 + id: 'point-02',
23 + code: 'W2D02',
24 + title: '泰康之家申园体验区',
25 + description: '完成现场扫码后可查看展区亮点,并领取到店权益。',
26 + status: '未打卡',
27 + cover:
28 + 'https://cdn.ipadbiz.cn/lls_prog/images/check_detail_img.png?imageMogr2/strip/quality/60',
29 + discountTitle: '打卡点专属优惠',
30 + discountContent:
31 + '<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>',
32 + },
33 + {
34 + id: 'point-03',
35 + code: 'W2D03',
36 + title: '泰康之家乐龄生活馆',
37 + description: '扫码进入生活馆详情页,完成互动任务后即可点亮本点位。',
38 + status: '已打卡',
39 + cover:
40 + 'https://cdn.ipadbiz.cn/lls_prog/images/check_detail_img.png?imageMogr2/strip/quality/60',
41 + discountTitle: '打卡点专属优惠',
42 + discountContent:
43 + '<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>',
44 + },
45 +]
46 +
47 +/**
48 + * 获取扫码打卡点列表 mock 数据
49 + * @param {string} activityId
50 + * @returns {Array}
51 + */
52 +export const getMockScanCheckinPoints = activityId => {
53 + return mockScanCheckinPoints.map(item => ({
54 + ...item,
55 + activityId: activityId || '',
56 + }))
57 +}
58 +
59 +/**
60 + * 获取扫码打卡点详情 mock 数据
61 + * @param {string} detailId
62 + * @returns {Object|null}
63 + */
64 +export const getMockScanCheckinDetail = detailId => {
65 + const detail = mockScanCheckinPoints.find(item => item.id === detailId)
66 +
67 + if (!detail) {
68 + return null
69 + }
70 +
71 + return {
72 + ...detail,
73 + guideText: detail.description,
74 + }
75 +}