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:
eaiadmin
2026-09-14 00:53:36 +08:00
co-authored by Claude Code
parent 752c7837ef
commit 0455f064ac
299 changed files with 1588 additions and 432 deletions
@@ -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,
},
})
}