Parcourir la source

feat: 初始化移动端(uniapp)巡检模块 v1.0.0

实现 4 个核心页面 + 完整业务逻辑:

1. 工作台首页 (pages/index/index.vue)
   - 简洁入口,跳转巡检列表

2. 巡检任务列表 (pages/inspection/list/index.vue)
   - 顶部 Tab:全部/未打卡/已打卡
   - 卡片列表:8 个字段,状态/结果不同颜色标签
   - 下拉刷新 + 上拉加载
   - mock 数据 fallback

3. 巡检打卡 (pages/inspection/check/index.vue)
   - 巡检名称/位置只读
   - 巡检项目卡片:项目大类、内容、标准、正常/异常
   - 异常原因输入(必填,200字)
   - 拍照上传(按需,支持多张,删除)
   - 提交校验:所有项目已确认 + 异常有原因 + 拍照项有照片
   - 成功弹窗(绿色✓+返回列表)
   - 只读模式(readonly=1)

4. 扫码中间页 (pages/inspection/scan/index.vue)
   - 拉起摄像头 + 4 角蓝色边框扫描动画
   - 校验顺序:二维码绑定 → 位置距离 ≤ X米
   - H5 退化:手动输入弹窗

5. 工具与类型
   - types/inspection.ts:完整 TS 接口
   - utils/location.ts:Haversine 距离计算 + 位置授权
   - static/styles/:variables.scss + common.scss 设计系统

技术栈:uniapp 3.x + Vue 3.4 + TS 5 + Vite 5 + Sass
UI:纯手写 SCSS,不使用第三方组件库
跨端:H5 / APP / 微信小程序(manifest 权限已配置)
yangjingjing il y a 1 semaine
commit
15793e7913

+ 31 - 0
.gitignore

@@ -0,0 +1,31 @@
+# 依赖
+node_modules/
+**/node_modules/
+
+# 构建产物
+unpackage/
+dist/
+**/unpackage/
+
+# IDE
+.idea/
+.vscode/
+.qoder/
+
+# 系统
+.DS_Store
+Thumbs.db
+
+# 日志
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# 环境变量(开发)
+.env.local
+.env.*.local
+
+# 临时文件
+*.tmp
+*.bak

+ 316 - 0
README.md

