<template>
  <PageContainer title="浼氳绠＄悊">
    <MeetingQueryToolbar v-model:query-form="queryForm" @load="load" @reset-query="resetQuery" />
    <MeetingListTable
      :rows="rows"
      :summary-task-map="summaryTaskMap"
      :can-create="canCreate"
      :can-submit="canSubmit"
      :can-withdraw="canWithdraw"
      :can-delete="canDelete"
      :can-cancel="canCancel"
      :can-material-save="canMaterialSave"
      :can-material-history="canMaterialHistory"
      :can-material-export="canMaterialExport"
      :to-yuan="toYuan"
      :status-formatter="statusFormatter"
      :audit-node-formatter="auditNodeFormatter"
      :is-meeting-audit-approved="isMeetingAuditApproved"
      @open-detail="openDetailDrawer"
      @open-audit-progress="openAuditProgress"
      @open-edit="openEditDrawer"
      @open-bind-experts="handleOpenBindExperts"
      @submit="handleSubmit"
      @withdraw="handleWithdraw"
      @delete-draft="handleDeleteDraft"
      @cancel="handleCancelMeeting"
      @open-material="openMaterial"
      @upload-labor-agreement="handleUploadLaborAgreementEntry"
        @open-history="openHistory"
        @export-materials="handleExportMaterials"
        @generate-summary="handleGenerateSummary"
        @check-summary-status="handleCheckSummaryStatus"
        @refresh-summary-token="handleRefreshSummaryToken"
        @download-summary="handleDownloadSummary"
      />
    <input
      ref="meetingLaborAgreementFileInputRef"
      type="file"
      accept=".jpg,.jpeg,.png,.bmp,.tif,.tiff,.pdf,.doc,.docx,.xlsx,.xls,.ofd"
      style="display: none"
      @change="handleMeetingLaborAgreementFilePicked"
    />
    <MeetingEditDrawer
      v-model="editDrawerVisible"
      v-model:meeting-form="meetingForm"
      v-model:meeting-form-time-range="meetingFormTimeRange"
      :meeting-category-options="meetingCategoryOptions"
      @save="handleUpdate"
    />
    <MeetingDetailDrawer
      v-model="detailDrawerVisible"
      :current-meeting-detail="currentMeetingDetail"
      :meeting-change-logs="meetingChangeLogs"
      :to-yuan="toYuan"
      :to-zh-status="toZhStatus"
      :to-zh-audit-node="toZhAuditNode"
      :can-invoice-config="canInvoiceConfig"
      :can-change-log-read="canChangeLogRead"
      @open-invoice-config="openInvoiceConfigDialog"
    />
    <MeetingMaterialDrawer
      v-model:material-dialog-visible="materialDialogVisible"
      v-model:selected-module-code="selectedModuleCode"
      v-model:expert-sub-module="expertSubModule"
      v-model:selected-expert-labor-amount-yuan="selectedExpertLaborAmountYuan"
      :material-loading="materialModuleLoading"
      :material-budget-loading="materialBudgetLoading"
      :material-dialog-title="materialDialogTitle"
      :material-review-notice="materialReviewNotice"
      :material-budget-summary="materialBudgetSummary"
      :basic-form="basicForm"
      :bound-expert-rows="boundExpertRows"
      :is-review-approved="isReviewApproved"
      :to-review-tag-type="toReviewTagType"
      :to-review-result-text="toReviewResultText"
      :to-review-reason-text="toReviewReasonText"
      :agenda-upload-file-list="agendaUploadFileList"
      :before-agenda-upload="beforeAgendaUpload"
      :handle-agenda-picture-change="handleAgendaPictureChange"
      :handle-agenda-picture-preview="handleAgendaPicturePreview"
      :handle-agenda-picture-remove="handleAgendaPictureRemove"
      :agenda-doc-rows="agendaDocRows"
      :sign-in-upload-file-list="signInUploadFileList"
      :before-sign-in-upload="beforeSignInUpload"
      :handle-sign-in-picture-change="handleSignInPictureChange"
      :handle-sign-in-picture-preview="handleSignInPicturePreview"
      :handle-sign-in-picture-remove="handleSignInPictureRemove"
      :theme-photo-upload-file-list="themePhotoUploadFileList"
      :before-theme-photo-upload="beforeThemePhotoUpload"
      :handle-theme-photo-picture-change="handleThemePhotoPictureChange"
      :handle-theme-photo-picture-preview="handleThemePhotoPicturePreview"
      :handle-theme-photo-picture-remove="handleThemePhotoPictureRemove"
      :invitation-upload-file-list="invitationUploadFileList"
      :before-invitation-upload="beforeInvitationUpload"
      :handle-invitation-picture-change="handleInvitationPictureChange"
      :handle-invitation-picture-preview="handleInvitationPicturePreview"
      :handle-invitation-picture-remove="handleInvitationPictureRemove"
      :invitation-doc-rows="invitationDocRows"
      :expert-profile-upload-file-list="expertProfileUploadFileList"
      :before-expert-profile-upload="beforeExpertProfileUpload"
      :handle-expert-profile-file-change="handleExpertProfileFileChange"
      :handle-expert-profile-file-preview="handleExpertProfileFilePreview"
      :handle-expert-profile-file-remove="handleExpertProfileFileRemove"
      :selected-expert-material-id="selectedExpertMaterialId"
      :handle-select-expert-for-material="handleSelectExpertForMaterial"
      :expert-module-done-text="expertModuleDoneText"
      :before-photo-upload="beforePhotoUpload"
      :on-photo-file-change="onPhotoFileChange"
      :photo-form="photoForm"
      :photo-summary-by-expert="photoSummaryByExpert"
      :selected-expert-photos="selectedExpertPhotos"
      :build-photo-item-key="buildPhotoItemKey"
      :handle-view-oss="handleViewOss"
      :preview-oss-document="openDocPicturePreview"
      :remove-selected-expert-photo="removeSelectedExpertPhoto"
      :before-labor-protocol-upload="beforeLaborProtocolUpload"
      :on-labor-protocol-file-change="onLaborProtocolFileChange"
      :labor-protocol-upload-file-list="laborProtocolUploadFileList"
      :handle-labor-protocol-file-remove="handleLaborProtocolFileRemove"
      :selected-expert-labor="selectedExpertLabor"
      :before-labor-invoice-upload="beforeLaborInvoiceUpload"
      :on-labor-invoice-file-change="onLaborInvoiceFileChange"
      :labor-invoice-upload-file-list="laborInvoiceUploadFileList"
      :handle-labor-invoice-file-remove="handleLaborInvoiceFileRemove"
      :current-labor-review-key="currentLaborReviewKey"
      :meeting-invoice-section-defs="filteredMeetingInvoiceSectionDefs"
      :meeting-invoice-other-upload-file-list="meetingInvoiceOtherUploadFileList"
      :before-meeting-invoice-upload="beforeMeetingInvoiceUpload"
      :on-meeting-invoice-other-file-change="onMeetingInvoiceOtherFileChange"
      :handle-meeting-invoice-section-picture-preview="handleMeetingInvoiceSectionPicturePreview"
      :handle-meeting-invoice-other-picture-remove="handleMeetingInvoiceOtherPictureRemove"
      :get-meeting-invoice-other-file-field-key="getMeetingInvoiceOtherFileFieldKey"
      :build-meeting-invoice-other-review-item-key="buildMeetingInvoiceOtherReviewItemKey"
      :get-meeting-invoice-section-upload-file-list="meetingInvoiceUiFns.getMeetingInvoiceSectionUploadFileList"
      :build-meeting-invoice-upload-change-handler="meetingInvoiceUiFns.buildMeetingInvoiceUploadChangeHandler"
      :build-meeting-invoice-picture-remove-handler="meetingInvoiceUiFns.buildMeetingInvoicePictureRemoveHandler"
      :get-meeting-invoice-file-row="meetingInvoiceUiFns.getMeetingInvoiceFileRow"
      :open-ocr-raw-dialog="meetingInvoiceUiFns.openOcrRawDialog"
      :run-meeting-invoice-ocr="meetingInvoiceUiFns.runMeetingInvoiceOcr"
      :meeting-invoice-form="meetingInvoiceForm"
      :build-meeting-invoice-field-item-key="meetingInvoiceUiFns.buildMeetingInvoiceFieldItemKey"
      :build-meeting-invoice-amount-item-key="meetingInvoiceUiFns.buildMeetingInvoiceAmountItemKey"
      :to-yuan="toYuan"
      :material-max-file-size-mb="MATERIAL_MAX_FILE_SIZE_MB"
      :material-readonly="selectedMeetingMaterialReadonly"
      :can-material-save="canMaterialSave && !selectedMeetingMaterialReadonly"
      :can-material-submit="canMaterialSubmit"
      :check-form-changed="checkMaterialFormChanged"
      :do-save-material="handleSaveMaterial"
      @material-tab-change="handleMaterialTabChange"
      @save-material="handleSaveMaterial"
      @submit-material="handleSubmitMaterial"
    />
    <MeetingAuditProgressDialog
      v-model="auditProgressDialogVisible"
      :audit-progress-rows="auditProgressRows"
      :audit-progress-page-no="auditProgressPageNo"
      :audit-progress-page-size="auditProgressPageSize"
      :audit-progress-total="auditProgressTotal"
      :resolve-audit-time="resolveAuditTime"
      :detail-timeline-type="detailTimelineType"
      :to-zh-audit-node="toZhAuditNode"
      :to-zh-status="toZhStatus"
      :detail-status-tag-type="detailStatusTagType"
      @page-change="handleAuditProgressPageChange"
      @page-size-change="handleAuditProgressPageSizeChange"
    />
    <MeetingMaterialHistoryDialog
      v-model="historyDialogVisible"
      v-model:history-module-code="historyModuleCode"
      :history-rows="historyRows"
      :diff-rows="diffRows"
      :status-formatter="statusFormatter"
      @module-change="loadMaterialHistory"
      @compare-previous="compareWithPrevious"
    />
    <MeetingBindExpertDialog
      v-model="bindExpertVisible"
      v-model:active-tab="expertManageActiveTab"
      v-model:bind-expert-keyword="bindExpertKeyword"
      v-model:current-search-selected-platform-expert-ids="currentSearchSelectedPlatformExpertIds"
      :can-material-save="canManageMeetingExperts"
      :bound-expert-rows="boundExpertRows"
      :selected-platform-expert-ids="selectedPlatformExpertIds"
      :selected-platform-expert-name-map="selectedPlatformExpertNameMap"
      :platform-expert-searched="platformExpertSearched"
      :platform-expert-rows="platformExpertRows"
      :mask-phone="maskPhone"
      :mask-id-no="maskIdNo"
      @load-platform-experts="loadPlatformExperts"
      @remove-selected-platform-expert="removeSelectedPlatformExpert"
      @open-create-platform-expert="openCreatePlatformExpertDialog"
      @current-search-expert-select-change="handleCurrentSearchExpertSelectChange"
      @bind-experts="handleBindExpertsGuarded"
      @unbind-expert="handleUnbindExpertGuarded"
    />
    <MeetingCreatePlatformExpertDialog
      v-model="createPlatformExpertVisible"
      v-model:create-platform-expert-form="createPlatformExpertForm"
      :expert-title-options="expertTitleOptions"
      :expert-hospital-options="expertHospitalOptions"
      :create-expert-id-front-preview-url="createExpertIdFrontPreviewUrl"
      :create-expert-id-back-preview-url="createExpertIdBackPreviewUrl"
      :create-expert-card-front-preview-url="createExpertCardFrontPreviewUrl"
      @create-expert-id-front-change="onCreateExpertIdFrontChange"
      @create-expert-id-back-change="onCreateExpertIdBackChange"
      @create-expert-card-front-change="onCreateExpertCardFrontChange"
      @view-oss="handleViewOss"
      @save="handleCreatePlatformExpert"
    />
    <MeetingCreateExpertIdOcrDialog
      v-model="createExpertIdOcrDialogVisible"
      v-model:create-expert-id-ocr-data="createExpertIdOcrData"
      @cancel="cancelCreateExpertIdOcrFill"
      @confirm="confirmCreateExpertIdOcrFill"
    />
    <MeetingCreateExpertBankCardOcrDialog
      v-model="createExpertBankCardOcrDialogVisible"
      v-model:create-expert-bank-card-ocr-data="createExpertBankCardOcrData"
      @cancel="cancelCreateExpertBankCardOcrFill"
      @confirm="confirmCreateExpertBankCardOcrFill"
    />
    <MeetingDocPreviewDialog
      v-model="docPreviewDialogVisible"
      :doc-preview-dialog-image-url="docPreviewDialogImageUrl"
      :preview-kind="docPreviewDialogKind"
    />
    <MeetingOcrRawDialog
      v-model="ocrRawDialogVisible"
      v-model:ocr-raw-dialog-text="ocrRawDialogText"
      :ocr-raw-dialog-title="ocrRawDialogTitle"
    />
    <MeetingLaborInvoiceOcrConfirmDialog
      v-model="laborInvoiceOcrConfirmVisible"
      v-model:labor-invoice-ocr-confirm-data="laborInvoiceOcrConfirmData"
      :to-yuan="toYuan"
      @cancel="cancelLaborInvoiceOcrFill"
      @confirm="confirmLaborInvoiceOcrFill"
    />
    <MeetingLaborAgreementExtractConfirmDialog
      v-model="laborAgreementExtractConfirmVisible"
      v-model:labor-agreement-extract-confirm-data="laborAgreementExtractConfirmData"
      :to-yuan="toYuan"
      :applying="laborAgreementExtractApplying"
      @cancel="cancelMeetingLaborAgreementExtractApply"
      @confirm="confirmMeetingLaborAgreementExtractApply"
    />
    <MeetingMeetingInvoiceOcrConfirmDialog
      v-model="meetingInvoiceOcrConfirmVisible"
      v-model:meeting-invoice-ocr-confirm-data="meetingInvoiceOcrConfirmData"
      :to-yuan="toYuan"
      @cancel="cancelMeetingInvoiceOcrFill"
      @confirm="confirmMeetingInvoiceOcrFill"
    />
    <MeetingInvoiceConfigDialog
      v-model:dialog-visible="invoiceConfigDialogVisible"
      :meeting-id="invoiceConfigMeetingId"
      :initial-config-json="invoiceConfigInitialJson"
      :meeting-invoice-section-defs="meetingInvoiceSectionDefs"
      @success="handleInvoiceConfigSuccess"
    />
  </PageContainer>
</template>

<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import PageContainer from "../../components/PageContainer.vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { useRoute } from "vue-router";
import { Plus } from "@element-plus/icons-vue";
import MeetingQueryToolbar from "./meeting-page/MeetingQueryToolbar.vue";
import MeetingListTable from "./meeting-page/MeetingListTable.vue";
import MeetingEditDrawer from "./meeting-page/MeetingEditDrawer.vue";
import MeetingDetailDrawer from "./meeting-page/MeetingDetailDrawer.vue";
import MeetingMaterialDrawer from "./meeting-page/MeetingMaterialDrawer.vue";
import MeetingAuditProgressDialog from "./meeting-page/MeetingAuditProgressDialog.vue";
import MeetingMaterialHistoryDialog from "./meeting-page/MeetingMaterialHistoryDialog.vue";
import MeetingBindExpertDialog from "./meeting-page/MeetingBindExpertDialog.vue";
import MeetingCreatePlatformExpertDialog from "./meeting-page/MeetingCreatePlatformExpertDialog.vue";
import MeetingCreateExpertIdOcrDialog from "./meeting-page/MeetingCreateExpertIdOcrDialog.vue";
import MeetingCreateExpertBankCardOcrDialog from "./meeting-page/MeetingCreateExpertBankCardOcrDialog.vue";
import MeetingDocPreviewDialog from "./meeting-page/MeetingDocPreviewDialog.vue";
import MeetingOcrRawDialog from "./meeting-page/MeetingOcrRawDialog.vue";
import MeetingLaborInvoiceOcrConfirmDialog from "./meeting-page/MeetingLaborInvoiceOcrConfirmDialog.vue";
import MeetingLaborAgreementExtractConfirmDialog from "./meeting-page/MeetingLaborAgreementExtractConfirmDialog.vue";
import MeetingMeetingInvoiceOcrConfirmDialog from "./meeting-page/MeetingMeetingInvoiceOcrConfirmDialog.vue";
import MeetingInvoiceConfigDialog from "./meeting-page/MeetingInvoiceConfigDialog.vue";
import type { UploadProps, UploadUserFile } from "element-plus";
import SettlementSampleImage from "../../static/会议结算单.png";
import VenueSampleImage from "../../static/会场合同.png";
import VenueAgreementSampleImage from "../../static/会场发票.png";
import ConstructionSampleImage from "../../static/设备.png";
import CateringSampleImage from "../../static/餐饮发票.png";
import LocalTransportSampleImage from "../../static/小交通发票.png";
import LocalTransportSampleImage2 from "../../static/小交通发票.jpg";
import LocalTransportSampleImage3 from "../../static/小交通明细.png";
import IntercityTransportSampleImage from "../../static/大交通发票.png";
import IntercityTransportSampleImage2 from "../../static/大交通发票2.png";
import IntercityTransportSampleImage3 from "../../static/大交通明细单.png";
import IntercityTransportSampleImage4 from "../../static/大交通明细2.png";
import MaterialSampleImage from "../../static/物料明细.png";
import MaterialSampleImage2 from "../../static/物料.png";
import MaterialSampleImage3 from "../../static/物料2.png";
import MaterialSampleImage4 from "../../static/物料3.png";
import MaterialSampleImage5 from "../../static/物料4.png";
import MaterialSampleImage6 from "../../static/桌牌.png";
import DesignDraftSampleImage from "../../static/设计稿.png";
import EquipmentSampleImage2 from "../../static/设备2.png";
import {
  addMeetingTenantExpertBankCard,
  applyMeetingLaborAgreementExtract,
  bindMeetingExperts,
  createMeetingPlatformExpert,
  createMeetingMaterialsExportTask,
  fetchDictionaries,
  fetchExpertDictionaryOptions,
  fetchMeetingChangeLogs,
  fetchMeetingDetail,
  fetchMeetingExpertBindings,
  fetchFilePresignDownload,
  recognizeMultipleInvoice,
  fetchMeetingPlatformExperts,
  fetchMeetingMaterials,
  fetchMeetingMaterialCurrent,
  fetchMeetingMaterialHistory,
  fetchMeetingMaterialUploadSign,
  queryMeetingLaborAgreementExtract,
  fetchMeetings,
  fetchProjects,
  recognizeBankCard,
  recognizeIdCard,
  fetchAuditTasks,
  fetchMeetingSummaryTaskStatus,
  generateMeetingSummaryTask,
  downloadMeetingSummary,
  refreshMeetingSummaryToken,
  saveMeetingMaterial,
  submitMeetingLaborAgreementExtractTask,
  submitMeeting,
  submitMeetingMaterial,
  readAuditTaskMaterial,
  unbindMeetingExpert,
  updateMeeting,
  withdrawMeeting,
  deleteMeeting,
  cancelMeeting,
} from "../../api/modules";
import { PERMS } from "../../constants/permissions";
import { useAuthStore } from "../../stores/auth";
import { toZhAuditNode, toZhStatus } from "../../utils/status";

const rows = ref<any[]>([]);
const authStore = useAuthStore();
const canCreate = computed(() => authStore.hasPermission(PERMS.meeting.create));
const canSubmit = computed(() => authStore.hasPermission(PERMS.meeting.submit));
const canWithdraw = computed(() => authStore.hasPermission(PERMS.meeting.withdraw));
const canMaterialSave = computed(() => authStore.hasPermission(PERMS.meeting.materialSave));
const canMaterialSubmit = computed(() => authStore.hasPermission(PERMS.meeting.materialSubmit));
const canMaterialHistory = computed(() => authStore.hasPermission(PERMS.meeting.materialHistoryRead));
const canMaterialExport = computed(() => authStore.hasPermission(PERMS.meeting.materialExport));
const canInvoiceConfig = computed(() => authStore.hasPermission(PERMS.meeting.invoiceConfig));
const canChangeLogRead = computed(() => authStore.hasPermission(PERMS.meeting.changeLogRead));
const canDelete = computed(() => authStore.hasPermission(PERMS.meeting.delete));
const canCancel = computed(() => authStore.hasPermission(PERMS.meeting.cancel));
const normalizeAuditStatus = (value: unknown) => String(value || "").trim().toUpperCase();
const isMeetingAuditApproved = (row: any) => normalizeAuditStatus(row?.auditStatus) === "APPROVED";
const isMeetingMaterialLockedStatus = (value: unknown) => {
  const status = normalizeAuditStatus(value);
  return status === "IN_REVIEW" || status === "APPROVED";
};
const editDrawerVisible = ref(false);
const detailDrawerVisible = ref(false);
const currentEditMeetingId = ref<number | null>(null);
const currentEditOriginalBudgetCent = ref(0);
const currentEditAllowMeetingOverBudget = ref(false);
const currentMeetingDetail = ref<any | null>(null);
const meetingChangeLogs = ref<any[]>([]);
const queryForm = ref({
  projectId: undefined as number | undefined,
  projectName: "",
  topic: "",
  meetingStatus: "",
  auditStatus: "",
  currentAuditNode: "",
  currentAuditorUserId: undefined as number | undefined,
  meetingTimeRange: [] as string[],
  lastSubmitRange: [] as string[],
  includeDeleted: false,
});
const defaultMeetingForm = () => ({
  projectId: 1001,
  projectName: "",
  projectStartDate: "",
  projectEndDate: "",
  topic: "MVP浼氳",
  meetingCategory: "学术会",
  meetingForm: "绾夸笅",
  location: "",
  startTime: "",
  endTime: "",
  budgetYuan: 1000,
  laborRatio: 0,
  cateringRatio: 0,
});
const meetingForm = ref(defaultMeetingForm());
const meetingFormTimeRange = computed<string[]>({
  get() {
    if (meetingForm.value.startTime && meetingForm.value.endTime) {
      return [meetingForm.value.startTime, meetingForm.value.endTime];
    }
    return [];
  },
  set(value) {
    if (Array.isArray(value) && value.length === 2) {
      meetingForm.value.startTime = value[0] || "";
      meetingForm.value.endTime = value[1] || "";
      return;
    }
    meetingForm.value.startTime = "";
    meetingForm.value.endTime = "";
  },
});
const materialDialogVisible = ref(false);
const materialDialogTitle = ref("浼氳璧勬枡");
const selectedMeetingId = ref<number | null>(null);
const selectedMeetingAuditStatus = computed(() => {
  const meetingId = Number(selectedMeetingId.value || 0);
  if (!meetingId) {
    return "";
  }
  const currentDetailId = Number(currentMeetingDetail.value?.id || 0);
  if (currentDetailId === meetingId) {
    return normalizeAuditStatus(currentMeetingDetail.value?.auditStatus);
  }
  const currentRow = rows.value.find((item: any) => Number(item?.id || 0) === meetingId);
  return normalizeAuditStatus(currentRow?.auditStatus);
});
const selectedMeetingMaterialReadonly = computed(() =>
  isMeetingMaterialLockedStatus(selectedMeetingAuditStatus.value),
);
const route = useRoute();
const initialMaterialContentJson = ref("");
const materialSnapshotReady = ref(false);
const checkMaterialFormChanged = () => {
  if (!materialSnapshotReady.value || materialModuleLoading.value) {
    return false;
  }
  return initialMaterialContentJson.value !== buildContentJson();
};
const normalizeIdList = (value: unknown) =>
  (Array.isArray(value) ? value : [])
    .map((item) => Number(item || 0))
    .filter((item) => Number.isFinite(item) && item > 0)
    .sort((a, b) => a - b);
type MaterialModuleCode = "BASIC_INFO" | "WRITE_OFF_DOCS" | "EXPERT_PROFILE" | "EXPERT_LIST" | "MEETING_INVOICE";
type ExpertSubModuleCode = "ONSITE_PHOTO" | "LABOR_PROTOCOL";
type MeetingInvoiceSectionCode =
  | "SETTLEMENT_DETAIL"
  | "VENUE_CONFIRMATION"
  | "CONSTRUCTION_DETAIL"
  | "ACCOMMODATION_DETAIL"
  | "CATERING_DETAIL"
  | "LOCAL_TRANSPORT_DETAIL"
  | "INTERCITY_TRANSPORT_DETAIL"
  | "MATERIAL_DETAIL"
  | "DESIGN_DRAFT_DETAIL"
  | "OTHER";
type MeetingInvoiceUploadFieldKey =
  | "settlementFile"
  | "invoiceFile"
  | "detailFile"
  | "equipmentFile"
  | "materialFile"
  | "designDraftFile";
