Merge remote-tracking branch 'origin/dev_新疆_塔河种业' into dev_新疆_塔河种业

# Conflicts:
# src/api/mes/pro/qrExport/index.ts
# src/views/mes/pro/bagCode/index.vue
已添加2个文件
已修改4个文件
835 ■■■■■ 文件已修改
src/api/mes/pro/pallet/index.ts 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/bagCode/modules/bind-pallet.vue 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/qrexport/qr-export-progress.vue 189 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/qrexport/use-qr-export.ts 387 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/seedQuality/index.vue 214 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
vite.config.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/pallet/index.ts
@@ -129,6 +129,14 @@
  );
}
/** æŸ¥è¯¢è¢‹ç ç»‘定托盘时的可选目标托盘列表(未绑批次的临时托盘 + æ­£å¼æ‰˜ç›˜ï¼ŒæŽ’除已作废) */
export function getPalletBindableList(itemId?: number) {
  return requestClient.get<MesProPalletApi.Pallet[]>(
    '/mes/pro/pallet/bindable-list',
    { params: { itemId } },
  );
}
/** æŸ¥è¯¢æœªç»‘定批次的临时托盘列表(供批次新增/补录绑定托盘下拉使用) */
export function getUnboundPalletList() {
  return requestClient.get<MesProPalletApi.Pallet[]>(
src/views/mes/pro/bagCode/modules/bind-pallet.vue
@@ -1,5 +1,6 @@
<script lang="ts" setup>
  import type { VbenFormSchema } from "#/adapter/form";
  import type { MesProPalletApi } from "#/api/mes/pro/pallet";
  import { useVbenModal } from "@vben/common-ui";
  import { MesProPalletStatusEnum } from "@vben/constants";
@@ -8,19 +9,30 @@
  import { useVbenForm } from "#/adapter/form";
  import { bindBagCodePallet } from "#/api/mes/pro/bagCode";
  import { getPalletSimpleList } from "#/api/mes/pro/pallet";
  import {
    getPalletBindableList,
    getPalletSimpleList,
  } from "#/api/mes/pro/pallet";
  import { $t } from "#/locales";
  defineOptions({ name: "MesProBagCodeBindPallet" });
  /** å¼¹çª—传入数据 */
  /** å¼¹çª—传入数据(batchId ä¸ºç©ºè¡¨ç¤ºæ‰€é€‰è¢‹ç å°šæœªå½•入生产批次) */
  interface ModalData {
    batchId: number;
    batchId?: number;
    batchCode?: string;
    itemId?: number;
    ids: number[];
  }
  const emit = defineEmits(["success"]);
  /** æœªå½•入批次时的托盘展示名:正式托盘带批次号、临时托盘回退到临时码,便于在混合列表中分辨 */
  function palletLabel(pallet: MesProPalletApi.Pallet) {
    const code = pallet.code || pallet.tempCode || "-";
    const batchPart = pallet.batchCode ? `批次 ${pallet.batchCode}` : "临时托盘";
    return `${code}(${batchPart} / å·²è£… ${pallet.bagCount ?? 0} è¢‹ï¼‰`;
  }
  /** æž„建表单(托盘下拉选项由外部加载注入) */
  function buildSchema(palletOptions: { label: string; value: number }[]): VbenFormSchema[] {
@@ -90,20 +102,23 @@
      const data = modalApi.getData<ModalData>();
      modalApi.lock();
      try {
        // åŠ è½½è¯¥æ‰¹æ¬¡ä¸‹çš„æ‰˜ç›˜ï¼ˆæŽ’é™¤å·²ä½œåºŸï¼‰
        const pallets = await getPalletSimpleList(data.batchId);
        // å·²å½•入批次:只列本批次的托盘;未录入批次:列该品种可用的临时托盘与正式托盘,
        // é€‰åˆ°æ­£å¼æ‰˜ç›˜æ—¶ç”±åŽç«¯å…ˆæŠŠè¿™æ‰¹è¢‹ç è¡¥å½•进该批次,再完成绑定
        const pallets = data.batchId
          ? await getPalletSimpleList(data.batchId)
          : await getPalletBindableList(data.itemId);
        const options = pallets
          .filter(
            (pallet) => pallet.status !== MesProPalletStatusEnum.CANCELED
          )
          .filter((pallet) => pallet.status !== MesProPalletStatusEnum.CANCELED)
          .map((pallet) => ({
            label: `${pallet.code}(已装 ${pallet.bagCount ?? 0} è¢‹ï¼‰`,
            label: data.batchId
              ? `${pallet.code}(已装 ${pallet.bagCount ?? 0} è¢‹ï¼‰`
              : palletLabel(pallet),
            value: pallet.id!,
          }));
        await formApi.setState({ schema: buildSchema(options) });
        await formApi.setValues({
          bagCount: data.ids.length,
          batchCode: data.batchCode,
          batchCode: data.batchCode || "未录入批次",
        });
      } finally {
        modalApi.unlock();
src/views/mes/pro/qrexport/qr-export-progress.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,189 @@
<script lang="ts" setup>
  import { computed } from 'vue';
  import { Button, Progress } from 'ant-design-vue';
  import { useQrExport } from './use-qr-export';
  defineOptions({ name: 'QrExportProgress' });
  const { state, cancel, minimize, expand, close } = useQrExport();
  /** é¢æ¿æ˜¯å¦å±•示(idle è¡¨ç¤ºæ²¡æœ‰ä»»ä½•任务) */
  const visible = computed(() => state.phase !== 'idle');
  /** è¿›è¡Œä¸­çš„阶段才允许取消 */
  const cancelable = computed(() =>
    ['counting', 'downloading', 'generating'].includes(state.phase),
  );
  const percent = computed(() => {
    switch (state.phase) {
      case 'counting': {
        return 0;
      }
      case 'done': {
        return 100;
      }
      case 'downloading': {
        if (state.downloadTotal > 0) {
          return Math.min(
            100,
            Math.round((state.downloadLoaded / state.downloadTotal) * 100),
          );
        }
        return 0;
      }
      case 'generating': {
        return state.progress;
      }
      default: {
        return state.progress;
      }
    }
  });
  const progressStatus = computed(() => {
    if (state.phase === 'failed') return 'exception';
    if (state.phase === 'done') return 'success';
    return 'active';
  });
  /** ç”Ÿæˆé˜¶æ®µä¸Žä¸‹è½½é˜¶æ®µéƒ½ä¸å¯é¢„知时长,用不确定态进度条 */
  const indeterminate = computed(
    () => state.phase === 'counting' || state.phase === 'sync',
  );
  const title = computed(() => {
    switch (state.phase) {
      case 'canceled': {
        return '导出已取消';
      }
      case 'counting': {
        return '正在统计数量';
      }
      case 'done': {
        return '导出完成';
      }
      case 'downloading': {
        return '正在下载';
      }
      case 'failed': {
        return '导出失败';
      }
      case 'generating': {
        return '正在生成二维码';
      }
      case 'sync': {
        return '正在导出';
      }
      default: {
        return '二维码导出';
      }
    }
  });
  const detail = computed(() => {
    if (state.phase === 'failed') {
      return state.errorMsg || '导出失败';
    }
    if (state.phase === 'downloading') {
      if (state.downloadTotal > 0) {
        return `${formatSize(state.downloadLoaded)} / ${formatSize(state.downloadTotal)}`;
      }
      return `已下载 ${formatSize(state.downloadLoaded)}`;
    }
    return state.message;
  });
  /** æ”¶èµ·åŽæµ®æ ‡ä¸Šä»è¦èƒ½çœ‹åˆ°è¿›åº¦ï¼Œé¿å…ç”¨æˆ·ä»¥ä¸ºä»»åŠ¡å·²ç»åœäº† */
  const pillText = computed(() =>
    state.phase === 'generating' ? `${percent.value}%` : title.value,
  );
  function formatSize(bytes: number) {
    if (!bytes) return '0 B';
    if (bytes < 1024) return `${bytes} B`;
    if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
    if (bytes < 1024 * 1024 * 1024) {
      return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
    }
    return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
  }
</script>
<template>
  <!-- æ”¶èµ·æ€ï¼šå°æµ®æ ‡ï¼Œç‚¹å‡»å¯é‡æ–°å±•å¼€ -->
  <div
    v-if="visible && state.minimized"
    class="fixed right-6 bottom-6 z-[2000] flex cursor-pointer items-center gap-2 rounded-full bg-white px-4 py-2 shadow-lg ring-1 ring-gray-200 hover:shadow-xl dark:bg-gray-800 dark:ring-gray-700"
    @click="expand"
  >
    <span
      class="inline-block h-2 w-2 rounded-full"
      :class="{
        'bg-blue-500': state.phase === 'generating' || state.phase === 'downloading',
        'bg-green-500': state.phase === 'done',
        'bg-red-500': state.phase === 'failed',
        'bg-gray-400': state.phase === 'canceled',
      }"
    ></span>
    <span class="text-sm text-gray-700 dark:text-gray-200">
      äºŒç»´ç å¯¼å‡º {{ pillText }}
    </span>
  </div>
  <!-- å±•开态:进度面板 -->
  <div
    v-else-if="visible"
    class="fixed right-6 bottom-6 z-[2000] w-80 rounded-lg bg-white p-4 shadow-lg ring-1 ring-gray-200 dark:bg-gray-800 dark:ring-gray-700"
  >
    <div class="mb-2 flex items-center justify-between">
      <span class="text-sm font-medium text-gray-800 dark:text-gray-100">
        {{ title }}
      </span>
      <div class="flex items-center gap-1">
        <!-- è¿›è¡Œä¸­æä¾›ã€Œéšè—ã€ï¼Œä»»åŠ¡è½¬å…¥åŽå°ç»§ç»­ï¼Œä¸ä¼šè¢«ä¸­æ–­ -->
        <Button
          v-if="state.phase !== 'done' && state.phase !== 'failed' && state.phase !== 'canceled'"
          type="link"
          size="small"
          @click="minimize"
        >
          éšè—
        </Button>
        <Button
          v-if="cancelable"
          type="link"
          size="small"
          danger
          @click="cancel"
        >
          å–消
        </Button>
        <Button
          v-if="!cancelable"
          type="link"
          size="small"
          @click="close"
        >
          å…³é—­
        </Button>
      </div>
    </div>
    <Progress
      :percent="percent"
      :status="progressStatus"
      :show-info="!indeterminate"
      size="small"
    />
    <div class="mt-2 truncate text-xs text-gray-500 dark:text-gray-400">
      {{ state.taskName }}
    </div>
    <div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
      {{ detail }}
    </div>
  </div>
</template>
src/views/mes/pro/qrexport/use-qr-export.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,387 @@
import type { MesProQrExportApi } from '#/api/mes/pro/qrExport';
import { reactive } from 'vue';
import { useAppConfig } from '@vben/hooks';
import { useAccessStore } from '@vben/stores';
import { downloadFileFromBlobPart } from '@vben/utils';
import { message } from 'ant-design-vue';
import {
  cancelQrExportTask,
  getQrExportCount,
  getQrExportDownloadPath,
  getQrExportTask,
  submitQrExportTask,
} from '#/api/mes/pro/qrExport';
/**
 * å¯¼å‡ºé˜¶æ®µ
 * <p>
 * counting ç»Ÿè®¡å¼ æ•° / sync å°æ‰¹é‡åŒæ­¥ä¸‹è½½ä¸­ / generating åŽå°ç”Ÿæˆä¸­ /
 * downloading ä¸‹è½½äº§ç‰©ä¸­ / done å®Œæˆ / canceled å·²å–消 / failed å¤±è´¥
 */
export type QrExportPhase =
  | 'canceled'
  | 'counting'
  | 'done'
  | 'downloading'
  | 'failed'
  | 'generating'
  | 'idle'
  | 'sync';
export interface QrExportState {
  bizType?: MesProQrExportApi.BizType;
  /** å·²ä¸‹è½½å­—节 */
  downloadLoaded: number;
  /** äº§ç‰©æ€»å­—节(取自 Content-Length,取不到时为 0 è¡¨ç¤ºæœªçŸ¥ï¼‰ */
  downloadTotal: number;
  errorMsg: string;
  /** å·²ç”Ÿæˆå¼ æ•° */
  finished: number;
  message: string;
  /** æ”¶èµ·ä¸ºæµ®æ ‡ï¼ˆä»»åŠ¡ç»§ç»­åœ¨åŽå°è·‘ï¼‰ */
  minimized: boolean;
  phase: QrExportPhase;
  /** è¿›åº¦ç™¾åˆ†æ¯”(0~100) */
  progress: number;
  taskId?: number;
  taskName: string;
  /** æ ‡ç­¾æ€»å¼ æ•° */
  total: number;
}
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
/**
 * å¯¼å‡ºçŠ¶æ€æåˆ°æ¨¡å—çº§å•ä¾‹ï¼šè¿›åº¦é¢æ¿è¢«æ”¶èµ·ã€æˆ–é¡µé¢ç»„ä»¶è¢«é”€æ¯ï¼ˆç”¨æˆ·åˆ‡èµ°å†åˆ‡å›žï¼‰æ—¶ï¼Œ
 * è½®è¯¢ä¸Žä¸‹è½½éƒ½å¿…须继续,不能因为组件卸载而中断。
 */
const state = reactive<QrExportState>(createInitialState());
let pollTimer: null | ReturnType<typeof setInterval> = null;
let downloadAbort: null | (() => void) = null;
/**
 * ç›®æ ‡æ–‡ä»¶å¥æŸ„
 * <p>
 * å¤§æ‰¹é‡å¯¼å‡ºçš„产物可达数百 MB,若整包读进内存再落盘,标签页极易被撑爆。因此优先在点击导出
 * ï¼ˆç”¨æˆ·æ‰‹åŠ¿ä»åœ¨æœ‰æ•ˆæœŸå†…ï¼‰æ—¶å–åˆ°ä¿å­˜å¥æŸ„ï¼Œäº§ç‰©è¾¹ä¸‹è½½è¾¹å†™ç›˜ï¼Œå†…å­˜åªå ä¸€ä¸ªåˆ†ç‰‡ã€‚
 * æµè§ˆå™¨ä¸æ”¯æŒæˆ–用户放弃选择时退回内存下载。
 */
let saveHandle: any = null;
/** å°è¯•取得保存句柄;不支持、被取消时返回 null(不视为错误) */
async function tryPickSaveFile(suggestedName: string): Promise<any> {
  const picker = (window as any).showSaveFilePicker;
  if (typeof picker !== 'function') {
    return null;
  }
  try {
    return await picker({
      suggestedName,
      types: [
        { description: 'ZIP åŽ‹ç¼©åŒ…', accept: { 'application/zip': ['.zip'] } },
      ],
    });
  } catch {
    return null;
  }
}
function createInitialState(): QrExportState {
  return {
    downloadLoaded: 0,
    downloadTotal: 0,
    errorMsg: '',
    finished: 0,
    message: '',
    minimized: false,
    phase: 'idle',
    progress: 0,
    taskName: '',
    total: 0,
  };
}
function resetState() {
  Object.assign(state, createInitialState());
}
function stopPolling() {
  if (pollTimer !== null) {
    clearInterval(pollTimer);
    pollTimer = null;
  }
}
/** æ˜¯å¦å¤„于进行中(面板需要展示) */
function isActive() {
  return [
    'counting',
    'generating',
    'downloading',
    'sync',
  ].includes(state.phase);
}
/** ç”±å¤–部页面注入的「小批量同步下载」实现,避免本模块依赖具体业务页 */
export interface StartOptions {
  fileName?: string;
  /** å°æ‰¹é‡ï¼ˆä¸è¶…过分界线)时如何同步下载,由调用页提供 */
  syncDownload?: () => Promise<void>;
}
/**
 * å‘起导出
 * <p>
 * å…ˆé—®åŽç«¯è¦å¼ æ•°ï¼šä¸è¶…过分界线直接同步下载(秒出、不排队);超过则转后台任务并展示进度。
 */
async function start(
  params: MesProQrExportApi.ExportParams,
  options: StartOptions = {},
) {
  if (isActive()) {
    message.warning('已有导出任务正在进行,请等待完成或取消后再试');
    return;
  }
  resetState();
  state.bizType = params.bizType;
  state.taskName = options.fileName ?? '二维码';
  state.phase = 'counting';
  state.message = '正在统计待导出数量…';
  try {
    const count = await getQrExportCount(params);
    state.total = count.totalCount;
    if (count.totalCount === 0) {
      state.phase = 'failed';
      state.errorMsg = '没有可导出的二维码';
      state.message = '没有可导出的二维码';
      return;
    }
    if (count.mode === 'SYNC') {
      await startSync(params, count, options);
      return;
    }
    // å¤§æ‰¹é‡ï¼šè¶ç”¨æˆ·æ‰‹åŠ¿ä»æœ‰æ•ˆå…ˆå–ä¿å­˜å¥æŸ„ï¼Œäº§ç‰©å¯ç›´æŽ¥æµå¼è½ç›˜
    saveHandle = await tryPickSaveFile(`${state.taskName}.zip`);
    await startAsync(params, count);
  } catch (error: any) {
    failWith(error);
  }
}
/** å°æ‰¹é‡ï¼šæ²¿ç”¨åŽŸæœ‰åŒæ­¥ä¸‹è½½æŽ¥å£ */
async function startSync(
  _params: MesProQrExportApi.ExportParams,
  count: MesProQrExportApi.ExportCount,
  options: StartOptions,
) {
  state.phase = 'sync';
  state.message = `共 ${count.totalCount} å¼ ï¼Œæ­£åœ¨ç”Ÿæˆâ€¦`;
  if (!options.syncDownload) {
    state.phase = 'failed';
    state.errorMsg = '缺少同步下载实现';
    return;
  }
  await options.syncDownload();
  state.phase = 'done';
  state.progress = 100;
  state.finished = count.totalCount;
  state.message = `已导出 ${count.totalCount} å¼ `;
}
/** å¤§æ‰¹é‡ï¼šæäº¤åŽå°ä»»åŠ¡å¹¶è½®è¯¢è¿›åº¦ */
async function startAsync(
  params: MesProQrExportApi.ExportParams,
  count: MesProQrExportApi.ExportCount,
) {
  state.phase = 'generating';
  state.message = `共 ${count.totalCount} å¼ ï¼Œæ­£åœ¨åŽå°ç”Ÿæˆâ€¦`;
  const task = await submitQrExportTask(params);
  state.taskId = task.id;
  state.taskName = task.taskName ?? state.taskName;
  state.total = task.totalCount ?? count.totalCount;
  state.finished = task.finishedCount ?? 0;
  state.progress = task.progress ?? 0;
  pollTask();
}
function pollTask() {
  stopPolling();
  pollTimer = setInterval(() => {
    void refreshTask();
  }, 1000);
}
async function refreshTask() {
  if (!state.taskId) return;
  try {
    const task = await getQrExportTask(state.taskId);
    state.total = task.totalCount ?? 0;
    state.finished = task.finishedCount ?? 0;
    state.progress = task.progress ?? 0;
    state.taskName = task.taskName ?? state.taskName;
    if (task.status === 30) {
      // å¾…下载
      stopPolling();
      state.progress = 100;
      await downloadProduct(task);
      return;
    }
    if (task.status === 40) {
      stopPolling();
      state.phase = 'canceled';
      state.message = '导出已取消';
      return;
    }
    if (task.status === 50) {
      stopPolling();
      state.phase = 'failed';
      state.errorMsg = task.errorMsg ?? '导出失败';
      state.message = '导出失败';
      return;
    }
    state.phase = 'generating';
    state.message = `正在生成(${state.finished} / ${state.total})…`;
  } catch (error: any) {
    stopPolling();
    failWith(error);
  }
}
/** ä¸‹è½½äº§ç‰©ï¼šä¼˜å…ˆæµå¼å†™å…¥ç£ç›˜ï¼Œé¿å…æ•°ç™¾ MB çš„内容整块驻留内存 */
async function downloadProduct(task: MesProQrExportApi.ExportTask) {
  state.phase = 'downloading';
  state.message = '正在下载…';
  state.downloadLoaded = 0;
  state.downloadTotal = task.fileSize ?? 0;
  const fileName = `${task.taskName ?? '二维码导出'}.zip`;
  const url = `${apiURL}${getQrExportDownloadPath(task.id)}`;
  const token = useAccessStore().accessToken;
  const controller = new AbortController();
  let canceled = false;
  let writable: any = null;
  downloadAbort = () => {
    canceled = true;
    controller.abort();
  };
  try {
    const response = await fetch(url, {
      headers: token ? { Authorization: `Bearer ${token}` } : {},
      signal: controller.signal,
    });
    if (!response.ok || !response.body) {
      throw new Error(`下载失败(HTTP ${response.status})`);
    }
    const contentLength = Number(response.headers.get('content-length') ?? 0);
    if (contentLength > 0) {
      state.downloadTotal = contentLength;
    }
    const reader = response.body.getReader();
    downloadAbort = () => {
      canceled = true;
      void reader.cancel();
    };
    const useDisk = Boolean(saveHandle);
    if (useDisk) {
      writable = await saveHandle.createWritable();
    }
    // è¾¹æ”¶è¾¹å†™ï¼šè¿›åº¦æ¥è‡ªçœŸå®žå­—节数;能写盘就只占一个分片的内存
    const chunks: Uint8Array[] = [];
    let loaded = 0;
    for (;;) {
      const { done, value } = await reader.read();
      if (done) break;
      if (value) {
        if (useDisk) {
          await writable.write(value);
        } else {
          chunks.push(value);
        }
        loaded += value.length;
        state.downloadLoaded = loaded;
      }
    }
    if (canceled) {
      if (useDisk) await writable.abort();
      return;
    }
    if (useDisk) {
      await writable.close();
    } else {
      const blob = new Blob(chunks as BlobPart[], { type: 'application/zip' });
      downloadFileFromBlobPart({ fileName, source: blob });
    }
    state.phase = 'done';
    state.message = `已导出 ${state.total} å¼ `;
  } catch (error: any) {
    if (canceled) return;
    failWith(error);
  } finally {
    downloadAbort = null;
    saveHandle = null;
  }
}
/** å–消:生成中取消任务,下载中中断下载 */
async function cancel() {
  if (state.phase === 'downloading') {
    downloadAbort?.();
    state.phase = 'canceled';
    state.message = '已取消下载(服务端文件会被自动清理)';
    return;
  }
  if (state.taskId && ['counting', 'generating'].includes(state.phase)) {
    try {
      await cancelQrExportTask(state.taskId);
    } catch {
      // å–消失败不阻断界面收尾:服务端任务的超时回收机制会兜底
    }
    stopPolling();
    state.phase = 'canceled';
    state.message = '已取消导出';
  }
}
/** æ”¶èµ·ä¸ºæµ®æ ‡ï¼Œä»»åŠ¡ç»§ç»­åœ¨åŽå°è·‘ */
function minimize() {
  state.minimized = true;
}
/** ä»Žæµ®æ ‡å±•开面板 */
function expand() {
  state.minimized = false;
}
/** å…³é—­é¢æ¿å¹¶æ¸…空状态(进行中需先取消) */
function close() {
  stopPolling();
  downloadAbort?.();
  downloadAbort = null;
  saveHandle = null;
  resetState();
}
function failWith(error: any) {
  state.phase = 'failed';
  state.errorMsg = error?.message ?? error?.msg ?? '导出失败';
  state.message = '导出失败';
}
export function useQrExport() {
  return {
    state,
    isActive,
    start,
    cancel,
    minimize,
    expand,
    close,
  };
}
src/views/mes/qc/seedQuality/index.vue
@@ -6,9 +6,27 @@
import { MesWmWarehouseTypeEnum } from '@vben/constants';
import { gsap } from 'gsap';
import type { SeedQualityScan } from '#/api/mes/qc/seedQuality';
import type { MesWmWarehouseApi } from '#/api/mes/wm/warehouse';
import { submitSeedQuality } from '#/api/mes/qc/seedQuality';
import {
  resolveSeedQuality,
  submitSeedQualityGroup,
} from '#/api/mes/qc/seedQuality';
import { getWarehouseSimpleList } from '#/api/mes/wm/warehouse';
/** æ‰«ç ç±»åž‹ï¼šè¢‹ç  / æ‰˜ç›˜ç  / æ‰¹æ¬¡ç ï¼ˆä¸Žç”Ÿäº§çº¿æ‰«ç å…¥åº“保持一致) */
type ScanType = 'BAG' | 'PALLET' | 'BATCH';
const SCAN_TYPE_LABEL: Record<ScanType, string> = {
  BAG: '袋码',
  PALLET: '托盘码',
  BATCH: '批次码',
};
const SCAN_TYPE_OPTIONS = [
  { label: '袋码', value: 'BAG' },
  { label: '托盘码', value: 'PALLET' },
  { label: '批次码', value: 'BATCH' },
] as const;
interface IndicatorForm {
  code: string;
@@ -20,11 +38,15 @@
}
const pageRoot = ref<HTMLElement>();
const scanType = ref<ScanType>('BAG');
const code = ref('');
const batchCode = ref('');
/** å·²è¯†åˆ«çš„质检对象:带出有效袋数、已检袋数与本次待检袋清单 */
const target = ref<SeedQualityScan>();
const reason = ref('');
const resolving = ref(false);
const loading = ref(false);
const submittedId = ref<number>();
/** æœ€è¿‘一次成功提交的袋数(用于结果反馈) */
const submittedCount = ref<number>();
let animationContext: gsap.Context | undefined;
/** å¯é€‰åˆæ ¼ä»“库(仓库类型=20 åˆæ ¼åº“) */
@@ -38,10 +60,19 @@
  { code: 'moisture', label: '水分', unit: '%', minValue: 0, maxValue: 100 },
]);
const pendingCount = computed(() => target.value?.pendingBagIds?.length ?? 0);
const completedCount = computed(() => target.value?.completedBagIds?.length ?? 0);
const enteredCount = computed(() => indicators.value.filter((item) => item.value !== undefined && item.value !== null).length);
const passedCount = computed(() => indicators.value.filter(isPassed).length);
const allEntered = computed(() => enteredCount.value === indicators.value.length);
const overallPassed = computed(() => allEntered.value && passedCount.value === indicators.value.length);
const isGroupScan = computed(() => scanType.value !== 'BAG');
const qualifiedWarehouseOptions = computed(() =>
  qualifiedWarehouses.value.map((item) => ({
    label: item.code ? `${item.name}(${item.code})` : (item.name ?? ''),
    value: item.id ?? 0,
  })),
);
function isPassed(item: IndicatorForm) {
  return item.value !== undefined && item.value !== null
@@ -49,11 +80,23 @@
    && item.value >= item.minValue && item.value <= item.maxValue;
}
function resetForm(clearResult = true) {
  code.value = '';
  batchCode.value = '';
/** ç»Ÿä¸€æå–后端业务错误信息 */
function errorText(error: unknown) {
  const response = error as {
    message?: string;
    msg?: string;
    response?: { data?: { message?: string; msg?: string } };
  };
  return response.response?.data?.message
    ?? response.response?.data?.msg
    ?? response.message
    ?? response.msg
    ?? '操作失败,请稍后重试';
}
/** æ¸…空指标与不合格原因 */
function resetIndicators() {
  reason.value = '';
  if (clearResult) submittedId.value = undefined;
  indicators.value.forEach((item) => {
    item.value = undefined;
    item.minValue = 0;
@@ -61,21 +104,74 @@
  });
}
/** åˆ‡æ¢æ‰«ç ç±»åž‹ï¼šç å€¼ä¸Žå·²è¯†åˆ«å¯¹è±¡åŒæ—¶å¤±æ•ˆï¼Œé¿å…ç”¨é”™ç±»åž‹çš„码值提交 */
function onScanTypeChange() {
  code.value = '';
  target.value = undefined;
  submittedCount.value = undefined;
  resetIndicators();
}
function resetForm() {
  code.value = '';
  target.value = undefined;
  submittedCount.value = undefined;
  resetIndicators();
}
/** è¯†åˆ«ç å€¼ï¼šç¡®å®šæœ¬æ¬¡è´¨æ£€èŒƒå›´ï¼ˆè¢‹ç  1 è¢‹ / æ‰˜ç›˜å·²è£…袋 / æ•´æ‰¹è¢‹æ•°ï¼‰ */
async function identify() {
  const value = code.value.trim();
  if (!value) {
    message.warning(`请输入或扫描${SCAN_TYPE_LABEL[scanType.value]}`);
    return;
  }
  resolving.value = true;
  submittedCount.value = undefined;
  try {
    const data = await resolveSeedQuality(value, scanType.value);
    if (data.canceled) {
      target.value = undefined;
      message.error('该质检对象已作废,不能质检');
      return;
    }
    if (!data.inbound) {
      target.value = undefined;
      message.error('该对象的有效袋码尚未全部入库,暂不能质检');
      return;
    }
    if (!data.pendingBagIds?.length) {
      target.value = undefined;
      message.error('该对象没有待质检袋码,请在质检记录中查看已有结果');
      return;
    }
    target.value = data;
    resetIndicators();
    message.success(`${SCAN_TYPE_LABEL[scanType.value]} ${data.code} å·²è¯†åˆ«ï¼Œæœ¬æ¬¡å¾…检 ${data.pendingBagIds.length} è¢‹`);
  } catch (error) {
    target.value = undefined;
    message.error(errorText(error));
  } finally {
    resolving.value = false;
  }
}
async function submit() {
  if (!code.value.trim()) {
    message.warning('请填写袋码');
  const current = target.value;
  if (!current) {
    message.warning(`请先识别${SCAN_TYPE_LABEL[scanType.value]}`);
    return;
  }
  if (!allEntered.value) {
    message.warning('请先填写四项指标的实测值');
    return;
  }
  if (!qualifiedWarehouseId.value) {
    message.warning('请选择入库合格库');
    return;
  }
  if (indicators.value.some((item) => item.minValue === undefined || item.maxValue === undefined || item.minValue > item.maxValue)) {
    message.warning('指标下限不能大于上限');
    return;
  }
  if (!qualifiedWarehouseId.value) {
    message.warning('请选择入库合格库');
    return;
  }
  if (!overallPassed.value && !reason.value.trim()) {
@@ -83,43 +179,53 @@
    return;
  }
  if (!overallPassed.value) {
  const count = current.pendingBagIds?.length ?? 0;
  const scopeNote = isGroupScan.value
    ? `指标与检验结论将应用于本次全部 ${count} è¢‹ï¼Œè¯·ç¡®è®¤æŠ½æ ·ç»“果适用于该范围。`
    : '';
    const confirmed = await new Promise<boolean>((resolve) => {
      Modal.confirm({
        title: '确认提交不合格结果?',
        content: '不合格品将由后端从暂存库存中清除,并保留不合格原因用于质量分析。',
      title: overallPassed.value ? '确认提交合格结果?' : '确认提交不合格结果?',
      content: overallPassed.value
        ? `本次 ${count} è¢‹å°†ç»Ÿä¸€åˆ¤å®šåˆæ ¼ï¼Œå¹¶ä»Žæš‚存库存转入所选合格库。${scopeNote}`
        : `本次 ${count} è¢‹å°†ä»Žæš‚存库存中清除并标记不合格,同时保留不合格原因用于质量分析。${scopeNote}`,
        okText: '确认提交',
        cancelText: '返回修改',
        okButtonProps: { danger: true },
      okButtonProps: { danger: !overallPassed.value },
        onOk: () => resolve(true),
        onCancel: () => resolve(false),
      });
    });
    if (!confirmed) return;
  }
  loading.value = true;
  const passed = overallPassed.value;
  try {
    submittedId.value = await submitSeedQuality({
      code: code.value.trim(),
      batchCode: batchCode.value.trim() || undefined,
    const result = await submitSeedQualityGroup({
      code: current.code,
      batchCode: current.batchCode || undefined,
      scanType: scanType.value,
      bagIds: current.pendingBagIds ?? [],
      qualifiedWarehouseId: qualifiedWarehouseId.value,
      reason: reason.value.trim() || undefined,
      indicators: indicators.value.map(({ code: indicatorCode, value, minValue, maxValue }) => ({
        code: indicatorCode,
        value: value as number,
        minValue: minValue as number,
        maxValue: maxValue as number,
      })),
      reason: reason.value.trim() || undefined,
      qualifiedWarehouseId: qualifiedWarehouseId.value,
    });
    message.success(`质检提交成功,质检编号:${submittedId.value}`);
    submittedCount.value = result.processedCount;
    message.success(`本次 ${result.processedCount} è¢‹å·²æäº¤ï¼š${passed ? '质检合格,已转入所选合格库' : '质检不合格,已记录原因并移除暂存库存'}`);
    if (pageRoot.value && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
      gsap.fromTo('.result-card', { scale: 0.97, autoAlpha: 0.5 }, { scale: 1, autoAlpha: 1, duration: 0.35, ease: 'power2.out' });
    }
    resetForm(false);
    // æ£€éªŒèŒƒå›´å·²æ¶ˆè´¹ï¼Œéœ€é‡æ–°è¯†åˆ«åŽæ‰èƒ½æäº¤ä¸‹ä¸€æ‰¹ï¼Œé¿å…è¯¯æŠŠä¸Šä¸€æ‰¹çš„æ¸…单再次提交
    code.value = '';
    target.value = undefined;
    resetIndicators();
  } catch (error) {
    const response = error as { message?: string; msg?: string };
    message.error(response.message ?? response.msg ?? '质检提交失败,请检查表单后重试');
    message.error(errorText(error));
  } finally {
    loading.value = false;
  }
@@ -129,7 +235,7 @@
  try {
    const warehouses = await getWarehouseSimpleList();
    qualifiedWarehouses.value = (warehouses ?? []).filter(
      (item) => item.type === MesWmWarehouseTypeEnum.QUALIFIED,
      (item) => item.type === MesWmWarehouseTypeEnum.QUALIFIED && !item.frozen,
    );
  } catch {
    qualifiedWarehouses.value = [];
@@ -157,14 +263,14 @@
      <div>
        <div class="eyebrow">MES / è´¨é‡ç®¡ç†</div>
        <h1>种子质检</h1>
        <p>录入纯度、净度、发芽率和水分,系统根据标准范围自动判定检验结论</p>
        <p>选择袋码、托盘码或批次码识别待检范围,录入纯度、净度、发芽率和水分,系统自动判定检验结论</p>
      </div>
      <div class="header-badge"><IconifyIcon icon="ep:checked" /> å››é¡¹æŒ‡æ ‡æ£€éªŒ</div>
      <div class="header-badge"><IconifyIcon icon="ep:checked" /> è¢‹ç  / æ‰˜ç›˜ç  / æ‰¹æ¬¡ç </div>
    </header>
    <section class="summary-grid" aria-label="质检摘要">
      <div class="summary-card blue"><IconifyIcon icon="ep:document-checked" /><div><span>检验项目</span><strong>4</strong><small>纯度、净度、发芽率、水分</small></div></div>
      <div class="summary-card green"><IconifyIcon icon="ep:success-filled" /><div><span>当前合格项</span><strong>{{ passedCount }} / 4</strong><small>实时依据上下限判断</small></div></div>
      <div class="summary-card" :class="target ? 'green' : 'blue'"><IconifyIcon icon="ep:box" /><div><span>本次待检</span><strong>{{ target ? `${pendingCount} è¢‹` : '待识别' }}</strong><small>{{ target ? `${SCAN_TYPE_LABEL[scanType]} ${target.code}` : `请选择类型后识别${SCAN_TYPE_LABEL[scanType]}` }}</small></div></div>
      <div class="summary-card" :class="overallPassed ? 'green' : allEntered ? 'red' : 'blue'"><IconifyIcon :icon="overallPassed ? 'ep:circle-check-filled' : allEntered ? 'ep:warning-filled' : 'ep:edit-pen'" /><div><span>总体结论</span><strong>{{ overallPassed ? '合格' : allEntered ? '不合格' : '待录入' }}</strong><small>{{ overallPassed ? '可提交合格结果' : allEntered ? '请填写不合格原因' : `已录入 ${enteredCount} / 4 é¡¹` }}</small></div></div>
    </section>
@@ -172,19 +278,43 @@
      <a-card class="form-card" :bordered="false">
        <template #title><div class="card-title"><IconifyIcon icon="ep:edit-pen" />质检信息</div></template>
        <a-form layout="vertical">
          <a-form-item label="扫码类型" required>
            <div class="scan-row">
              <a-select v-model:value="scanType" class="scan-type" size="large" :options="SCAN_TYPE_OPTIONS" :disabled="loading" @change="onScanTypeChange" />
              <a-input-search v-model:value="code" class="scan-input" size="large" :disabled="loading" :loading="resolving" enter-button="识别" :placeholder="`请输入或扫描${SCAN_TYPE_LABEL[scanType]}`" @search="identify" />
            </div>
            <div class="scan-hint"><IconifyIcon icon="ep:info-filled" /> è¢‹ç æŒ‰ 1 è¢‹ã€æ‰˜ç›˜ç æŒ‰å·²è£…袋数、批次码按批次袋数确定本次待检范围,已检与作废袋码不重复处理</div>
          </a-form-item>
          <div v-if="target" class="target-panel">
            <div class="target-head">
              <span class="target-type">{{ SCAN_TYPE_LABEL[scanType] }}已识别</span>
              <span class="target-code">{{ target.code }}</span>
              <a-tag v-if="target.businessCode" color="blue">业务码 {{ target.businessCode }}</a-tag>
              <a-tag v-if="target.batchCode" color="geekblue">批次 {{ target.batchCode }}</a-tag>
            </div>
            <div class="target-stats">
              <div><span>有效袋数</span><strong>{{ target.totalCount ?? 0 }}</strong></div>
              <div><span>已检袋数</span><strong>{{ completedCount }}</strong></div>
              <div class="highlight"><span>本次待检</span><strong>{{ pendingCount }} è¢‹</strong></div>
            </div>
            <p v-if="isGroupScan" class="target-warning">以下指标与检验结论将应用于本次全部 {{ pendingCount }} è¢‹ï¼Œè¯·ç¡®è®¤æŠ½æ ·ç»“果适用于该范围。</p>
          </div>
          <a-alert v-else class="target-empty" type="info" show-icon message="尚未识别质检对象" description="先选择扫码类型并识别码值,系统会带出该对象的有效袋数、已检袋数与本次待检袋数。" />
          <div class="base-grid">
            <a-form-item label="袋码" required><a-input v-model:value="code" size="large" :disabled="loading" placeholder="请输入或扫描袋码" /></a-form-item>
            <a-form-item label="生产批次"><a-input v-model:value="batchCode" size="large" :disabled="loading" placeholder="请输入批次号(选填)" /></a-form-item>
            <a-form-item label="入库合格库" required class="full-width">
            <a-form-item label="生产批次"><a-input :value="target?.batchCode ?? ''" size="large" disabled placeholder="识别后自动带出" /></a-form-item>
            <a-form-item label="入库合格库" required>
              <a-select
                v-model:value="qualifiedWarehouseId"
                size="large"
                :disabled="loading"
                :options="qualifiedWarehouseOptions"
                placeholder="请选择质检合格后入库的合格库"
                :options="qualifiedWarehouses.map((item) => ({ label: `${item.name}(${item.code})`, value: item.id }))"
              />
            </a-form-item>
          </div>
          <p v-if="!qualifiedWarehouses.length" class="target-warning">暂无可用合格库,请联系管理员配置后再提交质检。</p>
          <a-divider orientation="left">指标结果</a-divider>
          <div class="indicator-list">
@@ -200,16 +330,16 @@
          </div>
          <a-form-item label="不合格原因" :required="!overallPassed" class="reason-item"><a-textarea v-model:value="reason" :disabled="loading" :rows="3" :maxlength="500" show-count :placeholder="overallPassed ? '合格结果无需填写' : '请说明不合格原因'" /></a-form-item>
          <div class="form-actions"><a-button :disabled="loading" @click="resetForm">重置</a-button><a-button type="primary" size="large" :loading="loading" @click="submit"><IconifyIcon icon="ep:upload-filled" />提交质检</a-button></div>
          <div class="form-actions"><a-button :disabled="loading" @click="resetForm">重置</a-button><a-button type="primary" size="large" :loading="loading" :disabled="!target" @click="submit"><IconifyIcon icon="ep:upload-filled" />提交质检</a-button></div>
        </a-form>
      </a-card>
      <a-card class="result-card" :bordered="false">
        <template #title><div class="card-title"><IconifyIcon icon="ep:data-analysis" />判定说明</div></template>
        <div class="result-hero" :class="overallPassed ? 'passed' : allEntered ? 'failed-result' : 'pending-result'"><IconifyIcon :icon="overallPassed ? 'ep:circle-check-filled' : allEntered ? 'ep:warning-filled' : 'ep:edit-pen'" /><strong>{{ overallPassed ? '当前结果合格' : allEntered ? '当前存在不合格项' : '等待录入指标' }}</strong><span>{{ allEntered ? '实测值需处于最小值与最大值之间' : `已录入 ${enteredCount} / 4 é¡¹å®žæµ‹å€¼` }}</span></div>
        <div class="rule-list"><div><IconifyIcon icon="ep:check" /><span>四项指标必须全部提交</span></div><div><IconifyIcon icon="ep:check" /><span>边界值按合格处理</span></div><div><IconifyIcon icon="ep:check" /><span>不合格结果需保留原因</span></div><div><IconifyIcon icon="ep:check" /><span>质检员与检验时间由服务端记录</span></div></div>
        <a-alert message="库存流转由后端处理" description="合格品将按所选「入库合格库」从暂存库存入该合格库;不合格品将直接清除暂存库存并保留不合格原因。质检员和检验时间由服务端记录。" type="info" show-icon />
        <div v-if="submittedId" class="success-note"><IconifyIcon icon="ep:success-filled" /><span>最近提交质检编号:<strong>{{ submittedId }}</strong></span></div>
        <div class="rule-list"><div><IconifyIcon icon="ep:check" /><span>先识别码值再录入指标,四项指标必须全部提交</span></div><div><IconifyIcon icon="ep:check" /><span>边界值按合格处理</span></div><div><IconifyIcon icon="ep:check" /><span>托盘码、批次码的结论应用于本次全部待检袋</span></div><div><IconifyIcon icon="ep:check" /><span>不合格结果需保留原因</span></div><div><IconifyIcon icon="ep:check" /><span>质检员与检验时间由服务端记录</span></div></div>
        <a-alert message="库存流转由后端处理" description="合格袋将从暂存库存转入所选合格库;不合格袋将直接清除暂存库存并保留不合格原因。提交时服务端会重新核对本次待检袋清单,清单变化需重新识别。" type="info" show-icon />
        <div v-if="submittedCount" class="success-note"><IconifyIcon icon="ep:success-filled" /><span>最近提交质检:<strong>{{ submittedCount }} è¢‹</strong></span></div>
      </a-card>
    </section>
  </div>
@@ -218,9 +348,11 @@
<style scoped>
.quality-page { min-height: 100%; padding: 24px; background: #f4f7fb; color: #172033; }
.page-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 22px; }.eyebrow { color: #1677ff; font-size: 12px; font-weight: 700; letter-spacing: .08em; }.page-header h1 { margin: 6px 0; font-size: 28px; }.page-header p { margin: 0; color: #718096; }.header-badge { display: inline-flex; align-items: center; gap: 8px; padding: 9px 13px; border: 1px solid #cfe0fb; border-radius: 7px; color: #1677ff; background: #f0f6ff; font-size: 13px; font-weight: 600; }
.summary-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 18px; }.summary-card { display: flex; align-items: flex-start; gap: 14px; padding: 17px 19px; border: 1px solid #e8edf5; border-radius: 10px; background: #fff; box-shadow: 0 3px 12px #243b5310; }.summary-card > .iconify { flex: 0 0 38px; width: 38px; height: 38px; padding: 9px; border-radius: 8px; font-size: 20px; }.summary-card.blue > .iconify { color: #1677ff; background: #eaf3ff; }.summary-card.green > .iconify { color: #17a673; background: #e6f8f0; }.summary-card.red > .iconify { color: #e05252; background: #fff0f0; }.summary-card span, .summary-card small { display: block; color: #7b8799; font-size: 12px; }.summary-card strong { display: block; margin: 4px 0; font-size: 23px; }.content-grid { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(310px, .75fr); gap: 18px; }.form-card, .result-card { border-radius: 10px; box-shadow: 0 3px 12px #243b5310; }.card-title { display: flex; align-items: center; gap: 9px; font-weight: 700; }.card-title :deep(.iconify) { color: #1677ff; }.base-grid { display: grid; grid-template-columns: 1.3fr 1.2fr; gap: 16px; }.full-width { width: 100%; }
.summary-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 18px; }.summary-card { display: flex; align-items: flex-start; gap: 14px; padding: 17px 19px; border: 1px solid #e8edf5; border-radius: 10px; background: #fff; box-shadow: 0 3px 12px #243b5310; }.summary-card > .iconify { flex: 0 0 38px; width: 38px; height: 38px; padding: 9px; border-radius: 8px; font-size: 20px; }.summary-card.blue > .iconify { color: #1677ff; background: #eaf3ff; }.summary-card.green > .iconify { color: #17a673; background: #e6f8f0; }.summary-card.red > .iconify { color: #e05252; background: #fff0f0; }.summary-card span, .summary-card small { display: block; color: #7b8799; font-size: 12px; }.summary-card small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.summary-card strong { display: block; margin: 4px 0; font-size: 23px; }.content-grid { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(310px, .75fr); gap: 18px; }.form-card, .result-card { border-radius: 10px; box-shadow: 0 3px 12px #243b5310; }.card-title { display: flex; align-items: center; gap: 9px; font-weight: 700; }.card-title :deep(.iconify) { color: #1677ff; }.base-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }.full-width { width: 100%; }
.scan-row { display: flex; align-items: stretch; gap: 10px; }.scan-type { flex: 0 0 140px; }.scan-input { flex: 1 1 auto; min-width: 0; }.scan-hint { display: flex; align-items: center; gap: 8px; margin-top: 10px; color: #8793a5; font-size: 12px; line-height: 1.5; }.scan-hint :deep(.iconify) { flex: 0 0 auto; color: #1677ff; }
.target-panel { margin-bottom: 18px; padding: 14px 16px; border: 1px solid #d6e8ff; border-radius: 8px; background: #f7fbff; }.target-head { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }.target-type { color: #1677ff; font-size: 13px; font-weight: 700; }.target-code { font-size: 16px; font-weight: 700; letter-spacing: .02em; }.target-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-top: 12px; }.target-stats div { padding: 9px 12px; border: 1px solid #e8edf5; border-radius: 6px; background: #fff; }.target-stats span { display: block; color: #7b8799; font-size: 12px; }.target-stats strong { display: block; margin-top: 3px; font-size: 19px; }.target-stats .highlight { border-color: #b7ebd2; background: #f2fcf7; }.target-stats .highlight strong { color: #16835b; }.target-warning { margin: 12px 0 0; color: #c07a17; font-size: 12px; line-height: 1.6; }.target-empty { margin-bottom: 18px; }
.indicator-list { display: flex; flex-direction: column; gap: 10px; }.indicator-row { display: flex; align-items: center; gap: 9px; padding: 11px 12px; border: 1px solid #e8edf5; border-radius: 7px; background: #fbfcfe; }.indicator-row.failed { border-color: #ffd6d6; background: #fffafa; }.indicator-name { display: flex; align-items: center; gap: 8px; min-width: 82px; }.indicator-name small { color: #8793a5; font-size: 12px; }.indicator-dot { width: 8px; height: 8px; border-radius: 50%; }.indicator-dot.pass { background: #20b878; }.indicator-dot.fail { background: #e05252; }.indicator-input { width: 105px; }.range-input { width: 88px; }.range-separator { margin-left: auto; color: #8793a5; font-size: 12px; }.reason-item { margin-top: 18px; }.form-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 5px; }.form-actions :deep(.iconify) { margin-right: 5px; }
.result-hero { display: flex; flex-direction: column; align-items: center; gap: 8px; margin: 8px 0 22px; padding: 25px 15px; border-radius: 8px; text-align: center; }.result-hero :deep(.iconify) { font-size: 35px; }.result-hero strong { font-size: 18px; }.result-hero span { color: #718096; font-size: 12px; }.result-hero.passed { color: #16835b; background: #effaf5; }.result-hero.failed-result { color: #c53e3e; background: #fff3f3; }.result-hero.pending-result { color: #1677ff; background: #f0f6ff; }.rule-list { display: flex; flex-direction: column; gap: 14px; margin-bottom: 22px; }.rule-list div { display: flex; align-items: center; gap: 9px; color: #59677b; font-size: 13px; }.rule-list :deep(.iconify) { color: #20b878; }.success-note { display: flex; align-items: center; gap: 8px; margin-top: 16px; padding: 11px 12px; border-radius: 7px; color: #16835b; background: #effaf5; font-size: 13px; }
@media (max-width: 1050px) { .content-grid { grid-template-columns: 1fr; }.base-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 760px) { .quality-page { padding: 14px; }.page-header { display: block; }.header-badge { margin-top: 14px; }.summary-grid, .base-grid { grid-template-columns: 1fr; }.indicator-row { flex-wrap: wrap; }.range-separator { margin-left: 0; }.indicator-input { flex: 1; min-width: 105px; }.range-input { flex: 1; min-width: 75px; } }
@media (max-width: 760px) { .quality-page { padding: 14px; }.page-header { display: block; }.header-badge { margin-top: 14px; }.summary-grid, .base-grid, .target-stats { grid-template-columns: 1fr; }.scan-row { flex-wrap: wrap; }.scan-type { flex-basis: 100%; }.indicator-row { flex-wrap: wrap; }.range-separator { margin-left: 0; }.indicator-input { flex: 1; min-width: 105px; }.range-input { flex: 1; min-width: 75px; } }
</style>
vite.config.ts
@@ -76,7 +76,7 @@
        proxy: {
          "/admin-api": {
            changeOrigin: true,
            target: "http://localhost:48080",
            target: "http://127.0.0.1:48080",
            ws: true,
          },
        },