云
5 小时以前 5d321660a2f1a4b8756f816283e96481a8c934e4
feat(app): 更新应用名称并优化批次绑定功能

- 将应用标题从"工业AI智造平台"更新为"塔河种业加工销售供应链数字化管理平台"
- 替换认证页面的品牌标识为塔河种业Logo
- 移除原有的种植业相关统计卡片和监测面板组件
- 实现托盘和吨袋绑定的分页加载功能
- 添加绑定选项的搜索和滚动加载支持
- 优化绑定选项的禁用状态逻辑
- 更新公司配置中的平台名称
- 调整认证页面布局样式以适配新品牌
已修改11个文件
已添加1个文件
703 ■■■■■ 文件已修改
.env 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
company.config.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
public/images/tahe-logo.png 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/bagCode/index.ts 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/pallet/index.ts 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/layouts/auth.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/effects/common-ui/src/components/captcha/verification/index.vue 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/effects/common-ui/src/components/captcha/verification/verify-slide.vue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/effects/layouts/src/authentication/authentication.vue 266 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/_core/authentication/login.vue 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/batch/data.ts 74 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/batch/modules/form.vue 274 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.env
@@ -1,5 +1,5 @@
# 应用标题
VITE_APP_TITLE=工业AI智造平台
VITE_APP_TITLE=塔河种业加工销售供应链数字化管理平台
# 应用命名空间,用于缓存、store等功能的前缀,确保隔离
VITE_APP_NAMESPACE=yudao-vben-antd
company.config.ts
@@ -18,7 +18,7 @@
 */