type MeetingInvoiceUploadFieldDef = {
  key: MeetingInvoiceUploadFieldKey;
  label: string;
};
type MeetingInvoiceSectionDef = {
  code: MeetingInvoiceSectionCode;
  order: number;
  title: string;
  needAmount: boolean;
  maxUploadCount: number;
  uploadFields: MeetingInvoiceUploadFieldDef[];
  sampleImage?: any | any[];
};
type MeetingInvoiceFileValue = {
  fileName: string;
  ossKey: string;
  ocr?: {
    status: "idle" | "running" | "pending_confirm" | "success" | "failed";
    normalized?: { invoiceCode?: string; invoiceNum?: string; name?: string; totalAmountCent?: number | null; taxCent?: number | null };
    raw?: any;
    message?: string;
    updatedAt?: string;
  };
};
type MeetingInvoiceSectionState = {
  totalAmountYuan: number | null;
  files: Record<MeetingInvoiceUploadFieldKey, MeetingInvoiceFileValue[]>;
};
type MeetingInvoiceFormState = {
  sections: Record<MeetingInvoiceSectionCode, MeetingInvoiceSectionState>;
};
const meetingInvoiceUploadFieldKeys: MeetingInvoiceUploadFieldKey[] = [
  "settlementFile",
  "invoiceFile",
  "detailFile",
  "equipmentFile",
  "materialFile",
  "designDraftFile",
];
const meetingInvoiceSectionDefs: MeetingInvoiceSectionDef[] = [
  {
    code: "SETTLEMENT_DETAIL",
    order: 1,
    title: "会议结算单明细",
    needAmount: false,
    maxUploadCount: 5,
    uploadFields: [{ key: "settlementFile", label: "" }],
    sampleImage: SettlementSampleImage,
  },
  {
    code: "VENUE_CONFIRMATION",
    order: 2,
    title: "浼氬満纭鍑?浼氬満鍗忚",
    needAmount: true,
    maxUploadCount: 5,
    uploadFields: [
      { key: "invoiceFile", label: "鍙戠エ" },
      { key: "detailFile", label: "明细单" },
    ],
    sampleImage: [VenueSampleImage, VenueAgreementSampleImage],
  },
  {
    code: "CONSTRUCTION_DETAIL",
    order: 3,
    title: "浼氳鎼缓鏄庣粏",
    needAmount: true,
    maxUploadCount: 15,
    uploadFields: [
      { key: "invoiceFile", label: "鍙戠エ" },
      { key: "detailFile", label: "明细单" },
      { key: "equipmentFile", label: "璁惧鐓х墖" },
    ],
    sampleImage: [ConstructionSampleImage, EquipmentSampleImage2]
  },
  {
    code: "ACCOMMODATION_DETAIL",
    order: 4,
    title: "浣忓鏄庣粏",
    needAmount: true,
    maxUploadCount: 50,
    uploadFields: [
      { key: "invoiceFile", label: "鍙戠エ" },
      { key: "detailFile", label: "明细单" },
    ],
  },
  {
    code: "CATERING_DETAIL",
    order: 5,
    title: "椁愰ギ鏄庣粏",
    needAmount: true,
    maxUploadCount: 50,
    uploadFields: [
      { key: "invoiceFile", label: "鍙戠エ" },
      { key: "detailFile", label: "鏄庣粏鍗?姘村崟" },
    ],
    sampleImage: CateringSampleImage,
  },
  {
    code: "LOCAL_TRANSPORT_DETAIL",
    order: 6,
    title: "小交通明细",
    needAmount: true,
    maxUploadCount: 50,
    uploadFields: [
      { key: "invoiceFile", label: "鍙戠エ" },
      { key: "detailFile", label: "明细单" },
    ],
    sampleImage: [LocalTransportSampleImage, LocalTransportSampleImage2, LocalTransportSampleImage3],
  },
  {
    code: "INTERCITY_TRANSPORT_DETAIL",
    order: 7,
    title: "大交通明细",
    needAmount: true,
    maxUploadCount: 50,
    uploadFields: [
      { key: "invoiceFile", label: "鍙戠エ" },
      { key: "detailFile", label: "明细单" },
    ],
    sampleImage: [IntercityTransportSampleImage, IntercityTransportSampleImage2, IntercityTransportSampleImage3, IntercityTransportSampleImage4],
  },
  {
    code: "MATERIAL_DETAIL",
    order: 8,
    title: "鐗╂枡鏄庣粏",
    needAmount: true,
    maxUploadCount: 50,
    uploadFields: [
      { key: "invoiceFile", label: "鍙戠エ" },
      { key: "detailFile", label: "明细单" },
      { key: "materialFile", label: "鐗╂枡鐓х墖" },
    ],
    sampleImage: [MaterialSampleImage, MaterialSampleImage2, MaterialSampleImage3, MaterialSampleImage4, MaterialSampleImage5, MaterialSampleImage6],
  },
  {
    code: "DESIGN_DRAFT_DETAIL",
    order: 9,
    title: "设计稿明细",
    needAmount: true,
    maxUploadCount: 50,
    uploadFields: [
      { key: "invoiceFile", label: "鍙戠エ" },
      { key: "detailFile", label: "明细单" },
      { key: "designDraftFile", label: "设计稿" },
    ],
    sampleImage: DesignDraftSampleImage,
  },
  {
    code: "OTHER",
    order: 10,
    title: "鍏朵粬",
    needAmount: true,
    maxUploadCount: 5,
    uploadFields: [
      { key: "invoiceFile", label: "鍏朵粬闄勪欢1" },
      { key: "detailFile", label: "鍏朵粬闄勪欢2" },
      { key: "equipmentFile", label: "鍏朵粬闄勪欢3" },
      { key: "materialFile", label: "鍏朵粬闄勪欢4" },
      { key: "designDraftFile", label: "鍏朵粬闄勪欢5" },
    ],
  },
];
const createMeetingInvoiceSectionState = (): MeetingInvoiceSectionState => {
  const files = {} as Record<MeetingInvoiceUploadFieldKey, MeetingInvoiceFileValue[]>;
  meetingInvoiceUploadFieldKeys.forEach((key) => {
    files[key] = [];
  });
  return {
    totalAmountYuan: null,
    files,
  };
};
const createEmptyMeetingInvoiceForm = (): MeetingInvoiceFormState => {
  const sections = {} as Record<MeetingInvoiceSectionCode, MeetingInvoiceSectionState>;
  meetingInvoiceSectionDefs.forEach((section) => {
    sections[section.code] = createMeetingInvoiceSectionState();
  });
  return {
    sections,
  };
};
const meetingInvoiceSectionCodeSet = new Set<MeetingInvoiceSectionCode>(
  meetingInvoiceSectionDefs.map((item) => item.code),
);
const meetingInvoiceFieldKeySet = new Set<MeetingInvoiceUploadFieldKey>(meetingInvoiceUploadFieldKeys);
const isMeetingInvoiceSectionCode = (value: string): value is MeetingInvoiceSectionCode =>
  meetingInvoiceSectionCodeSet.has(value as MeetingInvoiceSectionCode);
const isMeetingInvoiceFieldKey = (value: string): value is MeetingInvoiceUploadFieldKey =>
  meetingInvoiceFieldKeySet.has(value as MeetingInvoiceUploadFieldKey);
const parseMeetingInvoiceAmountYuan = (raw: any) => {
  const yuanDirect = Number(raw?.totalAmountYuan ?? raw?.totalFeeYuan);
  if (Number.isFinite(yuanDirect) && yuanDirect >= 0) {
    return yuanDirect;
  }
  const cent = Number(raw?.totalAmountCent ?? raw?.totalFeeCent);
  if (Number.isFinite(cent) && cent >= 0) {
    return Number((cent / 100).toFixed(2));
  }
  return null;
};
const toMeetingInvoiceFileValue = (raw: any): MeetingInvoiceFileValue | null => {
  const ossKey = String(raw?.ossKey || raw?.objectKey || "").trim();
  if (!ossKey) {
    return null;
  }
  const fileName = String(raw?.fileName || raw?.name || "").trim();
  return {
    fileName,
    ossKey,
    ocr: raw?.ocr && typeof raw.ocr === "object" ? raw.ocr : undefined,
  };
};
const normalizeMeetingInvoiceForm = (raw: any): MeetingInvoiceFormState => {
  const normalized = createEmptyMeetingInvoiceForm();
  const sectionList = Array.isArray(raw?.sections) ? raw.sections : [];
  if (sectionList.length > 0) {
    sectionList.forEach((row: any) => {
      const sectionCodeRaw = String(row?.sectionCode || row?.code || "").trim();
      if (!isMeetingInvoiceSectionCode(sectionCodeRaw)) {
        return;
      }
      const sectionDef = meetingInvoiceSectionDefs.find((x) => x.code === sectionCodeRaw);
      if (!sectionDef) {
        return;
      }
      const sectionState = normalized.sections[sectionCodeRaw];
      sectionState.totalAmountYuan = parseMeetingInvoiceAmountYuan(row);
      const keyedFiles = Array.isArray(row?.files) ? row.files : [];
      keyedFiles.forEach((fileRow: any) => {
        const fieldKeyRaw = String(fileRow?.fieldKey || fileRow?.key || "").trim();
        if (!isMeetingInvoiceFieldKey(fieldKeyRaw)) {
          return;
        }
        const fileValue = toMeetingInvoiceFileValue(fileRow);
        if (!fileValue) {
          return;
        }
        sectionState.files[fieldKeyRaw].push(fileValue);
      });
      sectionDef.uploadFields.forEach((field) => {
        const legacyFieldRaw = row?.[field.key];
        const legacyFieldList = Array.isArray(legacyFieldRaw) ? legacyFieldRaw : [legacyFieldRaw];
        legacyFieldList.forEach((item: any) => {
          const fileValue = toMeetingInvoiceFileValue(item);
          if (fileValue) {
            sectionState.files[field.key].push(fileValue);
          }
        });
      });
      const noKeyFiles: MeetingInvoiceFileValue[] = keyedFiles
        .filter((fileRow: any) => !isMeetingInvoiceFieldKey(String(fileRow?.fieldKey || fileRow?.key || "").trim()))
        .map((fileRow: any) => toMeetingInvoiceFileValue(fileRow))
        .filter((x: MeetingInvoiceFileValue | null): x is MeetingInvoiceFileValue => !!x);
      const emptyFields = sectionDef.uploadFields.filter((field) => sectionState.files[field.key].length <= 0);
      noKeyFiles.forEach((fileRow: MeetingInvoiceFileValue, index: number) => {
        const target = emptyFields[index];
        if (!target) {
          return;
        }
        sectionState.files[target.key].push(fileRow);
      });
    });
    return normalized;
  }
  const legacyAttachments = Array.isArray(raw?.attachments) ? raw.attachments : [];
  if (legacyAttachments.length > 0) {
    const slots = meetingInvoiceSectionDefs.flatMap((section) =>
      section.uploadFields.map((field) => ({
        sectionCode: section.code,
        fieldKey: field.key,
      })),
    );
    let cursor = 0;
    legacyAttachments.forEach((row: any) => {
      const fileValue = toMeetingInvoiceFileValue(row);
      if (!fileValue) {
        return;
      }
      const slot = slots[cursor];
      if (!slot) {
        return;
      }
      normalized.sections[slot.sectionCode].files[slot.fieldKey].push(fileValue);
      cursor += 1;
    });
  }
  return normalized;
};
const filterMeetingInvoiceSectionDefsByMeetingForm = (
  meetingFormRaw: unknown,
  sectionDefs: MeetingInvoiceSectionDef[] = meetingInvoiceSectionDefs,
) => {
  const meetingForm = String(meetingFormRaw || "").trim();
  if (meetingForm !== "绾夸笂") {
    return sectionDefs;
  }
  const otherSection = sectionDefs.find((def) => def.code === "OTHER");
  return otherSection ? [otherSection] : sectionDefs;
};
const resolveSelectedMeetingForm = () => {
  const meetingId = Number(selectedMeetingId.value || 0);
  const currentDetailId = Number(currentMeetingDetail.value?.id || 0);
  if (meetingId > 0 && currentDetailId === meetingId) {
    return String(currentMeetingDetail.value?.meetingForm || "").trim();
  }
  if (meetingId > 0) {
    const currentRow = rows.value.find((item: any) => Number(item?.id || 0) === meetingId);
    return String(currentRow?.meetingForm || "").trim();
  }
  return String(currentMeetingDetail.value?.meetingForm || "").trim();
};
const filteredMeetingInvoiceSectionDefs = computed(() => {
  const meetingForm = resolveSelectedMeetingForm();
  if (meetingForm === "绾夸笂") {
    return filterMeetingInvoiceSectionDefsByMeetingForm(meetingForm);
  }
  const meetingId = Number(selectedMeetingId.value || 0);
  const currentDetailId = Number(currentMeetingDetail.value?.id || 0);
  let configJson = null;
  if (meetingId > 0 && currentDetailId === meetingId) {
    configJson = currentMeetingDetail.value?.invoiceConfigJson;
  } else if (meetingId > 0) {
    const currentRow = rows.value.find((item: any) => Number(item?.id || 0) === meetingId);
    configJson = currentRow?.invoiceConfigJson;
  } else {
    configJson = currentMeetingDetail.value?.invoiceConfigJson;
  }
  if (!configJson) return meetingInvoiceSectionDefs;
  try {
    const configArray = JSON.parse(configJson);
    if (!Array.isArray(configArray) || configArray.length === 0) return meetingInvoiceSectionDefs;
    const allowed = new Set(configArray);
    return meetingInvoiceSectionDefs.filter((def) => allowed.has(def.code));
  } catch {
    return meetingInvoiceSectionDefs;
  }
});
const invoiceConfigDialogVisible = ref(false);
const invoiceConfigMeetingId = ref<number | null>(null);
const invoiceConfigInitialJson = ref<string | null>(null);
const openInvoiceConfigDialog = () => {
  if (!currentMeetingDetail.value) return;
  invoiceConfigMeetingId.value = currentMeetingDetail.value.id;
  invoiceConfigInitialJson.value = currentMeetingDetail.value.invoiceConfigJson || null;
  invoiceConfigDialogVisible.value = true;
};
const handleInvoiceConfigSuccess = async () => {
  if (invoiceConfigMeetingId.value) {
    try {
      const res = await fetchMeetingDetail(invoiceConfigMeetingId.value);
      currentMeetingDetail.value = res.data;
    } catch {}
  }
};
const collectMeetingInvoiceFiles = (formData: MeetingInvoiceFormState) => {
  const files: Array<MeetingInvoiceFileValue> = [];
  filteredMeetingInvoiceSectionDefs.value.forEach((section) => {
    const sectionState = formData.sections[section.code];
    section.uploadFields.forEach((field) => {
      const rows = Array.isArray(sectionState?.files[field.key]) ? sectionState.files[field.key] : [];
      rows.forEach((row) => {
        if (!row?.ossKey) {
          return;
        }
        files.push({
          fileName: String(row.fileName || "").trim(),
          ossKey: String(row.ossKey || "").trim(),
          ocr: row.ocr,
        });
      });
    });
  });
  return files;
};
const selectedModuleCode = ref<
  MaterialModuleCode
>(
  "BASIC_INFO",
);
const expertSubModule = ref<ExpertSubModuleCode>("ONSITE_PHOTO");
const materialForm = ref({
  remark: "",
});
const basicForm = ref({
  chairmanExpertIds: [] as number[],
  speakerExpertIds: [] as number[],
  hostExpertIds: [] as number[],
  discussionGuestExpertIds: [] as number[],
  guestCount: 0,
  attendeeCount: 1,
  attendeeActualCount: 0,
  targetAudience: "",
  mainAgenda: "",
  improvementSuggestion: "",
  meetingEffect: "",
});
const docForm = ref({
  agendaLines: "",
  signInName: "",
  signInOssKey: "",
  themePhotoName: "",
  themePhotoOssKey: "",
  invitationLines: "",
});
const expertProfileForm = ref({
  fileName: "",
  ossKey: "",
});
const photoForm = ref({
  photoLines: "",
  summary: "",
});
const photoSummaryByExpert = ref<Record<number, string>>({});
const laborForm = ref({
  protocolName: "",
  protocolOssKey: "",
  detailLines: "",
});
const invoiceForm = ref({
  invoiceLines: "",
  summary: "",
});
const meetingInvoiceForm = ref<MeetingInvoiceFormState>(createEmptyMeetingInvoiceForm());
const agendaFile = ref<File | null>(null);
const signInFile = ref<File | null>(null);
const themePhotoFile = ref<File | null>(null);
const invitationFile = ref<File | null>(null);
const photoAutoUploading = ref(false);
const laborProtocolAutoUploading = ref(false);
const laborInvoiceAutoUploading = ref(false);
const invoiceAutoUploading = ref(false);
const meetingInvoiceFieldUploading = ref<Record<string, boolean>>({});
const selectedExpertMaterialId = ref<number | null>(null);
const photoByExpert = ref<Record<number, Array<{ name: string; ossKey: string; previewUrl?: string }>>>({});

type LaborInvoiceOcrState = {
  status: "idle" | "running" | "pending_confirm" | "success" | "failed";
  normalized?: { invoiceCode?: string; invoiceNum?: string; name?: string; totalAmountCent?: number | null; taxCent?: number | null };
  raw?: any;
  message?: string;
  updatedAt?: string;
};

type LaborRow = {
  protocolName: string;
  protocolOssKey: string;
  protocolFileName: string;
  protocolPreviewUrl?: string;
  invoiceFiles: Array<{
    fileName: string;
    ossKey: string;
    previewUrl?: string;
  }>;
  invoiceFileName: string;
  invoiceOssKey: string;
  invoicePreviewUrl?: string;
  invoiceOcr?: LaborInvoiceOcrState;
  amountCent: number;
  remark: string;
};

const laborByExpert = ref<Record<number, LaborRow>>({});
const photoUploadFiles = ref<File[]>([]);
const laborProtocolUploadFile = ref<File | null>(null);
const laborInvoiceUploadFiles = ref<File[]>([]);
const invoiceUploadFiles = ref<File[]>([]);
const invoiceByExpert = ref<Record<number, any[]>>({});
const invoiceDraft = ref({
  invoiceNo: "",
  amountCent: 0,
  taxCent: 0,
  expenseType: "鍏朵粬",
});
const historyDialogVisible = ref(false);
const historyRows = ref<any[]>([]);
const historyModuleCode = ref<
  MaterialModuleCode
>(
  "BASIC_INFO",
);
const diffRows = ref<Array<{ field: string; previous: string; current: string }>>([]);
type SummaryTaskState = {
  taskId?: number;
  token?: string;
  expireAt?: string;
  status?: string;
  errorMessage?: string;
  finishedAt?: string;
  createdAt?: string;
};

const summaryTaskMap = ref<Record<number, SummaryTaskState>>({});
const summaryTaskTimerMap: Record<number, number> = {};
const materialItemReviewMap = ref<Record<string, { reviewResult?: string; reviewReason?: string; updatedAt?: string }>>({});
const materialReviewNotice = ref("");
const docPreviewUrlMap = ref<Record<string, string>>({});
const materialBudgetBase = ref({
  budgetCent: 0,
  laborTotalCent: 0,
  invoiceTotalCent: 0,
  meetingInvoiceTotalCent: 0,
  ready: false,
});
const materialModuleLoading = ref(false);
const materialBudgetLoading = ref(false);
const materialLoadRequestId = ref(0);
const docPreviewDialogVisible = ref(false);
const docPreviewDialogImageUrl = ref("");
const docPreviewDialogKind = ref<"image" | "pdf">("image");
const auditProgressDialogVisible = ref(false);
const auditProgressRows = ref<any[]>([]);
const auditProgressMeetingId = ref<number | null>(null);
const auditProgressPageNo = ref(1);
const auditProgressPageSize = ref(20);
const auditProgressTotal = ref(0);
const bindExpertVisible = ref(false);
const expertManageActiveTab = ref<"bound" | "search">("bound");
const createPlatformExpertVisible = ref(false);
const bindExpertKeyword = ref("");
const selectedExpertManageMeetingId = ref<number | null>(null);
const selectedExpertManageMeetingAuditApproved = ref(false);
const selectedExpertManageMeetingMaterialLocked = ref(false);
const platformExpertRows = ref<any[]>([]);
const platformExpertSearched = ref(false);
const selectedPlatformExpertIds = ref<number[]>([]);
const currentSearchSelectedPlatformExpertIds = ref<number[]>([]);
const selectedPlatformExpertNameMap = ref<Record<number, string>>({});
const boundExpertRows = ref<any[]>([]);
const canManageMeetingExperts = computed(
  () => canMaterialSave.value && !selectedExpertManageMeetingAuditApproved.value && !selectedExpertManageMeetingMaterialLocked.value,
);
const expertTitleOptions = ref<any[]>([]);
const expertHospitalOptions = ref<any[]>([]);
const meetingCategoryOptions = ref<any[]>([]);
const createPlatformExpertForm = ref({
  expertName: "",
  idNo: "",
  phone: "",
  titleCode: "",
  title: "",
  hospitalCode: "",
  organization: "",
  idCardFrontOssKey: "",
  idCardBackOssKey: "",
  bankName: "",
  bankProvince: "",
  bankCity: "",
  bankBranchName: "",
  bankCardNo: "",
  accountName: "",
  bankCardFrontOssKey: "",
});
const createExpertIdFrontFile = ref<File | null>(null);
const createExpertIdBackFile = ref<File | null>(null);
const createExpertIdFrontUploading = ref(false);
const createExpertIdBackUploading = ref(false);
const idNoSearchPattern = /(^\d{15}$)|(^\d{17}[\dXx]$)/;
const createExpertIdFrontPreviewUrl = ref("");
const createExpertIdBackPreviewUrl = ref("");
const createExpertIdOcrDialogVisible = ref(false);
const createExpertIdOcrData = ref({
  side: "front" as "front" | "back",
  name: "",
  idNo: "",
  gender: "",
  ethnicity: "",
  birth: "",
  address: "",
  issueAuthority: "",
  signDate: "",
  expiryDate: "",
});
const createExpertCardFrontFile = ref<File | null>(null);
const createExpertCardFrontUploading = ref(false);
const createExpertCardFrontPreviewUrl = ref("");
const createExpertBankCardOcrDialogVisible = ref(false);
const createExpertBankCardOcrData = ref({
  bankName: "",
  bankCardNo: "",
  accountName: "",
  validDate: "",
  bankCardType: "",
});
const maskPhone = (value?: string) => {
  const phone = String(value || "").trim();
  if (!phone) {
    return "-";
  }
  if (phone.length <= 7) {
    return phone;
  }
  return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
};
const maskIdNo = (value?: string) => {
  const idNo = String(value || "").trim();
  if (!idNo) {
    return "-";
  }
  if (idNo.length <= 8) {
    return idNo;
  }
  return `${idNo.slice(0, 4)}********${idNo.slice(-4)}`;
};
const toYuan = (cent?: number) => ((Number(cent || 0) / 100).toFixed(2));
const toCent = (yuan?: number) => Math.round(Number(yuan || 0) * 100);
const centYuanFormatter = (_row: any, _column: any, value: unknown) => toYuan(Number(value || 0));
const statusFormatter = (_row: any, _column: any, value: unknown) => toZhStatus(value);
const auditNodeFormatter = (_row: any, _column: any, value: unknown) => toZhAuditNode(value);
const detailStatusTagType = (status: unknown): "success" | "danger" | "warning" | "info" => {
  const code = String(status || "").toUpperCase();
  if (code === "APPROVED") {
    return "success";
  }
  if (code === "REJECTED") {
    return "danger";
  }
  if (code === "RETURNED" || code === "RETURN" || code === "BACK") {
    return "warning";
  }
  return "info";
};
const detailTimelineType = (status: unknown): "success" | "warning" | "danger" | "primary" | "info" => {
  const code = String(status || "").toUpperCase();
  if (code === "APPROVED") {
    return "success";
  }
  if (code === "REJECTED") {
    return "danger";
  }
  if (code === "RETURNED" || code === "RETURN" || code === "BACK") {
    return "warning";
  }
  return "primary";
};
const resolveAuditTime = (row: any) => {
  const value = String(
    row?.lastActionAt
    || row?.auditAt
    || row?.auditTime
    || row?.updatedAt
    || row?.handledAt
    || row?.completedAt
    || row?.createdAt
    || "",
  ).trim();
  if (value) {
    return value;
  }
  const status = String(row?.status || "").toUpperCase();
  if (status === "PENDING") {
    return "待处理";
  }
  return "-";
};
const resolveReviewField = (itemKey: string | string[], field: "reviewResult" | "reviewReason" | "updatedAt") => {
  if (Array.isArray(itemKey)) {
    for (const key of itemKey) {
      const value = String(materialItemReviewMap.value[String(key || "")]?.[field] || "");
      if (value) {
        return value;
      }
    }
    return "";
  }
  return String(materialItemReviewMap.value[String(itemKey || "")]?.[field] || "");
};
const resolveReviewResult = (itemKey: string | string[]) => resolveReviewField(itemKey, "reviewResult").toUpperCase();
const toReviewResultText = (itemKey: string | string[]) => {
  const result = resolveReviewResult(itemKey);
  if (result === "APPROVED") {
    return "宸查€氳繃";
  }
  if (result === "REJECTED") {
    return "涓嶉€氳繃";
  }
  return "";
};
const toReviewTagType = (itemKey: string | string[]): "success" | "danger" | "info" => {
  const result = resolveReviewResult(itemKey);
  if (result === "APPROVED") {
    return "success";
  }
  if (result === "REJECTED") {
    return "danger";
  }
  return "info";
};
const toReviewReasonText = (itemKey: string | string[]) => resolveReviewField(itemKey, "reviewReason");
const isReviewApproved = (itemKey: string | string[]) => resolveReviewResult(itemKey) === "APPROVED";
const buildPhotoItemKey = (row: any, idx: number) => `photo:${row?.ossKey || row?.originIndex || idx + 1}`;
const buildLaborItemKey = (expertId: number) => `labor:${expertId || 1}`;
const buildInvoiceItemKey = (row: any, idx: number) => `invoice:${row?.invoiceNo || idx + 1}`;
const buildMeetingInvoiceFieldItemKey = (sectionCode: MeetingInvoiceSectionCode, fieldKey: MeetingInvoiceUploadFieldKey) =>
  `meeting_invoice:${sectionCode}:${fieldKey}`;
const buildMeetingInvoiceAmountItemKey = (sectionCode: MeetingInvoiceSectionCode) => `meeting_invoice:${sectionCode}:amount`;
const warnReviewedMaterialLocked = () => {
  ElMessage.warning("璇ヤ細璁祫鏂欎腑鐨勫凡瀹℃牳閫氳繃椤逛笉鍏佽淇敼");
};
const warnMeetingMaterialReadonly = () => {
  ElMessage.warning("该会议资料审核中或已审核通过，不允许再修改");
};

watch(
  () => filteredMeetingInvoiceSectionDefs.value.map((section) => [
    section.code,
    Number(meetingInvoiceForm.value.sections?.[section.code]?.totalAmountYuan || 0),
  ]),
  (entries, prevEntries) => {
    if (!Array.isArray(prevEntries)) {
      return;
    }
    const prevMap = new Map<string, number>(
      prevEntries.map((item: any) => [String(item?.[0] || ""), Number(item?.[1] || 0)]),
    );
    entries.forEach((item: any) => {
      const sectionCode = String(item?.[0] || "");
      const currentAmount = Number(item?.[1] || 0);
      const prevAmount = Number(prevMap.get(sectionCode) || 0);
      if (currentAmount === prevAmount) {
        return;
      }
      if (!isReviewApproved(buildMeetingInvoiceAmountItemKey(sectionCode as MeetingInvoiceSectionCode))) {
        return;
      }
      const section = meetingInvoiceForm.value.sections?.[sectionCode as MeetingInvoiceSectionCode];
      if (section) {
        section.totalAmountYuan = prevAmount;
      }
    });
  },
  { deep: false },
);

const currentLaborReviewKey = computed(() => buildLaborItemKey(Number(selectedExpertMaterialId.value || 0)));
const invitationDocRows = computed(() =>
  (docForm.value.invitationLines || "")
    .split("\n")
    .map((line) => line.trim())
    .filter((line) => line.length > 0)
    .map((line, index) => {
      const parts = line.split("|");
      return {
        itemKey: `invitation:${index + 1}`,
        name: String(parts[0] || "").trim(),
        ossKey: String(parts[1] || "").trim(),
      };
    }),
);
const agendaDocRows = computed(() =>
  (docForm.value.agendaLines || "")
    .split("\n")
    .map((line) => line.trim())
    .filter((line) => line.length > 0)
    .map((line, index) => {
      const parts = line.split("|");
      return {
        itemKey: `agenda:${index + 1}`,
        name: String(parts[0] || "").trim(),
        ossKey: String(parts[1] || "").trim(),
      };
    }),
);
const buildAgendaJsonPayload = () =>
  agendaDocRows.value.map((item) => ({
    name: item.name || "",
    fileName: item.name || "",
    ossKey: item.ossKey || "",
  }));
