feat: 新增语音转文字(ASR)功能
- 后端:新增 /api/audio/transcribe 接口,调用 Ollama whisper 进行语音识别 - 前端:新增 AudioTranscribePage.vue 页面,支持 MP3/WAV/M4A/OGG/FLAC 等格式 - 注册路由、工具卡片、智能助手欢迎语更新 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,384 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
"eai_agentplatform/backend/internal/store"
|
||||
"eai_agentplatform/backend/internal/web"
|
||||
)
|
||||
|
||||
// round1 保留 1 位小数
|
||||
func round1(f float64) float64 {
|
||||
return math.Round(f*10) / 10
|
||||
}
|
||||
|
||||
// examSummary 成绩汇总(传入某集合的 exam_record)
|
||||
func examSummary(recs []model.ExamRecord) gin.H {
|
||||
s := gin.H{"total": 0, "passed": 0, "failed": 0, "pass_rate": 0.0, "avg_score": 0.0, "max_score": 0, "min_score": 0}
|
||||
if len(recs) == 0 {
|
||||
return s
|
||||
}
|
||||
passed, sum, maxS, minS := 0, 0, -1, 1000000
|
||||
for _, r := range recs {
|
||||
if r.Passed {
|
||||
passed++
|
||||
}
|
||||
sum += r.Score
|
||||
if r.Score > maxS {
|
||||
maxS = r.Score
|
||||
}
|
||||
if r.Score < minS {
|
||||
minS = r.Score
|
||||
}
|
||||
}
|
||||
s["total"] = len(recs)
|
||||
s["passed"] = passed
|
||||
s["failed"] = len(recs) - passed
|
||||
s["pass_rate"] = round1(float64(passed) * 100 / float64(len(recs)))
|
||||
s["avg_score"] = round1(float64(sum) / float64(len(recs)))
|
||||
s["max_score"] = maxS
|
||||
s["min_score"] = minS
|
||||
return s
|
||||
}
|
||||
|
||||
// AdminDashboard GET /api/system/dashboard —— 管理员首页运营指标
|
||||
func AdminDashboard(c *gin.Context) {
|
||||
var employeeCount, adminCount, productCount, courseCount, questionCount, formalPaperCount int64
|
||||
var pendingMedia, pendingKnowledge, recentUsers int64
|
||||
|
||||
store.DB.Model(&model.User{}).Where("role = ?", "employee").Count(&employeeCount)
|
||||
store.DB.Model(&model.User{}).Where("role = ?", "admin").Count(&adminCount)
|
||||
store.DB.Model(&model.Product{}).Where("status = ?", "active").Count(&productCount)
|
||||
store.DB.Model(&model.Course{}).Where("status = ?", "active").Count(&courseCount)
|
||||
store.DB.Model(&model.Question{}).Where("status = ?", "active").Count(&questionCount)
|
||||
store.DB.Model(&model.ExamPaper{}).Where("type = ? AND status = ?", "formal", "active").Count(&formalPaperCount)
|
||||
store.DB.Model(&model.MediaFile{}).Where("status = ?", "pending").Count(&pendingMedia)
|
||||
store.DB.Model(&model.KnowledgeSource{}).Where("audit_status = ?", "pending").Count(&pendingKnowledge)
|
||||
store.DB.Model(&model.User{}).Where("created_at >= ?", time.Now().AddDate(0, 0, -7)).Count(&recentUsers)
|
||||
|
||||
// 近 7 天每日新增(首页迷你柱状图数据源)
|
||||
now := time.Now()
|
||||
dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).AddDate(0, 0, -6)
|
||||
var newRows []struct {
|
||||
Date string `gorm:"column:d"`
|
||||
Count int64 `gorm:"column:c"`
|
||||
}
|
||||
store.DB.Raw(`SELECT strftime('%Y-%m-%d', created_at) AS d, COUNT(*) AS c
|
||||
FROM user WHERE created_at >= ? GROUP BY d`, dayStart).Scan(&newRows)
|
||||
newByDay := map[string]int64{}
|
||||
for _, r := range newRows {
|
||||
newByDay[r.Date] = r.Count
|
||||
}
|
||||
newUsers7d := make([]gin.H, 0, 7)
|
||||
for i := 0; i < 7; i++ {
|
||||
d := dayStart.AddDate(0, 0, i).Format("2006-01-02")
|
||||
newUsers7d = append(newUsers7d, gin.H{"date": d, "count": newByDay[d]})
|
||||
}
|
||||
|
||||
var recs []model.ExamRecord
|
||||
store.DB.Find(&recs)
|
||||
|
||||
web.OK(c, gin.H{
|
||||
"employee_count": employeeCount,
|
||||
"admin_count": adminCount,
|
||||
"product_count": productCount,
|
||||
"course_count": courseCount,
|
||||
"question_count": questionCount,
|
||||
"formal_paper_count": formalPaperCount,
|
||||
"pending_material_count": pendingMedia + pendingKnowledge,
|
||||
"recent_new_users": recentUsers,
|
||||
"new_users_7d": newUsers7d,
|
||||
"exam": examSummary(recs),
|
||||
})
|
||||
}
|
||||
|
||||
// ExamStats GET /api/system/exam-stats —— 成绩统计(汇总 + 按考试 + 分数段)
|
||||
func ExamStats(c *gin.Context) {
|
||||
var recs []model.ExamRecord
|
||||
store.DB.Order("submitted_at DESC").Find(&recs)
|
||||
|
||||
// 按 paper 聚合
|
||||
var papers []model.ExamPaper
|
||||
store.DB.Order("id ASC").Find(&papers)
|
||||
paperName := map[uint]string{}
|
||||
paperType := map[uint]string{}
|
||||
for _, p := range papers {
|
||||
paperName[p.ID] = p.Name
|
||||
paperType[p.ID] = p.Type
|
||||
}
|
||||
|
||||
type paperAgg struct {
|
||||
PaperID uint `json:"paper_id"`
|
||||
ExamName string `json:"exam_name"`
|
||||
Type string `json:"type"`
|
||||
TakenCount int `json:"taken_count"`
|
||||
Passed int `json:"passed_count"`
|
||||
PassRate float64 `json:"pass_rate"`
|
||||
AvgScore float64 `json:"avg_score"`
|
||||
}
|
||||
pmap := map[uint]*paperAgg{}
|
||||
for _, r := range recs {
|
||||
a := pmap[r.PaperID]
|
||||
if a == nil {
|
||||
a = &paperAgg{PaperID: r.PaperID, ExamName: paperName[r.PaperID], Type: paperType[r.PaperID]}
|
||||
if a.ExamName == "" {
|
||||
a.ExamName = r.ExamName
|
||||
}
|
||||
pmap[r.PaperID] = a
|
||||
}
|
||||
a.TakenCount++
|
||||
if r.Passed {
|
||||
a.Passed++
|
||||
}
|
||||
}
|
||||
papersOut := make([]paperAgg, 0, len(pmap))
|
||||
for _, a := range pmap {
|
||||
a.PassRate = round1(float64(a.Passed) * 100 / float64(a.TakenCount))
|
||||
a.AvgScore = round1(avgScoreOf(recs, a.PaperID))
|
||||
papersOut = append(papersOut, *a)
|
||||
}
|
||||
|
||||
// 分数段分布
|
||||
bands := []string{"0-59", "60-69", "70-79", "80-89", "90-100"}
|
||||
bandCount := map[string]int{}
|
||||
for _, b := range bands {
|
||||
bandCount[b] = 0
|
||||
}
|
||||
for _, r := range recs {
|
||||
bandCount[scoreBand(r.Score)]++
|
||||
}
|
||||
distribution := make([]gin.H, 0, len(bands))
|
||||
for _, b := range bands {
|
||||
distribution = append(distribution, gin.H{"band": b, "count": bandCount[b]})
|
||||
}
|
||||
|
||||
web.OK(c, gin.H{
|
||||
"summary": examSummary(recs),
|
||||
"papers": papersOut,
|
||||
"distribution": distribution,
|
||||
})
|
||||
}
|
||||
|
||||
func avgScoreOf(recs []model.ExamRecord, paperID uint) float64 {
|
||||
sum, n := 0, 0
|
||||
for _, r := range recs {
|
||||
if r.PaperID == paperID {
|
||||
sum += r.Score
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(sum) / float64(n)
|
||||
}
|
||||
|
||||
func scoreBand(score int) string {
|
||||
switch {
|
||||
case score < 60:
|
||||
return "0-59"
|
||||
case score < 70:
|
||||
return "60-69"
|
||||
case score < 80:
|
||||
return "70-79"
|
||||
case score < 90:
|
||||
return "80-89"
|
||||
default:
|
||||
return "90-100"
|
||||
}
|
||||
}
|
||||
|
||||
// ExamCoverage GET /api/system/exam-coverage —— 正式考试覆盖度(缺考名单)
|
||||
func ExamCoverage(c *gin.Context) {
|
||||
var papers []model.ExamPaper
|
||||
store.DB.Where("type = ? AND status = ?", "formal", "active").Order("id ASC").Find(&papers)
|
||||
|
||||
var employees []model.User
|
||||
store.DB.Where("role = ? AND status = ?", "employee", "active").Order("id ASC").Find(&employees)
|
||||
|
||||
var recs []model.ExamRecord
|
||||
store.DB.Order("submitted_at DESC").Find(&recs)
|
||||
// 每人每场取最新一次(正式考单次,取首条即可)
|
||||
type key struct{ uid, pid uint }
|
||||
lmap := map[key]model.ExamRecord{}
|
||||
for _, r := range recs {
|
||||
k := key{r.UserID, r.PaperID}
|
||||
if _, ok := lmap[k]; !ok {
|
||||
lmap[k] = r
|
||||
}
|
||||
}
|
||||
|
||||
papersOut := make([]gin.H, 0, len(papers))
|
||||
for _, p := range papers {
|
||||
var taken []gin.H
|
||||
var pending []gin.H
|
||||
for _, e := range employees {
|
||||
r, ok := lmap[key{e.ID, p.ID}]
|
||||
if ok {
|
||||
taken = append(taken, gin.H{
|
||||
"user_id": e.ID, "username": e.Username, "full_name": e.FullName,
|
||||
"department": e.Department, "score": r.Score, "passed": r.Passed,
|
||||
"submitted_at": r.SubmittedAt,
|
||||
})
|
||||
} else {
|
||||
pending = append(pending, gin.H{
|
||||
"user_id": e.ID, "username": e.Username, "full_name": e.FullName,
|
||||
"department": e.Department,
|
||||
})
|
||||
}
|
||||
}
|
||||
papersOut = append(papersOut, gin.H{
|
||||
"paper_id": p.ID,
|
||||
"exam_name": p.Name,
|
||||
"employee_count": len(employees),
|
||||
"taken_count": len(taken),
|
||||
"pending_count": len(pending),
|
||||
"taken": taken,
|
||||
"pending": pending,
|
||||
})
|
||||
}
|
||||
web.OK(c, gin.H{"papers": papersOut})
|
||||
}
|
||||
|
||||
// PositionExamStats GET /api/system/exam-stats-by-position —— 成绩按岗位聚合
|
||||
func PositionExamStats(c *gin.Context) {
|
||||
var positions []model.Position
|
||||
store.DB.Where("status = ?", "active").Order("id ASC").Find(&positions)
|
||||
|
||||
var users []model.User
|
||||
store.DB.Find(&users)
|
||||
userPos := map[uint]uint{} // user_id -> position_id
|
||||
posEmployee := map[uint]int{} // position_id -> 员工数
|
||||
for _, u := range users {
|
||||
if u.PositionID == nil {
|
||||
continue
|
||||
}
|
||||
userPos[u.ID] = *u.PositionID
|
||||
posEmployee[*u.PositionID]++
|
||||
}
|
||||
|
||||
type posAgg struct {
|
||||
PositionID uint `json:"position_id"`
|
||||
PositionName string `json:"position_name"`
|
||||
EmployeeCount int `json:"employee_count"`
|
||||
TakenCount int `json:"taken_count"`
|
||||
PassedCount int `json:"passed_count"`
|
||||
PassRate float64 `json:"pass_rate"`
|
||||
AvgScore float64 `json:"avg_score"`
|
||||
}
|
||||
agg := map[uint]*posAgg{}
|
||||
for _, p := range positions {
|
||||
agg[p.ID] = &posAgg{PositionID: p.ID, PositionName: p.Name, EmployeeCount: posEmployee[p.ID]}
|
||||
}
|
||||
scoreSum := map[uint]int{}
|
||||
|
||||
var recs []model.ExamRecord
|
||||
store.DB.Find(&recs)
|
||||
for _, r := range recs {
|
||||
pid, ok := userPos[r.UserID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
a := agg[pid]
|
||||
if a == nil {
|
||||
a = &posAgg{PositionID: pid}
|
||||
agg[pid] = a
|
||||
}
|
||||
a.TakenCount++
|
||||
if r.Passed {
|
||||
a.PassedCount++
|
||||
}
|
||||
scoreSum[pid] += r.Score
|
||||
}
|
||||
|
||||
out := make([]posAgg, 0, len(agg))
|
||||
for _, p := range positions {
|
||||
a := agg[p.ID]
|
||||
if a == nil {
|
||||
continue
|
||||
}
|
||||
if a.TakenCount > 0 {
|
||||
a.PassRate = round1(float64(a.PassedCount) * 100 / float64(a.TakenCount))
|
||||
a.AvgScore = round1(float64(scoreSum[p.ID]) / float64(a.TakenCount))
|
||||
}
|
||||
out = append(out, *a)
|
||||
}
|
||||
web.OK(c, gin.H{"items": out})
|
||||
}
|
||||
|
||||
// UserOverview GET /api/system/users/{id}/overview —— 员工综合画像(考试 + AI 用量 + 学习进度)
|
||||
func UserOverview(c *gin.Context) {
|
||||
id, ok := parseID(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var u model.User
|
||||
if err := store.DB.First(&u, id).Error; err != nil {
|
||||
web.Fail(c, web.NewNotFoundError("用户不存在"))
|
||||
return
|
||||
}
|
||||
|
||||
var recs []model.ExamRecord
|
||||
store.DB.Where("user_id = ?", id).Order("submitted_at DESC").Find(&recs)
|
||||
|
||||
type aiAgg struct {
|
||||
Calls int64 `json:"calls"`
|
||||
Success int64 `json:"success"`
|
||||
Credits int64 `json:"credits"`
|
||||
}
|
||||
var ai aiAgg
|
||||
store.DB.Raw(`SELECT COUNT(*) AS calls,
|
||||
COALESCE(SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END), 0) AS success,
|
||||
COALESCE(SUM(credits_charged), 0) AS credits
|
||||
FROM ai_call_log WHERE user_id = ?`, id).Scan(&ai)
|
||||
|
||||
var lps []model.LearningProgress
|
||||
store.DB.Where("user_id = ?", id).Order("updated_at DESC").Find(&lps)
|
||||
companyViewed := false
|
||||
productCount, courseCount := 0, 0
|
||||
var lastViewed *time.Time
|
||||
for _, lp := range lps {
|
||||
switch lp.ItemType {
|
||||
case "company":
|
||||
companyViewed = true
|
||||
case "product":
|
||||
productCount++
|
||||
case "course":
|
||||
courseCount++
|
||||
}
|
||||
if lastViewed == nil || lp.UpdatedAt.After(*lastViewed) {
|
||||
t := lp.UpdatedAt
|
||||
lastViewed = &t
|
||||
}
|
||||
}
|
||||
|
||||
web.OK(c, gin.H{
|
||||
"user": gin.H{
|
||||
"id": u.ID, "username": u.Username, "full_name": u.FullName,
|
||||
"role": u.Role, "status": u.Status, "ai_points": u.AiPoints,
|
||||
"department": u.Department, "position": u.Position, "hire_batch": u.HireBatch,
|
||||
"created_at": u.CreatedAt,
|
||||
},
|
||||
"exam": gin.H{
|
||||
"summary": examSummary(recs),
|
||||
"records": recs,
|
||||
},
|
||||
"ai_usage": gin.H{
|
||||
"total_calls": ai.Calls,
|
||||
"success_calls": ai.Success,
|
||||
"total_credits": ai.Credits,
|
||||
"ai_points_left": u.AiPoints,
|
||||
},
|
||||
"learning": gin.H{
|
||||
"company_viewed": companyViewed,
|
||||
"product_count": productCount,
|
||||
"course_count": courseCount,
|
||||
"total_items": len(lps),
|
||||
"last_viewed_at": lastViewed,
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user