新增已丢单页面以及导出功能的调整

main
kangwenjing 2026-08-21 13:53:29 +08:00
parent 9453229055
commit 80eb1a493f
9 changed files with 162 additions and 43 deletions

View File

@ -5,11 +5,14 @@
</component>
<component name="ChangeListManager">
<list default="true" id="4c558d98-824e-4a48-ba48-bd2e6172f9f4" name="更改" comment="修改定位信息 0323">
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/service/impl/ExpansionServiceImpl.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/service/impl/ExpansionServiceImpl.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/resources/mapper/expansion/ExpansionMapper.xml" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/resources/mapper/expansion/ExpansionMapper.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/controller/OpportunityController.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/controller/OpportunityController.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/mapper/OpportunityMapper.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/mapper/OpportunityMapper.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/service/OpportunityService.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/service/OpportunityService.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/service/impl/OpportunityServiceImpl.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/service/impl/OpportunityServiceImpl.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/resources/mapper/opportunity/OpportunityMapper.xml" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/resources/mapper/opportunity/OpportunityMapper.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/src/pages/Expansion.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/src/pages/Expansion.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/src/pages/Work.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/src/pages/Work.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/test/java/com/unis/crm/service/impl/OpportunityServiceImplTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/test/java/com/unis/crm/service/impl/OpportunityServiceImplTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/src/lib/auth.ts" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/src/lib/auth.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/src/pages/Opportunities.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/src/pages/Opportunities.tsx" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@ -103,6 +106,7 @@
<workItem from="1776219416113" duration="650000" />
<workItem from="1776238420843" duration="21000" />
<workItem from="1787113214298" duration="3043000" />
<workItem from="1787211862072" duration="4949000" />
</task>
<task id="LOCAL-00001" summary="修改定位信息 0323">
<option name="closed" value="true" />

View File

@ -45,8 +45,10 @@ public class OpportunityController {
@RequestParam(value = "stage", required = false) String stage,
@RequestParam(value = "includeDetails", defaultValue = "true") boolean includeDetails,
@RequestParam(value = "limit", required = false) Integer limit,
@RequestParam(value = "archived", required = false) Boolean archived) {
return ApiResponse.success(opportunityService.getOverview(CurrentUserUtils.requireCurrentUserId(userId), keyword, stage, includeDetails, limit, archived));
@RequestParam(value = "archived", required = false) Boolean archived,
@RequestParam(value = "excludeStageCodes", required = false) List<String> excludeStageCodes,
@RequestParam(value = "stageCodes", required = false) List<String> stageCodes) {
return ApiResponse.success(opportunityService.getOverview(CurrentUserUtils.requireCurrentUserId(userId), keyword, stage, includeDetails, limit, archived, excludeStageCodes, stageCodes));
}
@GetMapping("/{opportunityId}")

View File