const isImageDocFile = (name: string, ossKey: string) => isImageFile(name || "") || isImageFile(ossKey || "");
const isPdfDocFile = (name: string, ossKey: string) => extractExt(name || "") === ".pdf" || extractExt(ossKey || "") === ".pdf";
const agendaUploadFileList = computed<UploadUserFile[]>(() =>
  agendaDocRows.value.map((item, index) => {
    const name = item.name || `浼氳鏃ョ▼${index + 1}`;
    const isImage = isImageDocFile(name, item.ossKey);
    return {
      name,
      url: isImage ? (docPreviewUrlMap.value[item.ossKey] || "") : "",
      status: "success",
      uid: index + 1,
      ossKey: item.ossKey,
    } as UploadUserFile & { ossKey: string };
  }),
);
const signInUploadFileList = computed<UploadUserFile[]>(() => {
  if (!docForm.value.signInOssKey) {
    return [];
  }
  const name = displayDocName(docForm.value.signInName, docForm.value.signInOssKey, "签到表");
  const isImage = isImageDocFile(name, docForm.value.signInOssKey);
  return [{
    name,
    url: isImage ? (docPreviewUrlMap.value[docForm.value.signInOssKey] || "") : "",
    status: "success",
    uid: 1,
    ossKey: docForm.value.signInOssKey,
  } as UploadUserFile & { ossKey: string }];
});
const themePhotoUploadFileList = computed<UploadUserFile[]>(() => {
  if (!docForm.value.themePhotoOssKey) {
    return [];
  }
  const name = displayDocName(docForm.value.themePhotoName, docForm.value.themePhotoOssKey, "涓婚鐓х墖");
  const isImage = isImageDocFile(name, docForm.value.themePhotoOssKey);
  return [{
    name,
    url: isImage ? (docPreviewUrlMap.value[docForm.value.themePhotoOssKey] || "") : "",
    status: "success",
    uid: 100001,
    ossKey: docForm.value.themePhotoOssKey,
  } as UploadUserFile & { ossKey: string }];
});
const invitationUploadFileList = computed<UploadUserFile[]>(() =>
  invitationDocRows.value.map((item, index) => {
    const isImage = isImageDocFile(item.name, item.ossKey);
    return {
      name: item.name || `閭€璇峰嚱${index + 1}`,
      url: isImage ? (docPreviewUrlMap.value[item.ossKey] || "") : "",
      status: "success",
      uid: index + 1,
      ossKey: item.ossKey,
    } as UploadUserFile & { ossKey: string };
  }),
);
const expertProfileUploadFileList = computed<UploadUserFile[]>(() => {
  const ossKey = String(expertProfileForm.value.ossKey || "").trim();
  if (!ossKey) {
    return [];
  }
  const fileName = String(expertProfileForm.value.fileName || "").trim() || "涓撳绠€浠?涓插満";
  return [{
    name: fileName,
    status: "success",
    uid: 200001,
    ossKey,
  } as UploadUserFile & { ossKey: string }];
});
const laborProtocolUploadFileList = computed<UploadUserFile[]>(() => {
  const expertId = Number(selectedExpertMaterialId.value || 0);
  if (!expertId) {
    return [];
  }
  const row = getCurrentLaborRow(expertId);
  const ossKey = String(row?.protocolOssKey || "").trim();
  if (!row || !ossKey) {
    return [];
  }
  const fileName = String(row.protocolFileName || "").trim() || String(row.protocolName || "").trim() || "鍗忚鏂囦欢";
  const preview = String(row.protocolPreviewUrl || "").trim();
  const isImage = isImageDocFile(fileName, ossKey);
  return [{
    name: fileName,
    url: isImage && preview ? preview : "",
    status: "success",
    uid: expertId * 10 + 1,
    ossKey,
  } as UploadUserFile & { ossKey: string }];
});
const laborInvoiceUploadFileList = computed<UploadUserFile[]>(() => {
  const expertId = Number(selectedExpertMaterialId.value || 0);
  if (!expertId) {
    return [];
  }
  const row = getCurrentLaborRow(expertId);
  const files = Array.isArray(row?.invoiceFiles) ? row.invoiceFiles : [];
  if (!row || files.length === 0) {
    return [];
  }
  return files.map((file, index) => {
    const ossKey = String(file?.ossKey || "").trim();
    const fileName = String(file?.fileName || "").trim() || `鍔冲姟鍙戠エ${index + 1}`;
    const preview = String(file?.previewUrl || "").trim();
    const isImage = isImageDocFile(fileName, ossKey);
    return {
      name: fileName,
      url: isImage && preview ? preview : "",
      status: "success",
      uid: 600000 + expertId * 100 + index,
      ossKey,
    } as UploadUserFile & { ossKey: string };
  });
});
type MeetingInvoiceUploadUserFile = UploadUserFile & {
  ossKey?: string;
  sectionCode?: MeetingInvoiceSectionCode;
  fieldKey?: MeetingInvoiceUploadFieldKey;
};
const getMeetingInvoiceSectionUploadFileList = (
  sectionCode: MeetingInvoiceSectionCode,
  fieldKey: MeetingInvoiceUploadFieldKey,
): UploadUserFile[] => {
  const rows = meetingInvoiceForm.value.sections?.[sectionCode]?.files?.[fieldKey] || [];
  const sectionIdx = meetingInvoiceSectionDefs.findIndex((item) => item.code === sectionCode);
  const fieldIdx = meetingInvoiceUploadFieldKeys.findIndex((item) => item === fieldKey);
  return rows
    .map((row, index) => {
      const ossKey = String(row?.ossKey || "").trim();
      if (!ossKey) {
        return null;
      }
      const name = String(row?.fileName || "").trim() || `${sectionCode}-${fieldKey}-${index + 1}`;
      const isImage = isImageDocFile(name, ossKey);
      return {
        name,
        url: isImage ? (docPreviewUrlMap.value[ossKey] || "") : "",
        status: "success",
        uid: (sectionIdx + 1) * 100000 + (fieldIdx + 1) * 1000 + index + 1,
        ossKey,
        sectionCode,
        fieldKey,
      } as MeetingInvoiceUploadUserFile;
    })
    .filter((item): item is MeetingInvoiceUploadUserFile => !!item);
};

const getMeetingInvoiceFileRow = (
  sectionCode: MeetingInvoiceSectionCode,
  fieldKey: MeetingInvoiceUploadFieldKey,
): MeetingInvoiceFileValue | null => {
  const rows = meetingInvoiceForm.value.sections?.[sectionCode]?.files?.[fieldKey] || [];
  if (!Array.isArray(rows) || rows.length <= 0) {
    return null;
  }
  return rows[rows.length - 1] || null;
};

const ocrRawDialogVisible = ref(false);
const ocrRawDialogTitle = ref("OCR鍘熷缁撴灉");
const ocrRawDialogText = ref("");
const laborInvoiceOcrConfirmVisible = ref(false);
const laborAgreementExtractConfirmVisible = ref(false);
const laborAgreementExtractApplying = ref(false);
const laborAgreementExtractConfirmData = ref({
  meetingId: 0,
  taskId: "",
  objectKey: "",
  fileName: "",
  parsedExpert: null as any,
  existingExpert: null as any,
  needsConfirm: false,
  nameMismatchFlag: false,
});
const laborInvoiceOcrPendingExpertId = ref<number | null>(null);
const laborInvoiceOcrConfirmData = ref({
  expertName: "",
  ocrName: "",
  nameMatched: false,
  invoiceCode: "",
  invoiceNum: "",
  totalAmountCent: 0,
  taxCent: 0,
  ossKey: "",
  fileName: "",
  raw: null as any,
  normalized: {} as any,
});
const meetingInvoiceOcrConfirmVisible = ref(false);
const meetingInvoiceOcrPendingTarget = ref<{ sectionCode: MeetingInvoiceSectionCode; fieldKey: MeetingInvoiceUploadFieldKey } | null>(null);
const meetingInvoiceOcrConfirmData = ref({
  sectionCode: "" as MeetingInvoiceSectionCode | "",
  fieldKey: "" as MeetingInvoiceUploadFieldKey | "",
  expertName: "",
  ocrName: "",
  nameMatched: false,
  invoiceCode: "",
  invoiceNum: "",
  totalAmountCent: 0,
  taxCent: 0,
  raw: null as any,
  normalized: {} as any,
});

const createEmptyLaborRow = (): LaborRow => ({
  protocolName: "",
  protocolOssKey: "",
  protocolFileName: "",
  protocolPreviewUrl: "",
  invoiceFiles: [],
  invoiceFileName: "",
  invoiceOssKey: "",
  invoicePreviewUrl: "",
  invoiceOcr: { status: "idle" },
  amountCent: 0,
  remark: "",
});

const mergeLaborRow = (current: LaborRow | null | undefined, incoming: LaborRow): LaborRow => {
  const base = current ? { ...current } : createEmptyLaborRow();
  const nextInvoiceFiles = [...(Array.isArray(base.invoiceFiles) ? base.invoiceFiles : [])];
  (Array.isArray(incoming.invoiceFiles) ? incoming.invoiceFiles : []).forEach((file) => {
    const ossKey = String(file?.ossKey || "").trim();
    if (!ossKey) {
      return;
    }
    if (!nextInvoiceFiles.some((item) => String(item?.ossKey || "").trim() === ossKey)) {
      nextInvoiceFiles.push({
        fileName: String(file?.fileName || "").trim(),
        ossKey,
        previewUrl: String(file?.previewUrl || "").trim(),
      });
    }
  });
  const merged: LaborRow = {
    ...base,
    ...incoming,
    protocolName: String(incoming.protocolName || "").trim() || base.protocolName,
    protocolOssKey: String(incoming.protocolOssKey || "").trim() || base.protocolOssKey,
    protocolFileName: String(incoming.protocolFileName || "").trim() || base.protocolFileName,
    protocolPreviewUrl: String(incoming.protocolPreviewUrl || "").trim() || base.protocolPreviewUrl,
    invoiceFiles: nextInvoiceFiles,
    invoiceFileName: String(incoming.invoiceFileName || "").trim() || base.invoiceFileName,
    invoiceOssKey: String(incoming.invoiceOssKey || "").trim() || base.invoiceOssKey,
    invoicePreviewUrl: String(incoming.invoicePreviewUrl || "").trim() || base.invoicePreviewUrl,
    invoiceOcr: incoming.invoiceOcr?.status ? incoming.invoiceOcr : base.invoiceOcr,
    amountCent: Math.max(Number(base.amountCent || 0), Number(incoming.amountCent || 0)),
    remark: String(incoming.remark || "").trim() || base.remark,
  };
  if (!merged.invoiceFileName && merged.invoiceFiles[0]) {
    merged.invoiceFileName = String(merged.invoiceFiles[0].fileName || "").trim();
  }
  if (!merged.invoiceOssKey && merged.invoiceFiles[0]) {
    merged.invoiceOssKey = String(merged.invoiceFiles[0].ossKey || "").trim();
  }
  return merged;
};

const openOcrRawDialog = (sectionCode: MeetingInvoiceSectionCode, fieldKey: MeetingInvoiceUploadFieldKey) => {
  const row = getMeetingInvoiceFileRow(sectionCode, fieldKey);
  const raw = row?.ocr?.raw;
  ocrRawDialogTitle.value = `OCR鍘熷缁撴灉 - ${sectionCode}/${fieldKey}`;
  try {
    ocrRawDialogText.value = raw ? JSON.stringify(raw, null, 2) : "";
  } catch (_e) {
    ocrRawDialogText.value = String(raw || "");
  }
  ocrRawDialogVisible.value = true;
};

const normalizeNameForCompare = (name: string) =>
  String(name || "")
    .trim()
    .replace(/\s+/g, "")
    .replace(/[.,!?;:，。！？；：“”’（）()\[\]<>《》\/\\-]/g, "");

const isSamePersonName = (a: string, b: string) => {
  const na = normalizeNameForCompare(a);
  const nb = normalizeNameForCompare(b);
  if (!na || !nb) {
    return false;
  }
  return na.includes(nb) || nb.includes(na);
};

const pickBestOcrResultNode = (raw: any) => {
  const list = Array.isArray(raw?.words_result) ? raw.words_result : [];
  if (list.length === 0) {
    return null;
  }
  const sorted = [...list].sort((x: any, y: any) => Number(y?.probability || 0) - Number(x?.probability || 0));
  return sorted[0] || null;
};

const extractFirstOcrWord = (result: any, keys: string[]) => {
  for (const key of keys) {
    const val = result?.[key];
    if (Array.isArray(val) && val.length > 0) {
      const w = String(val[0]?.word || "").trim();
      if (w) return w;
    }
    if (val && typeof val === "object" && "word" in val) {
      const w = String((val as any).word || "").trim();
      if (w) return w;
    }
    const s = String(val || "").trim();
    if (s) return s;
  }
  return "";
};

const extractOcrPersonName = (raw: any) => {
  const best = pickBestOcrResultNode(raw);
  const result = best?.result;
  if (!result) return "";
  const detectedType = String(best?.detectedType || raw?.detectedType || "").trim().toLowerCase();
  if (detectedType === "vat_invoice") {
    const sellerName = extractFirstOcrWord(result, ["SellerName"]);
    if (sellerName) {
      return sellerName;
    }
  }
  // 灏介噺瑕嗙洊甯歌鈥滀汉鍚嶁€濆瓧娈碉紙浜ら€氱エ鎹€佹梾瀹㈣繍杈撱€佽绋嬪崟绛夛級
  const name = extractFirstOcrWord(result, [
    "PassengName",
    "PassengerName",
    "name",
    "Name",
    "buyer-name",
  ]);
  return String(name || "").trim();
};

const resolveCurrentExpertName = (expertId: number) => {
  const row = (boundExpertRows.value || []).find((x: any) => Number(x?.expertId || 0) === expertId);
  return String(row?.expertName || row?.name || "").trim();
};

const runLaborInvoiceOcr = async (targetOssKey?: string) => {
  const expertId = Number(selectedExpertMaterialId.value || 0);
  if (!expertId) {
    ElMessage.warning("鏈€夋嫨涓撳");
    return;
  }
  const row = getCurrentLaborRow(expertId);
  const objectKey = String(targetOssKey || row?.invoiceOssKey || "").trim();
  if (!row || !objectKey) {
    ElMessage.warning("未找到可识别的劳务发票");
    return;
  }
  row.invoiceOcr = {
    status: "running",
    normalized: row.invoiceOcr?.normalized,
    raw: row.invoiceOcr?.raw,
    message: "",
    updatedAt: new Date().toISOString(),
  };
  try {
    const resp = await recognizeMultipleInvoice({ objectKey });
    const normalized = resp?.data?.normalized || {};
    const raw = resp?.data?.raw;
    const expertName = resolveCurrentExpertName(expertId);
    const normalizedName = String(normalized?.name || "").trim();
    const ocrName = normalizedName || extractOcrPersonName(raw);
    const sameName = expertName && ocrName ? isSamePersonName(expertName, ocrName) : false;
    const totalAmountCent = Number(normalized?.totalAmountCent || 0);
    const taxCent = Number(normalized?.taxCent || 0);

    console.groupCollapsed("[LaborInvoiceOCR] 濮撳悕姣斿璋冭瘯");
    console.info("expertId =", expertId);
    console.info("expertName =", expertName);
    console.info("normalized.name =", normalizedName);
    console.info("rawExtractedName =", extractOcrPersonName(raw));
    console.info("finalOcrName =", ocrName);
    console.info("sameName =", sameName);
    console.info("normalized.totalAmountCent =", totalAmountCent);
    console.info("invoiceOssKey =", objectKey);
    console.groupEnd();
    row.invoiceOcr = {
      status: "pending_confirm",
      normalized,
      raw,
      message: "",
      updatedAt: new Date().toISOString(),
    };
    laborInvoiceOcrPendingExpertId.value = expertId;
    laborInvoiceOcrConfirmData.value = {
      expertName,
      ocrName,
      nameMatched: !!sameName,
      invoiceCode: String(normalized?.invoiceCode || ""),
      invoiceNum: String(normalized?.invoiceNum || ""),
      totalAmountCent: Number.isFinite(totalAmountCent) ? Math.max(0, Math.round(totalAmountCent)) : 0,
      taxCent: Number.isFinite(taxCent) ? Math.max(0, Math.round(taxCent)) : 0,
      ossKey: objectKey,
      fileName: String(
        row.invoiceFiles?.find((item) => String(item?.ossKey || "").trim() === objectKey)?.fileName ||
        row.invoiceFileName ||
        getFileNameFromOssKey(objectKey) ||
        ""
      ).trim(),
      raw,
      normalized,
    };
    laborInvoiceOcrConfirmVisible.value = true;
  } catch (e: any) {
    row.invoiceOcr = {
      status: "failed",
      normalized: row.invoiceOcr?.normalized,
      raw: row.invoiceOcr?.raw,
      message: String(e?.message || "鍔冲姟鍙戠エOCR璇嗗埆澶辫触"),
      updatedAt: new Date().toISOString(),
    };
    ElMessage.error(row.invoiceOcr.message || "鍔冲姟鍙戠エOCR璇嗗埆澶辫触");
  }
};

const confirmLaborInvoiceOcrFill = () => {
  const expertId = Number(laborInvoiceOcrPendingExpertId.value || 0);
  if (!expertId) {
    laborInvoiceOcrConfirmVisible.value = false;
    return;
  }
  const row = getCurrentLaborRow(expertId);
  if (!row) {
    laborInvoiceOcrConfirmVisible.value = false;
    return;
  }
  const normalized = laborInvoiceOcrConfirmData.value.normalized || {};
  row.amountCent = Number(laborInvoiceOcrConfirmData.value.totalAmountCent || 0);
  row.invoiceOcr = {
    status: "success",
    normalized,
    raw: laborInvoiceOcrConfirmData.value.raw,
    message: laborInvoiceOcrConfirmData.value.nameMatched ? "" : "姓名与当前专家不一致，请人工核对",
    updatedAt: new Date().toISOString(),
  };
  if (!laborInvoiceOcrConfirmData.value.nameMatched) {
    ElMessage.warning(`劳务发票姓名识别为“${laborInvoiceOcrConfirmData.value.ocrName || "-"}”，与当前专家“${laborInvoiceOcrConfirmData.value.expertName || "-"}”不一致，请核对。`);
  } else {
    ElMessage.success("宸茬‘璁ゅ苟鍥炲～鍔冲姟鍙戠エOCR缁撴灉");
  }
  laborInvoiceOcrConfirmVisible.value = false;
};

const cancelLaborInvoiceOcrFill = () => {
  const expertId = Number(laborInvoiceOcrPendingExpertId.value || 0);
  if (!expertId) {
    laborInvoiceOcrConfirmVisible.value = false;
    return;
  }
  const row = getCurrentLaborRow(expertId);
  if (row?.invoiceOcr) {
    row.invoiceOcr = {
      ...row.invoiceOcr,
      status: "failed",
      message: "已取消回填",
      updatedAt: new Date().toISOString(),
    };
  }
  laborInvoiceOcrConfirmVisible.value = false;
};

const runMeetingInvoiceOcr = async (sectionCode: MeetingInvoiceSectionCode, fieldKey: MeetingInvoiceUploadFieldKey) => {
  const row = getMeetingInvoiceFileRow(sectionCode, fieldKey);
  const objectKey = String(row?.ossKey || "").trim();
  if (!row || !objectKey) {
    ElMessage.warning("未找到可识别的文件");
    return;
  }
  row.ocr = {
    status: "running",
    normalized: row.ocr?.normalized,
    raw: row.ocr?.raw,
    message: "",
    updatedAt: new Date().toISOString(),
  };
  try {
    const resp = await recognizeMultipleInvoice({ objectKey });
    const data = resp?.data || {};
    const normalized = data?.normalized || {};
    const raw = data?.raw;
    const expertId = Number(selectedExpertMaterialId.value || 0);
    const expertName = expertId ? resolveCurrentExpertName(expertId) : "";
    const normalizedName = String(normalized?.name || "").trim();
    const ocrName = normalizedName || extractOcrPersonName(raw);
    const sameName = expertName && ocrName ? isSamePersonName(expertName, ocrName) : false;
    const totalAmountCent = Number(normalized?.totalAmountCent || 0);
    const taxCent = Number(normalized?.taxCent || 0);
    row.ocr = {
      status: "pending_confirm",
      normalized,
      raw,
      message: "",
      updatedAt: new Date().toISOString(),
    };
    meetingInvoiceOcrPendingTarget.value = { sectionCode, fieldKey };
    meetingInvoiceOcrConfirmData.value = {
      sectionCode,
      fieldKey,
      expertName,
      ocrName,
      nameMatched: !!sameName,
      invoiceCode: String(normalized?.invoiceCode || ""),
      invoiceNum: String(normalized?.invoiceNum || ""),
      totalAmountCent: Number.isFinite(totalAmountCent) ? Math.max(0, Math.round(totalAmountCent)) : 0,
      taxCent: Number.isFinite(taxCent) ? Math.max(0, Math.round(taxCent)) : 0,
      raw,
      normalized,
    };
    meetingInvoiceOcrConfirmVisible.value = true;
  } catch (e: any) {
    row.ocr = {
      status: "failed",
      normalized: row.ocr?.normalized,
      raw: row.ocr?.raw,
      message: String(e?.message || "OCR璇嗗埆澶辫触"),
      updatedAt: new Date().toISOString(),
    };
    ElMessage.error(row.ocr.message || "OCR璇嗗埆澶辫触");
  }
};

const confirmMeetingInvoiceOcrFill = () => {
  const target = meetingInvoiceOcrPendingTarget.value;
  if (!target) {
    meetingInvoiceOcrConfirmVisible.value = false;
    return;
  }
  const row = getMeetingInvoiceFileRow(target.sectionCode, target.fieldKey);
  if (!row) {
    meetingInvoiceOcrConfirmVisible.value = false;
    return;
  }
  const normalized = meetingInvoiceOcrConfirmData.value.normalized || {};
  row.ocr = {
    status: "success",
    normalized,
    raw: meetingInvoiceOcrConfirmData.value.raw,
    message: "",
    updatedAt: new Date().toISOString(),
  };
  const section = meetingInvoiceForm.value.sections[target.sectionCode];
  if (section && Number(meetingInvoiceOcrConfirmData.value.totalAmountCent || 0) > 0) {
    const currentAmountYuan = Number(section.totalAmountYuan || 0);
    const newAmountYuan = Number((Number(meetingInvoiceOcrConfirmData.value.totalAmountCent || 0) / 100).toFixed(2));
    section.totalAmountYuan = Number((currentAmountYuan + newAmountYuan).toFixed(2));
  }
  ElMessage.success("宸茬‘璁ゅ苟鍥炲～浼氳鍙戠エOCR缁撴灉");
  meetingInvoiceOcrConfirmVisible.value = false;
};

const cancelMeetingInvoiceOcrFill = () => {
  const target = meetingInvoiceOcrPendingTarget.value;
  if (target) {
    const row = getMeetingInvoiceFileRow(target.sectionCode, target.fieldKey);
    if (row?.ocr) {
      row.ocr = {
        ...row.ocr,
        status: "failed",
        message: "已取消回填",
        updatedAt: new Date().toISOString(),
      };
    }
  }
  meetingInvoiceOcrConfirmVisible.value = false;
};
const meetingInvoiceOtherUploadFileList = computed<UploadUserFile[]>(() =>
  meetingInvoiceOtherFieldSlots
    .map((fieldKey, index) => {
      const rows = meetingInvoiceForm.value.sections.OTHER?.files[fieldKey] || [];
      return rows.map((row, rowIndex) => {
        const ossKey = String(row?.ossKey || "").trim();
        if (!ossKey) {
          return null;
        }
        const name = String(row?.fileName || "").trim() || `鍏朵粬闄勪欢${index + 1}-${rowIndex + 1}`;
        const isImage = isImageDocFile(name, ossKey);
        return {
          name,
          url: isImage ? (docPreviewUrlMap.value[ossKey] || "") : "",
          status: "success",
          uid: 10000 + (index + 1) * 100 + rowIndex + 1,
          ossKey,
          sectionCode: "OTHER",
          fieldKey,
        } as MeetingInvoiceUploadUserFile;
      });
    })
    .flat()
    .filter((item): item is MeetingInvoiceUploadUserFile => !!item),
);
const getMeetingInvoiceOtherFileFieldKey = (item: UploadUserFile): MeetingInvoiceUploadFieldKey => {
  const raw = String((item as MeetingInvoiceUploadUserFile)?.fieldKey || "").trim();
  if (isMeetingInvoiceFieldKey(raw)) {
    return raw;
  }
  return "invoiceFile";
};
const buildMeetingInvoiceOtherReviewItemKey = (item: UploadUserFile) =>
  buildMeetingInvoiceFieldItemKey("OTHER", getMeetingInvoiceOtherFileFieldKey(item));
const selectedExpertPhotos = computed(() => {
  const expertId = Number(selectedExpertMaterialId.value || 0);
  if (!expertId) {
    return [];
  }
  return photoByExpert.value[expertId] || [];
});

const findCurrentLaborRow = (expertId: number): LaborRow | null => {
  const id = Number(expertId || 0);
  if (!id) {
    return null;
  }
  return laborByExpert.value[id] || null;
};

const getCurrentLaborRow = (expertId: number): LaborRow | null => {
  const id = Number(expertId || 0);
  if (!id) {
    return null;
  }
  if (!laborByExpert.value[id]) {
    laborByExpert.value[id] = createEmptyLaborRow();
  }
  return laborByExpert.value[id];
};

const selectedExpertLabor = computed(() => {
  const expertId = Number(selectedExpertMaterialId.value || 0);
  if (!expertId) {
    return createEmptyLaborRow();
  }
  return findCurrentLaborRow(expertId) || createEmptyLaborRow();
});

const isLaborRowEmpty = (row: LaborRow | null | undefined) => {
  if (!row) {
    return true;
  }
  const hasProtocol = Boolean(
    String(row.protocolName || "").trim()
    || String(row.protocolFileName || "").trim()
    || String(row.protocolOssKey || "").trim(),
  );
  const invoiceFiles = Array.isArray(row.invoiceFiles) ? row.invoiceFiles : [];
  const hasInvoiceFiles = invoiceFiles.some((file) =>
    Boolean(String(file?.fileName || "").trim() || String(file?.ossKey || "").trim()),
  );
  const hasInvoice = Boolean(
    hasInvoiceFiles
    || String(row.invoiceFileName || "").trim()
    || String(row.invoiceOssKey || "").trim(),
  );
  const hasAmount = Number(row.amountCent || 0) > 0;
  const hasRemark = Boolean(String(row.remark || "").trim());
  const hasInvoiceOcr = row.invoiceOcr?.status && row.invoiceOcr.status !== "idle";
  return !hasProtocol && !hasInvoice && !hasAmount && !hasRemark && !hasInvoiceOcr;
};
const selectedExpertLaborAmountYuan = computed({
  get: () => Number(toYuan(selectedExpertLabor.value.amountCent)),
  set: (yuan: number | null | undefined) => {
    if (isReviewApproved(currentLaborReviewKey.value)) {
      return;
    }
    const expertId = Number(selectedExpertMaterialId.value || 0);
    const row = getCurrentLaborRow(expertId);
    if (!row) {
      return;
    }
    row.amountCent = toCent(Number(yuan || 0));
  },
});

const upsertLaborInvoiceSummary = (
  expertId: number,
  payload: {
    ossKey?: string;
    fileName?: string;
    invoiceNo?: string;
    amountCent?: number | null;
    taxCent?: number | null;
    expenseType?: string;
  },
) => {
  const id = Number(expertId || 0);
  const ossKey = String(payload?.ossKey || "").trim();
  if (!id || !ossKey) {
    return;
  }
  const list = ensureExpertInvoiceRows(id);
  const fallbackName = getFileNameFromOssKey(ossKey);
  const fileName = String(payload?.fileName || "").trim() || fallbackName;
  const invoiceNo = String(payload?.invoiceNo || "").trim() || fallbackName || "未填写编码";
  const amountCent = Math.max(0, Math.round(Number(payload?.amountCent || 0)));
  const taxCent = Math.max(0, Math.round(Number(payload?.taxCent || 0)));
  const expenseType = String(payload?.expenseType || "").trim() || "劳务费";
  const nextRow: ExpertInvoiceRow = {
    invoiceNo,
    amountCent,
    taxCent,
    expenseType,
    fileName,
    ossKey,
  };
  const index = list.findIndex((item) => String(item?.ossKey || "").trim() === ossKey);
  if (index >= 0) {
    list[index] = nextRow;
  } else {
    list.push(nextRow);
  }
};

