云
3 天以前 2f9c84cf0e0c03a8e3bf07d3924ccdf791070b77
feat(mobile): 添加移动端自动托盘生成功能和追溯定位功能

- 新增自动托盘生成流程,支持48袋和100袋两种规格
- 实现扫码连续采集功能,达到阈值后可选择生成托盘
- 添加托盘容量字段用于标称每托袋数
- 实现定位授权访问记录功能,支持位置分布统计
- 优化移动端扫码界面,增加连续扫描模式
- 移除物料主数据中的辅助单位和销售价等冗余字段
- 更新托盘API接口,新增容量参数支持
已修改7个文件
304 ■■■■■ 文件已修改
src/api/mes/pro/pallet/index.ts 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/trace-consumer-scan/index.ts 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/basicData/mdm/data.ts 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/basicData/mdm/index.vue 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/trace-consumer-scan/index.vue 87 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mobile/query.vue 82 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mobile/scanner.vue 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/pallet/index.ts
@@ -17,6 +17,7 @@
    batchId?: number; // 生产批次编号
    batchCode?: string; // 批次号
    palletNo?: number; // 批次内托盘序号
    capacity?: number; // 标称每托袋数
    autoGenerated?: boolean; // 是否自动生成(袋码数据包生成时自动创建)
    bagCount?: number; // 已绑定袋码数量
    status?: number; // 状态(字典 mes_pro_pallet_status)
@@ -44,7 +45,7 @@
    code?: string; // 托盘码(绑定批次后的正式托盘码)
    tempCode?: string; // 临时托盘码(二维码内容;未绑定批次时展示)
    palletNo?: number; // 批次内托盘序号
    capacity?: number; // 可装袋数
    capacity?: number; // 标称每托袋数
    bagCount?: number; // 已绑定袋码数量
    status?: number; // 状态(字典 mes_pro_pallet_status)
    batchId?: number; // 生产批次编号
@@ -67,6 +68,7 @@
  export interface BatchCreateParams {
    batchId: number; // 生产批次编号
    count: number; // 生成数量
    capacity?: number; // 标称每托袋数
    remark?: string; // 备注
  }
