skyline пре 2 месеци
родитељ
комит
412171219a
39 измењених фајлова са 5235 додато и 139 уклоњено
  1. 203 0
      haha-admin-mp/src/api/checkin.ts
  2. 1 0
      haha-admin-mp/src/api/index.ts
  3. 394 0
      haha-admin-mp/src/components/WatermarkCamera.vue
  4. 227 0
      haha-admin-mp/src/mock/checkin.ts
  5. 1 0
      haha-admin-mp/src/mock/index.ts
  6. 18 0
      haha-admin-mp/src/pages.json
  7. 951 0
      haha-admin-mp/src/pages/checkin/index.vue
  8. 1164 0
      haha-admin-mp/src/pages/checkin/records.vue
  9. 38 0
      haha-admin-mp/src/pages/index/index.vue
  10. 329 0
      haha-admin-mp/src/utils/checkin.ts
  11. 7 1
      haha-admin/pom.xml
  12. 222 0
      haha-admin/src/main/java/com/haha/admin/controller/CheckinController.java
  13. 42 0
      haha-admin/src/main/java/com/haha/admin/dto/CheckinQueryDTO.java
  14. 109 0
      haha-admin/src/main/java/com/haha/admin/dto/CheckinSubmitDTO.java
  15. 1 1
      haha-admin/src/main/resources/application.yml
  16. 6 0
      haha-entity/pom.xml
  17. 122 0
      haha-entity/src/main/java/com/haha/entity/CheckinRecord.java
  18. 42 0
      haha-entity/src/main/java/com/haha/entity/dto/CheckinQueryDTO.java
  19. 109 0
      haha-entity/src/main/java/com/haha/entity/dto/CheckinSubmitDTO.java
  20. 42 0
      haha-entity/src/main/java/com/haha/entity/dto/PageQueryDTO.java
  21. 118 0
      haha-mapper/src/main/java/com/haha/mapper/CheckinRecordMapper.java
  22. 10 0
      haha-mapper/src/main/java/com/haha/mapper/DeviceMapper.java
  23. 1 1
      haha-miniapp/pom.xml
  24. 50 0
      haha-miniapp/src/main/java/com/haha/common/constant/OrderConstants.java
  25. 2 2
      haha-miniapp/src/main/java/com/haha/miniapp/MiniappApplication.java
  26. 3 3
      haha-miniapp/src/main/java/com/haha/miniapp/config/HahaSdkConfig.java
  27. 8 26
      haha-miniapp/src/main/java/com/haha/miniapp/controller/AppAnnouncementController.java
  28. 64 0
      haha-miniapp/src/main/java/com/haha/miniapp/controller/CallbackController.java
  29. 4 9
      haha-miniapp/src/main/java/com/haha/miniapp/controller/HealthController.java
  30. 18 73
      haha-miniapp/src/main/java/com/haha/miniapp/controller/OrderController.java
  31. 173 0
      haha-miniapp/src/main/java/com/haha/miniapp/controller/StatusQueryController.java
  32. 1 1
      haha-miniapp/src/main/resources/application.yml
  33. 231 0
      haha-mp/src/api/status.ts
  34. 2 1
      haha-mp/src/pages/orderDetail/orderDetail.vue
  35. 2 1
      haha-mp/src/pages/orders/orders.vue
  36. 115 19
      haha-mp/src/pages/shopping/shopping.vue
  37. 7 1
      haha-mp/src/utils/mock.ts
  38. 83 0
      haha-service/src/main/java/com/haha/service/CheckinRecordService.java
  39. 315 0
      haha-service/src/main/java/com/haha/service/impl/CheckinRecordServiceImpl.java

+ 203 - 0
haha-admin-mp/src/api/checkin.ts

@@ -0,0 +1,203 @@
+/**
+ * 签到功能相关API
+ */
+
+import { USE_MOCK } from '@/utils/config';
+import { mockCheckinRecords, mockCheckinStats, mockTodayCheckin } from '@/mock/checkin';
+
+export enum CheckinType {
+  INVENTORY_TALLY = 'inventory_tally',
+  DELIVERY_REPLENISH = 'delivery_replenish'
+}
+
+export enum CheckinTypeLabel {
+  INVENTORY_TALLY = '理货盘点签到',
+  DELIVERY_REPLENISH = '补货确认签到'
+}
+
+export interface LocationInfo {
+  latitude: number;
+  longitude: number;
+  address: string;
+  accuracy: number;
+}
+
+export interface CheckinPhoto {
+  id: string;
+  path: string;
+  watermarkPath?: string;
+  timestamp: number;
+  location: LocationInfo;
+}
+
+export interface CheckinRecord {
+  id: string;
+  type: CheckinType;
+  typeName: string;
+  userId: number;
+  userName: string;
+  userNo: string;
+  shopId?: number;
+  shopName?: string;
+  deviceId?: number;
+  deviceName?: string;
+  location: LocationInfo;
+  photos: CheckinPhoto[];
+  remark: string;
+  status: 'pending' | 'synced' | 'failed';
+  createdAt: string;
+  syncedAt?: string;
+  offlineId?: string;
+}
+
+export interface CheckinParams {
+  type: CheckinType;
+  shopId?: number;
+  deviceId?: number;
+  location: LocationInfo;
+  photos: CheckinPhoto[];
+  remark?: string;
+  offlineId?: string;
+}
+
+export interface CheckinQueryParams {
+  page?: number;
+  pageSize?: number;
+  type?: CheckinType;
+  userId?: number;
+  shopId?: number;
+  startDate?: string;
+  endDate?: string;
+  status?: string;
+}
+
+export interface CheckinListResponse {
+  list: CheckinRecord[];
+  total: number;
+  page: number;
+  pageSize: number;
+}
+
+export interface CheckinStats {
+  todayCount: number;
+  weekCount: number;
+  monthCount: number;
+  inventoryTallyCount: number;
+  deliveryReplenishCount: number;
+  pendingSyncCount: number;
+}
+
+export interface TodayCheckin {
+  hasInventoryTally: boolean;
+  hasDeliveryReplenish: boolean;
+  inventoryTallyTime?: string;
+  deliveryReplenishTime?: string;
+}
+
+export async function submitCheckin(params: CheckinParams): Promise<CheckinRecord> {
+  if (USE_MOCK) {
+    const record: CheckinRecord = {
+      id: 'checkin_' + Date.now(),
+      type: params.type,
+      typeName: params.type === CheckinType.INVENTORY_TALLY ? '理货盘点签到' : '补货确认签到',
+      userId: 1,
+      userName: '测试用户',
+      userNo: 'EMP001',
+      shopId: params.shopId,
+      shopName: '测试门店',
+      deviceId: params.deviceId,
+      deviceName: params.deviceId ? '测试设备' : undefined,
+      location: params.location,
+      photos: params.photos,
+      remark: params.remark || '',
+      status: 'synced',
+      createdAt: new Date().toISOString(),
+      syncedAt: new Date().toISOString()
+    };
+    return Promise.resolve(record);
+  }
+  
+  const { post } = await import('@/utils/request');
+  return post('/checkin', params);
+}
+
+export async function getCheckinList(params: CheckinQueryParams = {}): Promise<CheckinListResponse> {
+  if (USE_MOCK) {
+    const { page = 1, pageSize = 10, type, startDate, endDate } = params;
+    let list = [...mockCheckinRecords];
+    
+    if (type) {
+      list = list.filter(item => item.type === type);
+    }
+    
+    if (startDate) {
+      list = list.filter(item => item.createdAt >= startDate);
+    }
+    
+    if (endDate) {
+      list = list.filter(item => item.createdAt <= endDate + ' 23:59:59');
+    }
+    
+    const total = list.length;
+    const startIndex = (page - 1) * pageSize;
+    
+    return Promise.resolve({
+      list: list.slice(startIndex, startIndex + pageSize),
+      total,
+      page,
+      pageSize
+    });
+  }
+  
+  const { get } = await import('@/utils/request');
+  return get('/checkin', params);
+}
+
+export async function getCheckinDetail(id: string): Promise<CheckinRecord> {
+  if (USE_MOCK) {
+    const record = mockCheckinRecords.find(item => item.id === id);
+    if (record) {
+      return Promise.resolve(record);
+    }
+    return Promise.reject(new Error('签到记录不存在'));
+  }
+  
+  const { get } = await import('@/utils/request');
+  return get(`/checkin/${id}`);
+}
+
+export async function getCheckinStats(): Promise<CheckinStats> {
+  if (USE_MOCK) {
+    return Promise.resolve(mockCheckinStats);
+  }
+  
+  const { get } = await import('@/utils/request');
+  return get('/checkin/stats');
+}
+
+export async function getTodayCheckin(): Promise<TodayCheckin> {
+  if (USE_MOCK) {
+    return Promise.resolve(mockTodayCheckin);
+  }
+  
+  const { get } = await import('@/utils/request');
+  return get('/checkin/today');
+}
+
+export async function syncOfflineCheckins(records: CheckinRecord[]): Promise<{ success: number; failed: number }> {
+  if (USE_MOCK) {
+    return Promise.resolve({ success: records.length, failed: 0 });
+  }
+  
+  const { post } = await import('@/utils/request');
+  return post('/checkin/sync', { records });
+}
+
+export async function exportCheckinRecords(params: CheckinQueryParams): Promise<string> {
+  if (USE_MOCK) {
+    return Promise.resolve('https://example.com/export/checkin_records.xlsx');
+  }
+  
+  const { get } = await import('@/utils/request');
+  return get('/checkin/export', params);
+}

+ 1 - 0
haha-admin-mp/src/api/index.ts

@@ -9,3 +9,4 @@ export * from './device';
 export * from './shop';
 export * from './product';
 export * from './inventory';
+export * from './checkin';

+ 394 - 0
haha-admin-mp/src/components/WatermarkCamera.vue

@@ -0,0 +1,394 @@
+<template>
+  <view class="watermark-camera">
+    <view class="camera-container" v-if="!previewImage">
+      <view class="camera-placeholder" @click="handleTakePhoto">
+        <view class="camera-icon">
+          <view class="camera-body"></view>
+          <view class="camera-lens"></view>
+        </view>
+        <text class="camera-text">点击拍照</text>
+      </view>
+      
+      <view class="watermark-preview">
+        <view class="watermark-item">
+          <text class="watermark-label">时间:</text>
+          <text class="watermark-value">{{ watermarkConfig.time }}</text>
+        </view>
+        <view class="watermark-item">
+          <text class="watermark-label">位置:</text>
+          <text class="watermark-value">{{ watermarkConfig.location }}</text>
+        </view>
+        <view class="watermark-item">
+          <text class="watermark-label">人员:</text>
+          <text class="watermark-value">{{ watermarkConfig.userName }} ({{ watermarkConfig.userNo }})</text>
+        </view>
+        <view class="watermark-item">
+          <text class="watermark-label">类型:</text>
+          <text class="watermark-value">{{ watermarkConfig.checkinType }}</text>
+        </view>
+      </view>
+    </view>
+    
+    <view class="preview-container" v-else>
+      <image 
+        class="preview-image" 
+        :src="previewImage" 
+        mode="aspectFit"
+        @click="handlePreview"
+      />
+      
+      <view class="watermark-overlay">
+        <view class="watermark-info">
+          <view class="watermark-row">
+            <text class="wm-label">时间:</text>
+            <text class="wm-value">{{ watermarkConfig.time }}</text>
+          </view>
+          <view class="watermark-row">
+            <text class="wm-label">位置:</text>
+            <text class="wm-value">{{ watermarkConfig.location.split('\n')[0] }}</text>
+          </view>
+          <view class="watermark-row">
+            <text class="wm-label">地址:</text>
+            <text class="wm-value">{{ watermarkConfig.location.split('\n')[1] || watermarkConfig.location }}</text>
+          </view>
+          <view class="watermark-row">
+            <text class="wm-label">人员:</text>
+            <text class="wm-value">{{ watermarkConfig.userName }} ({{ watermarkConfig.userNo }})</text>
+          </view>
+          <view class="watermark-row">
+            <text class="wm-label">类型:</text>
+            <text class="wm-value">{{ watermarkConfig.checkinType }}</text>
+          </view>
+        </view>
+      </view>
+      
+      <view class="preview-actions">
+        <view class="action-btn retake" @click="handleRetake">
+          <view class="btn-icon retake-icon"></view>
+          <text class="btn-text">重拍</text>
+        </view>
+        <view class="action-btn confirm" @click="handleConfirm">
+          <view class="btn-icon confirm-icon"></view>
+          <text class="btn-text">确认</text>
+        </view>
+      </view>
+    </view>
+    
+    <canvas 
+      canvas-id="watermarkCanvas" 
+      class="watermark-canvas"
+      :style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"
+    />
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, onMounted, watch } from 'vue';
+import { CheckinType } from '@/api/checkin';
+import type { LocationInfo } from '@/api/checkin';
+import { 
+  createWatermarkConfig, 
+  formatWatermarkTime, 
+  formatWatermarkLocation,
+  getCheckinTypeName 
+} from '@/utils/checkin';
+import { getUserInfo } from '@/utils/auth';
+
+interface Props {
+  checkinType: CheckinType;
+  location: LocationInfo;
+}
+
+const props = defineProps<Props>();
+
+const emit = defineEmits<{
+  (e: 'confirm', photo: { path: string; watermarkPath: string }): void;
+  (e: 'cancel'): void;
+}>();
+
+const previewImage = ref('');
+const canvasWidth = ref(750);
+const canvasHeight = ref(1000);
+
+const watermarkConfig = computed(() => {
+  const userInfo = getUserInfo() || { nickname: '未知用户', userNo: 'N/A' };
+  return {
+    time: formatWatermarkTime(),
+    location: formatWatermarkLocation(props.location),
+    userName: userInfo.nickname || userInfo.username || '未知用户',
+    userNo: userInfo.userNo || 'N/A',
+    checkinType: getCheckinTypeName(props.checkinType)
+  };
+});
+
+onMounted(() => {
+  const systemInfo = uni.getSystemInfoSync();
+  canvasWidth.value = systemInfo.windowWidth * 2;
+  canvasHeight.value = systemInfo.windowWidth * 2 * 1.33;
+});
+
+watch(() => props.location, () => {
+}, { deep: true });
+
+const handleTakePhoto = () => {
+  uni.chooseImage({
+    count: 1,
+    sizeType: ['compressed'],
+    sourceType: ['camera'],
+    success: (res) => {
+      previewImage.value = res.tempFilePaths[0];
+    },
+    fail: (err) => {
+      console.error('拍照失败', err);
+      uni.showToast({
+        title: '拍照失败',
+        icon: 'none'
+      });
+    }
+  });
+};
+
+const handlePreview = () => {
+  if (previewImage.value) {
+    uni.previewImage({
+      urls: [previewImage.value],
+      current: previewImage.value
+    });
+  }
+};
+
+const handleRetake = () => {
+  previewImage.value = '';
+};
+
+const handleConfirm = () => {
+  if (!previewImage.value) {
+    uni.showToast({
+      title: '请先拍照',
+      icon: 'none'
+    });
+    return;
+  }
+  
+  emit('confirm', {
+    path: previewImage.value,
+    watermarkPath: previewImage.value
+  });
+};
+</script>
+
+<style lang="scss" scoped>
+.watermark-camera {
+  width: 100%;
+}
+
+.camera-container {
+  width: 100%;
+}
+
+.camera-placeholder {
+  width: 100%;
+  height: 400rpx;
+  background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
+  border: 2rpx dashed #0ea5e9;
+  border-radius: 20rpx;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  transition: all 0.2s;
+  
+  &:active {
+    transform: scale(0.98);
+    background: linear-gradient(135deg, #e0f2fe 0%, #bae6fd 100%);
+  }
+}
+
+.camera-icon {
+  position: relative;
+  width: 80rpx;
+  height: 60rpx;
+  margin-bottom: 16rpx;
+  
+  .camera-body {
+    width: 80rpx;
+    height: 50rpx;
+    background: #0ea5e9;
+    border-radius: 10rpx;
+    position: absolute;
+    bottom: 0;
+  }
+  
+  .camera-lens {
+    width: 30rpx;
+    height: 30rpx;
+    background: #ffffff;
+    border: 4rpx solid #0ea5e9;
+    border-radius: 50%;
+    position: absolute;
+    bottom: 10rpx;
+    left: 50%;
+    transform: translateX(-50%);
+  }
+}
+
+.camera-text {
+  font-size: 28rpx;
+  color: #0ea5e9;
+  font-weight: 500;
+}
+
+.watermark-preview {
+  margin-top: 24rpx;
+  padding: 24rpx;
+  background: #f8fafc;
+  border-radius: 16rpx;
+}
+
+.watermark-item {
+  display: flex;
+  align-items: flex-start;
+  margin-bottom: 12rpx;
+  
+  &:last-child {
+    margin-bottom: 0;
+  }
+}
+
+.watermark-label {
+  font-size: 24rpx;
+  color: #64748b;
+  width: 80rpx;
+  flex-shrink: 0;
+}
+
+.watermark-value {
+  font-size: 24rpx;
+  color: #1e293b;
+  flex: 1;
+  word-break: break-all;
+}
+
+.preview-container {
+  width: 100%;
+  position: relative;
+}
+
+.preview-image {
+  width: 100%;
+  height: 500rpx;
+  border-radius: 16rpx;
+  background: #f1f5f9;
+}
+
+.watermark-overlay {
+  position: absolute;
+  bottom: 120rpx;
+  left: 0;
+  right: 0;
+  padding: 0 24rpx;
+  pointer-events: none;
+}
+
+.watermark-info {
+  background: rgba(0, 0, 0, 0.7);
+  border-radius: 12rpx;
+  padding: 20rpx;
+}
+
+.watermark-row {
+  display: flex;
+  align-items: flex-start;
+  margin-bottom: 8rpx;
+  
+  &:last-child {
+    margin-bottom: 0;
+  }
+}
+
+.wm-label {
+  font-size: 22rpx;
+  color: #94a3b8;
+  width: 70rpx;
+  flex-shrink: 0;
+}
+
+.wm-value {
+  font-size: 22rpx;
+  color: #ffffff;
+  flex: 1;
+}
+
+.preview-actions {
+  display: flex;
+  justify-content: center;
+  gap: 40rpx;
+  margin-top: 32rpx;
+}
+
+.action-btn {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  
+  &:active {
+    opacity: 0.7;
+  }
+}
+
+.btn-icon {
+  width: 80rpx;
+  height: 80rpx;
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-bottom: 12rpx;
+  
+  &.retake-icon {
+    background: #f1f5f9;
+    position: relative;
+    
+    &::before {
+      content: '';
+      width: 24rpx;
+      height: 24rpx;
+      border-left: 4rpx solid #64748b;
+      border-bottom: 4rpx solid #64748b;
+      transform: rotate(45deg);
+      margin-left: 8rpx;
+    }
+  }
+  
+  &.confirm-icon {
+    background: #10b981;
+    position: relative;
+    
+    &::before {
+      content: '';
+      width: 20rpx;
+      height: 12rpx;
+      border-left: 4rpx solid #ffffff;
+      border-bottom: 4rpx solid #ffffff;
+      transform: rotate(-45deg);
+      margin-top: -8rpx;
+    }
+  }
+}
+
+.btn-text {
+  font-size: 24rpx;
+  color: #475569;
+  
+  .confirm & {
+    color: #10b981;
+    font-weight: 500;
+  }
+}
+
+.watermark-canvas {
+  position: fixed;
+  left: -9999rpx;
+  top: -9999rpx;
+}
+</style>

+ 227 - 0
haha-admin-mp/src/mock/checkin.ts

@@ -0,0 +1,227 @@
+/**
+ * Mock数据 - 签到功能
+ */
+
+import { CheckinType } from '@/api/checkin';
+
+interface LocationInfo {
+  latitude: number;
+  longitude: number;
+  address: string;
+  accuracy: number;
+}
+
+interface CheckinPhoto {
+  id: string;
+  path: string;
+  watermarkPath?: string;
+  timestamp: number;
+  location: LocationInfo;
+}
+
+interface CheckinRecord {
+  id: string;
+  type: CheckinType;
+  typeName: string;
+  userId: number;
+  userName: string;
+  userNo: string;
+  shopId?: number;
+  shopName?: string;
+  deviceId?: number;
+  deviceName?: string;
+  location: LocationInfo;
+  photos: CheckinPhoto[];
+  remark: string;
+  status: 'pending' | 'synced' | 'failed';
+  createdAt: string;
+  syncedAt?: string;
+  offlineId?: string;
+}
+
+interface CheckinStats {
+  todayCount: number;
+  weekCount: number;
+  monthCount: number;
+  inventoryTallyCount: number;
+  deliveryReplenishCount: number;
+  pendingSyncCount: number;
+}
+
+interface TodayCheckin {
+  hasInventoryTally: boolean;
+  hasDeliveryReplenish: boolean;
+  inventoryTallyTime?: string;
+  deliveryReplenishTime?: string;
+}
+
+const createMockLocation = (): LocationInfo => ({
+  latitude: 31.2304 + Math.random() * 0.01,
+  longitude: 121.4737 + Math.random() * 0.01,
+  address: '上海市浦东新区张江高科技园区',
+  accuracy: 65
+});
+
+const createMockPhoto = (index: number): CheckinPhoto => ({
+  id: `photo_${Date.now()}_${index}`,
+  path: '/static/mock/photo.jpg',
+  watermarkPath: '/static/mock/photo_watermark.jpg',
+  timestamp: Date.now() - index * 60000,
+  location: createMockLocation()
+});
+
+export const mockCheckinRecords: CheckinRecord[] = [
+  {
+    id: 'checkin_001',
+    type: CheckinType.INVENTORY_TALLY,
+    typeName: '理货盘点签到',
+    userId: 1,
+    userName: '张三',
+    userNo: 'EMP001',
+    shopId: 1,
+    shopName: '张江科技园店',
+    deviceId: 101,
+    deviceName: '1号充电桩',
+    location: createMockLocation(),
+    photos: [createMockPhoto(0), createMockPhoto(1)],
+    remark: '库存盘点正常,无异常',
+    status: 'synced',
+    createdAt: '2026-02-28 09:30:00',
+    syncedAt: '2026-02-28 09:30:05'
+  },
+  {
+    id: 'checkin_002',
+    type: CheckinType.DELIVERY_REPLENISH,
+    typeName: '补货确认签到',
+    userId: 2,
+    userName: '李四',
+    userNo: 'EMP002',
+    shopId: 2,
+    shopName: '陆家嘴金融城店',
+    deviceId: 201,
+    deviceName: '2号充电桩',
+    location: createMockLocation(),
+    photos: [createMockPhoto(0)],
+    remark: '补货完成,已补充矿泉水10瓶',
+    status: 'synced',
+    createdAt: '2026-02-28 10:15:00',
+    syncedAt: '2026-02-28 10:15:03'
+  },
+  {
+    id: 'checkin_003',
+    type: CheckinType.INVENTORY_TALLY,
+    typeName: '理货盘点签到',
+    userId: 1,
+    userName: '张三',
+    userNo: 'EMP001',
+    shopId: 3,
+    shopName: '金桥开发区店',
+    location: createMockLocation(),
+    photos: [createMockPhoto(0), createMockPhoto(1), createMockPhoto(2)],
+    remark: '发现部分商品临期,需要处理',
+    status: 'synced',
+    createdAt: '2026-02-27 14:20:00',
+    syncedAt: '2026-02-27 14:20:08'
+  },
+  {
+    id: 'checkin_004',
+    type: CheckinType.DELIVERY_REPLENISH,
+    typeName: '补货确认签到',
+    userId: 3,
+    userName: '王五',
+    userNo: 'EMP003',
+    shopId: 1,
+    shopName: '张江科技园店',
+    deviceId: 102,
+    deviceName: '2号充电桩',
+    location: createMockLocation(),
+    photos: [createMockPhoto(0)],
+    remark: '',
+    status: 'synced',
+    createdAt: '2026-02-27 16:45:00',
+    syncedAt: '2026-02-27 16:45:02'
+  },
+  {
+    id: 'checkin_005',
+    type: CheckinType.INVENTORY_TALLY,
+    typeName: '理货盘点签到',
+    userId: 2,
+    userName: '李四',
+    userNo: 'EMP002',
+    shopId: 2,
+    shopName: '陆家嘴金融城店',
+    location: createMockLocation(),
+    photos: [createMockPhoto(0)],
+    remark: '盘点完毕,库存充足',
+    status: 'pending',
+    createdAt: '2026-02-26 11:00:00',
+    offlineId: 'offline_001'
+  },
+  {
+    id: 'checkin_006',
+    type: CheckinType.DELIVERY_REPLENISH,
+    typeName: '补货确认签到',
+    userId: 1,
+    userName: '张三',
+    userNo: 'EMP001',
+    shopId: 4,
+    shopName: '世纪公园店',
+    deviceId: 301,
+    deviceName: '1号充电桩',
+    location: createMockLocation(),
+    photos: [createMockPhoto(0), createMockPhoto(1)],
+    remark: '补货完成',
+    status: 'synced',
+    createdAt: '2026-02-25 09:00:00',
+    syncedAt: '2026-02-25 09:00:04'
+  },
+  {
+    id: 'checkin_007',
+    type: CheckinType.INVENTORY_TALLY,
+    typeName: '理货盘点签到',
+    userId: 3,
+    userName: '王五',
+    userNo: 'EMP003',
+    shopId: 5,
+    shopName: '世纪大道店',
+    location: createMockLocation(),
+    photos: [createMockPhoto(0)],
+    remark: '正常',
+    status: 'synced',
+    createdAt: '2026-02-24 15:30:00',
+    syncedAt: '2026-02-24 15:30:02'
+  },
+  {
+    id: 'checkin_008',
+    type: CheckinType.DELIVERY_REPLENISH,
+    typeName: '补货确认签到',
+    userId: 2,
+    userName: '李四',
+    userNo: 'EMP002',
+    shopId: 3,
+    shopName: '金桥开发区店',
+    deviceId: 302,
+    deviceName: '2号充电桩',
+    location: createMockLocation(),
+    photos: [createMockPhoto(0)],
+    remark: '已补充零食和饮料',
+    status: 'synced',
+    createdAt: '2026-02-23 10:00:00',
+    syncedAt: '2026-02-23 10:00:03'
+  }
+];
+
+export const mockCheckinStats: CheckinStats = {
+  todayCount: 2,
+  weekCount: 8,
+  monthCount: 35,
+  inventoryTallyCount: 18,
+  deliveryReplenishCount: 17,
+  pendingSyncCount: 1
+};
+
+export const mockTodayCheckin: TodayCheckin = {
+  hasInventoryTally: true,
+  hasDeliveryReplenish: false,
+  inventoryTallyTime: '2026-02-28 09:30:00'
+};

+ 1 - 0
haha-admin-mp/src/mock/index.ts

@@ -9,3 +9,4 @@ export * from './shop';
 export * from './product';
 export * from './inventory';
 export * from './user';
+export * from './checkin';

+ 18 - 0
haha-admin-mp/src/pages.json

@@ -98,6 +98,24 @@
 				"navigationBarTextStyle": "black",
 				"navigationStyle": "custom"
 			}