@ -45,7 +45,9 @@ public interface OpportunityMapper {
@Param("preSalesUserId") Long preSalesUserId,
@Param("preSalesUserNames") List<String> preSalesUserNames,
@Param("limit") Integer limit,
@Param("archived") Boolean archived);
@Param("archived") Boolean archived,
@Param("excludeStageCodes") List<String> excludeStageCodes,
@Param("stageCodes") List<String> stageCodes);
OpportunityItemDTO selectOpportunityDetail(
@Param("userId") Long userId,

View File

@ -22,6 +22,8 @@ public interface OpportunityService {
OpportunityOverviewDTO getOverview(Long userId, String keyword, String stage, boolean includeDetails, Integer limit, Boolean archived);
OpportunityOverviewDTO getOverview(Long userId, String keyword, String stage, boolean includeDetails, Integer limit, Boolean archived, List<String> excludeStageCodes, List<String> stageCodes);
OpportunityItemDTO getDetail(Long userId, Long opportunityId);
List<OmsPreSalesOptionDTO> getOmsPreSalesOptions(Long userId);

View File

@ -143,6 +143,11 @@ public class OpportunityServiceImpl implements OpportunityService {
@Override
public OpportunityOverviewDTO getOverview(Long userId, String keyword, String stage, boolean includeDetails, Integer limit, Boolean archived) {
return getOverview(userId, keyword, stage, includeDetails, limit, archived, null, null);
}
@Override
public OpportunityOverviewDTO getOverview(Long userId, String keyword, String stage, boolean includeDetails, Integer limit, Boolean archived, List<String> excludeStageCodes, List<String> stageCodes) {
String normalizedKeyword = normalizeKeyword(keyword);
String normalizedStage = normalizeStage(stage);
OpportunityVisibility visibility = resolveOpportunityVisibility(userId);
@ -156,7 +161,9 @@ public class OpportunityServiceImpl implements OpportunityService {
visibility.preSalesUserId(),
visibility.preSalesUserNames(),
limit != null && limit > 0 ? Math.min(limit, 1000) : null,
archived);
archived,
excludeStageCodes,
stageCodes);
if (includeDetails) {
attachFollowUps(userId, items, visibility);
}

View File

@ -261,6 +261,13 @@
<if test="archived != null">
and coalesce(o.archived, false) = #{archived}
</if>
<if test="excludeStageCodes != null and excludeStageCodes.size() > 0">
and coalesce(o.stage, '') not in (
<foreach item="code" collection="excludeStageCodes" open="(" separator="," close=")">
#{code}
</foreach>
)
</if>
<if test="keyword != null and keyword != ''">
and (
o.opportunity_name ilike concat('%', #{keyword}, '%')
@ -277,6 +284,13 @@
<if test="stage != null and stage != ''">
and o.stage = #{stage}
</if>
<if test="stageCodes != null and stageCodes.size() > 0">
and o.stage in (
<foreach item="code" collection="stageCodes" open="(" separator="," close=")">
#{code}
</foreach>
)
</if>
<include refid="opportunityVisibilityCondition"/>
order by coalesce(o.updated_at, o.created_at) desc, o.id desc
<if test="limit != null and limit > 0">

View File

@ -260,6 +260,8 @@ class OpportunityServiceImplTest {
eq(9L),
eq(List.of("张售前", "presales.zhang")),
eq(null),
eq(null),
eq(null),
eq(null))).thenReturn(List.of(item));
when(opportunityMapper.selectOpportunityFollowUps(
eq(9L),

View File

@ -1620,7 +1620,7 @@ export async function saveWorkDailyReport(payload: CreateWorkDailyReportPayload)
}, true);
}
export async function getOpportunityOverview(keyword?: string, stage?: string, includeDetails = true, limit?: number, archived?: boolean) {
export async function getOpportunityOverview(keyword?: string, stage?: string, includeDetails = true, limit?: number, archived?: boolean, excludeStageCodes?: string[], stageCodes?: string[]) {
const params = new URLSearchParams();
if (keyword && keyword.trim()) {
params.set("keyword", keyword.trim());
@ -1629,12 +1629,18 @@ export async function getOpportunityOverview(keyword?: string, stage?: string, i
params.set("stage", stage.trim());
}
params.set("includeDetails", String(includeDetails));
if (limit !== undefined) {
if (limit != null) {
params.set("limit", String(limit));
}
if (archived !== undefined) {
params.set("archived", String(archived));
}
if (excludeStageCodes && excludeStageCodes.length > 0) {
excludeStageCodes.forEach((code) => params.append("excludeStageCodes", code));
}
if (stageCodes && stageCodes.length > 0) {
stageCodes.forEach((code) => params.append("stageCodes", code));
}
const query = params.toString();
return request<OpportunityOverview>(`/api/opportunities/overview${query ? `?${query}` : ""}`, undefined, true);
}
@ -1685,7 +1691,7 @@ export async function getExpansionOverview(keyword?: string, includeDetails = tr
params.set("keyword", keyword.trim());
}
params.set("includeDetails", String(includeDetails));
if (limit !== undefined) {
if (limit != null) {
params.set("limit", String(limit));
}
const query = params.toString();
@ -1697,7 +1703,7 @@ export async function getOpportunityExpansionOptions(params?: { keyword?: string
if (params?.keyword && params.keyword.trim()) {
searchParams.set("keyword", params.keyword.trim());
}
if (params?.limit !== undefined) {
if (params?.limit != null) {
searchParams.set("limit", String(params.limit));
}
const query = searchParams.toString();

View File

@ -87,7 +87,7 @@ const COMPETITOR_OPTIONS = [
type CompetitorOption = (typeof COMPETITOR_OPTIONS)[number];
type OperatorMode = "none" | "h3c" | "channel" | "both";
type OpportunityArchiveTab = "active" | "archived";
type OpportunityArchiveTab = "active" | "archived" | "lost";
type OpportunityLocationState = { selectedId?: CrmId; archiveTab?: OpportunityArchiveTab } | null;
type OpportunityExportFilters = {
keyword?: string;
@ -444,19 +444,45 @@ function getOpportunityDictOptionLabel(option?: OpportunityDictOption) {
return option?.label?.trim() || option?.value?.trim() || "";
}
function isLostOpportunityStageOption(option?: OpportunityDictOption) {
const value = getOpportunityDictOptionValue(option).toLowerCase();
const label = getOpportunityDictOptionLabel(option).toLowerCase();
function isLostOpportunityStageOption(option?: { value?: string; label?: string; stageCode?: string; stage?: string }) {
const value = (option?.value?.trim() || option?.label?.trim() || option?.stageCode?.trim() || option?.stage?.trim() || "").toLowerCase();
const label = (option?.label?.trim() || option?.value?.trim() || option?.stage?.trim() || option?.stageCode?.trim() || "").toLowerCase();
return value === "lost" || value.includes("丢单") || label.includes("丢单") || value.includes("放弃") || label.includes("放弃");
}
function getDefaultOpportunityExportStageCodes(options: OpportunityDictOption[]) {
function getLostStageCodes(stageOptions: OpportunityDictOption[]) {
return stageOptions
.filter(isLostOpportunityStageOption)
.map((opt) => getOpportunityDictOptionValue(opt))
.filter(Boolean);
}
function getDefaultOpportunityExportStageCodes(options: OpportunityDictOption[], archiveTab?: OpportunityArchiveTab) {
const orderedValues = options.map(getOpportunityDictOptionValue).filter(Boolean);
if (archiveTab === "lost") {
const lostValues = options.filter(isLostOpportunityStageOption).map(getOpportunityDictOptionValue).filter(Boolean);
return normalizeOpportunityMultiSelectValues(lostValues.length > 0 ? lostValues : orderedValues);
}
const preferredValues = options.filter((option) => !isLostOpportunityStageOption(option)).map(getOpportunityDictOptionValue).filter(Boolean);
return normalizeOpportunityMultiSelectValues(preferredValues.length > 0 ? preferredValues : orderedValues);
}
function applyDefaultOpportunityExportStageFilters(filters: OpportunityExportFilters, stageOptions: OpportunityDictOption[]) {
function applyDefaultOpportunityExportStageFilters(filters: OpportunityExportFilters, stageOptions: OpportunityDictOption[], archiveTab?: OpportunityArchiveTab) {
if (archiveTab === "lost") {
if (filters.stageCodes === undefined) {
const lostValues = stageOptions.filter(isLostOpportunityStageOption).map(getOpportunityDictOptionValue).filter(Boolean);
return {
...filters,
stage: undefined,
stageCodes: normalizeOpportunityMultiSelectValues(lostValues),
};
}
return {
...filters,
stage: undefined,
stageCodes: normalizeOpportunityMultiSelectValues(filters.stageCodes),
};
}
if (filters.stageCodes !== undefined) {
return {
...filters,
@ -481,7 +507,7 @@ function applyDefaultOpportunityExportStageFilters(filters: OpportunityExportFil
return {
...filters,
stage: undefined,
stageCodes: getDefaultOpportunityExportStageCodes(stageOptions),
stageCodes: getDefaultOpportunityExportStageCodes(stageOptions, archiveTab),
};
}
@ -970,11 +996,12 @@ function OpportunityExportFilterModal({
onClose: () => void;
onConfirm: (filters: OpportunityExportFilters) => void;
}) {
const normalizedInitialFilters = applyDefaultOpportunityExportStageFilters(initialFilters, stageOptions);
const normalizedInitialFilters = applyDefaultOpportunityExportStageFilters(initialFilters, stageOptions, archiveTab);
const [draftFilters, setDraftFilters] = useState<OpportunityExportFilters>(normalizedInitialFilters);
const selectedFields = resolveSelectedOpportunityFields(draftFilters.selectedFields);
const defaultStageCodes = getDefaultOpportunityExportStageCodes(stageOptions);
const defaultStageCodes = getDefaultOpportunityExportStageCodes(stageOptions, archiveTab);
const exportStageOptions = stageOptions
.filter((option) => archiveTab === "lost" || archiveTab === "archived" || !isLostOpportunityStageOption(option))
.map((option) => {
const value = getOpportunityDictOptionValue(option);
const label = getOpportunityDictOptionLabel(option);
@ -984,7 +1011,7 @@ function OpportunityExportFilterModal({
const selectedStageCodes = normalizeOpportunityMultiSelectValues(draftFilters.stageCodes);
useEffect(() => {
setDraftFilters(applyDefaultOpportunityExportStageFilters(initialFilters, stageOptions));
setDraftFilters(applyDefaultOpportunityExportStageFilters(initialFilters, stageOptions, archiveTab));
}, [initialFilters, stageOptions]);
const hasDraftFilters = Boolean(
@ -997,7 +1024,7 @@ function OpportunityExportFilterModal({
|| draftFilters.operatorName
|| draftFilters.hasSalesExpansion
|| draftFilters.hasChannelExpansion,
) || !areSameOpportunityStringSets(selectedStageCodes, defaultStageCodes)
) || (archiveTab !== "lost" && !areSameOpportunityStringSets(selectedStageCodes, defaultStageCodes))
|| JSON.stringify(selectedFields) !== JSON.stringify(defaultOpportunityExportFields);
const hasSelectedFields = selectedFields.length > 0;
const handleFilterChange = (key: keyof OpportunityExportFilters, value: string) => {
@ -1044,15 +1071,15 @@ function OpportunityExportFilterModal({
return (
<ModalShell
title={`导出${archiveTab === "active" ? "未签单" : "已签单"}商机`}
subtitle="选择条件后导出 Excel不填条件则导出当前签单页签下的全部可见商机。"
title={`导出${archiveTab === "active" ? "未签单" : archiveTab === "lost" ? "已丢单" : "已签单"}商机`}
subtitle="选择条件后导出 Excel不填条件则导出当前页签下的全部可见商机。"
onClose={onClose}
footer={(
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-between">
<button
type="button"
onClick={() => setDraftFilters((current) => ({
...applyDefaultOpportunityExportStageFilters({}, stageOptions),
...applyDefaultOpportunityExportStageFilters({}, stageOptions, archiveTab),
selectedFields: resolveSelectedOpportunityFields(current.selectedFields),
}))}
disabled={!hasDraftFilters}
@ -1099,6 +1126,7 @@ function OpportunityExportFilterModal({
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
/>
</label>
{archiveTab !== "lost" ? (
<div className="space-y-3 rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-900/40 sm:col-span-2">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
@ -1157,6 +1185,7 @@ function OpportunityExportFilterModal({
<p className="text-xs text-slate-500 dark:text-slate-400"></p>
)}
</div>
) : null}
<label className="space-y-1.5">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<AdaptiveSelect
@ -1991,7 +2020,12 @@ export default function Opportunities() {
async function load() {
try {
const data = await getOpportunityOverview(keyword, filter, false, LIST_PAGE_SIZE + 1, archiveTab === "archived");
const lostStageCodes = getLostStageCodes(stageOptions);
const effectiveStageCodes = archiveTab === "lost" ? lostStageCodes : undefined;
const effectiveStage = archiveTab === "lost" ? undefined : filter;
const effectiveArchived = archiveTab === "archived";
const effectiveExcludeStageCodes = archiveTab === "active" ? lostStageCodes : undefined;
const data = await getOpportunityOverview(keyword, effectiveStage, false, LIST_PAGE_SIZE + 1, effectiveArchived, effectiveExcludeStageCodes, effectiveStageCodes);
if (!cancelled) {
setItems(data.items ?? []);
setSelectedItem(null);
@ -2008,16 +2042,20 @@ export default function Opportunities() {
return () => {
cancelled = true;
};
}, [keyword, filter, archiveTab]);
}, [keyword, filter, archiveTab, stageOptions]);
useEffect(() => {
setVisibleItemCount(LIST_PAGE_SIZE);
}, [keyword, filter, archiveTab]);
const lostCodes = getLostStageCodes(stageOptions);
if (archiveTab === "active" && lostCodes.includes(filter)) {
setFilter("全部");
}
}, [keyword, filter, archiveTab, stageOptions]);
useEffect(() => {
const requestedState = location.state as OpportunityLocationState;
const requestedArchiveTab = requestedState?.archiveTab;
if (requestedArchiveTab === "active" || requestedArchiveTab === "archived") {
if (requestedArchiveTab === "active" || requestedArchiveTab === "archived" || requestedArchiveTab === "lost") {
setArchiveTab(requestedArchiveTab);
}
}, [location.state]);
@ -2219,10 +2257,14 @@ export default function Opportunities() {
}
setExportFilters((current) => (
current.stageCodes === undefined
? applyDefaultOpportunityExportStageFilters(current, stageOptions)
? applyDefaultOpportunityExportStageFilters(current, stageOptions, archiveTab)
: current
));
}, [stageOptions]);
}, [stageOptions, archiveTab]);
useEffect(() => {
setExportFilters((current) => ({ ...current, stageCodes: undefined }));
}, [archiveTab]);
useEffect(() => {
if (!opportunityTypeOptions.length) {
@ -2319,7 +2361,10 @@ export default function Opportunities() {
const detailItem = selectedItem ? (selectedItemDetail ?? selectedItem) : null;
const followUpRecords: OpportunityFollowUp[] = detailItem?.followUps ?? [];
const filteredItems = items.filter((item) => (archiveTab === "active" ? !item.archived : Boolean(item.archived)));
const filteredItems = items.filter((item) => {
if (archiveTab === "lost") return !item.archived && isLostOpportunityStageOption(item);
return archiveTab === "active" ? !item.archived && !isLostOpportunityStageOption(item) : Boolean(item.archived);
});
const visibleItems = filteredItems.slice(0, visibleItemCount);
const hasMoreItems = visibleItems.length < filteredItems.length;
@ -2330,7 +2375,12 @@ export default function Opportunities() {
loadingMoreRef.current = true;
const nextVisibleCount = visibleItemCount + LIST_PAGE_SIZE;
try {
const data = await getOpportunityOverview(keyword, filter, false, nextVisibleCount + 1, archiveTab === "archived");
const lostStageCodes = getLostStageCodes(stageOptions);
const effectiveStageCodes = archiveTab === "lost" ? lostStageCodes : undefined;
const effectiveStage = archiveTab === "lost" ? undefined : filter;
const effectiveArchived = archiveTab === "archived";
const effectiveExcludeStageCodes = archiveTab === "active" ? lostStageCodes : undefined;
const data = await getOpportunityOverview(keyword, effectiveStage, false, nextVisibleCount + 1, effectiveArchived, effectiveExcludeStageCodes, effectiveStageCodes);
setItems(data.items ?? []);
setVisibleItemCount(nextVisibleCount);
} finally {
@ -2355,7 +2405,9 @@ export default function Opportunities() {
}, [hasMoreItems, visibleItemCount, filteredItems.length, keyword, filter, archiveTab]);
const stageFilterOptions = [
{ label: "全部", value: "全部" },
...stageOptions.map((item) => ({ label: item.label || item.value || "", value: item.value || "" })),
...stageOptions
.filter((item) => archiveTab === "lost" || archiveTab === "archived" || !isLostOpportunityStageOption(item))
.map((item) => ({ label: item.label || item.value || "", value: item.value || "" })),
].filter((item) => item.value);
const normalizedProjectLocation = form.projectLocation?.trim() || "";
const normalizedProjectOwnershipLocation = form.projectOwnershipLocation?.trim() || "";
@ -2515,20 +2567,28 @@ export default function Opportunities() {
return;
}
const normalizedFilters = applyDefaultOpportunityExportStageFilters(filters, stageOptions);
const normalizedFilters = applyDefaultOpportunityExportStageFilters(filters, stageOptions, archiveTab);
setExporting(true);
setExportError("");
setExportFilters(normalizedFilters);
persistOpportunityExportPreferences(normalizedFilters);
try {
const overview = await getOpportunityOverview("", "全部");
const lostStageCodes = getLostStageCodes(stageOptions);
const exportStageCodes = archiveTab === "lost" ? lostStageCodes : undefined;
const exportArchived = archiveTab === "archived";
const exportExcludeStageCodes = archiveTab === "active" ? lostStageCodes : undefined;
const overview = await getOpportunityOverview("", undefined, false, null, exportArchived, exportExcludeStageCodes, exportStageCodes);
const exportItems = (overview.items ?? [])
.filter((item) => (archiveTab === "active" ? !item.archived : Boolean(item.archived)))
.filter((item) => {
if (archiveTab === "lost") return !item.archived && isLostOpportunityStageOption(item);
return archiveTab === "active" ? !item.archived && !isLostOpportunityStageOption(item) : Boolean(item.archived);
})
.filter((item) => matchesOpportunityExportFilters(item, normalizedFilters, effectiveConfidenceOptions));
const selectedFieldKeys = resolveSelectedOpportunityFields(normalizedFilters.selectedFields);
const exportTabLabel = archiveTab === "active" ? "未签单" : archiveTab === "lost" ? "已丢单" : "已签单";
if (exportItems.length <= 0) {
throw new Error(`当前筛选条件下暂无可导出的${archiveTab === "active" ? "未签单" : "已签单"}商机`);
throw new Error(`当前筛选条件下暂无可导出的${exportTabLabel}商机`);
}
if (selectedFieldKeys.length <= 0) {
throw new Error("请至少选择一个导出字段");
@ -2615,7 +2675,7 @@ export default function Opportunities() {
});
const buffer = await workbook.xlsx.writeBuffer();
const filename = `商机储备_${archiveTab === "active" ? "未签单" : "已签单"}_${formatOpportunityExportFilenameTime()}.xlsx`;
const filename = `商机储备_${exportTabLabel}_${formatOpportunityExportFilenameTime()}.xlsx`;
downloadOpportunityExcelFile(filename, buffer);
setExportFilterOpen(false);
} catch (exportErr) {
@ -2839,7 +2899,12 @@ export default function Opportunities() {
};
const reload = async (preferredSelectedId?: CrmId) => {
const data = await getOpportunityOverview(keyword, filter, false);
const lostStageCodes = getLostStageCodes(stageOptions);
const effectiveStageCodes = archiveTab === "lost" ? lostStageCodes : undefined;
const effectiveStage = archiveTab === "lost" ? undefined : filter;
const effectiveArchived = archiveTab === "archived";
const effectiveExcludeStageCodes = archiveTab === "active" ? lostStageCodes : undefined;
const data = await getOpportunityOverview(keyword, effectiveStage, false, LIST_PAGE_SIZE + 1, effectiveArchived, effectiveExcludeStageCodes, effectiveStageCodes);
const nextItems = data.items ?? [];
setItems(nextItems);
if (preferredSelectedId) {
@ -3046,7 +3111,7 @@ export default function Opportunities() {
const renderEmpty = () => (
<div className="crm-empty-panel">
{archiveTab === "active" ? "暂无未签单商机,先新增一条试试。" : "暂无已签单商机。"}
{archiveTab === "active" ? "暂无未签单商机,先新增一条试试。" : archiveTab === "lost" ? "暂无已丢单商机。" : "暂无已签单商机。"}
</div>
);
@ -3107,6 +3172,19 @@ export default function Opportunities() {
>
</button>
<button
onClick={() => {
setArchiveTab("lost");
setExportError("");
}}
className={`flex-1 rounded-lg py-2 text-sm font-medium transition-colors duration-200 ${
archiveTab === "lost"
? "bg-white text-violet-600 shadow-sm dark:bg-slate-800 dark:text-violet-400"
: "text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"
}`}
>
</button>
</div>
<div className="flex items-center gap-3">
@ -3123,6 +3201,7 @@ export default function Opportunities() {
className="crm-input-text w-full rounded-xl border border-slate-200 bg-white py-2.5 pl-10 pr-4 text-slate-900 outline-none transition-all focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50 dark:text-white"
/>
</div>
{archiveTab !== "lost" ? (
<button
type="button"
onClick={() => setStageFilterOpen(true)}
@ -3137,6 +3216,7 @@ export default function Opportunities() {
<ListFilter className="h-5 w-5" />
{filter !== "全部" ? <span className="absolute right-2 top-2 h-2 w-2 rounded-full bg-violet-500" /> : null}
</button>
) : null}
</div>
{exportError ? <div className="crm-alert crm-alert-error">{exportError}</div> : null}
@ -3238,7 +3318,7 @@ export default function Opportunities() {
<AnimatePresence>
{exportFilterOpen ? (
<OpportunityExportFilterModal
initialFilters={applyDefaultOpportunityExportStageFilters(exportFilters, stageOptions)}
initialFilters={applyDefaultOpportunityExportStageFilters(exportFilters, stageOptions, archiveTab)}
exporting={exporting}
exportError={exportError}
archiveTab={archiveTab}