Эх сурвалжийг харах

Merge branch 'master' of http://git.yujin.shuziyunyao.com/yujin/forestry-wx

lisy 2 долоо хоног өмнө
parent
commit
a8d99adb5c

+ 55 - 0
src/components/ut-steps-title/ut-steps-title.vue

@@ -0,0 +1,55 @@
+<template>
+    <view class="ut-steps-title d-flex j-c a-c">
+        <view v-for="(step, index) in sortedSteps" :key="step.value" class="ut-steps-item d-flex a-c f-s-26" :class="{
+            'is-completed': step.status === 'completed',
+            'is-in-progress': step.status === 'in-progress',
+            'is-not-started': step.status === 'not-started',
+        }">
+            <!-- 步骤序号/图标 -->
+            <up-icon v-if="step.status === 'completed'" name="checkmark" size="26rpx" color="#333"></up-icon>
+            <!-- 步骤标题 -->
+            <text class="ut-steps-text" :class="{
+                'text-completed': step.status === 'completed',
+                'text-in-progress': step.status === 'in-progress',
+                'text-not-started': step.status === 'not-started',
+            }">{{ step.title }}</text>
+            <up-icon v-if="(index < sortedSteps.length - 1)" class="pd2-0-12" name="arrow-right" size="26rpx" :color="step.status === 'completed' ? '#333' : '#999'"></up-icon>
+        </view>
+    </view>
+</template>
+
+<script setup lang="ts">
+interface Step {
+    title: string;
+    value: string;
+    status: "not-started" | "in-progress" | "completed";
+    sort: number;
+}
+
+const props = defineProps<{
+    stepsObject: Record<string, Step>;
+}>();
+
+// 获取排序后的步骤数组
+const sortedSteps = computed(() => {
+    return Object.values(props.stepsObject).sort((a, b) => a.sort - b.sort);
+});
+</script>
+
+<style lang="scss" scoped>
+.ut-steps-text {
+    color: #999;
+
+    &.text-completed {
+        color: #333;
+        font-weight: 500;
+    }
+    &.text-in-progress {
+        color: #333;
+        font-weight: 500;
+    }
+    &.text-not-started {
+        color: #999;
+    }
+}
+</style>

+ 3 - 0
src/manifest.json

@@ -67,6 +67,9 @@
         "permission": {
             "scope.userLocation": {
                 "desc": "您的位置信息将用于基地位置选择"
+            },
+            "scope.bluetooth": {
+                "desc": "蓝牙权限将用于连接打印机"
             }
         },
         "requiredPrivateInfos": ["chooseAddress", "chooseLocation", "getLocation"]

+ 12 - 7
src/pages.json

@@ -445,13 +445,6 @@
                         "navigationBarTitleText": "包装任务详情"
                     }
                 },
-                // 打印追溯码
-                {
-                    "path": "print/index",
-                    "style": {
-                        "navigationBarTitleText": "打印追溯码"
-                    }
-                },
                 // 选择任务包装的对象
                 {
                     "path": "select-object/index",
@@ -461,6 +454,18 @@
                 }
             ]
         },
