云
10 小时以前 1ed040591afdcbe3c5df42ad5e02e2cfa56edd87
feat(mobile): 优化移动端质检记录界面功能

- 添加托盘、吨袋下拉选择的搜索与滚动分段加载功能
- 重构表单组件中的绑定选项处理逻辑,实现分页加载机制
- 扩展种子质量检测API接口,增加操作员姓名字段和批量处理ID列表
- 在移动端质检记录页面添加批量初检、审核、复检等功能模块
- 实现移动端仓库选择和批量审核操作界面
- 优化API请求错误处理,添加suppressErrorMessage参数配置
已修改7个文件
957 ■■■■■ 文件已修改
src/api/mes/qc/seedQuality/index.ts 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/request.ts 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/effects/request/src/request-client/types.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/batch/data.ts 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/batch/modules/form.vue 122 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/seedQuality/record.vue 506 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mobile/quality-records.vue 290 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/qc/seedQuality/index.ts
@@ -53,6 +53,7 @@
  opinion?: string;
  indicatorsJson?: string;
  operatorId?: number;
  operatorName?: string;
  createTime?: string;
}
export function getSeedQualityActions(id: number) {
@@ -78,21 +79,25 @@
  initialRecheckIds: number[];
  pendingProcessCount: number;
  pendingCompanyCount: number;
  pendingProcessIds?: number[];
  pendingInitialFailIds?: number[];
  pendingCompanyIds?: number[];
  pendingCompanyFailIds?: number[];
}
export function resolveSeedRetestScope(code: string, scanType: 'PALLET' | 'BATCH') {
  return requestClient.get<SeedRetestScope>('/mes/qc/seed-quality/retest-scope/resolve', { params: { code, scanType } });
  return requestClient.get<SeedRetestScope>('/mes/qc/seed-quality/retest-scope/resolve', { params: { code, scanType }, suppressErrorMessage: true });
}
export function submitSeedRetestScope(data: {
  code: string; scanType: 'PALLET' | 'BATCH'; workflowStatus: 10 | 50;
  qualityIds: number[]; indicators: MesQcSeedQualityApi.Indicator[]; reason?: string;
}) {
  return requestClient.post<{ processedCount: number }>('/mes/qc/seed-quality/retest-scope', data);
  return requestClient.post<{ processedCount: number }>('/mes/qc/seed-quality/retest-scope', data, { suppressErrorMessage: true });
}
export function approveSeedQuality(stage: 'process' | 'company', data: { id: number; approved: boolean; opinion: string }) {
  return requestClient.post(`/mes/qc/seed-quality/${stage}-approve`, data);
  return requestClient.post(`/mes/qc/seed-quality/${stage}-approve`, data, { suppressErrorMessage: true });
}
export function approveSeedQualityBatch(stage: 'process' | 'company', data: { ids: number[]; approved: boolean; opinion: string }) {
  return requestClient.post(`/mes/qc/seed-quality/${stage}-approve-batch`, data);
  return requestClient.post(`/mes/qc/seed-quality/${stage}-approve-batch`, data, { suppressErrorMessage: true });
}
export namespace MesQcSeedQualityApi {
@@ -127,8 +132,8 @@
  pendingBagIds?: number[]; completedBagIds?: number[];
}
export function resolveSeedQuality(code: string, scanType: 'BAG' | 'PALLET' | 'BATCH' = 'BAG') {
  return requestClient.get<SeedQualityScan>('/mes/qc/seed-quality/resolve', { params: { code, scanType } });
  return requestClient.get<SeedQualityScan>('/mes/qc/seed-quality/resolve', { params: { code, scanType }, suppressErrorMessage: true });
}
export function submitSeedQualityGroup(data: MesQcSeedQualityApi.SubmitParams & { scanType: 'BAG' | 'PALLET' | 'BATCH'; bagIds: number[] }) {
  return requestClient.post<{ processedCount: number }>('/mes/qc/seed-quality/submit-group', data);
  return requestClient.post<{ processedCount: number }>('/mes/qc/seed-quality/submit-group', data, { suppressErrorMessage: true });
}
src/api/request.ts
@@ -194,6 +194,9 @@
  // 通用的错误处理,如果没有进入上面的错误处理逻辑,就会进入这里
  client.addResponseInterceptor(
    errorMessageResponseInterceptor((msg: string, error) => {
      if (error?.config?.suppressErrorMessage || error?.response?.config?.suppressErrorMessage) {
        return;
      }
      // 这里可以根据业务进行定制,你可以拿到 error 内的信息进行定制化处理,根据不同的 code 做不同的提示,而不是直接使用 message.error 提示 msg
      // 当前mock接口返回的错误字段是 error 或者 message
      const responseData = error?.response?.data ?? {};
src/packages/effects/request/src/request-client/types.ts
@@ -6,6 +6,8 @@
} from 'axios';
type ExtendOptions<T = any> = {
  /** 由页面在表单内展示错误时,不再弹出全局错误提示。 */
  suppressErrorMessage?: boolean;
  /**
   * 参数序列化方式。预置的有
   * - brackets: ids[]=1&ids[]=2&ids[]=3
src/views/mes/pro/batch/data.ts
@@ -25,13 +25,20 @@
export type TonBagOption = PalletOption;
/** 托盘、吨袋下拉的交互事件(支持搜索与滚动分段加载) */
export interface BindingSelectHandlers {
  onChange?: (ids: number[]) => void;
  onPopupScroll?: (event: Event) => void;
  onSearch?: (keyword: string) => void;
}
/** 新增/修改的表单 */
export function useFormSchema(
  formType: FormType,
  palletOptions: PalletOption[] = [],
  onPalletChange?: (ids: number[]) => void,
  palletHandlers: BindingSelectHandlers = {},
  tonBagOptions: TonBagOption[] = [],
  onTonBagChange?: (ids: number[]) => void,
  tonBagHandlers: BindingSelectHandlers = {},
  existingBinding?: 'pallet' | 'tonBag',
): VbenFormSchema[] {
  const disabled = formType === 'detail';
@@ -165,7 +172,8 @@
        mode: 'multiple',
        optionFilterProp: 'label',
        options: palletOptions,
        onChange: onPalletChange,
        filterOption: false,
        ...palletHandlers,
        placeholder: '请选择相同品种的托盘',
        showSearch: true,
      },
@@ -194,7 +202,8 @@
        mode: 'multiple',
        optionFilterProp: 'label',
        options: tonBagOptions,
        onChange: onTonBagChange,
        filterOption: false,
        ...tonBagHandlers,
        placeholder: '请选择相同品种的吨袋',
        showSearch: true,
      },
src/views/mes/pro/batch/modules/form.vue
@@ -5,7 +5,7 @@
  import type { MesProBatchApi } from "#/api/mes/pro/batch";
  import type { SystemUserApi } from "#/api/system/user";
  import { computed, nextTick, ref } from "vue";
  import { computed, nextTick, reactive, ref } from "vue";
  import { useVbenModal } from "@vben/common-ui";
  import { DICT_TYPE } from "@vben/constants";
@@ -38,8 +38,77 @@
  const formType = ref<FormType>("create");
  const formData = ref<MesProBatchApi.Batch>();
  let palletOptions: PalletOption[] = [];
  let tonBagOptions: TonBagOption[] = [];
  const BINDING_OPTION_PAGE_SIZE = 50;
  const BINDING_SCROLL_THRESHOLD = 32;
  let palletOptionSource: PalletOption[] = [];
  let tonBagOptionSource: TonBagOption[] = [];
  const palletOptions = reactive<PalletOption[]>([]);
  const tonBagOptions = reactive<TonBagOption[]>([]);
  let palletKeyword = "";
  let tonBagKeyword = "";
  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 resetBindingOptions<T extends PalletOption>(
    target: T[],
    source: T[],
    keyword = ""
  ) {
    const filtered = filterBindingOptions(source, keyword);
    target.splice(0, target.length, ...filtered.slice(0, BINDING_OPTION_PAGE_SIZE));
  }
  function appendBindingOptions<T extends PalletOption>(
    target: T[],
    source: T[],
    keyword: string
  ) {
    const filtered = filterBindingOptions(source, keyword);
    if (target.length >= filtered.length) return;
    target.push(...filtered.slice(target.length, target.length + BINDING_OPTION_PAGE_SIZE));
  }
  function isPopupNearBottom(event: Event) {
    const element = event.target as HTMLElement;
    return element.scrollHeight - element.scrollTop - element.clientHeight <= BINDING_SCROLL_THRESHOLD;
  }
  function handlePalletSearch(keyword: string) {
    palletKeyword = keyword;
    resetBindingOptions(palletOptions, palletOptionSource, keyword);
  }
  function handleTonBagSearch(keyword: string) {
    tonBagKeyword = keyword;
    resetBindingOptions(tonBagOptions, tonBagOptionSource, keyword);
  }
  function handlePalletPopupScroll(event: Event) {
    if (isPopupNearBottom(event)) {
      appendBindingOptions(palletOptions, palletOptionSource, palletKeyword);
    }
  }
  function handleTonBagPopupScroll(event: Event) {
    if (isPopupNearBottom(event)) {
      appendBindingOptions(tonBagOptions, tonBagOptionSource, tonBagKeyword);
    }
  }
  const palletHandlers = {
    onChange: handlePalletChange,
    onPopupScroll: handlePalletPopupScroll,
    onSearch: handlePalletSearch,
  };
  const tonBagHandlers = {
    onChange: handleTonBagChange,
    onPopupScroll: handleTonBagPopupScroll,
    onSearch: handleTonBagSearch,
  };
  async function handlePalletChange(ids: number[] = []) {
    const values = await formApi.getValues();
    if (ids.length && (values.tonBagIds?.length || formData.value?.bindingMode === 'tonBag')) {
@@ -47,7 +116,7 @@
      await formApi.setFieldValue('palletIds', []);
      return;
    }
    const selected = palletOptions.filter(option => ids.includes(option.value));
    const selected = palletOptionSource.filter(option => ids.includes(option.value));
    const source = formData.value?.itemId ? formData.value : selected[0];
    if (selected.some(option => !option.itemId || (source?.itemId && option.itemId !== source.itemId))) {
      message.warning('生产批次只能绑定相同品种的托盘');
@@ -65,7 +134,7 @@
      await formApi.setFieldValue('tonBagIds', []);
      return;
    }
    const selected = tonBagOptions.filter(option => ids.includes(option.value));
    const selected = tonBagOptionSource.filter(option => ids.includes(option.value));
    const source = formData.value?.itemId ? formData.value : selected[0];
    if (selected.some(option => option.itemId !== source?.itemId)) {
      message.warning('生产批次只能绑定相同品种的吨袋');
@@ -129,8 +198,9 @@
  let workerRowSeq = 0;
  /** 添加人员行 */
  function handleAddWorker() {
    workerGridApi.grid.insertAt({ _rowKey: -(++workerRowSeq) }, -1);
  async function handleAddWorker() {
    await waitWorkerGridReady();
    await workerGridApi.grid.insertAt({ _rowKey: -(++workerRowSeq) }, -1);
  }
  /** 删除人员行 */
@@ -250,22 +320,47 @@
      const data = modalApi.getData<{ formType: FormType; id?: number }>();
      formType.value = data.formType;
      // 新增/补录/编辑态加载未绑定批次的托盘,供「绑定托盘」多选下拉使用
      palletOptions = [];
      tonBagOptions = [];
      palletOptionSource = [];
      tonBagOptionSource = [];
      palletOptions.splice(0);
      tonBagOptions.splice(0);
      palletKeyword = "";
      tonBagKeyword = "";
      // 先挂载基础表单,候选托盘/吨袋在后台加载,避免只显示人员配置的空窗期
      formApi.setState({
        schema: useFormSchema(
          formType.value,
          palletOptions,
          palletHandlers,
          tonBagOptions,
          tonBagHandlers
        ),
      });
      if (formType.value !== "detail") {
        const [pallets, tonBags] = await Promise.all([getUnboundPalletList(), getUnboundTonBags()]);
        palletOptions = pallets.map((pallet) => ({
        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,
        }));
        tonBagOptions = tonBags.map((bag) => ({
        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);
      }
      formApi.setState({ schema: useFormSchema(formType.value, palletOptions, handlePalletChange, tonBagOptions, handleTonBagChange) });
      // 重新同步一次 Schema,使依赖字段中的动态 options 立即拿到首批候选项
      formApi.setState({
        schema: useFormSchema(
          formType.value,
          palletOptions,
          palletHandlers,
          tonBagOptions,
          tonBagHandlers
        ),
      });
      if (formType.value !== "detail") {
        // 清空上次打开时勾选的托盘,避免残留值误触发「批次进入生产中」
        await formApi.setFieldValue("palletIds", undefined);
@@ -282,7 +377,7 @@
        try {
          formData.value = await getBatch(data.id);
          const existingBinding = formData.value.bindingMode;
          formApi.setState({ schema: useFormSchema(formType.value, palletOptions, handlePalletChange, tonBagOptions, handleTonBagChange, existingBinding) });
          formApi.setState({ schema: useFormSchema(formType.value, palletOptions, palletHandlers, tonBagOptions, tonBagHandlers, existingBinding) });
          await formApi.setValues(formData.value);
          const workers = (formData.value.workers ?? []).map(
            (worker, index) => ({ ...worker, _rowKey: worker.id ?? index })
@@ -295,6 +390,7 @@
      } else {
        await waitWorkerGridReady();
        await workerGridApi.grid.loadData([]);
        await handleAddWorker();
      }
    },
  });
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>
src/views/mobile/quality-records.vue
@@ -1,106 +1,272 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useAccess } from '@vben/access';
import { getSeedIndicatorRanges, getSeedQualityPage, resolveSeedRetestScope, submitSeedRetestScope, type SeedQualityRecord, type SeedRetestScope } from '#/api/mes/qc/seedQuality';
import { MesWmWarehouseTypeEnum } from '@vben/constants';
import {
  approveSeedQualityBatch,
  getSeedIndicatorRanges,
  getSeedQualityPage,
  resolveSeedQuality,
  resolveSeedRetestScope,
  submitSeedQualityGroup,
  submitSeedRetestScope,
  type SeedQualityRecord,
  type SeedQualityScan,
  type SeedRetestScope,
} from '#/api/mes/qc/seedQuality';
import { getWarehouseSimpleList } from '#/api/mes/wm/warehouse';
import { errorText } from '#/mobile/api';
import { extractCode } from '#/mobile/access';
import Scanner from './scanner.vue';
const rows = ref<SeedQualityRecord[]>([]), busy = ref(false), error = ref('');
type ReviewGroup = { ids: number[]; stage: 'company' | 'process'; status: number };
const { hasAccessByCodes } = useAccess();
const canInitial = computed(() => hasAccessByCodes(['mes:qc-seed-quality:submit']));
const canRetest = computed(() => hasAccessByCodes(['mes:qc-seed-quality:retest']));
const scanType = ref<'PALLET' | 'BATCH'>('PALLET'), scope = ref<SeedRetestScope>();
const workflowStatus = ref<10 | 50>(10), reason = ref(''), notice = ref('');
const canProcessReview = computed(() => hasAccessByCodes(['mes:qc-seed-quality:process-approve']));
const canCompanyReview = computed(() => hasAccessByCodes(['mes:qc-seed-quality:company-approve']));
const canBatchAction = computed(() => canInitial.value || canRetest.value || canProcessReview.value || canCompanyReview.value);
const rows = ref<SeedQualityRecord[]>([]), busy = ref(false), error = ref(''), notice = ref('');
const scanType = ref<'BATCH' | 'PALLET'>('PALLET'), scannedCode = ref('');
const scope = ref<SeedRetestScope>(), initialScope = ref<SeedQualityScan>();
const workflowStatus = ref<10 | 50>(10), reason = ref('');
const warehouseId = ref<number>();
const warehouseOptions = ref<Array<{ label: string; value: number }>>([]);
const reviewGroup = ref<ReviewGroup>(), reviewApproved = ref(true), reviewOpinion = ref('');
const indicators = ref([
  { code: 'purity', name: '纯度', value: '', minValue: '', maxValue: '' },
  { code: 'cleanliness', name: '净度', value: '', minValue: '', maxValue: '' },
  { code: 'germination_rate', name: '发芽率', value: '', minValue: '', maxValue: '' },
  { code: 'moisture', name: '水分', value: '', minValue: '', maxValue: '' },
]);
const initialIds = computed(() => initialScope.value?.pendingBagIds || []);
const targetIds = computed(() => workflowStatus.value === 10 ? scope.value?.retestIds || [] : scope.value?.initialRecheckIds || []);
const complete = computed(() => indicators.value.every(i => [i.value, i.minValue, i.maxValue].every(v => v.trim() !== '' && Number.isFinite(Number(v)))
  && Number(i.value) >= 0 && Number(i.value) <= 100 && Number(i.minValue) >= 0 && Number(i.maxValue) <= 100 && Number(i.minValue) <= Number(i.maxValue)));
const passed = computed(() => complete.value && indicators.value.every(i => Number(i.value) >= Number(i.minValue) && Number(i.value) <= Number(i.maxValue)));
async function identifyRetest(raw: string) {
  if (busy.value) return;
  busy.value = true; error.value = ''; notice.value = ''; scope.value = undefined;
  try {
    const data = await resolveSeedRetestScope(extractCode(raw), scanType.value);
    if (!data.retestIds.length && !data.initialRecheckIds.length) throw new Error('当前托盘或批次没有待复检或待重新初检的记录');
    scope.value = data;
    workflowStatus.value = data.retestIds.length ? 10 : 50;
    indicators.value.forEach(i => { i.value = ''; i.minValue = ''; i.maxValue = ''; }); reason.value = '';
    try {
      const ranges = await getSeedIndicatorRanges();
      indicators.value.forEach(i => { const range = ranges.find(r => r.indicatorCode === i.code); if (range) { i.minValue = String(range.minValue); i.maxValue = String(range.maxValue); } });
    } catch { /* 可手动填写本次范围 */ }
  } catch (e) { error.value = errorText(e); } finally { busy.value = false; }
const complete = computed(() => indicators.value.every((item) =>
  [item.value, item.minValue, item.maxValue].every((value) => String(value).trim() !== '' && Number.isFinite(Number(value)))
  && Number(item.value) >= 0 && Number(item.value) <= 100 && Number(item.minValue) >= 0
  && Number(item.maxValue) <= 100 && Number(item.minValue) <= Number(item.maxValue)));
const passed = computed(() => complete.value && indicators.value.every((item) =>
  Number(item.value) >= Number(item.minValue) && Number(item.value) <= Number(item.maxValue)));
const reviewGroups = computed<ReviewGroup[]>(() => {
  if (!scope.value) return [];
  return [
    { status: 20, stage: 'process' as const, ids: scope.value.pendingProcessIds || [] },
    { status: 25, stage: 'process' as const, ids: scope.value.pendingInitialFailIds || [] },
    { status: 30, stage: 'company' as const, ids: scope.value.pendingCompanyIds || [] },
    { status: 60, stage: 'company' as const, ids: scope.value.pendingCompanyFailIds || [] },
  ].filter((group) => group.ids.length && (group.stage === 'process' ? canProcessReview.value : canCompanyReview.value));
});
const statusNames: Record<number, string> = {
  10: '待复检', 20: '待初检审核', 25: '待初检不合格处理', 30: '待复检审核',
  40: '已放行', 50: '待重新初检', 60: '待复检不合格处理', 70: '已报废',
};
function resetIndicators() {
  indicators.value.forEach((item) => { item.value = ''; });
  reason.value = '';
}
async function submitRetest() {
  if (busy.value || !scope.value || !targetIds.value.length || !complete.value) return;
  if (!passed.value && !reason.value.trim()) { error.value = '检验不合格请填写原因'; return; }
  const count = targetIds.value.length;
  if (!window.confirm(`确认将本次指标用于 ${count} 袋${workflowStatus.value === 10 ? '复检' : '重新初检'}?`)) return;
async function hydrateReviewIds(current: SeedRetestScope, rawCode: string) {
  const loaded = (current.pendingProcessIds?.length || 0) + (current.pendingInitialFailIds?.length || 0)
    + (current.pendingCompanyIds?.length || 0) + (current.pendingCompanyFailIds?.length || 0);
  if (loaded >= current.pendingProcessCount + current.pendingCompanyCount) return;
  const batch = current.batchCode || (current.scanType === 'BATCH' ? current.code : undefined);
  if (!batch) return;
  const statuses = [20, 25, 30, 60] as const;
  const pages = await Promise.all(statuses.map((workflow) => getSeedQualityPage({
    batchCode: batch, pageNo: 1, pageSize: 500, workflowStatus: workflow,
  }).catch(() => ({ list: [] as SeedQualityRecord[], total: 0 }))));
  const scopeCodes = new Set([current.code, rawCode]);
  const ids = pages.map(({ list }) => list
    .filter((row) => current.scanType === 'BATCH' || (!!row.palletCode && scopeCodes.has(row.palletCode)))
    .map((row) => row.id));
  current.pendingProcessIds = ids[0];
  current.pendingInitialFailIds = ids[1];
  current.pendingCompanyIds = ids[2];
  current.pendingCompanyFailIds = ids[3];
}
async function identifyScope(raw: string) {
  if (busy.value || !canBatchAction.value) return;
  busy.value = true; error.value = ''; notice.value = '';
  scope.value = undefined; initialScope.value = undefined; reviewGroup.value = undefined;
  try {
    const codeValue = extractCode(raw);
    scannedCode.value = codeValue;
    const current = await resolveSeedRetestScope(codeValue, scanType.value);
    await hydrateReviewIds(current, codeValue);
    scope.value = current;
    scanType.value = current.scanType;
    workflowStatus.value = current.retestIds.length ? 10 : 50;
    initialScope.value = await resolveSeedQuality(codeValue, current.scanType);
    resetIndicators();
    if (!initialIds.value.length && !targetIds.value.length && !reviewGroups.value.length) {
      notice.value = '当前托盘或生产批次没有可处理的质检记录。';
    }
  } catch (exception) { error.value = errorText(exception); }
  finally { busy.value = false; }
}
function indicatorPayload() {
  return indicators.value.map((item) => ({ code: item.code, value: Number(item.value), minValue: Number(item.minValue), maxValue: Number(item.maxValue) }));
}
async function refreshScopeAndList(success: string) {
  await identifyScope(scannedCode.value);
  await load();
  notice.value = success;
}
async function submitInitial() {
  const ids = initialIds.value.slice(0, 500);
  if (busy.value || !canInitial.value || !initialScope.value || !ids.length || !complete.value) return;
  if (!warehouseId.value) { error.value = '请选择检验合格后的目标合格库'; return; }
  if (!passed.value && !reason.value.trim()) { error.value = '初检不合格请填写原因'; return; }
  if (!window.confirm(`确认批量提交 ${ids.length} 袋初检?`)) return;
  busy.value = true; error.value = '';
  try {
    const result = await submitSeedRetestScope({ code: scope.value.code, scanType: scope.value.scanType, workflowStatus: workflowStatus.value,
      qualityIds: targetIds.value, reason: reason.value.trim() || undefined,
      indicators: indicators.value.map(i => ({ code: i.code, value: Number(i.value), minValue: Number(i.minValue), maxValue: Number(i.maxValue) })) });
    notice.value = `已完成 ${result.processedCount} 袋检验,等待后续审核或不合格处理。`;
    scope.value = undefined;
  } catch (e) { error.value = errorText(e) + '。请重新扫码核对清单后再提交。'; scope.value = undefined; }
  finally { busy.value = false; if (notice.value) void load(); }
    const result = await submitSeedQualityGroup({ code: initialScope.value.code, batchCode: initialScope.value.batchCode,
      scanType: initialScope.value.scanType || scanType.value, bagIds: ids, qualifiedWarehouseId: warehouseId.value,
      reason: reason.value.trim() || undefined, indicators: indicatorPayload() });
    busy.value = false;
    await refreshScopeAndList(`已完成 ${result.processedCount} 袋初检。`);
  } catch (exception) { error.value = errorText(exception) + '。请重新识别后核对待检清单。'; }
  finally { busy.value = false; }
}
async function submitRetest() {
  const ids = targetIds.value.slice(0, 500);
  if (busy.value || !canRetest.value || !scope.value || !ids.length || !complete.value) return;
  if (!passed.value && !reason.value.trim()) { error.value = '检验不合格请填写原因'; return; }
  if (!window.confirm(`确认将本次指标用于 ${ids.length} 袋${workflowStatus.value === 10 ? '复检' : '重新初检'}?`)) return;
  busy.value = true; error.value = '';
  try {
    const result = await submitSeedRetestScope({ code: scope.value.code, scanType: scope.value.scanType,
      workflowStatus: workflowStatus.value, qualityIds: ids, reason: reason.value.trim() || undefined, indicators: indicatorPayload() });
    busy.value = false;
    await refreshScopeAndList(`已完成 ${result.processedCount} 袋检验。`);
  } catch (exception) { error.value = errorText(exception) + '。请重新识别后核对待检清单。'; }
  finally { busy.value = false; }
}
function selectReview(group: ReviewGroup) {
  reviewGroup.value = group; reviewApproved.value = true; reviewOpinion.value = ''; error.value = '';
}
async function submitReview() {
  if (busy.value || !reviewGroup.value?.ids.length) return;
  if (!reviewApproved.value && !reviewOpinion.value.trim()) { error.value = '驳回时请填写审核意见'; return; }
  const ids = reviewGroup.value.ids.slice(0, 500);
  if (!window.confirm(`确认${reviewApproved.value ? '通过' : '驳回'}本次 ${ids.length} 条记录?`)) return;
  busy.value = true; error.value = '';
  try {
    await approveSeedQualityBatch(reviewGroup.value.stage, { ids, approved: reviewApproved.value, opinion: reviewOpinion.value.trim() });
    busy.value = false;
    await refreshScopeAndList(`已完成 ${ids.length} 条审核。`);
  } catch (exception) { error.value = errorText(exception) + '。请重新识别后核对待审核清单。'; }
  finally { busy.value = false; }
}
const code = ref(''), batchCode = ref(''), result = ref(''), page = ref(0), total = ref(0);
const statusNames: Record<number, string> = { 10: '待复检', 20: '待初检审核', 25: '待初检不合格处理', 30: '待复检审核', 40: '已放行', 50: '待重新初检', 60: '待复检不合格处理', 70: '已报废' };
let filters: { code?: string; batchCode?: string; result?: number } = {};
let filters: { batchCode?: string; code?: string; result?: number } = {};
async function load(reset = true) {
  if (busy.value) return;
  busy.value = true; error.value = '';
  const next = reset ? 1 : page.value + 1;
  const query = reset ? { code: code.value.trim() || undefined, batchCode: batchCode.value.trim() || undefined, result: result.value ? Number(result.value) : undefined } : filters;
  const query = reset ? { code: code.value.trim() || undefined, batchCode: batchCode.value.trim() || undefined,
    result: result.value ? Number(result.value) : undefined } : filters;
  try {
    const data = await getSeedQualityPage({ ...query, pageNo: next, pageSize: 20 });
    rows.value = reset ? data.list : [...rows.value, ...data.list];
    page.value = next; total.value = data.total; filters = query;
  } catch (e) { error.value = errorText(e); } finally { busy.value = false; }
  } catch (exception) { error.value = errorText(exception); }
  finally { busy.value = false; }
}
onMounted(() => load());
onMounted(async () => {
  if (canInitial.value) {
    try {
      const warehouses = await getWarehouseSimpleList();
      warehouseOptions.value = warehouses.filter((item) => item.type === MesWmWarehouseTypeEnum.QUALIFIED && !item.frozen && item.id)
        .map((item) => ({ label: item.code ? `${item.name}(${item.code})` : item.name || '', value: item.id! }));
      if (warehouseOptions.value.length === 1) warehouseId.value = warehouseOptions.value[0]?.value;
    } catch { warehouseOptions.value = []; }
  }
  if (canInitial.value || canRetest.value) {
    try {
      const ranges = await getSeedIndicatorRanges();
      indicators.value.forEach((item) => { const range = ranges.find((row) => row.indicatorCode === item.code);
        if (range) { item.minValue = String(range.minValue); item.maxValue = String(range.maxValue); } });
    } catch { /* 合格范围仍可手工填写 */ }
  }
  await load();
});
</script>
<template>
  <section v-if="canRetest" class="m-panel">
    <h2 class="m-title">按托盘 / 批次批量质检</h2>
    <p class="m-muted">扫描托盘码或生产批次码,系统会列出待复检和待重新初检的袋数。</p>
    <label class="m-field"><span>扫码类型</span><select v-model="scanType" :disabled="busy" @change="scope = undefined"><option value="PALLET">托盘码</option><option value="BATCH">生产批次码</option></select></label>
    <Scanner :disabled="busy" @code="identifyRetest" />
    <form v-if="scope" @submit.prevent="submitRetest">
      <p class="m-muted">{{scope.code}} · {{scope.batchCode || '未标注批次'}} · 有效袋 {{scope.totalCount}}</p>
      <label class="m-field"><span>质检阶段</span><select v-model="workflowStatus" :disabled="busy"><option v-if="scope.retestIds.length" :value="10">复检({{scope.retestIds.length}} 袋)</option><option v-if="scope.initialRecheckIds.length" :value="50">重新初检({{scope.initialRecheckIds.length}} 袋)</option></select></label>
      <p class="m-warning">同一组指标将用于本阶段全部 {{targetIds.length}} 袋。请核对抽样结果是否适用。</p>
      <fieldset v-for="i in indicators" :key="i.code" class="m-panel m-quality-indicator" :disabled="busy"><legend>{{i.name}}(%)</legend>
        <label class="m-field"><span>实测值</span><input v-model="i.value" type="number" inputmode="decimal" min="0" max="100" step="any" required /></label>
        <div class="m-grid"><label class="m-field"><span>合格下限</span><input v-model="i.minValue" type="number" inputmode="decimal" min="0" max="100" step="any" required /></label><label class="m-field"><span>合格上限</span><input v-model="i.maxValue" type="number" inputmode="decimal" min="0" max="100" step="any" required /></label></div>
      </fieldset>
      <p class="m-muted">上下限从模板带入,可按本次检验标准调整。</p>
      <label class="m-field"><span>{{complete && !passed ? '不合格原因(必填)' : '检验备注'}}</span><textarea v-model="reason" :required="complete && !passed" maxlength="500" rows="3" /></label>
      <button class="m-primary" :disabled="busy || !complete || !targetIds.length">{{busy ? '正在提交…' : `提交 ${targetIds.length} 袋质检`}}</button>
    </form>
  <section v-if="canBatchAction" class="m-panel">
    <h2 class="m-title">批量检验与审核</h2>
    <p class="m-muted">扫描托盘码或生产批次码,系统会按当前账号权限列出批量初检、审核、复检操作。</p>
    <label class="m-field"><span>扫码类型</span><select v-model="scanType" :disabled="busy" @change="scope = undefined; initialScope = undefined"><option value="PALLET">托盘码</option><option value="BATCH">生产批次码</option></select></label>
    <Scanner :disabled="busy" @code="identifyScope" />
    <template v-if="scope">
      <div class="m-panel"><h3 class="m-title">已识别范围</h3><dl><div class="m-row"><dt>码值</dt><dd>{{ scope.code }}</dd></div><div class="m-row"><dt>生产批次</dt><dd>{{ scope.batchCode || '—' }}</dd></div><div class="m-row"><dt>有效袋数</dt><dd>{{ scope.totalCount }}</dd></div></dl></div>
      <form v-if="canInitial && initialIds.length" class="m-panel" @submit.prevent="submitInitial">
        <h3 class="m-title">批量初检({{ Math.min(initialIds.length, 500) }} 袋)</h3>
        <p v-if="!initialScope?.inbound" class="m-warning">该范围的有效袋码尚未全部入库,暂不能提交初检。</p>
        <template v-else>
          <label class="m-field"><span>目标合格库</span><select v-model="warehouseId" required :disabled="busy"><option :value="undefined" disabled>请选择合格库</option><option v-for="item in warehouseOptions" :key="item.value" :value="item.value">{{ item.label }}</option></select></label>
          <fieldset v-for="item in indicators" :key="`initial-${item.code}`" class="m-panel m-quality-indicator" :disabled="busy"><legend>{{ item.name }}(%)</legend><label class="m-field"><span>实测值</span><input v-model="item.value" type="number" inputmode="decimal" min="0" max="100" step="any" required /></label><div class="m-grid"><label class="m-field"><span>合格下限</span><input v-model="item.minValue" type="number" inputmode="decimal" min="0" max="100" step="any" required /></label><label class="m-field"><span>合格上限</span><input v-model="item.maxValue" type="number" inputmode="decimal" min="0" max="100" step="any" required /></label></div></fieldset>
          <label class="m-field"><span>{{ complete && !passed ? '不合格原因(必填)' : '检验备注' }}</span><textarea v-model="reason" rows="3" maxlength="500" /></label>
          <button class="m-primary" :disabled="busy || !complete || !warehouseId">{{ busy ? '正在提交…' : `提交 ${Math.min(initialIds.length, 500)} 袋初检` }}</button>
        </template>
      </form>
      <section v-if="reviewGroups.length" class="m-panel">
        <h3 class="m-title">批量审核</h3>
        <button v-for="group in reviewGroups" :key="group.status" type="button" class="m-secondary" :disabled="busy" @click="selectReview(group)">{{ statusNames[group.status] }} · {{ Math.min(group.ids.length, 500) }} 条</button>
        <form v-if="reviewGroup" @submit.prevent="submitReview">
          <label class="m-field"><span>审核结论</span><select v-model="reviewApproved" :disabled="busy"><option :value="true">通过 / 确认处理</option><option :value="false">驳回重新检验</option></select></label>
          <label class="m-field"><span>{{ reviewApproved ? '审核意见(选填)' : '驳回意见(必填)' }}</span><textarea v-model="reviewOpinion" rows="3" maxlength="500" /></label>
          <button class="m-primary" :disabled="busy">{{ busy ? '正在审核…' : `确认审核 ${Math.min(reviewGroup.ids.length, 500)} 条` }}</button>
        </form>
      </section>
      <form v-if="canRetest && (scope.retestIds.length || scope.initialRecheckIds.length)" class="m-panel" @submit.prevent="submitRetest">
        <h3 class="m-title">批量复检 / 重新初检</h3>
        <label class="m-field"><span>质检阶段</span><select v-model="workflowStatus" :disabled="busy"><option v-if="scope.retestIds.length" :value="10">复检({{ scope.retestIds.length }} 袋)</option><option v-if="scope.initialRecheckIds.length" :value="50">重新初检({{ scope.initialRecheckIds.length }} 袋)</option></select></label>
        <p class="m-warning">同一组指标将用于本次 {{ Math.min(targetIds.length, 500) }} 袋,请核对抽样结果。</p>
        <fieldset v-for="item in indicators" :key="`retest-${item.code}`" class="m-panel m-quality-indicator" :disabled="busy"><legend>{{ item.name }}(%)</legend><label class="m-field"><span>实测值</span><input v-model="item.value" type="number" inputmode="decimal" min="0" max="100" step="any" required /></label><div class="m-grid"><label class="m-field"><span>合格下限</span><input v-model="item.minValue" type="number" inputmode="decimal" min="0" max="100" step="any" required /></label><label class="m-field"><span>合格上限</span><input v-model="item.maxValue" type="number" inputmode="decimal" min="0" max="100" step="any" required /></label></div></fieldset>
        <label class="m-field"><span>{{ complete && !passed ? '不合格原因(必填)' : '检验备注' }}</span><textarea v-model="reason" rows="3" maxlength="500" /></label>
        <button class="m-primary" :disabled="busy || !complete || !targetIds.length">{{ busy ? '正在提交…' : `提交 ${Math.min(targetIds.length, 500)} 袋检验` }}</button>
      </form>
    </template>
  </section>
  <p v-if="notice" class="m-success" role="status">{{notice}}</p>
  <section v-else class="m-panel"><h2 class="m-title">批量检验与审核</h2><p class="m-muted">当前账号只有质检记录查看权限。如需批量操作,请联系管理员配置初检、复检或审核权限。</p></section>
  <p v-if="notice" class="m-success" role="status">{{ notice }}</p>
  <p v-if="error" class="m-error" role="alert">{{ error }}</p>
  <p class="m-muted">查看质检结果、批次与不合格原因。</p>
  <Scanner :disabled="busy" @code="value => { code = value; load(); }" />
  <form class="m-panel" @submit.prevent="load()">
    <label class="m-field"><span>业务码 / 追溯码</span><input v-model="code" :disabled="busy" placeholder="输入袋码" /></label>
    <label class="m-field"><span>生产批次</span><input v-model="batchCode" :disabled="busy" placeholder="输入批次号" /></label>
    <label class="m-field"><span>质检结果</span><select v-model="result" :disabled="busy"><option value="">全部结果</option><option value="1">合格</option><option value="2">不合格</option></select></label>
    <button class="m-secondary" :disabled="busy">{{busy ? '正在查询…' : '查询记录'}}</button>
    <button class="m-secondary" :disabled="busy">{{ busy ? '正在查询…' : '查询记录' }}</button>
  </form>
  <p v-if="error" class="m-error" role="alert">{{error}}</p>
  <p class="m-muted" role="status">共 {{total}} 条记录</p>
  <p class="m-muted" role="status">共 {{ total }} 条记录</p>
  <p v-if="!rows.length && !busy && !error" class="m-empty">暂无匹配的质检记录</p>
  <article v-for="row in rows" :key="row.id" class="m-panel">
    <strong :class="['m-status', row.result === 1 ? 'success' : row.result === 2 ? 'failed' : '']">{{row.result === 1 ? '合格' : row.result === 2 ? '不合格' : '处理中'}}</strong>
    <dl><div class="m-row"><dt>追溯码</dt><dd>{{row.code}}</dd></div><div class="m-row"><dt>生产批次</dt><dd>{{row.batchCode || '—'}}</dd></div><div class="m-row"><dt>流程状态</dt><dd>{{statusNames[row.workflowStatus] || '未知状态'}}</dd></div><div class="m-row"><dt>检验时间</dt><dd>{{row.inspectedAt || row.createTime || '—'}}</dd></div><div v-if="row.reason" class="m-row"><dt>原因 / 备注</dt><dd>{{row.reason}}</dd></div></dl>
    <strong :class="['m-status', row.result === 1 ? 'success' : row.result === 2 ? 'failed' : '']">{{ row.result === 1 ? '合格' : row.result === 2 ? '不合格' : '处理中' }}</strong>
    <dl><div class="m-row"><dt>追溯码</dt><dd>{{ row.code }}</dd></div><div class="m-row"><dt>生产批次</dt><dd>{{ row.batchCode || '—' }}</dd></div><div class="m-row"><dt>流程状态</dt><dd>{{ statusNames[row.workflowStatus] || '未知状态' }}</dd></div><div class="m-row"><dt>检验时间</dt><dd>{{ row.inspectedAt || row.createTime || '—' }}</dd></div><div v-if="row.reason" class="m-row"><dt>原因 / 备注</dt><dd>{{ row.reason }}</dd></div></dl>
  </article>
  <button v-if="rows.length < total" class="m-secondary" :disabled="busy" @click="load(false)">{{busy ? '正在加载…' : '加载更多'}}</button>
  <button v-if="rows.length < total" class="m-secondary" :disabled="busy" @click="load(false)">{{ busy ? '正在加载…' : '加载更多' }}</button>
</template>