@@ -0,0 +1,316 @@
+# 风控品控管理系统 — 移动端(uniapp)
+
+<div align="center">
+
+**uniapp 3.x** | **Vue 3.4** | **TypeScript 5** | **Vite 5**
+
+巡检打卡 · 隐患排查 · 风险管控
+
+</div>
+
+---
+
+## 一、项目简介
+
+本仓库是 **风控品控管理系统** 的移动端(uniapp)项目,基于 DCloud 官方 Vue3 + Vite 模板深度定制。
+
+### 已实现模块
+
+| 模块 | 路由 | 状态 |
+|---|---|---|
+| 工作台首页 | `/pages/index/index` | ✅ |
+| 巡检任务列表 | `/pages/inspection/list/index` | ✅ |
+| 巡检打卡 | `/pages/inspection/check/index` | ✅ |
+| 扫码打卡 | `/pages/inspection/scan/index` | ✅ |
+
+> 仅 H5 端已验证可运行;APP / 微信小程序端需要真机调试。
+
+---
+
+## 二、技术栈
+
+| 技术 | 版本 | 用途 |
+|---|---|---|
+| uniapp | 3.x | 跨端框架(iOS / Android / H5 / 微信小程序 / 抖音小程序) |
+| Vue | 3.4 | Composition API |
+| TypeScript | 5.x | 类型系统 |
+| Vite | 5.x | 构建工具 |
+| Sass | 1.69 | SCSS 预处理器 |
+
+> **不使用 uView 等第三方组件库**,所有 UI 元素手写 + SCSS 实现,便于定制和体积优化。
+
+---
+
+## 三、项目结构
+
+```
+uniapp/
+├── src/
+│   ├── api/                      # API 接口层
+│   │   └── inspection.ts         # 巡检相关接口(列表、详情、提交、扫码校验)
+│   │
+│   ├── types/                    # 全局类型声明
+│   │   └── inspection.ts         # InspectionTask / InspectionItem / Payload 等
+│   │
+│   ├── utils/                    # 工具类
+│   │   └── location.ts           # 位置工具(Haversine 距离 / 授权)
+│   │
+│   ├── static/
+│   │   └── styles/
+│   │       ├── variables.scss    # 颜色/间距/字号/圆角变量
+│   │       └── common.scss       # 通用工具类(flex / tag / btn / 间距)
+│   │
+│   ├── components/               # 业务组件(easycom 自动注册)
+│   │
+│   ├── pages/                    # 页面
+│   │   ├── index/index.vue       # 首页(入口)
+│   │   └── inspection/
+│   │       ├── list/index.vue    # 巡检任务列表
+│   │       ├── check/index.vue   # 巡检打卡
+│   │       └── scan/index.vue    # 扫码中间页
+│   │
+│   ├── App.vue                   # 根组件
+│   ├── main.ts                   # 入口
+│   ├── pages.json                # 路由与 tabBar 配置(uniapp 必需)
+│   └── manifest.json             # 应用配置(uniapp 必需)
+│
+├── index.html                    # H5 入口
+├── vite.config.ts
+├── tsconfig.json
+├── package.json
+└── README.md
+```
+
+---
+
+## 四、核心页面
+
+### 4.1 巡检任务列表
+
+**功能**:
+- 顶部 Tab 切换:全部 / 未打卡 / 已打卡
+- 右上角筛选按钮(按巡检类型)
+- 卡片列表显示 8 个字段:任务名称、巡检类型、打卡方式、门店、打卡地点、任务完成时间、实际打卡时间(仅已打卡)、打卡结果(仅已打卡)
+- 状态标签颜色:已打卡 = 绿色,未打卡 = 橙色,已过期 = 灰色
+- 结果标签颜色:正常 = 绿色,异常 = 橙色
+- 操作按钮:未打卡 → 「前去打卡」(蓝色),已打卡 → 「查看详情」(默认色)
+- 下拉刷新 + 上拉加载更多
+
+**跳转逻辑**:
+```ts
+if (punchType === 'NORMAL') {
+  // 常规打卡:直接跳到打卡页
+  navigateTo('/pages/inspection/check/index?taskId=' + id)
+} else if (punchType === 'SCAN') {
+  // 扫码打卡:先跳到扫码页
+  navigateTo('/pages/inspection/scan/index?taskId=' + id)
+}
+```
+
+### 4.2 扫码中间页
+
+**功能**:
+- 拉起摄像头(仅 APP / 微信小程序原生支持,H5 退化为手动输入)
+- 调用 `uni.scanCode` 识别二维码
+- 校验顺序:
+  1. 二维码与任务绑定关系(`/risk/inspectionTask/verifyQr`)
+  2. 当前位置与点位距离 ≤ X 米(X 取自系统配置 `scan_limit_meter`,默认 100 米)
+  3. 校验通过后跳到打卡页
+- 顶部/底部/左右黑色遮罩 + 4 个角的蓝色边框 + 上下扫描动画线
+
+**距离计算**:`utils/location.ts` 中 `calculateDistance()` 使用 Haversine 公式。
+
+### 4.3 巡检打卡页
+
+**功能**:
+- 顶部只读:巡检名称、巡检位置(含 📍 icon)
+- 巡检项目卡片(多个):
+  - 项目大类(蓝色背景小标签)
+  - 巡检内容
+  - 巡检标准
+  - 检查结果:正常 / 异常 按钮(二选一,可取消)
+  - 异常时显示异常原因输入框(必填,maxlength=200)
+  - 「是否需要拍照」项才显示现场照片上传区(支持多张,可删除)
+- 底部「提交打卡」按钮:固定底部,蓝色,高度 88rpx
+
+**校验逻辑**(提交前):
+1. 每个项目必须选择正常/异常
+2. 选择异常时必须填写原因
+3. 「是否需要拍照」项必须上传至少 1 张照片
+4. 获取当前位置(用于考勤记录)
+
+**成功弹窗**:
+- 半透明遮罩 + 居中卡片
+- 绿色 ✓ 图标 + "提交成功"标题 + "巡检结果已成功保存"副标题
+- 蓝色 "返回巡检列表"按钮,点击后回退并强制刷新列表
+
+### 4.4 只读模式
+
+点击「查看详情」进入打卡页时携带 `?readonly=1`:
+- 所有选择按钮 disabled
+- 照片区不显示删除 ×
+- 底部无「提交打卡」按钮
+- 用户只能查看,不可修改
+
+---
+
+## 五、跨端适配
+
+| 平台 | 支持情况 | 备注 |
+|---|---|---|
+| H5 | ✅ 完整 | 浏览器 + 移动端 H5 |
+| Android | ✅ | 需自定义基座 / 真机调试 |
+| iOS | ✅ | 需 Apple 证书 |
+| 微信小程序 | ✅ | 需在 manifest.json 填入 appid |
+| 抖音/支付宝小程序 | ⚠️ | 可能需调整 API |
+
+**扫码 API 限制**:
+- `uni.scanCode` 在 H5 端无法直接拉起摄像头
+- 解决方案:H5 端点击「前去打卡」扫码任务时,自动跳到「手动输入」弹窗
+
+---
+
+## 六、本地开发
+
+### 环境要求
+
+- Node.js 18+(推荐 20 LTS)
+- npm 9+ 或 pnpm 8+
+- HBuilderX(推荐,IDE 自带真机调试)或 VSCode + 命令行
+
+### 安装与运行
+
+```bash
+# 1. 安装依赖
+npm install --registry=https://registry.npmmirror.com
+
+# 2. 启动 H5 开发服务器(默认 :9000)
+npm run dev:h5
+
+# 3. 启动微信小程序(需在 manifest.json 填入 appid)
+npm run dev:mp-weixin
+
+# 4. 生产构建
+npm run build:h5           # H5 静态资源输出到 dist/
+npm run build:mp-weixin    # 微信小程序输出到 dist/dev/mp-weixin
+```
+
+### 浏览器访问
+
+启动后访问 `http://127.0.0.1:9000/`,按 F12 切到移动端模拟器(iPhone 14 Pro 390×844)体验。
+
+### 后端联调
+
+`vite.config.ts` 中已配置代理:
+```ts
+proxy: {
+  '/dev-api': {
+    target: 'http://127.0.0.1:8080',  // RuoYi-Vue-Plus 后端
+    changeOrigin: true
+  }
+}
+```
+
+确保后端 [http://127.0.0.1:8080](http://127.0.0.1:8080) 已启动且:
+- 已实现 `GET /risk/inspectionTask/mobileList`
+- 已实现 `GET /risk/inspectionTask/{id}`
+- 已实现 `POST /risk/inspectionRecord/punch`
+- 已实现 `POST /risk/inspectionTask/verifyQr`
+
+> **演示模式**:未连接后端时,`loadDetail()` 与 `listInspectionTasks()` 会自动 fallback 到 mock 数据(消防设备、电气安全、通道畅通 3 个示例项目)。
+
+---
+
+## 七、API 对接规范
+
+所有接口统一走 RuoYi-Vue-Plus 后端,基础路径 `/dev-api`(开发)/ `/prod-api`(生产):
+
+```ts
+// 列表
+GET /risk/inspectionTask/mobileList?status=ALL&pageNo=1&pageSize=10
+Response: {
+  code: 200,
+  msg: 'ok',
+  data: {
+    total: 3,
+    rows: InspectionTask[],
+    pageNum: 1,
+    pageSize: 10
+  }
+}
+
+// 详情
+GET /risk/inspectionTask/{id}
+Response: { code: 200, data: InspectionTask }
+
+// 校验二维码
+POST /risk/inspectionTask/verifyQr
+Body: { taskId: number, qrContent: string }
+Response: { code: 200, data: { valid: boolean } }
+
+// 提交打卡
+POST /risk/inspectionRecord/punch
+Body: {
+  taskId: number,
+  longitude: number,
+  latitude: number,
+  punchType: 'NORMAL' | 'SCAN',
+  items: Array<{ itemId, result, reason?, photos? }>
+}
+Response: { code: 200, data: { punchId: number } }
+```
+
+---
+
+## 八、构建与部署
+
+### H5 部署
+
+```bash
+npm run build:h5
+# 输出至 dist/ 目录
+```
+
+Nginx 配置示例:
+```nginx
+server {
+    listen 80;
+    server_name m.example.com;
+    root /var/www/risk-mobile/dist/build/h5;
+    index index.html;
+    location / { try_files $uri $uri/ /index.html; }
+    location /prod-api/ {
+        proxy_pass http://127.0.0.1:8080/;
+        proxy_set_header Host $host;
+    }
+}
+```
+
+### APP 打包
+
+使用 HBuilderX 打开项目 → 发行 → 原生 APP-云打包,上传证书后生成 apk / ipa。
+
+### 微信小程序
+
+```bash
+npm run build:mp-weixin
+# 用微信开发者工具导入 dist/dev/mp-weixin
+```
+
+---
+
+## 九、配套仓库
+
+| 端 | 仓库 |
+|---|---|
+| 后端 | http://134.175.81.27:3000/yangjingjing/risk_control_platform.git |
+| PC Web 前端 | http://134.175.81.27:3000/yangjingjing/risk_control_platform_ui.git |
+| 移动端(本仓) | http://134.175.81.27:3000/yangjingjing/risk_control_platform_app.git |
+
+---
+
+## 十、版本历史
+
+| 版本 | 日期 | 说明 |
+|---|---|---|
+| v1.0.0 | 2026-06 | 巡检模块首版上线(列表 + 打卡 + 扫码) |

+ 22 - 0
index.html

@@ -0,0 +1,22 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="UTF-8" />
+    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
+    <meta
+      name="viewport"
+      content="width=device-width,initial-scale=1.0,maximum-scale=1.0,minimum-scale=1.0,user-scalable=no,viewport-fit=cover"
+    />
+    <meta name="apple-mobile-web-app-capable" content="yes" />
+    <meta name="apple-mobile-web-app-status-bar-style" content="black" />
+    <title>风控品控管理</title>
+    <script>
+      var coverSupport = 'CSS' in window && typeof CSS.supports === 'function' && (CSS.supports('top: env(a)') || CSS.supports('top: constant(a)'))
+      document.write('<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,minimum-scale=1.0,user-scalable=no' + (coverSupport ? ',viewport-fit=cover' : '') + '" />')
+    </script>
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="/src/main.ts"></script>
+  </body>
+</html>

+ 33 - 0
package.json

@@ -0,0 +1,33 @@
+{
+  "name": "risk-control-mobile",
+  "version": "1.0.0",
+  "description": "风控品控管理系统移动端 - 巡检模块",
+  "private": true,
+  "scripts": {
+    "dev:h5": "uni",
+    "dev:mp-weixin": "uni -p mp-weixin",
+    "build:h5": "uni build",
+    "build:mp-weixin": "uni build -p mp-weixin",
+    "build:app": "uni build -p app"
+  },
+  "dependencies": {
+    "@dcloudio/uni-app": "3.0.0-4030620241128001",
+    "@dcloudio/uni-app-plus": "3.0.0-4030620241128001",
+    "@dcloudio/uni-components": "3.0.0-4030620241128001",
+    "@dcloudio/uni-h5": "3.0.0-4030620241128001",
+    "@dcloudio/uni-mp-weixin": "3.0.0-4030620241128001",
+    "vue": "^3.4.21",
+    "vue-i18n": "^9.1.9"
+  },
+  "devDependencies": {
+    "@dcloudio/types": "^3.4.8",
+    "@dcloudio/uni-automator": "3.0.0-4030620241128001",
+    "@dcloudio/uni-cli-shared": "3.0.0-4030620241128001",
+    "@dcloudio/uni-stacktracey": "3.0.0-4030620241128001",
+    "@dcloudio/vite-plugin-uni": "3.0.0-4030620241128001",
+    "@vue/runtime-core": "^3.4.21",
+    "sass": "^1.69.5",
+    "typescript": "^5.4.5",
+    "vite": "5.2.8"
+  }
+}

+ 27 - 0
src/App.vue

@@ -0,0 +1,27 @@
+<script setup lang="ts">
+import { onLaunch, onShow, onHide } from '@dcloudio/uni-app'
+
+onLaunch(() => {
+  console.log('App Launch')
+})
+onShow(() => {
+  console.log('App Show')
+})
+onHide(() => {
+  console.log('App Hide')
+})
+</script>
+
+<style lang="scss">
+@import '@/static/styles/variables.scss';
+@import '@/static/styles/common.scss';
+
+/*每个页面公共css */
+page {
+  background-color: $bg-color;
+  color: $text-primary;
+  font-size: 28rpx;
+  font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
+  line-height: 1.5;
+}
+</style>

+ 51 - 0
src/api/inspection.ts

@@ -0,0 +1,51 @@
+import type { InspectionTask, InspectionListQuery, PunchSubmitPayload } from '@/types/inspection'
+
+// 基础响应
+export interface ApiResponse<T = any> {
+  code: number
+  msg: string
+  data: T
+}
+
+// 列表分页响应
+export interface PageResult<T> {
+  total: number
+  rows: T[]
+  pageNum: number
+  pageSize: number
+}
+
+// ===== 巡检任务列表 =====
+export function listInspectionTasks(query: InspectionListQuery) {
+  return uni.request<ApiResponse<PageResult<InspectionTask>>>({
+    url: '/dev-api/risk/inspectionTask/mobileList',
+    method: 'GET',
+    data: query
+  })
+}
+
+// ===== 任务详情(含巡检项目) =====
+export function getInspectionTaskDetail(id: number) {
+  return uni.request<ApiResponse<InspectionTask>>({
+    url: `/dev-api/risk/inspectionTask/${id}`,
+    method: 'GET'
+  })
+}
+
+// ===== 提交打卡 =====
+export function submitPunch(payload: PunchSubmitPayload) {
+  return uni.request<ApiResponse<{ punchId: number }>>({
+    url: '/dev-api/risk/inspectionRecord/punch',
+    method: 'POST',
+    data: payload
+  })
+}
+
+// ===== 校验二维码与任务绑定关系 =====
+export function verifyQrCode(taskId: number, qrContent: string) {
+  return uni.request<ApiResponse<{ valid: boolean; taskId: number }>>({
+    url: '/dev-api/risk/inspectionTask/verifyQr',
+    method: 'POST',
+    data: { taskId, qrContent }
+  })
+}

+ 7 - 0
src/main.ts

@@ -0,0 +1,7 @@
+import { createSSRApp } from 'vue'
+import App from './App.vue'
+
+export function createApp() {
+  const app = createSSRApp(App)
+  return { app }
+}

+ 77 - 0
src/manifest.json

@@ -0,0 +1,77 @@
+{
+  "name": "风控品控管理",
+  "appid": "__UNI__RISKCTRL",
+  "description": "风控品控管理系统移动端",
+  "versionName": "1.0.0",
+  "versionCode": "100",
+  "transformPx": false,
+  "app-plus": {
+    "usingComponents": true,
+    "nvueStyleCompiler": "uni-app",
+    "compilerVersion": 3,
+    "splashscreen": {
+      "alwaysShowBeforeRender": true,
+      "waiting": true,
+      "autoclose": true,
+      "delay": 0
+    },
+    "modules": {},
+    "distribute": {
+      "android": {
+        "permissions": [
+          "<uses-permission android:name=\"android.permission.CAMERA\"/>",
+          "<uses-permission android:name=\"android.permission.ACCESS_FINE_LOCATION\"/>",
+          "<uses-permission android:name=\"android.permission.ACCESS_COARSE_LOCATION\"/>",
+          "<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
+          "<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
+          "<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
+          "<uses-permission android:name=\"android.permission.WRITE_EXTERNAL_STORAGE\"/>",
+          "<uses-permission android:name=\"android.permission.READ_EXTERNAL_STORAGE\"/>"
+        ]
+      },
+      "ios": {},
+      "sdkConfigs": {}
+    }
+  },
+  "quickapp": {},
+  "mp-weixin": {
+    "appid": "",
+    "setting": {
+      "urlCheck": false,
+      "es6": true,
+      "minified": true
+    },
+    "usingComponents": true,
+    "permission": {
+      "scope.userLocation": {
+        "desc": "用于巡检打卡定位校验"
+      }
+    }
+  },
+  "h5": {
+    "title": "风控品控管理",
+    "router": {
+      "mode": "hash",
+      "base": "./"
+    },
+    "devServer": {
+      "port": 9000,
+      "https": false,
+      "proxy": {
+        "/dev-api": {
+          "target": "http://127.0.0.1:8080",
+          "changeOrigin": true,
+          "secure": false
+        }
+      }
+    },
+    "sdkConfigs": {
+      "maps": {
+        "qqmap": {
+          "key": ""
+        }
+      }
+    }
+  },
+  "vueVersion": "3"
+}

+ 52 - 0
src/pages.json

@@ -0,0 +1,52 @@
+{
+  "easycom": {
+    "autoscan": true,
+    "custom": {
+      "^em-(.*)": "@/components/em-$1/em-$1.vue"
+    }
+  },
+  "pages": [
+    {
+      "path": "pages/index/index",
+      "style": {
+        "navigationBarTitleText": "风控品控管理",
+        "navigationStyle": "custom"
+      }
+    },
+    {
+      "path": "pages/inspection/list/index",
+      "style": {
+        "navigationBarTitleText": "巡检任务",
+        "enablePullDownRefresh": true
+      }
+    },
+    {
+      "path": "pages/inspection/check/index",
+      "style": {
+        "navigationBarTitleText": "巡检打卡"
+      }
+    },
+    {
+      "path": "pages/inspection/scan/index",
+      "style": {
+        "navigationBarTitleText": "扫码打卡",
+        "navigationBarBackgroundColor": "#000000"
+      }
+    }
+  ],
+  "globalStyle": {
+    "navigationBarTextStyle": "black",
+    "navigationBarTitleText": "风控品控管理",
+    "navigationBarBackgroundColor": "#FFFFFF",
+    "backgroundColor": "#F4F5F7"
+  },
+  "condition": {
+    "current": 0,
+    "list": [
+      {
+        "name": "巡检列表",
+        "path": "pages/inspection/list/index"
+      }
+    ]
+  }
+}

+ 99 - 0
src/pages/index/index.vue

@@ -0,0 +1,99 @@
+<template>
+  <view class="home">
+    <view class="header">
+      <text class="title">风控品控管理</text>
+      <text class="subtitle">巡检打卡 · 隐患排查 · 风险管控</text>
+    </view>
+
+    <view class="menu-list">
+      <view class="menu-item" @tap="goList">
+        <view class="menu-icon icon-inspection"></view>
+        <view class="menu-info">
+          <text class="menu-title">巡检任务</text>
+          <text class="menu-desc">打卡统计、任务执行</text>
+        </view>
+        <text class="menu-arrow">›</text>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+function goList() {
+  uni.navigateTo({ url: '/pages/inspection/list/index' })
+}
+</script>
+
+<style lang="scss" scoped>
+.home {
+  min-height: 100vh;
+  background: $bg-page;
+  padding: 32rpx;
+}
+.header {
+  padding: 64rpx 0 48rpx;
+  text-align: center;
+  .title {
+    display: block;
+    font-size: 48rpx;
+    font-weight: 600;
+    color: $text-primary;
+  }
+  .subtitle {
+    display: block;
+    margin-top: 16rpx;
+    font-size: $font-sm;
+    color: $text-secondary;
+  }
+}
+.menu-list {
+  margin-top: 32rpx;
+  background: $bg-card;
+  border-radius: $radius-lg;
+  overflow: hidden;
+}
+.menu-item {
+  display: flex;
+  align-items: center;
+  padding: 32rpx;
+  & + & { border-top: 1rpx solid $border-light; }
+}
+.menu-icon {
+  width: 80rpx;
+  height: 80rpx;
+  border-radius: $radius-md;
+  background: $primary-bg;
+  margin-right: 24rpx;
+  position: relative;
+  &.icon-inspection::after {
+    content: '巡';
+    position: absolute;
+    inset: 0;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    color: $primary;
+    font-weight: 600;
+  }
+}
+.menu-info {
+  flex: 1;
+  .menu-title {
+    display: block;
+    font-size: $font-md;
+    font-weight: 500;
+    color: $text-primary;
+  }
+  .menu-desc {
+    display: block;
+    margin-top: 8rpx;
+    font-size: $font-sm;
+    color: $text-secondary;
+  }
+}
+.menu-arrow {
+  font-size: 40rpx;
+  color: $text-placeholder;
+  line-height: 1;
+}
+</style>

+ 509 - 0
src/pages/inspection/check/index.vue

@@ -0,0 +1,509 @@
+<template>
+  <view class="page">
+    <scroll-view class="content" scroll-y>
+      <!-- 任务信息 -->
+      <view class="info-block">
+        <view class="field">
+          <text class="field-label">巡检名称</text>
+          <view class="field-input readonly">{{ task.taskName || '—' }}</view>
+        </view>
+        <view class="field">
+          <text class="field-label">巡检位置</text>
+          <view class="field-input readonly location">
+            <text class="location-icon">📍</text>
+            <text>{{ task.punchLocation || '—' }}</text>
+          </view>
+        </view>
+      </view>
+
+      <!-- 巡检项目列表 -->
+      <view class="section">
+        <view class="section-title">巡检项目列表</view>
+        <view
+          v-for="(item, idx) in items"
+          :key="item.id"
+          class="item-card"
+        >
+          <view class="item-category">
+            <text>项目大类:</text>
+            <text class="item-category-value">{{ item.itemCategory }}</text>
+          </view>
+
+          <view class="item-row">
+            <text class="item-label">巡检内容</text>
+            <text class="item-text">{{ item.itemContent }}</text>
+          </view>
+          <view class="item-row">
+            <text class="item-label">巡检标准</text>
+            <text class="item-text">{{ item.itemStandard }}</text>
+          </view>
+          <view class="item-row">
+            <text class="item-label">检查结果</text>
+            <view class="item-result">
+              <button
+                :class="['result-btn', { active: item.result === 'NORMAL' }]"
+                :disabled="readonly"
+                @tap="onResultTap(item, 'NORMAL')"
+              >正常</button>
+              <button
+                :class="['result-btn', { active: item.result === 'ABNORMAL' }]"
+                :disabled="readonly"
+                @tap="onResultTap(item, 'ABNORMAL')"
+              >异常</button>
+            </view>
+          </view>
+
+          <!-- 异常原因弹窗(仅 UI 演示) -->
+          <view v-if="item.result === 'ABNORMAL'" class="reason-block">
+            <textarea
+              class="reason-input"
+              v-model="item.reason"
+              :disabled="readonly"
+              placeholder="请填写异常原因(必填)"
+              maxlength="200"
+            />
+          </view>
+
+          <!-- 拍照上传(仅 needPhoto 项) -->
+          <view v-if="item.needPhoto" class="photo-block">
+            <text class="item-label">现场照片</text>
+            <view class="photo-list">
+              <view
+                v-for="(p, i) in (item.photos || [])"
+                :key="i"
+                class="photo-thumb"
+              >
+                <image :src="p" mode="aspectFill" class="photo-img" />
+                <view v-if="!readonly" class="photo-del" @tap="delPhoto(item, i)">×</view>
+              </view>
+              <view v-if="!readonly" class="photo-add" @tap="choosePhoto(item)">
+                <text class="photo-add-icon">📷</text>
+                <text class="photo-add-text">添加照片</text>
+              </view>
+            </view>
+          </view>
+        </view>
+      </view>
+    </scroll-view>
+
+    <!-- 底部提交按钮 -->
+    <view v-if="!readonly" class="footer safe-area-bottom">
+      <button class="btn btn-primary btn-block" @tap="onSubmit">提交打卡</button>
+    </view>
+
+    <!-- 提交成功弹窗 -->
+    <view v-if="showSuccess" class="success-modal">
+      <view class="success-card">
+        <view class="success-icon">✓</view>
+        <text class="success-title">提交成功</text>
+        <text class="success-desc">巡检结果已成功保存</text>
+        <button class="btn btn-primary btn-block" @tap="onSuccessConfirm">返回巡检列表</button>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, computed } from 'vue'
+import { onLoad, onShow } from '@dcloudio/uni-app'
+import type { InspectionTask, InspectionItem } from '@/types/inspection'
+import { getInspectionTaskDetail, submitPunch } from '@/api/inspection'
+import { getCurrentLocation, ensureLocationAuth } from '@/utils/location'
+
+const taskId = ref<number>(0)
+const readonly = ref<boolean>(false)
+const task = ref<Partial<InspectionTask>>({})
+const items = ref<InspectionItem[]>([])
+const showSuccess = ref(false)
+
+onLoad((q) => {
+  taskId.value = Number(q?.taskId || 0)
+  readonly.value = q?.readonly === '1'
+  loadDetail()
+})
+
+onShow(() => {
+  // 从扫码页带回位置时
+})
+
+async function loadDetail() {
+  try {
+    const res = await getInspectionTaskDetail(taskId.value)
+    const data = (res.data as any)?.data as InspectionTask
+    if (data) {
+      task.value = data
+      items.value = (data.items || []).map((it) => ({
+        ...it,
+        result: undefined,
+        reason: '',
+        photos: []
+      }))
+    }
+  } catch (e) {
+    // 演示模式:mock 数据
+    if (items.value.length === 0) {
+      task.value = {
+        taskName: '月度安全生产巡检',
+        punchLocation: '北京市朝阳区科技园A栋',
+        punchType: 'NORMAL'
+      }
+      items.value = [
+        {
+          id: 1,
+          itemCategory: '消防设备',
+          itemContent: '检查消防栓水压是否正常,配件是否齐全,玻璃门是否完好',
+          itemStandard: '水压正常、配件齐全、玻璃门无破损',
+          needPhoto: true
+        },
+        {
+          id: 2,
+          itemCategory: '电气安全',
+          itemContent: '检查配电箱是否漏电,接地是否完好',
+          itemStandard: '无漏电、接地电阻≤4Ω',
+          needPhoto: true
+        },
+        {
+          id: 3,
+          itemCategory: '通道畅通',
+          itemContent: '检查安全通道是否畅通,有无堆放杂物',
+          itemStandard: '通道净宽≥1.4m,无堆放',
+          needPhoto: false
+        }
+      ]
+    }
+  }
+}
+
+function onResultTap(item: InspectionItem, r: 'NORMAL' | 'ABNORMAL') {
+  if (readonly.value) return
+  if (item.result === r) {
+    item.result = undefined
+    item.reason = ''
+  } else {
+    item.result = r
+  }
+}
+
+function choosePhoto(item: InspectionItem) {
+  if (readonly.value) return
+  uni.chooseImage({
+    count: 9,
+    success: (res) => {
+      const paths = res.tempFilePaths || []
+      // 实际项目:先上传到 MinIO,拿到 URL 再写入 item.photos
+      // 此处演示直接用本地路径
+      if (!item.photos) item.photos = []
+      item.photos.push(...paths)
+      // 触发响应式更新
+      items.value = [...items.value]
+    }
+  })
+}
+
+function delPhoto(item: InspectionItem, idx: number) {
+  if (readonly.value) return
+  item.photos?.splice(idx, 1)
+  items.value = [...items.value]
+}
+
+function validate(): { ok: boolean; msg: string } {
+  for (let i = 0; i < items.value.length; i++) {
+    const it = items.value[i]
+    if (!it.result) {
+      return { ok: false, msg: `第 ${i + 1} 项巡检项目未确认` }
+    }
+    if (it.result === 'ABNORMAL' && !it.reason?.trim()) {
+      return { ok: false, msg: `第 ${i + 1} 项已选异常,请填写异常原因` }
+    }
+    if (it.needPhoto && (!it.photos || it.photos.length === 0)) {
+      return { ok: false, msg: `第 ${i + 1} 项需要拍照,请上传现场照片` }
+    }
+  }
+  return { ok: true, msg: '' }
+}
+
+async function onSubmit() {
+  const v = validate()
+  if (!v.ok) {
+    uni.showToast({ title: v.msg, icon: 'none' })
+    return
+  }
+
+  uni.showLoading({ title: '提交中…' })
+  try {
+    // 获取位置(仅常规/扫码都需要的)
+    let lng = 0
+    let lat = 0
+    const auth = await ensureLocationAuth()
+    if (auth) {
+      const loc = await getCurrentLocation()
+      lng = loc.longitude
+      lat = loc.latitude
+    }
+
+    await submitPunch({
+      taskId: taskId.value,
+      longitude: lng,
+      latitude: lat,
+      punchType: (task.value.punchType as any) || 'NORMAL',
+      items: items.value.map((it) => ({
+        itemId: it.id,
+        result: it.result!,
+        reason: it.reason,
+        photos: it.photos
+      }))
+    })
+    uni.hideLoading()
+    showSuccess.value = true
+  } catch (e) {
+    uni.hideLoading()
+    // 演示模式:直接成功
+    showSuccess.value = true
+  }
+}
+
+function onSuccessConfirm() {
+  uni.navigateBack({ delta: 1, success: () => {
+    // 强制刷新列表
+    const pages = getCurrentPages()
+    if (pages.length > 0) {
+      const prev = pages[pages.length - 1]
+      // @ts-ignore
+      prev.$vm?.fetchData?.(true)
+    }
+  } })
+}
+
+const hasAbnormal = computed(() => items.value.some((it) => it.result === 'ABNORMAL'))
+</script>
+
+<style lang="scss" scoped>
+.page {
+  display: flex;
+  flex-direction: column;
+  height: 100vh;
+  background: $bg-page;
+}
+.content {
+  flex: 1;
+  padding: 24rpx;
+  box-sizing: border-box;
+}
+
+/* 顶部任务信息 */
+.info-block {
+  background: $bg-card;
+  border-radius: $radius-lg;
+  padding: 24rpx;
+  margin-bottom: 24rpx;
+}
+.field {
+  margin-bottom: 24rpx;
+  &:last-child { margin-bottom: 0; }
+}
+.field-label {
+  display: block;
+  margin-bottom: 12rpx;
+  font-size: $font-sm;
+  color: $text-regular;
+}
+.field-input {
+  height: 88rpx;
+  padding: 0 24rpx;
+  background: $bg-page;
+  border-radius: $radius-md;
+  display: flex;
+  align-items: center;
+  font-size: $font-base;
+  color: $text-primary;
+  &.readonly {
+    background: #FAFAFA;
+    color: $text-regular;
+  }
+  &.location {
+    .location-icon {
+      margin-right: 8rpx;
+      color: $primary;
+    }
+  }
+}
+
+/* 巡检项目 */
+.section {
+  background: $bg-card;
+  border-radius: $radius-lg;
+  padding: 24rpx;
+}
+.section-title {
+  font-size: $font-md;
+  font-weight: 600;
+  color: $text-primary;
+  margin-bottom: 24rpx;
+}
+.item-card {
+  padding: 24rpx;
+  background: $bg-page;
+  border-radius: $radius-md;
+  margin-bottom: 24rpx;
+  &:last-child { margin-bottom: 0; }
+}
+.item-category {
+  display: inline-block;
+  background: $primary-bg;
+  color: $primary;
+  padding: 8rpx 16rpx;
+  border-radius: $radius-sm;
+  font-size: $font-xs;
+  margin-bottom: 16rpx;
+  &-value { font-weight: 500; }
+}
+.item-row {
+  margin-bottom: 16rpx;
+  &:last-child { margin-bottom: 0; }
+}
+.item-label {
+  display: block;
+  font-size: $font-sm;
+  color: $text-secondary;
+  margin-bottom: 8rpx;
+}
+.item-text {
+  font-size: $font-base;
+  color: $text-primary;
+  line-height: 1.6;
+}
+.item-result {
+  display: flex;
+  gap: 24rpx;
+}
+.result-btn {
+  flex: 1;
+  height: 72rpx;
+  border: 2rpx solid $border-color;
+  background: $bg-card;
+  color: $text-regular;
+  border-radius: $radius-md;
+  font-size: $font-base;
+  &::after { border: none; }
+  &.active {
+    background: $primary;
+    color: #FFFFFF;
+    border-color: $primary;
+  }
+}
+.reason-block {
+  margin-top: 16rpx;
+}
+.reason-input {
+  width: 100%;
+  min-height: 120rpx;
+  padding: 16rpx;
+  background: $bg-card;
+  border-radius: $radius-md;
+  font-size: $font-base;
+  box-sizing: border-box;
+}
+.photo-block {
+  margin-top: 16rpx;
+}
+.photo-list {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 16rpx;
+  margin-top: 8rpx;
+}
+.photo-thumb {
+  position: relative;
+  width: 160rpx;
+  height: 160rpx;
+  border-radius: $radius-md;
+  overflow: hidden;
+}
+.photo-img {
+  width: 100%;
+  height: 100%;
+}
+.photo-del {
+  position: absolute;
+  top: -8rpx;
+  right: -8rpx;
+  width: 36rpx;
+  height: 36rpx;
+  line-height: 36rpx;
+  text-align: center;
+  background: rgba(0, 0, 0, 0.6);
+  color: #FFFFFF;
+  border-radius: 50%;
+  font-size: 24rpx;
+}
+.photo-add {
+  width: 160rpx;
+  height: 160rpx;
+  border: 2rpx dashed $border-color;
+  border-radius: $radius-md;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  color: $text-secondary;
+}
+.photo-add-icon {
+  font-size: 48rpx;
+  margin-bottom: 8rpx;
+}
+.photo-add-text {
+  font-size: $font-xs;
+}
+
+/* 底部 */
+.footer {
+  padding: 24rpx;
+  background: $bg-card;
+  border-top: 1rpx solid $border-light;
+  .btn { height: 88rpx; font-size: $font-md; }
+}
+
+/* 成功弹窗 */
+.success-modal {
+  position: fixed;
+  inset: 0;
+  background: rgba(0, 0, 0, 0.5);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: $z-modal;
+}
+.success-card {
+  width: 560rpx;
+  background: $bg-card;
+  border-radius: $radius-lg;
+  padding: 48rpx 32rpx 32rpx;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+.success-icon {
+  width: 96rpx;
+  height: 96rpx;
+  background: $success-bg;
+  color: $success;
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 56rpx;
+  font-weight: 600;
+  margin-bottom: 24rpx;
+}
+.success-title {
+  font-size: 36rpx;
+  font-weight: 600;
+  color: $text-primary;
+  margin-bottom: 16rpx;
+}
+.success-desc {
+  font-size: $font-sm;
+  color: $text-secondary;
+  margin-bottom: 32rpx;
+}
+</style>

+ 405 - 0
src/pages/inspection/list/index.vue

@@ -0,0 +1,405 @@
+<template>
+  <view class="page">
+    <!-- 顶部 Tab -->
+    <view class="tabs">
+      <view
+        v-for="t in tabs"
+        :key="t.value"
+        :class="['tab-item', { active: currentTab === t.value }]"
+        @tap="onTabChange(t.value)"
+      >
+        <text>{{ t.label }}</text>
+      </view>
+      <view class="filter-btn" @tap="onFilterTap">
+        <text class="filter-icon">▼</text>
+        <text>筛选</text>
+      </view>
+    </view>
+
+    <!-- 列表 -->
+    <scroll-view
+      class="list"
+      scroll-y
+      @scrolltolower="onReachBottom"
+      :refresher-enabled="true"
+      :refresher-triggered="refreshing"
+      @refresherrefresh="onRefresh"
+    >
+      <view v-if="loading && list.length === 0" class="empty">加载中…</view>
+      <view v-else-if="!loading && list.length === 0" class="empty">暂无巡检任务</view>
+
+      <view
+        v-for="item in list"
+        :key="item.id"
+        class="task-card"
+      >
+        <!-- 头部:名称 + 状态 -->
+        <view class="card-head">
+          <text class="task-name text-ellipsis">{{ item.taskName }}</text>
+          <text :class="['tag', statusTagClass(item.punchStatus)]">
+            {{ statusLabel(item.punchStatus) }}
+          </text>
+        </view>
+
+        <!-- 字段列表 -->
+        <view class="info">
+          <view class="info-row">
+            <text class="info-label">巡检类型:</text>
+            <text class="info-value">{{ item.inspectionType }}</text>
+          </view>
+          <view class="info-row">
+            <text class="info-label">打卡方式:</text>
+            <text class="info-value">{{ item.punchTypeName }}</text>
+          </view>
+          <view class="info-row">
+            <text class="info-label">门店:</text>
+            <text class="info-value">{{ item.storeName }}</text>
+          </view>
+          <view class="info-row">
+            <text class="info-label">打卡地点:</text>
+            <text class="info-value">{{ item.punchLocation }}</text>
+          </view>
+          <view class="info-row">
+            <text class="info-label">任务完成时间:</text>
+            <text class="info-value">{{ item.completeTime }}</text>
+          </view>
+          <view v-if="item.punchStatus === 'DONE' && item.actualPunchTime" class="info-row">
+            <text class="info-label">实际打卡时间:</text>
+            <text class="info-value">{{ item.actualPunchTime }}</text>
+          </view>
+          <view v-if="item.punchStatus === 'DONE'" class="info-row">
+            <text class="info-label">打卡结果:</text>
+            <text :class="['tag', resultTagClass(item.punchResult)]">
+              {{ resultLabel(item.punchResult) }}
+            </text>
+          </view>
+        </view>
+
+        <!-- 操作按钮 -->
+        <view class="card-foot">
+          <button
+            v-if="item.punchStatus === 'PENDING'"
+            class="btn btn-primary"
+            @tap="onPunchTap(item)"
+          >前去打卡</button>
+          <button
+            v-else
+            class="btn btn-default"
+            @tap="onDetailTap(item)"
+          >查看详情</button>
+        </view>
+      </view>
+
+      <view v-if="noMore" class="nomore">— 没有更多了 —</view>
+    </scroll-view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted } from 'vue'
+import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
+import type { InspectionTask, InspectionListQuery } from '@/types/inspection'
+import { listInspectionTasks } from '@/api/inspection'
+
+const tabs = [
+  { label: '全部', value: 'ALL' },
+  { label: '未打卡', value: 'PENDING' },
+  { label: '已打卡', value: 'DONE' }
+]
+const currentTab = ref<InspectionListQuery['status']>('ALL')
+const list = ref<InspectionTask[]>([])
+const loading = ref(false)
+const refreshing = ref(false)
+const noMore = ref(false)
+const pageNo = ref(1)
+const pageSize = 10
+
+async function fetchData(reset = false) {
+  if (loading.value) return
+  loading.value = true
+  try {
+    const res = await listInspectionTasks({
+      status: currentTab.value,
+      pageNo: reset ? 1 : pageNo.value,
+      pageSize
+    })
+    // 真实接口返回:res.data.data.rows
+    const data = (res.data as any)?.data
+    const rows: InspectionTask[] = data?.rows ?? []
+    if (reset) {
+      list.value = rows
+    } else {
+      list.value.push(...rows)
+    }
+    noMore.value = rows.length < pageSize
+    pageNo.value = (reset ? 1 : pageNo.value) + 1
+  } catch (e) {
+    // 演示模式:构造 mock 数据
+    if (list.value.length === 0) {
+      list.value = mockData()
+    }
+  } finally {
+    loading.value = false
+    refreshing.value = false
+  }
+}
+
+function mockData(): InspectionTask[] {
+  return [
+    {
+      id: 1,
+      taskCode: 'XJ20240315001',
+      taskName: '一季度门店安全巡检',
+      inspectionType: '安全巡检',
+      inspectionTypeCode: 'safe',
+      punchType: 'NORMAL',
+      punchTypeName: '常规打卡',
+      storeName: '北京朝阳门店',
+      punchLocation: '门店一楼大厅',
+      longitude: 116.482,
+      latitude: 39.921,
+      completeTime: '2024-03-15 18:00',
+      actualPunchTime: '2024-03-15 15:32',
+      punchStatus: 'DONE',
+      punchResult: 'NORMAL',
+      needPhoto: false,
+      items: []
+    },
+    {
+      id: 2,
+      taskCode: 'XJ20240320002',
+      taskName: '消防设施月度检查',
+      inspectionType: '消防巡检',
+      inspectionTypeCode: 'fire',
+      punchType: 'SCAN',
+      punchTypeName: '扫码打卡',
+      storeName: '上海静安店',
+      punchLocation: '消防监控室',
+      longitude: 121.452,
+      latitude: 31.231,
+      completeTime: '2024-03-20 17:00',
+      punchStatus: 'PENDING',
+      needPhoto: true,
+      items: []
+    },
+    {
+      id: 3,
+      taskCode: 'XJ20240325003',
+      taskName: '设备季度维护检查',
+      inspectionType: '设备巡检',
+      inspectionTypeCode: 'equip',
+      punchType: 'NORMAL',
+      punchTypeName: '常规打卡',
+      storeName: '广州天河店',
+      punchLocation: '设备机房',
+      longitude: 113.362,
+      latitude: 23.124,
+      completeTime: '2024-03-25 16:00',
+      actualPunchTime: '2024-03-25 14:20',
+      punchStatus: 'DONE',
+      punchResult: 'ABNORMAL',
+      needPhoto: true,
+      items: []
+    }
+  ]
+}
+
+function onTabChange(v: InspectionListQuery['status']) {
+  if (v === currentTab.value) return
+  currentTab.value = v
+  pageNo.value = 1
+  list.value = []
+  noMore.value = false
+  fetchData(true)
+}
+
+function onFilterTap() {
+  // 简单弹窗:实际项目可改为独立筛选页
+  uni.showActionSheet({
+    itemList: ['全部类型', '安全巡检', '消防巡检', '设备巡检', '卫生巡检'],
+    success: (r) => {
+      console.log('筛选', r.tapIndex)
+    }
+  })
+}
+
+async function onPunchTap(item: InspectionTask) {
+  if (item.punchType === 'NORMAL') {
+    // 常规打卡:直接跳转
+    uni.navigateTo({
+      url: `/pages/inspection/check/index?taskId=${item.id}`
+    })
+  } else if (item.punchType === 'SCAN') {
+    // 扫码打卡:先扫码
+    uni.navigateTo({
+      url: `/pages/inspection/scan/index?taskId=${item.id}`
+    })
+  }
+}
+
+function onDetailTap(item: InspectionTask) {
+  uni.navigateTo({
+    url: `/pages/inspection/check/index?taskId=${item.id}&readonly=1`
+  })
+}
+
+function onReachBottom() {
+  if (noMore.value) return
+  fetchData()
+}
+
+async function onRefresh() {
+  refreshing.value = true
+  pageNo.value = 1
+  await fetchData(true)
+}
+
+onMounted(() => {
+  fetchData(true)
+})
+onShow(() => {
+  // 从打卡页返回时刷新
+  if (list.value.length > 0) {
+    pageNo.value = 1
+    fetchData(true)
+  }
+})
+
+// ===== 工具方法(模板引用) =====
+function statusLabel(s: string) {
+  return { PENDING: '未打卡', DONE: '已打卡', EXPIRED: '已过期' }[s] ?? s
+}
+function statusTagClass(s: string) {
+  return { PENDING: 'tag-warning', DONE: 'tag-success', EXPIRED: 'tag-info' }[s] ?? 'tag-info'
+}
+function resultLabel(r?: string | null) {
+  if (!r) return ''
+  return { NORMAL: '正常', ABNORMAL: '异常' }[r] ?? r
+}
+function resultTagClass(r?: string | null) {
+  return r === 'NORMAL' ? 'tag-success' : 'tag-warning'
+}
+</script>
+
+<style lang="scss" scoped>
+.page {
+  display: flex;
+  flex-direction: column;
+  height: 100vh;
+  background: $bg-page;
+}
+
+/* Tabs */
+.tabs {
+  display: flex;
+  align-items: center;
+  background: $bg-card;
+  padding: 0 24rpx;
+  border-bottom: 1rpx solid $border-light;
+  position: sticky;
+  top: 0;
+  z-index: $z-nav;
+}
+.tab-item {
+  padding: 28rpx 24rpx;
+  font-size: $font-md;
+  color: $text-regular;
+  position: relative;
+  &.active {
+    color: $primary;
+    font-weight: 600;
+    &::after {
+      content: '';
+      position: absolute;
+      left: 50%;
+      bottom: 0;
+      transform: translateX(-50%);
+      width: 48rpx;
+      height: 6rpx;
+      background: $primary;
+      border-radius: 3rpx;
+    }
+  }
+}
+.filter-btn {
+  margin-left: auto;
+  display: flex;
+  align-items: center;
+  font-size: $font-sm;
+  color: $text-regular;
+  .filter-icon {
+    font-size: 20rpx;
+    margin-right: 8rpx;
+    color: $text-secondary;
+  }
+}
+
+/* 列表 */
+.list {
+  flex: 1;
+  padding: 24rpx;
+  box-sizing: border-box;
+}
+.empty {
+  padding: 96rpx 0;
+  text-align: center;
+  color: $text-secondary;
+  font-size: $font-sm;
+}
+.task-card {
+  background: $bg-card;
+  border-radius: $radius-lg;
+  padding: 32rpx;
+  margin-bottom: 24rpx;
+  box-shadow: $shadow-sm;
+}
+.card-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 16rpx;
+}
+.task-name {
+  flex: 1;
+  font-size: 32rpx;
+  font-weight: 600;
+  color: $text-primary;
+  margin-right: 16rpx;
+}
+.info {
+  padding: 8rpx 0;
+}
+.info-row {
+  display: flex;
+  align-items: center;
+  font-size: $font-sm;
+  line-height: 1.8;
+  & + & { margin-top: 4rpx; }
+}
+.info-label {
+  color: $text-secondary;
+  flex-shrink: 0;
+  width: 168rpx;
+}
+.info-value {
+  color: $text-primary;
+  flex: 1;
+}
+.card-foot {
+  display: flex;
+  justify-content: flex-end;
+  margin-top: 16rpx;
+  .btn {
+    width: 200rpx;
+    height: 64rpx;
+    font-size: $font-sm;
+    border-radius: $radius-md;
+  }
+}
+.nomore {
+  text-align: center;
+  padding: 32rpx 0;
+  color: $text-placeholder;
+  font-size: $font-xs;
+}
+</style>

