修改商机字段展示与展示标签

main
kangwenjing 2026-04-09 15:35:10 +08:00
parent 9733de9f22
commit 2709171839
9 changed files with 211 additions and 54 deletions

View File

@ -91,6 +91,7 @@
<workItem from="1775198320186" duration="1451000" /> <workItem from="1775198320186" duration="1451000" />
<workItem from="1775200047785" duration="3902000" /> <workItem from="1775200047785" duration="3902000" />
<workItem from="1775541962012" duration="805000" /> <workItem from="1775541962012" duration="805000" />
<workItem from="1775611219527" duration="1273000" />
</task> </task>
<task id="LOCAL-00001" summary="修改定位信息 0323"> <task id="LOCAL-00001" summary="修改定位信息 0323">
<option name="closed" value="true" /> <option name="closed" value="true" />

View File

@ -8,6 +8,7 @@ public class OpportunityMetaDTO {
private List<OpportunityDictOptionDTO> operatorOptions; private List<OpportunityDictOptionDTO> operatorOptions;
private List<OpportunityDictOptionDTO> projectLocationOptions; private List<OpportunityDictOptionDTO> projectLocationOptions;
private List<OpportunityDictOptionDTO> opportunityTypeOptions; private List<OpportunityDictOptionDTO> opportunityTypeOptions;
private List<OpportunityDictOptionDTO> confidenceOptions;
public OpportunityMetaDTO() { public OpportunityMetaDTO() {
} }
@ -16,11 +17,13 @@ public class OpportunityMetaDTO {
List<OpportunityDictOptionDTO> stageOptions, List<OpportunityDictOptionDTO> stageOptions,
List<OpportunityDictOptionDTO> operatorOptions, List<OpportunityDictOptionDTO> operatorOptions,
List<OpportunityDictOptionDTO> projectLocationOptions, List<OpportunityDictOptionDTO> projectLocationOptions,
List<OpportunityDictOptionDTO> opportunityTypeOptions) { List<OpportunityDictOptionDTO> opportunityTypeOptions,
List<OpportunityDictOptionDTO> confidenceOptions) {
this.stageOptions = stageOptions; this.stageOptions = stageOptions;
this.operatorOptions = operatorOptions; this.operatorOptions = operatorOptions;
this.projectLocationOptions = projectLocationOptions; this.projectLocationOptions = projectLocationOptions;
this.opportunityTypeOptions = opportunityTypeOptions; this.opportunityTypeOptions = opportunityTypeOptions;
this.confidenceOptions = confidenceOptions;
} }
public List<OpportunityDictOptionDTO> getStageOptions() { public List<OpportunityDictOptionDTO> getStageOptions() {
@ -54,4 +57,12 @@ public class OpportunityMetaDTO {
public void setOpportunityTypeOptions(List<OpportunityDictOptionDTO> opportunityTypeOptions) { public void setOpportunityTypeOptions(List<OpportunityDictOptionDTO> opportunityTypeOptions) {
this.opportunityTypeOptions = opportunityTypeOptions; this.opportunityTypeOptions = opportunityTypeOptions;
} }
public List<OpportunityDictOptionDTO> getConfidenceOptions() {
return confidenceOptions;
}
public void setConfidenceOptions(List<OpportunityDictOptionDTO> confidenceOptions) {
this.confidenceOptions = confidenceOptions;
}
} }

View File

@ -25,6 +25,10 @@ public interface OpportunityMapper {
@Param("typeCode") String typeCode, @Param("typeCode") String typeCode,
@Param("itemValue") String itemValue); @Param("itemValue") String itemValue);
String selectDictValueByLabel(
@Param("typeCode") String typeCode,
@Param("itemLabel") String itemLabel);
@DataScope(tableAlias = "o", ownerColumn = "owner_user_id") @DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
List<OpportunityItemDTO> selectOpportunities( List<OpportunityItemDTO> selectOpportunities(
@Param("userId") Long userId, @Param("userId") Long userId,

View File

@ -35,6 +35,7 @@ public class OpportunityServiceImpl implements OpportunityService {
private static final String STAGE_TYPE_CODE = "sj_xmjd"; private static final String STAGE_TYPE_CODE = "sj_xmjd";
private static final String OPERATOR_TYPE_CODE = "sj_yzf"; private static final String OPERATOR_TYPE_CODE = "sj_yzf";
private static final String OPPORTUNITY_TYPE_CODE = "sj_jslx"; private static final String OPPORTUNITY_TYPE_CODE = "sj_jslx";
private static final String CONFIDENCE_TYPE_CODE = "sj_xmbwd";
private static final Logger log = LoggerFactory.getLogger(OpportunityServiceImpl.class); private static final Logger log = LoggerFactory.getLogger(OpportunityServiceImpl.class);
private final OpportunityMapper opportunityMapper; private final OpportunityMapper opportunityMapper;
@ -48,11 +49,13 @@ public class OpportunityServiceImpl implements OpportunityService {
@Override @Override
public OpportunityMetaDTO getMeta() { public OpportunityMetaDTO getMeta() {
List<OpportunityDictOptionDTO> opportunityTypeOptions = opportunityMapper.selectDictItems(OPPORTUNITY_TYPE_CODE); List<OpportunityDictOptionDTO> opportunityTypeOptions = opportunityMapper.selectDictItems(OPPORTUNITY_TYPE_CODE);
List<OpportunityDictOptionDTO> confidenceOptions = opportunityMapper.selectDictItems(CONFIDENCE_TYPE_CODE);
return new OpportunityMetaDTO( return new OpportunityMetaDTO(
opportunityMapper.selectDictItems(STAGE_TYPE_CODE), opportunityMapper.selectDictItems(STAGE_TYPE_CODE),
opportunityMapper.selectDictItems(OPERATOR_TYPE_CODE), opportunityMapper.selectDictItems(OPERATOR_TYPE_CODE),
opportunityMapper.selectProvinceAreaOptions(), opportunityMapper.selectProvinceAreaOptions(),
opportunityTypeOptions.isEmpty() ? buildDefaultOpportunityTypeOptions() : opportunityTypeOptions); opportunityTypeOptions.isEmpty() ? buildDefaultOpportunityTypeOptions() : opportunityTypeOptions,
confidenceOptions.isEmpty() ? buildDefaultConfidenceOptions() : confidenceOptions);
} }
private List<OpportunityDictOptionDTO> buildDefaultOpportunityTypeOptions() { private List<OpportunityDictOptionDTO> buildDefaultOpportunityTypeOptions() {
@ -62,6 +65,13 @@ public class OpportunityServiceImpl implements OpportunityService {
buildDictOption("替换", "替换")); buildDictOption("替换", "替换"));
} }
private List<OpportunityDictOptionDTO> buildDefaultConfidenceOptions() {
return List.of(
buildDictOption("A", "A"),
buildDictOption("B", "B"),
buildDictOption("C", "C"));
}
private OpportunityDictOptionDTO buildDictOption(String label, String value) { private OpportunityDictOptionDTO buildDictOption(String label, String value) {
OpportunityDictOptionDTO option = new OpportunityDictOptionDTO(); OpportunityDictOptionDTO option = new OpportunityDictOptionDTO();
option.setLabel(label); option.setLabel(label);
@ -488,13 +498,44 @@ public class OpportunityServiceImpl implements OpportunityService {
throw new BusinessException(blankMessage); throw new BusinessException(blankMessage);
} }
String upper = normalized.toUpperCase(); String dictValue = opportunityMapper.selectDictValueByLabel(CONFIDENCE_TYPE_CODE, normalized);
if (!isBlank(dictValue)) {
return dictValue;
}
String dictLabel = opportunityMapper.selectDictLabel(CONFIDENCE_TYPE_CODE, normalized);
if (!isBlank(dictLabel)) {
return normalized;
}
String legacyGrade = normalizeLegacyConfidenceGrade(normalized);
if (legacyGrade != null) {
String legacyDictValue = opportunityMapper.selectDictValueByLabel(CONFIDENCE_TYPE_CODE, legacyGrade);
if (!isBlank(legacyDictValue)) {
return legacyDictValue;
}
String legacyDictLabel = opportunityMapper.selectDictLabel(CONFIDENCE_TYPE_CODE, legacyGrade);
if (!isBlank(legacyDictLabel)) {
return legacyGrade;
}
return legacyGrade;
}
throw new BusinessException("项目把握度无效: " + normalized);
}
private String normalizeLegacyConfidenceGrade(String value) {
if (isBlank(value)) {
return null;
}
String upper = value.trim().toUpperCase();
if ("A".equals(upper) || "B".equals(upper) || "C".equals(upper)) { if ("A".equals(upper) || "B".equals(upper) || "C".equals(upper)) {
return upper; return upper;
} }
if (normalized.matches("\\d+(\\.\\d+)?")) { if (upper.matches("\\d+(\\.\\d+)?")) {
double numericValue = Double.parseDouble(normalized); double numericValue = Double.parseDouble(upper);
if (numericValue >= 80) { if (numericValue >= 80) {
return "A"; return "A";
} }
@ -503,8 +544,7 @@ public class OpportunityServiceImpl implements OpportunityService {
} }
return "C"; return "C";
} }
return null;
throw new BusinessException("项目把握度仅支持A、B、C");
} }
private BigDecimal requirePositiveAmount(BigDecimal value, String message) { private BigDecimal requirePositiveAmount(BigDecimal value, String message) {

View File

@ -72,7 +72,7 @@ unisbase:
access-token-safety-seconds: 120 access-token-safety-seconds: 120
oms: oms:
enabled: ${OMS_ENABLED:true} enabled: ${OMS_ENABLED:true}
base-url: ${OMS_BASE_URL:http://121.41.238.146:28080} base-url: ${OMS_BASE_URL:https://oms.unissense.top}
api-key: ${OMS_API_KEY:c7f858d0-30b8-4b7f-9ea1-0ccf5ceb1c54} api-key: ${OMS_API_KEY:c7f858d0-30b8-4b7f-9ea1-0ccf5ceb1c54}
api-key-header: ${OMS_API_KEY_HEADER:apiKey} api-key-header: ${OMS_API_KEY_HEADER:apiKey}
user-info-path: ${OMS_USER_INFO_PATH:/api/v1/user/info} user-info-path: ${OMS_USER_INFO_PATH:/api/v1/user/info}

View File

@ -35,6 +35,17 @@
limit 1 limit 1
</select> </select>
<select id="selectDictValueByLabel" resultType="java.lang.String">
select item_value
from sys_dict_item
where type_code = #{typeCode}
and item_label = #{itemLabel}
and status = 1
and coalesce(is_deleted, 0) = 0
order by sort_order asc nulls last, dict_item_id asc
limit 1
</select>
<select id="selectOpportunities" resultType="com.unis.crm.dto.opportunity.OpportunityItemDTO"> <select id="selectOpportunities" resultType="com.unis.crm.dto.opportunity.OpportunityItemDTO">
select select
o.id, o.id,

View File

@ -7,7 +7,7 @@ import { AdaptiveSelect } from "@/components/AdaptiveSelect";
import { useIsWecomBrowser } from "@/hooks/useIsWecomBrowser"; import { useIsWecomBrowser } from "@/hooks/useIsWecomBrowser";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const CONFIDENCE_OPTIONS = [ const FALLBACK_CONFIDENCE_OPTIONS = [
{ value: "A", label: "A" }, { value: "A", label: "A" },
{ value: "B", label: "B" }, { value: "B", label: "B" },
{ value: "C", label: "C" }, { value: "C", label: "C" },
@ -19,8 +19,6 @@ const FALLBACK_OPPORTUNITY_TYPE_OPTIONS = [
{ value: "替换", label: "替换" }, { value: "替换", label: "替换" },
] as const; ] as const;
type ConfidenceGrade = (typeof CONFIDENCE_OPTIONS)[number]["value"];
const COMPETITOR_OPTIONS = [ const COMPETITOR_OPTIONS = [
"深信服", "深信服",
"锐捷", "锐捷",
@ -159,7 +157,11 @@ function matchesOpportunityRelationFilter(hasRelation: boolean, filterValue?: st
return true; return true;
} }
function matchesOpportunityExportFilters(item: OpportunityItem, filters: OpportunityExportFilters) { function matchesOpportunityExportFilters(
item: OpportunityItem,
filters: OpportunityExportFilters,
confidenceOptions: OpportunityDictOption[],
) {
const keywordText = [ const keywordText = [
item.code, item.code,
item.name, item.name,
@ -188,7 +190,7 @@ function matchesOpportunityExportFilters(item: OpportunityItem, filters: Opportu
if (!matchesOpportunityTextFilter([item.stageCode, item.stage], filters.stage)) { if (!matchesOpportunityTextFilter([item.stageCode, item.stage], filters.stage)) {
return false; return false;
} }
if (filters.confidence && normalizeConfidenceGrade(item.confidence) !== filters.confidence) { if (filters.confidence && normalizeConfidenceValue(item.confidence, confidenceOptions) !== filters.confidence) {
return false; return false;
} }
if (!matchesOpportunityTextFilter([item.projectLocation], filters.projectLocation)) { if (!matchesOpportunityTextFilter([item.projectLocation], filters.projectLocation)) {
@ -206,14 +208,14 @@ function matchesOpportunityExportFilters(item: OpportunityItem, filters: Opportu
return matchesOpportunityRelationFilter(Boolean(item.channelExpansionId || item.channelExpansionName), filters.hasChannelExpansion); return matchesOpportunityRelationFilter(Boolean(item.channelExpansionId || item.channelExpansionName), filters.hasChannelExpansion);
} }
function toFormFromItem(item: OpportunityItem): CreateOpportunityPayload { function toFormFromItem(item: OpportunityItem, confidenceOptions: OpportunityDictOption[]): CreateOpportunityPayload {
return { return {
opportunityName: item.name || "", opportunityName: item.name || "",
customerName: item.client || "", customerName: item.client || "",
projectLocation: item.projectLocation || "", projectLocation: item.projectLocation || "",
amount: item.amount || 0, amount: item.amount || 0,
expectedCloseDate: item.date || "", expectedCloseDate: item.date || "",
confidencePct: normalizeConfidenceGrade(item.confidence), confidencePct: normalizeConfidenceValue(item.confidence, confidenceOptions),
stage: item.stageCode || item.stage || "", stage: item.stageCode || item.stage || "",
opportunityType: item.type || "", opportunityType: item.type || "",
productType: item.product || "VDI云桌面", productType: item.product || "VDI云桌面",
@ -226,9 +228,9 @@ function toFormFromItem(item: OpportunityItem): CreateOpportunityPayload {
}; };
} }
function normalizeConfidenceGrade(value?: string | number | null): ConfidenceGrade { function normalizeLegacyConfidenceGrade(value?: string | number | null) {
if (value === null || value === undefined) { if (value === null || value === undefined) {
return "C"; return "";
} }
if (typeof value === "number") { if (typeof value === "number") {
@ -247,22 +249,62 @@ function normalizeConfidenceGrade(value?: string | number | null): ConfidenceGra
if (normalized === "60") { if (normalized === "60") {
return "B"; return "B";
} }
return "C"; return "";
} }
function getConfidenceOptionValue(score?: string | number | null) { function getEffectiveConfidenceOptions(confidenceOptions: OpportunityDictOption[]) {
return normalizeConfidenceGrade(score); return confidenceOptions.length > 0 ? confidenceOptions : [...FALLBACK_CONFIDENCE_OPTIONS];
} }
function getConfidenceLabel(score?: string | number | null) { function normalizeConfidenceValue(score: string | number | null | undefined, confidenceOptions: OpportunityDictOption[]) {
const matchedOption = CONFIDENCE_OPTIONS.find((item) => item.value === normalizeConfidenceGrade(score)); const rawValue = typeof score === "string" ? score.trim() : typeof score === "number" ? String(score) : "";
return matchedOption?.label || "C"; const options = getEffectiveConfidenceOptions(confidenceOptions);
if (!rawValue) {
return "";
} }
function getConfidenceBadgeClass(score?: string | number | null) { const matchedByValue = options.find((item) => (item.value || "").trim() === rawValue);
const normalizedGrade = normalizeConfidenceGrade(score); if (matchedByValue?.value) {
if (normalizedGrade === "A") return "crm-pill crm-pill-emerald"; return matchedByValue.value;
if (normalizedGrade === "B") return "crm-pill crm-pill-amber"; }
const matchedByLabel = options.find((item) => (item.label || "").trim() === rawValue);
if (matchedByLabel?.value) {
return matchedByLabel.value;
}
const legacyGrade = normalizeLegacyConfidenceGrade(score);
if (!legacyGrade) {
return rawValue;
}
const legacyValueMatch = options.find((item) => (item.value || "").trim().toUpperCase() === legacyGrade);
if (legacyValueMatch?.value) {
return legacyValueMatch.value;
}
const legacyLabelMatch = options.find((item) => (item.label || "").trim().toUpperCase() === legacyGrade);
if (legacyLabelMatch?.value) {
return legacyLabelMatch.value;
}
return legacyGrade;
}
function getConfidenceLabel(score: string | number | null | undefined, confidenceOptions: OpportunityDictOption[]) {
const normalizedValue = normalizeConfidenceValue(score, confidenceOptions);
const matchedOption = getEffectiveConfidenceOptions(confidenceOptions).find((item) => (item.value || "") === normalizedValue);
return matchedOption?.label || matchedOption?.value || normalizedValue || "无";
}
function getConfidenceBadgeClass(score: string | number | null | undefined, confidenceOptions: OpportunityDictOption[]) {
const options = getEffectiveConfidenceOptions(confidenceOptions);
const normalizedValue = normalizeConfidenceValue(score, confidenceOptions);
const matchedIndex = options.findIndex((item) => (item.value || "") === normalizedValue);
if (matchedIndex < 0) return "crm-pill crm-pill-rose";
if (matchedIndex === 0) return "crm-pill crm-pill-emerald";
if (matchedIndex === 1) return "crm-pill crm-pill-amber";
return "crm-pill crm-pill-rose"; return "crm-pill crm-pill-rose";
} }
@ -553,6 +595,7 @@ function OpportunityExportFilterModal({
exportError, exportError,
archiveTab, archiveTab,
stageOptions, stageOptions,
confidenceOptions,
projectLocationOptions, projectLocationOptions,
opportunityTypeOptions, opportunityTypeOptions,
operatorOptions, operatorOptions,
@ -564,6 +607,7 @@ function OpportunityExportFilterModal({
exportError: string; exportError: string;
archiveTab: OpportunityArchiveTab; archiveTab: OpportunityArchiveTab;
stageOptions: OpportunityDictOption[]; stageOptions: OpportunityDictOption[];
confidenceOptions: OpportunityDictOption[];
projectLocationOptions: OpportunityDictOption[]; projectLocationOptions: OpportunityDictOption[];
opportunityTypeOptions: OpportunityDictOption[]; opportunityTypeOptions: OpportunityDictOption[];
operatorOptions: OpportunityDictOption[]; operatorOptions: OpportunityDictOption[];
@ -673,7 +717,10 @@ function OpportunityExportFilterModal({
value={draftFilters.confidence ?? ""} value={draftFilters.confidence ?? ""}
options={[ options={[
{ value: "", label: "全部把握度" }, { value: "", label: "全部把握度" },
...CONFIDENCE_OPTIONS.map((option) => ({ value: option.value, label: option.label })), ...getEffectiveConfidenceOptions(confidenceOptions).map((option) => ({
value: option.value || "",
label: option.label || option.value || "",
})),
]} ]}
placeholder="全部把握度" placeholder="全部把握度"
sheetTitle="选择项目把握度" sheetTitle="选择项目把握度"
@ -1315,6 +1362,7 @@ export default function Opportunities() {
const [operatorOptions, setOperatorOptions] = useState<OpportunityDictOption[]>([]); const [operatorOptions, setOperatorOptions] = useState<OpportunityDictOption[]>([]);
const [projectLocationOptions, setProjectLocationOptions] = useState<OpportunityDictOption[]>([]); const [projectLocationOptions, setProjectLocationOptions] = useState<OpportunityDictOption[]>([]);
const [opportunityTypeOptions, setOpportunityTypeOptions] = useState<OpportunityDictOption[]>([]); const [opportunityTypeOptions, setOpportunityTypeOptions] = useState<OpportunityDictOption[]>([]);
const [confidenceOptions, setConfidenceOptions] = useState<OpportunityDictOption[]>([]);
const [form, setForm] = useState<CreateOpportunityPayload>(defaultForm); const [form, setForm] = useState<CreateOpportunityPayload>(defaultForm);
const [pushPreSalesId, setPushPreSalesId] = useState<number | undefined>(undefined); const [pushPreSalesId, setPushPreSalesId] = useState<number | undefined>(undefined);
const [pushPreSalesName, setPushPreSalesName] = useState(""); const [pushPreSalesName, setPushPreSalesName] = useState("");
@ -1384,6 +1432,7 @@ export default function Opportunities() {
setOperatorOptions((data.operatorOptions ?? []).filter((item) => item.value)); setOperatorOptions((data.operatorOptions ?? []).filter((item) => item.value));
setProjectLocationOptions((data.projectLocationOptions ?? []).filter((item) => item.value)); setProjectLocationOptions((data.projectLocationOptions ?? []).filter((item) => item.value));
setOpportunityTypeOptions((data.opportunityTypeOptions ?? []).filter((item) => item.value)); setOpportunityTypeOptions((data.opportunityTypeOptions ?? []).filter((item) => item.value));
setConfidenceOptions((data.confidenceOptions ?? []).filter((item) => item.value));
} }
} catch { } catch {
if (!cancelled) { if (!cancelled) {
@ -1391,6 +1440,7 @@ export default function Opportunities() {
setOperatorOptions([]); setOperatorOptions([]);
setProjectLocationOptions([]); setProjectLocationOptions([]);
setOpportunityTypeOptions([]); setOpportunityTypeOptions([]);
setConfidenceOptions([]);
} }
} }
} }
@ -1419,6 +1469,21 @@ export default function Opportunities() {
setForm((current) => (current.opportunityType ? current : { ...current, opportunityType: defaultOpportunityType })); setForm((current) => (current.opportunityType ? current : { ...current, opportunityType: defaultOpportunityType }));
}, [opportunityTypeOptions]); }, [opportunityTypeOptions]);
useEffect(() => {
const effectiveConfidenceOptions = getEffectiveConfidenceOptions(confidenceOptions);
if (!effectiveConfidenceOptions.length) {
return;
}
const defaultConfidence = effectiveConfidenceOptions[0]?.value || "";
setForm((current) => ({
...current,
confidencePct: current.confidencePct
? normalizeConfidenceValue(current.confidencePct, effectiveConfidenceOptions)
: defaultConfidence,
}));
}, [confidenceOptions]);
const followUpRecords: OpportunityFollowUp[] = selectedItem?.followUps ?? []; const followUpRecords: OpportunityFollowUp[] = selectedItem?.followUps ?? [];
const visibleItems = items.filter((item) => (archiveTab === "active" ? !item.archived : Boolean(item.archived))); const visibleItems = items.filter((item) => (archiveTab === "active" ? !item.archived : Boolean(item.archived)));
const stageFilterOptions = [ const stageFilterOptions = [
@ -1443,6 +1508,11 @@ export default function Opportunities() {
const effectiveOpportunityTypeOptions = opportunityTypeOptions.length > 0 const effectiveOpportunityTypeOptions = opportunityTypeOptions.length > 0
? opportunityTypeOptions ? opportunityTypeOptions
: FALLBACK_OPPORTUNITY_TYPE_OPTIONS; : FALLBACK_OPPORTUNITY_TYPE_OPTIONS;
const effectiveConfidenceOptions = getEffectiveConfidenceOptions(confidenceOptions);
const buildEmptyForm = (): CreateOpportunityPayload => ({
...defaultForm,
confidencePct: effectiveConfidenceOptions[0]?.value || defaultForm.confidencePct,
});
const opportunityTypeSelectOptions = [ const opportunityTypeSelectOptions = [
{ value: "", label: "请选择" }, { value: "", label: "请选择" },
...effectiveOpportunityTypeOptions.map((item) => ({ ...effectiveOpportunityTypeOptions.map((item) => ({
@ -1510,9 +1580,11 @@ export default function Opportunities() {
}, [archiveTab, selectedItem, visibleItems]); }, [archiveTab, selectedItem, visibleItems]);
const getConfidenceColor = (score?: string | number | null) => { const getConfidenceColor = (score?: string | number | null) => {
const normalizedGrade = normalizeConfidenceGrade(score); const normalizedValue = normalizeConfidenceValue(score, effectiveConfidenceOptions);
if (normalizedGrade === "A") return "text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-500/10 border-emerald-200 dark:border-emerald-500/20"; const matchedIndex = effectiveConfidenceOptions.findIndex((item) => (item.value || "") === normalizedValue);
if (normalizedGrade === "B") return "text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-500/10 border-amber-200 dark:border-amber-500/20"; if (matchedIndex < 0) return "text-rose-600 dark:text-rose-400 bg-rose-50 dark:bg-rose-500/10 border-rose-200 dark:border-rose-500/20";
if (matchedIndex === 0) return "text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-500/10 border-emerald-200 dark:border-emerald-500/20";
if (matchedIndex === 1) return "text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-500/10 border-amber-200 dark:border-amber-500/20";
return "text-rose-600 dark:text-rose-400 bg-rose-50 dark:bg-rose-500/10 border-rose-200 dark:border-rose-500/20"; return "text-rose-600 dark:text-rose-400 bg-rose-50 dark:bg-rose-500/10 border-rose-200 dark:border-rose-500/20";
}; };
@ -1529,7 +1601,7 @@ export default function Opportunities() {
const overview = await getOpportunityOverview("", "全部"); const overview = await getOpportunityOverview("", "全部");
const exportItems = (overview.items ?? []) const exportItems = (overview.items ?? [])
.filter((item) => (archiveTab === "active" ? !item.archived : Boolean(item.archived))) .filter((item) => (archiveTab === "active" ? !item.archived : Boolean(item.archived)))
.filter((item) => matchesOpportunityExportFilters(item, filters)); .filter((item) => matchesOpportunityExportFilters(item, filters, effectiveConfidenceOptions));
if (exportItems.length <= 0) { if (exportItems.length <= 0) {
throw new Error(`当前筛选条件下暂无可导出的${archiveTab === "active" ? "未归档" : "已归档"}商机`); throw new Error(`当前筛选条件下暂无可导出的${archiveTab === "active" ? "未归档" : "已归档"}商机`);
} }
@ -1594,7 +1666,7 @@ export default function Opportunities() {
normalizeOpportunityExportText(item.type || "新建"), normalizeOpportunityExportText(item.type || "新建"),
normalizeOpportunityExportText(item.operatorName), normalizeOpportunityExportText(item.operatorName),
normalizeOpportunityExportText(item.stage), normalizeOpportunityExportText(item.stage),
getConfidenceLabel(item.confidence), getConfidenceLabel(item.confidence, effectiveConfidenceOptions),
item.amount === null || item.amount === undefined ? "" : `¥${formatAmount(item.amount)}`, item.amount === null || item.amount === undefined ? "" : `¥${formatAmount(item.amount)}`,
normalizeOpportunityExportText(item.date), normalizeOpportunityExportText(item.date),
normalizeOpportunityExportText(item.salesExpansionName || relatedSales?.name), normalizeOpportunityExportText(item.salesExpansionName || relatedSales?.name),
@ -1678,7 +1750,7 @@ export default function Opportunities() {
const handleOpenCreate = () => { const handleOpenCreate = () => {
setError(""); setError("");
setFieldErrors({}); setFieldErrors({});
setForm(defaultForm); setForm(buildEmptyForm());
setSelectedCompetitors([]); setSelectedCompetitors([]);
setCustomCompetitorName(""); setCustomCompetitorName("");
setCreateOpen(true); setCreateOpen(true);
@ -1690,7 +1762,7 @@ export default function Opportunities() {
setSubmitting(false); setSubmitting(false);
setError(""); setError("");
setFieldErrors({}); setFieldErrors({});
setForm(defaultForm); setForm(buildEmptyForm());
setSelectedCompetitors([]); setSelectedCompetitors([]);
setCustomCompetitorName(""); setCustomCompetitorName("");
}; };
@ -1739,7 +1811,7 @@ export default function Opportunities() {
} }
setError(""); setError("");
setFieldErrors({}); setFieldErrors({});
setForm(toFormFromItem(selectedItem)); setForm(toFormFromItem(selectedItem, effectiveConfidenceOptions));
const competitorState = parseCompetitorState(selectedItem.competitorName); const competitorState = parseCompetitorState(selectedItem.competitorName);
setSelectedCompetitors(competitorState.selections); setSelectedCompetitors(competitorState.selections);
setCustomCompetitorName(competitorState.customName); setCustomCompetitorName(competitorState.customName);
@ -1990,18 +2062,8 @@ export default function Opportunities() {
<h3 className="truncate text-base font-semibold text-slate-900 dark:text-white sm:text-lg">{opp.name || "未命名商机"}</h3> <h3 className="truncate text-base font-semibold text-slate-900 dark:text-white sm:text-lg">{opp.name || "未命名商机"}</h3>
<p className="mt-1 truncate text-xs leading-5 text-slate-500 dark:text-slate-400 sm:text-sm">{opp.code || "待生成"}</p> <p className="mt-1 truncate text-xs leading-5 text-slate-500 dark:text-slate-400 sm:text-sm">{opp.code || "待生成"}</p>
<div className="mt-2 flex flex-wrap items-center gap-2"> <div className="mt-2 flex flex-wrap items-center gap-2">
<span <span className={getConfidenceBadgeClass(opp.confidence, effectiveConfidenceOptions)}>
className={cn( {getConfidenceLabel(opp.confidence, effectiveConfidenceOptions)}
"rounded-full border px-2.5 py-1 text-[11px] font-semibold leading-none",
isOwnedByCurrentUser
? "border-emerald-200 bg-emerald-50 text-emerald-600 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-300"
: "border-slate-200 bg-slate-50 text-slate-500 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-300",
)}
>
{isOwnedByCurrentUser ? "我的" : "只读"}
</span>
<span className={getConfidenceBadgeClass(opp.confidence)}>
{getConfidenceLabel(opp.confidence)}
</span> </span>
<span className="crm-pill crm-pill-neutral"> <span className="crm-pill crm-pill-neutral">
{opp.stage || "初步沟通"} {opp.stage || "初步沟通"}
@ -2057,6 +2119,7 @@ export default function Opportunities() {
exportError={exportError} exportError={exportError}
archiveTab={archiveTab} archiveTab={archiveTab}
stageOptions={stageOptions} stageOptions={stageOptions}
confidenceOptions={effectiveConfidenceOptions}
projectLocationOptions={projectLocationOptions} projectLocationOptions={projectLocationOptions}
opportunityTypeOptions={opportunityTypeOptions} opportunityTypeOptions={opportunityTypeOptions}
operatorOptions={operatorOptions} operatorOptions={operatorOptions}
@ -2231,14 +2294,17 @@ export default function Opportunities() {
<label className="space-y-2"> <label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span> <span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<AdaptiveSelect <AdaptiveSelect
value={getConfidenceOptionValue(form.confidencePct)} value={normalizeConfidenceValue(form.confidencePct, effectiveConfidenceOptions)}
placeholder="请选择" placeholder="请选择"
sheetTitle="项目把握度" sheetTitle="项目把握度"
options={CONFIDENCE_OPTIONS.map((item) => ({ value: item.value, label: item.label }))} options={effectiveConfidenceOptions.map((item) => ({
value: item.value || "",
label: item.label || item.value || "",
}))}
className={cn( className={cn(
fieldErrors.confidencePct ? "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" : "", fieldErrors.confidencePct ? "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" : "",
)} )}
onChange={(value) => handleChange("confidencePct", normalizeConfidenceGrade(value))} onChange={(value) => handleChange("confidencePct", normalizeConfidenceValue(value, effectiveConfidenceOptions))}
/> />
{fieldErrors.confidencePct ? <p className="text-xs text-rose-500">{fieldErrors.confidencePct}</p> : null} {fieldErrors.confidencePct ? <p className="text-xs text-rose-500">{fieldErrors.confidencePct}</p> : null}
</label> </label>
@ -2461,7 +2527,7 @@ export default function Opportunities() {
<h3 className="line-clamp-1 text-lg font-bold leading-tight text-slate-900 dark:text-white sm:break-anywhere sm:line-clamp-none sm:text-xl">{selectedItem.name || "未命名商机"}</h3> <h3 className="line-clamp-1 text-lg font-bold leading-tight text-slate-900 dark:text-white sm:break-anywhere sm:line-clamp-none sm:text-xl">{selectedItem.name || "未命名商机"}</h3>
<div className="mt-3 flex flex-wrap gap-2"> <div className="mt-3 flex flex-wrap gap-2">
<span className="crm-pill crm-pill-neutral">{selectedItem.stage || "初步沟通"}</span> <span className="crm-pill crm-pill-neutral">{selectedItem.stage || "初步沟通"}</span>
<span className={`rounded-full px-2.5 py-1 text-xs font-medium ${getConfidenceColor(selectedItem.confidence)}`}> {getConfidenceLabel(selectedItem.confidence)}</span> <span className={`rounded-full px-2.5 py-1 text-xs font-medium ${getConfidenceColor(selectedItem.confidence)}`}> {getConfidenceLabel(selectedItem.confidence, effectiveConfidenceOptions)}</span>
</div> </div>
</div> </div>
@ -2478,7 +2544,7 @@ export default function Opportunities() {
<DetailItem label="售前" value={selectedPreSalesName} icon={<User className="h-3 w-3" />} /> <DetailItem label="售前" value={selectedPreSalesName} icon={<User className="h-3 w-3" />} />
<DetailItem label="预计金额(元)" value={<span className="text-rose-600 dark:text-rose-400">¥{formatAmount(selectedItem.amount)}</span>} icon={<DollarSign className="h-3 w-3" />} /> <DetailItem label="预计金额(元)" value={<span className="text-rose-600 dark:text-rose-400">¥{formatAmount(selectedItem.amount)}</span>} icon={<DollarSign className="h-3 w-3" />} />
<DetailItem label="预计下单时间" value={selectedItem.date || "待定"} icon={<Calendar className="h-3 w-3" />} /> <DetailItem label="预计下单时间" value={selectedItem.date || "待定"} icon={<Calendar className="h-3 w-3" />} />
<DetailItem label="项目把握度" value={getConfidenceLabel(selectedItem.confidence)} /> <DetailItem label="项目把握度" value={getConfidenceLabel(selectedItem.confidence, effectiveConfidenceOptions)} />
<DetailItem label="项目阶段" value={selectedItem.stage || "无"} icon={<Activity className="h-3 w-3" />} /> <DetailItem label="项目阶段" value={selectedItem.stage || "无"} icon={<Activity className="h-3 w-3" />} />
<DetailItem <DetailItem
label="是否已推送OMS" label="是否已推送OMS"

View File

@ -0,0 +1,21 @@
-- ensure opportunity confidence dictionary exists
begin;
insert into sys_dict_item (type_code, item_label, item_value, sort_order, status, is_deleted, remark)
select v.type_code, v.item_label, v.item_value, v.sort_order, 1, 0, '商机项目把握度'
from (
values
('sj_xmbwd', 'A', 'A', 1),
('sj_xmbwd', 'B', 'B', 2),
('sj_xmbwd', 'C', 'C', 3)
) as v(type_code, item_label, item_value, sort_order)
where not exists (
select 1
from sys_dict_item s
where s.type_code = v.type_code
and s.item_value = v.item_value
and coalesce(s.is_deleted, 0) = 0
);
commit;

View File

@ -943,7 +943,10 @@ FROM (
VALUES VALUES
('sj_jslx', '新建', '新建', 1), ('sj_jslx', '新建', '新建', 1),
('sj_jslx', '扩容', '扩容', 2), ('sj_jslx', '扩容', '扩容', 2),
('sj_jslx', '替换', '替换', 3) ('sj_jslx', '替换', '替换', 3),
('sj_xmbwd', 'A', 'A', 1),
('sj_xmbwd', 'B', 'B', 2),
('sj_xmbwd', 'C', 'C', 3)
) AS v(type_code, item_label, item_value, sort_order) ) AS v(type_code, item_label, item_value, sort_order)
WHERE NOT EXISTS ( WHERE NOT EXISTS (
SELECT 1 SELECT 1