diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java index 43ae7273..3f7116cc 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java @@ -187,8 +187,8 @@ public class AihrMobileController { } @GetMapping("/practice/team") - public R practiceTeam() { - return R.ok(mobileSeedService.practiceTeam(supervisorScopeExtPartyId())); + public R practiceTeam(@RequestParam(defaultValue = "false") boolean includeProfiles) { + return R.ok(mobileSeedService.practiceTeam(supervisorScopeExtPartyId(), includeProfiles)); } @GetMapping("/practice/reviews/{id}") diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrPracticeDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrPracticeDto.java index 3436d36e..e34c6f61 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrPracticeDto.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrPracticeDto.java @@ -424,11 +424,28 @@ public final class AihrPracticeDto { public record TeamMemberResponse(String extPartyId, String name) { } + /** + * Lightweight, full-team activity aggregate. Unlike the recent-detail lists, + * this is not capped globally so risk labels remain correct for every member. + */ + public record PracticeTeamProfileResponse( + String extPartyId, + Integer latestScore, + String latestScene, + String lastPracticeAt, + Boolean completedToday, + Integer pendingReview, + Integer pendingLowScore, + Integer pendingAssignment + ) { + } + public record PracticeTeamResponse( List members, List records, List assignments, - List mistakes + List mistakes, + List profiles ) { } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrMobileSeedService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrMobileSeedService.java index 12d5634a..6f97cb08 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrMobileSeedService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrMobileSeedService.java @@ -103,7 +103,11 @@ public class AihrMobileSeedService { } public PracticeTeamResponse practiceTeam(String supervisorExtPartyId) { - return practiceSeedService.practiceTeamSnapshot(supervisorExtPartyId, 200); + return practiceTeam(supervisorExtPartyId, false); + } + + public PracticeTeamResponse practiceTeam(String supervisorExtPartyId, boolean includeProfiles) { + return practiceSeedService.practiceTeamSnapshot(supervisorExtPartyId, 200, includeProfiles); } public String requireSupervisorIdentity(String authenticatedIdentity) { diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java index 24048d0e..fe944d9c 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java @@ -26,6 +26,7 @@ import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentBatchResponse; import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentRequest; import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentResponse; import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentTarget; +import org.dromara.aihr.domain.AihrPracticeDto.PracticeTeamProfileResponse; import org.dromara.aihr.domain.AihrPracticeDto.PracticeTeamResponse; import org.dromara.aihr.domain.AihrPracticeDto.PromptTemplateRequest; import org.dromara.aihr.domain.AihrPracticeDto.PromptTemplateResponse; @@ -988,19 +989,25 @@ public class AihrPracticeSeedService { } public PracticeTeamResponse practiceTeamSnapshot(String supervisorExtPartyId, int limit) { + return practiceTeamSnapshot(supervisorExtPartyId, limit, false); + } + + public PracticeTeamResponse practiceTeamSnapshot(String supervisorExtPartyId, int limit, boolean includeProfiles) { if (!orgSnapshotTableExists() && !isDemoProfile()) { - return new PracticeTeamResponse(List.of(), List.of(), List.of(), List.of()); + return new PracticeTeamResponse(List.of(), List.of(), List.of(), List.of(), List.of()); } TeamScope scope = teamScope(supervisorExtPartyId); int safeLimit = normalizeTeamLimit(limit); if (scope.scoped() && scope.extPartyIds().isEmpty()) { - return new PracticeTeamResponse(List.of(), List.of(), List.of(), List.of()); + return new PracticeTeamResponse(List.of(), List.of(), List.of(), List.of(), List.of()); } + List members = practiceTeamMembers(scope); return new PracticeTeamResponse( - practiceTeamMembers(scope), + members, practiceTeamRecords(scope, safeLimit), practiceTeamAssignments(scope, safeLimit), - practiceTeamMistakes(scope, safeLimit) + practiceTeamMistakes(scope, safeLimit), + includeProfiles ? practiceTeamProfileResponses(scope, members) : List.of() ); } @@ -1146,6 +1153,162 @@ public class AihrPracticeSeedService { return jdbcTemplate.query(sql, this::mapAssignment, args.toArray()); } + private List practiceTeamProfileResponses(TeamScope scope, + List members) { + return buildTeamProfileResponses( + members, + practiceTeamProfileRecordSummaries(scope), + practiceTeamProfileAssignmentSummaries(scope) + ); + } + + /** + * The team list cannot derive risk from the recent-detail payload because that + * payload is intentionally capped. Aggregate all activity server-side, then + * return only one compact fact row for each active member. + */ + static List buildTeamProfileResponses( + List members, + List records, + List assignments + ) { + Map aggregates = new LinkedHashMap<>(); + for (TeamMemberResponse member : members) { + String extPartyId = firstNonBlankStatic(member == null ? null : member.extPartyId(), ""); + if (!extPartyId.isEmpty()) { + aggregates.putIfAbsent(extPartyId, new TeamProfileAggregate(extPartyId)); + } + } + + for (TeamProfileRecordSummary record : records) { + TeamProfileAggregate aggregate = aggregates.get(firstNonBlankStatic(record == null ? null : record.extPartyId(), "")); + if (aggregate == null || record == null) { + continue; + } + aggregate.latestScore = record.latestScore(); + aggregate.latestScene = firstNonBlankStatic(record.latestScene(), ""); + aggregate.latestPracticeAt = record.latestPracticeAt(); + aggregate.completedToday = record.completedToday(); + aggregate.pendingReview = record.pendingReview(); + aggregate.pendingLowScore = record.pendingLowScore(); + } + + for (TeamProfileAssignmentSummary assignment : assignments) { + TeamProfileAggregate aggregate = aggregates.get(firstNonBlankStatic(assignment == null ? null : assignment.extPartyId(), "")); + if (aggregate == null || assignment == null) { + continue; + } + aggregate.pendingAssignment = assignment.pendingAssignment(); + aggregate.latestAssignmentScene = firstNonBlankStatic(assignment.latestScene(), ""); + } + + List result = new ArrayList<>(); + for (TeamProfileAggregate aggregate : aggregates.values()) { + result.add(new PracticeTeamProfileResponse( + aggregate.extPartyId, + aggregate.latestScore, + firstNonBlankStatic(aggregate.latestScene, firstNonBlankStatic(aggregate.latestAssignmentScene, "")), + formatTimestamp(aggregate.latestPracticeAt), + aggregate.completedToday, + aggregate.pendingReview, + aggregate.pendingLowScore, + aggregate.pendingAssignment + )); + } + return result; + } + + private List practiceTeamProfileRecordSummaries(TeamScope scope) { + ensurePracticeTable(); + List args = new ArrayList<>(); + args.add(tenantId()); + StringBuilder where = new StringBuilder("WHERE s.tenant_id = ? AND s.mode = 'mobile'"); + if (scope.scoped()) { + where.append(" AND s.ext_party_id IN (").append(inClause(scope.extPartyIds().size())).append(")"); + args.addAll(scope.extPartyIds()); + } + boolean hasOrgSnapshot = orgSnapshotTableExists(); + boolean hasPhone = hasOrgSnapshot && orgSnapshotColumnExists("person_phone"); + String identityExpression = hasOrgSnapshot + ? teamCanonicalIdentityExpression("s", hasPhone) + : "s.ext_party_id"; + LocalDate today = LocalDate.now(BUSINESS_ZONE); + args.add(Timestamp.valueOf(today.atStartOfDay())); + args.add(Timestamp.valueOf(today.plusDays(1).atStartOfDay())); + String sql = """ + WITH scoped_records AS ( + SELECT %s AS ext_party_id, s.scenario_name, s.total_score, s.status, s.finished_time, s.id + FROM aihr_practice_session s + %s + ), ranked_records AS ( + SELECT scoped_records.*, + ROW_NUMBER() OVER ( + PARTITION BY ext_party_id + ORDER BY finished_time DESC, id DESC + ) AS latest_rank + FROM scoped_records + ) + SELECT ext_party_id, + MAX(CASE WHEN latest_rank = 1 THEN total_score END) AS latest_score, + MAX(CASE WHEN latest_rank = 1 THEN scenario_name END) AS latest_scene, + MAX(CASE WHEN latest_rank = 1 THEN finished_time END) AS latest_practice_at, + MAX(CASE WHEN finished_time >= ? AND finished_time < ? THEN 1 ELSE 0 END) AS completed_today, + SUM(CASE WHEN status = '待复盘' THEN 1 ELSE 0 END) AS pending_review, + MIN(CASE WHEN status = '待复盘' AND total_score < 80 THEN total_score END) AS pending_low_score + FROM ranked_records + GROUP BY ext_party_id + """.formatted(identityExpression, where); + return jdbcTemplate.query(sql, (rs, rowNum) -> new TeamProfileRecordSummary( + rs.getString("ext_party_id"), + rs.getObject("latest_score") == null ? null : rs.getInt("latest_score"), + rs.getString("latest_scene"), + rs.getTimestamp("latest_practice_at"), + rs.getInt("completed_today") > 0, + rs.getInt("pending_review"), + rs.getObject("pending_low_score") == null ? null : rs.getInt("pending_low_score") + ), args.toArray()); + } + + private List practiceTeamProfileAssignmentSummaries(TeamScope scope) { + ensureAssignmentTable(); + List args = new ArrayList<>(); + args.add(tenantId()); + StringBuilder where = new StringBuilder("WHERE a.tenant_id = ? AND a.source <> 'daily'"); + if (scope.scoped()) { + where.append(" AND a.ext_party_id IN (").append(inClause(scope.extPartyIds().size())).append(")"); + args.addAll(scope.extPartyIds()); + } + boolean hasOrgSnapshot = orgSnapshotTableExists(); + boolean hasPhone = hasOrgSnapshot && orgSnapshotColumnExists("person_phone"); + String identityExpression = hasOrgSnapshot + ? teamCanonicalIdentityExpression("a", hasPhone) + : "a.ext_party_id"; + String sql = """ + WITH scoped_assignments AS ( + SELECT %s AS ext_party_id, a.scenario_name, a.status, a.create_time, a.id + FROM aihr_practice_assignment a + %s + ), ranked_assignments AS ( + SELECT scoped_assignments.*, + ROW_NUMBER() OVER ( + PARTITION BY ext_party_id + ORDER BY create_time DESC, id DESC + ) AS latest_rank + FROM scoped_assignments + ) + SELECT ext_party_id, + MAX(CASE WHEN latest_rank = 1 THEN scenario_name END) AS latest_scene, + SUM(CASE WHEN status IS NULL OR status <> '已完成' THEN 1 ELSE 0 END) AS pending_assignment + FROM ranked_assignments + GROUP BY ext_party_id + """.formatted(identityExpression, where); + return jdbcTemplate.query(sql, (rs, rowNum) -> new TeamProfileAssignmentSummary( + rs.getString("ext_party_id"), + rs.getString("latest_scene"), + rs.getInt("pending_assignment") + ), args.toArray()); + } + private List practiceTeamMistakes(TeamScope scope, int limit) { ensurePracticeTable(); List args = new ArrayList<>(); @@ -4597,7 +4760,7 @@ public class AihrPracticeSeedService { return Math.min(limit, 200); } - private String formatTimestamp(Timestamp value) { + private static String formatTimestamp(Timestamp value) { return value == null ? "" : value.toLocalDateTime().format(TIME_FORMATTER); } @@ -5341,6 +5504,30 @@ public class AihrPracticeSeedService { Set ambiguousExtPartyIds) { } + static record TeamProfileRecordSummary(String extPartyId, Integer latestScore, String latestScene, + Timestamp latestPracticeAt, boolean completedToday, int pendingReview, + Integer pendingLowScore) { + } + + static record TeamProfileAssignmentSummary(String extPartyId, String latestScene, int pendingAssignment) { + } + + private static final class TeamProfileAggregate { + private final String extPartyId; + private Integer latestScore; + private String latestScene = ""; + private Timestamp latestPracticeAt; + private boolean completedToday; + private Integer pendingLowScore; + private int pendingReview; + private int pendingAssignment; + private String latestAssignmentScene = ""; + + private TeamProfileAggregate(String extPartyId) { + this.extPartyId = extPartyId; + } + } + private record TeamScope(boolean scoped, List extPartyIds) { static TeamScope unscoped() { return new TeamScope(false, List.of()); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeDataToolServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeDataToolServiceTest.java index 4e34c1b4..e82d4f51 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeDataToolServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeDataToolServiceTest.java @@ -47,7 +47,7 @@ class AihrKnowledgeDataToolServiceTest { AihrMobileSeedService mobile = mock(AihrMobileSeedService.class); when(mobile.requireSupervisorIdentity("supervisor-1")).thenReturn("canonical-supervisor"); when(mobile.practiceTeam("canonical-supervisor")) - .thenReturn(new PracticeTeamResponse(List.of(), List.of(), List.of(), List.of())); + .thenReturn(new PracticeTeamResponse(List.of(), List.of(), List.of(), List.of(), List.of())); var service = new AihrKnowledgeDataToolService(mobile); var principal = new AihrKnowledgePrincipal("000000", 2L, "app_user", "supervisor-1", Set.of("employee", "supervisor"), Set.of("P1"), "app"); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java index 5a4ae154..9b598e39 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java @@ -8,9 +8,11 @@ import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentRequest; import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentResponse; import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentBatchRequest; import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentTarget; +import org.dromara.aihr.domain.AihrPracticeDto.PracticeTeamProfileResponse; import org.dromara.aihr.domain.AihrPracticeDto.ReviewAdviceRequest; import org.dromara.aihr.domain.AihrPracticeDto.ScenarioResponse; import org.dromara.aihr.domain.AihrPracticeDto.StartRequest; +import org.dromara.aihr.domain.AihrPracticeDto.TeamMemberResponse; import org.dromara.aihr.domain.AihrPracticeDto.TurnRequest; import org.dromara.common.core.constant.HttpStatus; import org.dromara.common.core.exception.ServiceException; @@ -235,6 +237,50 @@ public class AihrPracticeSeedServiceTest { assertTrue(code.contains("if (!orgSnapshotTableExists() && !isDemoProfile())")); } + @Test + public void teamProfilesAggregateEveryMemberBeforeThePageWindow() throws Exception { + List members = new ArrayList<>(); + for (int index = 0; index < 201; index++) { + members.add(new TeamMemberResponse("EMP-" + index, "员工" + index)); + } + + List profiles = AihrPracticeSeedService.buildTeamProfileResponses( + members, + List.of(new AihrPracticeSeedService.TeamProfileRecordSummary( + "EMP-200", 62, "投诉接待", Timestamp.valueOf("2026-07-23 10:00:00"), true, 1, 62 + )), + List.of(new AihrPracticeSeedService.TeamProfileAssignmentSummary( + "EMP-200", "投诉接待", 1 + )) + ); + + PracticeTeamProfileResponse lastMember = profiles.stream() + .filter(item -> "EMP-200".equals(item.extPartyId())) + .findFirst() + .orElseThrow(); + assertEquals(62, lastMember.pendingLowScore()); + assertEquals(1, lastMember.pendingReview()); + assertEquals(1, lastMember.pendingAssignment()); + assertTrue(lastMember.completedToday()); + + String source = Files.readString(Path.of("src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java")); + int methodStart = source.indexOf("private List practiceTeamProfileRecordSummaries"); + int methodEnd = source.indexOf("private List practiceTeamProfileAssignmentSummaries", methodStart); + assertTrue(methodStart >= 0 && methodEnd > methodStart); + String recordSummarySource = source.substring(methodStart, methodEnd); + assertFalse(recordSummarySource.contains("LIMIT ?")); + assertTrue(recordSummarySource.contains("ROW_NUMBER() OVER")); + assertTrue(recordSummarySource.contains("GROUP BY ext_party_id")); + assertTrue(recordSummarySource.contains("AS completed_today")); + int assignmentMethodEnd = source.indexOf("private List practiceTeamMistakes", methodEnd); + assertTrue(assignmentMethodEnd > methodEnd); + String assignmentSummarySource = source.substring(methodEnd, assignmentMethodEnd); + assertFalse(assignmentSummarySource.contains("LIMIT ?")); + assertTrue(assignmentSummarySource.contains("ROW_NUMBER() OVER")); + assertTrue(assignmentSummarySource.contains("GROUP BY ext_party_id")); + assertTrue(source.contains("includeProfiles ? practiceTeamProfileResponses(scope, members) : List.of()")); + } + @Test public void calibrationRequiresExplicitCorrectedScore() { AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), null, null, null); @@ -832,7 +878,8 @@ public class AihrPracticeSeedServiceTest { String controllerSource = Files.readString(source); assertTrue(controllerSource.contains("mobileSeedService.practiceReviews(supervisorScopeExtPartyId(),")); - assertTrue(controllerSource.contains("mobileSeedService.practiceTeam(supervisorScopeExtPartyId())")); + assertTrue(controllerSource.contains("@RequestParam(defaultValue = \"false\") boolean includeProfiles")); + assertTrue(controllerSource.contains("mobileSeedService.practiceTeam(supervisorScopeExtPartyId(), includeProfiles)")); assertTrue(controllerSource.contains("mobileSeedService.practiceReview(id, supervisorScopeExtPartyId())")); assertTrue(controllerSource.contains("mobileSeedService.markReviewed(id, bindReviewOperator(request), supervisorScopeExtPartyId())")); assertTrue(controllerSource.contains("mobileSeedService.practiceAlerts(supervisorScopeExtPartyId())")); @@ -1691,8 +1738,7 @@ public class AihrPracticeSeedServiceTest { "ext_party_id", extPartyId ))); } - if (sql.contains("FROM aihr_practice_assignment") - && (sql.contains("SELECT id, ext_party_id") || sql.contains("AS ext_party_id"))) { + if (sql.contains("FROM aihr_practice_assignment") && sql.contains("SELECT id,")) { assignmentsSql = sql; assignmentsArgs = List.of(args); String extPartyId = sql.contains("ext_party_id IN") diff --git a/frontend/src/store/modules/user.ts b/frontend/src/store/modules/user.ts index dc73fe9f..0612d404 100644 --- a/frontend/src/store/modules/user.ts +++ b/frontend/src/store/modules/user.ts @@ -3,6 +3,7 @@ import { getToken, removeToken, setToken } from '@/utils/auth'; import { login as loginApi, logout as logoutApi, getInfo as getUserInfo } from '@/api/login'; import { LoginData } from '@/api/types'; import defAva from '@/assets/images/profile.jpg'; +import { clearPendingBroadcastPublishesForUser } from '@/utils/broadcast-pending'; import { clearDynamicTenantContextState } from '@/utils/tenant-context-state'; import { defineStore } from 'pinia'; import { ref } from 'vue'; @@ -64,6 +65,7 @@ export const useUserStore = defineStore('user', () => { try { await logoutApi(); } finally { + clearPendingBroadcastPublishesForUser(userId.value); token.value = ''; roles.value = []; permissions.value = []; diff --git a/frontend/src/utils/broadcast-pending.test.ts b/frontend/src/utils/broadcast-pending.test.ts new file mode 100644 index 00000000..7ebc7145 --- /dev/null +++ b/frontend/src/utils/broadcast-pending.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { + broadcastPendingStorageKey, + createPendingBroadcastStorage, + type PendingBroadcastPublish +} from './broadcast-pending'; + +class MemoryStorage implements Storage { + private readonly values = new Map(); + + get length() { + return this.values.size; + } + + clear() { + this.values.clear(); + } + + getItem(key: string) { + return this.values.get(key) ?? null; + } + + key(index: number) { + return [...this.values.keys()][index] ?? null; + } + + removeItem(key: string) { + this.values.delete(key); + } + + setItem(key: string, value: string) { + this.values.set(key, String(value)); + } +} + +const pending: PendingBroadcastPublish = { + tenantId: 'tenant-a', + requestId: 'request-1', + title: '夏季服务标准通知', + content: '请按最新标准执行。' +}; + +describe('pending broadcast publish storage', () => { + it('writes a retryable publish only into session storage', () => { + const sessionStorage = new MemoryStorage(); + const legacyLocalStorage = new MemoryStorage(); + const storage = createPendingBroadcastStorage({ sessionStorage, legacyLocalStorage }); + + storage.write('operator-1', pending); + + const key = broadcastPendingStorageKey(pending.tenantId, 'operator-1'); + expect(sessionStorage.getItem(key)).toContain(pending.content); + expect(legacyLocalStorage.getItem(key)).toBeNull(); + }); + + it('moves an existing legacy payload into the current tab and removes its persistent copy', () => { + const sessionStorage = new MemoryStorage(); + const legacyLocalStorage = new MemoryStorage(); + const storage = createPendingBroadcastStorage({ sessionStorage, legacyLocalStorage }); + const key = broadcastPendingStorageKey(pending.tenantId, 'operator-1'); + legacyLocalStorage.setItem(key, JSON.stringify(pending)); + + expect(storage.read('operator-1', pending.tenantId)).toEqual(pending); + expect(sessionStorage.getItem(key)).toContain(pending.content); + expect(legacyLocalStorage.getItem(key)).toBeNull(); + }); + + it('removes unreadable legacy data instead of leaving a message body persisted', () => { + const sessionStorage = new MemoryStorage(); + const legacyLocalStorage = new MemoryStorage(); + const storage = createPendingBroadcastStorage({ sessionStorage, legacyLocalStorage }); + const key = broadcastPendingStorageKey(pending.tenantId, 'operator-1'); + legacyLocalStorage.setItem(key, '{not-json'); + + expect(storage.read('operator-1', pending.tenantId)).toBeUndefined(); + expect(legacyLocalStorage.getItem(key)).toBeNull(); + }); + + it('clears current account pending records from both session and legacy storage on logout', () => { + const sessionStorage = new MemoryStorage(); + const legacyLocalStorage = new MemoryStorage(); + const storage = createPendingBroadcastStorage({ sessionStorage, legacyLocalStorage }); + const currentKey = broadcastPendingStorageKey('tenant-a', 'operator-1'); + const otherUserKey = broadcastPendingStorageKey('tenant-a', 'operator-2'); + const otherTenantKey = broadcastPendingStorageKey('tenant-b', 'operator-1'); + + sessionStorage.setItem(currentKey, JSON.stringify(pending)); + sessionStorage.setItem(otherUserKey, JSON.stringify(pending)); + legacyLocalStorage.setItem(otherTenantKey, JSON.stringify(pending)); + legacyLocalStorage.setItem(otherUserKey, JSON.stringify(pending)); + + storage.clearForUser('operator-1'); + + expect(sessionStorage.getItem(currentKey)).toBeNull(); + expect(legacyLocalStorage.getItem(otherTenantKey)).toBeNull(); + expect(sessionStorage.getItem(otherUserKey)).toContain(pending.content); + expect(legacyLocalStorage.getItem(otherUserKey)).toContain(pending.content); + }); + + it('connects management logout to pending publish cleanup', () => { + const userStore = readFileSync(resolve(process.cwd(), 'src/store/modules/user.ts'), 'utf8'); + expect(userStore).toContain("import { clearPendingBroadcastPublishesForUser } from '@/utils/broadcast-pending';"); + expect(userStore).toContain('clearPendingBroadcastPublishesForUser(userId.value);'); + }); +}); diff --git a/frontend/src/utils/broadcast-pending.ts b/frontend/src/utils/broadcast-pending.ts new file mode 100644 index 00000000..969bde27 --- /dev/null +++ b/frontend/src/utils/broadcast-pending.ts @@ -0,0 +1,112 @@ +export const pendingBroadcastStoragePrefix = 'aihr.broadcast.pending.v2'; + +export interface PendingBroadcastPublish { + tenantId: string; + requestId: string; + title: string; + content: string; +} + +export interface BroadcastPendingStorageOptions { + sessionStorage: Storage; + legacyLocalStorage?: Storage; +} + +const accountKey = (userId: string | number) => String(userId).trim(); + +export const broadcastPendingStorageKey = (tenantId: string, userId: string | number) => { + const normalizedTenantId = String(tenantId).trim(); + const normalizedUserId = accountKey(userId); + return normalizedTenantId && normalizedUserId + ? `${pendingBroadcastStoragePrefix}:${normalizedTenantId}:${normalizedUserId}` + : ''; +}; + +const parsePendingPublish = (raw: string | null): PendingBroadcastPublish | undefined => { + if (!raw) return undefined; + try { + const candidate = JSON.parse(raw) as Partial; + if ( + typeof candidate.tenantId !== 'string' + || typeof candidate.requestId !== 'string' + || typeof candidate.title !== 'string' + || typeof candidate.content !== 'string' + ) { + return undefined; + } + return { + tenantId: candidate.tenantId, + requestId: candidate.requestId, + title: candidate.title, + content: candidate.content + }; + } catch { + return undefined; + } +}; + +const removeUserEntries = (storage: Storage | undefined, userId: string | number) => { + const normalizedUserId = accountKey(userId); + if (!storage || !normalizedUserId) return; + const suffix = `:${normalizedUserId}`; + const keys = Array.from({ length: storage.length }, (_, index) => storage.key(index)) + .filter((key): key is string => Boolean(key && key.startsWith(`${pendingBroadcastStoragePrefix}:`) && key.endsWith(suffix))); + keys.forEach((key) => storage.removeItem(key)); +}; + +/** + * Keeps retryable broadcast publishes in the current tab only. Legacy local + * storage entries are migrated once, then removed so message content does not + * remain on a shared browser after the tab or session ends. + */ +export const createPendingBroadcastStorage = ({ sessionStorage, legacyLocalStorage }: BroadcastPendingStorageOptions) => ({ + write(userId: string | number, pending: PendingBroadcastPublish) { + const key = broadcastPendingStorageKey(pending.tenantId, userId); + if (!key) throw new Error('missing broadcast pending storage key'); + sessionStorage.setItem(key, JSON.stringify(pending)); + }, + + read(userId: string | number, tenantId: string) { + const key = broadcastPendingStorageKey(tenantId, userId); + if (!key) return undefined; + const currentRaw = sessionStorage.getItem(key); + const current = parsePendingPublish(currentRaw); + if (current) return current; + + if (currentRaw) sessionStorage.removeItem(key); + + const legacyRaw = legacyLocalStorage?.getItem(key) ?? null; + const legacy = parsePendingPublish(legacyRaw); + if (!legacy) { + if (legacyRaw) legacyLocalStorage?.removeItem(key); + return undefined; + } + sessionStorage.setItem(key, JSON.stringify(legacy)); + legacyLocalStorage?.removeItem(key); + return legacy; + }, + + remove(userId: string | number, tenantId: string) { + const key = broadcastPendingStorageKey(tenantId, userId); + if (!key) return; + sessionStorage.removeItem(key); + legacyLocalStorage?.removeItem(key); + }, + + clearForUser(userId: string | number) { + removeUserEntries(sessionStorage, userId); + removeUserEntries(legacyLocalStorage, userId); + } +}); + +export const clearPendingBroadcastPublishesForUser = (userId: string | number) => { + if (typeof window === 'undefined') return; + try { + createPendingBroadcastStorage({ + sessionStorage: window.sessionStorage, + legacyLocalStorage: window.localStorage + }).clearForUser(userId); + } catch { + // Browser privacy mode can deny storage access. Logout must still complete. + } +}; diff --git a/frontend/src/views/content/broadcast.vue b/frontend/src/views/content/broadcast.vue index 1d541567..792d7295 100644 --- a/frontend/src/views/content/broadcast.vue +++ b/frontend/src/views/content/broadcast.vue @@ -147,6 +147,11 @@ import { type BroadcastStatus } from '@/api/aihr/broadcast'; import { useUserStore } from '@/store/modules/user'; +import { + broadcastPendingStorageKey, + createPendingBroadcastStorage, + type PendingBroadcastPublish +} from '@/utils/broadcast-pending'; const messages = ref([]); const total = ref(0); @@ -173,10 +178,8 @@ const rules = { const displayTime = (value?: string) => (value ? value.replace('T', ' ').slice(0, 16) : '—'); const createRequestId = () => globalThis.crypto?.randomUUID?.() ?? `broadcast-${Date.now()}-${Math.random().toString(36).slice(2, 12)}`; -const pendingPublishStoragePrefix = 'aihr.broadcast.pending.v2'; -type PendingPublish = { tenantId: string; requestId: string; title: string; content: string }; -const isValidPendingPublish = (candidate: Partial): candidate is PendingPublish => +const isValidPendingPublish = (candidate: Partial): candidate is PendingBroadcastPublish => typeof candidate.tenantId === 'string' && candidate.tenantId === scopeTenantId.value && candidate.tenantId === candidate.tenantId.trim() && candidate.tenantId.length <= 64 && typeof candidate.requestId === 'string' && candidate.requestId === candidate.requestId.trim() && @@ -184,42 +187,35 @@ const isValidPendingPublish = (candidate: Partial): candidate is typeof candidate.title === 'string' && !!candidate.title.trim() && candidate.title.length <= 200 && typeof candidate.content === 'string' && !!candidate.content.trim() && candidate.content.length <= 10_000; +const pendingPublishUserId = () => String(userStore.userId || '').trim(); const pendingPublishStorageKey = (tenantId = pendingTenantId.value || scopeTenantId.value) => - tenantId ? `${pendingPublishStoragePrefix}:${tenantId}:${userStore.userId || 'unknown'}` : ''; + broadcastPendingStorageKey(tenantId, pendingPublishUserId()); -const safelyReadStorage = (storageKey: string) => { +const pendingStorage = () => { + if (typeof window === 'undefined') throw new Error('browser storage unavailable'); + let legacyLocalStorage: Storage | undefined; try { - return storageKey ? localStorage.getItem(storageKey) : null; + legacyLocalStorage = window.localStorage; } catch { - storageAvailable.value = false; - ElMessage.warning('浏览器暂时无法读取待确认发布记录,请保持本页面打开后重试。'); - return null; - } -}; - -const safelyRemoveStorage = (storageKey: string) => { - try { - if (storageKey) localStorage.removeItem(storageKey); - return true; - } catch { - storageAvailable.value = false; - ElMessage.warning('浏览器暂时无法清理待确认发布记录,请勿在其他窗口重复发布。'); - return false; + // A browser may deny persistent storage while still allowing session storage. } + return createPendingBroadcastStorage({ + sessionStorage: window.sessionStorage, + legacyLocalStorage + }); }; const persistPendingPublish = () => { pendingTenantId.value = draftTenantId.value; - const storageKey = pendingPublishStorageKey(); - const pending: PendingPublish = { + const pending: PendingBroadcastPublish = { tenantId: pendingTenantId.value, requestId: form.requestId, title: form.title, content: form.content }; try { - if (!storageKey) throw new Error('missing tenant scope'); - localStorage.setItem(storageKey, JSON.stringify(pending)); + if (!pendingPublishStorageKey()) throw new Error('missing tenant scope'); + pendingStorage().write(pendingPublishUserId(), pending); return true; } catch { storageAvailable.value = false; @@ -230,33 +226,38 @@ const persistPendingPublish = () => { }; const clearPendingPublish = () => { - const cleared = safelyRemoveStorage(pendingPublishStorageKey()); - if (cleared) pendingTenantId.value = ''; - return cleared; + try { + const tenantId = pendingTenantId.value || scopeTenantId.value; + if (!pendingPublishStorageKey(tenantId)) throw new Error('missing tenant scope'); + pendingStorage().remove(pendingPublishUserId(), tenantId); + pendingTenantId.value = ''; + return true; + } catch { + storageAvailable.value = false; + ElMessage.warning('浏览器暂时无法清理待确认发布记录,请勿在其他窗口重复发布。'); + return false; + } }; -const readPendingPublish = (): PendingPublish | undefined => { - const storageKey = pendingPublishStorageKey(scopeTenantId.value); - const stored = safelyReadStorage(storageKey); - if (!stored) return undefined; +const readPendingPublish = (): PendingBroadcastPublish | undefined => { + const tenantId = scopeTenantId.value; try { - const candidate = JSON.parse(stored) as Partial; + if (!pendingPublishStorageKey(tenantId)) return undefined; + const candidate = pendingStorage().read(pendingPublishUserId(), tenantId); + if (!candidate) return undefined; if (!isValidPendingPublish(candidate)) { - throw new Error('invalid pending publish'); + pendingStorage().remove(pendingPublishUserId(), tenantId); + return undefined; } - return { - tenantId: candidate.tenantId, - requestId: candidate.requestId, - title: candidate.title, - content: candidate.content - }; + return candidate; } catch { - safelyRemoveStorage(storageKey); + storageAvailable.value = false; + ElMessage.warning('浏览器暂时无法读取待确认发布记录,请保持本页面打开后重试。'); return undefined; } }; -const applyPendingPublish = (candidate: PendingPublish) => { +const applyPendingPublish = (candidate: PendingBroadcastPublish) => { draftTenantId.value = candidate.tenantId; pendingTenantId.value = candidate.tenantId; form.requestId = candidate.requestId; diff --git a/mobile-uni/src/pages/supervisor/assign/index.vue b/mobile-uni/src/pages/supervisor/assign/index.vue index d632573a..58fba30b 100644 --- a/mobile-uni/src/pages/supervisor/assign/index.vue +++ b/mobile-uni/src/pages/supervisor/assign/index.vue @@ -126,7 +126,7 @@ /> - diff --git a/mobile-uni/src/pages/supervisor/index/index.vue b/mobile-uni/src/pages/supervisor/index/index.vue index aa3b7624..eacbde6a 100644 --- a/mobile-uni/src/pages/supervisor/index/index.vue +++ b/mobile-uni/src/pages/supervisor/index/index.vue @@ -80,6 +80,25 @@ + + + + + + + 近期训练走势 + + {{ visibleAlerts.length ? `${visibleAlerts.length} 项风险待跟进` : '团队状态稳定' }} + + + + {{ point.value }} + + {{ point.label }} + + + + @@ -144,6 +163,7 @@ import type { PracticeAssignment, PracticeRecord, PracticeTeamMember, + PracticeTeamProfileSummary, RoleHome } from '@/types/api'; import { clearAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth'; @@ -155,7 +175,10 @@ import SupervisorTabBar from '@/components/SupervisorTabBar.vue'; import StateBlock from '@/components/StateBlock.vue'; import { buildSupervisorMetrics, + buildTeamProfileRollup, buildTeamProfiles, + buildTrainingTrend, + actionablePracticeAlerts, isSupervisorAccessDenied } from '@/services/supervisor-view'; import { createPageRequestScope, currentAccountKey } from '@/services/request-scope'; @@ -165,6 +188,7 @@ const pendingReviews = ref([]); const records = ref([]); const assignments = ref([]); const members = ref([]); +const teamProfileSummaries = ref([]); const alerts = ref([]); const loggedIn = ref(false); const accessDenied = ref(false); @@ -183,13 +207,28 @@ const todayKey = `${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.ge const supervisorMetrics = computed(() => buildSupervisorMetrics(pendingReviews.value, assignments.value, alerts.value)); const teamProfiles = computed(() => - buildTeamProfiles(records.value, assignments.value, members.value)); -const teamTotal = computed(() => teamProfiles.value.length); + buildTeamProfiles(records.value, assignments.value, members.value, teamProfileSummaries.value)); +const teamProfileRollup = computed(() => buildTeamProfileRollup(teamProfiles.value, todayKey)); +const hasFullTeamProfileSummaries = computed(() => { + if (!members.value.length) return false; + const summaryIds = new Set( + teamProfileSummaries.value.map((item) => item.extPartyId?.trim()).filter(Boolean) + ); + return members.value.every((member) => summaryIds.has(member.extPartyId.trim())); +}); +const trainingTrend = computed(() => buildTrainingTrend(records.value)); +const visibleAlerts = computed(() => actionablePracticeAlerts(alerts.value).slice(0, 3)); +const teamTotal = computed(() => teamProfileRollup.value.teamTotal); const pendingReviewCount = computed(() => - supervisorMetrics.value.find((item) => item.key === 'pendingReviews')?.value || 0); + hasFullTeamProfileSummaries.value + ? teamProfileRollup.value.pendingReview + : supervisorMetrics.value.find((item) => item.key === 'pendingReviews')?.value || 0); const pendingAssignmentCount = computed(() => - supervisorMetrics.value.find((item) => item.key === 'pendingAssignments')?.value || 0); + hasFullTeamProfileSummaries.value + ? teamProfileRollup.value.pendingAssignment + : supervisorMetrics.value.find((item) => item.key === 'pendingAssignments')?.value || 0); const todayDone = computed(() => { + if (hasFullTeamProfileSummaries.value) return teamProfileRollup.value.todayDone; const trained = new Set(); records.value.forEach((item) => { if (item.time.startsWith(todayKey)) trained.add(item.extPartyId || item.trainee); @@ -307,6 +346,7 @@ const clearData = () => { records.value = []; assignments.value = []; members.value = []; + teamProfileSummaries.value = []; alerts.value = []; }; @@ -345,7 +385,7 @@ const loadPage = async () => { const [nextReviews, nextAlerts, snapshot, home, identity] = await Promise.all([ getPracticeReviews(), getPracticeAlerts(), - getPracticeTeamSnapshot(), + getPracticeTeamSnapshot(true), getMobileHome('supervisor').catch((): RoleHome | null => null), apiRequest({ url: '/api/aihr/mobile/me', @@ -361,6 +401,7 @@ const loadPage = async () => { records.value = snapshot.records; assignments.value = snapshot.assignments; members.value = snapshot.members; + teamProfileSummaries.value = snapshot.profiles || []; if (home?.location) communityName.value = home.location.split('·')[0].trim() || '住宅项目'; if (identity?.positionName) supervisorName.value = identity.positionName; } catch (error) { @@ -655,6 +696,68 @@ onUnload(invalidateRequests); grid-template-columns: repeat(3, 1fr); } +.training-trend-panel { + margin-top: 14px; + padding: 18px; +} + +.panel-head-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.trend-summary { + color: #8a8d93; + font-size: 12px; + white-space: nowrap; +} + +.trend-bars { + display: flex; + height: 128px; + align-items: flex-end; + justify-content: space-between; + gap: 10px; + margin-top: 14px; +} + +.trend-bar-item { + display: grid; + flex: 1; + min-width: 0; + height: 100%; + grid-template-rows: 18px 1fr 18px; + align-items: end; + gap: 4px; + text-align: center; +} + +.trend-value, +.trend-label { + color: #71717a; + font-size: 11px; + line-height: 1; +} + +.trend-track { + display: flex; + width: 100%; + height: 100%; + align-items: flex-end; + border-radius: 8px 8px 3px 3px; + background: #f4f4f5; + overflow: hidden; +} + +.trend-fill { + width: 100%; + min-height: 12%; + border-radius: 8px 8px 3px 3px; + background: linear-gradient(180deg, #ff7a83 0%, #e60012 100%); +} + .stat-item { display: flex; align-items: center; diff --git a/mobile-uni/src/pages/supervisor/member/index.vue b/mobile-uni/src/pages/supervisor/member/index.vue index c7f7b459..bf0a31d6 100644 --- a/mobile-uni/src/pages/supervisor/member/index.vue +++ b/mobile-uni/src/pages/supervisor/member/index.vue @@ -51,7 +51,7 @@ {{ totalSessions }} - 训练次数 + 当前明细 {{ profile?.pendingReview ?? 0 }} @@ -66,7 +66,7 @@ 训练趋势 - 最近 {{ trendItems.length }} 次 + 近期可见 {{ trendItems.length }} 次 @@ -86,7 +86,7 @@ - 已完成 {{ totalSessions }} 次训练 + 已加载 {{ totalSessions }} 条近期明细 {{ lowScoreScene ? `连续低分场景:${lowScoreScene}` : '暂无连续低分场景' }} @@ -127,7 +127,7 @@