diff --git a/backend/build/test-uploads/work-report-attachments/17-ece955a03030448596acdf45ae761491.zip b/backend/build/test-uploads/work-report-attachments/17-ece955a03030448596acdf45ae761491.zip
new file mode 100644
index 00000000..82090ee2
--- /dev/null
+++ b/backend/build/test-uploads/work-report-attachments/17-ece955a03030448596acdf45ae761491.zip
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/backend/src/main/java/com/unis/crm/common/OpportunitySchemaInitializer.java b/backend/src/main/java/com/unis/crm/common/OpportunitySchemaInitializer.java
index 2d7ab5a1..3d9ee88e 100644
--- a/backend/src/main/java/com/unis/crm/common/OpportunitySchemaInitializer.java
+++ b/backend/src/main/java/com/unis/crm/common/OpportunitySchemaInitializer.java
@@ -34,12 +34,14 @@ public class OpportunitySchemaInitializer implements ApplicationRunner {
                 statement.execute("alter table crm_opportunity add column if not exists pre_sales_name varchar(100)");
                 statement.execute("alter table crm_opportunity add column if not exists latest_progress text");
                 statement.execute("alter table crm_opportunity add column if not exists next_plan text");
+                statement.execute("alter table crm_opportunity add column if not exists updated_by bigint");
                 statement.execute("alter table crm_opportunity add column if not exists archived_at timestamptz");
                 statement.execute("alter table crm_opportunity add column if not exists actual_signed_amount numeric(18, 2)");
                 statement.execute("alter table crm_opportunity add column if not exists is_poc boolean not null default false");
                 statement.execute("create index if not exists idx_crm_opportunity_archived_at on crm_opportunity(archived_at)");
                 statement.execute("comment on column crm_opportunity.latest_progress is '项目最新进展'");
                 statement.execute("comment on column crm_opportunity.next_plan is '下一步销售计划'");
+                statement.execute("comment on column crm_opportunity.updated_by is '更新人ID'");
                 statement.execute("comment on column crm_opportunity.archived_at is '归档时间'");
                 statement.execute("comment on column crm_opportunity.actual_signed_amount is '实际签约金额'");
                 statement.execute("comment on column crm_opportunity.is_poc is '是否POC测试项目'");
diff --git a/backend/src/main/java/com/unis/crm/common/UserDataScopeSchemaInitializer.java b/backend/src/main/java/com/unis/crm/common/UserDataScopeSchemaInitializer.java
new file mode 100644
index 00000000..ba1a235e
--- /dev/null
+++ b/backend/src/main/java/com/unis/crm/common/UserDataScopeSchemaInitializer.java
@@ -0,0 +1,66 @@
+package com.unis.crm.common;
+
+import java.sql.Connection;
+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 UserDataScopeSchemaInitializer implements ApplicationRunner {
+
+    private static final Logger log = LoggerFactory.getLogger(UserDataScopeSchemaInitializer.class);
+
+    private final DataSource dataSource;
+
+    public UserDataScopeSchemaInitializer(DataSource dataSource) {
+        this.dataSource = dataSource;
+    }
+
+    @Override
+    public void run(ApplicationArguments args) {
+        try (Connection connection = dataSource.getConnection();
+             Statement statement = connection.createStatement()) {
+            statement.execute("""
+                    create table if not exists sys_user_data_scope_user (
+                      id bigserial primary key,
+                      tenant_id bigint not null,
+                      viewer_user_id bigint not null,
+                      owner_user_id bigint not null,
+                      resource_type varchar(50) not null default 'ALL',
+                      enabled boolean not null default true,
+                      expire_at timestamptz,
+                      remark varchar(500),
+                      created_by bigint,
+                      created_at timestamptz not null default now(),
+                      updated_at timestamptz not null default now(),
+                      is_deleted integer not null default 0
+                    )
+                    """);
+            statement.execute("""
+                    create unique index if not exists uk_user_data_scope_user
+                    on sys_user_data_scope_user (tenant_id, viewer_user_id, owner_user_id, resource_type)
+                    where is_deleted = 0
+                    """);
+            statement.execute("""
+                    create index if not exists idx_user_data_scope_viewer
+                    on sys_user_data_scope_user (tenant_id, viewer_user_id, resource_type, enabled)
+                    """);
+            statement.execute("""
+                    create index if not exists idx_user_data_scope_owner
+                    on sys_user_data_scope_user (tenant_id, owner_user_id)
+                    """);
+            statement.execute("comment on table sys_user_data_scope_user is '用户到用户的数据可见范围授权'");
+            statement.execute("comment on column sys_user_data_scope_user.viewer_user_id is '被授权查看数据的用户ID'");
+            statement.execute("comment on column sys_user_data_scope_user.owner_user_id is '数据归属用户ID'");
+            statement.execute("comment on column sys_user_data_scope_user.resource_type is '授权资源类型：OPPORTUNITY/EXPANSION/DAILY_REPORT/CHECKIN等'");
+            log.info("Ensured sys_user_data_scope_user exists");
+        } catch (SQLException exception) {
+            throw new IllegalStateException("Failed to initialize user data scope schema", exception);
+        }
+    }
+}
diff --git a/backend/src/main/java/com/unis/crm/controller/UserDataScopeAdminController.java b/backend/src/main/java/com/unis/crm/controller/UserDataScopeAdminController.java
new file mode 100644
index 00000000..fd36a001
--- /dev/null
+++ b/backend/src/main/java/com/unis/crm/controller/UserDataScopeAdminController.java
@@ -0,0 +1,64 @@
+package com.unis.crm.controller;
+
+import com.unis.crm.common.ApiResponse;
+import com.unis.crm.dto.datascope.UserDataScopeAssignmentDTO;
+import com.unis.crm.dto.datascope.UserDataScopeGrantDTO;
+import com.unis.crm.dto.datascope.UserDataScopeUserDTO;
+import com.unis.crm.service.UserDataScopeAdminService;
+import com.unisbase.common.annotation.Log;
+import java.util.List;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/sys/api/admin/user-data-scope")
+public class UserDataScopeAdminController {
+
+    private final UserDataScopeAdminService userDataScopeAdminService;
+
+    public UserDataScopeAdminController(UserDataScopeAdminService userDataScopeAdminService) {
+        this.userDataScopeAdminService = userDataScopeAdminService;
+    }
+
+    @GetMapping("/users")
+    public ApiResponse<List<UserDataScopeUserDTO>> listUsers(
+            @RequestParam(value = "tenantId", required = false) Long tenantId) {
+        return ApiResponse.success(userDataScopeAdminService.listUsers(tenantId));
+    }
+
+    @GetMapping("/grants")
+    public ApiResponse<List<UserDataScopeGrantDTO>> listGrants(
+            @RequestParam(value = "tenantId", required = false) Long tenantId,
+            @RequestParam(value = "viewerUserId", required = false) Long viewerUserId,
+            @RequestParam(value = "resourceType", required = false) String resourceType) {
+        return ApiResponse.success(userDataScopeAdminService.listGrants(tenantId, viewerUserId, resourceType));
+    }
+
+    @GetMapping("/assignment")
+    public ApiResponse<UserDataScopeAssignmentDTO> getAssignment(
+            @RequestParam(value = "tenantId", required = false) Long tenantId,
+            @RequestParam("viewerUserId") Long viewerUserId,
+            @RequestParam("resourceType") String resourceType) {
+        return ApiResponse.success(userDataScopeAdminService.getAssignment(tenantId, viewerUserId, resourceType));
+    }
+
+    @PutMapping("/assignment")
+    @Log(type = "系统管理", value = "保存用户数据授权")
+    public ApiResponse<Boolean> saveAssignment(@RequestBody UserDataScopeAssignmentDTO payload) {
+        return ApiResponse.success(userDataScopeAdminService.saveAssignment(payload));
+    }
+
+    @DeleteMapping("/grants/{id}")
+    @Log(type = "系统管理", value = "删除用户数据授权")
+    public ApiResponse<Boolean> deleteGrant(
+            @PathVariable("id") Long id,
+            @RequestParam(value = "tenantId", required = false) Long tenantId) {
+        return ApiResponse.success(userDataScopeAdminService.deleteGrant(tenantId, id));
+    }
+}
diff --git a/backend/src/main/java/com/unis/crm/dto/datascope/UserDataScopeAssignmentDTO.java b/backend/src/main/java/com/unis/crm/dto/datascope/UserDataScopeAssignmentDTO.java
new file mode 100644
index 00000000..5f896411
--- /dev/null
+++ b/backend/src/main/java/com/unis/crm/dto/datascope/UserDataScopeAssignmentDTO.java
@@ -0,0 +1,81 @@
+package com.unis.crm.dto.datascope;
+
+import java.time.OffsetDateTime;
+import java.util.ArrayList;
+import java.util.List;
+
+public class UserDataScopeAssignmentDTO {
+
+    private Long tenantId;
+    private Long viewerUserId;
+    private String resourceType;
+    private List<String> resourceTypes = new ArrayList<>();
+    private List<Long> ownerUserIds = new ArrayList<>();
+    private OffsetDateTime expireAt;
+    private String remark;
+    private Boolean enabled;
+
+    public Long getTenantId() {
+        return tenantId;
+    }
+
+    public void setTenantId(Long tenantId) {
+        this.tenantId = tenantId;
+    }
+
+    public Long getViewerUserId() {
+        return viewerUserId;
+    }
+
+    public void setViewerUserId(Long viewerUserId) {
+        this.viewerUserId = viewerUserId;
+    }
+
+    public String getResourceType() {
+        return resourceType;
+    }
+
+    public void setResourceType(String resourceType) {
+        this.resourceType = resourceType;
+    }
+
+    public List<String> getResourceTypes() {
+        return resourceTypes;
+    }
+
+    public void setResourceTypes(List<String> resourceTypes) {
+        this.resourceTypes = resourceTypes;
+    }
+
+    public List<Long> getOwnerUserIds() {
+        return ownerUserIds;
+    }
+
+    public void setOwnerUserIds(List<Long> ownerUserIds) {
+        this.ownerUserIds = ownerUserIds;
+    }
+
+    public OffsetDateTime getExpireAt() {
+        return expireAt;
+    }
+
+    public void setExpireAt(OffsetDateTime expireAt) {
+        this.expireAt = expireAt;
+    }
+
+    public String getRemark() {
+        return remark;
+    }
+
+    public void setRemark(String remark) {
+        this.remark = remark;
+    }
+
+    public Boolean getEnabled() {
+        return enabled;
+    }
+
+    public void setEnabled(Boolean enabled) {
+        this.enabled = enabled;
+    }
+}
diff --git a/backend/src/main/java/com/unis/crm/dto/datascope/UserDataScopeGrantDTO.java b/backend/src/main/java/com/unis/crm/dto/datascope/UserDataScopeGrantDTO.java
new file mode 100644
index 00000000..9e00cee7
--- /dev/null
+++ b/backend/src/main/java/com/unis/crm/dto/datascope/UserDataScopeGrantDTO.java
@@ -0,0 +1,115 @@
+package com.unis.crm.dto.datascope;
+
+import java.time.OffsetDateTime;
+
+public class UserDataScopeGrantDTO {
+
+    private Long id;
+    private Long tenantId;
+    private Long viewerUserId;
+    private String viewerName;
+    private Long ownerUserId;
+    private String ownerName;
+    private String resourceType;
+    private Boolean enabled;
+    private OffsetDateTime expireAt;
+    private String remark;
+    private OffsetDateTime createdAt;
+    private OffsetDateTime updatedAt;
+
+    public Long getId() {
+        return id;
+    }
+
+    public void setId(Long id) {
+        this.id = id;
+    }
+
+    public Long getTenantId() {
+        return tenantId;
+    }
+
+    public void setTenantId(Long tenantId) {
+        this.tenantId = tenantId;
+    }
+
+    public Long getViewerUserId() {
+        return viewerUserId;
+    }
+
+    public void setViewerUserId(Long viewerUserId) {
+        this.viewerUserId = viewerUserId;
+    }
+
+    public String getViewerName() {
+        return viewerName;
+    }
+
+    public void setViewerName(String viewerName) {
+        this.viewerName = viewerName;
+    }
+
+    public Long getOwnerUserId() {
+        return ownerUserId;
+    }
+
+    public void setOwnerUserId(Long ownerUserId) {
+        this.ownerUserId = ownerUserId;
+    }
+
+    public String getOwnerName() {
+        return ownerName;
+    }
+
+    public void setOwnerName(String ownerName) {
+        this.ownerName = ownerName;
+    }
+
+    public String getResourceType() {
+        return resourceType;
+    }
+
+    public void setResourceType(String resourceType) {
+        this.resourceType = resourceType;
+    }
+
+    public Boolean getEnabled() {
+        return enabled;
+    }
+
+    public void setEnabled(Boolean enabled) {
+        this.enabled = enabled;
+    }
+
+    public OffsetDateTime getExpireAt() {
+        return expireAt;
+    }
+
+    public void setExpireAt(OffsetDateTime expireAt) {
+        this.expireAt = expireAt;
+    }
+
+    public String getRemark() {
+        return remark;
+    }
+
+    public void setRemark(String remark) {
+        this.remark = remark;
+    }
+
+    public OffsetDateTime getCreatedAt() {
+        return createdAt;
+    }
+
+    public void setCreatedAt(OffsetDateTime createdAt) {
+        this.createdAt = createdAt;
+    }
+
+    public OffsetDateTime getUpdatedAt() {
+        return updatedAt;
+    }
+
+    public void setUpdatedAt(OffsetDateTime updatedAt) {
+        this.updatedAt = updatedAt;
+    }
+}
diff --git a/backend/src/main/java/com/unis/crm/dto/datascope/UserDataScopeUserDTO.java b/backend/src/main/java/com/unis/crm/dto/datascope/UserDataScopeUserDTO.java
new file mode 100644
index 00000000..a0b604d6
--- /dev/null
+++ b/backend/src/main/java/com/unis/crm/dto/datascope/UserDataScopeUserDTO.java
@@ -0,0 +1,59 @@
+package com.unis.crm.dto.datascope;
+
+public class UserDataScopeUserDTO {
+
+    private Long userId;
+    private String username;
+    private String displayName;
+    private String orgName;
+    private Integer status;
+    private Long tenantId;
+
+    public Long getUserId() {
+        return userId;
+    }
+
+    public void setUserId(Long userId) {
+        this.userId = userId;
+    }
+
+    public String getUsername() {
+        return username;
+    }
+
+    public void setUsername(String username) {
+        this.username = username;
+    }
+
+    public String getDisplayName() {
+        return displayName;
+    }
+
+    public void setDisplayName(String displayName) {
+        this.displayName = displayName;
+    }
+
+    public String getOrgName() {
+        return orgName;
+    }
+
+    public void setOrgName(String orgName) {
+        this.orgName = orgName;
+    }
+
+    public Integer getStatus() {
+        return status;
+    }
+
+    public void setStatus(Integer status) {
+        this.status = status;
+    }
+
+    public Long getTenantId() {
+        return tenantId;
+    }
+
+    public void setTenantId(Long tenantId) {
+        this.tenantId = tenantId;
+    }
+}
diff --git a/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityFollowUpDTO.java b/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityFollowUpDTO.java
index 41c466e0..03c26933 100644
--- a/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityFollowUpDTO.java
+++ b/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityFollowUpDTO.java
@@ -16,6 +16,7 @@ public class OpportunityFollowUpDTO {
     private String communicationContent;
     private String nextAction;
     private String user;
+    private String roleCodes;
     private List<WorkReportAttachmentDTO> attachments = new ArrayList<>();
 
     public Long getId() {
@@ -98,6 +99,14 @@ public class OpportunityFollowUpDTO {
         this.user = user;
     }
 
+    public String getRoleCodes() {
+        return roleCodes;
+    }
+
+    public void setRoleCodes(String roleCodes) {
+        this.roleCodes = roleCodes;
+    }
+
     public List<WorkReportAttachmentDTO> getAttachments() {
         return attachments;
     }
diff --git a/backend/src/main/java/com/unis/crm/dto/work/WorkCheckInExportDTO.java b/backend/src/main/java/com/unis/crm/dto/work/WorkCheckInExportDTO.java
index 6c4bff39..2ec4adf6 100644
--- a/backend/src/main/java/com/unis/crm/dto/work/WorkCheckInExportDTO.java
+++ b/backend/src/main/java/com/unis/crm/dto/work/WorkCheckInExportDTO.java
@@ -1,11 +1,13 @@
 package com.unis.crm.dto.work;
 
 import java.math.BigDecimal;
+import java.time.OffsetDateTime;
 import java.util.ArrayList;
 import java.util.List;
 
 public class WorkCheckInExportDTO {
 
+    private Long id;
     private String checkinDate;
     private String checkinTime;
     private String userName;
@@ -19,8 +21,17 @@ public class WorkCheckInExportDTO {
     private String status;
     private String createdAt;
     private String updatedAt;
+    private OffsetDateTime sortTime;
     private List<String> photoUrls = new ArrayList<>();
 
+    public Long getId() {
+        return id;
+    }
+
+    public void setId(Long id) {
+        this.id = id;
+    }
+
     public String getCheckinDate() {
         return checkinDate;
     }
@@ -125,6 +136,14 @@ public class WorkCheckInExportDTO {
         this.updatedAt = updatedAt;
     }
 
+    public OffsetDateTime getSortTime() {
+        return sortTime;
+    }
+
+    public void setSortTime(OffsetDateTime sortTime) {
+        this.sortTime = sortTime;
+    }
+
     public List<String> getPhotoUrls() {
         return photoUrls;
     }
diff --git a/backend/src/main/java/com/unis/crm/dto/work/WorkDailyReportExportDTO.java b/backend/src/main/java/com/unis/crm/dto/work/WorkDailyReportExportDTO.java
index c27b4e00..a58bc7c4 100644
--- a/backend/src/main/java/com/unis/crm/dto/work/WorkDailyReportExportDTO.java
+++ b/backend/src/main/java/com/unis/crm/dto/work/WorkDailyReportExportDTO.java
@@ -2,9 +2,11 @@ package com.unis.crm.dto.work;
 
 import java.util.ArrayList;
 import java.util.List;
+import java.time.OffsetDateTime;
 
 public class WorkDailyReportExportDTO {
 
+    private Long id;
     private String reportDate;
     private String submitTime;
     private String userName;
@@ -19,10 +21,19 @@ public class WorkDailyReportExportDTO {
     private String reviewedAt;
     private String createdAt;
     private String updatedAt;
+    private OffsetDateTime sortTime;
     private List<WorkReportLineItemDTO> lineItems = new ArrayList<>();
     private List<WorkReportAttachmentDTO> attachments = new ArrayList<>();
     private List<WorkTomorrowPlanItemDTO> planItems = new ArrayList<>();
 
+    public Long getId() {
+        return id;
+    }
+
+    public void setId(Long id) {
+        this.id = id;
+    }
+
     public String getReportDate() {
         return reportDate;
     }
@@ -135,6 +146,14 @@ public class WorkDailyReportExportDTO {
         this.updatedAt = updatedAt;
     }
 
+    public OffsetDateTime getSortTime() {
+        return sortTime;
+    }
+
+    public void setSortTime(OffsetDateTime sortTime) {
+        this.sortTime = sortTime;
+    }
+
     public List<WorkReportLineItemDTO> getLineItems() {
         return lineItems;
     }
diff --git a/backend/src/main/java/com/unis/crm/mapper/ExpansionMapper.java b/backend/src/main/java/com/unis/crm/mapper/ExpansionMapper.java
index 068415cf..bc967732 100644
--- a/backend/src/main/java/com/unis/crm/mapper/ExpansionMapper.java
+++ b/backend/src/main/java/com/unis/crm/mapper/ExpansionMapper.java
@@ -32,9 +32,17 @@ public interface ExpansionMapper {
     @DataScope(tableAlias = "s", ownerColumn = "owner_user_id")
     List<SalesExpansionItemDTO> selectSalesExpansions(@Param("userId") Long userId, @Param("keyword") String keyword);
 
+    List<SalesExpansionItemDTO> selectSalesExpansionsByOwnerUserIds(
+            @Param("ownerUserIds") List<Long> ownerUserIds,
+            @Param("keyword") String keyword);
+
     @DataScope(tableAlias = "c", ownerColumn = "owner_user_id")
     List<ChannelExpansionItemDTO> selectChannelExpansions(@Param("userId") Long userId, @Param("keyword") String keyword);
 
+    List<ChannelExpansionItemDTO> selectChannelExpansionsByOwnerUserIds(
+            @Param("ownerUserIds") List<Long> ownerUserIds,
+            @Param("keyword") String keyword);
+
     List<SalesExpansionItemDTO> selectSalesExpansionsForTenant(
             @Param("tenantId") Long tenantId,
             @Param("keyword") String keyword,
@@ -45,19 +53,14 @@ public interface ExpansionMapper {
             @Param("keyword") String keyword,
             @Param("limit") Integer limit);
 
-    @DataScope(tableAlias = "s", ownerColumn = "owner_user_id")
     List<ExpansionFollowUpDTO> selectSalesFollowUps(@Param("userId") Long userId, @Param("bizIds") List<Long> bizIds);
 
-    @DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
     List<RelatedProjectSummaryDTO> selectSalesRelatedProjects(@Param("userId") Long userId, @Param("bizIds") List<Long> bizIds);
 
-    @DataScope(tableAlias = "c", ownerColumn = "owner_user_id")
     List<ExpansionFollowUpDTO> selectChannelFollowUps(@Param("userId") Long userId, @Param("bizIds") List<Long> bizIds);
 
-    @DataScope(tableAlias = "c", ownerColumn = "owner_user_id")
     List<ChannelExpansionContactDTO> selectChannelContacts(@Param("userId") Long userId, @Param("bizIds") List<Long> bizIds);
 
-    @DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
     List<ChannelRelatedProjectSummaryDTO> selectChannelRelatedProjects(@Param("userId") Long userId, @Param("bizIds") List<Long> bizIds);
 
     int insertSalesExpansion(@Param("userId") Long userId, @Param("request") CreateSalesExpansionRequest request);
diff --git a/backend/src/main/java/com/unis/crm/mapper/WorkMapper.java b/backend/src/main/java/com/unis/crm/mapper/WorkMapper.java
index b2d8d416..b7631567 100644
--- a/backend/src/main/java/com/unis/crm/mapper/WorkMapper.java
+++ b/backend/src/main/java/com/unis/crm/mapper/WorkMapper.java
@@ -32,11 +32,23 @@ public interface WorkMapper {
     @DataScope(tableAlias = "c", ownerColumn = "user_id")
     List<WorkHistoryItemDTO> selectCheckInHistory(@Param("limit") int limit);
 
+    List<WorkHistoryItemDTO> selectCheckInHistoryByUserIds(
+            @Param("visibleUserIds") List<Long> visibleUserIds,
+            @Param("limit") int limit);
+
     @DataScope(tableAlias = "r", ownerColumn = "user_id")
     List<WorkHistoryItemDTO> selectReportHistory(@Param("limit") int limit);
 
+    List<WorkHistoryItemDTO> selectReportHistoryByUserIds(
+            @Param("visibleUserIds") List<Long> visibleUserIds,
+            @Param("limit") int limit);
+
     WorkHistoryItemDTO selectReportHistoryById(@Param("userId") Long userId, @Param("reportId") Long reportId);
 
+    WorkHistoryItemDTO selectReportHistoryByIdForUserIds(
+            @Param("reportId") Long reportId,
+            @Param("visibleUserIds") List<Long> visibleUserIds);
+
     @DataScope(tableAlias = "c", ownerColumn = "user_id")
     List<WorkCheckInExportDTO> selectCheckInExportRows(
             @Param("startDate") LocalDate startDate,
@@ -47,6 +59,16 @@ public interface WorkMapper {
             @Param("status") String status,
             @Param("limit") int limit);
 
+    List<WorkCheckInExportDTO> selectCheckInExportRowsByUserIds(
+            @Param("visibleUserIds") List<Long> visibleUserIds,
+            @Param("startDate") LocalDate startDate,
+            @Param("endDate") LocalDate endDate,
+            @Param("keyword") String keyword,
+            @Param("deptName") String deptName,
+            @Param("bizType") String bizType,
+            @Param("status") String status,
+            @Param("limit") int limit);
+
     @DataScope(tableAlias = "r", ownerColumn = "user_id")
     List<WorkDailyReportExportDTO> selectDailyReportExportRows(
             @Param("startDate") LocalDate startDate,
@@ -56,6 +78,15 @@ public interface WorkMapper {
             @Param("status") String status,
             @Param("limit") int limit);
 
+    List<WorkDailyReportExportDTO> selectDailyReportExportRowsByUserIds(
+            @Param("visibleUserIds") List<Long> visibleUserIds,
+            @Param("startDate") LocalDate startDate,
+            @Param("endDate") LocalDate endDate,
+            @Param("keyword") String keyword,
+            @Param("deptName") String deptName,
+            @Param("status") String status,
+            @Param("limit") int limit);
+
     Long selectTodayCheckInId(@Param("userId") Long userId);
 
     int insertCheckIn(@Param("userId") Long userId, @Param("request") CreateWorkCheckInRequest request);
@@ -164,7 +195,8 @@ public interface WorkMapper {
             @Param("opportunityId") Long opportunityId,
             @Param("latestProgress") String latestProgress,
             @Param("nextPlan") String nextPlan,
-            @Param("stage") String stage);
+            @Param("stage") String stage,
+            @Param("updatedBy") Long updatedBy);
 
     int deleteTodosByBiz(@Param("userId") Long userId, @Param("bizType") String bizType, @Param("bizId") Long bizId);
 
diff --git a/backend/src/main/java/com/unis/crm/service/CrmDataVisibilityService.java b/backend/src/main/java/com/unis/crm/service/CrmDataVisibilityService.java
new file mode 100644
index 00000000..000bbfe2
--- /dev/null
+++ b/backend/src/main/java/com/unis/crm/service/CrmDataVisibilityService.java
@@ -0,0 +1,19 @@
+package com.unis.crm.service;
+
+import java.util.List;
+
+public interface CrmDataVisibilityService {
+
+    String RESOURCE_ALL = "ALL";
+    String RESOURCE_OPPORTUNITY = "OPPORTUNITY";
+    String RESOURCE_EXPANSION = "EXPANSION";
+    String RESOURCE_DAILY_REPORT = "DAILY_REPORT";
+    String RESOURCE_CHECKIN = "CHECKIN";
+
+    DataVisibility resolveVisibility(Long currentUserId, Long tenantId, String resourceType);
+
+    record DataVisibility(
+            boolean allDataAccess,
+            List<Long> visibleOwnerUserIds) {
+    }
+}
diff --git a/backend/src/main/java/com/unis/crm/service/UserDataScopeAdminService.java b/backend/src/main/java/com/unis/crm/service/UserDataScopeAdminService.java
new file mode 100644
index 00000000..397465d8
--- /dev/null
+++ b/backend/src/main/java/com/unis/crm/service/UserDataScopeAdminService.java
@@ -0,0 +1,351 @@
+package com.unis.crm.service;
+
+import com.unis.crm.common.BusinessException;
+import com.unis.crm.common.UnauthorizedException;
+import com.unis.crm.dto.datascope.UserDataScopeAssignmentDTO;
+import com.unis.crm.dto.datascope.UserDataScopeGrantDTO;
+import com.unis.crm.dto.datascope.UserDataScopeUserDTO;
+import com.unisbase.security.PermissionService;
+import com.unisbase.security.SpringSecurityTenantProvider;
+import com.unisbase.security.SpringSecurityUserProvider;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.time.OffsetDateTime;
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+@Service
+public class UserDataScopeAdminService {
+
+    private static final String VIEW_PERM = "user_data_scope:view";
+    private static final String UPDATE_PERM = "user_data_scope:update";
+    private static final Set<String> RESOURCE_TYPES = Set.of(
+            "ALL",
+            "OPPORTUNITY",
+            "EXPANSION",
+            "DAILY_REPORT",
+            "CHECKIN",
+            "CUSTOMER",
+            "WORK");
+
+    private final JdbcTemplate jdbcTemplate;
+    private final PermissionService permissionService;
+    private final SpringSecurityTenantProvider tenantProvider;
+    private final SpringSecurityUserProvider userProvider;
+
+    public UserDataScopeAdminService(
+            JdbcTemplate jdbcTemplate,
+            PermissionService permissionService,
+            SpringSecurityTenantProvider tenantProvider,
+            SpringSecurityUserProvider userProvider) {
+        this.jdbcTemplate = jdbcTemplate;
+        this.permissionService = permissionService;
+        this.tenantProvider = tenantProvider;
+        this.userProvider = userProvider;
+    }
+
+    public List<UserDataScopeUserDTO> listUsers(Long tenantId) {
+        Long resolvedTenantId = resolveTenantId(tenantId);
+        requirePermission(VIEW_PERM, "无权查看数据授权配置");
+        return jdbcTemplate.query("""
+                select
+                  u.user_id,
+                  coalesce(nullif(u.username, ''), cast(u.user_id as varchar)) as username,
+                  coalesce(nullif(u.display_name, ''), nullif(u.username, ''), cast(u.user_id as varchar)) as display_name,
+                  coalesce(u.status, 1) as status,
+                  tu.tenant_id,
+                  string_agg(distinct o.org_name, '、' order by o.org_name) as org_name
+                from sys_user u
+                join sys_tenant_user tu on tu.user_id = u.user_id
+                left join sys_org o
+                  on o.id = tu.org_id
+                 and o.tenant_id = tu.tenant_id
+                 and coalesce(o.is_deleted, 0) = 0
+                where tu.tenant_id = ?
+                  and coalesce(u.is_deleted, 0) = 0
+                  and coalesce(tu.is_deleted, 0) = 0
+                group by u.user_id, u.username, u.display_name, u.status, tu.tenant_id
+                order by coalesce(u.status, 1) desc, display_name asc, username asc, u.user_id asc
+                """, (resultSet, rowNum) -> mapUser(resultSet), resolvedTenantId);
+    }
+
+    public List<UserDataScopeGrantDTO> listGrants(Long tenantId, Long viewerUserId, String resourceType) {
+        Long resolvedTenantId = resolveTenantId(tenantId);
+        requirePermission(VIEW_PERM, "无权查看数据授权配置");
+        String normalizedResourceType = normalizeResourceType(resourceType, false);
+        List<Object> args = new ArrayList<>();
+        args.add(resolvedTenantId);
+        StringBuilder sql = new StringBuilder("""
+                select
+                  g.id,
+                  g.tenant_id,
+                  g.viewer_user_id,
+                  coalesce(nullif(viewer.display_name, ''), nullif(viewer.username, ''), cast(g.viewer_user_id as varchar)) as viewer_name,
+                  g.owner_user_id,
+                  coalesce(nullif(owner_user.display_name, ''), nullif(owner_user.username, ''), cast(g.owner_user_id as varchar)) as owner_name,
+                  g.resource_type,
+                  g.enabled,
+                  g.expire_at,
+                  g.remark,
+                  g.created_at,
+                  g.updated_at
+                from sys_user_data_scope_user g
+                left join sys_user viewer on viewer.user_id = g.viewer_user_id
+                left join sys_user owner_user on owner_user.user_id = g.owner_user_id
+                where g.tenant_id = ?
+                  and coalesce(g.is_deleted, 0) = 0
+                """);
+        if (viewerUserId != null && viewerUserId > 0) {
+            sql.append(" and g.viewer_user_id = ?");
+            args.add(viewerUserId);
+        }
+        if (normalizedResourceType != null) {
+            sql.append(" and g.resource_type = ?");
+            args.add(normalizedResourceType);
+        }
+        sql.append(" order by viewer_name asc, g.resource_type asc, owner_name asc, g.id asc");
+        return jdbcTemplate.query(sql.toString(), (resultSet, rowNum) -> mapGrant(resultSet), args.toArray());
+    }
+
+    public UserDataScopeAssignmentDTO getAssignment(Long tenantId, Long viewerUserId, String resourceType) {
+        Long resolvedTenantId = resolveTenantId(tenantId);
+        requirePermission(VIEW_PERM, "无权查看数据授权配置");
+        Long normalizedViewerUserId = requirePositiveUserId(viewerUserId, "请选择查看人");
+        String normalizedResourceType = normalizeResourceType(resourceType, true);
+        requireUserInTenant(resolvedTenantId, normalizedViewerUserId, "查看人不存在或不属于当前租户");
+
+        List<UserDataScopeGrantDTO> grants = listGrants(resolvedTenantId, normalizedViewerUserId, normalizedResourceType);
+        UserDataScopeAssignmentDTO dto = new UserDataScopeAssignmentDTO();
+        dto.setTenantId(resolvedTenantId);
+        dto.setViewerUserId(normalizedViewerUserId);
+        dto.setResourceType(normalizedResourceType);
+        dto.setResourceTypes(List.of(normalizedResourceType));
+        dto.setOwnerUserIds(grants.stream()
+                .map(UserDataScopeGrantDTO::getOwnerUserId)
+                .filter(id -> id != null && id > 0)
+                .distinct()
+                .toList());
+        grants.stream().findFirst().ifPresent(first -> {
+            dto.setEnabled(first.getEnabled());
+            dto.setExpireAt(first.getExpireAt());
+            dto.setRemark(first.getRemark());
+        });
+        if (dto.getEnabled() == null) {
+            dto.setEnabled(Boolean.TRUE);
+        }
+        return dto;
+    }
+
+    @Transactional
+    public boolean saveAssignment(UserDataScopeAssignmentDTO payload) {
+        requirePermission(UPDATE_PERM, "无权修改数据授权配置");
+        if (payload == null) {
+            throw new BusinessException("请提交数据授权配置");
+        }
+        Long tenantId = resolveTenantId(payload.getTenantId());
+        Long viewerUserId = requirePositiveUserId(payload.getViewerUserId(), "请选择查看人");
+        List<String> resourceTypes = normalizeResourceTypes(payload.getResourceTypes(), payload.getResourceType());
+        requireUserInTenant(tenantId, viewerUserId, "查看人不存在或不属于当前租户");
+
+        List<Long> ownerUserIds = normalizeOwnerUserIds(payload.getOwnerUserIds(), viewerUserId);
+        validateOwnersInTenant(tenantId, ownerUserIds);
+        boolean enabled = payload.getEnabled() == null || Boolean.TRUE.equals(payload.getEnabled());
+        Long createdBy = userProvider == null ? null : userProvider.getCurrentUserId();
+
+        for (String resourceType : resourceTypes) {
+            jdbcTemplate.update("""
+                    update sys_user_data_scope_user
+                    set enabled = false,
+                        is_deleted = 1,
+                        updated_at = now()
+                    where tenant_id = ?
+                      and viewer_user_id = ?
+                      and resource_type = ?
+                      and coalesce(is_deleted, 0) = 0
+                    """, tenantId, viewerUserId, resourceType);
+
+            for (Long ownerUserId : ownerUserIds) {
+                jdbcTemplate.update("""
+                        insert into sys_user_data_scope_user (
+                          tenant_id,
+                          viewer_user_id,
+                          owner_user_id,
+                          resource_type,
+                          enabled,
+                          expire_at,
+                          remark,
+                          created_by,
+                          created_at,
+                          updated_at,
+                          is_deleted
+                        ) values (?, ?, ?, ?, ?, ?, ?, ?, now(), now(), 0)
+                        """,
+                        tenantId,
+                        viewerUserId,
+                        ownerUserId,
+                        resourceType,
+                        enabled,
+                        payload.getExpireAt(),
+                        trimToNull(payload.getRemark()),
+                        createdBy);
+            }
+        }
+        return true;
+    }
+
+    @Transactional
+    public boolean deleteGrant(Long tenantId, Long grantId) {
+        requirePermission(UPDATE_PERM, "无权修改数据授权配置");
+        Long resolvedTenantId = resolveTenantId(tenantId);
+        if (grantId == null || grantId <= 0) {
+            throw new BusinessException("数据授权记录不存在");
+        }
+        int updated = jdbcTemplate.update("""
+                update sys_user_data_scope_user
+                set enabled = false,
+                    is_deleted = 1,
+                    updated_at = now()
+                where id = ?
+                  and tenant_id = ?
+                  and coalesce(is_deleted, 0) = 0
+                """, grantId, resolvedTenantId);
+        if (updated <= 0) {
+            throw new BusinessException("数据授权记录不存在或已删除");
+        }
+        return true;
+    }
+
+    private UserDataScopeUserDTO mapUser(ResultSet resultSet) throws SQLException {
+        UserDataScopeUserDTO dto = new UserDataScopeUserDTO();
+        dto.setUserId(resultSet.getLong("user_id"));
+        dto.setUsername(resultSet.getString("username"));
+        dto.setDisplayName(resultSet.getString("display_name"));
+        dto.setStatus(resultSet.getInt("status"));
+        dto.setTenantId(resultSet.getLong("tenant_id"));
+        dto.setOrgName(resultSet.getString("org_name"));
+        return dto;
+    }
+
+    private UserDataScopeGrantDTO mapGrant(ResultSet resultSet) throws SQLException {
+        UserDataScopeGrantDTO dto = new UserDataScopeGrantDTO();
+        dto.setId(resultSet.getLong("id"));
+        dto.setTenantId(resultSet.getLong("tenant_id"));
+        dto.setViewerUserId(resultSet.getLong("viewer_user_id"));
+        dto.setViewerName(resultSet.getString("viewer_name"));
+        dto.setOwnerUserId(resultSet.getLong("owner_user_id"));
+        dto.setOwnerName(resultSet.getString("owner_name"));
+        dto.setResourceType(resultSet.getString("resource_type"));
+        dto.setEnabled(resultSet.getBoolean("enabled"));
+        dto.setExpireAt(resultSet.getObject("expire_at", OffsetDateTime.class));
+        dto.setRemark(resultSet.getString("remark"));
+        dto.setCreatedAt(resultSet.getObject("created_at", OffsetDateTime.class));
+        dto.setUpdatedAt(resultSet.getObject("updated_at", OffsetDateTime.class));
+        return dto;
+    }
+
+    private Long resolveTenantId(Long requestedTenantId) {
+        Long currentTenantId = tenantProvider == null ? null : tenantProvider.getCurrentTenantId();
+        if (currentTenantId != null && currentTenantId > 0) {
+            if (requestedTenantId != null && requestedTenantId > 0 && !currentTenantId.equals(requestedTenantId)) {
+                throw new BusinessException("只能配置当前租户的数据授权");
+            }
+            return currentTenantId;
+        }
+        if (requestedTenantId != null && requestedTenantId > 0) {
+            return requestedTenantId;
+        }
+        throw new BusinessException("请先切换到具体租户后再配置数据授权");
+    }
+
+    private void requirePermission(String perm, String message) {
+        if (!permissionService.hasPermi(perm)) {
+            throw new UnauthorizedException(message);
+        }
+    }
+
+    private Long requirePositiveUserId(Long userId, String message) {
+        if (userId == null || userId <= 0) {
+            throw new BusinessException(message);
+        }
+        return userId;
+    }
+
+    private void requireUserInTenant(Long tenantId, Long userId, String message) {
+        Integer count = jdbcTemplate.queryForObject("""
+                select count(1)
+                from sys_user u
+                join sys_tenant_user tu on tu.user_id = u.user_id
+                where tu.tenant_id = ?
+                  and u.user_id = ?
+                  and coalesce(u.is_deleted, 0) = 0
+                  and coalesce(tu.is_deleted, 0) = 0
+                """, Integer.class, tenantId, userId);
+        if (count == null || count <= 0) {
+            throw new BusinessException(message);
+        }
+    }
+
+    private void validateOwnersInTenant(Long tenantId, List<Long> ownerUserIds) {
+        for (Long ownerUserId : ownerUserIds) {
+            requireUserInTenant(tenantId, ownerUserId, "可见人员不存在或不属于当前租户");
+        }
+    }
+
+    private List<Long> normalizeOwnerUserIds(List<Long> ownerUserIds, Long viewerUserId) {
+        if (ownerUserIds == null || ownerUserIds.isEmpty()) {
+            return List.of();
+        }
+        Set<Long> normalized = new LinkedHashSet<>();
+        for (Long ownerUserId : ownerUserIds) {
+            if (ownerUserId != null && ownerUserId > 0 && !ownerUserId.equals(viewerUserId)) {
+                normalized.add(ownerUserId);
+            }
+        }
+        return List.copyOf(normalized);
+    }
+
+    private String normalizeResourceType(String resourceType, boolean required) {
+        String normalized = trimToNull(resourceType);
+        if (normalized == null) {
+            if (required) {
+                throw new BusinessException("请选择授权资源范围");
+            }
+            return null;
+        }
+        normalized = normalized.toUpperCase(Locale.ROOT);
+        if (!RESOURCE_TYPES.contains(normalized)) {
+            throw new BusinessException("不支持的授权资源范围");
+        }
+        return normalized;
+    }
+
+    private List<String> normalizeResourceTypes(List<String> resourceTypes, String fallbackResourceType) {
+        Set<String> normalized = new LinkedHashSet<>();
+        if (resourceTypes != null) {
+            for (String resourceType : resourceTypes) {
+                String value = normalizeResourceType(resourceType, false);
+                if (value != null) {
+                    normalized.add(value);
+                }
+            }
+        }
+        if (normalized.isEmpty()) {
+            normalized.add(normalizeResourceType(fallbackResourceType, true));
+        }
+        return List.copyOf(normalized);
+    }
+
+    private String trimToNull(String value) {
+        if (!StringUtils.hasText(value)) {
+            return null;
+        }
+        return value.trim();
+    }
+}
diff --git a/backend/src/main/java/com/unis/crm/service/impl/CrmDataVisibilityServiceImpl.java b/backend/src/main/java/com/unis/crm/service/impl/CrmDataVisibilityServiceImpl.java
new file mode 100644
index 00000000..31f38fa7
--- /dev/null
+++ b/backend/src/main/java/com/unis/crm/service/impl/CrmDataVisibilityServiceImpl.java
@@ -0,0 +1,87 @@
+package com.unis.crm.service.impl;
+
+import com.unis.crm.service.CrmDataVisibilityService;
+import com.unisbase.dto.DataScopeRuleDTO;
+import com.unisbase.service.DataScopeService;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Service;
+
+@Service
+public class CrmDataVisibilityServiceImpl implements CrmDataVisibilityService {
+
+    private final DataScopeService dataScopeService;
+    private final JdbcTemplate jdbcTemplate;
+
+    public CrmDataVisibilityServiceImpl(DataScopeService dataScopeService, JdbcTemplate jdbcTemplate) {
+        this.dataScopeService = dataScopeService;
+        this.jdbcTemplate = jdbcTemplate;
+    }
+
+    @Override
+    public DataVisibility resolveVisibility(Long currentUserId, Long tenantId, String resourceType) {
+        if (currentUserId == null || currentUserId <= 0) {
+            return new DataVisibility(false, List.of());
+        }
+        if (tenantId == null || tenantId <= 0 || dataScopeService == null) {
+            return new DataVisibility(false, List.of(currentUserId));
+        }
+
+        DataScopeRuleDTO baseRule = dataScopeService.resolveUserScope(currentUserId, tenantId);
+        if (baseRule != null && baseRule.isAllAccess()) {
+            return new DataVisibility(true, List.of());
+        }
+
+        Set<Long> visibleOwnerUserIds = new LinkedHashSet<>();
+        if (baseRule == null) {
+            visibleOwnerUserIds.add(currentUserId);
+        } else {
+            addValidUserIds(visibleOwnerUserIds, baseRule.getCreatorUserIds());
+        }
+        addValidUserIds(visibleOwnerUserIds, queryExplicitVisibleOwnerUserIds(currentUserId, tenantId, resourceType));
+
+        return new DataVisibility(false, List.copyOf(visibleOwnerUserIds));
+    }
+
+    private List<Long> queryExplicitVisibleOwnerUserIds(Long currentUserId, Long tenantId, String resourceType) {
+        String normalizedResourceType = normalizeResourceType(resourceType);
+        return jdbcTemplate.queryForList("""
+                        select distinct owner_user_id
+                        from sys_user_data_scope_user
+                        where tenant_id = ?
+                          and viewer_user_id = ?
+                          and owner_user_id > 0
+                          and enabled = true
+                          and coalesce(is_deleted, 0) = 0
+                          and (expire_at is null or expire_at > now())
+                          and resource_type in (?, ?)
+                        order by owner_user_id asc
+                        """,
+                Long.class,
+                tenantId,
+                currentUserId,
+                RESOURCE_ALL,
+                normalizedResourceType);
+    }
+
+    private void addValidUserIds(Set<Long> target, List<Long> source) {
+        if (source == null || source.isEmpty()) {
+            return;
+        }
+        for (Long userId : source) {
+            if (userId != null && userId > 0) {
+                target.add(userId);
+            }
+        }
+    }
+
+    private String normalizeResourceType(String resourceType) {
+        if (resourceType == null || resourceType.trim().isEmpty()) {
+            return RESOURCE_ALL;
+        }
+        return resourceType.trim().toUpperCase(Locale.ROOT);
+    }
+}
diff --git a/backend/src/main/java/com/unis/crm/service/impl/ExpansionServiceImpl.java b/backend/src/main/java/com/unis/crm/service/impl/ExpansionServiceImpl.java
index 4578f74a..996460a7 100644
--- a/backend/src/main/java/com/unis/crm/service/impl/ExpansionServiceImpl.java
+++ b/backend/src/main/java/com/unis/crm/service/impl/ExpansionServiceImpl.java
@@ -18,6 +18,8 @@ import com.unis.crm.dto.expansion.SalesExpansionItemDTO;
 import com.unis.crm.dto.expansion.UpdateChannelExpansionRequest;
 import com.unis.crm.dto.expansion.UpdateSalesExpansionRequest;
 import com.unis.crm.mapper.ExpansionMapper;
+import com.unis.crm.service.CrmDataVisibilityService;
+import com.unis.crm.service.CrmDataVisibilityService.DataVisibility;
 import com.unis.crm.service.ExpansionService;
 import com.unisbase.security.PermissionService;
 import com.unisbase.security.SpringSecurityTenantProvider;
@@ -57,14 +59,17 @@ public class ExpansionServiceImpl implements ExpansionService {
     private final ExpansionMapper expansionMapper;
     private final SpringSecurityTenantProvider tenantProvider;
     private final PermissionService permissionService;
+    private final CrmDataVisibilityService crmDataVisibilityService;
 
     public ExpansionServiceImpl(
             ExpansionMapper expansionMapper,
             SpringSecurityTenantProvider tenantProvider,
-            PermissionService permissionService) {
+            PermissionService permissionService,
+            CrmDataVisibilityService crmDataVisibilityService) {
         this.expansionMapper = expansionMapper;
         this.tenantProvider = tenantProvider;
         this.permissionService = permissionService;
+        this.crmDataVisibilityService = crmDataVisibilityService;
     }
 
     @Override
@@ -102,8 +107,17 @@ public class ExpansionServiceImpl implements ExpansionService {
     @Override
     public ExpansionOverviewDTO getOverview(Long userId, String keyword) {
         String normalizedKeyword = normalizeKeyword(keyword);
-        List<SalesExpansionItemDTO> salesItems = expansionMapper.selectSalesExpansions(userId, normalizedKeyword);
-        List<ChannelExpansionItemDTO> channelItems = expansionMapper.selectChannelExpansions(userId, normalizedKeyword);
+        List<Long> extraVisibleOwnerUserIds = resolveExtraVisibleOwnerUserIds(userId);
+        List<SalesExpansionItemDTO> salesItems = mergeSalesExpansionItems(
+                expansionMapper.selectSalesExpansions(userId, normalizedKeyword),
+                extraVisibleOwnerUserIds.isEmpty()
+                        ? List.of()
+                        : expansionMapper.selectSalesExpansionsByOwnerUserIds(extraVisibleOwnerUserIds, normalizedKeyword));
+        List<ChannelExpansionItemDTO> channelItems = mergeChannelExpansionItems(
+                expansionMapper.selectChannelExpansions(userId, normalizedKeyword),
+                extraVisibleOwnerUserIds.isEmpty()
+                        ? List.of()
+                        : expansionMapper.selectChannelExpansionsByOwnerUserIds(extraVisibleOwnerUserIds, normalizedKeyword));
 
         attachSalesFollowUps(userId, salesItems);
         attachSalesRelatedProjects(userId, salesItems);
@@ -115,6 +129,64 @@ public class ExpansionServiceImpl implements ExpansionService {
         return new ExpansionOverviewDTO(salesItems, channelItems);
     }
 
+    private List<Long> resolveExtraVisibleOwnerUserIds(Long userId) {
+        if (userId == null || userId <= 0 || crmDataVisibilityService == null) {
+            return List.of();
+        }
+        Long tenantId = tenantProvider == null ? null : tenantProvider.getCurrentTenantId();
+        if (tenantId == null || tenantId <= 0) {
+            return List.of();
+        }
+        DataVisibility visibility = crmDataVisibilityService.resolveVisibility(
+                userId,
+                tenantId,
+                CrmDataVisibilityService.RESOURCE_EXPANSION);
+        if (visibility.allDataAccess()) {
+            return List.of();
+        }
+        LinkedHashSet<Long> normalized = new LinkedHashSet<>();
+        for (Long ownerUserId : visibility.visibleOwnerUserIds()) {
+            if (ownerUserId != null && ownerUserId > 0 && !ownerUserId.equals(userId)) {
+                normalized.add(ownerUserId);
+            }
+        }
+        return List.copyOf(normalized);
+    }
+
+    private List<SalesExpansionItemDTO> mergeSalesExpansionItems(
+            List<SalesExpansionItemDTO> scopedItems,
+            List<SalesExpansionItemDTO> extraItems) {
+        Map<Long, SalesExpansionItemDTO> byId = new LinkedHashMap<>();
+        for (SalesExpansionItemDTO item : scopedItems) {
+            if (item != null && item.getId() != null) {
+                byId.putIfAbsent(item.getId(), item);
+            }
+        }
+        for (SalesExpansionItemDTO item : extraItems) {
+            if (item != null && item.getId() != null) {
+                byId.putIfAbsent(item.getId(), item);
+            }
+        }
+        return new ArrayList<>(byId.values());
+    }
+
+    private List<ChannelExpansionItemDTO> mergeChannelExpansionItems(
+            List<ChannelExpansionItemDTO> scopedItems,
+            List<ChannelExpansionItemDTO> extraItems) {
+        Map<Long, ChannelExpansionItemDTO> byId = new LinkedHashMap<>();
+        for (ChannelExpansionItemDTO item : scopedItems) {
+            if (item != null && item.getId() != null) {
+                byId.putIfAbsent(item.getId(), item);
+            }
+        }
+        for (ChannelExpansionItemDTO item : extraItems) {
+            if (item != null && item.getId() != null) {
+                byId.putIfAbsent(item.getId(), item);
+            }
+        }
+        return new ArrayList<>(byId.values());
+    }
+
     @Override
     public ExpansionOverviewDTO getOpportunityFormOptions(Long userId, String keyword, Integer limit) {
         if (userId == null || userId <= 0) {
diff --git a/backend/src/main/java/com/unis/crm/service/impl/OpportunityServiceImpl.java b/backend/src/main/java/com/unis/crm/service/impl/OpportunityServiceImpl.java
index 819acce4..f2df9f50 100644
--- a/backend/src/main/java/com/unis/crm/service/impl/OpportunityServiceImpl.java
+++ b/backend/src/main/java/com/unis/crm/service/impl/OpportunityServiceImpl.java
@@ -21,11 +21,11 @@ import com.unis.crm.dto.opportunity.PushOpportunityToOmsRequest;
 import com.unis.crm.dto.opportunity.UpdateOpportunityIntegrationRequest;
 import com.unis.crm.dto.work.WorkReportAttachmentDTO;
 import com.unis.crm.mapper.OpportunityMapper;
+import com.unis.crm.service.CrmDataVisibilityService;
+import com.unis.crm.service.CrmDataVisibilityService.DataVisibility;
 import com.unis.crm.service.OmsClient;
 import com.unis.crm.service.OpportunityService;
-import com.unisbase.dto.DataScopeRuleDTO;
 import com.unisbase.security.PermissionService;
-import com.unisbase.service.DataScopeService;
 import com.unisbase.spi.UnisBaseTenantProvider;
 import java.io.IOException;
 import java.math.BigDecimal;
@@ -63,7 +63,7 @@ public class OpportunityServiceImpl implements OpportunityService {
     private final OpportunityMapper opportunityMapper;
     private final OmsClient omsClient;
     private final ObjectMapper objectMapper;
-    private final DataScopeService dataScopeService;
+    private final CrmDataVisibilityService crmDataVisibilityService;
     private final UnisBaseTenantProvider tenantProvider;
     private final PermissionService permissionService;
 
@@ -71,13 +71,13 @@ public class OpportunityServiceImpl implements OpportunityService {
             OpportunityMapper opportunityMapper,
             OmsClient omsClient,
             ObjectMapper objectMapper,
-            DataScopeService dataScopeService,
+            CrmDataVisibilityService crmDataVisibilityService,
             UnisBaseTenantProvider tenantProvider,
             PermissionService permissionService) {
         this.opportunityMapper = opportunityMapper;
         this.omsClient = omsClient;
         this.objectMapper = objectMapper;
-        this.dataScopeService = dataScopeService;
+        this.crmDataVisibilityService = crmDataVisibilityService;
         this.tenantProvider = tenantProvider;
         this.permissionService = permissionService;
     }
@@ -318,18 +318,19 @@ public class OpportunityServiceImpl implements OpportunityService {
         }
         List<String> preSalesUserNames = resolveCurrentUserPreSalesNames(userId);
         Long tenantId = tenantProvider == null ? null : tenantProvider.getCurrentTenantId();
-        if (tenantId == null || dataScopeService == null) {
+        if (tenantId == null || crmDataVisibilityService == null) {
             return new OpportunityVisibility(false, List.of(userId), userId, preSalesUserNames);
         }
 
-        DataScopeRuleDTO rule = dataScopeService.resolveUserScope(userId, tenantId);
-        if (rule == null) {
-            return new OpportunityVisibility(false, List.of(userId), userId, preSalesUserNames);
-        }
-        if (rule.isAllAccess()) {
-            return new OpportunityVisibility(true, List.of(), userId, preSalesUserNames);
-        }
-        return new OpportunityVisibility(false, normalizeVisibleOwnerUserIds(rule.getCreatorUserIds()), userId, preSalesUserNames);
+        DataVisibility visibility = crmDataVisibilityService.resolveVisibility(
+                userId,
+                tenantId,
+                CrmDataVisibilityService.RESOURCE_OPPORTUNITY);
+        return new OpportunityVisibility(
+                visibility.allDataAccess(),
+                normalizeVisibleOwnerUserIds(visibility.visibleOwnerUserIds()),
+                userId,
+                preSalesUserNames);
     }
 
     private List<String> resolveCurrentUserPreSalesNames(Long userId) {
diff --git a/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java b/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java
index e78fad97..6c3d1227 100644
--- a/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java
+++ b/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java
@@ -24,8 +24,11 @@ import com.unis.crm.dto.work.WorkSuggestedActionDTO;
 import com.unis.crm.mapper.OpportunityMapper;
 import com.unis.crm.mapper.ProfileMapper;
 import com.unis.crm.mapper.WorkMapper;
+import com.unis.crm.service.CrmDataVisibilityService;
+import com.unis.crm.service.CrmDataVisibilityService.DataVisibility;
 import com.unis.crm.service.ReportReminderService;
 import com.unis.crm.service.WorkService;
+import com.unisbase.service.SysPermissionService;
 import com.unisbase.spi.UnisBaseTenantProvider;
 import java.io.IOException;
 import java.math.BigDecimal;
@@ -49,9 +52,11 @@ import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Comparator;
+import java.util.LinkedHashSet;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import java.util.UUID;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.regex.Matcher;
@@ -59,6 +64,7 @@ import java.util.regex.Pattern;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.core.io.Resource;
 import org.springframework.core.io.UrlResource;
+import org.springframework.jdbc.core.JdbcTemplate;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.transaction.support.TransactionSynchronization;
@@ -83,6 +89,9 @@ public class WorkServiceImpl implements WorkService {
     private static final String REPORT_ATTACHMENT_METADATA_SUFFIX = "[[/WORK_REPORT_ATTACHMENTS]]";
     private static final Pattern REPORT_ATTACHMENT_METADATA_PATTERN = Pattern.compile("\\[\\[WORK_REPORT_ATTACHMENTS]](.*?)\\[\\[/WORK_REPORT_ATTACHMENTS]]", Pattern.DOTALL);
     private static final String ONLY_SEE_ROLE_CODE = "only_see";
+    private static final String SALES_ROLE_CODE = "xs_col";
+    private static final String DAILY_REPORT_NOTIFY_USERS_PERMISSION = "daily_report:notify_users";
+    private static final String DAILY_REPORT_ATTACHMENTS_PERMISSION = "daily_report:attachments";
     private static final String WORK_REPORT_FOLLOW_UP_TYPE = "工作日报";
     private static final String LEGACY_NEXT_PLAN_LABEL = "后续规划";
     private static final String OPPORTUNITY_NEXT_PLAN_LABEL = "下一步销售计划";
@@ -105,8 +114,11 @@ public class WorkServiceImpl implements WorkService {
     private final HttpClient httpClient;
     private final ObjectMapper objectMapper;
     private final UnisBaseTenantProvider tenantProvider;
+    private final CrmDataVisibilityService crmDataVisibilityService;
     private final ReportReminderService reportReminderService;
     private final WorkReportProperties workReportProperties;
+    private final SysPermissionService sysPermissionService;
+    private final JdbcTemplate jdbcTemplate;
     private final Path checkInPhotoDirectory;
     private final Path reportAttachmentDirectory;
     private final String tencentMapKey;
@@ -120,6 +132,9 @@ public class WorkServiceImpl implements WorkService {
             WorkReportProperties workReportProperties,
             ObjectMapper objectMapper,
             UnisBaseTenantProvider tenantProvider,
+            CrmDataVisibilityService crmDataVisibilityService,
+            SysPermissionService sysPermissionService,
+            JdbcTemplate jdbcTemplate,
             @Value("${unisbase.app.upload-path}") String uploadPath,
             @Value("${unisbase.app.tencent-map.key:}") String tencentMapKey) {
         this.workMapper = workMapper;
@@ -129,6 +144,9 @@ public class WorkServiceImpl implements WorkService {
         this.workReportProperties = workReportProperties == null ? new WorkReportProperties() : workReportProperties;
         this.objectMapper = objectMapper;
         this.tenantProvider = tenantProvider;
+        this.crmDataVisibilityService = crmDataVisibilityService;
+        this.sysPermissionService = sysPermissionService;
+        this.jdbcTemplate = jdbcTemplate;
         this.httpClient = HttpClient.newBuilder()
                 .connectTimeout(Duration.ofSeconds(8))
                 .build();
@@ -151,7 +169,7 @@ public class WorkServiceImpl implements WorkService {
     @Override
     public List<WorkReportToUserDTO> listReportMessageUsers(Long userId, String keyword) {
         requireUser(userId);
-        if (!isDailyReportNotifyUsersVisible(loadUserRoleCodes(userId))) {
+        if (!isDailyReportNotifyUsersVisible(userId, loadUserRoleCodes(userId))) {
             throw new BusinessException("当前角色不可使用日报通知用户功能");
         }
         return workMapper.selectReportMessageUsers(resolveCurrentTenantId(), normalizeOptionalText(keyword), 50);
@@ -163,8 +181,8 @@ public class WorkServiceImpl implements WorkService {
         List<String> roleCodes = loadUserRoleCodes(userId);
         return new WorkDailyReportRequirementDTO(
                 isDailyReportObjectSelectionRequired(roleCodes),
-                isDailyReportNotifyUsersVisible(roleCodes),
-                isDailyReportAttachmentsVisible(roleCodes));
+                isDailyReportNotifyUsersVisible(userId, roleCodes),
+                isDailyReportAttachmentsVisible(userId, roleCodes));
     }
 
     @Override
@@ -175,7 +193,7 @@ public class WorkServiceImpl implements WorkService {
         int offset = (safePage - 1) * safeSize;
         HistoryType historyType = mapHistoryType(type);
         int fetchLimit = offset + safeSize + 1;
-        List<WorkHistoryItemDTO> historyItems = loadHistoryItems(historyType, fetchLimit);
+        List<WorkHistoryItemDTO> historyItems = loadHistoryItems(userId, historyType, fetchLimit);
 
         boolean hasMore = historyItems.size() > offset + safeSize;
         if (offset >= historyItems.size()) {
@@ -195,6 +213,12 @@ public class WorkServiceImpl implements WorkService {
             throw new BusinessException("日报不存在");
         }
         WorkHistoryItemDTO item = workMapper.selectReportHistoryById(userId, reportId);
+        if (item == null) {
+            List<Long> visibleUserIds = resolveExtraVisibleUserIds(userId, CrmDataVisibilityService.RESOURCE_DAILY_REPORT);
+            if (!visibleUserIds.isEmpty()) {
+                item = workMapper.selectReportHistoryByIdForUserIds(reportId, visibleUserIds);
+            }
+        }
         if (item == null) {
             throw new BusinessException("日报不存在或无权查看");
         }
@@ -214,14 +238,29 @@ public class WorkServiceImpl implements WorkService {
         requireUser(userId);
         validateDateRange(startDate, endDate);
         String normalizedBizType = normalizeBizType(bizType);
-        List<WorkCheckInExportDTO> rows = workMapper.selectCheckInExportRows(
-                startDate,
-                endDate,
-                normalizeOptionalText(keyword),
-                normalizeOptionalText(deptName),
-                normalizedBizType,
-                normalizeOptionalText(status),
-                EXPORT_LIMIT);
+        List<Long> extraVisibleUserIds = resolveExtraVisibleUserIds(
+                userId,
+                CrmDataVisibilityService.RESOURCE_CHECKIN);
+        List<WorkCheckInExportDTO> rows = limitMergedWorkCheckInExportRows(
+                workMapper.selectCheckInExportRows(
+                        startDate,
+                        endDate,
+                        normalizeOptionalText(keyword),
+                        normalizeOptionalText(deptName),
+                        normalizedBizType,
+                        normalizeOptionalText(status),
+                        EXPORT_LIMIT),
+                extraVisibleUserIds.isEmpty()
+                        ? List.of()
+                        : workMapper.selectCheckInExportRowsByUserIds(
+                                extraVisibleUserIds,
+                                startDate,
+                                endDate,
+                                normalizeOptionalText(keyword),
+                                normalizeOptionalText(deptName),
+                                normalizedBizType,
+                                normalizeOptionalText(status),
+                                EXPORT_LIMIT));
         normalizeCheckInExportRows(rows);
         return rows;
     }
@@ -238,13 +277,27 @@ public class WorkServiceImpl implements WorkService {
         requireUser(userId);
         validateDateRange(startDate, endDate);
         String normalizedBizType = normalizeBizType(bizType);
-        List<WorkDailyReportExportDTO> rows = workMapper.selectDailyReportExportRows(
-                startDate,
-                endDate,
-                normalizeOptionalText(keyword),
-                normalizeOptionalText(deptName),
-                normalizeOptionalText(status),
-                EXPORT_LIMIT);
+        List<Long> extraVisibleUserIds = resolveExtraVisibleUserIds(
+                userId,
+                CrmDataVisibilityService.RESOURCE_DAILY_REPORT);
+        List<WorkDailyReportExportDTO> rows = limitMergedWorkDailyReportExportRows(
+                workMapper.selectDailyReportExportRows(
+                        startDate,
+                        endDate,
+                        normalizeOptionalText(keyword),
+                        normalizeOptionalText(deptName),
+                        normalizeOptionalText(status),
+                        EXPORT_LIMIT),
+                extraVisibleUserIds.isEmpty()
+                        ? List.of()
+                        : workMapper.selectDailyReportExportRowsByUserIds(
+                                extraVisibleUserIds,
+                                startDate,
+                                endDate,
+                                normalizeOptionalText(keyword),
+                                normalizeOptionalText(deptName),
+                                normalizeOptionalText(status),
+                                EXPORT_LIMIT));
         normalizeDailyReportExportRows(rows);
         if (normalizedBizType == null) {
             return rows;
@@ -294,8 +347,8 @@ public class WorkServiceImpl implements WorkService {
         normalizeDailyReportRequest(
                 request,
                 isDailyReportObjectSelectionRequired(roleCodes),
-                isDailyReportNotifyUsersVisible(roleCodes),
-                isDailyReportAttachmentsVisible(roleCodes));
+                isDailyReportNotifyUsersVisible(userId, roleCodes),
+                isDailyReportAttachmentsVisible(userId, roleCodes));
         request.setSourceType(normalizeOptionalText(request.getSourceType()));
         if (request.getSourceType() == null) {
             request.setSourceType("manual");
@@ -327,7 +380,8 @@ public class WorkServiceImpl implements WorkService {
                 reportId,
                 previousReport == null ? List.of() : previousReport.getLineItems(),
                 request,
-                currentReport);
+                currentReport,
+                isSalesRole(roleCodes));
         syncTomorrowPlanTodo(userId, reportId, request.getPlanItems());
         syncReportMessages(userId, reportId, resolveReportDate(currentReport), request);
         Long finalReportId = reportId;
@@ -427,7 +481,7 @@ public class WorkServiceImpl implements WorkService {
     public WorkReportAttachmentDTO uploadReportAttachment(Long userId, MultipartFile file) {
         requireUser(userId);
         List<String> roleCodes = ensureWorkWriteAllowed(userId, "当前角色仅可查看日报历史记录");
-        if (!isDailyReportAttachmentsVisible(roleCodes)) {
+        if (!isDailyReportAttachmentsVisible(userId, roleCodes)) {
             throw new BusinessException("当前角色不可使用日报附件功能");
         }
         if (file == null || file.isEmpty()) {
@@ -528,12 +582,24 @@ public class WorkServiceImpl implements WorkService {
         return dailyReport.isRequiredByDefault();
     }
 
-    private boolean isDailyReportNotifyUsersVisible(List<String> roleCodes) {
-        return isFeatureVisible(roleCodes, workReportProperties.getDailyReport().getNotifyUsers());
+    private boolean isDailyReportNotifyUsersVisible(Long userId, List<String> roleCodes) {
+        return isPermissionControlledFeatureVisible(
+                userId,
+                DAILY_REPORT_NOTIFY_USERS_PERMISSION,
+                roleCodes,
+                workReportProperties.getDailyReport().getNotifyUsers());
     }
 
-    private boolean isDailyReportAttachmentsVisible(List<String> roleCodes) {
-        return isFeatureVisible(roleCodes, workReportProperties.getDailyReport().getAttachments());
+    private boolean isDailyReportAttachmentsVisible(Long userId, List<String> roleCodes) {
+        return isPermissionControlledFeatureVisible(
+                userId,
+                DAILY_REPORT_ATTACHMENTS_PERMISSION,
+                roleCodes,
+                workReportProperties.getDailyReport().getAttachments());
+    }
+
+    private boolean isSalesRole(List<String> roleCodes) {
+        return roleCodes != null && roleCodes.stream().anyMatch(SALES_ROLE_CODE::equals);
     }
 
     private boolean isFeatureVisible(List<String> roleCodes, WorkReportProperties.FeatureVisibility visibility) {
@@ -549,6 +615,45 @@ public class WorkServiceImpl implements WorkService {
         return safeVisibility.isVisibleByDefault();
     }
 
+    private boolean isPermissionControlledFeatureVisible(
+            Long userId,
+            String permissionCode,
+            List<String> roleCodes,
+            WorkReportProperties.FeatureVisibility fallbackVisibility) {
+        if (!permissionCodeExists(permissionCode)) {
+            return isFeatureVisible(roleCodes, fallbackVisibility);
+        }
+        return loadPermissionCodes(userId).contains(permissionCode);
+    }
+
+    private Set<String> loadPermissionCodes(Long userId) {
+        try {
+            Long tenantId = resolveCurrentTenantId();
+            if (tenantId == null || sysPermissionService == null) {
+                return Set.of();
+            }
+            Set<String> permissionCodes = sysPermissionService.listPermissionCodesByUserId(userId, tenantId);
+            return permissionCodes == null ? Set.of() : permissionCodes;
+        } catch (Exception ignored) {
+            return Set.of();
+        }
+    }
+
+    private boolean permissionCodeExists(String permissionCode) {
+        try {
+            if (jdbcTemplate == null) {
+                return false;
+            }
+            Integer count = jdbcTemplate.queryForObject(
+                    "select count(1) from sys_permission where code = ? and coalesce(is_deleted, 0) = 0",
+                    Integer.class,
+                    permissionCode);
+            return count != null && count > 0;
+        } catch (Exception ignored) {
+            return false;
+        }
+    }
+
     private boolean matchesConfiguredRole(List<String> userRoleCodes, List<String> configuredRoleCodes) {
         if (userRoleCodes == null || userRoleCodes.isEmpty() || configuredRoleCodes == null || configuredRoleCodes.isEmpty()) {
             return false;
@@ -568,18 +673,132 @@ public class WorkServiceImpl implements WorkService {
         return normalized == null ? null : normalized.toLowerCase();
     }
 
-    private List<WorkHistoryItemDTO> loadHistoryItems(HistoryType historyType, int fetchLimit) {
+    private List<WorkHistoryItemDTO> loadHistoryItems(Long userId, HistoryType historyType, int fetchLimit) {
         List<WorkHistoryItemDTO> historyItems = new ArrayList<>();
         if (historyType == null || historyType == HistoryType.CHECK_IN) {
             historyItems.addAll(workMapper.selectCheckInHistory(fetchLimit));
+            List<Long> visibleUserIds = resolveExtraVisibleUserIds(userId, CrmDataVisibilityService.RESOURCE_CHECKIN);
+            if (!visibleUserIds.isEmpty()) {
+                historyItems.addAll(workMapper.selectCheckInHistoryByUserIds(visibleUserIds, fetchLimit));
+            }
         }
         if (historyType == null || historyType == HistoryType.REPORT) {
             historyItems.addAll(workMapper.selectReportHistory(fetchLimit));
+            List<Long> visibleUserIds = resolveExtraVisibleUserIds(userId, CrmDataVisibilityService.RESOURCE_DAILY_REPORT);
+            if (!visibleUserIds.isEmpty()) {
+                historyItems.addAll(workMapper.selectReportHistoryByUserIds(visibleUserIds, fetchLimit));
+            }
         }
+        historyItems = deduplicateHistoryItems(historyItems);
         historyItems.sort(buildHistoryComparator());
         return historyItems;
     }
 
+    private List<Long> resolveExtraVisibleUserIds(Long userId, String resourceType) {
+        Long currentUserId = userId;
+        if (currentUserId == null || currentUserId <= 0) {
+            return List.of();
+        }
+        Long tenantId = tenantProvider == null ? null : tenantProvider.getCurrentTenantId();
+        if (tenantId == null || tenantId <= 0 || crmDataVisibilityService == null) {
+            return List.of();
+        }
+        DataVisibility visibility = crmDataVisibilityService.resolveVisibility(currentUserId, tenantId, resourceType);
+        if (visibility.allDataAccess()) {
+            return List.of();
+        }
+        return normalizeVisibleUserIds(visibility.visibleOwnerUserIds(), currentUserId);
+    }
+
+    private List<Long> normalizeVisibleUserIds(List<Long> userIds, Long currentUserId) {
+        if (userIds == null || userIds.isEmpty()) {
+            return List.of();
+        }
+        Set<Long> normalized = new LinkedHashSet<>();
+        for (Long userId : userIds) {
+            if (userId != null && userId > 0 && !userId.equals(currentUserId)) {
+                normalized.add(userId);
+            }
+        }
+        return List.copyOf(normalized);
+    }
+
+    private List<WorkHistoryItemDTO> deduplicateHistoryItems(List<WorkHistoryItemDTO> items) {
+        Map<String, WorkHistoryItemDTO> byKey = new LinkedHashMap<>();
+        for (WorkHistoryItemDTO item : items) {
+            if (item != null) {
+                byKey.putIfAbsent(item.getType() + ":" + item.getId(), item);
+            }
+        }
+        return new ArrayList<>(byKey.values());
+    }
+
+    private List<WorkCheckInExportDTO> limitMergedWorkCheckInExportRows(
+            List<WorkCheckInExportDTO> scopedRows,
+            List<WorkCheckInExportDTO> extraRows) {
+        Map<String, WorkCheckInExportDTO> byKey = new LinkedHashMap<>();
+        for (WorkCheckInExportDTO row : scopedRows) {
+            if (row != null) {
+                byKey.putIfAbsent(buildCheckInExportRowKey(row), row);
+            }
+        }
+        for (WorkCheckInExportDTO row : extraRows) {
+            if (row != null) {
+                byKey.putIfAbsent(buildCheckInExportRowKey(row), row);
+            }
+        }
+        return byKey.values().stream()
+                .sorted(buildCheckInExportComparator())
+                .limit(EXPORT_LIMIT)
+                .toList();
+    }
+
+    private List<WorkDailyReportExportDTO> limitMergedWorkDailyReportExportRows(
+            List<WorkDailyReportExportDTO> scopedRows,
+            List<WorkDailyReportExportDTO> extraRows) {
+        Map<String, WorkDailyReportExportDTO> byKey = new LinkedHashMap<>();
+        for (WorkDailyReportExportDTO row : scopedRows) {
+            if (row != null) {
+                byKey.putIfAbsent(buildDailyReportExportRowKey(row), row);
+            }
+        }
+        for (WorkDailyReportExportDTO row : extraRows) {
+            if (row != null) {
+                byKey.putIfAbsent(buildDailyReportExportRowKey(row), row);
+            }
+        }
+        return byKey.values().stream()
+                .sorted(buildDailyReportExportComparator())
+                .limit(EXPORT_LIMIT)
+                .toList();
+    }
+
+    private Comparator<WorkCheckInExportDTO> buildCheckInExportComparator() {
+        return Comparator
+                .comparing(WorkCheckInExportDTO::getSortTime, Comparator.nullsLast(Comparator.reverseOrder()))
+                .thenComparing(WorkCheckInExportDTO::getId, Comparator.nullsLast(Comparator.reverseOrder()));
+    }
+
+    private Comparator<WorkDailyReportExportDTO> buildDailyReportExportComparator() {
+        return Comparator
+                .comparing(WorkDailyReportExportDTO::getSortTime, Comparator.nullsLast(Comparator.reverseOrder()))
+                .thenComparing(WorkDailyReportExportDTO::getId, Comparator.nullsLast(Comparator.reverseOrder()));
+    }
+
+    private String buildCheckInExportRowKey(WorkCheckInExportDTO row) {
+        if (row.getId() != null) {
+            return "checkin:" + row.getId();
+        }
+        return "checkin:fallback:" + row.getCheckinDate() + "|" + row.getCheckinTime() + "|" + row.getUserName();
+    }
+
+    private String buildDailyReportExportRowKey(WorkDailyReportExportDTO row) {
+        if (row.getId() != null) {
+            return "daily-report:" + row.getId();
+        }
+        return "daily-report:fallback:" + row.getReportDate() + "|" + row.getSubmitTime() + "|" + row.getUserName();
+    }
+
     private HistoryType mapHistoryType(String type) {
         String normalizedType = normalizeOptionalText(type);
         if (normalizedType == null) {
@@ -1456,7 +1675,8 @@ public class WorkServiceImpl implements WorkService {
             Long reportId,
             List<WorkReportLineItemDTO> previousLineItems,
             CreateWorkDailyReportRequest request,
-            WorkDailyReportDTO currentReport) {
+            WorkDailyReportDTO currentReport,
+            boolean syncOpportunitySnapshot) {
         LocalDate reportDate = resolveReportDate(currentReport);
 
         for (WorkReportLineItemDTO item : previousLineItems) {
@@ -1498,11 +1718,14 @@ public class WorkServiceImpl implements WorkService {
                         WORK_REPORT_FOLLOW_UP_TYPE,
                         appendReportAttachmentMetadata(item.getContent(), item.getAttachments()),
                         resolveOpportunityNextAction(item));
-                workMapper.updateOpportunitySnapshot(
-                        item.getBizId(),
-                        normalizeSnapshotText(item.getLatestProgress()),
-                        normalizeSnapshotText(item.getNextPlan()),
-                        item.getStage());
+                if (syncOpportunitySnapshot) {
+                    workMapper.updateOpportunitySnapshot(
+                            item.getBizId(),
+                            normalizeSnapshotText(item.getLatestProgress()),
+                            normalizeSnapshotText(item.getNextPlan()),
+                            item.getStage(),
+                            userId);
+                }
                 continue;
             }
             workMapper.deleteDailyReportExpansionFollowUps(
diff --git a/backend/src/main/resources/application-prod.yml b/backend/src/main/resources/application-prod.yml
index af80c8b3..337cad8d 100644
--- a/backend/src/main/resources/application-prod.yml
+++ b/backend/src/main/resources/application-prod.yml
@@ -89,17 +89,3 @@ unisbase:
         required-role-codes: [crm_xs]
         # 允许不使用 #，可直接填写文字日报的角色编码。
         optional-role-codes: []
-        notify-users:
-          # 未命中下方任何角色配置时，是否默认显示“选择通知用户”按钮。
-          visible-by-default: true
-          # 明确显示“选择通知用户”按钮的角色编码。
-          visible-role-codes: []
-          # 明确隐藏“选择通知用户”按钮的角色编码；命中后优先级高于 visible-role-codes。
-          hidden-role-codes: []
-        attachments:
-          # 未命中下方任何角色配置时，是否默认显示日报附件功能。
-          visible-by-default: true
-          # 明确显示日报附件功能的角色编码。
-          visible-role-codes: []
-          # 明确隐藏日报附件功能的角色编码；命中后优先级高于 visible-role-codes。
-          hidden-role-codes: [crm_xs]
diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml
index bc7f2817..a7c97ebf 100644
--- a/backend/src/main/resources/application.yml
+++ b/backend/src/main/resources/application.yml
@@ -98,17 +98,3 @@ unisbase:
         required-role-codes: [crm_xs]
         # 允许不使用 #，可直接填写文字日报的角色编码。
         optional-role-codes: []
-        notify-users:
-          # 未命中下方任何角色配置时，是否默认显示“选择通知用户”按钮。
-          visible-by-default: true
-          # 明确显示“选择通知用户”按钮的角色编码。
-          visible-role-codes: []
-          # 明确隐藏“选择通知用户”按钮的角色编码；命中后优先级高于 visible-role-codes。
-          hidden-role-codes: []
-        attachments:
-          # 未命中下方任何角色配置时，是否默认显示日报附件功能。
-          visible-by-default: true
-          # 明确显示日报附件功能的角色编码。
-          visible-role-codes: []
-          # 明确隐藏日报附件功能的角色编码；命中后优先级高于 visible-role-codes。
-          hidden-role-codes: [crm_xs]
diff --git a/backend/src/main/resources/mapper/expansion/ExpansionMapper.xml b/backend/src/main/resources/mapper/expansion/ExpansionMapper.xml
index 7c6935ae..951c391e 100644
--- a/backend/src/main/resources/mapper/expansion/ExpansionMapper.xml
+++ b/backend/src/main/resources/mapper/expansion/ExpansionMapper.xml
@@ -4,6 +4,231 @@
         "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
 <mapper namespace="com.unis.crm.mapper.ExpansionMapper">
 
+    <sql id="salesExpansionSelectColumns">
+            s.id,
+            s.owner_user_id as ownerUserId,
+            coalesce(nullif(u.display_name, ''), nullif(u.username, ''), '无') as owner,
+            '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,
+            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
+                when 'high' then '高'
+                when 'medium' then '中'
+                when 'low' then '低'
+                else '无'
+            end as intent,
+            s.stage as stageCode,
+            case s.stage
+                when 'initial_contact' then '初步沟通'
+                when 'solution_discussion' then '方案交流'
+                when 'bidding' then '招投标'
+                when 'business_negotiation' then '商务谈判'
+                when 'won' then '已成交'
+                when 'lost' then '已放弃'
+                else coalesce(s.stage, '无')
+            end as stage,
+            s.has_desktop_exp as hasExp,
+            s.in_progress as inProgress,
+            (s.employment_status = 'active') as active,
+            s.employment_status as employmentStatus,
+            coalesce(to_char(s.expected_join_date, 'YYYY-MM-DD'), '无') as expectedJoinDate,
+            coalesce(to_char(s.created_at, 'YYYY-MM-DD HH24:MI'), '无') as createdAt,
+            coalesce(
+                to_char(
+                    case
+                        when sales_followup.latest_followup_time is null then s.updated_at
+                        else greatest(s.updated_at, sales_followup.latest_followup_time)
+                    end,
+                    'YYYY-MM-DD HH24:MI'
+                ),
+                '无'
+            ) as updatedAt,
+            coalesce(s.remark, '无') as notes
+    </sql>
+
+    <sql id="salesExpansionJoins">
+        left join (
+            select
+                f.biz_id,
+                max(f.followup_time) as latest_followup_time
+            from crm_expansion_followup f
+            where f.biz_type = 'sales'
+            group by f.biz_id
+        ) sales_followup on sales_followup.biz_id = s.id
+        left join sys_user u
+          on u.user_id = s.owner_user_id
+         and u.is_deleted = 0
+        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
+    </sql>
+
+    <sql id="salesExpansionKeywordFilter">
+        <if test="keyword != null and keyword != ''">
+          and (
+            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>
+    </sql>
+
+    <sql id="channelExpansionSelectColumns">
+            c.id,
+            c.owner_user_id as ownerUserId,
+            coalesce(nullif(u.display_name, ''), nullif(u.username, ''), '无') as owner,
+            'channel' as type,
+            coalesce(c.channel_code, '') as channelCode,
+            c.channel_name as name,
+            coalesce(c.province, '') as provinceCode,
+            coalesce(province_area.name, nullif(c.province, ''), '无') as province,
+            coalesce(c.city, '') as cityCode,
+            coalesce(city_area.name, nullif(c.city, ''), '无') as city,
+            coalesce(c.office_address, '无') as officeAddress,
+            coalesce(c.channel_industry, c.industry, '') as channelIndustryCode,
+            coalesce(c.channel_industry, c.industry, '无') as channelIndustry,
+            coalesce(c.certification_level, '无') as certificationLevel,
+            coalesce(trim(to_char(c.annual_revenue, 'FM999999990.##')), '') as annualRevenue,
+            case
+                when c.annual_revenue is null then '无'
+                else trim(to_char(c.annual_revenue, 'FM999999990.##')) || '万元'
+            end as revenue,
+            coalesce(c.staff_size, 0) as size,
+            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(c.channel_attribute, '') as channelAttributeCode,
+            coalesce(channel_attribute_dict.item_label, c.channel_attribute, '无') as channelAttribute,
+            coalesce(c.internal_attribute, '') as internalAttributeCode,
+            coalesce(internal_attribute_dict.item_label, c.internal_attribute, '无') as internalAttribute,
+            c.stage as stageCode,
+            case c.stage
+                when 'initial_contact' then '初步接触'
+                when 'solution_discussion' then '方案交流'
+                when 'bidding' then '招投标'
+                when 'business_negotiation' then '合作洽谈'
+                when 'won' then '已合作'
+                when 'lost' then '已终止'
+                else coalesce(c.stage, '无')
+            end as stage,
+            c.landed_flag as landed,
+            coalesce(to_char(c.expected_sign_date, 'YYYY-MM-DD'), '无') as expectedSignDate,
+            coalesce(to_char(c.created_at, 'YYYY-MM-DD HH24:MI'), '无') as createdAt,
+            coalesce(
+                to_char(
+                    case
+                        when channel_followup.latest_followup_time is null then c.updated_at
+                        else greatest(c.updated_at, channel_followup.latest_followup_time)
+                    end,
+                    'YYYY-MM-DD HH24:MI'
+                ),
+                '无'
+            ) as updatedAt,
+            coalesce(c.remark, '无') as notes
+    </sql>
+
+    <sql id="channelExpansionJoins">
+        left join (
+            select
+                f.biz_id,
+                max(f.followup_time) as latest_followup_time
+            from crm_expansion_followup f
+            where f.biz_type = 'channel'
+            group by f.biz_id
+        ) channel_followup on channel_followup.biz_id = c.id
+        left join sys_user u
+          on u.user_id = c.owner_user_id
+         and u.is_deleted = 0
+        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 cnarea province_area
+          on province_area.level = 1
+         and (province_area.name = c.province or province_area.area_code = c.province)
+        left join cnarea city_area
+          on city_area.level = 2
+         and (city_area.name = c.city or city_area.area_code = c.city)
+        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
+    </sql>
+
+    <sql id="channelExpansionKeywordFilter">
+        <if test="keyword != null and keyword != ''">
+          and (
+            c.channel_name 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 coalesce(c.city, '') ilike concat('%', #{keyword}, '%')
+            or coalesce(c.certification_level, '') ilike concat('%', #{keyword}, '%')
+            or coalesce(province_area.name, '') ilike concat('%', #{keyword}, '%')
+            or coalesce(city_area.name, '') ilike concat('%', #{keyword}, '%')
+            or exists (
+              select 1
+              from sys_dict_item industry_dict
+              where industry_dict.type_code = 'tz_sshy'
+                and industry_dict.status = 1
+                and coalesce(industry_dict.is_deleted, 0) = 0
+                and industry_dict.item_value = any(string_to_array(coalesce(c.channel_industry, c.industry, ''), ','))
+                and industry_dict.item_label 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>
+    </sql>
+
     <select id="selectDictItems" resultType="com.unis.crm.dto.expansion.DictOptionDTO">
         select
             item_label as label,
@@ -136,6 +361,19 @@
         order by s.updated_at desc, s.id desc
     </select>
 
+    <select id="selectSalesExpansionsByOwnerUserIds" resultType="com.unis.crm.dto.expansion.SalesExpansionItemDTO">
+        select
+        <include refid="salesExpansionSelectColumns"/>
+        from crm_sales_expansion s
+        <include refid="salesExpansionJoins"/>
+        where s.owner_user_id in
+          <foreach collection="ownerUserIds" item="ownerUserId" open="(" separator="," close=")">
+            #{ownerUserId}
+          </foreach>
+        <include refid="salesExpansionKeywordFilter"/>
+        order by s.updated_at desc, s.id desc
+    </select>
+
     <select id="selectChannelExpansions" resultType="com.unis.crm.dto.expansion.ChannelExpansionItemDTO">
         select
             c.id,
@@ -271,6 +509,19 @@
         order by c.updated_at desc, c.id desc
     </select>
 
+    <select id="selectChannelExpansionsByOwnerUserIds" resultType="com.unis.crm.dto.expansion.ChannelExpansionItemDTO">
+        select
+        <include refid="channelExpansionSelectColumns"/>
+        from crm_channel_expansion c
+        <include refid="channelExpansionJoins"/>
+        where c.owner_user_id in
+          <foreach collection="ownerUserIds" item="ownerUserId" open="(" separator="," close=")">
+            #{ownerUserId}
+          </foreach>
+        <include refid="channelExpansionKeywordFilter"/>
+        order by c.updated_at desc, c.id desc
+    </select>
+
     <select id="selectSalesExpansionsForTenant" resultType="com.unis.crm.dto.expansion.SalesExpansionItemDTO">
         select
             s.id,
diff --git a/backend/src/main/resources/mapper/opportunity/OpportunityMapper.xml b/backend/src/main/resources/mapper/opportunity/OpportunityMapper.xml
index c0e2845a..e94537be 100644
--- a/backend/src/main/resources/mapper/opportunity/OpportunityMapper.xml
+++ b/backend/src/main/resources/mapper/opportunity/OpportunityMapper.xml
@@ -377,10 +377,21 @@
             coalesce(f.followup_type, '无') as type,
             coalesce(f.content, '无') as content,
             coalesce(f.next_action, '') as nextAction,
-            coalesce(u.display_name, '无') as user
+            coalesce(u.display_name, '无') as user,
+            coalesce(role_info.role_codes, '') as roleCodes
         from crm_opportunity_followup f
         join crm_opportunity o on o.id = f.opportunity_id
         left join sys_user u on u.user_id = f.followup_user_id
+        left join (
+            select
+                ur.user_id,
+                string_agg(distinct r.role_code, ',' order by r.role_code) as role_codes
+            from sys_user_role ur
+            join sys_role r on r.role_id = ur.role_id
+            where coalesce(ur.is_deleted, 0) = 0
+              and coalesce(r.is_deleted, 0) = 0
+            group by ur.user_id
+        ) role_info on role_info.user_id = f.followup_user_id
         where f.opportunity_id in
           <foreach collection="opportunityIds" item="id" open="(" separator="," close=")">
               #{id}
@@ -448,6 +459,7 @@
             competitor_name,
             latest_progress,
             next_plan,
+            updated_by,
             pushed_to_oms,
             oms_push_time,
             description,
@@ -473,6 +485,7 @@
             #{request.competitorName},
             #{request.latestProgress},
             #{request.nextPlan},
+            #{userId},
             false,
             null,
             #{request.description},
@@ -509,6 +522,7 @@
     <update id="updateOpportunityCode">
         update crm_opportunity o
         set opportunity_code = #{opportunityCode},
+            updated_by = #{userId},
             updated_at = now()
         where o.id = #{opportunityId}
     </update>
@@ -575,6 +589,7 @@
         update crm_opportunity o
         set pre_sales_id = #{preSalesId},
             pre_sales_name = #{preSalesName},
+            updated_by = #{userId},
             updated_at = now()
         where o.id = #{opportunityId}
     </update>
@@ -597,6 +612,7 @@
             competitor_name = #{request.competitorName},
             latest_progress = #{request.latestProgress},
             next_plan = #{request.nextPlan},
+            updated_by = #{userId},
             description = #{request.description},
             status = case
                 when #{request.stage} = 'won' then 'won'
@@ -711,6 +727,7 @@
         set pushed_to_oms = true,
             oms_push_time = now(),
             opportunity_code = #{opportunityCode},
+            updated_by = #{userId},
             updated_at = now()
         where o.id = #{opportunityId}
     </update>
diff --git a/backend/src/main/resources/mapper/work/WorkMapper.xml b/backend/src/main/resources/mapper/work/WorkMapper.xml
index 15606bc3..965823b4 100644
--- a/backend/src/main/resources/mapper/work/WorkMapper.xml
+++ b/backend/src/main/resources/mapper/work/WorkMapper.xml
@@ -4,6 +4,58 @@
         "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
 <mapper namespace="com.unis.crm.mapper.WorkMapper">
 
+    <sql id="checkInExportFilters">
+        <if test="startDate != null">
+          and c.checkin_date &gt;= #{startDate}
+        </if>
+        <if test="endDate != null">
+          and c.checkin_date &lt;= #{endDate}
+        </if>
+        <if test="keyword != null and keyword != ''">
+          and (
+            coalesce(c.user_name, '') ilike concat('%', #{keyword}, '%')
+            or coalesce(u.display_name, '') ilike concat('%', #{keyword}, '%')
+            or coalesce(u.username, '') ilike concat('%', #{keyword}, '%')
+            or coalesce(c.biz_name, '') ilike concat('%', #{keyword}, '%')
+            or coalesce(c.location_text, '') ilike concat('%', #{keyword}, '%')
+            or coalesce(c.remark, '') ilike concat('%', #{keyword}, '%')
+          )
+        </if>
+        <if test="deptName != null and deptName != ''">
+          and coalesce(nullif(btrim(c.dept_name), ''), nullif(btrim(org_info.org_names), ''), '') ilike concat('%', #{deptName}, '%')
+        </if>
+        <if test="bizType != null and bizType != ''">
+          and c.biz_type = #{bizType}
+        </if>
+        <if test="status != null and status != ''">
+          and coalesce(c.status, 'normal') = #{status}
+        </if>
+    </sql>
+
+    <sql id="dailyReportExportFilters">
+        <if test="startDate != null">
+          and r.report_date &gt;= #{startDate}
+        </if>
+        <if test="endDate != null">
+          and r.report_date &lt;= #{endDate}
+        </if>
+        <if test="keyword != null and keyword != ''">
+          and (
+            coalesce(u.display_name, '') ilike concat('%', #{keyword}, '%')
+            or coalesce(u.username, '') ilike concat('%', #{keyword}, '%')
+            or coalesce(r.work_content, '') ilike concat('%', #{keyword}, '%')
+            or coalesce(r.tomorrow_plan, '') ilike concat('%', #{keyword}, '%')
+            or coalesce(rc.comment_content, '') ilike concat('%', #{keyword}, '%')
+          )
+        </if>
+        <if test="deptName != null and deptName != ''">
+          and coalesce(nullif(btrim(org_info.org_names), ''), '') ilike concat('%', #{deptName}, '%')
+        </if>
+        <if test="status != null and status != ''">
+          and coalesce(r.status, 'submitted') = #{status}
+        </if>
+    </sql>
+
     <select id="selectTodayCheckIn" resultType="com.unis.crm.dto.work.WorkCheckInDTO">
         select
             id,
@@ -228,6 +280,42 @@
         limit #{limit}
     </select>
 
+    <select id="selectCheckInHistoryByUserIds" resultType="com.unis.crm.dto.work.WorkHistoryItemDTO">
+        select
+            c.id,
+            '外勤打卡' as type,
+            to_char(c.checkin_date, 'YYYY-MM-DD') as date,
+            to_char(c.checkin_time, 'HH24:MI') as time,
+            case
+                when c.biz_name is not null and btrim(c.biz_name) &lt;&gt; '' then '关联对象：' || c.biz_name || E'\n'
+                else ''
+            end ||
+            case
+                when c.user_name is not null and btrim(c.user_name) &lt;&gt; '' then '打卡人：' || c.user_name || E'\n'
+                else ''
+            end ||
+            coalesce(c.location_text, '') ||
+            case
+                when c.remark is not null and btrim(c.remark) &lt;&gt; '' then E'\n备注：' || c.remark
+                else ''
+            end as content,
+            case coalesce(c.status, 'normal')
+                when 'normal' then '正常'
+                when 'updated' then '已更新'
+                else coalesce(c.status, '正常')
+            end as status,
+            null::integer as score,
+            null::text as comment,
+            coalesce(c.checkin_date::timestamp + c.checkin_time::time, c.created_at) as sort_time
+        from work_checkin c
+        where c.user_id in
+          <foreach collection="visibleUserIds" item="userId" open="(" separator="," close=")">
+            #{userId}
+          </foreach>
+        order by sort_time desc nulls last, id desc
+        limit #{limit}
+    </select>
+
     <select id="selectReportHistory" resultType="com.unis.crm.dto.work.WorkHistoryItemDTO">
         select
             r.id,
@@ -270,6 +358,52 @@
         limit #{limit}
     </select>
 
+    <select id="selectReportHistoryByUserIds" resultType="com.unis.crm.dto.work.WorkHistoryItemDTO">
+        select
+            r.id,
+            '日报' as type,
+            to_char(r.report_date, 'YYYY-MM-DD') as date,
+            to_char(r.submit_time, 'HH24:MI') as time,
+            case
+                when coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), '')) is not null
+                    then '提交人：' || coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), '')) || E'\n'
+                else ''
+            end ||
+            coalesce(r.work_content, '') ||
+            case
+                when r.tomorrow_plan is not null and btrim(r.tomorrow_plan) &lt;&gt; '' then E'\n明日计划：' || r.tomorrow_plan
+                else ''
+            end as content,
+            case coalesce(rc.comment_content, '')
+                when '' then
+                    case coalesce(r.status, 'submitted')
+                        when 'submitted' then '已提交'
+                        when 'reviewed' then '已点评'
+                        else coalesce(r.status, '已提交')
+                    end
+                else '已点评'
+            end as status,
+            rc.score,
+            rc.comment_content as comment,
+            coalesce(r.report_date::timestamp + r.submit_time::time, r.created_at) as sort_time
+        from work_daily_report r
+        left join sys_user u on u.user_id = r.user_id and u.is_deleted = 0
+        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 in
+          <foreach collection="visibleUserIds" item="userId" open="(" separator="," close=")">
+            #{userId}
+          </foreach>
+        order by sort_time desc nulls last, id desc
+        limit #{limit}
+    </select>
+
     <select id="selectReportHistoryById" resultType="com.unis.crm.dto.work.WorkHistoryItemDTO">
         select
             r.id,
@@ -321,8 +455,55 @@
         limit 1
     </select>
 
+    <select id="selectReportHistoryByIdForUserIds" resultType="com.unis.crm.dto.work.WorkHistoryItemDTO">
+        select
+            r.id,
+            '日报' as type,
+            to_char(r.report_date, 'YYYY-MM-DD') as date,
+            to_char(r.submit_time, 'HH24:MI') as time,
+            case
+                when coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), '')) is not null
+                    then '提交人：' || coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), '')) || E'\n'
+                else ''
+            end ||
+            coalesce(r.work_content, '') ||
+            case
+                when r.tomorrow_plan is not null and btrim(r.tomorrow_plan) &lt;&gt; '' then E'\n明日计划：' || r.tomorrow_plan
+                else ''
+            end as content,
+            case coalesce(rc.comment_content, '')
+                when '' then
+                    case coalesce(r.status, 'submitted')
+                        when 'submitted' then '已提交'
+                        when 'reviewed' then '已点评'
+                        else coalesce(r.status, '已提交')
+                    end
+                else '已点评'
+            end as status,
+            rc.score,
+            rc.comment_content as comment,
+            coalesce(r.report_date::timestamp + r.submit_time::time, r.created_at) as sort_time
+        from work_daily_report r
+        left join sys_user u on u.user_id = r.user_id and u.is_deleted = 0
+        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.id = #{reportId}
+          and r.user_id in
+          <foreach collection="visibleUserIds" item="userId" open="(" separator="," close=")">
+            #{userId}
+          </foreach>
+        limit 1
+    </select>
+
     <select id="selectCheckInExportRows" resultType="com.unis.crm.dto.work.WorkCheckInExportDTO">
         select
+            c.id,
             to_char(c.checkin_date, 'YYYY-MM-DD') as checkinDate,
             to_char(c.checkin_time, 'HH24:MI') as checkinTime,
             coalesce(nullif(btrim(c.user_name), ''), nullif(btrim(u.display_name), ''), nullif(btrim(u.username), ''), '') as userName,
@@ -335,7 +516,8 @@
             coalesce(c.remark, '') as remark,
             coalesce(c.status, 'normal') as status,
             to_char(c.created_at, 'YYYY-MM-DD HH24:MI') as createdAt,
-            to_char(c.updated_at, 'YYYY-MM-DD HH24:MI') as updatedAt
+            to_char(c.updated_at, 'YYYY-MM-DD HH24:MI') as updatedAt,
+            coalesce(c.checkin_date::timestamp + c.checkin_time::time, c.created_at) as sortTime
         from work_checkin c
         left join sys_user u on u.user_id = c.user_id and u.is_deleted = 0
         left join lateral (
@@ -347,37 +529,50 @@
               and o.is_deleted = 0
         ) org_info on true
         where 1 = 1
-        <if test="startDate != null">
-          and c.checkin_date &gt;= #{startDate}
-        </if>
-        <if test="endDate != null">
-          and c.checkin_date &lt;= #{endDate}
-        </if>
-        <if test="keyword != null and keyword != ''">
-          and (
-            coalesce(c.user_name, '') ilike concat('%', #{keyword}, '%')
-            or coalesce(u.display_name, '') ilike concat('%', #{keyword}, '%')
-            or coalesce(u.username, '') ilike concat('%', #{keyword}, '%')
-            or coalesce(c.biz_name, '') ilike concat('%', #{keyword}, '%')
-            or coalesce(c.location_text, '') ilike concat('%', #{keyword}, '%')
-            or coalesce(c.remark, '') ilike concat('%', #{keyword}, '%')
-          )
-        </if>
-        <if test="deptName != null and deptName != ''">
-          and coalesce(nullif(btrim(c.dept_name), ''), nullif(btrim(org_info.org_names), ''), '') ilike concat('%', #{deptName}, '%')
-        </if>
-        <if test="bizType != null and bizType != ''">
-          and c.biz_type = #{bizType}
-        </if>
-        <if test="status != null and status != ''">
-          and coalesce(c.status, 'normal') = #{status}
-        </if>
+        <include refid="checkInExportFilters"/>
+        order by coalesce(c.checkin_date::timestamp + c.checkin_time::time, c.created_at) desc nulls last, c.id desc
+        limit #{limit}
+    </select>
+
+    <select id="selectCheckInExportRowsByUserIds" resultType="com.unis.crm.dto.work.WorkCheckInExportDTO">
+        select
+            c.id,
+            to_char(c.checkin_date, 'YYYY-MM-DD') as checkinDate,
+            to_char(c.checkin_time, 'HH24:MI') as checkinTime,
+            coalesce(nullif(btrim(c.user_name), ''), nullif(btrim(u.display_name), ''), nullif(btrim(u.username), ''), '') as userName,
+            coalesce(nullif(btrim(c.dept_name), ''), nullif(btrim(org_info.org_names), ''), '') as deptName,
+            coalesce(c.biz_type, '') as bizType,
+            coalesce(c.biz_name, '') as bizName,
+            coalesce(c.location_text, '') as locationText,
+            c.longitude,
+            c.latitude,
+            coalesce(c.remark, '') as remark,
+            coalesce(c.status, 'normal') as status,
+            to_char(c.created_at, 'YYYY-MM-DD HH24:MI') as createdAt,
+            to_char(c.updated_at, 'YYYY-MM-DD HH24:MI') as updatedAt,
+            coalesce(c.checkin_date::timestamp + c.checkin_time::time, c.created_at) as sortTime
+        from work_checkin c
+        left join sys_user u on u.user_id = c.user_id and u.is_deleted = 0
+        left join lateral (
+            select string_agg(distinct o.org_name, '、' order by o.org_name) as org_names
+            from sys_tenant_user tu
+            join sys_org o on o.id = tu.org_id
+            where tu.user_id = c.user_id
+              and tu.is_deleted = 0
+              and o.is_deleted = 0
+        ) org_info on true
+        where c.user_id in
+          <foreach collection="visibleUserIds" item="userId" open="(" separator="," close=")">
+            #{userId}
+          </foreach>
+        <include refid="checkInExportFilters"/>
         order by coalesce(c.checkin_date::timestamp + c.checkin_time::time, c.created_at) desc nulls last, c.id desc
         limit #{limit}
     </select>
 
     <select id="selectDailyReportExportRows" resultType="com.unis.crm.dto.work.WorkDailyReportExportDTO">
         select
+            r.id,
             to_char(r.report_date, 'YYYY-MM-DD') as reportDate,
             to_char(r.submit_time, 'YYYY-MM-DD HH24:MI') as submitTime,
             coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), ''), '') as userName,
@@ -391,7 +586,8 @@
             coalesce(nullif(btrim(reviewer.display_name), ''), nullif(btrim(reviewer.username), ''), '') as reviewerName,
             to_char(rc.reviewed_at, 'YYYY-MM-DD HH24:MI') as reviewedAt,
             to_char(r.created_at, 'YYYY-MM-DD HH24:MI') as createdAt,
-            to_char(r.updated_at, 'YYYY-MM-DD HH24:MI') as updatedAt
+            to_char(r.updated_at, 'YYYY-MM-DD HH24:MI') as updatedAt,
+            coalesce(r.report_date::timestamp + r.submit_time::time, r.created_at) as sortTime
         from work_daily_report r
         left join sys_user u on u.user_id = r.user_id and u.is_deleted = 0
         left join lateral (
@@ -414,27 +610,55 @@
         ) rc on rc.report_id = r.id
         left join sys_user reviewer on reviewer.user_id = rc.reviewer_user_id and reviewer.is_deleted = 0
         where 1 = 1
-        <if test="startDate != null">
-          and r.report_date &gt;= #{startDate}
-        </if>
-        <if test="endDate != null">
-          and r.report_date &lt;= #{endDate}
-        </if>
-        <if test="keyword != null and keyword != ''">
-          and (
-            coalesce(u.display_name, '') ilike concat('%', #{keyword}, '%')
-            or coalesce(u.username, '') ilike concat('%', #{keyword}, '%')
-            or coalesce(r.work_content, '') ilike concat('%', #{keyword}, '%')
-            or coalesce(r.tomorrow_plan, '') ilike concat('%', #{keyword}, '%')
-            or coalesce(rc.comment_content, '') ilike concat('%', #{keyword}, '%')
-          )
-        </if>
-        <if test="deptName != null and deptName != ''">
-          and coalesce(nullif(btrim(org_info.org_names), ''), '') ilike concat('%', #{deptName}, '%')
-        </if>
-        <if test="status != null and status != ''">
-          and coalesce(r.status, 'submitted') = #{status}
-        </if>
+        <include refid="dailyReportExportFilters"/>
+        order by coalesce(r.report_date::timestamp + r.submit_time::time, r.created_at) desc nulls last, r.id desc
+        limit #{limit}
+    </select>
+
+    <select id="selectDailyReportExportRowsByUserIds" resultType="com.unis.crm.dto.work.WorkDailyReportExportDTO">
+        select
+            r.id,
+            to_char(r.report_date, 'YYYY-MM-DD') as reportDate,
+            to_char(r.submit_time, 'YYYY-MM-DD HH24:MI') as submitTime,
+            coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), ''), '') as userName,
+            coalesce(nullif(btrim(org_info.org_names), ''), '') as deptName,
+            coalesce(r.source_type, 'manual') as sourceType,
+            coalesce(r.status, 'submitted') as status,
+            coalesce(r.work_content, '') as workContent,
+            coalesce(r.tomorrow_plan, '') as tomorrowPlan,
+            rc.score,
+            rc.comment_content as comment,
+            coalesce(nullif(btrim(reviewer.display_name), ''), nullif(btrim(reviewer.username), ''), '') as reviewerName,
+            to_char(rc.reviewed_at, 'YYYY-MM-DD HH24:MI') as reviewedAt,
+            to_char(r.created_at, 'YYYY-MM-DD HH24:MI') as createdAt,
+            to_char(r.updated_at, 'YYYY-MM-DD HH24:MI') as updatedAt,
+            coalesce(r.report_date::timestamp + r.submit_time::time, r.created_at) as sortTime
+        from work_daily_report r
+        left join sys_user u on u.user_id = r.user_id and u.is_deleted = 0
+        left join lateral (
+            select string_agg(distinct o.org_name, '、' order by o.org_name) as org_names
+            from sys_tenant_user tu
+            join sys_org o on o.id = tu.org_id
+            where tu.user_id = r.user_id
+              and tu.is_deleted = 0
+              and o.is_deleted = 0
+        ) org_info on true
+        left join (
+            select distinct on (report_id)
+                report_id,
+                reviewer_user_id,
+                score,
+                comment_content,
+                reviewed_at
+            from work_daily_report_comment
+            order by report_id, reviewed_at desc nulls last, id desc
+        ) rc on rc.report_id = r.id
+        left join sys_user reviewer on reviewer.user_id = rc.reviewer_user_id and reviewer.is_deleted = 0
+        where r.user_id in
+          <foreach collection="visibleUserIds" item="userId" open="(" separator="," close=")">
+            #{userId}
+          </foreach>
+        <include refid="dailyReportExportFilters"/>
         order by coalesce(r.report_date::timestamp + r.submit_time::time, r.created_at) desc nulls last, r.id desc
         limit #{limit}
     </select>
@@ -752,6 +976,7 @@
         set latest_progress = #{latestProgress},
             next_plan = #{nextPlan},
             stage = coalesce(#{stage}, stage),
+            updated_by = #{updatedBy},
             updated_at = now()
         where id = #{opportunityId}
     </update>
diff --git a/backend/src/test/java/com/unis/crm/service/UserDataScopeAdminServiceTest.java b/backend/src/test/java/com/unis/crm/service/UserDataScopeAdminServiceTest.java
new file mode 100644
index 00000000..5e92d366
--- /dev/null
+++ b/backend/src/test/java/com/unis/crm/service/UserDataScopeAdminServiceTest.java
@@ -0,0 +1,200 @@
+package com.unis.crm.service;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.contains;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.unis.crm.common.BusinessException;
+import com.unis.crm.dto.datascope.UserDataScopeAssignmentDTO;
+import com.unisbase.security.PermissionService;
+import com.unisbase.security.SpringSecurityTenantProvider;
+import com.unisbase.security.SpringSecurityUserProvider;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.jdbc.core.JdbcTemplate;
+
+@ExtendWith(MockitoExtension.class)
+class UserDataScopeAdminServiceTest {
+
+    @Mock
+    private JdbcTemplate jdbcTemplate;
+
+    @Mock
+    private PermissionService permissionService;
+
+    @Mock
+    private SpringSecurityTenantProvider tenantProvider;
+
+    @Mock
+    private SpringSecurityUserProvider userProvider;
+
+    @InjectMocks
+    private UserDataScopeAdminService userDataScopeAdminService;
+
+    @Test
+    void saveAssignment_shouldReplaceCurrentViewerResourceGrants() {
+        when(permissionService.hasPermi("user_data_scope:update")).thenReturn(true);
+        when(tenantProvider.getCurrentTenantId()).thenReturn(100L);
+        when(userProvider.getCurrentUserId()).thenReturn(1L);
+        mockUserInTenant(100L, 9L);
+        mockUserInTenant(100L, 2L);
+        mockUserInTenant(100L, 3L);
+
+        UserDataScopeAssignmentDTO payload = new UserDataScopeAssignmentDTO();
+        payload.setTenantId(100L);
+        payload.setViewerUserId(9L);
+        payload.setResourceType("opportunity");
+        payload.setOwnerUserIds(List.of(2L, 3L, 2L, 9L));
+        payload.setRemark("临时授权");
+
+        boolean result = userDataScopeAdminService.saveAssignment(payload);
+
+        assertEquals(true, result);
+        verify(jdbcTemplate).update(
+                contains("update sys_user_data_scope_user"),
+                eq(100L),
+                eq(9L),
+                eq("OPPORTUNITY"));
+        verify(jdbcTemplate).update(
+                contains("insert into sys_user_data_scope_user"),
+                eq(100L),
+                eq(9L),
+                eq(2L),
+                eq("OPPORTUNITY"),
+                eq(true),
+                eq(null),
+                eq("临时授权"),
+                eq(1L));
+        verify(jdbcTemplate).update(
+                contains("insert into sys_user_data_scope_user"),
+                eq(100L),
+                eq(9L),
+                eq(3L),
+                eq("OPPORTUNITY"),
+                eq(true),
+                eq(null),
+                eq("临时授权"),
+                eq(1L));
+    }
+
+    @Test
+    void saveAssignment_shouldAllowClearingAllOwners() {
+        when(permissionService.hasPermi("user_data_scope:update")).thenReturn(true);
+        when(tenantProvider.getCurrentTenantId()).thenReturn(100L);
+        mockUserInTenant(100L, 9L);
+
+        UserDataScopeAssignmentDTO payload = new UserDataScopeAssignmentDTO();
+        payload.setTenantId(100L);
+        payload.setViewerUserId(9L);
+        payload.setResourceType("ALL");
+        payload.setOwnerUserIds(List.of());
+
+        userDataScopeAdminService.saveAssignment(payload);
+
+        verify(jdbcTemplate).update(
+                contains("update sys_user_data_scope_user"),
+                eq(100L),
+                eq(9L),
+                eq("ALL"));
+        verify(jdbcTemplate, never()).update(
+                contains("insert into sys_user_data_scope_user"),
+                any(Object[].class));
+    }
+
+    @Test
+    void saveAssignment_shouldSaveMultipleResourceTypes() {
+        when(permissionService.hasPermi("user_data_scope:update")).thenReturn(true);
+        when(tenantProvider.getCurrentTenantId()).thenReturn(100L);
+        when(userProvider.getCurrentUserId()).thenReturn(1L);
+        mockUserInTenant(100L, 9L);
+        mockUserInTenant(100L, 2L);
+
+        UserDataScopeAssignmentDTO payload = new UserDataScopeAssignmentDTO();
+        payload.setTenantId(100L);
+        payload.setViewerUserId(9L);
+        payload.setResourceTypes(List.of("opportunity", "expansion", "opportunity"));
+        payload.setOwnerUserIds(List.of(2L));
+
+        boolean result = userDataScopeAdminService.saveAssignment(payload);
+
+        assertEquals(true, result);
+        verify(jdbcTemplate).update(
+                contains("update sys_user_data_scope_user"),
+                eq(100L),
+                eq(9L),
+                eq("OPPORTUNITY"));
+        verify(jdbcTemplate).update(
+                contains("update sys_user_data_scope_user"),
+                eq(100L),
+                eq(9L),
+                eq("EXPANSION"));
+        verify(jdbcTemplate).update(
+                contains("insert into sys_user_data_scope_user"),
+                eq(100L),
+                eq(9L),
+                eq(2L),
+                eq("OPPORTUNITY"),
+                eq(true),
+                eq(null),
+                eq(null),
+                eq(1L));
+        verify(jdbcTemplate).update(
+                contains("insert into sys_user_data_scope_user"),
+                eq(100L),
+                eq(9L),
+                eq(2L),
+                eq("EXPANSION"),
+                eq(true),
+                eq(null),
+                eq(null),
+                eq(1L));
+    }
+
+    @Test
+    void saveAssignment_shouldRejectCrossTenantRequestWhenCurrentTenantIsConcrete() {
+        when(permissionService.hasPermi("user_data_scope:update")).thenReturn(true);
+        when(tenantProvider.getCurrentTenantId()).thenReturn(100L);
+
+        UserDataScopeAssignmentDTO payload = new UserDataScopeAssignmentDTO();
+        payload.setTenantId(200L);
+        payload.setViewerUserId(9L);
+        payload.setResourceType("OPPORTUNITY");
+
+        BusinessException exception = assertThrows(
+                BusinessException.class,
+                () -> userDataScopeAdminService.saveAssignment(payload));
+
+        assertEquals("只能配置当前租户的数据授权", exception.getMessage());
+        verify(jdbcTemplate, never()).update(contains("sys_user_data_scope_user"), org.mockito.ArgumentMatchers.<Object[]>any());
+    }
+
+    @Test
+    void deleteGrant_shouldSoftDeleteTenantScopedGrant() {
+        when(permissionService.hasPermi("user_data_scope:update")).thenReturn(true);
+        when(tenantProvider.getCurrentTenantId()).thenReturn(100L);
+        when(jdbcTemplate.update(contains("where id = ?"), eq(88L), eq(100L))).thenReturn(1);
+
+        boolean result = userDataScopeAdminService.deleteGrant(100L, 88L);
+
+        assertEquals(true, result);
+        verify(jdbcTemplate).update(contains("update sys_user_data_scope_user"), eq(88L), eq(100L));
+    }
+
+    private void mockUserInTenant(Long tenantId, Long userId) {
+        when(jdbcTemplate.queryForObject(
+                contains("from sys_user u"),
+                eq(Integer.class),
+                eq(tenantId),
+                eq(userId)))
+                .thenReturn(1);
+    }
+}
diff --git a/backend/src/test/java/com/unis/crm/service/impl/CrmDataVisibilityServiceImplTest.java b/backend/src/test/java/com/unis/crm/service/impl/CrmDataVisibilityServiceImplTest.java
new file mode 100644
index 00000000..f7eef460
--- /dev/null
+++ b/backend/src/test/java/com/unis/crm/service/impl/CrmDataVisibilityServiceImplTest.java
@@ -0,0 +1,69 @@
+package com.unis.crm.service.impl;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.unis.crm.service.CrmDataVisibilityService;
+import com.unis.crm.service.CrmDataVisibilityService.DataVisibility;
+import com.unisbase.dto.DataScopeRuleDTO;
+import com.unisbase.service.DataScopeService;
+import java.util.Arrays;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.jdbc.core.JdbcTemplate;
+
+@ExtendWith(MockitoExtension.class)
+class CrmDataVisibilityServiceImplTest {
+
+    @Mock
+    private DataScopeService dataScopeService;
+
+    @Mock
+    private JdbcTemplate jdbcTemplate;
+
+    @InjectMocks
+    private CrmDataVisibilityServiceImpl service;
+
+    @Test
+    void resolveVisibility_shouldMergeBaseScopeAndExplicitUserGrants() {
+        DataScopeRuleDTO rule = new DataScopeRuleDTO();
+        rule.setCreatorUserIds(Arrays.asList(2L, 3L, 2L, null, -1L));
+        when(dataScopeService.resolveUserScope(9L, 100L)).thenReturn(rule);
+        when(jdbcTemplate.queryForList(
+                anyString(),
+                eq(Long.class),
+                eq(100L),
+                eq(9L),
+                eq(CrmDataVisibilityService.RESOURCE_ALL),
+                eq(CrmDataVisibilityService.RESOURCE_OPPORTUNITY)))
+                .thenReturn(List.of(3L, 4L, 5L));
+
+        DataVisibility visibility = service.resolveVisibility(9L, 100L, CrmDataVisibilityService.RESOURCE_OPPORTUNITY);
+
+        assertFalse(visibility.allDataAccess());
+        assertEquals(List.of(2L, 3L, 4L, 5L), visibility.visibleOwnerUserIds());
+    }
+
+    @Test
+    void resolveVisibility_shouldKeepAllAccessWhenBaseScopeIsAll() {
+        DataScopeRuleDTO rule = new DataScopeRuleDTO();
+        rule.setAllAccess(true);
+        when(dataScopeService.resolveUserScope(9L, 100L)).thenReturn(rule);
+
+        DataVisibility visibility = service.resolveVisibility(9L, 100L, CrmDataVisibilityService.RESOURCE_OPPORTUNITY);
+
+        assertTrue(visibility.allDataAccess());
+        assertEquals(List.of(), visibility.visibleOwnerUserIds());
+        verify(jdbcTemplate, never()).queryForList(anyString(), eq(Long.class), org.mockito.ArgumentMatchers.<Object>any());
+    }
+}
diff --git a/backend/src/test/java/com/unis/crm/service/impl/ExpansionServiceImplTest.java b/backend/src/test/java/com/unis/crm/service/impl/ExpansionServiceImplTest.java
index 83ea7b6f..ca077364 100644
--- a/backend/src/test/java/com/unis/crm/service/impl/ExpansionServiceImplTest.java
+++ b/backend/src/test/java/com/unis/crm/service/impl/ExpansionServiceImplTest.java
@@ -18,6 +18,8 @@ import com.unis.crm.dto.expansion.SalesExpansionItemDTO;
 import com.unis.crm.dto.expansion.UpdateChannelExpansionRequest;
 import com.unis.crm.dto.expansion.UpdateSalesExpansionRequest;
 import com.unis.crm.mapper.ExpansionMapper;
+import com.unis.crm.service.CrmDataVisibilityService;
+import com.unis.crm.service.CrmDataVisibilityService.DataVisibility;
 import com.unisbase.security.PermissionService;
 import com.unisbase.security.SpringSecurityTenantProvider;
 import java.math.BigDecimal;
@@ -41,9 +43,34 @@ class ExpansionServiceImplTest {
     @Mock
     private PermissionService permissionService;
 
+    @Mock
+    private CrmDataVisibilityService crmDataVisibilityService;
+
     @InjectMocks
     private ExpansionServiceImpl expansionService;
 
+    @Test
+    void getOverview_shouldIncludeExplicitExpansionGrants() {
+        SalesExpansionItemDTO ownedItem = new SalesExpansionItemDTO();
+        ownedItem.setId(11L);
+        ownedItem.setName("本人拓展");
+        SalesExpansionItemDTO grantedItem = new SalesExpansionItemDTO();
+        grantedItem.setId(12L);
+        grantedItem.setName("授权拓展");
+        when(tenantProvider.getCurrentTenantId()).thenReturn(100L);
+        when(crmDataVisibilityService.resolveVisibility(29L, 100L, CrmDataVisibilityService.RESOURCE_EXPANSION))
+                .thenReturn(new DataVisibility(false, List.of(29L, 36L)));
+        when(expansionMapper.selectSalesExpansions(29L, null)).thenReturn(List.of(ownedItem));
+        when(expansionMapper.selectSalesExpansionsByOwnerUserIds(List.of(36L), null)).thenReturn(List.of(grantedItem));
+        when(expansionMapper.selectChannelExpansions(29L, null)).thenReturn(List.of());
+        when(expansionMapper.selectChannelExpansionsByOwnerUserIds(List.of(36L), null)).thenReturn(List.of());
+        ExpansionOverviewDTO result = expansionService.getOverview(29L, null);
+
+        assertEquals(2, result.getSalesItems().size());
+        assertEquals(12L, result.getSalesItems().get(1).getId());
+        verify(expansionMapper).selectSalesExpansionsByOwnerUserIds(List.of(36L), null);
+    }
+
     @Test
     void createSalesExpansion_shouldRejectDuplicateEmployeeNo() {
         CreateSalesExpansionRequest request = buildCreateSalesRequest();
diff --git a/backend/src/test/java/com/unis/crm/service/impl/OpportunityServiceImplTest.java b/backend/src/test/java/com/unis/crm/service/impl/OpportunityServiceImplTest.java
index a44e7a81..6acd38a3 100644
--- a/backend/src/test/java/com/unis/crm/service/impl/OpportunityServiceImplTest.java
+++ b/backend/src/test/java/com/unis/crm/service/impl/OpportunityServiceImplTest.java
@@ -23,10 +23,10 @@ import com.unis.crm.dto.opportunity.OpportunityOmsPushDataDTO;
 import com.unis.crm.dto.opportunity.PushOpportunityToOmsRequest;
 import com.unis.crm.dto.opportunity.UpdateOpportunityIntegrationRequest;
 import com.unis.crm.mapper.OpportunityMapper;
+import com.unis.crm.service.CrmDataVisibilityService;
+import com.unis.crm.service.CrmDataVisibilityService.DataVisibility;
 import com.unis.crm.service.OmsClient;
-import com.unisbase.dto.DataScopeRuleDTO;
 import com.unisbase.security.PermissionService;
-import com.unisbase.service.DataScopeService;
 import com.unisbase.spi.UnisBaseTenantProvider;
 import java.math.BigDecimal;
 import java.time.LocalDate;
@@ -50,7 +50,7 @@ class OpportunityServiceImplTest {
     private ObjectMapper objectMapper;
 
     @Mock
-    private DataScopeService dataScopeService;
+    private CrmDataVisibilityService crmDataVisibilityService;
 
     @Mock
     private UnisBaseTenantProvider tenantProvider;
@@ -217,10 +217,9 @@ class OpportunityServiceImplTest {
 
     @Test
     void getOverview_shouldKeepConfiguredDataScopeAndIncludePreSalesVisibility() {
-        DataScopeRuleDTO rule = new DataScopeRuleDTO();
-        rule.setCreatorUserIds(List.of(2L, 3L, 2L));
         when(tenantProvider.getCurrentTenantId()).thenReturn(100L);
-        when(dataScopeService.resolveUserScope(9L, 100L)).thenReturn(rule);
+        when(crmDataVisibilityService.resolveVisibility(9L, 100L, CrmDataVisibilityService.RESOURCE_OPPORTUNITY))
+                .thenReturn(new DataVisibility(false, List.of(2L, 3L, 2L)));
         CurrentUserAccountDTO currentUser = new CurrentUserAccountDTO();
         currentUser.setUserId(9L);
         currentUser.setUsername("presales.zhang");
diff --git a/backend/src/test/java/com/unis/crm/service/impl/WorkServiceImplTest.java b/backend/src/test/java/com/unis/crm/service/impl/WorkServiceImplTest.java
index 8f2e79b4..05002b74 100644
--- a/backend/src/test/java/com/unis/crm/service/impl/WorkServiceImplTest.java
+++ b/backend/src/test/java/com/unis/crm/service/impl/WorkServiceImplTest.java
@@ -23,15 +23,21 @@ import com.unis.crm.mapper.OpportunityMapper;
 import java.time.LocalDate;
 import com.unis.crm.mapper.ProfileMapper;
 import com.unis.crm.mapper.WorkMapper;
+import com.unis.crm.service.CrmDataVisibilityService;
+import com.unis.crm.service.CrmDataVisibilityService.DataVisibility;
 import com.unis.crm.service.ReportReminderService;
+import com.unisbase.service.SysPermissionService;
 import com.unisbase.spi.UnisBaseTenantProvider;
 import java.util.List;
+import java.time.OffsetDateTime;
+import java.util.Set;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
 import org.mockito.ArgumentCaptor;
 import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.jdbc.core.JdbcTemplate;
 import org.springframework.mock.web.MockMultipartFile;
 
 import static org.mockito.ArgumentMatchers.any;
@@ -56,6 +62,15 @@ class WorkServiceImplTest {
     @Mock
     private UnisBaseTenantProvider tenantProvider;
 
+    @Mock
+    private CrmDataVisibilityService crmDataVisibilityService;
+
+    @Mock
+    private SysPermissionService sysPermissionService;
+
+    @Mock
+    private JdbcTemplate jdbcTemplate;
+
     private WorkServiceImpl workService;
 
     private WorkReportProperties workReportProperties;
@@ -63,7 +78,19 @@ class WorkServiceImplTest {
     @BeforeEach
     void setUp() {
         workReportProperties = new WorkReportProperties();
-        workService = new WorkServiceImpl(workMapper, opportunityMapper, profileMapper, reportReminderService, workReportProperties, new ObjectMapper(), tenantProvider, "build/test-uploads", "");
+        workService = new WorkServiceImpl(
+                workMapper,
+                opportunityMapper,
+                profileMapper,
+                reportReminderService,
+                workReportProperties,
+                new ObjectMapper(),
+                tenantProvider,
+                crmDataVisibilityService,
+                sysPermissionService,
+                jdbcTemplate,
+                "build/test-uploads",
+                "");
     }
 
     @Test
@@ -86,6 +113,23 @@ class WorkServiceImplTest {
         verify(workMapper).selectReportHistory(3);
     }
 
+    @Test
+    void getHistory_shouldIncludeExplicitDailyReportGrants() {
+        when(tenantProvider.getCurrentTenantId()).thenReturn(100L);
+        when(crmDataVisibilityService.resolveVisibility(17L, 100L, CrmDataVisibilityService.RESOURCE_DAILY_REPORT))
+                .thenReturn(new DataVisibility(false, List.of(17L, 36L)));
+        when(workMapper.selectReportHistory(3)).thenReturn(List.of(
+                historyItem(21L, "日报", "2026-04-02", "09:15", "本人日报")));
+        when(workMapper.selectReportHistoryByUserIds(List.of(36L), 3)).thenReturn(List.of(
+                historyItem(22L, "日报", "2026-04-03", "09:15", "授权日报")));
+
+        WorkHistoryPageDTO result = workService.getHistory(17L, "report", 1, 2);
+
+        assertEquals(2, result.getItems().size());
+        assertEquals(22L, result.getItems().get(0).getId());
+        verify(workMapper).selectReportHistoryByUserIds(List.of(36L), 3);
+    }
+
     @Test
     void getHistory_shouldOnlyQueryCheckInHistoryWhenTypeIsCheckin() {
         when(workMapper.selectCheckInHistory(2)).thenReturn(List.of(
@@ -167,6 +211,31 @@ class WorkServiceImplTest {
         verify(workMapper, never()).selectReportMessageUsers(any(), any(), eq(50));
     }
 
+    @Test
+    void getDailyReportRequirement_shouldUsePermissionForButtonsAndConfigForObjectSelection() {
+        workReportProperties.getDailyReport().setRequiredRoleCodes(List.of("crm_xs"));
+        when(profileMapper.selectUserRoleCodes(17L)).thenReturn(List.of("crm_xs"));
+        when(tenantProvider.getCurrentTenantId()).thenReturn(100L);
+        when(jdbcTemplate.queryForObject(
+                eq("select count(1) from sys_permission where code = ? and coalesce(is_deleted, 0) = 0"),
+                eq(Integer.class),
+                eq("daily_report:notify_users")))
+                .thenReturn(1);
+        when(jdbcTemplate.queryForObject(
+                eq("select count(1) from sys_permission where code = ? and coalesce(is_deleted, 0) = 0"),
+                eq(Integer.class),
+                eq("daily_report:attachments")))
+                .thenReturn(1);
+        when(sysPermissionService.listPermissionCodesByUserId(17L, 100L))
+                .thenReturn(Set.of("daily_report:notify_users"));
+
+        var requirement = workService.getDailyReportRequirement(17L);
+
+        assertEquals(true, requirement.isObjectSelectionRequired());
+        assertEquals(true, requirement.isNotifyUsersVisible());
+        assertEquals(false, requirement.isAttachmentsVisible());
+    }
+
     @Test
     void saveDailyReport_shouldRejectEmptyContentByDefault() {
         when(profileMapper.selectUserRoleCodes(17L)).thenReturn(List.of("sales"));
@@ -248,7 +317,7 @@ class WorkServiceImplTest {
                 requestCaptor.getValue().getWorkContent().startsWith("1. 2026-04-28 整理客户资料，完成重点项目复盘"));
         verify(workMapper, never()).insertLegacyOpportunityFollowUp(anyLong(), anyLong(), any(), any(), any(), any());
         verify(workMapper, never()).insertLegacyExpansionFollowUp(any(), anyLong(), anyLong(), any(), any(), any(), any(), any(), any(), any());
-        verify(workMapper, never()).updateOpportunitySnapshot(anyLong(), any(), any(), any());
+        verify(workMapper, never()).updateOpportunitySnapshot(anyLong(), any(), any(), any(), anyLong());
     }
 
     @Test
@@ -331,6 +400,47 @@ class WorkServiceImplTest {
         assertEquals(List.of("/api/work/checkin-photos/a.jpg", "/api/work/checkin-photos/b.jpg"), result.get(0).getPhotoUrls());
     }
 
+    @Test
+    void exportCheckIns_shouldDeduplicateByIdOnly() {
+        WorkCheckInExportDTO first = new WorkCheckInExportDTO();
+        first.setId(101L);
+        first.setCheckinDate("2026-07-01");
+        first.setCheckinTime("09:30");
+        first.setUserName("张三");
+        first.setSortTime(OffsetDateTime.parse("2026-07-01T09:30:00+08:00"));
+        WorkCheckInExportDTO second = new WorkCheckInExportDTO();
+        second.setId(102L);
+        second.setCheckinDate("2026-07-01");
+        second.setCheckinTime("09:30");
+        second.setUserName("张三");
+        second.setSortTime(OffsetDateTime.parse("2026-07-01T08:30:00+08:00"));
+        WorkCheckInExportDTO duplicate = new WorkCheckInExportDTO();
+        duplicate.setId(101L);
+        duplicate.setCheckinDate("2026-07-01");
+        duplicate.setCheckinTime("09:30");
+        duplicate.setUserName("张三");
+        duplicate.setSortTime(OffsetDateTime.parse("2026-07-01T09:30:00+08:00"));
+        WorkCheckInExportDTO granted = new WorkCheckInExportDTO();
+        granted.setId(103L);
+        granted.setCheckinDate("2026-07-01");
+        granted.setCheckinTime("10:00");
+        granted.setUserName("王五");
+        granted.setSortTime(OffsetDateTime.parse("2026-07-01T10:00:00+08:00"));
+
+        when(workMapper.selectCheckInExportRows(null, null, null, null, null, null, 5000))
+                .thenReturn(List.of(first, second));
+        when(tenantProvider.getCurrentTenantId()).thenReturn(1L);
+        when(crmDataVisibilityService.resolveVisibility(17L, 1L, CrmDataVisibilityService.RESOURCE_CHECKIN))
+                .thenReturn(new DataVisibility(false, List.of(18L)));
+        when(workMapper.selectCheckInExportRowsByUserIds(List.of(18L), null, null, null, null, null, null, 5000))
+                .thenReturn(List.of(duplicate, granted));
+
+        List<WorkCheckInExportDTO> result = workService.exportCheckIns(17L, null, null, null, null, null, null);
+
+        assertEquals(3, result.size());
+        assertEquals(List.of(103L, 101L, 102L), result.stream().map(WorkCheckInExportDTO::getId).toList());
+    }
+
     @Test
     void exportDailyReports_shouldCleanMetadataAndFilterByBizType() {
         WorkDailyReportExportDTO opportunityReport = new WorkDailyReportExportDTO();
@@ -364,6 +474,47 @@ class WorkServiceImplTest {
         assertEquals("跟进客户", result.get(0).getPlanItems().get(0).getContent());
     }
 
+    @Test
+    void exportDailyReports_shouldDeduplicateByIdOnly() {
+        WorkDailyReportExportDTO first = new WorkDailyReportExportDTO();
+        first.setId(201L);
+        first.setReportDate("2026-07-01");
+        first.setSubmitTime("2026-07-01 18:00");
+        first.setUserName("李四");
+        first.setSortTime(OffsetDateTime.parse("2026-07-01T18:00:00+08:00"));
+        WorkDailyReportExportDTO second = new WorkDailyReportExportDTO();
+        second.setId(202L);
+        second.setReportDate("2026-07-01");
+        second.setSubmitTime("2026-07-01 18:00");
+        second.setUserName("李四");
+        second.setSortTime(OffsetDateTime.parse("2026-07-01T17:00:00+08:00"));
+        WorkDailyReportExportDTO duplicate = new WorkDailyReportExportDTO();
+        duplicate.setId(201L);
+        duplicate.setReportDate("2026-07-01");
+        duplicate.setSubmitTime("2026-07-01 18:00");
+        duplicate.setUserName("李四");
+        duplicate.setSortTime(OffsetDateTime.parse("2026-07-01T18:00:00+08:00"));
+        WorkDailyReportExportDTO granted = new WorkDailyReportExportDTO();
+        granted.setId(203L);
+        granted.setReportDate("2026-07-01");
+        granted.setSubmitTime("2026-07-01 18:30");
+        granted.setUserName("王五");
+        granted.setSortTime(OffsetDateTime.parse("2026-07-01T18:30:00+08:00"));
+
+        when(workMapper.selectDailyReportExportRows(null, null, null, null, null, 5000))
+                .thenReturn(List.of(first, second));
+        when(tenantProvider.getCurrentTenantId()).thenReturn(1L);
+        when(crmDataVisibilityService.resolveVisibility(17L, 1L, CrmDataVisibilityService.RESOURCE_DAILY_REPORT))
+                .thenReturn(new DataVisibility(false, List.of(18L)));
+        when(workMapper.selectDailyReportExportRowsByUserIds(List.of(18L), null, null, null, null, null, 5000))
+                .thenReturn(List.of(duplicate, granted));
+
+        List<WorkDailyReportExportDTO> result = workService.exportDailyReports(17L, null, null, null, null, null, null);
+
+        assertEquals(3, result.size());
+        assertEquals(List.of(203L, 201L, 202L), result.stream().map(WorkDailyReportExportDTO::getId).toList());
+    }
+
     @Test
     void saveDailyReport_shouldNotUseTomorrowPlanAsOpportunityNextAction() {
         CreateWorkDailyReportRequest request = new CreateWorkDailyReportRequest();
@@ -542,6 +693,7 @@ class WorkServiceImplTest {
         when(opportunityMapper.selectDictLabel("sj_xmjd", "方案交流")).thenReturn(null);
         when(opportunityMapper.selectDictValueByLabel("sj_xmjd", "方案交流")).thenReturn("solution_discussion");
         when(opportunityMapper.selectDictLabel("sj_xmjd", "solution_discussion")).thenReturn("方案交流");
+        when(profileMapper.selectUserRoleCodes(17L)).thenReturn(List.of("XS_Col"));
         when(workMapper.insertDailyReport(eq(17L), any(CreateWorkDailyReportRequest.class), any(LocalDate.class), any()))
                 .thenReturn(1);
         when(workMapper.selectTodayReportId(eq(17L), any(LocalDate.class)))
@@ -555,7 +707,47 @@ class WorkServiceImplTest {
                 eq(101L),
                 eq("推进中"),
                 eq("继续推进"),
-                eq("solution_discussion"));
+                eq("solution_discussion"),
+                eq(17L));
+    }
+
+    @Test
+    void saveDailyReport_shouldNotSyncOpportunitySnapshotForNonSalesRole() {
+        CreateWorkDailyReportRequest request = new CreateWorkDailyReportRequest();
+        WorkReportLineItemRequest lineItem = new WorkReportLineItemRequest();
+        lineItem.setWorkDate("2026-04-28");
+        lineItem.setBizType("opportunity");
+        lineItem.setBizId(101L);
+        lineItem.setBizName("项目A");
+        lineItem.setEditorText("""
+                @商机 项目A
+                # 项目最新进展：推进中
+                # 下一步销售计划：继续推进
+                """);
+        request.setLineItems(List.of(lineItem));
+
+        WorkTomorrowPlanItemRequest planItem = new WorkTomorrowPlanItemRequest();
+        planItem.setContent("这是整篇日报的明日计划");
+        request.setPlanItems(List.of(planItem));
+        request.setWorkContent("占位");
+        request.setTomorrowPlan("这是整篇日报的明日计划");
+        request.setSourceType("manual");
+
+        WorkDailyReportDTO currentReport = new WorkDailyReportDTO();
+        currentReport.setDate("2026-04-28");
+        currentReport.setSubmitTime("2026-04-28 10:00");
+
+        when(profileMapper.selectUserRoleCodes(17L)).thenReturn(List.of("SQ_Col"));
+        when(workMapper.insertDailyReport(eq(17L), any(CreateWorkDailyReportRequest.class), any(LocalDate.class), any()))
+                .thenReturn(1);
+        when(workMapper.selectTodayReportId(eq(17L), any(LocalDate.class)))
+                .thenReturn(null, 501L);
+        when(workMapper.selectTodayReport(eq(17L), any(LocalDate.class)))
+                .thenReturn(currentReport);
+
+        workService.saveDailyReport(17L, request);
+
+        verify(workMapper, never()).updateOpportunitySnapshot(anyLong(), any(), any(), any(), anyLong());
     }
 
     private WorkHistoryItemDTO historyItem(Long id, String type, String date, String time, String content) {
diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts
index 89255037..11c6ff02 100644
--- a/frontend/src/lib/auth.ts
+++ b/frontend/src/lib/auth.ts
@@ -459,6 +459,7 @@ export interface OpportunityFollowUp {
   communicationContent?: string;
   nextAction?: string;
   user?: string;
+  roleCodes?: string;
   attachments?: WorkReportAttachment[];
 }
 
@@ -816,12 +817,14 @@ interface ApiErrorBody {
 
 const LOGIN_PATH = "/login";
 const USER_CONTEXT_CACHE_TTL_MS = 2 * 60 * 1000;
+const REFRESH_AHEAD_MS = 60 * 1000;
 const CURRENT_USER_CACHE_KEY = "auth-cache:current-user";
 const PROFILE_OVERVIEW_CACHE_KEY = "auth-cache:profile-overview";
 const MY_PERMISSIONS_CACHE_KEY = "auth-cache:my-permissions";
 const memoryRequestCache = new Map<string, { expiresAt: number; value: unknown }>();
 const inFlightRequestCache = new Map<string, Promise<unknown>>();
 let authExpiryTimer: number | null = null;
+let refreshTokenPromise: Promise<string | null> | null = null;
 const AUTH_REQUIRED_MESSAGE_PATTERNS = [
   "登录已失效",
   "重新登录",
@@ -894,6 +897,15 @@ function hasAccessTokenExpired(token?: string | null, now = Date.now()) {
   return expiresAt <= now;
 }
 
+function isAccessTokenExpiringSoon(token?: string | null, now = Date.now()) {
+  const expiresAt = getAccessTokenExpiresAt(token);
+  if (expiresAt === null) {
+    return true;
+  }
+
+  return expiresAt - now <= REFRESH_AHEAD_MS;
+}
+
 function redirectToLoginForTimeout() {
   const currentUrl = new URL(window.location.href);
   if (currentUrl.pathname === LOGIN_PATH && currentUrl.searchParams.get("timeout") === "1") {
@@ -927,6 +939,70 @@ function handleUnauthorizedResponse() {
   redirectToLoginForTimeout();
 }
 
+function persistTokenPair(payload: Pick<TokenResponse, "accessToken" | "refreshToken" | "availableTenants">) {
+  localStorage.setItem("accessToken", payload.accessToken);
+  localStorage.setItem("refreshToken", payload.refreshToken);
+
+  if (payload.availableTenants) {
+    localStorage.setItem("availableTenants", JSON.stringify(payload.availableTenants));
+  }
+
+  const tokenPayload = getAccessTokenPayload(payload.accessToken);
+  if (tokenPayload?.tenantId !== undefined) {
+    localStorage.setItem("activeTenantId", String(tokenPayload.tenantId));
+  }
+
+  syncAuthExpiryTimer();
+}
+
+async function refreshAccessToken(): Promise<string | null> {
+  if (refreshTokenPromise) {
+    return refreshTokenPromise;
+  }
+
+  const refreshToken = localStorage.getItem("refreshToken");
+  if (!refreshToken) {
+    return null;
+  }
+
+  refreshTokenPromise = request<TokenResponse>("/api/sys/auth/refresh", {
+    method: "POST",
+    body: JSON.stringify({ refreshToken }),
+  })
+    .then((payload) => {
+      if (!payload.accessToken || !payload.refreshToken) {
+        throw new Error("刷新登录态失败");
+      }
+      persistTokenPair(payload);
+      return payload.accessToken;
+    })
+    .catch(() => null)
+    .finally(() => {
+      refreshTokenPromise = null;
+    });
+
+  return refreshTokenPromise;
+}
+
+async function ensureFreshAccessToken(force = false): Promise<string | null> {
+  const token = localStorage.getItem("accessToken");
+  if (!force && token && !isAccessTokenExpiringSoon(token)) {
+    return token;
+  }
+
+  const refreshedToken = await refreshAccessToken();
+  if (refreshedToken) {
+    return refreshedToken;
+  }
+
+  if (!token || hasAccessTokenExpired(token) || force) {
+    handleUnauthorizedResponse();
+    return null;
+  }
+
+  return token;
+}
+
 function syncAuthExpiryTimer() {
   clearAuthExpiryTimer();
 
@@ -937,13 +1013,14 @@ function syncAuthExpiryTimer() {
 
   const expiresAt = getAccessTokenExpiresAt(token);
   if (expiresAt === null || expiresAt <= Date.now()) {
-    handleUnauthorizedResponse();
+    void ensureFreshAccessToken(true);
     return;
   }
 
+  const refreshInMs = Math.max(0, expiresAt - Date.now() - REFRESH_AHEAD_MS);
   authExpiryTimer = window.setTimeout(() => {
-    handleUnauthorizedResponse();
-  }, expiresAt - Date.now());
+    void ensureFreshAccessToken(true);
+  }, refreshInMs);
 }
 
 function tryParseApiBody<T>(rawText: string, contentType?: string | null): (ApiEnvelope<T> & ApiErrorBody) | null {
@@ -1045,6 +1122,10 @@ async function request<T>(input: string, init?: RequestInit, withAuth = false):
   }
 
   if (withAuth) {
+    const token = await ensureFreshAccessToken();
+    if (!token) {
+      throw new Error("登录已失效，请重新登录");
+    }
     applyAuthHeaders(headers);
   }
 
@@ -1083,12 +1164,26 @@ async function request<T>(input: string, init?: RequestInit, withAuth = false):
 }
 
 export async function fetchWithAuth(input: string, init?: RequestInit) {
+  const token = await ensureFreshAccessToken();
+  if (!token) {
+    throw new Error("登录已失效，请重新登录");
+  }
   const response = await fetch(input, {
     ...init,
     headers: applyAuthHeaders(new Headers(init?.headers)),
   });
 
   if (shouldTreatAsUnauthorized(response)) {
+    const refreshedToken = await refreshAccessToken();
+    if (refreshedToken) {
+      const retryResponse = await fetch(input, {
+        ...init,
+        headers: applyAuthHeaders(new Headers(init?.headers)),
+      });
+      if (!shouldTreatAsUnauthorized(retryResponse)) {
+        return retryResponse;
+      }
+    }
     handleUnauthorizedResponse();
     throw new Error("登录已失效，请重新登录");
   }
@@ -1098,7 +1193,7 @@ export async function fetchWithAuth(input: string, init?: RequestInit) {
 
 export function isAuthed() {
   const token = localStorage.getItem("accessToken");
-  return Boolean(token) && !hasAccessTokenExpired(token);
+  return (Boolean(token) && !hasAccessTokenExpired(token)) || Boolean(localStorage.getItem("refreshToken"));
 }
 
 export function clearAuth() {
@@ -1114,23 +1209,8 @@ export function clearAuth() {
 
 export function persistLogin(payload: TokenResponse, username: string) {
   clearCachedAuthContext();
-  localStorage.setItem("accessToken", payload.accessToken);
-  localStorage.setItem("refreshToken", payload.refreshToken);
   localStorage.setItem("username", username);
-
-  if (payload.availableTenants) {
-    localStorage.setItem("availableTenants", JSON.stringify(payload.availableTenants));
-    try {
-      const tokenPayload = getAccessTokenPayload(payload.accessToken);
-      if (tokenPayload?.tenantId !== undefined) {
-        localStorage.setItem("activeTenantId", String(tokenPayload.tenantId));
-      }
-    } catch {
-      localStorage.removeItem("activeTenantId");
-    }
-  }
-
-  syncAuthExpiryTimer();
+  persistTokenPair(payload);
 }
 
 function getStoredUserId() {
diff --git a/frontend/src/pages/Opportunities.tsx b/frontend/src/pages/Opportunities.tsx
index 6f69ba7a..185c52b2 100644
--- a/frontend/src/pages/Opportunities.tsx
+++ b/frontend/src/pages/Opportunities.tsx
@@ -99,8 +99,9 @@ type OpportunityExportFilters = {
   operatorName?: string;
   hasSalesExpansion?: string;
   hasChannelExpansion?: string;
-  selectedFields?: OpportunityExportFieldKey[];
+  selectedFields?: LegacyOpportunityExportFieldKey[];
 };
+type LegacyOpportunityExportFieldKey = OpportunityExportFieldKey | "followUps";
 type OpportunityExportFieldKey =
   | "code"
   | "name"
@@ -119,7 +120,11 @@ type OpportunityExportFieldKey =
   | "isPoc"
   | "latestProgress"
   | "nextPlan"
-  | "followUps"
+  | "salesFollowUps"
+  | "preSalesFollowUps"
+  | "afterSalesFollowUps"
+  | "productFollowUps"
+  | "businessFollowUps"
   | "salesExpansionName"
   | "salesExpansionIntent"
   | "salesExpansionActive"
@@ -209,7 +214,7 @@ function loadOpportunityExportPreferences(): OpportunityExportFilters {
       return {};
     }
     const parsed = JSON.parse(rawValue);
-    return typeof parsed === "object" && parsed ? parsed as OpportunityExportFilters : {};
+    return typeof parsed === "object" && parsed ? normalizeOpportunityExportFilters(parsed as OpportunityExportFilters) : {};
   } catch {
     return {};
   }
@@ -220,12 +225,22 @@ function persistOpportunityExportPreferences(filters: OpportunityExportFilters)
     return;
   }
   try {
-    window.localStorage.setItem(OPPORTUNITY_EXPORT_PREFERENCES_STORAGE_KEY, JSON.stringify(filters));
+    window.localStorage.setItem(OPPORTUNITY_EXPORT_PREFERENCES_STORAGE_KEY, JSON.stringify(normalizeOpportunityExportFilters(filters)));
   } catch {
     // ignore storage failures
   }
 }
 
+function normalizeOpportunityExportFilters(filters: OpportunityExportFilters): OpportunityExportFilters {
+  if (!filters.selectedFields) {
+    return filters;
+  }
+  return {
+    ...filters,
+    selectedFields: normalizeOpportunityExportFieldKeys(filters.selectedFields),
+  };
+}
+
 function normalizeOpportunityExportText(value?: string | number | boolean | null) {
   if (value === null || value === undefined) {
     return "";
@@ -290,7 +305,11 @@ const opportunityExportColumns: OpportunityExportColumn[] = [
   { key: "competitorName", label: "竞争对手", value: (item) => normalizeOpportunityExportText(item.competitorName) },
   { key: "latestProgress", label: "项目最新进展", kind: "longText", value: (item) => normalizeOpportunityExportText(item.latestProgress) },
   { key: "nextPlan", label: OPPORTUNITY_NEXT_PLAN_LABEL, kind: "longText", value: (item) => normalizeOpportunityExportText(item.nextPlan) },
-  { key: "followUps", label: "跟进记录", kind: "followup", value: (item, relatedSales, relatedChannel) => buildOpportunityFollowUpExportText(item, relatedSales, relatedChannel) },
+  { key: "salesFollowUps", label: "销售跟进记录", kind: "followup", value: (item) => buildOpportunityFollowUpExportTextByRole(item, "XS_COL") },
+  { key: "preSalesFollowUps", label: "售前跟进记录", kind: "followup", value: (item) => buildOpportunityFollowUpExportTextByRole(item, "SQ_COL") },
+  { key: "afterSalesFollowUps", label: "售后跟进记录", kind: "followup", value: (item) => buildOpportunityFollowUpExportTextByRole(item, "SH_COL") },
+  { key: "productFollowUps", label: "产品跟进记录", kind: "followup", value: (item) => buildOpportunityFollowUpExportTextByRole(item, "CP_COL") },
+  { key: "businessFollowUps", label: "商务跟进记录", kind: "followup", value: (item) => buildOpportunityFollowUpExportTextByRole(item, "SW_COL") },
   { key: "salesExpansionName", label: "销售拓展人员姓名", value: (item, relatedSales) => normalizeOpportunityExportText(item.salesExpansionName || relatedSales?.name) },
   { key: "salesExpansionIntent", label: "销售拓展人员合作意向", value: (item, relatedSales) => normalizeOpportunityExportText(item.salesExpansionIntent || relatedSales?.intent) },
   { key: "salesExpansionActive", label: "销售拓展人员是否在职", value: (item, relatedSales) => {
@@ -327,20 +346,23 @@ const defaultOpportunityExportFields: OpportunityExportFieldKey[] = [
   "competitorName",
   "latestProgress",
   "nextPlan",
-  "followUps",
+  "salesFollowUps",
   "isPoc",
 ];
 
-function resolveSelectedOpportunityFields(selectedFields: OpportunityExportFieldKey[] | undefined) {
-  return selectedFields === undefined ? defaultOpportunityExportFields : selectedFields;
+function resolveSelectedOpportunityFields(selectedFields: LegacyOpportunityExportFieldKey[] | undefined) {
+  return selectedFields === undefined ? defaultOpportunityExportFields : normalizeOpportunityExportFieldKeys(selectedFields);
 }
 
-function buildOpportunityFollowUpExportText(
-  item: OpportunityItem,
-  _relatedSales: SalesExpansionItem | null,
-  _relatedChannel: ChannelExpansionItem | null,
-) {
+function normalizeOpportunityExportFieldKeys(selectedFields: LegacyOpportunityExportFieldKey[]) {
+  const validFieldKeys = new Set(opportunityExportColumns.map((column) => column.key));
+  const migratedFields = selectedFields.map((field) => field === "followUps" ? "salesFollowUps" : field);
+  return Array.from(new Set(migratedFields)).filter((field): field is OpportunityExportFieldKey => validFieldKeys.has(field as OpportunityExportFieldKey));
+}
+
+function buildOpportunityFollowUpExportTextByRole(item: OpportunityItem, roleCode: string) {
   return (item.followUps ?? [])
+    .filter((record) => opportunityFollowUpHasRole(record, roleCode))
     .map((record) => {
       const summary = getOpportunityFollowUpSummary(record);
       const lines = [
@@ -353,6 +375,14 @@ function buildOpportunityFollowUpExportText(
     .join("\n\n");
 }
 
+function opportunityFollowUpHasRole(record: OpportunityFollowUp, roleCode: string) {
+  const normalizedTarget = roleCode.trim().toUpperCase();
+  return (record.roleCodes ?? "")
+    .split(",")
+    .map((code) => code.trim().toUpperCase())
+    .includes(normalizedTarget);
+}
+
 function normalizeOpportunityExportFilterText(value?: string | number | boolean | null) {
   return normalizeOpportunityExportText(value).toLowerCase();
 }
@@ -2481,10 +2511,14 @@ export default function Opportunities() {
             wrapText: rowNumber > 1 && columns[columnNumber - 1]?.kind === "followup",
           };
         });
-        const followUpColumnIndex = columns.findIndex((column) => column.kind === "followup") + 1;
-        if (rowNumber > 1 && followUpColumnIndex > 0) {
-          const followUpText = normalizeOpportunityExportText(row.getCell(followUpColumnIndex).value as string | null | undefined);
-          const lineCount = followUpText ? followUpText.split("\n").length : 1;
+        const followUpColumnIndexes = columns
+          .map((column, index) => column.kind === "followup" ? index + 1 : 0)
+          .filter((columnIndex) => columnIndex > 0);
+        if (rowNumber > 1 && followUpColumnIndexes.length > 0) {
+          const lineCount = followUpColumnIndexes.reduce((maxLineCount, columnIndex) => {
+            const followUpText = normalizeOpportunityExportText(row.getCell(columnIndex).value as string | null | undefined);
+            return Math.max(maxLineCount, followUpText ? followUpText.split("\n").length : 1);
+          }, 1);
           row.height = Math.max(22, lineCount * 16);
         }
       });
diff --git a/frontend1/dist/index.html b/frontend1/dist/index.html
index 3dd8ea4d..d21f14fd 100644
--- a/frontend1/dist/index.html
+++ b/frontend1/dist/index.html
@@ -5,7 +5,7 @@
     <link rel="icon" type="image/svg+xml" href="/logo.svg" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <title>UnisBase - 智能会议系统</title>
-    <script type="module" crossorigin src="/assets/index-DNoKV8lp.js"></script>
+    <script type="module" crossorigin src="/assets/index-Bk1lir76.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-CaWPk49l.css">
   </head>
   <body>
diff --git a/frontend1/src/api/index.ts b/frontend1/src/api/index.ts
index 525f8a2a..8d8dfc42 100644
--- a/frontend1/src/api/index.ts
+++ b/frontend1/src/api/index.ts
@@ -1,7 +1,7 @@
 import http from "./http";
 import {
   BotCredential, DeviceInfo, RoleDataScope, SysPermission, SysRole, SysUser, UserProfile, SysParamVO, SysParamQuery, PageResult,
-  PermissionNode
+  PermissionNode, UserDataScopeAssignment, UserDataScopeGrant, UserDataScopeUser
 } from "../types";
 
 export async function pageParams(params: SysParamQuery) {
@@ -199,6 +199,31 @@ export async function unbindUserFromRole(roleId: number, userId: number) {
   return resp.data.data as boolean;
 }
 
+export async function listUserDataScopeUsers(params?: { tenantId?: number }) {
+  const resp = await http.get("/sys/api/admin/user-data-scope/users", { params });
+  return resp.data.data as UserDataScopeUser[];
+}
+
+export async function listUserDataScopeGrants(params?: { tenantId?: number; viewerUserId?: number; resourceType?: string }) {
+  const resp = await http.get("/sys/api/admin/user-data-scope/grants", { params });
+  return resp.data.data as UserDataScopeGrant[];
+}
+
+export async function getUserDataScopeAssignment(params: { tenantId?: number; viewerUserId: number; resourceType: string }) {
+  const resp = await http.get("/sys/api/admin/user-data-scope/assignment", { params });
+  return resp.data.data as UserDataScopeAssignment;
+}
+
+export async function saveUserDataScopeAssignment(payload: UserDataScopeAssignment) {
+  const resp = await http.put("/sys/api/admin/user-data-scope/assignment", payload);
+  return resp.data.data as boolean;
+}
+
+export async function deleteUserDataScopeGrant(id: number, params?: { tenantId?: number }) {
+  const resp = await http.delete(`/sys/api/admin/user-data-scope/grants/${id}`, { params });
+  return resp.data.data as boolean;
+}
+
 export async function fetchLogs(params: any) {
   const resp = await http.get("/sys/api/logs", { params });
   return resp.data.data;
diff --git a/frontend1/src/pages/index.ts b/frontend1/src/pages/index.ts
index 820155ed..de2ff80d 100644
--- a/frontend1/src/pages/index.ts
+++ b/frontend1/src/pages/index.ts
@@ -12,6 +12,7 @@ export { default as SysParams } from "./system/sys-params";
 export { default as PlatformSettings } from "./system/platform-settings";
 export { default as ReportReminderSettings } from "./system/report-reminder-settings";
 export { default as Logs } from "./system/logs";
+export { default as UserDataScope } from "./system/user-data-scope";
 export { default as Devices } from "./devices";
 export { default as UserRoleBinding } from "./bindings/user-role";
 export { default as RolePermissionBinding } from "./bindings/role-permission";
diff --git a/frontend1/src/pages/system/user-data-scope/index.less b/frontend1/src/pages/system/user-data-scope/index.less
new file mode 100644
index 00000000..c46bdc24
--- /dev/null
+++ b/frontend1/src/pages/system/user-data-scope/index.less
@@ -0,0 +1,48 @@
+.user-data-scope-page {
+  min-height: 100%;
+}
+
+.user-data-scope-overview {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  gap: 12px;
+  margin-bottom: 16px;
+}
+
+.user-data-scope-stat {
+  min-height: 72px;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  gap: 6px;
+  border: 1px solid #e5e7eb;
+  border-radius: 8px;
+  background: #fff;
+  padding: 12px 16px;
+}
+
+.user-data-scope-stat__label {
+  color: #6b7280;
+  font-size: 12px;
+}
+
+.user-data-scope-stat__value {
+  color: #111827;
+  font-size: 20px;
+  font-weight: 700;
+}
+
+.user-data-scope-filter,
+.user-data-scope-table-card {
+  border-radius: 8px !important;
+}
+
+.user-data-scope-table-card {
+  overflow: hidden;
+}
+
+@media (max-width: 991px) {
+  .user-data-scope-overview {
+    grid-template-columns: 1fr;
+  }
+}
diff --git a/frontend1/src/pages/system/user-data-scope/index.tsx b/frontend1/src/pages/system/user-data-scope/index.tsx
new file mode 100644
index 00000000..bd43dcfa
--- /dev/null
+++ b/frontend1/src/pages/system/user-data-scope/index.tsx
@@ -0,0 +1,540 @@
+import { App, Button, Card, Col, DatePicker, Drawer, Form, Input, Popconfirm, Row, Select, Space, Table, Tag, Tooltip, Typography } from "antd";
+import type { ColumnsType } from "antd/es/table";
+import dayjs from "dayjs";
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { DeleteOutlined, EditOutlined, PlusOutlined, SearchOutlined, TeamOutlined } from "@ant-design/icons";
+import PageHeader from "@/components/shared/PageHeader";
+import { deleteUserDataScopeGrant, listTenants, listUserDataScopeGrants, listUserDataScopeUsers, saveUserDataScopeAssignment } from "@/api";
+import { usePermission } from "@/hooks/usePermission";
+import { getStandardPagination } from "@/utils/pagination";
+import type { SysTenant, UserDataScopeGrant, UserDataScopeUser } from "@/types";
+import "./index.less";
+
+const { Text } = Typography;
+
+const resourceOptions = [
+  { label: "商机数据", value: "OPPORTUNITY" },
+  { label: "拓展数据", value: "EXPANSION" },
+  { label: "日报数据", value: "DAILY_REPORT" },
+  { label: "打卡数据", value: "CHECKIN" }
+];
+
+const resourceLabels: Record<string, string> = {
+  ALL: "全部数据",
+  OPPORTUNITY: "商机数据",
+  EXPANSION: "拓展数据",
+  DAILY_REPORT: "日报数据",
+  CHECKIN: "打卡数据",
+  CUSTOMER: "客户数据",
+  WORK: "工作数据"
+};
+
+interface UserDataScopeGrantGroup {
+  id: string;
+  grantIds: number[];
+  tenantId: number;
+  viewerUserId: number;
+  viewerName: string;
+  ownerUsers: Array<{ userId: number; name: string }>;
+  resourceTypes: string[];
+  enabled: boolean;
+  expireAt?: string;
+  remark?: string;
+  grants: UserDataScopeGrant[];
+}
+
+function getActiveTenantId() {
+  const raw = localStorage.getItem("activeTenantId");
+  const value = raw ? Number(raw) : 0;
+  return Number.isFinite(value) && value > 0 ? value : undefined;
+}
+
+function isPlatformMode() {
+  try {
+    const profileStr = sessionStorage.getItem("userProfile");
+    const profile = profileStr ? JSON.parse(profileStr) : {};
+    return Boolean(profile.isPlatformAdmin && Number(localStorage.getItem("activeTenantId") || profile.tenantId || 0) === 0);
+  } catch {
+    return false;
+  }
+}
+
+function userLabel(user: UserDataScopeUser) {
+  const name = user.displayName || user.username || String(user.userId);
+  return `${name}${user.username && user.username !== name ? ` (${user.username})` : ""}`;
+}
+
+function resourceLabel(value?: string) {
+  return value ? resourceLabels[value] || value : "-";
+}
+
+function isGroupExpired(group: UserDataScopeGrantGroup) {
+  return Boolean(group.expireAt && new Date(group.expireAt).getTime() <= Date.now());
+}
+
+function formatExpireAt(value?: string) {
+  return value ? value.replace("T", " ").slice(0, 16) : "永久";
+}
+
+function buildGrantGroups(source: UserDataScopeGrant[]) {
+  const buckets = new Map<string, Map<string, UserDataScopeGrant[]>>();
+  for (const grant of source) {
+    const bucketKey = [
+      grant.tenantId,
+      grant.viewerUserId,
+      grant.enabled ? "1" : "0",
+      grant.expireAt || "",
+      grant.remark || ""
+    ].join("|");
+    const resourceMap = buckets.get(bucketKey) || new Map<string, UserDataScopeGrant[]>();
+    const resourceGrants = resourceMap.get(grant.resourceType) || [];
+    resourceGrants.push(grant);
+    resourceMap.set(grant.resourceType, resourceGrants);
+    buckets.set(bucketKey, resourceMap);
+  }
+
+  const groups: UserDataScopeGrantGroup[] = [];
+  for (const [bucketKey, resourceMap] of buckets) {
+    const ownerSetMap = new Map<string, UserDataScopeGrant[]>();
+    for (const resourceGrants of resourceMap.values()) {
+      const ownerKey = resourceGrants
+        .map((grant) => grant.ownerUserId)
+        .sort((a, b) => a - b)
+        .join(",");
+      ownerSetMap.set(ownerKey, [...(ownerSetMap.get(ownerKey) || []), ...resourceGrants]);
+    }
+
+    for (const [ownerKey, groupGrants] of ownerSetMap) {
+      const first = groupGrants[0];
+      const ownerUsers = Array.from(
+        new Map(groupGrants.map((grant) => [grant.ownerUserId, { userId: grant.ownerUserId, name: grant.ownerName }])).values()
+      ).sort((a, b) => a.name.localeCompare(b.name, "zh-CN"));
+      const resourceTypes = Array.from(new Set(groupGrants.map((grant) => grant.resourceType)))
+        .sort((a, b) => resourceLabel(a).localeCompare(resourceLabel(b), "zh-CN"));
+
+      groups.push({
+        id: `${bucketKey}|${ownerKey}`,
+        grantIds: groupGrants.map((grant) => grant.id),
+        tenantId: first.tenantId,
+        viewerUserId: first.viewerUserId,
+        viewerName: first.viewerName,
+        ownerUsers,
+        resourceTypes,
+        enabled: first.enabled,
+        expireAt: first.expireAt,
+        remark: first.remark,
+        grants: groupGrants
+      });
+    }
+  }
+
+  return groups.sort((a, b) => (
+    a.viewerName.localeCompare(b.viewerName, "zh-CN")
+    || a.resourceTypes.join(",").localeCompare(b.resourceTypes.join(","), "zh-CN")
+    || a.ownerUsers.map((owner) => owner.name).join(",").localeCompare(b.ownerUsers.map((owner) => owner.name).join(","), "zh-CN")
+  ));
+}
+
+export default function UserDataScopePage() {
+  const { message } = App.useApp();
+  const { can } = usePermission();
+  const [form] = Form.useForm();
+  const [loading, setLoading] = useState(false);
+  const [saving, setSaving] = useState(false);
+  const [users, setUsers] = useState<UserDataScopeUser[]>([]);
+  const [grants, setGrants] = useState<UserDataScopeGrant[]>([]);
+  const [tenants, setTenants] = useState<SysTenant[]>([]);
+  const [drawerOpen, setDrawerOpen] = useState(false);
+  const [editingGrantGroup, setEditingGrantGroup] = useState<UserDataScopeGrantGroup | null>(null);
+  const [selectedTenantId, setSelectedTenantId] = useState<number | undefined>(() => getActiveTenantId());
+  const [query, setQuery] = useState<{ keyword?: string; viewerUserId?: number; resourceType?: string; current: number; pageSize: number }>({
+    current: 1,
+    pageSize: 10
+  });
+  const platformMode = useMemo(() => isPlatformMode(), []);
+
+  const loadTenants = useCallback(async () => {
+    if (!platformMode) {
+      return;
+    }
+    const response = await listTenants({ current: 1, size: 1000 });
+    setTenants(response?.records || []);
+  }, [platformMode]);
+
+  const loadUsers = useCallback(async () => {
+    if (!selectedTenantId) {
+      setUsers([]);
+      return;
+    }
+    const list = await listUserDataScopeUsers({ tenantId: selectedTenantId });
+    setUsers(list || []);
+  }, [selectedTenantId]);
+
+  const loadGrants = useCallback(async () => {
+    if (!selectedTenantId) {
+      setGrants([]);
+      return;
+    }
+    const list = await listUserDataScopeGrants({ tenantId: selectedTenantId });
+    setGrants(list || []);
+  }, [selectedTenantId]);
+
+  const loadPageData = useCallback(async () => {
+    setLoading(true);
+    try {
+      await Promise.all([loadTenants(), loadUsers(), loadGrants()]);
+    } finally {
+      setLoading(false);
+    }
+  }, [loadTenants, loadUsers, loadGrants]);
+
+  useEffect(() => {
+    loadPageData();
+  }, [loadPageData]);
+
+  const userOptions = useMemo(() => users.map((user) => ({
+    label: (
+      <Space direction="vertical" size={0}>
+        <span>{userLabel(user)}</span>
+        {user.orgName && <Text type="secondary" style={{ fontSize: 12 }}>{user.orgName}</Text>}
+      </Space>
+    ),
+    displayLabel: userLabel(user),
+    value: user.userId,
+    searchText: `${user.displayName || ""} ${user.username || ""} ${user.orgName || ""}`
+  })), [users]);
+
+  const grantGroups = useMemo(() => buildGrantGroups(grants), [grants]);
+
+  const filteredGrantGroups = useMemo(() => {
+    const normalizedKeyword = (query.keyword || "").trim().toLowerCase();
+    return grantGroups.filter((group) => {
+      if (query.viewerUserId && group.viewerUserId !== query.viewerUserId) {
+        return false;
+      }
+      if (query.resourceType && !group.resourceTypes.includes(query.resourceType)) {
+        return false;
+      }
+      if (!normalizedKeyword) {
+        return true;
+      }
+      return [
+        group.viewerName,
+        group.remark,
+        String(group.viewerUserId),
+        ...group.ownerUsers.flatMap((owner) => [owner.name, String(owner.userId)]),
+        ...group.resourceTypes.flatMap((type) => [type, resourceLabel(type)])
+      ].filter(Boolean).join(" ").toLowerCase().includes(normalizedKeyword);
+    });
+  }, [grantGroups, query.keyword, query.resourceType, query.viewerUserId]);
+
+  const pagedGrantGroups = useMemo(() => {
+    const start = (query.current - 1) * query.pageSize;
+    return filteredGrantGroups.slice(start, start + query.pageSize);
+  }, [filteredGrantGroups, query.current, query.pageSize]);
+
+  const activeGrantCount = useMemo(
+    () => grantGroups.filter((group) => group.enabled && !isGroupExpired(group)).length,
+    [grantGroups]
+  );
+
+  const selectedDrawerViewerId = Form.useWatch("viewerUserId", form);
+  const ownerOptions = useMemo(
+    () => userOptions.filter((option) => option.value !== selectedDrawerViewerId),
+    [selectedDrawerViewerId, userOptions]
+  );
+
+  const openCreate = () => {
+    setEditingGrantGroup(null);
+    form.resetFields();
+    form.setFieldsValue({
+      resourceTypes: ["OPPORTUNITY"],
+      enabled: true,
+      ownerUserIds: []
+    });
+    setDrawerOpen(true);
+  };
+
+  const openEdit = (record: UserDataScopeGrantGroup) => {
+    setEditingGrantGroup(record);
+    form.resetFields();
+    form.setFieldsValue({
+      viewerUserId: record.viewerUserId,
+      resourceTypes: record.resourceTypes,
+      ownerUserIds: record.ownerUsers.map((owner) => owner.userId),
+      enabled: record.enabled,
+      expireAt: record.expireAt ? dayjs(record.expireAt) : undefined,
+      remark: record.remark
+    });
+    setDrawerOpen(true);
+  };
+
+  const closeDrawer = () => {
+    setDrawerOpen(false);
+    setEditingGrantGroup(null);
+  };
+
+  const handleSave = async () => {
+    if (!selectedTenantId) {
+      message.warning("请先选择租户");
+      return;
+    }
+    const values = await form.validateFields();
+    const resourceTypes = values.resourceTypes || [];
+    setSaving(true);
+    try {
+      if (editingGrantGroup) {
+        const selectedResourceTypes = new Set(resourceTypes);
+        const removedGrantIds = editingGrantGroup.grants
+          .filter((grant) => !selectedResourceTypes.has(grant.resourceType))
+          .map((grant) => grant.id);
+        await Promise.all(removedGrantIds.map((id) => deleteUserDataScopeGrant(id, { tenantId: selectedTenantId })));
+      }
+      await saveUserDataScopeAssignment({
+        tenantId: selectedTenantId,
+        viewerUserId: values.viewerUserId,
+        resourceType: resourceTypes[0],
+        resourceTypes,
+        ownerUserIds: values.ownerUserIds || [],
+        enabled: values.enabled !== false,
+        expireAt: values.expireAt ? values.expireAt.toISOString() : undefined,
+        remark: values.remark
+      });
+      message.success("数据授权已保存");
+      closeDrawer();
+      await loadGrants();
+    } finally {
+      setSaving(false);
+    }
+  };
+
+  const handleDelete = async (record: UserDataScopeGrantGroup) => {
+    await Promise.all(record.grantIds.map((id) => deleteUserDataScopeGrant(id, { tenantId: selectedTenantId })));
+    message.success("数据授权已删除");
+    await loadGrants();
+  };
+
+  const columns: ColumnsType<UserDataScopeGrantGroup> = [
+    {
+      title: "查看人",
+      dataIndex: "viewerName",
+      width: 180,
+      render: (value: string, record) => (
+        <Space direction="vertical" size={0}>
+          <Text strong>{value}</Text>
+          <Text type="secondary" style={{ fontSize: 12 }}>ID: {record.viewerUserId}</Text>
+        </Space>
+      )
+    },
+    {
+      title: "可见人员",
+      dataIndex: "ownerUsers",
+      width: 260,
+      render: (_: unknown, record) => (
+        <Space wrap size={[6, 6]}>
+          {record.ownerUsers.map((owner) => (
+            <Tag key={owner.userId}>
+              {owner.name}
+              <Text type="secondary" style={{ marginLeft: 4, fontSize: 12 }}>ID: {owner.userId}</Text>
+            </Tag>
+          ))}
+        </Space>
+      )
+    },
+    {
+      title: "资源范围",
+      dataIndex: "resourceTypes",
+      width: 220,
+      render: (_: unknown, record) => (
+        <Space wrap size={[6, 6]}>
+          {record.resourceTypes.map((value) => (
+            <Tag key={value} color={value === "ALL" ? "blue" : "geekblue"}>{resourceLabel(value)}</Tag>
+          ))}
+        </Space>
+      )
+    },
+    {
+      title: "状态",
+      dataIndex: "enabled",
+      width: 100,
+      render: (enabled: boolean, record) => {
+        if (isGroupExpired(record)) {
+          return <Tag color="orange">已过期</Tag>;
+        }
+        return <Tag color={enabled ? "green" : "red"}>{enabled ? "启用" : "停用"}</Tag>;
+      }
+    },
+    {
+      title: "有效期",
+      dataIndex: "expireAt",
+      width: 170,
+      render: (value?: string) => formatExpireAt(value)
+    },
+    {
+      title: "备注",
+      dataIndex: "remark",
+      ellipsis: true,
+      render: (value?: string) => value ? <Tooltip title={value}>{value}</Tooltip> : "-"
+    },
+    {
+      title: "操作",
+      key: "action",
+      width: 120,
+      fixed: "right",
+      render: (_: unknown, record) => (
+        <Space size={4}>
+          <Tooltip title="编辑">
+            <Button
+              type="text"
+              icon={<EditOutlined />}
+              disabled={!can("user_data_scope:update")}
+              onClick={() => openEdit(record)}
+            />
+          </Tooltip>
+          <Popconfirm title="确认删除该组授权？" okText="删除" cancelText="取消" onConfirm={() => handleDelete(record)}>
+            <Button type="text" danger icon={<DeleteOutlined />} disabled={!can("user_data_scope:update")} />
+          </Popconfirm>
+        </Space>
+      )
+    }
+  ];
+
+  return (
+    <div className="app-page user-data-scope-page">
+      <PageHeader title="数据授权管理" subtitle="查看、添加或删除某个用户可额外查看的人员数据权限。" />
+
+      <div className="user-data-scope-overview">
+        <div className="user-data-scope-stat">
+          <span className="user-data-scope-stat__label">授权记录</span>
+          <span className="user-data-scope-stat__value">{grantGroups.length}</span>
+        </div>
+        <div className="user-data-scope-stat">
+          <span className="user-data-scope-stat__label">生效授权</span>
+          <span className="user-data-scope-stat__value">{activeGrantCount}</span>
+        </div>
+        <div className="user-data-scope-stat">
+          <span className="user-data-scope-stat__label">授权查看人</span>
+          <span className="user-data-scope-stat__value">{new Set(grants.map((grant) => grant.viewerUserId)).size}</span>
+        </div>
+      </div>
+
+      <Card className="app-page__filter-card user-data-scope-filter" styles={{ body: { padding: 16 } }}>
+        <div className="app-page__toolbar" style={{ justifyContent: "space-between", width: "100%" }}>
+          <Space wrap>
+            {platformMode && (
+              <Select
+                value={selectedTenantId}
+                placeholder="选择租户"
+                options={tenants.map((tenant) => ({ label: tenant.tenantName, value: tenant.id }))}
+                onChange={(value) => {
+                  setSelectedTenantId(value);
+                  setQuery((prev) => ({ ...prev, current: 1, viewerUserId: undefined }));
+                }}
+                style={{ width: 180 }}
+              />
+            )}
+            <Input
+              allowClear
+              prefix={<SearchOutlined />}
+              placeholder="搜索查看人、可见人员、备注"
+              value={query.keyword}
+              onChange={(event) => setQuery((prev) => ({ ...prev, keyword: event.target.value, current: 1 }))}
+              style={{ width: 260 }}
+            />
+            <Select
+              allowClear
+              showSearch
+              placeholder="筛选查看人"
+              value={query.viewerUserId}
+              options={userOptions}
+              optionFilterProp="searchText"
+              optionLabelProp="displayLabel"
+              onChange={(value) => setQuery((prev) => ({ ...prev, viewerUserId: value, current: 1 }))}
+              style={{ width: 220 }}
+            />
+            <Select
+              allowClear
+              placeholder="资源范围"
+              value={query.resourceType}
+              options={resourceOptions}
+              onChange={(value) => setQuery((prev) => ({ ...prev, resourceType: value, current: 1 }))}
+              style={{ width: 140 }}
+            />
+          </Space>
+          <Button type="primary" icon={<PlusOutlined />} disabled={!can("user_data_scope:update")} onClick={openCreate}>
+            新建授权
+          </Button>
+        </div>
+      </Card>
+
+      <Card className="app-page__content-card user-data-scope-table-card" styles={{ body: { padding: 0 } }}>
+        <Table
+          rowKey="id"
+          loading={loading}
+          columns={columns}
+          dataSource={pagedGrantGroups}
+          size="middle"
+          scroll={{ y: "calc(100vh - 390px)", x: 980 }}
+          pagination={getStandardPagination(
+            filteredGrantGroups.length,
+            query.current,
+            query.pageSize,
+            (page, pageSize) => setQuery((prev) => ({ ...prev, current: page, pageSize }))
+          )}
+        />
+      </Card>
+
+      <Drawer
+        title={<span><TeamOutlined className="mr-2" />{editingGrantGroup ? "编辑数据授权" : "新建数据授权"}</span>}
+        open={drawerOpen}
+        onClose={closeDrawer}
+        width={560}
+        destroyOnHidden
+        footer={<div className="app-page__drawer-footer"><Button onClick={closeDrawer}>取消</Button><Button type="primary" loading={saving} onClick={handleSave}>保存</Button></div>}
+      >
+        <Form form={form} layout="vertical" initialValues={{ resourceTypes: ["OPPORTUNITY"], enabled: true, ownerUserIds: [] }}>
+          <Form.Item label="查看人" name="viewerUserId" rules={[{ required: true, message: "请选择查看人" }]}>
+            <Select
+              showSearch
+              placeholder="选择谁可以看数据"
+              options={userOptions}
+              optionFilterProp="searchText"
+              optionLabelProp="displayLabel"
+              disabled={Boolean(editingGrantGroup)}
+            />
+          </Form.Item>
+          <Form.Item label="资源范围" name="resourceTypes" rules={[{ required: true, message: "请选择资源范围" }]}>
+            <Select mode="multiple" allowClear options={resourceOptions} maxTagCount="responsive" />
+          </Form.Item>
+          <Form.Item label="可见人员" name="ownerUserIds" rules={[{ required: true, message: "请选择可见人员" }]}>
+            <Select
+              mode="multiple"
+              showSearch
+              allowClear
+              placeholder="选择允许查看的数据归属人"
+              options={ownerOptions}
+              optionFilterProp="searchText"
+              optionLabelProp="displayLabel"
+              maxTagCount="responsive"
+            />
+          </Form.Item>
+          <Row gutter={16}>
+            <Col span={12}>
+              <Form.Item label="启用状态" name="enabled">
+                <Select options={[{ label: "启用", value: true }, { label: "停用", value: false }]} />
+              </Form.Item>
+            </Col>
+            <Col span={12}>
+              <Form.Item label="有效期" name="expireAt">
+                <DatePicker showTime allowClear placeholder="永久" style={{ width: "100%" }} />
+              </Form.Item>
+            </Col>
+          </Row>
+          <Form.Item label="备注" name="remark">
+            <Input.TextArea rows={3} maxLength={500} placeholder="例如：临时支持某销售团队的数据查看" />
+          </Form.Item>
+        </Form>
+      </Drawer>
+    </div>
+  );
+}
diff --git a/frontend1/src/routes/routes.tsx b/frontend1/src/routes/routes.tsx
index 28c6b762..3c2a0d97 100644
--- a/frontend1/src/routes/routes.tsx
+++ b/frontend1/src/routes/routes.tsx
@@ -13,6 +13,7 @@ const SysParams = lazy(() => import("@/pages/system/sys-params"));
 const PlatformSettings = lazy(() => import("@/pages/system/platform-settings"));
 const Dictionaries = lazy(() => import("@/pages/system/dictionaries"));
 const Logs = lazy(() => import("@/pages/system/logs"));
+const UserDataScope = lazy(() => import("@/pages/system/user-data-scope"));
 const ReportReminderSettings = lazy(() => import("@/features/report-reminder/pages/report-reminder-settings"));
 const DashboardAnalyticsSettings = lazy(() => import("@/features/dashboard-analytics/pages/dashboard-analytics-settings"));
 const OwnerTransfer = lazy(() => import("@/features/owner-transfer/pages/owner-transfer"));
@@ -44,6 +45,7 @@ export const menuRoutes: MenuRoute[] = [
   { path: "/platform-settings", label: "平台设置", element: <LazyPage><PlatformSettings /></LazyPage>, perm: "menu:platform" },
   { path: "/dictionaries", label: "字典管理", element: <LazyPage><Dictionaries /></LazyPage>, perm: "menu:dict" },
   { path: "/logs", label: "日志管理", element: <LazyPage><Logs /></LazyPage>, perm: "menu:logs" },
+  { path: "/data-scope-users", label: "数据授权管理", element: <LazyPage><UserDataScope /></LazyPage>, perm: "menu:data-scope-users" },
   { path: "/dashboard-analytics-settings", label: "首页经营分析配置", element: <LazyPage><DashboardAnalyticsSettings /></LazyPage>, perm: "menu:dashboard-analytics-settings" },
   { path: "/report-reminder-settings", label: "日报提醒设置", element: <LazyPage><ReportReminderSettings /></LazyPage>, perm: "menu:report-reminder-settings" },
   { path: "/owner-transfer", label: "归属人转移", element: <LazyPage><OwnerTransfer /></LazyPage>, perm: "menu:owner-transfer" },
diff --git a/frontend1/src/types/index.ts b/frontend1/src/types/index.ts
index 25611f8b..0a226d47 100644
--- a/frontend1/src/types/index.ts
+++ b/frontend1/src/types/index.ts
@@ -121,6 +121,41 @@ export interface SysOrg extends BaseEntity {
   sortOrder?: number;
 }
 
+export interface UserDataScopeUser {
+  userId: number;
+  username: string;
+  displayName: string;
+  orgName?: string;
+  status?: number;
+  tenantId?: number;
+}
+
+export interface UserDataScopeGrant {
+  id: number;
+  tenantId: number;
+  viewerUserId: number;
+  viewerName: string;
+  ownerUserId: number;
+  ownerName: string;
+  resourceType: string;
+  enabled: boolean;
+  expireAt?: string;
+  remark?: string;
+  createdAt?: string;
+  updatedAt?: string;
+}
+
+export interface UserDataScopeAssignment {
+  tenantId?: number;
+  viewerUserId: number;
+  resourceType?: string;
+  resourceTypes?: string[];
+  ownerUserIds: number[];
+  expireAt?: string;
+  remark?: string;
+  enabled?: boolean;
+}
+
 export interface SysLog {
   id: number;
   tenantId?: number;
diff --git a/sql/20260701_opportunity_updated_by_pg17.sql b/sql/20260701_opportunity_updated_by_pg17.sql
new file mode 100644
index 00000000..014ae52b
--- /dev/null
+++ b/sql/20260701_opportunity_updated_by_pg17.sql
@@ -0,0 +1,4 @@
+alter table if exists crm_opportunity
+  add column if not exists updated_by bigint;
+
+comment on column crm_opportunity.updated_by is '更新人ID';
diff --git a/sql/20260701_user_data_scope_user_pg17.sql b/sql/20260701_user_data_scope_user_pg17.sql
new file mode 100644
index 00000000..8af03f73
--- /dev/null
+++ b/sql/20260701_user_data_scope_user_pg17.sql
@@ -0,0 +1,284 @@
+create table if not exists sys_user_data_scope_user (
+  id bigserial primary key,
+  tenant_id bigint not null,
+  viewer_user_id bigint not null,
+  owner_user_id bigint not null,
+  resource_type varchar(50) not null default 'ALL',
+  enabled boolean not null default true,
+  expire_at timestamptz,
+  remark varchar(500),
+  created_by bigint,
+  created_at timestamptz not null default now(),
+  updated_at timestamptz not null default now(),
+  is_deleted integer not null default 0
+);
+
+create unique index if not exists uk_user_data_scope_user
+  on sys_user_data_scope_user (tenant_id, viewer_user_id, owner_user_id, resource_type)
+  where is_deleted = 0;
+
+create index if not exists idx_user_data_scope_viewer
+  on sys_user_data_scope_user (tenant_id, viewer_user_id, resource_type, enabled);
+
+create index if not exists idx_user_data_scope_owner
+  on sys_user_data_scope_user (tenant_id, owner_user_id);
+
+comment on table sys_user_data_scope_user is '用户到用户的数据可见范围授权';
+comment on column sys_user_data_scope_user.viewer_user_id is '被授权查看数据的用户ID';
+comment on column sys_user_data_scope_user.owner_user_id is '数据归属用户ID';
+comment on column sys_user_data_scope_user.resource_type is '授权资源类型：OPPORTUNITY/EXPANSION/DAILY_REPORT/CHECKIN等';
+
+do $$
+declare
+  v_system_parent_id bigint;
+  v_menu_perm_id bigint;
+  v_view_perm_id bigint;
+  v_update_perm_id bigint;
+  v_has_role_permission_tenant boolean;
+begin
+  perform setval('sys_permission_perm_id_seq', coalesce((select max(perm_id) from sys_permission), 0) + 1, false);
+  perform setval('sys_role_permission_id_seq', coalesce((select max(id) from sys_role_permission), 0) + 1, false);
+
+  select exists (
+    select 1
+    from information_schema.columns
+    where table_schema = current_schema()
+      and table_name = 'sys_role_permission'
+      and column_name = 'tenant_id'
+  ) into v_has_role_permission_tenant;
+
+  select perm_id
+    into v_system_parent_id
+  from sys_permission
+  where code = 'system'
+    and coalesce(is_deleted, 0) = 0
+  order by perm_id
+  limit 1;
+
+  if v_system_parent_id is null then
+    insert into sys_permission (
+      parent_id, name, code, perm_type, level, path, component, icon,
+      sort_order, is_visible, status, description, meta, is_deleted, created_at, updated_at
+    ) values (
+      null, '系统管理', 'system', 'directory', 1, null, null, 'SettingOutlined',
+      110, 1, 1, '系统管理目录', '{}'::jsonb, 0, now(), now()
+    )
+    returning perm_id into v_system_parent_id;
+  end if;
+
+  select perm_id
+    into v_menu_perm_id
+  from sys_permission
+  where code = 'menu:data-scope-users'
+  order by perm_id
+  limit 1;
+
+  if v_menu_perm_id is null then
+    insert into sys_permission (
+      parent_id, name, code, perm_type, level, path, component, icon,
+      sort_order, is_visible, status, description, meta, is_deleted, created_at, updated_at
+    ) values (
+      v_system_parent_id, '数据授权管理', 'menu:data-scope-users', 'menu', 2,
+      '/data-scope-users', null, 'TeamOutlined', 8, 1, 1,
+      '配置用户可额外查看哪些人员名下的数据', jsonb_build_object('tenantScoped', true), 0, now(), now()
+    )
+    returning perm_id into v_menu_perm_id;
+  else
+    update sys_permission
+    set parent_id = v_system_parent_id,
+        name = '数据授权管理',
+        perm_type = 'menu',
+        level = 2,
+        path = '/data-scope-users',
+        component = null,
+        icon = 'TeamOutlined',
+        sort_order = 8,
+        is_visible = 1,
+        status = 1,
+        description = '配置用户可额外查看哪些人员名下的数据',
+        meta = jsonb_build_object('tenantScoped', true),
+        is_deleted = 0,
+        updated_at = now()
+    where perm_id = v_menu_perm_id;
+  end if;
+
+  select perm_id
+    into v_view_perm_id
+  from sys_permission
+  where code = 'user_data_scope:view'
+  order by perm_id
+  limit 1;
+
+  if v_view_perm_id is null then
+    insert into sys_permission (
+      parent_id, name, code, perm_type, level, path, component, icon,
+      sort_order, is_visible, status, description, meta, is_deleted, created_at, updated_at
+    ) values (
+      v_menu_perm_id, '查看数据授权', 'user_data_scope:view', 'button', 3,
+      null, null, null, 1, 1, 1, '查看用户数据授权配置', '{}'::jsonb, 0, now(), now()
+    )
+    returning perm_id into v_view_perm_id;
+  else
+    update sys_permission
+    set parent_id = v_menu_perm_id,
+        name = '查看数据授权',
+        perm_type = 'button',
+        level = 3,
+        sort_order = 1,
+        is_visible = 1,
+        status = 1,
+        description = '查看用户数据授权配置',
+        meta = '{}'::jsonb,
+        is_deleted = 0,
+        updated_at = now()
+    where perm_id = v_view_perm_id;
+  end if;
+
+  select perm_id
+    into v_update_perm_id
+  from sys_permission
+  where code = 'user_data_scope:update'
+  order by perm_id
+  limit 1;
+
+  if v_update_perm_id is null then
+    insert into sys_permission (
+      parent_id, name, code, perm_type, level, path, component, icon,
+      sort_order, is_visible, status, description, meta, is_deleted, created_at, updated_at
+    ) values (
+      v_menu_perm_id, '修改数据授权', 'user_data_scope:update', 'button', 3,
+      null, null, null, 2, 1, 1, '保存用户数据授权配置', '{}'::jsonb, 0, now(), now()
+    )
+    returning perm_id into v_update_perm_id;
+  else
+    update sys_permission
+    set parent_id = v_menu_perm_id,
+        name = '修改数据授权',
+        perm_type = 'button',
+        level = 3,
+        sort_order = 2,
+        is_visible = 1,
+        status = 1,
+        description = '保存用户数据授权配置',
+        meta = '{}'::jsonb,
+        is_deleted = 0,
+        updated_at = now()
+    where perm_id = v_update_perm_id;
+  end if;
+
+  if v_has_role_permission_tenant then
+    insert into sys_role_permission (role_id, perm_id, tenant_id, is_deleted, created_at, updated_at)
+    select role_source.role_id, perm_source.perm_id, role_source.tenant_id, 0, now(), now()
+    from (
+      select distinct role_id, tenant_id
+      from (
+        select r.role_id, r.tenant_id
+        from sys_role r
+        where coalesce(r.is_deleted, 0) = 0
+          and (
+            r.role_code in ('TENANT_ADMIN', 'ADMIN', 'SYS_ADMIN', 'PLATFORM_ADMIN', 'SUPER_ADMIN')
+            or r.role_name ilike '%管理员%'
+            or r.role_name ilike '%admin%'
+          )
+        union
+        select r.role_id, r.tenant_id
+        from sys_user u
+        join sys_user_role ur on ur.user_id = u.user_id and coalesce(ur.is_deleted, 0) = 0
+        join sys_role r on r.role_id = ur.role_id and coalesce(r.is_deleted, 0) = 0
+        where coalesce(u.is_deleted, 0) = 0
+          and u.username = 'admin'
+      ) granted_roles
+    ) role_source
+    cross join (
+      select unnest(array[v_menu_perm_id, v_view_perm_id, v_update_perm_id]) as perm_id
+    ) perm_source
+    where perm_source.perm_id is not null
+      and not exists (
+        select 1
+        from sys_role_permission rp
+        where rp.role_id = role_source.role_id
+          and rp.perm_id = perm_source.perm_id
+      );
+
+    update sys_role_permission rp
+    set tenant_id = coalesce(rp.tenant_id, r.tenant_id),
+        is_deleted = 0,
+        updated_at = now()
+    from sys_role r,
+         sys_permission p
+    where rp.role_id = r.role_id
+      and p.perm_id = rp.perm_id
+      and coalesce(r.is_deleted, 0) = 0
+      and p.code in ('menu:data-scope-users', 'user_data_scope:view', 'user_data_scope:update')
+      and (
+        r.role_code in ('TENANT_ADMIN', 'ADMIN', 'SYS_ADMIN', 'PLATFORM_ADMIN', 'SUPER_ADMIN')
+        or r.role_name ilike '%管理员%'
+        or r.role_name ilike '%admin%'
+        or exists (
+          select 1
+          from sys_user u
+          join sys_user_role ur on ur.user_id = u.user_id and coalesce(ur.is_deleted, 0) = 0
+          where coalesce(u.is_deleted, 0) = 0
+            and u.username = 'admin'
+            and ur.role_id = r.role_id
+        )
+      );
+  else
+    insert into sys_role_permission (role_id, perm_id, is_deleted, created_at, updated_at)
+    select role_source.role_id, perm_source.perm_id, 0, now(), now()
+    from (
+      select distinct role_id
+      from (
+        select r.role_id
+        from sys_role r
+        where coalesce(r.is_deleted, 0) = 0
+          and (
+            r.role_code in ('TENANT_ADMIN', 'ADMIN', 'SYS_ADMIN', 'PLATFORM_ADMIN', 'SUPER_ADMIN')
+            or r.role_name ilike '%管理员%'
+            or r.role_name ilike '%admin%'
+          )
+        union
+        select r.role_id
+        from sys_user u
+        join sys_user_role ur on ur.user_id = u.user_id and coalesce(ur.is_deleted, 0) = 0
+        join sys_role r on r.role_id = ur.role_id and coalesce(r.is_deleted, 0) = 0
+        where coalesce(u.is_deleted, 0) = 0
+          and u.username = 'admin'
+      ) granted_roles
+    ) role_source
+    cross join (
+      select unnest(array[v_menu_perm_id, v_view_perm_id, v_update_perm_id]) as perm_id
+    ) perm_source
+    where perm_source.perm_id is not null
+      and not exists (
+        select 1
+        from sys_role_permission rp
+        where rp.role_id = role_source.role_id
+          and rp.perm_id = perm_source.perm_id
+      );
+
+    update sys_role_permission rp
+    set is_deleted = 0,
+        updated_at = now()
+    from sys_role r,
+         sys_permission p
+    where rp.role_id = r.role_id
+      and p.perm_id = rp.perm_id
+      and coalesce(r.is_deleted, 0) = 0
+      and p.code in ('menu:data-scope-users', 'user_data_scope:view', 'user_data_scope:update')
+      and (
+        r.role_code in ('TENANT_ADMIN', 'ADMIN', 'SYS_ADMIN', 'PLATFORM_ADMIN', 'SUPER_ADMIN')
+        or r.role_name ilike '%管理员%'
+        or r.role_name ilike '%admin%'
+        or exists (
+          select 1
+          from sys_user u
+          join sys_user_role ur on ur.user_id = u.user_id and coalesce(ur.is_deleted, 0) = 0
+          where coalesce(u.is_deleted, 0) = 0
+            and u.username = 'admin'
+            and ur.role_id = r.role_id
+        )
+      );
+  end if;
+end
+$$;
diff --git a/sql/20260701_work_report_button_permissions_pg17.sql b/sql/20260701_work_report_button_permissions_pg17.sql
new file mode 100644
index 00000000..4c33d782
--- /dev/null
+++ b/sql/20260701_work_report_button_permissions_pg17.sql
@@ -0,0 +1,242 @@
+-- 20260701
+-- 新增工作日报按钮权限点：
+--   daily_report:notify_users  选择通知用户
+--   daily_report:attachments   日报附件
+-- 执行后可在 frontend1 权限管理与角色权限绑定中维护这些权限。
+-- 可重复执行；CRM业务与工作节点仅用于权限管理，不在后台侧边栏显示。
+-- “# 关联对象是否必填”继续由 application.yml 的 work-report.daily-report 配置控制。
+
+begin;
+
+set search_path to public;
+
+do $$
+declare
+  v_business_parent_id bigint;
+  v_work_menu_perm_id bigint;
+  v_notify_users_perm_id bigint;
+  v_attachments_perm_id bigint;
+  v_has_role_permission_tenant boolean;
+begin
+  perform setval('sys_permission_perm_id_seq', coalesce((select max(perm_id) from sys_permission), 0) + 1, false);
+  perform setval('sys_role_permission_id_seq', coalesce((select max(id) from sys_role_permission), 0) + 1, false);
+
+  select exists (
+    select 1
+    from information_schema.columns
+    where table_schema = current_schema()
+      and table_name = 'sys_role_permission'
+      and column_name = 'tenant_id'
+  ) into v_has_role_permission_tenant;
+
+  select perm_id
+    into v_business_parent_id
+  from sys_permission
+  where code = 'crm'
+  order by perm_id
+  limit 1;
+
+  if v_business_parent_id is null then
+    insert into sys_permission (
+      parent_id, name, code, perm_type, level, path, component, icon,
+      sort_order, is_visible, status, description, meta, is_deleted, created_at, updated_at
+    ) values (
+      null, 'CRM业务', 'crm', 'directory', 1, null, null, 'AppstoreOutlined',
+      10, 0, 1, 'CRM前台业务权限分组，仅用于权限管理，不在后台侧边栏显示', '{}'::jsonb, 0, now(), now()
+    )
+    returning perm_id into v_business_parent_id;
+  else
+    update sys_permission
+    set name = 'CRM业务',
+        perm_type = 'directory',
+        level = 1,
+        path = null,
+        component = null,
+        icon = coalesce(nullif(icon, ''), 'AppstoreOutlined'),
+        sort_order = coalesce(sort_order, 10),
+        is_visible = 0,
+        status = 1,
+        description = 'CRM前台业务权限分组，仅用于权限管理，不在后台侧边栏显示',
+        meta = coalesce(meta, '{}'::jsonb),
+        is_deleted = 0,
+        updated_at = now()
+    where perm_id = v_business_parent_id;
+  end if;
+
+  select perm_id
+    into v_work_menu_perm_id
+  from sys_permission
+  where code = 'menu:work'
+  order by perm_id
+  limit 1;
+
+  if v_work_menu_perm_id is null then
+    insert into sys_permission (
+      parent_id, name, code, perm_type, level, path, component, icon,
+      sort_order, is_visible, status, description, meta, is_deleted, created_at, updated_at
+    ) values (
+      v_business_parent_id, '工作', 'menu:work', 'menu', 2,
+      null, null, 'ScheduleOutlined', 40, 0, 1,
+      'CRM前台工作台与日报权限分组，仅用于权限管理，不在后台侧边栏显示', '{}'::jsonb, 0, now(), now()
+    )
+    returning perm_id into v_work_menu_perm_id;
+  else
+    update sys_permission
+    set parent_id = v_business_parent_id,
+        name = '工作',
+        perm_type = 'menu',
+        level = 2,
+        path = null,
+        component = null,
+        icon = coalesce(nullif(icon, ''), 'ScheduleOutlined'),
+        sort_order = coalesce(sort_order, 40),
+        is_visible = 0,
+        status = 1,
+        description = 'CRM前台工作台与日报权限分组，仅用于权限管理，不在后台侧边栏显示',
+        meta = coalesce(meta, '{}'::jsonb),
+        is_deleted = 0,
+        updated_at = now()
+    where perm_id = v_work_menu_perm_id;
+  end if;
+
+  select perm_id
+    into v_notify_users_perm_id
+  from sys_permission
+  where code = 'daily_report:notify_users'
+  order by perm_id
+  limit 1;
+
+  if v_notify_users_perm_id is null then
+    insert into sys_permission (
+      parent_id, name, code, perm_type, level, path, component, icon,
+      sort_order, is_visible, status, description, meta, is_deleted, created_at, updated_at
+    ) values (
+      v_work_menu_perm_id, '选择通知用户', 'daily_report:notify_users', 'button', 3,
+      null, null, null, 1, 1, 1, '控制CRM前台日报中的选择通知用户按钮和用户选择接口', '{}'::jsonb, 0, now(), now()
+    )
+    returning perm_id into v_notify_users_perm_id;
+  else
+    update sys_permission
+    set parent_id = v_work_menu_perm_id,
+        name = '选择通知用户',
+        perm_type = 'button',
+        level = 3,
+        path = null,
+        component = null,
+        icon = null,
+        sort_order = 1,
+        is_visible = 1,
+        status = 1,
+        description = '控制CRM前台日报中的选择通知用户按钮和用户选择接口',
+        meta = '{}'::jsonb,
+        is_deleted = 0,
+        updated_at = now()
+    where perm_id = v_notify_users_perm_id;
+  end if;
+
+  select perm_id
+    into v_attachments_perm_id
+  from sys_permission
+  where code = 'daily_report:attachments'
+  order by perm_id
+  limit 1;
+
+  if v_attachments_perm_id is null then
+    insert into sys_permission (
+      parent_id, name, code, perm_type, level, path, component, icon,
+      sort_order, is_visible, status, description, meta, is_deleted, created_at, updated_at
+    ) values (
+      v_work_menu_perm_id, '日报附件', 'daily_report:attachments', 'button', 3,
+      null, null, null, 2, 1, 1, '控制CRM前台日报附件按钮、附件上传接口和日报附件保存', '{}'::jsonb, 0, now(), now()
+    )
+    returning perm_id into v_attachments_perm_id;
+  else
+    update sys_permission
+    set parent_id = v_work_menu_perm_id,
+        name = '日报附件',
+        perm_type = 'button',
+        level = 3,
+        path = null,
+        component = null,
+        icon = null,
+        sort_order = 2,
+        is_visible = 1,
+        status = 1,
+        description = '控制CRM前台日报附件按钮、附件上传接口和日报附件保存',
+        meta = '{}'::jsonb,
+        is_deleted = 0,
+        updated_at = now()
+    where perm_id = v_attachments_perm_id;
+  end if;
+
+  if v_has_role_permission_tenant then
+    insert into sys_role_permission (role_id, perm_id, tenant_id, is_deleted, created_at, updated_at)
+    select distinct r.role_id, permission_source.perm_id, r.tenant_id, 0, now(), now()
+    from sys_role r
+    cross join (
+      select unnest(array[v_business_parent_id, v_work_menu_perm_id, v_notify_users_perm_id, v_attachments_perm_id]) as perm_id
+    ) permission_source
+    where coalesce(r.is_deleted, 0) = 0
+      and permission_source.perm_id is not null
+      and (
+        r.role_code in ('TENANT_ADMIN', 'ADMIN', 'SYS_ADMIN', 'PLATFORM_ADMIN', 'SUPER_ADMIN')
+        or r.role_name ilike '%管理员%'
+        or r.role_name ilike '%admin%'
+      )
+      and not exists (
+        select 1
+        from sys_role_permission rp
+        where rp.role_id = r.role_id
+          and rp.perm_id = permission_source.perm_id
+      );
+
+    update sys_role_permission rp
+    set tenant_id = coalesce(rp.tenant_id, r.tenant_id),
+        is_deleted = 0,
+        updated_at = now()
+    from sys_role r
+    where rp.role_id = r.role_id
+      and rp.perm_id in (v_business_parent_id, v_work_menu_perm_id, v_notify_users_perm_id, v_attachments_perm_id)
+      and coalesce(r.is_deleted, 0) = 0
+      and (
+        r.role_code in ('TENANT_ADMIN', 'ADMIN', 'SYS_ADMIN', 'PLATFORM_ADMIN', 'SUPER_ADMIN')
+        or r.role_name ilike '%管理员%'
+        or r.role_name ilike '%admin%'
+      );
+  else
+    insert into sys_role_permission (role_id, perm_id, is_deleted, created_at, updated_at)
+    select distinct r.role_id, permission_source.perm_id, 0, now(), now()
+    from sys_role r
+    cross join (
+      select unnest(array[v_business_parent_id, v_work_menu_perm_id, v_notify_users_perm_id, v_attachments_perm_id]) as perm_id
+    ) permission_source
+    where coalesce(r.is_deleted, 0) = 0
+      and permission_source.perm_id is not null
+      and (
+        r.role_code in ('TENANT_ADMIN', 'ADMIN', 'SYS_ADMIN', 'PLATFORM_ADMIN', 'SUPER_ADMIN')
+        or r.role_name ilike '%管理员%'
+        or r.role_name ilike '%admin%'
+      )
+      and not exists (
+        select 1
+        from sys_role_permission rp
+        where rp.role_id = r.role_id
+          and rp.perm_id = permission_source.perm_id
+      );
+
+    update sys_role_permission rp
+    set is_deleted = 0,
+        updated_at = now()
+    from sys_role r
+    where rp.role_id = r.role_id
+      and rp.perm_id in (v_business_parent_id, v_work_menu_perm_id, v_notify_users_perm_id, v_attachments_perm_id)
+      and coalesce(r.is_deleted, 0) = 0
+      and (
+        r.role_code in ('TENANT_ADMIN', 'ADMIN', 'SYS_ADMIN', 'PLATFORM_ADMIN', 'SUPER_ADMIN')
+        or r.role_name ilike '%管理员%'
+        or r.role_name ilike '%admin%'
+      );
+  end if;
+end $$;
+
+commit;
diff --git a/sql/init_full_pg17.sql b/sql/init_full_pg17.sql
index 043e207a..c2edd31d 100644
--- a/sql/init_full_pg17.sql
+++ b/sql/init_full_pg17.sql
@@ -141,6 +141,7 @@ create table if not exists crm_opportunity (
   competitor_name varchar(200),
   latest_progress text,
   next_plan text,
+  updated_by bigint,
   archived boolean not null default false,
   archived_at timestamptz,
   pushed_to_oms boolean not null default false,
@@ -861,6 +862,7 @@ WITH column_comments(table_name, column_name, comment_text) AS (
     ('crm_opportunity', 'competitor_name', '竞品名称'),
     ('crm_opportunity', 'latest_progress', '项目最新进展'),
     ('crm_opportunity', 'next_plan', '下一步销售计划'),
+    ('crm_opportunity', 'updated_by', '更新人ID'),
     ('crm_opportunity', 'archived', '是否归档'),
     ('crm_opportunity', 'archived_at', '归档时间'),
     ('crm_opportunity', 'pushed_to_oms', '是否已推送OMS'),
