fix(aihr): harden broadcast and mobile assistant flows

This commit is contained in:
2026-07-23 03:35:36 +08:00
parent 62c8b41672
commit 4b31848e3d
30 changed files with 1127 additions and 208 deletions
@@ -187,8 +187,8 @@ public class AihrMobileController {
} }
@GetMapping("/practice/team") @GetMapping("/practice/team")
public R<PracticeTeamResponse> practiceTeam() { public R<PracticeTeamResponse> practiceTeam(@RequestParam(defaultValue = "false") boolean includeProfiles) {
return R.ok(mobileSeedService.practiceTeam(supervisorScopeExtPartyId())); return R.ok(mobileSeedService.practiceTeam(supervisorScopeExtPartyId(), includeProfiles));
} }
@GetMapping("/practice/reviews/{id}") @GetMapping("/practice/reviews/{id}")
@@ -424,11 +424,28 @@ public final class AihrPracticeDto {
public record TeamMemberResponse(String extPartyId, String name) { 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( public record PracticeTeamResponse(
List<TeamMemberResponse> members, List<TeamMemberResponse> members,
List<RecordResponse> records, List<RecordResponse> records,
List<PracticeAssignmentResponse> assignments, List<PracticeAssignmentResponse> assignments,
List<MistakeAggregateResponse> mistakes List<MistakeAggregateResponse> mistakes,
List<PracticeTeamProfileResponse> profiles
) { ) {
} }
@@ -103,7 +103,11 @@ public class AihrMobileSeedService {
} }
public PracticeTeamResponse practiceTeam(String supervisorExtPartyId) { 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) { public String requireSupervisorIdentity(String authenticatedIdentity) {
@@ -26,6 +26,7 @@ import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentBatchResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentRequest; import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentRequest;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentResponse; import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentTarget; 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.PracticeTeamResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PromptTemplateRequest; import org.dromara.aihr.domain.AihrPracticeDto.PromptTemplateRequest;
import org.dromara.aihr.domain.AihrPracticeDto.PromptTemplateResponse; import org.dromara.aihr.domain.AihrPracticeDto.PromptTemplateResponse;
@@ -988,19 +989,25 @@ public class AihrPracticeSeedService {
} }
public PracticeTeamResponse practiceTeamSnapshot(String supervisorExtPartyId, int limit) { public PracticeTeamResponse practiceTeamSnapshot(String supervisorExtPartyId, int limit) {
return practiceTeamSnapshot(supervisorExtPartyId, limit, false);
}
public PracticeTeamResponse practiceTeamSnapshot(String supervisorExtPartyId, int limit, boolean includeProfiles) {
if (!orgSnapshotTableExists() && !isDemoProfile()) { 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); TeamScope scope = teamScope(supervisorExtPartyId);
int safeLimit = normalizeTeamLimit(limit); int safeLimit = normalizeTeamLimit(limit);
if (scope.scoped() && scope.extPartyIds().isEmpty()) { 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<TeamMemberResponse> members = practiceTeamMembers(scope);
return new PracticeTeamResponse( return new PracticeTeamResponse(
practiceTeamMembers(scope), members,
practiceTeamRecords(scope, safeLimit), practiceTeamRecords(scope, safeLimit),
practiceTeamAssignments(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()); return jdbcTemplate.query(sql, this::mapAssignment, args.toArray());
} }
private List<PracticeTeamProfileResponse> practiceTeamProfileResponses(TeamScope scope,
List<TeamMemberResponse> 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<PracticeTeamProfileResponse> buildTeamProfileResponses(
List<TeamMemberResponse> members,
List<TeamProfileRecordSummary> records,
List<TeamProfileAssignmentSummary> assignments
) {
Map<String, TeamProfileAggregate> 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<PracticeTeamProfileResponse> 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<TeamProfileRecordSummary> practiceTeamProfileRecordSummaries(TeamScope scope) {
ensurePracticeTable();
List<Object> 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<TeamProfileAssignmentSummary> practiceTeamProfileAssignmentSummaries(TeamScope scope) {
ensureAssignmentTable();
List<Object> 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<MistakeAggregateResponse> practiceTeamMistakes(TeamScope scope, int limit) { private List<MistakeAggregateResponse> practiceTeamMistakes(TeamScope scope, int limit) {
ensurePracticeTable(); ensurePracticeTable();
List<Object> args = new ArrayList<>(); List<Object> args = new ArrayList<>();
@@ -4597,7 +4760,7 @@ public class AihrPracticeSeedService {
return Math.min(limit, 200); return Math.min(limit, 200);
} }
private String formatTimestamp(Timestamp value) { private static String formatTimestamp(Timestamp value) {
return value == null ? "" : value.toLocalDateTime().format(TIME_FORMATTER); return value == null ? "" : value.toLocalDateTime().format(TIME_FORMATTER);
} }
@@ -5341,6 +5504,30 @@ public class AihrPracticeSeedService {
Set<String> ambiguousExtPartyIds) { Set<String> 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<String> extPartyIds) { private record TeamScope(boolean scoped, List<String> extPartyIds) {
static TeamScope unscoped() { static TeamScope unscoped() {
return new TeamScope(false, List.of()); return new TeamScope(false, List.of());
@@ -47,7 +47,7 @@ class AihrKnowledgeDataToolServiceTest {
AihrMobileSeedService mobile = mock(AihrMobileSeedService.class); AihrMobileSeedService mobile = mock(AihrMobileSeedService.class);
when(mobile.requireSupervisorIdentity("supervisor-1")).thenReturn("canonical-supervisor"); when(mobile.requireSupervisorIdentity("supervisor-1")).thenReturn("canonical-supervisor");
when(mobile.practiceTeam("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 service = new AihrKnowledgeDataToolService(mobile);
var principal = new AihrKnowledgePrincipal("000000", 2L, "app_user", "supervisor-1", var principal = new AihrKnowledgePrincipal("000000", 2L, "app_user", "supervisor-1",
Set.of("employee", "supervisor"), Set.of("P1"), "app"); Set.of("employee", "supervisor"), Set.of("P1"), "app");
@@ -8,9 +8,11 @@ import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentRequest;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentResponse; import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentBatchRequest; import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentBatchRequest;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentTarget; 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.ReviewAdviceRequest;
import org.dromara.aihr.domain.AihrPracticeDto.ScenarioResponse; import org.dromara.aihr.domain.AihrPracticeDto.ScenarioResponse;
import org.dromara.aihr.domain.AihrPracticeDto.StartRequest; import org.dromara.aihr.domain.AihrPracticeDto.StartRequest;
import org.dromara.aihr.domain.AihrPracticeDto.TeamMemberResponse;
import org.dromara.aihr.domain.AihrPracticeDto.TurnRequest; import org.dromara.aihr.domain.AihrPracticeDto.TurnRequest;
import org.dromara.common.core.constant.HttpStatus; import org.dromara.common.core.constant.HttpStatus;
import org.dromara.common.core.exception.ServiceException; import org.dromara.common.core.exception.ServiceException;
@@ -235,6 +237,50 @@ public class AihrPracticeSeedServiceTest {
assertTrue(code.contains("if (!orgSnapshotTableExists() && !isDemoProfile())")); assertTrue(code.contains("if (!orgSnapshotTableExists() && !isDemoProfile())"));
} }
@Test
public void teamProfilesAggregateEveryMemberBeforeThePageWindow() throws Exception {
List<TeamMemberResponse> members = new ArrayList<>();
for (int index = 0; index < 201; index++) {
members.add(new TeamMemberResponse("EMP-" + index, "员工" + index));
}
List<PracticeTeamProfileResponse> 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<TeamProfileRecordSummary> practiceTeamProfileRecordSummaries");
int methodEnd = source.indexOf("private List<TeamProfileAssignmentSummary> 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<MistakeAggregateResponse> 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 @Test
public void calibrationRequiresExplicitCorrectedScore() { public void calibrationRequiresExplicitCorrectedScore() {
AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), null, null, null); AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), null, null, null);
@@ -832,7 +878,8 @@ public class AihrPracticeSeedServiceTest {
String controllerSource = Files.readString(source); String controllerSource = Files.readString(source);
assertTrue(controllerSource.contains("mobileSeedService.practiceReviews(supervisorScopeExtPartyId(),")); 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.practiceReview(id, supervisorScopeExtPartyId())"));
assertTrue(controllerSource.contains("mobileSeedService.markReviewed(id, bindReviewOperator(request), supervisorScopeExtPartyId())")); assertTrue(controllerSource.contains("mobileSeedService.markReviewed(id, bindReviewOperator(request), supervisorScopeExtPartyId())"));
assertTrue(controllerSource.contains("mobileSeedService.practiceAlerts(supervisorScopeExtPartyId())")); assertTrue(controllerSource.contains("mobileSeedService.practiceAlerts(supervisorScopeExtPartyId())"));
@@ -1691,8 +1738,7 @@ public class AihrPracticeSeedServiceTest {
"ext_party_id", extPartyId "ext_party_id", extPartyId
))); )));
} }
if (sql.contains("FROM aihr_practice_assignment") if (sql.contains("FROM aihr_practice_assignment") && sql.contains("SELECT id,")) {
&& (sql.contains("SELECT id, ext_party_id") || sql.contains("AS ext_party_id"))) {
assignmentsSql = sql; assignmentsSql = sql;
assignmentsArgs = List.of(args); assignmentsArgs = List.of(args);
String extPartyId = sql.contains("ext_party_id IN") String extPartyId = sql.contains("ext_party_id IN")
+2
View File
@@ -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 { login as loginApi, logout as logoutApi, getInfo as getUserInfo } from '@/api/login';
import { LoginData } from '@/api/types'; import { LoginData } from '@/api/types';
import defAva from '@/assets/images/profile.jpg'; import defAva from '@/assets/images/profile.jpg';
import { clearPendingBroadcastPublishesForUser } from '@/utils/broadcast-pending';
import { clearDynamicTenantContextState } from '@/utils/tenant-context-state'; import { clearDynamicTenantContextState } from '@/utils/tenant-context-state';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { ref } from 'vue'; import { ref } from 'vue';
@@ -64,6 +65,7 @@ export const useUserStore = defineStore('user', () => {
try { try {
await logoutApi(); await logoutApi();
} finally { } finally {
clearPendingBroadcastPublishesForUser(userId.value);
token.value = ''; token.value = '';
roles.value = []; roles.value = [];
permissions.value = []; permissions.value = [];
@@ -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<string, string>();
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);');
});
});
+112
View File
@@ -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<PendingBroadcastPublish>;
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.
}
};
+42 -41
View File
@@ -147,6 +147,11 @@ import {
type BroadcastStatus type BroadcastStatus
} from '@/api/aihr/broadcast'; } from '@/api/aihr/broadcast';
import { useUserStore } from '@/store/modules/user'; import { useUserStore } from '@/store/modules/user';
import {
broadcastPendingStorageKey,
createPendingBroadcastStorage,
type PendingBroadcastPublish
} from '@/utils/broadcast-pending';
const messages = ref<BroadcastAdminMessage[]>([]); const messages = ref<BroadcastAdminMessage[]>([]);
const total = ref(0); const total = ref(0);
@@ -173,10 +178,8 @@ const rules = {
const displayTime = (value?: string) => (value ? value.replace('T', ' ').slice(0, 16) : '—'); 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 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<PendingPublish>): candidate is PendingPublish => const isValidPendingPublish = (candidate: Partial<PendingBroadcastPublish>): candidate is PendingBroadcastPublish =>
typeof candidate.tenantId === 'string' && candidate.tenantId === scopeTenantId.value && typeof candidate.tenantId === 'string' && candidate.tenantId === scopeTenantId.value &&
candidate.tenantId === candidate.tenantId.trim() && candidate.tenantId.length <= 64 && candidate.tenantId === candidate.tenantId.trim() && candidate.tenantId.length <= 64 &&
typeof candidate.requestId === 'string' && candidate.requestId === candidate.requestId.trim() && typeof candidate.requestId === 'string' && candidate.requestId === candidate.requestId.trim() &&
@@ -184,42 +187,35 @@ const isValidPendingPublish = (candidate: Partial<PendingPublish>): candidate is
typeof candidate.title === 'string' && !!candidate.title.trim() && candidate.title.length <= 200 && typeof candidate.title === 'string' && !!candidate.title.trim() && candidate.title.length <= 200 &&
typeof candidate.content === 'string' && !!candidate.content.trim() && candidate.content.length <= 10_000; 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) => 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 { try {
return storageKey ? localStorage.getItem(storageKey) : null; legacyLocalStorage = window.localStorage;
} catch { } catch {
storageAvailable.value = false; // A browser may deny persistent storage while still allowing session storage.
ElMessage.warning('浏览器暂时无法读取待确认发布记录,请保持本页面打开后重试。');
return null;
}
};
const safelyRemoveStorage = (storageKey: string) => {
try {
if (storageKey) localStorage.removeItem(storageKey);
return true;
} catch {
storageAvailable.value = false;
ElMessage.warning('浏览器暂时无法清理待确认发布记录,请勿在其他窗口重复发布。');
return false;
} }
return createPendingBroadcastStorage({
sessionStorage: window.sessionStorage,
legacyLocalStorage
});
}; };
const persistPendingPublish = () => { const persistPendingPublish = () => {
pendingTenantId.value = draftTenantId.value; pendingTenantId.value = draftTenantId.value;
const storageKey = pendingPublishStorageKey(); const pending: PendingBroadcastPublish = {
const pending: PendingPublish = {
tenantId: pendingTenantId.value, tenantId: pendingTenantId.value,
requestId: form.requestId, requestId: form.requestId,
title: form.title, title: form.title,
content: form.content content: form.content
}; };
try { try {
if (!storageKey) throw new Error('missing tenant scope'); if (!pendingPublishStorageKey()) throw new Error('missing tenant scope');
localStorage.setItem(storageKey, JSON.stringify(pending)); pendingStorage().write(pendingPublishUserId(), pending);
return true; return true;
} catch { } catch {
storageAvailable.value = false; storageAvailable.value = false;
@@ -230,33 +226,38 @@ const persistPendingPublish = () => {
}; };
const clearPendingPublish = () => { const clearPendingPublish = () => {
const cleared = safelyRemoveStorage(pendingPublishStorageKey()); try {
if (cleared) pendingTenantId.value = ''; const tenantId = pendingTenantId.value || scopeTenantId.value;
return cleared; 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 readPendingPublish = (): PendingBroadcastPublish | undefined => {
const storageKey = pendingPublishStorageKey(scopeTenantId.value); const tenantId = scopeTenantId.value;
const stored = safelyReadStorage(storageKey);
if (!stored) return undefined;
try { try {
const candidate = JSON.parse(stored) as Partial<PendingPublish>; if (!pendingPublishStorageKey(tenantId)) return undefined;
const candidate = pendingStorage().read(pendingPublishUserId(), tenantId);
if (!candidate) return undefined;
if (!isValidPendingPublish(candidate)) { if (!isValidPendingPublish(candidate)) {
throw new Error('invalid pending publish'); pendingStorage().remove(pendingPublishUserId(), tenantId);
return undefined;
} }
return { return candidate;
tenantId: candidate.tenantId,
requestId: candidate.requestId,
title: candidate.title,
content: candidate.content
};
} catch { } catch {
safelyRemoveStorage(storageKey); storageAvailable.value = false;
ElMessage.warning('浏览器暂时无法读取待确认发布记录,请保持本页面打开后重试。');
return undefined; return undefined;
} }
}; };
const applyPendingPublish = (candidate: PendingPublish) => { const applyPendingPublish = (candidate: PendingBroadcastPublish) => {
draftTenantId.value = candidate.tenantId; draftTenantId.value = candidate.tenantId;
pendingTenantId.value = candidate.tenantId; pendingTenantId.value = candidate.tenantId;
form.requestId = candidate.requestId; form.requestId = candidate.requestId;
@@ -126,7 +126,7 @@
/> />
</view> </view>
<button class="primary-button submit-button" :disabled="assigning || historyLoading || !scenarioOptions.length || !selectedMemberIds.length" @click="assignPractice"> <button class="primary-button submit-button tabbar-safe-action" :disabled="assigning || historyLoading || !scenarioOptions.length || !selectedMemberIds.length" @click="assignPractice">
{{ assigning ? '指派中' : `确认派发(${selectedMemberIds.length}人)` }} {{ assigning ? '指派中' : `确认派发(${selectedMemberIds.length}人)` }}
</button> </button>
+108 -5
View File
@@ -80,6 +80,25 @@
</view> </view>
</view> </view>
<view v-if="trainingTrend.length" class="panel training-trend-panel">
<view class="panel-head-row">
<view class="panel-head">
<view class="panel-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3v18h18"/><path d="m7 15 4-4 3 2 5-6"/></svg>
</view>
<text class="panel-title">近期训练走势</text>
</view>
<text class="trend-summary">{{ visibleAlerts.length ? `${visibleAlerts.length} 项风险待跟进` : '团队状态稳定' }}</text>
</view>
<view class="trend-bars" role="img" aria-label="近期训练分数走势">
<view v-for="point in trainingTrend" :key="point.key" class="trend-bar-item">
<text class="trend-value">{{ point.value }}</text>
<view class="trend-track"><view class="trend-fill" :style="{ height: `${Math.max(12, point.value)}%` }" /></view>
<text class="trend-label">{{ point.label }}</text>
</view>
</view>
</view>
<view class="panel todo-panel"> <view class="panel todo-panel">
<view class="panel-head-row"> <view class="panel-head-row">
<view class="panel-head"> <view class="panel-head">
@@ -144,6 +163,7 @@ import type {
PracticeAssignment, PracticeAssignment,
PracticeRecord, PracticeRecord,
PracticeTeamMember, PracticeTeamMember,
PracticeTeamProfileSummary,
RoleHome RoleHome
} from '@/types/api'; } from '@/types/api';
import { clearAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth'; import { clearAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
@@ -155,7 +175,10 @@ import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
import StateBlock from '@/components/StateBlock.vue'; import StateBlock from '@/components/StateBlock.vue';
import { import {
buildSupervisorMetrics, buildSupervisorMetrics,
buildTeamProfileRollup,
buildTeamProfiles, buildTeamProfiles,
buildTrainingTrend,
actionablePracticeAlerts,
isSupervisorAccessDenied isSupervisorAccessDenied
} from '@/services/supervisor-view'; } from '@/services/supervisor-view';
import { createPageRequestScope, currentAccountKey } from '@/services/request-scope'; import { createPageRequestScope, currentAccountKey } from '@/services/request-scope';
@@ -165,6 +188,7 @@ const pendingReviews = ref<PracticeRecord[]>([]);
const records = ref<PracticeRecord[]>([]); const records = ref<PracticeRecord[]>([]);
const assignments = ref<PracticeAssignment[]>([]); const assignments = ref<PracticeAssignment[]>([]);
const members = ref<PracticeTeamMember[]>([]); const members = ref<PracticeTeamMember[]>([]);
const teamProfileSummaries = ref<PracticeTeamProfileSummary[]>([]);
const alerts = ref<PracticeAlert[]>([]); const alerts = ref<PracticeAlert[]>([]);
const loggedIn = ref(false); const loggedIn = ref(false);
const accessDenied = 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(() => const supervisorMetrics = computed(() =>
buildSupervisorMetrics(pendingReviews.value, assignments.value, alerts.value)); buildSupervisorMetrics(pendingReviews.value, assignments.value, alerts.value));
const teamProfiles = computed(() => const teamProfiles = computed(() =>
buildTeamProfiles(records.value, assignments.value, members.value)); buildTeamProfiles(records.value, assignments.value, members.value, teamProfileSummaries.value));
const teamTotal = computed(() => teamProfiles.value.length); 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(() => 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(() => 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(() => { const todayDone = computed(() => {
if (hasFullTeamProfileSummaries.value) return teamProfileRollup.value.todayDone;
const trained = new Set<string>(); const trained = new Set<string>();
records.value.forEach((item) => { records.value.forEach((item) => {
if (item.time.startsWith(todayKey)) trained.add(item.extPartyId || item.trainee); if (item.time.startsWith(todayKey)) trained.add(item.extPartyId || item.trainee);
@@ -307,6 +346,7 @@ const clearData = () => {
records.value = []; records.value = [];
assignments.value = []; assignments.value = [];
members.value = []; members.value = [];
teamProfileSummaries.value = [];
alerts.value = []; alerts.value = [];
}; };
@@ -345,7 +385,7 @@ const loadPage = async () => {
const [nextReviews, nextAlerts, snapshot, home, identity] = await Promise.all([ const [nextReviews, nextAlerts, snapshot, home, identity] = await Promise.all([
getPracticeReviews(), getPracticeReviews(),
getPracticeAlerts(), getPracticeAlerts(),
getPracticeTeamSnapshot(), getPracticeTeamSnapshot(true),
getMobileHome('supervisor').catch((): RoleHome | null => null), getMobileHome('supervisor').catch((): RoleHome | null => null),
apiRequest<MobileIdentity>({ apiRequest<MobileIdentity>({
url: '/api/aihr/mobile/me', url: '/api/aihr/mobile/me',
@@ -361,6 +401,7 @@ const loadPage = async () => {
records.value = snapshot.records; records.value = snapshot.records;
assignments.value = snapshot.assignments; assignments.value = snapshot.assignments;
members.value = snapshot.members; members.value = snapshot.members;
teamProfileSummaries.value = snapshot.profiles || [];
if (home?.location) communityName.value = home.location.split('·')[0].trim() || '住宅项目'; if (home?.location) communityName.value = home.location.split('·')[0].trim() || '住宅项目';
if (identity?.positionName) supervisorName.value = identity.positionName; if (identity?.positionName) supervisorName.value = identity.positionName;
} catch (error) { } catch (error) {
@@ -655,6 +696,68 @@ onUnload(invalidateRequests);
grid-template-columns: repeat(3, 1fr); 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 { .stat-item {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -51,7 +51,7 @@
<view class="member-stats"> <view class="member-stats">
<view class="member-stat"> <view class="member-stat">
<text class="member-stat-value">{{ totalSessions }}</text> <text class="member-stat-value">{{ totalSessions }}</text>
<text class="member-stat-label">训练次数</text> <text class="member-stat-label">当前明细</text>
</view> </view>
<view class="member-stat"> <view class="member-stat">
<text class="member-stat-value medium">{{ profile?.pendingReview ?? 0 }}</text> <text class="member-stat-value medium">{{ profile?.pendingReview ?? 0 }}</text>
@@ -66,7 +66,7 @@
<view class="panel trend-panel"> <view class="panel trend-panel">
<view class="section-head"> <view class="section-head">
<text class="panel-title">训练趋势</text> <text class="panel-title">训练趋势</text>
<text class="muted">最近 {{ trendItems.length }} 次</text> <text class="muted">近期可见 {{ trendItems.length }} 次</text>
</view> </view>
<view v-if="trendItems.length" class="trend-list"> <view v-if="trendItems.length" class="trend-list">
<view v-for="(item, index) in trendItems" :key="`${item.scene}-${index}`" class="trend-row"> <view v-for="(item, index) in trendItems" :key="`${item.scene}-${index}`" class="trend-row">
@@ -86,7 +86,7 @@
</view> </view>
<view class="evidence-list"> <view class="evidence-list">
<view class="evidence-row"> <view class="evidence-row">
<text class="evidence-text">已完成 <text class="evidence-strong">{{ totalSessions }}</text> 次训练</text> <text class="evidence-text">已加载 <text class="evidence-strong">{{ totalSessions }}</text> 条近期明细</text>
</view> </view>
<view class="evidence-row"> <view class="evidence-row">
<text class="evidence-text" :class="{ warn: lowScoreScene }">{{ lowScoreScene ? `连续低分场景:${lowScoreScene}` : '暂无连续低分场景' }}</text> <text class="evidence-text" :class="{ warn: lowScoreScene }">{{ lowScoreScene ? `连续低分场景:${lowScoreScene}` : '暂无连续低分场景' }}</text>
@@ -127,7 +127,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { onHide, onLoad, onShow, onUnload } from '@dcloudio/uni-app'; import { onHide, onLoad, onShow, onUnload } from '@dcloudio/uni-app';
import type { PracticeAssignment, PracticeRecord } from '@/types/api'; import type { PracticeAssignment, PracticeRecord, PracticeTeamProfileSummary } from '@/types/api';
import { clearAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth'; import { clearAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { resetPageScroll } from '@/services/navigation'; import { resetPageScroll } from '@/services/navigation';
import { getPracticeTeamSnapshot } from '@/services/practice'; import { getPracticeTeamSnapshot } from '@/services/practice';
@@ -148,6 +148,7 @@ const displayName = ref('成员');
const records = ref<PracticeRecord[]>([]); const records = ref<PracticeRecord[]>([]);
const assignments = ref<PracticeAssignment[]>([]); const assignments = ref<PracticeAssignment[]>([]);
const memberNames = ref<Record<string, string>>({}); const memberNames = ref<Record<string, string>>({});
const teamProfileSummaries = ref<PracticeTeamProfileSummary[]>([]);
const loggedIn = ref(false); const loggedIn = ref(false);
const accessDenied = ref(false); const accessDenied = ref(false);
const loading = ref(false); const loading = ref(false);
@@ -157,7 +158,7 @@ let visibleAccountKey = '';
const profiles = computed(() => { const profiles = computed(() => {
const members = Object.entries(memberNames.value).map(([id, name]) => ({ extPartyId: id, name })); const members = Object.entries(memberNames.value).map(([id, name]) => ({ extPartyId: id, name }));
return buildTeamProfiles(records.value, assignments.value, members); return buildTeamProfiles(records.value, assignments.value, members, teamProfileSummaries.value);
}); });
const profile = computed<TeamProfile | null>(() => const profile = computed<TeamProfile | null>(() =>
profiles.value.find((item) => item.extPartyId === extPartyId.value) profiles.value.find((item) => item.extPartyId === extPartyId.value)
@@ -194,7 +195,11 @@ const lowScoreScene = computed(() => {
return ''; return '';
}); });
const memberInitial = computed(() => displayName.value.trim().charAt(0) || '员'); const memberInitial = computed(() => displayName.value.trim().charAt(0) || '员');
const hasData = computed(() => records.value.length > 0 || assignments.value.length > 0); const hasData = computed(() =>
records.value.length > 0
|| assignments.value.length > 0
|| Object.keys(memberNames.value).length > 0
|| teamProfileSummaries.value.length > 0);
const loadFailed = computed(() => !loading.value && !!message.value && !hasData.value); const loadFailed = computed(() => !loading.value && !!message.value && !hasData.value);
const firstLoading = computed(() => loading.value && !hasData.value && !message.value); const firstLoading = computed(() => loading.value && !hasData.value && !message.value);
const riskBadgeText = computed(() => { const riskBadgeText = computed(() => {
@@ -220,6 +225,7 @@ const clearData = () => {
records.value = []; records.value = [];
assignments.value = []; assignments.value = [];
memberNames.value = {}; memberNames.value = {};
teamProfileSummaries.value = [];
}; };
const refreshAuthState = () => { const refreshAuthState = () => {
@@ -254,12 +260,13 @@ const loadPage = async () => {
} }
loading.value = true; loading.value = true;
try { try {
const snapshot = await getPracticeTeamSnapshot(); const snapshot = await getPracticeTeamSnapshot(true);
if (!requests.isCurrent(request)) return; if (!requests.isCurrent(request)) return;
accessDenied.value = false; accessDenied.value = false;
records.value = snapshot.records; records.value = snapshot.records;
assignments.value = snapshot.assignments; assignments.value = snapshot.assignments;
memberNames.value = Object.fromEntries(snapshot.members.map((item) => [item.extPartyId, item.name])); memberNames.value = Object.fromEntries(snapshot.members.map((item) => [item.extPartyId, item.name]));
teamProfileSummaries.value = snapshot.profiles || [];
if (!displayName.value || displayName.value === '成员') { if (!displayName.value || displayName.value === '成员') {
const mapped = memberNames.value[extPartyId.value]; const mapped = memberNames.value[extPartyId.value];
if (mapped) displayName.value = mapped; if (mapped) displayName.value = mapped;
@@ -196,7 +196,7 @@
/> />
</view> </view>
<button class="submit-button" :disabled="marking" @click="markReviewed"> <button class="primary-button submit-button tabbar-safe-action" :disabled="marking" @click="markReviewed">
{{ marking ? '提交中…' : '标记已复盘' }} {{ marking ? '提交中…' : '标记已复盘' }}
</button> </button>
</view> </view>
+69 -7
View File
@@ -81,11 +81,15 @@
<view class="panel team-panel"> <view class="panel team-panel">
<view class="section-head"> <view class="section-head">
<text class="panel-title">成员列表</text> <text class="panel-title">成员列表</text>
<text class="muted">按风险排序</text> <text class="muted">{{ filteredTeamProfiles.length }} 人 · 按风险排序</text>
</view> </view>
<view v-if="teamProfiles.length" class="team-list"> <view class="team-search-row">
<uni-icons type="search" size="16" color="#94a3b8" />
<input v-model="teamSearch" class="team-search-input" placeholder="搜索成员姓名或身份标识" @input="teamPage = 1" />
</view>
<view v-if="visibleTeamProfiles.length" class="team-list">
<view <view
v-for="member in teamProfiles" v-for="member in visibleTeamProfiles"
:key="member.key" :key="member.key"
class="team-row" class="team-row"
@click="openMember(member)" @click="openMember(member)"
@@ -106,7 +110,12 @@
<text v-else class="team-arrow">›</text> <text v-else class="team-arrow">›</text>
</view> </view>
</view> </view>
<StateBlock v-else-if="!loading" type="empty" title="暂无团队画像数据" :card="false" /> <StateBlock v-else-if="!loading" type="empty" :title="teamSearch ? '没有匹配的成员' : '暂无团队画像数据'" :card="false" />
<button
v-if="teamPagination.page < teamPagination.pageCount"
class="load-more-members"
@click="teamPage += 1"
>加载更多(已显示 {{ visibleTeamProfiles.length }}/{{ filteredTeamProfiles.length }} 人)</button>
</view> </view>
</template> </template>
<SupervisorTabBar v-if="loggedIn && !accessDenied" active="team" /> <SupervisorTabBar v-if="loggedIn && !accessDenied" active="team" />
@@ -116,7 +125,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { onHide, onShow, onUnload } from '@dcloudio/uni-app'; import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
import type { PracticeAssignment, PracticeDifficulty, PracticeRecord, PracticeTeamMember } from '@/types/api'; import type {
PracticeAssignment,
PracticeDifficulty,
PracticeRecord,
PracticeTeamMember,
PracticeTeamProfileSummary
} from '@/types/api';
import { clearAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth'; import { clearAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { resetPageScroll } from '@/services/navigation'; import { resetPageScroll } from '@/services/navigation';
import { assignPracticeDifficulty, getPracticeDifficulties, getPracticeTeamSnapshot } from '@/services/practice'; import { assignPracticeDifficulty, getPracticeDifficulties, getPracticeTeamSnapshot } from '@/services/practice';
@@ -124,8 +139,10 @@ import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
import StateBlock from '@/components/StateBlock.vue'; import StateBlock from '@/components/StateBlock.vue';
import { import {
buildTeamProfiles, buildTeamProfiles,
filterTeamProfiles,
inferSupervisorScenarioId, inferSupervisorScenarioId,
isSupervisorAccessDenied, isSupervisorAccessDenied,
paginateTeamProfiles,
type TeamProfile type TeamProfile
} from '@/services/supervisor-view'; } from '@/services/supervisor-view';
import { createPageRequestScope, currentAccountKey } from '@/services/request-scope'; import { createPageRequestScope, currentAccountKey } from '@/services/request-scope';
@@ -134,6 +151,7 @@ const currentPath = '/pages/supervisor/team/index';
const records = ref<PracticeRecord[]>([]); const records = ref<PracticeRecord[]>([]);
const assignments = ref<PracticeAssignment[]>([]); const assignments = ref<PracticeAssignment[]>([]);
const members = ref<PracticeTeamMember[]>([]); const members = ref<PracticeTeamMember[]>([]);
const teamProfileSummaries = ref<PracticeTeamProfileSummary[]>([]);
const mistakes = ref<PracticeDifficulty[]>([]); const mistakes = ref<PracticeDifficulty[]>([]);
const loggedIn = ref(false); const loggedIn = ref(false);
const accessDenied = ref(false); const accessDenied = ref(false);
@@ -147,10 +165,17 @@ const difficultySorts = [
type DifficultySort = typeof difficultySorts[number]['key']; type DifficultySort = typeof difficultySorts[number]['key'];
const difficultySort = ref<DifficultySort>('count'); const difficultySort = ref<DifficultySort>('count');
const assigningDifficultyKey = ref(''); const assigningDifficultyKey = ref('');
const teamSearch = ref('');
const teamPage = ref(1);
const teamPageSize = 20;
const requests = createPageRequestScope(); const requests = createPageRequestScope();
let visibleAccountKey = ''; let visibleAccountKey = '';
const teamProfiles = computed(() => buildTeamProfiles(records.value, assignments.value, members.value)); const teamProfiles = computed(() =>
buildTeamProfiles(records.value, assignments.value, members.value, teamProfileSummaries.value));
const filteredTeamProfiles = computed(() => filterTeamProfiles(teamProfiles.value, teamSearch.value));
const teamPagination = computed(() => paginateTeamProfiles(filteredTeamProfiles.value, teamPage.value, teamPageSize));
const visibleTeamProfiles = computed(() => teamPagination.value.items);
const riskCounts = computed(() => teamProfiles.value.reduce( const riskCounts = computed(() => teamProfiles.value.reduce(
(counts, item) => { (counts, item) => {
counts[item.risk] += 1; counts[item.risk] += 1;
@@ -171,7 +196,10 @@ const clearData = () => {
records.value = []; records.value = [];
assignments.value = []; assignments.value = [];
members.value = []; members.value = [];
teamProfileSummaries.value = [];
mistakes.value = []; mistakes.value = [];
teamSearch.value = '';
teamPage.value = 1;
}; };
const refreshAuthState = () => { const refreshAuthState = () => {
@@ -206,13 +234,15 @@ const loadPage = async () => {
} }
loading.value = true; loading.value = true;
try { try {
const [snapshot, difficulties] = await Promise.all([getPracticeTeamSnapshot(), getPracticeDifficulties(30)]); const [snapshot, difficulties] = await Promise.all([getPracticeTeamSnapshot(true), getPracticeDifficulties(30)]);
if (!requests.isCurrent(request)) return; if (!requests.isCurrent(request)) return;
accessDenied.value = false; accessDenied.value = false;
records.value = snapshot.records; records.value = snapshot.records;
assignments.value = snapshot.assignments; assignments.value = snapshot.assignments;
members.value = snapshot.members; members.value = snapshot.members;
teamProfileSummaries.value = snapshot.profiles || [];
mistakes.value = difficulties?.length ? difficulties : (snapshot.mistakes ?? []); mistakes.value = difficulties?.length ? difficulties : (snapshot.mistakes ?? []);
teamPage.value = 1;
} catch (error) { } catch (error) {
if (!requests.isCurrent(request)) return; if (!requests.isCurrent(request)) return;
handleRequestError(error, '团队画像加载失败'); handleRequestError(error, '团队画像加载失败');
@@ -343,6 +373,38 @@ onUnload(invalidateRequests);
min-width: 104px; min-width: 104px;
} }
.team-search-row {
display: flex;
align-items: center;
gap: 8px;
margin: 12px 0;
padding: 0 12px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #f8fafc;
}
.team-search-input {
flex: 1;
min-width: 0;
height: 38px;
color: #1e293b;
font-size: 14px;
}
.load-more-members {
display: block;
width: 100%;
min-height: 40px;
margin: 12px 0 0;
border: 1px solid #fecdd3;
border-radius: 12px;
background: #fff5f5;
color: #bd0010;
font-size: 13px;
font-weight: 700;
}
.refresh-button { .refresh-button {
min-width: 78px; min-width: 78px;
padding: 0 10px; padding: 0 10px;
@@ -894,10 +894,6 @@ const askTopic = (topic: string) => {
uni.switchTab({ url: '/pages/user/sop/index' }); uni.switchTab({ url: '/pages/user/sop/index' });
}; };
const goCases = () => {
uni.navigateTo({ url: '/pages/user/cases/index' });
};
onShow(() => { onShow(() => {
const nextAccountKey = currentAccountKey(); const nextAccountKey = currentAccountKey();
if (visibleAccountKey && visibleAccountKey !== nextAccountKey) { if (visibleAccountKey && visibleAccountKey !== nextAccountKey) {
+2 -1
View File
@@ -373,7 +373,7 @@
<view v-if="result" class="result-actions"> <view v-if="result" class="result-actions">
<button class="secondary-button" @click="openPracticeHistory">查看训练记录</button> <button class="secondary-button" @click="openPracticeHistory">查看训练记录</button>
<button class="primary-button" @click="start(selectedScenarioId)">再练一次</button> <button class="primary-button tabbar-safe-action" @click="start(selectedScenarioId)">再练一次</button>
</view> </view>
</view> </view>
</template> </template>
@@ -1443,6 +1443,7 @@ const finish = async (force = false) => {
const nextResult = await finishPractice(sessionId.value, assignmentId); const nextResult = await finishPractice(sessionId.value, assignmentId);
if (!requests.isCurrent(request)) return; if (!requests.isCurrent(request)) return;
result.value = nextResult; result.value = nextResult;
resetPageScroll();
status.value = 'finished'; status.value = 'finished';
message.value = '训练完成,主管端待复盘已更新'; message.value = '训练完成,主管端待复盘已更新';
completedAssignmentId.value = assignmentId; completedAssignmentId.value = assignmentId;
@@ -19,6 +19,7 @@
<view v-if="loggedIn" class="position-badge"> <view v-if="loggedIn" class="position-badge">
<text class="badge-text">{{ positionProfile.value }} · {{ positionProfile.heroTitle }}</text> <text class="badge-text">{{ positionProfile.value }} · {{ positionProfile.heroTitle }}</text>
</view> </view>
<text v-if="loggedIn" class="identity-source">岗位由人力系统组织同步</text>
<text v-else class="identity-note">登录后查看训练记录和能力画像</text> <text v-else class="identity-note">登录后查看训练记录和能力画像</text>
</view> </view>
<button v-if="!loggedIn" class="primary-gradient-btn compact-login-btn" @click="goLogin">去登录</button> <button v-if="!loggedIn" class="primary-gradient-btn compact-login-btn" @click="goLogin">去登录</button>
+121 -2
View File
@@ -160,7 +160,7 @@
class="bubble-action" class="bubble-action"
:class="{ negative: msg.feedbackVerdict === 'down' }" :class="{ negative: msg.feedbackVerdict === 'down' }"
:disabled="msg.feedbackSubmitting || Boolean(msg.feedbackVerdict)" :disabled="msg.feedbackSubmitting || Boolean(msg.feedbackVerdict)"
@click="feedback('down', msg)" @click="openNegativeFeedback(msg)"
> >
没解决 没解决
</button> </button>
@@ -168,6 +168,34 @@
{{ msg.feedbackVerdict === 'up' ? '已记录' : '已进入复核' }} {{ msg.feedbackVerdict === 'up' ? '已记录' : '已进入复核' }}
</text> </text>
</view> </view>
<view v-if="msg.feedbackPanelOpen && !msg.feedbackVerdict" class="negative-feedback-panel">
<text class="negative-feedback-title">哪里还没解决?(可多选)</text>
<view class="negative-feedback-options">
<button
v-for="option in feedbackReasonOptions"
:key="option.code"
class="negative-feedback-option"
:class="{ active: msg.feedbackReasonCodes.includes(option.code) }"
:disabled="msg.feedbackSubmitting"
@click="toggleNegativeFeedbackReason(msg, option.code)"
>{{ option.label }}</button>
</view>
<textarea
v-model="msg.feedbackComment"
class="textarea negative-feedback-comment"
maxlength="200"
:disabled="msg.feedbackSubmitting"
placeholder="可补充具体问题,方便复核"
/>
<text class="negative-feedback-hint">请至少选择一项原因后提交</text>
<button
class="bubble-action primary"
:disabled="msg.feedbackSubmitting || !msg.feedbackReasonCodes.length"
@click="submitNegativeFeedback(msg)"
>
{{ msg.feedbackSubmitting ? '提交中' : '提交复核' }}
</button>
</view>
<view v-if="msg.citationsOpen && msg.result.snippets?.length" class="bubble-citations"> <view v-if="msg.citationsOpen && msg.result.snippets?.length" class="bubble-citations">
<view v-for="(snippet, index) in msg.result.snippets" :key="`${snippet.docTitle}-${index}`" class="citation"> <view v-for="(snippet, index) in msg.result.snippets" :key="`${snippet.docTitle}-${index}`" class="citation">
@@ -442,6 +470,9 @@ interface MasterMsg {
toolCode: ToolCode; toolCode: ToolCode;
feedbackVerdict: AnswerFeedbackVerdict | ''; feedbackVerdict: AnswerFeedbackVerdict | '';
feedbackSubmitting: boolean; feedbackSubmitting: boolean;
feedbackPanelOpen: boolean;
feedbackReasonCodes: string[];
feedbackComment: string;
citationsOpen: boolean; citationsOpen: boolean;
voiceStatus: 'preparing' | 'ready' | 'unavailable'; voiceStatus: 'preparing' | 'ready' | 'unavailable';
voiceDuration: number | null; voiceDuration: number | null;
@@ -933,6 +964,9 @@ const sendQuestion = async (rawText: string, options: { voice?: boolean } = {})
toolCode: '', toolCode: '',
feedbackVerdict: '', feedbackVerdict: '',
feedbackSubmitting: false, feedbackSubmitting: false,
feedbackPanelOpen: false,
feedbackReasonCodes: [],
feedbackComment: '',
citationsOpen: false, citationsOpen: false,
voiceStatus: spoken ? 'preparing' : 'unavailable', voiceStatus: spoken ? 'preparing' : 'unavailable',
voiceDuration: null, voiceDuration: null,
@@ -1003,6 +1037,9 @@ const runDataTool = async (toolCode: 'MY_PRACTICE_SUMMARY' | 'TEAM_PRACTICE_SUMM
toolCode: effectiveToolCode, toolCode: effectiveToolCode,
feedbackVerdict: '', feedbackVerdict: '',
feedbackSubmitting: false, feedbackSubmitting: false,
feedbackPanelOpen: false,
feedbackReasonCodes: [],
feedbackComment: '',
citationsOpen: false, citationsOpen: false,
voiceStatus: 'unavailable', voiceStatus: 'unavailable',
voiceDuration: null, voiceDuration: null,
@@ -1205,6 +1242,33 @@ const serviceMemoryStatusLabel = (status?: string | null) => ({
const displayCitationDate = (value?: string | null) => value ? value.replace('T', ' ').slice(0, 16) : '未记录'; const displayCitationDate = (value?: string | null) => value ? value.replace('T', ' ').slice(0, 16) : '未记录';
const feedbackReasonOptions = [
{ code: 'NO_ANSWER', label: '缺少制度或SOP依据' },
{ code: 'NOT_SPECIFIC', label: '处理步骤不够具体' },
{ code: 'WRONG_REFERENCE', label: '答案或引用不正确' },
{ code: 'NOT_APPLICABLE', label: '不适用于当前现场' }
];
const openNegativeFeedback = (msg: MasterMsg) => {
if (msg.feedbackSubmitting || msg.feedbackVerdict) return;
msg.feedbackPanelOpen = !msg.feedbackPanelOpen;
};
const toggleNegativeFeedbackReason = (msg: MasterMsg, code: string) => {
if (msg.feedbackSubmitting) return;
msg.feedbackReasonCodes = msg.feedbackReasonCodes.includes(code)
? msg.feedbackReasonCodes.filter((item) => item !== code)
: [...msg.feedbackReasonCodes, code];
};
const submitNegativeFeedback = async (msg: MasterMsg) => {
if (!msg.feedbackReasonCodes.length) {
uni.showToast({ title: '请至少选择一项未解决原因', icon: 'none' });
return;
}
await feedback('down', msg);
};
const feedback = async (verdict: AnswerFeedbackVerdict, msg: MasterMsg) => { const feedback = async (verdict: AnswerFeedbackVerdict, msg: MasterMsg) => {
if (msg.feedbackSubmitting || msg.feedbackVerdict) return; if (msg.feedbackSubmitting || msg.feedbackVerdict) return;
const fragmentIds = Array.from( const fragmentIds = Array.from(
@@ -1224,9 +1288,12 @@ const feedback = async (verdict: AnswerFeedbackVerdict, msg: MasterMsg) => {
queryText: msg.result.queryText || '', queryText: msg.result.queryText || '',
fragmentIds, fragmentIds,
verdict, verdict,
reviewId: msg.result.reviewId reviewId: msg.result.reviewId,
reasonCodes: verdict === 'down' ? msg.feedbackReasonCodes : undefined,
comment: verdict === 'down' ? msg.feedbackComment.trim() : undefined
}); });
msg.feedbackVerdict = verdict; msg.feedbackVerdict = verdict;
msg.feedbackPanelOpen = false;
uni.showToast({ title: verdict === 'down' ? '已进入复核' : '已记录', icon: 'none' }); uni.showToast({ title: verdict === 'down' ? '已进入复核' : '已记录', icon: 'none' });
} catch (error) { } catch (error) {
uni.showToast({ title: requestErrorText(error, '反馈失败'), icon: 'none' }); uni.showToast({ title: requestErrorText(error, '反馈失败'), icon: 'none' });
@@ -2918,6 +2985,58 @@ onUnload(() => {
font-size: 12px; font-size: 12px;
} }
.negative-feedback-panel {
display: grid;
gap: 8px;
margin-top: 10px;
padding: 10px;
border: 1px solid #f1c5ca;
border-radius: 12px;
background: #fff8f8;
}
.negative-feedback-title {
color: #5b3136;
font-size: 12px;
font-weight: 700;
}
.negative-feedback-options {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.negative-feedback-option {
min-height: 28px;
margin: 0;
padding: 0 9px;
border: 1px solid #f0d6d9;
border-radius: 999px;
background: #ffffff;
color: #6b7280;
font-size: 12px;
}
.negative-feedback-option.active {
border-color: #e70014;
background: #fff0f1;
color: #bd0010;
}
.negative-feedback-comment {
min-height: 58px;
padding: 8px;
border-color: #f0d6d9;
background: #ffffff;
font-size: 13px;
}
.negative-feedback-hint {
color: #8a5960;
font-size: 12px;
}
.bubble-citations { .bubble-citations {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
-17
View File
@@ -310,23 +310,6 @@ const primaryScheduleTitle = computed(() => {
return ''; return '';
}); });
const assignmentSourceLabel = (source?: string) => {
if (source === 'daily') return '每日三题';
if (source === 'retry') return '错题再练';
if (source === 'camp') return '训练营';
return '专项训练';
};
const primaryAssignmentSourceLabel = computed(() =>
primaryAssignment.value ? assignmentSourceLabel(primaryAssignment.value.source) : ''
);
const primaryDisplayTitle = computed(() => {
const label = primaryAssignmentSourceLabel.value;
const title = primaryScheduleTitle.value;
return label && title ? `${label}:${title}` : title;
});
const primaryScheduleDesc = computed(() => { const primaryScheduleDesc = computed(() => {
const item = primaryAssignment.value; const item = primaryAssignment.value;
if (item) { if (item) {
+4 -2
View File
@@ -145,9 +145,11 @@ export const getPracticeReviews = () =>
timeout: 15000 timeout: 15000
}); });
export const getPracticeTeamSnapshot = () => export const getPracticeTeamSnapshot = (includeProfiles = false) =>
apiRequest<PracticeTeamSnapshot>({ apiRequest<PracticeTeamSnapshot>({
url: '/api/aihr/mobile/practice/team', url: includeProfiles
? '/api/aihr/mobile/practice/team?includeProfiles=true'
: '/api/aihr/mobile/practice/team',
method: 'GET', method: 'GET',
timeout: 15000 timeout: 15000
}); });
+78 -14
View File
@@ -2,7 +2,8 @@ import type {
PracticeAlert, PracticeAlert,
PracticeAssignment, PracticeAssignment,
PracticeRecord, PracticeRecord,
PracticeTeamMember PracticeTeamMember,
PracticeTeamProfileSummary
} from '@/types/api'; } from '@/types/api';
export type SupervisorMetricKey = 'pendingReviews' | 'pendingAssignments' | 'lowScores' | 'alerts'; export type SupervisorMetricKey = 'pendingReviews' | 'pendingAssignments' | 'lowScores' | 'alerts';
@@ -30,6 +31,7 @@ export interface TeamProfile {
latestScoreText: string; latestScoreText: string;
latestScene: string; latestScene: string;
lastPracticeAt: string; lastPracticeAt: string;
completedToday?: boolean;
pendingReview: number; pendingReview: number;
pendingAssignment: number; pendingAssignment: number;
reason: string; reason: string;
@@ -101,6 +103,9 @@ interface TeamProfileDraft {
export const manualPracticeAssignments = (assignments: PracticeAssignment[]) => export const manualPracticeAssignments = (assignments: PracticeAssignment[]) =>
assignments.filter((item) => item.source !== 'daily'); assignments.filter((item) => item.source !== 'daily');
const normalizedTeamCount = (value: number | null | undefined) =>
typeof value === 'number' && Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0;
export const actionablePracticeAlerts = (alerts: PracticeAlert[]) => export const actionablePracticeAlerts = (alerts: PracticeAlert[]) =>
alerts.filter((item) => item.type !== 'stable'); alerts.filter((item) => item.type !== 'stable');
@@ -186,9 +191,20 @@ export const buildSupervisorMetrics = (
export const buildTeamProfiles = ( export const buildTeamProfiles = (
reviews: PracticeRecord[], reviews: PracticeRecord[],
assignments: PracticeAssignment[], assignments: PracticeAssignment[],
members: PracticeTeamMember[] = [] members: PracticeTeamMember[] = [],
profileSummaries: PracticeTeamProfileSummary[] = []
): TeamProfile[] => { ): TeamProfile[] => {
const rows = new Map<string, TeamProfileDraft>(); const rows = new Map<string, TeamProfileDraft>();
const summariesByExtPartyId = new Map(
profileSummaries
.filter((item) => Boolean(item?.extPartyId?.trim()))
.map((item) => [item.extPartyId.trim(), item] as const)
);
const hasFullTeamSummaries = summariesByExtPartyId.size > 0;
const memberExtPartyIds = new Set(
members.map((item) => item.extPartyId.trim()).filter(Boolean)
);
const normalizedExtPartyId = (value?: string | null) => value?.trim() || '';
const ensure = (rawKey: string | undefined, rawName: string, rawExtPartyId?: string) => { const ensure = (rawKey: string | undefined, rawName: string, rawExtPartyId?: string) => {
const extPartyId = rawExtPartyId?.trim() || ''; const extPartyId = rawExtPartyId?.trim() || '';
const candidateName = rawName.trim(); const candidateName = rawName.trim();
@@ -224,6 +240,7 @@ export const buildTeamProfiles = (
}); });
reviews.forEach((item) => { reviews.forEach((item) => {
if (hasFullTeamSummaries && !memberExtPartyIds.has(normalizedExtPartyId(item.extPartyId))) return;
const row = ensure(item.extPartyId || item.trainee, item.trainee, item.extPartyId); const row = ensure(item.extPartyId || item.trainee, item.trainee, item.extPartyId);
if (isReviewEditable(item.status)) { if (isReviewEditable(item.status)) {
row.pendingReview += 1; row.pendingReview += 1;
@@ -245,6 +262,7 @@ export const buildTeamProfiles = (
}); });
manualPracticeAssignments(assignments).forEach((item) => { manualPracticeAssignments(assignments).forEach((item) => {
if (hasFullTeamSummaries && !memberExtPartyIds.has(normalizedExtPartyId(item.extPartyId))) return;
const row = ensure(item.extPartyId || item.traineeName, item.traineeName, item.extPartyId); const row = ensure(item.extPartyId || item.traineeName, item.traineeName, item.extPartyId);
if (item.status !== '已完成') row.pendingAssignment += 1; if (item.status !== '已完成') row.pendingAssignment += 1;
row.latestScene ||= item.scenarioName.trim(); row.latestScene ||= item.scenarioName.trim();
@@ -258,19 +276,31 @@ export const buildTeamProfiles = (
return Array.from(rows.values()) return Array.from(rows.values())
.map<TeamProfile>((row) => { .map<TeamProfile>((row) => {
const latestScore = row.latestPractice?.score; const summary = summariesByExtPartyId.get(row.extPartyId);
const latestScene = row.latestPractice?.scene || row.latestScene; const latestScore = typeof summary?.latestScore === 'number' && Number.isFinite(summary.latestScore)
const risk: TeamProfile['risk'] = row.pendingLowScore !== undefined ? summary.latestScore
: row.latestPractice?.score;
const latestScene = summary?.latestScene?.trim() || row.latestPractice?.scene || row.latestScene;
const lastPracticeAt = summary?.lastPracticeAt?.trim() || row.latestPractice?.time || '';
const completedToday = typeof summary?.completedToday === 'boolean'
? summary.completedToday
: undefined;
const pendingReview = summary ? normalizedTeamCount(summary.pendingReview) : row.pendingReview;
const pendingAssignment = summary ? normalizedTeamCount(summary.pendingAssignment) : row.pendingAssignment;
const pendingLowScore = summary && typeof summary.pendingLowScore === 'number' && Number.isFinite(summary.pendingLowScore)
? summary.pendingLowScore
: row.pendingLowScore;
const risk: TeamProfile['risk'] = pendingLowScore !== undefined
? 'high' ? 'high'
: row.pendingReview > 0 : pendingReview > 0
? 'medium' ? 'medium'
: 'low'; : 'low';
const reason = risk === 'high' const reason = risk === 'high'
? `${row.name} 有未复盘低分记录(${row.pendingLowScore}分),建议定向再练` ? `${row.name} 有未复盘低分记录(${pendingLowScore}分),建议定向再练`
: risk === 'medium' : risk === 'medium'
? `${row.name} 有 ${row.pendingReview} 条待复盘,建议完成点评后派专项` ? `${row.name} 有 ${pendingReview} 条待复盘,建议完成点评后派专项`
: row.pendingAssignment > 0 : pendingAssignment > 0
? `${row.name} 有 ${row.pendingAssignment} 项待训练,建议跟进完成` ? `${row.name} 有 ${pendingAssignment} 项待训练,建议跟进完成`
: `${row.name} 当前无待处理训练,可按需派发专项`; : `${row.name} 当前无待处理训练,可按需派发专项`;
return { return {
@@ -283,9 +313,10 @@ export const buildTeamProfiles = (
latestScore, latestScore,
latestScoreText: latestScore === undefined ? '暂无评分' : `${latestScore}分`, latestScoreText: latestScore === undefined ? '暂无评分' : `${latestScore}分`,
latestScene, latestScene,
lastPracticeAt: row.latestPractice?.time || '', lastPracticeAt,
pendingReview: row.pendingReview, completedToday,
pendingAssignment: row.pendingAssignment, pendingReview,
pendingAssignment,
reason, reason,
risk risk
}; };
@@ -297,6 +328,35 @@ export const buildTeamProfiles = (
|| (a.latestScore ?? 100) - (b.latestScore ?? 100)); || (a.latestScore ?? 100) - (b.latestScore ?? 100));
}; };
export interface TeamProfileRollup {
teamTotal: number;
pendingReview: number;
pendingAssignment: number;
todayDone: number;
}
/**
* Profiles are the only complete team-level source. Recent activity lists are
* intentionally capped, so dashboard counts must not derive from them.
*/
export const buildTeamProfileRollup = (
profiles: TeamProfile[],
todayKey: string
): TeamProfileRollup => profiles.reduce<TeamProfileRollup>((rollup, profile) => {
rollup.teamTotal += 1;
rollup.pendingReview += normalizedTeamCount(profile.pendingReview);
rollup.pendingAssignment += normalizedTeamCount(profile.pendingAssignment);
const completedToday = profile.completedToday === true
|| (profile.completedToday === undefined && Boolean(todayKey) && profile.lastPracticeAt.startsWith(todayKey));
if (completedToday) rollup.todayDone += 1;
return rollup;
}, {
teamTotal: 0,
pendingReview: 0,
pendingAssignment: 0,
todayDone: 0
});
export const filterTeamProfiles = <T extends Pick<TeamProfile, 'displayName' | 'extPartyId'>>( export const filterTeamProfiles = <T extends Pick<TeamProfile, 'displayName' | 'extPartyId'>>(
profiles: T[], profiles: T[],
query: string query: string
@@ -313,7 +373,11 @@ export const paginateTeamProfiles = <T>(profiles: T[], requestedPage: number, pa
const pageCount = Math.max(1, Math.ceil(profiles.length / safeSize)); const pageCount = Math.max(1, Math.ceil(profiles.length / safeSize));
const page = Math.min(pageCount, Math.max(1, Math.floor(requestedPage))); const page = Math.min(pageCount, Math.max(1, Math.floor(requestedPage)));
return { return {
items: profiles.slice((page - 1) * safeSize, page * safeSize), // The team page uses this as a progressive “load more” view, not a
// replacement pager. Keep earlier members visible when the next batch is
// requested so the control matches its label and does not make the list
// jump backward.
items: profiles.slice(0, page * safeSize),
page, page,
pageCount pageCount
}; };
+13
View File
@@ -540,11 +540,24 @@ export interface PracticeTeamMember {
name: string; name: string;
} }
/** Full-team, lightweight aggregate; detailed records remain intentionally capped. */
export interface PracticeTeamProfileSummary {
extPartyId: string;
latestScore?: number | null;
latestScene?: string | null;
lastPracticeAt?: string | null;
completedToday?: boolean | null;
pendingReview?: number | null;
pendingLowScore?: number | null;
pendingAssignment?: number | null;
}
export interface PracticeTeamSnapshot { export interface PracticeTeamSnapshot {
members: PracticeTeamMember[]; members: PracticeTeamMember[];
records: PracticeRecord[]; records: PracticeRecord[];
assignments: PracticeAssignment[]; assignments: PracticeAssignment[];
mistakes: PracticeMistakeAggregate[]; mistakes: PracticeMistakeAggregate[];
profiles?: PracticeTeamProfileSummary[];
} }
export interface PracticeScenarioOption { export interface PracticeScenarioOption {
+5 -3
View File
@@ -20,7 +20,8 @@ test('问师傅支持显式新对话、版本回写和受控原文件或视频
]); ]);
assert.match(page, /@new-conversation="startNewConversation"/); assert.match(page, /@new-conversation="startNewConversation"/);
assert.match(header, /@click="emit\('newConversation'\)">新对话/); assert.match(header, /@click="emit\('newConversation'\)"/);
assert.match(header, /class="new-chat-text">新对话/);
assert.match(page, /const contextVersion = ref\(0\)/); assert.match(page, /const contextVersion = ref\(0\)/);
assert.match(page, /applyConversationState\(nextResult\)/); assert.match(page, /applyConversationState\(nextResult\)/);
assert.match(page, /authenticatedFileUrl\(resource\.contentUrl\)/); assert.match(page, /authenticatedFileUrl\(resource\.contentUrl\)/);
@@ -37,7 +38,8 @@ test('工作助手按项目名称选择并为文字、媒体和工具请求携
read('../src/services/auth.ts') read('../src/services/auth.ts')
]); ]);
assert.match(page, />当前项目</); assert.match(page, /class="compact-project-strip"/);
assert.match(page, /projectContextLabel/);
assert.match(page, /选择当前项目/); assert.match(page, /选择当前项目/);
assert.match(page, /搜索项目名称/); assert.match(page, /搜索项目名称/);
assert.match(page, /saveCurrentProject\(project\)/); assert.match(page, /saveCurrentProject\(project\)/);
@@ -64,7 +66,7 @@ test('问师傅在真正开始会话后收起首次使用引导,保留对话
const page = await read('../src/pages/user/sop/index.vue'); const page = await read('../src/pages/user/sop/index.vue');
assert.match(page, /const conversationStarted = computed\(\(\) => messages\.value\.some/); assert.match(page, /const conversationStarted = computed\(\(\) => messages\.value\.some/);
assert.match(page, /v-if="loggedIn && !conversationStarted" class="project-context-bar"/); assert.match(page, /v-if="loggedIn && !conversationStarted" class="compact-project-strip"/);
assert.match(page, /v-if="!conversationStarted" class="bubble-row left"/); assert.match(page, /v-if="!conversationStarted" class="bubble-row left"/);
assert.match(page, /:quick-questions="conversationStarted \? \[\] : quickQuestions"/); assert.match(page, /:quick-questions="conversationStarted \? \[\] : quickQuestions"/);
}); });
+1 -1
View File
@@ -17,7 +17,7 @@ test('银城大喇叭仅使用认证后的真实广播接口', async () => {
test('首页喇叭只在确认有未读消息时显示红点,未登录保留回跳', async () => { test('首页喇叭只在确认有未读消息时显示红点,未登录保留回跳', async () => {
const page = await read('../src/pages/user/today/index.vue'); const page = await read('../src/pages/user/today/index.vue');
assert.match(page, /v-if="hasUnreadBroadcast" class="bell-dot"/); assert.match(page, /v-if="hasUnreadBroadcast" class="bell-dot(?:\s+[^\"]*)?"/);
assert.match(page, /getBroadcastUnreadCount/); assert.match(page, /getBroadcastUnreadCount/);
assert.match(page, /const unreadBroadcastCount = ref<number \| null>\(null\)/); assert.match(page, /const unreadBroadcastCount = ref<number \| null>\(null\)/);
assert.match(page, /const hasUnreadBroadcast = computed\(\(\) => typeof unreadBroadcastCount\.value === 'number'/); assert.match(page, /const hasUnreadBroadcast = computed\(\(\) => typeof unreadBroadcastCount\.value === 'number'/);
@@ -52,7 +52,8 @@ test('员工考试与主管组卷路由、题目播报、真实人员多选和
assert.match(employee, /播报题目/); assert.match(employee, /播报题目/);
assert.match(employee, /submitMyExam/); assert.match(employee, /submitMyExam/);
assert.match(edit, /getPracticeTeamSnapshot/); assert.match(edit, /getPracticeTeamSnapshot/);
assert.match(edit, /最多100人/); assert.match(edit, /selectedMemberIds\.value\.length < 100/);
assert.match(edit, /members\.value\.slice\(0, 100\)/);
assert.match(edit, /requestId/); assert.match(edit, /requestId/);
assert.match(service, /\/api\/aihr\/mobile\/exams\/supervisor/); assert.match(service, /\/api\/aihr\/mobile\/exams\/supervisor/);
}); });
+49 -36
View File
@@ -25,9 +25,11 @@ test('SOP 查询框不再覆盖 textarea 的 box-sizing', async () => {
test('员工首页核心工具保留真实业务入口', async () => { test('员工首页核心工具保留真实业务入口', async () => {
const source = await pageSource('../src/pages/user/today/index.vue'); const source = await pageSource('../src/pages/user/today/index.vue');
assert.match(source, /<text class="voice-title">问老师傅<\/text>/); assert.match(source, /class="voice-card employee-card"/);
assert.match(source, /<text class="question-board-title">问题榜<\/text>/); assert.match(source, /\{\{ voiceTitle \}\}/);
assert.match(source, /@click="goSop"/); assert.match(source, /class="tool-cell tool-questions"/);
assert.match(source, /<text class="tool-name">问题榜<\/text>/);
assert.match(source, /const ask = \(query: string\) =>/);
assert.match(source, /const goPractice = \(\) =>/); assert.match(source, /const goPractice = \(\) =>/);
assert.match(source, /const openQuestions = \(\) =>/); assert.match(source, /const openQuestions = \(\) =>/);
}); });
@@ -47,9 +49,10 @@ test('训练结果回到顶部,SOP 答案生成后定位到回答区', async (
pageSource('../src/services/navigation.ts') pageSource('../src/services/navigation.ts')
]); ]);
assert.match(practiceSource, /result\.value = await finishPractice\([\s\S]*?resetPageScroll\(\)/); assert.match(practiceSource, /const nextResult = await finishPractice\([\s\S]*?result\.value = nextResult;\s*resetPageScroll\(\)/);
assert.match(sopSource, /import \{ resetPageScroll, scrollPageToSelector \} from '@\/services\/navigation';/); assert.match(sopSource, /const scrollToBottom = \(\) =>/);
assert.match(sopSource, /result\.value = await searchKnowledge\(value\);\s*scrollPageToSelector\('\.answer-panel'/); assert.match(sopSource, /replaceMsg\(pending\.id/);
assert.match(sopSource, /threadScrollTop\.value = 100000/);
assert.match(navigationSource, /export const scrollPageToSelector = \(selector: string/); assert.match(navigationSource, /export const scrollPageToSelector = \(selector: string/);
}); });
@@ -65,9 +68,9 @@ test('训练历史按摘要、逐句复盘、SOP 依据和行动建议分层', a
test('案例页为无权限和无案例提供明确空状态', async () => { test('案例页为无权限和无案例提供明确空状态', async () => {
const source = await pageSource('../src/pages/user/cases/index.vue'); const source = await pageSource('../src/pages/user/cases/index.vue');
assert.match(source, /class="case-empty-state"/); assert.match(source, /<StateBlock v-else type="empty" :card="false" title="暂无入库案例"/);
assert.match(source, /暂时还没有可学习案例/); assert.match(source, /当前岗位可查看本项目已入库案例/);
assert.match(source, /提交权限由组织角色决定/); assert.match(source, /提交案例素材需要主管或项目负责人权限/);
}); });
test('manifest 明确声明为 uni-app Vue3 CLI 工程', async () => { test('manifest 明确声明为 uni-app Vue3 CLI 工程', async () => {
@@ -89,7 +92,7 @@ test('员工端 tabBar 继续保留四个固定入口', async () => {
test('登录页按高保真原型保留品牌结构和紧凑验证码行', async () => { test('登录页按高保真原型保留品牌结构和紧凑验证码行', async () => {
const source = await pageSource('../src/pages/auth/login/index.vue'); const source = await pageSource('../src/pages/auth/login/index.vue');
assert.match(source, /高效协同 · 贴心服务 · 让工作更简单/); assert.match(source, /手机号登录,立刻上手/);
assert.match(source, /<uni-icons[^>]+type="phone"/); assert.match(source, /<uni-icons[^>]+type="phone"/);
assert.match(source, /<uni-icons[^>]+type="locked"/); assert.match(source, /<uni-icons[^>]+type="locked"/);
assert.doesNotMatch(source, /class="logo-badge"/); assert.doesNotMatch(source, /class="logo-badge"/);
@@ -133,9 +136,9 @@ test('工作助手与查全网使用一致的来源切换头部', async () => {
assert.doesNotMatch(page, /(?:^|\n)\s*\.chat-source-switch(?:\s+\.source-option(?:\.active)?)?\s*\{/); assert.doesNotMatch(page, /(?:^|\n)\s*\.chat-source-switch(?:\s+\.source-option(?:\.active)?)?\s*\{/);
} }
assert.match(headerSource, /<text class="head-title">问 · 数字师傅<\/text>/); assert.match(headerSource, /<text class="brand-name">数字师傅<\/text>/);
assert.match(headerSource, /\.source-option\s*\{[^}]*height:\s*44px/); assert.match(headerSource, /\.source-option\s*\{[^}]*height:\s*28px/);
assert.match(headerSource, /\.new-chat-button\s*\{[^}]*min-height:\s*44px/); assert.match(headerSource, /\.new-chat-btn\s*\{[^}]*height:\s*28px/);
assert.match(headerSource, /@click="emit\('newConversation'\)"/); assert.match(headerSource, /@click="emit\('newConversation'\)"/);
assert.doesNotMatch(sopSource, /(?:^|\n)\s*\.source-switch\s*\{/); assert.doesNotMatch(sopSource, /(?:^|\n)\s*\.source-switch\s*\{/);
assert.doesNotMatch(sopSource, /(?:^|\n)\s*\.source-option(?:\.active)?\s*\{/); assert.doesNotMatch(sopSource, /(?:^|\n)\s*\.source-option(?:\.active)?\s*\{/);
@@ -179,12 +182,12 @@ test('主管可切换到学员显示模式且组织角色保持不变', async ()
test('员工首页保留师傅区、语音条、今日安排和问题榜结构', async () => { test('员工首页保留师傅区、语音条、今日安排和问题榜结构', async () => {
const source = await pageSource('../src/pages/user/today/index.vue'); const source = await pageSource('../src/pages/user/today/index.vue');
assert.match(source, /mentor-hero/); assert.match(source, /hero-immersive/);
assert.match(source, /voice-panel/); assert.match(source, /voice-card/);
assert.match(source, /今日安排/); assert.match(source, /今日安排/);
assert.match(source, /问题榜/); assert.match(source, /问题榜/);
assert.match(source, /brand-bar/); assert.match(source, /brand-bar/);
assert.match(source, /ask-card/); assert.match(source, /mentor-chips/);
assert.match(source, /小贴士/); assert.match(source, /小贴士/);
}); });
@@ -192,24 +195,24 @@ test('员工首页高保真入口继续使用真实图标和业务跳转', async
const source = await pageSource('../src/pages/user/today/index.vue'); const source = await pageSource('../src/pages/user/today/index.vue');
assert.match(source, /type="notification"/); assert.match(source, /type="notification"/);
assert.match(source, /type="chatboxes"/); assert.match(source, /type="chatboxes-filled"/);
assert.match(source, /type="compose"/); assert.match(source, /type="paperplane-filled"/);
assert.match(source, /type="location"/); assert.match(source, /type="staff-filled"/);
assert.match(source, /const goSop = \(\) =>/); assert.match(source, /const ask = \(query: string\) =>/);
assert.match(source, /const openPrimarySchedule = \(\) =>/); assert.match(source, /const openPrimarySchedule = \(\) =>/);
assert.match(source, /getMobileHome\('user'\)/); assert.match(source, /getMobileHome\('user'\)/);
assert.match(source, /getPracticeAssignments\(true\)/); assert.match(source, /getPracticeAssignments\(true\)/);
assert.match(source, /src="\/static\/mentor-hifi\.png"/); assert.match(source, /src="\/static\/mentor-female\.png"/);
}); });
test('员工首页今日安排保留派发任务来源', async () => { test('员工首页今日安排保留派发任务来源', async () => {
const source = await pageSource('../src/pages/user/today/index.vue'); const source = await pageSource('../src/pages/user/today/index.vue');
assert.match(source, /primaryAssignmentSourceLabel/); assert.match(source, /primaryAssignmentTagText/);
assert.match(source, /source === 'retry'[\s\S]*错题再练/); assert.match(source, /source === 'retry'[\s\S]*错题再练/);
assert.match(source, /source === 'camp'[\s\S]*训练营/); assert.match(source, /source === 'camp'[\s\S]*训练营/);
assert.match(source, /class="schedule-source"/); assert.match(source, /class="schedule-tag"/);
assert.match(source, /今天先完成每日三题/); assert.match(source, /提交后显示反馈与参考答案/);
}); });
test('问师傅页保留搜索框、答案区和总结卡入口', async () => { test('问师傅页保留搜索框、答案区和总结卡入口', async () => {
@@ -231,8 +234,14 @@ test('SOP 没解决先收集结构化原因再提交', async () => {
assert.match(source, /feedbackReasonOptions/); assert.match(source, /feedbackReasonOptions/);
assert.match(source, /negative-feedback-panel/); assert.match(source, /negative-feedback-panel/);
assert.match(source, /submitNegativeFeedback/); assert.match(source, /submitNegativeFeedback/);
assert.match(source, /reasonCodes:\s*selectedFeedbackReasons\.value/); for (const code of ['NO_ANSWER', 'NOT_SPECIFIC', 'WRONG_REFERENCE', 'NOT_APPLICABLE']) {
assert.match(source, /comment:\s*feedbackComment\.value\.trim\(\)/); assert.match(source, new RegExp(`code: '${code}'`));
}
assert.doesNotMatch(source, /MISSING_POLICY|ANSWER_INCORRECT/);
assert.match(source, /:disabled="msg\.feedbackSubmitting \|\| !msg\.feedbackReasonCodes\.length"/);
assert.match(source, /if \(!msg\.feedbackReasonCodes\.length\) \{[\s\S]*请至少选择一项未解决原因/);
assert.match(source, /reasonCodes: verdict === 'down' \? msg\.feedbackReasonCodes/);
assert.match(source, /comment: verdict === 'down' \? msg\.feedbackComment\.trim\(\)/);
assert.match(service, /reasonCodes\?:\s*string\[\]/); assert.match(service, /reasonCodes\?:\s*string\[\]/);
assert.match(service, /comment\?:\s*string/); assert.match(service, /comment\?:\s*string/);
}); });
@@ -250,8 +259,9 @@ test('训练页保留任务卡、对话区、回复区和结果区并互斥切
assert.match(source, /practiceView === 'prep'/); assert.match(source, /practiceView === 'prep'/);
assert.match(source, /practiceView === 'active'/); assert.match(source, /practiceView === 'active'/);
assert.match(source, /practiceView\.value === 'result'/); assert.match(source, /practiceView\.value === 'result'/);
assert.match(source, /result\.score\?\.length/); assert.match(source, /result\.totalScore/);
assert.match(source, /class="result-dimensions"/); assert.match(source, /result\.aiComment/);
assert.match(source, /result\.mentorRewrite/);
}); });
test('底部固定导航页面复用安全操作区', async () => { test('底部固定导航页面复用安全操作区', async () => {
@@ -283,7 +293,8 @@ test('员工问师傅页显示当前岗位而不是固定生活顾问', async ()
assert.match(source, /getSelectedPosition/); assert.match(source, /getSelectedPosition/);
assert.match(source, /const positionLabel = ref\(getSelectedPosition\(\)\)/); assert.match(source, /const positionLabel = ref\(getSelectedPosition\(\)\)/);
assert.match(source, /:context="`\$\{positionLabel\} · 住宅项目`"/); assert.match(source, /const headerContext = computed\(\(\) =>/);
assert.match(source, /\$\{positionLabel\.value\} · \$\{currentProject\.value\?\.projectName \|\| '住宅项目'\}/);
assert.doesNotMatch(source, /sop-context">管家(生活顾问) · 住宅项目/); assert.doesNotMatch(source, /sop-context">管家(生活顾问) · 住宅项目/);
}); });
@@ -331,14 +342,15 @@ test('学习页显示岗位 SOP 适配状态而不冒充上岗资格', async ()
]); ]);
assert.match(source, /positionSopSummary/); assert.match(source, /positionSopSummary/);
assert.match(source, /正式上岗资格/); assert.match(source, /上岗资格/);
assert.match(knowledgeService, /\/api\/knowledge\/position-sop/); assert.match(knowledgeService, /\/api\/knowledge\/position-sop/);
}); });
test('员工画像明确参考等级与人工贡献分语义', async () => { test('员工画像明确参考等级与人工贡献分语义', async () => {
const source = await pageSource('../src/pages/user/profile/index.vue'); const source = await pageSource('../src/pages/user/profile/index.vue');
assert.match(source, /训练画像等级(参考)/); assert.match(source, /AI训练综合分仅作参考/);
assert.match(source, /参考等级/);
assert.match(source, /人工贡献分/); assert.match(source, /人工贡献分/);
}); });
@@ -368,7 +380,7 @@ test('员工岗位只能来自人力系统且个人页不再允许手动切换',
pageSource('../src/services/auth.ts') pageSource('../src/services/auth.ts')
]); ]);
assert.match(profileSource, /岗位来自人力系统组织同步/); assert.match(profileSource, /岗位由人力系统组织同步/);
assert.doesNotMatch(profileSource, /changePosition/); assert.doesNotMatch(profileSource, /changePosition/);
assert.match(positionSource, /员工端不支持手动切换岗位/); assert.match(positionSource, /员工端不支持手动切换岗位/);
assert.doesNotMatch(positionSource, /saveSelectedPosition|selectPosition/); assert.doesNotMatch(positionSource, /saveSelectedPosition|selectPosition/);
@@ -388,12 +400,13 @@ test('主管专项从正式团队名单按姓名多选并一次批量派发', as
assert.match(source, /snapshot\.members/); assert.match(source, /snapshot\.members/);
assert.match(source, /targets: selectedMembers\.map/); assert.match(source, /targets: selectedMembers\.map/);
assert.match(source, /createPracticeAssignmentsBatch/); assert.match(source, /createPracticeAssignmentsBatch/);
assert.match(source, /当天/);
assert.match(source, /明天/); assert.match(source, /明天/);
assert.match(source, /3天内/); assert.match(source, /3天后/);
assert.match(source, /7天内/); assert.match(source, /7天后/);
assert.match(source, /dueDate: dueDate\.value/); assert.match(source, /dueDate: dueDate\.value/);
assert.match(source, /batchResultItems/); assert.match(source, /const resultItems: PracticeAssignmentBatchItemResult\[\] = result\.results \|\| \[\]/);
assert.match(source, /assignResultStorageKey/);
assert.match(source, /uni\.redirectTo\(\{ url: '\/pages\/supervisor\/assign\/result' \}\)/);
assert.match(source, /const maxAssignmentTargets = 100/); assert.match(source, /const maxAssignmentTargets = 100/);
assert.match(source, /teamMembers\.value\.slice\(0, maxAssignmentTargets\)/); assert.match(source, /teamMembers\.value\.slice\(0, maxAssignmentTargets\)/);
assert.match(source, /persistPendingBatchDraft/); assert.match(source, /persistPendingBatchDraft/);
+2 -2
View File
@@ -62,8 +62,8 @@ test('工作助手页面展示任务过期后不会消费待执行查询或数
const page = await read('../src/pages/user/sop/index.vue'); const page = await read('../src/pages/user/sop/index.vue');
assert.match(page, /const isCurrentShow = \(\) => generation === showGeneration && accountKey === currentAccountKey\(\)/); assert.match(page, /const isCurrentShow = \(\) => generation === showGeneration && accountKey === currentAccountKey\(\)/);
assert.match(page, /if \(!isCurrentShow\(\)\) return;\s*const pending = uni\.getStorageSync\(pendingSopQueryKey\)/); assert.match(page, /const pending = uni\.getStorageSync\(pendingSopQueryKey\);\s*if \(!isCurrentShow\(\)\) return;/);
assert.match(page, /if \(!isCurrentShow\(\)\) return;\s*const pendingTool = uni\.getStorageSync\(pendingToolKey\)/); assert.match(page, /const pendingTool = uni\.getStorageSync\(pendingToolKey\);[\s\S]*?if \(!isCurrentShow\(\)\) return;/);
assert.match(page, /if \(!isCurrentShow\(\)\) return;\s*uni\.removeStorageSync\(pendingToolKey\)/); assert.match(page, /if \(!isCurrentShow\(\)\) return;\s*uni\.removeStorageSync\(pendingToolKey\)/);
assert.match(page, /if \(!isCurrentShow\(\)\) return;\s*uni\.removeStorageSync\(pendingSopQueryKey\)/); assert.match(page, /if \(!isCurrentShow\(\)\) return;\s*uni\.removeStorageSync\(pendingSopQueryKey\)/);
assert.match(page, /const requestShowGeneration = showGeneration/); assert.match(page, /const requestShowGeneration = showGeneration/);
+120 -44
View File
@@ -20,6 +20,7 @@ const supervisorView = await vite.ssrLoadModule('/src/services/supervisor-view.t
const { const {
actionablePracticeAlerts, actionablePracticeAlerts,
buildSupervisorMetrics, buildSupervisorMetrics,
buildTeamProfileRollup,
buildTeamProfiles, buildTeamProfiles,
buildTrainingTrend, buildTrainingTrend,
createAsyncGenerationGuard, createAsyncGenerationGuard,
@@ -167,8 +168,11 @@ test('待复盘列表支持全部、低分和待处理筛选', () => {
assert.equal(filterReviewRecords(rows, 'all').length, 3); assert.equal(filterReviewRecords(rows, 'all').length, 3);
assert.deepEqual(filterReviewRecords(rows, 'low').map((item) => item.id), [1]); assert.deepEqual(filterReviewRecords(rows, 'low').map((item) => item.id), [1]);
assert.deepEqual(filterReviewRecords(rows, 'pending').map((item) => item.id), [1, 2]); assert.deepEqual(filterReviewRecords(rows, 'pending').map((item) => item.id), [1, 2]);
assert.match(reviewsPageSource, /reviewFilters/); assert.match(reviewsPageSource, /filterTabs/);
assert.match(reviewsPageSource, /visibleReviews/); assert.match(reviewsPageSource, /activeFilter/);
assert.match(reviewsPageSource, /displayedReviews/);
assert.match(reviewsPageSource, /below60/);
assert.match(reviewsPageSource, /repeatLow/);
}); });
test('主管页面共享三个业务场景及文本推荐规则', () => { test('主管页面共享三个业务场景及文本推荐规则', () => {
@@ -178,8 +182,8 @@ test('主管页面共享三个业务场景及文本推荐规则', () => {
'repair-elevator-delay' 'repair-elevator-delay'
]); ]);
assert.match(practiceServiceSource, /getPracticeScenarios/); assert.match(practiceServiceSource, /getPracticeScenarios/);
assert.match(assignPageSource, /v-for="item in filteredScenarioOptions"/); assert.match(assignPageSource, /v-for="item in scenarioOptions"/);
assert.match(assignPageSource, /scenarioSearch/); assert.match(assignPageSource, /assignmentPosition/);
assert.match(assignPageSource, /暂无可派发的已启用场景/); assert.match(assignPageSource, /暂无可派发的已启用场景/);
assert.match(reviewPageSource, /scenarioOptions/); assert.match(reviewPageSource, /scenarioOptions/);
assert.match(reviewPageSource, /scenarioOptions\.value = \[\]/); assert.match(reviewPageSource, /scenarioOptions\.value = \[\]/);
@@ -193,8 +197,9 @@ test('专项派发使用后端批量接口并保留逐人结果和截止日期',
assert.match(assignPageSource, /targets: selectedMembers\.map/); assert.match(assignPageSource, /targets: selectedMembers\.map/);
assert.match(assignPageSource, /requestId: pendingRequestId\.value/); assert.match(assignPageSource, /requestId: pendingRequestId\.value/);
assert.match(assignPageSource, /dueDate: dueDate\.value/); assert.match(assignPageSource, /dueDate: dueDate\.value/);
assert.match(assignPageSource, /batchResultItems\.value = result\.results/); assert.match(assignPageSource, /const resultItems: PracticeAssignmentBatchItemResult\[\] = result\.results \|\| \[\]/);
assert.match(assignPageSource, /item\.status !== 'FAILED'/); assert.match(assignPageSource, /uni\.setStorageSync\(assignResultStorageKey/);
assert.match(assignPageSource, /uni\.redirectTo\(\{ url: '\/pages\/supervisor\/assign\/result' \}\)/);
assert.match(assignPageSource, /const maxAssignmentTargets = 100/); assert.match(assignPageSource, /const maxAssignmentTargets = 100/);
assert.match(assignPageSource, /persistPendingBatchDraft/); assert.match(assignPageSource, /persistPendingBatchDraft/);
assert.match(assignPageSource, /restorePendingBatchDraft/); assert.match(assignPageSource, /restorePendingBatchDraft/);
@@ -288,13 +293,90 @@ test('团队画像搜索和分页不把 2316 名成员一次性渲染', () => {
assert.equal(paginateTeamProfiles(profiles, 1, 20).items.length, 20); assert.equal(paginateTeamProfiles(profiles, 1, 20).items.length, 20);
assert.equal(paginateTeamProfiles(profiles, 1, 20).pageCount, 116); assert.equal(paginateTeamProfiles(profiles, 1, 20).pageCount, 116);
const afterLoadingMore = paginateTeamProfiles(profiles, 2, 20);
assert.equal(afterLoadingMore.items.length, 40);
assert.equal(afterLoadingMore.items[0].key, 'member-0');
assert.equal(afterLoadingMore.items.at(-1).key, 'member-39');
assert.deepEqual( assert.deepEqual(
filterTeamProfiles(profiles, '员工 2315').map((item) => item.key), filterTeamProfiles(profiles, '员工 2315').map((item) => item.key),
['member-2315'] ['member-2315']
); );
assert.match(teamPageSource, /v-for="member in visibleTeamProfiles"/); assert.match(teamPageSource, /v-for="member in visibleTeamProfiles"/);
assert.match(teamPageSource, /v-if="selectedMember" class="panel member-detail-panel"/); assert.match(teamPageSource, /filterTeamProfiles\(teamProfiles\.value, teamSearch\.value\)/);
assert.match(teamPageSource, /<template v-else>[\s\S]*class="panel team-panel"/); assert.match(teamPageSource, /paginateTeamProfiles\(filteredTeamProfiles\.value, teamPage\.value, teamPageSize\)/);
assert.match(teamPageSource, /class="load-more-members"/);
});
test('全员汇总覆盖截断明细,较早成员的待复盘低分不能被降为正常', () => {
const truncatedRecords = Array.from({ length: 200 }, (_, index) => review({
id: index + 1,
extPartyId: `recent-${index}`,
trainee: `近期员工${index}`,
score: 95,
status: '已复盘'
}));
const profiles = buildTeamProfiles(
truncatedRecords,
[],
[
{ extPartyId: 'recent-0', name: '近期员工0' },
{ extPartyId: 'older-risk', name: '较早成员' }
],
[{
extPartyId: 'older-risk',
latestScore: 62,
latestScene: '投诉接待',
lastPracticeAt: '07-01 09:00',
pendingReview: 1,
pendingLowScore: 62,
pendingAssignment: 1
}]
);
const olderRisk = profiles.find((item) => item.extPartyId === 'older-risk');
assert.equal(olderRisk?.risk, 'high');
assert.equal(olderRisk?.pendingReview, 1);
assert.equal(olderRisk?.pendingAssignment, 1);
assert.equal(olderRisk?.latestScore, 62);
assert.match(teamPageSource, /snapshot\.profiles/);
assert.match(dashboardPageSource, /snapshot\.profiles/);
});
test('主管首页从全员汇总计算今日完训和待训练,不受 200 条明细上限影响', () => {
const members = Array.from({ length: 201 }, (_, index) => ({
extPartyId: `member-${index}`,
name: `员工${index}`
}));
const profiles = buildTeamProfiles(
Array.from({ length: 200 }, (_, index) => review({
id: index + 1,
extPartyId: `member-${index}`,
trainee: `员工${index}`,
time: '07-22 10:00',
score: 92,
status: '已复盘'
})),
[],
members,
members.map((member, index) => ({
extPartyId: member.extPartyId,
latestScore: index === 200 ? 88 : 92,
latestScene: '投诉接待',
lastPracticeAt: '07-22 10:00',
completedToday: index === 200,
pendingReview: 0,
pendingLowScore: null,
pendingAssignment: index === 200 ? 1 : 0
}))
);
assert.deepEqual(buildTeamProfileRollup(profiles, '07-23'), {
teamTotal: 201,
pendingReview: 0,
pendingAssignment: 1,
todayDone: 1
});
assert.match(dashboardPageSource, /buildTeamProfileRollup/);
}); });
test('员工和主管个人页只展示脱敏手机号', async () => { test('员工和主管个人页只展示脱敏手机号', async () => {
@@ -305,23 +387,22 @@ test('员工和主管个人页只展示脱敏手机号', async () => {
assert.equal(maskPhone('181368856126'), '181****6126'); assert.equal(maskPhone('181368856126'), '181****6126');
assert.equal(maskPhone('invalid'), 'invalid'); assert.equal(maskPhone('invalid'), 'invalid');
assert.match(employeeProfile, /displayPhone/); assert.match(employeeProfile, /maskedPhone/);
assert.match(supervisorProfile, /displayPhone/); assert.match(supervisorProfile, /maskedPhone/);
assert.doesNotMatch(employeeProfile, /\{\{ phone \|\|/); assert.doesNotMatch(employeeProfile, /\{\{ phone \|\|/);
assert.doesNotMatch(supervisorProfile, /\{\{ phone \|\|/); assert.doesNotMatch(supervisorProfile, /\{\{ phone \|\|/);
}); });
test('主管个人工作台展示真实指标和三项业务入口', async () => { test('主管个人页展示当前账号范围、团队成果和学员模式入口', async () => {
const source = await readFile(new URL('../src/pages/supervisor/profile/index.vue', import.meta.url), 'utf8'); const source = await readFile(new URL('../src/pages/supervisor/profile/index.vue', import.meta.url), 'utf8');
assert.match(source, /\{\{ loading \? '--' : teamCount \}\}/); assert.match(source, /maskedPhone/);
assert.match(source, /\{\{ loading \? '--' : pendingReviewCount \}\}/); assert.match(source, /主管端团队数据仅对当前项目范围可见/);
assert.match(source, /\{\{ loading \? '--' : alertCount \}\}/); assert.match(source, /团队工作成果/);
assert.match(source, /主管业务入口/); assert.match(source, /\/pages\/supervisor\/work-results\/index/);
assert.match(source, /\/pages\/supervisor\/team\/index/); assert.match(source, /进入学员模式/);
assert.match(source, /\/pages\/supervisor\/reviews\/index/); assert.match(source, /enterSupervisorLearnerMode/);
assert.match(source, /\/pages\/supervisor\/assign\/index/); assert.match(source, /<SupervisorTabBar active="profile"/);
assert.match(source, /getPracticeTeamSnapshot/);
}); });
test('组织快照姓名不可读时用稳定身份标识展示', () => { test('组织快照姓名不可读时用稳定身份标识展示', () => {
@@ -376,15 +457,15 @@ test('复盘详情只允许待复盘提交并保留未保存草稿', () => {
assert.match(reviewPageSource, /v-else class="panel review-complete"/); assert.match(reviewPageSource, /v-else class="panel review-complete"/);
assert.match(reviewPageSource, /if \(!editable\.value\)[\s\S]*该记录已完成复盘/); assert.match(reviewPageSource, /if \(!editable\.value\)[\s\S]*该记录已完成复盘/);
assert.match(reviewPageSource, /onShow\(\(\) => \{[\s\S]*refreshAuthState\(\);[\s\S]*if \(!loggedIn\.value\) \{[\s\S]*accessDenied\.value = false;[\s\S]*clearDetail\(\);\s*return;\s*\}[\s\S]*if \(!detail\.value && !loading\.value\)[\s\S]*loadDetail\(\)/); assert.match(reviewPageSource, /onShow\(\(\) => \{[\s\S]*refreshAuthState\(\);[\s\S]*if \(!loggedIn\.value\) \{[\s\S]*accessDenied\.value = false;[\s\S]*clearDetail\(\);\s*return;\s*\}[\s\S]*if \(!detail\.value && !loading\.value\)[\s\S]*loadDetail\(\)/);
assert.match(reviewPageSource, /训练摘要/); assert.match(reviewPageSource, /AI 摘要/);
assert.match(reviewPageSource, /录音与逐句复盘/); assert.match(reviewPageSource, /对话与录音/);
assert.match(reviewPageSource, /SOP 依据与改进建议/); assert.match(reviewPageSource, /标注与建议/);
assert.match(reviewPageSource, /下一步行动/); assert.match(reviewPageSource, /复盘后建议补练/);
}); });
test('复盘详情登录和完成导航不堆叠页面', () => { test('复盘详情登录和完成导航不堆叠页面', () => {
assert.match(reviewPageSource, /rememberLoginRedirect\(currentPath\(\)\);\s*uni\.redirectTo/); assert.match(reviewPageSource, /rememberLoginRedirect\(currentPath\(\)\);\s*uni\.redirectTo/);
assert.match(reviewPageSource, /reviewCompletionAction\(previousRoute\)/); assert.match(reviewPageSource, /const goBack = \(\) => \{[\s\S]*getCurrentPages\(\)\.length <= 1/);
assert.match(reviewPageSource, /uni\.navigateBack\(\{ delta: 1, fail: goToReviews \}\)/); assert.match(reviewPageSource, /uni\.navigateBack\(\{ delta: 1, fail: goToReviews \}\)/);
assert.match(reviewPageSource, /const goToReviews = \(\) => \{\s*uni\.redirectTo/); assert.match(reviewPageSource, /const goToReviews = \(\) => \{\s*uni\.redirectTo/);
}); });
@@ -428,7 +509,7 @@ test('五个主管页面优先显示统一无权限状态并清理私密数据',
pages.forEach(({ source, clear }) => { pages.forEach(({ source, clear }) => {
assert.match(source, /const accessDenied = ref\(false\)/); assert.match(source, /const accessDenied = ref\(false\)/);
assert.match(source, /<view v-if="accessDenied" class="panel supervisor-access-panel">[\s\S]*无主管权限[\s\S]*当前账号不是主管或项目经理,无法查看团队训练数据。[\s\S]*@click="goToEmployee"[\s\S]*返回员工端[\s\S]*@click="switchAccount"[\s\S]*切换账号[\s\S]*<\/view>\s*<view v-else-if="!loggedIn"/); assert.match(source, /<StateBlock\s+v-if="accessDenied"[\s\S]*type="access"[\s\S]*action-text="切换账号"[\s\S]*secondary-action-text="返回员工端"[\s\S]*@action="switchAccount"[\s\S]*@secondary-action="goToEmployee"/);
assert.match(source, new RegExp(`if \\(isSupervisorAccessDenied\\(error\\)\\) \\{[\\s\\S]*${clear}\\(\\);[\\s\\S]*accessDenied\\.value = true;[\\s\\S]*return;`)); assert.match(source, new RegExp(`if \\(isSupervisorAccessDenied\\(error\\)\\) \\{[\\s\\S]*${clear}\\(\\);[\\s\\S]*accessDenied\\.value = true;[\\s\\S]*return;`));
assert.match(source, /if \(!loggedIn\.value\) \{[\s\S]*accessDenied\.value = false;/); assert.match(source, /if \(!loggedIn\.value\) \{[\s\S]*accessDenied\.value = false;/);
assert.match(source, /accessDenied\.value = false;[\s\S]*(?:snapshot|nextReviews|detail\.value|nextAssignments)/); assert.match(source, /accessDenied\.value = false;[\s\S]*(?:snapshot|nextReviews|detail\.value|nextAssignments)/);
@@ -444,18 +525,11 @@ test('指派页通过团队快照在进入时校验主管权限', () => {
assert.doesNotMatch(assignPageSource, /getPracticeAssignments/); assert.doesNotMatch(assignPageSource, /getPracticeAssignments/);
}); });
test('指派表单和派发进度使用同页分段视图避免堆成长页', () => { test('指派页将逐人派发结果交由独立结果页呈现,避免在表单页堆叠', () => {
assert.match(assignPageSource, /assignView/); assert.match(assignPageSource, /class="panel form-panel"/);
assert.match(assignPageSource, /v-if="assignView === 'create'" class="panel assignment-form"/); assert.match(assignPageSource, /class="panel progress-panel"/);
assert.match(assignPageSource, /v-if="assignView === 'progress'" class="panel history-panel"/); assert.match(assignPageSource, /assignResultStorageKey/);
assert.match(assignPageSource, /class="assign-tabs"/); assert.match(assignPageSource, /\/pages\/supervisor\/assign\/result/);
assert.match(assignPageSource, /memberSuggestions/);
assert.match(assignPageSource, /selectTarget/);
assert.match(assignPageSource, /class="target-suggestions"/);
assert.match(assignPageSource, /assignmentDueTime/);
assert.match(assignPageSource, /assignmentGoal/);
assert.match(assignPageSource, /assignmentRequirements/);
assert.match(assignPageSource, /item\.dueTime/);
}); });
test('指派页保留 H5 查询参数二次解码', () => { test('指派页保留 H5 查询参数二次解码', () => {
@@ -463,14 +537,16 @@ test('指派页保留 H5 查询参数二次解码', () => {
}); });
test('主管 dashboard 和 team 使用团队快照,reviews 继续使用待复盘接口', () => { test('主管 dashboard 和 team 使用团队快照,reviews 继续使用待复盘接口', () => {
assert.match(practiceServiceSource, /export const getPracticeTeamSnapshot = \(\) =>/); assert.match(practiceServiceSource, /export const getPracticeTeamSnapshot = \(includeProfiles = false\) =>/);
assert.match(practiceServiceSource, /url: '\/api\/aihr\/mobile\/practice\/team'/); assert.match(practiceServiceSource, /'\/api\/aihr\/mobile\/practice\/team\?includeProfiles=true'/);
assert.match(dashboardPageSource, /getPracticeTeamSnapshot/); assert.match(practiceServiceSource, /: '\/api\/aihr\/mobile\/practice\/team'/);
assert.match(dashboardPageSource, /buildTeamProfiles\(records\.value, assignments\.value, members\.value\)/); assert.match(practiceServiceSource, /includeProfiles=true/);
assert.match(teamPageSource, /getPracticeTeamSnapshot/); assert.match(dashboardPageSource, /getPracticeTeamSnapshot\(true\)/);
assert.match(teamPageSource, /buildTeamProfiles\(records\.value, assignments\.value, members\.value\)/); assert.match(dashboardPageSource, /buildTeamProfiles\(records\.value, assignments\.value, members\.value, teamProfileSummaries\.value\)/);
assert.match(teamPageSource, /getPracticeTeamSnapshot\(true\)/);
assert.match(teamPageSource, /buildTeamProfiles\(records\.value, assignments\.value, members\.value, teamProfileSummaries\.value\)/);
assert.match(reviewsPageSource, /getPracticeReviews/); assert.match(reviewsPageSource, /getPracticeReviews/);
assert.doesNotMatch(reviewsPageSource, /getPracticeTeamSnapshot/); assert.match(reviewsPageSource, /getPracticeTeamSnapshot\(\)\.catch/);
}); });
test('五个主管页面登录都使用 redirectTo 避免返回栈重复', () => { test('五个主管页面登录都使用 redirectTo 避免返回栈重复', () => {
+6 -6
View File
@@ -7,10 +7,10 @@ const source = (path) => readFile(new URL(path, import.meta.url), 'utf8');
test('工作上报首屏是语音优先会话,不再暴露传统表单主路径', async () => { test('工作上报首屏是语音优先会话,不再暴露传统表单主路径', async () => {
const page = await source('../src/pages/user/report/index.vue'); const page = await source('../src/pages/user/report/index.vue');
assert.match(page, /工作上报/); assert.match(page, /成果投稿/);
assert.match(page, /数字师傅帮你整理/); assert.match(page, /把好经验沉淀给公司复用/);
assert.match(page, /我的上报/); assert.match(page, /我的投稿/);
assert.match(page, /今天处理了什么?按住说,也可以发照片或视频。/); assert.match(page, /哪段处置经验值得公司复用?按住说,也可以发照片或视频,我会整理成案例、SOP 或知识投稿。/);
assert.match(page, /<ChatComposer/); assert.match(page, /<ChatComposer/);
assert.doesNotMatch(page, /选择上报类型/); assert.doesNotMatch(page, /选择上报类型/);
assert.doesNotMatch(page, /class="type-grid"/); assert.doesNotMatch(page, /class="type-grid"/);
@@ -71,7 +71,7 @@ test('确认卡允许修改最终类型标题正文且不承诺自动脱敏', as
assert.doesNotMatch(page, /privacyReviewed/); assert.doesNotMatch(page, /privacyReviewed/);
assert.match(page, /editingDraft \? '完成修改' : '修改'/); assert.match(page, /editingDraft \? '完成修改' : '修改'/);
assert.match(page, /@click="confirmSubmit"/); assert.match(page, /@click="confirmSubmit"/);
assert.match(page, /'确认上报'/); assert.match(page, /'确认投稿'/);
assert.doesNotMatch(page, /自动隐藏/); assert.doesNotMatch(page, /自动隐藏/);
assert.doesNotMatch(page, />继续补充<\/button>/); assert.doesNotMatch(page, />继续补充<\/button>/);
}); });
@@ -122,7 +122,7 @@ test('AI 长草稿不被前端静默截断,改由确认时明确校验', async
assert.doesNotMatch(page, /String\(result\.content[\s\S]*?\.slice\(0, 2000\)/); assert.doesNotMatch(page, /String\(result\.content[\s\S]*?\.slice\(0, 2000\)/);
assert.match(page, /content\.length > 2000/); assert.match(page, /content\.length > 2000/);
assert.match(page, /上报内容不能超过2000字/); assert.match(page, /投稿内容不能超过2000字/);
}); });
test('工作上报服务声明整理契约并在正式提交携带 requestId', async () => { test('工作上报服务声明整理契约并在正式提交携带 requestId', async () => {