diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrDashboardController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrDashboardController.java new file mode 100644 index 00000000..856bb331 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrDashboardController.java @@ -0,0 +1,22 @@ +package org.dromara.aihr.controller; + +import lombok.RequiredArgsConstructor; +import org.dromara.aihr.domain.AihrDashboardDto.OverviewResponse; +import org.dromara.aihr.service.AihrDashboardService; +import org.dromara.common.core.domain.R; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/aihr/dashboard") +public class AihrDashboardController { + + private final AihrDashboardService dashboardService; + + @GetMapping("/overview") + public R overview() { + return R.ok(dashboardService.overview()); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrDashboardDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrDashboardDto.java new file mode 100644 index 00000000..9b1ba9dc --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrDashboardDto.java @@ -0,0 +1,42 @@ +package org.dromara.aihr.domain; + +import java.util.List; + +public final class AihrDashboardDto { + + private AihrDashboardDto() { + } + + public record OverviewResponse( + String snapshotTime, + List heroEntries, + List stages, + List risks, + List records, + List pipeline, + PipelineSummaryResponse pipelineSummary, + List metrics + ) { + } + + public record FeatureStatusResponse(String key, String status, String type, String note) { + } + + public record StageResponse(Integer step, String name, String desc, String label, String state, String tagType) { + } + + public record RiskResponse(String name, String owner, String status, String type) { + } + + public record TrainingRecordResponse(String name, String scene, Integer score, String status, String state, String time) { + } + + public record PipelineStepResponse(String name, String count, String state) { + } + + public record PipelineSummaryResponse(String todayNew, String pending, String completed) { + } + + public record MetricResponse(String label, String value, String target, String type) { + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrDashboardService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrDashboardService.java new file mode 100644 index 00000000..31e13a5a --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrDashboardService.java @@ -0,0 +1,304 @@ +package org.dromara.aihr.service; + +import lombok.RequiredArgsConstructor; +import org.dromara.aihr.domain.AihrDashboardDto.FeatureStatusResponse; +import org.dromara.aihr.domain.AihrDashboardDto.MetricResponse; +import org.dromara.aihr.domain.AihrDashboardDto.OverviewResponse; +import org.dromara.aihr.domain.AihrDashboardDto.PipelineStepResponse; +import org.dromara.aihr.domain.AihrDashboardDto.PipelineSummaryResponse; +import org.dromara.aihr.domain.AihrDashboardDto.RiskResponse; +import org.dromara.aihr.domain.AihrDashboardDto.StageResponse; +import org.dromara.aihr.domain.AihrDashboardDto.TrainingRecordResponse; +import org.springframework.dao.DataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class AihrDashboardService { + + private static final String TENANT_ID = "000000"; + private static final DateTimeFormatter SNAPSHOT_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + private static final DateTimeFormatter ROW_TIME = DateTimeFormatter.ofPattern("MM-dd HH:mm"); + + private final JdbcTemplate jdbcTemplate; + + public OverviewResponse overview() { + InterviewStats interview = interviewStats(); + PracticeStats practice = practiceStats(); + KnowledgeStats knowledge = knowledgeStats(); + CaseStats cases = caseStats(); + CandidateStats candidate = candidateStats(); + OrgStats org = orgStats(); + ModelStats models = modelStats(); + int completedPaths = bool(interview.total() > 0) + bool(practice.total() > 0) + bool(cases.stored() > 0) + bool(knowledge.fragments() > 0); + + return new OverviewResponse( + LocalDateTime.now().format(SNAPSHOT_TIME), + heroEntries(interview, practice, knowledge, cases, models), + stages(interview, practice, knowledge, cases, org), + risks(practice, knowledge, candidate, models), + trainingRecords(), + pipeline(cases), + new PipelineSummaryResponse(cases.todayNew() + " 条", cases.pending() + " 条", cases.stored() + " 条"), + List.of( + new MetricResponse("端到端路径", completedPaths + " 条", "L0 目标 >= 1 条", metricType(completedPaths, 1)), + new MetricResponse("SOP已入库", knowledge.completedDocs() + " 份", "B1 目标 >= 5 流程", metricType(knowledge.completedDocs(), 5)), + new MetricResponse("训练记录", practice.total() + " 条", "育:至少 1 类场景", metricType(practice.total(), 1)), + new MetricResponse("试点人数", org.activePeople() + " 人", "D1 目标 >= 20 人", metricType(org.activePeople(), 20)) + ) + ); + } + + private List heroEntries(InterviewStats interview, PracticeStats practice, KnowledgeStats knowledge, CaseStats cases, ModelStats models) { + return List.of( + new FeatureStatusResponse("interview", interview.total() > 0 ? "已有记录" : "可演示", "success", interview.total() + " 条面试结果"), + new FeatureStatusResponse("practice", practice.total() > 0 ? "已有记录" : "待跑通", practice.total() > 0 ? "success" : "warning", practice.total() + " 条训练记录"), + new FeatureStatusResponse("case", cases.stored() > 0 ? "已入库" : models.asrEnabled() ? "可试跑" : "样片兜底", cases.stored() > 0 ? "success" : "warning", cases.stored() + " 条案例入库"), + new FeatureStatusResponse("sop", knowledge.fragments() > 0 ? "可检索" : "待入库", knowledge.fragments() > 0 ? "success" : "warning", knowledge.fragments() + " 个知识片段") + ); + } + + private List stages(InterviewStats interview, PracticeStats practice, KnowledgeStats knowledge, CaseStats cases, OrgStats org) { + return List.of( + new StageResponse(1, "选:AI面试", "题目与评估", interview.total() > 0 ? "已留痕" : "可演示", "done", "success"), + new StageResponse(2, "用:岗位适配", "组织快照", org.activePeople() > 0 ? "已接入" : "待同步", org.activePeople() > 0 ? "done" : "idle", org.activePeople() > 0 ? "success" : "info"), + new StageResponse(3, "育:三角色对练", "实战陪练", practice.total() > 0 ? "已留痕" : "待演示", practice.total() > 0 ? "done" : "active", practice.total() > 0 ? "success" : "danger"), + new StageResponse(4, "留:成长追踪", "能力画像", practice.total() > 0 ? "可生成" : "待积累", practice.total() > 0 ? "active" : "idle", practice.total() > 0 ? "warning" : "info"), + new StageResponse(5, "案例沉淀", "知识与复用", cases.stored() > 0 || knowledge.completedDocs() > 0 ? "进行中" : "待入库", cases.stored() > 0 ? "done" : "idle", cases.stored() > 0 ? "success" : "info") + ); + } + + private InterviewStats interviewStats() { + if (!tableExists("aihr_interview_result")) { + return new InterviewStats(0); + } + return new InterviewStats(count("SELECT COUNT(*) FROM aihr_interview_result WHERE tenant_id = ?", TENANT_ID)); + } + + private List risks(PracticeStats practice, KnowledgeStats knowledge, CandidateStats candidate, ModelStats models) { + return List.of( + new RiskResponse("住宅 SOP 素材", "培训组", knowledge.completedDocs() >= 5 ? "已满足" : knowledge.completedDocs() + "/5", metricType(knowledge.completedDocs(), 5)), + new RiskResponse("方言 ASR", "技术组", models.asrEnabled() ? "已配置" : "未配置", models.asrEnabled() ? "success" : "warning"), + new RiskResponse("候选资料审核", "HR组", candidate.pending() > 0 ? "待审核 " + candidate.pending() : "无待审", candidate.pending() > 0 ? "warning" : "success"), + new RiskResponse("移动训练复盘", "主管", practice.pendingReview() > 0 ? "待复盘 " + practice.pendingReview() : "无待复盘", practice.pendingReview() > 0 ? "warning" : "success") + ); + } + + private List pipeline(CaseStats cases) { + return List.of( + new PipelineStepResponse("语音上传", cases.total() + " 条", state(cases.total())), + new PipelineStepResponse("ASR 转写", cases.transcribed() + " 条", state(cases.transcribed())), + new PipelineStepResponse("AI 整理", cases.organized() + " 条", state(cases.organized())), + new PipelineStepResponse("审核入库", cases.stored() + " 条", state(cases.stored())), + new PipelineStepResponse("样片展示", cases.stored() + " 条", state(cases.stored())) + ); + } + + private List trainingRecords() { + if (!tableExists("aihr_practice_session")) { + return List.of(); + } + try { + return jdbcTemplate.query(""" + SELECT trainee_name, scenario_name, total_score, status, finished_time + FROM aihr_practice_session + WHERE tenant_id = ? AND finished_time IS NOT NULL + ORDER BY finished_time DESC, id DESC + LIMIT 5 + """, this::trainingRecord, TENANT_ID); + } catch (DataAccessException e) { + return List.of(); + } + } + + private TrainingRecordResponse trainingRecord(ResultSet rs, int rowNum) throws SQLException { + int score = rs.getInt("total_score"); + String status = blankTo(rs.getString("status"), score > 0 ? "已完成" : "待评分"); + return new TrainingRecordResponse( + blankTo(rs.getString("trainee_name"), "-"), + blankTo(rs.getString("scenario_name"), "-"), + score, + status, + statusState(status, score), + formatTime(rs.getTimestamp("finished_time")) + ); + } + + private PracticeStats practiceStats() { + if (!tableExists("aihr_practice_session")) { + return new PracticeStats(0, 0, 0); + } + try { + return jdbcTemplate.queryForObject(""" + SELECT COUNT(*) total, + SUM(CASE WHEN status = '待复盘' THEN 1 ELSE 0 END) pending_review, + SUM(CASE WHEN DATE(finished_time) = CURRENT_DATE THEN 1 ELSE 0 END) today + FROM aihr_practice_session + WHERE tenant_id = ? AND finished_time IS NOT NULL + """, (rs, rowNum) -> new PracticeStats(rs.getInt("total"), rs.getInt("pending_review"), rs.getInt("today")), TENANT_ID); + } catch (DataAccessException e) { + return new PracticeStats(0, 0, 0); + } + } + + private KnowledgeStats knowledgeStats() { + if (!tableExists("aihr_knowledge_attach") || !tableExists("aihr_knowledge_fragment")) { + return new KnowledgeStats(0, 0, 0); + } + return new KnowledgeStats( + count("SELECT COUNT(*) FROM aihr_knowledge_attach WHERE tenant_id = ?", TENANT_ID), + count("SELECT COUNT(*) FROM aihr_knowledge_attach WHERE tenant_id = ? AND status = 2", TENANT_ID), + count("SELECT COUNT(*) FROM aihr_knowledge_fragment WHERE tenant_id = ?", TENANT_ID) + ); + } + + private CaseStats caseStats() { + if (!tableExists("aihr_case_record")) { + return new CaseStats(0, 0, 0, 0, 0); + } + try { + return jdbcTemplate.queryForObject(""" + SELECT COUNT(*) total, + SUM(CASE WHEN status IN ('已转写', '已整理', '已入库') THEN 1 ELSE 0 END) transcribed, + SUM(CASE WHEN status IN ('已整理', '已入库') THEN 1 ELSE 0 END) organized, + SUM(CASE WHEN status = '已入库' THEN 1 ELSE 0 END) stored, + SUM(CASE WHEN DATE(create_time) = CURRENT_DATE THEN 1 ELSE 0 END) today_new + FROM aihr_case_record + WHERE tenant_id = ? + """, (rs, rowNum) -> new CaseStats( + rs.getInt("total"), + rs.getInt("transcribed"), + rs.getInt("organized"), + rs.getInt("stored"), + rs.getInt("today_new") + ), TENANT_ID); + } catch (DataAccessException e) { + return new CaseStats(0, 0, 0, 0, 0); + } + } + + private CandidateStats candidateStats() { + if (!tableExists("aihr_candidate_material")) { + return new CandidateStats(0, 0); + } + try { + return jdbcTemplate.queryForObject(""" + SELECT COUNT(*) total, + SUM(CASE WHEN status = '待审核' THEN 1 ELSE 0 END) pending + FROM aihr_candidate_material + WHERE tenant_id = ? + """, (rs, rowNum) -> new CandidateStats(rs.getInt("total"), rs.getInt("pending")), TENANT_ID); + } catch (DataAccessException e) { + return new CandidateStats(0, 0); + } + } + + private OrgStats orgStats() { + if (!tableExists("aihr_org_snapshot")) { + return new OrgStats(0); + } + return new OrgStats(count(""" + SELECT COUNT(*) + FROM aihr_org_snapshot + WHERE tenant_id = ? AND employment_status = 'active' + """, TENANT_ID)); + } + + private ModelStats modelStats() { + if (!tableExists("aihr_model_config")) { + return new ModelStats(false); + } + return new ModelStats(count(""" + SELECT COUNT(*) + FROM aihr_model_config + WHERE tenant_id = ? AND category = 'asr' AND enabled = 1 + """, TENANT_ID) > 0); + } + + private boolean tableExists(String tableName) { + try { + Integer count = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? + """, Integer.class, tableName); + return count != null && count > 0; + } catch (DataAccessException e) { + return false; + } + } + + private int count(String sql, Object... args) { + try { + Integer value = jdbcTemplate.queryForObject(sql, Integer.class, args); + return value == null ? 0 : value; + } catch (DataAccessException e) { + return 0; + } + } + + private static String statusState(String status, int score) { + if (score > 0 && score < 70) { + return "danger"; + } + if (status.contains("待") || status.contains("进行")) { + return "active"; + } + return "done"; + } + + private static String state(int count) { + return count > 0 ? "done" : "todo"; + } + + private static String metricType(int value, int target) { + if (value >= target) { + return "success"; + } + return value > 0 ? "warning" : "danger"; + } + + private static int bool(boolean value) { + return value ? 1 : 0; + } + + private static String formatTime(Timestamp time) { + return time == null ? "" : time.toLocalDateTime().format(ROW_TIME); + } + + private static String blankTo(String value, String fallback) { + return value == null || value.isBlank() ? fallback : value; + } + + private record PracticeStats(int total, int pendingReview, int today) { + } + + private record InterviewStats(int total) { + } + + private record KnowledgeStats(int totalDocs, int completedDocs, int fragments) { + } + + private record CaseStats(int total, int transcribed, int organized, int stored, int todayNew) { + int pending() { + return Math.max(0, organized - stored); + } + } + + private record CandidateStats(int total, int pending) { + } + + private record OrgStats(int activePeople) { + } + + private record ModelStats(boolean asrEnabled) { + } +} diff --git a/frontend/src/api/aihr/dashboard.ts b/frontend/src/api/aihr/dashboard.ts new file mode 100644 index 00000000..c5e6ef0c --- /dev/null +++ b/frontend/src/api/aihr/dashboard.ts @@ -0,0 +1,74 @@ +import request from '@/utils/request'; + +type ApiResult = { + code: number; + msg: string; + data: T; +}; + +export type DashboardHeroEntry = { + key: string; + status: string; + type: string; + note: string; +}; + +export type DashboardStage = { + step: number; + name: string; + desc: string; + label: string; + state: string; + tagType: string; +}; + +export type DashboardRisk = { + name: string; + owner: string; + status: string; + type: string; +}; + +export type DashboardTrainingRecord = { + name: string; + scene: string; + score: number; + status: string; + state: string; + time: string; +}; + +export type DashboardPipelineStep = { + name: string; + count: string; + state: string; +}; + +export type DashboardMetric = { + label: string; + value: string; + target: string; + type: string; +}; + +export type DashboardOverview = { + snapshotTime: string; + heroEntries: DashboardHeroEntry[]; + stages: DashboardStage[]; + risks: DashboardRisk[]; + records: DashboardTrainingRecord[]; + pipeline: DashboardPipelineStep[]; + pipelineSummary: { + todayNew: string; + pending: string; + completed: string; + }; + metrics: DashboardMetric[]; +}; + +export function getDashboardOverview(): Promise> { + return request({ + url: '/api/aihr/dashboard/overview', + method: 'get' + }); +} diff --git a/frontend/src/views/index.vue b/frontend/src/views/index.vue index 1e44772b..25015b95 100644 --- a/frontend/src/views/index.vue +++ b/frontend/src/views/index.vue @@ -1,8 +1,8 @@