+        // 打印
+        {
+            "root": "plant/print",
+            "pages": [
+                {
+                    "path": "print-steps/index",
+                    "style": {
+                        "navigationBarTitleText": "打印步骤"
+                    }
+                }
+            ]
+        },
         // 往来单位管理
         {
             "root": "plant/contact-unit",

+ 1 - 1
src/plant/packaging/create/index.vue

@@ -167,7 +167,7 @@ const form = ref<PackTaskForm>({
     expireDate: '',
     expireDateUnit: '',
     refType: '',
-    packCodeType: '1',
+    packCodeType: '3',
 });
 
 const rules = reactive<Record<string, any>>({

+ 4 - 5
src/plant/packaging/list/index.vue

@@ -7,9 +7,9 @@
         <view class="pd3-24-24-0">
             <view class="d-flex a-c">
                 <view class="min-w-230 flex1">
-                    <ut-action-sheet v-model="form.processType" :tabs="[{ label: '全部', value: '', elTagClass: '15' }, ...pt_pack_code_type]" mode="custom" @change="onRefresh" title="选择入库类型">
+                    <ut-action-sheet v-model="form.refType" :tabs="[{ label: '全部', value: '', elTagClass: '15' }, ...pt_pack_ref_type]" mode="custom" @change="onRefresh" title="选择入库类型">
                         <view class="d-flex search-select-item a-c">
-                            <view class="flex1 ov-hd f-s-28 c-333 text-center f-w-5 up-line-1">{{ selectDictLabel(pt_pack_code_type, form.processType) || '全部' }} </view>
+                            <view class="flex1 ov-hd f-s-28 c-333 text-center f-w-5 up-line-1">{{ selectDictLabel(pt_pack_ref_type, form.refType) || '全部' }} </view>
                             <up-icon size="24rpx" color="#333" name="arrow-down-fill" class="mr-5"></up-icon>
                         </view>
                     </ut-action-sheet>
@@ -18,7 +18,6 @@
                     <ut-search ref="searchRef" v-model="form.keyword" @search="changeSeach" margin="0" :border="false" placeholder="搜品种名称、批号" bgColor="#fff" height="86rpx" borderRadius="10rpx"></ut-search>
                 </view>
             </view>
-            <view></view>
         </view>
         <view class="pd-24 bg-#f7f7f7">
             <up-swipe-action>
@@ -44,9 +43,9 @@ import { selectDictListClass } from '@/utils/ruoyi';
 import { getStorageRoomNames } from '@/utils/common';
 import FreshGoodsBottom from './model/fresh-goods-bottom.vue';
 const { proxy } = getCurrentInstance() as ComponentInternalInstance;
-const { pt_pack_code_type } = toRefs<any>(proxy?.useDict('pt_pack_code_type'));
+const { pt_pack_ref_type } = toRefs<any>(proxy?.useDict('pt_pack_ref_type'));
 const list = ref<any[]>();
-const form = ref({ keyword: '', restFlag: '1', processType: '', storageType: '5' });
+const form = ref({ keyword: '', refType: '' });
 const paging = ref();
 const showAddDialog = ref(false);
 const tabs = ref([

+ 0 - 3
src/plant/packaging/print/index.vue

@@ -1,3 +0,0 @@
-<template>
-    <view>xx</view>
-</template>

+ 58 - 0
src/plant/print/print-steps/index.vue

@@ -0,0 +1,58 @@
+<template>
+    <z-paging ref="paging" bgColor="#fff" safe-area-inset-bottom scroll-with-animation>
+        <template #top>
+            <ut-navbar :title="stepsObject[currentStep].title" :fixed="false" border :breadcrumb="false"></ut-navbar>
+            <view class=" pd-24">
+                <ut-steps-title :stepsObject="stepsObject"></ut-steps-title>
+            </view>
+            <view class="pd-5 bg-#f7f7f7"></view>
+        </template>
+        <view>
+            <template v-show="currentStep === 'connect'">
+                <!-- 业务组件链接打印机 -->
+                <connect-printer :info="stepsObject[currentStep]" @next="nextSteps" prevStepValue="confirm" nextStepValue="print"></connect-printer>
+            </template>
+        </view>
+    </z-paging>
+</template>
+<script setup lang="ts">
+import ConnectPrinter from './models/connect-printer.vue';
+const stepsObject = reactive<any>({
+    check: {
+        title: '核对信息',
+        value: 'check',
+        status: 'completed', // 为开始、进行中、已完成 三种状态分别值为 'not-started'、'in-progress'、'completed'
+        sort: 0
+    },
+    confirm: {
+        title: '确认数量',
+        value: 'confirm',
+        status: 'completed', // 为开始、进行中、已完成 三种状态分别值为 'not-started'、'in-progress'、'completed'
+        sort: 1
+    },
+    connect: {
+        title: '连接打印机',
+        value: 'connect',
+        status: 'in-progress', // 为开始、进行中、已完成 三种状态分别值为 'not-started'、'in-progress'、'completed'
+        sort: 2
+    },
+    print: {
+        title: '打印',
+        value: 'print',
+        status: 'not-started', // 为开始、进行中、已完成 三种状态分别值为 'not-started'、'in-progress'、'completed'
+        sort: 3
+    }
+})
+const currentStep = ref('connect');
+const paging = ref(null);
+onLoad((options: any) => {});
+const nextSteps = (data: any) => {
+    if (!data.info || !data?.info?.value) return
+    stepsObject[data?.info?.value] = {
+        ...data.info,
+    };
+    stepsObject[data?.nextStepValue].status = 'in-progress';
+    currentStep.value = data?.nextStepValue;
+};
+</script>
+<style lang="scss" scoped></style>

+ 363 - 0
src/plant/print/print-steps/models/connect-printer.vue

@@ -0,0 +1,363 @@
+<template>
+    <view class="connect-printer-page">
+        <!-- 提示信息图片 - 搜索中显示 gif,搜索完成显示 jpg -->
+        <view class="info-images">
+            <image v-if="isSearching" class="w-750 h-311" src="@/static/images/print/print_dt_ssly.gif" mode="widthFix" />
+            <image v-else class="w-750 h-311" src="@/static/images/print/print_jt_ssly.jpg" mode="widthFix" />
+        </view>
+
+        <!-- 操作按钮区域 -->
+        <view class="pd-24 action-buttons">
+            <up-button @click="handleStartSearch" class="mb-30" type="primary" :disabled="isConnected">
+                {{ isSearching ? '搜索中...' : '开始搜索' }}
+            </up-button>
+            <up-button v-if="isConnected" @click="handleDisconnect" class="mb-30" type="warning"> 断开连接 </up-button>
+            <view class="d-flex a-c j-c">
+                <up-button class="mr-30" plain type="primary">上一步</up-button>
+                <up-button @click="nextSteps" type="primary" plain>下一步</up-button>
+            </view>
+        </view>
+
+        <!-- 设备列表区域 -->
+        <view class="device-list-section">
+            <view class="f-s-30 c-#333 f-w-5 mb-20">请选择设备</view>
+
+            <!-- 搜索状态提示 -->
+            <view v-if="isSearching && devices.length === 0" class="searching-tip">
+                <text>正在搜索附近的蓝牙设备...</text>
+            </view>
+
+            <!-- 设备列表 -->
+            <view v-if="devices.length" class="device-list">
+                <view v-for="device in devices" :key="device.deviceId" class="device-item" :class="{ active: connectedDevice?.deviceId === device.deviceId }" @click="handleConnectDevice(device)">
+                    <view class="device-info">
+                        <view class="device-name">{{ device.name }}</view>
+                        <view class="device-address">{{ device.deviceId }}</view>
+                        <view v-if="device.RSSI !== undefined" class="signal-strength"> 信号:{{ device.RSSI }} dBm </view>
+                    </view>
+                    <view class="device-status">
+                        <text v-if="connectedDevice?.deviceId === device.deviceId" class="status-connected">已连接</text>
+                        <text v-else class="status-new">未连接</text>
+                    </view>
+                </view>
+            </view>
+
+            <!-- 无设备提示 -->
+            <view v-if="!isSearching && devices.length === 0" class="no-device-tip">
+                <ut-empty description="暂无可用设备" />
+            </view>
+        </view>
+    </view>
+</template>
+
+<script setup lang="ts">
+import { checkBluetoothPermission, initBluetoothAdapter, getBluetoothAdapterState, startBluetoothDevicesDiscovery, stopBluetoothDevicesDiscovery, connectBLEDevice, closeBLEConnection, getBLEDeviceServicesAndCharacteristics, getBluetoothDevices } from '@/utils/blue-device-services';
+const props = defineProps({
+    info: {
+        type: Object,
+        default: () => ({})
+    },
+    // 下一个步骤的标识,供父组件使用
+    nextStepValue: {
+        type: String,
+        default: ''
+    },
+    // 上一个步骤标识
+    prevStepValue: {
+        type: String,
+        default: ''
+    }
+});
+const emit = defineEmits(['connected', 'disconnected', 'next', 'prev']);
+const isSearching = ref(false);
+const isInitialized = ref(false);
+const devices = ref<Array<any>>([]);
+const connectedDevice = ref<any | null>(null);
+const isConnected = computed(() => !!connectedDevice.value);
+let stopDiscoveryListener: (() => void) | null = null;
+let searchTimeout: number | null = null;
+
+const toast = (title: string) => {
+    uni.showToast({ title, icon: 'none', duration: 2000 });
+};
+
+const updateDeviceList = (device: any) => {
+    if (!device?.deviceId) return;
+    if (device.name.startsWith('未知或不支持的设备')) return;
+    if (!device.name.startsWith('GP-')) return;
+    const idx = devices.value.findIndex((d) => d.deviceId === device.deviceId);
+    if (idx >= 0) {
+        devices.value[idx] = { ...devices.value[idx], ...device };
+    } else {
+        devices.value.unshift(device);
+    }
+};
+
+const stopSearch = async () => {
+    if (searchTimeout) {
+        clearTimeout(searchTimeout);
+        searchTimeout = null;
+    }
+
+    try {
+        await stopBluetoothDevicesDiscovery();
+    } catch (err) {
+        // ignore
+    }
+    stopDiscoveryListener?.();
+    stopDiscoveryListener = null;
+    isSearching.value = false;
+};
+
+const initBluetooth = async () => {
+    if (isInitialized.value) return;
+
+    try {
+        await checkBluetoothPermission();
+        await initBluetoothAdapter();
+
+        // 获取当前蓝牙模块下已发现及已连接的设备(去重)
+        const list = await getBluetoothDevices();
+        devices.value = [];
+        if (Array.isArray(list)) {
+            list.forEach((item) => updateDeviceList(item));
+        }
+
+        isInitialized.value = true;
+        toast('蓝牙初始化成功,请点击开始搜索');
+    } catch (error: any) {
+        console.error('蓝牙初始化失败', error);
+        toast(error?.message || '蓝牙初始化失败,请检查系统蓝牙权限');
+    }
+};
+
+const handleStartSearch = async () => {
+    if (isSearching.value) return;
+
+    if (!isInitialized.value) {
+        toast('请先初始化蓝牙,再开始搜索');
+        return;
+    }
+
+    isSearching.value = true;
+
+    if (searchTimeout) {
+        clearTimeout(searchTimeout);
+    }
+    searchTimeout = setTimeout(() => {
+        stopSearch();
+        toast('搜索超时,已自动停止');
+    }, 15000);
+
+    try {
+        const list = await getBluetoothDevices();
+        devices.value = [];
+        if (Array.isArray(list)) {
+            list.forEach((item) => updateDeviceList(item));
+        }
+    } catch {
+        // ignore
+    }
+
+    try {
+        const state = await getBluetoothAdapterState();
+        if (state.discovering) {
+            await stopBluetoothDevicesDiscovery();
+        }
+
+        stopDiscoveryListener = await startBluetoothDevicesDiscovery((device) => {
+            updateDeviceList(device);
+        });
+    } catch (error: any) {
+        console.error('蓝牙搜索失败', error);
+        toast(error?.message || '蓝牙扫描失败,请重试');
+        isSearching.value = false;
+        if (searchTimeout) {
+            clearTimeout(searchTimeout);
+            searchTimeout = null;
+        }
+    }
+};
+
+const handleConnectDevice = async (device: any) => {
+    if (!device?.deviceId) {
+        toast('无效设备,请重试');
+        return;
+    }
+
+    if (connectedDevice.value?.deviceId === device.deviceId) {
+        toast('已连接该设备');
+        return;
+    }
+
+    try {
+        await stopSearch();
+        await connectBLEDevice(device.deviceId);
+
+        connectedDevice.value = device;
+        updateDeviceList(device);
+
+        const { services, characteristicsMap } = await getBLEDeviceServicesAndCharacteristics(device.deviceId);
+        console.log('已获取服务', services);
+        console.log('已获取特征值', characteristicsMap);
+        toast('蓝牙已连接');
+    } catch (error: any) {
+        console.error('连接失败', error);
+        toast(error?.message || '设备连接失败,请重试');
+    }
+};
+
+const handleDisconnect = async () => {
+    if (!connectedDevice.value?.deviceId) return;
+
+    try {
+        await closeBLEConnection(connectedDevice.value.deviceId);
+        toast('已断开连接');
+    } catch (error) {
+        console.warn('断开连接失败', error);
+    }
+    connectedDevice.value = null;
+};
+const nextSteps = () => {
+    if (!connectedDevice.value) {
+        toast('请先连接设备');
+        return;
+    }
+   emit('next', {
+      info: props.info,
+      nextStepValue: props.nextStepValue || 'print'
+   });
+};
+onMounted(() => {
+    initBluetooth();
+});
+onBeforeUnmount(() => {
+    stopSearch();
+});
+</script>
+
+<style lang="scss" scoped>
+.connect-printer-page {
+    background-color: #f5f5f5;
+
+    .info-images {
+        display: flex;
+        flex-direction: column;
+        align-items: center;
+        padding: 20rpx;
+        background-color: #fff;
+
+        image {
+            margin-bottom: 20rpx;
+        }
+    }
+
+    .action-buttons {
+        background-color: #fff;
+        padding: 20rpx;
+    }
+
+    .device-list-section {
+        background-color: #fff;
+        padding: 20rpx;
+        margin-top: 20rpx;
+
+        .section-title {
+            font-size: 32rpx;
+            font-weight: bold;
+            color: #333;
+            padding-bottom: 20rpx;
+            border-bottom: 1rpx solid #eee;
+        }
+
+        .section-subtitle {
+            font-size: 28rpx;
+            color: #666;
+            margin: 20rpx 0;
+        }
+
+        .searching-tip {
+            display: flex;
+            flex-direction: column;
+            align-items: center;
+            padding: 60rpx 0;
+            color: #999;
+
+            text {
+                margin-top: 20rpx;
+            }
+        }
+
+        .no-device-tip {
+            padding: 60rpx 0;
+        }
+
+        .device-scroll {
+            max-height: 600rpx;
+        }
+
+        .device-item {
+            display: flex;
+            justify-content: space-between;
+            align-items: center;
+            padding: 30rpx;
+            margin-bottom: 10rpx;
+            background-color: #fafafa;
+            border-radius: 10rpx;
+            transition: all 0.3s;
+
+            &.active {
+                background-color: #e8f4ff;
+                border: 2rpx solid #1989fa;
+            }
+
+            &:active {
+                opacity: 0.8;
+            }
+
+            .device-info {
+                flex: 1;
+
+                .device-name {
+                    font-size: 30rpx;
+                    color: #333;
+                    font-weight: 500;
+                }
+
+                .device-address {
+                    font-size: 24rpx;
+                    color: #999;
+                    margin-top: 8rpx;
+                }
+
+                .signal-strength {
+                    font-size: 24rpx;
+                    color: #67c23a;
+                    margin-top: 8rpx;
+                }
+            }
+
+            .device-status {
+                .status-connected {
+                    font-size: 24rpx;
+                    color: #67c23a;
+                }
+
+                .status-paired {
+                    font-size: 24rpx;
+                    color: #909399;
+                }
+
+                .status-new {
+                    font-size: 24rpx;
+                    color: #1989fa;
+                }
+            }
+        }
+    }
+
+    .error-tip {
+        margin: 20rpx;
+    }
+}
+</style>

BIN
src/static/images/print/print_dt_ssly.gif


BIN
src/static/images/print/print_jt_ssly.jpg


+ 181 - 0
src/utils/blue-device-services.ts

@@ -0,0 +1,181 @@
+/**
+ * 蓝牙相关封装,面向 uni-app 的 API。
+ *
+ * 说明:
+ *  - 仅支持蓝牙低功耗(BLE),适用于打印机等外设。
+ *  - 组件内必须先调用 initBluetoothAdapter 之后才可开始搜索/连接。
+ */
+
+export async function checkBluetoothPermission(): Promise<void> {
+  return new Promise((resolve, reject) => {
+    uni.getSetting({
+      success: (res) => {
+        const hasLocation = res.authSetting?.['scope.userLocation']
+        if (hasLocation) {
+          return resolve()
+        }
+
+        uni.authorize({
+          scope: 'scope.userLocation',
+          success: () => resolve(),
+          fail: (err) => {
+            uni.showModal({
+              title: '权限提示',
+              content: '需要定位权限才能扫描蓝牙设备,请前往设置开启定位权限。',
+              showCancel: false,
+              confirmText: '去设置',
+              success: () => {
+                uni.openSetting({})
+              },
+            })
+            reject(err)
+          },
+        })
+      },
+      fail: reject,
+    })
+  })
+}
+
+export async function initBluetoothAdapter(): Promise<void> {
+  const closeAdapter = () =>
+    new Promise<void>((resolve) => {
+      uni.closeBluetoothAdapter({
+        success: () => resolve(),
+        fail: () => resolve(),
+      })
+    })
+
+  const openAdapter = () =>
+    new Promise<void>((resolve, reject) => {
+      uni.openBluetoothAdapter({
+        success: () => resolve(),
+        fail: (err) => {
+          console.log(err)
+          reject(new Error(err.errMsg || `openBluetoothAdapter(${mode}) 失败`))
+        },
+      })
+    })
+
+  // 先尝试关闭现有 adapter,避免重复初始化导致的异常
+  await closeAdapter()
+
+  // iOS 需要分别以主机/从机模式初始化一次,才能兼容全部场景。
+  const platform = uni.getSystemInfoSync?.()?.platform || ''
+  console.log(platform, 'initBluetoothAdapter platform')
+
+  // Android 等其他平台只需 central 模式
+  await openAdapter()
+}
+
+export async function getBluetoothAdapterState(): Promise<any> {
+  return new Promise((resolve, reject) => {
+    uni.getBluetoothAdapterState({
+      success: resolve,
+      fail: reject,
+    })
+  })
+}
+
+export async function startBluetoothDevicesDiscovery(
+  onDeviceFound: (device: any) => void,
+): Promise<() => void> {
+  return new Promise((resolve, reject) => {
+    let listener: any | null = null
+
+    uni.startBluetoothDevicesDiscovery({
+      allowDuplicatesKey: false,
+      success: () => {
+        listener = (res: any) => {
+          if (!res || !res.devices) return
+          res.devices.forEach((device: any) => {
+            if (device) onDeviceFound(device)
+          })
+        }
+
+        uni.onBluetoothDeviceFound(listener)
+
+        resolve(() => {
+          if (listener) {
+            uni.offBluetoothDeviceFound()
+            listener = null
+          }
+        })
+      },
+      fail: reject,
+    })
+  })
+}
+
+export async function stopBluetoothDevicesDiscovery(): Promise<void> {
+  return new Promise((resolve, reject) => {
+    uni.stopBluetoothDevicesDiscovery({
+      success: () => resolve(),
+      fail: reject,
+    })
+  })
+}
+
+export async function connectBLEDevice(deviceId: string): Promise<void> {
+  return new Promise((resolve, reject) => {
+    uni.createBLEConnection({
+      deviceId,
+      success: resolve,
+      fail: reject,
+    })
+  })
+}
+
+export async function closeBLEConnection(deviceId: string): Promise<void> {
+  return new Promise((resolve, reject) => {
+    uni.closeBLEConnection({
+      deviceId,
+      success: resolve,
+      fail: reject,
+    })
+  })
+}
+
+export async function getBluetoothDevices(): Promise<any[]> {
+  return new Promise((resolve, reject) => {
+    uni.getBluetoothDevices({
+      success: (res: any) => resolve(res.devices || []),
+      fail: reject,
+    })
+  })
+}
+
+export async function getBLEDeviceServicesAndCharacteristics(
+  deviceId: string,
+): Promise<{ services: any[]; characteristicsMap: Record<string, any[]> }> {
+  const services = await new Promise<any[]>((resolve, reject) => {
+    uni.getBLEDeviceServices({
+      deviceId,
+      success: (res: any) => resolve(res.services || []),
+      fail: reject,
+    })
+  })
+
+  const characteristicsMap: Record<string, any[]> = {}
+
+  await Promise.all(
+    services.map(async (service) => {
+      try {
+        const characteristics = await new Promise<any[]>((resolve, reject) => {
+          uni.getBLEDeviceCharacteristics({
+            deviceId,
+            serviceId: service.uuid,
+            success: (res: any) => resolve(res.characteristics || []),
+            fail: reject,
+          })
+        })
+        characteristicsMap[service.uuid] = characteristics
+      } catch (err) {
+        // 忽略某些服务获取特征值失败
+        console.warn('getBLEDeviceCharacteristics fail', service.uuid, err)
+      }
+    }),
+  )
+
+  return { services, characteristicsMap }
+}