云
14 小时以前 1ed040591afdcbe3c5df42ad5e02e2cfa56edd87
src/views/mes/qc/seedQuality/record.vue
@@ -4,7 +4,10 @@
import type { TablePaginationConfig } from 'ant-design-vue';
import { IconifyIcon } from '@vben/icons';
import { useAccess } from '@vben/access';
import { message } from 'ant-design-vue';
import { MesWmWarehouseTypeEnum } from '@vben/constants';
import { message, Modal } from 'ant-design-vue';
import type { MesWmWarehouseApi } from '#/api/mes/wm/warehouse';
import { getWarehouseSimpleList } from '#/api/mes/wm/warehouse';
import {
  getSeedQualityPage,
@@ -12,11 +15,15 @@
  getSeedQualityActions,
  getSeedIndicatorRanges,
  retestSeedQuality,
  retestSeedQualityBatch,
  resolveSeedRetestScope,
  submitSeedRetestScope,
  approveSeedQuality,
  approveSeedQualityBatch,
  resolveSeedQuality,
  submitSeedQualityGroup,
  type SeedQualityRecord,
  type SeedQualityScan,
  type SeedQualityIndicator,
  type SeedQualityAction,
  type SeedRetestScope,
@@ -30,6 +37,7 @@
const code = ref('');
const batchCode = ref('');
const result = ref<number>();
const workflowStatus = ref<number>();
const total = ref(0);
const statsTotal = ref(0);
@@ -40,6 +48,10 @@
  Math.max(statsTotal.value - passTotal.value - failTotal.value, 0),
);
const { hasAccessByCodes } = useAccess();
const canScopeAction = computed(() => [
  'mes:qc-seed-quality:submit', 'mes:qc-seed-quality:retest',
  'mes:qc-seed-quality:process-approve', 'mes:qc-seed-quality:company-approve',
].some((code) => hasAccessByCodes([code])));
const workflowLabels: Record<number, string> = {
  10: '待复检', 20: '待初检审核', 25: '待初检不合格处理', 30: '待复检审核',
  40: '已放行', 50: '初检驳回待重检', 60: '待复检不合格处理', 70: '已报废',
@@ -52,6 +64,11 @@
const indicatorLabels: Record<string, string> = {
  purity: '纯度', cleanliness: '净度', germination_rate: '发芽率', moisture: '水分',
};
function actionColor(action: string) {
  if (action.includes('REJECT')) return 'red';
  if (action.includes('APPROVE')) return 'green';
  return 'blue';
}
function actionIndicators(action: SeedQualityAction) {
  if (!action.indicatorsJson) return [];
  try {
@@ -75,32 +92,96 @@
const activeRecord = ref<SeedQualityRecord>();
const retestOpen = ref(false);
const retestBusy = ref(false);
const retestBatchIds = ref<number[]>([]);
const retestIndicators = ref<SeedQualityIndicator[]>([]);
const retestReason = ref('');
const scopeOpen = ref(false);
const scopeType = ref<'PALLET' | 'BATCH'>('PALLET');
const scopeCode = ref('');
const scopeError = ref('');
const initialScope = ref<SeedQualityScan>();
const initialIndicators = ref<SeedQualityIndicator[]>([]);
const initialReason = ref('');
const qualifiedWarehouses = ref<MesWmWarehouseApi.Warehouse[]>([]);
const qualifiedWarehouseId = ref<number>();
const retestScope = ref<SeedRetestScope>();
const scopeStatus = ref<10 | 50>(10);
const scopeBusy = ref(false);
const scopeIds = computed(() => scopeStatus.value === 10 ? retestScope.value?.retestIds || [] : retestScope.value?.initialRecheckIds || []);
const scopePendingCount = computed(() => (retestScope.value?.pendingProcessCount || 0) + (retestScope.value?.pendingCompanyCount || 0));
const initialIds = computed(() => initialScope.value?.pendingBagIds || []);
const initialSubmitIds = computed(() => initialIds.value.slice(0, 500));
const scopeSubmitIds = computed(() => scopeIds.value.slice(0, 500));
const initialPassed = computed(() => initialIndicators.value.length === 4 && initialIndicators.value.every((item) =>
  item.value !== undefined && item.minValue !== undefined && item.maxValue !== undefined
  && Number(item.value) >= Number(item.minValue) && Number(item.value) <= Number(item.maxValue)));
const qualifiedWarehouseOptions = computed(() => qualifiedWarehouses.value.map((warehouse) => ({
  label: warehouse.code ? `${warehouse.name}(${warehouse.code})` : warehouse.name || '',
  value: warehouse.id ?? 0,
})).filter((item) => item.value));
const scopeReviewGroups = computed(() => {
  const scope = retestScope.value;
  if (!scope) return [];
  return [
    { status: 20, stage: 'process' as const, ids: scope.pendingProcessIds || [] },
    { status: 25, stage: 'process' as const, ids: scope.pendingInitialFailIds || [] },
    { status: 30, stage: 'company' as const, ids: scope.pendingCompanyIds || [] },
    { status: 60, stage: 'company' as const, ids: scope.pendingCompanyFailIds || [] },
  ].filter((group) => group.ids.length && hasAccessByCodes([`mes:qc-seed-quality:${group.stage === 'process' ? 'process-approve' : 'company-approve'}`]));
});
const scopeHasReviewPermission = computed(() => {
  const scope = retestScope.value;
  if (!scope) return false;
  return (scope.pendingProcessCount > 0 && hasAccessByCodes(['mes:qc-seed-quality:process-approve']))
    || (scope.pendingCompanyCount > 0 && hasAccessByCodes(['mes:qc-seed-quality:company-approve']));
});
const reviewOpen = ref(false);
const reviewBusy = ref(false);
const reviewFromScope = ref(false);
const reviewStage = ref<'process' | 'company'>('process');
const reviewApproved = ref(true);
const reviewOpinion = ref('');
const reviewIds = ref<number[]>([]);
const selectedIds = ref<number[]>([]);
const selectedRows = computed(() => records.value.filter((record) => selectedIds.value.includes(record.id)));
const selectableStage = computed(() => {
  if (!selectedRows.value.length) return undefined;
  if (!selectedRows.value.every((record) => record.workflowStatus === selectedRows.value[0]?.workflowStatus)) return undefined;
  if (selectedRows.value.every((record) => [20, 25].includes(record.workflowStatus))) return 'process';
  if (selectedRows.value.every((record) => [30, 60].includes(record.workflowStatus))) return 'company';
  return undefined;
const selectedRecordMap = ref(new Map<number, SeedQualityRecord>());
const selectedRows = computed(() => selectedIds.value.map((id) => selectedRecordMap.value.get(id)).filter((row): row is SeedQualityRecord => !!row));
const reviewGroups = computed(() => [20, 25, 30, 60].map((status) => ({
  status,
  rows: selectedRows.value.filter((row) => row.workflowStatus === status),
  stage: status === 20 || status === 25 ? 'process' as const : 'company' as const,
})).filter((group) => group.rows.length && hasAccessByCodes([`mes:qc-seed-quality:${group.stage === 'process' ? 'process-approve' : 'company-approve'}`])));
const retestGroups = computed(() => {
  if (!hasAccessByCodes(['mes:qc-seed-quality:retest'])) return [];
  const groups = new Map<string, { status: 10 | 50; batchCode: string; rows: SeedQualityRecord[] }>();
  for (const row of selectedRows.value) {
    if (row.workflowStatus !== 10 && row.workflowStatus !== 50) continue;
    if (!row.batchCode) continue;
    const key = `${row.workflowStatus}:${row.batchCode}`;
    if (!groups.has(key)) groups.set(key, { status: row.workflowStatus, batchCode: row.batchCode, rows: [] });
    groups.get(key)!.rows.push(row);
  }
  return [...groups.values()];
});
function changeSelection(keys: (string | number)[]) {
  const nextIds = keys.map(Number);
  const nextMap = new Map(selectedRecordMap.value);
  for (const row of records.value) if (nextIds.includes(row.id)) nextMap.set(row.id, row);
  for (const id of nextMap.keys()) if (!nextIds.includes(id)) nextMap.delete(id);
  selectedRecordMap.value = nextMap;
  selectedIds.value = nextIds;
}
function clearSelection() {
  selectedIds.value = [];
  selectedRecordMap.value = new Map();
}
function removeSelection(ids: number[]) {
  const processed = new Set(ids);
  selectedIds.value = selectedIds.value.filter((id) => !processed.has(id));
  selectedRecordMap.value = new Map([...selectedRecordMap.value].filter(([id]) => !processed.has(id)));
}
async function openRetest(record: SeedQualityRecord) {
  retestBatchIds.value = [];
  activeRecord.value = record;
  try {
    const previous = await getSeedQualityIndicators(record.id);
@@ -117,36 +198,122 @@
    message.error((error as Error).message || '读取质检指标失败');
  }
}
function openScopeRetest() {
  scopeOpen.value = true; retestScope.value = undefined; scopeCode.value = ''; retestReason.value = '';
async function openBatchRetest(group: { rows: SeedQualityRecord[] }) {
  if (group.rows.length > 500) { message.warning('每次最多处理 500 条,请缩小选择范围'); return; }
  const first = group.rows[0];
  if (!first) return;
  await openRetest(first);
  if (retestOpen.value) retestBatchIds.value = group.rows.map((row) => row.id);
}
async function openScopeRetest() {
  scopeOpen.value = true; retestScope.value = undefined; initialScope.value = undefined;
  scopeCode.value = ''; scopeError.value = ''; retestReason.value = ''; initialReason.value = ''; qualifiedWarehouseId.value = undefined;
  try {
    qualifiedWarehouses.value = (await getWarehouseSimpleList()).filter((warehouse) =>
      warehouse.type === MesWmWarehouseTypeEnum.QUALIFIED && !warehouse.frozen);
    if (qualifiedWarehouses.value.length === 1) qualifiedWarehouseId.value = qualifiedWarehouses.value[0]?.id;
  } catch { qualifiedWarehouses.value = []; }
}
function scopeErrorText(error: unknown) {
  const response = error as { msg?: string; message?: string; data?: { msg?: string }; response?: { data?: { msg?: string } } };
  return response.response?.data?.msg || response.data?.msg || response.msg || response.message || '识别失败,请核对码值后重试';
}
async function hydrateScopeReviewIds(scope: SeedRetestScope, scannedCode: string) {
  const loadedCount = (scope.pendingProcessIds?.length || 0) + (scope.pendingInitialFailIds?.length || 0)
    + (scope.pendingCompanyIds?.length || 0) + (scope.pendingCompanyFailIds?.length || 0);
  if (loadedCount >= scope.pendingProcessCount + scope.pendingCompanyCount) return;
  const resolvedBatchCode = scope.batchCode || (scope.scanType === 'BATCH' ? scope.code : undefined);
  if (!resolvedBatchCode) return;
  const statuses = [20, 25, 30, 60] as const;
  const pages = await Promise.all(statuses.map((status) => getSeedQualityPage({
    pageNo: 1,
    pageSize: 500,
    batchCode: resolvedBatchCode,
    workflowStatus: status,
  }).catch(() => ({ list: [] as SeedQualityRecord[], total: 0 }))));
  const scopeCodes = new Set([scope.code, scannedCode]);
  const idsByStatus = pages.map(({ list }) => list
    .filter((row) => scope.scanType === 'BATCH' || (!!row.palletCode && scopeCodes.has(row.palletCode)))
    .map((row) => row.id));
  scope.pendingProcessIds = idsByStatus[0];
  scope.pendingInitialFailIds = idsByStatus[1];
  scope.pendingCompanyIds = idsByStatus[2];
  scope.pendingCompanyFailIds = idsByStatus[3];
}
async function identifyScope() {
  if (!scopeCode.value.trim()) { message.warning('请扫描或输入托盘码 / 生产批次码'); return; }
  scopeBusy.value = true; retestScope.value = undefined;
  scopeError.value = '';
  if (!scopeCode.value.trim()) { scopeError.value = `请扫描或输入${scopeType.value === 'PALLET' ? '托盘码' : '生产批次码'}`; return; }
  scopeBusy.value = true; retestScope.value = undefined; initialScope.value = undefined;
  try {
    const data = await resolveSeedRetestScope(scopeCode.value.trim(), scopeType.value);
    if (!data.retestIds.length && !data.initialRecheckIds.length) {
      const pending = [
        data.pendingProcessCount ? `${data.pendingProcessCount} 条待初检审核或不合格处理` : '',
        data.pendingCompanyCount ? `${data.pendingCompanyCount} 条待复检审核或不合格处理` : '',
      ].filter(Boolean).join('、');
      throw new Error(pending
        ? `当前范围有${pending},请先完成对应审核或处理`
        : '当前范围没有待复检或待重新初检的袋码');
    const scannedCode = scopeCode.value.trim();
    let data: SeedRetestScope;
    try {
      data = await resolveSeedRetestScope(scannedCode, scopeType.value);
    } catch (error) {
      if (!scopeErrorText(error).startsWith('未找到')) throw error;
      const otherType = scopeType.value === 'PALLET' ? 'BATCH' : 'PALLET';
      try {
        data = await resolveSeedRetestScope(scannedCode, otherType);
        scopeType.value = otherType;
      } catch (otherError) {
        if (scopeErrorText(otherError).startsWith('未找到')) {
          throw new Error('未找到托盘码或生产批次码,请核对完整码值');
        }
        throw otherError;
      }
    }
    await hydrateScopeReviewIds(data, scannedCode);
    retestScope.value = data; scopeStatus.value = data.retestIds.length ? 10 : 50;
    initialScope.value = await resolveSeedQuality(scannedCode, data.scanType);
    const ranges = await getSeedIndicatorRanges().catch(() => []);
    retestIndicators.value = Object.entries(indicatorLabels).map(([indicatorCode]) => {
    const emptyIndicators = () => Object.entries(indicatorLabels).map(([indicatorCode]) => {
      const range = ranges.find((row) => row.indicatorCode === indicatorCode);
      return { indicatorCode, value: undefined, minValue: range ? Number(range.minValue) : undefined as unknown as number,
        maxValue: range ? Number(range.maxValue) : undefined as unknown as number };
    });
    retestIndicators.value = emptyIndicators();
    initialIndicators.value = emptyIndicators();
    retestReason.value = '';
  } catch (error) { message.error((error as Error).message || '识别失败'); }
    initialReason.value = '';
  } catch (error) { scopeError.value = scopeErrorText(error); }
  finally { scopeBusy.value = false; }
}
async function saveScopeInitial() {
  const target = initialScope.value;
  if (!target || !target.inbound || !initialSubmitIds.value.length) return;
  if (!qualifiedWarehouseId.value) { scopeError.value = '请选择检验合格后入库的合格库'; return; }
  if (initialIndicators.value.some((item) => item.value === undefined || item.minValue === undefined || item.maxValue === undefined
    || Number(item.value) < 0 || Number(item.value) > 100 || Number(item.minValue) < 0 || Number(item.maxValue) > 100
    || Number(item.minValue) > Number(item.maxValue))) {
    scopeError.value = '请填写四项指标的实测值和 0–100 范围内的合格上下限'; return;
  }
  if (!initialPassed.value && !initialReason.value.trim()) { scopeError.value = '初检不合格时请填写原因'; return; }
  scopeError.value = '';
  const confirmed = await new Promise<boolean>((resolve) => Modal.confirm({
    title: `确认批量初检 ${initialSubmitIds.value.length} 袋?`,
    content: `同一组指标与${initialPassed.value ? '合格' : '不合格'}结论将应用于本次袋码。完成后会自动加载剩余记录。`,
    okText: '确认提交初检', cancelText: '返回核对',
    onOk: () => resolve(true), onCancel: () => resolve(false),
  }));
  if (!confirmed) return;
  scopeBusy.value = true;
  try {
    const submitted = await submitSeedQualityGroup({
      code: target.code, batchCode: target.batchCode, scanType: target.scanType || scopeType.value,
      bagIds: initialSubmitIds.value, qualifiedWarehouseId: qualifiedWarehouseId.value,
      reason: initialReason.value.trim() || undefined,
      indicators: initialIndicators.value.map((item) => ({ code: item.indicatorCode, value: Number(item.value),
        minValue: Number(item.minValue), maxValue: Number(item.maxValue) })),
    });
    message.success(`已完成 ${submitted.processedCount} 袋初检`);
    refreshAll();
    await identifyScope();
  } catch (error) { scopeError.value = `${scopeErrorText(error)}。请重新识别并核对待检清单`; initialScope.value = undefined; }
  finally { scopeBusy.value = false; }
}
async function saveScopeRetest() {
  if (!retestScope.value || !scopeIds.value.length) return;
  if (!retestScope.value || !scopeSubmitIds.value.length) return;
  if (retestIndicators.value.some(item => item.value === undefined || item.value === null || item.minValue === undefined || item.maxValue === undefined
    || Number(item.minValue) < 0 || Number(item.maxValue) > 100 || Number(item.minValue) > Number(item.maxValue))) {
    message.warning('请填写四项指标的实测值与本次合格范围'); return;
@@ -156,11 +323,13 @@
  scopeBusy.value = true;
  try {
    const result = await submitSeedRetestScope({ code: retestScope.value.code, scanType: retestScope.value.scanType, workflowStatus: scopeStatus.value,
      qualityIds: scopeIds.value, reason: retestReason.value.trim(), indicators: retestIndicators.value.map(item => ({
      qualityIds: scopeSubmitIds.value, reason: retestReason.value.trim(), indicators: retestIndicators.value.map(item => ({
        code: item.indicatorCode, value: Number(item.value), minValue: Number(item.minValue), maxValue: Number(item.maxValue),
      })) });
    message.success(`已完成 ${result.processedCount} 袋质检`); scopeOpen.value = false; refreshAll();
  } catch (error) { message.error((error as Error).message || '批量质检失败'); retestScope.value = undefined; }
    message.success(`已完成 ${result.processedCount} 袋质检`);
    refreshAll();
    await identifyScope();
  } catch (error) { scopeError.value = `${scopeErrorText(error)}。请重新识别并核对待检清单`; retestScope.value = undefined; }
  finally { scopeBusy.value = false; }
}
async function saveRetest() {
@@ -187,12 +356,16 @@
        minValue: Number(item.minValue), maxValue: Number(item.maxValue),
      })),
    };
    await retestSeedQuality(record.id, { ...payload, code: record.code });
    message.success(record.workflowStatus === 50
    if (retestBatchIds.value.length) {
      await retestSeedQualityBatch({ ids: retestBatchIds.value, ...payload });
    } else {
      await retestSeedQuality(record.id, { ...payload, code: record.code });
    }
    message.success(`${retestBatchIds.value.length || 1} 袋已提交${record.workflowStatus === 50 ? '重新初检' : '复检'}:${record.workflowStatus === 50
      ? passed ? '重新初检合格,待初检审核' : '重新初检不合格,待不合格处理'
      : passed ? '复检合格,待复检审核' : '复检不合格,待不合格处理');
      : passed ? '复检合格,待复检审核' : '复检不合格,待不合格处理'}`);
    retestOpen.value = false;
    selectedIds.value = [];
    removeSelection(retestBatchIds.value.length ? retestBatchIds.value : [record.id]);
    refreshAll();
  } catch (error) {
    message.error((error as Error).message || '复检提交失败');
@@ -201,6 +374,7 @@
  }
}
function openReview(record: SeedQualityRecord, stage: 'process' | 'company') {
  reviewFromScope.value = false;
  activeRecord.value = record;
  reviewIds.value = [record.id];
  reviewStage.value = stage;
@@ -208,12 +382,23 @@
  reviewOpinion.value = '';
  reviewOpen.value = true;
}
function openBatchReview() {
  const stage = selectableStage.value;
  if (!stage) return;
  activeRecord.value = selectedRows.value[0];
  reviewIds.value = selectedRows.value.map((row) => row.id);
  reviewStage.value = stage;
function openBatchReview(group: { stage: 'process' | 'company'; rows: SeedQualityRecord[] }) {
  if (group.rows.length > 500) { message.warning('每次最多处理 500 条,请缩小选择范围'); return; }
  reviewFromScope.value = false;
  activeRecord.value = group.rows[0];
  reviewIds.value = group.rows.map((row) => row.id);
  reviewStage.value = group.stage;
  reviewApproved.value = true;
  reviewOpinion.value = '';
  reviewOpen.value = true;
}
function openScopeReview(group: { status: number; stage: 'process' | 'company'; ids: number[] }) {
  const scope = retestScope.value;
  if (!scope || !group.ids.length) return;
  reviewFromScope.value = true;
  activeRecord.value = { id: group.ids[0]!, code: scope.code, result: 0, workflowStatus: group.status };
  reviewIds.value = group.ids.slice(0, 500);
  reviewStage.value = group.stage;
  reviewApproved.value = true;
  reviewOpinion.value = '';
  reviewOpen.value = true;
@@ -237,10 +422,12 @@
    }
    message.success(`已完成 ${reviewIds.value.length} 条审批`);
    reviewOpen.value = false;
    selectedIds.value = [];
    removeSelection(reviewIds.value);
    refreshAll();
    if (reviewFromScope.value) await identifyScope();
    reviewFromScope.value = false;
  } catch (error) {
    message.error((error as Error).message || '审批失败');
    message.error(scopeErrorText(error));
  } finally {
    reviewBusy.value = false;
  }
@@ -264,9 +451,9 @@
      code: code.value.trim() || undefined,
      batchCode: batchCode.value.trim() || undefined,
      result: result.value,
      workflowStatus: workflowStatus.value,
    });
    records.value = data.list;
    selectedIds.value = [];
    total.value = data.total;
  } finally {
    loading.value = false;
@@ -290,6 +477,7 @@
function searchRecords() {
  page.value = 1;
  clearSelection();
  void loadRecords();
}
@@ -297,11 +485,16 @@
  code.value = '';
  batchCode.value = '';
  result.value = undefined;
  workflowStatus.value = undefined;
  searchRecords();
}
function refreshAll() {
  void Promise.all([loadRecords(), loadStats()]);
}
function refreshAndClearSelection() {
  clearSelection();
  refreshAll();
}
function handleTableChange(pagination: TablePaginationConfig) {
@@ -348,6 +541,20 @@
      </div>
    </section>
    <!-- 移动端将批量入口前置,避免隐藏在较长的筛选区中 -->
    <section v-if="canScopeAction" class="mobile-batch-entry" aria-label="批量质检操作">
      <div class="mobile-batch-copy">
        <span class="mobile-batch-icon"><IconifyIcon icon="ep:full-screen" /></span>
        <div>
          <strong>批量检验与审核</strong>
          <p>扫描或输入托盘码、生产批次码,一次处理当前范围内的质检记录。</p>
        </div>
      </div>
      <a-button type="primary" size="large" block @click="openScopeRetest">
        打开批量操作
      </a-button>
    </section>
    <!-- 记录列表 -->
    <section class="record-card">
      <div class="card-toolbar">
@@ -357,8 +564,7 @@
          <span class="count-chip">{{ total.toLocaleString() }} 条</span>
        </div>
        <div class="filters">
          <a-button v-if="hasAccessByCodes(['mes:qc-seed-quality:retest'])" type="primary" @click="openScopeRetest">按托盘 / 批次批量复检</a-button>
          <a-button v-if="selectableStage && hasAccessByCodes([`mes:qc-seed-quality:${selectableStage === 'process' ? 'process-approve' : 'company-approve'}`])" @click="openBatchReview">批量{{ selectableStage === 'process' ? selectedRows[0]?.workflowStatus === 25 ? '初检不合格处理' : '初检审核' : selectedRows[0]?.workflowStatus === 60 ? '复检不合格处理' : '复检审核' }}({{ selectedRows.length }})</a-button>
          <a-button v-if="canScopeAction" class="desktop-scope-action" type="primary" @click="openScopeRetest">按托盘 / 批次批量处理</a-button>
          <a-input v-model:value="code" allow-clear placeholder="袋码" @press-enter="searchRecords">
            <template #prefix><IconifyIcon icon="ep:box" /></template>
          </a-input>
@@ -375,17 +581,43 @@
              { label: '处理中', value: 0 },
            ]"
          />
          <a-select
            v-model:value="workflowStatus"
            allow-clear
            placeholder="流程状态"
            :options="Object.entries(workflowLabels).map(([value, label]) => ({ label, value: Number(value) }))"
          />
          <a-button type="primary" @click="searchRecords"><IconifyIcon icon="ep:search" />查询</a-button>
          <a-button @click="resetFilters"><IconifyIcon icon="ep:refresh-left" />重置</a-button>
          <a-tooltip title="刷新数据">
            <a-button :loading="loading" aria-label="刷新数据" @click="refreshAll"><IconifyIcon icon="ep:refresh" /></a-button>
            <a-button :loading="loading" aria-label="刷新数据" @click="refreshAndClearSelection"><IconifyIcon icon="ep:refresh" /></a-button>
          </a-tooltip>
        </div>
      </div>
      <div v-if="selectedIds.length" class="selection-bar" aria-live="polite">
        <div class="selection-summary">
          <strong>已选 {{ selectedIds.length }} 条</strong>
          <span>按流程状态和生产批次列出可执行操作;其他记录继续保留选择。</span>
          <a-button type="link" @click="clearSelection">清空选择</a-button>
        </div>
        <div class="selection-actions">
          <a-button
            v-for="group in reviewGroups" :key="`review-${group.status}`"
            @click="openBatchReview(group)"
          >{{ workflowLabels[group.status] }} · 处理 {{ group.rows.length }} 条</a-button>
          <a-button
            v-for="group in retestGroups"
            :key="`retest-${group.status}-${group.batchCode}`"
            @click="openBatchRetest(group)"
          >{{ group.status === 50 ? '重新初检' : '复检' }} · {{ group.batchCode }} · {{ group.rows.length }} 袋</a-button>
          <span v-if="!reviewGroups.length && !retestGroups.length" class="muted">所选记录当前没有可批量处理的操作,请查看流程状态或单条操作。</span>
        </div>
      </div>
      <a-table
        :data-source="records"
        :row-selection="{ selectedRowKeys: selectedIds, onChange: (keys: (string | number)[]) => { selectedIds = keys.map(Number); } }"
        :row-selection="{ selectedRowKeys: selectedIds, preserveSelectedRowKeys: true, onChange: changeSelection }"
        :loading="loading"
        :scroll="{ x: 1900 }"
        :pagination="{
@@ -452,8 +684,8 @@
        </a-table-column>
      </a-table>
    </section>
    <a-modal v-model:open="retestOpen" :title="activeRecord?.workflowStatus === 50 ? '重新初检' : '种子质检复检'" :confirm-loading="retestBusy" :ok-text="activeRecord?.workflowStatus === 50 ? '提交初检' : '提交复检'" @ok="saveRetest">
      <p class="muted">袋码:{{ activeRecord?.code }}。四项指标与初检一致;请录入本次实测值并核对合格范围。</p>
    <a-modal v-model:open="retestOpen" wrap-class-name="seed-quality-modal" :title="activeRecord?.workflowStatus === 50 ? '重新初检' : '种子质检复检'" :confirm-loading="retestBusy" :ok-text="activeRecord?.workflowStatus === 50 ? '提交初检' : '提交复检'" @ok="saveRetest">
      <p class="muted">{{ retestBatchIds.length ? `本次处理 ${retestBatchIds.length} 袋,同一组指标与结论会应用于全部所选袋码。` : `袋码:${activeRecord?.code}。` }}请录入本次实测值并核对合格范围。</p>
      <div v-for="item in retestIndicators" :key="item.indicatorCode" class="retest-row">
        <strong>{{ indicatorLabels[item.indicatorCode] || item.indicatorCode }}</strong>
        <a-input-number v-model:value="item.value" :min="0" :precision="3" placeholder="本次实测值" />
@@ -462,43 +694,95 @@
      </div>
      <a-textarea v-model:value="retestReason" :rows="3" :maxlength="500" placeholder="本次检验不合格时填写原因" />
    </a-modal>
    <a-modal v-model:open="scopeOpen" title="按托盘 / 生产批次批量复检或重新初检" :confirm-loading="scopeBusy" ok-text="提交本阶段全部袋码" :ok-button-props="{ disabled: !scopeIds.length }" @ok="saveScopeRetest">
    <a-modal v-model:open="scopeOpen" wrap-class-name="seed-quality-modal seed-quality-scope-modal" title="按托盘 / 生产批次批量质检与审核" width="min(760px, calc(100vw - 24px))" :footer="null" :body-style="{ maxHeight: '70vh', overflowY: 'auto' }">
      <a-space class="scope-scan">
        <a-select v-model:value="scopeType" style="width: 130px" :options="[{ label: '托盘码', value: 'PALLET' }, { label: '生产批次码', value: 'BATCH' }]" @change="retestScope = undefined" />
        <a-input-search v-model:value="scopeCode" placeholder="扫描或输入码值" enter-button="识别" :loading="scopeBusy" @search="identifyScope" />
        <a-select v-model:value="scopeType" style="width: 130px" :options="[{ label: '托盘码', value: 'PALLET' }, { label: '生产批次码', value: 'BATCH' }]" @change="retestScope = undefined; initialScope = undefined; scopeError = ''" />
        <a-input-search v-model:value="scopeCode" :placeholder="scopeType === 'PALLET' ? '扫描或输入完整托盘码' : '扫描或输入完整生产批次码'" enter-button="识别" :loading="scopeBusy" @change="retestScope = undefined; initialScope = undefined; scopeError = ''" @search="identifyScope" />
      </a-space>
      <a-alert v-if="scopeError" class="scope-error" type="error" show-icon :message="scopeError" />
      <template v-if="retestScope">
        <p>已识别 {{ retestScope.code }} · 有效袋 {{ retestScope.totalCount }} · 生产批次 {{ retestScope.batchCode || '—' }}</p>
        <a-radio-group v-model:value="scopeStatus">
          <a-radio-button v-if="retestScope.retestIds.length" :value="10">复检 {{ retestScope.retestIds.length }} 袋</a-radio-button>
          <a-radio-button v-if="retestScope.initialRecheckIds.length" :value="50">重新初检 {{ retestScope.initialRecheckIds.length }} 袋</a-radio-button>
        </a-radio-group>
        <p class="muted">同一组指标将应用于本阶段全部 {{ scopeIds.length }} 袋。模板范围可以修改。</p>
        <div v-for="item in retestIndicators" :key="item.indicatorCode" class="retest-row">
          <strong>{{ indicatorLabels[item.indicatorCode] }}</strong>
          <a-input-number v-model:value="item.value" :min="0" :max="100" :precision="3" placeholder="实测值" />
          <a-input-number v-model:value="item.minValue" :min="0" :max="100" :precision="3" placeholder="下限" />
          <a-input-number v-model:value="item.maxValue" :min="0" :max="100" :precision="3" placeholder="上限" />
        </div>
        <a-textarea v-model:value="retestReason" :rows="3" :maxlength="500" placeholder="不合格时填写原因" />
        <p>已识别{{ retestScope.scanType === 'PALLET' ? '托盘码' : '生产批次码' }} {{ retestScope.code }} · 有效袋 {{ retestScope.totalCount }} · 生产批次 {{ retestScope.batchCode || '—' }}</p>
        <section class="scope-section">
          <h3>批量初检 <a-tag>{{ initialIds.length }} 袋待检</a-tag></h3>
          <template v-if="initialIds.length">
            <a-alert v-if="!initialScope?.inbound" type="warning" show-icon message="该范围的有效袋码尚未全部入库,暂不能提交初检" />
            <template v-else-if="hasAccessByCodes(['mes:qc-seed-quality:submit'])">
              <p class="muted">四项指标和结论会应用于本次 {{ initialSubmitIds.length }} 袋;超过 500 袋时系统会分批处理并自动加载剩余记录。</p>
              <a-form-item label="检验合格后入库的合格库" required>
                <a-select v-model:value="qualifiedWarehouseId" :options="qualifiedWarehouseOptions" placeholder="请选择合格库" style="width: 100%" />
              </a-form-item>
              <a-alert v-if="!qualifiedWarehouseOptions.length" type="warning" show-icon message="暂无可用合格库,请联系管理员配置" />
              <div v-for="item in initialIndicators" :key="`initial-${item.indicatorCode}`" class="retest-row">
                <strong>{{ indicatorLabels[item.indicatorCode] }}</strong>
                <a-input-number v-model:value="item.value" :min="0" :max="100" :precision="3" placeholder="实测值" />
                <a-input-number v-model:value="item.minValue" :min="0" :max="100" :precision="3" placeholder="下限" />
                <a-input-number v-model:value="item.maxValue" :min="0" :max="100" :precision="3" placeholder="上限" />
              </div>
              <a-textarea v-model:value="initialReason" :rows="2" :maxlength="500" :placeholder="initialPassed ? '合格结果无需填写原因' : '初检不合格时填写原因'" />
              <div class="scope-actions"><a-button type="primary" :loading="scopeBusy" @click="saveScopeInitial">提交本次 {{ initialSubmitIds.length }} 袋初检</a-button></div>
            </template>
            <p v-else class="muted">当前账号没有初检提交权限。</p>
          </template>
          <p v-else class="muted">当前范围没有待初检袋码。</p>
        </section>
        <section v-if="scopePendingCount" class="scope-section">
          <h3>批量审核 <a-tag color="orange">{{ scopePendingCount }} 条待处理</a-tag></h3>
          <p class="muted">按处理结果分组审核,完成后会自动更新下方可复检袋数。</p>
          <div v-if="scopeReviewGroups.length" class="scope-actions">
            <a-button v-for="group in scopeReviewGroups" :key="`scope-review-${group.status}`" :disabled="scopeBusy" @click="openScopeReview(group)">
              {{ workflowLabels[group.status] }} · 本次 {{ Math.min(group.ids.length, 500) }} 条<span v-if="group.ids.length > 500"> / 共 {{ group.ids.length }} 条</span>
            </a-button>
          </div>
          <p v-else-if="scopeHasReviewPermission" class="muted">待审核清单暂未加载成功,请重新识别;也可以在下方列表勾选记录后批量审核。</p>
          <p v-else class="muted">当前账号没有该阶段的审核权限,请由对应审核人处理。</p>
        </section>
        <section v-if="retestScope.retestIds.length || retestScope.initialRecheckIds.length" class="scope-section">
          <h3>批量复检 / 重新初检</h3>
          <template v-if="hasAccessByCodes(['mes:qc-seed-quality:retest'])">
            <a-radio-group v-model:value="scopeStatus">
              <a-radio-button v-if="retestScope.retestIds.length" :value="10">复检 {{ retestScope.retestIds.length }} 袋</a-radio-button>
              <a-radio-button v-if="retestScope.initialRecheckIds.length" :value="50">重新初检 {{ retestScope.initialRecheckIds.length }} 袋</a-radio-button>
            </a-radio-group>
            <p class="muted">同一组指标将应用于本次 {{ scopeSubmitIds.length }} 袋;超过 500 袋时完成后继续处理剩余记录。模板范围可以修改。</p>
            <div v-for="item in retestIndicators" :key="item.indicatorCode" class="retest-row">
              <strong>{{ indicatorLabels[item.indicatorCode] }}</strong>
              <a-input-number v-model:value="item.value" :min="0" :max="100" :precision="3" placeholder="实测值" />
              <a-input-number v-model:value="item.minValue" :min="0" :max="100" :precision="3" placeholder="下限" />
              <a-input-number v-model:value="item.maxValue" :min="0" :max="100" :precision="3" placeholder="上限" />
            </div>
            <a-textarea v-model:value="retestReason" :rows="2" :maxlength="500" placeholder="不合格时填写原因" />
            <div class="scope-actions"><a-button type="primary" :loading="scopeBusy" @click="saveScopeRetest">提交本次 {{ scopeSubmitIds.length }} 袋检验</a-button></div>
          </template>
          <p v-else class="muted">当前账号没有复检权限。</p>
        </section>
        <a-alert v-if="!initialIds.length && !scopePendingCount && !retestScope.retestIds.length && !retestScope.initialRecheckIds.length" type="success" show-icon message="当前范围没有待处理的质检记录" />
      </template>
    </a-modal>
    <a-modal v-model:open="reviewOpen" :title="[25, 60].includes(activeRecord?.workflowStatus ?? 0) ? '不合格处理' : reviewStage === 'process' ? '初检审核' : '复检审核'" :confirm-loading="reviewBusy" ok-text="提交审核" @ok="saveReview">
      <p>{{ reviewIds.length > 1 ? `共 ${reviewIds.length} 条记录` : activeRecord?.code }} · {{ workflowLabels[activeRecord?.workflowStatus ?? 0] }}</p>
    <a-modal v-model:open="reviewOpen" wrap-class-name="seed-quality-modal" :title="[25, 60].includes(activeRecord?.workflowStatus ?? 0) ? '不合格处理' : reviewStage === 'process' ? '初检审核' : '复检审核'" :confirm-loading="reviewBusy" :ok-text="reviewApproved ? '确认处理' : '确认驳回'" @ok="saveReview">
      <p>{{ reviewFromScope || reviewIds.length > 1 ? `共 ${reviewIds.length} 条记录` : activeRecord?.code }} · {{ workflowLabels[activeRecord?.workflowStatus ?? 0] }}</p>
      <a-radio-group v-model:value="reviewApproved" class="review-choice">
        <a-radio :value="true">{{ reviewStage === 'process' ? activeRecord?.workflowStatus === 25 ? '确认不合格,安排复检' : '通过,进入复检' : activeRecord?.workflowStatus === 30 ? '通过,放行入库' : '确认不合格,报废处置' }}</a-radio>
        <a-radio :value="false">{{ reviewStage === 'process' ? '驳回,重新初检' : '驳回,重新复检' }}</a-radio>
      </a-radio-group>
      <a-alert v-if="reviewIds.length > 1" type="info" show-icon :message="reviewApproved ? reviewStage === 'company' ? activeRecord?.workflowStatus === 60 ? `确认后 ${reviewIds.length} 袋将报废` : `确认后 ${reviewIds.length} 袋将放行入库` : `确认后 ${reviewIds.length} 袋将进入复检` : `驳回后 ${reviewIds.length} 袋将返回检验`" />
      <a-textarea v-model:value="reviewOpinion" :rows="3" :maxlength="500" :placeholder="reviewApproved ? '审批意见(选填)' : '请填写驳回意见'" />
    </a-modal>
    <a-modal v-model:open="historyOpen" :title="`流程记录 · ${activeRecord?.code || ''}`" :footer="null">
      <a-timeline v-if="historyActions.length">
    <a-modal v-model:open="historyOpen" wrap-class-name="seed-quality-modal seed-quality-history-modal"
      :title="`流程记录 · ${activeRecord?.code || ''}`" :footer="null"
      width="min(560px, calc(100vw - 24px))" :body-style="{ maxHeight: '70vh', overflowY: 'auto' }">
      <a-timeline v-if="historyActions.length" class="history-timeline">
        <a-timeline-item v-for="action in historyActions" :key="action.id">
          <strong>{{ actionLabels[action.action] || action.action }}</strong>
          <span class="muted"> · {{ formatDateTime(action.createTime) }} · 操作人 {{ action.operatorId || '—' }}</span>
          <div v-if="action.opinion">{{ action.opinion }}</div>
          <div v-for="indicator in actionIndicators(action)" :key="indicator.code" class="muted">
            {{ indicatorLabels[indicator.code] || indicator.code }}:{{ indicator.value }}({{ indicator.minValue }}—{{ indicator.maxValue }})
          <div class="history-head">
            <a-tag :color="actionColor(action.action)">{{ actionLabels[action.action] || action.action }}</a-tag>
            <span class="history-meta">{{ formatDateTime(action.createTime) }} · 操作人 {{ action.operatorName || action.operatorId || '—' }}</span>
          </div>
          <div v-if="action.opinion" class="history-opinion">{{ action.opinion }}</div>
          <div v-if="actionIndicators(action).length" class="history-indicators">
            <div v-for="indicator in actionIndicators(action)" :key="indicator.code" class="history-indicator">
              <span class="indicator-name">{{ indicatorLabels[indicator.code] || indicator.code }}</span>
              <strong class="indicator-value">{{ indicator.value }}</strong>
              <span class="indicator-range">{{ indicator.minValue }}–{{ indicator.maxValue }}</span>
            </div>
          </div>
        </a-timeline-item>
      </a-timeline>
@@ -536,6 +820,9 @@
.stat-card.pending .stat-icon { color: #d97706; background: #fffbeb; }
.stat-card.pending .stat-main strong { color: #d97706; }
/* ===== 移动端批量入口 ===== */
.mobile-batch-entry { display: none; }
/* ===== 明细卡片 ===== */
.record-card { padding: 6px 16px 16px; border: 1px solid #e8edf5; border-radius: 12px; background: #fff; box-shadow: 0 3px 12px #243b5310; }
.card-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 13px 0 15px; flex-wrap: wrap; border-bottom: 1px dashed #eef1f6; }
@@ -547,11 +834,34 @@
.filters .ant-select { width: 128px; }
.filters .ant-btn { display: inline-flex; align-items: center; gap: 5px; }
.filters :deep(.ant-btn .iconify) { font-size: 14px; }
.selection-bar { padding: 12px 0; border-bottom: 1px solid #eef1f6; }
.selection-summary, .selection-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.selection-summary { margin-bottom: 8px; color: #59677b; }
.selection-summary strong { color: #172033; }
.selection-actions .ant-btn { white-space: normal; height: auto; min-height: 32px; text-align: left; }
.retest-row { display: grid; grid-template-columns: 60px repeat(3, 1fr); gap: 8px; align-items: center; margin-bottom: 10px; }
.retest-row :deep(.ant-input-number) { width: 100%; }
.scope-scan { display: flex; width: 100%; margin-bottom: 16px; }
.scope-scan :deep(.ant-space-item:last-child) { flex: 1; }
.scope-error { margin-bottom: 14px; }
.scope-section { padding: 14px 0; border-top: 1px solid #eef1f6; }
.scope-section h3 { display: flex; align-items: center; gap: 8px; margin: 0 0 10px; font-size: 15px; font-weight: 700; }
.scope-section .muted { margin: 8px 0 12px; }
.scope-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
.review-choice { display: flex; gap: 16px; margin: 10px 0 18px; }
/* ===== 流程记录 ===== */
.history-timeline { padding-top: 6px; }
.history-timeline :deep(.ant-timeline-item) { padding-bottom: 22px; }
.history-head { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.history-head .ant-tag { margin: 0; font-weight: 600; }
.history-meta { color: #5b6b80; font-size: 12px; line-height: 1.6; }
.history-opinion { margin-top: 8px; padding: 8px 12px; border-left: 3px solid #1677ff; border-radius: 4px; background: #f5f9ff; color: #3d4a5c; font-size: 13px; line-height: 1.65; white-space: pre-wrap; overflow-wrap: anywhere; }
.history-indicators { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-top: 10px; }
.history-indicator { display: flex; align-items: baseline; gap: 6px; padding: 7px 11px; border: 1px solid #e3e9f2; border-radius: 6px; background: #fff; font-size: 13px; }
.indicator-name { color: #5b6b80; }
.indicator-value { color: #172033; font-size: 15px; font-weight: 600; font-variant-numeric: tabular-nums; }
.indicator-range { margin-left: auto; color: #667085; font-variant-numeric: tabular-nums; }
/* ===== 单元格 ===== */
.strong { font-weight: 600; color: #172033; }
@@ -571,11 +881,61 @@
/* ===== 响应式 ===== */
@media (max-width: 1100px) { .stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 760px) {
  .record-page { padding: 14px; }
  .record-page { padding: 12px; }
  .page-header { display: block; }
  .header-badge { margin-top: 12px; }
  .stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .mobile-batch-entry { display: block; margin-bottom: 12px; padding: 14px; border: 1px solid #cfe0fb; border-radius: 12px; background: linear-gradient(135deg, #f5f9ff 0%, #fff 100%); box-shadow: 0 3px 12px #243b5310; }
  .mobile-batch-copy { display: flex; align-items: flex-start; gap: 11px; margin-bottom: 12px; }
  .mobile-batch-copy strong { display: block; margin-bottom: 4px; color: #172033; font-size: 16px; }
  .mobile-batch-copy p { margin: 0; color: #718096; font-size: 13px; line-height: 1.55; }
  .mobile-batch-icon { display: flex; flex: 0 0 38px; width: 38px; height: 38px; align-items: center; justify-content: center; border-radius: 10px; color: #1677ff; background: #eaf3ff; font-size: 20px; }
  .desktop-scope-action { display: none !important; }
  .record-card { padding: 4px 12px 12px; }
  .card-toolbar { align-items: flex-start; flex-direction: column; }
  .filters .ant-input { width: 100%; flex: 1; }
  .filters { display: grid; width: 100%; grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .filters > :first-child,
  .filters .ant-input,
  .filters .ant-select { width: 100%; min-width: 0; }
  .filters > :first-child { grid-column: 1 / -1; min-height: 44px; }
  .filters .ant-input-affix-wrapper,
  .filters .ant-select-selector,
  .filters .ant-btn { min-height: 40px; }
  .selection-summary { align-items: flex-start; }
  .selection-summary span { flex-basis: 100%; }
  .selection-actions { display: grid; grid-template-columns: 1fr; }
  .selection-actions .ant-btn,
  .scope-actions .ant-btn { width: 100%; min-height: 44px; }
  .retest-row { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; padding: 10px 0; border-bottom: 1px solid #f0f2f5; }
  .retest-row strong { grid-column: 1 / -1; }
  .scope-scan { display: flex; flex-direction: column; gap: 8px; }
  .scope-scan :deep(.ant-space-item) { width: 100%; }
  .scope-scan :deep(.ant-select) { width: 100% !important; }
  .scope-scan :deep(.ant-input-search-button) { min-width: 72px; min-height: 40px; }
  .scope-section { padding: 16px 0; }
  .scope-section h3 { align-items: flex-start; flex-wrap: wrap; }
  .scope-actions { display: grid; grid-template-columns: 1fr; }
  .review-choice { align-items: flex-start; flex-direction: column; gap: 12px; }
  :deep(.ant-pagination) { justify-content: center; gap: 4px; }
  :deep(.ant-pagination-options),
  :deep(.ant-pagination-total-text) { display: none; }
}
@media (max-width: 480px) {
  .header-copy h1 { font-size: 23px; }
  .header-copy p { font-size: 13px; line-height: 1.6; }
  .stat-grid { grid-template-columns: 1fr; gap: 8px; }
  .stat-card { padding: 12px 14px; }
  .filters { grid-template-columns: 1fr; }
  .filters > :first-child { grid-column: auto; }
}
@media (max-width: 760px) {
  :global(.seed-quality-modal) { padding-bottom: 0; }
  :global(.seed-quality-modal .ant-modal) { top: 12px; width: calc(100vw - 24px) !important; max-width: calc(100vw - 24px); margin: 0 auto; padding-bottom: 12px; }
  :global(.seed-quality-modal .ant-modal-content) { max-height: calc(100dvh - 24px); padding: 18px 16px; overflow: hidden; }
  :global(.seed-quality-modal .ant-modal-header) { padding-right: 24px; }
  :global(.seed-quality-modal .ant-modal-title) { white-space: normal; line-height: 1.4; }
  :global(.seed-quality-modal .ant-modal-body) { max-height: calc(100dvh - 150px) !important; overflow-y: auto; overscroll-behavior: contain; }
  :global(.seed-quality-modal .ant-modal-footer) { display: grid; grid-template-columns: 1fr 1fr; }
  :global(.seed-quality-modal .ant-modal-footer .ant-btn) { min-height: 44px; margin-inline-start: 0 !important; }
}
</style>