工号与渠道名称校验重复

main
kangwenjing 2026-04-02 13:19:22 +08:00
parent efd3370519
commit 7080391f3a
20 changed files with 3027 additions and 137 deletions

View File

@ -0,0 +1,109 @@
package com.unis.crm.common;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import javax.sql.DataSource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
@Component
public class ExpansionSchemaInitializer implements ApplicationRunner {
private static final Logger log = LoggerFactory.getLogger(ExpansionSchemaInitializer.class);
private static final String CHANNEL_CODE_SEQUENCE = "crm_channel_expansion_code_seq";
private final DataSource dataSource;
public ExpansionSchemaInitializer(DataSource dataSource) {
this.dataSource = dataSource;
}
@Override
public void run(ApplicationArguments args) {
try (Connection connection = dataSource.getConnection()) {
if (!tableExists(connection, "crm_channel_expansion")) {
return;
}
ensureChannelCodeSequence(connection);
ensureChannelCodeUniqueIndex(connection);
log.info("Ensured channel expansion sequence compatibility");
} catch (SQLException exception) {
throw new IllegalStateException("Failed to initialize crm_channel_expansion schema compatibility", exception);
}
}
private void ensureChannelCodeSequence(Connection connection) throws SQLException {
try (Statement statement = connection.createStatement()) {
statement.execute("create sequence if not exists " + CHANNEL_CODE_SEQUENCE + " start with 1 increment by 1 minvalue 1");
}
}
private void ensureChannelCodeUniqueIndex(Connection connection) throws SQLException {
if (indexExists(connection, "uk_crm_channel_expansion_code")) {
return;
}
if (hasDuplicateChannelCodes(connection)) {
log.warn("Skip creating unique index uk_crm_channel_expansion_code because duplicate channel_code data already exists");
return;
}
try (Statement statement = connection.createStatement()) {
statement.execute("""
create unique index if not exists uk_crm_channel_expansion_code
on crm_channel_expansion(channel_code)
where channel_code is not null
""");
}
}
private boolean hasDuplicateChannelCodes(Connection connection) throws SQLException {
String sql = """
select 1
from crm_channel_expansion
where channel_code is not null
and btrim(channel_code) <> ''
group by channel_code
having count(1) > 1
limit 1
""";
try (Statement statement = connection.createStatement();
ResultSet resultSet = statement.executeQuery(sql)) {
return resultSet.next();
}
}
private boolean indexExists(Connection connection, String indexName) throws SQLException {
String sql = """
select 1
from pg_indexes
where schemaname = current_schema()
and indexname = ?
limit 1
""";
try (PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, indexName);
try (ResultSet resultSet = statement.executeQuery()) {
return resultSet.next();
}
}
}
private boolean tableExists(Connection connection, String tableName) throws SQLException {
if (existsInSchema(connection, null, tableName)) {
return true;
}
return existsInSchema(connection, "public", tableName);
}
private boolean existsInSchema(Connection connection, String schemaName, String tableName) throws SQLException {
try (ResultSet resultSet = connection.getMetaData()
.getTables(connection.getCatalog(), schemaName, tableName, new String[]{"TABLE"})) {
return resultSet.next();
}
}
}

View File

