main
kangwenjing 2026-03-26 17:29:55 +08:00
parent 13d3abeeee
commit 8606a02971
25595 changed files with 2201271 additions and 2463 deletions

BIN
.DS_Store vendored

Binary file not shown.

13
.gitignore vendored 100644
View File

@ -0,0 +1,13 @@
.DS_Store
.idea/
backend/.idea/
frontend/dist/
backend/target/
frontend/node_modules/
frontend/.cert/
*.log

View File

@ -4,19 +4,42 @@
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="4c558d98-824e-4a48-ba48-bd2e6172f9f4" name="更改" comment="">
<change beforePath="$PROJECT_DIR$/backend/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/backend/README.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/dist/assets/index-DTZ3L0iU.js" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/dist/assets/index-cLTs2L9U.css" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/dist/index.html" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/dist/index.html" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/index.html" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/index.html" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/src/App.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/src/App.tsx" afterDir="false" />
<list default="true" id="4c558d98-824e-4a48-ba48-bd2e6172f9f4" name="更改" comment="修改定位信息 0323">
<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/dto/expansion/ChannelExpansionItemDTO.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/dto/expansion/ChannelExpansionItemDTO.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/dto/expansion/CreateChannelExpansionRequest.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/dto/expansion/CreateChannelExpansionRequest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/dto/expansion/CreateExpansionFollowUpRequest.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/dto/expansion/CreateExpansionFollowUpRequest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/dto/expansion/CreateSalesExpansionRequest.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/dto/expansion/CreateSalesExpansionRequest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/dto/expansion/ExpansionFollowUpDTO.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/dto/expansion/ExpansionFollowUpDTO.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/dto/expansion/ExpansionMetaDTO.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/dto/expansion/ExpansionMetaDTO.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/dto/expansion/SalesExpansionItemDTO.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/dto/expansion/SalesExpansionItemDTO.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/dto/expansion/UpdateChannelExpansionRequest.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/dto/expansion/UpdateChannelExpansionRequest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/dto/expansion/UpdateSalesExpansionRequest.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/dto/expansion/UpdateSalesExpansionRequest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/dto/opportunity/CreateOpportunityRequest.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/dto/opportunity/CreateOpportunityRequest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityFollowUpDTO.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityFollowUpDTO.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityItemDTO.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityItemDTO.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/dto/work/CreateWorkDailyReportRequest.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/dto/work/CreateWorkDailyReportRequest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/dto/work/WorkDailyReportDTO.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/dto/work/WorkDailyReportDTO.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/mapper/ExpansionMapper.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/mapper/ExpansionMapper.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/mapper/WorkMapper.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/mapper/WorkMapper.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/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/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/java/com/unis/crm/service/impl/WorkServiceImpl.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/resources/mapper/dashboard/DashboardMapper.xml" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/resources/mapper/dashboard/DashboardMapper.xml" 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/resources/mapper/opportunity/OpportunityMapper.xml" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/resources/mapper/opportunity/OpportunityMapper.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/resources/mapper/work/WorkMapper.xml" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/resources/mapper/work/WorkMapper.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/src/components/Layout.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/src/components/Layout.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/src/index.css" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/src/index.css" 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/Dashboard.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/src/pages/Dashboard.tsx" 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/Opportunities.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/src/pages/Opportunities.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/src/pages/Profile.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/src/pages/Profile.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/vite.config.ts" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/vite.config.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/package.json" beforeDir="false" afterPath="$PROJECT_DIR$/package.json" 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$/sql/init_pg17.sql" beforeDir="false" afterPath="$PROJECT_DIR$/sql/init_pg17.sql" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@ -29,9 +52,9 @@
<component name="MarkdownSettingsMigration">
<option name="stateVersion" value="1" />
</component>
<component name="ProjectColorInfo"><![CDATA[{
"associatedIndex": 1
}]]></component>
<component name="ProjectColorInfo">{
&quot;associatedIndex&quot;: 1
}</component>
<component name="ProjectId" id="3BBm14kQhaD2gQxOS8rBU8WsdoX" />
<component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" />
@ -39,6 +62,10 @@
</component>
<component name="PropertiesComponent"><![CDATA[{
"keyToString": {
"RequestMappingsPanelOrder0": "0",
"RequestMappingsPanelOrder1": "1",
"RequestMappingsPanelWidth0": "75",
"RequestMappingsPanelWidth1": "75",
"RunOnceActivity.OpenProjectViewOnStart": "true",
"RunOnceActivity.ShowReadmeOnStart": "true",
"WebServerToolWindowFactoryState": "false",
@ -52,10 +79,19 @@
"project.structure.last.edited": "Libraries",
"project.structure.proportion": "0.0",
"project.structure.side.proportion": "0.0",
"ts.external.directory.path": "/Users/kangwenjing/Downloads/crm/unis_crm/frontend/node_modules/typescript/lib",
"vue.rearranger.settings.migration": "true"
}
}]]></component>
<component name="RunManager">
<component name="RunManager" selected="Spring Boot.UnisCrmBackendApplication">
<configuration name="unis-crm-backend中的所有" type="JUnit" factoryName="JUnit" temporary="true" nameIsGenerated="true">
<module name="unis-crm-backend" />
<option name="PACKAGE_NAME" value="" />
<option name="TEST_OBJECT" value="package" />
<method v="2">
<option name="Make" enabled="true" />
</method>
</configuration>
<configuration name="UnisCrmBackendApplication" type="SpringBootApplicationConfigurationType" factoryName="Spring Boot" nameIsGenerated="true">
<module name="unis-crm-backend" />
<option name="SPRING_BOOT_MAIN_CLASS" value="com.unis.crm.UnisCrmBackendApplication" />
@ -63,6 +99,11 @@
<option name="Make" enabled="true" />
</method>
</configuration>
<recent_temporary>
<list>
<item itemvalue="JUnit.unis-crm-backend中的所有" />
</list>
</recent_temporary>
</component>
<component name="SpellCheckerSettings" RuntimeDictionaries="0" Folders="0" CustomDictionaries="0" DefaultDictionary="应用程序级" UseSingleDictionary="true" transferred="true" />
<component name="TaskManager">
@ -73,10 +114,36 @@
<option name="presentableId" value="Default" />
<updated>1773970620258</updated>
<workItem from="1773970621225" duration="1084000" />
<workItem from="1774235389841" duration="216000" />
<workItem from="1774244004381" duration="10126000" />
</task>
<task id="LOCAL-00001" summary="修改定位信息 0323">
<option name="closed" value="true" />
<created>1774250470174</created>
<option name="number" value="00001" />
<option name="presentableId" value="LOCAL-00001" />
<option name="project" value="LOCAL" />
<updated>1774250470174</updated>
</task>
<option name="localTasksCounter" value="2" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" />
</component>
<component name="Vcs.Log.Tabs.Properties">
<option name="TAB_STATES">
<map>
<entry key="MAIN">
<value>
<State />
</value>
</entry>
</map>
</option>
</component>
<component name="VcsManagerConfiguration">
<MESSAGE value="修改定位信息 0323" />
<option name="LAST_COMMIT_MESSAGE" value="修改定位信息 0323" />
</component>
</project>

View File

@ -0,0 +1,75 @@
package com.unis.crm.common;
import java.sql.Connection;
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 WorkCheckInSchemaInitializer implements ApplicationRunner {
private static final Logger log = LoggerFactory.getLogger(WorkCheckInSchemaInitializer.class);
private final DataSource dataSource;
public WorkCheckInSchemaInitializer(DataSource dataSource) {
this.dataSource = dataSource;
}
@Override
public void run(ApplicationArguments args) {
try (Connection connection = dataSource.getConnection()) {
if (!tableExists(connection, "work_checkin")) {
return;
}
try (Statement statement = connection.createStatement()) {
statement.execute("alter table work_checkin add column if not exists biz_type varchar(20)");
statement.execute("alter table work_checkin add column if not exists biz_id bigint");
statement.execute("alter table work_checkin add column if not exists biz_name varchar(200)");
statement.execute("alter table work_checkin add column if not exists user_name varchar(100)");
statement.execute("alter table work_checkin add column if not exists dept_name varchar(200)");
statement.execute("create index if not exists idx_work_checkin_user_date_time on work_checkin (user_id, checkin_date desc, checkin_time desc, id desc)");
statement.execute("create index if not exists idx_work_checkin_date_biz on work_checkin (checkin_date desc, biz_type, biz_id)");
statement.execute("create index if not exists idx_work_checkin_date_dept on work_checkin (checkin_date desc, dept_name)");
statement.execute("""
do $$
begin
if not exists (
select 1
from pg_constraint
where conrelid = 'work_checkin'::regclass
and conname = 'work_checkin_biz_type_check'
) then
alter table work_checkin
add constraint work_checkin_biz_type_check
check (biz_type is null or biz_type in ('sales', 'channel', 'opportunity'));
end if;
end $$;
""");
}
log.info("Ensured compatibility columns and indexes exist for work_checkin");
} catch (SQLException exception) {
throw new IllegalStateException("Failed to initialize work_checkin schema compatibility", exception);
}
}
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,8 @@ import com.unis.crm.service.DashboardService;
import jakarta.validation.constraints.Min;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@ -26,4 +28,12 @@ public class DashboardController {
@RequestHeader("X-User-Id") @Min(1) Long userId) {
return ApiResponse.success(dashboardService.getHome(userId));
}
@PostMapping("/todos/{todoId}/complete")
public ApiResponse<Void> completeTodo(
@RequestHeader("X-User-Id") @Min(1) Long userId,
@PathVariable("todoId") @Min(1) Long todoId) {
dashboardService.completeTodo(userId, todoId);
return ApiResponse.success(null);
}
}

View File

