import { useCallback, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent, type ReactNode } from "react"; import { Search, Plus, Download, MapPin, Building2, User, Phone, X, Clock, FileText, Calendar, ChevronDown, ChevronRight, Check } from "lucide-react"; import { motion, AnimatePresence } from "motion/react"; import { useLocation } from "react-router-dom"; import { canUsePermission, checkChannelExpansionDuplicate, checkCrmExpansionDuplicate, checkSalesExpansionDuplicate, createChannelExpansion, createCrmExpansion, createSalesExpansion, decodeExpansionMultiValue, getCrmExpansionOverview, getExpansionCityOptions, getExpansionMeta, getExpansionOverview, getOpportunityExpansionOptions, getOpportunityMeta, getStoredCurrentUserId, listMyPermissions, moveChannelToCrm, moveCrmToChannel, updateChannelExpansion, updateCrmExpansion, updateSalesExpansion, type ChannelExpansionContact, type ChannelExpansionItem, type CrmExpansionContact, type CrmExpansionItem, type CrmId, type CreateChannelExpansionPayload, type CreateCrmExpansionPayload, type CreateSalesExpansionPayload, type ExpansionDictOption, type ExpansionFollowUp, type MoveChannelToCrmPayload, type MoveCrmToChannelPayload, type SalesExpansionItem, type UpdateCrmExpansionPayload, } from "@/lib/auth"; import { AdaptiveSelect } from "@/components/AdaptiveSelect"; import { SearchableSelect, type SearchableOption } from "@/components/SearchableSelect"; import { SearchOrInputSelect } from "@/components/SearchOrInputSelect"; import { ChannelContactRows, buildChannelContactRows, createDefaultChannelContacts, validateChannelContactRows, } from "@/features/crmQuickCreate/shared"; import { useIsMobileViewport } from "@/hooks/useIsMobileViewport"; import { useIsWecomBrowser } from "@/hooks/useIsWecomBrowser"; import { cn } from "@/lib/utils"; type ExpansionItem = SalesExpansionItem | ChannelExpansionItem | CrmExpansionItem; const LIST_PAGE_SIZE = 10; const SUPPLIER_SEARCH_LIMIT = 20; const SUPPLIER_SEARCH_DEBOUNCE_MS = 300; type ExpansionTab = "sales" | "channel" | "crm"; type ExpansionLocationState = { tab?: ExpansionTab; selectedId?: CrmId } | null; type ExpansionExportFilters = { keyword?: string; intent?: string; officeName?: string; industry?: string; employmentStatus?: string; province?: string; certificationLevel?: string; channelIndustry?: string; channelAttribute?: string; establishedStartDate?: string; establishedEndDate?: string; hasRelatedProject?: string; relatedProjectStageCodes?: string[]; selectedSalesFields?: SalesExportFieldKey[]; selectedChannelFields?: ChannelExportFieldKey[]; selectedCrmFields?: CrmExportFieldKey[]; }; type SalesExportFieldKey = | "employeeNo" | "name" | "phone" | "officeName" | "dept" | "title" | "industry" | "intent" | "active" | "hasExp" | "relatedProjects" | "relatedProjectAmount" | "owner" | "createdAt" | "updatedAt" | "followUps"; type ChannelExportFieldKey = | "channelCode" | "name" | "province" | "city" | "officeAddress" | "coverageItems" | "certificationLevel" | "channelIndustry" | "channelAttribute" | "internalAttribute" | "intent" | "establishedDate" | "revenue" | "size" | "registeredCapital" | "hasDesktopExp" | "relatedProjects" | "relatedProjectAmount" | "contacts" | "notes" | "owner" | "createdAt" | "updatedAt" | "followUps"; type CrmExportFieldKey = | "endUser" | "officeName" | "industryAttr" | "extensionType" | "purchaseDateText" | "warrantyExpiryText" | "onlineStatus" | "supplierName" | "h3cContactName" | "hasExpansionOpportunity" | "softwarePoints" | "expansionTimeText" | "expansionScale" | "hasMaintenanceOpportunity" | "contacts" | "followUps" | "owner" | "createdAt" | "updatedAt"; type ExportColumnKind = "default" | "longText" | "project" | "contact" | "followup"; type ExportCellValue = string | number; type RelatedProjectLike = { opportunityCode?: string; opportunityName?: string; stageCode?: string; stage?: string; amount?: number | null; }; type ExportColumn = { key: K; label: string; kind?: ExportColumnKind; numFmt?: string; value: (item: T) => ExportCellValue; }; type SalesCreateField = | "employeeNo" | "officeName" | "candidateName" | "mobile" | "targetDept" | "industry" | "title" | "intentLevel" | "employmentStatus" | "regionProvince" | "regionCity" | "regionItems"; type ChannelField = | "channelName" | "province" | "city" | "coverageProvince" | "coverageCity" | "coverageItems" | "officeAddress" | "channelIndustry" | "certificationLevel" | "annualRevenue" | "staffSize" | "registeredCapital" | "contactEstablishedDate" | "intentLevel" | "channelAttribute" | "channelAttributeCustom" | "internalAttribute" | "contacts"; function createEmptyCrmContact(): CrmExpansionContact { return { name: "", mobile: "", title: "", }; } const CHANNEL_REVENUE_LABEL = "年度营业额(万元)"; const CHANNEL_REGISTERED_CAPITAL_LABEL = "注册资金(万元)"; const EXPANSION_CREATE_PERMISSION = "expansion:create"; const EXPANSION_EXPORT_PREFERENCES_STORAGE_KEY = "crm:expansion-export-preferences"; const defaultSalesForm: CreateSalesExpansionPayload = { employeeNo: "", candidateName: "", officeName: "", mobile: "", industry: "", title: "", intentLevel: "medium", hasDesktopExp: false, employmentStatus: "active", regionProvince: [], regionCity: [], regionItems: [], }; const defaultChannelForm: CreateChannelExpansionPayload = { channelCode: "", channelName: "", province: "", city: "", coverageProvince: [], coverageCity: [], coverageItems: [], officeAddress: "", channelIndustry: [], certificationLevel: "", contactEstablishedDate: "", intentLevel: "medium", hasDesktopExp: false, channelAttribute: [], channelAttributeCustom: "", internalAttribute: [], stage: "initial_contact", remark: "", contacts: createDefaultChannelContacts(), }; const defaultMoveChannelForm: MoveChannelToCrmPayload = { officeName: "", extensionType: [], purchaseDate: "", warrantyExpiry: "", onlineStatus: "", supplierId: 0, supplierName: "", h3cContactId: 0, h3cContactName: "", hasExpansionOpportunity: "", softwarePoints: undefined, expansionTime: "", expansionScale: "", hasMaintenanceOpportunity: "", contacts: [], }; const defaultMoveCrmForm: MoveCrmToChannelPayload = { province: "", city: "", officeAddress: "", certificationLevel: "", annualRevenue: 0, staffSize: 0, registeredCapital: 0, channelAttribute: [], channelAttributeCustom: "", internalAttribute: [], coverageProvince: [], coverageCity: [], coverageItems: [], intentLevel: "", hasDesktopExp: false, stage: "initial_contact", landedFlag: false, expectedSignDate: "", contacts: createDefaultChannelContacts(), }; /** 在当前日期基础上加指定年数,返回 YYYY-MM-DD */ function addYearsToDate(dateText: string, years: number) { const normalized = dateText?.trim(); if (!normalized) { return ""; } const match = normalized.match(/^(\d{4})-(\d{2})-(\d{2})/); if (!match) { return normalized; } const year = Number(match[1]); const month = Number(match[2]); const day = Number(match[3]); if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) { return normalized; } const next = new Date(year + years, month - 1, day); const nextYear = next.getFullYear(); const nextMonth = `${next.getMonth() + 1}`.padStart(2, "0"); const nextDay = `${next.getDate()}`.padStart(2, "0"); return `${nextYear}-${nextMonth}-${nextDay}`; } /** 尝试用省份中文名匹配代表处字典项,匹配到返回字典 value,否则返回空 */ function matchOfficeValueByProvince(province: string | undefined, officeOptions: ExpansionDictOption[]) { const normalized = province?.trim(); if (!normalized) { return ""; } const exactMatch = officeOptions.find((option) => option.label === normalized); if (exactMatch) { return exactMatch.value ?? ""; } // cnarea 省份为全称(如"重庆市"),字典 label 为简称(如"重庆"),按前缀匹配回退 const prefixMatch = officeOptions.find((option) => { const label = option.label?.trim(); return label ? normalized.startsWith(label) : false; }); return prefixMatch?.value ?? ""; } function isOtherOption(option?: ExpansionDictOption) { const candidate = `${option?.label ?? ""}${option?.value ?? ""}`.toLowerCase(); return candidate.includes("其他") || candidate.includes("其它") || candidate.includes("other"); } function normalizeOptionalText(value?: string) { const trimmed = value?.trim(); return trimmed ? trimmed : undefined; } function loadExpansionExportPreferences(): ExpansionExportFilters { if (typeof window === "undefined") { return {}; } try { const rawValue = window.localStorage.getItem(EXPANSION_EXPORT_PREFERENCES_STORAGE_KEY); if (!rawValue) { return {}; } const parsed = JSON.parse(rawValue); return typeof parsed === "object" && parsed ? parsed as ExpansionExportFilters : {}; } catch { return {}; } } function persistExpansionExportPreferences(filters: ExpansionExportFilters) { if (typeof window === "undefined") { return; } try { window.localStorage.setItem(EXPANSION_EXPORT_PREFERENCES_STORAGE_KEY, JSON.stringify(filters)); } catch { // ignore storage failures } } function dedupeExpansionItemsById(items: T[]) { const seenIds = new Set(); return items.filter((item) => { if (item.id === null || item.id === undefined) { return true; } if (seenIds.has(item.id)) { return false; } seenIds.add(item.id); return true; }); } function getFieldInputClass(hasError: boolean) { return cn( "crm-input-box crm-input-text w-full border bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:bg-slate-900/50", hasError ? "border-rose-400 bg-rose-50/60 focus:border-rose-500 focus:ring-rose-500 dark:border-rose-500/70 dark:bg-rose-500/10" : "border-slate-200 dark:border-slate-800", ); } function normalizeExportText(value?: string | number | boolean | null) { if (value === null || value === undefined) { return ""; } const normalized = String(value).replace(/\r?\n/g, " ").trim(); if (!normalized || normalized === "无") { return ""; } return normalized; } // 后端 coverageItems 形如「省份|城市,省份|城市」,导出时转为「省份-城市、省份-城市」。 function formatCoverageItemsExport(value?: string) { if (!value?.trim()) { return ""; } return value .split(",") .map((part) => { const [province, city] = part.split("|"); const p = province?.trim() || ""; const c = city?.trim() || ""; return c ? `${p}-${c}` : p; }) .filter(Boolean) .join("、"); } function normalizeExportNumber(value?: string | number | null) { if (value === null || value === undefined) { return undefined; } if (typeof value === "number") { return Number.isFinite(value) ? value : undefined; } const normalized = value.replace(/[¥,\s]|人/g, "").trim(); if (!normalized) { return undefined; } const parsed = Number(normalized); return Number.isFinite(parsed) ? parsed : undefined; } function normalizeExportFilterText(value?: string | number | boolean | null) { return normalizeExportText(value).toLowerCase(); } function matchesExportKeyword(value: string, keyword?: string) { const normalizedKeyword = normalizeExportFilterText(keyword); return !normalizedKeyword || value.toLowerCase().includes(normalizedKeyword); } function matchesTextFilter(value: string | undefined, filterValue?: string) { const normalizedFilter = normalizeExportFilterText(filterValue); if (!normalizedFilter) { return true; } return normalizeExportFilterText(value).includes(normalizedFilter); } function matchesDateRange(value?: string, startDate?: string, endDate?: string) { const normalizedValue = normalizeExportText(value).slice(0, 10); if (!normalizedValue) { return !startDate && !endDate; } if (startDate && normalizedValue < startDate) { return false; } if (endDate && normalizedValue > endDate) { return false; } return true; } function hasRelatedProjects(projects?: Array<{ amount?: number }>) { return Boolean(projects?.length); } function matchesRelatedProjectFilter(projects: Array<{ amount?: number }> | undefined, filterValue?: string) { if (filterValue === "yes") { return hasRelatedProjects(projects); } if (filterValue === "no") { return !hasRelatedProjects(projects); } return true; } function normalizeMultiSelectValues(values?: string[]) { return Array.from(new Set((values ?? []).map((value) => value?.trim()).filter((value): value is string => Boolean(value)))); } function getDictOptionValue(option?: ExpansionDictOption) { const value = option?.value?.trim() || option?.label?.trim() || ""; return value; } function getDictOptionLabel(option?: ExpansionDictOption) { return option?.label?.trim() || option?.value?.trim() || ""; } function getDictLabelByValue(value: string | undefined | null, options: ExpansionDictOption[]) { const target = value?.trim(); if (!target) { return ""; } const matched = options.find((option) => option.value?.trim() === target); return matched?.label?.trim() || target; } function isLostProjectStageOption(option?: ExpansionDictOption) { const value = getDictOptionValue(option).toLowerCase(); const label = getDictOptionLabel(option).toLowerCase(); return value === "lost" || value.includes("丢单") || label.includes("丢单") || value.includes("放弃") || label.includes("放弃"); } function getDefaultRelatedProjectStageCodes(options: ExpansionDictOption[]) { const orderedValues = options.map(getDictOptionValue).filter(Boolean); const preferredValues = options.filter((option) => !isLostProjectStageOption(option)).map(getDictOptionValue).filter(Boolean); return normalizeMultiSelectValues(preferredValues.length > 0 ? preferredValues : orderedValues); } function applyDefaultRelatedProjectStageFilters(filters: ExpansionExportFilters, options: ExpansionDictOption[]) { if (filters.relatedProjectStageCodes !== undefined) { return { ...filters, relatedProjectStageCodes: normalizeMultiSelectValues(filters.relatedProjectStageCodes), }; } if (options.length <= 0) { return filters; } return { ...filters, relatedProjectStageCodes: getDefaultRelatedProjectStageCodes(options), }; } function areSameStringSets(leftValues?: string[], rightValues?: string[]) { const left = normalizeMultiSelectValues(leftValues).sort(); const right = normalizeMultiSelectValues(rightValues).sort(); return left.length === right.length && left.every((value, index) => value === right[index]); } function filterRelatedProjectsByStage(projects: T[] | undefined, selectedStageCodes?: string[]) { const projectList = projects ?? []; if (selectedStageCodes === undefined) { return projectList; } const normalizedStageCodes = new Set(normalizeMultiSelectValues(selectedStageCodes)); if (normalizedStageCodes.size <= 0) { return []; } return projectList.filter((project) => { const projectStageCode = project.stageCode?.trim(); const projectStageLabel = project.stage?.trim(); return (projectStageCode && normalizedStageCodes.has(projectStageCode)) || (projectStageLabel && normalizedStageCodes.has(projectStageLabel)); }); } function withFilteredSalesRelatedProjects(item: SalesExpansionItem, filters: ExpansionExportFilters): SalesExpansionItem { return { ...item, relatedProjects: filterRelatedProjectsByStage(item.relatedProjects, filters.relatedProjectStageCodes), }; } function withFilteredChannelRelatedProjects(item: ChannelExpansionItem, filters: ExpansionExportFilters): ChannelExpansionItem { return { ...item, relatedProjects: filterRelatedProjectsByStage(item.relatedProjects, filters.relatedProjectStageCodes), }; } function matchesSalesExportFilters(item: SalesExpansionItem, filters: ExpansionExportFilters) { const keywordText = [ item.employeeNo, item.name, item.owner, item.phone, item.officeName, item.dept, item.title, item.industry, item.intent, item.relatedProjects?.map((project) => `${project.opportunityCode ?? ""} ${project.opportunityName ?? ""}`).join(" "), item.followUps?.map((followUp) => `${followUp.content ?? ""} ${followUp.evaluationContent ?? ""} ${followUp.nextPlan ?? ""}`).join(" "), ].map(normalizeExportText).filter(Boolean).join(" "); if (!matchesExportKeyword(keywordText, filters.keyword)) { return false; } if (!matchesTextFilter(item.intent || item.intentLevel, filters.intent)) { return false; } if (!matchesTextFilter(item.officeName, filters.officeName)) { return false; } if (!matchesTextFilter(item.industry, filters.industry)) { return false; } if (filters.employmentStatus === "active" && item.active !== true && item.employmentStatus !== "active") { return false; } if (filters.employmentStatus === "inactive" && item.active !== false && item.employmentStatus !== "inactive") { return false; } return matchesRelatedProjectFilter(item.relatedProjects, filters.hasRelatedProject); } function matchesChannelExportFilters(item: ChannelExpansionItem, filters: ExpansionExportFilters) { const keywordText = [ item.channelCode, item.name, item.owner, item.province, item.city, item.officeAddress, item.certificationLevel, item.channelIndustry, item.channelAttribute, item.internalAttribute, item.intent, item.primaryContactName, item.primaryContactMobile, item.contacts?.map((contact) => `${contact.duty ?? ""} ${contact.name ?? ""} ${contact.mobile ?? ""} ${contact.title ?? ""} ${contact.wecomAdded ?? ""} ${contact.specialNote ?? ""}`).join(" "), item.relatedProjects?.map((project) => `${project.opportunityCode ?? ""} ${project.opportunityName ?? ""}`).join(" "), item.followUps?.map((followUp) => `${followUp.content ?? ""} ${followUp.evaluationContent ?? ""} ${followUp.nextPlan ?? ""}`).join(" "), ].map(normalizeExportText).filter(Boolean).join(" "); if (!matchesExportKeyword(keywordText, filters.keyword)) { return false; } if (!matchesTextFilter(item.intent || item.intentLevel, filters.intent)) { return false; } if (!matchesTextFilter(item.province, filters.province)) { return false; } if (!matchesTextFilter(item.certificationLevel, filters.certificationLevel)) { return false; } if (!matchesTextFilter(item.channelIndustry, filters.channelIndustry)) { return false; } if (!matchesTextFilter(item.channelAttribute, filters.channelAttribute)) { return false; } if (!matchesDateRange(item.establishedDate, filters.establishedStartDate, filters.establishedEndDate)) { return false; } return matchesRelatedProjectFilter(item.relatedProjects, filters.hasRelatedProject); } function matchesCrmExportFilters(item: CrmExpansionItem, filters: ExpansionExportFilters, officeOptions: ExpansionDictOption[]) { const keywordText = [ item.endUser, item.owner, getDictLabelByValue(item.officeName, officeOptions), item.industryAttr, item.supplierName, item.h3cContactName, item.contacts?.map((contact) => `${contact.name ?? ""} ${contact.mobile ?? ""} ${contact.title ?? ""}`).join(" "), item.followUps?.map((followUp) => `${followUp.content ?? ""} ${followUp.evaluationContent ?? ""} ${followUp.nextPlan ?? ""}`).join(" "), ].map(normalizeExportText).filter(Boolean).join(" "); if (!matchesExportKeyword(keywordText, filters.keyword)) { return false; } if (filters.officeName && getDictLabelByValue(item.officeName, officeOptions) !== filters.officeName) { return false; } return true; } function formatExportBoolean(value?: boolean, trueLabel = "是", falseLabel = "否") { if (value === null || value === undefined) { return ""; } return value ? trueLabel : falseLabel; } function formatExportFollowUps(followUps?: ExpansionFollowUp[]) { if (!followUps?.length) { return ""; } return followUps .map((followUp) => { const summary = getExpansionFollowUpSummary(followUp); const lines = [ [normalizeExportText(followUp.date), normalizeExportText(followUp.type)].filter(Boolean).join(" "), normalizeExportText(summary.visitStartTime) ? `拜访时间:${normalizeExportText(summary.visitStartTime)}` : "", normalizeExportText(summary.evaluationContent) ? `沟通内容:${normalizeExportText(summary.evaluationContent)}` : "", normalizeExportText(summary.nextPlan) ? `后续规划:${normalizeExportText(summary.nextPlan)}` : "", ].filter(Boolean); return lines.join("\n"); }) .filter(Boolean) .join("\n\n"); } function formatExportProjectCell(project?: { opportunityCode?: string; opportunityName?: string; amount?: number | null }) { if (!project) { return ""; } const segments = [ normalizeExportText(project.opportunityCode) ? `编码:${normalizeExportText(project.opportunityCode)}` : "", normalizeExportText(project.opportunityName) ? `项目名称:${normalizeExportText(project.opportunityName)}` : "", project.amount === null || project.amount === undefined ? "" : `金额:${formatAmount(Number(project.amount))}`, ].filter(Boolean); return segments.join("|"); } function formatExportProjectListCell(projects?: Array<{ opportunityCode?: string; opportunityName?: string; amount?: number | null }>) { if (!projects?.length) { return ""; } return projects .map((project) => { const content = formatExportProjectCell(project); return content || ""; }) .filter(Boolean) .join("\n"); } function formatExportContactCell( contact?: { duty?: string | null; name?: string | null; mobile?: string | null; title?: string | null; birthday?: string | null; wecomAdded?: string | null; specialNote?: string | null; }, wecomLabelByValue?: (value: string | null | undefined) => string, ) { if (!contact) { return ""; } const wecomLabel = wecomLabelByValue ? wecomLabelByValue(contact.wecomAdded) : normalizeExportText(contact.wecomAdded); const segments = [ normalizeExportText(contact.duty) ? `工作职责:${normalizeExportText(contact.duty)}` : "", normalizeExportText(contact.name) ? `姓名:${normalizeExportText(contact.name)}` : "", normalizeExportText(contact.title) ? `职务:${normalizeExportText(contact.title)}` : "", normalizeExportText(contact.mobile) ? `联系电话:${normalizeExportText(contact.mobile)}` : "", normalizeExportText(contact.birthday) ? `生日:${normalizeExportText(contact.birthday)}` : "", wecomLabel ? `是否加企业微信:${wecomLabel}` : "", normalizeExportText(contact.specialNote) ? `特别说明:${normalizeExportText(contact.specialNote)}` : "", ].filter(Boolean); return segments.join("|"); } function formatExportContactListCell( contacts?: Array<{ duty?: string | null; name?: string | null; mobile?: string | null; title?: string | null; birthday?: string | null; wecomAdded?: string | null; specialNote?: string | null; }>, wecomLabelByValue?: (value: string | null | undefined) => string, ) { if (!contacts?.length) { return ""; } return contacts .map((contact) => { const content = formatExportContactCell(contact, wecomLabelByValue); return content || ""; }) .filter(Boolean) .join("\n"); } function getExcelDisplayWidth(value?: string | null) { if (!value) { return 0; } return Array.from(value).reduce((total, char) => total + (/[\u4e00-\u9fff\u3400-\u4dbf\uff00-\uffef]/.test(char) ? 2 : 1), 0); } function getExcelWrappedLineCount(value: string | null | undefined, columnWidth: number) { if (!value) { return 1; } const safeWidth = Math.max(1, Math.floor(columnWidth)); return value.split("\n").reduce((total, line) => { const lineWidth = Math.max(1, getExcelDisplayWidth(line)); return total + Math.max(1, Math.ceil(lineWidth / safeWidth)); }, 0); } function formatExportFilenameTime(date = new Date()) { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0"); const hours = String(date.getHours()).padStart(2, "0"); const minutes = String(date.getMinutes()).padStart(2, "0"); const seconds = String(date.getSeconds()).padStart(2, "0"); return `${year}${month}${day}_${hours}${minutes}${seconds}`; } function downloadExcelFile(filename: string, content: BlobPart) { const blob = new Blob([content], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }); const objectUrl = window.URL.createObjectURL(blob); const link = document.createElement("a"); link.href = objectUrl; link.download = filename; document.body.appendChild(link); link.click(); document.body.removeChild(link); window.URL.revokeObjectURL(objectUrl); } const salesExportColumns: Array> = [ { key: "employeeNo", label: "工号", value: (item) => normalizeExportText(item.employeeNo) }, { key: "name", label: "姓名", value: (item) => normalizeExportText(item.name) }, { key: "phone", label: "联系方式", value: (item) => normalizeExportText(item.phone) }, { key: "officeName", label: "代表处/办事处", value: (item) => normalizeExportText(item.officeName) }, { key: "dept", label: "所属部门", value: (item) => normalizeExportText(item.dept) }, { key: "title", label: "职务", value: (item) => normalizeExportText(item.title) }, { key: "industry", label: "所属行业", value: (item) => normalizeExportText(item.industry) }, { key: "intent", label: "合作意向", value: (item) => normalizeExportText(item.intent) }, { key: "active", label: "销售是否在职", value: (item) => (item.active === null || item.active === undefined ? "" : item.active ? "是" : "否") }, { key: "hasExp", label: "销售以前是否做过云桌面项目", value: (item) => formatExportBoolean(item.hasExp) }, { key: "relatedProjects", label: "跟进的云桌面项目", kind: "project", value: (item) => formatExportProjectListCell(item.relatedProjects) }, { key: "relatedProjectAmount", label: "跟进项目金额", numFmt: "#,##0.00", value: (item) => sumRelatedProjectAmount(item.relatedProjects) ?? "" }, { key: "owner", label: "创建人", value: (item) => normalizeExportText(item.owner) }, { key: "createdAt", label: "创建时间", value: (item) => normalizeExportText(item.createdAt) }, { key: "updatedAt", label: "更新修改时间", value: (item) => normalizeExportText(item.updatedAt) }, { key: "followUps", label: "跟进记录", kind: "followup", value: (item) => formatExportFollowUps(item.followUps) }, ]; const defaultSalesExportFields: SalesExportFieldKey[] = [ "employeeNo", "name", "phone", "officeName", "dept", "title", "industry", "intent", "active", "hasExp", "relatedProjects", "followUps", ]; function buildChannelExportColumns(isOptions: ExpansionDictOption[]): Array> { const wecomLabelByValue = (value: string | null | undefined) => getDictLabelByValue(value, isOptions); return [ { key: "name", label: "渠道名称", value: (item) => normalizeExportText(item.name) }, { key: "province", label: "省份", value: (item) => normalizeExportText(item.province) }, { key: "city", label: "市", value: (item) => normalizeExportText(item.city) }, { key: "officeAddress", label: "办公地址", kind: "longText", value: (item) => normalizeExportText(item.officeAddress) }, { key: "coverageItems", label: "覆盖地市", value: (item) => formatCoverageItemsExport(item.coverageItems) }, { key: "channelIndustry", label: "聚焦行业", value: (item) => normalizeExportText(item.channelIndustry) }, { key: "certificationLevel", label: "汇智内部认证级别", value: (item) => normalizeExportText(item.certificationLevel) }, { key: "channelAttribute", label: "渠道属性", value: (item) => normalizeExportText(item.channelAttribute) }, { key: "internalAttribute", label: "新华三内部属性", value: (item) => normalizeExportText(item.internalAttribute) }, { key: "intent", label: "合作意向", value: (item) => normalizeExportText(item.intent) }, { key: "establishedDate", label: "建立联系时间", value: (item) => normalizeExportText(item.establishedDate) }, { key: "revenue", label: "年度营业额", numFmt: "#,##0.00", value: (item) => normalizeExportNumber(item.annualRevenue) ?? normalizeExportNumber(item.revenue) ?? "" }, { key: "size", label: "人员规模", numFmt: "#,##0", value: (item) => normalizeExportNumber(item.size) ?? "" }, { key: "registeredCapital", label: "注册资金", numFmt: "#,##0.00", value: (item) => normalizeExportNumber(item.registeredCapital) ?? "" }, { key: "hasDesktopExp", label: "以前是否做过云桌面项目", value: (item) => formatExportBoolean(item.hasDesktopExp) }, { key: "relatedProjects", label: "跟进的云桌面项目", kind: "project", value: (item) => formatExportProjectListCell(item.relatedProjects) }, { key: "contacts", label: "人员信息", kind: "contact", value: (item) => formatExportContactListCell(item.contacts, wecomLabelByValue) }, { key: "followUps", label: "跟进记录", kind: "followup", value: (item) => formatExportFollowUps(item.followUps) }, { key: "channelCode", label: "编码", value: (item) => normalizeExportText(item.channelCode) }, { key: "relatedProjectAmount", label: "跟进项目金额", numFmt: "#,##0.00", value: (item) => sumRelatedProjectAmount(item.relatedProjects) ?? "" }, { key: "notes", label: "备注说明", kind: "longText", value: (item) => normalizeExportText(item.notes) }, { key: "owner", label: "创建人", value: (item) => normalizeExportText(item.owner) }, { key: "createdAt", label: "创建时间", value: (item) => normalizeExportText(item.createdAt) }, { key: "updatedAt", label: "更新修改时间", value: (item) => normalizeExportText(item.updatedAt) }, ]; } const defaultChannelExportFields: ChannelExportFieldKey[] = [ "name", "province", "city", "officeAddress", "channelIndustry", "certificationLevel", "channelAttribute", "internalAttribute", "intent", "establishedDate", "revenue", "size", "registeredCapital", "hasDesktopExp", "relatedProjects", "contacts", "followUps", ]; function buildCrmExportColumns(officeOptions: ExpansionDictOption[]): Array> { return [ { key: "endUser", label: "最终用户", value: (item) => normalizeExportText(item.endUser) }, { key: "officeName", label: "代表处", value: (item) => getDictLabelByValue(item.officeName, officeOptions) }, { key: "industryAttr", label: "行业属性", value: (item) => normalizeExportText(item.industryAttr) }, { key: "extensionType", label: "类型", value: (item) => normalizeExportText(item.extensionType) }, { key: "purchaseDateText", label: "采购时间", value: (item) => normalizeExportText(item.purchaseDateText) }, { key: "warrantyExpiryText", label: "过保时间", value: (item) => normalizeExportText(item.warrantyExpiryText) }, { key: "onlineStatus", label: "在线情况", value: (item) => normalizeExportText(item.onlineStatus) }, { key: "supplierName", label: "进货商", value: (item) => normalizeExportText(item.supplierName) }, { key: "h3cContactName", label: "新华三对接人", value: (item) => normalizeExportText(item.h3cContactName) }, { key: "hasExpansionOpportunity", label: "是否有扩容机会", value: (item) => (item.hasExpansionOpportunity === "1" ? "是" : item.hasExpansionOpportunity === "0" ? "否" : "") }, { key: "softwarePoints", label: "软件点数", value: (item) => (item.softwarePoints == null ? "" : String(item.softwarePoints)) }, { key: "expansionTimeText", label: "扩容时间", value: (item) => normalizeExportText(item.expansionTimeText) }, { key: "expansionScale", label: "扩容规模", value: (item) => normalizeExportText(item.expansionScale) }, { key: "hasMaintenanceOpportunity", label: "是否有维保项目机会", value: (item) => (item.hasMaintenanceOpportunity === "1" ? "是" : item.hasMaintenanceOpportunity === "0" ? "否" : "") }, { key: "contacts", label: "人员信息", kind: "contact", value: (item) => formatExportContactListCell(item.contacts) }, { key: "followUps", label: "跟进记录", kind: "followup", value: (item) => formatExportFollowUps(item.followUps) }, { key: "owner", label: "创建人", value: (item) => normalizeExportText(item.owner) }, { key: "createdAt", label: "创建时间", value: (item) => normalizeExportText(item.createdAt) }, { key: "updatedAt", label: "更新修改时间", value: (item) => normalizeExportText(item.updatedAt) }, ]; } const defaultCrmExportFields: CrmExportFieldKey[] = [ "endUser", "officeName", "industryAttr", "extensionType", "purchaseDateText", "warrantyExpiryText", "onlineStatus", "softwarePoints", "expansionTimeText", "expansionScale", "hasMaintenanceOpportunity", "supplierName", "h3cContactName", "hasExpansionOpportunity", "contacts", "followUps", ]; function resolveSelectedExpansionFields(selectedFields: K[] | undefined, defaultFields: K[]) { return selectedFields === undefined ? defaultFields : selectedFields; } function buildExportRows(items: T[], columns: Array>) { return items.map((item) => columns.map((column) => column.value(item))); } function validateSalesCreateForm(form: CreateSalesExpansionPayload) { const errors: Partial> = {}; if (!form.employeeNo?.trim()) { errors.employeeNo = "请填写工号"; } if (!form.officeName?.trim()) { errors.officeName = "请选择代表处 / 办事处"; } if (!form.candidateName?.trim()) { errors.candidateName = "请填写姓名"; } if (!form.mobile?.trim()) { errors.mobile = "请填写联系方式"; } if ((form.regionProvince?.length ?? 0) <= 0) { errors.regionProvince = "请选择所属区域"; } if (!form.industry?.trim()) { errors.industry = "请选择所属行业"; } if (!form.title?.trim()) { errors.title = "请填写职务"; } if (!form.intentLevel?.trim()) { errors.intentLevel = "请选择合作意向"; } if (!form.employmentStatus?.trim()) { errors.employmentStatus = "请选择销售是否在职"; } return errors; } function validateChannelForm(form: CreateChannelExpansionPayload, channelOtherOptionValue?: string) { const errors: Partial> = {}; if (!form.channelName?.trim()) { errors.channelName = "请填写渠道名称"; } if (!form.province?.trim()) { errors.province = "请选择省份"; } if (!form.city?.trim()) { errors.city = "请选择市"; } if ((form.coverageProvince?.length ?? 0) <= 0) { errors.coverageProvince = "请选择覆盖省份"; } if ((form.coverageCity?.length ?? 0) <= 0) { errors.coverageCity = "请选择覆盖市/区/县"; } if (!form.officeAddress?.trim()) { errors.officeAddress = "请填写办公地址"; } if (!form.certificationLevel?.trim()) { errors.certificationLevel = "请选择汇智内部认证级别"; } if ((form.channelIndustry?.length ?? 0) <= 0) { errors.channelIndustry = "请选择聚焦行业"; } if (!form.annualRevenue || form.annualRevenue <= 0) { errors.annualRevenue = `请填写${CHANNEL_REVENUE_LABEL}`; } if (!form.staffSize || form.staffSize <= 0) { errors.staffSize = "请填写人员规模"; } if (!form.registeredCapital || form.registeredCapital <= 0) { errors.registeredCapital = `请填写${CHANNEL_REGISTERED_CAPITAL_LABEL}`; } if (!form.contactEstablishedDate?.trim()) { errors.contactEstablishedDate = "请选择建立联系时间"; } if (!form.intentLevel?.trim()) { errors.intentLevel = "请选择合作意向"; } if ((form.channelAttribute?.length ?? 0) <= 0) { errors.channelAttribute = "请选择渠道属性"; } if (channelOtherOptionValue && form.channelAttribute?.includes(channelOtherOptionValue) && !form.channelAttributeCustom?.trim()) { errors.channelAttributeCustom = "请选择“其它”后请补充具体渠道属性"; } if ((form.internalAttribute?.length ?? 0) <= 0) { errors.internalAttribute = "请选择新华三内部属性"; } const contactValidation = validateChannelContactRows(form.contacts); if (contactValidation.error) { errors.contacts = contactValidation.error; } return { errors, invalidContactRows: contactValidation.invalidContactRows }; } function validateCrmForm(form: CreateCrmExpansionPayload) { const errors: Partial> = {}; const invalidContactRows: number[] = []; if (!form.endUser?.trim()) errors.endUser = "请填写最终用户"; if (!form.officeName?.trim()) errors.officeName = "请选择代表处"; if ((form.industryAttr?.length ?? 0) <= 0) errors.industryAttr = "请选择行业属性"; if ((form.extensionType?.length ?? 0) <= 0) errors.extensionType = "请选择类型"; if (form.softwarePoints == null || Number.isNaN(form.softwarePoints) || form.softwarePoints < 0) errors.softwarePoints = "请填写软件点数"; if (!form.purchaseDate?.trim()) errors.purchaseDate = "请选择采购时间"; if (!form.warrantyExpiry?.trim()) errors.warrantyExpiry = "请选择过保时间"; if (!form.onlineStatus?.trim()) errors.onlineStatus = "请选择在线情况"; const contacts = form.contacts ?? []; if (contacts.length <= 0) { errors.contacts = "请至少填写一位联系人"; invalidContactRows.push(0); } else { contacts.forEach((contact, index) => { const hasName = Boolean(contact.name?.trim()); const hasMobile = Boolean(contact.mobile?.trim()); const hasTitle = Boolean(contact.title?.trim()); if (!hasName || !hasMobile || !hasTitle) { invalidContactRows.push(index); } }); if (invalidContactRows.length > 0) { errors.contacts = "请完整填写每位联系人的姓名、联系电话和职位"; } } if ((!form.supplierId || Number(form.supplierId) <= 0) && !form.supplierName?.trim()) errors.supplierId = "请选择或填写进货商"; if ((!form.h3cContactId || Number(form.h3cContactId) <= 0) && !form.h3cContactName?.trim()) errors.h3cContactId = "请选择或填写新华三对接人"; if (!form.hasExpansionOpportunity?.trim()) errors.hasExpansionOpportunity = "请选择是否有扩容机会"; if (form.hasExpansionOpportunity === "1") { if (!form.expansionTime?.trim()) errors.expansionTime = "请选择扩容时间"; if (!form.expansionScale?.trim()) errors.expansionScale = "请填写扩容规模"; } if (!form.hasMaintenanceOpportunity?.trim()) errors.hasMaintenanceOpportunity = "请选择是否有维保项目机会"; return { errors, invalidContactRows }; } function normalizeSalesPayload(payload: CreateSalesExpansionPayload): CreateSalesExpansionPayload { return { employeeNo: payload.employeeNo.trim(), candidateName: payload.candidateName.trim(), officeName: normalizeOptionalText(payload.officeName), mobile: normalizeOptionalText(payload.mobile), email: normalizeOptionalText(payload.email), targetDept: normalizeOptionalText(payload.targetDept), industry: normalizeOptionalText(payload.industry), title: normalizeOptionalText(payload.title), intentLevel: normalizeOptionalText(payload.intentLevel) ?? "medium", stage: normalizeOptionalText(payload.stage) ?? "initial_contact", hasDesktopExp: Boolean(payload.hasDesktopExp), inProgress: payload.inProgress ?? true, employmentStatus: normalizeOptionalText(payload.employmentStatus) ?? "active", expectedJoinDate: normalizeOptionalText(payload.expectedJoinDate), remark: normalizeOptionalText(payload.remark), regionProvince: Array.from(new Set((payload.regionProvince ?? []).map((value) => value?.trim()).filter((value): value is string => Boolean(value)))), regionCity: Array.from(new Set((payload.regionCity ?? []).map((value) => value?.trim()).filter((value): value is string => Boolean(value)))), regionItems: (payload.regionItems ?? []) .map((item) => ({ province: normalizeOptionalText(item.province) ?? "", city: normalizeOptionalText(item.city) ?? "", })) .filter((item) => item.province), }; } function normalizeChannelPayload(payload: CreateChannelExpansionPayload): CreateChannelExpansionPayload { return { channelCode: normalizeOptionalText(payload.channelCode), officeAddress: normalizeOptionalText(payload.officeAddress), channelIndustry: Array.from(new Set((payload.channelIndustry ?? []).map((value) => value?.trim()).filter((value): value is string => Boolean(value)))), channelName: payload.channelName.trim(), province: normalizeOptionalText(payload.province), city: normalizeOptionalText(payload.city), coverageProvince: Array.from(new Set((payload.coverageProvince ?? []).map((value) => value?.trim()).filter((value): value is string => Boolean(value)))), coverageCity: Array.from(new Set((payload.coverageCity ?? []).map((value) => value?.trim()).filter((value): value is string => Boolean(value)))), coverageItems: (payload.coverageItems ?? []) .map((item) => ({ province: normalizeOptionalText(item.province) ?? "", city: normalizeOptionalText(item.city) ?? "", })) .filter((item) => item.province), certificationLevel: normalizeOptionalText(payload.certificationLevel), annualRevenue: payload.annualRevenue || undefined, staffSize: payload.staffSize || undefined, registeredCapital: payload.registeredCapital || undefined, contactEstablishedDate: normalizeOptionalText(payload.contactEstablishedDate), intentLevel: normalizeOptionalText(payload.intentLevel) ?? "medium", hasDesktopExp: Boolean(payload.hasDesktopExp), channelAttribute: Array.from(new Set((payload.channelAttribute ?? []).map((value) => value?.trim()).filter((value): value is string => Boolean(value)))), channelAttributeCustom: normalizeOptionalText(payload.channelAttributeCustom), internalAttribute: Array.from(new Set((payload.internalAttribute ?? []).map((value) => value?.trim()).filter((value): value is string => Boolean(value)))), stage: normalizeOptionalText(payload.stage) ?? "initial_contact", remark: normalizeOptionalText(payload.remark), contacts: (payload.contacts ?? []) .map((contact) => ({ duty: normalizeOptionalText(contact.duty), name: normalizeOptionalText(contact.name), mobile: normalizeOptionalText(contact.mobile), title: normalizeOptionalText(contact.title), birthday: normalizeOptionalText(contact.birthday), wecomAdded: normalizeOptionalText(contact.wecomAdded), specialNote: normalizeOptionalText(contact.specialNote), })) .filter((contact) => contact.name || contact.mobile || contact.title || contact.birthday || contact.wecomAdded || contact.specialNote), }; } /** * 将后端返回的覆盖地市字符串(形如 "浙江省|杭州市,江苏省|南京市")解析为 [{ province, city }] 数组, * 用于编辑回显 coverageItems,避免保存时因 coverageItems 为空而清空已保存的覆盖地市。 */ function parseCoverageItemsString(rawValue?: string) { if (!rawValue) { return []; } const items: { province: string; city: string }[] = []; rawValue .split(",") .map((segment) => segment.trim()) .filter(Boolean) .forEach((segment) => { const [province = "", city = ""] = segment.split("|"); if (province) { items.push({ province: province.trim(), city: city.trim() }); } }); return items; } function normalizeOptionValue(rawValue: string | undefined, options: ExpansionDictOption[]) { const trimmed = rawValue?.trim(); if (!trimmed) { return ""; } const matched = options.find((option) => option.value === trimmed || option.label === trimmed); return matched?.value ?? trimmed; } function normalizeMultiOptionValues(rawValue: string | undefined, options: ExpansionDictOption[]) { const { values } = decodeExpansionMultiValue(rawValue); return Array.from(new Set(values.map((value) => normalizeOptionValue(value, options)).filter(Boolean))); } function ModalShell({ title, subtitle, onClose, children, footer, }: { title: string; subtitle: string; onClose: () => void; children: ReactNode; footer: ReactNode; }) { const isMobileViewport = useIsMobileViewport(); const isWecomBrowser = useIsWecomBrowser(); const disableMobileMotion = isMobileViewport || isWecomBrowser; return ( <>

{title}

{subtitle}

{children}
{footer}
); } function DetailItem({ label, value, icon, className = "", }: { label: string; value: ReactNode; icon?: ReactNode; className?: string; }) { return (

{icon} {label}

{value}
); } function RequiredMark() { return *; } type ChannelAddressValue = { province?: string; city?: string; officeAddress?: string; }; type ChannelAddressErrors = Partial>; /** * 地区级联选择器:选取省份后联动展示城市,作为单一复合控件使用(前端不分两个字段)。 */ function AddressCascaderSelect({ value, onChange, provinceOptions, getCities, isEdit, hasError, }: { value: { province?: string; city?: string }; onChange: (value: { province?: string; city?: string }) => void; provinceOptions: ExpansionDictOption[]; getCities: (provinceName: string | undefined, isEdit: boolean) => Promise; isEdit: boolean; hasError?: boolean; }) { const [open, setOpen] = useState(false); const [activeProvince, setActiveProvince] = useState(""); const [cityOptions, setCityOptions] = useState([]); const [loadingCity, setLoadingCity] = useState(false); const containerRef = useRef(null); const panelRef = useRef(null); const selectedProvinceLabel = provinceOptions.find((option) => option.value === value?.province)?.label ?? value?.province ?? ""; const loadCities = async (province: string) => { setLoadingCity(true); setCityOptions([]); try { const options = await getCities(province, isEdit); setCityOptions(options ?? []); } catch { setCityOptions([]); } finally { setLoadingCity(false); } }; useEffect(() => { if (!open) { return; } const initialProvince = value?.province || ""; setActiveProvince(initialProvince); if (initialProvince) { void loadCities(initialProvince); } else { setCityOptions([]); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); useEffect(() => { if (!open) { return; } const handlePointerDown = (event: MouseEvent) => { const targetNode = event.target as Node; if (!containerRef.current?.contains(targetNode) && !panelRef.current?.contains(targetNode)) { setOpen(false); } }; const handleEscape = (event: KeyboardEvent) => { if (event.key === "Escape") { setOpen(false); } }; document.addEventListener("mousedown", handlePointerDown); document.addEventListener("keydown", handleEscape); return () => { document.removeEventListener("mousedown", handlePointerDown); document.removeEventListener("keydown", handleEscape); }; }, [open]); const selectedCityLabel = activeProvince ? (cityOptions.find((option) => option.value === value?.city)?.label ?? value?.city ?? "") : ""; const displayText = selectedProvinceLabel ? selectedCityLabel ? `${selectedProvinceLabel} / ${selectedCityLabel}` : `${selectedProvinceLabel} / 请选择市` : "请选择省份"; return (
{open ? (

省份

{provinceOptions.map((option) => { const isActive = activeProvince === option.value; return ( ); })}

{loadingCity ? (
加载中…
) : !activeProvince ? (
请先选择省份
) : cityOptions.length === 0 ? (
暂无可筛选城市
) : ( cityOptions.map((option) => { const isSelected = value?.province === activeProvince && value?.city === option.value; return ( ); }) )}
) : null}
); } /** * 渠道地址复合字段:省份、市、办公地址 作为一个前端字段维护。 * 内部仍保存到三个数据库字段,但对外呈现为统一的地址区块。 */ function ChannelAddressField({ value, onChange, provinceOptions, isEdit, errors, loadCityOptions, }: { value: ChannelAddressValue; onChange: (value: ChannelAddressValue) => void; provinceOptions: ExpansionDictOption[]; isEdit: boolean; errors?: ChannelAddressErrors; loadCityOptions: (provinceName: string | undefined, isEdit: boolean) => Promise; }) { const addressValue = { province: value.province, city: value.city }; const officeAddress = value.officeAddress || ""; const areaHasError = Boolean(errors?.province || errors?.city); return ( <> ); } /** * 覆盖地市级联选择器(多选):省可多选,选中省后联动勾选该省城市,作为单一复合控件使用。 * 对外仍以 coverageProvince / coverageCity 两个数组维护。 */ function CoverageCascaderSelect({ value, onChange, provinceOptions, getCities, isEdit, hasError, }: { value: { provinces?: string[]; cities?: string[]; items?: { province?: string; city?: string }[] }; onChange: (value: { provinces: string[]; cities: string[]; items: { province: string; city: string }[] }) => void; provinceOptions: ExpansionDictOption[]; getCities: (provinceName: string | undefined, isEdit: boolean) => Promise; isEdit: boolean; hasError?: boolean; }) { const [open, setOpen] = useState(false); const [selection, setSelection] = useState>({}); const [cityCache, setCityCache] = useState>({}); const [loadingKeys, setLoadingKeys] = useState>({}); const [activeProvince, setActiveProvince] = useState(""); const containerRef = useRef(null); const panelRef = useRef(null); useEffect(() => { if (!open) { return; } (async () => { const provinces = value?.provinces ?? []; const cities = value?.cities ?? []; const nextSelection: Record = {}; const nextCache: Record = {}; for (const province of provinces) { let options: ExpansionDictOption[] = []; try { options = (await getCities(province, isEdit)) ?? []; } catch { options = []; } nextCache[province] = options; const optionValues = new Set(options.map((option) => option.value)); nextSelection[province] = cities.filter((city) => optionValues.has(city)); } setCityCache(nextCache); setSelection(nextSelection); setActiveProvince(provinces[0] ?? ""); })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); useEffect(() => { if (!open) { return; } const provinces = Object.keys(selection); const cities = provinces.flatMap((province) => selection[province] ?? []); const items = provinces.flatMap((province) => { const provinceCities = selection[province] ?? []; return provinceCities.length > 0 ? provinceCities.map((city) => ({ province, city })) : [{ province, city: "" }]; }); onChange({ provinces, cities, items }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, selection]); // 打开编辑(或回显)时,依据已保存的成对 coverageItems 直接初始化 selection, // 使“省份:市/区/县”的对应关系在下拉未打开时也能正常展示。 const hydratedCoverageRef = useRef(""); useEffect(() => { if (open) { return; } const items = value?.items ?? []; const signature = items.map((item) => `${item.province}|${item.city}`).join(";"); if (!signature || hydratedCoverageRef.current === signature) { return; } hydratedCoverageRef.current = signature; const nextSelection: Record = {}; for (const item of items) { if (!item.province) { continue; } const provinceCities = nextSelection[item.province] ?? (nextSelection[item.province] = []); if (item.city && !provinceCities.includes(item.city)) { provinceCities.push(item.city); } } setSelection(nextSelection); // eslint-disable-next-line react-hooks/exhaustive-deps }, [value?.items, open]); useEffect(() => { if (!open) { return; } const handlePointerDown = (event: MouseEvent) => { const targetNode = event.target as Node; if (!containerRef.current?.contains(targetNode) && !panelRef.current?.contains(targetNode)) { setOpen(false); } }; const handleEscape = (event: KeyboardEvent) => { if (event.key === "Escape") { setOpen(false); } }; document.addEventListener("mousedown", handlePointerDown); document.addEventListener("keydown", handleEscape); return () => { document.removeEventListener("mousedown", handlePointerDown); document.removeEventListener("keydown", handleEscape); }; }, [open]); const loadProvinceCities = async (province: string) => { if (cityCache[province]) { return; } setLoadingKeys((current) => ({ ...current, [province]: true })); try { const options = (await getCities(province, isEdit)) ?? []; setCityCache((current) => ({ ...current, [province]: options })); } catch { setCityCache((current) => ({ ...current, [province]: [] })); } finally { setLoadingKeys((current) => { const next = { ...current }; delete next[province]; return next; }); } }; const toggleProvince = (province: string) => { setSelection((current) => { const next = { ...current }; if (province in next) { delete next[province]; } else { next[province] = []; } return next; }); setActiveProvince(province); void loadProvinceCities(province); }; const toggleCity = (city: string) => { if (!activeProvince) { return; } setSelection((current) => { const provinceCities = current[activeProvince] ?? []; const nextCities = provinceCities.includes(city) ? provinceCities.filter((item) => item !== city) : [...provinceCities, city]; return { ...current, [activeProvince]: nextCities }; }); }; const selectedCount = (Object.values(selection) as string[][]).reduce((sum, cities) => sum + cities.length, 0); // 面板未打开时(如刚进入编辑),selection 尚未初始化,直接依据 value 回显已保存的覆盖地市, // 避免编辑时"覆盖地市"显示为空。 const valueProvinces = (value?.provinces ?? []).filter(Boolean); const hasSelection = Object.keys(selection).length > 0 || selectedCount > 0; const hasSelectionValue = hasSelection || (valueProvinces ?? []).length > 0; const handleClearCoverage = (event: ReactMouseEvent) => { event.stopPropagation(); event.preventDefault(); setSelection({}); setActiveProvince(null); hydratedCoverageRef.current = ""; onChange({ provinces: [], cities: [], items: [] }); }; const formatSelected = () => { if (!hasSelection && valueProvinces.length > 0) { const provinceLabels = valueProvinces.map( (province) => provinceOptions.find((option) => option.value === province)?.label ?? province, ); const cityCount = (value?.cities ?? []).filter(Boolean).length; return cityCount > 0 ? `${provinceLabels.join("、")}(共${cityCount}个市)` : provinceLabels.join("、"); } const parts: string[] = []; for (const option of provinceOptions) { const provinceCities = selection[option.value]; if (provinceCities === undefined) { continue; } const provinceLabel = option.label || option.value; if (provinceCities.length === 0) { parts.push(provinceLabel); continue; } const cityLabels = provinceCities .map((cityValue) => (cityCache[option.value] ?? []).find((item) => item.value === cityValue)?.label ?? cityValue) .filter(Boolean); parts.push(`${provinceLabel}:${cityLabels.join("、")}`); } return parts.length > 0 ? parts.join(",") : "请选择覆盖地市(可多选)"; }; return (
) : null} {open ? (

省份(可多选)

{provinceOptions.map((option) => { const isSelected = option.value in selection; return ( ); })}

市(可多选)

{loadingKeys[activeProvince] ? (
加载中…
) : !activeProvince ? (
请先选择省份
) : (cityCache[activeProvince] ?? []).length === 0 ? (
暂无可筛选城市
) : ( (cityCache[activeProvince] ?? []).map((option) => { const isSelected = (selection[activeProvince] ?? []).includes(option.value); return ( ); }) )}
) : null}
); } function ExpansionExportFilterModal({ activeTab, initialFilters, exporting, exportError, officeOptions, industryOptions, provinceOptions, certificationLevelOptions, channelAttributeOptions, relatedProjectStageOptions, isOptions, onClose, onConfirm, }: { activeTab: ExpansionTab; initialFilters: ExpansionExportFilters; exporting: boolean; exportError: string; officeOptions: ExpansionDictOption[]; industryOptions: ExpansionDictOption[]; provinceOptions: ExpansionDictOption[]; certificationLevelOptions: ExpansionDictOption[]; channelAttributeOptions: ExpansionDictOption[]; relatedProjectStageOptions: ExpansionDictOption[]; isOptions: ExpansionDictOption[]; onClose: () => void; onConfirm: (filters: ExpansionExportFilters) => void; }) { const normalizedInitialFilters = applyDefaultRelatedProjectStageFilters(initialFilters, relatedProjectStageOptions); const [draftFilters, setDraftFilters] = useState(normalizedInitialFilters); const isSalesTab = activeTab === "sales"; const isCrmTab = activeTab === "crm"; const selectedSalesFields = resolveSelectedExpansionFields(draftFilters.selectedSalesFields, defaultSalesExportFields); const selectedChannelFields = resolveSelectedExpansionFields(draftFilters.selectedChannelFields, defaultChannelExportFields); const selectedCrmFields = resolveSelectedExpansionFields(draftFilters.selectedCrmFields, defaultCrmExportFields); const crmExportColumns = buildCrmExportColumns(officeOptions); const activeFieldOptions = isSalesTab ? salesExportColumns : isCrmTab ? crmExportColumns : buildChannelExportColumns(isOptions); const selectedFieldKeys = isSalesTab ? selectedSalesFields : isCrmTab ? selectedCrmFields : selectedChannelFields; const defaultRelatedProjectStageCodes = getDefaultRelatedProjectStageCodes(relatedProjectStageOptions); const relatedProjectStageFilterOptions = relatedProjectStageOptions .map((option) => { const value = getDictOptionValue(option); const label = getDictOptionLabel(option); return value && label ? { value, label } : null; }) .filter((option): option is { value: string; label: string } => Boolean(option)); const selectedRelatedProjectStageCodes = normalizeMultiSelectValues(draftFilters.relatedProjectStageCodes); useEffect(() => { setDraftFilters(applyDefaultRelatedProjectStageFilters(initialFilters, relatedProjectStageOptions)); }, [initialFilters, relatedProjectStageOptions]); const hasDraftFilters = Boolean( draftFilters.keyword || draftFilters.intent || draftFilters.officeName || draftFilters.industry || draftFilters.employmentStatus || draftFilters.province || draftFilters.certificationLevel || draftFilters.channelIndustry || draftFilters.channelAttribute || draftFilters.establishedStartDate || draftFilters.establishedEndDate || draftFilters.hasRelatedProject, ) || !areSameStringSets(selectedRelatedProjectStageCodes, defaultRelatedProjectStageCodes) || (isSalesTab ? JSON.stringify(selectedSalesFields) !== JSON.stringify(defaultSalesExportFields) : isCrmTab ? JSON.stringify(selectedCrmFields) !== JSON.stringify(defaultCrmExportFields) : JSON.stringify(selectedChannelFields) !== JSON.stringify(defaultChannelExportFields)); const hasSelectedFields = selectedFieldKeys.length > 0; const toggleField = (fieldKey: string) => { if (isSalesTab) { setDraftFilters((current) => { const currentFields = resolveSelectedExpansionFields(current.selectedSalesFields, defaultSalesExportFields); const nextFields = currentFields.includes(fieldKey as SalesExportFieldKey) ? currentFields.filter((item) => item !== fieldKey) : [...currentFields, fieldKey as SalesExportFieldKey]; return { ...current, selectedSalesFields: nextFields }; }); return; } if (isCrmTab) { setDraftFilters((current) => { const currentFields = resolveSelectedExpansionFields(current.selectedCrmFields, defaultCrmExportFields); const nextFields = currentFields.includes(fieldKey as CrmExportFieldKey) ? currentFields.filter((item) => item !== fieldKey) : [...currentFields, fieldKey as CrmExportFieldKey]; return { ...current, selectedCrmFields: nextFields }; }); return; } setDraftFilters((current) => { const currentFields = resolveSelectedExpansionFields(current.selectedChannelFields, defaultChannelExportFields); const nextFields = currentFields.includes(fieldKey as ChannelExportFieldKey) ? currentFields.filter((item) => item !== fieldKey) : [...currentFields, fieldKey as ChannelExportFieldKey]; return { ...current, selectedChannelFields: nextFields }; }); }; const handleRelatedProjectStageToggle = (stageCode: string) => { setDraftFilters((current) => { const currentStageCodes = normalizeMultiSelectValues(current.relatedProjectStageCodes); const nextStageCodeSet = currentStageCodes.includes(stageCode) ? new Set(currentStageCodes.filter((value) => value !== stageCode)) : new Set([...currentStageCodes, stageCode]); const orderedStageCodes = relatedProjectStageFilterOptions .map((option) => option.value) .filter((value) => nextStageCodeSet.has(value)); return { ...current, relatedProjectStageCodes: orderedStageCodes }; }); }; const handleFilterChange = (key: keyof ExpansionExportFilters, value: string) => { setDraftFilters((current) => ({ ...current, [key]: value })); }; const renderOption = (option: ExpansionDictOption) => { const value = option.label || option.value || ""; return value ? : null; }; const toSearchableOptions = (options: ExpansionDictOption[], allLabel: string) => [ { value: "", label: allLabel }, ...options .map((option) => { const value = option.label || option.value || ""; return value ? { value, label: value } : null; }) .filter((option): option is { value: string; label: string } => Boolean(option)), ]; return ( )} >
{!isCrmTab ? ( <>

关联项目阶段

多选,默认排除已丢单/已放弃阶段。

{relatedProjectStageFilterOptions.length > 0 ? (
) : null}
{relatedProjectStageFilterOptions.length > 0 ? (
{relatedProjectStageFilterOptions.map((option) => { const checked = selectedRelatedProjectStageCodes.includes(option.value); return ( ); })}
) : (

未加载到阶段字典,导出时不会按关联项目阶段过滤。

)}
) : null}

导出字段

默认已按模板字段勾选,可取消不需要导出的字段。

{activeFieldOptions.map((field) => { const checked = selectedFieldKeys.includes(field.key); return ( ); })}
{!hasSelectedFields ?

请至少保留一个导出字段

: null}
{isSalesTab ? ( <> ) : isCrmTab ? ( <> ) : ( <> )}
{exportError ?
{exportError}
: null}
); } export default function Expansion() { const currentUserId = getStoredCurrentUserId(); const location = useLocation(); const isMobileViewport = useIsMobileViewport(); const isWecomBrowser = useIsWecomBrowser(); const disableMobileMotion = isMobileViewport || isWecomBrowser; const [activeTab, setActiveTab] = useState("sales"); const [selectedItem, setSelectedItem] = useState(null); const [keyword, setKeyword] = useState(""); const [salesData, setSalesData] = useState([]); const [channelData, setChannelData] = useState([]); const [crmData, setCrmData] = useState([]); const [supplierChannelOptions, setSupplierChannelOptions] = useState([]); const [supplierSalesOptions, setSupplierSalesOptions] = useState([]); const [supplierChannelQuery, setSupplierChannelQuery] = useState(""); const [supplierSalesQuery, setSupplierSalesQuery] = useState(""); const [loadingSupplierChannelOptions, setLoadingSupplierChannelOptions] = useState(false); const [loadingSupplierSalesOptions, setLoadingSupplierSalesOptions] = useState(false); const [supplierRefreshTick, setSupplierRefreshTick] = useState(0); const [visibleItemCount, setVisibleItemCount] = useState(LIST_PAGE_SIZE); const loadMoreRef = useRef(null); const loadingMoreRef = useRef(false); const loadedTabKeysRef = useRef(new Set()); const [loadingMore, setLoadingMore] = useState(false); const [loadMoreError, setLoadMoreError] = useState(""); const [officeOptions, setOfficeOptions] = useState([]); const [industryOptions, setIndustryOptions] = useState([]); const [provinceOptions, setProvinceOptions] = useState([]); const [certificationLevelOptions, setCertificationLevelOptions] = useState([]); const [createCityOptions, setCreateCityOptions] = useState([]); const [editCityOptions, setEditCityOptions] = useState([]); const [createCoverageCityOptions, setCreateCoverageCityOptions] = useState([]); const [editCoverageCityOptions, setEditCoverageCityOptions] = useState([]); const [channelAttributeOptions, setChannelAttributeOptions] = useState([]); const [internalAttributeOptions, setInternalAttributeOptions] = useState([]); const [extensionTypeOptions, setExtensionTypeOptions] = useState([]); const [onlineStatusOptions, setOnlineStatusOptions] = useState([]); const [isOptions, setIsOptions] = useState([]); const [relatedProjectStageOptions, setRelatedProjectStageOptions] = useState([]); const [nextChannelCode, setNextChannelCode] = useState(""); const channelOtherOptionValue = channelAttributeOptions.find(isOtherOption)?.value ?? ""; const [refreshTick, setRefreshTick] = useState(0); const [createOpen, setCreateOpen] = useState(false); const [editOpen, setEditOpen] = useState(false); const [submitting, setSubmitting] = useState(false); const [exporting, setExporting] = useState(false); const [exportFilterOpen, setExportFilterOpen] = useState(false); const [exportFilters, setExportFilters] = useState(() => loadExpansionExportPreferences()); const [permissionCodes, setPermissionCodes] = useState(null); const [salesDuplicateChecking, setSalesDuplicateChecking] = useState(false); const [channelDuplicateChecking, setChannelDuplicateChecking] = useState(false); const [createError, setCreateError] = useState(""); const [editError, setEditError] = useState(""); const [exportError, setExportError] = useState(""); const [salesDuplicateMessage, setSalesDuplicateMessage] = useState(""); const [channelDuplicateMessage, setChannelDuplicateMessage] = useState(""); const [crmDuplicateMessage, setCrmDuplicateMessage] = useState(""); const [salesCreateFieldErrors, setSalesCreateFieldErrors] = useState>>({}); const [salesEditFieldErrors, setSalesEditFieldErrors] = useState>>({}); const [channelCreateFieldErrors, setChannelCreateFieldErrors] = useState>>({}); const [channelEditFieldErrors, setChannelEditFieldErrors] = useState>>({}); const [invalidCreateChannelContactRows, setInvalidCreateChannelContactRows] = useState([]); const [invalidEditChannelContactRows, setInvalidEditChannelContactRows] = useState([]); const [salesDetailTab, setSalesDetailTab] = useState<"projects" | "followups">("projects"); const [channelDetailTab, setChannelDetailTab] = useState<"projects" | "contacts" | "followups">("projects"); const [crmDetailTab, setCrmDetailTab] = useState<"contacts" | "followups">("contacts"); const canEditSelectedItem = Boolean(selectedItem && currentUserId !== undefined && selectedItem.ownerUserId === currentUserId); const [salesForm, setSalesForm] = useState(defaultSalesForm); const [channelForm, setChannelForm] = useState(defaultChannelForm); const [crmForm, setCrmForm] = useState({ endUser: "", officeName: "", industryAttr: [], extensionType: [], purchaseDate: "", warrantyExpiry: "", onlineStatus: "", contacts: [createEmptyCrmContact()], supplierId: 0, supplierName: "", h3cContactId: 0, h3cContactName: "", hasExpansionOpportunity: "", softwarePoints: undefined, expansionTime: "", expansionScale: "", hasMaintenanceOpportunity: "", }); const [editSalesForm, setEditSalesForm] = useState(defaultSalesForm); const [editChannelForm, setEditChannelForm] = useState(defaultChannelForm); const [editCrmForm, setEditCrmForm] = useState({ endUser: "", officeName: "", industryAttr: [], extensionType: [], purchaseDate: "", warrantyExpiry: "", onlineStatus: "", contacts: [createEmptyCrmContact()], supplierId: 0, supplierName: "", h3cContactId: 0, h3cContactName: "", hasExpansionOpportunity: "", softwarePoints: undefined, expansionTime: "", expansionScale: "", hasMaintenanceOpportunity: "", }); const [crmFieldErrors, setCrmFieldErrors] = useState>>({}); const [editCrmFieldErrors, setEditCrmFieldErrors] = useState>>({}); const [invalidCreateCrmContactRows, setInvalidCreateCrmContactRows] = useState([]); const [invalidEditCrmContactRows, setInvalidEditCrmContactRows] = useState([]); const [moveOpen, setMoveOpen] = useState(false); const [moveSubmitting, setMoveSubmitting] = useState(false); const [moveError, setMoveError] = useState(""); const [moveChannelForm, setMoveChannelForm] = useState(defaultMoveChannelForm); const [moveCrmForm, setMoveCrmForm] = useState(defaultMoveCrmForm); const [moveChannelFieldErrors, setMoveChannelFieldErrors] = useState>>({}); const [moveCrmFieldErrors, setMoveCrmFieldErrors] = useState>>({}); const [invalidMoveCrmContactRows, setInvalidMoveCrmContactRows] = useState([]); const [invalidMoveChannelContactRows, setInvalidMoveChannelContactRows] = useState([]); const [moveCityOptions, setMoveCityOptions] = useState([]); const hasForegroundModal = createOpen || editOpen || exportFilterOpen || moveOpen; const canCreateExpansion = permissionCodes !== null && canUsePermission(EXPANSION_CREATE_PERMISSION, permissionCodes); const loadMeta = useCallback(async () => { const data = await getExpansionMeta(); setOfficeOptions(data.officeOptions ?? []); setIndustryOptions(data.industryOptions ?? []); setProvinceOptions(data.provinceOptions ?? []); setCertificationLevelOptions(data.certificationLevelOptions ?? []); setChannelAttributeOptions(data.channelAttributeOptions ?? []); setInternalAttributeOptions(data.internalAttributeOptions ?? []); setExtensionTypeOptions(data.extensionTypeOptions ?? []); setOnlineStatusOptions(data.onlineStatusOptions ?? []); setIsOptions(data.isOptions ?? []); setNextChannelCode(data.nextChannelCode ?? ""); return data; }, []); useEffect(() => { let cancelled = false; const timer = window.setTimeout(async () => { setLoadingSupplierChannelOptions(true); try { const data = await getOpportunityExpansionOptions({ keyword: supplierChannelQuery || undefined, limit: SUPPLIER_SEARCH_LIMIT, }); if (!cancelled) { setSupplierChannelOptions(data.channelItems ?? []); } } catch { if (!cancelled) { setSupplierChannelOptions([]); } } finally { if (!cancelled) { setLoadingSupplierChannelOptions(false); } } }, SUPPLIER_SEARCH_DEBOUNCE_MS); return () => { cancelled = true; window.clearTimeout(timer); }; }, [supplierChannelQuery, supplierRefreshTick]); useEffect(() => { let cancelled = false; const timer = window.setTimeout(async () => { setLoadingSupplierSalesOptions(true); try { const data = await getOpportunityExpansionOptions({ keyword: supplierSalesQuery || undefined, limit: SUPPLIER_SEARCH_LIMIT, }); if (!cancelled) { setSupplierSalesOptions(data.salesItems ?? []); } } catch { if (!cancelled) { setSupplierSalesOptions([]); } } finally { if (!cancelled) { setLoadingSupplierSalesOptions(false); } } }, SUPPLIER_SEARCH_DEBOUNCE_MS); return () => { cancelled = true; window.clearTimeout(timer); }; }, [supplierSalesQuery, supplierRefreshTick]); useEffect(() => { let cancelled = false; async function loadPermissions() { try { const permissions = await listMyPermissions(); if (!cancelled) { setPermissionCodes( permissions .filter((permission) => permission.status !== 0) .map((permission) => permission.code) .filter(Boolean), ); } } catch { if (!cancelled) { setPermissionCodes(null); } } } void loadPermissions(); return () => { cancelled = true; }; }, []); useEffect(() => { let cancelled = false; async function loadRelatedProjectStageDict() { try { const data = await getOpportunityMeta(); if (!cancelled) { // 使用全量阶段(含已禁用字典项),保证销售/渠道导出弹窗与默认导出不遗漏相关商机 setRelatedProjectStageOptions(data.allStageOptions ?? data.stageOptions ?? []); } } catch { if (!cancelled) { setRelatedProjectStageOptions([]); } } } void loadRelatedProjectStageDict(); return () => { cancelled = true; }; }, []); useEffect(() => { if (relatedProjectStageOptions.length <= 0) { return; } setExportFilters((current) => ( current.relatedProjectStageCodes === undefined ? applyDefaultRelatedProjectStageFilters(current, relatedProjectStageOptions) : current )); }, [relatedProjectStageOptions]); const loadCityOptions = useCallback(async (provinceName?: string, isEdit = false) => { const setter = isEdit ? setEditCityOptions : setCreateCityOptions; const normalizedProvinceName = provinceName?.trim(); if (!normalizedProvinceName) { setter([]); return []; } try { const options = await getExpansionCityOptions(normalizedProvinceName); setter(options ?? []); return options ?? []; } catch { setter([]); return []; } }, []); const loadCoverageCityOptions = useCallback(async (provinceNames: string[], isEdit = false) => { const setter = isEdit ? setEditCoverageCityOptions : setCreateCoverageCityOptions; const normalizedProvinces = Array.from(new Set((provinceNames ?? []).map((name) => name?.trim()).filter(Boolean))); if (normalizedProvinces.length === 0) { setter([]); return []; } try { const results = await Promise.all(normalizedProvinces.map((name) => getExpansionCityOptions(name).catch(() => [] as ExpansionDictOption[]))); const merged: ExpansionDictOption[] = []; const seen = new Set(); for (const options of results) { for (const option of options ?? []) { const value = option.value ?? ""; if (!value || seen.has(value)) { continue; } seen.add(value); merged.push(option); } } setter(merged); return merged; } catch { setter([]); return []; } }, []); useEffect(() => { const requestedTab = (location.state as ExpansionLocationState)?.tab; if (requestedTab === "sales" || requestedTab === "channel" || requestedTab === "crm") { setActiveTab(requestedTab); } }, [location.state]); // 拉取含详情(contacts/相关项目/跟进等)的结果,并替换当前选中项, // 确保从任意入口(列表点击、跨页跳转、编辑等)进入详情都能展示完整数据。 const enrichSelectedItem = useCallback(async (item: ExpansionItem) => { if (!item || !item.id) { return; } try { if (item.type === "crm") { const data = await getCrmExpansionOverview(keyword, true); const detailedItem = (data.crmItems ?? []).find((candidate) => candidate.id === item.id); if (detailedItem) { setSelectedItem((current) => current?.id === detailedItem.id ? detailedItem : current); } return; } const data = await getExpansionOverview(keyword, true); const detailedItem = item.type === "sales" ? (data.salesItems ?? []).find((candidate) => candidate.id === item.id) : (data.channelItems ?? []).find((candidate) => candidate.id === item.id); if (detailedItem) { setSelectedItem((current) => current?.id === detailedItem.id ? detailedItem : current); } } catch { // 详情补齐失败时保留已有数据,不影响展示 } }, [keyword]); useEffect(() => { const requestedState = location.state as ExpansionLocationState; const requestedTab = requestedState?.tab; const requestedId = requestedState?.selectedId; if (!requestedId) { return; } const selectAndEnrich = (match: ExpansionItem | undefined) => { if (!match) { return; } setSelectedItem(match); void enrichSelectedItem(match); }; if (requestedTab === "sales") { selectAndEnrich(salesData.find((item) => item.id === requestedId)); return; } if (requestedTab === "channel") { selectAndEnrich(channelData.find((item) => item.id === requestedId)); return; } if (requestedTab === "crm") { selectAndEnrich(crmData.find((item) => item.id === requestedId)); return; } selectAndEnrich(salesData.find((item) => item.id === requestedId) ?? channelData.find((item) => item.id === requestedId) ?? crmData.find((item) => item.id === requestedId) ?? null); }, [location.state, salesData, channelData, crmData, enrichSelectedItem]); useEffect(() => { let cancelled = false; async function loadMetaOptions() { try { const data = await loadMeta(); if (!cancelled) { setOfficeOptions(data.officeOptions ?? []); setIndustryOptions(data.industryOptions ?? []); setProvinceOptions(data.provinceOptions ?? []); setCertificationLevelOptions(data.certificationLevelOptions ?? []); setChannelAttributeOptions(data.channelAttributeOptions ?? []); setInternalAttributeOptions(data.internalAttributeOptions ?? []); setNextChannelCode(data.nextChannelCode ?? ""); } } catch { if (!cancelled) { setOfficeOptions([]); setIndustryOptions([]); setProvinceOptions([]); setCertificationLevelOptions([]); setChannelAttributeOptions([]); setInternalAttributeOptions([]); setNextChannelCode(""); } } } void loadMetaOptions(); return () => { cancelled = true; }; }, [loadMeta]); useEffect(() => { let cancelled = false; const loadKey = `${activeTab}:${keyword}:${refreshTick}`; async function loadExpansionData() { try { if (activeTab === "crm") { if (!loadedTabKeysRef.current.has(loadKey)) { const data = await getCrmExpansionOverview(keyword, false, LIST_PAGE_SIZE + 1); if (cancelled) { return; } setCrmData(dedupeExpansionItemsById(data.crmItems ?? [])); loadedTabKeysRef.current.add(loadKey); } setSelectedItem(null); } else { const salesKey = `sales:${keyword}:${refreshTick}`; const channelKey = `channel:${keyword}:${refreshTick}`; if (!loadedTabKeysRef.current.has(salesKey) && !loadedTabKeysRef.current.has(channelKey)) { const data = await getExpansionOverview(keyword, false, LIST_PAGE_SIZE + 1); if (cancelled) { return; } setSalesData(dedupeExpansionItemsById(data.salesItems ?? [])); setChannelData(dedupeExpansionItemsById(data.channelItems ?? [])); loadedTabKeysRef.current.add(salesKey); loadedTabKeysRef.current.add(channelKey); } setSelectedItem(null); } } catch { if (!cancelled) { if (activeTab === "crm") { setCrmData([]); } else { setSalesData([]); setChannelData([]); } setSelectedItem(null); } } } void loadExpansionData(); return () => { cancelled = true; }; }, [keyword, refreshTick, activeTab]); useEffect(() => { setVisibleItemCount(LIST_PAGE_SIZE); }, [keyword, activeTab]); const activeData = activeTab === "sales" ? salesData : activeTab === "channel" ? channelData : crmData; const hasMoreItems = visibleItemCount < activeData.length; const supplierChannelSearchOptions: SearchableOption[] = supplierChannelOptions.map((item) => ({ value: item.id, label: item.name || `渠道#${item.id}`, keywords: [item.channelCode || "", item.province || "", item.primaryContactName || "", item.primaryContactMobile || ""], })); const supplierSalesSearchOptions: SearchableOption[] = supplierSalesOptions.map((item) => ({ value: item.id, label: [item.name, item.employeeNo, item.officeName].filter(Boolean).join(" - ") || `负责人#${item.id}`, keywords: [item.employeeNo || "", item.officeName || "", item.phone || "", item.title || ""], })); const loadMoreItems = async () => { if (!hasMoreItems || loadingMoreRef.current) { return; } loadingMoreRef.current = true; setLoadingMore(true); setLoadMoreError(""); const nextVisibleCount = visibleItemCount + LIST_PAGE_SIZE; try { if (activeTab === "crm") { const data = await getCrmExpansionOverview(keyword, false, nextVisibleCount + 1); setCrmData(dedupeExpansionItemsById(data.crmItems ?? [])); setVisibleItemCount(nextVisibleCount); } else { const data = await getExpansionOverview(keyword, false, nextVisibleCount + 1); setSalesData(dedupeExpansionItemsById(data.salesItems ?? [])); setChannelData(dedupeExpansionItemsById(data.channelItems ?? [])); setVisibleItemCount(nextVisibleCount); } } catch { setLoadMoreError("加载更多失败,请重试"); } finally { loadingMoreRef.current = false; setLoadingMore(false); } }; useEffect(() => { if (!hasMoreItems) { return; } const handleScroll = () => { const loadMoreElement = loadMoreRef.current; if (loadMoreElement && loadMoreElement.getBoundingClientRect().top <= window.innerHeight + 120) { void loadMoreItems(); } }; window.addEventListener("scroll", handleScroll, true); return () => window.removeEventListener("scroll", handleScroll, true); }, [hasMoreItems, visibleItemCount, activeData.length, keyword, activeTab]); const followUpRecords: ExpansionFollowUp[] = selectedItem?.followUps ?? []; const handleSelectItem = (item: ExpansionItem) => { setSelectedItem(item); if (item.type === "crm") { setCrmDetailTab("contacts"); } void enrichSelectedItem(item); }; useEffect(() => { if (selectedItem?.type === "sales") { setSalesDetailTab("projects"); } else if (selectedItem?.type === "channel") { setChannelDetailTab("projects"); } }, [selectedItem]); useEffect(() => { if (!createOpen || activeTab !== "sales") { setSalesDuplicateChecking(false); return; } const normalizedEmployeeNo = salesForm.employeeNo.trim(); if (!normalizedEmployeeNo) { setSalesDuplicateChecking(false); setSalesDuplicateMessage(""); return; } let cancelled = false; const timer = window.setTimeout(async () => { setSalesDuplicateChecking(true); try { const result = await checkSalesExpansionDuplicate(normalizedEmployeeNo); if (!cancelled) { setSalesDuplicateMessage(result.duplicated ? result.message || "工号重复,请确认该人员是否已存在!" : ""); } } catch { if (!cancelled) { setSalesDuplicateMessage(""); } } finally { if (!cancelled) { setSalesDuplicateChecking(false); } } }, 400); return () => { cancelled = true; window.clearTimeout(timer); }; }, [activeTab, createOpen, salesForm.employeeNo]); useEffect(() => { if (!createOpen || activeTab !== "channel") { setChannelDuplicateChecking(false); return; } const normalizedChannelName = channelForm.channelName.trim(); if (!normalizedChannelName) { setChannelDuplicateChecking(false); setChannelDuplicateMessage(""); return; } let cancelled = false; const timer = window.setTimeout(async () => { setChannelDuplicateChecking(true); try { const result = await checkChannelExpansionDuplicate(normalizedChannelName); if (!cancelled) { setChannelDuplicateMessage(result.duplicated ? result.message || "渠道重复,请确认该渠道是否已存在!" : ""); } } catch { if (!cancelled) { setChannelDuplicateMessage(""); } } finally { if (!cancelled) { setChannelDuplicateChecking(false); } } }, 400); return () => { cancelled = true; window.clearTimeout(timer); }; }, [activeTab, channelForm.channelName, createOpen]); const handleSalesChange = (key: K, value: CreateSalesExpansionPayload[K]) => { setSalesForm((current) => ({ ...current, [key]: value })); if (key === "employeeNo") { setSalesDuplicateMessage(""); setSalesDuplicateChecking(false); } if (key in salesCreateFieldErrors) { setSalesCreateFieldErrors((current) => { const next = { ...current }; delete next[key as SalesCreateField]; return next; }); } }; const handleChannelChange = (key: K, value: CreateChannelExpansionPayload[K]) => { setChannelForm((current) => ({ ...current, [key]: value })); if (key === "channelName") { setChannelDuplicateMessage(""); setChannelDuplicateChecking(false); } if (key in channelCreateFieldErrors) { setChannelCreateFieldErrors((current) => { const next = { ...current }; delete next[key as ChannelField]; return next; }); } }; const handleEditSalesChange = (key: K, value: CreateSalesExpansionPayload[K]) => { setEditSalesForm((current) => ({ ...current, [key]: value })); if (key in salesEditFieldErrors) { setSalesEditFieldErrors((current) => { const next = { ...current }; delete next[key as SalesCreateField]; return next; }); } }; const handleEditChannelChange = (key: K, value: CreateChannelExpansionPayload[K]) => { setEditChannelForm((current) => ({ ...current, [key]: value })); if (key in channelEditFieldErrors) { setChannelEditFieldErrors((current) => { const next = { ...current }; delete next[key as ChannelField]; return next; }); } }; const handleCrmChange = (key: K, value: CreateCrmExpansionPayload[K]) => { setCrmForm((current) => { const next = { ...current, [key]: value }; if (key === "purchaseDate") { next.warrantyExpiry = addYearsToDate(String(value ?? ""), 3); } return next; }); if (key in crmFieldErrors) { setCrmFieldErrors((current) => { const next = { ...current }; delete next[key]; return next; }); } }; const handleEditCrmChange = (key: K, value: CreateCrmExpansionPayload[K]) => { setEditCrmForm((current) => ({ ...current, [key]: value })); if (key in editCrmFieldErrors) { setEditCrmFieldErrors((current) => { const next = { ...current }; delete next[key]; return next; }); } }; const handleCrmContactChange = (index: number, key: keyof CrmExpansionContact, value: string, isEdit = false) => { const setter = isEdit ? setEditCrmForm : setCrmForm; setter((current) => { const nextContacts = [...(current.contacts ?? [])]; const target = { ...(nextContacts[index] ?? createEmptyCrmContact()), [key]: value }; nextContacts[index] = target; return { ...current, contacts: nextContacts }; }); if (isEdit) { setEditCrmFieldErrors((current) => { if (!current.contacts) { return current; } const next = { ...current }; delete next.contacts; return next; }); setInvalidEditCrmContactRows((current) => current.filter((rowIndex) => rowIndex !== index)); return; } setCrmFieldErrors((current) => { if (!current.contacts) { return current; } const next = { ...current }; delete next.contacts; return next; }); setInvalidCreateCrmContactRows((current) => current.filter((rowIndex) => rowIndex !== index)); }; const addCrmContact = (isEdit = false) => { const setter = isEdit ? setEditCrmForm : setCrmForm; setter((current) => ({ ...current, contacts: [...(current.contacts ?? []), createEmptyCrmContact()], })); }; const removeCrmContact = (index: number, isEdit = false) => { const setter = isEdit ? setEditCrmForm : setCrmForm; setter((current) => { const currentContacts = current.contacts ?? []; const nextContacts = currentContacts.filter((_, contactIndex) => contactIndex !== index); return { ...current, contacts: nextContacts.length > 0 ? nextContacts : [createEmptyCrmContact()], }; }); }; const handleMoveCrmContactChange = (index: number, key: keyof CrmExpansionContact, value: string) => { setMoveChannelForm((current) => { const nextContacts = [...(current.contacts ?? [])]; const target = { ...(nextContacts[index] ?? createEmptyCrmContact()), [key]: value }; nextContacts[index] = target; return { ...current, contacts: nextContacts }; }); setMoveChannelFieldErrors((current) => { if (!current.contacts) { return current; } const next = { ...current }; delete next.contacts; return next; }); setInvalidMoveCrmContactRows((current) => current.filter((rowIndex) => rowIndex !== index)); }; const addMoveCrmContact = () => { setMoveChannelForm((current) => ({ ...current, contacts: [...(current.contacts ?? []), createEmptyCrmContact()], })); }; const removeMoveCrmContact = (index: number) => { setMoveChannelForm((current) => { const currentContacts = current.contacts ?? []; const nextContacts = currentContacts.filter((_, contactIndex) => contactIndex !== index); return { ...current, contacts: nextContacts.length > 0 ? nextContacts : [createEmptyCrmContact()], }; }); }; const handleMoveChannelContactChange = (index: number, key: keyof ChannelExpansionContact, value: string) => { setMoveCrmForm((current) => { const nextContacts = [...(current.contacts ?? [])]; const target = { ...(nextContacts[index] ?? {}), [key]: value }; nextContacts[index] = target; return { ...current, contacts: nextContacts }; }); setMoveCrmFieldErrors((current) => { if (!current.contacts) { return current; } const next = { ...current }; delete next.contacts; return next; }); setInvalidMoveChannelContactRows((current) => current.filter((rowIndex) => rowIndex !== index)); }; const handleChannelContactChange = (index: number, key: keyof ChannelExpansionContact, value: string, isEdit = false) => { const setter = isEdit ? setEditChannelForm : setChannelForm; setter((current) => { const nextContacts = [...(current.contacts ?? [])]; const target = { ...(nextContacts[index] ?? {}), [key]: value }; nextContacts[index] = target; return { ...current, contacts: nextContacts }; }); if (isEdit) { setChannelEditFieldErrors((current) => { if (!current.contacts) { return current; } const next = { ...current }; delete next.contacts; return next; }); setInvalidEditChannelContactRows((current) => current.filter((rowIndex) => rowIndex !== index)); return; } setChannelCreateFieldErrors((current) => { if (!current.contacts) { return current; } const next = { ...current }; delete next.contacts; return next; }); setInvalidCreateChannelContactRows((current) => current.filter((rowIndex) => rowIndex !== index)); }; const resetCreateState = () => { setCreateOpen(false); setCreateError(""); setSalesDuplicateChecking(false); setChannelDuplicateChecking(false); setSalesDuplicateMessage(""); setChannelDuplicateMessage(""); setCrmDuplicateMessage(""); setSalesCreateFieldErrors({}); setChannelCreateFieldErrors({}); setCrmFieldErrors({}); setInvalidCreateChannelContactRows([]); setInvalidCreateCrmContactRows([]); setSalesForm(defaultSalesForm); setChannelForm(defaultChannelForm); setCrmForm({ endUser: "", officeName: "", industryAttr: [], extensionType: [], purchaseDate: "", warrantyExpiry: "", onlineStatus: "", contacts: [createEmptyCrmContact()], supplierId: 0, supplierName: "", h3cContactId: 0, h3cContactName: "", hasExpansionOpportunity: "", softwarePoints: undefined, expansionTime: "", expansionScale: "", hasMaintenanceOpportunity: "", }); setCreateCityOptions([]); setCreateCoverageCityOptions([]); }; const resetEditState = () => { setEditOpen(false); setEditError(""); setSalesEditFieldErrors({}); setChannelEditFieldErrors({}); setEditCrmFieldErrors({}); setInvalidEditChannelContactRows([]); setInvalidEditCrmContactRows([]); setEditSalesForm(defaultSalesForm); setEditChannelForm(defaultChannelForm); setEditCrmForm({ endUser: "", officeName: "", industryAttr: [], extensionType: [], purchaseDate: "", warrantyExpiry: "", onlineStatus: "", contacts: [createEmptyCrmContact()], supplierId: 0, supplierName: "", h3cContactId: 0, h3cContactName: "", hasExpansionOpportunity: "", softwarePoints: undefined, expansionTime: "", expansionScale: "", hasMaintenanceOpportunity: "", }); setEditCityOptions([]); setEditCoverageCityOptions([]); }; const handleOpenCreate = async () => { setCreateError(""); setSalesCreateFieldErrors({}); setChannelCreateFieldErrors({}); setCrmFieldErrors({}); setInvalidCreateChannelContactRows([]); setInvalidCreateCrmContactRows([]); try { await loadMeta(); } catch {} setCreateCityOptions([]); setCreateCoverageCityOptions([]); setSupplierRefreshTick((current) => current + 1); setCreateOpen(true); }; const handleOpenEdit = async () => { if (!selectedItem) { return; } if (!canEditSelectedItem) { return; } setEditError(""); let latestIndustryOptions = industryOptions; let latestCertificationLevelOptions = certificationLevelOptions; try { const meta = await loadMeta(); latestIndustryOptions = meta.industryOptions ?? []; latestCertificationLevelOptions = meta.certificationLevelOptions ?? []; } catch {} if (selectedItem.type === "sales") { setSalesEditFieldErrors({}); setEditSalesForm({ employeeNo: selectedItem.employeeNo === "无" ? "" : selectedItem.employeeNo ?? "", candidateName: selectedItem.name ?? "", officeName: selectedItem.officeCode ?? "", mobile: selectedItem.phone === "无" ? "" : selectedItem.phone ?? "", targetDept: selectedItem.dept === "无" ? "" : selectedItem.dept ?? selectedItem.targetDept ?? "", industry: selectedItem.industryCode ?? "", title: selectedItem.title === "无" ? "" : selectedItem.title ?? "", intentLevel: selectedItem.intentLevel ?? "medium", hasDesktopExp: Boolean(selectedItem.hasExp), employmentStatus: selectedItem.active ? "active" : "left", regionProvince: decodeExpansionMultiValue(selectedItem.regionProvince).values, regionCity: decodeExpansionMultiValue(selectedItem.regionCity).values, regionItems: parseCoverageItemsString(selectedItem.regionItems), }); } else if (selectedItem.type === "crm") { setEditCrmFieldErrors({}); setInvalidEditCrmContactRows([]); setEditCrmForm({ endUser: selectedItem.endUser ?? "", officeName: selectedItem.officeName ?? "", industryAttr: normalizeMultiOptionValues( selectedItem.industryAttrCode ?? (selectedItem.industryAttr === "无" ? "" : selectedItem.industryAttr), industryOptions, ), extensionType: normalizeMultiOptionValues( selectedItem.extensionType ?? "", extensionTypeOptions, ), purchaseDate: selectedItem.purchaseDate ?? "", warrantyExpiry: selectedItem.warrantyExpiry ?? "", onlineStatus: selectedItem.onlineStatus ?? "", contacts: (selectedItem.contacts?.length ?? 0) > 0 ? selectedItem.contacts?.map((contact) => ({ name: contact.name === "无" ? "" : contact.name ?? "", mobile: contact.mobile === "无" ? "" : contact.mobile ?? "", title: contact.title === "无" ? "" : contact.title ?? "", })) : [createEmptyCrmContact()], supplierId: selectedItem.supplierId ?? 0, supplierName: selectedItem.supplierName ?? "", h3cContactId: selectedItem.h3cContactId ?? 0, h3cContactName: selectedItem.h3cContactName ?? "", hasExpansionOpportunity: selectedItem.hasExpansionOpportunity ?? "", softwarePoints: selectedItem.softwarePoints, expansionTime: selectedItem.expansionTime ?? "", expansionScale: selectedItem.expansionScale ?? "", hasMaintenanceOpportunity: selectedItem.hasMaintenanceOpportunity ?? "", }); } else { // 列表条目通过 includeDetails=false 获取,不含 contacts;若当前选中项缺少联系人, // 拉取包含详情的结果补齐,避免编辑时人员信息丢失(或保存时被清空)。 let sourceItem = selectedItem; if ((selectedItem.contacts?.length ?? 0) === 0) { try { const data = await getExpansionOverview(keyword, true); const detailedItem = (data.channelItems ?? []).find((candidate) => candidate.id === selectedItem.id); if (detailedItem) { sourceItem = detailedItem; setSelectedItem((current) => current?.id === detailedItem.id ? detailedItem : current); } } catch {} } const parsedChannelAttributes = decodeExpansionMultiValue(sourceItem.channelAttributeCode); const parsedInternalAttributes = decodeExpansionMultiValue(sourceItem.internalAttributeCode); const parsedCoverageProvinces = decodeExpansionMultiValue(sourceItem.coverageProvince).values; const normalizedProvinceName = sourceItem.province === "无" ? "" : sourceItem.province ?? ""; const normalizedCityName = sourceItem.city === "无" ? "" : sourceItem.city ?? ""; setChannelEditFieldErrors({}); setInvalidEditChannelContactRows([]); setEditChannelForm({ channelCode: sourceItem.channelCode ?? "", channelName: sourceItem.name ?? "", province: normalizedProvinceName, city: normalizedCityName, coverageProvince: parsedCoverageProvinces, coverageCity: decodeExpansionMultiValue(sourceItem.coverageCity).values, coverageItems: parseCoverageItemsString(sourceItem.coverageItems), officeAddress: sourceItem.officeAddress === "无" ? "" : sourceItem.officeAddress ?? "", channelIndustry: normalizeMultiOptionValues( sourceItem.channelIndustryCode ?? (sourceItem.channelIndustry === "无" ? "" : sourceItem.channelIndustry), latestIndustryOptions, ), certificationLevel: normalizeOptionValue( sourceItem.certificationLevel === "无" ? "" : sourceItem.certificationLevel, latestCertificationLevelOptions, ) || "", annualRevenue: sourceItem.annualRevenue ? Number(sourceItem.annualRevenue) : undefined, staffSize: sourceItem.size ?? undefined, registeredCapital: sourceItem.registeredCapital ? Number(sourceItem.registeredCapital) : undefined, contactEstablishedDate: sourceItem.establishedDate === "无" ? "" : sourceItem.establishedDate ?? "", intentLevel: sourceItem.intentLevel ?? "medium", hasDesktopExp: Boolean(sourceItem.hasDesktopExp), channelAttribute: parsedChannelAttributes.values, channelAttributeCustom: parsedChannelAttributes.customText, internalAttribute: parsedInternalAttributes.values, stage: sourceItem.stageCode ?? "initial_contact", remark: sourceItem.notes === "无" ? "" : sourceItem.notes ?? "", contacts: buildChannelContactRows(sourceItem.contacts), }); void loadCityOptions(normalizedProvinceName, true); void loadCoverageCityOptions(parsedCoverageProvinces, true); } setSupplierRefreshTick((current) => current + 1); setEditOpen(true); }; const handleCreateSubmit = async () => { if (submitting) { return; } setCreateError(""); if (activeTab === "sales") { const validationErrors = validateSalesCreateForm(salesForm); if (Object.keys(validationErrors).length > 0) { setSalesCreateFieldErrors(validationErrors); setCreateError("请先完整填写销售人员拓展必填字段"); return; } } else if (activeTab === "crm") { const { errors: validationErrors, invalidContactRows } = validateCrmForm(crmForm); if (Object.keys(validationErrors).length > 0) { setCrmFieldErrors(validationErrors); setInvalidCreateCrmContactRows(invalidContactRows); setCreateError("请先完整填写CRM拓展必填字段"); return; } } else { const { errors: validationErrors, invalidContactRows } = validateChannelForm(channelForm, channelOtherOptionValue); if (Object.keys(validationErrors).length > 0) { setChannelCreateFieldErrors(validationErrors); setInvalidCreateChannelContactRows(invalidContactRows); setCreateError("请先完整填写渠道拓展必填字段"); return; } } if (activeTab === "sales") { const duplicateResult = await checkSalesExpansionDuplicate(salesForm.employeeNo.trim()); if (duplicateResult.duplicated) { const duplicateMessage = duplicateResult.message || "工号重复,请确认该人员是否已存在!"; setSalesDuplicateMessage(duplicateMessage); setSalesCreateFieldErrors((current) => ({ ...current, employeeNo: duplicateMessage })); setCreateError(duplicateMessage); return; } } else if (activeTab === "channel") { const duplicateResult = await checkChannelExpansionDuplicate(channelForm.channelName.trim()); if (duplicateResult.duplicated) { const duplicateMessage = duplicateResult.message || "渠道重复,请确认该渠道是否已存在!"; setChannelDuplicateMessage(duplicateMessage); setChannelCreateFieldErrors((current) => ({ ...current, channelName: duplicateMessage })); setCreateError(duplicateMessage); return; } } else if (activeTab === "crm") { const duplicateResult = await checkCrmExpansionDuplicate(crmForm.endUser.trim()); if (duplicateResult.duplicated) { const duplicateMessage = duplicateResult.message || "最终用户重复,请确认该客户是否已存在!"; setCrmDuplicateMessage(duplicateMessage); setCrmFieldErrors((current) => ({ ...current, endUser: duplicateMessage })); setCreateError(duplicateMessage); return; } } setSubmitting(true); try { if (activeTab === "sales") { await createSalesExpansion(normalizeSalesPayload(salesForm)); } else if (activeTab === "crm") { await createCrmExpansion(crmForm); } else { await createChannelExpansion(normalizeChannelPayload(channelForm)); } resetCreateState(); setRefreshTick((current) => current + 1); } catch (error) { setCreateError(error instanceof Error ? error.message : "新增失败"); } finally { setSubmitting(false); } }; const handleEditSubmit = async () => { if (!selectedItem || submitting) { return; } if (!canEditSelectedItem) { setEditError("仅可编辑本人创建的数据"); return; } setEditError(""); if (selectedItem.type === "sales") { const validationErrors = validateSalesCreateForm(editSalesForm); if (Object.keys(validationErrors).length > 0) { setSalesEditFieldErrors(validationErrors); setEditError("请先完整填写销售人员拓展必填字段"); return; } } else if (selectedItem.type === "crm") { const { errors: validationErrors, invalidContactRows } = validateCrmForm(editCrmForm); if (Object.keys(validationErrors).length > 0) { setEditCrmFieldErrors(validationErrors); setInvalidEditCrmContactRows(invalidContactRows); setEditError("请先完整填写CRM拓展必填字段"); return; } } else { const { errors: validationErrors, invalidContactRows } = validateChannelForm(editChannelForm, channelOtherOptionValue); if (Object.keys(validationErrors).length > 0) { setChannelEditFieldErrors(validationErrors); setInvalidEditChannelContactRows(invalidContactRows); setEditError("请先完整填写渠道拓展必填字段"); return; } } if (selectedItem.type === "sales") { const duplicateResult = await checkSalesExpansionDuplicate(editSalesForm.employeeNo.trim(), selectedItem.id); if (duplicateResult.duplicated) { const duplicateMessage = duplicateResult.message || "工号重复,请确认该人员是否已存在!"; setSalesDuplicateMessage(duplicateMessage); setSalesEditFieldErrors((current) => ({ ...current, employeeNo: duplicateMessage })); setEditError(duplicateMessage); return; } } else if (selectedItem.type === "channel") { const duplicateResult = await checkChannelExpansionDuplicate(editChannelForm.channelName.trim(), selectedItem.id); if (duplicateResult.duplicated) { const duplicateMessage = duplicateResult.message || "渠道重复,请确认该渠道是否已存在!"; setChannelDuplicateMessage(duplicateMessage); setChannelEditFieldErrors((current) => ({ ...current, channelName: duplicateMessage })); setEditError(duplicateMessage); return; } } else if (selectedItem.type === "crm") { const duplicateResult = await checkCrmExpansionDuplicate(editCrmForm.endUser.trim(), selectedItem.id); if (duplicateResult.duplicated) { const duplicateMessage = duplicateResult.message || "最终用户重复,请确认该客户是否已存在!"; setCrmDuplicateMessage(duplicateMessage); setEditCrmFieldErrors((current) => ({ ...current, endUser: duplicateMessage })); setEditError(duplicateMessage); return; } } setSubmitting(true); try { if (selectedItem.type === "sales") { await updateSalesExpansion(selectedItem.id, normalizeSalesPayload(editSalesForm)); } else if (selectedItem.type === "crm") { await updateCrmExpansion(selectedItem.id, editCrmForm); } else { await updateChannelExpansion(selectedItem.id, normalizeChannelPayload(editChannelForm)); } resetEditState(); setSelectedItem(null); setRefreshTick((current) => current + 1); } catch (error) { setEditError(error instanceof Error ? error.message : "编辑失败"); } finally { setSubmitting(false); } }; const resetMoveState = () => { setMoveOpen(false); setMoveError(""); setMoveChannelFieldErrors({}); setMoveCrmFieldErrors({}); setMoveChannelForm(defaultMoveChannelForm); setMoveCrmForm(defaultMoveCrmForm); setMoveCityOptions([]); setInvalidMoveCrmContactRows([]); setInvalidMoveChannelContactRows([]); setSupplierChannelQuery(""); setSupplierSalesQuery(""); }; const handleOpenMove = async () => { if (!selectedItem) { return; } if (!canEditSelectedItem) { return; } setMoveError(""); setMoveChannelFieldErrors({}); setMoveCrmFieldErrors({}); setSupplierRefreshTick((current) => current + 1); if (selectedItem.type === "channel") { // 采购时间/过保时间不沿渠道「成立时间」强预填,留空让用户手选,避免实际不符 setMoveChannelForm({ officeName: matchOfficeValueByProvince(selectedItem.province, officeOptions), extensionType: [], purchaseDate: "", warrantyExpiry: "", onlineStatus: "", supplierId: 0, supplierName: "", h3cContactId: 0, h3cContactName: "", hasExpansionOpportunity: "", softwarePoints: undefined, expansionTime: "", expansionScale: "", hasMaintenanceOpportunity: "", contacts: (selectedItem.contacts?.length ?? 0) > 0 ? selectedItem.contacts?.map((contact) => ({ name: contact.name === "无" ? "" : contact.name ?? "", mobile: contact.mobile === "无" ? "" : contact.mobile ?? "", title: contact.title === "无" ? "" : contact.title ?? "", })) : [], }); setMoveCityOptions([]); } else if (selectedItem.type === "crm") { const initialProvince = officeOptions.find((option) => option.value === selectedItem.officeName)?.label ?? selectedItem.officeName ?? ""; setMoveCrmForm({ province: initialProvince, city: "", officeAddress: "", certificationLevel: "", annualRevenue: 0, staffSize: 0, registeredCapital: 0, channelAttribute: [], channelAttributeCustom: "", internalAttribute: [], coverageProvince: [], coverageCity: [], intentLevel: "", hasDesktopExp: false, stage: "initial_contact", landedFlag: false, expectedSignDate: "", contacts: buildChannelContactRows(selectedItem.contacts), }); if (initialProvince) { try { setMoveCityOptions(await getExpansionCityOptions(initialProvince)); } catch { setMoveCityOptions([]); } } else { setMoveCityOptions([]); } } setMoveOpen(true); }; const validateMoveChannelForm = (form: MoveChannelToCrmPayload, sourceId: CrmId) => { const errors: Partial> = {}; const invalidContactRows: number[] = []; if (!form.officeName?.trim()) errors.officeName = "请选择代表处"; if ((form.extensionType?.length ?? 0) <= 0) errors.extensionType = "请选择类型"; if (form.softwarePoints == null || Number.isNaN(form.softwarePoints) || form.softwarePoints < 0) errors.softwarePoints = "请填写软件点数"; if (!form.purchaseDate) errors.purchaseDate = "请选择采购时间"; if (!form.warrantyExpiry) errors.warrantyExpiry = "请选择过保时间"; if (!form.onlineStatus?.trim()) errors.onlineStatus = "请选择在线情况"; if ((!form.supplierId || String(form.supplierId) === "0") && !form.supplierName?.trim()) errors.supplierId = "请选择或填写进货商"; else if (String(form.supplierId) === String(sourceId)) errors.supplierId = "不能选择被迁移的渠道本身"; if ((!form.h3cContactId || String(form.h3cContactId) === "0") && !form.h3cContactName?.trim()) errors.h3cContactId = "请选择或填写新华三对接人"; if (!form.hasExpansionOpportunity?.trim()) errors.hasExpansionOpportunity = "请选择是否有扩容机会"; if (form.hasExpansionOpportunity === "1") { if (!form.expansionTime?.trim()) errors.expansionTime = "请选择扩容时间"; if (!form.expansionScale?.trim()) errors.expansionScale = "请填写扩容规模"; } if (!form.hasMaintenanceOpportunity?.trim()) errors.hasMaintenanceOpportunity = "请选择是否有维保项目机会"; const contacts = form.contacts ?? []; if (contacts.length <= 0) { errors.contacts = "请至少填写一位联系人"; invalidContactRows.push(0); } else { contacts.forEach((contact, index) => { const hasName = Boolean(contact.name?.trim()); const hasMobile = Boolean(contact.mobile?.trim()); const hasTitle = Boolean(contact.title?.trim()); if (!hasName || !hasMobile || !hasTitle) { invalidContactRows.push(index); } }); if (invalidContactRows.length > 0) { errors.contacts = "请完整填写每位联系人的姓名、联系电话和职位"; } } return { errors, invalidContactRows }; }; const validateMoveCrmForm = (form: MoveCrmToChannelPayload) => { const errors: Partial> = {}; if (!form.city?.trim()) errors.city = "请选择市"; if (!form.officeAddress?.trim()) errors.officeAddress = "请填写办公地址"; if (!form.certificationLevel?.trim()) errors.certificationLevel = "请选择汇智内部认证级别"; if (!(form.annualRevenue > 0)) errors.annualRevenue = "请填写年度营业额"; if (!(form.staffSize > 0)) errors.staffSize = "请填写人员规模"; if (!(form.registeredCapital > 0)) errors.registeredCapital = "请填写注册资金(万元)"; if (!form.channelAttribute?.length) errors.channelAttribute = "请选择渠道属性"; if (!form.internalAttribute?.length) errors.internalAttribute = "请选择新华三内部属性"; if (!form.coverageProvince?.length) errors.coverageProvince = "请选择覆盖省份"; if (!form.coverageCity?.length) errors.coverageCity = "请选择覆盖市/区/县"; if (!form.intentLevel?.trim()) errors.intentLevel = "请选择合作意向"; const contactValidation = validateChannelContactRows(form.contacts); if (contactValidation.error) { errors.contacts = contactValidation.error; } return { errors, invalidContactRows: contactValidation.invalidContactRows }; }; const handleMoveSubmit = async () => { if (!selectedItem || moveSubmitting) { return; } if (!canEditSelectedItem) { setMoveError("仅可操作本人创建的数据"); return; } setMoveError(""); if (selectedItem.type === "channel") { const { errors: validationErrors, invalidContactRows } = validateMoveChannelForm(moveChannelForm, selectedItem.id); if (Object.keys(validationErrors).length > 0) { setMoveChannelFieldErrors(validationErrors); setInvalidMoveCrmContactRows(invalidContactRows); setMoveError("请先完整填写必填字段"); return; } } else if (selectedItem.type === "crm") { const { errors: validationErrors, invalidContactRows } = validateMoveCrmForm(moveCrmForm); if (Object.keys(validationErrors).length > 0) { setMoveCrmFieldErrors(validationErrors); setInvalidMoveChannelContactRows(invalidContactRows); setMoveError("请先完整填写必填字段"); return; } } else { return; } setMoveSubmitting(true); try { if (selectedItem.type === "channel") { await moveChannelToCrm(selectedItem.id, moveChannelForm); } else { await moveCrmToChannel(selectedItem.id, moveCrmForm); } resetMoveState(); setSelectedItem(null); setRefreshTick((current) => current + 1); } catch (error) { setMoveError(error instanceof Error ? error.message : "迁移失败"); } finally { setMoveSubmitting(false); } }; const renderEmpty = () => (
暂无拓展数据,先新增一条试试。
); const renderFollowUpTimeline = () => { if (followUpRecords.length <= 0) { return (
暂无跟进记录
); } return (
{followUpRecords.map((record) => { const summary = getExpansionFollowUpSummary(record); return (
{selectedItem?.type === "crm" ? (

拜访时间

{summary.visitStartTime}

拜访内容

{record.content || "无"}

) : (

拜访时间

{summary.visitStartTime}

沟通内容

{summary.evaluationContent}

后续规划

{summary.nextPlan}

)}

跟进人: {record.user || "无"}{record.date || "无"}

)})}
); }; const handleTabChange = (tab: ExpansionTab) => { setActiveTab(tab); setSelectedItem(null); setExportError(""); }; const handleExport = async (filters: ExpansionExportFilters) => { if (exporting) { return; } const isSalesTab = activeTab === "sales"; const isCrmTab = activeTab === "crm"; const normalizedFilters = applyDefaultRelatedProjectStageFilters(filters, relatedProjectStageOptions); setExporting(true); setExportError(""); setExportFilters(normalizedFilters); persistExpansionExportPreferences(normalizedFilters); try { const overview = !isCrmTab ? await getExpansionOverview("") : null; const exportSalesItems = overview ? dedupeExpansionItemsById(overview.salesItems ?? []) .map((item) => withFilteredSalesRelatedProjects(item, normalizedFilters)) .filter((item) => matchesSalesExportFilters(item, normalizedFilters)) : []; const exportChannelItems = overview ? dedupeExpansionItemsById(overview.channelItems ?? []) .map((item) => withFilteredChannelRelatedProjects(item, normalizedFilters)) .filter((item) => matchesChannelExportFilters(item, normalizedFilters)) : []; const exportCrmItems = isCrmTab ? dedupeExpansionItemsById((await getCrmExpansionOverview("")).crmItems ?? []) .filter((item) => matchesCrmExportFilters(item, normalizedFilters, officeOptions)) : []; const exportItems = isSalesTab ? exportSalesItems : isCrmTab ? exportCrmItems : exportChannelItems; const selectedSalesFieldKeys = resolveSelectedExpansionFields(normalizedFilters.selectedSalesFields, defaultSalesExportFields); const selectedChannelFieldKeys = resolveSelectedExpansionFields(normalizedFilters.selectedChannelFields, defaultChannelExportFields); const selectedCrmFieldKeys = resolveSelectedExpansionFields(normalizedFilters.selectedCrmFields, defaultCrmExportFields); const selectedFieldKeys = isSalesTab ? selectedSalesFieldKeys : isCrmTab ? selectedCrmFieldKeys : selectedChannelFieldKeys; if (exportItems.length <= 0) { throw new Error(`当前筛选条件下暂无可导出的${isSalesTab ? "销售人员拓展" : isCrmTab ? "CRM拓展" : "渠道拓展"}数据`); } if (selectedFieldKeys.length <= 0) { throw new Error("请至少选择一个导出字段"); } const ExcelJS = await import("exceljs"); const workbook = new ExcelJS.Workbook(); const worksheet = workbook.addWorksheet(isSalesTab ? "销售人员拓展" : isCrmTab ? "CRM拓展" : "渠道拓展"); const salesColumns = salesExportColumns.filter((column) => selectedSalesFieldKeys.includes(column.key)); const channelColumns = buildChannelExportColumns(isOptions).filter((column) => selectedChannelFieldKeys.includes(column.key)); const crmColumns = buildCrmExportColumns(officeOptions).filter((column) => selectedCrmFieldKeys.includes(column.key)); const columns = isSalesTab ? salesColumns : isCrmTab ? crmColumns : channelColumns; const headers = columns.map((column) => column.label); const rows = isSalesTab ? buildExportRows(exportSalesItems, salesColumns) : isCrmTab ? buildExportRows(exportCrmItems, crmColumns) : buildExportRows(exportChannelItems, channelColumns); worksheet.addRow(headers); rows.forEach((row) => { worksheet.addRow(row); }); worksheet.views = [{ state: "frozen", ySplit: 1 }]; worksheet.getRow(1).height = 24; worksheet.getRow(1).font = { bold: true }; worksheet.getRow(1).alignment = { vertical: "middle", horizontal: "center" }; const columnWidths = columns.map((column, index) => { const columnValues = rows.map((row) => row[index]).filter((value): value is string => typeof value === "string"); if (column.kind === "followup") { return 42; } if (column.kind === "project" || column.kind === "contact") { return Math.min( 80, Math.max( 16, getExcelDisplayWidth(column.label) + 2, columnValues.reduce((maxWidth, value) => { const longestLineWidth = value.split("\n").reduce((lineMax, line) => Math.max(lineMax, getExcelDisplayWidth(line)), 0); return Math.max(maxWidth, longestLineWidth + 2); }, 0), ), ); } if (column.kind === "longText") { return 24; } if (column.label.includes("渠道属性") || column.label.includes("内部属性") || column.label.includes("聚焦行业")) { return 18; } return 16; }); columns.forEach((columnConfig, index) => { const column = worksheet.getColumn(index + 1); column.width = columnWidths[index]; if (columnConfig.numFmt) { column.numFmt = columnConfig.numFmt; } column.alignment = { vertical: "top", horizontal: "left", wrapText: columnConfig.kind === "followup" || columnConfig.kind === "project" || columnConfig.kind === "contact", }; }); worksheet.eachRow((row, rowNumber) => { row.eachCell((cell, columnNumber) => { cell.border = { top: { style: "thin", color: { argb: "FFE2E8F0" } }, left: { style: "thin", color: { argb: "FFE2E8F0" } }, bottom: { style: "thin", color: { argb: "FFE2E8F0" } }, right: { style: "thin", color: { argb: "FFE2E8F0" } }, }; cell.alignment = { vertical: "top", horizontal: rowNumber === 1 ? "center" : "left", wrapText: rowNumber > 1 && (columns[columnNumber - 1]?.kind === "followup" || columns[columnNumber - 1]?.kind === "project" || columns[columnNumber - 1]?.kind === "contact"), }; }); if (rowNumber > 1) { const wrappedLineCount = columns.reduce((maxLineCount, column, index) => { if (column.kind !== "project" && column.kind !== "contact" && column.kind !== "followup") { return maxLineCount; } const cellValue = row.getCell(index + 1).value; const text = typeof cellValue === "string" ? cellValue : ""; return Math.max(maxLineCount, getExcelWrappedLineCount(text, columnWidths[index])); }, 1); row.height = Math.max(22, wrappedLineCount * 16); } }); const buffer = await workbook.xlsx.writeBuffer(); const filename = `${isSalesTab ? "销售人员拓展" : isCrmTab ? "CRM拓展" : "渠道拓展"}_${formatExportFilenameTime()}.xlsx`; downloadExcelFile(filename, buffer); setExportFilterOpen(false); } catch (error) { setExportError(error instanceof Error ? error.message : "导出失败,请稍后重试"); } finally { setExporting(false); } }; const renderSalesForm = ( form: CreateSalesExpansionPayload, onChange: (key: K, value: CreateSalesExpansionPayload[K]) => void, fieldErrors?: Partial>, isEdit = false, ) => (
); const renderChannelForm = ( form: CreateChannelExpansionPayload, onChange: (key: K, value: CreateChannelExpansionPayload[K]) => void, isEdit = false, fieldErrors?: Partial>, invalidContactRows: number[] = [], ) => { return (
{ onChange("province", address.province); onChange("city", address.city); onChange("officeAddress", address.officeAddress); }} provinceOptions={provinceOptions} isEdit={isEdit} errors={fieldErrors} loadCityOptions={loadCityOptions} /> {channelOtherOptionValue && (form.channelAttribute ?? []).includes(channelOtherOptionValue) ? ( ) : null}
人员信息
handleChannelContactChange(index, key, value, isEdit)} wecomOptions={isOptions} /> {fieldErrors?.contacts ?

{fieldErrors.contacts}

: null}