const removeLaborInvoiceSummary = (expertId: number, ossKey?: string) => {
  const id = Number(expertId || 0);
  const key = String(ossKey || "").trim();
  if (!id || !key) {
    return;
  }
  const list = invoiceByExpert.value[id] || [];
  const index = list.findIndex((item) => String(item?.ossKey || "").trim() === key);
  if (index >= 0) {
    list.splice(index, 1);
  }
};

const ensureExpertMaterialRows = () => {
  const ids = boundExpertRows.value.map((x: any) => Number(x?.expertId || 0)).filter((x: number) => x > 0);
  ids.forEach((expertId) => {
    if (!photoByExpert.value[expertId]) {
      photoByExpert.value[expertId] = [];
    }
    if (!laborByExpert.value[expertId]) {
      laborByExpert.value[expertId] = createEmptyLaborRow();
    }
    if (!invoiceByExpert.value[expertId]) {
      invoiceByExpert.value[expertId] = [];
    }
  });
  if (!selectedExpertMaterialId.value && ids.length > 0) {
    selectedExpertMaterialId.value = ids[0];
  }
};

const handleSelectExpertForMaterial = (row: any) => {
  selectedExpertMaterialId.value = Number(row?.expertId || 0) || null;
  ensureExpertMaterialRows();
};

const expertModuleDoneText = (expertId: number) => {
  const id = Number(expertId || 0);
  if (!id) {
    return "鏈～";
  }
  if (selectedModuleCode.value === "EXPERT_LIST" && expertSubModule.value === "ONSITE_PHOTO") {
    return `${(photoByExpert.value[id] || []).length}张`;
  }
  if (selectedModuleCode.value === "EXPERT_LIST" && expertSubModule.value === "LABOR_PROTOCOL") {
    const row = laborByExpert.value[id];
    if (!row || isLaborRowEmpty(row)) {
      return "鏈～";
    }
    return row.protocolOssKey ? "已上传协议" : "已填写";
  }
  return "-";
};

const putToSignedUrl = async (url: string, file: File, contentType?: string) => {
  const result = await fetch(url, {
    method: "PUT",
    headers: {
      "Content-Type": contentType || file.type || "application/octet-stream",
    },
    body: file,
  });
  if (!result.ok) {
    throw new Error(`涓婁紶澶辫触: ${result.status}`);
  }
};

const buildQueryParams = () => {
  const params: any = {};
  if (queryForm.value.projectId) {
    params.projectId = queryForm.value.projectId;
  }
  if (queryForm.value.projectName?.trim()) {
    params.projectName = queryForm.value.projectName.trim();
  }
  if (queryForm.value.topic?.trim()) {
    params.topic = queryForm.value.topic.trim();
  }
  if (queryForm.value.meetingStatus) {
    params.meetingStatus = queryForm.value.meetingStatus;
  }
  if (queryForm.value.auditStatus) {
    params.auditStatus = queryForm.value.auditStatus;
  }
  if (queryForm.value.currentAuditNode?.trim()) {
    params.currentAuditNode = queryForm.value.currentAuditNode.trim();
  }
  if (queryForm.value.currentAuditorUserId) {
    params.currentAuditorUserId = queryForm.value.currentAuditorUserId;
  }
  if (queryForm.value.meetingTimeRange?.length === 2) {
    params.meetingStartFrom = queryForm.value.meetingTimeRange[0];
    params.meetingStartTo = queryForm.value.meetingTimeRange[1];
  }
  if (queryForm.value.lastSubmitRange?.length === 2) {
    params.lastSubmitFrom = queryForm.value.lastSubmitRange[0];
    params.lastSubmitTo = queryForm.value.lastSubmitRange[1];
  }
  if (queryForm.value.includeDeleted) {
    params.includeDeleted = true;
  }
  return params;
};

const load = async () => {
  const resp = await fetchMeetings(buildQueryParams());
  rows.value = resp?.data?.list || [];
};

const setSummaryTaskState = (meetingId: number, patch: SummaryTaskState) => {
  summaryTaskMap.value[meetingId] = {
    ...(summaryTaskMap.value[meetingId] || {}),
    ...patch,
  };
};

const clearSummaryTaskPolling = (meetingId: number) => {
  const timer = summaryTaskTimerMap[meetingId];
  if (typeof timer === "number") {
    window.clearTimeout(timer);
    delete summaryTaskTimerMap[meetingId];
  }
};

const syncSummaryTaskStatus = async (meetingId: number, taskId?: number, silent = false) => {
  try {
    const resp = await fetchMeetingSummaryTaskStatus(
      meetingId,
      taskId && taskId > 0 ? { taskId } : undefined,
    );
    const data = resp?.data || {};
    const nextTaskId = Number(data?.taskId || taskId || 0);
    const nextState: SummaryTaskState = {
      taskId: nextTaskId > 0 ? nextTaskId : undefined,
      token: String(data?.downloadToken || "").trim() || undefined,
      expireAt: String(data?.expireAt || "").trim() || undefined,
      status: String(data?.status || "").trim().toUpperCase() || undefined,
      errorMessage: String(data?.errorMessage || "").trim() || undefined,
      finishedAt: String(data?.finishedAt || "").trim() || undefined,
      createdAt: String(data?.createdAt || "").trim() || undefined,
    };
    setSummaryTaskState(meetingId, nextState);
    const status = String(nextState.status || "").toUpperCase();
    if (status === "PENDING" || status === "PROCESSING") {
      return nextState;
    }
    clearSummaryTaskPolling(meetingId);
    return nextState;
  } catch (error) {
    if (silent) {
      return null;
    }
    throw error;
  }
};

const scheduleSummaryTaskPolling = (meetingId: number, taskId: number, delay = 3000) => {
  clearSummaryTaskPolling(meetingId);
  if (!taskId || taskId <= 0) {
    return;
  }
  summaryTaskTimerMap[meetingId] = window.setTimeout(async () => {
    const state = await syncSummaryTaskStatus(meetingId, taskId, true);
    const status = String(state?.status || "").toUpperCase();
    if (status === "PENDING" || status === "PROCESSING") {
      scheduleSummaryTaskPolling(meetingId, taskId, delay);
    }
  }, delay);
};

const ensureSummaryTaskState = async (meetingId: number) => {
  const cached = summaryTaskMap.value[meetingId] || {};
  const cachedTaskId = Number(cached.taskId || 0);
  return (
    (await syncSummaryTaskStatus(meetingId, cachedTaskId > 0 ? cachedTaskId : undefined, true)) ||
    summaryTaskMap.value[meetingId] ||
    null
  );
};

const showSummaryTaskStatusMessage = (state: SummaryTaskState | null | undefined) => {
  if (!state?.taskId) {
    ElMessage.warning("璇峰厛鐢熸垚浼氳鎬荤粨浠诲姟");
    return;
  }
  const status = String(state.status || "").toUpperCase();
  if (status === "SUCCESS") {
    ElMessage.success("会议总结已生成，可直接下载");
    return;
  }
  if (status === "FAILED") {
    ElMessage.error(state.errorMessage ? `浼氳鎬荤粨浠诲姟澶辫触锛${state.errorMessage}` : "浼氳鎬荤粨浠诲姟澶辫触");
    return;
  }
  ElMessage.info(`浼氳鎬荤粨浠诲姟褰撳墠鐘舵€侊細${toZhStatus(status || "PENDING")}`);
};

const syncSearchRoute = async () => {
  const topicKeyword = String(route.query.topic || "").trim();
  if (queryForm.value.topic !== topicKeyword) {
    queryForm.value.topic = topicKeyword;
  }
  await load();

  const meetingId = Number(route.query.meetingId || 0);
  if (!Number.isFinite(meetingId) || meetingId <= 0) {
    return;
  }
  try {
    const resp = await fetchMeetingDetail(meetingId);
    currentMeetingDetail.value = resp?.data || null;
    detailDrawerVisible.value = true;
  } catch (_e) {
    // ignore invalid deep link
  }
};

const resetQuery = async () => {
  queryForm.value = {
    projectId: undefined,
    projectName: "",
    topic: "",
    meetingStatus: "",
    auditStatus: "",
    currentAuditNode: "",
    currentAuditorUserId: undefined,
    meetingTimeRange: [],
    lastSubmitRange: [],
    includeDeleted: false,
  };
  await load();
};

const openEditDrawer = async (row: any) => {
  if (isMeetingAuditApproved(row)) {
    ElMessage.warning("该会议已审核通过，不允许再修改会议信息");
    return;
  }
  const resp = await fetchMeetingDetail(Number(row.id));
  const detail = resp?.data || row;
  const projectId = Number(detail.projectId || 0);
  let allowMeetingOverBudget = false;
  let projectInfo: any = null;
  if (projectId > 0) {
    try {
      const projectResp = await fetchProjects();
      const projectList = Array.isArray(projectResp?.data?.list)
        ? projectResp.data.list
        : (Array.isArray(projectResp?.data) ? projectResp.data : []);
      projectInfo = projectList.find((item: any) => Number(item?.id || 0) === projectId) || null;
      allowMeetingOverBudget = !!projectInfo?.allowMeetingOverBudget;
    } catch (_e) {
      allowMeetingOverBudget = false;
    }
  }
  currentEditMeetingId.value = Number(row.id);
  currentEditOriginalBudgetCent.value = Math.max(0, Number(detail.budgetCent || 0));
  currentEditAllowMeetingOverBudget.value = allowMeetingOverBudget;
  meetingForm.value = {
    projectId,
    projectName: detail.projectName || projectInfo?.name || "",
    projectStartDate: detail.projectStartDate || projectInfo?.startDate || "",
    projectEndDate: detail.projectEndDate || projectInfo?.endDate || "",
    topic: detail.topic || "",
    meetingCategory: detail.meetingCategory || "",
    meetingForm: detail.meetingForm || "",
    location: detail.location || "",
    startTime: detail.startTime || "",
    endTime: detail.endTime || "",
    budgetYuan: Number((Number(detail.budgetCent || 0) / 100).toFixed(2)),
    laborRatio: Number(detail.laborRatio || 0),
    cateringRatio: Number(detail.cateringRatio || 0),
  };
  editDrawerVisible.value = true;
};

const openDetailDrawer = async (row: any) => {
  const meetingId = Number(row.id || 0);
  const [detailResp, changeResp] = await Promise.all([
    fetchMeetingDetail(meetingId),
    canChangeLogRead.value ? fetchMeetingChangeLogs(meetingId) : Promise.resolve(null),
  ]);
  currentMeetingDetail.value = detailResp?.data || row;
  meetingChangeLogs.value = changeResp?.data || [];
  detailDrawerVisible.value = true;
};

const handleUpdate = async () => {
  if (!currentEditMeetingId.value) {
    return;
  }
  const currentRow = rows.value.find((item: any) => Number(item?.id || 0) === Number(currentEditMeetingId.value || 0));
  if (isMeetingAuditApproved(currentRow)) {
    ElMessage.warning("该会议已审核通过，不允许再修改会议信息");
    editDrawerVisible.value = false;
    return;
  }
  const nextBudgetCent = toCent(meetingForm.value.budgetYuan);
  const prevBudgetCent = Math.max(0, Number(currentEditOriginalBudgetCent.value || 0));
  if (!currentEditAllowMeetingOverBudget.value && nextBudgetCent > prevBudgetCent) {
    ElMessage.warning("褰撳墠椤圭洰涓嶅厑璁镐細璁绠楄皟楂橈紝浠呮敮鎸佷繚鎸佷笉鍙樻垨璋冧綆棰勭畻");
    return;
  }
  await updateMeeting(currentEditMeetingId.value, {
    projectId: meetingForm.value.projectId,
    topic: meetingForm.value.topic,
    meetingCategory: meetingForm.value.meetingCategory,
    meetingForm: meetingForm.value.meetingForm,
    location: meetingForm.value.location,
    startTime: meetingForm.value.startTime,
    endTime: meetingForm.value.endTime,
    budgetCent: nextBudgetCent,
    laborRatio: meetingForm.value.laborRatio,
    cateringRatio: meetingForm.value.cateringRatio,
  });
  ElMessage.success("浼氳缂栬緫鎴愬姛");
  editDrawerVisible.value = false;
  await load();
};

const REQUIRED_MODULES = [
  { code: "BASIC_INFO", name: "浼氳鍩烘湰淇℃伅妯″潡" },
  { code: "WRITE_OFF_DOCS", name: "鏍搁攢鏉愭枡妯″潡" },
  { code: "EXPERT_PROFILE", name: "涓撳绠€浠?涓插満妯″潡" },
  { code: "EXPERT_LIST", name: "涓撳鍒楄〃妯″潡" },
  { code: "MEETING_INVOICE", name: "浼氳鍙戠エ妯″潡" },
];

const handleSubmit = async (meetingId: number) => {
  const materialsResp = await fetchMeetingMaterials(meetingId);
  const materials = Array.isArray(materialsResp?.data?.list)
    ? materialsResp.data.list
    : Array.isArray(materialsResp?.data)
      ? materialsResp.data
      : [];

  const missingModuleNames = REQUIRED_MODULES
    .filter((mod) => {
      const backendModuleCode = resolveBackendMaterialModuleCode(mod.code);
      const material = materials.find((item: any) => String(item?.moduleCode || "").trim() === backendModuleCode);
      if (!material) {
        return true;
      }
      if (mod.code === "EXPERT_PROFILE") {
        const parsed = safeParse(String(material?.contentJson || "{}"));
        return !String(parsed?.profileFile?.ossKey || parsed?.ossKey || "").trim();
      }
      return false;
    })
    .map((mod) => mod.name);

  if (missingModuleNames.length > 0) {
    ElMessageBox.alert(`请先完善以下模块后再提交审核：${missingModuleNames.join("、")}`, "提示", {
      confirmButtonText: "纭畾",
      type: "warning",
    });
    return;
  }

  await submitMeeting(meetingId, {
    idempotencyKey: `submit-${Date.now()}-${meetingId}`,
    remark: "会议级提交审核",
  });
  ElMessage.success("会议已提交审核");
  await load();
};

const handleWithdraw = async (meetingId: number) => {
  await withdrawMeeting(meetingId, {
    idempotencyKey: `withdraw-${Date.now()}-${meetingId}`,
    reason: "浼氳鎾ゅ洖鎻愪氦",
  });
  ElMessage.success("会议已撤回到待提交状态");
  await load();
};

const handleDeleteDraft = async (meetingId: number) => {
  await ElMessageBox.confirm("确认删除该草稿会议？删除后不可恢复。", "删除草稿", {
    type: "warning",
    confirmButtonText: "纭鍒犻櫎",
    cancelButtonText: "鍙栨秷",
  });
  await deleteMeeting(meetingId);
  ElMessage.success("草稿会议已删除");
  await load();
};

const handleCancelMeeting = async (meetingId: number) => {
  const result = await ElMessageBox.prompt("请输入取消原因", "取消会议", {
    confirmButtonText: "纭鍙栨秷",
    cancelButtonText: "杩斿洖",
    inputPlaceholder: "请输入取消原因",
    inputValidator: (val: string) => (val && val.trim() ? true : "鍙栨秷鍘熷洜涓嶈兘涓虹┖"),
  }).catch(() => null);
  if (!result) return;
  await cancelMeeting(meetingId, { reason: result.value });
  ElMessage.success("会议已取消");
  await load();
};

const handleExportMaterials = async (meetingId: number) => {
  const resp = await createMeetingMaterialsExportTask(meetingId, {
    idempotencyKey: `material-export-${meetingId}-${Date.now()}`,
    fileName: buildMeetingExportFileName(meetingId, "会议资料包", ".zip"),
  });
  const taskId = Number(resp?.data?.taskId || 0);
  ElMessage.success(taskId > 0 ? `璧勬枡鍖呭鍑轰换鍔″凡鍒涘缓锛${taskId}锛岃鍓嶅線瀵煎嚭浠诲姟涓績涓嬭浇` : "璧勬枡鍖呭鍑轰换鍔″凡鍒涘缓锛岃鍓嶅線瀵煎嚭浠诲姟涓績涓嬭浇");
};

const handleGenerateSummary = async (meetingId: number) => {
  const resp = await generateMeetingSummaryTask(meetingId, {
    idempotencyKey: `summary-generate-${meetingId}-${Date.now()}`,
    fileName: buildMeetingExportFileName(meetingId, "浼氳鎬荤粨", ".docx"),
  });
  const taskId = Number(resp?.data?.taskId || 0);
  if (taskId > 0) {
    setSummaryTaskState(meetingId, {
      taskId,
      status: String(resp?.data?.status || "PENDING").trim().toUpperCase() || "PENDING",
      token: undefined,
      expireAt: undefined,
      errorMessage: undefined,
      finishedAt: undefined,
    });
    scheduleSummaryTaskPolling(meetingId, taskId);
  }
  ElMessage.success(taskId > 0 ? `会议总结任务已创建：${taskId}，当前状态为待处理` : "会议总结任务已创建");
};

const handleCheckSummaryStatus = async (meetingId: number) => {
  const state = await ensureSummaryTaskState(meetingId);
  showSummaryTaskStatusMessage(state);
};

const handleRefreshSummaryToken = async (meetingId: number) => {
  const state = await ensureSummaryTaskState(meetingId);
  const taskId = Number(state?.taskId || 0);
  if (!taskId) {
    ElMessage.warning("璇峰厛鐢熸垚浼氳鎬荤粨浠诲姟");
    return;
  }
  const status = String(state?.status || "").toUpperCase();
  if (status !== "SUCCESS") {
    showSummaryTaskStatusMessage(state);
    return;
  }
  const resp = await refreshMeetingSummaryToken(meetingId, { taskId });
  setSummaryTaskState(meetingId, {
    taskId,
    status: "SUCCESS",
    token: String(resp?.data?.downloadToken || "").trim() || undefined,
    expireAt: String(resp?.data?.expireAt || "").trim() || undefined,
  });
  ElMessage.success("下载令牌已刷新");
};

const handleDownloadSummary = async (meetingId: number) => {
  const state = ((await ensureSummaryTaskState(meetingId)) || {}) as SummaryTaskState;
  const status = String(state.status || "").toUpperCase();
  if (status !== "SUCCESS") {
    showSummaryTaskStatusMessage(state);
    return;
  }
  if (!state.taskId || !state.token) {
    const refreshResp = await refreshMeetingSummaryToken(meetingId, { taskId: Number(state.taskId) });
    setSummaryTaskState(meetingId, {
      taskId: Number(state.taskId),
      status: "SUCCESS",
      token: String(refreshResp?.data?.downloadToken || "").trim() || undefined,
      expireAt: String(refreshResp?.data?.expireAt || "").trim() || undefined,
    });
    state.token = String(refreshResp?.data?.downloadToken || "").trim() || undefined;
  }
  const resp = await downloadMeetingSummary(meetingId, { taskId: state.taskId, token: state.token });
  const signedUrl = resp?.data?.signedUrl;
  if (signedUrl) {
    window.open(signedUrl, "_blank");
  }
  ElMessage.success("总结下载链接已生成");
};