@@ -86,6 +88,7 @@
/** 预生成托盘临时二维码(不绑定生产批次) */
export function preGeneratePallet(data: {
  count: number;
  capacity?: number;
  remark?: string;
}) {
  return requestClient.post<MesProPalletApi.PreGenerateResult[]>(
src/api/mes/trace-consumer-scan/index.ts
@@ -1,6 +1,25 @@
import { requestClient } from '#/api/request';
export namespace MesTraceConsumerScanApi {
  export interface LocationVisit {
    id: number;
    traceCode: string;
    latitude: number;
    longitude: number;
    accuracy: number;
    visitTime: string;
  }
  export interface LocationVisitPage {
    list: LocationVisit[];
    total: number;
  }
  export interface LocationDistribution {
    latitude: number;
    longitude: number;
    count: number;
  }
  /** 消费者扫码访问汇总 */
  export interface Summary {
    total: number; // 总访问次数
@@ -24,6 +43,31 @@
  }
}
/** 授权定位的追溯访问记录;沿用扫码统计查询权限 */
export function getTraceLocationVisits(params: {
  pageNo: number;
  pageSize: number;
  startTime?: string;
  endTime?: string;
  traceCode?: string;
}) {
  return requestClient.get<MesTraceConsumerScanApi.LocationVisitPage>(
    '/mes/trace-consumer-scan/location-visits',
    { params },
  );
}
export function getTraceLocationDistribution(params: {
  startTime?: string;
  endTime?: string;
  traceCode?: string;
}) {
  return requestClient.get<MesTraceConsumerScanApi.LocationDistribution[]>(
    '/mes/trace-consumer-scan/location-distribution',
    { params },
  );
}
/** 获得消费者扫码访问汇总 */
export function getTraceConsumerScanSummary(
  startTime?: string,
src/views/basicData/mdm/data.ts
@@ -503,57 +503,15 @@
      minWidth: 80,
    },
    {
      field: "unitMeasureName2",
      title: "辅单位1",
      minWidth: 80,
    },
    {
      field: "unitMeasureRate1",
      title: "辅单位1换算比率",
      minWidth: 130,
    },
    {
      field: "unitMeasureName3",
      title: "辅单位2",
      minWidth: 80,
    },
    {
      field: "unitMeasureRate2",
      title: "辅单位2换算比率",
      minWidth: 130,
    },
    {
      field: "warehouseName",
      title: "默认仓库",
      minWidth: 100,
    },
    {
      field: "salesPrice",
      title: "销售价",
      minWidth: 100,
      formatter: ({ cellValue }) => {
        return cellValue != null ? `¥${Number(cellValue).toFixed(2)}` : "";
      },
    },
    {
      field: "createTime",
      title: "创建时间",
      minWidth: 180,
      formatter: "formatDateTime",
    },
    {
      field: "isBatchManaged",
      title: "批次管理",
      width: 80,
      fixed: "right",
      slots: { default: "isBatchManaged" },
    },
    {
      field: "syncedMes",
      title: "同步状态",
      width: 100,
      fixed: "right",
      slots: { default: "syncedMes" },
    },
    {
      field: "status",
src/views/basicData/mdm/index.vue
@@ -7,7 +7,7 @@
import { confirm, Page, useVbenModal } from '#/packages/effects/common-ui/src';
import { downloadFileFromBlobPart, isEmpty } from '#/packages/utils/src';
import { message, Switch, Tag } from 'ant-design-vue';
import { message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
@@ -16,7 +16,6 @@
  // exportItem,
  getItemPage,
  updateItemStatus,
  updateItemSync,
} from '#/api/mdm/item';
import { $t } from '#/locales';
@@ -99,23 +98,6 @@
  }
}
const syncingId = ref<number | null>(null);
/** 切换同步状态:关闭开关会从 MES 删除品种,需确认 */
async function handleSyncChange(row: MdmItemApi.Item, checked: boolean) {
  if (!checked) {
    await confirm('关闭同步将从 MES 删除该品种及关联配置(BOM/SOP/SIP/批次配置),确认继续?');
  }
  syncingId.value = row.id!;
  try {
    await updateItemSync(row.id!, checked);
    message.success(checked ? '已同步到 MES' : '已取消同步');
    handleRefresh();
  } finally {
    syncingId.value = null;
  }
}
const checkedIds = ref<number[]>([]);
function handleRowCheckboxChange({
  records,
@@ -191,19 +173,6 @@
              onClick: handleDeleteBatch,
            },
          ]"
        />
      </template>
      <template #isBatchManaged="{ row }">
        <Tag v-if="row.isBatchManaged" color="success">是</Tag>
        <Tag v-else>否</Tag>
      </template>
      <template #syncedMes="{ row }">
        <Switch
          :checked="row.syncedMes"
          :loading="syncingId === row.id"
          checkedChildren="已同步"
          unCheckedChildren="未同步"
          @change="handleSyncChange(row, $event)"
        />
      </template>
      <template #actions="{ row }">
src/views/mes/trace-consumer-scan/index.vue
@@ -1,12 +1,14 @@
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { computed, ref, watch } from 'vue';
import { Page } from '@vben/common-ui';
import { Card, Col, DatePicker, Row } from 'ant-design-vue';
import { Card, Col, DatePicker, Input, Row, Table } from 'ant-design-vue';
import dayjs from 'dayjs';
import { getRangePickerDefaultProps } from '#/utils';
import { getTraceLocationDistribution, getTraceLocationVisits } from '#/api/mes/trace-consumer-scan';
import type { MesTraceConsumerScanApi } from '#/api/mes/trace-consumer-scan';
import DistributionChart from './modules/distribution-chart.vue';
import SummaryCards from './modules/summary-cards.vue';
@@ -22,6 +24,59 @@
const startTime = computed(() => dateRange.value?.[0]);
const endTime = computed(() => dateRange.value?.[1]);
const traceCode = ref('');
const pageNo = ref(1);
const locationLoading = ref(false);
const locationRows = ref<MesTraceConsumerScanApi.LocationVisit[]>([]);
const locationDistribution = ref<MesTraceConsumerScanApi.LocationDistribution[]>([]);
const locationTotal = ref(0);
const locationColumns = [
  { title: '访问时间', dataIndex: 'visitTime', key: 'visitTime' },
  { title: '追溯码', dataIndex: 'traceCode', key: 'traceCode' },
  { title: '纬度', dataIndex: 'latitude', key: 'latitude' },
  { title: '经度', dataIndex: 'longitude', key: 'longitude' },
  { title: '精度(米)', dataIndex: 'accuracy', key: 'accuracy' },
  { title: '位置', key: 'map' },
];
async function loadLocationVisits() {
  locationLoading.value = true;
  try {
    const page = await getTraceLocationVisits({
      pageNo: pageNo.value,
      pageSize: 20,
      startTime: startTime.value,
      endTime: endTime.value,
      traceCode: traceCode.value.trim() || undefined,
    });
    locationRows.value = page.list;
    locationTotal.value = page.total;
    locationDistribution.value = await getTraceLocationDistribution({
      startTime: startTime.value,
      endTime: endTime.value,
      traceCode: traceCode.value.trim() || undefined,
    });
  } finally {
    locationLoading.value = false;
  }
}
watch([startTime, endTime, traceCode], () => {
  pageNo.value = 1;
  loadLocationVisits();
}, { immediate: true });
function mapUrl(row: Record<string, any>) {
  const lat = Number(row.latitude);
  const lon = Number(row.longitude);
  return `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lon}#map=16/${lat}/${lon}`;
}
function handleLocationPageChange(pagination: { current?: number }) {
  pageNo.value = pagination.current || 1;
  loadLocationVisits();
}
</script>
<template>
@@ -46,5 +101,33 @@
        <DistributionChart :start-time="startTime" :end-time="endTime" />
      </Col>
    </Row>
    <Card title="定位授权访问" class="mb-4">
      <p class="mb-3 text-sm text-muted-foreground">
        当前筛选条件下共 {{ locationTotal }} 次授权定位访问;下表显示用户授权后上传的位置及定位精度。
      </p>
      <Input v-model:value="traceCode" class="mb-3 max-w-xs" allow-clear placeholder="按追溯码筛选" />
      <div v-if="locationDistribution.length" class="mb-4">
        <div class="mb-2 font-medium">热门位置(坐标保留两位小数,最多 20 处)</div>
        <div class="flex flex-wrap gap-2">
          <span v-for="item in locationDistribution" :key="`${item.latitude},${item.longitude}`" class="rounded bg-green-50 px-3 py-1 text-sm text-green-800">
            {{ item.latitude }}, {{ item.longitude }}:{{ item.count }} 次
          </span>
        </div>
      </div>
      <Table
        :columns="locationColumns"
        :data-source="locationRows"
        :loading="locationLoading"
        :pagination="{ current: pageNo, pageSize: 20, total: locationTotal, showSizeChanger: false }"
        :row-key="(row: MesTraceConsumerScanApi.LocationVisit) => row.id"
        :scroll="{ x: 850 }"
        @change="handleLocationPageChange"
      >
        <template #bodyCell="{ column, record }">
          <a v-if="column.key === 'map'" :href="mapUrl(record)" target="_blank" rel="noopener noreferrer">查看地图</a>
        </template>
      </Table>
    </Card>
  </Page>
</template>
src/views/mobile/query.vue
@@ -3,7 +3,7 @@
import { onBeforeRouteLeave, useRoute } from 'vue-router';
import { useUserStore } from '@vben/stores';
import { bindBagCodePallet, getBagCode, getBagCodePage } from '#/api/mes/pro/bagCode';
import { bindPalletBatch, getPallet, getPalletPage } from '#/api/mes/pro/pallet';
import { batchCreatePallet, bindPalletBatch, getPallet, getPalletPage, preGeneratePallet } from '#/api/mes/pro/pallet';
import { getBatchPage } from '#/api/mes/pro/batch';
import { can } from '#/mobile/access';
import { lookup, errorText, writeBlockReason, type TraceResult, type TraceObject } from '#/mobile/api';
@@ -13,10 +13,12 @@
const expected={bags:'bag',pallets:'pallet',batches:'batch'}[key];
const busy=ref(false),error=ref(''),notice=ref(''),detail=ref<TraceResult>(),rows=ref<TraceObject[]>([]),page=ref(1),total=ref(0),filter=ref('');
const binding=ref(false),target=ref<TraceResult>(),bags=ref<TraceResult[]>([]);
type PendingBind={kind:string;ids:number[];targetId:number};
const autoPacking=ref(false),palletSize=ref<48|100>(48),thresholdPrompt=ref(false),thresholdPassed=ref(false);
type PendingBind={kind:string;ids:number[];targetId:number;stage?:'creating'|'binding';palletCode?:string};
const pending=ref<PendingBind>();
const storageKey='mom-mobile-bind-'+useUserStore().userInfo?.id+'-'+key;
const canBind=computed(()=>key==='bags'?can('mes:pro-bag-code:update')&&can('mes:pro-pallet:query'):key==='pallets'&&can('mes:pro-pallet:update')&&can('mes:pro-batch:query'));
const canAutoPack=computed(()=>key==='bags'&&can('mes:pro-bag-code:update')&&can('mes:pro-pallet:create')&&can('mes:pro-pallet:query'));
let appliedFilter='';
async function load(reset=false){
  if(busy.value)return;busy.value=true;error.value='';
@@ -31,10 +33,22 @@
}
onMounted(()=>{try{pending.value=JSON.parse(sessionStorage.getItem(storageKey)||'null')||undefined;}catch{sessionStorage.removeItem(storageKey);}load(true);});
async function identify(code:string){
  if(busy.value||pending.value)return;busy.value=true;error.value='';notice.value='';
  if(busy.value||pending.value||thresholdPrompt.value)return;busy.value=true;error.value='';notice.value='';
  try{
    const r=await lookup(code);
    if(binding.value&&writeBlockReason(r))throw new Error(writeBlockReason(r));
    if((binding.value||autoPacking.value)&&writeBlockReason(r))throw new Error(writeBlockReason(r));
    if(autoPacking.value){
      if(r.type!=='bag')throw new Error('请扫描袋码');
      if(r.data.palletId)throw new Error('该袋码已绑定托盘,请核实后操作');
      if(!r.data.itemId)throw new Error('该袋码缺少品种,无法绑定托盘');
      if(bags.value.some(b=>b.data.id===r.data.id))throw new Error('本次已经采集该袋码');
      const first=bags.value[0];
      if(first&&first.data.itemId!==r.data.itemId)throw new Error('袋码品种与本次托盘不一致');
      if(first&&first.data.batchId!==r.data.batchId)throw new Error('袋码所属批次与本次托盘不一致');
      bags.value.push(r);
      if(bags.value.length===palletSize.value&&!thresholdPassed.value)thresholdPrompt.value=true;
      return;
    }
    if(!binding.value){
      if(r.type!==expected)throw new Error('该码类型与当前查询页面不一致,请使用对应查询入口');
      detail.value=r;
@@ -56,7 +70,34 @@
  }catch(e){error.value=errorText(e);}finally{busy.value=false;}
}
function startBind(){binding.value=true;detail.value=undefined;target.value=undefined;bags.value=[];notice.value='';error.value='';}
function startAutoPack(){autoPacking.value=true;binding.value=false;detail.value=undefined;target.value=undefined;bags.value=[];thresholdPrompt.value=false;thresholdPassed.value=false;notice.value='';error.value='';}
function stopAutoPack(){autoPacking.value=false;bags.value=[];thresholdPrompt.value=false;thresholdPassed.value=false;}
function removeBag(index:number){bags.value.splice(index,1);if(bags.value.length<palletSize.value){thresholdPrompt.value=false;thresholdPassed.value=false;}}
function clearPending(){pending.value=undefined;sessionStorage.removeItem(storageKey);}
async function createAndBindPallet(){
  if(busy.value||pending.value||bags.value.length<palletSize.value)return;
  const ids=bags.value.map(b=>b.data.id!);
  const operation:PendingBind={kind:'auto-bags',ids,targetId:0,stage:'creating'};
  pending.value=operation;sessionStorage.setItem(storageKey,JSON.stringify(operation));busy.value=true;error.value='';thresholdPrompt.value=false;
  try{
    const batchId=bags.value[0]?.data.batchId;
    if(batchId){
      const [id]=await batchCreatePallet({batchId,count:1,capacity:palletSize.value});
      if(!id)throw new Error('未取得新托盘编号');
      operation.targetId=id;operation.stage='binding';sessionStorage.setItem(storageKey,JSON.stringify(operation));
      const created=await getPallet(id);
      operation.palletCode=created.code||created.tempCode||String(id);
    }else{
      const [created]=await preGeneratePallet({count:1,capacity:palletSize.value});
      if(!created?.id)throw new Error('未取得新托盘编号');
      operation.targetId=created.id;operation.palletCode=created.tempCode;operation.stage='binding';
    }
    sessionStorage.setItem(storageKey,JSON.stringify(operation));
    await bindBagCodePallet({ids,palletId:operation.targetId});
    clearPending();stopAutoPack();notice.value=`已生成并绑定托盘码 ${operation.palletCode},共 ${ids.length} 袋。`;
  }catch(e){error.value=operation.targetId?`托盘码 ${operation.palletCode} 的绑定结果待确认:${errorText(e)}。请先核实,勿重复生成。`:`生成结果待确认:${errorText(e)}。请在托盘列表核实,勿重复生成。`;}
  finally{busy.value=false;}
}
async function submitBind(){
  if(busy.value||!target.value?.data.id)return;
  const ids=key==='bags'?bags.value.map(b=>b.data.id!):detail.value?.data.id?[detail.value.data.id]:[];
@@ -74,26 +115,35 @@
  if(!pending.value||busy.value)return;busy.value=true;error.value='';
  try{
    const p=pending.value;
    const states=await Promise.all(p.ids.map(id=>p.kind==='bags'?getBagCode(id):getPallet(id)));
    const all=states.every(s=>p.kind==='bags'?('palletId'in s&&s.palletId===p.targetId):s.batchId===p.targetId);
    const none=states.every(s=>p.kind==='bags'?(!('palletId'in s)||!s.palletId):!s.batchId);
    if(all){notice.value='已核实:所有对象绑定成功。';clearPending();binding.value=false;bags.value=[];detail.value=undefined;}
    if(p.kind==='auto-bags'&&!p.targetId){error.value='尚未取得托盘编号,请在托盘列表核实生成结果后联系负责人处理。';return;}
    const states=await Promise.all(p.ids.map(id=>p.kind==='bags'||p.kind==='auto-bags'?getBagCode(id):getPallet(id)));
    const bagOperation=p.kind==='bags'||p.kind==='auto-bags';
    const all=states.every(s=>bagOperation?('palletId'in s&&s.palletId===p.targetId):s.batchId===p.targetId);
    const none=states.every(s=>bagOperation?(!('palletId'in s)||!s.palletId):!s.batchId);
    if(all){notice.value=p.kind==='auto-bags'?`已核实:托盘码 ${p.palletCode} 已绑定 ${p.ids.length} 袋。`:'已核实:所有对象绑定成功。';clearPending();binding.value=false;stopAutoPack();detail.value=undefined;}
    else if(none&&p.kind==='auto-bags'){await bindBagCodePallet({ids:p.ids,palletId:p.targetId});notice.value=`已完成托盘码 ${p.palletCode} 的袋码绑定。`;clearPending();stopAutoPack();}
    else if(none){notice.value='已核实:对象仍未绑定。请重新查询对象后采集提交。';clearPending();binding.value=false;bags.value=[];detail.value=undefined;}
    else error.value='对象绑定结果不一致,请联系负责人核对。当前保留待确认操作。';
  }catch(e){error.value=errorText(e);}finally{busy.value=false;}
}
onBeforeRouteLeave(()=>!busy.value&&(!binding.value||!!pending.value||window.confirm('离开将放弃本次未提交的绑定清单,是否继续?')));
onBeforeRouteLeave(()=>!busy.value&&(!(binding.value||autoPacking.value)||!!pending.value||window.confirm('离开将放弃本次未提交的绑定清单,是否继续?')));
</script>
<template>
  <p v-if="notice" class="m-success" role="status">{{notice}}</p>
  <p v-if="error" class="m-error" role="alert">{{error}}</p>
  <div v-if="pending" class="m-panel"><h2 class="m-title">绑定结果待确认</h2><p>本次 {{pending.ids.length}} 个对象,目标编号 {{pending.targetId}}。</p><button class="m-primary" :disabled="busy" @click="verifyBind">核实绑定结果</button></div>
  <div v-if="pending" class="m-panel"><h2 class="m-title">{{pending.stage==='creating'?'生成结果待确认':'绑定结果待确认'}}</h2><p>本次 {{pending.ids.length}} 个对象<span v-if="pending.targetId">,托盘码 {{pending.palletCode||pending.targetId}}</span>。</p><button class="m-primary" :disabled="busy||!pending.targetId" @click="verifyBind">核实绑定结果</button><p v-if="!pending.targetId" class="m-warning">生成请求的结果尚不明确,请在托盘列表核实并联系负责人处理。</p></div>
  <template v-else>
    <template v-if="binding">
      <h2 class="m-title">{{!target?(key==='bags'?'先扫描目标托盘':'先扫描目标批次'):(key==='bags'?'逐袋采集':'扫描待绑定托盘')}}</h2>
      <div v-if="target" class="m-panel"><p>目标:{{target.code}}</p><p class="m-muted">{{target.data.itemName||'未录入品种'}} · {{target.data.batchCode||'未绑定批次'}}</p></div>
    </template>
    <Scanner :disabled="busy" @code="identify"/>
    <template v-if="autoPacking"><h2 class="m-title">扫描袋码生成托盘</h2><p class="m-muted">{{palletSize}} 袋型 · 已采集 {{bags.length}} 袋<span v-if="bags.length<palletSize">,还需 {{palletSize-bags.length}} 袋</span>。生成后将自动绑定本次采集的袋码。</p></template>
    <Scanner :disabled="busy||thresholdPrompt" :continuous="autoPacking" @code="identify"/>
    <section v-if="autoPacking&&thresholdPrompt" class="m-panel" role="alert" aria-labelledby="pallet-threshold-title" aria-describedby="pallet-threshold-description">
      <h2 id="pallet-threshold-title" class="m-title">已扫描 {{palletSize}} 袋</h2>
      <p id="pallet-threshold-description">现在生成并绑定托盘码,或继续扫描更多袋码加入本托盘。</p>
      <div class="m-action-bar"><button class="m-primary" :disabled="busy" @click="createAndBindPallet">生成并绑定托盘码</button><button class="m-secondary" :disabled="busy" @click="thresholdPrompt=false;thresholdPassed=true">继续扫码绑定</button></div>
    </section>
    <div v-if="detail&&!binding" class="m-panel">
      <h2 class="m-title">{{detail.data.itemName||'对象详情'}}</h2>
      <p v-if="detail.type === 'batch'" class="m-status">{{detail.data.status === 30 ? '已完成' : detail.data.status === 20 ? '生产中' : detail.data.status === 40 ? '已作废' : '草稿'}} · 已入库 {{detail.data.inboundBagQuantity || 0}} 袋</p>
@@ -103,13 +153,15 @@
      <dl v-if="detail.type==='bag'"><div class="m-row"><dt>追溯码</dt><dd>{{detail.data.code}}</dd></div><div class="m-row"><dt>业务码</dt><dd>{{detail.data.businessCode||'未生成'}}</dd></div></dl>
      <dl v-else><div class="m-row"><dt>业务码</dt><dd>{{detail.data.businessCode||'未生成'}}</dd></div></dl>
    </div>
    <template v-if="binding">
    <template v-if="binding||autoPacking">
      <p v-if="key==='bags'" class="m-muted">已采集 {{bags.length}} 袋,尚未提交绑定</p>
      <article v-for="(bag,i) in bags" :key="bag.data.id" class="m-record"><p>{{bag.code}}</p><button class="m-detail-toggle" :disabled="busy" @click="bags.splice(i,1)">移除</button></article>
      <p v-if="key==='pallets'&&detail">待绑定托盘:{{detail.code}}</p>
      <div class="m-action-bar"><button class="m-primary" :disabled="busy||!target||(key==='bags'?!bags.length:!detail)" @click="submitBind">{{busy?'正在绑定…':'确认绑定'}}</button><button class="m-secondary" :disabled="busy" @click="binding=false;bags=[];target=undefined;detail=undefined">取消本次绑定</button></div>
      <article v-for="(bag,i) in bags" :key="bag.data.id" class="m-record"><p>{{bag.code}}</p><button class="m-detail-toggle" :disabled="busy" @click="removeBag(i)">移除</button></article>
      <p v-if="binding&&key==='pallets'&&detail">待绑定托盘:{{detail.code}}</p>
      <div v-if="autoPacking&&!thresholdPrompt" class="m-action-bar"><button class="m-primary" :disabled="busy||bags.length<palletSize" @click="createAndBindPallet">{{busy?'正在生成…':'生成并绑定托盘码'}}</button><button class="m-secondary" :disabled="busy" @click="stopAutoPack">取消本次扫码</button></div>
      <div v-else-if="binding" class="m-action-bar"><button class="m-primary" :disabled="busy||!target||(key==='bags'?!bags.length:!detail)" @click="submitBind">{{busy?'正在绑定…':'确认绑定'}}</button><button class="m-secondary" :disabled="busy" @click="binding=false;bags=[];target=undefined;detail=undefined">取消本次绑定</button></div>
    </template>
    <template v-else>
      <div v-if="canAutoPack" class="m-panel"><h2 class="m-title">扫描袋码自动生成托盘</h2><label class="m-field"><span>托盘码类型</span><select v-model.number="palletSize" :disabled="busy"><option :value="48">48 袋</option><option :value="100">100 袋</option></select></label><button class="m-primary" :disabled="busy" @click="startAutoPack">开始扫码生成托盘</button></div>
      <button v-if="canBind" class="m-primary" :disabled="busy" @click="startBind">{{key==='bags'?'开始袋码绑定托盘':'开始托盘绑定批次'}}</button>
      <h2 class="m-section-title">对象列表</h2>
      <form class="m-stack" @submit.prevent="load(true)"><label class="m-field"><span>按码值查询</span><input v-model="filter" placeholder="输入码值" /></label><button class="m-secondary" :disabled="busy">查询列表</button></form>
src/views/mobile/scanner.vue
@@ -1,12 +1,13 @@
<script setup lang="ts">
import { onBeforeUnmount, onDeactivated, onMounted, ref } from 'vue';
import { nextTick, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue';
import Icon from '#/mobile/icon.vue';
const props=defineProps<{disabled?:boolean}>();
const props=defineProps<{disabled?:boolean;continuous?:boolean}>();
const emit=defineEmits<{code:[value:string]}>();
const input=ref(''),error=ref(''),active=ref(false),starting=ref(false),video=ref<HTMLVideoElement>();
const input=ref(''),error=ref(''),active=ref(false),starting=ref(false),video=ref<HTMLVideoElement>(),codeInput=ref<HTMLInputElement>();
let stream:MediaStream|undefined,frame=0,generation=0,last=0;
function stop(){generation++;active.value=false;starting.value=false;cancelAnimationFrame(frame);stream?.getTracks().forEach(t=>t.stop());stream=undefined;if(video.value)video.value.srcObject=null;}
function accept(value:string){if(props.disabled)return;stop();input.value=value;emit('code',value);}
function accept(value:string){if(props.disabled)return;stop();input.value=props.continuous?'':value;emit('code',value);}
watch([()=>props.disabled,()=>props.continuous],async ([disabled,continuous])=>{if(!disabled&&continuous){await nextTick();codeInput.value?.focus();}});
async function start(){
  error.value='';stop();const ticket=generation;starting.value=true;
  try{
@@ -52,7 +53,7 @@
  <template v-else><video ref="video" class="m-camera" muted playsinline aria-label="二维码取景"/><button type="button" class="m-secondary" @click="stop">关闭摄像头</button></template>
  <p v-if="error" class="m-error" role="alert">{{error}}</p>
  <form @submit.prevent="accept(input)">
    <label class="m-field" style="margin-top:16px"><span>码值 / 溯源链接</span><input v-model="input" autocomplete="off" :disabled="disabled" placeholder="手动输入或使用扫码设备" maxlength="2048" /></label>
    <label class="m-field" style="margin-top:16px"><span>码值 / 溯源链接</span><input ref="codeInput" v-model="input" autocomplete="off" :disabled="disabled" placeholder="手动输入或使用扫码设备" maxlength="2048" /></label>
    <button class="m-secondary" :disabled="disabled||!input.trim()" type="submit">{{disabled?'正在处理…':'识别码值'}}</button>
  </form>
</div>