Files
eaiadminandClaude Code 0455f064ac feat: 新增语音转文字(ASR)功能
- 后端:新增 /api/audio/transcribe 接口,调用 Ollama whisper 进行语音识别
- 前端:新增 AudioTranscribePage.vue 页面,支持 MP3/WAV/M4A/OGG/FLAC 等格式
- 注册路由、工具卡片、智能助手欢迎语更新

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-14 00:53:36 +08:00

385 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
},
})
}