const buildMeetingExportFileName = (meetingId: number, suffix: string, extension: string) => {
  const row = rows.value.find((item: any) => Number(item?.id || 0) === meetingId);
  const topic = String(row?.topic || currentMeetingDetail.value?.topic || "").trim();
  const safeTopic = (topic || "会议").replace(/[\\/:*?"<>|]/g, "_").replace(/\s+/g, " ").trim();
  const ext = extension.startsWith(".") ? extension : `.${extension}`;
  return `${safeTopic}-${suffix}${ext}`;
};

const loadBoundExperts = async (meetingId: number, requestId?: number) => {
  try {
    const resp = await fetchMeetingExpertBindings(meetingId);
    if (typeof requestId === "number" && !isMaterialLoadActive(requestId, meetingId)) {
      return [];
    }
    const list = resp?.data || [];
    boundExpertRows.value = list.map((item: any) => ({
      expertId: item?.expertId,
      // 浼樺厛灞曠ず鍚庣杩斿洖鐨勮劚鏁忓瓧娈碉紝鏈彁渚涙椂鍏滃簳鍘熷瓧娈?
      expertName: item?.expertNameMasked || item?.maskedExpertName || item?.expertName || "",
      title: item?.title || "",
      organization: item?.organizationMasked || item?.maskedOrganization || item?.organization || "",
      phone: item?.phoneMasked || item?.maskedPhone || item?.phone || "",
    }));
    basicForm.value.guestCount = boundExpertRows.value.length;
    ensureExpertMaterialRows();
    return list;
  } catch (_e) {
    if (typeof requestId === "number" && !isMaterialLoadActive(requestId, meetingId)) {
      return [];
    }
    boundExpertRows.value = [];
    basicForm.value.guestCount = 0;
    selectedExpertMaterialId.value = null;
    return [];
  }
};

const loadPlatformExperts = async () => {
  const keyword = String(bindExpertKeyword.value || "").trim();
  if (!keyword) {
    ElMessage.warning("请输入身份证号");
    return;
  }
  if (!idNoSearchPattern.test(keyword)) {
    ElMessage.warning("仅支持身份证号搜索");
    return;
  }
  try {
    const resp = await fetchMeetingPlatformExperts({ keyword });
    platformExpertRows.value = resp?.data?.list || [];
    for (const item of platformExpertRows.value) {
      const id = Number(item?.id || 0);
      if (!id) {
        continue;
      }
      selectedPlatformExpertNameMap.value[id] = String(item?.expertName || "").trim() || `涓撳#${id}`;
    }
    const rowIdSet = new Set(platformExpertRows.value.map((item: any) => Number(item?.id || 0)).filter((id: number) => id > 0));
    currentSearchSelectedPlatformExpertIds.value = selectedPlatformExpertIds.value.filter((id) => rowIdSet.has(id));
  } catch (e: any) {
    platformExpertRows.value = [];
    currentSearchSelectedPlatformExpertIds.value = [];
    ElMessage.error(e?.response?.data?.message || "骞冲彴涓撳鍔犺浇澶辫触");
  } finally {
    platformExpertSearched.value = true;
  }
};

const getBoundExpertIdSet = () =>
  new Set(
    boundExpertRows.value
      .map((item: any) => Number(item?.expertId || 0))
      .filter((id: number) => id > 0),
  );

const getPlatformExpertRowIdSet = () =>
  new Set(
    platformExpertRows.value
      .map((item: any) => Number(item?.id || 0))
      .filter((id: number) => id > 0),
  );

const findBoundExpertRow = (expertId: number) =>
  boundExpertRows.value.find((item: any) => Number(item?.expertId || 0) === expertId) || null;

const removeSelectedPlatformExpertLocal = (expertId: number) => {
  const targetId = Number(expertId || 0);
  if (!targetId) {
    return;
  }
  selectedPlatformExpertIds.value = selectedPlatformExpertIds.value.filter((id) => id !== targetId);
  currentSearchSelectedPlatformExpertIds.value = currentSearchSelectedPlatformExpertIds.value.filter((id) => Number(id || 0) !== targetId);
};

const handleCurrentSearchExpertSelectChange = (checkedIds: Array<number | string>) => {
  const rowIdSet = getPlatformExpertRowIdSet();
  const checkedIdSet = new Set(
    (checkedIds || [])
      .map((id) => Number(id || 0))
      .filter((id) => id > 0),
  );
  const boundExpertIdSet = getBoundExpertIdSet();
  const removedBoundIds = selectedPlatformExpertIds.value.filter(
    (id) => rowIdSet.has(id) && boundExpertIdSet.has(id) && !checkedIdSet.has(id),
  );
  if (removedBoundIds.length > 0) {
    removedBoundIds.forEach((id) => checkedIdSet.add(id));
    currentSearchSelectedPlatformExpertIds.value = Array.from(checkedIdSet);
    ElMessage.warning("宸茬粦瀹氫笓瀹惰鍏堣蛋瑙ｇ粦娴佺▼锛屼笉鑳界洿鎺ュ湪妫€绱㈢粨鏋滀腑鍒犻櫎");
  }
  const nextSelected = selectedPlatformExpertIds.value
    .filter((id) => !rowIdSet.has(id));
  checkedIdSet.forEach((id) => nextSelected.push(id));
  selectedPlatformExpertIds.value = Array.from(new Set(nextSelected));
};

const removeSelectedPlatformExpert = async (expertId: number) => {
  const targetId = Number(expertId || 0);
  if (!targetId) {
    return;
  }
  const boundRow = findBoundExpertRow(targetId);
  if (boundRow) {
    await handleUnbindExpertGuarded(boundRow);
    return;
  }
  removeSelectedPlatformExpertLocal(targetId);
};

const openCreatePlatformExpertDialog = () => {
  createPlatformExpertForm.value = {
    expertName: "",
    idNo: "",
    phone: "",
    titleCode: "",
    title: "",
    hospitalCode: "",
    organization: "",
    idCardFrontOssKey: "",
    idCardBackOssKey: "",
    bankName: "",
    bankProvince: "",
    bankCity: "",
    bankBranchName: "",
    bankCardNo: "",
    accountName: "",
    bankCardFrontOssKey: "",
  };
  createExpertIdFrontFile.value = null;
  createExpertIdBackFile.value = null;
  createExpertIdFrontPreviewUrl.value = "";
  createExpertIdBackPreviewUrl.value = "";
  createExpertIdOcrDialogVisible.value = false;
  createExpertCardFrontFile.value = null;
  createExpertCardFrontPreviewUrl.value = "";
  createExpertBankCardOcrDialogVisible.value = false;
  createPlatformExpertVisible.value = true;
};

const onCreateExpertIdFrontChange = async (uploadFile: any) => {
  createExpertIdFrontFile.value = (uploadFile?.raw as File) || null;
  if (!createExpertIdFrontFile.value || createExpertIdFrontUploading.value) {
    return;
  }
  createExpertIdFrontUploading.value = true;
  try {
    await uploadCreateExpertAsset("ID_FRONT");
  } finally {
    createExpertIdFrontUploading.value = false;
  }
};

const onCreateExpertIdBackChange = async (uploadFile: any) => {
  createExpertIdBackFile.value = (uploadFile?.raw as File) || null;
  if (!createExpertIdBackFile.value || createExpertIdBackUploading.value) {
    return;
  }
  createExpertIdBackUploading.value = true;
  try {
    await uploadCreateExpertAsset("ID_BACK");
  } finally {
    createExpertIdBackUploading.value = false;
  }
};

const onCreateExpertCardFrontChange = async (uploadFile: any) => {
  createExpertCardFrontFile.value = (uploadFile?.raw as File) || null;
  if (!createExpertCardFrontFile.value || createExpertCardFrontUploading.value) {
    return;
  }
  createExpertCardFrontUploading.value = true;
  try {
    await uploadCreateExpertAsset("CARD");
  } finally {
    createExpertCardFrontUploading.value = false;
  }
};

const runCreateExpertIdOcr = async (side: "front" | "back", objectKey: string) => {
  try {
    const resp = await recognizeIdCard({ objectKey, idCardSide: side });
    const normalized = resp?.data?.normalized || {};
    createExpertIdOcrData.value = {
      side,
      name: String(normalized?.name || ""),
      idNo: String(normalized?.idNo || ""),
      gender: String(normalized?.gender || ""),
      ethnicity: String(normalized?.ethnicity || ""),
      birth: String(normalized?.birth || ""),
      address: String(normalized?.address || ""),
      issueAuthority: String(normalized?.issueAuthority || ""),
      signDate: String(normalized?.signDate || ""),
      expiryDate: String(normalized?.expiryDate || ""),
    };
    createExpertIdOcrDialogVisible.value = true;
  } catch (e: any) {
    ElMessage.warning(e?.response?.data?.message || e?.message || "韬唤璇丱CR璇嗗埆澶辫触");
  }
};

const confirmCreateExpertIdOcrFill = () => {
  if (createExpertIdOcrData.value.side === "front") {
    if (createExpertIdOcrData.value.name) {
      createPlatformExpertForm.value.expertName = createExpertIdOcrData.value.name;
    }
    if (createExpertIdOcrData.value.idNo) {
      createPlatformExpertForm.value.idNo = createExpertIdOcrData.value.idNo;
    }
  }
  createExpertIdOcrDialogVisible.value = false;
  ElMessage.success("宸茬‘璁ゅ苟鍥炲～韬唤璇丱CR缁撴灉");
};

const cancelCreateExpertIdOcrFill = () => {
  createExpertIdOcrDialogVisible.value = false;
};

const runCreateExpertBankCardOcr = async (objectKey: string) => {
  try {
    const resp = await recognizeBankCard({ objectKey });
    const normalized = resp?.data?.normalized || {};
    createExpertBankCardOcrData.value = {
      bankName: String(normalized?.bankName || ""),
      bankCardNo: String(normalized?.bankCardNumber || "").replace(/\s+/g, ""),
      accountName: String(normalized?.holderName || ""),
      validDate: String(normalized?.validDate || ""),
      bankCardType: String(normalized?.bankCardType || ""),
    };
    createExpertBankCardOcrDialogVisible.value = true;
  } catch (e: any) {
    ElMessage.warning(e?.response?.data?.message || e?.message || "閾惰鍗CR璇嗗埆澶辫触");
  }
};

const confirmCreateExpertBankCardOcrFill = () => {
  if (createExpertBankCardOcrData.value.bankName) {
    createPlatformExpertForm.value.bankName = createExpertBankCardOcrData.value.bankName;
  }
  if (createExpertBankCardOcrData.value.bankCardNo) {
    createPlatformExpertForm.value.bankCardNo = createExpertBankCardOcrData.value.bankCardNo;
  }
  if (createExpertBankCardOcrData.value.accountName) {
    createPlatformExpertForm.value.accountName = createExpertBankCardOcrData.value.accountName;
  }
  createExpertBankCardOcrDialogVisible.value = false;
  ElMessage.success("宸茬‘璁ゅ苟鍥炲～閾惰鍗CR缁撴灉");
};

const cancelCreateExpertBankCardOcrFill = () => {
  createExpertBankCardOcrDialogVisible.value = false;
};

const uploadCreateExpertAsset = async (assetType: "ID_FRONT" | "ID_BACK" | "CARD") => {
  if (!selectedExpertManageMeetingId.value) {
    ElMessage.warning("璇峰厛閫夋嫨浼氳鍚庡啀鏂板涓撳");
    return;
  }
  const file =
    assetType === "ID_FRONT"
      ? createExpertIdFrontFile.value
      : assetType === "ID_BACK"
      ? createExpertIdBackFile.value
      : createExpertCardFrontFile.value;
  if (!file) {
    ElMessage.warning("璇峰厛閫夋嫨鏂囦欢");
    return;
  }
  const signResp = await fetchMeetingMaterialUploadSign(selectedExpertManageMeetingId.value, "BASIC_INFO", {
    fileName: file.name,
    contentType: file.type || "application/octet-stream",
  });
  const uploadUrl = signResp?.data?.uploadUrl;
  const objectKey = signResp?.data?.objectKey;
  if (!uploadUrl || !objectKey) {
    ElMessage.error("鑾峰彇涓婁紶绛惧悕澶辫触");
    return;
  }
  await putToSignedUrl(uploadUrl, file, signResp?.data?.contentType || file.type);
  if (assetType === "ID_FRONT") {
    createPlatformExpertForm.value.idCardFrontOssKey = objectKey;
    createExpertIdFrontPreviewUrl.value = isImageFile(file.name) ? URL.createObjectURL(file) : "";
    createExpertIdFrontFile.value = null;
    await runCreateExpertIdOcr("front", objectKey);
  } else if (assetType === "ID_BACK") {
    createPlatformExpertForm.value.idCardBackOssKey = objectKey;
    createExpertIdBackPreviewUrl.value = isImageFile(file.name) ? URL.createObjectURL(file) : "";
    createExpertIdBackFile.value = null;
    await runCreateExpertIdOcr("back", objectKey);
  } else if (assetType === "CARD") {
    createPlatformExpertForm.value.bankCardFrontOssKey = objectKey;
    createExpertCardFrontPreviewUrl.value = isImageFile(file.name) ? URL.createObjectURL(file) : "";
    createExpertCardFrontFile.value = null;
    await runCreateExpertBankCardOcr(objectKey);
  }
  ElMessage.success("涓婁紶鎴愬姛");
};

const loadExpertDictOptions = async () => {
  const resp = await fetchExpertDictionaryOptions();
  expertTitleOptions.value = resp?.data?.titles || [];
  expertHospitalOptions.value = resp?.data?.hospitals || [];
};

const loadMeetingCategoryOptions = async () => {
  const resp = await fetchDictionaries({ dictType: "MEETING_CATEGORY", enabledOnly: true });
  meetingCategoryOptions.value = resp?.data || [];
};

const handleCreatePlatformExpert = async () => {
  if (!createPlatformExpertForm.value.expertName.trim()) {
    ElMessage.warning("请填写专家姓名");
    return;
  }
  if (!createPlatformExpertForm.value.idNo.trim()) {
    ElMessage.warning("请填写身份证号");
    return;
  }
  if (!createPlatformExpertForm.value.phone.trim()) {
    ElMessage.warning("璇峰～鍐欐墜鏈哄彿");
    return;
  }
  if (!createPlatformExpertForm.value.titleCode.trim()) {
    ElMessage.warning("璇烽€夋嫨鑱岀О");
    return;
  }
  if (!createPlatformExpertForm.value.hospitalCode.trim()) {
    ElMessage.warning("璇烽€夋嫨鍖婚櫌");
    return;
  }
  if (!createPlatformExpertForm.value.bankName.trim()) {
    ElMessage.warning("璇峰～鍐欏紑鎴疯");
    return;
  }
  if (!createPlatformExpertForm.value.bankProvince.trim()) {
    ElMessage.warning("璇峰～鍐欏紑鎴疯鐪佷唤");
    return;
  }
  if (!createPlatformExpertForm.value.bankCity.trim()) {
    ElMessage.warning("璇峰～鍐欏紑鎴疯鍩庡競");
    return;
  }
  if (!createPlatformExpertForm.value.bankBranchName.trim()) {
    ElMessage.warning("璇峰～鍐欒缁嗗紑鎴疯");
    return;
  }
  if (!createPlatformExpertForm.value.bankCardNo.trim()) {
    ElMessage.warning("请填写银行卡号");
    return;
  }
  if (!createPlatformExpertForm.value.accountName.trim()) {
    ElMessage.warning("璇峰～鍐欏紑鎴峰悕");
    return;
  }
  const selectedTitle = expertTitleOptions.value.find((item) => item.dictCode === createPlatformExpertForm.value.titleCode);
  const selectedHospital = expertHospitalOptions.value.find((item) => item.dictCode === createPlatformExpertForm.value.hospitalCode);
  const expertResp = await createMeetingPlatformExpert({
    expertName: createPlatformExpertForm.value.expertName.trim(),
    idNo: createPlatformExpertForm.value.idNo.trim(),
    phone: createPlatformExpertForm.value.phone.trim(),
    titleCode: createPlatformExpertForm.value.titleCode || undefined,
    title: selectedTitle?.dictName || undefined,
    hospitalCode: createPlatformExpertForm.value.hospitalCode || undefined,
    organization: selectedHospital?.dictName || undefined,
    idCardFrontOssKey: createPlatformExpertForm.value.idCardFrontOssKey || undefined,
    idCardBackOssKey: createPlatformExpertForm.value.idCardBackOssKey || undefined,
  });
  const expertId = Number(expertResp?.data?.id || 0);
  if (expertId > 0) {
    await addMeetingTenantExpertBankCard(expertId, {
      bankName: createPlatformExpertForm.value.bankName.trim(),
      bankProvince: createPlatformExpertForm.value.bankProvince.trim(),
      bankCity: createPlatformExpertForm.value.bankCity.trim(),
      bankBranchName: createPlatformExpertForm.value.bankBranchName.trim(),
      bankCardNo: createPlatformExpertForm.value.bankCardNo.trim(),
      accountName: createPlatformExpertForm.value.accountName.trim(),
      isDefault: true,
      bankCardFrontOssKey: createPlatformExpertForm.value.bankCardFrontOssKey.trim() || undefined,
    });
  }
  ElMessage.success("专家已新增到平台库");
  createPlatformExpertVisible.value = false;
  bindExpertKeyword.value = createPlatformExpertForm.value.idNo.trim();
  await loadPlatformExperts();
};

const openBindExperts = async (row: any) => {
  selectedExpertManageMeetingId.value = row.id;
  selectedExpertManageMeetingAuditApproved.value = isMeetingAuditApproved(row);
  selectedExpertManageMeetingMaterialLocked.value = isMeetingMaterialLockedStatus(row?.auditStatus);
  expertManageActiveTab.value = canManageMeetingExperts.value ? "search" : "bound";
  bindExpertKeyword.value = "";
  platformExpertRows.value = [];
  platformExpertSearched.value = false;
  currentSearchSelectedPlatformExpertIds.value = [];
  const profiles = await loadBoundExperts(row.id);
  selectedPlatformExpertNameMap.value = {};
  for (const item of profiles || []) {
    const id = Number(item?.expertId || 0);
    if (!id) {
      continue;
    }
    selectedPlatformExpertNameMap.value[id] = String(item?.expertName || "").trim() || `涓撳#${id}`;
  }
  selectedPlatformExpertIds.value = profiles
    .map((item: any) => Number(item?.expertId || 0))
    .filter((id: number) => id > 0);
  bindExpertVisible.value = true;
};

const handleBindExperts = async () => {
  if (!selectedExpertManageMeetingId.value) {
    return;
  }
  if (!canManageMeetingExperts.value) {
    ElMessage.warning("该会议资料审核中或已审核通过，不允许再绑定专家");
    bindExpertVisible.value = false;
    return;
  }
  const meetingId = selectedExpertManageMeetingId.value;
  await bindMeetingExperts(meetingId, {
    expertIds: selectedPlatformExpertIds.value,
  });
  ElMessage.success("涓撳缁戝畾鎴愬姛");
  await loadBoundExperts(meetingId);
  bindExpertVisible.value = false;
};

const handleUnbindExpert = async (row: any) => {
  if (!selectedExpertManageMeetingId.value) {
    return;
  }
  if (!canManageMeetingExperts.value) {
    ElMessage.warning("该会议资料审核中或已审核通过，不允许再解绑专家");
    return;
  }
  const confirm = await ElMessageBox.confirm(
    `确认解绑专家「${row?.expertName || row?.expertId || ""}」吗？`,
    "瑙ｇ粦纭",
    {
      confirmButtonText: "纭畾",
      cancelButtonText: "鍙栨秷",
      type: "warning",
    },
  ).catch(() => null);
  if (!confirm) {
    return;
  }
  try {
    const targetId = Number(row?.expertId || 0);
    await unbindMeetingExpert(selectedExpertManageMeetingId.value, targetId);
    ElMessage.success("瑙ｇ粦鎴愬姛");
    await loadBoundExperts(selectedExpertManageMeetingId.value);
    removeSelectedPlatformExpertLocal(targetId);
  } catch (_e) {
    // Request interceptor already shows the backend message.
  }
};

const handleOpenBindExperts = async (row: any) => {
  await openBindExperts(row);
};

const pollMeetingLaborAgreementExtract = async (meetingId: number, taskId: string) => {
  const maxAttempts = 20;
  for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
    if (attempt > 0) {
      await new Promise((resolve) => window.setTimeout(resolve, 3000));
    }
    const resp = await queryMeetingLaborAgreementExtract(meetingId, { taskId });
    const data = resp?.data;
    const status = String(data?.status || "").trim().toUpperCase();
    if (status === "SUCCESS") {
      return data;
    }
    if (status === "FAILED") {
      throw new Error(String(data?.reason || "鍔冲姟鍗忚鎶藉彇澶辫触"));
    }
  }
  throw new Error("鍔冲姟鍗忚鎶藉彇瓒呮椂锛岃绋嶅悗閲嶈瘯");
};

const openMeetingLaborAgreementExtractConfirm = (
  meetingId: number,
  taskId: string,
  objectKey: string,
  fileName: string,
  extracted: any,
) => {
  laborAgreementExtractConfirmData.value = {
    meetingId,
    taskId,
    objectKey: String(objectKey || "").trim(),
    fileName: String(fileName || "").trim(),
    parsedExpert: extracted?.parsedExpert || null,
    existingExpert: extracted?.existingExpert || null,
    needsConfirm: !!extracted?.needsConfirm,
    nameMismatchFlag: !!extracted?.nameMismatchFlag,
  };
  laborAgreementExtractConfirmVisible.value = true;
};

const finishMeetingLaborAgreementExtractApply = async (meetingId: number) => {
  await load();
  if (bindExpertVisible.value && Number(selectedExpertManageMeetingId.value || 0) === meetingId) {
    await loadBoundExperts(meetingId);
  }
  if (materialDialogVisible.value && Number(selectedMeetingId.value || 0) === meetingId) {
    startMaterialModuleLoad(meetingId, selectedModuleCode.value, { reloadExperts: true });
  }
};

const confirmMeetingLaborAgreementExtractApply = async () => {
  const meetingId = Number(laborAgreementExtractConfirmData.value.meetingId || 0);
  const taskId = String(laborAgreementExtractConfirmData.value.taskId || "").trim();
  if (!meetingId || !taskId || laborAgreementExtractApplying.value) {
    return;
  }
  const existingExpertId = Number(laborAgreementExtractConfirmData.value?.existingExpert?.expertId || 0);
  const updateExistingExpert = existingExpertId > 0 && !!laborAgreementExtractConfirmData.value?.needsConfirm;
  laborAgreementExtractApplying.value = true;
  try {
    await applyMeetingLaborAgreementExtract(meetingId, {
      taskId,
      existingExpertId: existingExpertId > 0 ? existingExpertId : undefined,
      updateExistingExpert: updateExistingExpert || undefined,
      objectKey: String(laborAgreementExtractConfirmData.value.objectKey || "").trim() || undefined,
      fileName: String(laborAgreementExtractConfirmData.value.fileName || "").trim() || undefined,
    });
    await finishMeetingLaborAgreementExtractApply(meetingId);
    laborAgreementExtractConfirmVisible.value = false;
    ElMessage.success("劳务协议抽取并导入成功");
    if (laborAgreementExtractConfirmData.value.nameMismatchFlag) {
      ElMessage.warning("OCR识别到乙方与账户名不一致，已允许保存并自动打标，请人工复核。");
    }
  } finally {
    laborAgreementExtractApplying.value = false;
  }
};

const cancelMeetingLaborAgreementExtractApply = () => {
  if (laborAgreementExtractApplying.value) {
    return;
  }
  laborAgreementExtractConfirmVisible.value = false;
};
const applyMeetingLaborAgreementExtractFlow = async (meetingId: number, taskId: string, extracted: any) => {
  openMeetingLaborAgreementExtractConfirm(
    meetingId,
    taskId,
    String(extracted?._objectKey || ""),
    String(extracted?._fileName || ""),
    extracted,
  );
  return true;
};
const handleMeetingLaborAgreementFilePicked = async (event: Event) => {
  const input = event.target as HTMLInputElement | null;
  const file = input?.files?.[0] || null;
  const meetingId = Number(meetingLaborAgreementTargetMeetingId.value || 0);
  if (input) {
    input.value = "";
  }
  if (!meetingId || !file) {
    return;
  }
  if (meetingLaborAgreementUploading.value) {
    return;
  }
  if (isMeetingMaterialLockedStatus(rows.value.find((item: any) => Number(item?.id || 0) === meetingId)?.auditStatus)) {
    warnMeetingMaterialReadonly();
    return;
  }
  if (!validateMaterialFile(file, "鍔冲姟鍗忚", 50)) {
    return;
  }
  meetingLaborAgreementUploading.value = true;
  try {
    const signResp = await fetchMeetingMaterialUploadSign(meetingId, "EXPERT_LIST", {
      fileName: file.name,
      contentType: file.type || "application/octet-stream",
    });
    const uploadUrl = signResp?.data?.uploadUrl;
    const objectKey = signResp?.data?.objectKey;
    if (!uploadUrl || !objectKey) {
      throw new Error("鑾峰彇涓婁紶绛惧悕澶辫触");
    }
    await putToSignedUrl(uploadUrl, file, signResp?.data?.contentType || file.type);
    const submitResp = await submitMeetingLaborAgreementExtractTask(meetingId, {
      objectKey,
      fileName: file.name,
    });
    const taskId = String(submitResp?.data?.taskId || "").trim();
    if (!taskId) {
      throw new Error("未获取到OCR任务号");
    }
    ElMessage.info("鍔冲姟鍗忚宸蹭笂浼狅紝姝ｅ湪鎶藉彇涓撳淇℃伅");
    const extracted = await pollMeetingLaborAgreementExtract(meetingId, taskId);
    await applyMeetingLaborAgreementExtractFlow(meetingId, taskId, extracted);
  } catch (error: any) {
    const message = String(error?.message || "").trim();
    if (message) {
      ElMessage.error(message);
    }
  } finally {
    meetingLaborAgreementUploading.value = false;
    meetingLaborAgreementTargetMeetingId.value = null;
  }
};

const handleUploadLaborAgreementEntry = async (row: any) => {
  const meetingId = Number(row?.id || 0);
  if (!meetingId) {
    return;
  }
  if (isMeetingMaterialLockedStatus(row?.auditStatus)) {
    warnMeetingMaterialReadonly();
    return;
  }
  if (!canMaterialSave.value) {
    ElMessage.warning("当前无会议资料保存权限");
    return;
  }
  meetingLaborAgreementTargetMeetingId.value = meetingId;
  await nextTick();
  meetingLaborAgreementFileInputRef.value?.click();
};

const handleBindExpertsGuarded = async () => {
  if (selectedExpertManageMeetingMaterialLocked.value || selectedExpertManageMeetingAuditApproved.value) {
    warnMeetingMaterialReadonly();
    bindExpertVisible.value = false;
    return;
  }
  await handleBindExperts();
};

const handleUnbindExpertGuarded = async (row: any) => {
  if (selectedExpertManageMeetingMaterialLocked.value || selectedExpertManageMeetingAuditApproved.value) {
    warnMeetingMaterialReadonly();
    return;
  }
  await handleUnbindExpert(row);
};

const moduleTitleMap: Record<string, string> = {
  BASIC_INFO: "浼氳鍩烘湰淇℃伅妯″潡",
  WRITE_OFF_DOCS: "鏍搁攢鏉愭枡妯″潡",
  EXPERT_PROFILE: "涓撳绠€浠?涓插満妯″潡",
  EXPERT_LIST: "涓撳鍒楄〃妯″潡",
  MEETING_INVOICE: "浼氳鍙戠エ妯″潡",
};
type BackendMaterialModuleCode = "BASIC_INFO" | "WRITE_OFF_DOCS" | "EXPERT_PROFILE" | "EXPERT_LIST" | "MEETING_INVOICE";
const resolveBackendMaterialModuleCode = (moduleCode: MaterialModuleCode): BackendMaterialModuleCode => {
  if (moduleCode === "EXPERT_PROFILE") {
    return "WRITE_OFF_DOCS";
  }
  return moduleCode;
};
const resolveAuditBackendMaterialModuleCode = (moduleCode: MaterialModuleCode): BackendMaterialModuleCode => {
  return moduleCode;
};

const isMaterialLoadActive = (
  requestId: number,
  meetingId?: number,
  moduleCode?: MaterialModuleCode,
) => {
  if (requestId !== materialLoadRequestId.value) {
    return false;
  }
  if (!materialDialogVisible.value) {
    return false;
  }
  if (typeof meetingId === "number" && Number(selectedMeetingId.value || 0) !== Number(meetingId || 0)) {
    return false;
  }
  if (moduleCode && selectedModuleCode.value !== moduleCode) {
    return false;
  }
  return true;
};

const prepareMaterialModuleLoad = (moduleCode: MaterialModuleCode) => {
  materialDialogTitle.value = moduleTitleMap[moduleCode] || "浼氳璧勬枡妯″潡";
  materialReviewNotice.value = "瀹℃牳淇℃伅鍔犺浇涓?..";
};

const extractExt = (fileName: string) => {
  const idx = (fileName || "").lastIndexOf(".");
  if (idx <= 0 || idx >= fileName.length - 1) {
    return "";
  }
  return fileName.substring(idx).toLowerCase();
};
const getFileNameFromOssKey = (ossKey?: string) => {
  const key = String(ossKey || "").trim();
  if (!key) {
    return "";
  }
  const parts = key.split("/");
  return String(parts[parts.length - 1] || "").trim();
};
const displayDocName = (name: string, ossKey: string, fallback: string) => {
  const display = String(name || "").trim() || getFileNameFromOssKey(ossKey);
  return display || fallback;
};
const resolveStoredDocFileName = (raw: any) =>
  String(raw?.fileName || raw?.name || "").trim();
const rebuildInvitationLines = (rows: Array<{ name?: string; ossKey?: string }>) => {
  docForm.value.invitationLines = rows
    .map((item) => {
      const name = String(item?.name || "").trim();
      const ossKey = String(item?.ossKey || "").trim();
      if (!ossKey) {
        return "";
      }
      return `${name}|${ossKey}`;
    })
    .filter((line) => line.length > 0)
    .join("\n");
};
const parseWriteOffAgendaFromParsed = (agenda: unknown): string => {
  if (Array.isArray(agenda)) {
    return agenda
      .map((x: any) => `${resolveStoredDocFileName(x)}|${String(x?.ossKey || "").trim()}`.trim())
      .filter((line: string) => line.length > 0)
      .join("\n");
  }
  if (agenda && typeof agenda === "object" && String((agenda as { ossKey?: string }).ossKey || "").trim()) {
    const a = agenda as { name?: string; fileName?: string; ossKey?: string };
    return `${resolveStoredDocFileName(a)}|${String(a.ossKey || "").trim()}`.trim();
  }
  return "";
};
const rebuildAgendaLines = (rows: Array<{ name?: string; ossKey?: string }>) => {
  docForm.value.agendaLines = rows
    .map((item) => {
      const name = String(item?.name || "").trim();
      const ossKey = String(item?.ossKey || "").trim();
      if (!ossKey) {
        return "";
      }
      return `${name}|${ossKey}`;
    })
    .filter((line) => line.length > 0)
    .join("\n");
};
const cacheDocPreviewUrl = async (ossKey?: string) => {
  const key = String(ossKey || "").trim();
  if (!key || docPreviewUrlMap.value[key]) {
    return;
  }
  try {
    const resp = await fetchFilePresignDownload({ objectKey: key });
    const signedUrl = String(resp?.data?.signedUrl || "").trim();
    if (signedUrl) {
      docPreviewUrlMap.value = {
        ...docPreviewUrlMap.value,
        [key]: signedUrl,
      };
    }
  } catch (_e) {
    // ignore preview-url failure
  }
};
const syncWriteOffDocPreviewUrls = async () => {
  const keys = [
    ...agendaDocRows.value.map((x) => x.ossKey),
    docForm.value.signInOssKey,
    docForm.value.themePhotoOssKey,
    ...invitationDocRows.value.map((x) => x.ossKey),
  ];
  await Promise.allSettled(keys.map((key) => cacheDocPreviewUrl(key)));
};
const openDocPicturePreview = async (ossKey: string, fileNameHint = "") => {
  const key = String(ossKey || "").trim();
  if (!key) {
    return;
  }
  await cacheDocPreviewUrl(key);
  const url = String(docPreviewUrlMap.value[key] || "").trim();
  if (url) {
    docPreviewDialogKind.value = isPdfDocFile(fileNameHint, key) ? "pdf" : "image";
    docPreviewDialogImageUrl.value = url;
    docPreviewDialogVisible.value = true;
    return;
  }
  await handleViewOss(key);
};
const handleDocPicturePreview = async (
  uploadFile: Parameters<NonNullable<UploadProps["onPreview"]>>[0],
  target: "agenda" | "signIn" | "themePhoto" | "invitation",
) => {
  const row = uploadFile as UploadUserFile & { ossKey?: string };
  const fallbackOssKey = target === "agenda"
    ? ""
    : target === "signIn"
      ? String(docForm.value.signInOssKey || "")
      : target === "themePhoto"
        ? String(docForm.value.themePhotoOssKey || "")
      : "";
  const nameHint = String(row?.name || "").trim();
  const ossKey = String(row?.ossKey || fallbackOssKey || "").trim();
  if (ossKey) {
    await openDocPicturePreview(ossKey, nameHint);
    return;
  }
  const url = String(uploadFile?.url || "").trim();
  if (url) {
    const rawMime = String((row?.raw as File | undefined)?.type || "").trim();
    const isPdf =
      isPdfDocFile(nameHint, "") || rawMime === "application/pdf";
    docPreviewDialogKind.value = isPdf ? "pdf" : "image";
    docPreviewDialogImageUrl.value = url;
    docPreviewDialogVisible.value = true;
  }
};
const handleAgendaPictureRemove: UploadProps["onRemove"] = async (uploadFile) => {
  const row = uploadFile as UploadUserFile & { ossKey?: string };
  const targetOssKey = String(row?.ossKey || "").trim();
  if (!targetOssKey) {
    return;
  }
  const idx = agendaDocRows.value.findIndex((item) => String(item.ossKey || "") === targetOssKey);
  const reviewKey = idx === 0 && agendaDocRows.value[idx]?.itemKey
    ? [agendaDocRows.value[idx].itemKey, "agenda"]
    : agendaDocRows.value[idx]?.itemKey;
  if (reviewKey && isReviewApproved(reviewKey)) {
    warnReviewedMaterialLocked();
    return;
  }
  if (idx >= 0) {
    removeAgendaDocAt(idx);
  }
};
const handleSignInPictureRemove: UploadProps["onRemove"] = async () => {
  if (isReviewApproved("signInSheet")) {
    warnReviewedMaterialLocked();
    return;
  }
  removeSignInDoc();
};
const handleThemePhotoPictureRemove: UploadProps["onRemove"] = async () => {
  if (isReviewApproved("themePhoto")) {
    warnReviewedMaterialLocked();
    return;
  }
  removeThemePhotoDoc();
};
const handleInvitationPictureRemove: UploadProps["onRemove"] = async (uploadFile) => {
  const row = uploadFile as UploadUserFile & { ossKey?: string };
  const targetOssKey = String(row?.ossKey || "").trim();
  if (!targetOssKey) {
    return;
  }
  const idx = invitationDocRows.value.findIndex((item) => String(item.ossKey || "") === targetOssKey);
  if (idx >= 0 && isReviewApproved(invitationDocRows.value[idx]?.itemKey || "")) {
    warnReviewedMaterialLocked();
    return;
  }
  if (idx >= 0) {
    removeInvitationDoc(idx);
  }
};
const handleAgendaPicturePreview: UploadProps["onPreview"] = async (uploadFile) => {
  await handleDocPicturePreview(uploadFile, "agenda");
};
const handleSignInPicturePreview: UploadProps["onPreview"] = async (uploadFile) => {
  await handleDocPicturePreview(uploadFile, "signIn");
};
const handleThemePhotoPicturePreview: UploadProps["onPreview"] = async (uploadFile) => {
  await handleDocPicturePreview(uploadFile, "themePhoto");
};
const handleInvitationPicturePreview: UploadProps["onPreview"] = async (uploadFile) => {
  await handleDocPicturePreview(uploadFile, "invitation");
};
const removeAgendaDocAt = (index: number) => {
  const rows = [...agendaDocRows.value];
  if (index < 0 || index >= rows.length) {
    return;
  }
  const removedKey = String(rows[index]?.ossKey || "").trim();
  if (removedKey) {
    const next = { ...docPreviewUrlMap.value };
    delete next[removedKey];
    docPreviewUrlMap.value = next;
  }
  rows.splice(index, 1);
  rebuildAgendaLines(rows);
};
const removeSignInDoc = () => {
  const key = String(docForm.value.signInOssKey || "").trim();
  if (key) {
    const next = { ...docPreviewUrlMap.value };
    delete next[key];
    docPreviewUrlMap.value = next;
  }
  docForm.value.signInName = "";
  docForm.value.signInOssKey = "";
};
const removeThemePhotoDoc = () => {
  const key = String(docForm.value.themePhotoOssKey || "").trim();
  if (key) {
    const next = { ...docPreviewUrlMap.value };
    delete next[key];
    docPreviewUrlMap.value = next;
  }
  docForm.value.themePhotoName = "";
  docForm.value.themePhotoOssKey = "";
};
const removeInvitationDoc = (index: number) => {
  const rows = [...invitationDocRows.value];
  if (index < 0 || index >= rows.length) {
    return;
  }
  const removedKey = String(rows[index]?.ossKey || "").trim();
  if (removedKey) {
    const next = { ...docPreviewUrlMap.value };
    delete next[removedKey];
    docPreviewUrlMap.value = next;
  }
  rows.splice(index, 1);
  rebuildInvitationLines(rows);
};

const MATERIAL_ACCEPT_EXT = new Set([".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".pdf", ".tif", ".tiff", ".doc", ".docx", ".xls", ".xlsx", ".ofd"]);
const EXPERT_PROFILE_ACCEPT_EXT = new Set([".pdf", ".ppt", ".pptx"]);
const MATERIAL_MAX_FILE_SIZE_MB = 2;
const MATERIAL_MAX_FILE_SIZE_BYTES = MATERIAL_MAX_FILE_SIZE_MB * 1024 * 1024;
const isMaterialUploadAllowed = (file?: File | null) => {
  if (!file) {
    return false;
  }
  const ext = extractExt(file.name || "");
  const mime = String(file.type || "").toLowerCase();
  return MATERIAL_ACCEPT_EXT.has(ext) || mime === "application/pdf" || mime.startsWith("image/");
};
const isImageFile = (fileNameOrKey: string) => {
  const ext = extractExt(fileNameOrKey || "");
  return [".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp"].includes(ext);
};
const validateImageFile = (file: File | null, label: string, maxSizeMb = MATERIAL_MAX_FILE_SIZE_MB) => {
  if (!file) {
    return false;
  }
  const ext = extractExt(file.name || "");
  const mime = String(file.type || "").toLowerCase();
  if (!isImageFile(ext) && !mime.startsWith("image/")) {
    ElMessage.warning(`${label}仅支持图片文件`);
    return false;
  }
  if (Number(file.size || 0) > maxSizeMb * 1024 * 1024) {
    ElMessage.warning(`${label}澶у皬涓嶈兘瓒呰繃${maxSizeMb}MB`);
    return false;
  }
  return true;
};
const validateMaterialFile = (file: File | null, label: string, maxSizeMb = MATERIAL_MAX_FILE_SIZE_MB) => {
  if (!file) {
    return false;
  }
  if (!isMaterialUploadAllowed(file)) {
    ElMessage.warning(`${label}浠呮敮鎸佸浘鐗囨垨PDF鏂囦欢`);
    return false;
  }
  if (Number(file.size || 0) > maxSizeMb * 1024 * 1024) {
    ElMessage.warning(`${label}澶у皬涓嶈兘瓒呰繃${maxSizeMb}MB`);
    return false;
  }
  return true;
};
const validateExpertProfileFile = (file: File | null, maxSizeMb = MATERIAL_MAX_FILE_SIZE_MB) => {
  if (!file) {
    return false;
  }
  const ext = extractExt(file.name || "");
  const mime = String(file.type || "").toLowerCase();
  const isPptMime = mime === "application/vnd.ms-powerpoint"
    || mime === "application/vnd.openxmlformats-officedocument.presentationml.presentation";
  if (!EXPERT_PROFILE_ACCEPT_EXT.has(ext) && mime !== "application/pdf" && !isPptMime) {
    ElMessage.warning("涓撳绠€浠?涓插満浠呮敮鎸丳DF鎴朠PT鏂囦欢");
    return false;
  }
  if (Number(file.size || 0) > maxSizeMb * 1024 * 1024) {
    ElMessage.warning(`涓撳绠€浠?涓插満鏂囦欢澶у皬涓嶈兘瓒呰繃${maxSizeMb}MB`);
    return false;
  }
  return true;
};
const beforeAgendaUpload = (rawFile: any, maxSizeMb?: number) => {
  if (!validateMaterialFile((rawFile as File) || null, "浼氳鏃ョ▼", maxSizeMb)) {
    return false;
  }
  if (agendaDocRows.value.length >= 5) {
    ElMessage.warning("会议日程最多上传5个文件");
    return false;
  }
  return true;
};
const beforeSignInUpload = (rawFile: any, maxSizeMb?: number) =>
  validateMaterialFile((rawFile as File) || null, "签到表", maxSizeMb);
const beforeThemePhotoUpload = (rawFile: any, maxSizeMb?: number) =>
  validateImageFile((rawFile as File) || null, "涓婚鐓х墖", maxSizeMb);
const beforeInvitationUpload = (rawFile: any, maxSizeMb?: number) =>
  validateMaterialFile((rawFile as File) || null, "閭€璇峰嚱", maxSizeMb);
const beforePhotoUpload: UploadProps["beforeUpload"] = (rawFile) =>
  validateMaterialFile((rawFile as File) || null, "鐜板満鐓х墖");
const beforeLaborProtocolUpload: UploadProps["beforeUpload"] = (rawFile) =>
  validateMaterialFile((rawFile as File) || null, "鍗忚鏂囦欢");
const beforeLaborInvoiceUpload: UploadProps["beforeUpload"] = (rawFile) =>
  validateMaterialFile((rawFile as File) || null, "鍔冲姟鍙戠エ");
const beforeMeetingInvoiceUpload: UploadProps["beforeUpload"] = (rawFile) =>
  validateMaterialFile((rawFile as File) || null, "浼氳鍙戠エ闄勪欢");
const beforeExpertProfileUpload = (rawFile: any, maxSizeMb?: number) =>
  validateExpertProfileFile((rawFile as File) || null, maxSizeMb);

const onAgendaFileChange = (uploadFile: any) => {
  const file = (uploadFile?.raw as File) || null;
  agendaFile.value = validateMaterialFile(file, "浼氳鏃ョ▼") ? file : null;
};

const onSignInFileChange = (uploadFile: any) => {
  const file = (uploadFile?.raw as File) || null;
  signInFile.value = validateMaterialFile(file, "签到表") ? file : null;
};

const onInvitationFileChange = (uploadFile: any) => {
  const file = (uploadFile?.raw as File) || null;
  invitationFile.value = validateMaterialFile(file, "閭€璇峰嚱") ? file : null;
};

const handleAgendaPictureChange = async (uploadFile: any, uploadFiles: any, maxSizeMb?: number) => {
  if (isReviewApproved("agenda")) {
    warnReviewedMaterialLocked();
    return;
  }
  const file = (uploadFile?.raw as File) || null;
  agendaFile.value = validateMaterialFile(file, "浼氳鏃ョ▼", maxSizeMb) ? file : null;
  if (!agendaFile.value) {
    return;
  }
  await uploadDocFile("agenda");
};

const handleSignInPictureChange = async (uploadFile: any, uploadFiles: any, maxSizeMb?: number) => {
  if (isReviewApproved("signInSheet")) {
    warnReviewedMaterialLocked();
    return;
  }
  const file = (uploadFile?.raw as File) || null;
  signInFile.value = validateMaterialFile(file, "签到表", maxSizeMb) ? file : null;
  if (!signInFile.value) {
    return;
  }
  await uploadDocFile("signIn");
};

const handleThemePhotoPictureChange = async (uploadFile: any, uploadFiles: any, maxSizeMb?: number) => {
  if (isReviewApproved("themePhoto")) {
    warnReviewedMaterialLocked();
    return;
  }
  const file = (uploadFile?.raw as File) || null;
  themePhotoFile.value = validateImageFile(file, "涓婚鐓х墖", maxSizeMb) ? file : null;
  if (!themePhotoFile.value) {
    return;
  }
  await uploadDocFile("themePhoto");
};

const handleInvitationPictureChange = async (uploadFile: any, uploadFiles: any, maxSizeMb?: number) => {
  const file = (uploadFile?.raw as File) || null;
  invitationFile.value = validateMaterialFile(file, "閭€璇峰嚱", maxSizeMb) ? file : null;
  if (!invitationFile.value) {
    return;
  }
  await uploadDocFile("invitation");
};
const handleExpertProfileFileChange = async (uploadFile: any, uploadFiles: any, maxSizeMb?: number) => {
  if (isReviewApproved(["expert_profile_file", "profileFile"])) {
    warnReviewedMaterialLocked();
    return;
  }
  const file = (uploadFile?.raw as File) || null;
  if (!validateExpertProfileFile(file, maxSizeMb) || !file || !selectedMeetingId.value) {
    return;
  }
  const signResp = await fetchMeetingMaterialUploadSign(selectedMeetingId.value, "WRITE_OFF_DOCS", {
    fileName: file.name,
    contentType: file.type || "application/octet-stream",
  });
  const uploadUrl = signResp?.data?.uploadUrl;
  const objectKey = signResp?.data?.objectKey;
  if (!uploadUrl || !objectKey) {
    ElMessage.error("鑾峰彇涓婁紶绛惧悕澶辫触");
    return;
  }
  await putToSignedUrl(uploadUrl, file, signResp?.data?.contentType || file.type);
  expertProfileForm.value.fileName = file.name;
  expertProfileForm.value.ossKey = objectKey;
  ElMessage.success("涓撳绠€浠?涓插満鏂囦欢涓婁紶鎴愬姛");
};
const handleExpertProfileFilePreview: UploadProps["onPreview"] = async (uploadFile) => {
  const ossKey = String(expertProfileForm.value.ossKey || "").trim();
  if (!ossKey) {
    return;
  }
  const name = String(uploadFile?.name || expertProfileForm.value.fileName || "").trim();
  const ext = extractExt(name);
  const rawMime = String((uploadFile?.raw as File | undefined)?.type || "").trim();
  const keyLower = ossKey.toLowerCase();
  const isPdf =
    ext === ".pdf" ||
    rawMime === "application/pdf" ||
    keyLower.endsWith(".pdf");
  if (!isPdf) {
    await handleViewOss(ossKey);
    return;
  }
  await cacheDocPreviewUrl(ossKey);
  const url = String(docPreviewUrlMap.value[ossKey] || "").trim();
  if (url) {
    docPreviewDialogKind.value = "pdf";
    docPreviewDialogImageUrl.value = url;
    docPreviewDialogVisible.value = true;
    return;
  }
  await handleViewOss(ossKey);
};
const handleExpertProfileFileRemove: UploadProps["onRemove"] = async () => {
  if (isReviewApproved(["expert_profile_file", "profileFile"])) {
    warnReviewedMaterialLocked();
    return;
  }
  expertProfileForm.value.fileName = "";
  expertProfileForm.value.ossKey = "";
};

const handleLaborProtocolFileRemove: UploadProps["onRemove"] = async () => {
  if (isReviewApproved(currentLaborReviewKey.value)) {
    warnReviewedMaterialLocked();
    return;
  }
  const expertId = Number(selectedExpertMaterialId.value || 0);
  const row = getCurrentLaborRow(expertId);
  if (!row) {
    return;
  }
  const prev = String(row.protocolPreviewUrl || "").trim();
  if (prev.startsWith("blob:")) {
    try {
      URL.revokeObjectURL(prev);
    } catch (_e) {
      // ignore
    }
  }
  row.protocolOssKey = "";
  row.protocolFileName = "";
  row.protocolPreviewUrl = "";
  laborProtocolUploadFile.value = null;
};

const handleLaborInvoiceFileRemove: UploadProps["onRemove"] = async (uploadFile) => {
  if (isReviewApproved(currentLaborReviewKey.value)) {
    warnReviewedMaterialLocked();
    return;
  }
  const expertId = Number(selectedExpertMaterialId.value || 0);
  const row = getCurrentLaborRow(expertId);
  if (!row) {
    return;
  }
  const uploadRow = uploadFile as UploadUserFile & { ossKey?: string };
  const targetKey = String(uploadRow?.ossKey || "").trim();
  const targetName = String(uploadRow?.name || "").trim();
  const invoiceFiles = Array.isArray(row.invoiceFiles) ? row.invoiceFiles : [];
  let removeIndex = -1;
  if (targetKey) {
    removeIndex = invoiceFiles.findIndex((item) => String(item?.ossKey || "").trim() === targetKey);
  }
  if (removeIndex < 0 && targetName) {
    removeIndex = invoiceFiles.findIndex((item) => String(item?.fileName || "").trim() === targetName);
  }
  if (removeIndex < 0) {
    removeIndex = invoiceFiles.length - 1;
  }
  if (removeIndex < 0) {
    return;
  }
  const removed = invoiceFiles[removeIndex];
  const removedKey = String(removed?.ossKey || "").trim();
  const prev = String(removed?.previewUrl || "").trim();
  if (prev.startsWith("blob:")) {
    try {
      URL.revokeObjectURL(prev);
    } catch (_e) {
      // ignore
    }
  }
  invoiceFiles.splice(removeIndex, 1);
  const first = invoiceFiles[0];
  row.invoiceFileName = String(first?.fileName || "").trim();
  row.invoiceOssKey = String(first?.ossKey || "").trim();
  row.invoicePreviewUrl = String(first?.previewUrl || "").trim();
  row.invoiceOcr = { status: "idle" };
  laborInvoiceUploadFiles.value = [];
};

const uploadDocFile = async (target: "agenda" | "signIn" | "themePhoto" | "invitation") => {
  if (!selectedMeetingId.value) {
    return;
  }
  const file = target === "agenda"
    ? agendaFile.value
    : target === "signIn"
      ? signInFile.value
      : target === "themePhoto"
        ? themePhotoFile.value
        : invitationFile.value;
  if (!file) {
    ElMessage.warning("璇峰厛閫夋嫨鏂囦欢");
    return;
  }
  const signResp = await fetchMeetingMaterialUploadSign(selectedMeetingId.value, "WRITE_OFF_DOCS", {
    fileName: file.name,
    contentType: file.type || "application/octet-stream",
  });
  const uploadUrl = signResp?.data?.uploadUrl;
  const objectKey = signResp?.data?.objectKey;
  const contentType = signResp?.data?.contentType || file.type || "application/octet-stream";
  if (!uploadUrl || !objectKey) {
    ElMessage.error("鑾峰彇涓婁紶绛惧悕澶辫触");
    return;
  }
  await putToSignedUrl(uploadUrl, file, contentType);
  if (target === "agenda") {
    const line = `${file.name}|${objectKey}`;
    docForm.value.agendaLines = docForm.value.agendaLines ? `${docForm.value.agendaLines}\n${line}` : line;
    agendaFile.value = null;
  } else if (target === "signIn") {
    docForm.value.signInName = file.name;
    docForm.value.signInOssKey = objectKey;
    signInFile.value = null;
  } else if (target === "themePhoto") {
    docForm.value.themePhotoName = file.name;
    docForm.value.themePhotoOssKey = objectKey;
    themePhotoFile.value = null;
  } else {
    const line = `${file.name}|${objectKey}`;
    docForm.value.invitationLines = docForm.value.invitationLines ? `${docForm.value.invitationLines}\n${line}` : line;
    invitationFile.value = null;
  }
  await cacheDocPreviewUrl(objectKey);
  ElMessage.success("文件已上传到OSS并回填");
};

const onPhotoFileChange = async (uploadFile: any) => {
  const file = (uploadFile?.raw as File) || null;
  if (!validateMaterialFile(file, "鐜板満鐓х墖") || !file) {
    return;
  }
  const exists = photoUploadFiles.value.some((x) => x.name === file.name && x.size === file.size && x.lastModified === file.lastModified);
  if (!exists) {
    photoUploadFiles.value.push(file);
  }
  if (photoAutoUploading.value) {
    return;
  }
  photoAutoUploading.value = true;
  try {
    while (photoUploadFiles.value.length > 0) {
      await uploadPhotoFile();
    }
  } finally {
    photoAutoUploading.value = false;
  }
};

const onLaborProtocolFileChange = async (uploadFile: any) => {
  if (isReviewApproved(currentLaborReviewKey.value)) {
    warnReviewedMaterialLocked();
    return;
  }
  const file = (uploadFile?.raw as File) || null;
  laborProtocolUploadFile.value = validateMaterialFile(file, "鍗忚鏂囦欢") ? file : null;
  if (!laborProtocolUploadFile.value || laborProtocolAutoUploading.value) {
    return;
  }
  laborProtocolAutoUploading.value = true;
  try {
    await uploadLaborProtocolFile();
  } finally {
    laborProtocolAutoUploading.value = false;
  }
};

const onLaborInvoiceFileChange = async (uploadFile: any) => {
  if (isReviewApproved(currentLaborReviewKey.value)) {
    warnReviewedMaterialLocked();
    return;
  }
  const file = (uploadFile?.raw as File) || null;
  if (!validateMaterialFile(file, "鍔冲姟鍙戠エ") || !file) {
    return;
  }
  const exists = laborInvoiceUploadFiles.value.some((x) => x.name === file.name && x.size === file.size && x.lastModified === file.lastModified);
  if (!exists) {
    laborInvoiceUploadFiles.value.push(file);
  }
  if (laborInvoiceAutoUploading.value) {
    return;
  }
  laborInvoiceAutoUploading.value = true;
  try {
    while (laborInvoiceUploadFiles.value.length > 0) {
      await uploadLaborInvoiceFile();
    }
  } finally {
    laborInvoiceAutoUploading.value = false;
  }
};

const onInvoiceFileChange = async (uploadFile: any) => {
  const file = (uploadFile?.raw as File) || null;
  if (!validateMaterialFile(file, "鍙戠エ闄勪欢") || !file) {
    return;
  }
  const exists = invoiceUploadFiles.value.some((x) => x.name === file.name && x.size === file.size && x.lastModified === file.lastModified);
  if (!exists) {
    invoiceUploadFiles.value.push(file);
  }
  if (invoiceAutoUploading.value) {
    return;
  }
  invoiceAutoUploading.value = true;
  try {
    await uploadInvoiceFile();
  } finally {
    invoiceAutoUploading.value = false;
  }
};

const ensureMeetingInvoiceSection = (sectionCode: MeetingInvoiceSectionCode) => {
  if (!meetingInvoiceForm.value.sections[sectionCode]) {
    meetingInvoiceForm.value.sections[sectionCode] = createMeetingInvoiceSectionState();
  }
  return meetingInvoiceForm.value.sections[sectionCode];
};
const onMeetingInvoiceSectionFileChange = async (
  sectionCode: MeetingInvoiceSectionCode,
  fieldKey: MeetingInvoiceUploadFieldKey,
  uploadFile: any,
) => {
  if (isReviewApproved(buildMeetingInvoiceFieldItemKey(sectionCode, fieldKey))) {
    warnReviewedMaterialLocked();
    return;
  }
  const file = (uploadFile?.raw as File) || null;
  if (!validateMaterialFile(file, "浼氳鍙戠エ闄勪欢") || !file || !selectedMeetingId.value) {
    return;
  }
  const uploadTaskKey = `${sectionCode}:${fieldKey}`;
  if (meetingInvoiceFieldUploading.value[uploadTaskKey]) {
    return;
  }
  meetingInvoiceFieldUploading.value = {
    ...meetingInvoiceFieldUploading.value,
    [uploadTaskKey]: true,
  };
  try {
    const signResp = await fetchMeetingMaterialUploadSign(selectedMeetingId.value, "MEETING_INVOICE", {
      fileName: file.name,
      contentType: file.type || "application/octet-stream",
    });
    const uploadUrl = signResp?.data?.uploadUrl;
    const objectKey = signResp?.data?.objectKey;
    if (!uploadUrl || !objectKey) {
      ElMessage.error("鑾峰彇涓婁紶绛惧悕澶辫触");
      return;
    }
    await putToSignedUrl(uploadUrl, file, signResp?.data?.contentType || file.type);
    const section = ensureMeetingInvoiceSection(sectionCode);
    section.files[fieldKey].push({
      fileName: file.name,
      ossKey: objectKey,
      ocr: fieldKey === "invoiceFile" ? { status: "idle" } : undefined,
    });
    await cacheDocPreviewUrl(objectKey);
    ElMessage.success("浼氳鍙戠エ闄勪欢涓婁紶鎴愬姛");
    if (fieldKey === "invoiceFile") {
      await runMeetingInvoiceOcr(sectionCode, fieldKey);
    }
  } finally {
    meetingInvoiceFieldUploading.value = {
      ...meetingInvoiceFieldUploading.value,
      [uploadTaskKey]: false,
    };
  }
};
const buildMeetingInvoiceUploadChangeHandler = (
  sectionCode: MeetingInvoiceSectionCode,
  fieldKey: MeetingInvoiceUploadFieldKey,
): UploadProps["onChange"] =>
  async (uploadFile) => {
    await onMeetingInvoiceSectionFileChange(sectionCode, fieldKey, uploadFile);
  };
const handleMeetingInvoiceSectionPicturePreview: UploadProps["onPreview"] = async (uploadFile) => {
  const row = uploadFile as MeetingInvoiceUploadUserFile;
  const nameHint = String(row?.name || "").trim();
  const ossKey = String(row?.ossKey || "").trim();
  if (ossKey) {
    await openDocPicturePreview(ossKey, nameHint);
    return;
  }
  const url = String(uploadFile?.url || "").trim();
  if (url) {
    const rawMime = String((row?.raw as File | undefined)?.type || "").trim();
    const isPdf =
      isPdfDocFile(nameHint, "") || rawMime === "application/pdf";
    docPreviewDialogKind.value = isPdf ? "pdf" : "image";
    docPreviewDialogImageUrl.value = url;
    docPreviewDialogVisible.value = true;
  }
};
const buildMeetingInvoicePictureRemoveHandler = (
  sectionCode: MeetingInvoiceSectionCode,
  fieldKey: MeetingInvoiceUploadFieldKey,
): UploadProps["onRemove"] =>
  async (uploadFile) => {
    if (isReviewApproved(buildMeetingInvoiceFieldItemKey(sectionCode, fieldKey))) {
      warnReviewedMaterialLocked();
      return;
    }
    const ossKey = String((uploadFile as MeetingInvoiceUploadUserFile)?.ossKey || "").trim();
    removeMeetingInvoiceSectionFile(sectionCode, fieldKey, ossKey);
  };
const meetingInvoiceOtherFieldSlots: MeetingInvoiceUploadFieldKey[] = [
  "invoiceFile",
  "detailFile",
  "equipmentFile",
  "materialFile",
  "designDraftFile",
];
const meetingInvoiceOtherMaxUploadCount = (() => {
  const sectionDef = meetingInvoiceSectionDefs.find((item) => item.code === "OTHER");
  const configured = Number(sectionDef?.maxUploadCount || 0);
  const safeCount = Number.isFinite(configured) ? Math.floor(configured) : 0;
  return Math.max(1, Math.min(meetingInvoiceOtherFieldSlots.length, safeCount || meetingInvoiceOtherFieldSlots.length));
})();
let meetingInvoiceOtherUploadQueue: Promise<void> = Promise.resolve();
const getMeetingInvoiceOtherFiles = () => meetingInvoiceOtherUploadFileList.value.map((item) => ({
  fieldKey: String((item as MeetingInvoiceUploadUserFile).fieldKey || "") as MeetingInvoiceUploadFieldKey,
  fileName: String(item.name || "").trim(),
  ossKey: String((item as MeetingInvoiceUploadUserFile).ossKey || "").trim(),
}));
const handleMeetingInvoiceOtherPictureRemove: UploadProps["onRemove"] = async (uploadFile) => {
  const row = uploadFile as MeetingInvoiceUploadUserFile;
  const directFieldKey = String(row?.fieldKey || "").trim();
  const directOssKey = String(row?.ossKey || "").trim();
  if (isMeetingInvoiceFieldKey(directFieldKey)) {
    if (isReviewApproved(buildMeetingInvoiceFieldItemKey("OTHER", directFieldKey))) {
      warnReviewedMaterialLocked();
      return;
    }
    removeMeetingInvoiceSectionFile("OTHER", directFieldKey, directOssKey);
    return;
  }
  const ossKey = directOssKey;
  if (!ossKey) {
    return;
  }
  const fallback = meetingInvoiceOtherFieldSlots.find((key) => {
    const saved = meetingInvoiceForm.value.sections.OTHER?.files[key] || [];
    return saved.some((item) => String(item?.ossKey || "").trim() === ossKey);
  });
  if (fallback) {
    if (isReviewApproved(buildMeetingInvoiceFieldItemKey("OTHER", fallback))) {
      warnReviewedMaterialLocked();
      return;
    }
    removeMeetingInvoiceSectionFile("OTHER", fallback, ossKey);
  }
};
const onMeetingInvoiceOtherFileChange: UploadProps["onChange"] = async (uploadFile) => {
  meetingInvoiceOtherUploadQueue = meetingInvoiceOtherUploadQueue
    .then(async () => {
      const current = getMeetingInvoiceOtherFiles();
      if (current.length >= meetingInvoiceOtherMaxUploadCount) {
        ElMessage.warning(`第10项“其他”最多上传${meetingInvoiceOtherMaxUploadCount}个文件`);
        return;
      }
      const used = new Set(current.map((x) => x.fieldKey));
      meetingInvoiceOtherFieldSlots.slice(0, meetingInvoiceOtherMaxUploadCount).forEach((fieldKey) => {
        const taskKey = `OTHER:${fieldKey}`;
        if (meetingInvoiceFieldUploading.value[taskKey]) {
          used.add(fieldKey);
        }
      });
      const targetField = meetingInvoiceOtherFieldSlots
        .slice(0, meetingInvoiceOtherMaxUploadCount)
        .find((fieldKey) => !used.has(fieldKey));
      if (!targetField) {
        ElMessage.warning(`第10项“其他”最多上传${meetingInvoiceOtherMaxUploadCount}个文件`);
        return;
      }
      await onMeetingInvoiceSectionFileChange("OTHER", targetField, uploadFile);
    })
    .catch(() => {
      // keep queue alive for subsequent uploads
    });
  await meetingInvoiceOtherUploadQueue;
};

const uploadPhotoFile = async () => {
  if (!selectedMeetingId.value || !selectedExpertMaterialId.value) {
    return;
  }
  if (photoUploadFiles.value.length === 0) {
    ElMessage.warning("璇峰厛閫夋嫨鐓х墖");
    return;
  }
  const expertId = Number(selectedExpertMaterialId.value);
  if (!photoByExpert.value[expertId]) {
    photoByExpert.value[expertId] = [];
  }
  let successCount = 0;
  for (const file of photoUploadFiles.value) {
    const signResp = await fetchMeetingMaterialUploadSign(selectedMeetingId.value, "EXPERT_LIST", {
      fileName: file.name,
      contentType: file.type || "application/octet-stream",
    });
    const uploadUrl = signResp?.data?.uploadUrl;
    const objectKey = signResp?.data?.objectKey;
    if (!uploadUrl || !objectKey) {
      continue;
    }
    await putToSignedUrl(uploadUrl, file, signResp?.data?.contentType || file.type);
    photoByExpert.value[expertId].push({ name: file.name, ossKey: objectKey });
    const latest = photoByExpert.value[expertId][photoByExpert.value[expertId].length - 1];
    if (latest && isImageFile(file.name)) {
      latest.previewUrl = URL.createObjectURL(file);
    }
    successCount++;
  }
  photoUploadFiles.value = [];
  ElMessage.success(`鐜板満鐓х墖涓婁紶鎴愬姛锛${successCount}寮狅級`);
};

const uploadLaborProtocolFile = async () => {
  if (!selectedMeetingId.value || !selectedExpertMaterialId.value) {
    return;
  }
  if (!laborProtocolUploadFile.value) {
    ElMessage.warning("璇峰厛閫夋嫨鍗忚鏂囦欢");
    return;
  }
  const signResp = await fetchMeetingMaterialUploadSign(selectedMeetingId.value, "EXPERT_LIST", {
    fileName: laborProtocolUploadFile.value.name,
    contentType: laborProtocolUploadFile.value.type || "application/octet-stream",
  });
  const uploadUrl = signResp?.data?.uploadUrl;
  const objectKey = signResp?.data?.objectKey;
  if (!uploadUrl || !objectKey) {
    ElMessage.error("鑾峰彇涓婁紶绛惧悕澶辫触");
    return;
  }
  await putToSignedUrl(uploadUrl, laborProtocolUploadFile.value, signResp?.data?.contentType || laborProtocolUploadFile.value.type);
  const expertId = Number(selectedExpertMaterialId.value);
  const row = getCurrentLaborRow(expertId);
  if (row) {
    const localFile = laborProtocolUploadFile.value;
    row.protocolFileName = localFile.name;
    row.protocolName =
      row.protocolName || localFile.name.replace(extractExt(localFile.name), "");
    row.protocolOssKey = objectKey;
    if (isImageFile(localFile.name) || isImageFile(objectKey)) {
      row.protocolPreviewUrl = URL.createObjectURL(localFile);
    } else {
      row.protocolPreviewUrl = "";
    }
  }
  laborProtocolUploadFile.value = null;
  ElMessage.success("鍔冲姟鍗忚涓婁紶鎴愬姛");
};

const uploadLaborInvoiceFile = async () => {
  if (!selectedMeetingId.value || !selectedExpertMaterialId.value) {
    return;
  }
  if (laborInvoiceUploadFiles.value.length === 0) {
    ElMessage.warning("璇峰厛閫夋嫨鍔冲姟鍙戠エ");
    return;
  }
  const localFile = laborInvoiceUploadFiles.value[0];
  const signResp = await fetchMeetingMaterialUploadSign(selectedMeetingId.value, "EXPERT_LIST", {
    fileName: localFile.name,
    contentType: localFile.type || "application/octet-stream",
  });
  const uploadUrl = signResp?.data?.uploadUrl;
  const objectKey = signResp?.data?.objectKey;
  if (!uploadUrl || !objectKey) {
    ElMessage.error("鑾峰彇涓婁紶绛惧悕澶辫触");
    return;
  }
  await putToSignedUrl(uploadUrl, localFile, signResp?.data?.contentType || localFile.type);
  const expertId = Number(selectedExpertMaterialId.value);
  const row = getCurrentLaborRow(expertId);
  if (row) {
    const previewUrl = isImageFile(localFile.name) || isImageFile(objectKey) ? URL.createObjectURL(localFile) : "";
    if (!Array.isArray(row.invoiceFiles)) {
      row.invoiceFiles = [];
    }
    row.invoiceFiles.push({
      fileName: localFile.name,
      ossKey: objectKey,
      previewUrl,
    });
    row.invoiceFileName = localFile.name;
    row.invoiceOssKey = objectKey;
    row.invoiceOcr = { status: "idle" };
    row.invoicePreviewUrl = previewUrl;
  }
  laborInvoiceUploadFiles.value.shift();
  ElMessage.success("鍔冲姟鍙戠エ涓婁紶鎴愬姛");
  if (objectKey) {
    await runLaborInvoiceOcr(objectKey);
  }
};

const uploadInvoiceFile = async () => {
  if (!selectedMeetingId.value || !selectedExpertMaterialId.value) {
    return;
  }
  if (invoiceUploadFiles.value.length === 0) {
    ElMessage.warning("璇峰厛閫夋嫨鍙戠エ闄勪欢");
    return;
  }
  if (!invoiceDraft.value.invoiceNo.trim()) {
    ElMessage.warning("璇峰～鍐欏彂绁ㄥ彿");
    return;
  }
  const expertId = Number(selectedExpertMaterialId.value);
  if (!invoiceByExpert.value[expertId]) {
    invoiceByExpert.value[expertId] = [];
  }
  let successCount = 0;
  for (const file of invoiceUploadFiles.value) {
    const signResp = await fetchMeetingMaterialUploadSign(selectedMeetingId.value, "EXPERT_LIST", {
      fileName: file.name,
      contentType: file.type || "application/octet-stream",
    });
    const uploadUrl = signResp?.data?.uploadUrl;
    const objectKey = signResp?.data?.objectKey;
    if (!uploadUrl || !objectKey) {
      continue;
    }
    await putToSignedUrl(uploadUrl, file, signResp?.data?.contentType || file.type);
    invoiceByExpert.value[expertId].push({
      invoiceNo: invoiceDraft.value.invoiceNo.trim(),
      amountCent: Number(invoiceDraft.value.amountCent || 0),
      taxCent: Number(invoiceDraft.value.taxCent || 0),
      expenseType: (invoiceDraft.value.expenseType || "鍏朵粬").trim() || "鍏朵粬",
      fileName: file.name,
      ossKey: objectKey,
    });
    successCount++;
  }
  invoiceUploadFiles.value = [];
  invoiceDraft.value.invoiceNo = "";
  invoiceDraft.value.amountCent = 0;
  invoiceDraft.value.taxCent = 0;
  invoiceDraft.value.expenseType = "鍏朵粬";
  ElMessage.success(`鍙戠エ闄勪欢涓婁紶鎴愬姛锛${successCount}寮狅級`);
};

const removeSelectedExpertPhoto = (index: number) => {
  const expertId = Number(selectedExpertMaterialId.value || 0);
  if (!expertId) {
    return;
  }
  const list = photoByExpert.value[expertId] || [];
  if (isReviewApproved(buildPhotoItemKey(list[index], index))) {
    warnReviewedMaterialLocked();
    return;
  }
  list.splice(index, 1);
};

const removeSelectedExpertInvoice = (index: number) => {
  const expertId = Number(selectedExpertMaterialId.value || 0);
  if (!expertId) {
    return;
  }
  const list = invoiceByExpert.value[expertId] || [];
  if (isReviewApproved(buildInvoiceItemKey(list[index], index))) {
    warnReviewedMaterialLocked();
    return;
  }
  list.splice(index, 1);
};

const removeMeetingInvoiceSectionFile = (
  sectionCode: MeetingInvoiceSectionCode,
  fieldKey: MeetingInvoiceUploadFieldKey,
  ossKey?: string,
) => {
  const section = meetingInvoiceForm.value.sections[sectionCode];
  if (!section) {
    return;
  }
  const rows = Array.isArray(section.files[fieldKey]) ? section.files[fieldKey] : [];
  let removedKey = "";
  let removedItem: MeetingInvoiceFileValue | null | undefined = null;
  if (ossKey) {
    const removeIndex = rows.findIndex((item) => String(item?.ossKey || "").trim() === ossKey);
    if (removeIndex < 0) {
      return;
    }
    removedItem = rows[removeIndex];
    removedKey = String(removedItem?.ossKey || "").trim();
    rows.splice(removeIndex, 1);
  } else {
    removedItem = rows.pop();
    removedKey = String(removedItem?.ossKey || "").trim();
  }
  if (!removedKey) {
    return;
  }
  if (removedItem?.ocr?.status === "success" && removedItem.ocr?.normalized) {
    const deductCent = Number(removedItem.ocr.normalized.totalAmountCent || 0);
    if (deductCent > 0) {
      const deductYuan = Number((deductCent / 100).toFixed(2));
      const currentYuan = Number(section.totalAmountYuan || 0);
      section.totalAmountYuan = Number(Math.max(0, currentYuan - deductYuan).toFixed(2));
    }
  }
  const next = { ...docPreviewUrlMap.value };
  delete next[removedKey];
  docPreviewUrlMap.value = next;
};

const handleViewOss = async (ossKey: string) => {
  if (!ossKey) {
    ElMessage.warning("OSSKey 涓虹┖");
    return;
  }
  try {
    const resp = await fetchFilePresignDownload({ objectKey: ossKey });
    const signedUrl = resp?.data?.signedUrl;
    if (signedUrl) {
      window.open(signedUrl, "_blank");
    } else {
      ElMessage.warning("鏈幏鍙栧埌鏌ョ湅閾炬帴");
    }
  } catch (_e) {
    ElMessage.error("鏌ョ湅澶辫触锛岃妫€鏌?file.download 鏉冮檺");
  }
};

const resetMaterialForms = () => {
  materialForm.value = { remark: "" };
  expertSubModule.value = "ONSITE_PHOTO";
  basicForm.value = {
    chairmanExpertIds: [],
    speakerExpertIds: [],
    hostExpertIds: [],
    discussionGuestExpertIds: [],
    guestCount: boundExpertRows.value.length,
    attendeeCount: 1,
    attendeeActualCount: 0,
    targetAudience: "",
    mainAgenda: "",
    improvementSuggestion: "",
    meetingEffect: "",
  };
  docForm.value = { agendaLines: "", signInName: "", signInOssKey: "", themePhotoName: "", themePhotoOssKey: "", invitationLines: "" };
  expertProfileForm.value = { fileName: "", ossKey: "" };
  photoForm.value = { photoLines: "", summary: "" };
  photoSummaryByExpert.value = {};
  laborForm.value = { protocolName: "", protocolOssKey: "", detailLines: "" };
  invoiceForm.value = { invoiceLines: "", summary: "" };
  meetingInvoiceForm.value = createEmptyMeetingInvoiceForm();
  agendaFile.value = null;
  signInFile.value = null;
  themePhotoFile.value = null;
  invitationFile.value = null;
  selectedExpertMaterialId.value = null;
  photoByExpert.value = {};
  laborByExpert.value = {};
  invoiceByExpert.value = {};
  photoUploadFiles.value = [];
  laborProtocolUploadFile.value = null;
  laborInvoiceUploadFiles.value = [];
  invoiceUploadFiles.value = [];
  invoiceDraft.value = { invoiceNo: "", amountCent: 0, taxCent: 0, expenseType: "鍏朵粬" };
  meetingInvoiceFieldUploading.value = {};
  materialItemReviewMap.value = {};
  materialReviewNotice.value = "";
  docPreviewUrlMap.value = {};
  materialBudgetBase.value = {
    budgetCent: 0,
    laborTotalCent: 0,
    invoiceTotalCent: 0,
    meetingInvoiceTotalCent: 0,
    ready: false,
  };
};

const setMaterialReviewRows = (rows: any[]) => {
  const map: Record<string, { reviewResult?: string; reviewReason?: string; updatedAt?: string }> = {};
  (rows || []).forEach((row: any) => {
    const key = String(row?.itemKey || "");
    if (!key) {
      return;
    }
    map[key] = {
      reviewResult: String(row?.reviewResult || ""),
      reviewReason: String(row?.reviewReason || ""),
      updatedAt: String(row?.updatedAt || ""),
    };
  });
  materialItemReviewMap.value = map;
};

const loadMaterialReviews = async (
  meetingId: number,
  moduleCode: MaterialModuleCode,
  currentRespData?: any,
  requestId?: number,
) => {
  const backendModuleCode = resolveAuditBackendMaterialModuleCode(moduleCode);
  const directRows = Array.isArray(currentRespData?.itemReviews) ? currentRespData.itemReviews : [];
  if (directRows.length > 0) {
    if (typeof requestId === "number" && !isMaterialLoadActive(requestId, meetingId, moduleCode)) {
      return;
    }
    setMaterialReviewRows(directRows);
    materialReviewNotice.value = "";
    return;
  }
  try {
    const tasksResp = await fetchAuditTasks({
      mine: false,
      meetingId,
      pageNo: 1,
      pageSize: 100,
      sortBy: "lastActionAt",
      order: "desc",
    });
    const all = Array.isArray(tasksResp?.data?.list) ? tasksResp.data.list : [];
    const taskRows = all
      .sort((a: any, b: any) => Number(b?.id || 0) - Number(a?.id || 0))
      .slice(0, 6);
    for (const task of taskRows) {
      if (typeof requestId === "number" && !isMaterialLoadActive(requestId, meetingId, moduleCode)) {
        return;
      }
      const taskId = Number(task?.id || 0);
      if (!taskId) {
        continue;
      }
      const materialResp = await readAuditTaskMaterial(taskId, backendModuleCode);
      if (typeof requestId === "number" && !isMaterialLoadActive(requestId, meetingId, moduleCode)) {
        return;
      }
      const reviewRows = Array.isArray(materialResp?.data?.itemReviews) ? materialResp.data.itemReviews : [];
      if (reviewRows.length > 0) {
        setMaterialReviewRows(reviewRows);
        materialReviewNotice.value = "";
        return;
      }
    }
    materialReviewNotice.value = "褰撳墠妯″潡鏆傛棤鏉＄洰瀹℃牳鏄庣粏";
  } catch (_e) {
    if (typeof requestId === "number" && !isMaterialLoadActive(requestId, meetingId, moduleCode)) {
      return;
    }
    materialItemReviewMap.value = {};
    materialReviewNotice.value = "当前账号暂无条目审核明细权限或暂时无数据";
  }
};

const loadBasicInfoForMaterial = async (meetingId: number, requestId?: number) => {
  try {
    const resp = await fetchMeetingMaterialCurrent(
      meetingId,
      resolveBackendMaterialModuleCode("BASIC_INFO"),
    );
    if (typeof requestId === "number" && !isMaterialLoadActive(requestId, meetingId, "EXPERT_LIST")) {
      return;
    }
    const existing = resp?.data;
    if (existing?.contentJson) {
      const parsed = JSON.parse(existing.contentJson);
      basicForm.value.chairmanExpertIds = normalizeIdList(parsed.chairmanExpertIds);
      basicForm.value.speakerExpertIds = normalizeIdList(parsed.speakerExpertIds);
      basicForm.value.hostExpertIds = normalizeIdList(parsed.hostExpertIds);
      basicForm.value.discussionGuestExpertIds = normalizeIdList(parsed.discussionGuestExpertIds);
    }
  } catch (_e) {
    // ignore basic info load error for expert list roles
  }
};

const loadMaterialModule = async (
  meetingId: number,
  moduleCode: MaterialModuleCode,
  requestId?: number,
) => {
  const backendModuleCode = resolveBackendMaterialModuleCode(moduleCode);
  selectedModuleCode.value = moduleCode;
  materialDialogTitle.value = moduleTitleMap[moduleCode] || "浼氳璧勬枡妯″潡";
  resetMaterialForms();
  if (moduleCode === "EXPERT_LIST") {
    await loadBasicInfoForMaterial(meetingId, requestId);
    if (typeof requestId === "number" && !isMaterialLoadActive(requestId, meetingId, moduleCode)) {
      return;
    }
  }
  try {
    const resp = await fetchMeetingMaterialCurrent(meetingId, backendModuleCode);
    if (typeof requestId === "number" && !isMaterialLoadActive(requestId, meetingId, moduleCode)) {
      return;
    }
    const existing = resp?.data;
    void loadMaterialReviews(meetingId, moduleCode, existing, requestId);
    if (existing?.contentJson) {
      const parsed = JSON.parse(existing.contentJson);
      if (moduleCode === "BASIC_INFO") {
        basicForm.value.chairmanExpertIds = normalizeIdList(parsed.chairmanExpertIds);
        basicForm.value.speakerExpertIds = normalizeIdList(parsed.speakerExpertIds);
        basicForm.value.hostExpertIds = normalizeIdList(parsed.hostExpertIds);
        basicForm.value.discussionGuestExpertIds = normalizeIdList(parsed.discussionGuestExpertIds);
        basicForm.value.guestCount = boundExpertRows.value.length;
        basicForm.value.attendeeCount = Number(parsed.attendeeCount || 1);
        basicForm.value.attendeeActualCount = Number(parsed.attendeeActualCount || 0);
        basicForm.value.targetAudience = parsed.targetAudience || "";
        basicForm.value.mainAgenda = parsed.mainAgenda || "";
        basicForm.value.improvementSuggestion = parsed.improvementSuggestion || "";
        basicForm.value.meetingEffect = parsed.meetingEffect || "";
      } else if (moduleCode === "WRITE_OFF_DOCS") {
        docForm.value.agendaLines = parseWriteOffAgendaFromParsed(parsed.agenda);
        docForm.value.signInName = resolveStoredDocFileName(parsed.signInSheet);
        docForm.value.signInOssKey = parsed.signInSheet?.ossKey || "";
        docForm.value.themePhotoName = resolveStoredDocFileName(parsed.themePhoto);
        docForm.value.themePhotoOssKey = parsed.themePhoto?.ossKey || "";
        expertProfileForm.value.fileName = resolveStoredDocFileName(parsed?.profileFile) || String(parsed?.fileName || "").trim();
        expertProfileForm.value.ossKey = String(parsed?.profileFile?.ossKey || parsed?.ossKey || "").trim();
        const invitationList = Array.isArray(parsed.invitation) ? parsed.invitation : [];
        docForm.value.invitationLines = invitationList
          .map((x: any) => `${resolveStoredDocFileName(x)}|${x?.ossKey || ""}`.trim())
          .join("\n");
        void syncWriteOffDocPreviewUrls();
      } else if (moduleCode === "EXPERT_PROFILE") {
        docForm.value.agendaLines = parseWriteOffAgendaFromParsed(parsed.agenda);
        docForm.value.signInName = resolveStoredDocFileName(parsed.signInSheet);
        docForm.value.signInOssKey = parsed.signInSheet?.ossKey || "";
        docForm.value.themePhotoName = resolveStoredDocFileName(parsed.themePhoto);
        docForm.value.themePhotoOssKey = parsed.themePhoto?.ossKey || "";
        const invitationList = Array.isArray(parsed.invitation) ? parsed.invitation : [];
        docForm.value.invitationLines = invitationList
          .map((x: any) => `${resolveStoredDocFileName(x)}|${x?.ossKey || ""}`.trim())
          .join("\n");
        expertProfileForm.value.fileName = resolveStoredDocFileName(parsed?.profileFile) || String(parsed?.fileName || "").trim();
        expertProfileForm.value.ossKey = String(parsed?.profileFile?.ossKey || parsed?.ossKey || "").trim();
      } else if (moduleCode === "EXPERT_LIST") {
        const onsitePhoto = parsed?.onsitePhoto || {};
        const laborProtocol = parsed?.laborProtocol || {};
        const invoiceDetail = parsed?.invoiceDetail || {};
        const photos = Array.isArray(onsitePhoto.photos) ? onsitePhoto.photos : [];
        photos.forEach((x: any) => {
          const expertId = Number(x?.expertId || 0);
          if (expertId > 0) {
            if (!photoByExpert.value[expertId]) {
              photoByExpert.value[expertId] = [];
            }
            photoByExpert.value[expertId].push({
              name: x?.name || x?.fileName || "",
              ossKey: x?.ossKey || "",
              previewUrl: "",
            });
          }
        });
        const globalSummary = onsitePhoto.summary || "";
        const expertSummaries = Array.isArray(onsitePhoto.expertSummaries) ? onsitePhoto.expertSummaries : [];
        expertSummaries.forEach((x: any) => {
          const expertId = Number(x?.expertId || 0);
          if (expertId > 0) {
            photoSummaryByExpert.value[expertId] = x?.summary || "";
          }
        });
        if (globalSummary && expertSummaries.length === 0) {
          boundExpertRows.value.forEach((expert: any) => {
             photoSummaryByExpert.value[expert.expertId] = globalSummary;
          });
        }
        photoForm.value.summary = globalSummary;
        const details = Array.isArray(laborProtocol.details) ? laborProtocol.details : [];
        details.forEach((x: any) => {
          const expertId = Number(x?.expertId || 0);
          if (expertId > 0) {
            if (!laborByExpert.value[expertId]) {
              laborByExpert.value[expertId] = createEmptyLaborRow();
            }
            const protoKey = String(x?.protocolFile?.ossKey || "").trim();
            const baseRow: LaborRow = {
              protocolName: x?.protocolFile?.name || "",
              protocolOssKey: protoKey,
              protocolFileName: String(x?.protocolFile?.fileName || "").trim() || getFileNameFromOssKey(protoKey),
              protocolPreviewUrl: "",
              invoiceFiles: (Array.isArray(x?.invoiceFiles) ? x.invoiceFiles : [])
                .map((file: any) => ({
                  fileName: String(file?.fileName || file?.name || "").trim(),
                  ossKey: String(file?.ossKey || "").trim(),
                  previewUrl: "",
                }))
                .filter((file: { fileName: string; ossKey: string; previewUrl: string }) => file.ossKey),
              invoiceFileName: x?.invoiceFile?.name || "",
              invoiceOssKey: x?.invoiceFile?.ossKey || "",
              invoicePreviewUrl: "",
              invoiceOcr: x?.invoiceOcr && typeof x.invoiceOcr === "object" ? x.invoiceOcr : undefined,
              amountCent: Number(x?.amountCent || 0),
              remark: x?.remark || "",
            };
            if (baseRow.invoiceFiles.length === 0 && baseRow.invoiceOssKey) {
              baseRow.invoiceFiles = [{
                fileName: String(baseRow.invoiceFileName || "").trim() || getFileNameFromOssKey(baseRow.invoiceOssKey),
                ossKey: baseRow.invoiceOssKey,
                previewUrl: "",
              }];
            }
            if (!baseRow.invoiceFileName && baseRow.invoiceFiles[0]) {
              baseRow.invoiceFileName = String(baseRow.invoiceFiles[0].fileName || "").trim();
            }
            if (!baseRow.invoiceOssKey && baseRow.invoiceFiles[0]) {
              baseRow.invoiceOssKey = String(baseRow.invoiceFiles[0].ossKey || "").trim();
            }
            laborByExpert.value[expertId] = mergeLaborRow(laborByExpert.value[expertId], baseRow);
          }
        });
        invoiceForm.value.summary = invoiceDetail.summary || "";
      } else if (moduleCode === "MEETING_INVOICE") {
        meetingInvoiceForm.value = normalizeMeetingInvoiceForm(parsed);
        void Promise.allSettled(
          collectMeetingInvoiceFiles(meetingInvoiceForm.value)
            .map((x) => String(x?.ossKey || "").trim())
            .filter((key: string) => key.length > 0)
            .map((key: string) => cacheDocPreviewUrl(key)),
        );
      }
    }
  } catch (_e) {
    // ignore when current material does not exist
  }
  if (moduleCode === "EXPERT_LIST") {
    void (async () => {
      const tasks: Array<Promise<void>> = [];
      Object.keys(photoByExpert.value).forEach((id) => {
        const expertId = Number(id || 0);
        (photoByExpert.value[expertId] || []).forEach((item) => {
          const nameOrKey = item.name || item.ossKey || "";
          if (!item.ossKey || !isImageFile(nameOrKey)) {
            return;
          }
          tasks.push(
            (async () => {
              try {
                const resp = await fetchFilePresignDownload({ objectKey: item.ossKey });
                if (typeof requestId === "number" && !isMaterialLoadActive(requestId, meetingId, moduleCode)) {
                  return;
                }
                item.previewUrl = resp?.data?.signedUrl || "";
              } catch (_e) {
                if (typeof requestId === "number" && !isMaterialLoadActive(requestId, meetingId, moduleCode)) {
                  return;
                }
                item.previewUrl = "";
              }
            })(),
          );
        });
      });
      if (tasks.length > 0) {
        await Promise.allSettled(tasks);
      }

      Object.keys(laborByExpert.value).forEach((id) => {
        const expertId = Number(id || 0);
        const row = laborByExpert.value[expertId];
        if (!row) {
          return;
        }
        const invoiceFiles = Array.isArray(row.invoiceFiles) ? row.invoiceFiles : [];
        if (invoiceFiles.length === 0 && row.invoiceOssKey) {
          invoiceFiles.push({
            fileName: String(row.invoiceFileName || "").trim(),
            ossKey: String(row.invoiceOssKey || "").trim(),
            previewUrl: "",
          });
        }
        if (invoiceFiles.length === 0) {
          row.invoicePreviewUrl = "";
          return;
        }
        invoiceFiles.forEach((file, index) => {
          const key = String(file?.ossKey || "").trim();
          const nameOrKey = String(file?.fileName || key || "").trim();
          if (!key || !isImageFile(nameOrKey)) {
            file.previewUrl = "";
            if (index === 0) {
              row.invoicePreviewUrl = "";
            }
            return;
          }
          tasks.push(
            (async () => {
              try {
                const resp = await fetchFilePresignDownload({ objectKey: key });
                if (typeof requestId === "number" && !isMaterialLoadActive(requestId, meetingId, moduleCode)) {
                  return;
                }
                file.previewUrl = resp?.data?.signedUrl || "";
                if (index === 0) {
                  row.invoicePreviewUrl = file.previewUrl;
                }
              } catch (_e) {
                if (typeof requestId === "number" && !isMaterialLoadActive(requestId, meetingId, moduleCode)) {
                  return;
                }
                file.previewUrl = "";
                if (index === 0) {
                  row.invoicePreviewUrl = "";
                }
              }
            })(),
          );
        });
      });
      if (tasks.length > 0) {
        await Promise.allSettled(tasks);
      }

      const protocolPreviewTasks: Array<Promise<void>> = [];
      Object.keys(laborByExpert.value).forEach((id) => {
        const expertId = Number(id || 0);
        const row = laborByExpert.value[expertId];
        if (!row) {
          return;
        }
        const key = String(row.protocolOssKey || "").trim();
        const nameOrKey = String(row.protocolFileName || key || "").trim();
        if (!key || !isImageFile(nameOrKey)) {
          row.protocolPreviewUrl = "";
          return;
        }
        protocolPreviewTasks.push(
          (async () => {
            try {
              const resp = await fetchFilePresignDownload({ objectKey: key });
              if (typeof requestId === "number" && !isMaterialLoadActive(requestId, meetingId, moduleCode)) {
                return;
              }
              row.protocolPreviewUrl = resp?.data?.signedUrl || "";
            } catch (_e) {
              if (typeof requestId === "number" && !isMaterialLoadActive(requestId, meetingId, moduleCode)) {
                return;
              }
              row.protocolPreviewUrl = "";
            }
          })(),
        );
      });
      if (protocolPreviewTasks.length > 0) {
        await Promise.allSettled(protocolPreviewTasks);
      }
    })();
  }
  if (moduleCode === "EXPERT_PROFILE") {
    const profileKey = String(expertProfileForm.value.ossKey || "").trim();
    if (profileKey && isImageFile(profileKey)) {
      void cacheDocPreviewUrl(profileKey);
    }
  }
  if (typeof requestId === "number" && !isMaterialLoadActive(requestId, meetingId, moduleCode)) {
    return;
  }
  ensureExpertMaterialRows();
  await nextTick();
  if (typeof requestId === "number" && !isMaterialLoadActive(requestId, meetingId, moduleCode)) {
    return;
  }
  initialMaterialContentJson.value = buildContentJson();
  materialSnapshotReady.value = true;
};

const loadMeetingAuditProgress = async () => {
  const meetingId = Number(auditProgressMeetingId.value || 0);
  if (!meetingId) {
    auditProgressRows.value = [];
    auditProgressTotal.value = 0;
    return;
  }
  const resp = await fetchAuditTasks({
    mine: false,
    meetingId,
    pageNo: auditProgressPageNo.value,
    pageSize: auditProgressPageSize.value,
    sortBy: "lastActionAt",
    order: "desc",
  });
  auditProgressRows.value = Array.isArray(resp?.data?.list) ? resp.data.list : [];
  auditProgressTotal.value = Number(resp?.data?.total || 0);
  auditProgressPageNo.value = Number(resp?.data?.pageNo || auditProgressPageNo.value || 1);
  auditProgressPageSize.value = Number(resp?.data?.pageSize || auditProgressPageSize.value || 20);
};

const openAuditProgress = async (row: any) => {
  const meetingId = Number(row?.id || 0);
  if (!meetingId) {
    return;
  }
  auditProgressMeetingId.value = meetingId;
  auditProgressPageNo.value = 1;
  auditProgressDialogVisible.value = true;
  auditProgressRows.value = [];
  auditProgressTotal.value = 0;
  try {
    await loadMeetingAuditProgress();
  } catch (_e) {
    auditProgressRows.value = [];
    auditProgressTotal.value = 0;
    ElMessage.warning("当前账号暂无查看审核进度与意见权限");
  }
};

const handleAuditProgressPageChange = async (pageNo: number) => {
  auditProgressPageNo.value = Number(pageNo || 1);
  await loadMeetingAuditProgress();
};

const handleAuditProgressPageSizeChange = async (pageSize: number) => {
  auditProgressPageSize.value = Number(pageSize || 20);
  auditProgressPageNo.value = 1;
  await loadMeetingAuditProgress();
};

const startMaterialModuleLoad = (
  meetingId: number,
  moduleCode: MaterialModuleCode,
  options?: { reloadExperts?: boolean },
) => {
  const requestId = materialLoadRequestId.value + 1;
  materialLoadRequestId.value = requestId;
  materialSnapshotReady.value = false;
  materialModuleLoading.value = true;
  materialBudgetLoading.value = true;
  prepareMaterialModuleLoad(moduleCode);

  void (async () => {
    try {
      if (options?.reloadExperts) {
        await loadBoundExperts(meetingId, requestId);
      }
      if (!isMaterialLoadActive(requestId, meetingId, moduleCode)) {
        return;
      }
      await loadMaterialModule(meetingId, moduleCode, requestId);
    } finally {
      if (requestId === materialLoadRequestId.value) {
        materialModuleLoading.value = false;
      }
    }
  })();

  void refreshMaterialBudgetBase(meetingId, requestId);
};

const openMaterial = (row: any, moduleCode: MaterialModuleCode = "BASIC_INFO") => {
  const meetingId = Number(row?.id || 0);
  if (!meetingId) {
    return;
  }
  selectedMeetingId.value = meetingId;
  selectedModuleCode.value = moduleCode;
  materialDialogVisible.value = true;
  startMaterialModuleLoad(meetingId, moduleCode, { reloadExperts: true });
};

const handleMaterialTabChange = (tabName: string | number) => {
  if (!selectedMeetingId.value) {
    return;
  }
  const moduleCode = String(tabName) as MaterialModuleCode;
  if (moduleCode === "EXPERT_LIST") {
    expertSubModule.value = "ONSITE_PHOTO";
  }
  startMaterialModuleLoad(Number(selectedMeetingId.value), moduleCode);
};

const resolveSelectedMeetingBudgetCent = () => {
  if (materialBudgetSummary.value.ready) {
    return Math.max(0, Number(materialBudgetSummary.value.budgetCent || 0));
  }
  const meetingId = Number(selectedMeetingId.value || 0);
  const currentDetailId = Number(currentMeetingDetail.value?.id || 0);
  if (meetingId > 0 && currentDetailId === meetingId) {
    return Math.max(0, Number(currentMeetingDetail.value?.budgetCent || 0));
  }
  const currentRow = rows.value.find((item: any) => Number(item?.id || 0) === meetingId);
  return Math.max(0, Number(currentRow?.budgetCent || 0));
};

const resolveSelectedMeetingCateringRatio = () => {
  const meetingId = Number(selectedMeetingId.value || 0);
  const currentDetailId = Number(currentMeetingDetail.value?.id || 0);
  if (meetingId > 0 && currentDetailId === meetingId) {
    return Math.max(0, Number(currentMeetingDetail.value?.cateringRatio || 0));
  }
  const currentRow = rows.value.find((item: any) => Number(item?.id || 0) === meetingId);
  return Math.max(0, Number(currentRow?.cateringRatio || 0));
};

const validateMeetingInvoiceRequired = () => {
  if (selectedModuleCode.value !== "MEETING_INVOICE") {
    return true;
  }
  for (const section of filteredMeetingInvoiceSectionDefs.value) {
    const sectionState = meetingInvoiceForm.value.sections[section.code];
    if (section.code === "OTHER") {
      const otherCount = getMeetingInvoiceOtherFiles().length;
      if (otherCount > meetingInvoiceOtherMaxUploadCount) {
        ElMessage.warning(`“其他”附件数量不能超过${meetingInvoiceOtherMaxUploadCount}个`);
        return false;
      }
      continue;
    }
    for (const uploadField of section.uploadFields) {
      const files = Array.isArray(sectionState?.files?.[uploadField.key]) ? sectionState.files[uploadField.key] : [];
      if (files.length <= 0) {
        ElMessage.warning(`请完善“${section.title}”中的“${uploadField.label}”`);
        return false;
      }
    }
    if (section.needAmount) {
      const amount = Number(sectionState?.totalAmountYuan);
      if (!Number.isFinite(amount) || amount < 0) {
        ElMessage.warning(`请填写“${section.title}”的总费用金额（单位：元）`);
        return false;
      }
      if (section.code === "CATERING_DETAIL") {
        const budgetCent = resolveSelectedMeetingBudgetCent();
        const cateringRatio = resolveSelectedMeetingCateringRatio();
        const maxCateringCent = Math.floor(budgetCent * cateringRatio);
        const amountCent = toCent(amount);
        if (amountCent > maxCateringCent) {
          ElMessage.warning(
            `鈥滈楗槑缁嗏€濋噾棰濅笉鑳借秴杩囦細璁绠?椁愯垂鍗犳瘮锛堜笂闄${toYuan(maxCateringCent)}鍏冿級`,
          );
          return false;
        }
      }
    }
  }
  return true;
};
const validateExpertProfileRequired = () => {
  if (selectedModuleCode.value !== "EXPERT_PROFILE") {
    return true;
  }
  if (!String(expertProfileForm.value.ossKey || "").trim()) {
    ElMessage.warning("璇蜂笂浼犱笓瀹剁畝浠?涓插満鏂囦欢");
    return false;
  }
  return true;
};

const calcExpertUsageFromCurrentForm = () => {
  const laborTotalCent = Object.values(laborByExpert.value).reduce((sum: number, row) => {
    return sum + Math.max(0, Number(row?.amountCent || 0));
  }, 0);
  const invoiceTotalCent = 0;
  return { laborTotalCent, invoiceTotalCent };
};

const calcMeetingInvoiceUsageFromForm = (formState: MeetingInvoiceFormState) => {
  return filteredMeetingInvoiceSectionDefs.value.reduce((sum: number, section) => {
    if (!section.needAmount) {
      return sum;
    }
    const amountYuan = Number(formState.sections?.[section.code]?.totalAmountYuan);
    if (!Number.isFinite(amountYuan) || amountYuan < 0) {
      return sum;
    }
    return sum + toCent(amountYuan);
  }, 0);
};

const calcExpertUsageFromContentJson = (contentJson: string) => {
  const parsed = safeParse(contentJson || "{}");
  const laborDetails = Array.isArray(parsed?.laborProtocol?.details) ? parsed.laborProtocol.details : [];
  const laborTotalCent = laborDetails.reduce((sum: number, row: any) => sum + Math.max(0, Number(row?.amountCent || 0)), 0);
  const invoiceTotalCent = 0;
  return { laborTotalCent, invoiceTotalCent };
};

const calcMeetingInvoiceUsageFromContentJson = (contentJson: string) => {
  const parsed = safeParse(contentJson || "{}");
  const normalized = normalizeMeetingInvoiceForm(parsed);
  return calcMeetingInvoiceUsageFromForm(normalized);
};

const materialBudgetSummary = computed(() => {
  const base = materialBudgetBase.value;
  if (!base.ready) {
    return {
      budgetCent: 0,
      laborTotalCent: 0,
      invoiceTotalCent: 0,
      meetingInvoiceTotalCent: 0,
      usedTotalCent: 0,
      remainCent: 0,
      overCent: 0,
      ready: false,
    };
  }
  let laborTotalCent = base.laborTotalCent;
  let invoiceTotalCent = base.invoiceTotalCent;
  let meetingInvoiceTotalCent = base.meetingInvoiceTotalCent;
  if (selectedModuleCode.value === "EXPERT_LIST") {
    const currentExpertUsage = calcExpertUsageFromCurrentForm();
    laborTotalCent = currentExpertUsage.laborTotalCent;
    invoiceTotalCent = currentExpertUsage.invoiceTotalCent;
  }
  if (selectedModuleCode.value === "MEETING_INVOICE") {
    meetingInvoiceTotalCent = calcMeetingInvoiceUsageFromForm(meetingInvoiceForm.value);
  }
  const usedTotalCent = laborTotalCent + invoiceTotalCent + meetingInvoiceTotalCent;
  const remainCent = Math.max(0, base.budgetCent - usedTotalCent);
  const overCent = Math.max(0, usedTotalCent - base.budgetCent);
  return {
    budgetCent: base.budgetCent,
    laborTotalCent,
    invoiceTotalCent,
    meetingInvoiceTotalCent,
    usedTotalCent,
    remainCent,
    overCent,
    ready: true,
  };
});

const readMeetingMaterialContentJson = async (meetingId: number, moduleCode: "EXPERT_LIST" | "MEETING_INVOICE") => {
  try {
    const resp = await fetchMeetingMaterialCurrent(meetingId, moduleCode);
    return String(resp?.data?.contentJson || "");
  } catch (_e) {
    return "";
  }
};

const resolveMeetingBudgetCent = async (meetingId: number) => {
  const currentDetailId = Number(currentMeetingDetail.value?.id || 0);
  if (currentDetailId === meetingId) {
    const budget = Number(currentMeetingDetail.value?.budgetCent || 0);
    if (Number.isFinite(budget) && budget >= 0) {
      return budget;
    }
  }
  const currentRow = rows.value.find((item: any) => Number(item?.id || 0) === meetingId);
  if (currentRow) {
    const budget = Number(currentRow?.budgetCent || 0);
    if (Number.isFinite(budget) && budget >= 0) {
      return budget;
    }
  }
  try {
    const resp = await fetchMeetingDetail(meetingId);
    return Math.max(0, Number(resp?.data?.budgetCent || 0));
  } catch (_e) {
    return 0;
  }
};

const refreshMaterialBudgetBase = async (meetingIdInput?: number, requestId?: number) => {
  const meetingId = Number(meetingIdInput ?? selectedMeetingId.value ?? 0);
  if (!meetingId) {
    if (typeof requestId !== "number" || requestId === materialLoadRequestId.value) {
      materialBudgetBase.value = {
        budgetCent: 0,
        laborTotalCent: 0,
        invoiceTotalCent: 0,
        meetingInvoiceTotalCent: 0,
        ready: false,
      };
      materialBudgetLoading.value = false;
    }
    return;
  }
  try {
    const [budgetCent, expertContentJson, meetingInvoiceContentJson] = await Promise.all([
      resolveMeetingBudgetCent(meetingId),
      readMeetingMaterialContentJson(meetingId, "EXPERT_LIST"),
      readMeetingMaterialContentJson(meetingId, "MEETING_INVOICE"),
    ]);
    if (typeof requestId === "number" && !isMaterialLoadActive(requestId, meetingId)) {
      return;
    }
    const expertUsage = calcExpertUsageFromContentJson(expertContentJson);
    const meetingInvoiceTotalCent = calcMeetingInvoiceUsageFromContentJson(meetingInvoiceContentJson);
    materialBudgetBase.value = {
      budgetCent,
      laborTotalCent: expertUsage.laborTotalCent,
      invoiceTotalCent: expertUsage.invoiceTotalCent,
      meetingInvoiceTotalCent,
      ready: true,
    };
  } finally {
    if (typeof requestId !== "number" || requestId === materialLoadRequestId.value) {
      materialBudgetLoading.value = false;
    }
  }
};

const validateMeetingBudgetLimit = async () => {
  await refreshMaterialBudgetBase();
  if (!materialBudgetSummary.value.ready) {
    return true;
  }
  const summary = materialBudgetSummary.value;
  if (summary.overCent <= 0) {
    return true;
  }
  ElMessage.warning(`预算校验不通过：当前会议预算${toYuan(summary.budgetCent)}元，已用${toYuan(summary.usedTotalCent)}元，超预算${toYuan(summary.overCent)}元`);
  return false;
};


const handleSaveMaterial = async () => {
  if (!selectedMeetingId.value) {
    return false;
  }
  if (selectedMeetingMaterialReadonly.value) {
    warnMeetingMaterialReadonly();
    return false;
  }
  if (materialModuleLoading.value) {
    return false;
  }
  if (!validateMeetingInvoiceRequired()) {
    return false;
  }
  if (!validateExpertProfileRequired()) {
    return false;
  }
  if (!(await validateMeetingBudgetLimit())) {
    return false;
  }
  try {
    await saveMeetingMaterial(
      selectedMeetingId.value,
      resolveBackendMaterialModuleCode(selectedModuleCode.value),
      {
        contentJson: buildContentJson(),
        remark: materialForm.value.remark,
      },
    );
    ElMessage.success("妯″潡淇濆瓨鎴愬姛");
    initialMaterialContentJson.value = buildContentJson();
    return true;
  } catch (e) {
    return false;
  }
};

const handleSubmitMaterial = async () => {
  if (!selectedMeetingId.value) {
    return;
  }
  if (selectedMeetingMaterialReadonly.value) {
    warnMeetingMaterialReadonly();
    return;
  }
  if (materialModuleLoading.value) {
    return;
  }
  if (!validateMeetingInvoiceRequired()) {
    return;
  }
  if (!validateExpertProfileRequired()) {
    return;
  }
  if (!(await validateMeetingBudgetLimit())) {
    return;
  }
  await submitMeetingMaterial(
    selectedMeetingId.value,
    resolveBackendMaterialModuleCode(selectedModuleCode.value),
    {
    contentJson: buildContentJson(),
    remark: materialForm.value.remark,
    },
  );
  ElMessage.success("妯″潡鎻愪氦鎴愬姛");
  materialDialogVisible.value = false;
};

const buildContentJson = () => {
  if (selectedModuleCode.value === "BASIC_INFO") {
    return JSON.stringify({
      chairmanExpertIds: normalizeIdList(basicForm.value.chairmanExpertIds),
      speakerExpertIds: normalizeIdList(basicForm.value.speakerExpertIds),
      hostExpertIds: normalizeIdList(basicForm.value.hostExpertIds),
      discussionGuestExpertIds: normalizeIdList(basicForm.value.discussionGuestExpertIds),
      guestCount: basicForm.value.guestCount,
      attendeeCount: basicForm.value.attendeeCount,
      attendeeActualCount: basicForm.value.attendeeActualCount,
      targetAudience: basicForm.value.targetAudience,
      mainAgenda: basicForm.value.mainAgenda,
      improvementSuggestion: basicForm.value.improvementSuggestion,
      meetingEffect: basicForm.value.meetingEffect,
    });
  }
  if (selectedModuleCode.value === "EXPERT_LIST") {
    const photos: Array<{ expertId: number; expertName: string; name: string; ossKey: string }> = [];
    Object.keys(photoByExpert.value).forEach((id) => {
      const expertId = Number(id || 0);
      const expertName = boundExpertRows.value.find((x: any) => Number(x?.expertId || 0) === expertId)?.expertName || "";
      (photoByExpert.value[expertId] || []).forEach((x) => {
        photos.push({
          expertId,
          expertName,
          name: x.name,
          ossKey: x.ossKey,
        });
      });
    });
    const details: Array<{
      expertId: number;
      expertName: string;
      protocolFile: { name: string; fileName?: string; ossKey: string };
      invoiceFile: { name: string; ossKey: string };
      invoiceFiles?: Array<{ name: string; fileName?: string; ossKey: string }>;
      invoiceOcr?: any;
      amountCent: number;
      remark: string;
    }> = [];
    Object.keys(laborByExpert.value).forEach((id) => {
      const expertId = Number(id || 0);
      const row = laborByExpert.value[expertId];
      if (!row || isLaborRowEmpty(row)) {
        return;
      }
      const expertName = boundExpertRows.value.find((x: any) => Number(x?.expertId || 0) === expertId)?.expertName || "";
      details.push({
        expertId,
        expertName,
        protocolFile: {
          name: row.protocolName || "",
          fileName: row.protocolFileName || "",
          ossKey: row.protocolOssKey || "",
        },
        invoiceFile: {
          name: row.invoiceFileName || "",
          ossKey: row.invoiceOssKey || "",
        },
        invoiceFiles: (Array.isArray(row.invoiceFiles) ? row.invoiceFiles : [])
          .filter((file) => String(file?.ossKey || "").trim())
          .map((file) => ({
            name: String(file?.fileName || "").trim(),
            fileName: String(file?.fileName || "").trim(),
            ossKey: String(file?.ossKey || "").trim(),
          })),
        invoiceOcr: row.invoiceOcr,
        amountCent: Number(row.amountCent || 0),
        remark: row.remark || "",
      });
    });
    const invoices: Array<any> = [];
    return JSON.stringify({
      onsitePhoto: {
        photos,
        summary: photoForm.value.summary,
        expertSummaries: Object.keys(photoSummaryByExpert.value).map((id) => {
          const expertId = Number(id || 0);
          const expertName = boundExpertRows.value.find((x: any) => Number(x?.expertId || 0) === expertId)?.expertName || "";
          return { expertId, expertName, summary: photoSummaryByExpert.value[expertId] || "" };
        }),
      },
      laborProtocol: {
        details,
      },
      invoiceDetail: {
        invoices,
        summary: invoiceForm.value.summary,
      },
    });
  }
  if (selectedModuleCode.value === "MEETING_INVOICE") {
    const sections = filteredMeetingInvoiceSectionDefs.value.map((section) => {
      const sectionState = meetingInvoiceForm.value.sections[section.code];
      const files = section.uploadFields
        .flatMap((field) => {
          const rows = Array.isArray(sectionState?.files[field.key]) ? sectionState.files[field.key] : [];
          return rows.map((row) => {
            const ossKey = String(row?.ossKey || "").trim();
            if (!ossKey) {
              return null;
            }
            return {
              fieldKey: field.key,
              label: field.label,
              fileName: String(row?.fileName || "").trim(),
              ossKey,
            };
          });
        })
        .filter((x): x is { fieldKey: MeetingInvoiceUploadFieldKey; label: string; fileName: string; ossKey: string } => !!x);
      const totalAmountYuan = Number(sectionState?.totalAmountYuan);
      return {
        sectionCode: section.code,
        sectionTitle: section.title,
        totalAmountYuan: section.needAmount && Number.isFinite(totalAmountYuan) && totalAmountYuan >= 0
          ? Number(totalAmountYuan.toFixed(2))
          : null,
        totalAmountCent: section.needAmount && Number.isFinite(totalAmountYuan) && totalAmountYuan >= 0
          ? toCent(totalAmountYuan)
          : null,
        files,
      };
    });
    return JSON.stringify({
      sections,
    });
  }
  if (selectedModuleCode.value === "EXPERT_PROFILE") {
    const signInFileName = String(docForm.value.signInName || "").trim();
    return JSON.stringify({
      agenda: buildAgendaJsonPayload(),
      signInSheet: {
        name: signInFileName,
        fileName: signInFileName,
        ossKey: docForm.value.signInOssKey,
      },
      themePhoto: {
        name: String(docForm.value.themePhotoName || "").trim(),
        fileName: String(docForm.value.themePhotoName || "").trim(),
        ossKey: String(docForm.value.themePhotoOssKey || "").trim(),
      },
      invitation: (docForm.value.invitationLines || "")
        .split("\n")
        .map((line) => line.trim())
        .filter((line) => line.length > 0)
        .map((line) => {
          const parts = line.split("|");
          const fileName = (parts[0] || "").trim();
          return {
            name: fileName,
            fileName,
            ossKey: (parts[1] || "").trim(),
          };
        }),
      profileFile: {
        name: String(expertProfileForm.value.fileName || "").trim(),
        fileName: String(expertProfileForm.value.fileName || "").trim(),
        ossKey: String(expertProfileForm.value.ossKey || "").trim(),
      },
    });
  }
  if (selectedModuleCode.value === "WRITE_OFF_DOCS") {
    const signInFileName = String(docForm.value.signInName || "").trim();
    const invitations = (docForm.value.invitationLines || "")
      .split("\n")
      .map((line) => line.trim())
      .filter((line) => line.length > 0)
      .map((line) => {
        const parts = line.split("|");
        const fileName = (parts[0] || "").trim();
        return {
          name: fileName,
          fileName,
          ossKey: (parts[1] || "").trim(),
        };
      });
    return JSON.stringify({
      agenda: buildAgendaJsonPayload(),
      signInSheet: {
        name: signInFileName,
        fileName: signInFileName,
        ossKey: docForm.value.signInOssKey,
      },
      themePhoto: {
        name: String(docForm.value.themePhotoName || "").trim(),
        fileName: String(docForm.value.themePhotoName || "").trim(),
        ossKey: String(docForm.value.themePhotoOssKey || "").trim(),
      },
      invitation: invitations,
      profileFile: {
        name: String(expertProfileForm.value.fileName || "").trim(),
        fileName: String(expertProfileForm.value.fileName || "").trim(),
        ossKey: String(expertProfileForm.value.ossKey || "").trim(),
      },
    });
  }
  const invitations = (docForm.value.invitationLines || "")
    .split("\n")
    .map((line) => line.trim())
    .filter((line) => line.length > 0)
    .map((line) => {
      const parts = line.split("|");
      const fileName = (parts[0] || "").trim();
      return {
        name: fileName,
        fileName,
        ossKey: (parts[1] || "").trim(),
      };
    });
  const signInFileName = String(docForm.value.signInName || "").trim();
  return JSON.stringify({
    agenda: buildAgendaJsonPayload(),
    signInSheet: {
      name: signInFileName,
      fileName: signInFileName,
      ossKey: docForm.value.signInOssKey,
    },
    themePhoto: {
      name: String(docForm.value.themePhotoName || "").trim(),
      fileName: String(docForm.value.themePhotoName || "").trim(),
      ossKey: String(docForm.value.themePhotoOssKey || "").trim(),
    },
    invitation: invitations,
  });
};

const openHistory = async (row: any) => {
  selectedMeetingId.value = row.id;
  historyModuleCode.value = "BASIC_INFO";
  historyDialogVisible.value = true;
  diffRows.value = [];
  await loadMaterialHistory();
};

const loadMaterialHistory = async () => {
  if (!selectedMeetingId.value) {
    return;
  }
  const backendHistoryModuleCode = resolveBackendMaterialModuleCode(historyModuleCode.value);
  const resp = await fetchMeetingMaterialHistory(selectedMeetingId.value, backendHistoryModuleCode);
  historyRows.value = resp?.data?.list || [];
  diffRows.value = [];
};

const compareWithPrevious = (index: number) => {
  if (index < 0 || index + 1 >= historyRows.value.length) {
    diffRows.value = [];
    ElMessage.warning("没有上一版本可对比");
    return;
  }
  const current = safeParse(historyRows.value[index].contentJson);
  const previous = safeParse(historyRows.value[index + 1].contentJson);
  const currentFlat = flatten(current);
  const previousFlat = flatten(previous);
  const keys = new Set([...Object.keys(currentFlat), ...Object.keys(previousFlat)]);
  const diffs: Array<{ field: string; previous: string; current: string }> = [];
  keys.forEach((key) => {
    const a = stringifyValue(previousFlat[key]);
    const b = stringifyValue(currentFlat[key]);
    if (a !== b) {
      diffs.push({ field: key, previous: a, current: b });
    }
  });
  diffRows.value = diffs;
};

const safeParse = (val: string) => {
  try {
    return JSON.parse(val || "{}");
  } catch (_e) {
    return {};
  }
};

const flatten = (obj: any, prefix = "", result: Record<string, any> = {}) => {
  if (obj === null || obj === undefined) {
    result[prefix || "root"] = obj;
    return result;
  }
  if (typeof obj !== "object") {
    result[prefix || "root"] = obj;
    return result;
  }
  if (Array.isArray(obj)) {
    obj.forEach((item, idx) => flatten(item, `${prefix}[${idx}]`, result));
    if (obj.length === 0) {
      result[prefix || "root"] = [];
    }
    return result;
  }
  const keys = Object.keys(obj);
  if (keys.length === 0) {
    result[prefix || "root"] = {};
    return result;
  }
  keys.forEach((key) => {
    const next = prefix ? `${prefix}.${key}` : key;
    flatten(obj[key], next, result);
  });
  return result;
};

const stringifyValue = (v: any) => {
  if (v === undefined) {
    return "";
  }
  if (typeof v === "string") {
    return v;
  }
  return JSON.stringify(v);
};

const meetingInvoiceUiFns = {
  getMeetingInvoiceSectionUploadFileList: getMeetingInvoiceSectionUploadFileList as any,
  buildMeetingInvoiceUploadChangeHandler: buildMeetingInvoiceUploadChangeHandler as any,
  buildMeetingInvoicePictureRemoveHandler: buildMeetingInvoicePictureRemoveHandler as any,
  getMeetingInvoiceFileRow: getMeetingInvoiceFileRow as any,
  openOcrRawDialog: openOcrRawDialog as any,
  runMeetingInvoiceOcr: runMeetingInvoiceOcr as any,
  buildMeetingInvoiceFieldItemKey: buildMeetingInvoiceFieldItemKey as any,
  buildMeetingInvoiceAmountItemKey: buildMeetingInvoiceAmountItemKey as any,
};

watch(
  () => `${String(route.query.topic || "")}|${String(route.query.meetingId || "")}`,
  () => {
    syncSearchRoute();
  },
);

watch(materialDialogVisible, (visible) => {
  if (visible) {
    return;
  }
  materialLoadRequestId.value += 1;
  materialSnapshotReady.value = false;
  initialMaterialContentJson.value = "";
  materialModuleLoading.value = false;
  materialBudgetLoading.value = false;
});

onMounted(async () => {
  await Promise.all([loadExpertDictOptions(), loadMeetingCategoryOptions()]);
  await syncSearchRoute();
});

onBeforeUnmount(() => {
  Object.keys(summaryTaskTimerMap).forEach((meetingId) => {
    clearSummaryTaskPolling(Number(meetingId));
  });
});
</script>