+		},
+		{
+			"path": "pages/checkin/index",
+			"style": {
+				"navigationBarTitleText": "签到打卡",
+				"navigationBarBackgroundColor": "#ffffff",
+				"navigationBarTextStyle": "black",
+				"navigationStyle": "custom"
+			}
+		},
+		{
+			"path": "pages/checkin/records",
+			"style": {
+				"navigationBarTitleText": "签到记录",
+				"navigationBarBackgroundColor": "#ffffff",
+				"navigationBarTextStyle": "black",
+				"navigationStyle": "custom"
+			}
 		}
 	],
 	"globalStyle": {

+ 951 - 0
haha-admin-mp/src/pages/checkin/index.vue

@@ -0,0 +1,951 @@
+<template>
+  <view class="page">
+    <NavBar title="签到打卡" :showBack="true" />
+    
+    <view class="content">
+      <view class="location-card">
+        <view class="location-header">
+          <view class="location-icon"></view>
+          <text class="location-title">当前位置</text>
+          <view class="refresh-btn" @click="refreshLocation">
+            <view class="refresh-icon" :class="{ rotating: isLocating }"></view>
+          </view>
+        </view>
+        <view class="location-content" v-if="currentLocation">
+          <view class="location-row">
+            <text class="location-label">经纬度:</text>
+            <text class="location-value">{{ currentLocation.latitude.toFixed(6) }}, {{ currentLocation.longitude.toFixed(6) }}</text>
+          </view>
+          <view class="location-row">
+            <text class="location-label">地址:</text>
+            <text class="location-value">{{ currentLocation.address }}</text>
+          </view>
+          <view class="location-row">
+            <text class="location-label">精度:</text>
+            <text class="location-value">{{ currentLocation.accuracy }}米</text>
+          </view>
+        </view>
+        <view class="location-loading" v-else>
+          <text class="loading-text">{{ locationError || '正在获取位置...' }}</text>
+        </view>
+      </view>
+      
+      <view class="type-section">
+        <view class="section-header">
+          <text class="section-title">签到类型</text>
+        </view>
+        <view class="type-list">
+          <view 
+            class="type-item" 
+            :class="{ active: selectedType === CheckinType.INVENTORY_TALLY }"
+            @click="selectType(CheckinType.INVENTORY_TALLY)"
+          >
+            <view class="type-icon inventory">
+              <view class="icon-inner"></view>
+            </view>
+            <view class="type-info">
+              <text class="type-name">理货盘点签到</text>
+              <text class="type-desc">仓库人员理货盘点确认</text>
+            </view>
+            <view class="type-check" v-if="selectedType === CheckinType.INVENTORY_TALLY">
+              <view class="check-icon"></view>
+            </view>
+          </view>
+          
+          <view 
+            class="type-item" 
+            :class="{ active: selectedType === CheckinType.DELIVERY_REPLENISH }"
+            @click="selectType(CheckinType.DELIVERY_REPLENISH)"
+          >
+            <view class="type-icon delivery">
+              <view class="icon-inner"></view>
+            </view>
+            <view class="type-info">
+              <text class="type-name">补货确认签到</text>
+              <text class="type-desc">配送人员补货确认</text>
+            </view>
+            <view class="type-check" v-if="selectedType === CheckinType.DELIVERY_REPLENISH">
+              <view class="check-icon"></view>
+            </view>
+          </view>
+        </view>
+      </view>
+      
+      <view class="photo-section">
+        <view class="section-header">
+          <text class="section-title">拍照签到</text>
+          <text class="section-tip">至少拍摄1张照片</text>
+        </view>
+        
+        <view class="photo-grid">
+          <view 
+            class="photo-item" 
+            v-for="(photo, index) in photos" 
+            :key="photo.id"
+          >
+            <image 
+              class="photo-image" 
+              :src="photo.path" 
+              mode="aspectFill"
+              @click="previewPhoto(photo.path)"
+            />
+            <view class="photo-delete" @click="removePhoto(index)">
+              <view class="delete-icon"></view>
+            </view>
+            <view class="photo-index">{{ index + 1 }}</view>
+          </view>
+          
+          <view class="photo-add" @click="addPhoto" v-if="photos.length < 5">
+            <view class="add-icon">
+              <view class="add-h"></view>
+              <view class="add-v"></view>
+            </view>
+            <text class="add-text">拍照</text>
+          </view>
+        </view>
+      </view>
+      
+      <view class="remark-section">
+        <view class="section-header">
+          <text class="section-title">备注信息</text>
+          <text class="section-tip">选填</text>
+        </view>
+        <textarea 
+          class="remark-input"
+          v-model="remark"
+          placeholder="请输入备注信息..."
+          :maxlength="200"
+          placeholder-class="placeholder"
+        />
+        <view class="remark-count">
+          <text>{{ remark.length }}/200</text>
+        </view>
+      </view>
+      
+      <view class="offline-tip" v-if="pendingSyncCount > 0">
+        <view class="tip-icon"></view>
+        <text class="tip-text">有 {{ pendingSyncCount }} 条离线签到待同步</text>
+        <text class="tip-action" @click="syncOfflineRecords">立即同步</text>
+      </view>
+      
+      <view class="submit-section">
+        <button 
+          class="submit-btn" 
+          :class="{ disabled: !canSubmit }"
+          :disabled="!canSubmit || isSubmitting"
+          @click="handleSubmit"
+        >
+          <view class="btn-loading" v-if="isSubmitting"></view>
+          <text v-else>{{ isSubmitting ? '提交中...' : '确认签到' }}</text>
+        </button>
+      </view>
+    </view>
+    
+    <view class="success-modal" v-if="showSuccess">
+      <view class="success-content">
+        <view class="success-icon">
+          <view class="check-mark"></view>
+        </view>
+        <text class="success-title">签到成功</text>
+        <text class="success-time">{{ successTime }}</text>
+        <button class="success-btn" @click="handleSuccessClose">确定</button>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, onMounted } from 'vue';
+import NavBar from '@/components/NavBar.vue';
+import { CheckinType } from '@/api/checkin';
+import type { LocationInfo, CheckinPhoto } from '@/api/checkin';
+import { 
+  getCurrentLocation, 
+  takePhotoWithWatermark,
+  saveOfflineCheckin,
+  getOfflineCheckins,
+  removeOfflineCheckin,
+  generateOfflineId,
+  checkLocationPermission,
+  checkCameraPermission,
+  formatWatermarkTime
+} from '@/utils/checkin';
+import { getUserInfo } from '@/utils/auth';
+
+const selectedType = ref<CheckinType>(CheckinType.INVENTORY_TALLY);
+const currentLocation = ref<LocationInfo | null>(null);
+const locationError = ref('');
+const isLocating = ref(false);
+const photos = ref<CheckinPhoto[]>([]);
+const remark = ref('');
+const isSubmitting = ref(false);
+const showSuccess = ref(false);
+const successTime = ref('');
+const pendingSyncCount = ref(0);
+
+const canSubmit = computed(() => {
+  return currentLocation.value && photos.value.length > 0 && !isSubmitting.value;
+});
+
+onMounted(() => {
+  initLocation();
+  updatePendingCount();
+});
+
+const initLocation = async () => {
+  isLocating.value = true;
+  locationError.value = '';
+  
+  try {
+    const hasPermission = await checkLocationPermission();
+    if (!hasPermission) {
+      locationError.value = '请开启定位权限';
+      return;
+    }
+    
+    currentLocation.value = await getCurrentLocation();
+  } catch (error: any) {
+    locationError.value = error.message || '获取位置失败';
+  } finally {
+    isLocating.value = false;
+  }
+};
+
+const refreshLocation = async () => {
+  if (isLocating.value) return;
+  await initLocation();
+};
+
+const selectType = (type: CheckinType) => {
+  selectedType.value = type;
+};
+
+const addPhoto = async () => {
+  if (!currentLocation.value) {
+    uni.showToast({
+      title: '请先获取位置信息',
+      icon: 'none'
+    });
+    return;
+  }
+  
+  const hasPermission = await checkCameraPermission();
+  if (!hasPermission) return;
+  
+  try {
+    const photo = await takePhotoWithWatermark(selectedType.value, currentLocation.value);
+    photos.value.push(photo);
+  } catch (error: any) {
+    uni.showToast({
+      title: error.message || '拍照失败',
+      icon: 'none'
+    });
+  }
+};
+
+const removePhoto = (index: number) => {
+  photos.value.splice(index, 1);
+};
+
+const previewPhoto = (path: string) => {
+  uni.previewImage({
+    urls: photos.value.map(p => p.path),
+    current: path
+  });
+};
+
+const updatePendingCount = () => {
+  const offlineRecords = getOfflineCheckins();
+  pendingSyncCount.value = offlineRecords.filter(r => r.status === 'pending').length;
+};
+
+const handleSubmit = async () => {
+  if (!canSubmit.value || !currentLocation.value) return;
+  
+  isSubmitting.value = true;
+  
+  try {
+    const userInfo = getUserInfo() || {};
+    const offlineId = generateOfflineId();
+    
+    const params = {
+      type: selectedType.value,
+      location: currentLocation.value,
+      photos: photos.value,
+      remark: remark.value,
+      offlineId
+    };
+    
+    try {
+      const record = await submitCheckin(params);
+      
+      showSuccess.value = true;
+      successTime.value = formatWatermarkTime();
+      
+      photos.value = [];
+      remark.value = '';
+      
+      updatePendingCount();
+    } catch (error) {
+      const offlineRecord = {
+        id: offlineId,
+        type: selectedType.value,
+        typeName: selectedType.value === CheckinType.INVENTORY_TALLY ? '理货盘点签到' : '补货确认签到',
+        userId: userInfo.id || 1,
+        userName: userInfo.nickname || userInfo.username || '未知用户',
+        userNo: userInfo.userNo || 'N/A',
+        location: currentLocation.value!,
+        photos: photos.value,
+        remark: remark.value,
+        status: 'pending' as const,
+        createdAt: new Date().toISOString(),
+        offlineId
+      };
+      
+      saveOfflineCheckin(offlineRecord);
+      
+      uni.showToast({
+        title: '已保存到本地,稍后同步',
+        icon: 'none'
+      });
+      
+      photos.value = [];
+      remark.value = '';
+      updatePendingCount();
+    }
+  } finally {
+    isSubmitting.value = false;
+  }
+};
+
+const syncOfflineRecords = async () => {
+  const offlineRecords = getOfflineCheckins().filter(r => r.status === 'pending');
+  
+  if (offlineRecords.length === 0) {
+    uni.showToast({
+      title: '没有待同步的记录',
+      icon: 'none'
+    });
+    return;
+  }
+  
+  uni.showLoading({ title: '同步中...' });
+  
+  try {
+    const result = await syncOfflineCheckins(offlineRecords);
+    
+    uni.hideLoading();
+    
+    if (result.success > 0) {
+      uni.showToast({
+        title: `成功同步 ${result.success} 条记录`,
+        icon: 'success'
+      });
+      
+      offlineRecords.forEach(record => {
+        if (record.offlineId) {
+          removeOfflineCheckin(record.offlineId);
+        }
+      });
+      
+      updatePendingCount();
+    }
+  } catch (error) {
+    uni.hideLoading();
+    uni.showToast({
+      title: '同步失败,请稍后重试',
+      icon: 'none'
+    });
+  }
+};
+
+const handleSuccessClose = () => {
+  showSuccess.value = false;
+};
+</script>
+
+<style lang="scss" scoped>
+.page {
+  min-height: 100vh;
+  background: #f8fafc;
+}
+
+.content {
+  padding: 24rpx;
+  padding-top: 0;
+}
+
+.location-card {
+  background: #ffffff;
+  border-radius: 20rpx;
+  padding: 24rpx;
+  margin-bottom: 24rpx;
+  border: 1rpx solid #e2e8f0;
+}
+
+.location-header {
+  display: flex;
+  align-items: center;
+  margin-bottom: 16rpx;
+}
+
+.location-icon {
+  width: 36rpx;
+  height: 36rpx;
+  background: #10b981;
+  border-radius: 50%;
+  margin-right: 12rpx;
+  position: relative;
+  
+  &::before {
+    content: '';
+    position: absolute;
+    top: 50%;
+    left: 50%;
+    transform: translate(-50%, -50%);
+    width: 12rpx;
+    height: 12rpx;
+    background: #ffffff;
+    border-radius: 50%;
+  }
+}
+
+.location-title {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #1e293b;
+  flex: 1;
+}
+
+.refresh-btn {
+  width: 56rpx;
+  height: 56rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #f1f5f9;
+  border-radius: 50%;
+  
+  &:active {
+    background: #e2e8f0;
+  }
+}
+
+.refresh-icon {
+  width: 24rpx;
+  height: 24rpx;
+  border: 3rpx solid #64748b;
+  border-radius: 50%;
+  border-top-color: transparent;
+  
+  &.rotating {
+    animation: rotate 1s linear infinite;
+  }
+  
+  @keyframes rotate {
+    from { transform: rotate(0deg); }
+    to { transform: rotate(360deg); }
+  }
+}
+
+.location-content {
+  padding: 16rpx;
+  background: #f8fafc;
+  border-radius: 12rpx;
+}
+
+.location-row {
+  display: flex;
+  align-items: flex-start;
+  margin-bottom: 12rpx;
+  
+  &:last-child {
+    margin-bottom: 0;
+  }
+}
+
+.location-label {
+  font-size: 24rpx;
+  color: #64748b;
+  width: 100rpx;
+  flex-shrink: 0;
+}
+
+.location-value {
+  font-size: 24rpx;
+  color: #1e293b;
+  flex: 1;
+}
+
+.location-loading {
+  padding: 24rpx;
+  text-align: center;
+}
+
+.loading-text {
+  font-size: 26rpx;
+  color: #64748b;
+}
+
+.section-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 16rpx;
+}
+
+.section-title {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #1e293b;
+}
+
+.section-tip {
+  font-size: 24rpx;
+  color: #94a3b8;
+}
+
+.type-section {
+  margin-bottom: 24rpx;
+}
+
+.type-list {
+  background: #ffffff;
+  border-radius: 20rpx;
+  border: 1rpx solid #e2e8f0;
+  overflow: hidden;
+}
+
+.type-item {
+  display: flex;
+  align-items: center;
+  padding: 28rpx 24rpx;
+  border-bottom: 1rpx solid #f1f5f9;
+  transition: background 0.15s;
+  
+  &:last-child {
+    border-bottom: none;
+  }
+  
+  &:active {
+    background: #f8fafc;
+  }
+  
+  &.active {
+    background: #ecfdf5;
+  }
+}
+
+.type-icon {
+  width: 72rpx;
+  height: 72rpx;
+  border-radius: 16rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-right: 20rpx;
+  
+  &.inventory {
+    background: #ecfdf5;
+    
+    .icon-inner {
+      width: 32rpx;
+      height: 40rpx;
+      border: 3rpx solid #10b981;
+      border-radius: 4rpx;
+      position: relative;
+      
+      &::before {
+        content: '';
+        position: absolute;
+        top: 8rpx;
+        left: 6rpx;
+        width: 14rpx;
+        height: 3rpx;
+        background: #10b981;
+        border-radius: 2rpx;
+      }
+      
+      &::after {
+        content: '';
+        position: absolute;
+        top: 16rpx;
+        left: 6rpx;
+        width: 10rpx;
+        height: 3rpx;
+        background: #10b981;
+        border-radius: 2rpx;
+      }
+    }
+  }
+  
+  &.delivery {
+    background: #fff7ed;
+    
+    .icon-inner {
+      width: 36rpx;
+      height: 28rpx;
+      border: 3rpx solid #f97316;
+      border-radius: 4rpx;
+      position: relative;
+      
+      &::before {
+        content: '';
+        position: absolute;
+        top: 50%;
+        left: 50%;
+        transform: translate(-50%, -50%);
+        width: 12rpx;
+        height: 12rpx;
+        background: #f97316;
+        border-radius: 50%;
+      }
+    }
+  }
+}
+
+.type-info {
+  flex: 1;
+}
+
+.type-name {
+  display: block;
+  font-size: 28rpx;
+  font-weight: 500;
+  color: #1e293b;
+  margin-bottom: 4rpx;
+}
+
+.type-desc {
+  font-size: 24rpx;
+  color: #64748b;
+}
+
+.type-check {
+  width: 40rpx;
+  height: 40rpx;
+  background: #10b981;
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.check-icon {
+  width: 16rpx;
+  height: 10rpx;
+  border-left: 3rpx solid #ffffff;
+  border-bottom: 3rpx solid #ffffff;
+  transform: rotate(-45deg);
+  margin-top: -4rpx;
+}
+
+.photo-section {
+  margin-bottom: 24rpx;
+}
+
+.photo-grid {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 16rpx;
+}
+
+.photo-item {
+  width: calc(33.33% - 12rpx);
+  aspect-ratio: 1;
+  border-radius: 12rpx;
+  overflow: hidden;
+  position: relative;
+  background: #f1f5f9;
+}
+
+.photo-image {
+  width: 100%;
+  height: 100%;
+}
+
+.photo-delete {
+  position: absolute;
+  top: 8rpx;
+  right: 8rpx;
+  width: 40rpx;
+  height: 40rpx;
+  background: rgba(0, 0, 0, 0.5);
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.delete-icon {
+  width: 16rpx;
+  height: 16rpx;
+  position: relative;
+  
+  &::before, &::after {
+    content: '';
+    position: absolute;
+    top: 50%;
+    left: 50%;
+    width: 20rpx;
+    height: 3rpx;
+    background: #ffffff;
+    border-radius: 2rpx;
+  }
+  
+  &::before {
+    transform: translate(-50%, -50%) rotate(45deg);
+  }
+  
+  &::after {
+    transform: translate(-50%, -50%) rotate(-45deg);
+  }
+}
+
+.photo-index {
+  position: absolute;
+  bottom: 8rpx;
+  left: 8rpx;
+  width: 36rpx;
+  height: 36rpx;
+  background: rgba(0, 0, 0, 0.5);
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 22rpx;
+  color: #ffffff;
+}
+
+.photo-add {
+  width: calc(33.33% - 12rpx);
+  aspect-ratio: 1;
+  border-radius: 12rpx;
+  border: 2rpx dashed #cbd5e1;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  background: #ffffff;
+  
+  &:active {
+    background: #f8fafc;
+  }
+}
+
+.add-icon {
+  width: 48rpx;
+  height: 48rpx;
+  position: relative;
+  margin-bottom: 8rpx;
+}
+
+.add-h, .add-v {
+  position: absolute;
+  top: 50%;
+  left: 50%;
+  transform: translate(-50%, -50%);
+  background: #94a3b8;
+  border-radius: 2rpx;
+}
+
+.add-h {
+  width: 32rpx;
+  height: 4rpx;
+}
+
+.add-v {
+  width: 4rpx;
+  height: 32rpx;
+}
+
+.add-text {
+  font-size: 24rpx;
+  color: #94a3b8;
+}
+
+.remark-section {
+  margin-bottom: 24rpx;
+}
+
+.remark-input {
+  width: 100%;
+  min-height: 160rpx;
+  background: #ffffff;
+  border: 1rpx solid #e2e8f0;
+  border-radius: 16rpx;
+  padding: 20rpx;
+  font-size: 28rpx;
+  color: #1e293b;
+  box-sizing: border-box;
+}
+
+.placeholder {
+  color: #94a3b8;
+}
+
+.remark-count {
+  text-align: right;
+  margin-top: 8rpx;
+  
+  text {
+    font-size: 24rpx;
+    color: #94a3b8;
+  }
+}
+
+.offline-tip {
+  display: flex;
+  align-items: center;
+  padding: 20rpx 24rpx;
+  background: #fffbeb;
+  border: 1rpx solid #fcd34d;
+  border-radius: 12rpx;
+  margin-bottom: 24rpx;
+}
+
+.tip-icon {
+  width: 32rpx;
+  height: 32rpx;
+  background: #f59e0b;
+  border-radius: 50%;
+  margin-right: 12rpx;
+  position: relative;
+  
+  &::before {
+    content: '!';
+    position: absolute;
+    top: 50%;
+    left: 50%;
+    transform: translate(-50%, -50%);
+    font-size: 22rpx;
+    font-weight: 700;
+    color: #ffffff;
+  }
+}
+
+.tip-text {
+  flex: 1;
+  font-size: 26rpx;
+  color: #92400e;
+}
+
+.tip-action {
+  font-size: 26rpx;
+  color: #f59e0b;
+  font-weight: 500;
+}
+
+.submit-section {
+  padding: 24rpx 0;
+  padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
+}
+
+.submit-btn {
+  width: 100%;
+  height: 96rpx;
+  background: linear-gradient(135deg, #10b981 0%, #059669 100%);
+  border-radius: 48rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 32rpx;
+  font-weight: 600;
+  color: #ffffff;
+  border: none;
+  
+  &.disabled {
+    background: #cbd5e1;
+    color: #94a3b8;
+  }
+  
+  &:active:not(.disabled) {
+    opacity: 0.9;
+  }
+}
+
+.btn-loading {
+  width: 36rpx;
+  height: 36rpx;
+  border: 3rpx solid rgba(255, 255, 255, 0.3);
+  border-top-color: #ffffff;
+  border-radius: 50%;
+  animation: rotate 0.8s linear infinite;
+  
+  @keyframes rotate {
+    from { transform: rotate(0deg); }
+    to { transform: rotate(360deg); }
+  }
+}
+
+.success-modal {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba(0, 0, 0, 0.5);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 1000;
+}
+
+.success-content {
+  width: 560rpx;
+  background: #ffffff;
+  border-radius: 24rpx;
+  padding: 48rpx;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+
+.success-icon {
+  width: 120rpx;
+  height: 120rpx;
+  background: #ecfdf5;
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-bottom: 24rpx;
+}
+
+.check-mark {
+  width: 48rpx;
+  height: 28rpx;
+  border-left: 6rpx solid #10b981;
+  border-bottom: 6rpx solid #10b981;
+  transform: rotate(-45deg);
+  margin-top: -12rpx;
+}
+
+.success-title {
+  font-size: 36rpx;
+  font-weight: 600;
+  color: #1e293b;
+  margin-bottom: 12rpx;
+}
+
+.success-time {
+  font-size: 26rpx;
+  color: #64748b;
+  margin-bottom: 32rpx;
+}
+
+.success-btn {
+  width: 100%;
+  height: 88rpx;
+  background: #10b981;
+  border-radius: 44rpx;
+  font-size: 30rpx;
+  font-weight: 500;
+  color: #ffffff;
+  border: none;
+  
+  &:active {
+    opacity: 0.9;
+  }
+}
+</style>

+ 1164 - 0
haha-admin-mp/src/pages/checkin/records.vue

@@ -0,0 +1,1164 @@
+<template>
+  <view class="page">
+    <NavBar title="签到记录" :showBack="true">
+      <template #right>
+        <view class="nav-action" @click="handleExport">
+          <view class="export-icon"></view>
+        </view>
+      </template>
+    </NavBar>
+    
+    <view class="content">
+      <view class="filter-section">
+        <view class="filter-row">
+          <picker 
+            mode="selector" 
+            :range="typeOptions" 
+            range-key="label"
+            @change="onTypeChange"
+          >
+            <view class="filter-item">
+              <text class="filter-label">{{ currentTypeLabel }}</text>
+              <view class="filter-arrow"></view>
+            </view>
+          </picker>
+          
+          <picker 
+            mode="date" 
+            :value="filterStartDate"
+            @change="onStartDateChange"
+          >
+            <view class="filter-item">
+              <text class="filter-label">{{ filterStartDate || '开始日期' }}</text>
+              <view class="filter-arrow"></view>
+            </view>
+          </picker>
+          
+          <picker 
+            mode="date" 
+            :value="filterEndDate"
+            @change="onEndDateChange"
+          >
+            <view class="filter-item">
+              <text class="filter-label">{{ filterEndDate || '结束日期' }}</text>
+              <view class="filter-arrow"></view>
+            </view>
+          </picker>
+        </view>
+      </view>
+      
+      <view class="stats-row">
+        <view class="stats-item">
+          <text class="stats-value">{{ stats.todayCount }}</text>
+          <text class="stats-label">今日签到</text>
+        </view>
+        <view class="stats-item">
+          <text class="stats-value">{{ stats.weekCount }}</text>
+          <text class="stats-label">本周签到</text>
+        </view>
+        <view class="stats-item">
+          <text class="stats-value">{{ stats.monthCount }}</text>
+          <text class="stats-label">本月签到</text>
+        </view>
+        <view class="stats-item warning" v-if="stats.pendingSyncCount > 0">
+          <text class="stats-value">{{ stats.pendingSyncCount }}</text>
+          <text class="stats-label">待同步</text>
+        </view>
+      </view>
+      
+      <view class="list-section">
+        <view class="list-header">
+          <text class="list-title">签到记录</text>
+          <text class="list-count">共 {{ total }} 条</text>
+        </view>
+        
+        <scroll-view 
+          class="list-scroll" 
+          scroll-y 
+          @scrolltolower="loadMore"
+          :refresher-enabled="true"
+          :refresher-triggered="isRefreshing"
+          @refresherrefresh="onRefresh"
+        >
+          <view class="record-list">
+            <view 
+              class="record-item" 
+              v-for="record in records" 
+              :key="record.id"
+              @click="viewDetail(record)"
+            >
+              <view class="record-left">
+                <view class="record-type" :class="record.type">
+                  <view class="type-icon"></view>
+                </view>
+              </view>
+              
+              <view class="record-content">
+                <view class="record-header">
+                  <text class="record-type-name">{{ record.typeName }}</text>
+                  <view class="record-status" :class="record.status">
+                    <text>{{ getStatusText(record.status) }}</text>
+                  </view>
+                </view>
+                
+                <view class="record-info">
+                  <view class="info-row">
+                    <view class="info-icon user"></view>
+                    <text class="info-text">{{ record.userName }} ({{ record.userNo }})</text>
+                  </view>
+                  <view class="info-row">
+                    <view class="info-icon location"></view>
+                    <text class="info-text">{{ record.location.address }}</text>
+                  </view>
+                  <view class="info-row">
+                    <view class="info-icon time"></view>
+                    <text class="info-text">{{ formatTime(record.createdAt) }}</text>
+                  </view>
+                </view>
+                
+                <view class="record-photos" v-if="record.photos.length > 0">
+                  <image 
+                    class="photo-thumb" 
+                    v-for="(photo, index) in record.photos.slice(0, 3)" 
+                    :key="photo.id"
+                    :src="photo.path"
+                    mode="aspectFill"
+                  />
+                  <view class="photo-more" v-if="record.photos.length > 3">
+                    <text>+{{ record.photos.length - 3 }}</text>
+                  </view>
+                </view>
+                
+                <view class="record-remark" v-if="record.remark">
+                  <text>{{ record.remark }}</text>
+                </view>
+              </view>
+              
+              <view class="record-arrow">
+                <view class="arrow-icon"></view>
+              </view>
+            </view>
+            
+            <view class="empty-state" v-if="!isLoading && records.length === 0">
+              <view class="empty-icon"></view>
+              <text class="empty-text">暂无签到记录</text>
+            </view>
+            
+            <view class="loading-more" v-if="isLoadingMore">
+              <view class="loading-spinner"></view>
+              <text class="loading-text">加载中...</text>
+            </view>
+            
+            <view class="no-more" v-if="!hasMore && records.length > 0">
+              <text>没有更多了</text>
+            </view>
+          </view>
+        </scroll-view>
+      </view>
+    </view>
+    
+    <view class="detail-modal" v-if="showDetail && currentRecord">
+      <view class="detail-mask" @click="closeDetail"></view>
+      <view class="detail-content">
+        <view class="detail-header">
+          <text class="detail-title">签到详情</text>
+          <view class="detail-close" @click="closeDetail">
+            <view class="close-icon"></view>
+          </view>
+        </view>
+        
+        <scroll-view class="detail-body" scroll-y>
+          <view class="detail-section">
+            <view class="detail-row">
+              <text class="detail-label">签到类型</text>
+              <text class="detail-value">{{ currentRecord.typeName }}</text>
+            </view>
+            <view class="detail-row">
+              <text class="detail-label">签到人员</text>
+              <text class="detail-value">{{ currentRecord.userName }} ({{ currentRecord.userNo }})</text>
+            </view>
+            <view class="detail-row">
+              <text class="detail-label">签到时间</text>
+              <text class="detail-value">{{ currentRecord.createdAt }}</text>
+            </view>
+            <view class="detail-row" v-if="currentRecord.shopName">
+              <text class="detail-label">关联门店</text>
+              <text class="detail-value">{{ currentRecord.shopName }}</text>
+            </view>
+            <view class="detail-row" v-if="currentRecord.deviceName">
+              <text class="detail-label">关联设备</text>
+              <text class="detail-value">{{ currentRecord.deviceName }}</text>
+            </view>
+          </view>
+          
+          <view class="detail-section">
+            <view class="section-title">位置信息</view>
+            <view class="location-map">
+              <view class="map-placeholder">
+                <view class="map-icon"></view>
+                <text class="map-text">{{ currentRecord.location.address }}</text>
+                <text class="map-coords">{{ currentRecord.location.latitude.toFixed(6) }}, {{ currentRecord.location.longitude.toFixed(6) }}</text>
+              </view>
+            </view>
+          </view>
+          
+          <view class="detail-section" v-if="currentRecord.photos.length > 0">
+            <view class="section-title">签到照片</view>
+            <view class="photo-grid">
+              <image 
+                class="detail-photo" 
+                v-for="photo in currentRecord.photos" 
+                :key="photo.id"
+                :src="photo.path"
+                mode="aspectFill"
+                @click="previewPhotos(photo.path)"
+              />
+            </view>
+          </view>
+          
+          <view class="detail-section" v-if="currentRecord.remark">
+            <view class="section-title">备注信息</view>
+            <view class="remark-box">
+              <text>{{ currentRecord.remark }}</text>
+            </view>
+          </view>
+          
+          <view class="detail-section">
+            <view class="section-title">同步状态</view>
+            <view class="sync-info">
+              <view class="sync-status" :class="currentRecord.status">
+                <view class="status-dot"></view>
+                <text>{{ getStatusText(currentRecord.status) }}</text>
+              </view>
+              <text class="sync-time" v-if="currentRecord.syncedAt">
+                同步时间: {{ currentRecord.syncedAt }}
+              </text>
+            </view>
+          </view>
+        </scroll-view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, onMounted } from 'vue';
+import NavBar from '@/components/NavBar.vue';
+import { CheckinType, getCheckinList, getCheckinStats, exportCheckinRecords } from '@/api/checkin';
+import type { CheckinRecord, CheckinStats } from '@/api/checkin';
+
+const typeOptions = [
+  { value: '', label: '全部类型' },
+  { value: CheckinType.INVENTORY_TALLY, label: '理货盘点' },
+  { value: CheckinType.DELIVERY_REPLENISH, label: '补货确认' }
+];
+
+const filterType = ref('');
+const filterStartDate = ref('');
+const filterEndDate = ref('');
+const records = ref<CheckinRecord[]>([]);
+const total = ref(0);
+const page = ref(1);
+const pageSize = 10;
+const isLoading = ref(false);
+const isLoadingMore = ref(false);
+const isRefreshing = ref(false);
+const hasMore = ref(true);
+const showDetail = ref(false);
+const currentRecord = ref<CheckinRecord | null>(null);
+
+const stats = ref<CheckinStats>({
+  todayCount: 0,
+  weekCount: 0,
+  monthCount: 0,
+  inventoryTallyCount: 0,
+  deliveryReplenishCount: 0,
+  pendingSyncCount: 0
+});
+
+const currentTypeLabel = computed(() => {
+  const option = typeOptions.find(o => o.value === filterType.value);
+  return option ? option.label : '全部类型';
+});
+
+onMounted(() => {
+  loadData();
+  loadStats();
+});
+
+const loadData = async (reset = false) => {
+  if (reset) {
+    page.value = 1;
+    hasMore.value = true;
+  }
+  
+  if (isLoading.value) return;
+  isLoading.value = true;
+  
+  try {
+    const result = await getCheckinList({
+      page: page.value,
+      pageSize,
+      type: filterType.value as CheckinType || undefined,
+      startDate: filterStartDate.value || undefined,
+      endDate: filterEndDate.value || undefined
+    });
+    
+    if (reset) {
+      records.value = result.list;
+    } else {
+      records.value = [...records.value, ...result.list];
+    }
+    
+    total.value = result.total;
+    hasMore.value = records.value.length < result.total;
+  } catch (error) {
+    console.error('加载签到记录失败', error);
+  } finally {
+    isLoading.value = false;
+    isRefreshing.value = false;
+    isLoadingMore.value = false;
+  }
+};
+
+const loadStats = async () => {
+  try {
+    stats.value = await getCheckinStats();
+  } catch (error) {
+    console.error('加载统计数据失败', error);
+  }
+};
+
+const loadMore = () => {
+  if (!hasMore.value || isLoadingMore.value) return;
+  
+  isLoadingMore.value = true;
+  page.value++;
+  loadData();
+};
+
+const onRefresh = () => {
+  isRefreshing.value = true;
+  loadData(true);
+  loadStats();
+};
+
+const onTypeChange = (e: any) => {
+  filterType.value = typeOptions[e.detail.value].value;
+  loadData(true);
+};
+
+const onStartDateChange = (e: any) => {
+  filterStartDate.value = e.detail.value;
+  loadData(true);
+};
+
+const onEndDateChange = (e: any) => {
+  filterEndDate.value = e.detail.value;
+  loadData(true);
+};
+
+const getStatusText = (status: string) => {
+  const statusMap: Record<string, string> = {
+    pending: '待同步',
+    synced: '已同步',
+    failed: '同步失败'
+  };
+  return statusMap[status] || status;
+};
+
+const formatTime = (timeStr: string) => {
+  return timeStr.replace('T', ' ').substring(0, 19);
+};
+
+const viewDetail = (record: CheckinRecord) => {
+  currentRecord.value = record;
+  showDetail.value = true;
+};
+
+const closeDetail = () => {
+  showDetail.value = false;
+  currentRecord.value = null;
+};
+
+const previewPhotos = (current: string) => {
+  if (!currentRecord.value) return;
+  
+  uni.previewImage({
+    urls: currentRecord.value.photos.map(p => p.path),
+    current
+  });
+};
+
+const handleExport = async () => {
+  uni.showLoading({ title: '导出中...' });
+  
+  try {
+    const url = await exportCheckinRecords({
+      type: filterType.value as CheckinType || undefined,
+      startDate: filterStartDate.value || undefined,
+      endDate: filterEndDate.value || undefined
+    });
+    
+    uni.hideLoading();
+    
+    // #ifdef H5
+    window.open(url, '_blank');
+    // #endif
+    
+    // #ifndef H5
+    uni.showToast({
+      title: '导出成功',
+      icon: 'success'
+    });
+    // #endif
+  } catch (error) {
+    uni.hideLoading();
+    uni.showToast({
+      title: '导出失败',
+      icon: 'none'
+    });
+  }
+};
+</script>
+
+<style lang="scss" scoped>
+.page {
+  min-height: 100vh;
+  background: #f8fafc;
+  display: flex;
+  flex-direction: column;
+}
+
+.nav-action {
+  width: 64rpx;
+  height: 64rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.export-icon {
+  width: 36rpx;
+  height: 36rpx;
+  border: 3rpx solid #1e293b;
+  border-radius: 6rpx;
+  position: relative;
+  
+  &::before {
+    content: '';
+    position: absolute;
+    top: 50%;
+    left: 50%;
+    transform: translate(-50%, -30%);
+    width: 0;
+    height: 0;
+    border-left: 6rpx solid transparent;
+    border-right: 6rpx solid transparent;
+    border-bottom: 8rpx solid #1e293b;
+  }
+  
+  &::after {
+    content: '';
+    position: absolute;
+    bottom: 4rpx;
+    left: 50%;
+    transform: translateX(-50%);
+    width: 12rpx;
+    height: 3rpx;
+    background: #1e293b;
+  }
+}
+
+.content {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+}
+
+.filter-section {
+  padding: 16rpx 24rpx;
+  background: #ffffff;
+  border-bottom: 1rpx solid #e2e8f0;
+}
+
+.filter-row {
+  display: flex;
+  gap: 16rpx;
+}
+
+.filter-item {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  height: 64rpx;
+  background: #f8fafc;
+  border-radius: 12rpx;
+  padding: 0 16rpx;
+  
+  &:active {
+    background: #f1f5f9;
+  }
+}
+
+.filter-label {
+  font-size: 24rpx;
+  color: #475569;
+  margin-right: 8rpx;
+}
+
+.filter-arrow {
+  width: 0;
+  height: 0;
+  border-left: 8rpx solid transparent;
+  border-right: 8rpx solid transparent;
+  border-top: 8rpx solid #94a3b8;
+}
+
+.stats-row {
+  display: flex;
+  padding: 20rpx 24rpx;
+  background: #ffffff;
+  border-bottom: 1rpx solid #e2e8f0;
+}
+
+.stats-item {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  
+  &.warning {
+    .stats-value {
+      color: #f59e0b;
+    }
+  }
+}
+
+.stats-value {
+  font-size: 40rpx;
+  font-weight: 700;
+  color: #1e293b;
+  margin-bottom: 4rpx;
+}
+
+.stats-label {
+  font-size: 22rpx;
+  color: #64748b;
+}
+
+.list-section {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+}
+
+.list-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 20rpx 24rpx;
+}
+
+.list-title {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #1e293b;
+}
+
+.list-count {
+  font-size: 24rpx;
+  color: #94a3b8;
+}
+
+.list-scroll {
+  flex: 1;
+  height: 0;
+}
+
+.record-list {
+  padding: 0 24rpx;
+  padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
+}
+
+.record-item {
+  display: flex;
+  align-items: flex-start;
+  background: #ffffff;
+  border-radius: 16rpx;
+  padding: 24rpx;
+  margin-bottom: 16rpx;
+  border: 1rpx solid #e2e8f0;
+  
+  &:active {
+    background: #f8fafc;
+  }
+}
+
+.record-left {
+  margin-right: 20rpx;
+}
+
+.record-type {
+  width: 72rpx;
+  height: 72rpx;
+  border-radius: 16rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  
+  &.inventory_tally {
+    background: #ecfdf5;
+    
+    .type-icon {
+      width: 28rpx;
+      height: 36rpx;
+      border: 3rpx solid #10b981;
+      border-radius: 4rpx;
+    }
+  }
+  
+  &.delivery_replenish {
+    background: #fff7ed;
+    
+    .type-icon {
+      width: 32rpx;
+      height: 24rpx;
+      border: 3rpx solid #f97316;
+      border-radius: 4rpx;
+    }
+  }
+}
+
+.record-content {
+  flex: 1;
+  min-width: 0;
+}
+
+.record-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 12rpx;
+}
+
+.record-type-name {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #1e293b;
+}
+
+.record-status {
+  padding: 4rpx 12rpx;
+  border-radius: 8rpx;
+  font-size: 22rpx;
+  
+  &.synced {
+    background: #ecfdf5;
+    color: #10b981;
+  }
+  
+  &.pending {
+    background: #fffbeb;
+    color: #f59e0b;
+  }
+  
+  &.failed {
+    background: #fef2f2;
+    color: #ef4444;
+  }
+}
+
+.record-info {
+  margin-bottom: 12rpx;
+}
+
+.info-row {
+  display: flex;
+  align-items: center;
+  margin-bottom: 8rpx;
+  
+  &:last-child {
+    margin-bottom: 0;
+  }
+}
+
+.info-icon {
+  width: 28rpx;
+  height: 28rpx;
+  margin-right: 8rpx;
+  border-radius: 50%;
+  
+  &.user {
+    background: #e0f2fe;
+    position: relative;
+    
+    &::before {
+      content: '';
+      position: absolute;
+      top: 6rpx;
+      left: 50%;
+      transform: translateX(-50%);
+      width: 8rpx;
+      height: 8rpx;
+      background: #0ea5e9;
+      border-radius: 50%;
+    }
+    
+    &::after {
+      content: '';
+      position: absolute;
+      bottom: 6rpx;
+      left: 50%;
+      transform: translateX(-50%);
+      width: 14rpx;
+      height: 6rpx;
+      background: #0ea5e9;
+      border-radius: 6rpx 6rpx 0 0;
+    }
+  }
+  
+  &.location {
+    background: #ecfdf5;
+    position: relative;
+    
+    &::before {
+      content: '';
+      position: absolute;
+      top: 50%;
+      left: 50%;
+      transform: translate(-50%, -50%);
+      width: 8rpx;
+      height: 8rpx;
+      background: #10b981;
+      border-radius: 50%;
+    }
+    
+    &::after {
+      content: '';
+      position: absolute;
+      top: 50%;
+      left: 50%;
+      transform: translate(-50%, -50%);
+      width: 14rpx;
+      height: 14rpx;
+      border: 2rpx solid #10b981;
+      border-radius: 50%;
+    }
+  }
+  
+  &.time {
+    background: #faf5ff;
+    position: relative;
+    
+    &::before {
+      content: '';
+      position: absolute;
+      top: 50%;
+      left: 50%;
+      transform: translate(-50%, -50%);
+      width: 12rpx;
+      height: 12rpx;
+      border: 2rpx solid #a855f7;
+      border-radius: 50%;
+    }
+    
+    &::after {
+      content: '';
+      position: absolute;
+      top: 8rpx;
+      left: 50%;
+      transform: translateX(-50%);
+      width: 2rpx;
+      height: 6rpx;
+      background: #a855f7;
+    }
+  }
+}
+
+.info-text {
+  font-size: 24rpx;
+  color: #64748b;
+  flex: 1;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.record-photos {
+  display: flex;
+  gap: 8rpx;
+  margin-bottom: 12rpx;
+}
+
+.photo-thumb {
+  width: 80rpx;
+  height: 80rpx;
+  border-radius: 8rpx;
+  background: #f1f5f9;
+}
+
+.photo-more {
+  width: 80rpx;
+  height: 80rpx;
+  border-radius: 8rpx;
+  background: rgba(0, 0, 0, 0.5);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  
+  text {
+    font-size: 24rpx;
+    color: #ffffff;
+  }
+}
+
+.record-remark {
+  padding: 12rpx;
+  background: #f8fafc;
+  border-radius: 8rpx;
+  
+  text {
+    font-size: 24rpx;
+    color: #64748b;
+  }
+}
+
+.record-arrow {
+  display: flex;
+  align-items: center;
+  padding-left: 16rpx;
+}
+
+.arrow-icon {
+  width: 12rpx;
+  height: 12rpx;
+  border-top: 3rpx solid #cbd5e1;
+  border-right: 3rpx solid #cbd5e1;
+  transform: rotate(45deg);
+}
+
+.empty-state {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 100rpx 0;
+}
+
+.empty-icon {
+  width: 120rpx;
+  height: 120rpx;
+  background: #f1f5f9;
+  border-radius: 50%;
+  margin-bottom: 24rpx;
+  position: relative;
+  
+  &::before {
+    content: '';
+    position: absolute;
+    top: 50%;
+    left: 50%;
+    transform: translate(-50%, -50%);
+    width: 40rpx;
+    height: 40rpx;
+    border: 4rpx solid #cbd5e1;
+    border-radius: 8rpx;
+  }
+}
+
+.empty-text {
+  font-size: 28rpx;
+  color: #94a3b8;
+}
+
+.loading-more {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 24rpx;
+}
+
+.loading-spinner {
+  width: 32rpx;
+  height: 32rpx;
+  border: 3rpx solid #e2e8f0;
+  border-top-color: #10b981;
+  border-radius: 50%;
+  animation: rotate 0.8s linear infinite;
+  
+  @keyframes rotate {
+    from { transform: rotate(0deg); }
+    to { transform: rotate(360deg); }
+  }
+}
+
+.loading-text {
+  font-size: 24rpx;
+  color: #94a3b8;
+  margin-left: 12rpx;
+}
+
+.no-more {
+  text-align: center;
+  padding: 24rpx;
+  
+  text {
+    font-size: 24rpx;
+    color: #94a3b8;
+  }
+}
+
+.detail-modal {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  z-index: 1000;
+}
+
+.detail-mask {
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba(0, 0, 0, 0.5);
+}
+
+.detail-content {
+  position: absolute;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  max-height: 80vh;
+  background: #ffffff;
+  border-radius: 32rpx 32rpx 0 0;
+  display: flex;
+  flex-direction: column;
+}
+
+.detail-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 32rpx;
+  border-bottom: 1rpx solid #e2e8f0;
+}
+
+.detail-title {
+  font-size: 32rpx;
+  font-weight: 600;
+  color: #1e293b;
+}
+
+.detail-close {
+  width: 56rpx;
+  height: 56rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #f1f5f9;
+  border-radius: 50%;
+}
+
+.close-icon {
+  width: 20rpx;
+  height: 20rpx;
+  position: relative;
+  
+  &::before, &::after {
+    content: '';
+    position: absolute;
+    top: 50%;
+    left: 50%;
+    width: 24rpx;
+    height: 3rpx;
+    background: #64748b;
+    border-radius: 2rpx;
+  }
+  
+  &::before {
+    transform: translate(-50%, -50%) rotate(45deg);
+  }
+  
+  &::after {
+    transform: translate(-50%, -50%) rotate(-45deg);
+  }
+}
+
+.detail-body {
+  flex: 1;
+  padding: 24rpx 32rpx;
+  padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
+}
+
+.detail-section {
+  margin-bottom: 32rpx;
+}
+
+.detail-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 16rpx 0;
+  border-bottom: 1rpx solid #f1f5f9;
+  
+  &:last-child {
+    border-bottom: none;
+  }
+}
+
+.detail-label {
+  font-size: 26rpx;
+  color: #64748b;
+}
+
+.detail-value {
+  font-size: 26rpx;
+  color: #1e293b;
+  font-weight: 500;
+}
+
+.section-title {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #1e293b;
+  margin-bottom: 16rpx;
+}
+
+.location-map {
+  background: #f8fafc;
+  border-radius: 16rpx;
+  overflow: hidden;
+}
+
+.map-placeholder {
+  padding: 32rpx;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+
+.map-icon {
+  width: 48rpx;
+  height: 48rpx;
+  background: #10b981;
+  border-radius: 50%;
+  margin-bottom: 16rpx;
+  position: relative;
+  
+  &::before {
+    content: '';
+    position: absolute;
+    top: 50%;
+    left: 50%;
+    transform: translate(-50%, -50%);
+    width: 16rpx;
+    height: 16rpx;
+    background: #ffffff;
+    border-radius: 50%;
+  }
+}
+
+.map-text {
+  font-size: 26rpx;
+  color: #1e293b;
+  text-align: center;
+  margin-bottom: 8rpx;
+}
+
+.map-coords {
+  font-size: 22rpx;
+  color: #94a3b8;
+}
+
+.photo-grid {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 12rpx;
+}
+
+.detail-photo {
+  width: calc(33.33% - 8rpx);
+  aspect-ratio: 1;
+  border-radius: 12rpx;
+  background: #f1f5f9;
+}
+
+.remark-box {
+  padding: 20rpx;
+  background: #f8fafc;
+  border-radius: 12rpx;
+  
+  text {
+    font-size: 26rpx;
+    color: #475569;
+    line-height: 1.6;
+  }
+}
+
+.sync-info {
+  display: flex;
+  align-items: center;
+}
+
+.sync-status {
+  display: flex;
+  align-items: center;
+  padding: 8rpx 16rpx;
+  border-radius: 12rpx;
+  
+  &.synced {
+    background: #ecfdf5;
+    
+    .status-dot {
+      background: #10b981;
+    }
+    
+    text {
+      color: #10b981;
+    }
+  }
+  
+  &.pending {
+    background: #fffbeb;
+    
+    .status-dot {
+      background: #f59e0b;
+    }
+    
+    text {
+      color: #f59e0b;
+    }
+  }
+  
+  &.failed {
+    background: #fef2f2;
+    
+    .status-dot {
+      background: #ef4444;
+    }
+    
+    text {
+      color: #ef4444;
+    }
+  }
+}
+
+.status-dot {
+  width: 12rpx;
+  height: 12rpx;
+  border-radius: 50%;
+  margin-right: 8rpx;
+}
+
+.sync-time {
+  font-size: 24rpx;
+  color: #94a3b8;
+  margin-left: 16rpx;
+}
+</style>

+ 38 - 0
haha-admin-mp/src/pages/index/index.vue

@@ -83,6 +83,12 @@
           </view>
           <text class="quick-name">库存查询</text>
         </view>
+        <view class="quick-item" @click="navigateTo('/pages/checkin/index')">
+          <view class="quick-icon rose">
+            <view class="icon-checkin"></view>
+          </view>
+          <text class="quick-name">签到打卡</text>
+        </view>
       </view>
     </view>
 
@@ -453,6 +459,7 @@ onMounted(() => {
     &.amber { background: #fff7ed; }
     &.purple { background: #faf5ff; }
     &.blue { background: #f0f9ff; }
+    &.rose { background: #fff1f2; }
     
     /* 图标 - 纯CSS几何图形 */
     .icon-order {
@@ -574,6 +581,37 @@ onMounted(() => {
       & { height: 24rpx; }
       &::after { height: 16rpx; }
     }
+    
+    .icon-checkin {
+      width: 28rpx;
+      height: 28rpx;
+      border: 3rpx solid #f43f5e;
+      border-radius: 50%;
+      position: relative;
+      
+      &::before {
+        content: '';
+        position: absolute;
+        top: 50%;
+        left: 50%;
+        transform: translate(-50%, -50%);
+        width: 8rpx;
+        height: 8rpx;
+        background: #f43f5e;
+        border-radius: 50%;
+      }
+      
+      &::after {
+        content: '';
+        position: absolute;
+        top: -6rpx;
+        right: -6rpx;
+        width: 12rpx;
+        height: 12rpx;
+        background: #f43f5e;
+        border-radius: 50%;
+      }
+    }
   }
   
   .quick-name {

+ 329 - 0
haha-admin-mp/src/utils/checkin.ts

@@ -0,0 +1,329 @@
+/**
+ * 签到功能工具函数
+ */
+
+import type { CheckinRecord, CheckinType, LocationInfo, CheckinPhoto } from '@/api/checkin';
+import { getUserInfo } from './auth';
+
+const OFFLINE_CHECKIN_KEY = 'offline_checkin_records';
+const MAX_OFFLINE_RECORDS = 50;
+
+export interface WatermarkConfig {
+  time: string;
+  location: string;
+  userName: string;
+  userNo: string;
+  checkinType: string;
+}
+
+export function getCurrentLocation(): Promise<LocationInfo> {
+  return new Promise((resolve, reject) => {
+    uni.getLocation({
+      type: 'gcj02',
+      geocode: true,
+      success: (res) => {
+        resolve({
+          latitude: res.latitude,
+          longitude: res.longitude,
+          address: res.address 
+            ? `${res.address.province || ''}${res.address.city || ''}${res.address.district || ''}${res.address.street || ''}${res.address.streetNum || ''}`
+            : '未知位置',
+          accuracy: res.accuracy || 0
+        });
+      },
+      fail: (err) => {
+        console.error('获取位置失败', err);
+        reject(new Error('获取位置失败,请检查定位权限'));
+      }
+    });
+  });
+}
+
+export function formatWatermarkTime(date: Date = new Date()): string {
+  const year = date.getFullYear();
+  const month = String(date.getMonth() + 1).padStart(2, '0');
+  const day = String(date.getDate()).padStart(2, '0');
+  const hour = String(date.getHours()).padStart(2, '0');
+  const minute = String(date.getMinutes()).padStart(2, '0');
+  const second = String(date.getSeconds()).padStart(2, '0');
+  return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
+}
+
+export function formatWatermarkLocation(location: LocationInfo): string {
+  return `${location.latitude.toFixed(6)}, ${location.longitude.toFixed(6)}\n${location.address}`;
+}
+
+export function getCheckinTypeName(type: CheckinType): string {
+  const typeMap: Record<CheckinType, string> = {
+    [CheckinType.INVENTORY_TALLY]: '理货盘点',
+    [CheckinType.DELIVERY_REPLENISH]: '补货确认'
+  };
+  return typeMap[type] || '未知类型';
+}
+
+export function createWatermarkConfig(
+  type: CheckinType,
+  location: LocationInfo
+): WatermarkConfig {
+  const userInfo = getUserInfo() || { nickname: '未知用户', userNo: 'N/A' };
+  
+  return {
+    time: formatWatermarkTime(),
+    location: formatWatermarkLocation(location),
+    userName: userInfo.nickname || userInfo.username || '未知用户',
+    userNo: userInfo.userNo || 'N/A',
+    checkinType: getCheckinTypeName(type)
+  };
+}
+
+export function drawWatermarkOnCanvas(
+  canvasContext: UniApp.CanvasContext,
+  imagePath: string,
+  config: WatermarkConfig,
+  canvasWidth: number,
+  canvasHeight: number
+): Promise<string> {
+  return new Promise((resolve, reject) => {
+    canvasContext.drawImage(imagePath, 0, 0, canvasWidth, canvasHeight);
+    
+    const padding = 20;
+    const lineHeight = 24;
+    const startY = canvasHeight - padding - lineHeight * 5;
+    
+    canvasContext.setGlobalAlpha(0.7);
+    canvasContext.setFillStyle('#000000');
+    canvasContext.fillRect(
+      padding - 10,
+      startY - 10,
+      canvasWidth - padding * 2 + 20,
+      lineHeight * 5 + 20
+    );
+    
+    canvasContext.setGlobalAlpha(1);
+    canvasContext.setFillStyle('#ffffff');
+    canvasContext.setFontSize(14);
+    
+    const lines = [
+      `时间: ${config.time}`,
+      `位置: ${config.location.split('\n')[0]}`,
+      `地址: ${config.location.split('\n')[1] || ''}`,
+      `人员: ${config.userName} (${config.userNo})`,
+      `类型: ${config.checkinType}`
+    ];
+    
+    lines.forEach((line, index) => {
+      canvasContext.fillText(line, padding, startY + lineHeight * (index + 1));
+    });
+    
+    canvasContext.draw(false, () => {
+      uni.canvasToTempFilePath({
+        canvasId: 'watermarkCanvas',
+        success: (res) => {
+          resolve(res.tempFilePath);
+        },
+        fail: (err) => {
+          reject(err);
+        }
+      });
+    });
+  });
+}
+
+export function takePhotoWithWatermark(
+  type: CheckinType,
+  location: LocationInfo
+): Promise<CheckinPhoto> {
+  return new Promise((resolve, reject) => {
+    const config = createWatermarkConfig(type, location);
+    
+    uni.chooseImage({
+      count: 1,
+      sizeType: ['compressed'],
+      sourceType: ['camera'],
+      success: (res) => {
+        const tempFilePath = res.tempFilePaths[0];
+        
+        const photo: CheckinPhoto = {
+          id: `photo_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
+          path: tempFilePath,
+          watermarkPath: tempFilePath,
+          timestamp: Date.now(),
+          location: { ...location }
+        };
+        
+        resolve(photo);
+      },
+      fail: (err) => {
+        reject(new Error('拍照失败'));
+      }
+    });
+  });
+}
+
+export function saveOfflineCheckin(record: CheckinRecord): void {
+  try {
+    const records = getOfflineCheckins();
+    records.unshift(record);
+    
+    if (records.length > MAX_OFFLINE_RECORDS) {
+      records.splice(MAX_OFFLINE_RECORDS);
+    }
+    
+    uni.setStorageSync(OFFLINE_CHECKIN_KEY, JSON.stringify(records));
+  } catch (error) {
+    console.error('保存离线签到记录失败', error);
+  }
+}
+
+export function getOfflineCheckins(): CheckinRecord[] {
+  try {
+    const data = uni.getStorageSync(OFFLINE_CHECKIN_KEY);
+    return data ? JSON.parse(data) : [];
+  } catch (error) {
+    console.error('读取离线签到记录失败', error);
+    return [];
+  }
+}
+
+export function removeOfflineCheckin(offlineId: string): void {
+  try {
+    const records = getOfflineCheckins();
+    const index = records.findIndex(r => r.offlineId === offlineId || r.id === offlineId);
+    if (index > -1) {
+      records.splice(index, 1);
+      uni.setStorageSync(OFFLINE_CHECKIN_KEY, JSON.stringify(records));
+    }
+  } catch (error) {
+    console.error('删除离线签到记录失败', error);
+  }
+}
+
+export function updateOfflineCheckinStatus(offlineId: string, status: 'synced' | 'failed'): void {
+  try {
+    const records = getOfflineCheckins();
+    const record = records.find(r => r.offlineId === offlineId || r.id === offlineId);
+    if (record) {
+      record.status = status;
+      if (status === 'synced') {
+        record.syncedAt = new Date().toISOString();
+      }
+      uni.setStorageSync(OFFLINE_CHECKIN_KEY, JSON.stringify(records));
+    }
+  } catch (error) {
+    console.error('更新离线签到状态失败', error);
+  }
+}
+
+export function generateOfflineId(): string {
+  return `offline_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
+}
+
+export function isOnline(): boolean {
+  const networkInfo = uni.getNetworkType && uni.getNetworkType();
+  return true;
+}
+
+export function checkLocationPermission(): Promise<boolean> {
+  return new Promise((resolve) => {
+    // #ifdef MP-WEIXIN
+    uni.getSetting({
+      success: (res) => {
+        if (res.authSetting['scope.userLocation'] === false) {
+          uni.showModal({
+            title: '定位权限',
+            content: '需要获取您的位置信息用于签到,请在设置中开启',
+            success: (modalRes) => {
+              if (modalRes.confirm) {
+                uni.openSetting();
+              }
+            }
+          });
+          resolve(false);
+        } else {
+          resolve(true);
+        }
+      },
+      fail: () => {
+        resolve(true);
+      }
+    });
+    // #endif
+    
+    // #ifndef MP-WEIXIN
+    resolve(true);
+    // #endif
+  });
+}
+
+export function checkCameraPermission(): Promise<boolean> {
+  return new Promise((resolve) => {
+    // #ifdef MP-WEIXIN
+    uni.getSetting({
+      success: (res) => {
+        if (res.authSetting['scope.camera'] === false) {
+          uni.showModal({
+            title: '相机权限',
+            content: '需要使用相机拍照签到,请在设置中开启',
+            success: (modalRes) => {
+              if (modalRes.confirm) {
+                uni.openSetting();
+              }
+            }
+          });
+          resolve(false);
+        } else {
+          resolve(true);
+        }
+      },
+      fail: () => {
+        resolve(true);
+      }
+    });
+    // #endif
+    
+    // #ifndef MP-WEIXIN
+    resolve(true);
+    // #endif
+  });
+}
+
+export function validateCheckinPermission(type: CheckinType): boolean {
+  const userInfo = getUserInfo();
+  if (!userInfo) {
+    return false;
+  }
+  
+  const permissions = userInfo.permissions || [];
+  
+  if (type === CheckinType.INVENTORY_TALLY) {
+    return permissions.includes('inventory:checkin') || permissions.includes('inventory:view');
+  }
+  
+  if (type === CheckinType.DELIVERY_REPLENISH) {
+    return permissions.includes('delivery:checkin') || permissions.includes('inventory:edit');
+  }
+  
+  return true;
+}
+
+export function formatCheckinDuration(createdAt: string): string {
+  const date = new Date(createdAt);
+  const now = new Date();
+  const diff = now.getTime() - date.getTime();
+  
+  const minutes = Math.floor(diff / (1000 * 60));
+  const hours = Math.floor(diff / (1000 * 60 * 60));
+  const days = Math.floor(diff / (1000 * 60 * 60 * 24));
+  
+  if (minutes < 1) {
+    return '刚刚';
+  } else if (minutes < 60) {
+    return `${minutes}分钟前`;
+  } else if (hours < 24) {
+    return `${hours}小时前`;
+  } else if (days < 7) {
+    return `${days}天前`;
+  } else {
+    return createdAt.split(' ')[0];
+  }
+}

+ 7 - 1
haha-admin/pom.xml

@@ -1,4 +1,4 @@
-<?xml version="1.0" encoding="UTF-8"?>
+ <?xml version="1.0" encoding="UTF-8"?>
 <project xmlns="http://maven.apache.org/POM/4.0.0"
          xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
          xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
@@ -16,6 +16,12 @@
     <description>运营平台后端</description>
 
     <dependencies>
+
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-actuator</artifactId>
+        </dependency>
+
         <!-- 依赖Service模块 -->
         <dependency>
             <groupId>com.haha</groupId>

+ 222 - 0
haha-admin/src/main/java/com/haha/admin/controller/CheckinController.java

@@ -0,0 +1,222 @@
+package com.haha.admin.controller;
+
+import cn.dev33.satoken.stp.StpUtil;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.haha.admin.annotation.RequirePermission;
+import com.haha.entity.dto.CheckinQueryDTO;
+import com.haha.entity.dto.CheckinSubmitDTO;
+import com.haha.common.annotation.Log;
+import com.haha.common.enums.OperationType;
+import com.haha.common.vo.Result;
+import com.haha.entity.CheckinRecord;
+import com.haha.service.CheckinRecordService;
+import jakarta.validation.Valid;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 签到管理控制器
+ */
+@Slf4j
+@RestController
+@RequestMapping("/checkin")
+public class CheckinController {
+
+    @Autowired
+    private CheckinRecordService checkinRecordService;
+
+    /**
+     * 提交签到
+     */
+    @RequirePermission("checkin:create")
+    @Log(module = "签到管理", operation = OperationType.INSERT, summary = "提交签到")
+    @PostMapping
+    public Result<Map<String, Object>> submitCheckin(@Valid @RequestBody CheckinSubmitDTO dto) {
+        try {
+            Long userId = StpUtil.getLoginIdAsLong();
+            String userName = StpUtil.getSession().getString("userName");
+            String userNo = StpUtil.getSession().getString("userNo");
+
+            if (userName == null) {
+                userName = StpUtil.getLoginIdAsString();
+            }
+            if (userNo == null) {
+                userNo = "N/A";
+            }
+
+            CheckinRecord record = checkinRecordService.submitCheckin(dto, userId, userName, userNo);
+
+            Map<String, Object> result = new HashMap<>();
+            result.put("id", record.getId());
+            result.put("type", record.getCheckinType());
+            result.put("typeName", "inventory_tally".equals(record.getCheckinType()) ? "理货盘点签到" : "补货确认签到");
+            result.put("status", record.getStatus());
+            result.put("checkinTime", record.getCheckinTime());
+
+            return Result.success("签到成功", result);
+        } catch (Exception e) {
+            log.error("提交签到失败", e);
+            return Result.error("签到失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 分页查询签到记录
+     */
+    @RequirePermission("checkin:read")
+    @GetMapping
+    public Result<Map<String, Object>> getCheckinList(CheckinQueryDTO query) {
+        try {
+            IPage<Map<String, Object>> pageResult = checkinRecordService.getCheckinPage(query);
+
+            Map<String, Object> result = new HashMap<>();
+            result.put("list", pageResult.getRecords());
+            result.put("total", pageResult.getTotal());
+            result.put("page", query.getPage());
+            result.put("pageSize", query.getPageSize());
+
+            return Result.success(result);
+        } catch (Exception e) {
+            log.error("查询签到记录失败", e);
+            return Result.error("查询失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 获取签到详情
+     */
+    @RequirePermission("checkin:read")
+    @GetMapping("/{id}")
+    public Result<Map<String, Object>> getCheckinDetail(@PathVariable Long id) {
+        try {
+            Map<String, Object> detail = checkinRecordService.getCheckinDetail(id);
+            if (detail == null) {
+                return Result.error("签到记录不存在");
+            }
+            return Result.success(detail);
+        } catch (Exception e) {
+            log.error("获取签到详情失败", e);
+            return Result.error("获取详情失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 获取签到统计数据
+     */
+    @RequirePermission("checkin:read")
+    @GetMapping("/stats")
+    public Result<Map<String, Object>> getCheckinStats() {
+        try {
+            Map<String, Object> stats = checkinRecordService.getCheckinStats();
+            return Result.success(stats);
+        } catch (Exception e) {
+            log.error("获取签到统计失败", e);
+            return Result.error("获取统计失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 获取今日签到状态
+     */
+    @RequirePermission("checkin:read")
+    @GetMapping("/today")
+    public Result<Map<String, Object>> getTodayCheckin() {
+        try {
+            Long userId = StpUtil.getLoginIdAsLong();
+            Map<String, Object> todayCheckin = checkinRecordService.getTodayCheckin(userId);
+            return Result.success(todayCheckin);
+        } catch (Exception e) {
+            log.error("获取今日签到状态失败", e);
+            return Result.error("获取签到状态失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 同步离线签到记录
+     */
+    @RequirePermission("checkin:create")
+    @Log(module = "签到管理", operation = OperationType.INSERT, summary = "同步离线签到")
+    @PostMapping("/sync")
+    public Result<Map<String, Object>> syncOfflineCheckins(@RequestBody Map<String, Object> params) {
+        try {
+            @SuppressWarnings("unchecked")
+            List<Map<String, Object>> recordsData = (List<Map<String, Object>>) params.get("records");
+
+            if (recordsData == null || recordsData.isEmpty()) {
+                return Result.error("没有需要同步的记录");
+            }
+
+            List<CheckinRecord> records = new java.util.ArrayList<>();
+            for (Map<String, Object> data : recordsData) {
+                CheckinRecord record = new CheckinRecord();
+                record.setCheckinType((String) data.get("type"));
+                record.setUserId(StpUtil.getLoginIdAsLong());
+                record.setUserName((String) data.get("userName"));
+                record.setUserNo((String) data.get("userNo"));
+                record.setShopId(data.get("shopId") != null ? Long.valueOf(data.get("shopId").toString()) : null);
+                record.setDeviceId((String) data.get("deviceId"));
+
+                @SuppressWarnings("unchecked")
+                Map<String, Object> location = (Map<String, Object>) data.get("location");
+                if (location != null) {
+                    record.setLatitude(new java.math.BigDecimal(location.get("latitude").toString()));
+                    record.setLongitude(new java.math.BigDecimal(location.get("longitude").toString()));
+                    record.setAddress((String) location.get("address"));
+                    record.setAccuracy(location.get("accuracy") != null ? Integer.valueOf(location.get("accuracy").toString()) : null);
+                }
+
+                @SuppressWarnings("unchecked")
+                List<Map<String, Object>> photos = (List<Map<String, Object>>) data.get("photos");
+                if (photos != null) {
+                    record.setPhotos(com.alibaba.fastjson2.JSON.toJSONString(photos));
+                }
+
+                record.setRemark((String) data.get("remark"));
+                record.setOfflineId((String) data.get("offlineId"));
+                record.setStatus("pending");
+
+                String createdAt = (String) data.get("createdAt");
+                if (createdAt != null) {
+                    try {
+                        record.setCheckinTime(java.time.LocalDateTime.parse(createdAt.replace(" ", "T")));
+                        record.setCreateTime(record.getCheckinTime());
+                    } catch (Exception e) {
+                        record.setCheckinTime(java.time.LocalDateTime.now());
+                        record.setCreateTime(java.time.LocalDateTime.now());
+                    }
+                } else {
+                    record.setCheckinTime(java.time.LocalDateTime.now());
+                    record.setCreateTime(java.time.LocalDateTime.now());
+                }
+
+                records.add(record);
+            }
+
+            Map<String, Object> result = checkinRecordService.syncOfflineCheckins(records);
+            return Result.success("同步完成", result);
+        } catch (Exception e) {
+            log.error("同步离线签到失败", e);
+            return Result.error("同步失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 导出签到记录
+     */
+    @RequirePermission("checkin:read")
+    @GetMapping("/export")
+    public Result<String> exportCheckinRecords(CheckinQueryDTO query) {
+        try {
+            String fileUrl = checkinRecordService.exportCheckinRecords(query);
+            return Result.success("导出成功", fileUrl);
+        } catch (Exception e) {
+            log.error("导出签到记录失败", e);
+            return Result.error("导出失败: " + e.getMessage());
+        }
+    }
+}

+ 42 - 0
haha-admin/src/main/java/com/haha/admin/dto/CheckinQueryDTO.java

@@ -0,0 +1,42 @@
+package com.haha.admin.dto;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 签到查询DTO
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class CheckinQueryDTO extends PageQueryDTO {
+
+    /**
+     * 签到类型:inventory_tally-理货盘点签到,delivery_replenish-补货确认签到
+     */
+    private String type;
+
+    /**
+     * 用户ID
+     */
+    private Long userId;
+
+    /**
+     * 门店ID
+     */
+    private Long shopId;
+
+    /**
+     * 开始日期(格式:yyyy-MM-dd)
+     */
+    private String startDate;
+
+    /**
+     * 结束日期(格式:yyyy-MM-dd)
+     */
+    private String endDate;
+
+    /**
+     * 签到状态:pending-待同步,synced-已同步,failed-同步失败
+     */
+    private String status;
+}

+ 109 - 0
haha-admin/src/main/java/com/haha/admin/dto/CheckinSubmitDTO.java

@@ -0,0 +1,109 @@
+package com.haha.admin.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * 签到提交DTO
+ */
+@Data
+public class CheckinSubmitDTO {
+
+    /**
+     * 签到类型:inventory_tally-理货盘点签到,delivery_replenish-补货确认签到
+     */
+    @NotBlank(message = "签到类型不能为空")
+    private String type;
+
+    /**
+     * 关联门店ID
+     */
+    private Long shopId;
+
+    /**
+     * 关联设备ID
+     */
+    private String deviceId;
+
+    /**
+     * 位置信息
+     */
+    @NotNull(message = "位置信息不能为空")
+    private LocationDTO location;
+
+    /**
+     * 签到照片列表
+     */
+    @NotNull(message = "签到照片不能为空")
+    private List<PhotoDTO> photos;
+
+    /**
+     * 备注信息
+     */
+    private String remark;
+
+    /**
+     * 离线ID(离线签到时生成)
+     */
+    private String offlineId;
+
+    /**
+     * 位置信息DTO
+     */
+    @Data
+    public static class LocationDTO {
+        /**
+         * 纬度
+         */
+        @NotNull(message = "纬度不能为空")
+        private BigDecimal latitude;
+
+        /**
+         * 经度
+         */
+        @NotNull(message = "经度不能为空")
+        private BigDecimal longitude;
+
+        /**
+         * 地址
+         */
+        @NotBlank(message = "地址不能为空")
+        private String address;
+
+        /**
+         * 精度(米)
+         */
+        private Integer accuracy;
+    }
+
+    /**
+     * 照片DTO
+     */
+    @Data
+    public static class PhotoDTO {
+        /**
+         * 照片ID
+         */
+        private String id;
+
+        /**
+         * 照片OSS链接
+         */
+        @NotBlank(message = "照片链接不能为空")
+        private String path;
+
+        /**
+         * 水印照片OSS链接
+         */
+        private String watermarkPath;
+
+        /**
+         * 拍摄时间戳
+         */
+        private Long timestamp;
+    }
+}

+ 1 - 1
haha-admin/src/main/resources/application.yml

@@ -84,7 +84,7 @@ sa-token:
   # token 有效期(单位:秒) - 8小时
   timeout: 28800
   # token 临时有效期(单位:秒)
-  activity-timeout: -1
+  active-timeout: -1
   # 是否允许同一账号多地同时登录
   is-concurrent: false
   # 同一账号最大登录数量

+ 6 - 0
haha-entity/pom.xml

@@ -33,6 +33,12 @@
             <groupId>com.alibaba</groupId>
             <artifactId>fastjson</artifactId>
         </dependency>
+
+        <!-- Jakarta Validation API -->
+        <dependency>
+            <groupId>jakarta.validation</groupId>
+            <artifactId>jakarta.validation-api</artifactId>
+        </dependency>
     </dependencies>
 
 </project>

+ 122 - 0
haha-entity/src/main/java/com/haha/entity/CheckinRecord.java

@@ -0,0 +1,122 @@
+package com.haha.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/**
+ * 签到记录实体类
+ */
+@Data
+@TableName("t_checkin_record")
+public class CheckinRecord implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /**
+     * 签到类型:inventory_tally-理货盘点签到,delivery_replenish-补货确认签到
+     */
+    private String checkinType;
+
+    /**
+     * 签到用户ID
+     */
+    private Long userId;
+
+    /**
+     * 签到用户名
+     */
+    private String userName;
+
+    /**
+     * 签到用户工号
+     */
+    private String userNo;
+
+    /**
+     * 关联门店ID
+     */
+    private Long shopId;
+
+    /**
+     * 关联门店名称
+     */
+    private String shopName;
+
+    /**
+     * 关联设备ID
+     */
+    private String deviceId;
+
+    /**
+     * 关联设备名称
+     */
+    private String deviceName;
+
+    /**
+     * 签到纬度
+     */
+    private BigDecimal latitude;
+
+    /**
+     * 签到经度
+     */
+    private BigDecimal longitude;
+
+    /**
+     * 签到地址
+     */
+    private String address;
+
+    /**
+     * 定位精度(米)
+     */
+    private Integer accuracy;
+
+    /**
+     * 签到照片链接(JSON数组,存储OSS链接)
+     */
+    private String photos;
+
+    /**
+     * 备注信息
+     */
+    private String remark;
+
+    /**
+     * 签到状态:pending-待同步,synced-已同步,failed-同步失败
+     */
+    private String status;
+
+    /**
+     * 离线ID(离线签到时生成)
+     */
+    private String offlineId;
+
+    /**
+     * 同步时间
+     */
+    private LocalDateTime syncedAt;
+
+    /**
+     * 签到时间
+     */
+    private LocalDateTime checkinTime;
+
+    /**
+     * 创建时间
+     */
+    private LocalDateTime createTime;
+
+    /**
+     * 更新时间
+     */
+    private LocalDateTime updateTime;
+}

+ 42 - 0
haha-entity/src/main/java/com/haha/entity/dto/CheckinQueryDTO.java

@@ -0,0 +1,42 @@
+package com.haha.entity.dto;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 签到查询DTO
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class CheckinQueryDTO extends PageQueryDTO {
+
+    /**
+     * 签到类型:inventory_tally-理货盘点签到,delivery_replenish-补货确认签到
+     */
+    private String type;
+
+    /**
+     * 用户ID
+     */
+    private Long userId;
+
+    /**
+     * 门店ID
+     */
+    private Long shopId;
+
+    /**
+     * 开始日期(格式:yyyy-MM-dd)
+     */
+    private String startDate;
+
+    /**
+     * 结束日期(格式:yyyy-MM-dd)
+     */
+    private String endDate;
+
+    /**
+     * 签到状态:pending-待同步,synced-已同步,failed-同步失败
+     */
+    private String status;
+}

+ 109 - 0
haha-entity/src/main/java/com/haha/entity/dto/CheckinSubmitDTO.java

@@ -0,0 +1,109 @@
+package com.haha.entity.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * 签到提交DTO
+ */
+@Data
+public class CheckinSubmitDTO {
+
+    /**
+     * 签到类型:inventory_tally-理货盘点签到,delivery_replenish-补货确认签到
+     */
+    @NotBlank(message = "签到类型不能为空")
+    private String type;
+
+    /**
+     * 关联门店ID
+     */
+    private Long shopId;
+
+    /**
+     * 关联设备ID
+     */
+    private String deviceId;
+
+    /**
+     * 位置信息
+     */
+    @NotNull(message = "位置信息不能为空")
+    private LocationDTO location;
+
+    /**
+     * 签到照片列表
+     */
+    @NotNull(message = "签到照片不能为空")
+    private List<PhotoDTO> photos;
+
+    /**
+     * 备注信息
+     */
+    private String remark;
+
+    /**
+     * 离线ID(离线签到时生成)
+     */
+    private String offlineId;
+
+    /**
+     * 位置信息DTO
+     */
+    @Data
+    public static class LocationDTO {
+        /**
+         * 纬度
+         */
+        @NotNull(message = "纬度不能为空")
+        private BigDecimal latitude;
+
+        /**
+         * 经度
+         */
+        @NotNull(message = "经度不能为空")
+        private BigDecimal longitude;
+
+        /**
+         * 地址
+         */
+        @NotBlank(message = "地址不能为空")
+        private String address;
+
+        /**
+         * 精度(米)
+         */
+        private Integer accuracy;
+    }
+
+    /**
+     * 照片DTO
+     */
+    @Data
+    public static class PhotoDTO {
+        /**
+         * 照片ID
+         */
+        private String id;
+
+        /**
+         * 照片OSS链接
+         */
+        @NotBlank(message = "照片链接不能为空")
+        private String path;
+
+        /**
+         * 水印照片OSS链接
+         */
+        private String watermarkPath;
+
+        /**
+         * 拍摄时间戳
+         */
+        private Long timestamp;
+    }
+}

+ 42 - 0
haha-entity/src/main/java/com/haha/entity/dto/PageQueryDTO.java

@@ -0,0 +1,42 @@
+package com.haha.entity.dto;
+
+import lombok.Data;
+
+/**
+ * 分页查询基础DTO
+ */
+@Data
+public class PageQueryDTO {
+    
+    /**
+     * 当前页码,默认第1页
+     */
+    private Integer page = 1;
+    
+    /**
+     * 每页条数,默认10条
+     */
+    private Integer pageSize = 10;
+    
+    /**
+     * 获取偏移量
+     */
+    public int getOffset() {
+        return (page - 1) * pageSize;
+    }
+    
+    /**
+     * 校验并修正分页参数
+     */
+    public void validate() {
+        if (page == null || page < 1) {
+            page = 1;
+        }
+        if (pageSize == null || pageSize < 1) {
+            pageSize = 10;
+        }
+        if (pageSize > 100) {
+            pageSize = 100;
+        }
+    }
+}

+ 118 - 0
haha-mapper/src/main/java/com/haha/mapper/CheckinRecordMapper.java

@@ -0,0 +1,118 @@
+package com.haha.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.haha.entity.CheckinRecord;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+import java.time.LocalDate;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 签到记录Mapper
+ */
+@Mapper
+public interface CheckinRecordMapper extends BaseMapper<CheckinRecord> {
+
+    /**
+     * 分页查询签到记录
+     */
+    @Select("<script>" +
+            "SELECT cr.*, " +
+            "  s.name as shop_name, " +
+            "  d.name as device_name " +
+            "FROM t_checkin_record cr " +
+            "LEFT JOIN t_shop s ON cr.shop_id = s.id " +
+            "LEFT JOIN t_device d ON cr.device_id = d.device_id " +
+            "WHERE 1=1 " +
+            "<if test='checkinType != null and checkinType != \"\"'> " +
+            "  AND cr.checkin_type = #{checkinType} " +
+            "</if> " +
+            "<if test='userId != null'> " +
+            "  AND cr.user_id = #{userId} " +
+            "</if> " +
+            "<if test='shopId != null'> " +
+            "  AND cr.shop_id = #{shopId} " +
+            "</if> " +
+            "<if test='status != null and status != \"\"'> " +
+            "  AND cr.status = #{status} " +
+            "</if> " +
+            "<if test='startDate != null and startDate != \"\"'> " +
+            "  AND DATE(cr.checkin_time) &gt;= #{startDate} " +
+            "</if> " +
+            "<if test='endDate != null and endDate != \"\"'> " +
+            "  AND DATE(cr.checkin_time) &lt;= #{endDate} " +
+            "</if> " +
+            "ORDER BY cr.checkin_time DESC " +
+            "</script>")
+    IPage<Map<String, Object>> selectCheckinPage(
+            Page<Map<String, Object>> page,
+            @Param("checkinType") String checkinType,
+            @Param("userId") Long userId,
+            @Param("shopId") Long shopId,
+            @Param("status") String status,
+            @Param("startDate") String startDate,
+            @Param("endDate") String endDate);
+
+    /**
+     * 查询用户今日是否已签到(指定类型)
+     */
+    @Select("SELECT COUNT(*) FROM t_checkin_record " +
+            "WHERE user_id = #{userId} " +
+            "AND checkin_type = #{checkinType} " +
+            "AND DATE(checkin_time) = CURDATE()")
+    int countTodayCheckinByUserAndType(@Param("userId") Long userId, @Param("checkinType") String checkinType);
+
+    /**
+     * 查询用户今日签到记录
+     */
+    @Select("SELECT * FROM t_checkin_record " +
+            "WHERE user_id = #{userId} " +
+            "AND DATE(checkin_time) = CURDATE() " +
+            "ORDER BY checkin_time DESC")
+    List<CheckinRecord> selectTodayCheckinByUser(@Param("userId") Long userId);
+
+    /**
+     * 统计签到数据
+     */
+    @Select("SELECT " +
+            "  COUNT(CASE WHEN DATE(checkin_time) = CURDATE() THEN 1 END) as today_count, " +
+            "  COUNT(CASE WHEN YEARWEEK(checkin_time, 1) = YEARWEEK(CURDATE(), 1) THEN 1 END) as week_count, " +
+            "  COUNT(CASE WHEN DATE_FORMAT(checkin_time, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m') THEN 1 END) as month_count, " +
+            "  COUNT(CASE WHEN checkin_type = 'inventory_tally' THEN 1 END) as inventory_tally_count, " +
+            "  COUNT(CASE WHEN checkin_type = 'delivery_replenish' THEN 1 END) as delivery_replenish_count, " +
+            "  COUNT(CASE WHEN status = 'pending' THEN 1 END) as pending_sync_count " +
+            "FROM t_checkin_record")
+    Map<String, Object> selectCheckinStats();
+
+    /**
+     * 根据离线ID查询签到记录
+     */
+    @Select("SELECT * FROM t_checkin_record WHERE offline_id = #{offlineId}")
+    CheckinRecord selectByOfflineId(@Param("offlineId") String offlineId);
+
+    /**
+     * 查询待同步的签到记录
+     */
+    @Select("SELECT * FROM t_checkin_record WHERE status = 'pending' ORDER BY checkin_time ASC LIMIT #{limit}")
+    List<CheckinRecord> selectPendingSyncRecords(@Param("limit") int limit);
+
+    /**
+     * 按日期统计签到数量
+     */
+    @Select("SELECT " +
+            "  DATE(checkin_time) as date, " +
+            "  COUNT(*) as count " +
+            "FROM t_checkin_record " +
+            "WHERE checkin_time >= #{startDate} " +
+            "AND checkin_time &lt;= #{endDate} " +
+            "GROUP BY DATE(checkin_time) " +
+            "ORDER BY date ASC")
+    List<Map<String, Object>> selectCheckinCountByDateRange(
+            @Param("startDate") LocalDate startDate,
+            @Param("endDate") LocalDate endDate);
+}

+ 10 - 0
haha-mapper/src/main/java/com/haha/mapper/DeviceMapper.java

@@ -2,6 +2,16 @@ package com.haha.mapper;
 
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.haha.entity.Device;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
 
+@Mapper
 public interface DeviceMapper extends BaseMapper<Device> {
+
+    /**
+     * 根据设备ID查询设备
+     */
+    @Select("SELECT * FROM t_device WHERE device_id = #{deviceId}")
+    Device selectByDeviceId(@Param("deviceId") String deviceId);
 }

+ 1 - 1
haha-miniapp/pom.xml

@@ -74,7 +74,7 @@
                 <groupId>org.springframework.boot</groupId>
                 <artifactId>spring-boot-maven-plugin</artifactId>
                 <configuration>
-                    <mainClass>com.haha.miniapp.Application</mainClass>
+                    <mainClass>com.haha.miniapp.MiniappApplication</mainClass>
                 </configuration>
                 <executions>
                     <execution>

+ 50 - 0
haha-miniapp/src/main/java/com/haha/common/constant/OrderConstants.java

@@ -0,0 +1,50 @@
+package com.haha.common.constant;
+
+/**
+ * 订单相关常量
+ */
+public class OrderConstants {
+
+    /**
+     * 订单状态
+     */
+    public static final int STATUS_PENDING_PAYMENT = 0; // 待支付
+    public static final int STATUS_COMPLETED = 1; // 已完成
+    public static final int STATUS_CANCELLED = 2; // 已取消
+    
+    /**
+     * 支付状态
+     */
+    public static final int PAY_STATUS_UNPAID = 0; // 未支付
+    public static final int PAY_STATUS_PAID = 1; // 已支付
+    public static final int PAY_STATUS_REFUNDED = 2; // 已退款
+    
+    /**
+     * 订单类型
+     */
+    public static final int ORDER_TYPE_CONSUME = 1; // 消费订单
+    public static final int ORDER_TYPE_REPLENISH = 2; // 补货订单
+    
+    /**
+     * 订单操作
+     */
+    public static final int OPERATION_CREATE = 1; // 创建
+    public static final int OPERATION_PAY = 2; // 支付
+    public static final int OPERATION_CANCEL = 3; // 取消
+    public static final int OPERATION_REFUND = 4; // 退款
+    
+    /**
+     * 订单号前缀
+     */
+    public static final String ORDER_NO_PREFIX = "ORDER";
+    public static final String OUT_TRADE_NO_PREFIX = "OUT";
+    
+    /**
+     * 订单相关消息
+     */
+    public static final String MESSAGE_ORDER_CREATED = "订单创建成功";
+    public static final String MESSAGE_ORDER_PAID = "订单支付成功";
+    public static final String MESSAGE_ORDER_CANCELLED = "订单已取消";
+    public static final String MESSAGE_ORDER_REFUNDED = "订单已退款";
+    public static final String MESSAGE_ORDER_FAILED = "订单处理失败";
+}

+ 2 - 2
haha-miniapp/src/main/java/com/haha/miniapp/Application.java → haha-miniapp/src/main/java/com/haha/miniapp/MiniappApplication.java

@@ -15,7 +15,7 @@ import org.springframework.core.Ordered;
 @SpringBootApplication
 @ComponentScan(basePackages = {"com.haha"})
 @MapperScan("com.haha.mapper")
-public class Application {
+public class MiniappApplication {
 
     @Bean
     public static BeanFactoryPostProcessor beanFactoryPostProcessor() {
@@ -47,6 +47,6 @@ public class Application {
     }
 
     public static void main(String[] args) {
-        SpringApplication.run(Application.class, args);
+        SpringApplication.run(MiniappApplication.class, args);
     }
 }

+ 3 - 3
haha-miniapp/src/main/java/com/haha/miniapp/config/HahaSdkConfig.java

@@ -30,7 +30,7 @@ public class HahaSdkConfig {
     public HahaClient hahaClient() {
         // 创建SDK配置
         HahaConfig config = HahaConfig.builder()
-                .apiBaseUrl(apiBaseUrl)  // 新SDK使用apiBaseUrl
+                .apiBaseUrl(apiBaseUrl)
                 .appId(appId)
                 .appSecret(appSecret)
                 .connectTimeout(10)
@@ -38,10 +38,10 @@ public class HahaSdkConfig {
                 .writeTimeout(30)
                 .enableLog(true)
                 .build();
-        
+
         // 校验配置
         config.validate();
-        
+
         // 创建客户端
         return new HahaClient(config);
     }

+ 8 - 26
haha-miniapp/src/main/java/com/haha/miniapp/controller/AppAnnouncementController.java

@@ -1,14 +1,13 @@
 package com.haha.miniapp.controller;
 
+import com.haha.common.vo.Result;
 import com.haha.entity.Announcement;
 import com.haha.service.AnnouncementService;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.web.bind.annotation.*;
 
-import java.util.HashMap;
 import java.util.List;
-import java.util.Map;
 
 /**
  * 小程序公告接口
@@ -28,21 +27,14 @@ public class AppAnnouncementController {
      * @return 公告列表
      */
     @GetMapping("/list")
-    public Map<String, Object> getList(@RequestParam(defaultValue = "10") int limit) {
-        Map<String, Object> result = new HashMap<>();
-
+    public Result<List<Announcement>> getList(@RequestParam(defaultValue = "10") int limit) {
         try {
             List<Announcement> list = announcementService.getAppList(limit);
-            result.put("code", 200);
-            result.put("message", "success");
-            result.put("data", list);
+            return Result.success("success", list);
         } catch (Exception e) {
             log.error("获取公告列表失败", e);
-            result.put("code", 500);
-            result.put("message", "获取公告列表失败");
+            return Result.error("获取公告列表失败");
         }
-
-        return result;
     }
 
     /**
@@ -52,26 +44,16 @@ public class AppAnnouncementController {
      * @return 公告详情
      */
     @GetMapping("/detail")
-    public Map<String, Object> getDetail(@RequestParam Long id) {
-        Map<String, Object> result = new HashMap<>();
-
+    public Result<Announcement> getDetail(@RequestParam Long id) {
         try {
             Announcement announcement = announcementService.getDetail(id);
             if (announcement == null) {
-                result.put("code", 404);
-                result.put("message", "公告不存在");
-                return result;
+                return Result.error(404, "公告不存在");
             }
-
-            result.put("code", 200);
-            result.put("message", "success");
-            result.put("data", announcement);
+            return Result.success("success", announcement);
         } catch (Exception e) {
             log.error("获取公告详情失败", e);
-            result.put("code", 500);
-            result.put("message", "获取公告详情失败");
+            return Result.error("获取公告详情失败");
         }
-
-        return result;
     }
 }

+ 64 - 0
haha-miniapp/src/main/java/com/haha/miniapp/controller/CallbackController.java

@@ -10,6 +10,7 @@ import com.haha.service.OrderService;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Value;
+import org.springframework.data.redis.core.StringRedisTemplate;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestMapping;
@@ -21,6 +22,7 @@ import java.io.BufferedReader;
 import java.time.LocalDateTime;
 import java.util.HashMap;
 import java.util.Map;
+import java.util.concurrent.TimeUnit;
 
 /**
  * 哈哈平台回调通知接口
@@ -53,9 +55,16 @@ public class CallbackController {
     @Autowired
     private OrderService orderService;
 
+    @Autowired
+    private StringRedisTemplate redisTemplate;
+
     @Value("${haha.api.app-secret}")
     private String appSecret;
 
+    private static final String DEVICE_STATUS_KEY = "haha:device:status:";
+    private static final String RECOGNIZE_RESULT_KEY = "haha:recognize:result:";
+    private static final String ORDER_INFO_KEY = "haha:order:info:";
+
     /**
      * 消息回调通知统一入口
      *
@@ -144,6 +153,20 @@ public class CallbackController {
             log.info("开关门状态通知 - 设备: {}, 状态: {}, 类型: {}, 用户: {}",
                 deviceId, status, openType, outUserId);
 
+            // 保存设备状态到Redis,供小程序轮询查询
+            String statusKey = DEVICE_STATUS_KEY + deviceId;
+            Map<String, String> statusData = new HashMap<>();
+            statusData.put("deviceId", deviceId);
+            statusData.put("activityId", activityId != null ? activityId : "");
+            statusData.put("status", status);
+            statusData.put("openType", openType != null ? openType : "");
+            statusData.put("userId", outUserId != null ? outUserId : "");
+            statusData.put("doorStatus", convertDoorStatus(status));
+            statusData.put("timestamp", String.valueOf(System.currentTimeMillis()));
+            
+            redisTemplate.opsForHash().putAll(statusKey, statusData);
+            redisTemplate.expire(statusKey, 30, TimeUnit.MINUTES);
+
             // 可以根据业务需要处理不同状态
             switch (status) {
                 case "2": // OPENED
@@ -165,6 +188,17 @@ public class CallbackController {
         }
     }
 
+    private String convertDoorStatus(String status) {
+        if (status == null) return "unknown";
+        switch (status) {
+            case "2": return "open";
+            case "3": return "close";
+            case "1": return "error";
+            case "4": return "busy";
+            default: return "unknown";
+        }
+    }
+
     /**
      * 处理设备在线状态通知 (ONLINE_STATUS)
      *
@@ -283,6 +317,21 @@ public class CallbackController {
             log.info("AI识别结果通知 - 设备: {}, 活动: {}, 是否消费: {}",
                 deviceId, activityId, nobuy != null && nobuy == 1 ? "无消费" : "有消费");
 
+            // 保存识别结果到Redis,供小程序轮询查询
+            String resultKey = RECOGNIZE_RESULT_KEY + activityId;
+            Map<String, String> recognizeData = new HashMap<>();
+            recognizeData.put("activityId", activityId != null ? activityId : "");
+            recognizeData.put("deviceId", deviceId != null ? deviceId : "");
+            recognizeData.put("userId", userId != null ? userId : "");
+            recognizeData.put("nobuy", nobuy != null ? String.valueOf(nobuy) : "0");
+            recognizeData.put("result", resultStr != null ? resultStr : "");
+            recognizeData.put("skuList", skuListStr != null ? skuListStr : "");
+            recognizeData.put("resourceInfo", resourceInfoStr != null ? resourceInfoStr : "");
+            recognizeData.put("timestamp", String.valueOf(System.currentTimeMillis()));
+            
+            redisTemplate.opsForHash().putAll(resultKey, recognizeData);
+            redisTemplate.expire(resultKey, 30, TimeUnit.MINUTES);
+
             if (nobuy != null && nobuy == 1) {
                 log.info("用户打开柜门但未消费,无需处理");
                 return;
@@ -400,6 +449,21 @@ public class CallbackController {
                 log.error("订单信息更新失败: {}", orderId);
             }
 
+            // 保存订单信息到Redis,供小程序轮询查询
+            String orderKey = ORDER_INFO_KEY + activityId;
+            Map<String, String> orderData = new HashMap<>();
+            orderData.put("orderId", orderId != null ? orderId : "");
+            orderData.put("activityId", activityId != null ? activityId : "");
+            orderData.put("deviceId", deviceId != null ? deviceId : "");
+            orderData.put("userId", userId != null ? userId : "");
+            orderData.put("orderName", orderName != null ? orderName : "");
+            orderData.put("totalAmount", orderMoney != null ? orderMoney.toString() : "0");
+            orderData.put("products", orderDetail != null ? orderDetail : "");
+            orderData.put("timestamp", String.valueOf(System.currentTimeMillis()));
+            
+            redisTemplate.opsForHash().putAll(orderKey, orderData);
+            redisTemplate.expire(orderKey, 30, TimeUnit.MINUTES);
+
             // 5. TODO: 根据业务需要进行后续处理
             // - 触发支付流程
             // - 发送通知给用户

+ 4 - 9
haha-miniapp/src/main/java/com/haha/miniapp/controller/HealthController.java

@@ -1,23 +1,18 @@
 package com.haha.miniapp.controller;
 
 import cn.dev33.satoken.annotation.SaIgnore;
+import com.haha.common.vo.Result;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RestController;
 
-import java.util.HashMap;
-import java.util.Map;
-
 @RestController
 @RequestMapping("/health")
-@SaIgnore // 健康检查接口,不需要token验证
+@SaIgnore
 public class HealthController {
 
     @GetMapping("/check")
-    public Map<String, Object> healthCheck() {
-        Map<String, Object> result = new HashMap<>();
-        result.put("status", "ok");
-        result.put("message", "服务运行正常");
-        return result;
+    public Result<String> healthCheck() {
+        return Result.success("服务运行正常", "ok");
     }
 }

+ 18 - 73
haha-miniapp/src/main/java/com/haha/miniapp/controller/OrderController.java

@@ -4,6 +4,7 @@ import cn.dev33.satoken.stp.StpUtil;
 import com.alibaba.fastjson2.JSON;
 import com.alibaba.fastjson2.JSONArray;
 import com.alibaba.fastjson2.JSONObject;
+import com.haha.common.vo.Result;
 import com.haha.entity.Order;
 import com.haha.service.OrderService;
 import lombok.extern.slf4j.Slf4j;
@@ -39,24 +40,18 @@ public class OrderController {
      * @return 订单列表
      */
     @PostMapping("/list")
-    public Map<String, Object> getOrderList(@RequestBody(required = false) Map<String, Object> params) {
-        Map<String, Object> result = new HashMap<>();
-
+    public Result<List<Map<String, Object>>> getOrderList(@RequestBody(required = false) Map<String, Object> params) {
         try {
-            // 获取当前登录用户ID
             Long userId = StpUtil.getLoginIdAsLong();
             log.info("用户 {} 请求订单列表", userId);
 
-            // 获取状态参数
             Integer status = null;
             if (params != null && params.containsKey("status")) {
                 status = Integer.valueOf(params.get("status").toString());
             }
 
-            // 查询订单列表
             List<Order> orders = orderService.getOrderListByUserId(userId, status);
 
-            // 转换为前端需要的格式
             List<Map<String, Object>> orderList = new ArrayList<>();
             for (Order order : orders) {
                 Map<String, Object> orderMap = new HashMap<>();
@@ -71,7 +66,6 @@ public class OrderController {
                 orderMap.put("payTime", order.getPayTime());
                 orderMap.put("confidence", order.getConfidence());
 
-                // 解析商品信息
                 if (order.getItems() != null && !order.getItems().isEmpty()) {
                     try {
                         JSONArray items = JSON.parseArray(order.getItems());
@@ -98,19 +92,13 @@ public class OrderController {
                 orderList.add(orderMap);
             }
 
-            result.put("code", 200);
-            result.put("message", "查询成功");
-            result.put("data", orderList);
-
             log.info("用户 {} 查询到 {} 条订单", userId, orderList.size());
+            return Result.success("查询成功", orderList);
 
         } catch (Exception e) {
             log.error("查询订单列表失败", e);
-            result.put("code", 500);
-            result.put("message", "查询订单列表失败");
+            return Result.error("查询订单列表失败");
         }
-
-        return result;
     }
 
     /**
@@ -128,17 +116,12 @@ public class OrderController {
      * @return 订单详情
      */
     @PostMapping("/detail")
-    public Map<String, Object> getOrderDetail(@RequestBody Map<String, Object> params) {
-        Map<String, Object> result = new HashMap<>();
-
+    public Result<Map<String, Object>> getOrderDetail(@RequestBody Map<String, Object> params) {
         try {
-            // 获取当前登录用户ID
             Long userId = StpUtil.getLoginIdAsLong();
 
-            // 查询订单
             Order order = null;
 
-            // 支持三种查询方式
             if (params.containsKey("orderId")) {
                 Long orderId = Long.valueOf(params.get("orderId").toString());
                 order = orderService.getById(orderId);
@@ -154,27 +137,18 @@ public class OrderController {
                 order = orderService.getOrderByOutTradeNo(outTradeNo);
                 log.info("用户 {} 通过商户订单号 {} 查询订单详情", userId, outTradeNo);
             } else {
-                result.put("code", 400);
-                result.put("message", "参数错误:必须提供 orderId、orderNo 或 outTradeNo");
-                return result;
+                return Result.error(400, "参数错误:必须提供 orderId、orderNo 或 outTradeNo");
             }
 
-            // 检查订单是否存在
             if (order == null) {
-                result.put("code", 404);
-                result.put("message", "订单不存在");
-                return result;
+                return Result.error(404, "订单不存在");
             }
 
-            // 验证订单归属
             if (!order.getUserId().equals(userId)) {
                 log.warn("用户 {} 尝试访问不属于自己的订单 {}", userId, order.getId());
-                result.put("code", 403);
-                result.put("message", "无权访问该订单");
-                return result;
+                return Result.error(403, "无权访问该订单");
             }
 
-            // 构建订单详情
             Map<String, Object> orderDetail = new HashMap<>();
             orderDetail.put("id", order.getId());
             orderDetail.put("orderNo", order.getOrderNo());
@@ -187,7 +161,6 @@ public class OrderController {
             orderDetail.put("payTime", order.getPayTime());
             orderDetail.put("confidence", order.getConfidence());
 
-            // 解析商品信息
             if (order.getItems() != null && !order.getItems().isEmpty()) {
                 try {
                     JSONArray items = JSON.parseArray(order.getItems());
@@ -212,23 +185,16 @@ public class OrderController {
                 orderDetail.put("products", new ArrayList<>());
             }
 
-            // 返回状态文本
             String statusText = getStatusText(order.getStatus());
             orderDetail.put("statusText", statusText);
 
-            result.put("code", 200);
-            result.put("message", "查询成功");
-            result.put("data", orderDetail);
-
             log.info("用户 {} 查询订单详情成功: {}", userId, order.getOrderNo());
+            return Result.success("查询成功", orderDetail);
 
         } catch (Exception e) {
             log.error("查询订单详情失败", e);
-            result.put("code", 500);
-            result.put("message", "查询订单详情失败");
+            return Result.error("查询订单详情失败");
         }
-
-        return result;
     }
 
     /**
@@ -238,64 +204,43 @@ public class OrderController {
      * @return 操作结果
      */
     @PostMapping("/cancel")
-    public Map<String, Object> cancelOrder(@RequestBody Map<String, Object> params) {
-        Map<String, Object> result = new HashMap<>();
-
+    public Result<Void> cancelOrder(@RequestBody Map<String, Object> params) {
         try {
-            // 获取当前登录用户ID
             Long userId = StpUtil.getLoginIdAsLong();
 
-            // 参数校验
             if (!params.containsKey("orderId")) {
-                result.put("code", 400);
-                result.put("message", "参数错误:orderId 不能为空");
-                return result;
+                return Result.error(400, "参数错误:orderId 不能为空");
             }
 
             Long orderId = Long.valueOf(params.get("orderId").toString());
 
-            // 查询订单
             Order order = orderService.getById(orderId);
             if (order == null) {
-                result.put("code", 404);
-                result.put("message", "订单不存在");
-                return result;
+                return Result.error(404, "订单不存在");
             }
 
-            // 验证订单归属
             if (!order.getUserId().equals(userId)) {
                 log.warn("用户 {} 尝试取消不属于自己的订单 {}", userId, orderId);
-                result.put("code", 403);
-                result.put("message", "无权操作该订单");
-                return result;
+                return Result.error(403, "无权操作该订单");
             }
 
-            // 检查订单状态(只能取消待支付的订单)
             if (order.getStatus() != 0) {
-                result.put("code", 400);
-                result.put("message", "该订单不能取消");
-                return result;
+                return Result.error(400, "该订单不能取消");
             }
 
-            // 取消订单
             boolean success = orderService.cancelOrder(orderId);
 
             if (success) {
-                result.put("code", 200);
-                result.put("message", "订单已取消");
                 log.info("用户 {} 取消订单 {} 成功", userId, orderId);
+                return Result.success("订单已取消", null);
             } else {
-                result.put("code", 500);
-                result.put("message", "取消订单失败");
+                return Result.error("取消订单失败");
             }
 
         } catch (Exception e) {
             log.error("取消订单失败", e);
-            result.put("code", 500);
-            result.put("message", "取消订单失败");
+            return Result.error("取消订单失败");
         }
-
-        return result;
     }
 
     /**

+ 173 - 0
haha-miniapp/src/main/java/com/haha/miniapp/controller/StatusQueryController.java

@@ -0,0 +1,173 @@
+package com.haha.miniapp.controller;
+
+import cn.dev33.satoken.annotation.SaIgnore;
+import com.haha.common.vo.Result;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 状态查询控制器
+ * 供小程序轮询查询设备状态、识别结果和订单信息
+ * 
+ * 数据流程:
+ * 1. 哈哈平台通过回调推送状态到 CallbackController
+ * 2. CallbackController 将状态保存到 Redis
+ * 3. 小程序通过此控制器轮询 Redis 获取最新状态
+ */
+@Slf4j
+@RestController
+@RequestMapping("/status")
+@SaIgnore
+public class StatusQueryController {
+
+    @Autowired
+    private StringRedisTemplate redisTemplate;
+
+    private static final String DEVICE_STATUS_KEY = "haha:device:status:";
+    private static final String RECOGNIZE_RESULT_KEY = "haha:recognize:result:";
+    private static final String ORDER_INFO_KEY = "haha:order:info:";
+
+    /**
+     * 查询设备状态
+     * 
+     * @param deviceId 设备ID
+     * @return 设备状态信息
+     */
+    @PostMapping("/device")
+    public Result<Map<String, String>> queryDeviceStatus(@RequestBody Map<String, String> params) {
+        String deviceId = params.get("deviceId");
+        if (deviceId == null || deviceId.isEmpty()) {
+            return Result.error(400, "设备ID不能为空");
+        }
+        
+        String statusKey = DEVICE_STATUS_KEY + deviceId;
+        Map<Object, Object> statusData = redisTemplate.opsForHash().entries(statusKey);
+        
+        if (statusData.isEmpty()) {
+            Map<String, String> emptyResult = new HashMap<>();
+            emptyResult.put("deviceId", deviceId);
+            emptyResult.put("doorStatus", "unknown");
+            return Result.success("暂无状态数据", emptyResult);
+        }
+        
+        Map<String, String> result = new HashMap<>();
+            statusData.forEach((k, v) -> result.put(k.toString(), v != null ? v.toString() : ""));
+        
+        return Result.success("查询成功", result);
+    }
+
+    /**
+     * 查询识别结果
+     * 
+     * @param activityId 活动ID
+     * @return 识别结果
+     */
+    @PostMapping("/recognize")
+    public Result<Map<String, String>> queryRecognizeResult(@RequestBody Map<String, String> params) {
+        String activityId = params.get("activityId");
+        if (activityId == null || activityId.isEmpty()) {
+            return Result.error(400, "活动ID不能为空");
+        }
+        
+        String resultKey = RECOGNIZE_RESULT_KEY + activityId;
+        Map<Object, Object> resultData = redisTemplate.opsForHash().entries(resultKey);
+        
+        if (resultData.isEmpty()) {
+            return Result.error(404, "识别结果尚未生成,请稍后重试");
+        }
+        
+        Map<String, String> result = new HashMap<>();
+        resultData.forEach((k, v) -> result.put(k.toString(), v != null ? v.toString() : ""));
+        
+        return Result.success("查询成功", result);
+    }
+
+    /**
+     * 查询订单信息
+     * 
+     * @param orderId 订单ID
+     * @param activityId 活动ID
+     * @return 订单信息
+     */
+    @PostMapping("/order")
+    public Result<Map<String, String>> queryOrderInfo(@RequestBody Map<String, String> params) {
+        String orderId = params.get("orderId");
+        String activityId = params.get("activityId");
+        
+        if ((orderId == null || orderId.isEmpty()) && (activityId == null || activityId.isEmpty())) {
+            return Result.error(400, "订单ID或活动ID不能同时为空");
+        }
+        
+        String orderKey = ORDER_INFO_KEY + (orderId != null && !orderId.isEmpty() ? orderId : activityId);
+        Map<Object, Object> orderData = redisTemplate.opsForHash().entries(orderKey);
+        
+        if (orderData.isEmpty()) {
+            return Result.error(404, "订单信息尚未生成,请稍后重试");
+        }
+        
+        Map<String, String> result = new HashMap<>();
+        orderData.forEach((k, v) -> result.put(k.toString(), v != null ? v.toString() : ""));
+        
+        return Result.success("查询成功", result);
+    }
+
+    /**
+     * 综合状态查询
+     * 根据设备ID查询所有相关状态(设备状态 + 最新的识别结果和订单)
+     * 
+     * @param deviceId 设备ID
+     * @return 综合状态信息
+     */
+    @PostMapping("/all")
+    public Result<Map<String, Object>> queryAllStatus(@RequestBody Map<String, String> params) {
+        String deviceId = params.get("deviceId");
+        if (deviceId == null || deviceId.isEmpty()) {
+            return Result.error(400, "设备ID不能为空");
+        }
+        
+        Map<String, Object> result = new HashMap<>();
+        
+        // 查询设备状态
+        String statusKey = DEVICE_STATUS_KEY + deviceId;
+        Map<Object, Object> statusData = redisTemplate.opsForHash().entries(statusKey);
+        if (!statusData.isEmpty()) {
+            Map<String, String> deviceStatus = new HashMap<>();
+            statusData.forEach((k, v) -> deviceStatus.put(k.toString(), v != null ? v.toString() : ""));
+            result.put("deviceStatus", deviceStatus);
+            
+            // 如果有活动ID,查询识别结果和订单
+            String activityId = deviceStatus.get("activityId");
+            if (activityId != null && !activityId.isEmpty()) {
+                // 查询识别结果
+                String recognizeKey = RECOGNIZE_RESULT_KEY + activityId;
+                Map<Object, Object> recognizeData = redisTemplate.opsForHash().entries(recognizeKey);
+                if (!recognizeData.isEmpty()) {
+                    Map<String, String> recognizeResult = new HashMap<>();
+                    recognizeData.forEach((k, v) -> recognizeResult.put(k.toString(), v != null ? v.toString() : ""));
+                    result.put("recognizeResult", recognizeResult);
+                }
+                
+                // 查询订单信息
+                String orderKey = ORDER_INFO_KEY + activityId;
+                Map<Object, Object> orderData = redisTemplate.opsForHash().entries(orderKey);
+                if (!orderData.isEmpty()) {
+                    Map<String, String> orderInfo = new HashMap<>();
+                    orderData.forEach((k, v) -> orderInfo.put(k.toString(), v != null ? v.toString() : ""));
+                    result.put("orderInfo", orderInfo);
+                }
+            }
+        } else {
+            Map<String, String> emptyStatus = new HashMap<>();
+            emptyStatus.put("deviceId", deviceId);
+            emptyStatus.put("doorStatus", "unknown");
+            result.put("deviceStatus", emptyStatus);
+        }
+        
+        return Result.success("查询成功", result);
+    }
+}

+ 1 - 1
haha-miniapp/src/main/resources/application.yml

@@ -103,7 +103,7 @@ sa-token:
   # token 有效期(单位:秒)- 7天
   timeout: 604800
   # token 临时有效期(单位:秒)
-  activity-timeout: -1
+  active-timeout: -1
   # 是否允许同一账号多地同时登录
   is-concurrent: true
   # 同一账号最大登录数量

+ 231 - 0
haha-mp/src/api/status.ts

@@ -0,0 +1,231 @@
+/**
+ * 状态查询相关API
+ * 用于查询设备状态、识别结果和订单信息
+ */
+
+import { post } from '../utils/request';
+
+/**
+ * 设备状态响应
+ */
+export interface DeviceStatusResponse {
+  deviceId: string;
+  doorStatus: 'open' | 'close' | 'error' | 'busy' | 'unknown';
+  activityId: string;
+  userId: string;
+  status: string;
+  openType: string;
+  timestamp: string;
+}
+
+/**
+ * 识别结果响应
+ */
+export interface RecognizeResultResponse {
+  activityId: string;
+  deviceId: string;
+  userId: string;
+  nobuy: string;
+  result: string;
+  skuList: string;
+  resourceInfo: string;
+  timestamp: string;
+}
+
+/**
+ * 订单信息响应
+ */
+export interface OrderInfoResponse {
+  orderId: string;
+  activityId: string;
+  deviceId: string;
+  userId: string;
+  orderName: string;
+  totalAmount: string;
+  products: string;
+  timestamp: string;
+}
+
+/**
+ * 综合状态响应
+ */
+export interface AllStatusResponse {
+  deviceStatus: DeviceStatusResponse;
+  recognizeResult?: RecognizeResultResponse;
+  orderInfo?: OrderInfoResponse;
+}
+
+/**
+ * 查询设备状态
+ * @param deviceId 设备ID
+ */
+export const queryDeviceStatus = (deviceId: string): Promise<DeviceStatusResponse> => {
+  return post<DeviceStatusResponse>('/status/device', { deviceId });
+};
+
+/**
+ * 查询识别结果
+ * @param activityId 活动ID
+ */
+export const queryRecognizeResult = (activityId: string): Promise<RecognizeResultResponse> => {
+  return post<RecognizeResultResponse>('/status/recognize', { activityId });
+};
+
+/**
+ * 查询订单信息
+ * @param orderId 订单ID
+ * @param activityId 活动ID(与orderId二选一)
+ */
+export const queryOrderInfo = (orderId?: string, activityId?: string): Promise<OrderInfoResponse> => {
+  return post<OrderInfoResponse>('/status/order', { orderId, activityId });
+};
+
+/**
+ * 综合状态查询
+ * @param deviceId 设备ID
+ */
+export const queryAllStatus = (deviceId: string): Promise<AllStatusResponse> => {
+  return post<AllStatusResponse>('/status/all', { deviceId });
+};
+
+/**
+ * 轮询设备状态
+ * @param deviceId 设备ID
+ * @param timeout 超时时间(毫秒)
+ * @param interval 轮询间隔(毫秒)
+ */
+export const pollDeviceStatus = (
+  deviceId: string,
+  timeout: number = 120000,
+  interval: number = 2000
+): Promise<DeviceStatusResponse> => {
+  return new Promise((resolve, reject) => {
+    const startTime = Date.now();
+    
+    const poll = async () => {
+      try {
+        const response = await queryDeviceStatus(deviceId);
+        
+        // 检查是否有有效的状态更新
+        if (response && response.doorStatus && response.doorStatus !== 'unknown') {
+          const statusTime = parseInt(response.timestamp) || 0;
+          // 如果状态更新时间在开始轮询之后,说明是新状态
+          if (statusTime > startTime - 10000) {
+            resolve(response);
+            return;
+          }
+        }
+        
+        // 检查是否超时
+        if (Date.now() - startTime >= timeout) {
+          reject(new Error('轮询超时'));
+          return;
+        }
+        
+        // 继续轮询
+        setTimeout(poll, interval);
+      } catch (error) {
+        // 查询失败,继续轮询
+        if (Date.now() - startTime >= timeout) {
+          reject(new Error('轮询超时'));
+          return;
+        }
+        setTimeout(poll, interval);
+      }
+    };
+    
+    poll();
+  });
+};
+
+/**
+ * 轮询识别结果
+ * @param activityId 活动ID
+ * @param timeout 超时时间(毫秒)
+ * @param interval 轮询间隔(毫秒)
+ */
+export const pollRecognizeResult = (
+  activityId: string,
+  timeout: number = 60000,
+  interval: number = 1000
+): Promise<RecognizeResultResponse> => {
+  return new Promise((resolve, reject) => {
+    const startTime = Date.now();
+    
+    const poll = async () => {
+      try {
+        const result = await queryRecognizeResult(activityId);
+        
+        // 检查是否有识别结果
+        if (result && result.result) {
+          resolve(result);
+          return;
+        }
+        
+        // 检查是否超时
+        if (Date.now() - startTime >= timeout) {
+          reject(new Error('识别超时'));
+          return;
+        }
+        
+        // 继续轮询
+        setTimeout(poll, interval);
+      } catch (error) {
+        // 查询失败,继续轮询
+        if (Date.now() - startTime >= timeout) {
+          reject(new Error('识别超时'));
+          return;
+        }
+        setTimeout(poll, interval);
+      }
+    };
+    
+    poll();
+  });
+};
+
+/**
+ * 轮询订单信息
+ * @param activityId 活动ID
+ * @param timeout 超时时间(毫秒)
+ * @param interval 轮询间隔(毫秒)
+ */
+export const pollOrderInfo = (
+  activityId: string,
+  timeout: number = 60000,
+  interval: number = 1000
+): Promise<OrderInfoResponse> => {
+  return new Promise((resolve, reject) => {
+    const startTime = Date.now();
+    
+    const poll = async () => {
+      try {
+        const order = await queryOrderInfo(undefined, activityId);
+        
+        // 检查是否有订单信息
+        if (order && order.orderId) {
+          resolve(order);
+          return;
+        }
+        
+        // 检查是否超时
+        if (Date.now() - startTime >= timeout) {
+          reject(new Error('获取订单超时'));
+          return;
+        }
+        
+        // 继续轮询
+        setTimeout(poll, interval);
+      } catch (error) {
+        // 查询失败,继续轮询
+        if (Date.now() - startTime >= timeout) {
+          reject(new Error('获取订单超时'));
+          return;
+        }
+        setTimeout(poll, interval);
+      }
+    };
+    
+    poll();
+  });
+};

+ 2 - 1
haha-mp/src/pages/orderDetail/orderDetail.vue

@@ -119,7 +119,8 @@
 
 <script setup lang="ts">
 import { ref, onMounted } from 'vue';
-import { getOrderDetail, OrderInfo } from '../../api/order';
+import { getOrderDetail } from '../../api/order';
+import type { OrderInfo } from '../../api/order';
 import { checkAuth } from '../../utils/auth';
 
 const order = ref<OrderInfo | null>(null);

+ 2 - 1
haha-mp/src/pages/orders/orders.vue

@@ -57,7 +57,8 @@
 
 <script setup lang="ts">
 import { ref, onMounted } from 'vue';
-import { getOrderList, OrderInfo } from '../../api/order';
+import { getOrderList } from '../../api/order';
+import type { OrderInfo } from '../../api/order';
 import { checkAuth } from '../../utils/auth';
 
 const orders = ref<OrderInfo[]>([]);

+ 115 - 19
haha-mp/src/pages/shopping/shopping.vue

@@ -35,41 +35,59 @@
       </view>
       <button class="view-order-button" @click="goToOrderDetail">查看订单详情</button>
     </view>
+    
+    <!-- 错误状态 -->
+    <view v-else-if="doorStatus === 'error'" class="shopping-section">
+      <view class="error-icon">❌</view>
+      <view class="status-title">出错了</view>
+      <view class="status-tip">{{ errorMessage }}</view>
+      <button class="retry-button" @click="goHome">返回首页</button>
+    </view>
   </view>
 </template>
 
 <script setup lang="ts">
 import { ref, onMounted, onUnmounted } from 'vue';
-import { mockApi } from '../../utils/mock';
+import { pollDeviceStatus, pollRecognizeResult, pollOrderInfo } from '../../api/status';
+import type { RecognizeResultResponse } from '../../api/status';
 
-const doorStatus = ref<'opened' | 'closing' | 'closed'>('opened');
+const doorStatus = ref<'opened' | 'closing' | 'closed' | 'error'>('opened');
 const countdown = ref(60);
-const purchasedProducts = ref<any[]>([
-  { id: '1', name: '可口可乐', price: 3.5, quantity: 2 },
-  { id: '3', name: '矿泉水', price: 2, quantity: 1 }
-]);
-const totalPrice = ref(9);
+const purchasedProducts = ref<any[]>([]);
+const totalPrice = ref(0);
+const errorMessage = ref('');
+const currentDeviceId = ref('');
+const currentActivityId = ref('');
+const currentOrderNo = ref('');
+
 let countdownTimer: number | null = null;
+let statusCheckTimer: number | null = null;
 
 onMounted(() => {
+  // 获取存储的设备信息
+  currentDeviceId.value = uni.getStorageSync('currentDeviceId') || '';
+  currentOrderNo.value = uni.getStorageSync('currentOrderNo') || '';
+  
+  if (!currentDeviceId.value) {
+    doorStatus.value = 'error';
+    errorMessage.value = '未找到设备信息';
+    return;
+  }
+  
   // 开始倒计时
   startCountdown();
   
-  // 模拟用户关门
-  setTimeout(() => {
-    doorStatus.value = 'closing';
-    
-    // 模拟AI识别完成
-    setTimeout(() => {
-      doorStatus.value = 'closed';
-    }, 3000);
-  }, 5000);
+  // 开始轮询设备状态
+  startStatusPolling();
 });
 
 onUnmounted(() => {
   if (countdownTimer) {
     clearInterval(countdownTimer);
   }
+  if (statusCheckTimer) {
+    clearTimeout(statusCheckTimer);
+  }
 });
 
 const startCountdown = () => {
@@ -80,6 +98,77 @@ const startCountdown = () => {
   }, 1000);
 };
 
+const startStatusPolling = () => {
+  // 轮询设备状态,等待关门
+  pollDeviceStatus(currentDeviceId.value, 120000, 2000)
+    .then((status) => {
+      console.log('设备状态更新:', status);
+      
+      // 检查门是否关闭
+            if (status.doorStatus === 'close') {
+                // 门已关,进入识别阶段
+                doorStatus.value = 'closing';
+                currentActivityId.value = status.activityId;
+                
+                // 开始轮询识别结果
+                return pollRecognizeResult(status.activityId, 60000, 1000);
+            } else {
+                // 继续等待关门
+                return new Promise<RecognizeResultResponse>((_, reject) => {
+                    startStatusPolling();
+                    setTimeout(() => reject(new Error('继续轮询')), 1000);
+                });
+            }
+    })
+    .then((recognizeResult) => {
+      if (recognizeResult) {
+        console.log('识别结果:', recognizeResult);
+        
+        // 开始轮询订单信息
+        return pollOrderInfo(currentActivityId.value, 30000, 1000);
+      }
+    })
+    .then((orderInfo) => {
+      if (orderInfo) {
+        console.log('订单信息:', orderInfo);
+        
+        // 解析商品列表
+        if (orderInfo.products) {
+          try {
+            purchasedProducts.value = JSON.parse(orderInfo.products);
+          } catch (e) {
+            console.error('解析商品列表失败:', e);
+            purchasedProducts.value = [];
+          }
+        }
+        
+        // 设置总价
+        totalPrice.value = parseFloat(orderInfo.totalAmount) || 0;
+        
+        // 进入结算完成阶段
+        doorStatus.value = 'closed';
+        
+        // 停止倒计时
+        if (countdownTimer) {
+          clearInterval(countdownTimer);
+        }
+      }
+    })
+    .catch((error) => {
+      console.error('状态轮询失败:', error);
+      
+      // 如果还在开门状态,继续轮询
+      if (doorStatus.value === 'opened') {
+        statusCheckTimer = setTimeout(() => {
+          startStatusPolling();
+        }, 3000);
+      } else {
+        doorStatus.value = 'error';
+        errorMessage.value = error.message || '获取状态失败';
+      }
+    });
+};
+
 const showProblem = () => {
   uni.showActionSheet({
     itemList: ['辅助远程开门', '报修'],
@@ -100,8 +189,15 @@ const showProblem = () => {
 };
 
 const goToOrderDetail = () => {
+  // 跳转到订单详情页
   uni.navigateTo({
-    url: '/pages/order-detail/order-detail'
+    url: `/pages/orderDetail/orderDetail?orderNo=${currentOrderNo.value}`
+  });
+};
+
+const goHome = () => {
+  uni.reLaunch({
+    url: '/pages/index/index'
   });
 };
 </script>
@@ -126,7 +222,7 @@ const goToOrderDetail = () => {
   width: 100%;
 }
 
-.status-icon, .loading-icon, .success-icon {
+.status-icon, .loading-icon, .success-icon, .error-icon {
   font-size: 120rpx;
   margin-bottom: 40rpx;
 }
@@ -226,7 +322,7 @@ const goToOrderDetail = () => {
   color: #FF6B6B;
 }
 
-.view-order-button {
+.view-order-button, .retry-button {
   background-color: #FFD700;
   color: #333;
   border: none;

+ 7 - 1
haha-mp/src/utils/mock.ts

@@ -75,10 +75,16 @@ export const mockData = {
   }
 };
 
+// Mock API 类型定义
+interface MockResponse<T> {
+  success: boolean;
+  data: T;
+}
+
 // Mock API 函数
 export const mockApi = {
   // 获取用户信息
-  getUserInfo: () => {
+  getUserInfo: (): Promise<MockResponse<typeof mockData.userInfo>> => {
     return new Promise((resolve) => {
       setTimeout(() => {
         resolve({ success: true, data: mockData.userInfo });

+ 83 - 0
haha-service/src/main/java/com/haha/service/CheckinRecordService.java

@@ -0,0 +1,83 @@
+package com.haha.service;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.haha.entity.dto.CheckinQueryDTO;
+import com.haha.entity.dto.CheckinSubmitDTO;
+import com.haha.entity.CheckinRecord;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 签到服务接口
+ */
+public interface CheckinRecordService extends IService<CheckinRecord> {
+
+    /**
+     * 提交签到
+     *
+     * @param dto    签到信息
+     * @param userId 用户ID
+     * @param userName 用户名
+     * @param userNo 用户工号
+     * @return 签到记录
+     */
+    CheckinRecord submitCheckin(CheckinSubmitDTO dto, Long userId, String userName, String userNo);
+
+    /**
+     * 分页查询签到记录
+     *
+     * @param query 查询条件
+     * @return 分页结果
+     */
+    IPage<Map<String, Object>> getCheckinPage(CheckinQueryDTO query);
+
+    /**
+     * 获取签到详情
+     *
+     * @param id 签到ID
+     * @return 签到详情
+     */
+    Map<String, Object> getCheckinDetail(Long id);
+
+    /**
+     * 获取签到统计数据
+     *
+     * @return 统计数据
+     */
+    Map<String, Object> getCheckinStats();
+
+    /**
+     * 获取用户今日签到状态
+     *
+     * @param userId 用户ID
+     * @return 今日签到状态
+     */
+    Map<String, Object> getTodayCheckin(Long userId);
+
+    /**
+     * 同步离线签到记录
+     *
+     * @param records 离线签到记录列表
+     * @return 同步结果
+     */
+    Map<String, Object> syncOfflineCheckins(List<CheckinRecord> records);
+
+    /**
+     * 导出签到记录
+     *
+     * @param query 查询条件
+     * @return 导出文件URL
+     */
+    String exportCheckinRecords(CheckinQueryDTO query);
+
+    /**
+     * 检查用户今日是否已签到指定类型
+     *
+     * @param userId     用户ID
+     * @param checkinType 签到类型
+     * @return 是否已签到
+     */
+    boolean hasTodayCheckin(Long userId, String checkinType);
+}

+ 315 - 0
haha-service/src/main/java/com/haha/service/impl/CheckinRecordServiceImpl.java

@@ -0,0 +1,315 @@
+package com.haha.service.impl;
+
+import com.alibaba.fastjson2.JSON;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.haha.entity.dto.CheckinQueryDTO;
+import com.haha.entity.dto.CheckinSubmitDTO;
+import com.haha.entity.CheckinRecord;
+import com.haha.entity.Device;
+import com.haha.entity.Shop;
+import com.haha.mapper.CheckinRecordMapper;
+import com.haha.mapper.DeviceMapper;
+import com.haha.mapper.ShopMapper;
+import com.haha.service.CheckinRecordService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.*;
+
+/**
+ * 签到服务实现类
+ */
+@Slf4j
+@Service
+public class CheckinRecordServiceImpl extends ServiceImpl<CheckinRecordMapper, CheckinRecord>
+        implements CheckinRecordService {
+
+    @Autowired
+    private CheckinRecordMapper checkinRecordMapper;
+
+    @Autowired
+    private ShopMapper shopMapper;
+
+    @Autowired
+    private DeviceMapper deviceMapper;
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public CheckinRecord submitCheckin(CheckinSubmitDTO dto, Long userId, String userName, String userNo) {
+        log.info("用户[{}]提交签到, 类型: {}", userName, dto.getType());
+
+        CheckinRecord record = new CheckinRecord();
+        record.setCheckinType(dto.getType());
+        record.setUserId(userId);
+        record.setUserName(userName);
+        record.setUserNo(userNo);
+        record.setShopId(dto.getShopId());
+        record.setDeviceId(dto.getDeviceId());
+        record.setLatitude(dto.getLocation().getLatitude());
+        record.setLongitude(dto.getLocation().getLongitude());
+        record.setAddress(dto.getLocation().getAddress());
+        record.setAccuracy(dto.getLocation().getAccuracy());
+        record.setRemark(dto.getRemark());
+        record.setOfflineId(dto.getOfflineId());
+        record.setCheckinTime(LocalDateTime.now());
+        record.setStatus("synced");
+        record.setSyncedAt(LocalDateTime.now());
+        record.setCreateTime(LocalDateTime.now());
+        record.setUpdateTime(LocalDateTime.now());
+
+        if (dto.getShopId() != null) {
+            Shop shop = shopMapper.selectById(dto.getShopId());
+            if (shop != null) {
+                record.setShopName(shop.getName());
+            }
+        }
+
+        if (StringUtils.hasText(dto.getDeviceId())) {
+            Device device = deviceMapper.selectByDeviceId(dto.getDeviceId());
+            if (device != null) {
+                record.setDeviceName(device.getName());
+            }
+        }
+
+        if (dto.getPhotos() != null && !dto.getPhotos().isEmpty()) {
+            List<Map<String, Object>> photoList = new ArrayList<>();
+            for (CheckinSubmitDTO.PhotoDTO photo : dto.getPhotos()) {
+                Map<String, Object> photoMap = new HashMap<>();
+                photoMap.put("id", photo.getId());
+                photoMap.put("path", photo.getPath());
+                photoMap.put("watermarkPath", photo.getWatermarkPath());
+                photoMap.put("timestamp", photo.getTimestamp());
+                photoList.add(photoMap);
+            }
+            record.setPhotos(JSON.toJSONString(photoList));
+        }
+
+        save(record);
+
+        log.info("签到记录创建成功, ID: {}", record.getId());
+        return record;
+    }
+
+    @Override
+    public IPage<Map<String, Object>> getCheckinPage(CheckinQueryDTO query) {
+        query.validate();
+        Page<Map<String, Object>> page = new Page<>(query.getPage(), query.getPageSize());
+
+        IPage<Map<String, Object>> result = checkinRecordMapper.selectCheckinPage(
+                page,
+                query.getType(),
+                query.getUserId(),
+                query.getShopId(),
+                query.getStatus(),
+                query.getStartDate(),
+                query.getEndDate()
+        );
+
+        for (Map<String, Object> record : result.getRecords()) {
+            processCheckinRecord(record);
+        }
+
+        return result;
+    }
+
+    @Override
+    public Map<String, Object> getCheckinDetail(Long id) {
+        CheckinRecord record = getById(id);
+        if (record == null) {
+            return null;
+        }
+
+        Map<String, Object> result = new HashMap<>();
+        result.put("id", record.getId());
+        result.put("checkinType", record.getCheckinType());
+        result.put("typeName", getTypeName(record.getCheckinType()));
+        result.put("userId", record.getUserId());
+        result.put("userName", record.getUserName());
+        result.put("userNo", record.getUserNo());
+        result.put("shopId", record.getShopId());
+        result.put("shopName", record.getShopName());
+        result.put("deviceId", record.getDeviceId());
+        result.put("deviceName", record.getDeviceName());
+        result.put("latitude", record.getLatitude());
+        result.put("longitude", record.getLongitude());
+        result.put("address", record.getAddress());
+        result.put("accuracy", record.getAccuracy());
+        result.put("remark", record.getRemark());
+        result.put("status", record.getStatus());
+        result.put("offlineId", record.getOfflineId());
+        result.put("checkinTime", formatDateTime(record.getCheckinTime()));
+        result.put("syncedAt", formatDateTime(record.getSyncedAt()));
+        result.put("createTime", formatDateTime(record.getCreateTime()));
+
+        if (StringUtils.hasText(record.getPhotos())) {
+            result.put("photos", JSON.parseArray(record.getPhotos()));
+        } else {
+            result.put("photos", new ArrayList<>());
+        }
+
+        Map<String, Object> location = new HashMap<>();
+        location.put("latitude", record.getLatitude());
+        location.put("longitude", record.getLongitude());
+        location.put("address", record.getAddress());
+        location.put("accuracy", record.getAccuracy());
+        result.put("location", location);
+
+        return result;
+    }
+
+    @Override
+    public Map<String, Object> getCheckinStats() {
+        Map<String, Object> stats = checkinRecordMapper.selectCheckinStats();
+
+        stats.put("todayCount", stats.getOrDefault("today_count", 0));
+        stats.put("weekCount", stats.getOrDefault("week_count", 0));
+        stats.put("monthCount", stats.getOrDefault("month_count", 0));
+        stats.put("inventoryTallyCount", stats.getOrDefault("inventory_tally_count", 0));
+        stats.put("deliveryReplenishCount", stats.getOrDefault("delivery_replenish_count", 0));
+        stats.put("pendingSyncCount", stats.getOrDefault("pending_sync_count", 0));
+
+        stats.remove("today_count");
+        stats.remove("week_count");
+        stats.remove("month_count");
+        stats.remove("inventory_tally_count");
+        stats.remove("delivery_replenish_count");
+        stats.remove("pending_sync_count");
+
+        return stats;
+    }
+
+    @Override
+    public Map<String, Object> getTodayCheckin(Long userId) {
+        Map<String, Object> result = new HashMap<>();
+        result.put("hasInventoryTally", false);
+        result.put("hasDeliveryReplenish", false);
+
+        List<CheckinRecord> todayRecords = checkinRecordMapper.selectTodayCheckinByUser(userId);
+
+        for (CheckinRecord record : todayRecords) {
+            if ("inventory_tally".equals(record.getCheckinType())) {
+                result.put("hasInventoryTally", true);
+                result.put("inventoryTallyTime", formatDateTime(record.getCheckinTime()));
+            } else if ("delivery_replenish".equals(record.getCheckinType())) {
+                result.put("hasDeliveryReplenish", true);
+                result.put("deliveryReplenishTime", formatDateTime(record.getCheckinTime()));
+            }
+        }
+
+        return result;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Map<String, Object> syncOfflineCheckins(List<CheckinRecord> records) {
+        log.info("开始同步离线签到记录, 数量: {}", records.size());
+
+        int successCount = 0;
+        int failedCount = 0;
+        List<String> failedIds = new ArrayList<>();
+
+        for (CheckinRecord record : records) {
+            try {
+                if (StringUtils.hasText(record.getOfflineId())) {
+                    CheckinRecord existing = checkinRecordMapper.selectByOfflineId(record.getOfflineId());
+                    if (existing != null) {
+                        log.info("离线签到记录已存在, offlineId: {}", record.getOfflineId());
+                        successCount++;
+                        continue;
+                    }
+                }
+
+                record.setStatus("synced");
+                record.setSyncedAt(LocalDateTime.now());
+                record.setUpdateTime(LocalDateTime.now());
+
+                if (record.getCreateTime() == null) {
+                    record.setCreateTime(LocalDateTime.now());
+                }
+
+                save(record);
+                successCount++;
+            } catch (Exception e) {
+                log.error("同步离线签到记录失败: {}", e.getMessage(), e);
+                failedCount++;
+                if (StringUtils.hasText(record.getOfflineId())) {
+                    failedIds.add(record.getOfflineId());
+                }
+            }
+        }
+
+        Map<String, Object> result = new HashMap<>();
+        result.put("success", successCount);
+        result.put("failed", failedCount);
+        result.put("failedIds", failedIds);
+
+        log.info("离线签到同步完成, 成功: {}, 失败: {}", successCount, failedCount);
+        return result;
+    }
+
+    @Override
+    public String exportCheckinRecords(CheckinQueryDTO query) {
+        log.info("导出签到记录, 条件: {}", query);
+        return "https://oss.example.com/exports/checkin_records_" + System.currentTimeMillis() + ".xlsx";
+    }
+
+    @Override
+    public boolean hasTodayCheckin(Long userId, String checkinType) {
+        return checkinRecordMapper.countTodayCheckinByUserAndType(userId, checkinType) > 0;
+    }
+
+    private void processCheckinRecord(Map<String, Object> record) {
+        record.put("typeName", getTypeName((String) record.get("checkin_type")));
+
+        if (record.get("checkin_time") != null) {
+            record.put("checkinTime", formatDateTime(record.get("checkin_time")));
+        }
+        if (record.get("synced_at") != null) {
+            record.put("syncedAt", formatDateTime(record.get("synced_at")));
+        }
+        if (record.get("create_time") != null) {
+            record.put("createdAt", formatDateTime(record.get("create_time")));
+        }
+
+        Object photos = record.get("photos");
+        if (photos != null && StringUtils.hasText(photos.toString())) {
+            record.put("photos", JSON.parseArray(photos.toString()));
+        } else {
+            record.put("photos", new ArrayList<>());
+        }
+
+        Map<String, Object> location = new HashMap<>();
+        location.put("latitude", record.get("latitude"));
+        location.put("longitude", record.get("longitude"));
+        location.put("address", record.get("address"));
+        location.put("accuracy", record.get("accuracy"));
+        record.put("location", location);
+    }
+
+    private String getTypeName(String type) {
+        if ("inventory_tally".equals(type)) {
+            return "理货盘点签到";
+        } else if ("delivery_replenish".equals(type)) {
+            return "补货确认签到";
+        }
+        return "未知类型";
+    }
+
+    private String formatDateTime(Object dateTime) {
+        if (dateTime == null) {
+            return null;
+        }
+        if (dateTime instanceof LocalDateTime) {
+            return ((LocalDateTime) dateTime).format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
+        }
+        return dateTime.toString().replace("T", " ").substring(0, Math.min(19, dateTime.toString().length()));
+    }
+}