feat(aihr): admin dashboard with real aggregates
新增 /api/aihr/dashboard 聚合面试/对练/知识库/组织快照真实数据, 管理端首页从静态卡片改为 API 优先 + fallback。
This commit is contained in:
+22
@@ -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<OverviewResponse> overview() {
|
||||
return R.ok(dashboardService.overview());
|
||||
}
|
||||
}
|
||||
+42
@@ -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<FeatureStatusResponse> heroEntries,
|
||||
List<StageResponse> stages,
|
||||
List<RiskResponse> risks,
|
||||
List<TrainingRecordResponse> records,
|
||||
List<PipelineStepResponse> pipeline,
|
||||
PipelineSummaryResponse pipelineSummary,
|
||||
List<MetricResponse> 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) {
|
||||
}
|
||||
}
|
||||
+304
@@ -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<FeatureStatusResponse> 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<StageResponse> 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<RiskResponse> 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<PipelineStepResponse> 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<TrainingRecordResponse> 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) {
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user