diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java index a3544ccd..b6c80a6d 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java @@ -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) { diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java index 0f41b73b..07e229db 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java @@ -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()); diff --git a/docs/BRD_IMPLEMENTATION_AUDIT.md b/docs/BRD_IMPLEMENTATION_AUDIT.md index 8eba0b03..e1498340 100644 --- a/docs/BRD_IMPLEMENTATION_AUDIT.md +++ b/docs/BRD_IMPLEMENTATION_AUDIT.md @@ -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 计。未新增表字段;正式试点仍需核对时间来源、异常时长和课程学习时长是否纳入统一口径。 diff --git a/mobile-uni/src/pages/user/practice/index.vue b/mobile-uni/src/pages/user/practice/index.vue index cbefd50a..e3e9909a 100644 --- a/mobile-uni/src/pages/user/practice/index.vue +++ b/mobile-uni/src/pages/user/practice/index.vue @@ -62,6 +62,9 @@ + + {{ item.sourceLabel }} + {{ item.title }} {{ item.desc }} @@ -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(() => { 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' }); diff --git a/mobile-uni/src/pages/user/today/index.vue b/mobile-uni/src/pages/user/today/index.vue index e44691f4..37fe27fb 100644 --- a/mobile-uni/src/pages/user/today/index.vue +++ b/mobile-uni/src/pages/user/today/index.vue @@ -98,6 +98,9 @@ + + {{ primaryAssignmentSourceLabel }} + {{ primaryScheduleTitle }} {{ primaryScheduleTime }} @@ -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; diff --git a/mobile-uni/tests/mobile-user-pages.test.mjs b/mobile-uni/tests/mobile-user-pages.test.mjs index 8ae06665..88f5f84a 100644 --- a/mobile-uni/tests/mobile-user-pages.test.mjs +++ b/mobile-uni/tests/mobile-user-pages.test.mjs @@ -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'),