0326
parent
13d3abeeee
commit
8606a02971
|
|
@ -0,0 +1,13 @@
|
|||
.DS_Store
|
||||
|
||||
.idea/
|
||||
backend/.idea/
|
||||
|
||||
frontend/dist/
|
||||
backend/target/
|
||||
|
||||
frontend/node_modules/
|
||||
|
||||
frontend/.cert/
|
||||
|
||||
*.log
|
||||
|
|
@ -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">{
|
||||
"associatedIndex": 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>
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
CurrentUserUtils.requireCurrentUserId(userId);
|
||||
@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 + "格式不正确");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -5,4 +5,6 @@ import com.unis.crm.dto.dashboard.DashboardHomeDTO;
|
|||
public interface DashboardService {
|
||||
|
||||
DashboardHomeDTO getHome(Long userId);
|
||||
|
||||
void completeTodo(Long userId, Long todoId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 <> #{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>
|
||||
|
|
|
|||
|
|
@ -4,39 +4,130 @@
|
|||
"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,
|
||||
case coalesce(o.stage, 'initial_contact')
|
||||
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 '方案交流'
|
||||
when 'bidding' then '招投标'
|
||||
when 'business_negotiation' then '商务谈判'
|
||||
when 'won' then '已成交'
|
||||
when 'lost' then '已放弃'
|
||||
else coalesce(o.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), ''), '') <> ''
|
||||
order by f.followup_time desc, f.id desc
|
||||
limit 1
|
||||
), '') as latestProgress,
|
||||
coalesce((
|
||||
select
|
||||
case
|
||||
when coalesce(nullif(btrim(f.next_action), ''), '') <> '' 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), ''), '') <> ''
|
||||
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 coalesce(o.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,
|
||||
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
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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) <> '' 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) <> '' then '关联对象:' || c.biz_name || E'\n'
|
||||
else ''
|
||||
end ||
|
||||
case
|
||||
when c.user_name is not null and btrim(c.user_name) <> '' then '打卡人:' || c.user_name || E'\n'
|
||||
else ''
|
||||
end ||
|
||||
case
|
||||
when c.dept_name is not null and btrim(c.dept_name) <> '' then '所属部门:' || c.dept_name || E'\n'
|
||||
else ''
|
||||
end ||
|
||||
coalesce(c.location_text, '') ||
|
||||
case
|
||||
when c.remark is not null and btrim(c.remark) <> '' 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) <> '' 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 >= #{reportDate}::timestamp
|
||||
and followup_time < (#{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 >= #{reportDate}::timestamp
|
||||
and followup_time < (#{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
|
||||
|
|
|
|||
|
|
@ -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 | 创建时间 |
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -15,4 +15,5 @@ npm run dev
|
|||
|
||||
```bash
|
||||
GEMINI_API_KEY=your_key_here
|
||||
VITE_TENCENT_MAP_KEY=your_tencent_map_key
|
||||
```
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,32 +130,85 @@ 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 (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.path}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-xl px-3 py-3 text-sm font-medium transition-all duration-200",
|
||||
isActive
|
||||
? "bg-violet-50 dark:bg-violet-500/10 text-violet-600 dark:text-violet-400 shadow-sm"
|
||||
: "text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800/50 hover:text-slate-900 dark:hover:text-slate-50"
|
||||
)}
|
||||
>
|
||||
<item.icon className={cn("h-5 w-5 transition-transform", isActive && "scale-110")} />
|
||||
{item.name}
|
||||
</Link>
|
||||
<div key={item.name} className="space-y-1">
|
||||
<Link
|
||||
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
|
||||
? "bg-violet-50 dark:bg-violet-500/10 text-violet-600 dark:text-violet-400 shadow-sm"
|
||||
: "text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800/50 hover:text-slate-900 dark:hover:text-slate-50"
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,6 +470,30 @@ 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>>();
|
||||
|
||||
function applyAuthHeaders(headers: Headers) {
|
||||
const token = localStorage.getItem("accessToken");
|
||||
if (token) {
|
||||
headers.set("Authorization", `Bearer ${token}`);
|
||||
}
|
||||
|
||||
const userId = getStoredUserId();
|
||||
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);
|
||||
|
|
@ -356,15 +502,7 @@ async function request<T>(input: string, init?: RequestInit, withAuth = false):
|
|||
}
|
||||
|
||||
if (withAuth) {
|
||||
const token = localStorage.getItem("accessToken");
|
||||
if (token) {
|
||||
headers.set("Authorization", `Bearer ${token}`);
|
||||
}
|
||||
|
||||
const userId = getStoredUserId();
|
||||
if (userId !== undefined) {
|
||||
headers.set("X-User-Id", String(userId));
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,115 +49,309 @@ 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">
|
||||
{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"
|
||||
>
|
||||
<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}`} />
|
||||
{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="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-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 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-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="crm-card crm-card-pad-lg rounded-2xl"
|
||||
>
|
||||
<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>
|
||||
<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="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>
|
||||
) : (
|
||||
<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={() => 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="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="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">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-violet-400 opacity-20"></span>
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-violet-500"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-white">{news.title || "无"}</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>
|
||||
))}
|
||||
</div>
|
||||
{hasMoreActivities && !showAllActivities ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAllActivities(true)}
|
||||
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>
|
||||
</motion.div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{hasMoreTodos && !showAllTodos ? (
|
||||
<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"
|
||||
>
|
||||
...
|
||||
</button>
|
||||
) : null}
|
||||
</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"
|
||||
>
|
||||
<h2 className="mb-4 text-lg font-semibold text-slate-900 dark:text-white">最新动态</h2>
|
||||
<div className="space-y-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">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-violet-400 opacity-20"></span>
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-violet-500"></span>
|
||||
<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>
|
||||
<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="mt-1 text-[10px] text-slate-400 dark:text-slate-500">{news.timeText || "无"}</p>
|
||||
<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>
|
||||
{hasMoreActivities && !showAllActivities ? (
|
||||
<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"
|
||||
>
|
||||
...
|
||||
</button>
|
||||
) : null}
|
||||
</motion.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
|
|
@ -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
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
# AGENTS.md(Frontend)
|
||||
|
||||
## 一、项目定位
|
||||
|
||||
本模块为 **后台管理 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 结构
|
||||
* 不模拟后端业务逻辑
|
||||
|
||||
---
|
||||
|
||||
**一句话原则:**
|
||||
|
||||
> 用最直接的组件 + 最清晰的状态 + 最稳定的交互,
|
||||
> 构建可长期维护的管理后台。
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1 @@
|
|||
../baseline-browser-mapping/dist/cli.js
|
||||
|
|
@ -0,0 +1 @@
|
|||
../browserslist/cli.js
|
||||
|
|
@ -0,0 +1 @@
|
|||
../errno/cli.js
|
||||
|
|
@ -0,0 +1 @@
|
|||
../esbuild/bin/esbuild
|
||||
|
|
@ -0,0 +1 @@
|
|||
../jsesc/bin/jsesc
|
||||
|
|
@ -0,0 +1 @@
|
|||
../json5/lib/cli.js
|
||||
|
|
@ -0,0 +1 @@
|
|||
../less/bin/lessc
|
||||
|
|
@ -0,0 +1 @@
|
|||
../loose-envify/cli.js
|
||||
|
|
@ -0,0 +1 @@
|
|||
../mime/cli.js
|
||||
|
|
@ -0,0 +1 @@
|
|||
../nanoid/bin/nanoid.cjs
|
||||
|
|
@ -0,0 +1 @@
|
|||
../needle/bin/needle
|
||||
|
|
@ -0,0 +1 @@
|
|||
../@babel/parser/bin/babel-parser.js
|
||||
|
|
@ -0,0 +1 @@
|
|||
../rollup/dist/bin/rollup
|
||||
|
|
@ -0,0 +1 @@
|
|||
../semver/bin/semver.js
|
||||
|
|
@ -0,0 +1 @@
|
|||
../typescript/bin/tsc
|
||||
|
|
@ -0,0 +1 @@
|
|||
../typescript/bin/tsserver
|
||||
|
|
@ -0,0 +1 @@
|
|||
../update-browserslist-db/cli.js
|
||||
|
|
@ -0,0 +1 @@
|
|||
../vite/bin/vite.js
|
||||
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
Loading…
Reference in New Issue