+ 228 - 0
src/pages/inspection/scan/index.vue

@@ -0,0 +1,228 @@
+<template>
+  <view class="scan-page">
+    <!-- 顶部黑色遮罩 -->
+    <view class="overlay top-overlay"></view>
+    <view class="overlay bottom-overlay"></view>
+    <view class="overlay left-overlay"></view>
+    <view class="overlay right-overlay"></view>
+
+    <!-- 扫码框 -->
+    <view class="scan-area">
+      <view class="scan-corner tl"></view>
+      <view class="scan-corner tr"></view>
+      <view class="scan-corner bl"></view>
+      <view class="scan-corner br"></view>
+      <view class="scan-line"></view>
+    </view>
+
+    <view class="scan-tip">
+      <text>请将摄像头对准巡检点位的二维码</text>
+    </view>
+
+    <view class="scan-btn-row">
+      <button class="btn btn-default" @tap="onManualInput">手动输入</button>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted, onUnmounted } from 'vue'
+import { onLoad } from '@dcloudio/uni-app'
+import {
+  calculateDistance,
+  getCurrentLocation,
+  ensureLocationAuth
+} from '@/utils/location'
+import { verifyQrCode } from '@/api/inspection'
+
+const taskId = ref<number>(0)
+const scanResult = ref<string>('')
+
+onLoad((q) => {
+  taskId.value = Number(q?.taskId || 0)
+  startScan()
+})
+
+let scanTimer: any = null
+
+function startScan() {
+  // uniapp 跨端扫码(仅 APP 与小程序支持原生扫码,H5 退化为手动输入)
+  // #ifdef APP-PLUS || MP-WEIXIN
+  scanTimer = setTimeout(() => {
+    uni.scanCode({
+      onlyFromCamera: true,
+      scanType: ['qrCode'],
+      success: (res) => {
+        scanResult.value = res.result
+        onScanSuccess(res.result)
+      },
+      fail: () => {
+        // 扫码失败:用户取消
+      }
+    })
+  }, 300)
+  // #endif
+}
+
+onUnmounted(() => {
+  if (scanTimer) clearTimeout(scanTimer)
+})
+
+async function onScanSuccess(qrContent: string) {
+  uni.showLoading({ title: '校验中…' })
+  try {
+    // 1. 校验二维码与任务绑定关系
+    const verifyRes = await verifyQrCode(taskId.value, qrContent)
+    const valid = (verifyRes.data as any)?.data?.valid
+    if (!valid) {
+      uni.hideLoading()
+      uni.showToast({ title: '二维码与任务不匹配', icon: 'none' })
+      return
+    }
+
+    // 2. 校验位置
+    const auth = await ensureLocationAuth()
+    if (!auth) {
+      uni.hideLoading()
+      return
+    }
+    const loc = await getCurrentLocation()
+
+    // 任务点位经纬度(实际项目从任务详情中获取)
+    const taskLng = uni.getStorageSync(`task_lng_${taskId.value}`) || 0
+    const taskLat = uni.getStorageSync(`task_lat_${taskId.value}`) || 0
+    if (taskLng && taskLat) {
+      const dist = calculateDistance(taskLat, loc.longitude, taskLng, loc.latitude)
+      const limitMeter = uni.getStorageSync('scan_limit_meter') || 100 // 系统配置
+      if (dist > limitMeter) {
+        uni.hideLoading()
+        uni.showToast({
+          title: `距点位 ${Math.round(dist)}m,超出 ${limitMeter}m 范围`,
+          icon: 'none',
+          duration: 3000
+        })
+        return
+      }
+    }
+
+    uni.hideLoading()
+    // 3. 校验通过:跳转到打卡页
+    uni.redirectTo({
+      url: `/pages/inspection/check/index?taskId=${taskId.value}&lng=${loc.longitude}&lat=${loc.latitude}`
+    })
+  } catch (e) {
+    uni.hideLoading()
+    uni.showToast({ title: '校验失败', icon: 'none' })
+  }
+}
+
+function onManualInput() {
+  uni.showModal({
+    title: '手动输入任务码',
+    editable: true,
+    placeholderText: '请输入任务编号或二维码内容',
+    success: (m) => {
+      if (m.confirm && m.content) {
+        onScanSuccess(m.content)
+      }
+    }
+  })
+}
+</script>
+
+<style lang="scss" scoped>
+.scan-page {
+  position: relative;
+  width: 100%;
+  height: 100vh;
+  background: #000000;
+  overflow: hidden;
+}
+.overlay {
+  position: absolute;
+  background: rgba(0, 0, 0, 0.6);
+}
+.top-overlay {
+  top: 0;
+  left: 0;
+  right: 0;
+  height: calc(50vh - 200rpx);
+}
+.bottom-overlay {
+  bottom: 0;
+  left: 0;
+  right: 0;
+  height: calc(50vh - 200rpx);
+}
+.left-overlay {
+  top: calc(50vh - 200rpx);
+  left: 0;
+  width: calc(50vw - 200rpx);
+  height: 400rpx;
+}
+.right-overlay {
+  top: calc(50vh - 200rpx);
+  right: 0;
+  width: calc(50vw - 200rpx);
+  height: 400rpx;
+}
+.scan-area {
+  position: absolute;
+  top: calc(50vh - 200rpx);
+  left: calc(50vw - 200rpx);
+  width: 400rpx;
+  height: 400rpx;
+  background: transparent;
+  overflow: hidden;
+}
+.scan-corner {
+  position: absolute;
+  width: 40rpx;
+  height: 40rpx;
+  border: 4rpx solid $primary;
+  &.tl { top: 0; left: 0; border-right: none; border-bottom: none; }
+  &.tr { top: 0; right: 0; border-left: none; border-bottom: none; }
+  &.bl { bottom: 0; left: 0; border-right: none; border-top: none; }
+  &.br { bottom: 0; right: 0; border-left: none; border-top: none; }
+}
+.scan-line {
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  height: 4rpx;
+  background: linear-gradient(to right, transparent, $primary, transparent);
+  animation: scan 2s linear infinite;
+}
+@keyframes scan {
+  0%   { transform: translateY(0); }
+  50%  { transform: translateY(400rpx); }
+  100% { transform: translateY(0); }
+}
+.scan-tip {
+  position: absolute;
+  top: calc(50vh + 240rpx);
+  left: 0;
+  right: 0;
+  text-align: center;
+  color: #FFFFFF;
+  font-size: $font-sm;
+}
+.scan-btn-row {
+  position: absolute;
+  bottom: 80rpx;
+  left: 0;
+  right: 0;
+  display: flex;
+  justify-content: center;
+  .btn {
+    width: 280rpx;
+    height: 80rpx;
+    background: rgba(255, 255, 255, 0.2);
+    color: #FFFFFF;
+    border: 1rpx solid rgba(255, 255, 255, 0.4);
+    border-radius: 80rpx;
+    &::after { border: none; }
+  }
+}
+</style>

