fix(aihr): align task labels and training duration

This commit is contained in:
2026-07-14 14:15:02 +08:00
parent 23328f66ff
commit 8bb43a2394
6 changed files with 92 additions and 7 deletions
@@ -1568,7 +1568,12 @@ public class AihrPracticeSeedService {
COALESCE(ROUND(AVG(dim_communication)), 0) communication,
COALESCE(ROUND(AVG(dim_emotion)), 0) emotion,
COALESCE(ROUND(AVG(dim_marketing)), 0) marketing,
COALESCE(SUM(incentive_point), 0) incentive_points
COALESCE(SUM(incentive_point), 0) incentive_points,
COALESCE(ROUND(SUM(CASE
WHEN started_time IS NOT NULL AND finished_time IS NOT NULL
THEN GREATEST(TIMESTAMPDIFF(SECOND, started_time, finished_time), 0)
ELSE 0
END) / 60.0), 0) training_minutes
FROM aihr_practice_session
WHERE tenant_id = ? AND mode = 'mobile' AND (? = '' OR ext_party_id = ?)
""", (rs, rowNum) -> new CompetencySnapshot(
@@ -1579,10 +1584,11 @@ public class AihrPracticeSeedService {
rs.getInt("communication"),
rs.getInt("emotion"),
rs.getInt("marketing"),
rs.getInt("incentive_points")
rs.getInt("incentive_points"),
rs.getInt("training_minutes")
), tenantId(), party, party);
CompetencySnapshot snapshot = rows.isEmpty() ? new CompetencySnapshot(0, 0, 0, 0, 0, 0, 0, 0) : rows.get(0);
int trainingMinutes = snapshot.completed() * 8;
CompetencySnapshot snapshot = rows.isEmpty() ? new CompetencySnapshot(0, 0, 0, 0, 0, 0, 0, 0, 0) : rows.get(0);
int trainingMinutes = snapshot.trainingMinutes();
int contribution = Math.min(100, Math.max(0, snapshot.incentivePoints()));
int assessmentScore = snapshot.score();
String aiLevel = aiLevel(snapshot.completed(), assessmentScore);
@@ -3894,7 +3900,7 @@ public class AihrPracticeSeedService {
return Math.round((left + right) / 2.0f);
}
private record CompetencySnapshot(Integer completed, Integer score, Integer pendingReview, Integer compliance, Integer communication, Integer emotion, Integer marketing, Integer incentivePoints) {
private record CompetencySnapshot(Integer completed, Integer score, Integer pendingReview, Integer compliance, Integer communication, Integer emotion, Integer marketing, Integer incentivePoints, Integer trainingMinutes) {
}
private record EvidenceScoreSnapshot(Integer aiScore, Integer humanScore, Integer finalScore, Integer calibrationCount) {
@@ -677,7 +677,8 @@ public class AihrPracticeSeedServiceTest {
"communication", "0",
"emotion", "0",
"marketing", "0",
"incentive_points", "0"
"incentive_points", "0",
"training_minutes", "0"
)));
AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, null, null);
@@ -724,13 +725,15 @@ public class AihrPracticeSeedServiceTest {
"communication", "80",
"emotion", "78",
"marketing", "74",
"incentive_points", "20"
"incentive_points", "20",
"training_minutes", "42"
)));
AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, null, null);
CompetencyResponse profile = service.competency("13900001001");
assertEquals("初级", profile.aiLevel());
assertEquals(42, profile.trainingMinutes());
assertEquals(20, profile.contribution());
assertEquals(100, profile.growthPath().get(0).progress());
assertEquals("已达成", profile.growthPath().get(0).status());
+2
View File
@@ -277,3 +277,5 @@
- 2026-07-14 BRD M1 派发场景状态与展示一致性复核:修复停用场景仍可通过直接派发接口进入待训练,以及客户端传入场景名称可与场景 ID 不一致的问题;主管派发现在复用启停校验,记录名称始终取服务端场景定义。历史任务读取不受影响,未修改生产数据。
- 2026-07-14 BRD M1 场景筛选回退语义复核:场景表查询成功但岗位/类型没有启用场景时,旧逻辑仍回退到内置场景,可能把不匹配或已停用场景展示给员工/主管;现仅在数据库异常时保留 seed fallback,真实空结果保持为空,并补岗位筛选回归测试。未修改生产数据。
- 2026-07-14 BRD M1 主管场景入口回退复核:后端已启用场景 API 优先,但主管指派页模板仍循环三条静态场景,且 API 正常空结果会继续显示静态选项;现改为循环 API 场景,真实空结果显示“暂无可派发的已启用场景”,只有请求失败才使用本地兜底。复盘后再练入口同步保持真实空结果为空,未修改生产数据。
- 2026-07-14 BRD 4.4 任务来源展示补齐:首页“今日安排”和练习页“最近练过”此前只把非每日任务显示为场景名/“专项训练”,会遮蔽主管派发的错题再练与训练营来源;现统一显示“每日三题/错题再练/训练营/专项训练”标签。该修复只补来源可见性,不把 `source='camp'` 扩展为尚未确认的训练营计划、进度或完成规则。
- 2026-07-14 BRD 4.5/B5 训练时长口径修复:能力画像此前用“完成次数 × 8 分钟”估算训练时长,无法作为真实时长证据;现复用 `started_time/finished_time` 按实际秒数聚合并换算分钟,无时间证据的历史记录按 0 计。未新增表字段;正式试点仍需核对时间来源、异常时长和课程学习时长是否纳入统一口径。
@@ -62,6 +62,9 @@
<view v-for="item in recentPracticeItems" :key="item.id" class="assignment-row recent-row" :class="{ highlighted: item.assignment && highlightedAssignmentId === item.assignment.id }" @click="openRecentItem(item)">
<view class="recent-icon" aria-hidden="true">{{ item.icon }}</view>
<view class="assignment-copy">
<text v-if="item.sourceLabel" class="assignment-badge" :class="item.assignment?.source || 'manual'">
{{ item.sourceLabel }}
</text>
<text class="assignment-title">{{ item.title }}</text>
<text class="assignment-desc">{{ item.desc }}</text>
</view>
@@ -271,6 +274,7 @@ type RecentPracticeItem = {
id: string;
title: string;
desc: string;
sourceLabel?: string;
status: string;
icon: string;
assignment?: PracticeAssignment;
@@ -334,6 +338,7 @@ const recentPracticeItems = computed<RecentPracticeItem[]>(() => {
id: `assignment-${item.id}`,
title: item.question || item.scenarioName || '专项训练',
desc: item.status === '已完成' ? '已完成' : '待复盘',
sourceLabel: assignmentSourceLabel(item.source),
status: item.status,
icon: item.scenarioName?.includes('催费') ? '⌕' : item.scenarioName?.includes('报修') ? '⌁' : '◉',
assignment: item
@@ -370,6 +375,12 @@ const assignmentDesc = (item: PracticeAssignment) => {
return item.feedback || item.reason;
};
const assignmentSourceLabel = (source?: string) => {
if (source === 'retry') return '错题再练';
if (source === 'camp') return '训练营';
return '专项训练';
};
const goLogin = () => {
rememberLoginRedirect('/pages/user/practice/index');
uni.navigateTo({ url: '/pages/auth/login/index' });
+45
View File
@@ -98,6 +98,9 @@
<uni-icons class="schedule-icon" type="compose" size="26" color="#2f6bff" />
</view>
<view class="schedule-copy">
<text v-if="primaryAssignmentSourceLabel" class="schedule-source" :class="primaryAssignmentSourceClass">
{{ primaryAssignmentSourceLabel }}
</text>
<text class="schedule-title">{{ primaryScheduleTitle }}</text>
<view class="schedule-meta">
<text class="meta-item"><uni-icons type="calendar" size="15" color="#8a8d93" /> {{ primaryScheduleTime }}</text>
@@ -247,6 +250,21 @@ const primaryScheduleTitle = computed(() => {
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 primaryAssignmentSourceClass = computed(() =>
primaryAssignment.value?.source || 'manual'
);
const primaryScheduleDesc = computed(() => {
const item = primaryAssignment.value;
if (item) {
@@ -861,6 +879,33 @@ onShow(() => {
line-height: 1.25;
}
.schedule-source {
display: inline-flex;
width: fit-content;
padding: 3px 8px;
border-radius: 999px;
background: #edf3ff;
color: #2f6bff;
font-size: 12px;
font-weight: 800;
line-height: 1.3;
}
.schedule-source.retry {
background: #fff1f2;
color: #d51b2b;
}
.schedule-source.camp {
background: #fff7eb;
color: #b86b00;
}
.schedule-source.daily {
background: #eef8f2;
color: #258652;
}
.schedule-meta {
display: flex;
flex-wrap: wrap;
@@ -119,6 +119,15 @@ test('员工首页高保真入口继续使用真实图标和业务跳转', async
assert.match(source, /src="\/static\/mentor-hifi\.png"/);
});
test('员工首页今日安排保留派发任务来源', async () => {
const source = await pageSource('../src/pages/user/today/index.vue');
assert.match(source, /primaryAssignmentSourceLabel/);
assert.match(source, /source === 'retry'[\s\S]*错题再练/);
assert.match(source, /source === 'camp'[\s\S]*训练营/);
assert.match(source, /class="schedule-source"/);
});
test('问师傅页保留搜索框、答案区和总结卡入口', async () => {
const source = await pageSource('../src/pages/user/sop/index.vue');
@@ -137,6 +146,15 @@ test('训练页保留任务卡、对话区、回复区和结果区', async () =>
assert.match(source, /result-card/);
});
test('训练页最近练过保留派发任务来源', async () => {
const source = await pageSource('../src/pages/user/practice/index.vue');
assert.match(source, /sourceLabel: assignmentSourceLabel\(item\.source\)/);
assert.match(source, /class="assignment-badge"/);
assert.match(source, /source === 'retry'[\s\S]*错题再练/);
assert.match(source, /source === 'camp'[\s\S]*训练营/);
});
test('学习页展示真实错题本并可进入已有再练任务', async () => {
const [source, practiceService] = await Promise.all([
pageSource('../src/pages/user/learning/index.vue'),