export const companies: Record<string, CompanyConfig> = {
  TEST: {
    name: '新疆塔河种业',
    name: '塔河种业加工销售供应链数字化管理平台',
    logo: '/logos/TEST.png',
    favicon: '/logos/TEST.ico',
    apiUrl: 'http://1.95.78.124:9008/admin-api',
public/images/tahe-logo.png
src/api/mes/pro/bagCode/index.ts
@@ -263,9 +263,16 @@
  );
}
/** 可绑定批次的吨袋(无托盘且未绑定批次) */
export function getUnboundTonBags() {
  return requestClient.get<MesProBagCodeApi.BagCode[]>('/mes/pro/bag-code/unbound-ton-bags');
/** 分页查询可绑定批次的吨袋(无托盘且未绑定批次,品种计量单位为吨) */
export function getUnboundTonBagPage(params: {
  keyword?: string;
  pageNo: number;
  pageSize: number;
}) {
  return requestClient.get<PageResult<MesProBagCodeApi.BagCode>>(
    '/mes/pro/bag-code/unbound-ton-bag-page',
    { params },
  );
}
/** 批量导出袋码 Excel(交付印刷) */
src/api/mes/pro/pallet/index.ts
@@ -137,10 +137,15 @@
  );
}
/** 查询未绑定批次的临时托盘列表(供批次新增/补录绑定托盘下拉使用) */
export function getUnboundPalletList() {
  return requestClient.get<MesProPalletApi.Pallet[]>(
    '/mes/pro/pallet/unbound-list',
/** 分页查询未绑定批次的临时托盘(供批次新增/补录绑定托盘下拉滚动加载) */
export function getUnboundPalletPage(params: {
  keyword?: string;
  pageNo: number;
  pageSize: number;
}) {
  return requestClient.get<PageResult<MesProPalletApi.Pallet>>(
    '/mes/pro/pallet/unbound-page',
    { params },
  );
}
src/layouts/auth.vue
@@ -9,7 +9,7 @@
declare const __APP_LOGO__: string;
const appName = computed(() => '种植业数字化平台');
const appName = computed(() => '塔河种业加工销售供应链数字化管理平台');
const logo = computed(() => __APP_LOGO__ || preferences.logo.source);
const logoDark = computed(() => preferences.logo.sourceDark);
</script>
@@ -25,7 +25,7 @@
    :logo="logo"
    :logo-dark="logoDark"
    page-description="数字赋能农业 · 科技引领未来"
    page-title="种植业数字化平台"
    page-title="塔河种业加工销售供应链数字化管理平台"
  >
    <!-- 自定义工具栏 -->
    <!-- <template #toolbar></template> -->
src/packages/effects/common-ui/src/components/captcha/verification/index.vue
@@ -5,7 +5,15 @@
 */
import type { VerificationProps } from './typing';
import { defineAsyncComponent, markRaw, ref, toRefs, watchEffect } from 'vue';
import {
  computed,
  defineAsyncComponent,
  markRaw,
  nextTick,
  ref,
  toRefs,
  watchEffect,
} from 'vue';
import { IconifyIcon } from '../../../../../../icons/src';
@@ -58,8 +66,20 @@
};
const show = () => {
  if (mode.value === 'pop') showBox.value = true;
  if (mode.value === 'pop') {
    showBox.value = true;
    // 每次打开按当前视口重新计算尺寸,适配移动端
    nextTick(() => instance.value?.init?.());
  }
};
/** 弹窗最大宽度,避免窄屏溢出 */
const boxMaxWidth = computed(() => {
  const width = Number.parseInt(String(props.imgSize.width));
  const limit =
    typeof window === 'undefined' ? 420 : window.innerWidth - 24;
  return `${Math.min(width + 20, limit)}px`;
});
const onError = (proxy: any) => {
  emit('onError', proxy);
@@ -109,7 +129,7 @@
  <div v-show="showBox">
    <div
      :class="mode === 'pop' ? 'verifybox' : ''"
      :style="{ 'max-width': `${parseInt(imgSize.width) + 20}px` }"
      :style="{ 'max-width': boxMaxWidth }"
    >
      <div v-if="mode === 'pop'" class="verifybox-top">
        {{ $t('ui.captcha.title') }}
src/packages/effects/common-ui/src/components/captcha/verification/verify-slide.vue
@@ -108,7 +108,8 @@
  window.removeEventListener('touchend', end);
  window.removeEventListener('mouseup', end);
  window.addEventListener('touchmove', move);
  // passive: false 以便拖动滑块时阻止页面滚动
  window.addEventListener('touchmove', move, { passive: false });
  window.addEventListener('mousemove', move);
  // 鼠标松开
@@ -144,6 +145,8 @@
// 鼠标移动
function move(e: MouseEvent | TouchEvent) {
  if (status.value && isEnd.value === false) {
    // 移动端拖动时阻止页面跟随滚动
    if (e.cancelable) e.preventDefault();
    const x =
      ((e as TouchEvent).touches
        ? (e as TouchEvent).touches[0]?.pageX
src/packages/effects/layouts/src/authentication/authentication.vue
@@ -106,27 +106,16 @@
      <div class="agri-promo-layer absolute inset-0 size-full overflow-hidden">
        <div
          :key="authPanelLeft ? 'left' : authPanelRight ? 'right' : 'center'"
          class="agri-promo relative flex h-full flex-col justify-center px-8 text-left"
          class="agri-promo relative flex h-full flex-col items-end justify-start px-10 pt-16 text-left"
        >
          <!-- 品牌区 -->
          <div class="agri-brand">
            <div class="flex items-center gap-4">
              <svg viewBox="0 0 64 64" class="size-14 shrink-0" aria-hidden="true">
                <circle
                  cx="32" cy="32" r="25" fill="none" stroke="#6ee7b7" stroke-width="2.5"
                  stroke-linecap="round" stroke-dasharray="118 40" transform="rotate(-30 32 32)"
                />
                <circle
                  cx="32" cy="32" r="25" fill="none" stroke="#a7f3d0" stroke-width="2.5"
                  stroke-linecap="round" stroke-dasharray="26 132" transform="rotate(115 32 32)"
                />
                <circle cx="53" cy="17" r="3" fill="#6ee7b7" />
                <circle cx="9" cy="41" r="2.4" fill="#a7f3d0" />
                <path d="M32 45c0-8 0-12 0-16" stroke="#a7f3d0" stroke-width="2.4" stroke-linecap="round" fill="none" />
                <path d="M32 34C24 32 19.5 26 19.5 19.5 27.5 21.5 32 26 32 34Z" fill="#34d399" />
                <path d="M32 30c6.5-1.5 10.5-6.5 10.5-12C36 20 32 24 32 30Z" fill="#6ee7b7" />
                <path d="M20 47q12-6 24 0" stroke="#a7f3d0" stroke-width="2.4" stroke-linecap="round" fill="none" />
              </svg>
              <img
                src="/images/tahe-logo.png"
                alt="塔河种业"
                class="h-16 w-auto shrink-0 rounded-xl bg-white/95 p-1.5 shadow-lg"
              />
              <div>
                <div class="agri-brand-title text-3xl font-semibold tracking-[0.14em] xl:text-4xl">
                  {{ pageTitle }}
@@ -136,125 +125,13 @@
                </div>
              </div>
            </div>
            <div class="agri-brand-tag mt-6 flex items-center gap-4">
            <div class="agri-brand-tag mt-6 flex items-center justify-end gap-4">
              <span class="agri-brand-tag-line h-px w-12"></span>
              <span class="text-base tracking-[0.22em]">{{ pageDescription }}</span>
              <span class="agri-brand-tag-line h-px w-12"></span>
            </div>
          </div>
          <div class="mt-12 flex items-start gap-10">
            <!-- 数据卡片列 -->
            <div class="agri-stats flex w-60 shrink-0 flex-col gap-4" aria-label="平台运行概览">
              <div class="agri-stat">
                <span class="agri-stat-icon">
                  <svg viewBox="0 0 24 24" class="size-5" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
                    <path d="M12 20v-8" />
                    <path d="M12 12C12 8 9 5 4.5 4.5 5 9 8 12 12 12Z" />
                    <path d="M12 10c0-3.5 2.6-6 7-6.5-.4 4-3 6.5-7 6.5Z" />
                    <path d="M5 20h14" />
                  </svg>
                </span>
                <div>
                  <span class="agri-stat-label">种植面积</span>
                  <div class="agri-stat-num">12,580<i>亩</i></div>
                  <span class="agri-stat-trend agri-stat-up">较昨日 ↑ 2.35%</span>
                </div>
              </div>
              <div class="agri-stat">
                <span class="agri-stat-icon">
                  <svg viewBox="0 0 24 24" class="size-5" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
                    <rect x="6" y="6" width="12" height="12" rx="2" />
                    <rect x="10" y="10" width="4" height="4" />
                    <path d="M9 2v4M15 2v4M9 18v4M15 18v4M2 9h4M2 15h4M18 9h4M18 15h4" />
                  </svg>
                </span>
                <div>
                  <span class="agri-stat-label">在线设备</span>
                  <div class="agri-stat-num">1,286<i>台</i></div>
                  <span class="agri-stat-trend agri-stat-up">较昨日 ↑ 5.21%</span>
                </div>
              </div>
              <div class="agri-stat">
                <span class="agri-stat-icon">
                  <svg viewBox="0 0 24 24" class="size-5" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
                    <path d="M12 3s-6 6.6-6 11a6 6 0 0 0 12 0c0-4.4-6-11-6-11Z" />
                    <path d="M9.5 14a2.5 2.5 0 0 0 2.5 2.5" />
                  </svg>
                </span>
                <div>
                  <span class="agri-stat-label">今日灌溉</span>
                  <div class="agri-stat-num">328<i>亩</i></div>
                  <span class="agri-stat-trend agri-stat-up">较昨日 ↑ 1.15%</span>
                </div>
              </div>
              <div class="agri-stat">
                <span class="agri-stat-icon agri-stat-icon-warn">
                  <svg viewBox="0 0 24 24" class="size-5" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
                    <path d="M18 9a6 6 0 1 0-12 0c0 5-2 6-2 6h16s-2-1-2-6" />
                    <path d="M10.3 19a2 2 0 0 0 3.4 0" />
                  </svg>
                </span>
                <div>
                  <span class="agri-stat-label">AI 预警</span>
                  <div class="agri-stat-num">6<i>条</i></div>
                  <span class="agri-stat-trend agri-stat-down">较昨日 ↓ 12.50%</span>
                </div>
              </div>
            </div>
            <!-- 监测面板列 -->
            <div class="agri-monitors hidden w-72 shrink-0 flex-col gap-6 xl:flex">
              <div class="agri-monitor">
                <div class="agri-monitor-title">土壤墒情监测</div>
                <div class="mt-3 flex items-baseline gap-2">
                  <svg viewBox="0 0 24 24" class="size-5 self-center" aria-hidden="true">
                    <path d="M12 3c0 0-6 6.6-6 11a6 6 0 0 0 12 0c0-4.4-6-11-6-11Z" fill="#7dd3fc" />
                  </svg>
                  <strong>68.5%</strong>
                  <span>土壤湿度</span>
                </div>
                <svg viewBox="0 0 260 92" class="mt-3 w-full" aria-hidden="true">
                  <defs>
    <linearGradient id="agriSoilGrad" x1="0" y1="0" x2="0" y2="1">
      <stop offset="0%" stop-color="rgba(110, 231, 183, 0.55)" />
      <stop offset="100%" stop-color="rgba(110, 231, 183, 0.04)" />
    </linearGradient>
                  </defs>
                  <g stroke="rgba(255, 255, 255, 0.14)" stroke-dasharray="3 4" stroke-width="1">
                    <path d="M28 10h226" /><path d="M28 45h226" /><path d="M28 80h226" />
                  </g>
                  <g fill="rgba(255, 255, 255, 0.55)" font-size="8">
                    <text x="2" y="13">100%</text><text x="6" y="48">50%</text><text x="6" y="83">0%</text>
                  </g>
                  <path
                    d="M28 62 C48 58 60 68 76 60 C94 51 108 64 124 56 C142 47 158 54 174 42 C192 30 210 40 228 30 C240 24 248 27 254 22 L254 80 L28 80 Z"
                    fill="url(#agriSoilGrad)"
                  />
                  <path
                    d="M28 62 C48 58 60 68 76 60 C94 51 108 64 124 56 C142 47 158 54 174 42 C192 30 210 40 228 30 C240 24 248 27 254 22"
                    fill="none" stroke="#6ee7b7" stroke-width="2" stroke-linecap="round"
                  />
                </svg>
                <div class="agri-monitor-axis">
                  <span>00:00</span><span>06:00</span><span>12:00</span><span>18:00</span><span>24:00</span>
                </div>
              </div>
              <div class="agri-monitor agri-monitor-ai">
                <div class="agri-monitor-title flex items-center gap-2">
                  <svg viewBox="0 0 24 24" class="size-4.5" fill="none" stroke="#a7f3d0" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
                    <path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9Z" />
                    <path d="M19 15l.9 2.1L22 18l-2.1.9L19 21l-.9-2.1L16 18l2.1-.9Z" />
                  </svg>
                  AI 智能分析
                </div>
                <p>
                  当前作物长势良好,土壤墒情适宜,未来3天无明显降雨,建议适时灌溉,保证作物稳产高产。
                </p>
              </div>
            </div>
          </div>
        </div>
      </div>
@@ -392,124 +269,6 @@
  margin-left: clamp(2rem, 6vw, 7rem);
  padding-top: 3rem;
  padding-bottom: 4.5rem;
}
.agri-brand svg {
  filter: drop-shadow(0 6px 16px rgba(16, 185, 129, 0.35));
}
/* ---------- 数据卡片 ---------- */
.agri-stat {
  display: flex;
  align-items: center;
  gap: 0.85rem;
  border: 1px solid rgba(167, 243, 208, 0.42);
  border-radius: 1.1rem;
  background: linear-gradient(135deg, rgba(8, 87, 65, 0.66), rgba(4, 57, 49, 0.3));
  padding: 0.9rem 1rem;
  box-shadow: 0 12px 28px rgba(0, 36, 28, 0.16);
  transition: border-color 200ms ease, background 200ms ease;
  color: rgba(236, 253, 245, 0.92);
  backdrop-filter: blur(12px);
}
.agri-stat-icon {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 2.6rem;
  height: 2.6rem;
  flex-shrink: 0;
  border-radius: 9999px;
  background: linear-gradient(135deg, #10b981, #047857);
  color: #ecfdf5;
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.22);
}
.agri-stat-icon-warn {
  background: linear-gradient(135deg, #f59e0b, #b45309);
}
.agri-stat-label {
  font-size: 0.75rem;
  color: rgba(236, 253, 245, 0.75);
}
.agri-stat-num {
  margin-top: 0.1rem;
  font-size: 1.35rem;
  font-weight: 700;
  color: #fff;
  line-height: 1.2;
}
.agri-stat-num i {
  margin-left: 0.3rem;
  font-size: 0.75rem;
  font-style: normal;
  font-weight: 400;
  color: rgba(236, 253, 245, 0.8);
}
.agri-stat-trend {
  display: block;
  margin-top: 0.15rem;
  font-size: 0.7rem;
}
.agri-stat-up { color: #6ee7b7; }
.agri-stat-down { color: #fca5a5; }
/* ---------- 监测面板 ---------- */
.agri-monitor {
  border: 1px solid rgba(125, 211, 252, 0.4);
  border-radius: 1.1rem;
  background: linear-gradient(160deg, rgba(8, 51, 68, 0.6), rgba(6, 78, 59, 0.32));
  padding: 1rem 1.1rem;
  color: rgba(236, 253, 245, 0.92);
  backdrop-filter: blur(12px);
  box-shadow: 0 0 24px rgba(56, 189, 248, 0.18), 0 14px 30px rgba(0, 36, 28, 0.18);
}
.agri-monitor-title {
  font-size: 0.85rem;
  font-weight: 600;
  letter-spacing: 0.08em;
  color: #bae6fd;
}
.agri-monitor strong {
  font-size: 1.6rem;
  font-weight: 700;
  color: #fff;
}
.agri-monitor strong + span,
.agri-monitor-title + div span {
  font-size: 0.75rem;
  color: rgba(236, 253, 245, 0.75);
}
.agri-monitor-axis {
  display: flex;
  justify-content: space-between;
  margin-top: 0.35rem;
  padding-left: 1.6rem;
  font-size: 0.7rem;
  color: rgba(255, 255, 255, 0.6);
}
.agri-monitor-ai {
  margin-left: 2.5rem;
  border-color: rgba(167, 243, 208, 0.4);
  box-shadow: 0 0 24px rgba(52, 211, 153, 0.16), 0 14px 30px rgba(0, 36, 28, 0.18);
}
.agri-monitor-ai p {
  margin: 0.6rem 0 0;
  font-size: 0.78rem;
  line-height: 1.7;
  color: rgba(236, 253, 245, 0.85);
}
/* ---------- 右侧登录卡片 ---------- */
@@ -665,17 +424,6 @@
    animation: agri-in-right 0.75s cubic-bezier(0.22, 0.61, 0.36, 1) both;
  }
  .agri-stat,
  .agri-monitor {
    animation: agri-in-up 0.55s cubic-bezier(0.22, 0.61, 0.36, 1) both;
  }
  .agri-stat:nth-child(1) { animation-delay: 0.2s; }
  .agri-stat:nth-child(2) { animation-delay: 0.28s; }
  .agri-stat:nth-child(3) { animation-delay: 0.36s; }
  .agri-stat:nth-child(4) { animation-delay: 0.44s; }
  .agri-monitor:nth-child(1) { animation-delay: 0.3s; }
  .agri-monitor:nth-child(2) { animation-delay: 0.4s; }
}
@keyframes agri-in-left {
src/views/_core/authentication/login.vue
@@ -27,6 +27,15 @@
const captchaType = 'blockPuzzle'; // 验证码类型:'blockPuzzle' | 'clickWord'
/** 滑块验证码尺寸:移动端按视口宽度收缩,避免超出屏幕无法拖动 */
const captchaImgSize = computed(() => {
  const width =
    terminalType.value === 'mobile'
      ? Math.min(315, Math.max(260, window.innerWidth - 48))
      : 400;
  return { width: `${width}px`, height: `${Math.round(width / 2)}px` };
});
/** 处理登录 */
async function handleLogin(values: AuthApi.LoginParams) {
  if (captchaEnable) {
@@ -87,6 +96,14 @@
<template>
  <div>
    <div class="login-brand">
      <img
        src="/images/tahe-logo.png"
        alt="塔河种业"
        class="login-brand-logo"
      />
      <p class="login-brand-name">塔河种业加工销售供应链数字化管理平台</p>
    </div>
    <fieldset class="terminal-selector">
      <legend>选择使用端</legend>
      <div class="terminal-options">
@@ -108,7 +125,7 @@
      :show-field-labels="terminalType === 'mobile'"
      :submit-button-text="terminalType === 'mobile' ? '登录移动端' : '登录 PC 端'"
      title="欢迎回来"
      :sub-title="terminalType === 'mobile' ? '使用已有账号登录现场作业' : '登录种植业数字化平台,开启智慧种植'"
      :sub-title="terminalType === 'mobile' ? '使用已有账号登录现场作业' : '登录塔河种业加工销售供应链数字化管理平台'"
      :show-code-login="false"
      :show-qrcode-login="false"
      :show-third-party-login="false"
@@ -124,7 +141,7 @@
      :captcha-type="captchaType"
      :check-captcha-api="checkCaptcha"
      :get-captcha-api="getCaptcha"
      :img-size="{ width: '400px', height: '200px' }"
      :img-size="captchaImgSize"
      mode="pop"
      @on-success="handleVerifySuccess"
    />
@@ -132,6 +149,9 @@
</template>
<style scoped>
.login-brand { display: flex; flex-direction: column; align-items: center; gap: 8px; margin-bottom: 24px; }
.login-brand-logo { height: 72px; width: auto; }
.login-brand-name { margin: 0; text-align: center; font-size: 16px; font-weight: 600; letter-spacing: 0.02em; color: #0f172a; }
.terminal-selector { border: 0; margin: 0 0 24px; padding: 0; }
.login-error { padding:12px;color:#b91c1c;background:#fef2f2;border-radius:8px; }
.terminal-selector legend { font-size: 16px; margin-bottom: 8px; }
src/views/mes/pro/batch/data.ts
@@ -16,20 +16,58 @@
/** 未绑定托盘下拉选项 */
export interface PalletOption {
  label: string;
  value: number;
  disabled?: boolean;
  itemCode?: string;
  itemId?: number;
  itemName?: string;
  itemCode?: string;
  label: string;
  value: number;
}
export type TonBagOption = PalletOption;
/** 托盘、吨袋下拉的交互事件(支持搜索与滚动分段加载) */
/** 绑定类型(托盘/吨袋互斥,编辑补录时按已有绑定锁定其中一方) */
export type BindingMode = 'pallet' | 'tonBag';
/** 托盘、吨袋下拉的交互事件(支持远程搜索与滚动分页加载) */
export interface BindingSelectHandlers {
  onChange?: (ids: number[]) => void;
  onPopupScroll?: (event: Event) => void;
  onSearch?: (keyword: string) => void;
}
/**
 * 刷新托盘选项禁用态。
 *
 * 下拉已改为分页加载,选项数组本身就是渲染数据源,因此禁用态直接写到选项对象上,
 * 每次加载新一页或表单值变化时重算,避免依赖机制里做数组映射导致新加载的数据被旧快照覆盖。
 */
export function applyPalletOptionState(
  options: PalletOption[],
  values: Record<string, any>,
  existingBinding?: BindingMode,
) {
  options.forEach((option) => {
    option.disabled =
      existingBinding === 'tonBag' ||
      !!values.tonBagIds?.length ||
      !option.itemId ||
      (!!values.itemId && option.itemId !== values.itemId);
  });
}
/** 刷新吨袋选项禁用态,规则与 {@link applyPalletOptionState} 对称 */
export function applyTonBagOptionState(
  options: TonBagOption[],
  values: Record<string, any>,
  existingBinding?: BindingMode,
) {
  options.forEach((option) => {
    option.disabled =
      existingBinding === 'pallet' ||
      !!values.palletIds?.length ||
      (!!values.itemId && option.itemId !== values.itemId);
  });
}
/** 新增/修改的表单 */
@@ -39,7 +77,7 @@
  palletHandlers: BindingSelectHandlers = {},
  tonBagOptions: TonBagOption[] = [],
  tonBagHandlers: BindingSelectHandlers = {},
  existingBinding?: 'pallet' | 'tonBag',
  existingBinding?: BindingMode,
): VbenFormSchema[] {
  const disabled = formType === 'detail';
  return [
@@ -180,12 +218,13 @@
      dependencies: {
        triggerFields: ['itemId', 'tonBagIds'],
        show: () => formType !== 'detail',
        componentProps: (values) => ({
          options: palletOptions.map(option => ({ ...option,
            disabled: existingBinding === 'tonBag' || !!values.tonBagIds?.length || !option.itemId || (!!values.itemId && option.itemId !== values.itemId),
          })),
          disabled: disabled || existingBinding === 'tonBag' || !!values.tonBagIds?.length,
        }),
        componentProps: (values) => {
          applyPalletOptionState(palletOptions, values, existingBinding);
          return {
            options: palletOptions,
            disabled: disabled || existingBinding === 'tonBag' || !!values.tonBagIds?.length,
          };
        },
      },
      help: '只能绑定相同品种的托盘,品种信息自动带入;缺少品种的托盘请先装袋。绑定后批次直接进入生产中:托盘生成正式托盘码,托盘下未录批次的袋码自动级联录入本批次(袋码品种须一致);编辑时仅需勾选新增托盘,已绑定的不可解绑',
    },
@@ -210,12 +249,13 @@
      dependencies: {
        triggerFields: ['itemId', 'palletIds'],
        show: () => formType !== 'detail',
        componentProps: (values) => ({
          options: tonBagOptions.map(option => ({ ...option,
            disabled: existingBinding === 'pallet' || !!values.palletIds?.length || (!!values.itemId && option.itemId !== values.itemId),
          })),
          disabled: disabled || existingBinding === 'pallet' || !!values.palletIds?.length,
        }),
        componentProps: (values) => {
          applyTonBagOptionState(tonBagOptions, values, existingBinding);
          return {
            options: tonBagOptions,
            disabled: disabled || existingBinding === 'pallet' || !!values.palletIds?.length,
          };
        },
      },
      help: '仅可绑定计量单位为吨、无托盘且未录入批次的袋码;与绑定托盘互斥。编辑时仅选择新增吨袋,已绑定的不可解绑',
    },
src/views/mes/pro/batch/modules/form.vue
@@ -21,12 +21,14 @@
    getBatch,
    updateBatch,
  } from "#/api/mes/pro/batch";
  import { getUnboundPalletList } from "#/api/mes/pro/pallet";
  import { getUnboundTonBags } from "#/api/mes/pro/bagCode";
  import { getUnboundPalletPage } from "#/api/mes/pro/pallet";
  import { getUnboundTonBagPage } from "#/api/mes/pro/bagCode";
  import { $t } from "#/locales";
  import { UserSelect } from "#/views/system/user/components";
  import {
    applyPalletOptionState,
    applyTonBagOptionState,
    useFormSchema,
    useWorkerEditGridColumns,
    useWorkerReadGridColumns,
@@ -38,38 +40,177 @@
  const formType = ref<FormType>("create");
  const formData = ref<MesProBatchApi.Batch>();
  const BINDING_OPTION_PAGE_SIZE = 50;
  /** 绑定下拉每次加载的条数 */
  const BINDING_PAGE_SIZE = 50;
  /** 距离下拉底部多少像素时触发加载下一页 */
  const BINDING_SCROLL_THRESHOLD = 32;
  let palletOptionSource: PalletOption[] = [];
  let tonBagOptionSource: TonBagOption[] = [];
  /** 输入框搜索防抖时间(毫秒) */
  const BINDING_SEARCH_DEBOUNCE = 300;
  /**
   * 绑定下拉的分页加载状态。
   *
   * 下拉选项本身就是渲染数据源(保持数组引用不变,直接 splice 更新),
   * 已加载的分页结果放在 loaded 中,跨页选中的选项单独记录,避免换页搜索后标签丢失。
   */
  interface BindingLoader<T extends PalletOption> {
    hasMore: boolean;
    keyword: string;
    loaded: T[];
    loading: boolean;
    pageNo: number;
    seq: number;
    timer?: ReturnType<typeof setTimeout>;
  }
  function createBindingLoader<T extends PalletOption>(): BindingLoader<T> {
    return {
      hasMore: true,
      keyword: "",
      loaded: [],
      loading: false,
      pageNo: 0,
      seq: 0,
    };
  }
  const palletOptions = reactive<PalletOption[]>([]);
  const tonBagOptions = reactive<TonBagOption[]>([]);
  let palletKeyword = "";
  let tonBagKeyword = "";
  const palletSelected = new Map<number, PalletOption>();
  const tonBagSelected = new Map<number, TonBagOption>();
  const palletLoader = createBindingLoader<PalletOption>();
  const tonBagLoader = createBindingLoader<TonBagOption>();
  function filterBindingOptions<T extends PalletOption>(source: T[], keyword: string): T[] {
    const normalized = keyword.trim().toLowerCase();
    if (!normalized) return source;
    return source.filter((option) => option.label.toLowerCase().includes(normalized));
  /** 已加载分页结果 + 跨页选中项合并为下拉渲染数据 */
  function renderBindingOptions<T extends PalletOption>(
    target: T[],
    loader: BindingLoader<T>,
    selected: Map<number, T>
  ) {
    const merged = [...loader.loaded];
    const loadedIds = new Set(loader.loaded.map((option) => option.value));
    selected.forEach((option, id) => {
      if (!loadedIds.has(id)) merged.push(option);
    });
    target.splice(0, target.length, ...merged);
  }
  function resetBindingOptions<T extends PalletOption>(
  /** 同步选中项(换页搜索后仍需展示已选标签) */
  function syncBindingSelection<T extends PalletOption>(
    target: T[],
    source: T[],
    keyword = ""
    loader: BindingLoader<T>,
    selected: Map<number, T>,
    ids: number[]
  ) {
    const filtered = filterBindingOptions(source, keyword);
    target.splice(0, target.length, ...filtered.slice(0, BINDING_OPTION_PAGE_SIZE));
    const previous = new Map(selected);
    selected.clear();
    ids.forEach((id) => {
      const option = previous.get(id)
        ?? loader.loaded.find((item) => item.value === id);
      if (option) selected.set(id, option);
    });
    renderBindingOptions(target, loader, selected);
  }
  function appendBindingOptions<T extends PalletOption>(
  /** 选项禁用态依赖当前表单值,每次加载新一页后重新计算 */
  async function refreshBindingOptionState() {
    const values = await formApi.getValues();
    const existingBinding = formData.value?.bindingMode;
    applyPalletOptionState(palletOptions, values, existingBinding);
    applyTonBagOptionState(tonBagOptions, values, existingBinding);
  }
  /** 拉取一页绑定候选项;reset=true 表示从第一页重新开始(首次打开、搜索) */
  async function loadBindingPage<T extends PalletOption>(
    loader: BindingLoader<T>,
    target: T[],
    source: T[],
    keyword: string
    selected: Map<number, T>,
    fetchPage: (params: {
      keyword?: string;
      pageNo: number;
      pageSize: number;
    }) => Promise<{ list: T[]; total: number }>,
    reset: boolean
  ) {
    const filtered = filterBindingOptions(source, keyword);
    if (target.length >= filtered.length) return;
    target.push(...filtered.slice(target.length, target.length + BINDING_OPTION_PAGE_SIZE));
    if (!reset && (loader.loading || !loader.hasMore)) return;
    const seq = ++loader.seq;
    loader.loading = true;
    try {
      const pageNo = reset ? 1 : loader.pageNo + 1;
      const page = await fetchPage({
        keyword: loader.keyword || undefined,
        pageNo,
        pageSize: BINDING_PAGE_SIZE,
      });
      // 已被更新的搜索或并发请求覆盖,丢弃本次结果
      if (seq !== loader.seq) return;
      loader.loaded = reset ? page.list : [...loader.loaded, ...page.list];
      loader.pageNo = pageNo;
      loader.hasMore = loader.loaded.length < page.total;
      renderBindingOptions(target, loader, selected);
      await refreshBindingOptionState();
    } finally {
      if (seq === loader.seq) loader.loading = false;
    }
  }
  function fetchPalletPage(params: {
    keyword?: string;
    pageNo: number;
    pageSize: number;
  }) {
    return getUnboundPalletPage(params).then((page) => ({
      list: page.list.map((pallet) => ({
        label: `${pallet.tempCode ?? `托盘${pallet.id}`} · ${pallet.itemName || "待完善品种"}(已装 ${pallet.bagCount ?? 0} 袋)`,
        value: pallet.id as number,
        itemId: pallet.itemId,
        itemName: pallet.itemName,
        itemCode: pallet.itemCode,
      })),
      total: page.total,
    }));
  }
  function fetchTonBagPage(params: {
    keyword?: string;
    pageNo: number;
    pageSize: number;
  }) {
    return getUnboundTonBagPage(params).then((page) => ({
      list: page.list.map((bag) => ({
        label: `${bag.code ?? `吨袋${bag.id}`} · ${bag.itemName || "待完善品种"}`,
        value: bag.id as number,
        itemId: bag.itemId,
        itemName: bag.itemName,
        itemCode: bag.itemCode,
      })),
      total: page.total,
    }));
  }
  function loadPalletPage(reset: boolean) {
    return loadBindingPage(palletLoader, palletOptions, palletSelected, fetchPalletPage, reset);
  }
  function loadTonBagPage(reset: boolean) {
    return loadBindingPage(tonBagLoader, tonBagOptions, tonBagSelected, fetchTonBagPage, reset);
  }
  /** 重置下拉分页状态(每次打开弹窗调用),同时让在途请求的结果失效 */
  function resetBindingLoader<T extends PalletOption>(
    loader: BindingLoader<T>,
    target: T[],
    selected: Map<number, T>
  ) {
    clearTimeout(loader.timer);
    loader.seq += 1;
    loader.hasMore = true;
    loader.keyword = "";
    loader.loaded = [];
    loader.loading = false;
    loader.pageNo = 0;
    target.splice(0, target.length);
    selected.clear();
  }
  function isPopupNearBottom(event: Event) {
@@ -77,26 +218,30 @@
    return element.scrollHeight - element.scrollTop - element.clientHeight <= BINDING_SCROLL_THRESHOLD;
  }
  function scheduleBindingSearch<T extends PalletOption>(
    loader: BindingLoader<T>,
    keyword: string,
    reload: (reset: boolean) => Promise<void>
  ) {
    loader.keyword = keyword.trim();
    clearTimeout(loader.timer);
    loader.timer = setTimeout(() => void reload(true), BINDING_SEARCH_DEBOUNCE);
  }
  function handlePalletSearch(keyword: string) {
    palletKeyword = keyword;
    resetBindingOptions(palletOptions, palletOptionSource, keyword);
    scheduleBindingSearch(palletLoader, keyword, loadPalletPage);
  }
  function handleTonBagSearch(keyword: string) {
    tonBagKeyword = keyword;
    resetBindingOptions(tonBagOptions, tonBagOptionSource, keyword);
    scheduleBindingSearch(tonBagLoader, keyword, loadTonBagPage);
  }
  function handlePalletPopupScroll(event: Event) {
    if (isPopupNearBottom(event)) {
      appendBindingOptions(palletOptions, palletOptionSource, palletKeyword);
    }
    if (isPopupNearBottom(event)) void loadPalletPage(false);
  }
  function handleTonBagPopupScroll(event: Event) {
    if (isPopupNearBottom(event)) {
      appendBindingOptions(tonBagOptions, tonBagOptionSource, tonBagKeyword);
    }
    if (isPopupNearBottom(event)) void loadTonBagPage(false);
  }
  const palletHandlers = {
@@ -109,19 +254,27 @@
    onPopupScroll: handleTonBagPopupScroll,
    onSearch: handleTonBagSearch,
  };
  async function handlePalletChange(ids: number[] = []) {
    const values = await formApi.getValues();
    if (ids.length && (values.tonBagIds?.length || formData.value?.bindingMode === 'tonBag')) {
      message.warning('绑定托盘和绑定吨袋只能选择一种');
      await formApi.setFieldValue('palletIds', []);
      syncBindingSelection(palletOptions, palletLoader, palletSelected, []);
      return;
    }
    const selected = palletOptionSource.filter(option => ids.includes(option.value));
    const selected = ids
      .map((id) => palletSelected.get(id) ?? palletLoader.loaded.find((option) => option.value === id))
      .filter((option): option is PalletOption => !!option);
    const source = formData.value?.itemId ? formData.value : selected[0];
    let finalIds = ids;
    if (selected.some(option => !option.itemId || (source?.itemId && option.itemId !== source.itemId))) {
      message.warning('生产批次只能绑定相同品种的托盘');
      await formApi.setFieldValue('palletIds', selected.filter(option => option.itemId === source?.itemId).map(option => option.value));
      finalIds = selected.filter(option => option.itemId === source?.itemId).map(option => option.value);
      await formApi.setFieldValue('palletIds', finalIds);
    }
    syncBindingSelection(palletOptions, palletLoader, palletSelected, finalIds);
    await refreshBindingOptionState();
    await formApi.setFieldValue('itemId', source?.itemId);
    await formApi.setFieldValue('itemName', source?.itemName);
    await formApi.setFieldValue('itemCode', source?.itemCode);
@@ -132,14 +285,21 @@
    if (ids.length && (values.palletIds?.length || formData.value?.bindingMode === 'pallet')) {
      message.warning('绑定托盘和绑定吨袋只能选择一种');
      await formApi.setFieldValue('tonBagIds', []);
      syncBindingSelection(tonBagOptions, tonBagLoader, tonBagSelected, []);
      return;
    }
    const selected = tonBagOptionSource.filter(option => ids.includes(option.value));
    const selected = ids
      .map((id) => tonBagSelected.get(id) ?? tonBagLoader.loaded.find((option) => option.value === id))
      .filter((option): option is TonBagOption => !!option);
    const source = formData.value?.itemId ? formData.value : selected[0];
    let finalIds = ids;
    if (selected.some(option => option.itemId !== source?.itemId)) {
      message.warning('生产批次只能绑定相同品种的吨袋');
      await formApi.setFieldValue('tonBagIds', selected.filter(option => option.itemId === source?.itemId).map(option => option.value));
      finalIds = selected.filter(option => option.itemId === source?.itemId).map(option => option.value);
      await formApi.setFieldValue('tonBagIds', finalIds);
    }
    syncBindingSelection(tonBagOptions, tonBagLoader, tonBagSelected, finalIds);
    await refreshBindingOptionState();
    await formApi.setFieldValue('itemId', source?.itemId);
    await formApi.setFieldValue('itemName', source?.itemName);
    await formApi.setFieldValue('itemCode', source?.itemCode);
@@ -319,14 +479,9 @@
      }
      const data = modalApi.getData<{ formType: FormType; id?: number }>();
      formType.value = data.formType;
      // 新增/补录/编辑态加载未绑定批次的托盘,供「绑定托盘」多选下拉使用
      palletOptionSource = [];
      tonBagOptionSource = [];
      palletOptions.splice(0);
      tonBagOptions.splice(0);
      palletKeyword = "";
      tonBagKeyword = "";
      // 先挂载基础表单,候选托盘/吨袋在后台加载,避免只显示人员配置的空窗期
      // 每次打开重置绑定候选,下拉改为滚动分页加载,首批在表单挂载后异步拉取
      resetBindingLoader(palletLoader, palletOptions, palletSelected);
      resetBindingLoader(tonBagLoader, tonBagOptions, tonBagSelected);
      formApi.setState({
        schema: useFormSchema(
          formType.value,
@@ -337,34 +492,11 @@
        ),
      });
      if (formType.value !== "detail") {
        const [pallets, tonBags] = await Promise.all([getUnboundPalletList(), getUnboundTonBags()]);
        palletOptionSource = pallets.map((pallet) => ({
          label: `${pallet.tempCode ?? `托盘${pallet.id}`} · ${pallet.itemName || "待完善品种"}(已装 ${pallet.bagCount ?? 0} 袋)`,
          value: pallet.id as number,
          itemId: pallet.itemId, itemName: pallet.itemName, itemCode: pallet.itemCode,
        }));
        tonBagOptionSource = tonBags.map((bag) => ({
          label: `${bag.code ?? `吨袋${bag.id}`} · ${bag.itemName || '待完善品种'}`,
          value: bag.id as number,
          itemId: bag.itemId, itemName: bag.itemName, itemCode: bag.itemCode,
        }));
        resetBindingOptions(palletOptions, palletOptionSource);
        resetBindingOptions(tonBagOptions, tonBagOptionSource);
      }
      // 重新同步一次 Schema,使依赖字段中的动态 options 立即拿到首批候选项
      formApi.setState({
        schema: useFormSchema(
          formType.value,
          palletOptions,
          palletHandlers,
          tonBagOptions,
          tonBagHandlers
        ),
      });
      if (formType.value !== "detail") {
        // 清空上次打开时勾选的托盘,避免残留值误触发「批次进入生产中」
        // 清空上次打开时勾选的托盘/吨袋,避免残留值误触发「批次进入生产中」
        await formApi.setFieldValue("palletIds", undefined);
        await formApi.setFieldValue("tonBagIds", undefined);
        void loadPalletPage(true);
        void loadTonBagPage(true);
      }
      // 根据表单态切换人员子表列
      workerGridApi.setGridOptions({
@@ -379,6 +511,8 @@
          const existingBinding = formData.value.bindingMode;
          formApi.setState({ schema: useFormSchema(formType.value, palletOptions, palletHandlers, tonBagOptions, tonBagHandlers, existingBinding) });
          await formApi.setValues(formData.value);
          // 已有绑定会锁定另一侧下拉,选项禁用态需要按最新表单值重算
          await refreshBindingOptionState();
          const workers = (formData.value.workers ?? []).map(
            (worker, index) => ({ ...worker, _rowKey: worker.id ?? index })
          );