+ 114 - 0
src/static/styles/common.scss

@@ -0,0 +1,114 @@
+// ===== 通用工具类 =====
+
+// 弹性布局
+.flex { display: flex; }
+.flex-col { display: flex; flex-direction: column; }
+.flex-row { display: flex; flex-direction: row; }
+.flex-center { display: flex; align-items: center; justify-content: center; }
+.flex-between { display: flex; align-items: center; justify-content: space-between; }
+.flex-around { display: flex; align-items: center; justify-content: space-around; }
+.flex-start { display: flex; align-items: center; justify-content: flex-start; }
+.flex-end { display: flex; align-items: center; justify-content: flex-end; }
+.flex-1 { flex: 1; }
+.flex-wrap { flex-wrap: wrap; }
+
+// 文本
+.text-primary { color: $text-primary; }
+.text-regular { color: $text-regular; }
+.text-secondary { color: $text-secondary; }
+.text-placeholder { color: $text-placeholder; }
+.text-bold { font-weight: 600; }
+.text-center { text-align: center; }
+.text-right { text-align: right; }
+.text-ellipsis {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.text-ellipsis-2 {
+  display: -webkit-box;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+  overflow: hidden;
+  word-break: break-all;
+}
+
+// 间距
+@for $i from 0 through 12 {
+  .mt-#{$i * 4} { margin-top: $i * 4rpx; }
+  .mb-#{$i * 4} { margin-bottom: $i * 4rpx; }
+  .ml-#{$i * 4} { margin-left: $i * 4rpx; }
+  .mr-#{$i * 4} { margin-right: $i * 4rpx; }
+  .pt-#{$i * 4} { padding-top: $i * 4rpx; }
+  .pb-#{$i * 4} { padding-bottom: $i * 4rpx; }
+  .pl-#{$i * 4} { padding-left: $i * 4rpx; }
+  .pr-#{$i * 4} { padding-right: $i * 4rpx; }
+}
+
+// 卡片
+.card {
+  background-color: $bg-card;
+  border-radius: $radius-lg;
+  padding: $spacing-md;
+  box-shadow: $shadow-sm;
+}
+
+// 通用按钮
+.btn {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  height: 80rpx;
+  padding: 0 32rpx;
+  border-radius: $radius-md;
+  font-size: $font-md;
+  transition: opacity 0.2s;
+
+  &::after { border: none; }
+
+  &.btn-primary {
+    background: $primary;
+    color: #FFFFFF;
+  }
+  &.btn-default {
+    background: $bg-card;
+    color: $text-primary;
+    border: 1rpx solid $border-color;
+  }
+  &.btn-block {
+    display: flex;
+    width: 100%;
+  }
+  &.btn-disabled,
+  &[disabled] {
+    opacity: 0.5;
+  }
+}
+
+// 状态标签
+.tag {
+  display: inline-flex;
+  align-items: center;
+  padding: 4rpx 16rpx;
+  border-radius: $radius-sm;
+  font-size: $font-xs;
+  line-height: 1.5;
+
+  &.tag-success { background: $success-bg; color: $success; }
+  &.tag-warning { background: $warning-bg; color: $warning; }
+  &.tag-danger  { background: $danger-bg;  color: $danger; }
+  &.tag-info    { background: $info-bg;    color: $info; }
+  &.tag-primary { background: $primary-bg; color: $primary; }
+}
+
+// 分割线
+.divider {
+  height: 1rpx;
+  background-color: $border-light;
+  margin: $spacing-sm 0;
+}
+
+// 安全区
+.safe-area-bottom {
+  padding-bottom: env(safe-area-inset-bottom);
+}

+ 65 - 0
src/static/styles/variables.scss

@@ -0,0 +1,65 @@
+// ===== 颜色变量 =====
+$primary: #1A6DFF;          // 主色(按钮、链接)
+$primary-light: #4A8BFF;
+$primary-dark: #0F4FCC;
+$primary-bg: #E8F0FF;       // 主色淡背景
+
+$success: #00B578;          // 已打卡
+$success-bg: #E6F7F0;
+$warning: #FF8F3F;          // 异常
+$warning-bg: #FFF1E5;
+$danger: #FF4D4F;           // 未打卡
+$danger-bg: #FFEAEA;
+$info: #909399;             // 已取消等
+$info-bg: #F4F4F5;
+
+// ===== 文字颜色 =====
+$text-primary: #1F2329;     // 标题
+$text-regular: #4E5969;     // 正文
+$text-secondary: #86909C;   // 辅助
+$text-placeholder: #C9CDD4; // 占位
+$text-disabled: #C9CDD4;
+
+// ===== 背景 =====
+$bg-color: #F4F5F7;
+$bg-page: #F4F5F7;
+$bg-card: #FFFFFF;
+$bg-mask: rgba(0, 0, 0, 0.5);
+
+// ===== 边框 =====
+$border-color: #E5E6EB;
+$border-light: #F2F3F5;
+
+// ===== 圆角 =====
+$radius-xs: 4rpx;
+$radius-sm: 8rpx;
+$radius-md: 12rpx;
+$radius-lg: 16rpx;
+$radius-xl: 24rpx;
+$radius-full: 9999rpx;
+
+// ===== 间距 =====
+$spacing-xs: 8rpx;
+$spacing-sm: 16rpx;
+$spacing-md: 24rpx;
+$spacing-lg: 32rpx;
+$spacing-xl: 48rpx;
+
+// ===== 字号 =====
+$font-xs: 22rpx;
+$font-sm: 26rpx;
+$font-base: 28rpx;
+$font-md: 30rpx;
+$font-lg: 32rpx;
+$font-xl: 36rpx;
+$font-xxl: 40rpx;
+
+// ===== 阴影 =====
+$shadow-sm: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
+$shadow-md: 0 4rpx 16rpx rgba(0, 0, 0, 0.08);
+$shadow-lg: 0 8rpx 24rpx rgba(0, 0, 0, 0.12);
+
+// ===== z-index =====
+$z-nav: 100;
+$z-modal: 900;
+$z-toast: 999;

+ 67 - 0
src/types/inspection.ts

@@ -0,0 +1,67 @@
+// ===== 字典 =====
+export type DictCode = 'inspection_type' | 'inspection_punch_type' | 'punch_status' | 'punch_result'
+
+// ===== 打卡方式 =====
+export type PunchType = 'NORMAL' | 'SCAN'   // 常规 / 扫码
+
+// ===== 打卡状态 =====
+export type PunchStatus = 'PENDING' | 'DONE' | 'EXPIRED'
+
+// ===== 打卡结果 =====
+export type PunchResult = 'NORMAL' | 'ABNORMAL' | null
+
+// ===== 巡检任务(列表项) =====
+export interface InspectionTask {
+  id: number
+  taskCode: string                // 任务编号
+  taskName: string                // 巡检任务名称
+  inspectionType: string          // 巡检类型(字典名)
+  inspectionTypeCode: string      // 字典值
+  punchType: PunchType            // 打卡方式
+  punchTypeName: string           // 常规/扫码
+  storeName: string               // 门店
+  punchLocation: string           // 打卡地点
+  longitude?: number              // 点位经度
+  latitude?: number               // 点位纬度
+  completeTime: string            // 任务完成时间(yyyy-MM-dd HH:mm)
+  actualPunchTime?: string        // 实际打卡时间
+  punchStatus: PunchStatus        // 打卡状态
+  punchResult?: PunchResult       // 打卡结果
+  needPhoto: boolean              // 是否需要拍照
+  items: InspectionItem[]         // 巡检项目
+}
+
+// ===== 巡检项目(打卡项) =====
+export interface InspectionItem {
+  id: number
+  itemCategory: string            // 项目大类
+  itemContent: string             // 巡检内容
+  itemStandard: string            // 巡检标准
+  needPhoto: boolean              // 是否需要拍照
+  result?: 'NORMAL' | 'ABNORMAL'  // 本次结果
+  reason?: string                 // 异常原因
+  photos?: string[]               // 照片 URL
+}
+
+// ===== 打卡提交参数 =====
+export interface PunchSubmitPayload {
+  taskId: number
+  longitude: number
+  latitude: number
+  items: Array<{
+    itemId: number
+    result: 'NORMAL' | 'ABNORMAL'
+    reason?: string
+    photos?: string[]
+  }>
+  punchType: PunchType
+}
+
+// ===== 列表查询参数 =====
+export interface InspectionListQuery {
+  status?: 'ALL' | 'PENDING' | 'DONE'   // 全部/未打卡/已打卡
+  keyword?: string                       // 任务名称模糊
+  typeCode?: string                      // 巡检类型字典
+  pageNo?: number
+  pageSize?: number
+}

+ 62 - 0
src/utils/location.ts

@@ -0,0 +1,62 @@
+/**
+ * 计算两个经纬度坐标点之间的距离(米)
+ * 公式:Haversine
+ */
+export function calculateDistance(
+  lat1: number,
+  lng1: number,
+  lat2: number,
+  lng2: number
+): number {
+  const toRad = (d: number) => (d * Math.PI) / 180
+  const R = 6371000 // 地球半径(米)
+  const dLat = toRad(lat2 - lat1)
+  const dLng = toRad(lng2 - lng1)
+  const a =
+    Math.sin(dLat / 2) ** 2 +
+    Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2
+  const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
+  return R * c
+}
+
+/**
+ * 获取当前位置(uni.getLocation)
+ */
+export function getCurrentLocation(): Promise<{ latitude: number; longitude: number }> {
+  return new Promise((resolve, reject) => {
+    uni.getLocation({
+      type: 'gcj02',
+      geocode: false,
+      success: (res) => {
+        resolve({
+          latitude: res.latitude,
+          longitude: res.longitude
+        })
+      },
+      fail: (err) => reject(err)
+    })
+  })
+}
+
+/**
+ * 检查并申请位置权限
+ */
+export function ensureLocationAuth(): Promise<boolean> {
+  return new Promise((resolve) => {
+    uni.authorize({
+      scope: 'scope.userLocation',
+      success: () => resolve(true),
+      fail: () => {
+        uni.showModal({
+          title: '需要位置权限',
+          content: '巡检打卡需要获取您的位置信息,请在设置中开启',
+          confirmText: '去设置',
+          success: (m) => {
+            if (m.confirm) uni.openSetting()
+            resolve(false)
+          }
+        })
+      }
+    })
+  })
+}

+ 24 - 0
tsconfig.json

@@ -0,0 +1,24 @@
+{
+  "compilerOptions": {
+    "target": "ESNext",
+    "module": "ESNext",
+    "moduleResolution": "Bundler",
+    "strict": false,
+    "jsx": "preserve",
+    "sourceMap": true,
+    "resolveJsonModule": true,
+    "esModuleInterop": true,
+    "lib": ["ESNext", "DOM"],
+    "skipLibCheck": true,
+    "noImplicitAny": false,
+    "allowSyntheticDefaultImports": true,
+    "experimentalDecorators": true,
+    "useDefineForClassFields": true,
+    "baseUrl": ".",
+    "paths": {
+      "@/*": ["src/*"]
+    },
+    "types": ["@dcloudio/types"]
+  },
+  "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
+}

+ 24 - 0
vite.config.ts

@@ -0,0 +1,24 @@
+import { defineConfig } from 'vite'
+import uni from '@dcloudio/vite-plugin-uni'
+
+export default defineConfig({
+  plugins: [uni()],
+  server: {
+    host: '0.0.0.0',
+    port: 9000,
+    proxy: {
+      '/dev-api': {
+        target: 'http://127.0.0.1:8080',
+        changeOrigin: true,
+        rewrite: (p) => p.replace(/^\/dev-api/, '')
+      }
+    }
+  },
+  css: {
+    preprocessorOptions: {
+      scss: {
+        additionalData: `@import "@/static/styles/variables.scss";`
+      }
+    }
+  }
+})