@ -6,6 +6,7 @@ import com.unis.crm.dto.expansion.CreateChannelExpansionRequest;
import com.unis.crm.dto.expansion.CreateExpansionFollowUpRequest;
import com.unis.crm.dto.expansion.CreateSalesExpansionRequest;
import com.unis.crm.dto.expansion.DictOptionDTO;
import com.unis.crm.dto.expansion.ExpansionDuplicateCheckDTO;
import com.unis.crm.dto.expansion.ExpansionMetaDTO;
import com.unis.crm.dto.expansion.ExpansionOverviewDTO;
import com.unis.crm.dto.expansion.UpdateChannelExpansionRequest;
@ -21,6 +22,7 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
@RestController
@ -54,6 +56,24 @@ public class ExpansionController {
return ApiResponse.success(expansionService.getOverview(CurrentUserUtils.requireCurrentUserId(userId), keyword));
}
@RequestMapping(value = "/sales/duplicate-check", method = {RequestMethod.GET, RequestMethod.POST})
public ApiResponse<ExpansionDuplicateCheckDTO> checkSalesDuplicate(
@RequestHeader("X-User-Id") Long userId,
@RequestParam("employeeNo") String employeeNo,
@RequestParam(value = "excludeId", required = false) Long excludeId) {
return ApiResponse.success(expansionService.checkSalesEmployeeNoDuplicate(
CurrentUserUtils.requireCurrentUserId(userId), employeeNo, excludeId));
}
@RequestMapping(value = "/channel/duplicate-check", method = {RequestMethod.GET, RequestMethod.POST})
public ApiResponse<ExpansionDuplicateCheckDTO> checkChannelDuplicate(
@RequestHeader("X-User-Id") Long userId,
@RequestParam("channelName") String channelName,
@RequestParam(value = "excludeId", required = false) Long excludeId) {
return ApiResponse.success(expansionService.checkChannelNameDuplicate(
CurrentUserUtils.requireCurrentUserId(userId), channelName, excludeId));
}
@PostMapping("/sales")
public ApiResponse<Long> createSales(
@RequestHeader("X-User-Id") Long userId,

View File

@ -0,0 +1,31 @@
package com.unis.crm.dto.expansion;
public class ExpansionDuplicateCheckDTO {
private boolean duplicated;
private String message;
public ExpansionDuplicateCheckDTO() {
}
public ExpansionDuplicateCheckDTO(boolean duplicated, String message) {
this.duplicated = duplicated;
this.message = message;
}
public boolean isDuplicated() {
return duplicated;
}
public void setDuplicated(boolean duplicated) {
this.duplicated = duplicated;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
}

View File

@ -64,13 +64,18 @@ public interface ExpansionMapper {
@DataScope(tableAlias = "s", ownerColumn = "owner_user_id")
int updateSalesExpansion(@Param("userId") Long userId, @Param("id") Long id, @Param("request") UpdateSalesExpansionRequest request);
int countSalesExpansionByEmployeeNo(@Param("userId") Long userId, @Param("employeeNo") String employeeNo);
int countSalesExpansionByEmployeeNo(@Param("employeeNo") String employeeNo);
int countSalesExpansionByEmployeeNoExcludingId(
@Param("userId") Long userId,
@Param("employeeNo") String employeeNo,
@Param("excludeId") Long excludeId);
int countChannelExpansionByChannelName(@Param("channelName") String channelName);
int countChannelExpansionByChannelNameExcludingId(
@Param("channelName") String channelName,
@Param("excludeId") Long excludeId);
@DataScope(tableAlias = "c", ownerColumn = "owner_user_id")
int updateChannelExpansion(@Param("userId") Long userId, @Param("id") Long id, @Param("request") UpdateChannelExpansionRequest request);

View File

@ -4,6 +4,7 @@ import com.unis.crm.dto.expansion.CreateChannelExpansionRequest;
import com.unis.crm.dto.expansion.CreateExpansionFollowUpRequest;
import com.unis.crm.dto.expansion.CreateSalesExpansionRequest;
import com.unis.crm.dto.expansion.DictOptionDTO;
import com.unis.crm.dto.expansion.ExpansionDuplicateCheckDTO;
import com.unis.crm.dto.expansion.ExpansionMetaDTO;
import com.unis.crm.dto.expansion.ExpansionOverviewDTO;
import com.unis.crm.dto.expansion.UpdateChannelExpansionRequest;
@ -18,6 +19,10 @@ public interface ExpansionService {
ExpansionOverviewDTO getOverview(Long userId, String keyword);
ExpansionDuplicateCheckDTO checkSalesEmployeeNoDuplicate(Long userId, String employeeNo, Long excludeId);
ExpansionDuplicateCheckDTO checkChannelNameDuplicate(Long userId, String channelName, Long excludeId);
Long createSalesExpansion(Long userId, CreateSalesExpansionRequest request);
Long createChannelExpansion(Long userId, CreateChannelExpansionRequest request);

View File

@ -8,6 +8,7 @@ import com.unis.crm.dto.expansion.CreateChannelExpansionRequest;
import com.unis.crm.dto.expansion.CreateExpansionFollowUpRequest;
import com.unis.crm.dto.expansion.CreateSalesExpansionRequest;
import com.unis.crm.dto.expansion.DictOptionDTO;
import com.unis.crm.dto.expansion.ExpansionDuplicateCheckDTO;
import com.unis.crm.dto.expansion.ExpansionMetaDTO;
import com.unis.crm.dto.expansion.ExpansionFollowUpDTO;
import com.unis.crm.dto.expansion.ExpansionOverviewDTO;
@ -32,10 +33,13 @@ import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class ExpansionServiceImpl implements ExpansionService {
private static final String SALES_DUPLICATE_MESSAGE = "工号重复,请确认该人员是否已存在!";
private static final String CHANNEL_DUPLICATE_MESSAGE = "渠道重复,请确认该渠道是否已存在!";
private static final String OFFICE_TYPE_CODE = "tz_bsc";
private static final String INDUSTRY_TYPE_CODE = "tz_sshy";
private static final String CERTIFICATION_LEVEL_TYPE_CODE = "tz_rzjb";
@ -99,6 +103,32 @@ public class ExpansionServiceImpl implements ExpansionService {
return new ExpansionOverviewDTO(salesItems, channelItems);
}
@Override
public ExpansionDuplicateCheckDTO checkSalesEmployeeNoDuplicate(Long userId, String employeeNo, Long excludeId) {
String normalizedEmployeeNo = trimToNull(employeeNo);
if (normalizedEmployeeNo == null) {
return new ExpansionDuplicateCheckDTO(false, null);
}
int count = excludeId == null
? expansionMapper.countSalesExpansionByEmployeeNo(normalizedEmployeeNo)
: expansionMapper.countSalesExpansionByEmployeeNoExcludingId(normalizedEmployeeNo, excludeId);
return new ExpansionDuplicateCheckDTO(count > 0, count > 0 ? SALES_DUPLICATE_MESSAGE : null);
}
@Override
public ExpansionDuplicateCheckDTO checkChannelNameDuplicate(Long userId, String channelName, Long excludeId) {
String normalizedChannelName = trimToNull(channelName);
if (normalizedChannelName == null) {
return new ExpansionDuplicateCheckDTO(false, null);
}
int count = excludeId == null
? expansionMapper.countChannelExpansionByChannelName(normalizedChannelName)
: expansionMapper.countChannelExpansionByChannelNameExcludingId(normalizedChannelName, excludeId);
return new ExpansionDuplicateCheckDTO(count > 0, count > 0 ? CHANNEL_DUPLICATE_MESSAGE : null);
}
@Override
public Long createSalesExpansion(Long userId, CreateSalesExpansionRequest request) {
fillSalesDefaults(request);
@ -111,8 +141,10 @@ public class ExpansionServiceImpl implements ExpansionService {
}
@Override
@Transactional
public Long createChannelExpansion(Long userId, CreateChannelExpansionRequest request) {
fillChannelDefaults(request);
ensureUniqueChannelName(userId, request.getChannelName(), null);
expansionMapper.insertChannelExpansion(userId, request);
if (request.getId() == null) {
throw new BusinessException("渠道拓展新增失败");
@ -132,8 +164,10 @@ public class ExpansionServiceImpl implements ExpansionService {
}
@Override
@Transactional
public void updateChannelExpansion(Long userId, Long id, UpdateChannelExpansionRequest request) {
fillChannelDefaults(request);
ensureUniqueChannelName(userId, request.getChannelName(), id);
int updated = expansionMapper.updateChannelExpansion(userId, id, request);
if (updated <= 0) {
throw new BusinessException("未找到可编辑的渠道拓展记录");
@ -380,10 +414,19 @@ public class ExpansionServiceImpl implements ExpansionService {
private void ensureUniqueEmployeeNo(Long userId, String employeeNo, Long excludeId) {
int count = excludeId == null
? expansionMapper.countSalesExpansionByEmployeeNo(userId, employeeNo)
: expansionMapper.countSalesExpansionByEmployeeNoExcludingId(userId, employeeNo, excludeId);
? expansionMapper.countSalesExpansionByEmployeeNo(employeeNo)
: expansionMapper.countSalesExpansionByEmployeeNoExcludingId(employeeNo, excludeId);
if (count > 0) {
throw new BusinessException("该工号已存在,请检查后再提交");
throw new BusinessException(SALES_DUPLICATE_MESSAGE);
}
}
private void ensureUniqueChannelName(Long userId, String channelName, Long excludeId) {
int count = excludeId == null
? expansionMapper.countChannelExpansionByChannelName(channelName)
: expansionMapper.countChannelExpansionByChannelNameExcludingId(channelName, excludeId);
if (count > 0) {
throw new BusinessException(CHANNEL_DUPLICATE_MESSAGE);
}
}

View File

@ -42,13 +42,12 @@
</select>
<select id="selectNextChannelCode" resultType="java.lang.String">
select 'QD-' || to_char(current_date, 'YYYYMMDD') || '-' || lpad((
coalesce((
select count(1)
from crm_channel_expansion
where created_at::date = current_date
), 0) + 1
)::text, 3, '0')
select 'QD-' || to_char(current_date, 'YYYYMMDD') || '-' || lpad(
(case when seq.is_called then seq.last_value + 1 else seq.last_value end)::text,
6,
'0'
)
from crm_channel_expansion_code_seq seq
</select>
<select id="selectSalesExpansions" resultType="com.unis.crm.dto.expansion.SalesExpansionItemDTO">
@ -380,13 +379,7 @@
owner_user_id,
remark
) values (
'QD-' || to_char(current_date, 'YYYYMMDD') || '-' || lpad((
coalesce((
select count(1)
from crm_channel_expansion
where created_at::date = current_date
), 0) + 1
)::text, 3, '0'),
'QD-' || to_char(current_date, 'YYYYMMDD') || '-' || lpad(nextval('crm_channel_expansion_code_seq')::text, 6, '0'),
#{request.province},
#{request.city},
#{request.channelName},
@ -459,15 +452,26 @@
<select id="countSalesExpansionByEmployeeNo" resultType="int">
select count(1)
from crm_sales_expansion
where owner_user_id = #{userId}
and employee_no = #{employeeNo}
where employee_no = #{employeeNo}
</select>
<select id="countSalesExpansionByEmployeeNoExcludingId" resultType="int">
select count(1)
from crm_sales_expansion
where owner_user_id = #{userId}
and employee_no = #{employeeNo}
where employee_no = #{employeeNo}
and id &lt;&gt; #{excludeId}
</select>
<select id="countChannelExpansionByChannelName" resultType="int">
select count(1)
from crm_channel_expansion
where channel_name = #{channelName}
</select>
<select id="countChannelExpansionByChannelNameExcludingId" resultType="int">
select count(1)
from crm_channel_expansion
where channel_name = #{channelName}
and id &lt;&gt; #{excludeId}
</select>

File diff suppressed because it is too large Load Diff

View File

@ -1,80 +1,86 @@
{
"hash": "9b703341",
"hash": "4ed97220",
"configHash": "4d48f89c",
"lockfileHash": "446f7b50",
"browserHash": "97389217",
"lockfileHash": "25980767",
"browserHash": "0ac37406",
"optimized": {
"react": {
"src": "../../react/index.js",
"file": "react.js",
"fileHash": "0e15d179",
"fileHash": "5499e597",
"needsInterop": true
},
"react-dom": {
"src": "../../react-dom/index.js",
"file": "react-dom.js",
"fileHash": "56e0ac1a",
"fileHash": "ee512445",
"needsInterop": true
},
"react/jsx-dev-runtime": {
"src": "../../react/jsx-dev-runtime.js",
"file": "react_jsx-dev-runtime.js",
"fileHash": "3873dbfb",
"fileHash": "b416d357",
"needsInterop": true
},
"react/jsx-runtime": {
"src": "../../react/jsx-runtime.js",
"file": "react_jsx-runtime.js",
"fileHash": "36bae069",
"fileHash": "d2d6e3f0",
"needsInterop": true
},
"clsx": {
"src": "../../clsx/dist/clsx.mjs",
"file": "clsx.js",
"fileHash": "ef2b5b9d",
"fileHash": "48844b26",
"needsInterop": false
},
"date-fns": {
"src": "../../date-fns/index.js",
"file": "date-fns.js",
"fileHash": "b5d072ca",
"fileHash": "e0776950",
"needsInterop": false
},
"lucide-react": {
"src": "../../lucide-react/dist/esm/lucide-react.js",
"file": "lucide-react.js",
"fileHash": "938b715f",
"fileHash": "2b6331af",
"needsInterop": false
},
"motion/react": {
"src": "../../motion/dist/es/react.mjs",
"file": "motion_react.js",
"fileHash": "fb0e4ab1",
"fileHash": "6ad27c2c",
"needsInterop": false
},
"react-dom/client": {
"src": "../../react-dom/client.js",
"file": "react-dom_client.js",
"fileHash": "c4c41e0d",
"fileHash": "105dfe43",
"needsInterop": true
},
"react-router-dom": {
"src": "../../react-router-dom/dist/index.mjs",
"file": "react-router-dom.js",
"fileHash": "5254f612",
"fileHash": "8926882a",
"needsInterop": false
},
"tailwind-merge": {
"src": "../../tailwind-merge/dist/bundle-mjs.mjs",
"file": "tailwind-merge.js",
"fileHash": "9fbd3db9",
"fileHash": "2c815557",
"needsInterop": false
},
"date-fns/locale": {
"src": "../../date-fns/locale.js",
"file": "date-fns_locale.js",
"fileHash": "31cc6364",
"fileHash": "00bef214",
"needsInterop": false
},
"exceljs": {
"src": "../../exceljs/dist/exceljs.min.js",
"file": "exceljs.js",
"fileHash": "7f44c50c",
"needsInterop": true
}
},
"chunks": {

File diff suppressed because it is too large Load Diff

View File

@ -17,6 +17,7 @@
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"dotenv": "^17.2.3",
"exceljs": "^4.4.0",
"express": "^4.21.2",
"lucide-react": "^0.546.0",
"motion": "^12.23.24",

View File

@ -443,6 +443,11 @@ export interface ExpansionMeta {
nextChannelCode?: string;
}
export interface ExpansionDuplicateCheck {
duplicated?: boolean;
message?: string;
}
export interface CreateSalesExpansionPayload {
employeeNo: string;
candidateName: string;
@ -896,6 +901,22 @@ export async function getExpansionCityOptions(provinceName: string) {
return request<ExpansionDictOption[]>(`/api/expansion/areas/cities?${params.toString()}`, undefined, true);
}
export async function checkSalesExpansionDuplicate(employeeNo: string, excludeId?: number) {
const params = new URLSearchParams({ employeeNo });
if (excludeId) {
params.set("excludeId", String(excludeId));
}
return request<ExpansionDuplicateCheck>(`/api/expansion/sales/duplicate-check?${params.toString()}`, undefined, true);
}
export async function checkChannelExpansionDuplicate(channelName: string, excludeId?: number) {
const params = new URLSearchParams({ channelName });
if (excludeId) {
params.set("excludeId", String(excludeId));
}
return request<ExpansionDuplicateCheck>(`/api/expansion/channel/duplicate-check?${params.toString()}`, undefined, true);
}
export async function createSalesExpansion(payload: CreateSalesExpansionPayload) {
return request<number>("/api/expansion/sales", {
method: "POST",

View File

@ -1,8 +1,10 @@
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { Search, Plus, MapPin, Building2, User, Phone, X, Clock, FileText, Calendar, ChevronRight } from "lucide-react";
import { Search, Plus, Download, MapPin, Building2, User, Phone, X, Clock, FileText, Calendar, ChevronRight } from "lucide-react";
import { motion, AnimatePresence } from "motion/react";
import { useLocation } from "react-router-dom";
import {
checkChannelExpansionDuplicate,
checkSalesExpansionDuplicate,
createChannelExpansion,
createSalesExpansion,
decodeExpansionMultiValue,
@ -125,6 +127,202 @@ function getFieldInputClass(hasError: boolean) {
);
}
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;
}
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 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);
}
function buildSalesExportHeaders(items: SalesExpansionItem[]) {
const maxProjects = Math.max(0, ...items.map((item) => item.relatedProjects?.length ?? 0));
const headers = [
"工号",
"姓名",
"联系方式",
"代表处 / 办事处",
"所属部门",
"职务",
"所属行业",
"合作意向",
"销售是否在职",
"销售以前是否做过云桌面项目",
"跟进项目金额",
];
for (let index = 0; index < maxProjects; index += 1) {
headers.push(`项目${index + 1}编码`, `项目${index + 1}名称`, `项目${index + 1}金额`);
}
headers.push("跟进记录");
return headers;
}
function buildSalesExportData(items: SalesExpansionItem[]) {
const maxProjects = Math.max(0, ...items.map((item) => item.relatedProjects?.length ?? 0));
return items.map((item) => {
const row = [
normalizeExportText(item.employeeNo),
normalizeExportText(item.name),
normalizeExportText(item.phone),
normalizeExportText(item.officeName),
normalizeExportText(item.dept),
normalizeExportText(item.title),
normalizeExportText(item.industry),
normalizeExportText(item.intent),
item.active === null || item.active === undefined ? "" : item.active ? "是" : "否",
formatExportBoolean(item.hasExp),
normalizeExportText(formatRelatedProjectAmount(item.relatedProjects)),
];
for (let index = 0; index < maxProjects; index += 1) {
const project = item.relatedProjects?.[index];
row.push(
normalizeExportText(project?.opportunityCode),
normalizeExportText(project?.opportunityName),
project?.amount === null || project?.amount === undefined ? "" : formatAmount(Number(project.amount)),
);
}
row.push(formatExportFollowUps(item.followUps));
return row;
});
}
function buildChannelExportHeaders(items: ChannelExpansionItem[]) {
const maxProjects = Math.max(0, ...items.map((item) => item.relatedProjects?.length ?? 0));
const maxContacts = Math.max(0, ...items.map((item) => item.contacts?.length ?? 0));
const headers = [
"编码",
"渠道名称",
"省份",
"市",
"办公地址",
"认证级别",
"聚焦行业",
"渠道属性",
"新华三内部属性",
"合作意向",
"建立联系时间",
"营收规模",
"人员规模",
"以前是否做过云桌面项目",
"跟进项目金额",
];
for (let index = 0; index < maxProjects; index += 1) {
headers.push(`项目${index + 1}编码`, `项目${index + 1}名称`, `项目${index + 1}金额`);
}
for (let index = 0; index < maxContacts; index += 1) {
headers.push(`人员${index + 1}姓名`, `人员${index + 1}联系电话`, `人员${index + 1}职位`);
}
headers.push("备注说明");
headers.push("跟进记录");
return headers;
}
function buildChannelExportData(items: ChannelExpansionItem[]) {
const maxProjects = Math.max(0, ...items.map((item) => item.relatedProjects?.length ?? 0));
const maxContacts = Math.max(0, ...items.map((item) => item.contacts?.length ?? 0));
return items.map((item) => {
const row = [
normalizeExportText(item.channelCode),
normalizeExportText(item.name),
normalizeExportText(item.province),
normalizeExportText(item.city),
normalizeExportText(item.officeAddress),
normalizeExportText(item.certificationLevel),
normalizeExportText(item.channelIndustry),
normalizeExportText(item.channelAttribute),
normalizeExportText(item.internalAttribute),
normalizeExportText(item.intent),
normalizeExportText(item.establishedDate),
normalizeExportText(item.revenue),
item.size ? `${item.size}` : "",
formatExportBoolean(item.hasDesktopExp),
normalizeExportText(formatRelatedProjectAmount(item.relatedProjects)),
];
for (let index = 0; index < maxProjects; index += 1) {
const project = item.relatedProjects?.[index];
row.push(
normalizeExportText(project?.opportunityCode),
normalizeExportText(project?.opportunityName),
project?.amount === null || project?.amount === undefined ? "" : formatAmount(Number(project.amount)),
);
}
for (let index = 0; index < maxContacts; index += 1) {
const contact = item.contacts?.[index];
row.push(
normalizeExportText(contact?.name),
normalizeExportText(contact?.mobile),
normalizeExportText(contact?.title),
);
}
row.push(normalizeExportText(item.notes));
row.push(formatExportFollowUps(item.followUps));
return row;
});
}
function validateSalesCreateForm(form: CreateSalesExpansionPayload) {
const errors: Partial<Record<SalesCreateField, string>> = {};
@ -392,8 +590,14 @@ export default function Expansion() {
const [createOpen, setCreateOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [exporting, setExporting] = useState(false);
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 [salesCreateFieldErrors, setSalesCreateFieldErrors] = useState<Partial<Record<SalesCreateField, string>>>({});
const [salesEditFieldErrors, setSalesEditFieldErrors] = useState<Partial<Record<SalesCreateField, string>>>({});
const [channelCreateFieldErrors, setChannelCreateFieldErrors] = useState<Partial<Record<ChannelField, string>>>({});
@ -520,8 +724,88 @@ export default function Expansion() {
}
}, [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 = <K extends keyof CreateSalesExpansionPayload>(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 };
@ -533,6 +817,10 @@ export default function Expansion() {
const handleChannelChange = <K extends keyof CreateChannelExpansionPayload>(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 };
@ -618,6 +906,10 @@ export default function Expansion() {
const resetCreateState = () => {
setCreateOpen(false);
setCreateError("");
setSalesDuplicateChecking(false);
setChannelDuplicateChecking(false);
setSalesDuplicateMessage("");
setChannelDuplicateMessage("");
setSalesCreateFieldErrors({});
setChannelCreateFieldErrors({});
setInvalidCreateChannelContactRows([]);
@ -744,6 +1036,26 @@ export default function Expansion() {
}
}
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 {
const duplicateResult = await checkChannelExpansionDuplicate(channelForm.channelName.trim());
if (duplicateResult.duplicated) {
const duplicateMessage = duplicateResult.message || "渠道重复,请确认该渠道是否已存在!";
setChannelDuplicateMessage(duplicateMessage);
setChannelCreateFieldErrors((current) => ({ ...current, channelName: duplicateMessage }));
setCreateError(duplicateMessage);
return;
}
}
setSubmitting(true);
try {
@ -843,18 +1155,100 @@ export default function Expansion() {
const handleTabChange = (tab: ExpansionTab) => {
setActiveTab(tab);
setSelectedItem(null);
setExportError("");
};
const handleExport = async () => {
if (exporting) {
return;
}
const isSalesTab = activeTab === "sales";
const items = isSalesTab ? salesData : channelData;
if (items.length <= 0) {
setExportError(`当前${isSalesTab ? "销售人员拓展" : "渠道拓展"}暂无可导出数据`);
return;
}
setExporting(true);
setExportError("");
try {
const ExcelJS = await import("exceljs");
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet(isSalesTab ? "销售人员拓展" : "渠道拓展");
const headers = isSalesTab ? buildSalesExportHeaders(salesData) : buildChannelExportHeaders(channelData);
const rows = isSalesTab ? buildSalesExportData(salesData) : buildChannelExportData(channelData);
worksheet.addRow(headers);
rows.forEach((row) => {
worksheet.addRow(row);
});
worksheet.views = [{ state: "frozen", ySplit: 1 }];
const followUpColumnIndex = headers.indexOf("跟进记录") + 1;
worksheet.getRow(1).height = 24;
worksheet.getRow(1).font = { bold: true };
worksheet.getRow(1).alignment = { vertical: "middle", horizontal: "center" };
headers.forEach((header, index) => {
const column = worksheet.getColumn(index + 1);
if (header === "跟进记录") {
column.width = 42;
} else if (header.includes("办公地址") || header.includes("备注")) {
column.width = 24;
} else if (header.includes("项目") && header.includes("名称")) {
column.width = 24;
} else if (header.includes("渠道属性") || header.includes("内部属性") || header.includes("聚焦行业")) {
column.width = 18;
} else {
column.width = 16;
}
});
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: headers[columnNumber - 1] === "跟进记录",
};
});
if (rowNumber > 1 && followUpColumnIndex > 0) {
const followUpText = normalizeExportText(row.getCell(followUpColumnIndex).value as string | null | undefined);
const lineCount = followUpText ? followUpText.split("\n").length : 1;
row.height = Math.max(22, lineCount * 16);
}
});
const buffer = await workbook.xlsx.writeBuffer();
const filename = `${isSalesTab ? "销售人员拓展" : "渠道拓展"}_${formatExportFilenameTime()}.xlsx`;
downloadExcelFile(filename, buffer);
} catch (error) {
setExportError(error instanceof Error ? error.message : "导出失败,请稍后重试");
} finally {
setExporting(false);
}
};
const renderSalesForm = (
form: CreateSalesExpansionPayload,
onChange: <K extends keyof CreateSalesExpansionPayload>(key: K, value: CreateSalesExpansionPayload[K]) => void,
fieldErrors?: Partial<Record<SalesCreateField, string>>,
isEdit = false,
) => (
<div className="crm-form-grid">
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<input value={form.employeeNo} onChange={(e) => onChange("employeeNo", e.target.value)} className={getFieldInputClass(Boolean(fieldErrors?.employeeNo))} />
{fieldErrors?.employeeNo ? <p className="text-xs text-rose-500">{fieldErrors.employeeNo}</p> : null}
{!fieldErrors?.employeeNo && !isEdit && salesDuplicateMessage ? <p className="text-xs text-rose-500">{salesDuplicateMessage}</p> : null}
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"> / <RequiredMark /></span>
@ -993,6 +1387,7 @@ export default function Expansion() {
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<input value={form.channelName} onChange={(e) => onChange("channelName", e.target.value)} className={getFieldInputClass(Boolean(fieldErrors?.channelName))} />
{fieldErrors?.channelName ? <p className="text-xs text-rose-500">{fieldErrors.channelName}</p> : null}
{!fieldErrors?.channelName && !isEdit && channelDuplicateMessage ? <p className="text-xs text-rose-500">{channelDuplicateMessage}</p> : null}
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
@ -1216,8 +1611,16 @@ export default function Expansion() {
<header className="crm-page-header">
<div className="crm-page-heading">
<h1 className="crm-page-title"></h1>
<p className="crm-page-subtitle hidden sm:block"></p>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleExport}
disabled={exporting}
className={cn("crm-btn-sm crm-btn-secondary flex items-center gap-2 disabled:cursor-not-allowed disabled:opacity-60", disableMobileMotion ? "active:scale-100" : "active:scale-95")}
>
<Download className="crm-icon-md" />
<span className="hidden sm:inline">{exporting ? "导出中..." : "导出"}</span>
</button>
<button
onClick={handleOpenCreate}
className={cn("crm-btn-sm crm-btn-primary flex items-center gap-2", disableMobileMotion ? "active:scale-100" : "active:scale-95")}
@ -1225,6 +1628,7 @@ export default function Expansion() {
<Plus className="crm-icon-md" />
<span className="hidden sm:inline"></span>
</button>
</div>
</header>
<div className={cn("crm-filter-bar flex", !disableMobileMotion && "backdrop-blur-sm")}>
@ -1252,11 +1656,16 @@ export default function Expansion() {
type="text"
placeholder="搜索工号、姓名、渠道名称、行业..."
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
onChange={(event) => {
setKeyword(event.target.value);
setExportError("");
}}
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white pl-10 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>
{exportError ? <div className="crm-alert crm-alert-error">{exportError}</div> : null}
<div className="crm-list-stack">
{activeTab === "sales" ? (
salesData.length > 0 ? (
@ -1352,7 +1761,7 @@ export default function Expansion() {
</div>
)}
>
{activeTab === "sales" ? renderSalesForm(salesForm, handleSalesChange, salesCreateFieldErrors) : renderChannelForm(channelForm, handleChannelChange, false, channelCreateFieldErrors, invalidCreateChannelContactRows)}
{activeTab === "sales" ? renderSalesForm(salesForm, handleSalesChange, salesCreateFieldErrors, false) : renderChannelForm(channelForm, handleChannelChange, false, channelCreateFieldErrors, invalidCreateChannelContactRows)}
{createError ? <div className="crm-alert crm-alert-error mt-4">{createError}</div> : null}
</ModalShell>
)}
@ -1371,7 +1780,7 @@ export default function Expansion() {
</div>
)}
>
{selectedItem.type === "sales" ? renderSalesForm(editSalesForm, handleEditSalesChange, salesEditFieldErrors) : renderChannelForm(editChannelForm, handleEditChannelChange, true, channelEditFieldErrors, invalidEditChannelContactRows)}
{selectedItem.type === "sales" ? renderSalesForm(editSalesForm, handleEditSalesChange, salesEditFieldErrors, true) : renderChannelForm(editChannelForm, handleEditChannelChange, true, channelEditFieldErrors, invalidEditChannelContactRows)}
{editError ? <div className="crm-alert crm-alert-error mt-4">{editError}</div> : null}
</ModalShell>
)}

View File

@ -262,22 +262,11 @@ export default function LoginPage() {
<h1>{appName}</h1>
</div>
</div>
<div className="login-intro-copy">
<p className="login-panel-eyebrow"></p>
<h2></h2>
<p>{systemDescription}</p>
</div>
<div className="login-intro-points">
<span></span>
<span></span>
<span></span>
</div>
</div>
<div className="login-panel-formWrap">
<div className="login-panel-header">
<p className="login-panel-eyebrow"></p>
<h3>{appName}</h3>
</div>
<form className="login-form" onSubmit={handleSubmit}>

View File

@ -1,5 +1,5 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { Search, Plus, ChevronRight, ChevronDown, Check, Building, Calendar, DollarSign, Activity, X, Clock, FileText, User, Tag, AlertTriangle, ListFilter } from "lucide-react";
import { Search, Plus, Download, ChevronRight, ChevronDown, Check, Building, Calendar, DollarSign, Activity, X, Clock, FileText, User, Tag, AlertTriangle, ListFilter } from "lucide-react";
import { motion, AnimatePresence } from "motion/react";
import { createOpportunity, getExpansionOverview, getOpportunityMeta, getOpportunityOmsPreSalesOptions, getOpportunityOverview, pushOpportunityToOms, updateOpportunity, type ChannelExpansionItem, type CreateOpportunityPayload, type OmsPreSalesOption, type OpportunityDictOption, type OpportunityFollowUp, type OpportunityItem, type PushOpportunityToOmsPayload, type SalesExpansionItem } from "@/lib/auth";
import { AdaptiveSelect } from "@/components/AdaptiveSelect";
@ -74,6 +74,39 @@ function formatAmount(value?: number) {
return new Intl.NumberFormat("zh-CN").format(Number(value));
}
function normalizeOpportunityExportText(value?: string | number | boolean | null) {
if (value === null || value === undefined) {
return "";
}
const normalized = String(value).replace(/\r?\n/g, " ").trim();
if (!normalized || normalized === "无" || normalized === "待定" || normalized === "未关联") {
return "";
}
return normalized;
}
function downloadOpportunityExcelFile(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);
}
function formatOpportunityExportFilenameTime(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 toFormFromItem(item: OpportunityItem): CreateOpportunityPayload {
return {
opportunityName: item.name || "",
@ -480,6 +513,8 @@ function SearchableSelect({
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [desktopDropdownPlacement, setDesktopDropdownPlacement] = useState<"top" | "bottom">("bottom");
const [desktopDropdownMaxHeight, setDesktopDropdownMaxHeight] = useState(256);
const containerRef = useRef<HTMLDivElement | null>(null);
const isMobile = useIsMobileViewport();
const normalizedOptions = dedupeSearchableOptions(options);
@ -502,6 +537,25 @@ function SearchableSelect({
return;
}
const updateDesktopDropdownLayout = () => {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect || typeof window === "undefined") {
return;
}
const viewportHeight = window.innerHeight;
const safePadding = 24;
const panelPadding = 96;
const availableBelow = Math.max(160, viewportHeight - rect.bottom - safePadding - panelPadding);
const availableAbove = Math.max(160, rect.top - safePadding - panelPadding);
const shouldOpenUpward = availableBelow < 280 && availableAbove > availableBelow;
setDesktopDropdownPlacement(shouldOpenUpward ? "top" : "bottom");
setDesktopDropdownMaxHeight(Math.min(320, shouldOpenUpward ? availableAbove : availableBelow));
};
updateDesktopDropdownLayout();
const handlePointerDown = (event: MouseEvent) => {
if (!containerRef.current?.contains(event.target as Node)) {
setOpen(false);
@ -509,9 +563,17 @@ function SearchableSelect({
}
};
const handleViewportChange = () => {
updateDesktopDropdownLayout();
};
document.addEventListener("mousedown", handlePointerDown);
window.addEventListener("resize", handleViewportChange);
window.addEventListener("scroll", handleViewportChange, true);
return () => {
document.removeEventListener("mousedown", handlePointerDown);
window.removeEventListener("resize", handleViewportChange);
window.removeEventListener("scroll", handleViewportChange, true);
};
}, [isMobile, open]);
@ -540,7 +602,7 @@ function SearchableSelect({
/>
</div>
<div className="mt-3 max-h-64 space-y-1 overflow-y-auto">
<div className="mt-3 max-h-64 space-y-1 overflow-y-auto overscroll-contain pr-1">
<button
type="button"
onClick={() => {
@ -610,9 +672,17 @@ function SearchableSelect({
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 8 }}
className="absolute z-20 mt-2 w-full rounded-2xl border border-slate-200 bg-white p-3 shadow-xl dark:border-slate-800 dark:bg-slate-900"
className={cn(
"absolute z-20 w-full rounded-2xl border border-slate-200 bg-white p-3 shadow-xl dark:border-slate-800 dark:bg-slate-900",
desktopDropdownPlacement === "top" ? "bottom-full mb-2" : "top-full mt-2",
)}
>
<div
style={{ maxHeight: `${desktopDropdownMaxHeight}px` }}
className="overflow-y-auto overscroll-contain pr-1"
>
{renderSearchBody()}
</div>
</motion.div>
) : null}
</AnimatePresence>
@ -857,7 +927,9 @@ export default function Opportunities() {
const [pushConfirmOpen, setPushConfirmOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [pushingOms, setPushingOms] = useState(false);
const [exporting, setExporting] = useState(false);
const [error, setError] = useState("");
const [exportError, setExportError] = useState("");
const [items, setItems] = useState<OpportunityItem[]>([]);
const [salesExpansionOptions, setSalesExpansionOptions] = useState<SalesExpansionItem[]>([]);
const [channelExpansionOptions, setChannelExpansionOptions] = useState<ChannelExpansionItem[]>([]);
@ -1065,6 +1137,147 @@ export default function Opportunities() {
return "text-rose-600 dark:text-rose-400 bg-rose-50 dark:bg-rose-500/10 border-rose-200 dark:border-rose-500/20";
};
const handleExport = async () => {
if (exporting) {
return;
}
if (visibleItems.length <= 0) {
setExportError(`当前${archiveTab === "active" ? "未归档" : "已归档"}商机暂无可导出数据`);
return;
}
setExporting(true);
setExportError("");
try {
const ExcelJS = await import("exceljs");
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet("商机储备");
const headers = [
"项目编号",
"项目名称",
"项目地",
"最终客户",
"建设类型",
"运作方",
"项目阶段",
"项目把握度",
"预计金额(元)",
"预计下单时间",
"销售拓展人员姓名",
"销售拓展人员合作意向",
"销售拓展人员是否在职",
"拓展渠道名称",
"拓展渠道合作意向",
"拓展渠道建立联系时间",
"新华三负责人",
"售前",
"竞争对手",
"项目最新进展",
"后续规划",
"备注说明",
"跟进记录",
];
worksheet.addRow(headers);
visibleItems.forEach((item) => {
const relatedSales = item.salesExpansionId
? salesExpansionOptions.find((option) => option.id === item.salesExpansionId) ?? null
: null;
const relatedChannel = item.channelExpansionId
? channelExpansionOptions.find((option) => option.id === item.channelExpansionId) ?? null
: null;
const followUpText = (item.followUps ?? [])
.map((record) => {
const summary = getOpportunityFollowUpSummary(record);
const lines = [
[normalizeOpportunityExportText(record.date), normalizeOpportunityExportText(record.user)].filter(Boolean).join(" / "),
normalizeOpportunityExportText(summary.communicationContent),
].filter(Boolean);
return lines.join("\n");
})
.filter(Boolean)
.join("\n\n");
worksheet.addRow([
normalizeOpportunityExportText(item.code),
normalizeOpportunityExportText(item.name),
normalizeOpportunityExportText(item.projectLocation),
normalizeOpportunityExportText(item.client),
normalizeOpportunityExportText(item.type || "新建"),
normalizeOpportunityExportText(item.operatorName),
normalizeOpportunityExportText(item.stage),
getConfidenceLabel(item.confidence),
item.amount === null || item.amount === undefined ? "" : `¥${formatAmount(item.amount)}`,
normalizeOpportunityExportText(item.date),
normalizeOpportunityExportText(item.salesExpansionName || relatedSales?.name),
normalizeOpportunityExportText(relatedSales?.intent),
relatedSales ? (relatedSales.active ? "是" : "否") : "",
normalizeOpportunityExportText(item.channelExpansionName || relatedChannel?.name),
normalizeOpportunityExportText(relatedChannel?.intent),
normalizeOpportunityExportText(relatedChannel?.establishedDate),
normalizeOpportunityExportText(item.salesExpansionName || relatedSales?.name),
normalizeOpportunityExportText(item.preSalesName),
normalizeOpportunityExportText(item.competitorName),
normalizeOpportunityExportText(item.latestProgress),
normalizeOpportunityExportText(item.nextPlan),
normalizeOpportunityExportText(item.notes),
followUpText,
]);
});
worksheet.views = [{ state: "frozen", ySplit: 1 }];
const followUpColumnIndex = headers.indexOf("跟进记录") + 1;
worksheet.getRow(1).height = 24;
worksheet.getRow(1).font = { bold: true };
worksheet.getRow(1).alignment = { vertical: "middle", horizontal: "center" };
headers.forEach((header, index) => {
const column = worksheet.getColumn(index + 1);
if (header === "跟进记录") {
column.width = 42;
} else if (header.includes("项目最新进展") || header.includes("后续规划") || header.includes("备注")) {
column.width = 24;
} else if (header.includes("项目名称") || header.includes("最终客户")) {
column.width = 20;
} else {
column.width = 16;
}
});
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: headers[columnNumber - 1] === "跟进记录",
};
});
if (rowNumber > 1 && followUpColumnIndex > 0) {
const followUpText = normalizeOpportunityExportText(row.getCell(followUpColumnIndex).value as string | null | undefined);
const lineCount = followUpText ? followUpText.split("\n").length : 1;
row.height = Math.max(22, lineCount * 16);
}
});
const buffer = await workbook.xlsx.writeBuffer();
const filename = `商机储备_${archiveTab === "active" ? "未归档" : "已归档"}_${formatOpportunityExportFilenameTime()}.xlsx`;
downloadOpportunityExcelFile(filename, buffer);
} catch (exportErr) {
setExportError(exportErr instanceof Error ? exportErr.message : "导出失败,请稍后重试");
} finally {
setExporting(false);
}
};
const handleChange = <K extends keyof CreateOpportunityPayload>(key: K, value: CreateOpportunityPayload[K]) => {
setForm((current) => ({ ...current, [key]: value }));
if (key in fieldErrors) {
@ -1261,8 +1474,16 @@ export default function Opportunities() {
<header className="crm-page-header">
<div className="crm-page-heading">
<h1 className="crm-page-title"></h1>
<p className="crm-page-subtitle hidden sm:block"></p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => void handleExport()}
disabled={exporting}
className={cn("crm-btn-sm crm-btn-secondary flex items-center gap-2 disabled:cursor-not-allowed disabled:opacity-60", disableMobileMotion ? "active:scale-100" : "active:scale-95")}
>
<Download className="crm-icon-md" />
<span className="hidden sm:inline">{exporting ? "导出中..." : "导出"}</span>
</button>
<button
onClick={handleOpenCreate}
className={cn("crm-btn-sm crm-btn-primary flex items-center gap-2", disableMobileMotion ? "active:scale-100" : "active:scale-95")}
@ -1270,11 +1491,15 @@ export default function Opportunities() {
<Plus className="crm-icon-md" />
<span className="hidden sm:inline"></span>
</button>
</div>
</header>
<div className={cn("crm-filter-bar flex", !disableMobileMotion && "backdrop-blur-sm")}>
<button
onClick={() => setArchiveTab("active")}
onClick={() => {
setArchiveTab("active");
setExportError("");
}}
className={`flex-1 rounded-lg py-2 text-sm font-medium transition-colors duration-200 ${
archiveTab === "active"
? "bg-white text-violet-600 shadow-sm dark:bg-slate-800 dark:text-violet-400"
@ -1284,7 +1509,10 @@ export default function Opportunities() {
</button>
<button
onClick={() => setArchiveTab("archived")}
onClick={() => {
setArchiveTab("archived");
setExportError("");
}}
className={`flex-1 rounded-lg py-2 text-sm font-medium transition-colors duration-200 ${
archiveTab === "archived"
? "bg-white text-violet-600 shadow-sm dark:bg-slate-800 dark:text-violet-400"
@ -1302,7 +1530,10 @@ export default function Opportunities() {
type="text"
placeholder="搜索项目名称、最终客户、编码..."
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
onChange={(event) => {
setKeyword(event.target.value);
setExportError("");
}}
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>
@ -1322,6 +1553,8 @@ export default function Opportunities() {
</button>
</div>
{exportError ? <div className="crm-alert crm-alert-error">{exportError}</div> : null}
<div className="crm-list-stack">
{visibleItems.length > 0 ? (
visibleItems.map((opp, i) => (
@ -1336,7 +1569,7 @@ export default function Opportunities() {
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<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>
<div className="shrink-0 flex items-center gap-2 pl-2">
<span className={getConfidenceBadgeClass(opp.confidence)}>
@ -1418,6 +1651,7 @@ export default function Opportunities() {
type="button"
onClick={() => {
setFilter(stage.value);
setExportError("");
setStageFilterOpen(false);
}}
className={`flex w-full items-center justify-between rounded-2xl border px-4 py-3 text-left text-sm font-medium transition-colors ${

View File

@ -376,29 +376,6 @@ export default function Profile() {
</div>
</div>
</div>
<div className="mt-6 grid grid-cols-3 gap-2.5 border-t border-slate-50 pt-6 dark:border-slate-800/50 sm:gap-0 sm:divide-x sm:divide-slate-100 dark:sm:divide-slate-800">
<button
type="button"
onClick={handleNavigateToMonthlyOpportunity}
className="rounded-2xl bg-slate-50 px-2 py-4 text-center transition-colors hover:bg-slate-100 active:scale-100 dark:bg-slate-800/40 dark:hover:bg-slate-800 sm:rounded-none sm:bg-transparent sm:px-2 sm:active:scale-[0.99]"
>
<p className="text-lg font-bold text-slate-900 dark:text-white sm:text-2xl">{numericValue(overview?.monthlyOpportunityCount)}</p>
<p className="mt-1 text-[11px] text-slate-500 dark:text-slate-400"></p>
</button>
<button
type="button"
onClick={handleNavigateToMonthlyExpansion}
className="rounded-2xl bg-slate-50 px-2 py-4 text-center transition-colors hover:bg-slate-100 active:scale-100 dark:bg-slate-800/40 dark:hover:bg-slate-800 sm:rounded-none sm:bg-transparent sm:px-2 sm:active:scale-[0.99]"
>
<p className="text-lg font-bold text-slate-900 dark:text-white sm:text-2xl">{numericValue(overview?.monthlyExpansionCount)}</p>
<p className="mt-1 text-[11px] text-slate-500 dark:text-slate-400"></p>
</button>
<div className="rounded-2xl bg-slate-50 px-2 py-4 text-center dark:bg-slate-800/40 sm:rounded-none sm:bg-transparent sm:px-2">
<p className="text-lg font-bold text-slate-900 dark:text-white sm:text-2xl">{numericValue(overview?.averageScore)}</p>
<p className="mt-1 text-[11px] text-slate-500 dark:text-slate-400"></p>
</div>
</div>
</motion.div>
<motion.div

View File

@ -2425,15 +2425,45 @@ function getHistoryLabelBySection(section: WorkSection) {
}
function buildSalesOptions(items: SalesExpansionItem[]): WorkRelationOption[] {
return items.filter((item) => item.id).map((item) => ({ id: item.id, label: item.name || `人员拓展#${item.id}` }));
const seenIds = new Set<number>();
return items
.filter((item): item is SalesExpansionItem & { id: number } => typeof item.id === "number")
.filter((item) => {
if (seenIds.has(item.id)) {
return false;
}
seenIds.add(item.id);
return true;
})
.map((item) => ({ id: item.id, label: item.name || `人员拓展#${item.id}` }));
}
function buildChannelOptions(items: ChannelExpansionItem[]): WorkRelationOption[] {
return items.filter((item) => item.id).map((item) => ({ id: item.id, label: item.name || `拓展渠道#${item.id}` }));
const seenIds = new Set<number>();
return items
.filter((item): item is ChannelExpansionItem & { id: number } => typeof item.id === "number")
.filter((item) => {
if (seenIds.has(item.id)) {
return false;
}
seenIds.add(item.id);
return true;
})
.map((item) => ({ id: item.id, label: item.name || `拓展渠道#${item.id}` }));
}
function buildOpportunityOptions(items: OpportunityItem[]): WorkRelationOption[] {
return items.filter((item) => item.id).map((item) => ({ id: item.id, label: item.name || `商机#${item.id}` }));
const seenIds = new Set<number>();
return items
.filter((item): item is OpportunityItem & { id: number } => typeof item.id === "number")
.filter((item) => {
if (seenIds.has(item.id)) {
return false;
}
seenIds.add(item.id);
return true;
})
.map((item) => ({ id: item.id, label: item.name || `商机#${item.id}` }));
}
function getBizTypeLabel(bizType: BizType) {

View File

@ -75,8 +75,10 @@
.login-brand-lockup h1 {
margin: 0;
font-size: clamp(1.8rem, 3vw, 2.7rem);
font-size: clamp(1.7rem, 2.4vw, 2.45rem);
font-weight: 700;
line-height: 1.08;
white-space: nowrap;
color: #0f172a;
}
@ -109,8 +111,8 @@
display: flex;
min-height: 100%;
flex-direction: column;
justify-content: space-between;
gap: 32px;
justify-content: center;
gap: 0;
padding: 36px;
border-radius: 28px;
background:
@ -119,37 +121,6 @@
border: 1px solid rgba(226, 232, 240, 0.84);
}
.login-intro-copy h2 {
margin: 10px 0 12px;
font-size: clamp(2rem, 3vw, 3rem);
line-height: 1.04;
letter-spacing: -0.03em;
color: #0f172a;
}
.login-intro-copy p:last-child {
margin: 0;
max-width: 30rem;
font-size: 1rem;
line-height: 1.8;
color: #64748b;
}
.login-intro-points {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.login-intro-points span {
padding: 10px 14px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.74);
border: 1px solid rgba(139, 92, 246, 0.12);
color: #475569;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
}
.login-panel-formWrap {
display: flex;
flex-direction: column;

View File

@ -303,6 +303,7 @@ create index if not exists idx_crm_customer_name on crm_customer(customer_name);
create index if not exists idx_sys_user_org_id on sys_user(org_id);
create unique index if not exists uk_crm_sales_expansion_owner_employee_no
on crm_sales_expansion(owner_user_id, employee_no);
create sequence if not exists crm_channel_expansion_code_seq start with 1 increment by 1 minvalue 1;
create index if not exists idx_crm_opportunity_customer on crm_opportunity(customer_id);
create index if not exists idx_crm_opportunity_owner on crm_opportunity(owner_user_id);
create index if not exists idx_crm_opportunity_sales_expansion on crm_opportunity(sales_expansion_id);
@ -318,6 +319,9 @@ create index if not exists idx_crm_sales_expansion_mobile on crm_sales_expansion
create index if not exists idx_crm_channel_expansion_owner on crm_channel_expansion(owner_user_id);
create index if not exists idx_crm_channel_expansion_stage on crm_channel_expansion(stage);
create index if not exists idx_crm_channel_expansion_name on crm_channel_expansion(channel_name);
create unique index if not exists uk_crm_channel_expansion_code
on crm_channel_expansion(channel_code)
where channel_code is not null;
create index if not exists idx_crm_channel_expansion_contact_channel on crm_channel_expansion_contact(channel_expansion_id);
create index if not exists idx_crm_opportunity_channel_expansion on crm_opportunity(channel_expansion_id);
create index if not exists idx_crm_expansion_followup_biz_time

View File

@ -359,6 +359,7 @@ end;
$$;
create unique index if not exists uk_crm_sales_expansion_owner_employee_no
on crm_sales_expansion(owner_user_id, employee_no);
create sequence if not exists crm_channel_expansion_code_seq start with 1 increment by 1 minvalue 1;
create index if not exists idx_crm_opportunity_customer on crm_opportunity(customer_id);
create index if not exists idx_crm_opportunity_owner on crm_opportunity(owner_user_id);
create index if not exists idx_crm_opportunity_sales_expansion on crm_opportunity(sales_expansion_id);
@ -374,6 +375,9 @@ create index if not exists idx_crm_sales_expansion_mobile on crm_sales_expansion
create index if not exists idx_crm_channel_expansion_owner on crm_channel_expansion(owner_user_id);
create index if not exists idx_crm_channel_expansion_stage on crm_channel_expansion(stage);
create index if not exists idx_crm_channel_expansion_name on crm_channel_expansion(channel_name);
create unique index if not exists uk_crm_channel_expansion_code
on crm_channel_expansion(channel_code)
where channel_code is not null;
create index if not exists idx_crm_channel_expansion_contact_channel on crm_channel_expansion_contact(channel_expansion_id);
create index if not exists idx_crm_opportunity_channel_expansion on crm_opportunity(channel_expansion_id);
create index if not exists idx_crm_expansion_followup_biz_time