@ -4,6 +4,7 @@ import com.unis.crm.common.ApiResponse;
import com.unis.crm.common.CurrentUserUtils;
import com.unis.crm.dto.opportunity.CreateOpportunityFollowUpRequest;
import com.unis.crm.dto.opportunity.CreateOpportunityRequest;
import com.unis.crm.dto.opportunity.OpportunityMetaDTO;
import com.unis.crm.dto.opportunity.OpportunityOverviewDTO;
import com.unis.crm.service.OpportunityService;
import jakarta.validation.Valid;
@ -27,6 +28,11 @@ public class OpportunityController {
this.opportunityService = opportunityService;
}
@GetMapping("/meta")
public ApiResponse<OpportunityMetaDTO> getMeta() {
return ApiResponse.success(opportunityService.getMeta());
}
@GetMapping("/overview")
public ApiResponse<OpportunityOverviewDTO> getOverview(
@RequestHeader("X-User-Id") Long userId,
@ -50,6 +56,13 @@ public class OpportunityController {
return ApiResponse.success(opportunityService.updateOpportunity(CurrentUserUtils.requireCurrentUserId(userId), opportunityId, request));
}
@PostMapping("/{opportunityId}/push-oms")
public ApiResponse<Long> pushToOms(
@RequestHeader("X-User-Id") Long userId,
@PathVariable("opportunityId") Long opportunityId) {
return ApiResponse.success(opportunityService.pushToOms(CurrentUserUtils.requireCurrentUserId(userId), opportunityId));
}
@PostMapping("/{opportunityId}/followups")
public ApiResponse<Long> createFollowUp(
@RequestHeader("X-User-Id") Long userId,

View File

@ -1,13 +1,13 @@
package com.unis.crm.controller;
import com.unis.crm.common.ApiResponse;
import com.unis.crm.common.BusinessException;
import com.unis.crm.common.CurrentUserUtils;
import com.unis.crm.dto.work.CreateWorkCheckInRequest;
import com.unis.crm.dto.work.CreateWorkDailyReportRequest;
import com.unis.crm.dto.work.WorkHistoryPageDTO;
import com.unis.crm.dto.work.WorkOverviewDTO;
import com.unis.crm.service.WorkService;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.Valid;
import java.math.BigDecimal;
import org.springframework.core.io.Resource;
@ -43,12 +43,25 @@ public class WorkController {
return ApiResponse.success(workService.getOverview(CurrentUserUtils.requireCurrentUserId(userId)));
}
@GetMapping("/history")
public ApiResponse<WorkHistoryPageDTO> getHistory(
@RequestHeader("X-User-Id") Long userId,
@RequestParam(value = "type", required = false) String type,
@RequestParam(value = "page", defaultValue = "1") int page,
@RequestParam(value = "size", defaultValue = "8") int size) {
return ApiResponse.success(workService.getHistory(CurrentUserUtils.requireCurrentUserId(userId), type, page, size));
}
@GetMapping("/reverse-geocode")
public ApiResponse<String> reverseGeocode(
@RequestHeader("X-User-Id") Long userId,
@RequestParam("lat") @DecimalMin(value = "-90.0") @DecimalMax(value = "90.0") BigDecimal latitude,
@RequestParam("lon") @DecimalMin(value = "-180.0") @DecimalMax(value = "180.0") BigDecimal longitude) {
@RequestHeader(value = "X-User-Id", required = false) Long userId,
@RequestParam(value = "lat", required = false) String latitudeText,
@RequestParam(value = "lon", required = false) String longitudeText) {
if (userId != null) {
CurrentUserUtils.requireCurrentUserId(userId);
}
BigDecimal latitude = parseCoordinate(latitudeText, "纬度", new BigDecimal("-90"), new BigDecimal("90"));
BigDecimal longitude = parseCoordinate(longitudeText, "经度", new BigDecimal("-180"), new BigDecimal("180"));
return ApiResponse.success(workService.resolveLocationName(latitude, longitude));
}
@ -81,4 +94,19 @@ public class WorkController {
@Valid @RequestBody CreateWorkDailyReportRequest request) {
return ApiResponse.success(workService.saveDailyReport(CurrentUserUtils.requireCurrentUserId(userId), request));
}
private BigDecimal parseCoordinate(String value, String fieldName, BigDecimal min, BigDecimal max) {
if (value == null || value.isBlank()) {
throw new BusinessException(fieldName + "不能为空");
}
try {
BigDecimal coordinate = new BigDecimal(value.trim());
if (coordinate.compareTo(min) < 0 || coordinate.compareTo(max) > 0) {
throw new BusinessException(fieldName + "超出有效范围");
}
return coordinate;
} catch (NumberFormatException ex) {
throw new BusinessException(fieldName + "格式不正确");
}
}
}

View File

@ -1,17 +1,22 @@
package com.unis.crm.dto.dashboard;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import java.time.OffsetDateTime;
public class DashboardTodoDTO {
@JsonSerialize(using = ToStringSerializer.class)
private Long id;
private String title;
private String bizType;
@JsonSerialize(using = ToStringSerializer.class)
private Long bizId;
private String priority;
private String status;
private OffsetDateTime dueDate;
private OffsetDateTime createdAt;
private OffsetDateTime updatedAt;
public Long getId() {
return id;
@ -76,4 +81,12 @@ public class DashboardTodoDTO {
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
public OffsetDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(OffsetDateTime updatedAt) {
this.updatedAt = updatedAt;
}
}

View File

@ -0,0 +1,50 @@
package com.unis.crm.dto.expansion;
public class ChannelExpansionContactDTO {
private Long id;
private Long channelExpansionId;
private String name;
private String mobile;
private String title;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Long getChannelExpansionId() {
return channelExpansionId;
}
public void setChannelExpansionId(Long channelExpansionId) {
this.channelExpansionId = channelExpansionId;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getMobile() {
return mobile;
}
public void setMobile(String mobile) {
this.mobile = mobile;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
}

View File

@ -0,0 +1,32 @@
package com.unis.crm.dto.expansion;
public class ChannelExpansionContactRequest {
private String name;
private String mobile;
private String title;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getMobile() {
return mobile;
}
public void setMobile(String mobile) {
this.mobile = mobile;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
}

View File

@ -7,20 +7,30 @@ public class ChannelExpansionItemDTO {
private Long id;
private String type;
private String channelCode;
private String name;
private String province;
private String industry;
private String officeAddress;
private String channelIndustry;
private String annualRevenue;
private String revenue;
private Integer size;
private String contact;
private String contactTitle;
private String phone;
private String primaryContactName;
private String primaryContactTitle;
private String primaryContactMobile;
private String establishedDate;
private String intentLevel;
private String intent;
private Boolean hasDesktopExp;
private String channelAttribute;
private String internalAttribute;
private String stageCode;
private String stage;
private Boolean landed;
private String expectedSignDate;
private String notes;
private List<ChannelExpansionContactDTO> contacts = new ArrayList<>();
private List<ChannelRelatedProjectSummaryDTO> relatedProjects = new ArrayList<>();
private List<ExpansionFollowUpDTO> followUps = new ArrayList<>();
public Long getId() {
@ -39,6 +49,14 @@ public class ChannelExpansionItemDTO {
this.type = type;
}
public String getChannelCode() {
return channelCode;
}
public void setChannelCode(String channelCode) {
this.channelCode = channelCode;
}
public String getName() {
return name;
}
@ -55,12 +73,20 @@ public class ChannelExpansionItemDTO {
this.province = province;
}
public String getIndustry() {
return industry;
public String getOfficeAddress() {
return officeAddress;
}
public void setIndustry(String industry) {
this.industry = industry;
public void setOfficeAddress(String officeAddress) {
this.officeAddress = officeAddress;
}
public String getChannelIndustry() {
return channelIndustry;
}
public void setChannelIndustry(String channelIndustry) {
this.channelIndustry = channelIndustry;
}
public String getAnnualRevenue() {
@ -87,28 +113,76 @@ public class ChannelExpansionItemDTO {
this.size = size;
}
public String getContact() {
return contact;
public String getPrimaryContactName() {
return primaryContactName;
}
public void setContact(String contact) {
this.contact = contact;
public void setPrimaryContactName(String primaryContactName) {
this.primaryContactName = primaryContactName;
}
public String getContactTitle() {
return contactTitle;
public String getPrimaryContactTitle() {
return primaryContactTitle;
}
public void setContactTitle(String contactTitle) {
this.contactTitle = contactTitle;
public void setPrimaryContactTitle(String primaryContactTitle) {
this.primaryContactTitle = primaryContactTitle;
}
public String getPhone() {
return phone;
public String getPrimaryContactMobile() {
return primaryContactMobile;
}
public void setPhone(String phone) {
this.phone = phone;
public void setPrimaryContactMobile(String primaryContactMobile) {
this.primaryContactMobile = primaryContactMobile;
}
public String getEstablishedDate() {
return establishedDate;
}
public void setEstablishedDate(String establishedDate) {
this.establishedDate = establishedDate;
}
public String getIntentLevel() {
return intentLevel;
}
public void setIntentLevel(String intentLevel) {
this.intentLevel = intentLevel;
}
public String getIntent() {
return intent;
}
public void setIntent(String intent) {
this.intent = intent;
}
public Boolean getHasDesktopExp() {
return hasDesktopExp;
}
public void setHasDesktopExp(Boolean hasDesktopExp) {
this.hasDesktopExp = hasDesktopExp;
}
public String getChannelAttribute() {
return channelAttribute;
}
public void setChannelAttribute(String channelAttribute) {
this.channelAttribute = channelAttribute;
}
public String getInternalAttribute() {
return internalAttribute;
}
public void setInternalAttribute(String internalAttribute) {
this.internalAttribute = internalAttribute;
}
public String getStageCode() {
@ -151,6 +225,22 @@ public class ChannelExpansionItemDTO {
this.notes = notes;
}
public List<ChannelExpansionContactDTO> getContacts() {
return contacts;
}
public void setContacts(List<ChannelExpansionContactDTO> contacts) {
this.contacts = contacts;
}
public List<ChannelRelatedProjectSummaryDTO> getRelatedProjects() {
return relatedProjects;
}
public void setRelatedProjects(List<ChannelRelatedProjectSummaryDTO> relatedProjects) {
this.relatedProjects = relatedProjects;
}
public List<ExpansionFollowUpDTO> getFollowUps() {
return followUps;
}

View File

@ -0,0 +1,52 @@
package com.unis.crm.dto.expansion;
import java.math.BigDecimal;
public class ChannelRelatedProjectSummaryDTO {
private Long channelExpansionId;
private Long opportunityId;
private String opportunityCode;
private String opportunityName;
private BigDecimal amount;
public Long getChannelExpansionId() {
return channelExpansionId;
}
public void setChannelExpansionId(Long channelExpansionId) {
this.channelExpansionId = channelExpansionId;
}
public Long getOpportunityId() {
return opportunityId;
}
public void setOpportunityId(Long opportunityId) {
this.opportunityId = opportunityId;
}
public String getOpportunityCode() {
return opportunityCode;
}
public void setOpportunityCode(String opportunityCode) {
this.opportunityCode = opportunityCode;
}
public String getOpportunityName() {
return opportunityName;
}
public void setOpportunityName(String opportunityName) {
this.opportunityName = opportunityName;
}
public BigDecimal getAmount() {
return amount;
}
public void setAmount(BigDecimal amount) {
this.amount = amount;
}
}

View File

@ -4,26 +4,33 @@ import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
public class CreateChannelExpansionRequest {
private Long id;
private String channelCode;
private String officeAddress;
private String channelIndustry;
@NotBlank(message = "渠道名称不能为空")
@Size(max = 200, message = "渠道名称不能超过200字符")
private String channelName;
private String province;
private String industry;
private BigDecimal annualRevenue;
private Integer staffSize;
private String contactName;
private String contactTitle;
private String contactMobile;
private LocalDate contactEstablishedDate;
private String intentLevel;
private Boolean hasDesktopExp;
private String channelAttribute;
private String internalAttribute;
private String stage;
private Boolean landedFlag;
private LocalDate expectedSignDate;
private String remark;
private List<ChannelExpansionContactRequest> contacts = new ArrayList<>();
public Long getId() {
return id;
@ -33,6 +40,30 @@ public class CreateChannelExpansionRequest {
this.id = id;
}
public String getChannelCode() {
return channelCode;
}
public void setChannelCode(String channelCode) {
this.channelCode = channelCode;
}
public String getOfficeAddress() {
return officeAddress;
}
public void setOfficeAddress(String officeAddress) {
this.officeAddress = officeAddress;
}
public String getChannelIndustry() {
return channelIndustry;
}
public void setChannelIndustry(String channelIndustry) {
this.channelIndustry = channelIndustry;
}
public String getChannelName() {
return channelName;
}
@ -49,14 +80,6 @@ public class CreateChannelExpansionRequest {
this.province = province;
}
public String getIndustry() {
return industry;
}
public void setIndustry(String industry) {
this.industry = industry;
}
public BigDecimal getAnnualRevenue() {
return annualRevenue;
}
@ -73,28 +96,44 @@ public class CreateChannelExpansionRequest {
this.staffSize = staffSize;
}
public String getContactName() {
return contactName;
public LocalDate getContactEstablishedDate() {
return contactEstablishedDate;
}
public void setContactName(String contactName) {
this.contactName = contactName;
public void setContactEstablishedDate(LocalDate contactEstablishedDate) {
this.contactEstablishedDate = contactEstablishedDate;
}
public String getContactTitle() {
return contactTitle;
public String getIntentLevel() {
return intentLevel;
}
public void setContactTitle(String contactTitle) {
this.contactTitle = contactTitle;
public void setIntentLevel(String intentLevel) {
this.intentLevel = intentLevel;
}
public String getContactMobile() {
return contactMobile;
public Boolean getHasDesktopExp() {
return hasDesktopExp;
}
public void setContactMobile(String contactMobile) {
this.contactMobile = contactMobile;
public void setHasDesktopExp(Boolean hasDesktopExp) {
this.hasDesktopExp = hasDesktopExp;
}
public String getChannelAttribute() {
return channelAttribute;
}
public void setChannelAttribute(String channelAttribute) {
this.channelAttribute = channelAttribute;
}
public String getInternalAttribute() {
return internalAttribute;
}
public void setInternalAttribute(String internalAttribute) {
this.internalAttribute = internalAttribute;
}
public String getStage() {
@ -128,4 +167,12 @@ public class CreateChannelExpansionRequest {
public void setRemark(String remark) {
this.remark = remark;
}
public List<ChannelExpansionContactRequest> getContacts() {
return contacts;
}
public void setContacts(List<ChannelExpansionContactRequest> contacts) {
this.contacts = contacts;
}
}

View File

@ -17,6 +17,10 @@ public class CreateExpansionFollowUpRequest {
@NotNull(message = "跟进时间不能为空")
private OffsetDateTime followUpTime;
private OffsetDateTime visitStartTime;
private String evaluationContent;
private String nextPlan;
public String getFollowUpType() {
return followUpType;
}
@ -48,4 +52,28 @@ public class CreateExpansionFollowUpRequest {
public void setFollowUpTime(OffsetDateTime followUpTime) {
this.followUpTime = followUpTime;
}
public OffsetDateTime getVisitStartTime() {
return visitStartTime;
}
public void setVisitStartTime(OffsetDateTime visitStartTime) {
this.visitStartTime = visitStartTime;
}
public String getEvaluationContent() {
return evaluationContent;
}
public void setEvaluationContent(String evaluationContent) {
this.evaluationContent = evaluationContent;
}
public String getNextPlan() {
return nextPlan;
}
public void setNextPlan(String nextPlan) {
this.nextPlan = nextPlan;
}
}

View File

@ -8,13 +8,18 @@ public class CreateSalesExpansionRequest {
private Long id;
@NotBlank(message = "工号不能为空")
@Size(max = 50, message = "工号不能超过50字符")
private String employeeNo;
@NotBlank(message = "候选人姓名不能为空")
@Size(max = 50, message = "候选人姓名不能超过50字符")
private String candidateName;
private String officeName;
private String mobile;
private String email;
private Long targetDeptId;
private String targetDept;
private String industry;
private String title;
private String intentLevel;
@ -33,6 +38,14 @@ public class CreateSalesExpansionRequest {
this.id = id;
}
public String getEmployeeNo() {
return employeeNo;
}
public void setEmployeeNo(String employeeNo) {
this.employeeNo = employeeNo;
}
public String getCandidateName() {
return candidateName;
}
@ -45,6 +58,14 @@ public class CreateSalesExpansionRequest {
return mobile;
}
public String getOfficeName() {
return officeName;
}
public void setOfficeName(String officeName) {
this.officeName = officeName;
}
public void setMobile(String mobile) {
this.mobile = mobile;
}
@ -57,12 +78,12 @@ public class CreateSalesExpansionRequest {
this.email = email;
}
public Long getTargetDeptId() {
return targetDeptId;
public String getTargetDept() {
return targetDept;
}
public void setTargetDeptId(Long targetDeptId) {
this.targetDeptId = targetDeptId;
public void setTargetDept(String targetDept) {
this.targetDept = targetDept;
}
public String getIndustry() {

View File

@ -0,0 +1,23 @@
package com.unis.crm.dto.expansion;
public class DictOptionDTO {
private String label;
private String value;
public String getLabel() {
return label;
}
public void setLabel(String label) {
this.label = label;
}
public String getValue() {
return value;
}
public void setValue(String value) {
this.value = value;
}
}

View File

@ -12,6 +12,9 @@ public class ExpansionFollowUpDTO {
private String type;
private String content;
private String user;
private String visitStartTime;
private String evaluationContent;
private String nextPlan;
public Long getId() {
return id;
@ -76,4 +79,28 @@ public class ExpansionFollowUpDTO {
public void setUser(String user) {
this.user = user;
}
public String getVisitStartTime() {
return visitStartTime;
}
public void setVisitStartTime(String visitStartTime) {
this.visitStartTime = visitStartTime;
}
public String getEvaluationContent() {
return evaluationContent;
}
public void setEvaluationContent(String evaluationContent) {
this.evaluationContent = evaluationContent;
}
public String getNextPlan() {
return nextPlan;
}
public void setNextPlan(String nextPlan) {
this.nextPlan = nextPlan;
}
}

View File

@ -4,20 +4,65 @@ import java.util.List;
public class ExpansionMetaDTO {
private List<DepartmentOptionDTO> departments;
private List<DictOptionDTO> officeOptions;
private List<DictOptionDTO> industryOptions;
private List<DictOptionDTO> channelAttributeOptions;
private List<DictOptionDTO> internalAttributeOptions;
private String nextChannelCode;
public ExpansionMetaDTO() {
}
public ExpansionMetaDTO(List<DepartmentOptionDTO> departments) {
this.departments = departments;
public ExpansionMetaDTO(
List<DictOptionDTO> officeOptions,
List<DictOptionDTO> industryOptions,
List<DictOptionDTO> channelAttributeOptions,
List<DictOptionDTO> internalAttributeOptions,
String nextChannelCode) {
this.officeOptions = officeOptions;
this.industryOptions = industryOptions;
this.channelAttributeOptions = channelAttributeOptions;
this.internalAttributeOptions = internalAttributeOptions;
this.nextChannelCode = nextChannelCode;
}
public List<DepartmentOptionDTO> getDepartments() {
return departments;
public List<DictOptionDTO> getOfficeOptions() {
return officeOptions;
}
public void setDepartments(List<DepartmentOptionDTO> departments) {
this.departments = departments;
public void setOfficeOptions(List<DictOptionDTO> officeOptions) {
this.officeOptions = officeOptions;
}
public List<DictOptionDTO> getIndustryOptions() {
return industryOptions;
}
public void setIndustryOptions(List<DictOptionDTO> industryOptions) {
this.industryOptions = industryOptions;
}
public List<DictOptionDTO> getChannelAttributeOptions() {
return channelAttributeOptions;
}
public void setChannelAttributeOptions(List<DictOptionDTO> channelAttributeOptions) {
this.channelAttributeOptions = channelAttributeOptions;
}
public List<DictOptionDTO> getInternalAttributeOptions() {
return internalAttributeOptions;
}
public void setInternalAttributeOptions(List<DictOptionDTO> internalAttributeOptions) {
this.internalAttributeOptions = internalAttributeOptions;
}
public String getNextChannelCode() {
return nextChannelCode;
}
public void setNextChannelCode(String nextChannelCode) {
this.nextChannelCode = nextChannelCode;
}
}

View File

@ -0,0 +1,52 @@
package com.unis.crm.dto.expansion;
import java.math.BigDecimal;
public class RelatedProjectSummaryDTO {
private Long salesExpansionId;
private Long opportunityId;
private String opportunityCode;
private String opportunityName;
private BigDecimal amount;
public Long getSalesExpansionId() {
return salesExpansionId;
}
public void setSalesExpansionId(Long salesExpansionId) {
this.salesExpansionId = salesExpansionId;
}
public Long getOpportunityId() {
return opportunityId;
}
public void setOpportunityId(Long opportunityId) {
this.opportunityId = opportunityId;
}
public String getOpportunityCode() {
return opportunityCode;
}
public void setOpportunityCode(String opportunityCode) {
this.opportunityCode = opportunityCode;
}
public String getOpportunityName() {
return opportunityName;
}
public void setOpportunityName(String opportunityName) {
this.opportunityName = opportunityName;
}
public BigDecimal getAmount() {
return amount;
}
public void setAmount(BigDecimal amount) {
this.amount = amount;
}
}

View File

@ -7,11 +7,15 @@ public class SalesExpansionItemDTO {
private Long id;
private String type;
private String employeeNo;
private String name;
private String officeCode;
private String officeName;
private String phone;
private String email;
private Long targetDeptId;
private String targetDept;
private String dept;
private String industryCode;
private String industry;
private String title;
private String intentLevel;
@ -24,6 +28,7 @@ public class SalesExpansionItemDTO {
private String employmentStatus;
private String expectedJoinDate;
private String notes;
private java.util.List<RelatedProjectSummaryDTO> relatedProjects = new java.util.ArrayList<>();
private List<ExpansionFollowUpDTO> followUps = new ArrayList<>();
public Long getId() {
@ -42,6 +47,14 @@ public class SalesExpansionItemDTO {
this.type = type;
}
public String getEmployeeNo() {
return employeeNo;
}
public void setEmployeeNo(String employeeNo) {
this.employeeNo = employeeNo;
}
public String getName() {
return name;
}
@ -50,10 +63,26 @@ public class SalesExpansionItemDTO {
this.name = name;
}
public String getOfficeCode() {
return officeCode;
}
public void setOfficeCode(String officeCode) {
this.officeCode = officeCode;
}
public String getPhone() {
return phone;
}
public String getOfficeName() {
return officeName;
}
public void setOfficeName(String officeName) {
this.officeName = officeName;
}
public void setPhone(String phone) {
this.phone = phone;
}
@ -66,12 +95,12 @@ public class SalesExpansionItemDTO {
this.email = email;
}
public Long getTargetDeptId() {
return targetDeptId;
public String getTargetDept() {
return targetDept;
}
public void setTargetDeptId(Long targetDeptId) {
this.targetDeptId = targetDeptId;
public void setTargetDept(String targetDept) {
this.targetDept = targetDept;
}
public String getDept() {
@ -90,6 +119,14 @@ public class SalesExpansionItemDTO {
this.industry = industry;
}
public String getIndustryCode() {
return industryCode;
}
public void setIndustryCode(String industryCode) {
this.industryCode = industryCode;
}
public String getTitle() {
return title;
}
@ -178,6 +215,14 @@ public class SalesExpansionItemDTO {
this.notes = notes;
}
public java.util.List<RelatedProjectSummaryDTO> getRelatedProjects() {
return relatedProjects;
}
public void setRelatedProjects(java.util.List<RelatedProjectSummaryDTO> relatedProjects) {
this.relatedProjects = relatedProjects;
}
public List<ExpansionFollowUpDTO> getFollowUps() {
return followUps;
}

View File

@ -4,24 +4,56 @@ import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
public class UpdateChannelExpansionRequest {
private String channelCode;
private String officeAddress;
private String channelIndustry;
@NotBlank(message = "渠道名称不能为空")
@Size(max = 200, message = "渠道名称不能超过200字符")
private String channelName;
private String province;
private String industry;
private BigDecimal annualRevenue;
private Integer staffSize;
private String contactName;
private String contactTitle;
private String contactMobile;
private LocalDate contactEstablishedDate;
private String intentLevel;
private Boolean hasDesktopExp;
private String channelAttribute;
private String internalAttribute;
private String stage;
private Boolean landedFlag;
private LocalDate expectedSignDate;
private String remark;
private List<ChannelExpansionContactRequest> contacts = new ArrayList<>();
public String getChannelCode() {
return channelCode;
}
public void setChannelCode(String channelCode) {
this.channelCode = channelCode;
}
public String getOfficeAddress() {
return officeAddress;
}
public void setOfficeAddress(String officeAddress) {
this.officeAddress = officeAddress;
}
public String getChannelIndustry() {
return channelIndustry;
}
public void setChannelIndustry(String channelIndustry) {
this.channelIndustry = channelIndustry;
}
public String getChannelName() {
return channelName;
@ -39,14 +71,6 @@ public class UpdateChannelExpansionRequest {
this.province = province;
}
public String getIndustry() {
return industry;
}
public void setIndustry(String industry) {
this.industry = industry;
}
public BigDecimal getAnnualRevenue() {
return annualRevenue;
}
@ -63,28 +87,44 @@ public class UpdateChannelExpansionRequest {
this.staffSize = staffSize;
}
public String getContactName() {
return contactName;
public LocalDate getContactEstablishedDate() {
return contactEstablishedDate;
}
public void setContactName(String contactName) {
this.contactName = contactName;
public void setContactEstablishedDate(LocalDate contactEstablishedDate) {
this.contactEstablishedDate = contactEstablishedDate;
}
public String getContactTitle() {
return contactTitle;
public String getIntentLevel() {
return intentLevel;
}
public void setContactTitle(String contactTitle) {
this.contactTitle = contactTitle;
public void setIntentLevel(String intentLevel) {
this.intentLevel = intentLevel;
}
public String getContactMobile() {
return contactMobile;
public Boolean getHasDesktopExp() {
return hasDesktopExp;
}
public void setContactMobile(String contactMobile) {
this.contactMobile = contactMobile;
public void setHasDesktopExp(Boolean hasDesktopExp) {
this.hasDesktopExp = hasDesktopExp;
}
public String getChannelAttribute() {
return channelAttribute;
}
public void setChannelAttribute(String channelAttribute) {
this.channelAttribute = channelAttribute;
}
public String getInternalAttribute() {
return internalAttribute;
}
public void setInternalAttribute(String internalAttribute) {
this.internalAttribute = internalAttribute;
}
public String getStage() {
@ -118,4 +158,12 @@ public class UpdateChannelExpansionRequest {
public void setRemark(String remark) {
this.remark = remark;
}
public List<ChannelExpansionContactRequest> getContacts() {
return contacts;
}
public void setContacts(List<ChannelExpansionContactRequest> contacts) {
this.contacts = contacts;
}
}

View File

@ -6,13 +6,18 @@ import java.time.LocalDate;
public class UpdateSalesExpansionRequest {
@NotBlank(message = "工号不能为空")
@Size(max = 50, message = "工号不能超过50字符")
private String employeeNo;
@NotBlank(message = "候选人姓名不能为空")
@Size(max = 50, message = "候选人姓名不能超过50字符")
private String candidateName;
private String officeName;
private String mobile;
private String email;
private Long targetDeptId;
private String targetDept;
private String industry;
private String title;
private String intentLevel;
@ -23,6 +28,14 @@ public class UpdateSalesExpansionRequest {
private LocalDate expectedJoinDate;
private String remark;
public String getEmployeeNo() {
return employeeNo;
}
public void setEmployeeNo(String employeeNo) {
this.employeeNo = employeeNo;
}
public String getCandidateName() {
return candidateName;
}
@ -35,6 +48,14 @@ public class UpdateSalesExpansionRequest {
return mobile;
}
public String getOfficeName() {
return officeName;
}
public void setOfficeName(String officeName) {
this.officeName = officeName;
}
public void setMobile(String mobile) {
this.mobile = mobile;
}
@ -47,12 +68,12 @@ public class UpdateSalesExpansionRequest {
this.email = email;
}
public Long getTargetDeptId() {
return targetDeptId;
public String getTargetDept() {
return targetDept;
}
public void setTargetDeptId(Long targetDeptId) {
this.targetDeptId = targetDeptId;
public void setTargetDept(String targetDept) {
this.targetDept = targetDept;
}
public String getIndustry() {

View File

@ -16,10 +16,16 @@ public class CreateOpportunityRequest {
@Size(max = 200, message = "商机名称不能超过200字符")
private String opportunityName;
@NotBlank(message = "客户名称不能为空")
@Size(max = 200, message = "客户名称不能超过200字符")
@NotBlank(message = "最终客户不能为空")
@Size(max = 200, message = "最终客户不能超过200字符")
private String customerName;
@Size(max = 100, message = "项目地不能超过100字符")
private String projectLocation;
@Size(max = 100, message = "运作方不能超过100字符")
private String operatorName;
@NotNull(message = "商机金额不能为空")
private BigDecimal amount;
@ -34,6 +40,9 @@ public class CreateOpportunityRequest {
private String opportunityType;
private String productType;
private String source;
private Long salesExpansionId;
private Long channelExpansionId;
private String competitorName;
private Boolean pushedToOms;
private String description;
@ -69,6 +78,22 @@ public class CreateOpportunityRequest {
this.amount = amount;
}
public String getProjectLocation() {
return projectLocation;
}
public void setProjectLocation(String projectLocation) {
this.projectLocation = projectLocation;
}
public String getOperatorName() {
return operatorName;
}
public void setOperatorName(String operatorName) {
this.operatorName = operatorName;
}
public LocalDate getExpectedCloseDate() {
return expectedCloseDate;
}
@ -117,6 +142,30 @@ public class CreateOpportunityRequest {
this.source = source;
}
public Long getSalesExpansionId() {
return salesExpansionId;
}
public void setSalesExpansionId(Long salesExpansionId) {
this.salesExpansionId = salesExpansionId;
}
public Long getChannelExpansionId() {
return channelExpansionId;
}
public void setChannelExpansionId(Long channelExpansionId) {
this.channelExpansionId = channelExpansionId;
}
public String getCompetitorName() {
return competitorName;
}
public void setCompetitorName(String competitorName) {
this.competitorName = competitorName;
}
public Boolean getPushedToOms() {
return pushedToOms;
}

View File

@ -0,0 +1,23 @@
package com.unis.crm.dto.opportunity;
public class OpportunityDictOptionDTO {
private String label;
private String value;
public String getLabel() {
return label;
}
public void setLabel(String label) {
this.label = label;
}
public String getValue() {
return value;
}
public void setValue(String value) {
this.value = value;
}
}

View File

@ -7,6 +7,10 @@ public class OpportunityFollowUpDTO {
private String date;
private String type;
private String content;
private String latestProgress;
private String communicationTime;
private String communicationContent;
private String nextAction;
private String user;
public Long getId() {
@ -49,6 +53,38 @@ public class OpportunityFollowUpDTO {
this.content = content;
}
public String getLatestProgress() {
return latestProgress;
}
public void setLatestProgress(String latestProgress) {
this.latestProgress = latestProgress;
}
public String getCommunicationTime() {
return communicationTime;
}
public void setCommunicationTime(String communicationTime) {
this.communicationTime = communicationTime;
}
public String getCommunicationContent() {
return communicationContent;
}
public void setCommunicationContent(String communicationContent) {
this.communicationContent = communicationContent;
}
public String getNextAction() {
return nextAction;
}
public void setNextAction(String nextAction) {
this.nextAction = nextAction;
}
public String getUser() {
return user;
}

View File

@ -11,14 +11,25 @@ public class OpportunityItemDTO {
private String name;
private String client;
private String owner;
private String projectLocation;
private String operatorCode;
private String operatorName;
private BigDecimal amount;
private String date;
private Integer confidence;
private String stageCode;
private String stage;
private String type;
private Boolean pushedToOms;
private String product;
private String source;
private Long salesExpansionId;
private String salesExpansionName;
private Long channelExpansionId;
private String channelExpansionName;
private String competitorName;
private String latestProgress;
private String nextPlan;
private String notes;
private List<OpportunityFollowUpDTO> followUps = new ArrayList<>();
@ -62,6 +73,30 @@ public class OpportunityItemDTO {
this.owner = owner;
}
public String getProjectLocation() {
return projectLocation;
}
public void setProjectLocation(String projectLocation) {
this.projectLocation = projectLocation;
}
public String getOperatorName() {
return operatorName;
}
public void setOperatorName(String operatorName) {
this.operatorName = operatorName;
}
public String getOperatorCode() {
return operatorCode;
}
public void setOperatorCode(String operatorCode) {
this.operatorCode = operatorCode;
}
public BigDecimal getAmount() {
return amount;
}
@ -94,6 +129,14 @@ public class OpportunityItemDTO {
this.stage = stage;
}
public String getStageCode() {
return stageCode;
}
public void setStageCode(String stageCode) {
this.stageCode = stageCode;
}
public String getType() {
return type;
}
@ -126,6 +169,62 @@ public class OpportunityItemDTO {
this.source = source;
}
public Long getSalesExpansionId() {
return salesExpansionId;
}
public void setSalesExpansionId(Long salesExpansionId) {
this.salesExpansionId = salesExpansionId;
}
public String getSalesExpansionName() {
return salesExpansionName;
}
public void setSalesExpansionName(String salesExpansionName) {
this.salesExpansionName = salesExpansionName;
}
public Long getChannelExpansionId() {
return channelExpansionId;
}
public void setChannelExpansionId(Long channelExpansionId) {
this.channelExpansionId = channelExpansionId;
}
public String getChannelExpansionName() {
return channelExpansionName;
}
public void setChannelExpansionName(String channelExpansionName) {
this.channelExpansionName = channelExpansionName;
}
public String getCompetitorName() {
return competitorName;
}
public void setCompetitorName(String competitorName) {
this.competitorName = competitorName;
}
public String getLatestProgress() {
return latestProgress;
}
public void setLatestProgress(String latestProgress) {
this.latestProgress = latestProgress;
}
public String getNextPlan() {
return nextPlan;
}
public void setNextPlan(String nextPlan) {
this.nextPlan = nextPlan;
}
public String getNotes() {
return notes;
}

View File

@ -0,0 +1,33 @@
package com.unis.crm.dto.opportunity;
import java.util.List;
public class OpportunityMetaDTO {
private List<OpportunityDictOptionDTO> stageOptions;
private List<OpportunityDictOptionDTO> operatorOptions;
public OpportunityMetaDTO() {
}
public OpportunityMetaDTO(List<OpportunityDictOptionDTO> stageOptions, List<OpportunityDictOptionDTO> operatorOptions) {
this.stageOptions = stageOptions;
this.operatorOptions = operatorOptions;
}
public List<OpportunityDictOptionDTO> getStageOptions() {
return stageOptions;
}
public void setStageOptions(List<OpportunityDictOptionDTO> stageOptions) {
this.stageOptions = stageOptions;
}
public List<OpportunityDictOptionDTO> getOperatorOptions() {
return operatorOptions;
}
public void setOperatorOptions(List<OpportunityDictOptionDTO> operatorOptions) {
this.operatorOptions = operatorOptions;
}
}

View File

@ -17,6 +17,17 @@ public class CreateWorkCheckInRequest {
private BigDecimal longitude;
private BigDecimal latitude;
private List<String> photoUrls;
private String bizType;
private Long bizId;
@Size(max = 200, message = "关联对象名称不能超过200字符")
private String bizName;
@Size(max = 100, message = "打卡人不能超过100字符")
private String userName;
@Size(max = 200, message = "所属部门不能超过200字符")
private String deptName;
public String getLocationText() {
return locationText;
@ -57,4 +68,44 @@ public class CreateWorkCheckInRequest {
public void setPhotoUrls(List<String> photoUrls) {
this.photoUrls = photoUrls;
}
public String getBizType() {
return bizType;
}
public void setBizType(String bizType) {
this.bizType = bizType;
}
public Long getBizId() {
return bizId;
}
public void setBizId(Long bizId) {
this.bizId = bizId;
}
public String getBizName() {
return bizName;
}
public void setBizName(String bizName) {
this.bizName = bizName;
}
public String getUserName() {
return userName;
}
public void setUserName(String userName) {
this.userName = userName;
}
public String getDeptName() {
return deptName;
}
public void setDeptName(String deptName) {
this.deptName = deptName;
}
}

View File

@ -1,7 +1,11 @@
package com.unis.crm.dto.work;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
import jakarta.validation.Valid;
import java.util.ArrayList;
import java.util.List;
public class CreateWorkDailyReportRequest {
@ -9,10 +13,18 @@ public class CreateWorkDailyReportRequest {
@Size(max = 4000, message = "今日工作内容不能超过4000字符")
private String workContent;
@NotEmpty(message = "请至少填写一条今日工作内容")
@Valid
private List<WorkReportLineItemRequest> lineItems = new ArrayList<>();
@NotBlank(message = "明日工作计划不能为空")
@Size(max = 4000, message = "明日工作计划不能超过4000字符")
private String tomorrowPlan;
@NotEmpty(message = "请至少填写一条明日工作计划")
@Valid
private List<WorkTomorrowPlanItemRequest> planItems = new ArrayList<>();
@Size(max = 50, message = "来源类型不能超过50字符")
private String sourceType;
@ -39,4 +51,20 @@ public class CreateWorkDailyReportRequest {
public void setSourceType(String sourceType) {
this.sourceType = sourceType;
}
public List<WorkReportLineItemRequest> getLineItems() {
return lineItems;
}
public void setLineItems(List<WorkReportLineItemRequest> lineItems) {
this.lineItems = lineItems;
}
public List<WorkTomorrowPlanItemRequest> getPlanItems() {
return planItems;
}
public void setPlanItems(List<WorkTomorrowPlanItemRequest> planItems) {
this.planItems = planItems;
}
}

View File

@ -14,6 +14,11 @@ public class WorkCheckInDTO {
private BigDecimal longitude;
private BigDecimal latitude;
private List<String> photoUrls;
private String bizType;
private Long bizId;
private String bizName;
private String userName;
private String deptName;
public Long getId() {
return id;
@ -86,4 +91,44 @@ public class WorkCheckInDTO {
public void setPhotoUrls(List<String> photoUrls) {
this.photoUrls = photoUrls;
}
public String getBizType() {
return bizType;
}
public void setBizType(String bizType) {
this.bizType = bizType;
}
public Long getBizId() {
return bizId;
}
public void setBizId(Long bizId) {
this.bizId = bizId;
}
public String getBizName() {
return bizName;
}
public void setBizName(String bizName) {
this.bizName = bizName;
}
public String getUserName() {
return userName;
}
public void setUserName(String userName) {
this.userName = userName;
}
public String getDeptName() {
return deptName;
}
public void setDeptName(String deptName) {
this.deptName = deptName;
}
}

View File

@ -11,6 +11,8 @@ public class WorkDailyReportDTO {
private String status;
private Integer score;
private String comment;
private java.util.List<WorkReportLineItemDTO> lineItems = new java.util.ArrayList<>();
private java.util.List<WorkTomorrowPlanItemDTO> planItems = new java.util.ArrayList<>();
public Long getId() {
return id;
@ -83,4 +85,20 @@ public class WorkDailyReportDTO {
public void setComment(String comment) {
this.comment = comment;
}
public java.util.List<WorkReportLineItemDTO> getLineItems() {
return lineItems;
}
public void setLineItems(java.util.List<WorkReportLineItemDTO> lineItems) {
this.lineItems = lineItems;
}
public java.util.List<WorkTomorrowPlanItemDTO> getPlanItems() {
return planItems;
}
public void setPlanItems(java.util.List<WorkTomorrowPlanItemDTO> planItems) {
this.planItems = planItems;
}
}

View File

@ -0,0 +1,53 @@
package com.unis.crm.dto.work;
import java.util.List;
public class WorkHistoryPageDTO {
private List<WorkHistoryItemDTO> items;
private boolean hasMore;
private int page;
private int size;
public WorkHistoryPageDTO() {
}
public WorkHistoryPageDTO(List<WorkHistoryItemDTO> items, boolean hasMore, int page, int size) {
this.items = items;
this.hasMore = hasMore;
this.page = page;
this.size = size;
}
public List<WorkHistoryItemDTO> getItems() {
return items;
}
public void setItems(List<WorkHistoryItemDTO> items) {
this.items = items;
}
public boolean isHasMore() {
return hasMore;
}
public void setHasMore(boolean hasMore) {
this.hasMore = hasMore;
}
public int getPage() {
return page;
}
public void setPage(int page) {
this.page = page;
}
public int getSize() {
return size;
}
public void setSize(int size) {
this.size = size;
}
}

View File

@ -0,0 +1,113 @@
package com.unis.crm.dto.work;
public class WorkReportLineItemDTO {
private String workDate;
private String bizType;
private Long bizId;
private String bizName;
private String editorText;
private String content;
private String visitStartTime;
private String evaluationContent;
private String nextPlan;
private String latestProgress;
private String communicationTime;
private String communicationContent;
public String getWorkDate() {
return workDate;
}
public void setWorkDate(String workDate) {
this.workDate = workDate;
}
public String getBizType() {
return bizType;
}
public void setBizType(String bizType) {
this.bizType = bizType;
}
public Long getBizId() {
return bizId;
}
public void setBizId(Long bizId) {
this.bizId = bizId;
}
public String getBizName() {
return bizName;
}
public void setBizName(String bizName) {
this.bizName = bizName;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public String getEditorText() {
return editorText;
}
public void setEditorText(String editorText) {
this.editorText = editorText;
}
public String getVisitStartTime() {
return visitStartTime;
}
public void setVisitStartTime(String visitStartTime) {
this.visitStartTime = visitStartTime;
}
public String getEvaluationContent() {
return evaluationContent;
}
public void setEvaluationContent(String evaluationContent) {
this.evaluationContent = evaluationContent;
}
public String getNextPlan() {
return nextPlan;
}
public void setNextPlan(String nextPlan) {
this.nextPlan = nextPlan;
}
public String getLatestProgress() {
return latestProgress;
}
public void setLatestProgress(String latestProgress) {
this.latestProgress = latestProgress;
}
public String getCommunicationTime() {
return communicationTime;
}
public void setCommunicationTime(String communicationTime) {
this.communicationTime = communicationTime;
}
public String getCommunicationContent() {
return communicationContent;
}
public void setCommunicationContent(String communicationContent) {
this.communicationContent = communicationContent;
}
}

View File

@ -0,0 +1,130 @@
package com.unis.crm.dto.work;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
public class WorkReportLineItemRequest {
@NotBlank(message = "工作日期不能为空")
private String workDate;
@NotBlank(message = "跟进对象类型不能为空")
private String bizType;
@NotNull(message = "跟进对象不能为空")
private Long bizId;
@Size(max = 200, message = "对象名称不能超过200字符")
private String bizName;
@NotBlank(message = "日报内容不能为空")
@Size(max = 4000, message = "日报内容不能超过4000字符")
private String editorText;
@NotBlank(message = "工作内容不能为空")
@Size(max = 1000, message = "工作内容不能超过1000字符")
private String content;
private String visitStartTime;
private String evaluationContent;
private String nextPlan;
private String latestProgress;
private String communicationTime;
private String communicationContent;
public String getWorkDate() {
return workDate;
}
public void setWorkDate(String workDate) {
this.workDate = workDate;
}
public String getBizType() {
return bizType;
}
public void setBizType(String bizType) {
this.bizType = bizType;
}
public Long getBizId() {
return bizId;
}
public void setBizId(Long bizId) {
this.bizId = bizId;
}
public String getBizName() {
return bizName;
}
public void setBizName(String bizName) {
this.bizName = bizName;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public String getEditorText() {
return editorText;
}
public void setEditorText(String editorText) {
this.editorText = editorText;
}
public String getVisitStartTime() {
return visitStartTime;
}
public void setVisitStartTime(String visitStartTime) {
this.visitStartTime = visitStartTime;
}
public String getEvaluationContent() {
return evaluationContent;
}
public void setEvaluationContent(String evaluationContent) {
this.evaluationContent = evaluationContent;
}
public String getNextPlan() {
return nextPlan;
}
public void setNextPlan(String nextPlan) {
this.nextPlan = nextPlan;
}
public String getLatestProgress() {
return latestProgress;
}
public void setLatestProgress(String latestProgress) {
this.latestProgress = latestProgress;
}
public String getCommunicationTime() {
return communicationTime;
}
public void setCommunicationTime(String communicationTime) {
this.communicationTime = communicationTime;
}
public String getCommunicationContent() {
return communicationContent;
}
public void setCommunicationContent(String communicationContent) {
this.communicationContent = communicationContent;
}
}

View File

@ -0,0 +1,14 @@
package com.unis.crm.dto.work;
public class WorkTomorrowPlanItemDTO {
private String content;
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
}

View File

@ -0,0 +1,19 @@
package com.unis.crm.dto.work;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public class WorkTomorrowPlanItemRequest {
@NotBlank(message = "明日工作计划内容不能为空")
@Size(max = 200, message = "单条明日工作计划不能超过200字符")
private String content;
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
}

View File

@ -17,7 +17,9 @@ public interface DashboardMapper {
List<DashboardStatDTO> selectDashboardStats(@Param("userId") Long userId);
List<DashboardTodoDTO> selectPendingTodos(@Param("userId") Long userId);
List<DashboardTodoDTO> selectTodos(@Param("userId") Long userId);
int markTodoDone(@Param("userId") Long userId, @Param("todoId") Long todoId);
List<DashboardActivityDTO> selectLatestActivities(@Param("userId") Long userId);
}

View File

@ -1,11 +1,15 @@
package com.unis.crm.mapper;
import com.unis.crm.dto.expansion.ChannelExpansionItemDTO;
import com.unis.crm.dto.expansion.ChannelExpansionContactDTO;
import com.unis.crm.dto.expansion.ChannelExpansionContactRequest;
import com.unis.crm.dto.expansion.ChannelRelatedProjectSummaryDTO;
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.DepartmentOptionDTO;
import com.unis.crm.dto.expansion.DictOptionDTO;
import com.unis.crm.dto.expansion.ExpansionFollowUpDTO;
import com.unis.crm.dto.expansion.RelatedProjectSummaryDTO;
import com.unis.crm.dto.expansion.SalesExpansionItemDTO;
import com.unis.crm.dto.expansion.UpdateChannelExpansionRequest;
import com.unis.crm.dto.expansion.UpdateSalesExpansionRequest;
@ -16,7 +20,9 @@ import org.apache.ibatis.annotations.Param;
@Mapper
public interface ExpansionMapper {
List<DepartmentOptionDTO> selectDepartments();
List<DictOptionDTO> selectDictItems(@Param("typeCode") String typeCode);
String selectNextChannelCode();
List<SalesExpansionItemDTO> selectSalesExpansions(@Param("userId") Long userId, @Param("keyword") String keyword);
@ -24,14 +30,34 @@ public interface ExpansionMapper {
List<ExpansionFollowUpDTO> selectSalesFollowUps(@Param("userId") Long userId, @Param("bizIds") List<Long> bizIds);
List<RelatedProjectSummaryDTO> selectSalesRelatedProjects(@Param("userId") Long userId, @Param("bizIds") List<Long> bizIds);
List<ExpansionFollowUpDTO> selectChannelFollowUps(@Param("userId") Long userId, @Param("bizIds") List<Long> bizIds);
List<ChannelExpansionContactDTO> selectChannelContacts(@Param("userId") Long userId, @Param("bizIds") List<Long> bizIds);
List<ChannelRelatedProjectSummaryDTO> selectChannelRelatedProjects(@Param("userId") Long userId, @Param("bizIds") List<Long> bizIds);
int insertSalesExpansion(@Param("userId") Long userId, @Param("request") CreateSalesExpansionRequest request);
int insertChannelExpansion(@Param("userId") Long userId, @Param("request") CreateChannelExpansionRequest request);
int insertChannelContact(
@Param("channelExpansionId") Long channelExpansionId,
@Param("sortOrder") Integer sortOrder,
@Param("contact") ChannelExpansionContactRequest contact);
int deleteChannelContacts(@Param("channelExpansionId") Long channelExpansionId);
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 countSalesExpansionByEmployeeNoExcludingId(
@Param("userId") Long userId,
@Param("employeeNo") String employeeNo,
@Param("excludeId") Long excludeId);
int updateChannelExpansion(@Param("userId") Long userId, @Param("id") Long id, @Param("request") UpdateChannelExpansionRequest request);
int countOwnedSalesExpansion(@Param("userId") Long userId, @Param("id") Long id);

View File

@ -2,6 +2,7 @@ package com.unis.crm.mapper;
import com.unis.crm.dto.opportunity.CreateOpportunityFollowUpRequest;
import com.unis.crm.dto.opportunity.CreateOpportunityRequest;
import com.unis.crm.dto.opportunity.OpportunityDictOptionDTO;
import com.unis.crm.dto.opportunity.OpportunityFollowUpDTO;
import com.unis.crm.dto.opportunity.OpportunityItemDTO;
import java.util.List;
@ -11,6 +12,12 @@ import org.apache.ibatis.annotations.Param;
@Mapper
public interface OpportunityMapper {
List<OpportunityDictOptionDTO> selectDictItems(@Param("typeCode") String typeCode);
String selectDictLabel(
@Param("typeCode") String typeCode,
@Param("itemValue") String itemValue);
List<OpportunityItemDTO> selectOpportunities(
@Param("userId") Long userId,
@Param("keyword") String keyword,
@ -35,12 +42,18 @@ public interface OpportunityMapper {
int countOwnedOpportunity(@Param("userId") Long userId, @Param("id") Long id);
Boolean selectPushedToOms(@Param("userId") Long userId, @Param("id") Long id);
int updateOpportunity(
@Param("userId") Long userId,
@Param("opportunityId") Long opportunityId,
@Param("customerId") Long customerId,
@Param("request") CreateOpportunityRequest request);
int pushOpportunityToOms(
@Param("userId") Long userId,
@Param("opportunityId") Long opportunityId);
int insertOpportunityFollowUp(
@Param("userId") Long userId,
@Param("opportunityId") Long opportunityId,

View File

@ -21,6 +21,12 @@ public interface WorkMapper {
List<WorkHistoryItemDTO> selectHistory(@Param("userId") Long userId);
List<WorkHistoryItemDTO> selectHistoryPage(
@Param("userId") Long userId,
@Param("historyType") String historyType,
@Param("limit") int limit,
@Param("offset") int offset);
Long selectTodayCheckInId(@Param("userId") Long userId);
int insertCheckIn(@Param("userId") Long userId, @Param("request") CreateWorkCheckInRequest request);
@ -33,6 +39,84 @@ public interface WorkMapper {
int updateDailyReport(@Param("reportId") Long reportId, @Param("request") CreateWorkDailyReportRequest request);
int deleteGeneratedExpansionFollowUps(@Param("reportId") Long reportId);
int deleteGeneratedOpportunityFollowUps(@Param("reportId") Long reportId);
int deleteLegacyExpansionFollowUp(
@Param("bizType") String bizType,
@Param("bizId") Long bizId,
@Param("userId") Long userId,
@Param("followUpTime") java.time.OffsetDateTime followUpTime,
@Param("followUpType") String followUpType);
int deleteDailyReportExpansionFollowUps(
@Param("bizType") String bizType,
@Param("bizId") Long bizId,
@Param("userId") Long userId,
@Param("reportDate") java.time.LocalDate reportDate,
@Param("followUpType") String followUpType);
int deleteLegacyOpportunityFollowUp(
@Param("opportunityId") Long opportunityId,
@Param("userId") Long userId,
@Param("followUpTime") java.time.OffsetDateTime followUpTime,
@Param("followUpType") String followUpType);
int deleteDailyReportOpportunityFollowUps(
@Param("opportunityId") Long opportunityId,
@Param("userId") Long userId,
@Param("reportDate") java.time.LocalDate reportDate,
@Param("followUpType") String followUpType);
int insertGeneratedExpansionFollowUp(
@Param("bizType") String bizType,
@Param("bizId") Long bizId,
@Param("userId") Long userId,
@Param("reportId") Long reportId,
@Param("followUpTime") java.time.OffsetDateTime followUpTime,
@Param("followUpType") String followUpType,
@Param("content") String content,
@Param("nextAction") String nextAction,
@Param("visitStartTime") java.time.OffsetDateTime visitStartTime,
@Param("evaluationContent") String evaluationContent,
@Param("nextPlan") String nextPlan);
int insertGeneratedOpportunityFollowUp(
@Param("opportunityId") Long opportunityId,
@Param("userId") Long userId,
@Param("reportId") Long reportId,
@Param("followUpTime") java.time.OffsetDateTime followUpTime,
@Param("followUpType") String followUpType,
@Param("content") String content,
@Param("nextAction") String nextAction);
int insertLegacyExpansionFollowUp(
@Param("bizType") String bizType,
@Param("bizId") Long bizId,
@Param("userId") Long userId,
@Param("followUpTime") java.time.OffsetDateTime followUpTime,
@Param("followUpType") String followUpType,
@Param("content") String content,
@Param("nextAction") String nextAction,
@Param("visitStartTime") java.time.OffsetDateTime visitStartTime,
@Param("evaluationContent") String evaluationContent,
@Param("nextPlan") String nextPlan);
int insertLegacyOpportunityFollowUp(
@Param("opportunityId") Long opportunityId,
@Param("userId") Long userId,
@Param("followUpTime") java.time.OffsetDateTime followUpTime,
@Param("followUpType") String followUpType,
@Param("content") String content,
@Param("nextAction") String nextAction);
int deleteTodosByBiz(@Param("userId") Long userId, @Param("bizType") String bizType, @Param("bizId") Long bizId);
int deleteTodosByBizType(@Param("userId") Long userId, @Param("bizType") String bizType);
int deletePendingTodosByBiz(@Param("userId") Long userId, @Param("bizType") String bizType, @Param("bizId") Long bizId);
Long selectTodoIdByBiz(@Param("userId") Long userId, @Param("bizType") String bizType, @Param("bizId") Long bizId);
int insertTodo(

View File

@ -5,4 +5,6 @@ import com.unis.crm.dto.dashboard.DashboardHomeDTO;
public interface DashboardService {
DashboardHomeDTO getHome(Long userId);
void completeTodo(Long userId, Long todoId);
}

View File

@ -2,15 +2,20 @@ package com.unis.crm.service;
import com.unis.crm.dto.opportunity.CreateOpportunityFollowUpRequest;
import com.unis.crm.dto.opportunity.CreateOpportunityRequest;
import com.unis.crm.dto.opportunity.OpportunityMetaDTO;
import com.unis.crm.dto.opportunity.OpportunityOverviewDTO;
public interface OpportunityService {
OpportunityMetaDTO getMeta();
OpportunityOverviewDTO getOverview(Long userId, String keyword, String stage);
Long createOpportunity(Long userId, CreateOpportunityRequest request);
Long updateOpportunity(Long userId, Long opportunityId, CreateOpportunityRequest request);
Long pushToOms(Long userId, Long opportunityId);
Long createFollowUp(Long userId, Long opportunityId, CreateOpportunityFollowUpRequest request);
}

View File

@ -2,6 +2,7 @@ package com.unis.crm.service;
import com.unis.crm.dto.work.CreateWorkCheckInRequest;
import com.unis.crm.dto.work.CreateWorkDailyReportRequest;
import com.unis.crm.dto.work.WorkHistoryPageDTO;
import com.unis.crm.dto.work.WorkOverviewDTO;
import java.math.BigDecimal;
import org.springframework.core.io.Resource;
@ -11,6 +12,8 @@ public interface WorkService {
WorkOverviewDTO getOverview(Long userId);
WorkHistoryPageDTO getHistory(Long userId, String type, int page, int size);
Long saveCheckIn(Long userId, CreateWorkCheckInRequest request);
Long saveDailyReport(Long userId, CreateWorkDailyReportRequest request);

View File

@ -36,7 +36,7 @@ public class DashboardServiceImpl implements DashboardService {
}
List<DashboardStatDTO> stats = dashboardMapper.selectDashboardStats(userId);
List<DashboardTodoDTO> todos = dashboardMapper.selectPendingTodos(userId);
List<DashboardTodoDTO> todos = dashboardMapper.selectTodos(userId);
List<DashboardActivityDTO> activities = dashboardMapper.selectLatestActivities(userId);
enrichActivityTimeText(activities);
@ -58,6 +58,20 @@ public class DashboardServiceImpl implements DashboardService {
);
}
@Override
public void completeTodo(Long userId, Long todoId) {
if (userId == null) {
throw new BusinessException("未获取到当前登录用户,禁止操作他人数据");
}
if (todoId == null || todoId <= 0) {
throw new BusinessException("待办不存在");
}
int updated = dashboardMapper.markTodoDone(userId, todoId);
if (updated <= 0) {
throw new BusinessException("待办不存在或已完成");
}
}
private void enrichActivityTimeText(List<DashboardActivityDTO> activities) {
if (activities == null || activities.isEmpty()) {
return;

View File

@ -2,12 +2,16 @@ package com.unis.crm.service.impl;
import com.unis.crm.common.BusinessException;
import com.unis.crm.dto.expansion.ChannelExpansionItemDTO;
import com.unis.crm.dto.expansion.ChannelExpansionContactRequest;
import com.unis.crm.dto.expansion.ChannelRelatedProjectSummaryDTO;
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.ExpansionMetaDTO;
import com.unis.crm.dto.expansion.ExpansionFollowUpDTO;
import com.unis.crm.dto.expansion.ExpansionOverviewDTO;
import com.unis.crm.dto.expansion.RelatedProjectSummaryDTO;
import com.unis.crm.dto.expansion.SalesExpansionItemDTO;
import com.unis.crm.dto.expansion.UpdateChannelExpansionRequest;
import com.unis.crm.dto.expansion.UpdateSalesExpansionRequest;
@ -18,6 +22,7 @@ import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.ArrayList;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -26,6 +31,10 @@ import org.springframework.stereotype.Service;
@Service
public class ExpansionServiceImpl implements ExpansionService {
private static final String OFFICE_TYPE_CODE = "tz_bsc";
private static final String INDUSTRY_TYPE_CODE = "tz_sshy";
private static final String CHANNEL_ATTRIBUTE_TYPE_CODE = "tz_qdsx";
private static final String INTERNAL_ATTRIBUTE_TYPE_CODE = "tz_xhsnbsx";
private static final DateTimeFormatter FOLLOW_UP_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
private static final Logger log = LoggerFactory.getLogger(ExpansionServiceImpl.class);
@ -38,10 +47,20 @@ public class ExpansionServiceImpl implements ExpansionService {
@Override
public ExpansionMetaDTO getMeta() {
try {
return new ExpansionMetaDTO(expansionMapper.selectDepartments());
return new ExpansionMetaDTO(
loadDictOptions(OFFICE_TYPE_CODE),
loadDictOptions(INDUSTRY_TYPE_CODE),
loadDictOptions(CHANNEL_ATTRIBUTE_TYPE_CODE),
loadDictOptions(INTERNAL_ATTRIBUTE_TYPE_CODE),
expansionMapper.selectNextChannelCode());
} catch (Exception ex) {
log.warn("Failed to load expansion departments, fallback to empty list", ex);
return new ExpansionMetaDTO(Collections.emptyList());
log.warn("Failed to load expansion dict options, fallback to empty list", ex);
return new ExpansionMetaDTO(
Collections.emptyList(),
Collections.emptyList(),
Collections.emptyList(),
Collections.emptyList(),
null);
}
}
@ -52,7 +71,10 @@ public class ExpansionServiceImpl implements ExpansionService {
List<ChannelExpansionItemDTO> channelItems = expansionMapper.selectChannelExpansions(userId, normalizedKeyword);
attachSalesFollowUps(userId, salesItems);
attachSalesRelatedProjects(userId, salesItems);
attachChannelFollowUps(userId, channelItems);
attachChannelContacts(userId, channelItems);
attachChannelRelatedProjects(userId, channelItems);
return new ExpansionOverviewDTO(salesItems, channelItems);
}
@ -60,6 +82,7 @@ public class ExpansionServiceImpl implements ExpansionService {
@Override
public Long createSalesExpansion(Long userId, CreateSalesExpansionRequest request) {
fillSalesDefaults(request);
ensureUniqueEmployeeNo(userId, request.getEmployeeNo(), null);
expansionMapper.insertSalesExpansion(userId, request);
if (request.getId() == null) {
throw new BusinessException("销售拓展新增失败");
@ -74,12 +97,14 @@ public class ExpansionServiceImpl implements ExpansionService {
if (request.getId() == null) {
throw new BusinessException("渠道拓展新增失败");
}
replaceChannelContacts(request.getId(), request.getContacts());
return request.getId();
}
@Override
public void updateSalesExpansion(Long userId, Long id, UpdateSalesExpansionRequest request) {
fillSalesDefaults(request);
ensureUniqueEmployeeNo(userId, request.getEmployeeNo(), id);
int updated = expansionMapper.updateSalesExpansion(userId, id, request);
if (updated <= 0) {
throw new BusinessException("未找到可编辑的销售拓展记录");
@ -93,6 +118,7 @@ public class ExpansionServiceImpl implements ExpansionService {
if (updated <= 0) {
throw new BusinessException("未找到可编辑的渠道拓展记录");
}
replaceChannelContacts(id, request.getContacts());
}
@Override
@ -142,8 +168,63 @@ public class ExpansionServiceImpl implements ExpansionService {
}
}
private void attachChannelContacts(Long userId, List<ChannelExpansionItemDTO> channelItems) {
List<Long> bizIds = channelItems.stream()
.map(ChannelExpansionItemDTO::getId)
.filter(Objects::nonNull)
.toList();
if (bizIds.isEmpty()) {
return;
}
Map<Long, List<com.unis.crm.dto.expansion.ChannelExpansionContactDTO>> byChannelId = expansionMapper
.selectChannelContacts(userId, bizIds)
.stream()
.collect(Collectors.groupingBy(com.unis.crm.dto.expansion.ChannelExpansionContactDTO::getChannelExpansionId));
for (ChannelExpansionItemDTO item : channelItems) {
item.setContacts(byChannelId.getOrDefault(item.getId(), Collections.emptyList()));
}
}
private void attachChannelRelatedProjects(Long userId, List<ChannelExpansionItemDTO> channelItems) {
List<Long> bizIds = channelItems.stream()
.map(ChannelExpansionItemDTO::getId)
.filter(Objects::nonNull)
.toList();
if (bizIds.isEmpty()) {
return;
}
Map<Long, List<ChannelRelatedProjectSummaryDTO>> grouped = expansionMapper
.selectChannelRelatedProjects(userId, bizIds)
.stream()
.collect(Collectors.groupingBy(ChannelRelatedProjectSummaryDTO::getChannelExpansionId));
for (ChannelExpansionItemDTO item : channelItems) {
item.setRelatedProjects(grouped.getOrDefault(item.getId(), Collections.emptyList()));
}
}
private void attachSalesRelatedProjects(Long userId, List<SalesExpansionItemDTO> salesItems) {
List<Long> bizIds = salesItems.stream()
.map(SalesExpansionItemDTO::getId)
.filter(Objects::nonNull)
.toList();
if (bizIds.isEmpty()) {
return;
}
Map<Long, List<RelatedProjectSummaryDTO>> grouped = expansionMapper.selectSalesRelatedProjects(userId, bizIds).stream()
.collect(Collectors.groupingBy(RelatedProjectSummaryDTO::getSalesExpansionId));
for (SalesExpansionItemDTO item : salesItems) {
item.setRelatedProjects(grouped.getOrDefault(item.getId(), Collections.emptyList()));
}
}
private void fillFollowUpDisplayFields(ExpansionFollowUpDTO followUp) {
if (followUp.getFollowUpTime() != null) {
if (isBlank(followUp.getDate()) && followUp.getFollowUpTime() != null) {
followUp.setDate(followUp.getFollowUpTime().format(FOLLOW_UP_TIME_FORMATTER));
}
if (isBlank(followUp.getType())) {
@ -155,6 +236,19 @@ public class ExpansionServiceImpl implements ExpansionService {
if (isBlank(followUp.getUser())) {
followUp.setUser("无");
}
if (isBlank(followUp.getVisitStartTime())) {
followUp.setVisitStartTime("无");
}
if (isBlank(followUp.getEvaluationContent())) {
followUp.setEvaluationContent("无");
}
if (isBlank(followUp.getNextPlan())) {
followUp.setNextPlan("无");
}
}
private List<DictOptionDTO> loadDictOptions(String typeCode) {
return expansionMapper.selectDictItems(typeCode);
}
private String normalizeKeyword(String keyword) {
@ -169,7 +263,29 @@ public class ExpansionServiceImpl implements ExpansionService {
return value == null || value.trim().isEmpty();
}
private String normalizeRequiredText(String value, String message) {
if (value == null) {
throw new BusinessException(message);
}
String trimmed = value.trim();
if (trimmed.isEmpty()) {
throw new BusinessException(message);
}
return trimmed;
}
private void ensureUniqueEmployeeNo(Long userId, String employeeNo, Long excludeId) {
int count = excludeId == null
? expansionMapper.countSalesExpansionByEmployeeNo(userId, employeeNo)
: expansionMapper.countSalesExpansionByEmployeeNoExcludingId(userId, employeeNo, excludeId);
if (count > 0) {
throw new BusinessException("该工号已存在,请检查后再提交");
}
}
private void fillSalesDefaults(CreateSalesExpansionRequest request) {
request.setEmployeeNo(normalizeRequiredText(request.getEmployeeNo(), "工号不能为空"));
request.setCandidateName(normalizeRequiredText(request.getCandidateName(), "候选人姓名不能为空"));
if (isBlank(request.getIntentLevel())) {
request.setIntentLevel("medium");
}
@ -188,6 +304,8 @@ public class ExpansionServiceImpl implements ExpansionService {
}
private void fillSalesDefaults(UpdateSalesExpansionRequest request) {
request.setEmployeeNo(normalizeRequiredText(request.getEmployeeNo(), "工号不能为空"));
request.setCandidateName(normalizeRequiredText(request.getCandidateName(), "候选人姓名不能为空"));
if (isBlank(request.getIntentLevel())) {
request.setIntentLevel("medium");
}
@ -206,21 +324,83 @@ public class ExpansionServiceImpl implements ExpansionService {
}
private void fillChannelDefaults(CreateChannelExpansionRequest request) {
request.setChannelName(normalizeRequiredText(request.getChannelName(), "渠道名称不能为空"));
if (isBlank(request.getStage())) {
request.setStage("initial_contact");
}
if (request.getLandedFlag() == null) {
request.setLandedFlag(Boolean.FALSE);
}
if (isBlank(request.getIntentLevel())) {
request.setIntentLevel("medium");
}
if (request.getHasDesktopExp() == null) {
request.setHasDesktopExp(Boolean.FALSE);
}
request.setContacts(normalizeContacts(request.getContacts()));
}
private void fillChannelDefaults(UpdateChannelExpansionRequest request) {
request.setChannelName(normalizeRequiredText(request.getChannelName(), "渠道名称不能为空"));
if (isBlank(request.getStage())) {
request.setStage("initial_contact");
}
if (request.getLandedFlag() == null) {
request.setLandedFlag(Boolean.FALSE);
}
if (isBlank(request.getIntentLevel())) {
request.setIntentLevel("medium");
}
if (request.getHasDesktopExp() == null) {
request.setHasDesktopExp(Boolean.FALSE);
}
request.setContacts(normalizeContacts(request.getContacts()));
}
private List<ChannelExpansionContactRequest> normalizeContacts(List<ChannelExpansionContactRequest> contacts) {
if (contacts == null || contacts.isEmpty()) {
return new ArrayList<>();
}
return contacts.stream()
.map(this::normalizeContact)
.filter(Objects::nonNull)
.toList();
}
private ChannelExpansionContactRequest normalizeContact(ChannelExpansionContactRequest contact) {
if (contact == null) {
return null;
}
String name = trimToNull(contact.getName());
String mobile = trimToNull(contact.getMobile());
String title = trimToNull(contact.getTitle());
if (name == null && mobile == null && title == null) {
return null;
}
contact.setName(name);
contact.setMobile(mobile);
contact.setTitle(title);
return contact;
}
private void replaceChannelContacts(Long channelExpansionId, List<ChannelExpansionContactRequest> contacts) {
expansionMapper.deleteChannelContacts(channelExpansionId);
if (contacts == null || contacts.isEmpty()) {
return;
}
for (int index = 0; index < contacts.size(); index++) {
expansionMapper.insertChannelContact(channelExpansionId, index + 1, contacts.get(index));
}
}
private String trimToNull(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private String normalizeBizType(String bizType) {

View File

@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.unis.crm.common.BusinessException;
import com.unis.crm.dto.opportunity.CreateOpportunityFollowUpRequest;
import com.unis.crm.dto.opportunity.CreateOpportunityRequest;
import com.unis.crm.dto.opportunity.OpportunityMetaDTO;
import com.unis.crm.dto.opportunity.OpportunityFollowUpDTO;
import com.unis.crm.dto.opportunity.OpportunityItemDTO;
import com.unis.crm.dto.opportunity.OpportunityOverviewDTO;
@ -19,12 +20,22 @@ import org.springframework.stereotype.Service;
@Service
public class OpportunityServiceImpl implements OpportunityService {
private static final String STAGE_TYPE_CODE = "sj_xmjd";
private static final String OPERATOR_TYPE_CODE = "sj_yzf";
private final OpportunityMapper opportunityMapper;
public OpportunityServiceImpl(OpportunityMapper opportunityMapper) {
this.opportunityMapper = opportunityMapper;
}
@Override
public OpportunityMetaDTO getMeta() {
return new OpportunityMetaDTO(
opportunityMapper.selectDictItems(STAGE_TYPE_CODE),
opportunityMapper.selectDictItems(OPERATOR_TYPE_CODE));
}
@Override
public OpportunityOverviewDTO getOverview(Long userId, String keyword, String stage) {
String normalizedKeyword = normalizeKeyword(keyword);
@ -58,6 +69,9 @@ public class OpportunityServiceImpl implements OpportunityService {
if (opportunityMapper.countOwnedOpportunity(userId, opportunityId) <= 0) {
throw new BusinessException("无权编辑该商机");
}
if (Boolean.TRUE.equals(opportunityMapper.selectPushedToOms(userId, opportunityId))) {
throw new BusinessException("该商机已推送 OMS不能再编辑");
}
fillDefaults(request);
Long customerId = opportunityMapper.selectOwnedCustomerIdByName(userId, request.getCustomerName().trim());
@ -73,6 +87,24 @@ public class OpportunityServiceImpl implements OpportunityService {
return opportunityId;
}
@Override
public Long pushToOms(Long userId, Long opportunityId) {
if (opportunityId == null || opportunityId <= 0) {
throw new BusinessException("商机不存在");
}
if (opportunityMapper.countOwnedOpportunity(userId, opportunityId) <= 0) {
throw new BusinessException("无权操作该商机");
}
if (Boolean.TRUE.equals(opportunityMapper.selectPushedToOms(userId, opportunityId))) {
throw new BusinessException("该商机已推送 OMS请勿重复操作");
}
int updated = opportunityMapper.pushOpportunityToOms(userId, opportunityId);
if (updated <= 0) {
throw new BusinessException("推送 OMS 失败");
}
return opportunityId;
}
@Override
public Long createFollowUp(Long userId, Long opportunityId, CreateOpportunityFollowUpRequest request) {
if (opportunityId == null || opportunityId <= 0) {
@ -115,6 +147,10 @@ public class OpportunityServiceImpl implements OpportunityService {
if (isBlank(followUp.getContent())) {
followUp.setContent("无");
}
applyStructuredFollowUpSummary(followUp);
if (isBlank(followUp.getNextAction())) {
followUp.setNextAction("无");
}
if (isBlank(followUp.getUser())) {
followUp.setUser("无");
}
@ -123,16 +159,86 @@ public class OpportunityServiceImpl implements OpportunityService {
}
}
private void applyStructuredFollowUpSummary(OpportunityFollowUpDTO followUp) {
if (followUp == null) {
return;
}
String latestProgress = firstNonBlank(
followUp.getLatestProgress(),
extractFollowUpField(followUp.getContent(), "项目最新进展"));
if (!isBlank(latestProgress)) {
followUp.setLatestProgress(latestProgress);
}
CommunicationRecord communicationRecord = parseCommunicationRecord(
extractFollowUpField(followUp.getContent(), "交流记录"));
if (!isBlank(communicationRecord.time())) {
followUp.setCommunicationTime(communicationRecord.time());
}
if (!isBlank(communicationRecord.content())) {
followUp.setCommunicationContent(communicationRecord.content());
}
if (isBlank(followUp.getNextAction())) {
String nextPlan = extractFollowUpField(followUp.getContent(), "后续规划");
if (!isBlank(nextPlan)) {
followUp.setNextAction(nextPlan);
}
}
}
private String extractFollowUpField(String content, String label) {
if (isBlank(content) || isBlank(label)) {
return null;
}
String normalizedContent = content.replace("\r", "");
String prefix = label + "";
int startIndex = normalizedContent.indexOf(prefix);
if (startIndex < 0) {
return null;
}
int valueStart = startIndex + prefix.length();
int lineEnd = normalizedContent.indexOf('\n', valueStart);
String value = lineEnd >= 0
? normalizedContent.substring(valueStart, lineEnd)
: normalizedContent.substring(valueStart);
return normalizeOptionalText(value);
}
private CommunicationRecord parseCommunicationRecord(String rawRecord) {
String normalizedRecord = normalizeOptionalText(rawRecord);
if (normalizedRecord == null) {
return new CommunicationRecord(null, null);
}
String[] tokens = normalizedRecord.split("\\s+", 2);
if (tokens.length == 2 && isDateTimeToken(tokens[0])) {
return new CommunicationRecord(tokens[0], normalizeOptionalText(tokens[1]));
}
return new CommunicationRecord(null, normalizedRecord);
}
private boolean isDateTimeToken(String value) {
if (isBlank(value)) {
return false;
}
return value.matches("\\d{4}-\\d{2}-\\d{2}[T\\s]\\d{2}:\\d{2}");
}
private void fillDefaults(CreateOpportunityRequest request) {
request.setCustomerName(request.getCustomerName().trim());
request.setOpportunityName(request.getOpportunityName().trim());
request.setProjectLocation(normalizeOptionalText(request.getProjectLocation()));
request.setOperatorName(normalizeOptionalText(request.getOperatorName()));
request.setCompetitorName(normalizeOptionalText(request.getCompetitorName()));
request.setDescription(normalizeOptionalText(request.getDescription()));
if (request.getExpectedCloseDate() == null) {
throw new BusinessException("预计结单日期不能为空");
}
if (isBlank(request.getStage())) {
request.setStage("initial_contact");
} else {
request.setStage(toStageCode(request.getStage()));
request.setStage(normalizeStageValue(request.getStage()));
}
if (isBlank(request.getOpportunityType())) {
request.setOpportunityType("新建");
@ -167,13 +273,33 @@ public class OpportunityServiceImpl implements OpportunityService {
if (trimmed.isEmpty() || "全部".equals(trimmed)) {
return null;
}
return toStageCode(trimmed);
return normalizeStageValue(trimmed);
}
private boolean isBlank(String value) {
return value == null || value.trim().isEmpty();
}
private String normalizeOptionalText(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private String firstNonBlank(String... values) {
if (values == null) {
return null;
}
for (String value : values) {
if (!isBlank(value)) {
return value;
}
}
return null;
}
private String toStageCode(String value) {
return switch (value) {
case "初步沟通", "initial_contact" -> "initial_contact";
@ -182,7 +308,29 @@ public class OpportunityServiceImpl implements OpportunityService {
case "商务谈判", "business_negotiation" -> "business_negotiation";
case "已成交", "won" -> "won";
case "已放弃", "lost" -> "lost";
default -> throw new BusinessException("不支持的商机阶段");
default -> value;
};
}
private String normalizeStageValue(String value) {
String trimmed = value == null ? null : value.trim();
if (trimmed == null || trimmed.isEmpty()) {
return "initial_contact";
}
String dictLabel = opportunityMapper.selectDictLabel(STAGE_TYPE_CODE, trimmed);
if (!isBlank(dictLabel)) {
return trimmed;
}
String directCode = toStageCode(trimmed);
if (!Objects.equals(directCode, trimmed)) {
return directCode;
}
throw new BusinessException("项目阶段无效: " + trimmed);
}
private record CommunicationRecord(String time, String content) {
}
}

View File

@ -47,6 +47,8 @@ unisbase:
app:
upload-path: /Users/kangwenjing/Downloads/crm/uploads
resource-prefix: /sys/api/static/
tencent-map:
key: ${TENCENT_MAP_KEY:LJYBZ-HCQCV-N37PU-5FIOX-QFA26-FPB6U}
captcha:
ttl-seconds: 120
max-attempts: 5

View File

@ -52,6 +52,8 @@ unisbase:
app:
upload-path: /Users/kangwenjing/Downloads/crm/uploads
resource-prefix: /sys/api/static/
tencent-map:
key: ${TENCENT_MAP_KEY:LJYBZ-HCQCV-N37PU-5FIOX-QFA26-FPB6U}
captcha:
ttl-seconds: 120
max-attempts: 5

View File

@ -16,10 +16,28 @@
select
u.user_id as userId,
u.display_name as realName,
null as jobTitle,
null as deptName,
null as hireDate
role_info.role_names as jobTitle,
org_info.org_names as deptName,
coalesce(org_info.joined_date, u.created_at::date) as hireDate
from sys_user u
left join lateral (
select string_agg(distinct r.role_name, '、' order by r.role_name) as role_names
from sys_user_role ur
join sys_role r on r.role_id = ur.role_id
where ur.user_id = u.user_id
and ur.is_deleted = 0
and r.is_deleted = 0
) role_info on true
left join lateral (
select
string_agg(distinct o.org_name, '、' order by o.org_name) as org_names,
min(tu.created_at)::date as joined_date
from sys_tenant_user tu
join sys_org o on o.id = tu.org_id
where tu.user_id = u.user_id
and tu.is_deleted = 0
and o.is_deleted = 0
) org_info on true
where u.user_id = #{userId}
and u.status = 1
limit 1
@ -35,33 +53,33 @@
union all
select '跟进中客户' as name,
select '已推送OMS项目' as name,
count(1)::bigint as value,
'followingCustomers' as metricKey
from crm_customer
where owner_user_id = #{userId}
and status = 'following'
union all
select '已成单项目' as name,
count(1)::bigint as value,
'wonProjects' as metricKey
'pushedOmsProjects' as metricKey
from crm_opportunity
where owner_user_id = #{userId}
and stage = 'won'
and coalesce(pushed_to_oms, false) = true
union all
select '本月打卡天数' as name,
count(distinct checkin_date)::bigint as value,
select '本月新增渠道' as name,
count(1)::bigint as value,
'monthlyChannels' as metricKey
from crm_channel_expansion
where owner_user_id = #{userId}
and date_trunc('month', created_at) = date_trunc('month', now())
union all
select '本月打卡次数' as name,
count(1)::bigint as value,
'monthlyCheckins' as metricKey
from work_checkin
where user_id = #{userId}
and date_trunc('month', checkin_date::timestamp) = date_trunc('month', now())
</select>
<select id="selectPendingTodos" resultType="com.unis.crm.dto.dashboard.DashboardTodoDTO">
<select id="selectTodos" resultType="com.unis.crm.dto.dashboard.DashboardTodoDTO">
select
id,
title,
@ -70,20 +88,37 @@
priority,
status,
due_date as dueDate,
created_at as createdAt
created_at as createdAt,
updated_at as updatedAt
from work_todo
where user_id = #{userId}
and status = 'todo'
and status in ('todo', 'done')
order by
case status
when 'todo' then 1
else 2
end,
case priority
when 'high' then 1
when 'medium' then 2
else 3
end,
coalesce(due_date, created_at) asc
limit 6
case
when status = 'todo' then coalesce(due_date, created_at)
else coalesce(updated_at, created_at)
end asc,
id desc
</select>
<update id="markTodoDone">
update work_todo
set status = 'done',
updated_at = now()
where id = #{todoId}
and user_id = #{userId}
and status in ('todo', 'done')
</update>
<select id="selectLatestActivities" resultType="com.unis.crm.dto.dashboard.DashboardActivityDTO">
with latest_report_comment as (
select distinct on (c.report_id)

View File

@ -4,25 +4,41 @@
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.unis.crm.mapper.ExpansionMapper">
<select id="selectDepartments" resultType="com.unis.crm.dto.expansion.DepartmentOptionDTO">
<select id="selectDictItems" resultType="com.unis.crm.dto.expansion.DictOptionDTO">
select
id,
org_name as name
from sys_org
where status = 1
order by id asc
item_label as label,
item_value as value
from sys_dict_item
where type_code = #{typeCode}
and status = 1
and coalesce(is_deleted, 0) = 0
order by sort_order asc nulls last, dict_item_id asc
</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>
<select id="selectSalesExpansions" resultType="com.unis.crm.dto.expansion.SalesExpansionItemDTO">
select
s.id,
'sales' as type,
coalesce(s.employee_no, '无') as employeeNo,
s.candidate_name as name,
coalesce(office_dict.item_value, s.office_name, '') as officeCode,
coalesce(office_dict.item_label, nullif(s.office_name, ''), '无') as officeName,
coalesce(s.mobile, '无') as phone,
coalesce(s.email, '无') as email,
s.target_dept_id as targetDeptId,
'无' as dept,
coalesce(s.industry, '无') as industry,
coalesce(s.target_dept, '') as targetDept,
coalesce(nullif(s.target_dept, ''), '无') as dept,
coalesce(industry_dict.item_value, s.industry, '') as industryCode,
coalesce(industry_dict.item_label, nullif(s.industry, ''), '无') as industry,
coalesce(s.title, '无') as title,
s.intent_level as intentLevel,
case s.intent_level
@ -48,11 +64,24 @@
coalesce(to_char(s.expected_join_date, 'YYYY-MM-DD'), '无') as expectedJoinDate,
coalesce(s.remark, '无') as notes
from crm_sales_expansion s
left join sys_dict_item office_dict
on office_dict.type_code = 'tz_bsc'
and office_dict.item_value = s.office_name
and office_dict.status = 1
and coalesce(office_dict.is_deleted, 0) = 0
left join sys_dict_item industry_dict
on industry_dict.type_code = 'tz_sshy'
and industry_dict.item_value = s.industry
and industry_dict.status = 1
and coalesce(industry_dict.is_deleted, 0) = 0
where s.owner_user_id = #{userId}
<if test="keyword != null and keyword != ''">
and (
s.candidate_name ilike concat('%', #{keyword}, '%')
or coalesce(s.industry, '') ilike concat('%', #{keyword}, '%')
coalesce(s.employee_no, '') ilike concat('%', #{keyword}, '%')
or s.candidate_name ilike concat('%', #{keyword}, '%')
or coalesce(office_dict.item_label, '') ilike concat('%', #{keyword}, '%')
or coalesce(s.target_dept, '') ilike concat('%', #{keyword}, '%')
or coalesce(industry_dict.item_label, '') ilike concat('%', #{keyword}, '%')
)
</if>
order by s.updated_at desc, s.id desc
@ -62,9 +91,11 @@
select
c.id,
'channel' as type,
coalesce(c.channel_code, '') as channelCode,
c.channel_name as name,
coalesce(c.province, '无') as province,
coalesce(c.industry, '无') as industry,
coalesce(c.office_address, '无') as officeAddress,
coalesce(c.channel_industry, c.industry, '无') as channelIndustry,
coalesce(cast(c.annual_revenue as varchar), '') as annualRevenue,
case
when c.annual_revenue is null then '无'
@ -72,9 +103,20 @@
else trim(to_char(c.annual_revenue, 'FM999999990.##'))
end as revenue,
coalesce(c.staff_size, 0) as size,
coalesce(c.contact_name, '无') as contact,
coalesce(c.contact_title, '无') as contactTitle,
coalesce(c.contact_mobile, '无') as phone,
coalesce(primary_contact.contact_name, c.contact_name, '无') as primaryContactName,
coalesce(primary_contact.contact_title, c.contact_title, '无') as primaryContactTitle,
coalesce(primary_contact.contact_mobile, c.contact_mobile, '无') as primaryContactMobile,
coalesce(to_char(c.contact_established_date, 'YYYY-MM-DD'), '无') as establishedDate,
c.intent_level as intentLevel,
case c.intent_level
when 'high' then '高'
when 'medium' then '中'
when 'low' then '低'
else '无'
end as intent,
coalesce(c.has_desktop_exp, false) as hasDesktopExp,
coalesce(channel_attribute_dict.item_label, c.channel_attribute, '无') as channelAttribute,
coalesce(internal_attribute_dict.item_label, c.internal_attribute, '无') as internalAttribute,
c.stage as stageCode,
case c.stage
when 'initial_contact' then '初步接触'
@ -89,12 +131,43 @@
coalesce(to_char(c.expected_sign_date, 'YYYY-MM-DD'), '无') as expectedSignDate,
coalesce(c.remark, '无') as notes
from crm_channel_expansion c
left join lateral (
select
contact_name,
contact_title,
contact_mobile
from crm_channel_expansion_contact cc
where cc.channel_expansion_id = c.id
order by cc.sort_order asc nulls last, cc.id asc
limit 1
) primary_contact on true
left join sys_dict_item channel_attribute_dict
on channel_attribute_dict.type_code = 'tz_qdsx'
and channel_attribute_dict.item_value = c.channel_attribute
and channel_attribute_dict.status = 1
and coalesce(channel_attribute_dict.is_deleted, 0) = 0
left join sys_dict_item internal_attribute_dict
on internal_attribute_dict.type_code = 'tz_xhsnbsx'
and internal_attribute_dict.item_value = c.internal_attribute
and internal_attribute_dict.status = 1
and coalesce(internal_attribute_dict.is_deleted, 0) = 0
where c.owner_user_id = #{userId}
<if test="keyword != null and keyword != ''">
and (
c.channel_name ilike concat('%', #{keyword}, '%')
or coalesce(c.industry, '') ilike concat('%', #{keyword}, '%')
or coalesce(c.channel_code, '') ilike concat('%', #{keyword}, '%')
or coalesce(c.channel_industry, c.industry, '') ilike concat('%', #{keyword}, '%')
or coalesce(c.province, '') ilike concat('%', #{keyword}, '%')
or exists (
select 1
from crm_channel_expansion_contact cc
where cc.channel_expansion_id = c.id
and (
coalesce(cc.contact_name, '') ilike concat('%', #{keyword}, '%')
or coalesce(cc.contact_mobile, '') ilike concat('%', #{keyword}, '%')
or coalesce(cc.contact_title, '') ilike concat('%', #{keyword}, '%')
)
)
)
</if>
order by c.updated_at desc, c.id desc
@ -106,9 +179,13 @@
f.biz_id as bizId,
f.biz_type as bizType,
f.followup_time as followUpTime,
to_char(f.followup_time, 'YYYY-MM-DD HH24:MI') as date,
f.followup_type as type,
coalesce(f.content, '无') as content,
coalesce(u.display_name, '无') as user
coalesce(u.display_name, '无') as user,
coalesce(to_char(f.visit_start_time, 'YYYY-MM-DD HH24:MI'), '无') as visitStartTime,
coalesce(f.evaluation_content, '无') as evaluationContent,
coalesce(f.next_plan, '无') as nextPlan
from crm_expansion_followup f
join crm_sales_expansion s on s.id = f.biz_id and f.biz_type = 'sales'
left join sys_user u on u.user_id = f.followup_user_id
@ -120,15 +197,35 @@
order by f.followup_time desc, f.id desc
</select>
<select id="selectSalesRelatedProjects" resultType="com.unis.crm.dto.expansion.RelatedProjectSummaryDTO">
select
o.sales_expansion_id as salesExpansionId,
o.id as opportunityId,
o.opportunity_code as opportunityCode,
o.opportunity_name as opportunityName,
o.amount
from crm_opportunity o
where o.owner_user_id = #{userId}
and o.sales_expansion_id in
<foreach collection="bizIds" item="id" open="(" separator="," close=")">
#{id}
</foreach>
order by coalesce(o.updated_at, o.created_at) desc, o.id desc
</select>
<select id="selectChannelFollowUps" resultType="com.unis.crm.dto.expansion.ExpansionFollowUpDTO">
select
f.id,
f.biz_id as bizId,
f.biz_type as bizType,
f.followup_time as followUpTime,
to_char(f.followup_time, 'YYYY-MM-DD HH24:MI') as date,
f.followup_type as type,
coalesce(f.content, '无') as content,
coalesce(u.display_name, '无') as user
coalesce(u.display_name, '无') as user,
coalesce(to_char(f.visit_start_time, 'YYYY-MM-DD HH24:MI'), '无') as visitStartTime,
coalesce(f.evaluation_content, '无') as evaluationContent,
coalesce(f.next_plan, '无') as nextPlan
from crm_expansion_followup f
join crm_channel_expansion c on c.id = f.biz_id and f.biz_type = 'channel'
left join sys_user u on u.user_id = f.followup_user_id
@ -140,12 +237,47 @@
order by f.followup_time desc, f.id desc
</select>
<select id="selectChannelContacts" resultType="com.unis.crm.dto.expansion.ChannelExpansionContactDTO">
select
cc.id,
cc.channel_expansion_id as channelExpansionId,
coalesce(cc.contact_name, '无') as name,
coalesce(cc.contact_mobile, '无') as mobile,
coalesce(cc.contact_title, '无') as title
from crm_channel_expansion_contact cc
join crm_channel_expansion c on c.id = cc.channel_expansion_id
where c.owner_user_id = #{userId}
and cc.channel_expansion_id in
<foreach collection="bizIds" item="id" open="(" separator="," close=")">
#{id}
</foreach>
order by cc.sort_order asc nulls last, cc.id asc
</select>
<select id="selectChannelRelatedProjects" resultType="com.unis.crm.dto.expansion.ChannelRelatedProjectSummaryDTO">
select
o.channel_expansion_id as channelExpansionId,
o.id as opportunityId,
o.opportunity_code as opportunityCode,
o.opportunity_name as opportunityName,
o.amount
from crm_opportunity o
where o.owner_user_id = #{userId}
and o.channel_expansion_id in
<foreach collection="bizIds" item="id" open="(" separator="," close=")">
#{id}
</foreach>
order by coalesce(o.updated_at, o.created_at) desc, o.id desc
</select>
<insert id="insertSalesExpansion" useGeneratedKeys="true" keyProperty="request.id">
insert into crm_sales_expansion (
employee_no,
candidate_name,
office_name,
mobile,
email,
target_dept_id,
target_dept,
industry,
title,
intent_level,
@ -157,10 +289,12 @@
owner_user_id,
remark
) values (
#{request.employeeNo},
#{request.candidateName},
#{request.officeName},
#{request.mobile},
#{request.email},
#{request.targetDeptId},
#{request.targetDept},
#{request.industry},
#{request.title},
#{request.intentLevel},
@ -176,28 +310,48 @@
<insert id="insertChannelExpansion" useGeneratedKeys="true" keyProperty="request.id">
insert into crm_channel_expansion (
channel_name,
channel_code,
province,
industry,
channel_name,
office_address,
channel_industry,
annual_revenue,
staff_size,
contact_established_date,
intent_level,
has_desktop_exp,
contact_name,
contact_title,
contact_mobile,
channel_attribute,
internal_attribute,
stage,
landed_flag,
expected_sign_date,
owner_user_id,
remark
) values (
#{request.channelName},
'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'),
#{request.province},
#{request.industry},
#{request.channelName},
#{request.officeAddress},
#{request.channelIndustry},
#{request.annualRevenue},
#{request.staffSize},
#{request.contactName},
#{request.contactTitle},
#{request.contactMobile},
#{request.contactEstablishedDate},
#{request.intentLevel},
#{request.hasDesktopExp},
null,
null,
null,
#{request.channelAttribute},
#{request.internalAttribute},
#{request.stage},
#{request.landedFlag},
#{request.expectedSignDate},
@ -206,12 +360,39 @@
)
</insert>
<insert id="insertChannelContact">
insert into crm_channel_expansion_contact (
channel_expansion_id,
contact_name,
contact_mobile,
contact_title,
sort_order,
created_at,
updated_at
) values (
#{channelExpansionId},
#{contact.name},
#{contact.mobile},
#{contact.title},
#{sortOrder},
now(),
now()
)
</insert>
<delete id="deleteChannelContacts">
delete from crm_channel_expansion_contact
where channel_expansion_id = #{channelExpansionId}
</delete>
<update id="updateSalesExpansion">
update crm_sales_expansion
set candidate_name = #{request.candidateName},
set employee_no = #{request.employeeNo},
candidate_name = #{request.candidateName},
office_name = #{request.officeName},
mobile = #{request.mobile},
email = #{request.email},
target_dept_id = #{request.targetDeptId},
target_dept = #{request.targetDept},
industry = #{request.industry},
title = #{request.title},
intent_level = #{request.intentLevel},
@ -225,16 +406,37 @@
and owner_user_id = #{userId}
</update>
<select id="countSalesExpansionByEmployeeNo" resultType="int">
select count(1)
from crm_sales_expansion
where owner_user_id = #{userId}
and 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}
and id &lt;&gt; #{excludeId}
</select>
<update id="updateChannelExpansion">
update crm_channel_expansion
set channel_name = #{request.channelName},
province = #{request.province},
industry = #{request.industry},
office_address = #{request.officeAddress},
channel_industry = #{request.channelIndustry},
annual_revenue = #{request.annualRevenue},
staff_size = #{request.staffSize},
contact_name = #{request.contactName},
contact_title = #{request.contactTitle},
contact_mobile = #{request.contactMobile},
contact_established_date = #{request.contactEstablishedDate},
intent_level = #{request.intentLevel},
has_desktop_exp = #{request.hasDesktopExp},
contact_name = null,
contact_title = null,
contact_mobile = null,
channel_attribute = #{request.channelAttribute},
internal_attribute = #{request.internalAttribute},
stage = #{request.stage},
landed_flag = #{request.landedFlag},
expected_sign_date = #{request.expectedSignDate},
@ -265,7 +467,10 @@
followup_type,
content,
next_action,
followup_user_id
followup_user_id,
visit_start_time,
evaluation_content,
next_plan
) values (
#{bizType},
#{bizId},
@ -273,7 +478,10 @@
#{request.followUpType},
#{request.content},
#{request.nextAction},
#{userId}
#{userId},
#{request.visitStartTime},
#{request.evaluationContent},
#{request.nextPlan}
)
</insert>
</mapper>

View File

@ -4,16 +4,44 @@
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.unis.crm.mapper.OpportunityMapper">
<select id="selectDictItems" resultType="com.unis.crm.dto.opportunity.OpportunityDictOptionDTO">
select
item_label as label,
item_value as value
from sys_dict_item
where type_code = #{typeCode}
and status = 1
and coalesce(is_deleted, 0) = 0
order by sort_order asc nulls last, dict_item_id asc
</select>
<select id="selectDictLabel" resultType="java.lang.String">
select item_label
from sys_dict_item
where type_code = #{typeCode}
and item_value = #{itemValue}
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
o.id,
o.opportunity_code as code,
o.opportunity_name as name,
coalesce(c.customer_name, '未命名客户') as client,
coalesce(c.customer_name, '未填写最终客户') as client,
coalesce(u.display_name, '当前用户') as owner,
coalesce(o.project_location, '') as projectLocation,
coalesce(operator_dict.item_value, o.operator_name, '') as operatorCode,
coalesce(operator_dict.item_label, nullif(o.operator_name, ''), '') as operatorName,
o.amount,
to_char(o.expected_close_date, 'YYYY-MM-DD') as date,
o.confidence_pct as confidence,
coalesce(stage_dict.item_value, o.stage, '') as stageCode,
coalesce(
stage_dict.item_label,
case coalesce(o.stage, 'initial_contact')
when 'initial_contact' then '初步沟通'
when 'solution_discussion' then '方案交流'
@ -22,21 +50,84 @@
when 'won' then '已成交'
when 'lost' then '已放弃'
else coalesce(o.stage, '初步沟通')
end as stage,
end
) as stage,
coalesce(o.opportunity_type, '新建') as type,
coalesce(o.pushed_to_oms, false) as pushedToOms,
coalesce(o.product_type, 'VDI云桌面') as product,
coalesce(o.source, '主动开发') as source,
o.sales_expansion_id as salesExpansionId,
coalesce(se.candidate_name, '') as salesExpansionName,
o.channel_expansion_id as channelExpansionId,
coalesce(ce.channel_name, '') as channelExpansionName,
coalesce(o.competitor_name, '') as competitorName,
coalesce((
select
case
when f.content like '项目最新进展:%' then
split_part(split_part(f.content, E'\n', 1), '项目最新进展:', 2)
else f.content
end
from crm_opportunity_followup f
where f.opportunity_id = o.id
and coalesce(nullif(btrim(f.content), ''), '') &lt;&gt; ''
order by f.followup_time desc, f.id desc
limit 1
), '') as latestProgress,
coalesce((
select
case
when coalesce(nullif(btrim(f.next_action), ''), '') &lt;&gt; '' then f.next_action
when f.content like '%后续规划:%' then
split_part(split_part(f.content, '后续规划:', 2), E'\n', 1)
else ''
end
from crm_opportunity_followup f
where f.opportunity_id = o.id
and (
coalesce(nullif(btrim(f.next_action), ''), '') &lt;&gt; ''
or f.content like '%后续规划:%'
)
order by f.followup_time desc, f.id desc
limit 1
), '') as nextPlan,
coalesce(o.description, '') as notes
from crm_opportunity o
left join crm_customer c on c.id = o.customer_id
left join sys_user u on u.user_id = o.owner_user_id
left join crm_sales_expansion se on se.id = o.sales_expansion_id
left join crm_channel_expansion ce on ce.id = o.channel_expansion_id
left join sys_dict_item stage_dict
on stage_dict.type_code = 'sj_xmjd'
and (
stage_dict.item_value = o.stage
or stage_dict.item_label = case coalesce(o.stage, 'initial_contact')
when 'initial_contact' then '初步沟通'
when 'solution_discussion' then '方案交流'
when 'bidding' then '招投标'
when 'business_negotiation' then '商务谈判'
when 'won' then '已成交'
when 'lost' then '已放弃'
else o.stage
end
)
and stage_dict.status = 1
and coalesce(stage_dict.is_deleted, 0) = 0
left join sys_dict_item operator_dict
on operator_dict.type_code = 'sj_yzf'
and operator_dict.item_value = o.operator_name
and operator_dict.status = 1
and coalesce(operator_dict.is_deleted, 0) = 0
where o.owner_user_id = #{userId}
<if test="keyword != null and keyword != ''">
and (
o.opportunity_name ilike concat('%', #{keyword}, '%')
or o.opportunity_code ilike concat('%', #{keyword}, '%')
or coalesce(c.customer_name, '') ilike concat('%', #{keyword}, '%')
or coalesce(o.project_location, '') ilike concat('%', #{keyword}, '%')
or coalesce(o.operator_name, '') ilike concat('%', #{keyword}, '%')
or coalesce(operator_dict.item_label, '') ilike concat('%', #{keyword}, '%')
or coalesce(o.competitor_name, '') ilike concat('%', #{keyword}, '%')
)
</if>
<if test="stage != null and stage != ''">
@ -52,6 +143,7 @@
to_char(f.followup_time, 'YYYY-MM-DD HH24:MI') as date,
coalesce(f.followup_type, '无') as type,
coalesce(f.content, '无') as content,
coalesce(f.next_action, '') as nextAction,
coalesce(u.display_name, '无') as user
from crm_opportunity_followup f
join crm_opportunity o on o.id = f.opportunity_id
@ -100,6 +192,8 @@
opportunity_name,
customer_id,
owner_user_id,
project_location,
operator_name,
amount,
expected_close_date,
confidence_pct,
@ -107,6 +201,9 @@
opportunity_type,
product_type,
source,
sales_expansion_id,
channel_expansion_id,
competitor_name,
pushed_to_oms,
oms_push_time,
description,
@ -118,6 +215,8 @@
#{request.opportunityName},
#{customerId},
#{userId},
#{request.projectLocation},
#{request.operatorName},
#{request.amount},
#{request.expectedCloseDate},
#{request.confidencePct},
@ -125,6 +224,9 @@
#{request.opportunityType},
#{request.productType},
#{request.source},
#{request.salesExpansionId},
#{request.channelExpansionId},
#{request.competitorName},
#{request.pushedToOms},
case when #{request.pushedToOms} then now() else null end,
#{request.description},
@ -145,10 +247,20 @@
and owner_user_id = #{userId}
</select>
<select id="selectPushedToOms" resultType="java.lang.Boolean">
select coalesce(pushed_to_oms, false)
from crm_opportunity
where id = #{id}
and owner_user_id = #{userId}
limit 1
</select>
<update id="updateOpportunity">
update crm_opportunity
set opportunity_name = #{request.opportunityName},
customer_id = #{customerId},
project_location = #{request.projectLocation},
operator_name = #{request.operatorName},
amount = #{request.amount},
expected_close_date = #{request.expectedCloseDate},
confidence_pct = #{request.confidencePct},
@ -156,6 +268,9 @@
opportunity_type = #{request.opportunityType},
product_type = #{request.productType},
source = #{request.source},
sales_expansion_id = #{request.salesExpansionId},
channel_expansion_id = #{request.channelExpansionId},
competitor_name = #{request.competitorName},
pushed_to_oms = #{request.pushedToOms},
oms_push_time = case
when #{request.pushedToOms} then coalesce(oms_push_time, now())
@ -172,6 +287,16 @@
and owner_user_id = #{userId}
</update>
<update id="pushOpportunityToOms">
update crm_opportunity
set pushed_to_oms = true,
oms_push_time = coalesce(oms_push_time, now()),
updated_at = now()
where id = #{opportunityId}
and owner_user_id = #{userId}
and coalesce(pushed_to_oms, false) = false
</update>
<insert id="insertOpportunityFollowUp">
insert into crm_opportunity_followup (
opportunity_id,

View File

@ -9,8 +9,13 @@
id,
to_char(checkin_date, 'YYYY-MM-DD') as date,
to_char(checkin_time, 'HH24:MI') as time,
biz_type as bizType,
biz_id as bizId,
coalesce(biz_name, '') as bizName,
coalesce(location_text, '') as locationText,
coalesce(remark, '') as remark,
coalesce(user_name, '') as userName,
coalesce(dept_name, '') as deptName,
coalesce(status, 'normal') as status,
longitude,
latitude
@ -90,7 +95,7 @@
select
coalesce(o.created_at, now()) as action_time,
coalesce(nullif(btrim(cust.customer_name), ''), nullif(btrim(o.opportunity_name), ''), '商机客户') as group_name,
coalesce(nullif(btrim(cust.customer_name), ''), nullif(btrim(o.opportunity_name), ''), '最终客户待补充') as group_name,
'新增商机:' ||
coalesce(o.opportunity_name, '未命名商机') ||
case
@ -144,7 +149,7 @@
select
coalesce(f.followup_time, now()) as action_time,
coalesce(nullif(btrim(cust.customer_name), ''), nullif(btrim(o.opportunity_name), ''), '商机客户') as group_name,
coalesce(nullif(btrim(cust.customer_name), ''), nullif(btrim(o.opportunity_name), ''), '最终客户待补充') as group_name,
'商机跟进' ||
case
when f.followup_type is not null and btrim(f.followup_type) &lt;&gt; '' then ',方式:' || f.followup_type
@ -164,6 +169,88 @@
</select>
<select id="selectHistory" resultType="com.unis.crm.dto.work.WorkHistoryItemDTO">
select
id,
type,
date,
time,
content,
status,
score,
comment
from (
select
c.id,
'外勤打卡' as type,
to_char(c.checkin_date, 'YYYY-MM-DD') as date,
to_char(c.checkin_time, 'HH24:MI') as time,
case
when c.biz_name is not null and btrim(c.biz_name) &lt;&gt; '' then '关联对象:' || c.biz_name || E'\n'
else ''
end ||
case
when c.user_name is not null and btrim(c.user_name) &lt;&gt; '' then '打卡人:' || c.user_name || E'\n'
else ''
end ||
case
when c.dept_name is not null and btrim(c.dept_name) &lt;&gt; '' then '所属部门:' || c.dept_name || E'\n'
else ''
end ||
coalesce(c.location_text, '') ||
case
when c.remark is not null and btrim(c.remark) &lt;&gt; '' then E'\n备注' || c.remark
else ''
end as content,
case coalesce(c.status, 'normal')
when 'normal' then '正常'
when 'updated' then '已更新'
else coalesce(c.status, '正常')
end as status,
null::integer as score,
null::text as comment,
coalesce(c.checkin_date::timestamp + c.checkin_time::time, c.created_at) as sort_time
from work_checkin c
where c.user_id = #{userId}
union all
select
r.id,
'日报' as type,
to_char(r.report_date, 'YYYY-MM-DD') as date,
to_char(r.submit_time, 'HH24:MI') as time,
coalesce(r.work_content, '') ||
case
when r.tomorrow_plan is not null and btrim(r.tomorrow_plan) &lt;&gt; '' then E'\n明日计划' || r.tomorrow_plan
else ''
end as content,
case coalesce(rc.comment_content, '')
when '' then
case coalesce(r.status, 'submitted')
when 'submitted' then '已提交'
when 'reviewed' then '已点评'
else coalesce(r.status, '已提交')
end
else '已点评'
end as status,
rc.score,
rc.comment_content as comment,
coalesce(r.report_date::timestamp + r.submit_time::time, r.created_at) as sort_time
from work_daily_report r
left join (
select distinct on (report_id)
report_id,
score,
comment_content
from work_daily_report_comment
order by report_id, reviewed_at desc nulls last, id desc
) rc on rc.report_id = r.id
where r.user_id = #{userId}
) history
order by sort_time desc nulls last, id desc
</select>
<select id="selectHistoryPage" resultType="com.unis.crm.dto.work.WorkHistoryItemDTO">
select
id,
type,
@ -230,7 +317,12 @@
) rc on rc.report_id = r.id
where r.user_id = #{userId}
) history
<if test="historyType != null and historyType != ''">
where history.type = #{historyType}
</if>
order by sort_time desc nulls last, id desc
limit #{limit}
offset #{offset}
</select>
<select id="selectTodayCheckInId" resultType="java.lang.Long">
@ -248,10 +340,15 @@
user_id,
checkin_date,
checkin_time,
biz_type,
biz_id,
biz_name,
longitude,
latitude,
location_text,
remark,
user_name,
dept_name,
status,
created_at,
updated_at
@ -260,10 +357,15 @@
#{userId},
current_date,
now(),
#{request.bizType},
#{request.bizId},
#{request.bizName},
#{request.longitude},
#{request.latitude},
#{request.locationText},
#{request.remark},
#{request.userName},
#{request.deptName},
'normal',
now(),
now()
@ -273,10 +375,15 @@
<update id="updateCheckIn">
update work_checkin
set checkin_time = now(),
biz_type = #{request.bizType},
biz_id = #{request.bizId},
biz_name = #{request.bizName},
longitude = #{request.longitude},
latitude = #{request.latitude},
location_text = #{request.locationText},
remark = #{request.remark},
user_name = #{request.userName},
dept_name = #{request.deptName},
status = 'normal',
updated_at = now()
where id = #{checkInId}
@ -326,6 +433,187 @@
where id = #{reportId}
</update>
<delete id="deleteGeneratedExpansionFollowUps">
delete from crm_expansion_followup
where source_type = 'work_report'
and source_id = #{reportId}
</delete>
<delete id="deleteGeneratedOpportunityFollowUps">
delete from crm_opportunity_followup
where source_type = 'work_report'
and source_id = #{reportId}
</delete>
<delete id="deleteLegacyExpansionFollowUp">
delete from crm_expansion_followup
where biz_type = #{bizType}
and biz_id = #{bizId}
and followup_user_id = #{userId}
and followup_time = #{followUpTime}
and followup_type = #{followUpType}
</delete>
<delete id="deleteDailyReportExpansionFollowUps">
delete from crm_expansion_followup
where biz_type = #{bizType}
and biz_id = #{bizId}
and followup_user_id = #{userId}
and followup_type = #{followUpType}
and followup_time &gt;= #{reportDate}::timestamp
and followup_time &lt; (#{reportDate}::timestamp + interval '1 day')
</delete>
<delete id="deleteLegacyOpportunityFollowUp">
delete from crm_opportunity_followup
where opportunity_id = #{opportunityId}
and followup_user_id = #{userId}
and followup_time = #{followUpTime}
and followup_type = #{followUpType}
</delete>
<delete id="deleteDailyReportOpportunityFollowUps">
delete from crm_opportunity_followup
where opportunity_id = #{opportunityId}
and followup_user_id = #{userId}
and followup_type = #{followUpType}
and followup_time &gt;= #{reportDate}::timestamp
and followup_time &lt; (#{reportDate}::timestamp + interval '1 day')
</delete>
<insert id="insertGeneratedExpansionFollowUp">
insert into crm_expansion_followup (
biz_type,
biz_id,
followup_time,
followup_type,
content,
next_action,
followup_user_id,
visit_start_time,
evaluation_content,
next_plan,
source_type,
source_id,
created_at,
updated_at
) values (
#{bizType},
#{bizId},
#{followUpTime},
#{followUpType},
#{content},
#{nextAction},
#{userId},
#{visitStartTime},
#{evaluationContent},
#{nextPlan},
'work_report',
#{reportId},
now(),
now()
)
</insert>
<insert id="insertGeneratedOpportunityFollowUp">
insert into crm_opportunity_followup (
opportunity_id,
followup_time,
followup_type,
content,
next_action,
followup_user_id,
source_type,
source_id,
created_at,
updated_at
) values (
#{opportunityId},
#{followUpTime},
#{followUpType},
#{content},
#{nextAction},
#{userId},
'work_report',
#{reportId},
now(),
now()
)
</insert>
<insert id="insertLegacyExpansionFollowUp">
insert into crm_expansion_followup (
biz_type,
biz_id,
followup_time,
followup_type,
content,
next_action,
followup_user_id,
visit_start_time,
evaluation_content,
next_plan,
created_at,
updated_at
) values (
#{bizType},
#{bizId},
#{followUpTime},
#{followUpType},
#{content},
#{nextAction},
#{userId},
#{visitStartTime},
#{evaluationContent},
#{nextPlan},
now(),
now()
)
</insert>
<insert id="insertLegacyOpportunityFollowUp">
insert into crm_opportunity_followup (
opportunity_id,
followup_time,
followup_type,
content,
next_action,
followup_user_id,
created_at,
updated_at
) values (
#{opportunityId},
#{followUpTime},
#{followUpType},
#{content},
#{nextAction},
#{userId},
now(),
now()
)
</insert>
<delete id="deleteTodosByBiz">
delete from work_todo
where user_id = #{userId}
and biz_type = #{bizType}
and biz_id = #{bizId}
</delete>
<delete id="deleteTodosByBizType">
delete from work_todo
where user_id = #{userId}
and biz_type = #{bizType}
</delete>
<delete id="deletePendingTodosByBiz">
delete from work_todo
where user_id = #{userId}
and biz_type = #{bizType}
and biz_id = #{bizId}
and status = 'todo'
</delete>
<select id="selectTodoIdByBiz" resultType="java.lang.Long">
select id
from work_todo

View File

@ -149,8 +149,8 @@
| opportunity_type | varchar(50) | 类型,如新建/扩容 |
| product_type | varchar(100) | 产品类别如VDI/VOI/IDV云桌面 |
| source | varchar(50) | 商机来源 |
| pushed_to_oms | tinyint | 是否已推送OMS |
| oms_push_time | datetime | 推送OMS时间 |
| pushed_to_oms | tinyint | 是否已推送 OMS |
| oms_push_time | datetime | 推送 OMS 时间 |
| description | text | 商机说明/备注 |
| status | varchar(30) | 正常/赢单/输单/关闭 |
| created_at | datetime | 创建时间 |

BIN
frontend/.DS_Store vendored

Binary file not shown.

View File

@ -15,4 +15,5 @@ npm run dev
```bash
GEMINI_API_KEY=your_key_here
VITE_TENCENT_MAP_KEY=your_tencent_map_key
```

BIN
frontend/node_modules/.DS_Store generated vendored

Binary file not shown.

View File

@ -2,76 +2,85 @@
"hash": "9b703341",
"configHash": "4d48f89c",
"lockfileHash": "446f7b50",
"browserHash": "0342a104",
"browserHash": "97389217",
"optimized": {
"react": {
"src": "../../react/index.js",
"file": "react.js",
"fileHash": "38eec15c",
"fileHash": "0e15d179",
"needsInterop": true
},
"react-dom": {
"src": "../../react-dom/index.js",
"file": "react-dom.js",
"fileHash": "885db03a",
"fileHash": "56e0ac1a",
"needsInterop": true
},
"react/jsx-dev-runtime": {
"src": "../../react/jsx-dev-runtime.js",
"file": "react_jsx-dev-runtime.js",
"fileHash": "63ca1c37",
"fileHash": "3873dbfb",
"needsInterop": true
},
"react/jsx-runtime": {
"src": "../../react/jsx-runtime.js",
"file": "react_jsx-runtime.js",
"fileHash": "645313a0",
"fileHash": "36bae069",
"needsInterop": true
},
"clsx": {
"src": "../../clsx/dist/clsx.mjs",
"file": "clsx.js",
"fileHash": "c1313132",
"fileHash": "ef2b5b9d",
"needsInterop": false
},
"date-fns": {
"src": "../../date-fns/index.js",
"file": "date-fns.js",
"fileHash": "c2142fc9",
"fileHash": "b5d072ca",
"needsInterop": false
},
"lucide-react": {
"src": "../../lucide-react/dist/esm/lucide-react.js",
"file": "lucide-react.js",
"fileHash": "9e28d5e6",
"fileHash": "938b715f",
"needsInterop": false
},
"motion/react": {
"src": "../../motion/dist/es/react.mjs",
"file": "motion_react.js",
"fileHash": "4806ae85",
"fileHash": "fb0e4ab1",
"needsInterop": false
},
"react-dom/client": {
"src": "../../react-dom/client.js",
"file": "react-dom_client.js",
"fileHash": "2ee244fc",
"fileHash": "c4c41e0d",
"needsInterop": true
},
"react-router-dom": {
"src": "../../react-router-dom/dist/index.mjs",
"file": "react-router-dom.js",
"fileHash": "d896198d",
"fileHash": "5254f612",
"needsInterop": false
},
"tailwind-merge": {
"src": "../../tailwind-merge/dist/bundle-mjs.mjs",
"file": "tailwind-merge.js",
"fileHash": "d32046f9",
"fileHash": "9fbd3db9",
"needsInterop": false
},
"date-fns/locale": {
"src": "../../date-fns/locale.js",
"file": "date-fns_locale.js",
"fileHash": "31cc6364",
"needsInterop": false
}
},
"chunks": {
"chunk-7VK2ROTQ": {
"file": "chunk-7VK2ROTQ.js"
},
"chunk-5MXL5BYH": {
"file": "chunk-5MXL5BYH.js"
},

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -40,7 +40,7 @@ export default function App() {
<Route index element={<Dashboard />} />
<Route path="expansion" element={<Expansion />} />
<Route path="opportunities" element={<Opportunities />} />
<Route path="work" element={<Work />} />
<Route path="work/*" element={<Work />} />
<Route path="profile" element={<Profile />} />
</Route>
</Routes>

View File

@ -0,0 +1,204 @@
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion } from "motion/react";
import { Check, ChevronDown, X } from "lucide-react";
import { cn } from "@/lib/utils";
export type AdaptiveSelectOption = {
value: string;
label: string;
disabled?: boolean;
};
type AdaptiveSelectProps = {
value?: string;
options: AdaptiveSelectOption[];
placeholder?: string;
sheetTitle?: string;
disabled?: boolean;
className?: string;
onChange: (value: string) => void;
};
function useIsMobileViewport() {
const [isMobile, setIsMobile] = useState(() => {
if (typeof window === "undefined") {
return false;
}
return window.matchMedia("(max-width: 639px)").matches;
});
useEffect(() => {
if (typeof window === "undefined") {
return;
}
const mediaQuery = window.matchMedia("(max-width: 639px)");
const handleChange = () => setIsMobile(mediaQuery.matches);
handleChange();
if (typeof mediaQuery.addEventListener === "function") {
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
}
mediaQuery.addListener(handleChange);
return () => mediaQuery.removeListener(handleChange);
}, []);
return isMobile;
}
export function AdaptiveSelect({
value = "",
options,
placeholder = "请选择",
sheetTitle,
disabled = false,
className,
onChange,
}: AdaptiveSelectProps) {
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const isMobile = useIsMobileViewport();
const selectedOption = options.find((option) => option.value === value);
const selectedLabel = value ? selectedOption?.label || placeholder : placeholder;
useEffect(() => {
if (!open || isMobile) {
return;
}
const handlePointerDown = (event: MouseEvent) => {
if (!containerRef.current?.contains(event.target as Node)) {
setOpen(false);
}
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setOpen(false);
}
};
document.addEventListener("mousedown", handlePointerDown);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousedown", handlePointerDown);
document.removeEventListener("keydown", handleEscape);
};
}, [isMobile, open]);
useEffect(() => {
if (!open || !isMobile) {
return;
}
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = previousOverflow;
};
}, [isMobile, open]);
const handleSelect = (nextValue: string) => {
onChange(nextValue);
setOpen(false);
};
const renderOption = (option: AdaptiveSelectOption) => {
const isSelected = option.value === value;
return (
<button
key={`${option.value}-${option.label}`}
type="button"
disabled={option.disabled}
onClick={() => handleSelect(option.value)}
className={cn(
"flex w-full items-center justify-between rounded-xl px-3 py-3 text-left text-sm transition-colors",
isSelected
? "bg-violet-600 text-white shadow-sm"
: "text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800",
option.disabled ? "cursor-not-allowed opacity-50" : "",
)}
>
<span className="break-anywhere">{option.label}</span>
{isSelected ? <Check className="h-4 w-4 shrink-0" /> : null}
</button>
);
};
return (
<div ref={containerRef} className="relative">
<button
type="button"
disabled={disabled}
onClick={() => {
if (!disabled) {
setOpen((current) => !current);
}
}}
className={cn(
"crm-btn-sm crm-input-text flex w-full items-center justify-between border border-slate-200 bg-white text-left outline-none transition-colors hover:border-slate-300 focus:border-violet-500 focus:ring-1 focus:ring-violet-500 disabled:cursor-not-allowed disabled:opacity-60 dark:border-slate-800 dark:bg-slate-900/50 dark:hover:border-slate-700",
className,
)}
>
<span className={value ? "text-slate-900 dark:text-white" : "crm-field-note"}>
{selectedLabel}
</span>
<ChevronDown className={cn("h-4 w-4 shrink-0 text-slate-400 transition-transform", open ? "rotate-180" : "")} />
</button>
<AnimatePresence>
{open && !isMobile ? (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 8 }}
className="absolute z-30 mt-2 w-full rounded-2xl border border-slate-200 bg-white p-2 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
>
<div className="max-h-72 space-y-1 overflow-y-auto pr-1">{options.map(renderOption)}</div>
</motion.div>
) : null}
</AnimatePresence>
<AnimatePresence>
{open && isMobile ? (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[120] bg-slate-900/35 backdrop-blur-sm dark:bg-slate-950/70"
onClick={() => setOpen(false)}
/>
<motion.div
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 24 }}
className="fixed inset-x-0 bottom-0 z-[130] px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] pt-3"
>
<div className="mx-auto w-full max-w-lg rounded-3xl border border-slate-200 bg-white shadow-2xl dark:border-slate-800 dark:bg-slate-900">
<div className="flex items-center justify-between border-b border-slate-100 px-5 py-4 dark:border-slate-800">
<div>
<p className="text-base font-semibold text-slate-900 dark:text-white">{sheetTitle || placeholder}</p>
<p className="crm-field-note mt-1"></p>
</div>
<button
type="button"
onClick={() => setOpen(false)}
className="rounded-full p-2 text-slate-400 transition-colors hover:bg-slate-100 dark:hover:bg-slate-800"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="max-h-[60vh] space-y-2 overflow-y-auto px-4 py-4 pb-[calc(1rem+env(safe-area-inset-bottom))]">
{options.map(renderOption)}
</div>
</div>
</motion.div>
</>
) : null}
</AnimatePresence>
</div>
);
}

View File

@ -1,23 +1,120 @@
import { Link, Outlet, useLocation } from "react-router-dom";
import { Home, Users, Briefcase, CalendarCheck, User, Moon, Sun } from "lucide-react";
import { useEffect, useRef, useState, type MouseEvent } from "react";
import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
import { Bell, Home, Users, Briefcase, CalendarCheck, User, Moon, Sun } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTheme } from "./ThemeProvider";
import { motion, AnimatePresence } from "motion/react";
import { getWorkOverview } from "@/lib/auth";
const navItems = [
type NavChildItem = {
name: string;
path: string;
};
type NavItem = {
name: string;
path: string;
icon: typeof Home;
defaultPath?: string;
children?: NavChildItem[];
};
const navItems: NavItem[] = [
{ name: "首页", path: "/", icon: Home },
{ name: "拓展", path: "/expansion", icon: Users },
{ name: "商机", path: "/opportunities", icon: Briefcase },
{ name: "工作", path: "/work", icon: CalendarCheck },
{
name: "工作",
path: "/work",
defaultPath: "/work/checkin",
icon: CalendarCheck,
children: [
{ name: "打卡", path: "/work/checkin" },
{ name: "日报", path: "/work/report" },
],
},
{ name: "我的", path: "/profile", icon: User },
];
function isAfterDailyReportReminderTime(now = new Date()) {
return now.getHours() >= 18;
}
export default function Layout() {
const location = useLocation();
const navigate = useNavigate();
const { theme, setTheme } = useTheme();
const [hasPendingDailyReport, setHasPendingDailyReport] = useState(false);
const [afterReminderTime, setAfterReminderTime] = useState(isAfterDailyReportReminderTime());
const [mobileBellHint, setMobileBellHint] = useState("");
const mobileBellTimerRef = useRef<number | null>(null);
const isActivePath = (path: string) => location.pathname === path || (path !== "/" && location.pathname.startsWith(`${path}/`));
const activeNavItem = navItems.find((item) => isActivePath(item.path)) ?? navItems[0];
const contentKey = activeNavItem.path;
const isDashboardPage = activeNavItem.path === "/";
useEffect(() => {
let cancelled = false;
async function loadDailyReportNotice() {
if (!isDashboardPage) {
setHasPendingDailyReport(false);
return;
}
try {
const workOverview = await getWorkOverview();
if (!cancelled) {
setHasPendingDailyReport(!workOverview.todayReport?.status);
}
} catch {
if (!cancelled) {
setHasPendingDailyReport(false);
}
}
}
void loadDailyReportNotice();
return () => {
cancelled = true;
};
}, [isDashboardPage]);
useEffect(() => {
const updateReminderTime = () => setAfterReminderTime(isAfterDailyReportReminderTime());
updateReminderTime();
const timer = window.setInterval(updateReminderTime, 60 * 1000);
return () => {
window.clearInterval(timer);
};
}, []);
useEffect(() => () => {
if (mobileBellTimerRef.current) {
window.clearTimeout(mobileBellTimerRef.current);
}
}, []);
const shouldShowDailyReportReminder = isDashboardPage;
const hasDailyReportReminderDot = isDashboardPage && afterReminderTime && hasPendingDailyReport;
const handleMobileBellClick = (event: MouseEvent<HTMLButtonElement>) => {
if (!hasDailyReportReminderDot) {
navigate("/work/report");
return;
}
event.preventDefault();
setMobileBellHint("今日日报尚未提交");
if (mobileBellTimerRef.current) {
window.clearTimeout(mobileBellTimerRef.current);
}
mobileBellTimerRef.current = window.setTimeout(() => {
setMobileBellHint("");
navigate("/work/report");
}, 900);
};
return (
<div className="flex h-screen bg-slate-50 dark:bg-slate-950 text-slate-900 dark:text-slate-50 transition-colors duration-300">
<div className="flex min-h-dvh overflow-x-hidden bg-slate-50 text-slate-900 transition-colors duration-300 dark:bg-slate-950 dark:text-slate-50 md:h-screen md:overflow-hidden">
{/* Desktop Sidebar */}
<aside className="hidden w-64 flex-col border-r border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 md:flex transition-colors duration-300">
<div className="flex h-16 items-center justify-between border-b border-slate-200 dark:border-slate-800 px-6">
@ -33,11 +130,11 @@ export default function Layout() {
</div>
<nav className="flex-1 space-y-2 p-4">
{navItems.map((item) => {
const isActive = location.pathname === item.path || (item.path !== '/' && location.pathname.startsWith(item.path));
const isActive = isActivePath(item.path);
return (
<div key={item.name} className="space-y-1">
<Link
key={item.name}
to={item.path}
to={item.defaultPath ?? item.path}
className={cn(
"flex items-center gap-3 rounded-xl px-3 py-3 text-sm font-medium transition-all duration-200",
isActive
@ -48,17 +145,70 @@ export default function Layout() {
<item.icon className={cn("h-5 w-5 transition-transform", isActive && "scale-110")} />
{item.name}
</Link>
</div>
);
})}
</nav>
</aside>
{/* Main Content */}
<main className="flex-1 overflow-y-auto pb-20 md:pb-0 relative">
<div className="mx-auto max-w-5xl p-4 md:p-8">
<main className="relative min-w-0 flex-1 overflow-x-hidden overflow-y-auto pb-[calc(6.5rem+env(safe-area-inset-bottom))] md:pb-0">
<div className="mx-auto w-full max-w-5xl px-4 py-4 md:px-8 md:py-8">
<header className="sticky top-0 z-30 -mx-4 mb-4 flex items-center justify-between gap-3 border-b border-slate-200 bg-white/90 px-4 pb-3.5 pt-[calc(0.9rem+env(safe-area-inset-top))] backdrop-blur-xl transition-colors duration-300 dark:border-slate-800 dark:bg-slate-950/90 md:hidden">
<div className="min-w-0 flex-1 pr-2">
<p className="bg-gradient-to-r from-violet-600 to-indigo-600 bg-clip-text text-sm font-bold uppercase tracking-[0.14em] text-transparent">
CRM
</p>
</div>
<div className="flex shrink-0 items-center gap-2.5">
{shouldShowDailyReportReminder ? (
<div className="relative">
<button
type="button"
onClick={handleMobileBellClick}
className="relative inline-flex h-9 w-9 items-center justify-center rounded-full border border-slate-200/80 bg-white/80 text-slate-500 shadow-sm transition-colors hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900/70 dark:text-slate-400 dark:hover:bg-slate-800"
aria-label={hasDailyReportReminderDot ? "今日日报未提交,前往日报" : "日报提醒,前往日报"}
title={hasDailyReportReminderDot ? "今日日报未提交" : "日报提醒"}
>
<Bell className="h-4 w-4" />
{hasDailyReportReminderDot ? (
<span className="absolute right-1.5 top-1.5 h-2 w-2 rounded-full bg-rose-500 ring-2 ring-white dark:ring-slate-950" />
) : null}
</button>
{mobileBellHint ? (
<div className="absolute right-0 top-[calc(100%+8px)] whitespace-nowrap rounded-xl bg-slate-900 px-3 py-2 text-xs font-medium text-white shadow-lg dark:bg-slate-100 dark:text-slate-900">
{mobileBellHint}
</div>
) : null}
</div>
) : null}
<button
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
className="inline-flex h-9 w-9 items-center justify-center rounded-full border border-slate-200/80 bg-white/80 text-slate-500 shadow-sm transition-colors hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900/70 dark:text-slate-400 dark:hover:bg-slate-800"
aria-label={theme === "dark" ? "切换亮色模式" : "切换暗色模式"}
>
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</button>
</div>
</header>
{shouldShowDailyReportReminder ? (
<div className="mb-4 hidden justify-end md:flex">
<Link
to="/work/report"
className="relative inline-flex h-10 w-10 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-600 shadow-sm transition-colors hover:border-violet-300 hover:text-violet-600 dark:border-slate-800 dark:bg-slate-900/60 dark:text-slate-300 dark:hover:border-violet-500/40 dark:hover:text-violet-300"
aria-label={hasDailyReportReminderDot ? "今日日报未提交,前往日报" : "日报提醒,前往日报"}
title={hasDailyReportReminderDot ? "今日日报未提交" : "日报提醒"}
>
<Bell className="h-4 w-4" />
{hasDailyReportReminderDot ? (
<span className="absolute right-2.5 top-2.5 h-2 w-2 rounded-full bg-rose-500 ring-2 ring-white dark:ring-slate-900" />
) : null}
</Link>
</div>
) : null}
<AnimatePresence mode="wait">
<motion.div
key={location.pathname}
key={contentKey}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
@ -71,15 +221,15 @@ export default function Layout() {
</main>
{/* Mobile Bottom Nav */}
<nav className="fixed bottom-0 left-0 right-0 z-50 flex h-16 border-t border-slate-200 dark:border-slate-800 bg-white/80 dark:bg-slate-900/80 backdrop-blur-xl pb-safe md:hidden transition-colors duration-300">
<nav className="fixed bottom-0 left-0 right-0 z-30 flex h-[calc(4rem+env(safe-area-inset-bottom))] border-t border-slate-200 bg-white/80 px-2 pb-[env(safe-area-inset-bottom)] backdrop-blur-xl transition-colors duration-300 dark:border-slate-800 dark:bg-slate-900/80 md:hidden">
{navItems.map((item) => {
const isActive = location.pathname === item.path || (item.path !== '/' && location.pathname.startsWith(item.path));
const isActive = isActivePath(item.path);
return (
<Link
key={item.name}
to={item.path}
to={item.defaultPath ?? item.path}
className={cn(
"flex flex-1 flex-col items-center justify-center gap-1 text-[10px] font-medium transition-all duration-200",
"flex min-w-0 flex-1 flex-col items-center justify-center gap-1 px-1 text-[10px] font-medium transition-all duration-200",
isActive ? "text-violet-600 dark:text-violet-400" : "text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-50"
)}
>
@ -89,7 +239,7 @@ export default function Layout() {
)}>
<item.icon className={cn("h-5 w-5", isActive && "fill-violet-100 dark:fill-violet-500/20")} />
</div>
{item.name}
<span className="truncate">{item.name}</span>
</Link>
);
})}

View File

@ -0,0 +1,72 @@
import { useEffect, useState, type ImgHTMLAttributes } from "react"
import { fetchWithAuth } from "@/lib/auth"
import { cn } from "@/lib/utils"
type ProtectedImageProps = Omit<ImgHTMLAttributes<HTMLImageElement>, "src"> & {
src: string
}
export function ProtectedImage({ alt, className, src, ...props }: ProtectedImageProps) {
const [objectUrl, setObjectUrl] = useState<string>()
const [loadFailed, setLoadFailed] = useState(false)
useEffect(() => {
let active = true
let nextObjectUrl: string | undefined
setObjectUrl(undefined)
setLoadFailed(false)
void (async () => {
try {
const response = await fetchWithAuth(src)
if (!response.ok) {
throw new Error(`图片加载失败(${response.status})`)
}
const blob = await response.blob()
nextObjectUrl = URL.createObjectURL(blob)
if (!active) {
URL.revokeObjectURL(nextObjectUrl)
return
}
setObjectUrl(nextObjectUrl)
} catch {
if (active) {
setLoadFailed(true)
}
}
})()
return () => {
active = false
if (nextObjectUrl) {
URL.revokeObjectURL(nextObjectUrl)
}
}
}, [src])
if (loadFailed) {
return (
<div
aria-label={alt}
className={cn(
className,
"flex items-center justify-center bg-slate-100 text-xs text-slate-400 dark:bg-slate-800 dark:text-slate-500",
)}
role="img"
>
</div>
)
}
if (!objectUrl) {
return <div aria-label={alt} className={cn(className, "animate-pulse bg-slate-100 dark:bg-slate-800")} />
}
return <img {...props} alt={alt} className={className} src={objectUrl} />
}

View File

@ -8,17 +8,367 @@
background: #f8fafc;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body,
#root {
width: 100%;
min-height: 100%;
overflow-x: hidden;
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
min-width: 320px;
overflow-x: hidden;
}
img,
video,
canvas,
svg {
max-width: 100%;
}
button,
input,
textarea,
select {
font: inherit;
min-width: 0;
}
@media (max-width: 767px) {
input,
textarea,
select {
font-size: 16px !important;
}
}
@layer components {
label > span:first-child,
.crm-field-label {
font-size: 0.875rem;
line-height: 1.4;
font-weight: 500;
color: #334155;
}
.dark label > span:first-child,
.dark .crm-field-label {
color: #cbd5e1;
}
.crm-field-note {
font-size: 0.75rem;
line-height: 1.6;
color: #64748b;
}
.dark .crm-field-note {
color: #94a3b8;
}
.crm-empty-state {
text-align: center;
font-size: 0.875rem;
line-height: 1.6;
color: #94a3b8;
}
.dark .crm-empty-state {
color: #64748b;
}
.crm-input-text {
font-size: 0.875rem;
line-height: 1.55;
border-radius: 1rem;
min-height: 3rem;
}
.crm-input-box {
border-radius: 1rem;
min-height: 3rem;
padding: 0.75rem 1rem;
}
.crm-input-box-readonly {
border-radius: 1rem;
min-height: 3rem;
padding: 0.75rem 1rem;
}
.crm-btn {
min-height: 3rem;
border-radius: 1rem;
padding: 0.75rem 1rem;
font-size: 0.875rem;
line-height: 1.4;
font-weight: 600;
}
.crm-btn-sm {
min-height: 2.75rem;
border-radius: 1rem;
padding: 0.625rem 1rem;
font-size: 0.875rem;
line-height: 1.4;
font-weight: 500;
}
.crm-card {
border-radius: 1rem;
border: 1px solid #e2e8f0;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.06);
backdrop-filter: blur(12px);
}
.crm-card-subtle {
border-radius: 1rem;
border: 1px solid #eef2f7;
background: rgba(248, 250, 252, 0.72);
}
.dark .crm-card {
border-color: rgba(51, 65, 85, 0.9);
background: rgba(15, 23, 42, 0.72);
box-shadow: 0 12px 32px rgba(2, 6, 23, 0.28);
}
.dark .crm-card-subtle {
border-color: rgba(51, 65, 85, 0.8);
background: rgba(30, 41, 59, 0.38);
}
.crm-pill {
display: inline-flex;
align-items: center;
border-radius: 9999px;
padding: 0.3rem 0.65rem;
font-size: 0.75rem;
line-height: 1.2;
font-weight: 600;
}
.crm-pill-neutral {
background: #f1f5f9;
color: #475569;
}
.crm-pill-violet {
background: #f5f3ff;
color: #7c3aed;
}
.crm-pill-emerald {
background: #ecfdf5;
color: #059669;
}
.crm-pill-amber {
background: #fffbeb;
color: #d97706;
}
.crm-pill-rose {
background: #fff1f2;
color: #e11d48;
}
.dark .crm-pill-neutral {
background: rgba(51, 65, 85, 0.85);
color: #cbd5e1;
}
.dark .crm-pill-violet {
background: rgba(139, 92, 246, 0.14);
color: #c4b5fd;
}
.dark .crm-pill-emerald {
background: rgba(16, 185, 129, 0.14);
color: #6ee7b7;
}
.dark .crm-pill-amber {
background: rgba(245, 158, 11, 0.14);
color: #fcd34d;
}
.dark .crm-pill-rose {
background: rgba(244, 63, 94, 0.14);
color: #fda4af;
}
.crm-page-stack {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.crm-section-stack {
display: flex;
flex-direction: column;
gap: 1rem;
}
.crm-list-stack {
display: flex;
flex-direction: column;
gap: 0.875rem;
}
.crm-card-pad {
padding: 1rem;
}
.crm-card-pad-lg {
padding: 1.25rem;
}
.crm-modal-stack {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.crm-form-grid {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
}
.crm-form-section {
display: flex;
flex-direction: column;
gap: 0.875rem;
border-radius: 1rem;
border: 1px solid #e2e8f0;
background: rgba(248, 250, 252, 0.72);
padding: 1rem;
}
.crm-form-section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.crm-detail-grid {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
border-radius: 1rem;
border: 1px solid #e2e8f0;
background: rgba(248, 250, 252, 0.72);
padding: 1rem;
}
.crm-detail-item {
display: flex;
min-width: 0;
flex-direction: column;
gap: 0.35rem;
}
.crm-detail-label {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
line-height: 1.4;
color: #64748b;
}
.crm-detail-value {
font-size: 0.9375rem;
line-height: 1.6;
font-weight: 500;
color: #0f172a;
overflow-wrap: anywhere;
word-break: break-word;
}
.dark .crm-form-section,
.dark .crm-detail-grid {
border-color: rgba(51, 65, 85, 0.8);
background: rgba(30, 41, 59, 0.38);
}
.dark .crm-detail-label {
color: #94a3b8;
}
.dark .crm-detail-value {
color: #f8fafc;
}
@media (min-width: 640px) {
.crm-page-stack {
gap: 1.5rem;
}
.crm-section-stack {
gap: 1.25rem;
}
.crm-list-stack {
gap: 1rem;
}
.crm-card-pad {
padding: 1.25rem;
}
.crm-card-pad-lg {
padding: 1.5rem;
}
.crm-modal-stack {
gap: 1.5rem;
}
.crm-form-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1.25rem;
}
.crm-form-section,
.crm-detail-grid {
padding: 1.25rem;
}
}
.crm-input-text::placeholder,
input::placeholder,
textarea::placeholder {
color: #94a3b8;
opacity: 1;
}
.dark .crm-input-text::placeholder,
.dark input::placeholder,
.dark textarea::placeholder {
color: #64748b;
}
}
@layer utilities {
.break-anywhere {
overflow-wrap: anywhere;
word-break: break-word;
}
.scrollbar-hide {
/* IE and Edge */
-ms-overflow-style: none;

View File

@ -55,14 +55,15 @@ export interface DashboardStat {
}
export interface DashboardTodo {
id: number;
id: string;
title?: string;
bizType?: string;
bizId?: number;
bizId?: string;
priority?: string;
status?: string;
dueDate?: string;
createdAt?: string;
updatedAt?: string;
}
export interface DashboardActivity {
@ -127,6 +128,11 @@ export interface WorkCheckIn {
longitude?: number;
latitude?: number;
photoUrls?: string[];
bizType?: "sales" | "channel" | "opportunity";
bizId?: number;
bizName?: string;
userName?: string;
deptName?: string;
}
export interface WorkDailyReport {
@ -134,7 +140,9 @@ export interface WorkDailyReport {
date?: string;
submitTime?: string;
workContent?: string;
lineItems?: WorkReportLineItem[];
tomorrowPlan?: string;
planItems?: WorkTomorrowPlanItem[];
sourceType?: string;
status?: string;
score?: number;
@ -160,16 +168,49 @@ export interface WorkOverview {
history?: WorkHistoryItem[];
}
export interface WorkHistoryPage {
items?: WorkHistoryItem[];
hasMore?: boolean;
page?: number;
size?: number;
}
export interface CreateWorkCheckInPayload {
locationText: string;
remark?: string;
longitude?: number;
latitude?: number;
photoUrls?: string[];
bizType?: "sales" | "channel" | "opportunity";
bizId?: number;
bizName?: string;
userName?: string;
deptName?: string;
}
export interface WorkReportLineItem {
workDate: string;
bizType: "sales" | "channel" | "opportunity";
bizId: number;
bizName?: string;
editorText?: string;
content: string;
visitStartTime?: string;
evaluationContent?: string;
nextPlan?: string;
latestProgress?: string;
communicationTime?: string;
communicationContent?: string;
}
export interface WorkTomorrowPlanItem {
content: string;
}
export interface CreateWorkDailyReportPayload {
workContent: string;
lineItems: WorkReportLineItem[];
planItems: WorkTomorrowPlanItem[];
tomorrowPlan: string;
sourceType?: string;
}
@ -180,6 +221,10 @@ export interface OpportunityFollowUp {
date?: string;
type?: string;
content?: string;
latestProgress?: string;
communicationTime?: string;
communicationContent?: string;
nextAction?: string;
user?: string;
}
@ -189,14 +234,25 @@ export interface OpportunityItem {
name?: string;
client?: string;
owner?: string;
projectLocation?: string;
operatorCode?: string;
operatorName?: string;
amount?: number;
date?: string;
confidence?: number;
stageCode?: string;
stage?: string;
type?: string;
pushedToOms?: boolean;
product?: string;
source?: string;
salesExpansionId?: number;
salesExpansionName?: string;
channelExpansionId?: number;
channelExpansionName?: string;
competitorName?: string;
latestProgress?: string;
nextPlan?: string;
notes?: string;
followUps?: OpportunityFollowUp[];
}
@ -205,9 +261,21 @@ export interface OpportunityOverview {
items?: OpportunityItem[];
}
export interface OpportunityDictOption {
label?: string;
value?: string;
}
export interface OpportunityMeta {
stageOptions?: OpportunityDictOption[];
operatorOptions?: OpportunityDictOption[];
}
export interface CreateOpportunityPayload {
opportunityName: string;
customerName: string;
projectLocation?: string;
operatorName?: string;
amount: number;
expectedCloseDate: string;
confidencePct: number;
@ -215,6 +283,9 @@ export interface CreateOpportunityPayload {
opportunityType?: string;
productType?: string;
source?: string;
salesExpansionId?: number;
channelExpansionId?: number;
competitorName?: string;
pushedToOms?: boolean;
description?: string;
}
@ -234,16 +305,23 @@ export interface ExpansionFollowUp {
type?: string;
content?: string;
user?: string;
visitStartTime?: string;
evaluationContent?: string;
nextPlan?: string;
}
export interface SalesExpansionItem {
id: number;
type: "sales";
employeeNo?: string;
name?: string;
officeCode?: string;
officeName?: string;
phone?: string;
email?: string;
targetDeptId?: number;
targetDept?: string;
dept?: string;
industryCode?: string;
industry?: string;
title?: string;
intentLevel?: string;
@ -256,48 +334,86 @@ export interface SalesExpansionItem {
employmentStatus?: string;
expectedJoinDate?: string;
notes?: string;
relatedProjects?: RelatedProjectSummary[];
followUps?: ExpansionFollowUp[];
}
export interface RelatedProjectSummary {
opportunityId: number;
opportunityCode?: string;
opportunityName?: string;
amount?: number;
}
export interface ChannelExpansionItem {
id: number;
type: "channel";
channelCode?: string;
name?: string;
province?: string;
industry?: string;
officeAddress?: string;
channelIndustry?: string;
annualRevenue?: string;
revenue?: string;
size?: number;
contact?: string;
contactTitle?: string;
phone?: string;
primaryContactName?: string;
primaryContactTitle?: string;
primaryContactMobile?: string;
establishedDate?: string;
intentLevel?: string;
intent?: string;
hasDesktopExp?: boolean;
channelAttribute?: string;
internalAttribute?: string;
stageCode?: string;
stage?: string;
landed?: boolean;
expectedSignDate?: string;
notes?: string;
contacts?: ChannelExpansionContact[];
relatedProjects?: ChannelRelatedProjectSummary[];
followUps?: ExpansionFollowUp[];
}
export interface ChannelExpansionContact {
id?: number;
name?: string;
mobile?: string;
title?: string;
}
export interface ChannelRelatedProjectSummary {
opportunityId: number;
opportunityCode?: string;
opportunityName?: string;
amount?: number;
}
export interface ExpansionOverview {
salesItems?: SalesExpansionItem[];
channelItems?: ChannelExpansionItem[];
}
export interface DepartmentOption {
id: number;
name?: string;
export interface ExpansionDictOption {
label?: string;
value?: string;
}
export interface ExpansionMeta {
departments?: DepartmentOption[];
officeOptions?: ExpansionDictOption[];
industryOptions?: ExpansionDictOption[];
channelAttributeOptions?: ExpansionDictOption[];
internalAttributeOptions?: ExpansionDictOption[];
nextChannelCode?: string;
}
export interface CreateSalesExpansionPayload {
employeeNo: string;
candidateName: string;
officeName?: string;
mobile?: string;
email?: string;
targetDeptId?: number;
targetDept?: string;
industry?: string;
title?: string;
intentLevel?: string;
@ -310,18 +426,21 @@ export interface CreateSalesExpansionPayload {
}
export interface CreateChannelExpansionPayload {
channelCode?: string;
officeAddress?: string;
channelIndustry?: string;
channelName: string;
province?: string;
industry?: string;
annualRevenue?: number;
staffSize?: number;
contactName?: string;
contactTitle?: string;
contactMobile?: string;
contactEstablishedDate?: string;
intentLevel?: string;
hasDesktopExp?: boolean;
channelAttribute?: string;
internalAttribute?: string;
stage?: string;
landedFlag?: boolean;
expectedSignDate?: string;
remark?: string;
contacts?: ChannelExpansionContact[];
}
export interface UpdateSalesExpansionPayload extends CreateSalesExpansionPayload {}
@ -333,6 +452,9 @@ export interface CreateExpansionFollowUpPayload {
content: string;
nextAction?: string;
followUpTime: string;
visitStartTime?: string;
evaluationContent?: string;
nextPlan?: string;
}
interface ApiEnvelope<T> {
@ -348,14 +470,13 @@ interface ApiErrorBody {
}
const LOGIN_PATH = "/login";
const USER_CONTEXT_CACHE_TTL_MS = 2 * 60 * 1000;
const CURRENT_USER_CACHE_KEY = "auth-cache:current-user";
const PROFILE_OVERVIEW_CACHE_KEY = "auth-cache:profile-overview";
const memoryRequestCache = new Map<string, { expiresAt: number; value: unknown }>();
const inFlightRequestCache = new Map<string, Promise<unknown>>();
async function request<T>(input: string, init?: RequestInit, withAuth = false): Promise<T> {
const headers = new Headers(init?.headers);
if (!headers.has("Content-Type") && init?.body && !(init.body instanceof FormData)) {
headers.set("Content-Type", "application/json");
}
if (withAuth) {
function applyAuthHeaders(headers: Headers) {
const token = localStorage.getItem("accessToken");
if (token) {
headers.set("Authorization", `Bearer ${token}`);
@ -365,6 +486,23 @@ async function request<T>(input: string, init?: RequestInit, withAuth = false):
if (userId !== undefined) {
headers.set("X-User-Id", String(userId));
}
return headers;
}
function handleUnauthorizedResponse() {
clearAuth();
window.location.href = `${LOGIN_PATH}?timeout=1`;
}
async function request<T>(input: string, init?: RequestInit, withAuth = false): Promise<T> {
const headers = new Headers(init?.headers);
if (!headers.has("Content-Type") && init?.body && !(init.body instanceof FormData)) {
headers.set("Content-Type", "application/json");
}
if (withAuth) {
applyAuthHeaders(headers);
}
const response = await fetch(input, {
@ -373,8 +511,7 @@ async function request<T>(input: string, init?: RequestInit, withAuth = false):
});
if (response.status === 401 || response.status === 403) {
clearAuth();
window.location.href = `${LOGIN_PATH}?timeout=1`;
handleUnauthorizedResponse();
throw new Error("登录已失效,请重新登录");
}
@ -402,6 +539,20 @@ async function request<T>(input: string, init?: RequestInit, withAuth = false):
return body.data;
}
export async function fetchWithAuth(input: string, init?: RequestInit) {
const response = await fetch(input, {
...init,
headers: applyAuthHeaders(new Headers(init?.headers)),
});
if (response.status === 401 || response.status === 403) {
handleUnauthorizedResponse();
throw new Error("登录已失效,请重新登录");
}
return response;
}
export function isAuthed() {
return Boolean(localStorage.getItem("accessToken"));
}
@ -413,9 +564,11 @@ export function clearAuth() {
localStorage.removeItem("availableTenants");
localStorage.removeItem("activeTenantId");
sessionStorage.removeItem("userProfile");
clearCachedAuthContext();
}
export function persistLogin(payload: TokenResponse, username: string) {
clearCachedAuthContext();
localStorage.setItem("accessToken", payload.accessToken);
localStorage.setItem("refreshToken", payload.refreshToken);
localStorage.setItem("username", username);
@ -487,22 +640,36 @@ export async function getOpenPlatformConfig() {
}
export async function getCurrentUser() {
return request<UserProfile>("/api/sys/api/users/me", undefined, true);
return getCachedAuthedRequest<UserProfile>(
CURRENT_USER_CACHE_KEY,
() => request<UserProfile>("/api/sys/api/users/me", undefined, true),
);
}
export async function getDashboardHome() {
return request<DashboardHome>("/api/dashboard/home", undefined, true);
}
export async function completeDashboardTodo(todoId: string) {
return request<void>(`/api/dashboard/todos/${todoId}/complete`, {
method: "POST",
}, true);
}
export async function getProfileOverview() {
return request<ProfileOverview>("/api/profile/overview", undefined, true);
return getCachedAuthedRequest<ProfileOverview>(
PROFILE_OVERVIEW_CACHE_KEY,
() => request<ProfileOverview>("/api/profile/overview", undefined, true),
);
}
export async function updateCurrentUserProfile(payload: UpdateCurrentUserProfilePayload) {
return request<boolean>("/api/sys/api/users/profile", {
const result = await request<boolean>("/api/sys/api/users/profile", {
method: "PUT",
body: JSON.stringify(payload),
}, true);
clearCachedAuthContext();
return result;
}
export async function updateCurrentUserPassword(payload: UpdateCurrentUserPasswordPayload) {
@ -523,141 +690,17 @@ export async function getWorkOverview() {
return request<WorkOverview>("/api/work/overview", undefined, true);
}
export async function getWorkHistory(type: "checkin" | "report", page = 1, size = 8) {
const params = new URLSearchParams({
type,
page: String(page),
size: String(size),
});
return request<WorkHistoryPage>(`/api/work/history?${params.toString()}`, undefined, true);
}
export async function reverseWorkGeocode(latitude: number, longitude: number) {
const nominatimParams = new URLSearchParams({
format: "jsonv2",
addressdetails: "1",
namedetails: "1",
extratags: "1",
zoom: "19",
lat: String(latitude),
lon: String(longitude),
"accept-language": "zh-CN,zh;q=0.9,en;q=0.8",
});
try {
const response = await fetch(`https://nominatim.openstreetmap.org/reverse?${nominatimParams.toString()}`, {
headers: {
Accept: "application/json",
},
});
if (!response.ok) {
throw new Error(`Nominatim reverse geocoding failed (${response.status})`);
}
const payload = (await response.json()) as {
display_name?: string;
address?: Record<string, string>;
namedetails?: Record<string, string>;
};
const orderedLocation = buildNominatimLocationName(payload);
if (orderedLocation) {
return orderedLocation;
}
} catch {
// Fall through to the browser-side free fallback.
}
const bigDataCloudParams = new URLSearchParams({
latitude: String(latitude),
longitude: String(longitude),
localityLanguage: "zh",
});
const fallbackResponse = await fetch(`https://api.bigdatacloud.net/data/reverse-geocode-client?${bigDataCloudParams.toString()}`);
if (!fallbackResponse.ok) {
throw new Error("地点解析失败,请稍后重试");
}
const fallbackPayload = (await fallbackResponse.json()) as {
locality?: string;
city?: string;
principalSubdivision?: string;
countryName?: string;
};
const fallbackLocation = joinLocationParts(
fallbackPayload.countryName,
fallbackPayload.principalSubdivision,
fallbackPayload.city,
fallbackPayload.locality,
);
if (fallbackLocation) {
return fallbackLocation;
}
throw new Error("未能解析出具体地点名称");
}
function buildNominatimLocationName(payload: {
display_name?: string;
address?: Record<string, string>;
namedetails?: Record<string, string>;
}) {
const address = payload.address ?? {};
const namedetails = payload.namedetails ?? {};
const regionPart = joinLocationParts(
firstDefined(address.state, address.province, address.region),
firstDefined(address.city, address.municipality, address.town, address.county),
firstDefined(address.district, address.city_district, address.borough),
);
const streetPart = joinLocationParts(
firstDefined(address.suburb, address.township, address.quarter, address.neighbourhood),
firstDefined(address.road, address.street, address.pedestrian),
joinLocationParts(address.house_number, address.house_name),
);
const buildingPart = firstDefined(
address.building,
address.city_block,
address.amenity,
address.office,
address.shop,
address.commercial,
address.residential,
address.industrial,
address.retail,
namedetails.name,
namedetails.official_name,
namedetails.short_name,
);
return firstDefined(
joinLocationParts(regionPart, streetPart, buildingPart),
joinLocationParts(regionPart, streetPart),
normalizeLocationText(payload.display_name),
);
}
function firstDefined(...values: Array<string | undefined>) {
for (const value of values) {
const normalized = normalizeLocationText(value);
if (normalized) {
return normalized;
}
}
return undefined;
}
function joinLocationParts(...values: Array<string | undefined>) {
const result: string[] = [];
for (const value of values) {
const normalized = normalizeLocationText(value);
if (!normalized || result.includes(normalized)) {
continue;
}
result.push(normalized);
}
return result.length ? result.join("") : undefined;
}
function normalizeLocationText(value?: string) {
const normalized = value?.trim();
return normalized ? normalized : undefined;
return request<string>(`/api/work/reverse-geocode?lat=${latitude}&lon=${longitude}`, undefined, true);
}
export async function saveWorkCheckIn(payload: CreateWorkCheckInPayload) {
@ -695,6 +738,10 @@ export async function getOpportunityOverview(keyword?: string, stage?: string) {
return request<OpportunityOverview>(`/api/opportunities/overview${query ? `?${query}` : ""}`, undefined, true);
}
export async function getOpportunityMeta() {
return request<OpportunityMeta>("/api/opportunities/meta", undefined, true);
}
export async function createOpportunity(payload: CreateOpportunityPayload) {
return request<number>("/api/opportunities", {
method: "POST",
@ -709,6 +756,12 @@ export async function updateOpportunity(opportunityId: number, payload: CreateOp
}, true);
}
export async function pushOpportunityToOms(opportunityId: number) {
return request<number>(`/api/opportunities/${opportunityId}/push-oms`, {
method: "POST",
}, true);
}
export async function createOpportunityFollowUp(opportunityId: number, payload: CreateOpportunityFollowUpPayload) {
return request<number>(`/api/opportunities/${opportunityId}/followups`, {
method: "POST",
@ -767,3 +820,83 @@ export async function createExpansionFollowUp(
body: JSON.stringify(payload),
}, true);
}
function readCachedValue<T>(cacheKey: string) {
const memoryValue = memoryRequestCache.get(cacheKey);
if (memoryValue && memoryValue.expiresAt > Date.now()) {
return memoryValue.value as T;
}
memoryRequestCache.delete(cacheKey);
try {
const raw = sessionStorage.getItem(cacheKey);
if (!raw) {
return null;
}
const parsed = JSON.parse(raw) as { expiresAt?: number; value?: T };
if (!parsed.expiresAt || parsed.expiresAt <= Date.now()) {
sessionStorage.removeItem(cacheKey);
return null;
}
memoryRequestCache.set(cacheKey, { expiresAt: parsed.expiresAt, value: parsed.value });
return parsed.value ?? null;
} catch {
sessionStorage.removeItem(cacheKey);
return null;
}
}
function writeCachedValue<T>(cacheKey: string, value: T, ttlMs = USER_CONTEXT_CACHE_TTL_MS) {
const payload = {
expiresAt: Date.now() + ttlMs,
value,
};
memoryRequestCache.set(cacheKey, payload);
try {
sessionStorage.setItem(cacheKey, JSON.stringify(payload));
} catch {
// Ignore session cache failures and keep in-memory cache only.
}
}
function clearCachedAuthContext() {
[CURRENT_USER_CACHE_KEY, PROFILE_OVERVIEW_CACHE_KEY].forEach((cacheKey) => {
memoryRequestCache.delete(cacheKey);
inFlightRequestCache.delete(cacheKey);
try {
sessionStorage.removeItem(cacheKey);
} catch {
// Ignore session cache cleanup failures.
}
});
}
async function getCachedAuthedRequest<T>(
cacheKey: string,
fetcher: () => Promise<T>,
ttlMs = USER_CONTEXT_CACHE_TTL_MS,
) {
const cachedValue = readCachedValue<T>(cacheKey);
if (cachedValue) {
return cachedValue;
}
const inFlight = inFlightRequestCache.get(cacheKey);
if (inFlight) {
return inFlight as Promise<T>;
}
const requestPromise = fetcher()
.then((result) => {
writeCachedValue(cacheKey, result, ttlMs);
inFlightRequestCache.delete(cacheKey);
return result;
})
.catch((error) => {
inFlightRequestCache.delete(cacheKey);
throw error;
});
inFlightRequestCache.set(cacheKey, requestPromise);
return requestPromise;
}

View File

@ -0,0 +1,436 @@
const DEFAULT_TENCENT_MAP_KEY = "LJYBZ-HCQCV-N37PU-5FIOX-QFA26-FPB6U";
const TENCENT_MAP_REFERER = "unis-crm-work";
const TENCENT_GEOLOCATION_SCRIPT_ID = "tencent-geolocation-sdk";
const TENCENT_GEOLOCATION_SCRIPT_SRC = "https://mapapi.qq.com/web/lbs/h5-components/geolocation/geolocation.min.js";
type BrowserGeolocationOptions = PositionOptions;
type BrowserLocationSamplingOptions = {
targetAccuracy?: number;
totalTimeout?: number;
maxSamples?: number;
};
type TencentGeolocationOptions = {
timeout?: number;
highAccuracy?: boolean;
maximumAge?: number;
};
type TencentGeolocationResult = {
province?: string;
city?: string;
district?: string;
addr?: string;
lat: number;
lng: number;
accuracy?: number;
type?: "h5" | "wx" | "qq" | "x5" | "ip";
};
type TencentGeolocationError = {
status?: number;
message?: string;
};
type TencentGeolocationInstance = {
getLocation: (
sucCallback: (result: TencentGeolocationResult) => void,
errCallback?: ((error: TencentGeolocationError) => void) | null,
options?: TencentGeolocationOptions,
) => void;
};
type TencentGeolocationConstructor = new (options: {
key: string;
referer: string;
domain?: string;
}) => TencentGeolocationInstance;
declare global {
interface Window {
LBS?: {
WebComponent?: {
Geolocation?: TencentGeolocationConstructor;
};
};
}
}
export type TencentMapLocation = {
latitude: number;
longitude: number;
address: string;
accuracy?: number;
sourceType?: "browser" | "tencent";
};
const EARTH_SEMI_MAJOR_AXIS = 6378245.0;
const EARTH_ECCENTRICITY = 0.00669342162296594323;
let geolocationSdkPromise: Promise<TencentGeolocationConstructor> | null = null;
export async function resolveTencentMapLocation() {
let sdkError: Error | null = null;
let browserError: Error | null = null;
const browserResult = await resolveWithBrowser().catch((error) => {
browserError = error instanceof Error ? error : new Error("浏览器定位失败");
return null;
});
const sdkResult = await resolveWithTencentSdk().catch((error) => {
sdkError = error instanceof Error ? error : new Error("腾讯地图定位失败");
return null;
});
const bestResult = pickBetterLocationResult(sdkResult, browserResult);
if (bestResult) {
return bestResult;
}
throw browserError || sdkError || new Error("定位失败,请稍后重试。");
}
async function resolveWithTencentSdk() {
const Geolocation = await loadTencentGeolocationSdk();
try {
const preciseLocation = await getTencentLocationOnce(Geolocation, {
timeout: 12000,
highAccuracy: true,
maximumAge: 0,
});
if (preciseLocation.type !== "ip") {
return normalizeTencentLocation(preciseLocation);
}
} catch {
// Fall through to the lower-accuracy Tencent attempt below.
}
const fallbackLocation = await getTencentLocationOnce(Geolocation, {
timeout: 15000,
highAccuracy: false,
maximumAge: 300000,
});
return normalizeTencentLocation(fallbackLocation);
}
async function resolveWithBrowser() {
const preciseLocation = await getBestAvailablePosition(
{
enableHighAccuracy: true,
timeout: 18000,
maximumAge: 0,
},
{
targetAccuracy: 25,
totalTimeout: 15000,
maxSamples: 6,
},
).catch(async (firstError) => {
try {
return await getCurrentPosition({
enableHighAccuracy: false,
timeout: 18000,
maximumAge: 30000,
});
} catch {
throw firstError;
}
});
return {
latitude: Number(preciseLocation.coords.latitude.toFixed(6)),
longitude: Number(preciseLocation.coords.longitude.toFixed(6)),
address: "",
accuracy: preciseLocation.coords.accuracy,
sourceType: "browser" as const,
};
}
function pickBetterLocationResult(
sdkResult: TencentMapLocation | null,
browserResult: TencentMapLocation | null,
) {
if (sdkResult && !browserResult) {
return sdkResult;
}
if (!sdkResult && browserResult) {
return browserResult;
}
if (!sdkResult || !browserResult) {
return null;
}
const sdkAccuracy = sdkResult.accuracy ?? Number.POSITIVE_INFINITY;
const browserAccuracy = browserResult.accuracy ?? Number.POSITIVE_INFINITY;
if (Number.isFinite(browserAccuracy) && browserAccuracy <= 80) {
if (!Number.isFinite(sdkAccuracy) || browserAccuracy + 15 < sdkAccuracy) {
return browserResult;
}
}
if (sdkResult.address && sdkAccuracy <= browserAccuracy + 20) {
return sdkResult;
}
if (sdkAccuracy + 15 < browserAccuracy) {
return sdkResult;
}
return browserResult;
}
function loadTencentGeolocationSdk() {
if (window.LBS?.WebComponent?.Geolocation) {
return Promise.resolve(window.LBS.WebComponent.Geolocation);
}
if (geolocationSdkPromise) {
return geolocationSdkPromise;
}
const key = getTencentMapKey();
geolocationSdkPromise = new Promise<TencentGeolocationConstructor>((resolve, reject) => {
const resolveSdk = () => {
const Geolocation = window.LBS?.WebComponent?.Geolocation;
if (!Geolocation) {
reject(new Error("腾讯地图定位组件加载失败,请刷新页面重试。"));
return;
}
resolve(Geolocation);
};
const existingScript = document.getElementById(TENCENT_GEOLOCATION_SCRIPT_ID) as HTMLScriptElement | null;
if (existingScript) {
if (window.LBS?.WebComponent?.Geolocation) {
resolveSdk();
return;
}
existingScript.addEventListener("load", resolveSdk, { once: true });
existingScript.addEventListener("error", () => reject(new Error("腾讯地图定位组件加载失败,请检查网络后重试。")), { once: true });
return;
}
const script = document.createElement("script");
script.id = TENCENT_GEOLOCATION_SCRIPT_ID;
script.src = TENCENT_GEOLOCATION_SCRIPT_SRC;
script.async = true;
script.defer = true;
script.onload = resolveSdk;
script.onerror = () => reject(new Error("腾讯地图定位组件加载失败,请检查网络后重试。"));
document.head.appendChild(script);
}).catch((error) => {
geolocationSdkPromise = null;
throw error;
});
return geolocationSdkPromise;
}
function getTencentLocationOnce(Geolocation: TencentGeolocationConstructor, options: TencentGeolocationOptions) {
const geoInstance = new Geolocation({
key: getTencentMapKey(),
referer: TENCENT_MAP_REFERER,
});
return new Promise<TencentGeolocationResult>((resolve, reject) => {
geoInstance.getLocation(
(result) => resolve(result),
(error) => reject(new Error(getTencentLocationErrorMessage(error))),
options,
);
});
}
function getTencentMapKey() {
const key = (import.meta.env.VITE_TENCENT_MAP_KEY || DEFAULT_TENCENT_MAP_KEY).trim();
if (!key) {
throw new Error("未配置腾讯地图 Key无法完成定位。");
}
return key;
}
function normalizeTencentLocation(result: TencentGeolocationResult): TencentMapLocation {
const wgs84Point = gcj02ToWgs84(result.lat, result.lng);
return {
latitude: Number(wgs84Point.latitude.toFixed(6)),
longitude: Number(wgs84Point.longitude.toFixed(6)),
address: buildTencentAddress(result),
accuracy: result.accuracy,
sourceType: "tencent" as const,
};
}
function buildTencentAddress(result: TencentGeolocationResult) {
return [
normalizeText(result.addr),
[result.province, result.city, result.district].map(normalizeText).filter(Boolean).join(""),
].find(Boolean) || "";
}
function getCurrentPosition(options: BrowserGeolocationOptions) {
if (!("geolocation" in navigator)) {
throw new Error("当前浏览器不支持定位,请更换浏览器后重试。");
}
return new Promise<GeolocationPosition>((resolve, reject) => {
navigator.geolocation.getCurrentPosition(
(position) => resolve(position),
(error) => reject(new Error(getBrowserLocationErrorMessage(error))),
options,
);
});
}
function getBestAvailablePosition(options: BrowserGeolocationOptions, samplingOptions: BrowserLocationSamplingOptions) {
if (!("geolocation" in navigator)) {
throw new Error("当前浏览器不支持定位,请更换浏览器后重试。");
}
const targetAccuracy = samplingOptions.targetAccuracy ?? 35;
const totalTimeout = samplingOptions.totalTimeout ?? 9000;
const maxSamples = samplingOptions.maxSamples ?? 4;
return new Promise<GeolocationPosition>((resolve, reject) => {
let settled = false;
let sampleCount = 0;
let bestPosition: GeolocationPosition | null = null;
let lastError: GeolocationPositionError | null = null;
const finalize = (position?: GeolocationPosition) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
navigator.geolocation.clearWatch(watchId);
if (position) {
resolve(position);
return;
}
if (bestPosition) {
resolve(bestPosition);
return;
}
reject(new Error(getBrowserLocationErrorMessage(lastError)));
};
const timer = window.setTimeout(() => finalize(), totalTimeout);
const watchId = navigator.geolocation.watchPosition(
(position) => {
sampleCount += 1;
if (!bestPosition || position.coords.accuracy < bestPosition.coords.accuracy) {
bestPosition = position;
}
if (position.coords.accuracy <= targetAccuracy || sampleCount >= maxSamples) {
finalize(bestPosition ?? position);
}
},
(error) => {
lastError = error;
if (!bestPosition) {
finalize();
}
},
options,
);
});
}
function normalizeText(value?: string | null) {
return value?.trim() || "";
}
function getTencentLocationErrorMessage(error: TencentGeolocationError) {
switch (error.status) {
case 10101:
case 10302:
case 10305:
return "定位权限被拒绝,请在手机浏览器里允许位置权限后再重试。";
case 10103:
case 10002:
case 10402:
case 10502:
return "腾讯地图定位超时,请重试。";
case 10104:
return "当前浏览器不支持腾讯地图定位,请更换浏览器后重试。";
case 10001:
return "当前位置暂时不可用,请移动到开阔区域后重试。";
case 10601:
case 10602:
return "腾讯地图地址解析失败,请稍后重试。";
default:
return error.message?.trim() || "腾讯地图定位失败,请稍后重试。";
}
}
function getBrowserLocationErrorMessage(error?: GeolocationPositionError | null) {
if (!error) {
return "定位失败,请稍后重试。";
}
switch (error.code) {
case error.PERMISSION_DENIED:
return "定位权限被拒绝,请在手机浏览器里允许位置权限后再重试。";
case error.POSITION_UNAVAILABLE:
return "当前位置暂时不可用,请移动到开阔区域后重试。";
case error.TIMEOUT:
return "定位超时,请重试。";
default:
return error.message?.trim() || "定位失败,请稍后重试。";
}
}
export function wgs84ToGcj02(latitude: number, longitude: number) {
if (isOutsideChina(latitude, longitude)) {
return { latitude, longitude };
}
let deltaLatitude = transformLatitude(longitude - 105.0, latitude - 35.0);
let deltaLongitude = transformLongitude(longitude - 105.0, latitude - 35.0);
const radLatitude = (latitude / 180.0) * Math.PI;
let magic = Math.sin(radLatitude);
magic = 1 - EARTH_ECCENTRICITY * magic * magic;
const sqrtMagic = Math.sqrt(magic);
deltaLatitude = (deltaLatitude * 180.0)
/ (((EARTH_SEMI_MAJOR_AXIS * (1 - EARTH_ECCENTRICITY)) / (magic * sqrtMagic)) * Math.PI);
deltaLongitude = (deltaLongitude * 180.0)
/ ((EARTH_SEMI_MAJOR_AXIS / sqrtMagic) * Math.cos(radLatitude) * Math.PI);
return {
latitude: latitude + deltaLatitude,
longitude: longitude + deltaLongitude,
};
}
export function gcj02ToWgs84(latitude: number, longitude: number) {
if (isOutsideChina(latitude, longitude)) {
return { latitude, longitude };
}
const normalizedGcj02Point = wgs84ToGcj02(latitude, longitude);
return {
latitude: latitude * 2 - normalizedGcj02Point.latitude,
longitude: longitude * 2 - normalizedGcj02Point.longitude,
};
}
function isOutsideChina(latitude: number, longitude: number) {
return longitude < 72.004 || longitude > 137.8347 || latitude < 0.8293 || latitude > 55.8271;
}
function transformLatitude(longitude: number, latitude: number) {
let result = -100.0 + 2.0 * longitude + 3.0 * latitude + 0.2 * latitude * latitude
+ 0.1 * longitude * latitude + 0.2 * Math.sqrt(Math.abs(longitude));
result += (20.0 * Math.sin(6.0 * longitude * Math.PI) + 20.0 * Math.sin(2.0 * longitude * Math.PI)) * 2.0 / 3.0;
result += (20.0 * Math.sin(latitude * Math.PI) + 40.0 * Math.sin((latitude / 3.0) * Math.PI)) * 2.0 / 3.0;
result += (160.0 * Math.sin((latitude / 12.0) * Math.PI) + 320 * Math.sin((latitude * Math.PI) / 30.0)) * 2.0 / 3.0;
return result;
}
function transformLongitude(longitude: number, latitude: number) {
let result = 300.0 + longitude + 2.0 * latitude + 0.1 * longitude * longitude
+ 0.1 * longitude * latitude + 0.1 * Math.sqrt(Math.abs(longitude));
result += (20.0 * Math.sin(6.0 * longitude * Math.PI) + 20.0 * Math.sin(2.0 * longitude * Math.PI)) * 2.0 / 3.0;
result += (20.0 * Math.sin(longitude * Math.PI) + 40.0 * Math.sin((longitude / 3.0) * Math.PI)) * 2.0 / 3.0;
result += (150.0 * Math.sin((longitude / 12.0) * Math.PI) + 300.0 * Math.sin((longitude / 30.0) * Math.PI)) * 2.0 / 3.0;
return result;
}

View File

@ -0,0 +1,93 @@
const DEFAULT_TENCENT_MAP_KEY = "LJYBZ-HCQCV-N37PU-5FIOX-QFA26-FPB6U";
const TENCENT_MAP_GL_SCRIPT_ID = "tencent-map-gl-sdk";
type TMapLatLngLike = {
getLat?: () => number;
getLng?: () => number;
lat?: number;
lng?: number;
};
type TMapClickEvent = {
latLng?: TMapLatLngLike;
};
type TMapMapInstance = {
on: (event: string, handler: (event: TMapClickEvent) => void) => void;
off?: (event: string, handler: (event: TMapClickEvent) => void) => void;
getCenter?: () => TMapLatLngLike;
setCenter?: (latLng: unknown) => void;
panTo?: (latLng: unknown) => void;
destroy?: () => void;
};
type TMapMultiMarkerInstance = {
setGeometries: (geometries: unknown[]) => void;
};
type TMapApi = {
Map: new (container: HTMLElement, options: Record<string, unknown>) => TMapMapInstance;
LatLng: new (lat: number, lng: number) => TMapLatLngLike;
MultiMarker: new (options: Record<string, unknown>) => TMapMultiMarkerInstance;
MultiCircle: new (options: Record<string, unknown>) => unknown;
MarkerStyle: new (options: Record<string, unknown>) => unknown;
CircleStyle: new (options: Record<string, unknown>) => unknown;
};
declare global {
interface Window {
TMap?: TMapApi;
}
}
let tencentMapGlPromise: Promise<TMapApi> | null = null;
export async function loadTencentMapGlApi() {
if (window.TMap) {
return window.TMap;
}
if (tencentMapGlPromise) {
return tencentMapGlPromise;
}
const key = (import.meta.env.VITE_TENCENT_MAP_KEY || DEFAULT_TENCENT_MAP_KEY).trim();
if (!key) {
throw new Error("未配置腾讯地图 Key无法展示地图。");
}
tencentMapGlPromise = new Promise<TMapApi>((resolve, reject) => {
const resolveApi = () => {
if (!window.TMap) {
reject(new Error("腾讯地图组件加载失败,请刷新页面后重试。"));
return;
}
resolve(window.TMap);
};
const existingScript = document.getElementById(TENCENT_MAP_GL_SCRIPT_ID) as HTMLScriptElement | null;
if (existingScript) {
if (window.TMap) {
resolveApi();
return;
}
existingScript.addEventListener("load", resolveApi, { once: true });
existingScript.addEventListener("error", () => reject(new Error("腾讯地图组件加载失败,请检查网络后重试。")), { once: true });
return;
}
const script = document.createElement("script");
script.id = TENCENT_MAP_GL_SCRIPT_ID;
script.src = `https://map.qq.com/api/gljs?v=1.exp&key=${encodeURIComponent(key)}`;
script.async = true;
script.defer = true;
script.onload = resolveApi;
script.onerror = () => reject(new Error("腾讯地图组件加载失败,请检查网络后重试。"));
document.head.appendChild(script);
}).catch((error) => {
tencentMapGlPromise = null;
throw error;
});
return tencentMapGlPromise;
}

View File

@ -1,21 +1,25 @@
import { useEffect, useState } from "react";
import { BarChart3, TrendingUp, Users, CheckCircle2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { BarChart3, Building2, Check, TrendingUp, Users } from "lucide-react";
import { motion } from "motion/react";
import { getDashboardHome, type DashboardActivity, type DashboardHome, type DashboardStat, type DashboardTodo } from "@/lib/auth";
import { completeDashboardTodo, getDashboardHome, type DashboardActivity, type DashboardHome, type DashboardStat, type DashboardTodo } from "@/lib/auth";
const DASHBOARD_PREVIEW_COUNT = 5;
const DASHBOARD_HISTORY_PREVIEW_COUNT = 3;
const baseStats = [
{ name: "本月新增商机", metricKey: "monthlyOpportunities", icon: TrendingUp, color: "text-emerald-600 dark:text-emerald-400", bg: "bg-emerald-100 dark:bg-emerald-500/20" },
{ name: "跟进中客户", metricKey: "followingCustomers", icon: Users, color: "text-blue-600 dark:text-blue-400", bg: "bg-blue-100 dark:bg-blue-500/20" },
{ name: "已成单项目", metricKey: "wonProjects", icon: CheckCircle2, color: "text-violet-600 dark:text-violet-400", bg: "bg-violet-100 dark:bg-violet-500/20" },
{ name: "本月打卡数", metricKey: "monthlyCheckins", icon: BarChart3, color: "text-amber-600 dark:text-amber-400", bg: "bg-amber-100 dark:bg-amber-500/20" },
{ name: "已推送OMS项目", metricKey: "pushedOmsProjects", icon: Users, color: "text-blue-600 dark:text-blue-400", bg: "bg-blue-100 dark:bg-blue-500/20" },
{ name: "本月新增渠道", metricKey: "monthlyChannels", icon: Building2, color: "text-violet-600 dark:text-violet-400", bg: "bg-violet-100 dark:bg-violet-500/20" },
{ name: "本月打卡数", metricKey: "monthlyCheckins", icon: BarChart3, color: "text-amber-600 dark:text-amber-400", bg: "bg-amber-100 dark:bg-amber-500/20" },
] as const;
export default function Dashboard() {
const [home, setHome] = useState<DashboardHome>({});
const [showAllTodos, setShowAllTodos] = useState(false);
const [loading, setLoading] = useState(true);
const [showAllActivities, setShowAllActivities] = useState(false);
const [showAllHistoryTodos, setShowAllHistoryTodos] = useState(false);
const [historyExpanded, setHistoryExpanded] = useState(false);
const [completingTodoId, setCompletingTodoId] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
@ -30,6 +34,10 @@ export default function Dashboard() {
if (!cancelled) {
setHome({});
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
@ -41,90 +49,195 @@ export default function Dashboard() {
}, []);
useEffect(() => {
setShowAllTodos(false);
setShowAllActivities(false);
}, [home.todos, home.activities]);
setShowAllHistoryTodos(false);
setHistoryExpanded(false);
}, [home.activities, home.todos]);
const statMap = new Map((home.stats ?? []).map((item: DashboardStat) => [item.metricKey, item.value]));
const stats = baseStats.map((stat) => ({
...stat,
value: statMap.get(stat.metricKey),
}));
const todos = (home.todos?.length ? home.todos : [{ id: 0, title: "无" }]) as DashboardTodo[];
const pendingTodos = useMemo(
() => (home.todos ?? []).filter((item) => item.status !== "done"),
[home.todos],
);
const historyTodos = useMemo(
() => (home.todos ?? []).filter((item) => item.status === "done"),
[home.todos],
);
const visibleHistoryTodos = showAllHistoryTodos ? historyTodos : historyTodos.slice(0, DASHBOARD_HISTORY_PREVIEW_COUNT);
const activities = (home.activities?.length
? home.activities
: [{ id: 0, title: "无", content: "无", timeText: "无" }]) as DashboardActivity[];
const visibleTodos = showAllTodos ? todos : todos.slice(0, DASHBOARD_PREVIEW_COUNT);
const visibleActivities = showAllActivities ? activities : activities.slice(0, DASHBOARD_PREVIEW_COUNT);
const hasMoreTodos = todos.length > DASHBOARD_PREVIEW_COUNT && todos[0]?.id !== 0;
const hasMoreActivities = activities.length > DASHBOARD_PREVIEW_COUNT && activities[0]?.id !== 0;
const hasMoreHistoryTodos = historyTodos.length > DASHBOARD_HISTORY_PREVIEW_COUNT;
const handleCompleteTodo = async (todoId: string) => {
if (!todoId || completingTodoId === todoId) {
return;
}
setCompletingTodoId(todoId);
try {
await completeDashboardTodo(todoId);
setHome((current) => ({
...current,
todos: (current.todos ?? []).map((item) => (
item.id === todoId
? { ...item, status: "done", updatedAt: new Date().toISOString() }
: item
)),
}));
} catch {
// Keep current UI state when completion fails silently.
} finally {
setCompletingTodoId(null);
}
};
return (
<div className="space-y-6">
<header>
<h1 className="text-2xl font-bold tracking-tight text-slate-900 dark:text-white"></h1>
<p className="text-sm text-slate-500 dark:text-slate-400">
<div className="crm-page-stack">
<header className="space-y-1 sm:space-y-2">
<h1 className="text-xl font-bold tracking-tight text-slate-900 dark:text-white sm:text-2xl"></h1>
<p className="text-xs leading-5 text-slate-500 dark:text-slate-400 sm:text-sm">
{home.realName || "无"} {home.onboardingDays ?? 0}
</p>
</header>
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
{loading ? (
<DashboardSkeleton />
) : (
<>
<div className="grid grid-cols-2 gap-2.5 sm:gap-4 xl:grid-cols-4">
{stats.map((stat, i) => (
<motion.div
key={stat.name}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1 }}
className="rounded-2xl border border-slate-100 dark:border-slate-800 bg-white dark:bg-slate-900/50 p-5 shadow-sm backdrop-blur-sm transition-all hover:shadow-md dark:hover:bg-slate-900"
className="crm-card rounded-xl p-3 transition-all hover:shadow-md dark:hover:bg-slate-900 sm:rounded-2xl sm:p-5"
>
<div className="flex items-center gap-4">
<div className={`flex h-12 w-12 items-center justify-center rounded-xl ${stat.bg}`}>
<stat.icon className={`h-6 w-6 ${stat.color}`} />
<div className="flex items-center gap-2.5 sm:gap-4">
<div className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${stat.bg} sm:h-12 sm:w-12 sm:rounded-xl`}>
<stat.icon className={`h-4.5 w-4.5 ${stat.color} sm:h-6 sm:w-6`} />
</div>
<div>
<p className="text-sm font-medium text-slate-500 dark:text-slate-400">{stat.name}</p>
<p className="text-2xl font-bold text-slate-900 dark:text-white">{stat.value ?? 0}</p>
<div className="min-w-0 flex-1">
<p className="truncate text-[11px] font-medium leading-4 text-slate-500 dark:text-slate-400 sm:text-sm sm:leading-5">{stat.name}</p>
<p className="mt-1 text-lg font-bold leading-none text-slate-900 dark:text-white sm:text-2xl">{stat.value ?? 0}</p>
</div>
</div>
</motion.div>
))}
</div>
<div className="grid gap-6 md:grid-cols-2">
<div className="grid gap-5 md:grid-cols-2 md:gap-6">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="rounded-2xl border border-slate-100 dark:border-slate-800 bg-white dark:bg-slate-900/50 p-6 shadow-sm backdrop-blur-sm"
className="crm-card crm-card-pad-lg rounded-2xl"
>
<h2 className="mb-4 text-lg font-semibold text-slate-900 dark:text-white"></h2>
<ul className="space-y-3">
{visibleTodos.map((task: DashboardTodo, i: number) => (
<li key={task.id ?? i} className="group flex cursor-pointer items-center gap-3 rounded-xl border border-slate-50 dark:border-slate-800/50 p-3 transition-all hover:bg-slate-50 dark:hover:bg-slate-800">
<div className="flex h-5 w-5 items-center justify-center rounded-full border-2 border-slate-300 dark:border-slate-600 group-hover:border-violet-500 dark:group-hover:border-violet-400 transition-colors" />
<span className="text-sm text-slate-700 dark:text-slate-300 group-hover:text-slate-900 dark:group-hover:text-white transition-colors">{task.title || "无"}</span>
<div className="mb-3 flex items-center justify-between sm:mb-4">
<h2 className="text-base font-semibold text-slate-900 dark:text-white sm:text-lg"></h2>
<span className="text-[11px] text-slate-400 dark:text-slate-500 sm:text-xs">
{pendingTodos.length + historyTodos.length}
</span>
</div>
<div className="crm-section-stack sm:gap-5">
<div className="crm-card-subtle rounded-xl p-3 sm:p-4">
<div className="mb-2 flex items-center gap-2 sm:mb-3">
<span className="crm-pill crm-pill-neutral"></span>
<span className="text-xs text-slate-400 dark:text-slate-500">{pendingTodos.length} </span>
</div>
{pendingTodos.length ? (
<ul className="space-y-2.5 sm:space-y-3">
{pendingTodos.map((task: DashboardTodo) => (
<li key={task.id} className="group flex items-start gap-2.5 rounded-xl border border-white/80 bg-white px-3 py-2.5 transition-all hover:bg-slate-50 dark:border-slate-700/60 dark:bg-slate-900/40 dark:hover:bg-slate-800 sm:gap-3 sm:p-3">
<button
type="button"
onClick={() => void handleCompleteTodo(task.id)}
disabled={completingTodoId === task.id}
className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 border-slate-300 text-transparent transition-colors hover:border-violet-500 disabled:cursor-not-allowed disabled:opacity-60 dark:border-slate-600 dark:hover:border-violet-400"
aria-label={`完成待办:${getTodoDisplayTitle(task)}`}
>
<Check className="h-3 w-3" />
</button>
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-slate-700 transition-colors group-hover:text-slate-900 dark:text-slate-300 dark:group-hover:text-white">
{getTodoDisplayTitle(task)}
</p>
</div>
</li>
))}
</ul>
{hasMoreTodos && !showAllTodos ? (
) : (
<div className="crm-empty-state rounded-xl border border-dashed border-slate-200 px-4 py-5 dark:border-slate-700">
</div>
)}
</div>
<div className="crm-card-subtle rounded-xl p-3 sm:p-4">
<button
type="button"
onClick={() => setShowAllTodos(true)}
className="mt-4 w-full rounded-xl border border-dashed border-slate-200 dark:border-slate-700 px-3 py-2 text-sm font-medium text-slate-500 transition-colors hover:border-violet-300 hover:text-violet-600 dark:text-slate-400 dark:hover:border-violet-700 dark:hover:text-violet-400"
onClick={() => setHistoryExpanded((current) => !current)}
className="flex w-full items-center justify-between rounded-xl border border-slate-200 bg-white px-4 py-3 text-left transition-colors hover:border-violet-300 hover:bg-violet-50/50 dark:border-slate-700 dark:bg-slate-900/40 dark:hover:border-violet-600 dark:hover:bg-slate-800"
>
...
<div className="flex items-center gap-2">
<span className="crm-pill crm-pill-violet"></span>
<span className="text-xs text-slate-400 dark:text-slate-500">{historyTodos.length} </span>
</div>
<span className="text-sm font-medium text-slate-500 dark:text-slate-400">
{historyExpanded ? "收起" : "展开"}
</span>
</button>
{historyExpanded ? (
historyTodos.length ? (
<>
<ul className="mt-3 space-y-2.5 sm:space-y-3">
{visibleHistoryTodos.map((task: DashboardTodo) => (
<li key={task.id} className="flex items-start gap-2.5 rounded-xl border border-white/80 bg-white px-3 py-2.5 dark:border-slate-700/60 dark:bg-slate-900/40 sm:gap-3 sm:p-3">
<div className="mt-1 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-emerald-500 text-white">
<Check className="h-3 w-3" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-slate-500 line-through dark:text-slate-400">
{getTodoDisplayTitle(task)}
</p>
</div>
</li>
))}
</ul>
{hasMoreHistoryTodos ? (
<button
type="button"
onClick={() => setShowAllHistoryTodos((current) => !current)}
className="mt-3 w-full rounded-xl border border-dashed border-slate-200 px-3 py-2 text-sm font-medium text-slate-500 transition-colors hover:border-violet-300 hover:text-violet-600 dark:border-slate-700 dark:text-slate-400 dark:hover:border-violet-700 dark:hover:text-violet-400"
>
{showAllHistoryTodos ? "收起历史明细" : `展开更多 (${historyTodos.length})`}
</button>
) : null}
</>
) : (
<div className="crm-empty-state mt-3 rounded-xl border border-dashed border-slate-200 px-4 py-6 dark:border-slate-700">
</div>
)
) : null}
</div>
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
className="rounded-2xl border border-slate-100 dark:border-slate-800 bg-white dark:bg-slate-900/50 p-6 shadow-sm backdrop-blur-sm"
className="crm-card crm-card-pad-lg rounded-2xl"
>
<h2 className="mb-4 text-lg font-semibold text-slate-900 dark:text-white"></h2>
<div className="space-y-5">
<div className="crm-section-stack sm:gap-5">
{visibleActivities.map((news: DashboardActivity, i: number) => (
<div key={news.id ?? i} className="flex gap-4">
<div className="relative mt-1 flex h-3 w-3 items-center justify-center">
@ -133,7 +246,7 @@ export default function Dashboard() {
</div>
<div>
<p className="text-sm font-medium text-slate-900 dark:text-white">{news.title || "无"}</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">{news.content || "无"}</p>
<p className="crm-field-note mt-0.5">{news.content || "无"}</p>
<p className="mt-1 text-[10px] text-slate-400 dark:text-slate-500">{news.timeText || "无"}</p>
</div>
</div>
@ -143,13 +256,102 @@ export default function Dashboard() {
<button
type="button"
onClick={() => setShowAllActivities(true)}
className="mt-4 w-full rounded-xl border border-dashed border-slate-200 dark:border-slate-700 px-3 py-2 text-sm font-medium text-slate-500 transition-colors hover:border-violet-300 hover:text-violet-600 dark:text-slate-400 dark:hover:border-violet-700 dark:hover:text-violet-400"
className="mt-4 w-full rounded-xl border border-dashed border-slate-200 px-3 py-2 text-sm font-medium text-slate-500 transition-colors hover:border-violet-300 hover:text-violet-600 dark:border-slate-700 dark:text-slate-400 dark:hover:border-violet-700 dark:hover:text-violet-400"
>
...
</button>
) : null}
</motion.div>
</div>
</>
)}
</div>
);
}
function DashboardSkeleton() {
return (
<>
<div className="grid grid-cols-2 gap-2.5 sm:gap-4 xl:grid-cols-4">
{[0, 1, 2, 3].map((item) => (
<div
key={`dashboard-stat-skeleton-${item}`}
className="crm-card rounded-xl p-3 sm:rounded-2xl sm:p-5"
>
<div className="flex items-center gap-2.5 sm:gap-4">
<div className="h-9 w-9 animate-pulse rounded-lg bg-slate-100 dark:bg-slate-800 sm:h-12 sm:w-12 sm:rounded-xl" />
<div className="min-w-0 flex-1 space-y-2">
<div className="h-3 w-20 animate-pulse rounded bg-slate-100 dark:bg-slate-800 sm:h-4" />
<div className="h-6 w-10 animate-pulse rounded bg-slate-100 dark:bg-slate-800 sm:h-8" />
</div>
</div>
</div>
))}
</div>
<div className="grid gap-5 md:grid-cols-2 md:gap-6">
<div className="crm-card crm-card-pad-lg rounded-2xl">
<div className="mb-4 flex items-center justify-between">
<div className="h-5 w-20 animate-pulse rounded bg-slate-100 dark:bg-slate-800" />
<div className="h-3 w-12 animate-pulse rounded bg-slate-100 dark:bg-slate-800" />
</div>
<div className="crm-section-stack sm:gap-5">
<div className="crm-card-subtle rounded-xl p-3 sm:p-4">
<div className="mb-3 flex items-center gap-2">
<div className="h-6 w-10 animate-pulse rounded-full bg-slate-100 dark:bg-slate-800" />
<div className="h-3 w-20 animate-pulse rounded bg-slate-100 dark:bg-slate-800" />
</div>
<div className="space-y-3">
{[0, 1].map((item) => (
<div key={`todo-skeleton-${item}`} className="flex items-start gap-3 rounded-xl border border-white/80 bg-white px-3 py-3 dark:border-slate-700/60 dark:bg-slate-900/40">
<div className="mt-0.5 h-5 w-5 animate-pulse rounded-full bg-slate-100 dark:bg-slate-800" />
<div className="flex-1 space-y-2">
<div className="h-3 w-11/12 animate-pulse rounded bg-slate-100 dark:bg-slate-800" />
<div className="h-3 w-2/3 animate-pulse rounded bg-slate-100 dark:bg-slate-800" />
</div>
</div>
))}
</div>
</div>
<div className="crm-card-subtle rounded-xl p-3 sm:p-4">
<div className="flex items-center justify-between rounded-xl border border-slate-200 bg-white px-4 py-3 dark:border-slate-700 dark:bg-slate-900/40">
<div className="flex items-center gap-2">
<div className="h-6 w-10 animate-pulse rounded-full bg-slate-100 dark:bg-slate-800" />
<div className="h-3 w-20 animate-pulse rounded bg-slate-100 dark:bg-slate-800" />
</div>
<div className="h-4 w-10 animate-pulse rounded bg-slate-100 dark:bg-slate-800" />
</div>
</div>
</div>
</div>
<div className="crm-card crm-card-pad-lg rounded-2xl">
<div className="mb-4 h-5 w-24 animate-pulse rounded bg-slate-100 dark:bg-slate-800" />
<div className="crm-section-stack sm:gap-5">
{[0, 1, 2].map((item) => (
<div key={`activity-skeleton-${item}`} className="flex gap-4">
<div className="mt-1 h-3 w-3 animate-pulse rounded-full bg-violet-100 dark:bg-violet-500/20" />
<div className="flex-1 space-y-2">
<div className="h-4 w-32 animate-pulse rounded bg-slate-100 dark:bg-slate-800" />
<div className="h-3 w-full animate-pulse rounded bg-slate-100 dark:bg-slate-800" />
<div className="h-3 w-10 animate-pulse rounded bg-slate-100 dark:bg-slate-800" />
</div>
</div>
))}
</div>
</div>
</div>
</>
);
}
function getTodoDisplayTitle(task: DashboardTodo) {
const title = task.title?.trim();
if (!title) {
return "无";
}
if (task.bizType === "report" && title.startsWith("明日工作计划:")) {
return title.slice("明日工作计划:".length).trim() || "明日工作计划";
}
return title;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,6 @@
import { useEffect, useState, type ReactNode } from "react";
import { AnimatePresence, motion } from "motion/react";
import {
Bell,
BriefcaseBusiness,
ChevronRight,
CircleUserRound,
@ -34,7 +33,7 @@ import {
} from "@/lib/auth";
type MenuItem = {
key: "personal" | "notice" | "security" | "help";
key: "personal" | "security" | "help";
icon: typeof User;
label: string;
color: string;
@ -45,7 +44,6 @@ type EditableProfileForm = UpdateCurrentUserProfilePayload;
const MENU_ITEMS: MenuItem[] = [
{ key: "personal", icon: User, label: "个人资料", color: "text-blue-500 dark:text-blue-400", bg: "bg-blue-50 dark:bg-blue-500/10" },
{ key: "notice", icon: Bell, label: "消息通知", color: "text-amber-500 dark:text-amber-400", bg: "bg-amber-50 dark:bg-amber-500/10" },
{ key: "security", icon: Shield, label: "账号安全", color: "text-emerald-500 dark:text-emerald-400", bg: "bg-emerald-50 dark:bg-emerald-500/10" },
{ key: "help", icon: HelpCircle, label: "帮助中心", color: "text-violet-500 dark:text-violet-400", bg: "bg-violet-50 dark:bg-violet-500/10" },
];
@ -100,14 +98,14 @@ function PageModal({
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
className="fixed inset-0 z-50 p-0 sm:p-6"
className="fixed inset-0 z-50 px-0 pb-0 pt-[env(safe-area-inset-top)] sm:p-6"
>
<div className="mx-auto h-full w-full sm:max-w-3xl">
<div className="mx-auto h-[calc(100dvh-env(safe-area-inset-top))] w-full sm:h-full sm:max-w-3xl">
<div className="flex h-full flex-col overflow-hidden border border-slate-200 bg-white shadow-2xl dark:border-slate-800 dark:bg-slate-900 sm:rounded-3xl">
<div className="flex items-center justify-between border-b border-slate-100 px-5 py-4 dark:border-slate-800 sm:px-6">
<div>
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">{title}</h2>
{subtitle ? <p className="mt-1 text-xs text-slate-500 dark:text-slate-400">{subtitle}</p> : null}
<h2 className="text-base font-semibold text-slate-900 dark:text-white sm:text-lg">{title}</h2>
{subtitle ? <p className="mt-1 text-xs leading-5 text-slate-500 dark:text-slate-400">{subtitle}</p> : null}
</div>
<button
onClick={onClose}
@ -117,7 +115,7 @@ function PageModal({
</button>
</div>
<div className="flex-1 overflow-y-auto px-5 py-5 sm:px-6">{children}</div>
{footer ? <div className="border-t border-slate-100 px-5 py-4 dark:border-slate-800 sm:px-6">{footer}</div> : null}
{footer ? <div className="border-t border-slate-100 px-5 pb-[calc(1rem+env(safe-area-inset-bottom))] pt-4 dark:border-slate-800 sm:px-6 sm:pb-4">{footer}</div> : null}
</div>
</div>
</motion.div>
@ -285,6 +283,10 @@ export default function Profile() {
setSecuritySuccess("");
};
const handleOpenHelp = () => {
window.alert("请联系系统管理员!");
};
const handleUpdatePassword = async () => {
if (securitySaving) {
return;
@ -334,57 +336,58 @@ export default function Profile() {
const currentPhone = displayText(currentUser?.phone);
return (
<div className="space-y-5 sm:space-y-6">
<header className="flex items-center justify-between">
<h1 className="text-2xl font-bold tracking-tight text-slate-900 dark:text-white"></h1>
<div className="crm-page-stack">
<header className="flex flex-wrap items-center justify-between gap-3">
<h1 className="text-xl font-bold tracking-tight text-slate-900 dark:text-white sm:text-2xl"></h1>
</header>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="rounded-2xl border border-slate-100 bg-white p-4 shadow-sm backdrop-blur-sm transition-all dark:border-slate-800 dark:bg-slate-900/50 sm:p-6"
className="crm-card crm-card-pad-lg relative rounded-2xl transition-all"
>
<div className="flex flex-col gap-5 sm:flex-row sm:items-start sm:justify-between">
<button
onClick={() => void handleOpenProfile()}
className="absolute right-4 top-4 rounded-full bg-slate-50 p-2 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600 dark:bg-slate-800 dark:text-slate-500 dark:hover:bg-slate-700 dark:hover:text-slate-300 sm:right-6 sm:top-6"
>
<Settings className="h-5 w-5" />
</button>
<div className="flex flex-col gap-5 pr-12 sm:flex-row sm:items-start">
<div className="flex items-center gap-4">
<div className="flex h-16 w-16 shrink-0 items-center justify-center rounded-full bg-violet-100 text-2xl font-bold text-violet-600 dark:bg-violet-500/20 dark:text-violet-400">
{avatarText}
</div>
<div className="min-w-0 flex-1">
<h2 className="truncate text-xl font-bold text-slate-900 dark:text-white">{displayName}</h2>
<div className="mt-1 text-sm text-slate-500 dark:text-slate-400">
<h2 className="truncate text-lg font-bold text-slate-900 dark:text-white sm:text-xl">{displayName}</h2>
<div className="mt-1 text-xs leading-5 text-slate-500 dark:text-slate-400 sm:text-sm">
<p>{orgNames}</p>
<p>{roleNames}</p>
</div>
</div>
</div>
<button
onClick={() => void handleOpenProfile()}
className="self-end rounded-full bg-slate-50 p-2 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600 dark:bg-slate-800 dark:text-slate-500 dark:hover:bg-slate-700 dark:hover:text-slate-300 sm:self-start"
>
<Settings className="h-5 w-5" />
</button>
</div>
<div className="mt-6 grid grid-cols-1 gap-3 border-t border-slate-50 pt-6 dark:border-slate-800/50 sm:grid-cols-3 sm:gap-0 sm:divide-x sm:divide-slate-100 dark:sm:divide-slate-800">
<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-4 py-4 text-center transition-colors hover:bg-slate-100 active:scale-[0.99] dark:bg-slate-800/40 dark:hover:bg-slate-800 sm:rounded-none sm:bg-transparent sm:px-2"
className="rounded-2xl bg-slate-50 px-2 py-4 text-center transition-colors hover:bg-slate-100 active:scale-[0.99] dark:bg-slate-800/40 dark:hover:bg-slate-800 sm:rounded-none sm:bg-transparent sm:px-2"
>
<p className="text-2xl font-bold text-slate-900 dark:text-white">{numericValue(overview?.monthlyOpportunityCount)}</p>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400"></p>
<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-4 py-4 text-center transition-colors hover:bg-slate-100 active:scale-[0.99] dark:bg-slate-800/40 dark:hover:bg-slate-800 sm:rounded-none sm:bg-transparent sm:px-2"
className="rounded-2xl bg-slate-50 px-2 py-4 text-center transition-colors hover:bg-slate-100 active:scale-[0.99] dark:bg-slate-800/40 dark:hover:bg-slate-800 sm:rounded-none sm:bg-transparent sm:px-2"
>
<p className="text-2xl font-bold text-slate-900 dark:text-white">{numericValue(overview?.monthlyExpansionCount)}</p>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400"></p>
<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-4 py-4 text-center dark:bg-slate-800/40 sm:rounded-none sm:bg-transparent sm:px-2">
<p className="text-2xl font-bold text-slate-900 dark:text-white">{numericValue(overview?.averageScore)}</p>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400"></p>
<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>
@ -393,7 +396,7 @@ export default function Profile() {
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="overflow-hidden rounded-2xl border border-slate-100 bg-white shadow-sm backdrop-blur-sm transition-all dark:border-slate-800 dark:bg-slate-900/50"
className="crm-card overflow-hidden rounded-2xl transition-all"
>
<ul className="divide-y divide-slate-50 dark:divide-slate-800/50">
<li>
@ -420,6 +423,8 @@ export default function Profile() {
? () => void handleOpenProfile()
: item.key === "security"
? handleOpenSecurity
: item.key === "help"
? handleOpenHelp
: undefined
}
className="flex w-full items-center justify-between p-4 text-left transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/50"
@ -457,14 +462,14 @@ export default function Profile() {
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<button
onClick={handleCloseProfile}
className="rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
className="crm-btn rounded-xl border border-slate-200 bg-white text-slate-700 transition-colors hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
>
</button>
<button
onClick={() => void handleSave()}
disabled={saving || detailLoading}
className="rounded-xl bg-violet-600 px-4 py-3 text-sm font-medium text-white shadow-sm transition-colors hover:bg-violet-700 disabled:cursor-not-allowed disabled:opacity-60"
className="crm-btn rounded-xl bg-violet-600 text-white shadow-sm transition-colors hover:bg-violet-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{saving ? "保存中..." : "保存资料"}
</button>
@ -476,8 +481,8 @@ export default function Profile() {
{error}
</div>
) : (
<div className="space-y-6">
<div className="flex flex-col gap-4 rounded-2xl border border-slate-100 bg-slate-50/70 p-4 dark:border-slate-800 dark:bg-slate-800/20 sm:flex-row sm:items-center">
<div className="crm-modal-stack">
<div className="crm-form-section sm:flex-row sm:items-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-violet-100 text-2xl font-bold text-violet-600 dark:bg-violet-500/20 dark:text-violet-400">
{(form.displayName || currentUser?.displayName || "无").slice(0, 1)}
</div>
@ -491,18 +496,18 @@ export default function Profile() {
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="crm-form-grid">
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<input
value={form.displayName || ""}
onChange={(event) => setForm((current) => ({ ...current, displayName: event.target.value }))}
className="w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
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>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<div className="w-full rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-500 dark:border-slate-800 dark:bg-slate-800/40 dark:text-slate-400">
<div className="crm-input-box-readonly crm-input-text w-full border border-slate-200 bg-slate-50 text-slate-500 dark:border-slate-800 dark:bg-slate-800/40 dark:text-slate-400">
{displayText(currentUser?.username)}
</div>
</label>
@ -514,7 +519,7 @@ export default function Profile() {
<input
value={form.phone || ""}
onChange={(event) => setForm((current) => ({ ...current, phone: event.target.value }))}
className="w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
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>
<label className="space-y-2">
@ -525,13 +530,13 @@ export default function Profile() {
<input
value={form.email || ""}
onChange={(event) => setForm((current) => ({ ...current, email: event.target.value }))}
className="w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
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>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="rounded-2xl border border-slate-100 bg-slate-50/70 p-4 dark:border-slate-800 dark:bg-slate-800/20">
<div className="crm-form-grid">
<div className="crm-form-section">
<div className="flex items-center gap-2 text-sm font-semibold text-slate-900 dark:text-white">
<BriefcaseBusiness className="h-4 w-4 text-violet-500" />
@ -541,7 +546,7 @@ export default function Profile() {
<p>{displayText(overview?.accountStatus)}</p>
</div>
</div>
<div className="rounded-2xl border border-slate-100 bg-slate-50/70 p-4 dark:border-slate-800 dark:bg-slate-800/20">
<div className="crm-form-section">
<div className="flex items-center gap-2 text-sm font-semibold text-slate-900 dark:text-white">
<MapPinned className="h-4 w-4 text-emerald-500" />
@ -556,7 +561,7 @@ export default function Profile() {
</div>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="crm-form-grid">
<div className="rounded-xl border border-slate-100 bg-slate-50/70 px-4 py-3 text-sm text-slate-500 dark:border-slate-800 dark:bg-slate-800/20 dark:text-slate-400">
{currentPhone}
</div>
@ -588,28 +593,28 @@ export default function Profile() {
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<button
onClick={handleCloseSecurity}
className="rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
className="crm-btn rounded-xl border border-slate-200 bg-white text-slate-700 transition-colors hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
>
</button>
<button
onClick={() => void handleUpdatePassword()}
disabled={securitySaving}
className="rounded-xl bg-emerald-600 px-4 py-3 text-sm font-medium text-white shadow-sm transition-colors hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-60"
className="crm-btn rounded-xl bg-emerald-600 text-white shadow-sm transition-colors hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{securitySaving ? "提交中..." : "更新密码"}
</button>
</div>
)}
>
<div className="space-y-6">
<div className="rounded-2xl border border-slate-100 bg-slate-50/70 p-4 dark:border-slate-800 dark:bg-slate-800/20">
<div className="crm-modal-stack">
<div className="crm-form-section">
<p className="text-sm text-slate-600 dark:text-slate-300">
</p>
</div>
<div className="grid grid-cols-1 gap-4">
<div className="crm-section-stack">
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<div className="relative">
@ -618,7 +623,7 @@ export default function Profile() {
autoComplete="current-password"
value={securityForm.oldPassword}
onChange={(event) => setSecurityForm((current) => ({ ...current, oldPassword: event.target.value }))}
className="w-full rounded-xl border border-slate-200 bg-white px-4 py-3 pr-12 text-sm outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 dark:border-slate-800 dark:bg-slate-900/50"
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white pr-12 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 dark:border-slate-800 dark:bg-slate-900/50"
/>
<button
type="button"
@ -639,7 +644,7 @@ export default function Profile() {
autoComplete="new-password"
value={securityForm.newPassword}
onChange={(event) => setSecurityForm((current) => ({ ...current, newPassword: event.target.value }))}
className="w-full rounded-xl border border-slate-200 bg-white px-4 py-3 pr-12 text-sm outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 dark:border-slate-800 dark:bg-slate-900/50"
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white pr-12 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 dark:border-slate-800 dark:bg-slate-900/50"
/>
<button
type="button"
@ -660,7 +665,7 @@ export default function Profile() {
autoComplete="new-password"
value={securityForm.confirmPassword}
onChange={(event) => setSecurityForm((current) => ({ ...current, confirmPassword: event.target.value }))}
className="w-full rounded-xl border border-slate-200 bg-white px-4 py-3 pr-12 text-sm outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 dark:border-slate-800 dark:bg-slate-900/50"
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white pr-12 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 dark:border-slate-800 dark:bg-slate-900/50"
/>
<button
type="button"

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,8 @@
position: relative;
min-height: 100vh;
min-height: 100dvh;
overflow: hidden;
overflow-x: hidden;
overflow-y: auto;
background:
radial-gradient(circle at top left, rgba(139, 92, 246, 0.12), transparent 34%),
radial-gradient(circle at bottom right, rgba(59, 130, 246, 0.08), transparent 30%),
@ -310,6 +311,7 @@
.login-page-grid {
grid-template-columns: 1fr;
gap: 28px;
align-items: start;
padding-top: max(24px, env(safe-area-inset-top));
padding-bottom: max(24px, env(safe-area-inset-bottom));
}
@ -325,6 +327,7 @@
}
.login-panel {
order: -1;
justify-content: flex-start;
}
}

1
frontend/src/vite-env.d.ts vendored 100644
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -1,3 +1,4 @@
import fs from 'fs';
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import path from 'path';
@ -5,6 +6,15 @@ import {defineConfig, loadEnv} from 'vite';
export default defineConfig(({mode}) => {
const env = loadEnv(mode, '.', '');
const certPath = path.resolve(__dirname, '.cert/dev.crt');
const keyPath = path.resolve(__dirname, '.cert/dev.key');
const https =
fs.existsSync(certPath) && fs.existsSync(keyPath)
? {
cert: fs.readFileSync(certPath),
key: fs.readFileSync(keyPath),
}
: undefined;
return {
plugins: [react(), tailwindcss()],
define: {
@ -16,6 +26,7 @@ export default defineConfig(({mode}) => {
},
},
server: {
https,
// HMR is disabled in AI Studio via DISABLE_HMR env var.
// Do not modify—file watching is disabled to prevent flickering during agent edits.
hmr: process.env.DISABLE_HMR !== 'true',

View File

@ -0,0 +1,54 @@
# IMPLEMENTATION_PLAN.md - Login Page Optimization
## Stage 1: Visual and Functional Refinement of Login Page
Goal:
- Refine the login page UI based on standard high-quality admin dashboard patterns and the provided design reference.
- Improve the interaction for captcha and device code acquisition.
Success Criteria:
- A more professional and visually balanced layout.
- Clearer separation between standard login and device-bound login.
- Responsive design working across mobile and desktop.
Tests:
- Verify captcha refreshing on click.
- Verify login flow with and without device code.
- Verify responsive layout at 1200px, 980px, and 640px.
Status:
- Complete
## Stage 2: Dashboard Layout and Dynamic Menu
Goal:
- Implement a professional dashboard layout with stats and recent activities.
- Make the sidebar menu dynamic based on backend permission data.
Success Criteria:
- Dashboard shows meaningful stats cards and status indicators.
- Sidebar reflects the permissions/menus defined in the backend.
Tests:
- Verify that changing permission status in backend updates the sidebar.
- Verify dashboard responsiveness.
Status:
- In Progress
## Stage 3: User-Role and Role-Permission Binding Pages
Goal:
- Add admin pages for binding users to roles and roles to permissions.
Success Criteria:
- Two new pages available at /user-roles and /role-permissions.
- Role and permission selection UI with save actions wired to API endpoints.
Tests:
- Select user and roles, then save.
- Select role and permissions, then save.
- Verify behavior when APIs are missing or return errors.
Status:
- In Progress

View File

@ -0,0 +1,199 @@
# AGENTS.mdFrontend
## 一、项目定位
本模块为 **后台管理 Web 页面**,用于:
* 用户 / 角色 / 权限管理
* 设备管理
* 任务与状态查看
这是一个 **管理后台系统**,不是面向终端用户的产品页面,设计以“效率与稳定”为首要目标。
---
## 二、技术栈(必须遵守)
* React: **18**
* Language: **TypeScript**
* UI Library: **Ant Design**
* Router: React Router
* HTTP: Axios
* State: React Hooks必要时可用 Zustand / Redux
* Build: Vite 或 CRA以项目实际为准
⚠️ 禁止引入与 Ant Design 冲突的 UI 框架或样式体系。
---
## 三、目录结构约定
```
src
├── api # 后端接口封装
├── components # 通用组件
├── layouts # 页面布局
├── pages # 页面级组件
├── routes # 路由定义
├── hooks # 自定义 hooks
├── utils # 工具函数
└── types # TS 类型定义
```
---
## 四、角色与理念
你是一位**务实型前端开发者 Agent**,目标是:
> 以清晰的数据流和稳定的交互,构建易维护的管理后台。
> 阅读对应的后端controller了解接口
### 核心原则
* 优先使用frontend/src/components/shared中的组件 如果是typeScript 需要修改为typeScript
* 清晰的意图胜于技巧性的实现
* 组件简单直观优于过度抽象
* 奥卡姆剃刀:不必要的复杂度一律删除
* 组合优于继承
* 显式状态优于隐式副作用
### 编码风格
* 准确简洁,贴近业务语义
* 小修改不输出摘要
* 拒绝炫技与过度封装
---
## 五、开发流程(强制)
### 行为约束
1. 在执行任何修改前,必须**阅读并遵守**本项目的设计文档(位于 `docs/design/`)。
2. 所有功能改动都必须更新设计文档
3. 遵循代码风格、目录结构和 Git 工作流规则
### 5.1 规划阶段
复杂页面必须先给出实现计划:
`IMPLEMENTATION_PLAN.md`
```
## Stage N: [Name]
Goal:
- 可交付界面或功能
Success Criteria:
- 可验证交互
Tests:
- 操作与边界场景
Status:
- Not Started | In Progress | Complete
```
---
### 5.2 实现循环
1. **理解**
* 查找 ≥3 个相似页面
* 遵循项目交互约定
2. **测试/验证**
* 先定义接口与数据结构
* 明确边界与异常
3. **实现**
* 最小可用组件
* 先通再优
4. **重构**
* 保证可读与可复用
---
### 5.3 三次机会规则
同一问题最多尝试 **3 次**
若仍失败,必须输出:
* 已尝试方案
* 具体错误
* 类似实现对比
* 根本性问题反思
---
## 六、质量关卡DoD
交付前必须:
* 类型检查通过
* 无 ESLint 警告
* 接口异常已处理
* 表单有校验
* 交互可回滚
* 不随意引入新依赖
---
## 七、UI 与交互准则
* 严格使用 Ant Design 组件
* 表单必须:
* 校验
* 防重复提交
* 明确错误提示
* 表格必须:
* 分页
* 加载态
* 空状态
* 接口必须:
* 统一封装
* 错误拦截
* 类型定义
---
## 八、代码规范
* 页面 = 容器 + 组件
* 禁止:
* 页面直调 axios
* any 类型
* 过度全局状态
* 组件内写业务接口
* 数据流:
API → Hooks → Page → Component
---
## 九、与后端协同
* 所有接口走 `src/api`
* 类型以后端契约为准
* 使用统一 Result 结构
* 不模拟后端业务逻辑
---
**一句话原则:**
> 用最直接的组件 + 最清晰的状态 + 最稳定的交互,
> 构建可长期维护的管理后台。

View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>UnisBase - 智能会议系统</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@ -0,0 +1 @@
../baseline-browser-mapping/dist/cli.js

1
frontend1/node_modules/.bin/browserslist generated vendored 120000
View File

@ -0,0 +1 @@
../browserslist/cli.js

1
frontend1/node_modules/.bin/errno generated vendored 120000
View File

@ -0,0 +1 @@
../errno/cli.js

1
frontend1/node_modules/.bin/esbuild generated vendored 120000
View File

@ -0,0 +1 @@
../esbuild/bin/esbuild

1
frontend1/node_modules/.bin/jsesc generated vendored 120000
View File

@ -0,0 +1 @@
../jsesc/bin/jsesc

1
frontend1/node_modules/.bin/json5 generated vendored 120000
View File

@ -0,0 +1 @@
../json5/lib/cli.js

1
frontend1/node_modules/.bin/lessc generated vendored 120000
View File

@ -0,0 +1 @@
../less/bin/lessc

1
frontend1/node_modules/.bin/loose-envify generated vendored 120000
View File

@ -0,0 +1 @@
../loose-envify/cli.js

1
frontend1/node_modules/.bin/mime generated vendored 120000
View File

@ -0,0 +1 @@
../mime/cli.js

1
frontend1/node_modules/.bin/nanoid generated vendored 120000
View File

@ -0,0 +1 @@
../nanoid/bin/nanoid.cjs

1
frontend1/node_modules/.bin/needle generated vendored 120000
View File

@ -0,0 +1 @@
../needle/bin/needle

1
frontend1/node_modules/.bin/parser generated vendored 120000
View File

@ -0,0 +1 @@
../@babel/parser/bin/babel-parser.js

1
frontend1/node_modules/.bin/rollup generated vendored 120000
View File

@ -0,0 +1 @@
../rollup/dist/bin/rollup

1
frontend1/node_modules/.bin/semver generated vendored 120000
View File

@ -0,0 +1 @@
../semver/bin/semver.js

1
frontend1/node_modules/.bin/tsc generated vendored 120000
View File

@ -0,0 +1 @@
../typescript/bin/tsc

1
frontend1/node_modules/.bin/tsserver generated vendored 120000
View File

@ -0,0 +1 @@
../typescript/bin/tsserver

View File

@ -0,0 +1 @@
../update-browserslist-db/cli.js

1
frontend1/node_modules/.bin/vite generated vendored 120000
View File

@ -0,0 +1 @@
../vite/bin/vite.js

3506
frontend1/node_modules/.package-lock.json generated vendored 100644

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More