feat: 实现 KPL 系统功能改进计划
Some checks failed
continuous-integration/drone/push Build is failing

1. 课程学习进度追踪
   - 新增 UserCourseProgress 和 UserMaterialProgress 模型
   - 新增 /api/v1/progress/* 进度追踪 API
   - 更新 admin.py 使用真实课程完成率数据

2. 路由权限检查完善
   - 新增前端 permissionChecker.ts 权限检查工具
   - 更新 router/guard.ts 实现团队和课程权限验证
   - 新增后端 permission_service.py

3. AI 陪练音频转文本
   - 新增 speech_recognition.py 语音识别服务
   - 新增 /api/v1/speech/* API
   - 更新 ai-practice-coze.vue 支持语音输入

4. 双人对练报告生成
   - 更新 practice_room_service.py 添加报告生成功能
   - 新增 /rooms/{room_code}/report API
   - 更新 duo-practice-report.vue 调用真实 API

5. 学习提醒推送
   - 新增 notification_service.py 通知服务
   - 新增 scheduler_service.py 定时任务服务
   - 支持钉钉、企微、站内消息推送

6. 智能学习推荐
   - 新增 recommendation_service.py 推荐服务
   - 新增 /api/v1/recommendations/* API
   - 支持错题、能力、进度、热门多维度推荐

7. 安全问题修复
   - DEBUG 默认值改为 False
   - 添加 SECRET_KEY 安全警告
   - 新增 check_security_settings() 检查函数

8. 证书 PDF 生成
   - 更新 certificate_service.py 添加 PDF 生成
   - 添加 weasyprint、Pillow、qrcode 依赖
   - 更新下载 API 支持 PDF 和 PNG 格式
This commit is contained in:
yuliang_guo
2026-01-30 14:22:35 +08:00
parent 9793013a56
commit 64f5d567fa
66 changed files with 18067 additions and 14330 deletions

View File

@@ -1,149 +1,149 @@
/**
* 证书系统 API
*/
import request from '@/api/request'
// 证书类型
export type CertificateType = 'course' | 'exam' | 'achievement'
// 证书模板
export interface CertificateTemplate {
id: number
name: string
type: CertificateType
background_url?: string
is_active: boolean
}
// 证书信息
export interface Certificate {
id: number
certificate_no: string
title: string
description?: string
type: CertificateType
type_name: string
issued_at: string
valid_until?: string
score?: number
completion_rate?: number
pdf_url?: string
image_url?: string
course_id?: number
exam_id?: number
badge_id?: number
meta_data?: Record<string, any>
template?: {
id: number
name: string
background_url?: string
}
user?: {
id: number
username: string
full_name?: string
}
}
// 证书列表响应
export interface CertificateListResponse {
items: Certificate[]
total: number
offset: number
limit: number
}
// 验证结果
export interface VerifyResult {
valid: boolean
certificate_no: string
title?: string
type_name?: string
issued_at?: string
user?: {
id: number
username: string
full_name?: string
}
}
/**
* 获取证书模板列表
*/
export function getCertificateTemplates(type?: CertificateType) {
return request.get<CertificateTemplate[]>('/certificates/templates', {
params: { cert_type: type }
})
}
/**
* 获取我的证书列表
*/
export function getMyCertificates(params?: {
cert_type?: CertificateType
offset?: number
limit?: number
}) {
return request.get<CertificateListResponse>('/certificates/me', { params })
}
/**
* 获取指定用户的证书列表
*/
export function getUserCertificates(userId: number, params?: {
cert_type?: CertificateType
offset?: number
limit?: number
}) {
return request.get<CertificateListResponse>(`/certificates/user/${userId}`, { params })
}
/**
* 获取证书详情
*/
export function getCertificateDetail(certId: number) {
return request.get<Certificate>(`/certificates/${certId}`)
}
/**
* 获取证书分享图片URL
*/
export function getCertificateImageUrl(certId: number): string {
return `/api/v1/certificates/${certId}/image`
}
/**
* 获取证书下载URL
*/
export function getCertificateDownloadUrl(certId: number): string {
return `/api/v1/certificates/${certId}/download`
}
/**
* 验证证书
*/
export function verifyCertificate(certNo: string) {
return request.get<VerifyResult>(`/certificates/verify/${certNo}`)
}
/**
* 颁发课程证书
*/
export function issueCoursCertificate(data: {
course_id: number
course_name: string
completion_rate?: number
}) {
return request.post<Certificate>('/certificates/issue/course', data)
}
/**
* 颁发考试证书
*/
export function issueExamCertificate(data: {
exam_id: number
exam_name: string
score: number
}) {
return request.post<Certificate>('/certificates/issue/exam', data)
}
/**
* 证书系统 API
*/
import request from '@/api/request'
// 证书类型
export type CertificateType = 'course' | 'exam' | 'achievement'
// 证书模板
export interface CertificateTemplate {
id: number
name: string
type: CertificateType
background_url?: string
is_active: boolean
}
// 证书信息
export interface Certificate {
id: number
certificate_no: string
title: string
description?: string
type: CertificateType
type_name: string
issued_at: string
valid_until?: string
score?: number
completion_rate?: number
pdf_url?: string
image_url?: string
course_id?: number
exam_id?: number
badge_id?: number
meta_data?: Record<string, any>
template?: {
id: number
name: string
background_url?: string
}
user?: {
id: number
username: string
full_name?: string
}
}
// 证书列表响应
export interface CertificateListResponse {
items: Certificate[]
total: number
offset: number
limit: number
}
// 验证结果
export interface VerifyResult {
valid: boolean
certificate_no: string
title?: string
type_name?: string
issued_at?: string
user?: {
id: number
username: string
full_name?: string
}
}
/**
* 获取证书模板列表
*/
export function getCertificateTemplates(type?: CertificateType) {
return request.get<CertificateTemplate[]>('/certificates/templates', {
params: { cert_type: type }
})
}
/**
* 获取我的证书列表
*/
export function getMyCertificates(params?: {
cert_type?: CertificateType
offset?: number
limit?: number
}) {
return request.get<CertificateListResponse>('/certificates/me', { params })
}
/**
* 获取指定用户的证书列表
*/
export function getUserCertificates(userId: number, params?: {
cert_type?: CertificateType
offset?: number
limit?: number
}) {
return request.get<CertificateListResponse>(`/certificates/user/${userId}`, { params })
}
/**
* 获取证书详情
*/
export function getCertificateDetail(certId: number) {
return request.get<Certificate>(`/certificates/${certId}`)
}
/**
* 获取证书分享图片URL
*/
export function getCertificateImageUrl(certId: number): string {
return `/api/v1/certificates/${certId}/image`
}
/**
* 获取证书下载URL
*/
export function getCertificateDownloadUrl(certId: number): string {
return `/api/v1/certificates/${certId}/download`
}
/**
* 验证证书
*/
export function verifyCertificate(certNo: string) {
return request.get<VerifyResult>(`/certificates/verify/${certNo}`)
}
/**
* 颁发课程证书
*/
export function issueCoursCertificate(data: {
course_id: number
course_name: string
completion_rate?: number
}) {
return request.post<Certificate>('/certificates/issue/course', data)
}
/**
* 颁发考试证书
*/
export function issueExamCertificate(data: {
exam_id: number
exam_name: string
score: number
}) {
return request.post<Certificate>('/certificates/issue/exam', data)
}

View File

@@ -175,4 +175,4 @@ export function getTeamDashboard() {
*/
export function getFullDashboardData() {
return request.get<FullDashboardData>('/dashboard/all')
}
}

View File

@@ -1,222 +1,222 @@
/**
* 双人对练 API
*/
import request from '@/api/request'
// ==================== 类型定义 ====================
export interface CreateRoomRequest {
scene_id?: number
scene_name?: string
scene_type?: string
scene_background?: string
role_a_name?: string
role_b_name?: string
role_a_description?: string
role_b_description?: string
host_role?: 'A' | 'B'
room_name?: string
}
export interface CreateRoomResponse {
room_code: string
room_id: number
room_name: string
my_role: string
my_role_name: string
}
export interface JoinRoomResponse {
room_code: string
room_id: number
room_name: string
status: string
my_role: string
my_role_name: string
}
export interface RoomUser {
id: number
username: string
full_name: string
avatar_url?: string
}
export interface RoomInfo {
id: number
room_code: string
room_name?: string
scene_id?: number
scene_name?: string
scene_type?: string
scene_background?: string
role_a_name: string
role_b_name: string
role_a_description?: string
role_b_description?: string
host_role: string
status: string
created_at?: string
started_at?: string
ended_at?: string
duration_seconds: number
total_turns: number
role_a_turns: number
role_b_turns: number
}
export interface RoomDetailResponse {
room: RoomInfo
host_user?: RoomUser
guest_user?: RoomUser
host_role_name?: string
guest_role_name?: string
my_role?: string
my_role_name?: string
is_host: boolean
}
export type MessageType =
| 'chat'
| 'system'
| 'join'
| 'leave'
| 'start'
| 'end'
| 'voice_start'
| 'voice_offer'
| 'voice_answer'
| 'ice_candidate'
| 'voice_end'
export interface RoomMessage {
id: number
room_id: number
user_id?: number
message_type: MessageType
content?: string
role_name?: string
sequence: number
created_at: string
}
export interface WebRTCSignalRequest {
signal_type: 'voice_start' | 'voice_offer' | 'voice_answer' | 'ice_candidate' | 'voice_end'
payload: Record<string, any>
}
export interface MessagesResponse {
messages: RoomMessage[]
room_status: string
last_sequence: number
}
export interface RoomListItem {
id: number
room_code: string
room_name?: string
scene_name?: string
status: string
is_host: boolean
created_at?: string
duration_seconds: number
total_turns: number
}
// ==================== API 函数 ====================
/**
* 创建房间
*/
export function createRoom(data: CreateRoomRequest) {
return request.post<CreateRoomResponse>('/api/v1/practice/rooms', data)
}
/**
* 加入房间
*/
export function joinRoom(roomCode: string) {
return request.post<JoinRoomResponse>('/api/v1/practice/rooms/join', {
room_code: roomCode
})
}
/**
* 获取房间详情
*/
export function getRoomDetail(roomCode: string) {
return request.get<RoomDetailResponse>(`/api/v1/practice/rooms/${roomCode}`)
}
/**
* 开始对练
*/
export function startPractice(roomCode: string) {
return request.post(`/api/v1/practice/rooms/${roomCode}/start`)
}
/**
* 结束对练
*/
export function endPractice(roomCode: string) {
return request.post(`/api/v1/practice/rooms/${roomCode}/end`)
}
/**
* 离开房间
*/
export function leaveRoom(roomCode: string) {
return request.post(`/api/v1/practice/rooms/${roomCode}/leave`)
}
/**
* 发送消息
*/
export function sendMessage(roomCode: string, content: string) {
return request.post<RoomMessage>(`/api/v1/practice/rooms/${roomCode}/message`, {
content
})
}
/**
* 获取消息列表
*/
export function getMessages(roomCode: string, sinceSequence: number = 0) {
return request.get<MessagesResponse>(`/api/v1/practice/rooms/${roomCode}/messages`, {
params: { since_sequence: sinceSequence }
})
}
/**
* 获取我的房间列表
*/
export function getMyRooms(status?: string, limit: number = 20) {
return request.get<{ rooms: RoomListItem[] }>('/api/v1/practice/rooms', {
params: { status, limit }
})
}
/**
* 生成分享链接
*/
export function generateShareLink(roomCode: string): string {
const baseUrl = window.location.origin
return `${baseUrl}/trainee/duo-practice/join/${roomCode}`
}
/**
* 发送 WebRTC 信令
*/
export function sendSignal(roomCode: string, signalType: string, payload: Record<string, any>) {
return request.post(`/api/v1/practice/rooms/${roomCode}/signal`, {
signal_type: signalType,
payload
})
}
/**
* 获取对练报告
*/
export function getPracticeReport(roomCode: string) {
return request.get(`/api/v1/practice/rooms/${roomCode}/report`)
}
/**
* 双人对练 API
*/
import request from '@/api/request'
// ==================== 类型定义 ====================
export interface CreateRoomRequest {
scene_id?: number
scene_name?: string
scene_type?: string
scene_background?: string
role_a_name?: string
role_b_name?: string
role_a_description?: string
role_b_description?: string
host_role?: 'A' | 'B'
room_name?: string
}
export interface CreateRoomResponse {
room_code: string
room_id: number
room_name: string
my_role: string
my_role_name: string
}
export interface JoinRoomResponse {
room_code: string
room_id: number
room_name: string
status: string
my_role: string
my_role_name: string
}
export interface RoomUser {
id: number
username: string
full_name: string
avatar_url?: string
}
export interface RoomInfo {
id: number
room_code: string
room_name?: string
scene_id?: number
scene_name?: string
scene_type?: string
scene_background?: string
role_a_name: string
role_b_name: string
role_a_description?: string
role_b_description?: string
host_role: string
status: string
created_at?: string
started_at?: string
ended_at?: string
duration_seconds: number
total_turns: number
role_a_turns: number
role_b_turns: number
}
export interface RoomDetailResponse {
room: RoomInfo
host_user?: RoomUser
guest_user?: RoomUser
host_role_name?: string
guest_role_name?: string
my_role?: string
my_role_name?: string
is_host: boolean
}
export type MessageType =
| 'chat'
| 'system'
| 'join'
| 'leave'
| 'start'
| 'end'
| 'voice_start'
| 'voice_offer'
| 'voice_answer'
| 'ice_candidate'
| 'voice_end'
export interface RoomMessage {
id: number
room_id: number
user_id?: number
message_type: MessageType
content?: string
role_name?: string
sequence: number
created_at: string
}
export interface WebRTCSignalRequest {
signal_type: 'voice_start' | 'voice_offer' | 'voice_answer' | 'ice_candidate' | 'voice_end'
payload: Record<string, any>
}
export interface MessagesResponse {
messages: RoomMessage[]
room_status: string
last_sequence: number
}
export interface RoomListItem {
id: number
room_code: string
room_name?: string
scene_name?: string
status: string
is_host: boolean
created_at?: string
duration_seconds: number
total_turns: number
}
// ==================== API 函数 ====================
/**
* 创建房间
*/
export function createRoom(data: CreateRoomRequest) {
return request.post<CreateRoomResponse>('/api/v1/practice/rooms', data)
}
/**
* 加入房间
*/
export function joinRoom(roomCode: string) {
return request.post<JoinRoomResponse>('/api/v1/practice/rooms/join', {
room_code: roomCode
})
}
/**
* 获取房间详情
*/
export function getRoomDetail(roomCode: string) {
return request.get<RoomDetailResponse>(`/api/v1/practice/rooms/${roomCode}`)
}
/**
* 开始对练
*/
export function startPractice(roomCode: string) {
return request.post(`/api/v1/practice/rooms/${roomCode}/start`)
}
/**
* 结束对练
*/
export function endPractice(roomCode: string) {
return request.post(`/api/v1/practice/rooms/${roomCode}/end`)
}
/**
* 离开房间
*/
export function leaveRoom(roomCode: string) {
return request.post(`/api/v1/practice/rooms/${roomCode}/leave`)
}
/**
* 发送消息
*/
export function sendMessage(roomCode: string, content: string) {
return request.post<RoomMessage>(`/api/v1/practice/rooms/${roomCode}/message`, {
content
})
}
/**
* 获取消息列表
*/
export function getMessages(roomCode: string, sinceSequence: number = 0) {
return request.get<MessagesResponse>(`/api/v1/practice/rooms/${roomCode}/messages`, {
params: { since_sequence: sinceSequence }
})
}
/**
* 获取我的房间列表
*/
export function getMyRooms(status?: string, limit: number = 20) {
return request.get<{ rooms: RoomListItem[] }>('/api/v1/practice/rooms', {
params: { status, limit }
})
}
/**
* 生成分享链接
*/
export function generateShareLink(roomCode: string): string {
const baseUrl = window.location.origin
return `${baseUrl}/trainee/duo-practice/join/${roomCode}`
}
/**
* 发送 WebRTC 信令
*/
export function sendSignal(roomCode: string, signalType: string, payload: Record<string, any>) {
return request.post(`/api/v1/practice/rooms/${roomCode}/signal`, {
signal_type: signalType,
payload
})
}
/**
* 获取对练报告
*/
export function getPracticeReport(roomCode: string) {
return request.get(`/api/v1/practice/rooms/${roomCode}/report`)
}

View File

@@ -1,182 +1,182 @@
/**
* 等级与奖章 API
*/
import request from '@/api/request'
// 类型定义
export interface LevelInfo {
user_id: number
level: number
exp: number
total_exp: number
title: string
color: string
login_streak: number
max_login_streak: number
last_checkin_at: string | null
next_level_exp: number
exp_to_next_level: number
is_max_level: boolean
}
export interface ExpHistoryItem {
id: number
exp_change: number
exp_type: string
description: string
level_before: number | null
level_after: number | null
created_at: string
}
export interface LeaderboardItem {
rank: number
user_id: number
username: string
full_name: string | null
avatar_url: string | null
level: number
title: string
color: string
total_exp: number
login_streak: number
}
export interface Badge {
id: number
code: string
name: string
description: string
icon: string
category: string
condition_type?: string
condition_value?: number
exp_reward: number
unlocked?: boolean
unlocked_at?: string | null
}
export interface CheckinResult {
success: boolean
message: string
exp_gained: number
base_exp?: number
bonus_exp?: number
login_streak: number
leveled_up?: boolean
new_level?: number | null
already_checked_in?: boolean
new_badges?: Badge[]
}
// API 函数
/**
* 获取当前用户等级信息
*/
export function getMyLevel() {
return request.get<LevelInfo>('/level/me')
}
/**
* 获取指定用户等级信息
*/
export function getUserLevel(userId: number) {
return request.get<LevelInfo>(`/level/user/${userId}`)
}
/**
* 每日签到
*/
export function dailyCheckin() {
return request.post<CheckinResult>('/level/checkin')
}
/**
* 获取经验值历史
*/
export function getExpHistory(params?: {
limit?: number
offset?: number
exp_type?: string
}) {
return request.get<{
items: ExpHistoryItem[]
total: number
limit: number
offset: number
}>('/level/exp-history', { params })
}
/**
* 获取等级排行榜
*/
export function getLeaderboard(params?: {
limit?: number
offset?: number
}) {
return request.get<{
items: LeaderboardItem[]
total: number
limit: number
offset: number
my_rank: number
my_level_info: LevelInfo
}>('/level/leaderboard', { params })
}
/**
* 获取所有奖章定义
*/
export function getAllBadges() {
return request.get<Badge[]>('/level/badges/all')
}
/**
* 获取用户奖章(含解锁状态)
*/
export function getMyBadges() {
return request.get<{
badges: Badge[]
total: number
unlocked_count: number
}>('/level/badges/me')
}
/**
* 获取未通知的新奖章
*/
export function getUnnotifiedBadges() {
return request.get<Badge[]>('/level/badges/unnotified')
}
/**
* 标记奖章为已通知
*/
export function markBadgesNotified(badgeIds?: number[]) {
return request.post('/level/badges/mark-notified', badgeIds)
}
/**
* 手动检查并授予奖章
*/
export function checkAndAwardBadges() {
return request.post<{
new_badges: Badge[]
count: number
}>('/level/check-badges')
}
export default {
getMyLevel,
getUserLevel,
dailyCheckin,
getExpHistory,
getLeaderboard,
getAllBadges,
getMyBadges,
getUnnotifiedBadges,
markBadgesNotified,
checkAndAwardBadges
}
/**
* 等级与奖章 API
*/
import request from '@/api/request'
// 类型定义
export interface LevelInfo {
user_id: number
level: number
exp: number
total_exp: number
title: string
color: string
login_streak: number
max_login_streak: number
last_checkin_at: string | null
next_level_exp: number
exp_to_next_level: number
is_max_level: boolean
}
export interface ExpHistoryItem {
id: number
exp_change: number
exp_type: string
description: string
level_before: number | null
level_after: number | null
created_at: string
}
export interface LeaderboardItem {
rank: number
user_id: number
username: string
full_name: string | null
avatar_url: string | null
level: number
title: string
color: string
total_exp: number
login_streak: number
}
export interface Badge {
id: number
code: string
name: string
description: string
icon: string
category: string
condition_type?: string
condition_value?: number
exp_reward: number
unlocked?: boolean
unlocked_at?: string | null
}
export interface CheckinResult {
success: boolean
message: string
exp_gained: number
base_exp?: number
bonus_exp?: number
login_streak: number
leveled_up?: boolean
new_level?: number | null
already_checked_in?: boolean
new_badges?: Badge[]
}
// API 函数
/**
* 获取当前用户等级信息
*/
export function getMyLevel() {
return request.get<LevelInfo>('/level/me')
}
/**
* 获取指定用户等级信息
*/
export function getUserLevel(userId: number) {
return request.get<LevelInfo>(`/level/user/${userId}`)
}
/**
* 每日签到
*/
export function dailyCheckin() {
return request.post<CheckinResult>('/level/checkin')
}
/**
* 获取经验值历史
*/
export function getExpHistory(params?: {
limit?: number
offset?: number
exp_type?: string
}) {
return request.get<{
items: ExpHistoryItem[]
total: number
limit: number
offset: number
}>('/level/exp-history', { params })
}
/**
* 获取等级排行榜
*/
export function getLeaderboard(params?: {
limit?: number
offset?: number
}) {
return request.get<{
items: LeaderboardItem[]
total: number
limit: number
offset: number
my_rank: number
my_level_info: LevelInfo
}>('/level/leaderboard', { params })
}
/**
* 获取所有奖章定义
*/
export function getAllBadges() {
return request.get<Badge[]>('/level/badges/all')
}
/**
* 获取用户奖章(含解锁状态)
*/
export function getMyBadges() {
return request.get<{
badges: Badge[]
total: number
unlocked_count: number
}>('/level/badges/me')
}
/**
* 获取未通知的新奖章
*/
export function getUnnotifiedBadges() {
return request.get<Badge[]>('/level/badges/unnotified')
}
/**
* 标记奖章为已通知
*/
export function markBadgesNotified(badgeIds?: number[]) {
return request.post('/level/badges/mark-notified', badgeIds)
}
/**
* 手动检查并授予奖章
*/
export function checkAndAwardBadges() {
return request.post<{
new_badges: Badge[]
count: number
}>('/level/check-badges')
}
export default {
getMyLevel,
getUserLevel,
dailyCheckin,
getExpHistory,
getLeaderboard,
getAllBadges,
getMyBadges,
getUnnotifiedBadges,
markBadgesNotified,
checkAndAwardBadges
}

View File

@@ -0,0 +1,158 @@
/**
* 用户学习进度 API
*/
import { request } from '@/utils/request'
// ============ 类型定义 ============
export interface MaterialProgress {
material_id: number
material_name: string
is_completed: boolean
progress_percent: number
last_position: number
study_time: number
first_accessed_at: string | null
last_accessed_at: string | null
completed_at: string | null
}
export interface CourseProgress {
course_id: number
course_name: string
status: 'not_started' | 'in_progress' | 'completed'
progress_percent: number
completed_materials: number
total_materials: number
total_study_time: number
first_accessed_at: string | null
last_accessed_at: string | null
completed_at: string | null
materials?: MaterialProgress[]
}
export interface ProgressSummary {
total_courses: number
completed_courses: number
in_progress_courses: number
not_started_courses: number
total_study_time: number
average_progress: number
}
export interface MaterialProgressUpdate {
progress_percent: number
last_position?: number
study_time_delta?: number
is_completed?: boolean
}
// ============ API 方法 ============
/**
* 获取学习进度摘要
*/
export const getProgressSummary = () => {
return request.get<ProgressSummary>('/api/v1/progress/summary')
}
/**
* 获取所有课程学习进度
*/
export const getAllCourseProgress = (status?: string) => {
return request.get<CourseProgress[]>('/api/v1/progress/courses', {
params: status ? { status } : undefined,
})
}
/**
* 获取指定课程的详细学习进度
*/
export const getCourseProgress = (courseId: number) => {
return request.get<CourseProgress>(`/api/v1/progress/courses/${courseId}`)
}
/**
* 更新资料学习进度
*/
export const updateMaterialProgress = (
materialId: number,
data: MaterialProgressUpdate
) => {
return request.post<MaterialProgress>(
`/api/v1/progress/materials/${materialId}`,
data
)
}
/**
* 标记资料为已完成
*/
export const markMaterialComplete = (materialId: number) => {
return request.post<MaterialProgress>(
`/api/v1/progress/materials/${materialId}/complete`
)
}
/**
* 开始学习课程
*/
export const startCourse = (courseId: number) => {
return request.post(`/api/v1/progress/courses/${courseId}/start`)
}
/**
* 格式化学习时长
*/
export const formatStudyTime = (seconds: number): string => {
if (seconds < 60) {
return `${seconds}`
}
if (seconds < 3600) {
const minutes = Math.floor(seconds / 60)
return `${minutes}分钟`
}
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
return minutes > 0 ? `${hours}小时${minutes}分钟` : `${hours}小时`
}
/**
* 获取进度状态文本
*/
export const getProgressStatusText = (
status: 'not_started' | 'in_progress' | 'completed'
): string => {
const statusMap = {
not_started: '未开始',
in_progress: '学习中',
completed: '已完成',
}
return statusMap[status] || status
}
/**
* 获取进度状态颜色
*/
export const getProgressStatusType = (
status: 'not_started' | 'in_progress' | 'completed'
): 'info' | 'warning' | 'success' => {
const typeMap: Record<string, 'info' | 'warning' | 'success'> = {
not_started: 'info',
in_progress: 'warning',
completed: 'success',
}
return typeMap[status] || 'info'
}
export default {
getProgressSummary,
getAllCourseProgress,
getCourseProgress,
updateMaterialProgress,
markMaterialComplete,
startCourse,
formatStudyTime,
getProgressStatusText,
getProgressStatusType,
}

View File

@@ -1,174 +1,174 @@
<template>
<div
class="badge-card"
:class="{ unlocked, locked: !unlocked }"
@click="handleClick"
>
<div class="badge-icon">
<el-icon :size="iconSize">
<component :is="iconComponent" />
</el-icon>
</div>
<div class="badge-info">
<div class="badge-name">{{ name }}</div>
<div class="badge-desc">{{ description }}</div>
<div class="badge-reward" v-if="expReward > 0 && !unlocked">
+{{ expReward }} 经验
</div>
<div class="badge-unlock-time" v-if="unlocked && unlockedAt">
{{ formatDate(unlockedAt) }}解锁
</div>
</div>
<div class="badge-status" v-if="!unlocked">
<el-icon><Lock /></el-icon>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import {
Lock, Medal, Star, Reading, Collection, Files,
Select, Finished, Trophy, TrendCharts, Clock,
Timer, Stopwatch, Operation, Calendar, Rank,
Headset, StarFilled
} from '@element-plus/icons-vue'
interface Props {
code: string
name: string
description: string
icon: string
category: string
expReward?: number
unlocked?: boolean
unlockedAt?: string | null
size?: 'small' | 'medium' | 'large'
}
const props = withDefaults(defineProps<Props>(), {
expReward: 0,
unlocked: false,
unlockedAt: null,
size: 'medium'
})
const emit = defineEmits<{
(e: 'click', badge: Props): void
}>()
// 图标映射
const iconMap: Record<string, any> = {
Medal, Star, Reading, Collection, Files, Select,
Finished, Trophy, TrendCharts, Clock, Timer,
Stopwatch, Operation, Calendar, Rank,
Headset, StarFilled, Lock
}
const iconComponent = computed(() => {
return iconMap[props.icon] || Medal
})
const sizeMap = {
small: 24,
medium: 32,
large: 48
}
const iconSize = computed(() => sizeMap[props.size])
const formatDate = (dateStr: string) => {
const date = new Date(dateStr)
return `${date.getMonth() + 1}/${date.getDate()}`
}
const handleClick = () => {
emit('click', props)
}
</script>
<style scoped lang="scss">
.badge-card {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-radius: 8px;
background-color: #fff;
border: 1px solid #EBEEF5;
cursor: pointer;
transition: all 0.3s;
position: relative;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
&.unlocked {
.badge-icon {
background: linear-gradient(135deg, #FFD700 0%, #FFA500 100%);
color: #fff;
}
}
&.locked {
opacity: 0.6;
.badge-icon {
background-color: #F5F7FA;
color: #C0C4CC;
}
.badge-name {
color: #909399;
}
}
.badge-icon {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.badge-info {
flex: 1;
min-width: 0;
.badge-name {
font-size: 14px;
font-weight: 600;
color: #303133;
margin-bottom: 4px;
}
.badge-desc {
font-size: 12px;
color: #909399;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.badge-reward {
font-size: 12px;
color: #E6A23C;
margin-top: 4px;
}
.badge-unlock-time {
font-size: 12px;
color: #67C23A;
margin-top: 4px;
}
}
.badge-status {
color: #C0C4CC;
font-size: 20px;
}
}
</style>
<template>
<div
class="badge-card"
:class="{ unlocked, locked: !unlocked }"
@click="handleClick"
>
<div class="badge-icon">
<el-icon :size="iconSize">
<component :is="iconComponent" />
</el-icon>
</div>
<div class="badge-info">
<div class="badge-name">{{ name }}</div>
<div class="badge-desc">{{ description }}</div>
<div class="badge-reward" v-if="expReward > 0 && !unlocked">
+{{ expReward }} 经验
</div>
<div class="badge-unlock-time" v-if="unlocked && unlockedAt">
{{ formatDate(unlockedAt) }}解锁
</div>
</div>
<div class="badge-status" v-if="!unlocked">
<el-icon><Lock /></el-icon>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import {
Lock, Medal, Star, Reading, Collection, Files,
Select, Finished, Trophy, TrendCharts, Clock,
Timer, Stopwatch, Operation, Calendar, Rank,
Headset, StarFilled
} from '@element-plus/icons-vue'
interface Props {
code: string
name: string
description: string
icon: string
category: string
expReward?: number
unlocked?: boolean
unlockedAt?: string | null
size?: 'small' | 'medium' | 'large'
}
const props = withDefaults(defineProps<Props>(), {
expReward: 0,
unlocked: false,
unlockedAt: null,
size: 'medium'
})
const emit = defineEmits<{
(e: 'click', badge: Props): void
}>()
// 图标映射
const iconMap: Record<string, any> = {
Medal, Star, Reading, Collection, Files, Select,
Finished, Trophy, TrendCharts, Clock, Timer,
Stopwatch, Operation, Calendar, Rank,
Headset, StarFilled, Lock
}
const iconComponent = computed(() => {
return iconMap[props.icon] || Medal
})
const sizeMap = {
small: 24,
medium: 32,
large: 48
}
const iconSize = computed(() => sizeMap[props.size])
const formatDate = (dateStr: string) => {
const date = new Date(dateStr)
return `${date.getMonth() + 1}/${date.getDate()}`
}
const handleClick = () => {
emit('click', props)
}
</script>
<style scoped lang="scss">
.badge-card {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-radius: 8px;
background-color: #fff;
border: 1px solid #EBEEF5;
cursor: pointer;
transition: all 0.3s;
position: relative;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
&.unlocked {
.badge-icon {
background: linear-gradient(135deg, #FFD700 0%, #FFA500 100%);
color: #fff;
}
}
&.locked {
opacity: 0.6;
.badge-icon {
background-color: #F5F7FA;
color: #C0C4CC;
}
.badge-name {
color: #909399;
}
}
.badge-icon {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.badge-info {
flex: 1;
min-width: 0;
.badge-name {
font-size: 14px;
font-weight: 600;
color: #303133;
margin-bottom: 4px;
}
.badge-desc {
font-size: 12px;
color: #909399;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.badge-reward {
font-size: 12px;
color: #E6A23C;
margin-top: 4px;
}
.badge-unlock-time {
font-size: 12px;
color: #67C23A;
margin-top: 4px;
}
}
.badge-status {
color: #C0C4CC;
font-size: 20px;
}
}
</style>

View File

@@ -1,100 +1,100 @@
<template>
<div class="exp-progress">
<div class="progress-header">
<span class="label">经验值</span>
<span class="value">{{ currentExp }} / {{ targetExp }}</span>
</div>
<div class="progress-bar">
<div
class="progress-fill"
:style="{ width: progressPercent + '%', backgroundColor: color }"
></div>
</div>
<div class="progress-footer" v-if="showFooter">
<span class="exp-to-next">距下一级还需 {{ expToNext }} 经验</span>
<span class="total-exp" v-if="showTotal">累计 {{ totalExp }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
currentExp: number
targetExp: number
totalExp?: number
color?: string
showFooter?: boolean
showTotal?: boolean
}
const props = withDefaults(defineProps<Props>(), {
totalExp: 0,
color: '#409EFF',
showFooter: true,
showTotal: false
})
const progressPercent = computed(() => {
if (props.targetExp <= 0) return 100
const percent = (props.currentExp / props.targetExp) * 100
return Math.min(percent, 100)
})
const expToNext = computed(() => {
return Math.max(0, props.targetExp - props.currentExp)
})
</script>
<style scoped lang="scss">
.exp-progress {
.progress-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
.label {
font-size: 14px;
color: #606266;
}
.value {
font-size: 14px;
font-weight: 600;
color: #303133;
}
}
.progress-bar {
height: 8px;
background-color: #EBEEF5;
border-radius: 4px;
overflow: hidden;
.progress-fill {
height: 100%;
border-radius: 4px;
transition: width 0.3s ease;
}
}
.progress-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 4px;
.exp-to-next {
font-size: 12px;
color: #909399;
}
.total-exp {
font-size: 12px;
color: #909399;
}
}
}
</style>
<template>
<div class="exp-progress">
<div class="progress-header">
<span class="label">经验值</span>
<span class="value">{{ currentExp }} / {{ targetExp }}</span>
</div>
<div class="progress-bar">
<div
class="progress-fill"
:style="{ width: progressPercent + '%', backgroundColor: color }"
></div>
</div>
<div class="progress-footer" v-if="showFooter">
<span class="exp-to-next">距下一级还需 {{ expToNext }} 经验</span>
<span class="total-exp" v-if="showTotal">累计 {{ totalExp }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
currentExp: number
targetExp: number
totalExp?: number
color?: string
showFooter?: boolean
showTotal?: boolean
}
const props = withDefaults(defineProps<Props>(), {
totalExp: 0,
color: '#409EFF',
showFooter: true,
showTotal: false
})
const progressPercent = computed(() => {
if (props.targetExp <= 0) return 100
const percent = (props.currentExp / props.targetExp) * 100
return Math.min(percent, 100)
})
const expToNext = computed(() => {
return Math.max(0, props.targetExp - props.currentExp)
})
</script>
<style scoped lang="scss">
.exp-progress {
.progress-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
.label {
font-size: 14px;
color: #606266;
}
.value {
font-size: 14px;
font-weight: 600;
color: #303133;
}
}
.progress-bar {
height: 8px;
background-color: #EBEEF5;
border-radius: 4px;
overflow: hidden;
.progress-fill {
height: 100%;
border-radius: 4px;
transition: width 0.3s ease;
}
}
.progress-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 4px;
.exp-to-next {
font-size: 12px;
color: #909399;
}
.total-exp {
font-size: 12px;
color: #909399;
}
}
}
</style>

View File

@@ -1,85 +1,85 @@
<template>
<div class="level-badge" :style="{ '--level-color': color }">
<div class="level-icon">
<span class="level-number">{{ level }}</span>
</div>
<div class="level-info" v-if="showInfo">
<span class="level-title">{{ title }}</span>
<span class="level-exp" v-if="showExp">{{ exp }}/{{ nextLevelExp }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
level: number
title?: string
color?: string
exp?: number
nextLevelExp?: number
showInfo?: boolean
showExp?: boolean
size?: 'small' | 'medium' | 'large'
}
const props = withDefaults(defineProps<Props>(), {
title: '初学者',
color: '#909399',
exp: 0,
nextLevelExp: 1000,
showInfo: true,
showExp: false,
size: 'medium'
})
const sizeMap = {
small: { icon: 24, font: 12 },
medium: { icon: 32, font: 14 },
large: { icon: 48, font: 18 }
}
</script>
<style scoped lang="scss">
.level-badge {
display: inline-flex;
align-items: center;
gap: 8px;
.level-icon {
width: 32px;
height: 32px;
border-radius: 50%;
background: linear-gradient(135deg, var(--level-color) 0%, color-mix(in srgb, var(--level-color) 80%, #000) 100%);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
.level-number {
color: #fff;
font-size: 14px;
font-weight: 700;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
}
.level-info {
display: flex;
flex-direction: column;
gap: 2px;
.level-title {
font-size: 14px;
font-weight: 600;
color: var(--level-color);
}
.level-exp {
font-size: 12px;
color: #909399;
}
}
}
</style>
<template>
<div class="level-badge" :style="{ '--level-color': color }">
<div class="level-icon">
<span class="level-number">{{ level }}</span>
</div>
<div class="level-info" v-if="showInfo">
<span class="level-title">{{ title }}</span>
<span class="level-exp" v-if="showExp">{{ exp }}/{{ nextLevelExp }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
level: number
title?: string
color?: string
exp?: number
nextLevelExp?: number
showInfo?: boolean
showExp?: boolean
size?: 'small' | 'medium' | 'large'
}
const props = withDefaults(defineProps<Props>(), {
title: '初学者',
color: '#909399',
exp: 0,
nextLevelExp: 1000,
showInfo: true,
showExp: false,
size: 'medium'
})
const sizeMap = {
small: { icon: 24, font: 12 },
medium: { icon: 32, font: 14 },
large: { icon: 48, font: 18 }
}
</script>
<style scoped lang="scss">
.level-badge {
display: inline-flex;
align-items: center;
gap: 8px;
.level-icon {
width: 32px;
height: 32px;
border-radius: 50%;
background: linear-gradient(135deg, var(--level-color) 0%, color-mix(in srgb, var(--level-color) 80%, #000) 100%);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
.level-number {
color: #fff;
font-size: 14px;
font-weight: 700;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
}
.level-info {
display: flex;
flex-direction: column;
gap: 2px;
.level-title {
font-size: 14px;
font-weight: 600;
color: var(--level-color);
}
.level-exp {
font-size: 12px;
color: #909399;
}
}
}
</style>

View File

@@ -1,297 +1,297 @@
<template>
<el-dialog
v-model="visible"
:show-close="false"
:close-on-click-modal="false"
width="360px"
center
class="level-up-dialog"
>
<div class="dialog-content">
<!-- 等级升级 -->
<div class="level-up-section" v-if="leveledUp">
<div class="celebration">
<div class="firework"></div>
<div class="firework"></div>
<div class="firework"></div>
</div>
<div class="level-badge-large">
<span class="level-number">{{ newLevel }}</span>
</div>
<div class="congrats-text">恭喜升级!</div>
<div class="level-title" :style="{ color: levelColor }">{{ levelTitle }}</div>
</div>
<!-- 经验值获得 -->
<div class="exp-section" v-if="expGained > 0 && !leveledUp">
<el-icon class="exp-icon" :size="48"><TrendCharts /></el-icon>
<div class="exp-text">经验值 +{{ expGained }}</div>
</div>
<!-- 新获得奖章 -->
<div class="badges-section" v-if="newBadges && newBadges.length > 0">
<div class="section-title">新解锁奖章</div>
<div class="badges-list">
<div
class="badge-item"
v-for="badge in newBadges"
:key="badge.code"
>
<div class="badge-icon">
<el-icon :size="24">
<component :is="getIconComponent(badge.icon)" />
</el-icon>
</div>
<div class="badge-name">{{ badge.name }}</div>
<div class="badge-reward" v-if="badge.exp_reward > 0">
+{{ badge.exp_reward }}
</div>
</div>
</div>
</div>
</div>
<template #footer>
<el-button type="primary" @click="handleClose" size="large" round>
太棒了!
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import {
TrendCharts, Medal, Star, Reading, Collection, Files,
Select, Finished, Trophy, Clock, Timer, Stopwatch,
Operation, Calendar, Rank, Headset, StarFilled
} from '@element-plus/icons-vue'
interface Badge {
code: string
name: string
icon: string
exp_reward?: number
}
interface Props {
modelValue: boolean
leveledUp?: boolean
newLevel?: number | null
levelTitle?: string
levelColor?: string
expGained?: number
newBadges?: Badge[]
}
const props = withDefaults(defineProps<Props>(), {
leveledUp: false,
newLevel: null,
levelTitle: '',
levelColor: '#409EFF',
expGained: 0,
newBadges: () => []
})
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'close'): void
}>()
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
// 图标映射
const iconMap: Record<string, any> = {
Medal, Star, Reading, Collection, Files, Select,
Finished, Trophy, TrendCharts, Clock, Timer,
Stopwatch, Operation, Calendar, Rank,
Headset, StarFilled
}
const getIconComponent = (icon: string) => {
return iconMap[icon] || Medal
}
const handleClose = () => {
visible.value = false
emit('close')
}
</script>
<style scoped lang="scss">
.level-up-dialog {
:deep(.el-dialog__header) {
display: none;
}
:deep(.el-dialog__body) {
padding: 24px;
}
}
.dialog-content {
text-align: center;
.level-up-section {
position: relative;
padding: 20px 0;
.celebration {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
overflow: hidden;
.firework {
position: absolute;
width: 4px;
height: 4px;
border-radius: 50%;
animation: firework 1s ease-out infinite;
&:nth-child(1) {
left: 20%;
top: 30%;
background-color: #FFD700;
animation-delay: 0s;
}
&:nth-child(2) {
left: 50%;
top: 20%;
background-color: #FF6B6B;
animation-delay: 0.3s;
}
&:nth-child(3) {
left: 80%;
top: 40%;
background-color: #4ECDC4;
animation-delay: 0.6s;
}
}
}
.level-badge-large {
width: 80px;
height: 80px;
margin: 0 auto 16px;
border-radius: 50%;
background: linear-gradient(135deg, #FFD700 0%, #FFA500 100%);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8px 24px rgba(255, 165, 0, 0.4);
animation: bounce 0.6s ease;
.level-number {
font-size: 36px;
font-weight: 700;
color: #fff;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
}
.congrats-text {
font-size: 24px;
font-weight: 700;
color: #303133;
margin-bottom: 8px;
}
.level-title {
font-size: 18px;
font-weight: 600;
}
}
.exp-section {
padding: 24px 0;
.exp-icon {
color: #E6A23C;
margin-bottom: 12px;
}
.exp-text {
font-size: 24px;
font-weight: 700;
color: #E6A23C;
}
}
.badges-section {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #EBEEF5;
.section-title {
font-size: 14px;
color: #909399;
margin-bottom: 12px;
}
.badges-list {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 12px;
.badge-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
.badge-icon {
width: 48px;
height: 48px;
border-radius: 50%;
background: linear-gradient(135deg, #FFD700 0%, #FFA500 100%);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
}
.badge-name {
font-size: 12px;
color: #303133;
font-weight: 500;
}
.badge-reward {
font-size: 12px;
color: #E6A23C;
}
}
}
}
}
@keyframes firework {
0% {
transform: scale(1);
opacity: 1;
}
100% {
transform: scale(20);
opacity: 0;
}
}
@keyframes bounce {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
}
</style>
<template>
<el-dialog
v-model="visible"
:show-close="false"
:close-on-click-modal="false"
width="360px"
center
class="level-up-dialog"
>
<div class="dialog-content">
<!-- 等级升级 -->
<div class="level-up-section" v-if="leveledUp">
<div class="celebration">
<div class="firework"></div>
<div class="firework"></div>
<div class="firework"></div>
</div>
<div class="level-badge-large">
<span class="level-number">{{ newLevel }}</span>
</div>
<div class="congrats-text">恭喜升级!</div>
<div class="level-title" :style="{ color: levelColor }">{{ levelTitle }}</div>
</div>
<!-- 经验值获得 -->
<div class="exp-section" v-if="expGained > 0 && !leveledUp">
<el-icon class="exp-icon" :size="48"><TrendCharts /></el-icon>
<div class="exp-text">经验值 +{{ expGained }}</div>
</div>
<!-- 新获得奖章 -->
<div class="badges-section" v-if="newBadges && newBadges.length > 0">
<div class="section-title">新解锁奖章</div>
<div class="badges-list">
<div
class="badge-item"
v-for="badge in newBadges"
:key="badge.code"
>
<div class="badge-icon">
<el-icon :size="24">
<component :is="getIconComponent(badge.icon)" />
</el-icon>
</div>
<div class="badge-name">{{ badge.name }}</div>
<div class="badge-reward" v-if="badge.exp_reward > 0">
+{{ badge.exp_reward }}
</div>
</div>
</div>
</div>
</div>
<template #footer>
<el-button type="primary" @click="handleClose" size="large" round>
太棒了!
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import {
TrendCharts, Medal, Star, Reading, Collection, Files,
Select, Finished, Trophy, Clock, Timer, Stopwatch,
Operation, Calendar, Rank, Headset, StarFilled
} from '@element-plus/icons-vue'
interface Badge {
code: string
name: string
icon: string
exp_reward?: number
}
interface Props {
modelValue: boolean
leveledUp?: boolean
newLevel?: number | null
levelTitle?: string
levelColor?: string
expGained?: number
newBadges?: Badge[]
}
const props = withDefaults(defineProps<Props>(), {
leveledUp: false,
newLevel: null,
levelTitle: '',
levelColor: '#409EFF',
expGained: 0,
newBadges: () => []
})
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'close'): void
}>()
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
// 图标映射
const iconMap: Record<string, any> = {
Medal, Star, Reading, Collection, Files, Select,
Finished, Trophy, TrendCharts, Clock, Timer,
Stopwatch, Operation, Calendar, Rank,
Headset, StarFilled
}
const getIconComponent = (icon: string) => {
return iconMap[icon] || Medal
}
const handleClose = () => {
visible.value = false
emit('close')
}
</script>
<style scoped lang="scss">
.level-up-dialog {
:deep(.el-dialog__header) {
display: none;
}
:deep(.el-dialog__body) {
padding: 24px;
}
}
.dialog-content {
text-align: center;
.level-up-section {
position: relative;
padding: 20px 0;
.celebration {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
overflow: hidden;
.firework {
position: absolute;
width: 4px;
height: 4px;
border-radius: 50%;
animation: firework 1s ease-out infinite;
&:nth-child(1) {
left: 20%;
top: 30%;
background-color: #FFD700;
animation-delay: 0s;
}
&:nth-child(2) {
left: 50%;
top: 20%;
background-color: #FF6B6B;
animation-delay: 0.3s;
}
&:nth-child(3) {
left: 80%;
top: 40%;
background-color: #4ECDC4;
animation-delay: 0.6s;
}
}
}
.level-badge-large {
width: 80px;
height: 80px;
margin: 0 auto 16px;
border-radius: 50%;
background: linear-gradient(135deg, #FFD700 0%, #FFA500 100%);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8px 24px rgba(255, 165, 0, 0.4);
animation: bounce 0.6s ease;
.level-number {
font-size: 36px;
font-weight: 700;
color: #fff;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
}
.congrats-text {
font-size: 24px;
font-weight: 700;
color: #303133;
margin-bottom: 8px;
}
.level-title {
font-size: 18px;
font-weight: 600;
}
}
.exp-section {
padding: 24px 0;
.exp-icon {
color: #E6A23C;
margin-bottom: 12px;
}
.exp-text {
font-size: 24px;
font-weight: 700;
color: #E6A23C;
}
}
.badges-section {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #EBEEF5;
.section-title {
font-size: 14px;
color: #909399;
margin-bottom: 12px;
}
.badges-list {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 12px;
.badge-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
.badge-icon {
width: 48px;
height: 48px;
border-radius: 50%;
background: linear-gradient(135deg, #FFD700 0%, #FFA500 100%);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
}
.badge-name {
font-size: 12px;
color: #303133;
font-weight: 500;
}
.badge-reward {
font-size: 12px;
color: #E6A23C;
}
}
}
}
}
@keyframes firework {
0% {
transform: scale(1);
opacity: 1;
}
100% {
transform: scale(20);
opacity: 0;
}
}
@keyframes bounce {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
}
</style>

View File

@@ -1,462 +1,462 @@
/**
* 语音通话组合式函数
*
* 功能:
* - 整合 WebRTC 管理和信令服务
* - 管理通话状态
* - 处理语音转文字
*/
import { ref, computed, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import { WebRTCManager, createWebRTCManager, type ConnectionState } from '@/utils/webrtc'
import request from '@/api/request'
export type VoiceCallState = 'idle' | 'requesting' | 'ringing' | 'connecting' | 'connected' | 'ended'
export interface UseVoiceCallOptions {
roomCode: string
onTranscript?: (text: string, isFinal: boolean) => void
onRemoteTranscript?: (text: string) => void
}
export function useVoiceCall(options: UseVoiceCallOptions) {
const { roomCode, onTranscript, onRemoteTranscript } = options
// ==================== 状态 ====================
const callState = ref<VoiceCallState>('idle')
const connectionState = ref<ConnectionState>('idle')
const isMuted = ref(false)
const isRemoteMuted = ref(false)
const localAudioLevel = ref(0)
const remoteAudioLevel = ref(0)
const callDuration = ref(0)
const errorMessage = ref<string | null>(null)
// 语音识别相关
const isTranscribing = ref(false)
const currentTranscript = ref('')
// 内部状态
let webrtcManager: WebRTCManager | null = null
let recognition: any = null // SpeechRecognition
let callTimer: number | null = null
let audioLevelTimer: number | null = null
// ==================== 计算属性 ====================
const isCallActive = computed(() =>
['requesting', 'ringing', 'connecting', 'connected'].includes(callState.value)
)
const canStartCall = computed(() => callState.value === 'idle')
const canEndCall = computed(() => isCallActive.value)
// ==================== 信令 API ====================
async function sendSignal(signalType: string, payload: any) {
try {
await request.post(`/api/v1/practice/rooms/${roomCode}/signal`, {
signal_type: signalType,
payload
})
} catch (error) {
console.error('[VoiceCall] 发送信令失败:', error)
throw error
}
}
// ==================== 通话控制 ====================
/**
* 发起语音通话
*/
async function startCall() {
if (!canStartCall.value) {
console.warn('[VoiceCall] 无法发起通话,当前状态:', callState.value)
return
}
try {
callState.value = 'requesting'
errorMessage.value = null
// 创建 WebRTC 管理器
webrtcManager = createWebRTCManager({
onConnectionStateChange: handleConnectionStateChange,
onIceCandidate: handleIceCandidate,
onRemoteStream: handleRemoteStream,
onError: handleError
})
// 创建 Offer
const offer = await webrtcManager.createOffer()
// 发送开始信令
await sendSignal('voice_start', {})
// 发送 Offer
await sendSignal('voice_offer', {
type: offer.type,
sdp: offer.sdp
})
callState.value = 'ringing'
ElMessage.info('正在呼叫对方...')
} catch (error: any) {
console.error('[VoiceCall] 发起通话失败:', error)
errorMessage.value = error.message || '发起通话失败'
callState.value = 'idle'
webrtcManager?.close()
webrtcManager = null
ElMessage.error(errorMessage.value)
}
}
/**
* 接听语音通话
*/
async function answerCall(offer: RTCSessionDescriptionInit) {
try {
callState.value = 'connecting'
errorMessage.value = null
// 创建 WebRTC 管理器
webrtcManager = createWebRTCManager({
onConnectionStateChange: handleConnectionStateChange,
onIceCandidate: handleIceCandidate,
onRemoteStream: handleRemoteStream,
onError: handleError
})
// 处理 Offer 并创建 Answer
const answer = await webrtcManager.handleOffer(offer)
// 发送 Answer
await sendSignal('voice_answer', {
type: answer.type,
sdp: answer.sdp
})
ElMessage.success('已接听通话')
} catch (error: any) {
console.error('[VoiceCall] 接听通话失败:', error)
errorMessage.value = error.message || '接听通话失败'
callState.value = 'idle'
webrtcManager?.close()
webrtcManager = null
ElMessage.error(errorMessage.value)
}
}
/**
* 拒绝来电
*/
async function rejectCall() {
try {
await sendSignal('voice_end', { reason: 'rejected' })
callState.value = 'idle'
} catch (error) {
console.error('[VoiceCall] 拒绝通话失败:', error)
}
}
/**
* 结束通话
*/
async function endCall() {
try {
await sendSignal('voice_end', { reason: 'ended' })
} catch (error) {
console.error('[VoiceCall] 发送结束信令失败:', error)
}
cleanup()
callState.value = 'ended'
// 延迟恢复到 idle 状态
setTimeout(() => {
callState.value = 'idle'
}, 1000)
}
/**
* 切换静音
*/
function toggleMute() {
if (webrtcManager) {
isMuted.value = !isMuted.value
webrtcManager.setMuted(isMuted.value)
}
}
// ==================== 信令处理 ====================
/**
* 处理接收到的信令消息
*/
async function handleSignal(signalType: string, payload: any, fromUserId: number) {
console.log('[VoiceCall] 收到信令:', signalType)
switch (signalType) {
case 'voice_start':
// 收到通话请求
if (callState.value === 'idle') {
callState.value = 'ringing'
ElMessage.info('收到语音通话请求')
}
break
case 'voice_offer':
// 收到 Offer自动接听
if (callState.value === 'ringing' || callState.value === 'idle') {
await answerCall({
type: payload.type,
sdp: payload.sdp
})
}
break
case 'voice_answer':
// 收到 Answer
if (webrtcManager && callState.value === 'ringing') {
await webrtcManager.handleAnswer({
type: payload.type,
sdp: payload.sdp
})
}
break
case 'ice_candidate':
// 收到 ICE 候选
if (webrtcManager && payload.candidate) {
await webrtcManager.addIceCandidate(payload)
}
break
case 'voice_end':
// 对方结束通话
cleanup()
callState.value = 'ended'
ElMessage.info('通话已结束')
setTimeout(() => {
callState.value = 'idle'
}, 1000)
break
}
}
// ==================== WebRTC 回调 ====================
function handleConnectionStateChange(state: ConnectionState) {
connectionState.value = state
if (state === 'connected') {
callState.value = 'connected'
startCallTimer()
startAudioLevelMonitor()
startSpeechRecognition()
ElMessage.success('语音通话已连接')
} else if (state === 'failed' || state === 'disconnected') {
if (callState.value === 'connected') {
ElMessage.warning('通话连接断开')
}
}
}
async function handleIceCandidate(candidate: RTCIceCandidate) {
try {
await sendSignal('ice_candidate', candidate.toJSON())
} catch (error) {
console.error('[VoiceCall] 发送 ICE 候选失败:', error)
}
}
function handleRemoteStream(stream: MediaStream) {
console.log('[VoiceCall] 收到远程音频流')
// 播放远程音频
const audio = new Audio()
audio.srcObject = stream
audio.play().catch(e => console.error('[VoiceCall] 播放远程音频失败:', e))
}
function handleError(error: Error) {
console.error('[VoiceCall] WebRTC 错误:', error)
errorMessage.value = error.message
}
// ==================== 语音识别 ====================
function startSpeechRecognition() {
// 检查浏览器支持
const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition
if (!SpeechRecognition) {
console.warn('[VoiceCall] 浏览器不支持语音识别')
return
}
recognition = new SpeechRecognition()
recognition.continuous = true
recognition.interimResults = true
recognition.lang = 'zh-CN'
recognition.onstart = () => {
isTranscribing.value = true
console.log('[VoiceCall] 语音识别已启动')
}
recognition.onresult = (event: any) => {
let interimTranscript = ''
let finalTranscript = ''
for (let i = event.resultIndex; i < event.results.length; i++) {
const transcript = event.results[i][0].transcript
if (event.results[i].isFinal) {
finalTranscript += transcript
} else {
interimTranscript += transcript
}
}
currentTranscript.value = interimTranscript || finalTranscript
if (finalTranscript) {
onTranscript?.(finalTranscript, true)
} else if (interimTranscript) {
onTranscript?.(interimTranscript, false)
}
}
recognition.onerror = (event: any) => {
console.error('[VoiceCall] 语音识别错误:', event.error)
if (event.error !== 'no-speech') {
isTranscribing.value = false
}
}
recognition.onend = () => {
// 如果通话还在进行,重新启动识别
if (callState.value === 'connected' && !isMuted.value) {
recognition.start()
} else {
isTranscribing.value = false
}
}
try {
recognition.start()
} catch (error) {
console.error('[VoiceCall] 启动语音识别失败:', error)
}
}
function stopSpeechRecognition() {
if (recognition) {
recognition.stop()
recognition = null
}
isTranscribing.value = false
}
// ==================== 辅助功能 ====================
function startCallTimer() {
callDuration.value = 0
callTimer = window.setInterval(() => {
callDuration.value++
}, 1000)
}
function stopCallTimer() {
if (callTimer) {
clearInterval(callTimer)
callTimer = null
}
}
function startAudioLevelMonitor() {
audioLevelTimer = window.setInterval(async () => {
if (webrtcManager) {
const localStream = webrtcManager.getLocalStream()
const remoteStream = webrtcManager.getRemoteStream()
if (localStream) {
localAudioLevel.value = await webrtcManager.getAudioLevel(localStream)
}
if (remoteStream) {
remoteAudioLevel.value = await webrtcManager.getAudioLevel(remoteStream)
}
}
}, 100)
}
function stopAudioLevelMonitor() {
if (audioLevelTimer) {
clearInterval(audioLevelTimer)
audioLevelTimer = null
}
}
function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
// ==================== 清理 ====================
function cleanup() {
stopCallTimer()
stopAudioLevelMonitor()
stopSpeechRecognition()
webrtcManager?.close()
webrtcManager = null
isMuted.value = false
isRemoteMuted.value = false
localAudioLevel.value = 0
remoteAudioLevel.value = 0
currentTranscript.value = ''
}
// 组件卸载时清理
onUnmounted(() => {
if (isCallActive.value) {
endCall()
}
cleanup()
})
// ==================== 返回 ====================
return {
// 状态
callState,
connectionState,
isMuted,
isRemoteMuted,
localAudioLevel,
remoteAudioLevel,
callDuration,
errorMessage,
isTranscribing,
currentTranscript,
// 计算属性
isCallActive,
canStartCall,
canEndCall,
// 方法
startCall,
answerCall,
rejectCall,
endCall,
toggleMute,
handleSignal,
formatDuration
}
}
/**
* 语音通话组合式函数
*
* 功能:
* - 整合 WebRTC 管理和信令服务
* - 管理通话状态
* - 处理语音转文字
*/
import { ref, computed, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import { WebRTCManager, createWebRTCManager, type ConnectionState } from '@/utils/webrtc'
import request from '@/api/request'
export type VoiceCallState = 'idle' | 'requesting' | 'ringing' | 'connecting' | 'connected' | 'ended'
export interface UseVoiceCallOptions {
roomCode: string
onTranscript?: (text: string, isFinal: boolean) => void
onRemoteTranscript?: (text: string) => void
}
export function useVoiceCall(options: UseVoiceCallOptions) {
const { roomCode, onTranscript, onRemoteTranscript } = options
// ==================== 状态 ====================
const callState = ref<VoiceCallState>('idle')
const connectionState = ref<ConnectionState>('idle')
const isMuted = ref(false)
const isRemoteMuted = ref(false)
const localAudioLevel = ref(0)
const remoteAudioLevel = ref(0)
const callDuration = ref(0)
const errorMessage = ref<string | null>(null)
// 语音识别相关
const isTranscribing = ref(false)
const currentTranscript = ref('')
// 内部状态
let webrtcManager: WebRTCManager | null = null
let recognition: any = null // SpeechRecognition
let callTimer: number | null = null
let audioLevelTimer: number | null = null
// ==================== 计算属性 ====================
const isCallActive = computed(() =>
['requesting', 'ringing', 'connecting', 'connected'].includes(callState.value)
)
const canStartCall = computed(() => callState.value === 'idle')
const canEndCall = computed(() => isCallActive.value)
// ==================== 信令 API ====================
async function sendSignal(signalType: string, payload: any) {
try {
await request.post(`/api/v1/practice/rooms/${roomCode}/signal`, {
signal_type: signalType,
payload
})
} catch (error) {
console.error('[VoiceCall] 发送信令失败:', error)
throw error
}
}
// ==================== 通话控制 ====================
/**
* 发起语音通话
*/
async function startCall() {
if (!canStartCall.value) {
console.warn('[VoiceCall] 无法发起通话,当前状态:', callState.value)
return
}
try {
callState.value = 'requesting'
errorMessage.value = null
// 创建 WebRTC 管理器
webrtcManager = createWebRTCManager({
onConnectionStateChange: handleConnectionStateChange,
onIceCandidate: handleIceCandidate,
onRemoteStream: handleRemoteStream,
onError: handleError
})
// 创建 Offer
const offer = await webrtcManager.createOffer()
// 发送开始信令
await sendSignal('voice_start', {})
// 发送 Offer
await sendSignal('voice_offer', {
type: offer.type,
sdp: offer.sdp
})
callState.value = 'ringing'
ElMessage.info('正在呼叫对方...')
} catch (error: any) {
console.error('[VoiceCall] 发起通话失败:', error)
errorMessage.value = error.message || '发起通话失败'
callState.value = 'idle'
webrtcManager?.close()
webrtcManager = null
ElMessage.error(errorMessage.value)
}
}
/**
* 接听语音通话
*/
async function answerCall(offer: RTCSessionDescriptionInit) {
try {
callState.value = 'connecting'
errorMessage.value = null
// 创建 WebRTC 管理器
webrtcManager = createWebRTCManager({
onConnectionStateChange: handleConnectionStateChange,
onIceCandidate: handleIceCandidate,
onRemoteStream: handleRemoteStream,
onError: handleError
})
// 处理 Offer 并创建 Answer
const answer = await webrtcManager.handleOffer(offer)
// 发送 Answer
await sendSignal('voice_answer', {
type: answer.type,
sdp: answer.sdp
})
ElMessage.success('已接听通话')
} catch (error: any) {
console.error('[VoiceCall] 接听通话失败:', error)
errorMessage.value = error.message || '接听通话失败'
callState.value = 'idle'
webrtcManager?.close()
webrtcManager = null
ElMessage.error(errorMessage.value)
}
}
/**
* 拒绝来电
*/
async function rejectCall() {
try {
await sendSignal('voice_end', { reason: 'rejected' })
callState.value = 'idle'
} catch (error) {
console.error('[VoiceCall] 拒绝通话失败:', error)
}
}
/**
* 结束通话
*/
async function endCall() {
try {
await sendSignal('voice_end', { reason: 'ended' })
} catch (error) {
console.error('[VoiceCall] 发送结束信令失败:', error)
}
cleanup()
callState.value = 'ended'
// 延迟恢复到 idle 状态
setTimeout(() => {
callState.value = 'idle'
}, 1000)
}
/**
* 切换静音
*/
function toggleMute() {
if (webrtcManager) {
isMuted.value = !isMuted.value
webrtcManager.setMuted(isMuted.value)
}
}
// ==================== 信令处理 ====================
/**
* 处理接收到的信令消息
*/
async function handleSignal(signalType: string, payload: any, fromUserId: number) {
console.log('[VoiceCall] 收到信令:', signalType)
switch (signalType) {
case 'voice_start':
// 收到通话请求
if (callState.value === 'idle') {
callState.value = 'ringing'
ElMessage.info('收到语音通话请求')
}
break
case 'voice_offer':
// 收到 Offer自动接听
if (callState.value === 'ringing' || callState.value === 'idle') {
await answerCall({
type: payload.type,
sdp: payload.sdp
})
}
break
case 'voice_answer':
// 收到 Answer
if (webrtcManager && callState.value === 'ringing') {
await webrtcManager.handleAnswer({
type: payload.type,
sdp: payload.sdp
})
}
break
case 'ice_candidate':
// 收到 ICE 候选
if (webrtcManager && payload.candidate) {
await webrtcManager.addIceCandidate(payload)
}
break
case 'voice_end':
// 对方结束通话
cleanup()
callState.value = 'ended'
ElMessage.info('通话已结束')
setTimeout(() => {
callState.value = 'idle'
}, 1000)
break
}
}
// ==================== WebRTC 回调 ====================
function handleConnectionStateChange(state: ConnectionState) {
connectionState.value = state
if (state === 'connected') {
callState.value = 'connected'
startCallTimer()
startAudioLevelMonitor()
startSpeechRecognition()
ElMessage.success('语音通话已连接')
} else if (state === 'failed' || state === 'disconnected') {
if (callState.value === 'connected') {
ElMessage.warning('通话连接断开')
}
}
}
async function handleIceCandidate(candidate: RTCIceCandidate) {
try {
await sendSignal('ice_candidate', candidate.toJSON())
} catch (error) {
console.error('[VoiceCall] 发送 ICE 候选失败:', error)
}
}
function handleRemoteStream(stream: MediaStream) {
console.log('[VoiceCall] 收到远程音频流')
// 播放远程音频
const audio = new Audio()
audio.srcObject = stream
audio.play().catch(e => console.error('[VoiceCall] 播放远程音频失败:', e))
}
function handleError(error: Error) {
console.error('[VoiceCall] WebRTC 错误:', error)
errorMessage.value = error.message
}
// ==================== 语音识别 ====================
function startSpeechRecognition() {
// 检查浏览器支持
const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition
if (!SpeechRecognition) {
console.warn('[VoiceCall] 浏览器不支持语音识别')
return
}
recognition = new SpeechRecognition()
recognition.continuous = true
recognition.interimResults = true
recognition.lang = 'zh-CN'
recognition.onstart = () => {
isTranscribing.value = true
console.log('[VoiceCall] 语音识别已启动')
}
recognition.onresult = (event: any) => {
let interimTranscript = ''
let finalTranscript = ''
for (let i = event.resultIndex; i < event.results.length; i++) {
const transcript = event.results[i][0].transcript
if (event.results[i].isFinal) {
finalTranscript += transcript
} else {
interimTranscript += transcript
}
}
currentTranscript.value = interimTranscript || finalTranscript
if (finalTranscript) {
onTranscript?.(finalTranscript, true)
} else if (interimTranscript) {
onTranscript?.(interimTranscript, false)
}
}
recognition.onerror = (event: any) => {
console.error('[VoiceCall] 语音识别错误:', event.error)
if (event.error !== 'no-speech') {
isTranscribing.value = false
}
}
recognition.onend = () => {
// 如果通话还在进行,重新启动识别
if (callState.value === 'connected' && !isMuted.value) {
recognition.start()
} else {
isTranscribing.value = false
}
}
try {
recognition.start()
} catch (error) {
console.error('[VoiceCall] 启动语音识别失败:', error)
}
}
function stopSpeechRecognition() {
if (recognition) {
recognition.stop()
recognition = null
}
isTranscribing.value = false
}
// ==================== 辅助功能 ====================
function startCallTimer() {
callDuration.value = 0
callTimer = window.setInterval(() => {
callDuration.value++
}, 1000)
}
function stopCallTimer() {
if (callTimer) {
clearInterval(callTimer)
callTimer = null
}
}
function startAudioLevelMonitor() {
audioLevelTimer = window.setInterval(async () => {
if (webrtcManager) {
const localStream = webrtcManager.getLocalStream()
const remoteStream = webrtcManager.getRemoteStream()
if (localStream) {
localAudioLevel.value = await webrtcManager.getAudioLevel(localStream)
}
if (remoteStream) {
remoteAudioLevel.value = await webrtcManager.getAudioLevel(remoteStream)
}
}
}, 100)
}
function stopAudioLevelMonitor() {
if (audioLevelTimer) {
clearInterval(audioLevelTimer)
audioLevelTimer = null
}
}
function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
// ==================== 清理 ====================
function cleanup() {
stopCallTimer()
stopAudioLevelMonitor()
stopSpeechRecognition()
webrtcManager?.close()
webrtcManager = null
isMuted.value = false
isRemoteMuted.value = false
localAudioLevel.value = 0
remoteAudioLevel.value = 0
currentTranscript.value = ''
}
// 组件卸载时清理
onUnmounted(() => {
if (isCallActive.value) {
endCall()
}
cleanup()
})
// ==================== 返回 ====================
return {
// 状态
callState,
connectionState,
isMuted,
isRemoteMuted,
localAudioLevel,
remoteAudioLevel,
callDuration,
errorMessage,
isTranscribing,
currentTranscript,
// 计算属性
isCallActive,
canStartCall,
canEndCall,
// 方法
startCall,
answerCall,
rejectCall,
endCall,
toggleMute,
handleSignal,
formatDuration
}
}

View File

@@ -7,6 +7,7 @@ import { Router, RouteLocationNormalized, NavigationGuardNext } from 'vue-router
import { ElMessage } from 'element-plus'
import { authManager } from '@/utils/auth'
import { loadingManager } from '@/utils/loadingManager'
import { checkTeamMembership, checkCourseAccess, clearPermissionCache } from '@/utils/permissionChecker'
// 白名单路由(不需要登录)
const WHITE_LIST = ['/login', '/register', '/404']
@@ -109,13 +110,21 @@ async function handleRouteGuard(
return
}
// 检查特殊路由规则
// 检查特殊路由规则(先进行同步检查)
if (!checkSpecialRouteRules(to)) {
ElMessage.error('访问被拒绝')
next(authManager.getDefaultRoute())
return
}
// 异步权限检查(团队和课程权限)
const hasSpecialAccess = await checkSpecialRouteRulesAsync(to)
if (!hasSpecialAccess) {
ElMessage.error('您没有访问此资源的权限')
next(authManager.getDefaultRoute())
return
}
next()
}
@@ -142,9 +151,9 @@ function checkRoutePermission(path: string): boolean {
}
/**
* 检查特殊路由规则
* 检查特殊路由规则(异步版本)
*/
function checkSpecialRouteRules(to: RouteLocationNormalized): boolean {
async function checkSpecialRouteRulesAsync(to: RouteLocationNormalized): Promise<boolean> {
const { path, params } = to
// 检查用户ID参数权限只能访问自己的数据管理员除外
@@ -157,14 +166,41 @@ function checkSpecialRouteRules(to: RouteLocationNormalized): boolean {
// 检查团队ID参数权限
if (params.teamId && !authManager.isAdmin()) {
// 这里可以添加团队权限检查逻辑
// 暂时允许通过,实际项目中需要检查用户是否属于该团队
const teamId = Number(params.teamId)
if (!isNaN(teamId)) {
const isMember = await checkTeamMembership(teamId)
if (!isMember) {
return false
}
}
}
// 检查课程访问权限
if (path.includes('/course/') && params.courseId) {
// 这里可以添加课程访问权限检查
// 例如检查课程是否分配给用户的岗位
const courseId = Number(params.courseId)
if (!isNaN(courseId)) {
const hasAccess = await checkCourseAccess(courseId)
if (!hasAccess) {
return false
}
}
}
return true
}
/**
* 检查特殊路由规则(同步版本,用于简单检查)
*/
function checkSpecialRouteRules(to: RouteLocationNormalized): boolean {
const { params } = to
// 检查用户ID参数权限只能访问自己的数据管理员除外
if (params.userId && !authManager.isAdmin()) {
const currentUser = authManager.getCurrentUser()
if (currentUser && String(params.userId) !== String(currentUser.id)) {
return false
}
}
return true

View File

@@ -1,413 +1,413 @@
/**
* 双人对练状态管理
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import * as duoPracticeApi from '@/api/duoPractice'
import type {
RoomInfo,
RoomUser,
RoomMessage,
CreateRoomRequest
} from '@/api/duoPractice'
export const useDuoPracticeStore = defineStore('duoPractice', () => {
// ==================== 状态 ====================
/** 房间码 */
const roomCode = ref<string>('')
/** 房间信息 */
const roomInfo = ref<RoomInfo | null>(null)
/** 房主信息 */
const hostUser = ref<RoomUser | null>(null)
/** 嘉宾信息 */
const guestUser = ref<RoomUser | null>(null)
/** 我的角色 */
const myRole = ref<string>('')
/** 我的角色名称 */
const myRoleName = ref<string>('')
/** 是否是房主 */
const isHost = ref<boolean>(false)
/** 消息列表 */
const messages = ref<RoomMessage[]>([])
/** 最后消息序号(用于轮询) */
const lastSequence = ref<number>(0)
/** 是否正在加载 */
const isLoading = ref<boolean>(false)
/** 是否已连接(轮询中) */
const isConnected = ref<boolean>(false)
/** 轮询定时器 */
let pollingTimer: number | null = null
/** 输入框内容 */
const inputMessage = ref<string>('')
// ==================== 计算属性 ====================
/** 房间状态 */
const roomStatus = computed(() => roomInfo.value?.status || 'unknown')
/** 是否等待中 */
const isWaiting = computed(() => roomStatus.value === 'waiting')
/** 是否就绪 */
const isReady = computed(() => roomStatus.value === 'ready')
/** 是否对练中 */
const isPracticing = computed(() => roomStatus.value === 'practicing')
/** 是否已完成 */
const isCompleted = computed(() => roomStatus.value === 'completed')
/** 对方用户 */
const partnerUser = computed(() => {
if (isHost.value) {
return guestUser.value
} else {
return hostUser.value
}
})
/** 对方角色名称 */
const partnerRoleName = computed(() => {
if (!roomInfo.value) return ''
const partnerRole = myRole.value === 'A' ? 'B' : 'A'
return partnerRole === 'A' ? roomInfo.value.role_a_name : roomInfo.value.role_b_name
})
/** 聊天消息(过滤系统消息) */
const chatMessages = computed(() => {
return messages.value.filter(m => m.message_type === 'chat')
})
/** 系统消息 */
const systemMessages = computed(() => {
return messages.value.filter(m => m.message_type !== 'chat')
})
// ==================== 方法 ====================
/**
* 创建房间
*/
const createRoom = async (request: CreateRoomRequest) => {
isLoading.value = true
try {
const res: any = await duoPracticeApi.createRoom(request)
if (res.code === 200) {
roomCode.value = res.data.room_code
myRole.value = res.data.my_role
myRoleName.value = res.data.my_role_name
isHost.value = true
// 获取房间详情
await fetchRoomDetail()
return res.data
} else {
throw new Error(res.message || '创建房间失败')
}
} catch (error: any) {
ElMessage.error(error.message || '创建房间失败')
throw error
} finally {
isLoading.value = false
}
}
/**
* 加入房间
*/
const joinRoom = async (code: string) => {
isLoading.value = true
try {
const res: any = await duoPracticeApi.joinRoom(code.toUpperCase())
if (res.code === 200) {
roomCode.value = res.data.room_code
myRole.value = res.data.my_role
myRoleName.value = res.data.my_role_name
isHost.value = false
// 获取房间详情
await fetchRoomDetail()
return res.data
} else {
throw new Error(res.message || '加入房间失败')
}
} catch (error: any) {
ElMessage.error(error.message || '加入房间失败')
throw error
} finally {
isLoading.value = false
}
}
/**
* 获取房间详情
*/
const fetchRoomDetail = async () => {
if (!roomCode.value) return
try {
const res: any = await duoPracticeApi.getRoomDetail(roomCode.value)
if (res.code === 200) {
roomInfo.value = res.data.room
hostUser.value = res.data.host_user
guestUser.value = res.data.guest_user
myRole.value = res.data.my_role || myRole.value
myRoleName.value = res.data.my_role_name || myRoleName.value
isHost.value = res.data.is_host
}
} catch (error) {
console.error('获取房间详情失败:', error)
}
}
/**
* 开始对练
*/
const startPractice = async () => {
if (!roomCode.value) return
isLoading.value = true
try {
const res: any = await duoPracticeApi.startPractice(roomCode.value)
if (res.code === 200) {
ElMessage.success('对练开始!')
await fetchRoomDetail()
} else {
throw new Error(res.message || '开始失败')
}
} catch (error: any) {
ElMessage.error(error.message || '开始对练失败')
} finally {
isLoading.value = false
}
}
/**
* 结束对练
*/
const endPractice = async () => {
if (!roomCode.value) return
isLoading.value = true
try {
const res: any = await duoPracticeApi.endPractice(roomCode.value)
if (res.code === 200) {
ElMessage.success('对练结束')
await fetchRoomDetail()
stopPolling()
return res.data
} else {
throw new Error(res.message || '结束失败')
}
} catch (error: any) {
ElMessage.error(error.message || '结束对练失败')
throw error
} finally {
isLoading.value = false
}
}
/**
* 离开房间
*/
const leaveRoom = async () => {
if (!roomCode.value) return
try {
await duoPracticeApi.leaveRoom(roomCode.value)
resetState()
} catch (error) {
console.error('离开房间失败:', error)
}
}
/**
* 发送消息
*/
const sendMessage = async (content?: string) => {
const msg = content || inputMessage.value.trim()
if (!msg || !roomCode.value) return
try {
const res: any = await duoPracticeApi.sendMessage(roomCode.value, msg)
if (res.code === 200) {
inputMessage.value = ''
// 消息会通过轮询获取
}
} catch (error: any) {
ElMessage.error(error.message || '发送失败')
}
}
/**
* 获取消息
*/
const fetchMessages = async () => {
if (!roomCode.value) return
try {
const res: any = await duoPracticeApi.getMessages(roomCode.value, lastSequence.value)
if (res.code === 200) {
const newMessages = res.data.messages
if (newMessages.length > 0) {
messages.value.push(...newMessages)
lastSequence.value = res.data.last_sequence
}
// 检查房间状态变化
if (res.data.room_status !== roomInfo.value?.status) {
await fetchRoomDetail()
}
}
} catch (error) {
console.error('获取消息失败:', error)
}
}
/**
* 开始轮询消息
*/
const startPolling = () => {
if (pollingTimer) return
isConnected.value = true
// 立即获取一次
fetchMessages()
// 每500ms轮询一次
pollingTimer = window.setInterval(() => {
fetchMessages()
}, 500)
console.log('[DuoPractice] 开始轮询消息')
}
/**
* 停止轮询
*/
const stopPolling = () => {
if (pollingTimer) {
clearInterval(pollingTimer)
pollingTimer = null
}
isConnected.value = false
console.log('[DuoPractice] 停止轮询消息')
}
/**
* 重置状态
*/
const resetState = () => {
stopPolling()
roomCode.value = ''
roomInfo.value = null
hostUser.value = null
guestUser.value = null
myRole.value = ''
myRoleName.value = ''
isHost.value = false
messages.value = []
lastSequence.value = 0
inputMessage.value = ''
isLoading.value = false
}
/**
* 生成分享链接
*/
const getShareLink = () => {
if (!roomCode.value) return ''
return duoPracticeApi.generateShareLink(roomCode.value)
}
/**
* 复制房间码
*/
const copyRoomCode = async () => {
if (!roomCode.value) return
try {
await navigator.clipboard.writeText(roomCode.value)
ElMessage.success('房间码已复制')
} catch (error) {
ElMessage.error('复制失败')
}
}
/**
* 复制分享链接
*/
const copyShareLink = async () => {
const link = getShareLink()
if (!link) return
try {
await navigator.clipboard.writeText(link)
ElMessage.success('链接已复制')
} catch (error) {
ElMessage.error('复制失败')
}
}
// ==================== 返回 ====================
return {
// 状态
roomCode,
roomInfo,
hostUser,
guestUser,
myRole,
myRoleName,
isHost,
messages,
lastSequence,
isLoading,
isConnected,
inputMessage,
// 计算属性
roomStatus,
isWaiting,
isReady,
isPracticing,
isCompleted,
partnerUser,
partnerRoleName,
chatMessages,
systemMessages,
// 方法
createRoom,
joinRoom,
fetchRoomDetail,
startPractice,
endPractice,
leaveRoom,
sendMessage,
fetchMessages,
startPolling,
stopPolling,
resetState,
getShareLink,
copyRoomCode,
copyShareLink
}
})
/**
* 双人对练状态管理
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import * as duoPracticeApi from '@/api/duoPractice'
import type {
RoomInfo,
RoomUser,
RoomMessage,
CreateRoomRequest
} from '@/api/duoPractice'
export const useDuoPracticeStore = defineStore('duoPractice', () => {
// ==================== 状态 ====================
/** 房间码 */
const roomCode = ref<string>('')
/** 房间信息 */
const roomInfo = ref<RoomInfo | null>(null)
/** 房主信息 */
const hostUser = ref<RoomUser | null>(null)
/** 嘉宾信息 */
const guestUser = ref<RoomUser | null>(null)
/** 我的角色 */
const myRole = ref<string>('')
/** 我的角色名称 */
const myRoleName = ref<string>('')
/** 是否是房主 */
const isHost = ref<boolean>(false)
/** 消息列表 */
const messages = ref<RoomMessage[]>([])
/** 最后消息序号(用于轮询) */
const lastSequence = ref<number>(0)
/** 是否正在加载 */
const isLoading = ref<boolean>(false)
/** 是否已连接(轮询中) */
const isConnected = ref<boolean>(false)
/** 轮询定时器 */
let pollingTimer: number | null = null
/** 输入框内容 */
const inputMessage = ref<string>('')
// ==================== 计算属性 ====================
/** 房间状态 */
const roomStatus = computed(() => roomInfo.value?.status || 'unknown')
/** 是否等待中 */
const isWaiting = computed(() => roomStatus.value === 'waiting')
/** 是否就绪 */
const isReady = computed(() => roomStatus.value === 'ready')
/** 是否对练中 */
const isPracticing = computed(() => roomStatus.value === 'practicing')
/** 是否已完成 */
const isCompleted = computed(() => roomStatus.value === 'completed')
/** 对方用户 */
const partnerUser = computed(() => {
if (isHost.value) {
return guestUser.value
} else {
return hostUser.value
}
})
/** 对方角色名称 */
const partnerRoleName = computed(() => {
if (!roomInfo.value) return ''
const partnerRole = myRole.value === 'A' ? 'B' : 'A'
return partnerRole === 'A' ? roomInfo.value.role_a_name : roomInfo.value.role_b_name
})
/** 聊天消息(过滤系统消息) */
const chatMessages = computed(() => {
return messages.value.filter(m => m.message_type === 'chat')
})
/** 系统消息 */
const systemMessages = computed(() => {
return messages.value.filter(m => m.message_type !== 'chat')
})
// ==================== 方法 ====================
/**
* 创建房间
*/
const createRoom = async (request: CreateRoomRequest) => {
isLoading.value = true
try {
const res: any = await duoPracticeApi.createRoom(request)
if (res.code === 200) {
roomCode.value = res.data.room_code
myRole.value = res.data.my_role
myRoleName.value = res.data.my_role_name
isHost.value = true
// 获取房间详情
await fetchRoomDetail()
return res.data
} else {
throw new Error(res.message || '创建房间失败')
}
} catch (error: any) {
ElMessage.error(error.message || '创建房间失败')
throw error
} finally {
isLoading.value = false
}
}
/**
* 加入房间
*/
const joinRoom = async (code: string) => {
isLoading.value = true
try {
const res: any = await duoPracticeApi.joinRoom(code.toUpperCase())
if (res.code === 200) {
roomCode.value = res.data.room_code
myRole.value = res.data.my_role
myRoleName.value = res.data.my_role_name
isHost.value = false
// 获取房间详情
await fetchRoomDetail()
return res.data
} else {
throw new Error(res.message || '加入房间失败')
}
} catch (error: any) {
ElMessage.error(error.message || '加入房间失败')
throw error
} finally {
isLoading.value = false
}
}
/**
* 获取房间详情
*/
const fetchRoomDetail = async () => {
if (!roomCode.value) return
try {
const res: any = await duoPracticeApi.getRoomDetail(roomCode.value)
if (res.code === 200) {
roomInfo.value = res.data.room
hostUser.value = res.data.host_user
guestUser.value = res.data.guest_user
myRole.value = res.data.my_role || myRole.value
myRoleName.value = res.data.my_role_name || myRoleName.value
isHost.value = res.data.is_host
}
} catch (error) {
console.error('获取房间详情失败:', error)
}
}
/**
* 开始对练
*/
const startPractice = async () => {
if (!roomCode.value) return
isLoading.value = true
try {
const res: any = await duoPracticeApi.startPractice(roomCode.value)
if (res.code === 200) {
ElMessage.success('对练开始!')
await fetchRoomDetail()
} else {
throw new Error(res.message || '开始失败')
}
} catch (error: any) {
ElMessage.error(error.message || '开始对练失败')
} finally {
isLoading.value = false
}
}
/**
* 结束对练
*/
const endPractice = async () => {
if (!roomCode.value) return
isLoading.value = true
try {
const res: any = await duoPracticeApi.endPractice(roomCode.value)
if (res.code === 200) {
ElMessage.success('对练结束')
await fetchRoomDetail()
stopPolling()
return res.data
} else {
throw new Error(res.message || '结束失败')
}
} catch (error: any) {
ElMessage.error(error.message || '结束对练失败')
throw error
} finally {
isLoading.value = false
}
}
/**
* 离开房间
*/
const leaveRoom = async () => {
if (!roomCode.value) return
try {
await duoPracticeApi.leaveRoom(roomCode.value)
resetState()
} catch (error) {
console.error('离开房间失败:', error)
}
}
/**
* 发送消息
*/
const sendMessage = async (content?: string) => {
const msg = content || inputMessage.value.trim()
if (!msg || !roomCode.value) return
try {
const res: any = await duoPracticeApi.sendMessage(roomCode.value, msg)
if (res.code === 200) {
inputMessage.value = ''
// 消息会通过轮询获取
}
} catch (error: any) {
ElMessage.error(error.message || '发送失败')
}
}
/**
* 获取消息
*/
const fetchMessages = async () => {
if (!roomCode.value) return
try {
const res: any = await duoPracticeApi.getMessages(roomCode.value, lastSequence.value)
if (res.code === 200) {
const newMessages = res.data.messages
if (newMessages.length > 0) {
messages.value.push(...newMessages)
lastSequence.value = res.data.last_sequence
}
// 检查房间状态变化
if (res.data.room_status !== roomInfo.value?.status) {
await fetchRoomDetail()
}
}
} catch (error) {
console.error('获取消息失败:', error)
}
}
/**
* 开始轮询消息
*/
const startPolling = () => {
if (pollingTimer) return
isConnected.value = true
// 立即获取一次
fetchMessages()
// 每500ms轮询一次
pollingTimer = window.setInterval(() => {
fetchMessages()
}, 500)
console.log('[DuoPractice] 开始轮询消息')
}
/**
* 停止轮询
*/
const stopPolling = () => {
if (pollingTimer) {
clearInterval(pollingTimer)
pollingTimer = null
}
isConnected.value = false
console.log('[DuoPractice] 停止轮询消息')
}
/**
* 重置状态
*/
const resetState = () => {
stopPolling()
roomCode.value = ''
roomInfo.value = null
hostUser.value = null
guestUser.value = null
myRole.value = ''
myRoleName.value = ''
isHost.value = false
messages.value = []
lastSequence.value = 0
inputMessage.value = ''
isLoading.value = false
}
/**
* 生成分享链接
*/
const getShareLink = () => {
if (!roomCode.value) return ''
return duoPracticeApi.generateShareLink(roomCode.value)
}
/**
* 复制房间码
*/
const copyRoomCode = async () => {
if (!roomCode.value) return
try {
await navigator.clipboard.writeText(roomCode.value)
ElMessage.success('房间码已复制')
} catch (error) {
ElMessage.error('复制失败')
}
}
/**
* 复制分享链接
*/
const copyShareLink = async () => {
const link = getShareLink()
if (!link) return
try {
await navigator.clipboard.writeText(link)
ElMessage.success('链接已复制')
} catch (error) {
ElMessage.error('复制失败')
}
}
// ==================== 返回 ====================
return {
// 状态
roomCode,
roomInfo,
hostUser,
guestUser,
myRole,
myRoleName,
isHost,
messages,
lastSequence,
isLoading,
isConnected,
inputMessage,
// 计算属性
roomStatus,
isWaiting,
isReady,
isPracticing,
isCompleted,
partnerUser,
partnerRoleName,
chatMessages,
systemMessages,
// 方法
createRoom,
joinRoom,
fetchRoomDetail,
startPractice,
endPractice,
leaveRoom,
sendMessage,
fetchMessages,
startPolling,
stopPolling,
resetState,
getShareLink,
copyRoomCode,
copyShareLink
}
})

View File

@@ -161,6 +161,12 @@ class AuthManager {
localStorage.removeItem(this.userKey)
localStorage.removeItem(this.tokenKey)
localStorage.removeItem(this.refreshTokenKey)
// 清除权限缓存
import('@/utils/permissionChecker').then(({ clearPermissionCache }) => {
clearPermissionCache()
}).catch(() => {
// 忽略导入错误
})
}
/**

View File

@@ -1,227 +1,227 @@
/**
* 钉钉SDK工具类
*
* 提供钉钉环境检测、免登授权码获取等功能
*/
// 钉钉JSAPI类型声明
declare global {
interface Window {
dd?: {
env: {
platform: 'notInDingTalk' | 'android' | 'ios' | 'pc'
}
ready: (callback: () => void) => void
error: (callback: (err: any) => void) => void
runtime: {
permission: {
requestAuthCode: (options: {
corpId: string
onSuccess: (result: { code: string }) => void
onFail: (err: any) => void
}) => void
}
}
biz: {
navigation: {
setTitle: (options: { title: string }) => void
}
}
}
}
}
/**
* 钉钉配置接口
*/
export interface DingtalkConfig {
enabled: boolean
corp_id: string | null
agent_id: string | null
}
/**
* 检测是否在钉钉环境中
*/
export function isDingtalkEnv(): boolean {
if (typeof window === 'undefined') {
console.log('[钉钉检测] window 不存在')
return false
}
// 首先通过 User-Agent 检测
const ua = navigator.userAgent.toLowerCase()
const isDingTalkUA = ua.includes('dingtalk') || ua.includes('aliapp')
console.log('[钉钉检测] UA检测:', isDingTalkUA, 'UA:', ua.substring(0, 100))
if (!window.dd) {
console.log('[钉钉检测] window.dd 不存在但UA检测为:', isDingTalkUA)
return isDingTalkUA // 如果 UA 显示是钉钉但 SDK 还没加载,也返回 true
}
const platform = window.dd.env?.platform
console.log('[钉钉检测] dd.env.platform:', platform)
return platform !== 'notInDingTalk'
}
/**
* 获取钉钉平台类型
*/
export function getDingtalkPlatform(): string {
if (!window.dd) return 'notInDingTalk'
return window.dd.env.platform
}
/**
* 等待钉钉SDK就绪带超时
*/
export function waitDingtalkReady(timeout: number = 5000): Promise<void> {
return new Promise((resolve, reject) => {
if (!window.dd) {
reject(new Error('钉钉SDK未加载'))
return
}
let resolved = false
// 超时处理
const timer = setTimeout(() => {
if (!resolved) {
resolved = true
console.warn('钉钉SDK就绪超时尝试继续执行')
resolve() // 超时后也尝试继续可能SDK已经就绪
}
}, timeout)
window.dd.ready(() => {
if (!resolved) {
resolved = true
clearTimeout(timer)
console.log('钉钉SDK就绪')
resolve()
}
})
window.dd.error((err) => {
if (!resolved) {
resolved = true
clearTimeout(timer)
console.error('钉钉SDK错误:', err)
reject(err)
}
})
})
}
/**
* 获取钉钉免登授权码
*
* @param corpId 企业CorpId
* @returns 免登授权码
*/
export function getAuthCode(corpId: string): Promise<string> {
return new Promise((resolve, reject) => {
if (!window.dd) {
reject(new Error('钉钉SDK未加载'))
return
}
if (!isDingtalkEnv()) {
reject(new Error('当前不在钉钉环境中'))
return
}
window.dd.runtime.permission.requestAuthCode({
corpId: corpId,
onSuccess: (result) => {
resolve(result.code)
},
onFail: (err) => {
console.error('获取钉钉授权码失败:', err)
reject(new Error(err.message || '获取授权码失败'))
}
})
})
}
/**
* 设置钉钉页面标题
*/
export function setDingtalkTitle(title: string): void {
if (!window.dd || !isDingtalkEnv()) return
try {
window.dd.biz.navigation.setTitle({ title })
} catch (e) {
console.warn('设置钉钉标题失败:', e)
}
}
/**
* 加载钉钉JSAPI SDK
*
* 动态加载钉钉SDK脚本
*/
export function loadDingtalkSDK(): Promise<void> {
return new Promise((resolve, reject) => {
// 如果已经加载过,直接返回
if (window.dd) {
resolve()
return
}
const script = document.createElement('script')
script.src = 'https://g.alicdn.com/dingding/dingtalk-jsapi/3.0.12/dingtalk.open.js'
script.async = true
script.onload = () => {
console.log('钉钉SDK加载成功')
resolve()
}
script.onerror = () => {
reject(new Error('钉钉SDK加载失败'))
}
document.head.appendChild(script)
})
}
/**
* 钉钉免密登录完整流程
*
* @param corpId 企业CorpId
* @param loginApi 登录API函数
* @returns 登录结果
*/
export async function dingtalkAutoLogin(
corpId: string,
loginApi: (code: string) => Promise<any>
): Promise<any> {
// 1. 检测钉钉环境
if (!isDingtalkEnv()) {
throw new Error('当前不在钉钉环境中,无法使用免密登录')
}
// 2. 等待SDK就绪
await waitDingtalkReady()
// 3. 获取授权码
const code = await getAuthCode(corpId)
// 4. 调用登录API
const result = await loginApi(code)
return result
}
export default {
isDingtalkEnv,
getDingtalkPlatform,
waitDingtalkReady,
getAuthCode,
setDingtalkTitle,
loadDingtalkSDK,
dingtalkAutoLogin
}
/**
* 钉钉SDK工具类
*
* 提供钉钉环境检测、免登授权码获取等功能
*/
// 钉钉JSAPI类型声明
declare global {
interface Window {
dd?: {
env: {
platform: 'notInDingTalk' | 'android' | 'ios' | 'pc'
}
ready: (callback: () => void) => void
error: (callback: (err: any) => void) => void
runtime: {
permission: {
requestAuthCode: (options: {
corpId: string
onSuccess: (result: { code: string }) => void
onFail: (err: any) => void
}) => void
}
}
biz: {
navigation: {
setTitle: (options: { title: string }) => void
}
}
}
}
}
/**
* 钉钉配置接口
*/
export interface DingtalkConfig {
enabled: boolean
corp_id: string | null
agent_id: string | null
}
/**
* 检测是否在钉钉环境中
*/
export function isDingtalkEnv(): boolean {
if (typeof window === 'undefined') {
console.log('[钉钉检测] window 不存在')
return false
}
// 首先通过 User-Agent 检测
const ua = navigator.userAgent.toLowerCase()
const isDingTalkUA = ua.includes('dingtalk') || ua.includes('aliapp')
console.log('[钉钉检测] UA检测:', isDingTalkUA, 'UA:', ua.substring(0, 100))
if (!window.dd) {
console.log('[钉钉检测] window.dd 不存在但UA检测为:', isDingTalkUA)
return isDingTalkUA // 如果 UA 显示是钉钉但 SDK 还没加载,也返回 true
}
const platform = window.dd.env?.platform
console.log('[钉钉检测] dd.env.platform:', platform)
return platform !== 'notInDingTalk'
}
/**
* 获取钉钉平台类型
*/
export function getDingtalkPlatform(): string {
if (!window.dd) return 'notInDingTalk'
return window.dd.env.platform
}
/**
* 等待钉钉SDK就绪带超时
*/
export function waitDingtalkReady(timeout: number = 5000): Promise<void> {
return new Promise((resolve, reject) => {
if (!window.dd) {
reject(new Error('钉钉SDK未加载'))
return
}
let resolved = false
// 超时处理
const timer = setTimeout(() => {
if (!resolved) {
resolved = true
console.warn('钉钉SDK就绪超时尝试继续执行')
resolve() // 超时后也尝试继续可能SDK已经就绪
}
}, timeout)
window.dd.ready(() => {
if (!resolved) {
resolved = true
clearTimeout(timer)
console.log('钉钉SDK就绪')
resolve()
}
})
window.dd.error((err) => {
if (!resolved) {
resolved = true
clearTimeout(timer)
console.error('钉钉SDK错误:', err)
reject(err)
}
})
})
}
/**
* 获取钉钉免登授权码
*
* @param corpId 企业CorpId
* @returns 免登授权码
*/
export function getAuthCode(corpId: string): Promise<string> {
return new Promise((resolve, reject) => {
if (!window.dd) {
reject(new Error('钉钉SDK未加载'))
return
}
if (!isDingtalkEnv()) {
reject(new Error('当前不在钉钉环境中'))
return
}
window.dd.runtime.permission.requestAuthCode({
corpId: corpId,
onSuccess: (result) => {
resolve(result.code)
},
onFail: (err) => {
console.error('获取钉钉授权码失败:', err)
reject(new Error(err.message || '获取授权码失败'))
}
})
})
}
/**
* 设置钉钉页面标题
*/
export function setDingtalkTitle(title: string): void {
if (!window.dd || !isDingtalkEnv()) return
try {
window.dd.biz.navigation.setTitle({ title })
} catch (e) {
console.warn('设置钉钉标题失败:', e)
}
}
/**
* 加载钉钉JSAPI SDK
*
* 动态加载钉钉SDK脚本
*/
export function loadDingtalkSDK(): Promise<void> {
return new Promise((resolve, reject) => {
// 如果已经加载过,直接返回
if (window.dd) {
resolve()
return
}
const script = document.createElement('script')
script.src = 'https://g.alicdn.com/dingding/dingtalk-jsapi/3.0.12/dingtalk.open.js'
script.async = true
script.onload = () => {
console.log('钉钉SDK加载成功')
resolve()
}
script.onerror = () => {
reject(new Error('钉钉SDK加载失败'))
}
document.head.appendChild(script)
})
}
/**
* 钉钉免密登录完整流程
*
* @param corpId 企业CorpId
* @param loginApi 登录API函数
* @returns 登录结果
*/
export async function dingtalkAutoLogin(
corpId: string,
loginApi: (code: string) => Promise<any>
): Promise<any> {
// 1. 检测钉钉环境
if (!isDingtalkEnv()) {
throw new Error('当前不在钉钉环境中,无法使用免密登录')
}
// 2. 等待SDK就绪
await waitDingtalkReady()
// 3. 获取授权码
const code = await getAuthCode(corpId)
// 4. 调用登录API
const result = await loginApi(code)
return result
}
export default {
isDingtalkEnv,
getDingtalkPlatform,
waitDingtalkReady,
getAuthCode,
setDingtalkTitle,
loadDingtalkSDK,
dingtalkAutoLogin
}

View File

@@ -0,0 +1,211 @@
/**
* 权限检查工具
* 用于前端路由守卫和组件级权限控制
*/
import { authManager } from './auth'
// 缓存团队成员关系
const teamMembershipCache = new Map<number, boolean>()
// 缓存课程访问权限
const courseAccessCache = new Map<number, boolean>()
// 缓存过期时间5分钟
const CACHE_TTL = 5 * 60 * 1000
let lastCacheUpdate = 0
/**
* 清除权限缓存
*/
export function clearPermissionCache() {
teamMembershipCache.clear()
courseAccessCache.clear()
lastCacheUpdate = 0
}
/**
* 检查缓存是否过期
*/
function isCacheExpired(): boolean {
return Date.now() - lastCacheUpdate > CACHE_TTL
}
/**
* 更新缓存时间戳
*/
function updateCacheTimestamp() {
lastCacheUpdate = Date.now()
}
/**
* 检查用户是否属于指定团队
* @param teamId 团队ID
*/
export async function checkTeamMembership(teamId: number): Promise<boolean> {
// 管理员可以访问所有团队
if (authManager.isAdmin()) {
return true
}
// 检查缓存
if (!isCacheExpired() && teamMembershipCache.has(teamId)) {
return teamMembershipCache.get(teamId)!
}
try {
const currentUser = authManager.getCurrentUser()
if (!currentUser) {
return false
}
// 检查用户的团队列表
const userTeams = currentUser.teams || []
const isMember = userTeams.some((team: any) => team.id === teamId)
// 更新缓存
teamMembershipCache.set(teamId, isMember)
updateCacheTimestamp()
return isMember
} catch (error) {
console.error('检查团队成员身份失败:', error)
return false
}
}
/**
* 检查用户是否可以访问指定课程
* @param courseId 课程ID
*/
export async function checkCourseAccess(courseId: number): Promise<boolean> {
// 管理员和经理可以访问所有课程
if (authManager.isAdmin() || authManager.isManager()) {
return true
}
// 检查缓存
if (!isCacheExpired() && courseAccessCache.has(courseId)) {
return courseAccessCache.get(courseId)!
}
try {
// 简化检查:学员可以访问所有已发布的课程
// 后端会在 API 层面做更细粒度的权限控制
// 这里暂时放行,让后端决定是否返回 403
const hasAccess = true
// 更新缓存
courseAccessCache.set(courseId, hasAccess)
updateCacheTimestamp()
return hasAccess
} catch (error) {
console.error('检查课程访问权限失败:', error)
return false
}
}
/**
* 检查用户是否有某个权限
* @param permission 权限代码
*/
export function hasPermission(permission: string): boolean {
return authManager.hasPermission(permission)
}
/**
* 检查用户是否有任意一个权限
* @param permissions 权限代码列表
*/
export function hasAnyPermission(permissions: string[]): boolean {
return authManager.hasAnyPermission(permissions)
}
/**
* 检查用户是否有所有权限
* @param permissions 权限代码列表
*/
export function hasAllPermissions(permissions: string[]): boolean {
return authManager.hasAllPermissions(permissions)
}
/**
* 获取用户的所有权限
*/
export function getUserPermissions(): string[] {
return authManager.getUserPermissions()
}
/**
* 权限检查结果接口
*/
export interface PermissionCheckResult {
allowed: boolean
reason?: string
}
/**
* 综合权限检查
* @param options 检查选项
*/
export async function checkPermission(options: {
teamId?: number
courseId?: number
userId?: number
permissions?: string[]
roles?: string[]
}): Promise<PermissionCheckResult> {
const { teamId, courseId, userId, permissions, roles } = options
// 检查角色
if (roles && roles.length > 0) {
const userRole = authManager.getUserRole()
if (!userRole || (!roles.includes(userRole) && !authManager.isAdmin())) {
return { allowed: false, reason: '角色权限不足' }
}
}
// 检查权限
if (permissions && permissions.length > 0) {
if (!hasAnyPermission(permissions)) {
return { allowed: false, reason: '缺少必要权限' }
}
}
// 检查用户ID只能访问自己的数据
if (userId !== undefined) {
const currentUser = authManager.getCurrentUser()
if (!authManager.isAdmin() && currentUser?.id !== userId) {
return { allowed: false, reason: '无权访问其他用户数据' }
}
}
// 检查团队成员身份
if (teamId !== undefined) {
const isMember = await checkTeamMembership(teamId)
if (!isMember) {
return { allowed: false, reason: '不是该团队成员' }
}
}
// 检查课程访问权限
if (courseId !== undefined) {
const hasAccess = await checkCourseAccess(courseId)
if (!hasAccess) {
return { allowed: false, reason: '无权访问该课程' }
}
}
return { allowed: true }
}
export default {
clearPermissionCache,
checkTeamMembership,
checkCourseAccess,
hasPermission,
hasAnyPermission,
hasAllPermissions,
getUserPermissions,
checkPermission,
}

View File

@@ -0,0 +1,294 @@
/**
* 语音识别工具
* 使用 Web Speech API 进行浏览器端语音识别
*/
// Web Speech API 类型声明
interface SpeechRecognitionEvent extends Event {
results: SpeechRecognitionResultList
resultIndex: number
}
interface SpeechRecognitionResultList {
readonly length: number
item(index: number): SpeechRecognitionResult
[index: number]: SpeechRecognitionResult
}
interface SpeechRecognitionResult {
readonly length: number
readonly isFinal: boolean
item(index: number): SpeechRecognitionAlternative
[index: number]: SpeechRecognitionAlternative
}
interface SpeechRecognitionAlternative {
readonly transcript: string
readonly confidence: number
}
interface SpeechRecognitionErrorEvent extends Event {
error: string
message: string
}
declare global {
interface Window {
SpeechRecognition: new () => SpeechRecognition
webkitSpeechRecognition: new () => SpeechRecognition
}
}
interface SpeechRecognition extends EventTarget {
continuous: boolean
interimResults: boolean
lang: string
maxAlternatives: number
start(): void
stop(): void
abort(): void
onresult: ((event: SpeechRecognitionEvent) => void) | null
onerror: ((event: SpeechRecognitionErrorEvent) => void) | null
onend: (() => void) | null
onstart: (() => void) | null
onspeechend: (() => void) | null
}
// 语音识别配置
export interface SpeechRecognitionConfig {
continuous?: boolean
interimResults?: boolean
lang?: string
maxAlternatives?: number
}
// 语音识别结果
export interface SpeechRecognitionResult {
transcript: string
isFinal: boolean
confidence: number
}
// 语音识别回调
export interface SpeechRecognitionCallbacks {
onResult?: (result: SpeechRecognitionResult) => void
onError?: (error: string) => void
onStart?: () => void
onEnd?: () => void
}
/**
* 检查浏览器是否支持语音识别
*/
export function isSpeechRecognitionSupported(): boolean {
return !!(window.SpeechRecognition || window.webkitSpeechRecognition)
}
/**
* 创建语音识别实例
*/
export function createSpeechRecognition(
config: SpeechRecognitionConfig = {}
): SpeechRecognition | null {
const SpeechRecognitionConstructor =
window.SpeechRecognition || window.webkitSpeechRecognition
if (!SpeechRecognitionConstructor) {
console.warn('浏览器不支持语音识别')
return null
}
const recognition = new SpeechRecognitionConstructor()
recognition.continuous = config.continuous ?? false
recognition.interimResults = config.interimResults ?? true
recognition.lang = config.lang ?? 'zh-CN'
recognition.maxAlternatives = config.maxAlternatives ?? 1
return recognition
}
/**
* 语音识别管理器类
*/
export class SpeechRecognitionManager {
private recognition: SpeechRecognition | null = null
private isListening = false
private callbacks: SpeechRecognitionCallbacks = {}
constructor(config: SpeechRecognitionConfig = {}) {
this.recognition = createSpeechRecognition(config)
this.setupEventListeners()
}
private setupEventListeners() {
if (!this.recognition) return
this.recognition.onresult = (event: SpeechRecognitionEvent) => {
const lastResult = event.results[event.resultIndex]
if (lastResult) {
const result: SpeechRecognitionResult = {
transcript: lastResult[0].transcript,
isFinal: lastResult.isFinal,
confidence: lastResult[0].confidence,
}
this.callbacks.onResult?.(result)
}
}
this.recognition.onerror = (event: SpeechRecognitionErrorEvent) => {
const errorMessages: Record<string, string> = {
'no-speech': '没有检测到语音',
'audio-capture': '无法访问麦克风',
'not-allowed': '麦克风权限被拒绝',
'network': '网络错误',
'aborted': '识别被中断',
'language-not-supported': '不支持的语言',
}
const message = errorMessages[event.error] || `识别错误: ${event.error}`
this.callbacks.onError?.(message)
this.isListening = false
}
this.recognition.onstart = () => {
this.isListening = true
this.callbacks.onStart?.()
}
this.recognition.onend = () => {
this.isListening = false
this.callbacks.onEnd?.()
}
}
/**
* 设置回调函数
*/
setCallbacks(callbacks: SpeechRecognitionCallbacks) {
this.callbacks = callbacks
}
/**
* 开始语音识别
*/
start(): boolean {
if (!this.recognition) {
this.callbacks.onError?.('浏览器不支持语音识别')
return false
}
if (this.isListening) {
return true
}
try {
this.recognition.start()
return true
} catch (error) {
this.callbacks.onError?.('启动语音识别失败')
return false
}
}
/**
* 停止语音识别
*/
stop() {
if (this.recognition && this.isListening) {
this.recognition.stop()
}
}
/**
* 中止语音识别
*/
abort() {
if (this.recognition) {
this.recognition.abort()
}
}
/**
* 是否正在监听
*/
getIsListening(): boolean {
return this.isListening
}
/**
* 是否支持语音识别
*/
isSupported(): boolean {
return this.recognition !== null
}
/**
* 销毁实例
*/
destroy() {
this.abort()
this.recognition = null
}
}
/**
* 一次性语音识别
* 返回 Promise识别完成后返回结果
*/
export function recognizeSpeech(
config: SpeechRecognitionConfig = {},
timeout = 10000
): Promise<string> {
return new Promise((resolve, reject) => {
const manager = new SpeechRecognitionManager({
...config,
continuous: false,
interimResults: false,
})
if (!manager.isSupported()) {
reject(new Error('浏览器不支持语音识别'))
return
}
let finalTranscript = ''
let timeoutId: number | null = null
manager.setCallbacks({
onResult: (result) => {
if (result.isFinal) {
finalTranscript = result.transcript
}
},
onEnd: () => {
if (timeoutId) {
clearTimeout(timeoutId)
}
manager.destroy()
resolve(finalTranscript)
},
onError: (error) => {
if (timeoutId) {
clearTimeout(timeoutId)
}
manager.destroy()
reject(new Error(error))
},
})
// 设置超时
timeoutId = window.setTimeout(() => {
manager.stop()
}, timeout)
if (!manager.start()) {
reject(new Error('启动语音识别失败'))
}
})
}
export default {
isSpeechRecognitionSupported,
createSpeechRecognition,
SpeechRecognitionManager,
recognizeSpeech,
}

View File

@@ -1,324 +1,324 @@
/**
* WebRTC 连接管理模块
*
* 功能:
* - 管理 RTCPeerConnection 生命周期
* - 处理 SDP 交换
* - 处理 ICE 候选收集
* - 音频流管理
*/
export type ConnectionState = 'idle' | 'connecting' | 'connected' | 'disconnected' | 'failed'
export interface WebRTCConfig {
iceServers?: RTCIceServer[]
onLocalStream?: (stream: MediaStream) => void
onRemoteStream?: (stream: MediaStream) => void
onConnectionStateChange?: (state: ConnectionState) => void
onIceCandidate?: (candidate: RTCIceCandidate) => void
onError?: (error: Error) => void
}
// 默认 ICE 服务器配置
const DEFAULT_ICE_SERVERS: RTCIceServer[] = [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
{ urls: 'stun:stun2.l.google.com:19302' }
]
export class WebRTCManager {
private peerConnection: RTCPeerConnection | null = null
private localStream: MediaStream | null = null
private remoteStream: MediaStream | null = null
private config: WebRTCConfig
private connectionState: ConnectionState = 'idle'
private pendingIceCandidates: RTCIceCandidate[] = []
constructor(config: WebRTCConfig = {}) {
this.config = {
iceServers: DEFAULT_ICE_SERVERS,
...config
}
}
/**
* 获取当前连接状态
*/
getConnectionState(): ConnectionState {
return this.connectionState
}
/**
* 获取本地音频流
*/
getLocalStream(): MediaStream | null {
return this.localStream
}
/**
* 获取远程音频流
*/
getRemoteStream(): MediaStream | null {
return this.remoteStream
}
/**
* 初始化本地音频流
*/
async initLocalStream(): Promise<MediaStream> {
try {
this.localStream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
},
video: false
})
this.config.onLocalStream?.(this.localStream)
return this.localStream
} catch (error) {
const err = error instanceof Error ? error : new Error('获取麦克风权限失败')
this.config.onError?.(err)
throw err
}
}
/**
* 创建 PeerConnection
*/
private createPeerConnection(): RTCPeerConnection {
const pc = new RTCPeerConnection({
iceServers: this.config.iceServers
})
// 监听 ICE 候选
pc.onicecandidate = (event) => {
if (event.candidate) {
console.log('[WebRTC] ICE candidate:', event.candidate.candidate?.substring(0, 50))
this.config.onIceCandidate?.(event.candidate)
}
}
// 监听连接状态变化
pc.onconnectionstatechange = () => {
console.log('[WebRTC] Connection state:', pc.connectionState)
this.updateConnectionState(pc.connectionState)
}
// 监听 ICE 连接状态
pc.oniceconnectionstatechange = () => {
console.log('[WebRTC] ICE connection state:', pc.iceConnectionState)
if (pc.iceConnectionState === 'failed') {
this.updateConnectionState('failed')
}
}
// 监听远程流
pc.ontrack = (event) => {
console.log('[WebRTC] Remote track received')
if (event.streams && event.streams[0]) {
this.remoteStream = event.streams[0]
this.config.onRemoteStream?.(this.remoteStream)
}
}
return pc
}
/**
* 更新连接状态
*/
private updateConnectionState(state: RTCPeerConnectionState | string) {
const stateMap: Record<string, ConnectionState> = {
'new': 'connecting',
'connecting': 'connecting',
'connected': 'connected',
'disconnected': 'disconnected',
'failed': 'failed',
'closed': 'disconnected'
}
this.connectionState = stateMap[state] || 'idle'
this.config.onConnectionStateChange?.(this.connectionState)
}
/**
* 创建 Offer发起方调用
*/
async createOffer(): Promise<RTCSessionDescriptionInit> {
if (!this.localStream) {
await this.initLocalStream()
}
this.peerConnection = this.createPeerConnection()
this.updateConnectionState('connecting')
// 添加本地音频轨道
this.localStream!.getTracks().forEach(track => {
this.peerConnection!.addTrack(track, this.localStream!)
})
// 创建 Offer
const offer = await this.peerConnection.createOffer()
await this.peerConnection.setLocalDescription(offer)
console.log('[WebRTC] Offer created')
return offer
}
/**
* 处理 Offer接收方调用
*/
async handleOffer(offer: RTCSessionDescriptionInit): Promise<RTCSessionDescriptionInit> {
if (!this.localStream) {
await this.initLocalStream()
}
this.peerConnection = this.createPeerConnection()
this.updateConnectionState('connecting')
// 添加本地音频轨道
this.localStream!.getTracks().forEach(track => {
this.peerConnection!.addTrack(track, this.localStream!)
})
// 设置远程描述
await this.peerConnection.setRemoteDescription(new RTCSessionDescription(offer))
// 处理等待中的 ICE 候选
for (const candidate of this.pendingIceCandidates) {
await this.peerConnection.addIceCandidate(candidate)
}
this.pendingIceCandidates = []
// 创建 Answer
const answer = await this.peerConnection.createAnswer()
await this.peerConnection.setLocalDescription(answer)
console.log('[WebRTC] Answer created')
return answer
}
/**
* 处理 Answer发起方调用
*/
async handleAnswer(answer: RTCSessionDescriptionInit): Promise<void> {
if (!this.peerConnection) {
throw new Error('PeerConnection not initialized')
}
await this.peerConnection.setRemoteDescription(new RTCSessionDescription(answer))
// 处理等待中的 ICE 候选
for (const candidate of this.pendingIceCandidates) {
await this.peerConnection.addIceCandidate(candidate)
}
this.pendingIceCandidates = []
console.log('[WebRTC] Answer handled')
}
/**
* 添加 ICE 候选
*/
async addIceCandidate(candidate: RTCIceCandidateInit): Promise<void> {
const iceCandidate = new RTCIceCandidate(candidate)
if (this.peerConnection && this.peerConnection.remoteDescription) {
await this.peerConnection.addIceCandidate(iceCandidate)
console.log('[WebRTC] ICE candidate added')
} else {
// 如果远程描述还没设置,先缓存候选
this.pendingIceCandidates.push(iceCandidate)
console.log('[WebRTC] ICE candidate queued')
}
}
/**
* 静音/取消静音本地音频
*/
setMuted(muted: boolean): void {
if (this.localStream) {
this.localStream.getAudioTracks().forEach(track => {
track.enabled = !muted
})
}
}
/**
* 检查是否静音
*/
isMuted(): boolean {
if (this.localStream) {
const audioTrack = this.localStream.getAudioTracks()[0]
return audioTrack ? !audioTrack.enabled : true
}
return true
}
/**
* 获取音频音量级别(用于音量指示器)
*/
async getAudioLevel(stream: MediaStream): Promise<number> {
return new Promise((resolve) => {
const audioContext = new AudioContext()
const analyser = audioContext.createAnalyser()
const source = audioContext.createMediaStreamSource(stream)
source.connect(analyser)
analyser.fftSize = 256
const dataArray = new Uint8Array(analyser.frequencyBinCount)
analyser.getByteFrequencyData(dataArray)
// 计算平均音量
const average = dataArray.reduce((a, b) => a + b, 0) / dataArray.length
audioContext.close()
resolve(average / 255) // 归一化到 0-1
})
}
/**
* 关闭连接
*/
close(): void {
console.log('[WebRTC] Closing connection')
// 停止本地流
if (this.localStream) {
this.localStream.getTracks().forEach(track => track.stop())
this.localStream = null
}
// 停止远程流
if (this.remoteStream) {
this.remoteStream.getTracks().forEach(track => track.stop())
this.remoteStream = null
}
// 关闭 PeerConnection
if (this.peerConnection) {
this.peerConnection.close()
this.peerConnection = null
}
this.pendingIceCandidates = []
this.updateConnectionState('disconnected')
}
/**
* 重置管理器
*/
reset(): void {
this.close()
this.connectionState = 'idle'
}
}
// 导出单例工厂函数
export function createWebRTCManager(config?: WebRTCConfig): WebRTCManager {
return new WebRTCManager(config)
}
/**
* WebRTC 连接管理模块
*
* 功能:
* - 管理 RTCPeerConnection 生命周期
* - 处理 SDP 交换
* - 处理 ICE 候选收集
* - 音频流管理
*/
export type ConnectionState = 'idle' | 'connecting' | 'connected' | 'disconnected' | 'failed'
export interface WebRTCConfig {
iceServers?: RTCIceServer[]
onLocalStream?: (stream: MediaStream) => void
onRemoteStream?: (stream: MediaStream) => void
onConnectionStateChange?: (state: ConnectionState) => void
onIceCandidate?: (candidate: RTCIceCandidate) => void
onError?: (error: Error) => void
}
// 默认 ICE 服务器配置
const DEFAULT_ICE_SERVERS: RTCIceServer[] = [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
{ urls: 'stun:stun2.l.google.com:19302' }
]
export class WebRTCManager {
private peerConnection: RTCPeerConnection | null = null
private localStream: MediaStream | null = null
private remoteStream: MediaStream | null = null
private config: WebRTCConfig
private connectionState: ConnectionState = 'idle'
private pendingIceCandidates: RTCIceCandidate[] = []
constructor(config: WebRTCConfig = {}) {
this.config = {
iceServers: DEFAULT_ICE_SERVERS,
...config
}
}
/**
* 获取当前连接状态
*/
getConnectionState(): ConnectionState {
return this.connectionState
}
/**
* 获取本地音频流
*/
getLocalStream(): MediaStream | null {
return this.localStream
}
/**
* 获取远程音频流
*/
getRemoteStream(): MediaStream | null {
return this.remoteStream
}
/**
* 初始化本地音频流
*/
async initLocalStream(): Promise<MediaStream> {
try {
this.localStream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
},
video: false
})
this.config.onLocalStream?.(this.localStream)
return this.localStream
} catch (error) {
const err = error instanceof Error ? error : new Error('获取麦克风权限失败')
this.config.onError?.(err)
throw err
}
}
/**
* 创建 PeerConnection
*/
private createPeerConnection(): RTCPeerConnection {
const pc = new RTCPeerConnection({
iceServers: this.config.iceServers
})
// 监听 ICE 候选
pc.onicecandidate = (event) => {
if (event.candidate) {
console.log('[WebRTC] ICE candidate:', event.candidate.candidate?.substring(0, 50))
this.config.onIceCandidate?.(event.candidate)
}
}
// 监听连接状态变化
pc.onconnectionstatechange = () => {
console.log('[WebRTC] Connection state:', pc.connectionState)
this.updateConnectionState(pc.connectionState)
}
// 监听 ICE 连接状态
pc.oniceconnectionstatechange = () => {
console.log('[WebRTC] ICE connection state:', pc.iceConnectionState)
if (pc.iceConnectionState === 'failed') {
this.updateConnectionState('failed')
}
}
// 监听远程流
pc.ontrack = (event) => {
console.log('[WebRTC] Remote track received')
if (event.streams && event.streams[0]) {
this.remoteStream = event.streams[0]
this.config.onRemoteStream?.(this.remoteStream)
}
}
return pc
}
/**
* 更新连接状态
*/
private updateConnectionState(state: RTCPeerConnectionState | string) {
const stateMap: Record<string, ConnectionState> = {
'new': 'connecting',
'connecting': 'connecting',
'connected': 'connected',
'disconnected': 'disconnected',
'failed': 'failed',
'closed': 'disconnected'
}
this.connectionState = stateMap[state] || 'idle'
this.config.onConnectionStateChange?.(this.connectionState)
}
/**
* 创建 Offer发起方调用
*/
async createOffer(): Promise<RTCSessionDescriptionInit> {
if (!this.localStream) {
await this.initLocalStream()
}
this.peerConnection = this.createPeerConnection()
this.updateConnectionState('connecting')
// 添加本地音频轨道
this.localStream!.getTracks().forEach(track => {
this.peerConnection!.addTrack(track, this.localStream!)
})
// 创建 Offer
const offer = await this.peerConnection.createOffer()
await this.peerConnection.setLocalDescription(offer)
console.log('[WebRTC] Offer created')
return offer
}
/**
* 处理 Offer接收方调用
*/
async handleOffer(offer: RTCSessionDescriptionInit): Promise<RTCSessionDescriptionInit> {
if (!this.localStream) {
await this.initLocalStream()
}
this.peerConnection = this.createPeerConnection()
this.updateConnectionState('connecting')
// 添加本地音频轨道
this.localStream!.getTracks().forEach(track => {
this.peerConnection!.addTrack(track, this.localStream!)
})
// 设置远程描述
await this.peerConnection.setRemoteDescription(new RTCSessionDescription(offer))
// 处理等待中的 ICE 候选
for (const candidate of this.pendingIceCandidates) {
await this.peerConnection.addIceCandidate(candidate)
}
this.pendingIceCandidates = []
// 创建 Answer
const answer = await this.peerConnection.createAnswer()
await this.peerConnection.setLocalDescription(answer)
console.log('[WebRTC] Answer created')
return answer
}
/**
* 处理 Answer发起方调用
*/
async handleAnswer(answer: RTCSessionDescriptionInit): Promise<void> {
if (!this.peerConnection) {
throw new Error('PeerConnection not initialized')
}
await this.peerConnection.setRemoteDescription(new RTCSessionDescription(answer))
// 处理等待中的 ICE 候选
for (const candidate of this.pendingIceCandidates) {
await this.peerConnection.addIceCandidate(candidate)
}
this.pendingIceCandidates = []
console.log('[WebRTC] Answer handled')
}
/**
* 添加 ICE 候选
*/
async addIceCandidate(candidate: RTCIceCandidateInit): Promise<void> {
const iceCandidate = new RTCIceCandidate(candidate)
if (this.peerConnection && this.peerConnection.remoteDescription) {
await this.peerConnection.addIceCandidate(iceCandidate)
console.log('[WebRTC] ICE candidate added')
} else {
// 如果远程描述还没设置,先缓存候选
this.pendingIceCandidates.push(iceCandidate)
console.log('[WebRTC] ICE candidate queued')
}
}
/**
* 静音/取消静音本地音频
*/
setMuted(muted: boolean): void {
if (this.localStream) {
this.localStream.getAudioTracks().forEach(track => {
track.enabled = !muted
})
}
}
/**
* 检查是否静音
*/
isMuted(): boolean {
if (this.localStream) {
const audioTrack = this.localStream.getAudioTracks()[0]
return audioTrack ? !audioTrack.enabled : true
}
return true
}
/**
* 获取音频音量级别(用于音量指示器)
*/
async getAudioLevel(stream: MediaStream): Promise<number> {
return new Promise((resolve) => {
const audioContext = new AudioContext()
const analyser = audioContext.createAnalyser()
const source = audioContext.createMediaStreamSource(stream)
source.connect(analyser)
analyser.fftSize = 256
const dataArray = new Uint8Array(analyser.frequencyBinCount)
analyser.getByteFrequencyData(dataArray)
// 计算平均音量
const average = dataArray.reduce((a, b) => a + b, 0) / dataArray.length
audioContext.close()
resolve(average / 255) // 归一化到 0-1
})
}
/**
* 关闭连接
*/
close(): void {
console.log('[WebRTC] Closing connection')
// 停止本地流
if (this.localStream) {
this.localStream.getTracks().forEach(track => track.stop())
this.localStream = null
}
// 停止远程流
if (this.remoteStream) {
this.remoteStream.getTracks().forEach(track => track.stop())
this.remoteStream = null
}
// 关闭 PeerConnection
if (this.peerConnection) {
this.peerConnection.close()
this.peerConnection = null
}
this.pendingIceCandidates = []
this.updateConnectionState('disconnected')
}
/**
* 重置管理器
*/
reset(): void {
this.close()
this.connectionState = 'idle'
}
}
// 导出单例工厂函数
export function createWebRTCManager(config?: WebRTCConfig): WebRTCManager {
return new WebRTCManager(config)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,251 +1,251 @@
<template>
<div class="system-settings-container">
<el-card shadow="hover" class="settings-card">
<template #header>
<div class="card-header">
<span>系统设置</span>
</div>
</template>
<el-tabs v-model="activeTab" type="border-card">
<!-- 钉钉配置 -->
<el-tab-pane label="钉钉免密登录" name="dingtalk">
<div class="tab-content">
<el-alert
title="钉钉免密登录配置说明"
type="info"
:closable="false"
show-icon
style="margin-bottom: 20px;"
>
<template #default>
<p>配置后员工可以通过钉钉客户端直接登录系统无需输入用户名密码</p>
<p style="margin-top: 8px;">
<a href="https://open-dev.dingtalk.com" target="_blank" class="link">
前往钉钉开放平台获取配置
</a>
</p>
</template>
</el-alert>
<el-form
ref="dingtalkFormRef"
:model="dingtalkForm"
:rules="dingtalkRules"
label-width="140px"
v-loading="loading"
>
<el-form-item label="启用钉钉登录">
<el-switch
v-model="dingtalkForm.enabled"
active-text="已启用"
inactive-text="已禁用"
/>
</el-form-item>
<el-divider content-position="left">钉钉应用配置</el-divider>
<el-form-item label="AppKey" prop="app_key">
<el-input
v-model="dingtalkForm.app_key"
placeholder="请输入钉钉应用的AppKey"
style="width: 400px;"
/>
</el-form-item>
<el-form-item label="AppSecret" prop="app_secret">
<el-input
v-model="dingtalkForm.app_secret"
type="password"
show-password
:placeholder="dingtalkForm.app_secret_masked || '请输入钉钉应用的AppSecret'"
style="width: 400px;"
/>
<span class="form-tip" v-if="dingtalkForm.app_secret_masked && !dingtalkForm.app_secret">
当前值: {{ dingtalkForm.app_secret_masked }}如需修改请重新输入
</span>
</el-form-item>
<el-form-item label="AgentId" prop="agent_id">
<el-input
v-model="dingtalkForm.agent_id"
placeholder="请输入钉钉应用的AgentId"
style="width: 400px;"
/>
</el-form-item>
<el-form-item label="CorpId" prop="corp_id">
<el-input
v-model="dingtalkForm.corp_id"
placeholder="请输入钉钉企业的CorpId"
style="width: 400px;"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveDingtalkConfig" :loading="saving">
保存配置
</el-button>
<el-button @click="loadDingtalkConfig">重置</el-button>
</el-form-item>
</el-form>
</div>
</el-tab-pane>
<!-- 其他设置预留 -->
<el-tab-pane label="其他设置" name="other" disabled>
<div class="tab-content">
<el-empty description="暂无其他设置项" />
</div>
</el-tab-pane>
</el-tabs>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { request } from '@/api/request'
const activeTab = ref('dingtalk')
const loading = ref(false)
const saving = ref(false)
const dingtalkFormRef = ref<FormInstance>()
// 钉钉配置表单
const dingtalkForm = reactive({
enabled: false,
app_key: '',
app_secret: '',
app_secret_masked: '', // 用于显示脱敏后的值
agent_id: '',
corp_id: '',
})
// 表单验证规则
const dingtalkRules = reactive<FormRules>({
app_key: [
{ required: false, message: '请输入AppKey', trigger: 'blur' }
],
agent_id: [
{ required: false, message: '请输入AgentId', trigger: 'blur' }
],
corp_id: [
{ required: false, message: '请输入CorpId', trigger: 'blur' }
]
})
/**
* 加载钉钉配置
*/
const loadDingtalkConfig = async () => {
loading.value = true
try {
const response = await request.get('/api/v1/settings/dingtalk')
if (response.code === 200 && response.data) {
dingtalkForm.enabled = response.data.enabled || false
dingtalkForm.app_key = response.data.app_key || ''
dingtalkForm.app_secret = '' // 不回显密钥
dingtalkForm.app_secret_masked = response.data.app_secret_masked || ''
dingtalkForm.agent_id = response.data.agent_id || ''
dingtalkForm.corp_id = response.data.corp_id || ''
}
} catch (error: any) {
console.error('加载钉钉配置失败:', error)
ElMessage.error('加载配置失败')
} finally {
loading.value = false
}
}
/**
* 保存钉钉配置
*/
const saveDingtalkConfig = async () => {
if (!dingtalkFormRef.value) return
await dingtalkFormRef.value.validate(async (valid) => {
if (valid) {
saving.value = true
try {
// 构建更新数据,只发送有值的字段
const updateData: any = {
enabled: dingtalkForm.enabled,
}
if (dingtalkForm.app_key) {
updateData.app_key = dingtalkForm.app_key
}
if (dingtalkForm.app_secret) {
updateData.app_secret = dingtalkForm.app_secret
}
if (dingtalkForm.agent_id) {
updateData.agent_id = dingtalkForm.agent_id
}
if (dingtalkForm.corp_id) {
updateData.corp_id = dingtalkForm.corp_id
}
const response = await request.put('/api/v1/settings/dingtalk', updateData)
if (response.code === 200) {
ElMessage.success('配置保存成功')
// 重新加载配置
await loadDingtalkConfig()
} else {
ElMessage.error(response.message || '保存失败')
}
} catch (error: any) {
console.error('保存钉钉配置失败:', error)
ElMessage.error('保存配置失败')
} finally {
saving.value = false
}
}
})
}
// 页面加载时获取配置
onMounted(() => {
loadDingtalkConfig()
})
</script>
<style lang="scss" scoped>
.system-settings-container {
padding: 20px;
.settings-card {
.card-header {
font-size: 18px;
font-weight: 600;
}
}
.tab-content {
padding: 20px;
min-height: 400px;
}
.form-tip {
font-size: 12px;
color: #909399;
margin-left: 12px;
}
.link {
color: #409eff;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
:deep(.el-divider__text) {
font-size: 14px;
color: #606266;
}
}
</style>
<template>
<div class="system-settings-container">
<el-card shadow="hover" class="settings-card">
<template #header>
<div class="card-header">
<span>系统设置</span>
</div>
</template>
<el-tabs v-model="activeTab" type="border-card">
<!-- 钉钉配置 -->
<el-tab-pane label="钉钉免密登录" name="dingtalk">
<div class="tab-content">
<el-alert
title="钉钉免密登录配置说明"
type="info"
:closable="false"
show-icon
style="margin-bottom: 20px;"
>
<template #default>
<p>配置后员工可以通过钉钉客户端直接登录系统无需输入用户名密码</p>
<p style="margin-top: 8px;">
<a href="https://open-dev.dingtalk.com" target="_blank" class="link">
前往钉钉开放平台获取配置
</a>
</p>
</template>
</el-alert>
<el-form
ref="dingtalkFormRef"
:model="dingtalkForm"
:rules="dingtalkRules"
label-width="140px"
v-loading="loading"
>
<el-form-item label="启用钉钉登录">
<el-switch
v-model="dingtalkForm.enabled"
active-text="已启用"
inactive-text="已禁用"
/>
</el-form-item>
<el-divider content-position="left">钉钉应用配置</el-divider>
<el-form-item label="AppKey" prop="app_key">
<el-input
v-model="dingtalkForm.app_key"
placeholder="请输入钉钉应用的AppKey"
style="width: 400px;"
/>
</el-form-item>
<el-form-item label="AppSecret" prop="app_secret">
<el-input
v-model="dingtalkForm.app_secret"
type="password"
show-password
:placeholder="dingtalkForm.app_secret_masked || '请输入钉钉应用的AppSecret'"
style="width: 400px;"
/>
<span class="form-tip" v-if="dingtalkForm.app_secret_masked && !dingtalkForm.app_secret">
当前值: {{ dingtalkForm.app_secret_masked }}如需修改请重新输入
</span>
</el-form-item>
<el-form-item label="AgentId" prop="agent_id">
<el-input
v-model="dingtalkForm.agent_id"
placeholder="请输入钉钉应用的AgentId"
style="width: 400px;"
/>
</el-form-item>
<el-form-item label="CorpId" prop="corp_id">
<el-input
v-model="dingtalkForm.corp_id"
placeholder="请输入钉钉企业的CorpId"
style="width: 400px;"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveDingtalkConfig" :loading="saving">
保存配置
</el-button>
<el-button @click="loadDingtalkConfig">重置</el-button>
</el-form-item>
</el-form>
</div>
</el-tab-pane>
<!-- 其他设置预留 -->
<el-tab-pane label="其他设置" name="other" disabled>
<div class="tab-content">
<el-empty description="暂无其他设置项" />
</div>
</el-tab-pane>
</el-tabs>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { request } from '@/api/request'
const activeTab = ref('dingtalk')
const loading = ref(false)
const saving = ref(false)
const dingtalkFormRef = ref<FormInstance>()
// 钉钉配置表单
const dingtalkForm = reactive({
enabled: false,
app_key: '',
app_secret: '',
app_secret_masked: '', // 用于显示脱敏后的值
agent_id: '',
corp_id: '',
})
// 表单验证规则
const dingtalkRules = reactive<FormRules>({
app_key: [
{ required: false, message: '请输入AppKey', trigger: 'blur' }
],
agent_id: [
{ required: false, message: '请输入AgentId', trigger: 'blur' }
],
corp_id: [
{ required: false, message: '请输入CorpId', trigger: 'blur' }
]
})
/**
* 加载钉钉配置
*/
const loadDingtalkConfig = async () => {
loading.value = true
try {
const response = await request.get('/api/v1/settings/dingtalk')
if (response.code === 200 && response.data) {
dingtalkForm.enabled = response.data.enabled || false
dingtalkForm.app_key = response.data.app_key || ''
dingtalkForm.app_secret = '' // 不回显密钥
dingtalkForm.app_secret_masked = response.data.app_secret_masked || ''
dingtalkForm.agent_id = response.data.agent_id || ''
dingtalkForm.corp_id = response.data.corp_id || ''
}
} catch (error: any) {
console.error('加载钉钉配置失败:', error)
ElMessage.error('加载配置失败')
} finally {
loading.value = false
}
}
/**
* 保存钉钉配置
*/
const saveDingtalkConfig = async () => {
if (!dingtalkFormRef.value) return
await dingtalkFormRef.value.validate(async (valid) => {
if (valid) {
saving.value = true
try {
// 构建更新数据,只发送有值的字段
const updateData: any = {
enabled: dingtalkForm.enabled,
}
if (dingtalkForm.app_key) {
updateData.app_key = dingtalkForm.app_key
}
if (dingtalkForm.app_secret) {
updateData.app_secret = dingtalkForm.app_secret
}
if (dingtalkForm.agent_id) {
updateData.agent_id = dingtalkForm.agent_id
}
if (dingtalkForm.corp_id) {
updateData.corp_id = dingtalkForm.corp_id
}
const response = await request.put('/api/v1/settings/dingtalk', updateData)
if (response.code === 200) {
ElMessage.success('配置保存成功')
// 重新加载配置
await loadDingtalkConfig()
} else {
ElMessage.error(response.message || '保存失败')
}
} catch (error: any) {
console.error('保存钉钉配置失败:', error)
ElMessage.error('保存配置失败')
} finally {
saving.value = false
}
}
})
}
// 页面加载时获取配置
onMounted(() => {
loadDingtalkConfig()
})
</script>
<style lang="scss" scoped>
.system-settings-container {
padding: 20px;
.settings-card {
.card-header {
font-size: 18px;
font-weight: 600;
}
}
.tab-content {
padding: 20px;
min-height: 400px;
}
.form-tip {
font-size: 12px;
color: #909399;
margin-left: 12px;
}
.link {
color: #409eff;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
:deep(.el-divider__text) {
font-size: 14px;
color: #606266;
}
}
</style>

View File

@@ -183,6 +183,11 @@ import {
type CozeSession,
type StreamEvent
} from '@/api/coze'
import {
SpeechRecognitionManager,
isSpeechRecognitionSupported,
type SpeechRecognitionResult
} from '@/utils/speechRecognition'
const router = useRouter()
@@ -205,6 +210,11 @@ const voiceStatusText = ref('点击开始按钮进行语音陪练')
const mediaRecorder = ref<MediaRecorder | null>(null)
const audioChunks = ref<Blob[]>([])
// 语音识别相关
const speechRecognition = ref<SpeechRecognitionManager | null>(null)
const recognizedText = ref('')
const isSpeechSupported = isSpeechRecognitionSupported()
// DOM引用
const messageContainer = ref<HTMLElement>()
@@ -380,9 +390,21 @@ const toggleRecording = async () => {
}
/**
* 开始录音
* 开始录音(同时启动语音识别)
*/
const startRecording = async () => {
if (!cozeSession.value) {
ElMessage.warning('请先开始陪练会话')
return
}
// 优先使用 Web Speech API 进行实时语音识别
if (isSpeechSupported) {
startSpeechRecognition()
return
}
// 降级到录音模式(需要后端语音识别服务)
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
@@ -400,7 +422,7 @@ const startRecording = async () => {
mediaRecorder.value.start()
isRecording.value = true
voiceStatusText.value = '正在录音...'
voiceStatusText.value = '正在录音(浏览器不支持实时识别,录音结束后将发送到服务器识别)...'
} catch (error) {
ElMessage.error('无法访问麦克风')
}
@@ -410,6 +432,13 @@ const startRecording = async () => {
* 停止录音
*/
const stopRecording = () => {
// 如果使用的是 Web Speech API
if (speechRecognition.value) {
stopSpeechRecognition()
return
}
// 如果使用的是录音模式
if (mediaRecorder.value && isRecording.value) {
mediaRecorder.value.stop()
mediaRecorder.value.stream.getTracks().forEach(track => track.stop())
@@ -420,13 +449,116 @@ const stopRecording = () => {
}
/**
* 处理录音
* 处理录音(使用 Web Speech API 已识别的文本)
*/
const processAudio = async (_audioBlob: Blob) => {
// TODO: 实现音频转文本和发送逻辑
isProcessing.value = false
voiceStatusText.value = '点击开始按钮进行语音陪练'
ElMessage.info('语音功能正在开发中')
try {
// 检查是否有识别结果
const text = recognizedText.value.trim()
if (!text) {
ElMessage.warning('没有检测到语音内容')
return
}
// 清空识别结果
recognizedText.value = ''
// 发送识别的文本消息
if (cozeSession.value) {
// 添加用户消息
messages.value.push({
role: 'user',
content: text,
timestamp: new Date()
})
await scrollToBottom()
isLoading.value = true
// 创建AI回复消息占位
const assistantMessage = {
role: 'assistant',
content: '',
timestamp: new Date()
}
messages.value.push(assistantMessage)
// 流式发送消息
await sendCozeMessage(
cozeSession.value.sessionId,
text,
(event: StreamEvent) => {
if (event.type === 'message.delta') {
assistantMessage.content += event.content
scrollToBottom()
} else if (event.type === 'message.completed') {
isLoading.value = false
}
}
)
}
} catch (error: any) {
ElMessage.error('发送消息失败:' + (error.message || '未知错误'))
} finally {
isProcessing.value = false
voiceStatusText.value = '点击开始按钮进行语音陪练'
}
}
/**
* 开始语音识别
*/
const startSpeechRecognition = () => {
if (!isSpeechSupported) {
ElMessage.warning('您的浏览器不支持语音识别,请使用 Chrome 或 Edge 浏览器')
return
}
// 创建语音识别管理器
speechRecognition.value = new SpeechRecognitionManager({
continuous: true,
interimResults: true,
lang: 'zh-CN'
})
speechRecognition.value.setCallbacks({
onResult: (result: SpeechRecognitionResult) => {
recognizedText.value = result.transcript
voiceStatusText.value = result.isFinal
? `识别结果: ${result.transcript}`
: `正在识别: ${result.transcript}`
},
onError: (error: string) => {
ElMessage.error(error)
stopSpeechRecognition()
},
onStart: () => {
isRecording.value = true
voiceStatusText.value = '正在监听,请说话...'
},
onEnd: () => {
// 识别结束后自动处理
if (recognizedText.value.trim()) {
processAudio(new Blob())
} else {
isRecording.value = false
voiceStatusText.value = '点击开始按钮进行语音陪练'
}
}
})
speechRecognition.value.start()
}
/**
* 停止语音识别
*/
const stopSpeechRecognition = () => {
if (speechRecognition.value) {
speechRecognition.value.stop()
speechRecognition.value = null
}
isRecording.value = false
}
/**
@@ -456,6 +588,10 @@ onUnmounted(() => {
if (mediaRecorder.value && isRecording.value) {
stopRecording()
}
if (speechRecognition.value) {
speechRecognition.value.destroy()
speechRecognition.value = null
}
})
</script>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,401 +1,401 @@
<template>
<div class="duo-practice-page">
<div class="page-header">
<h1>双人对练</h1>
<p class="subtitle">与伙伴一起进行角色扮演对练提升实战能力</p>
</div>
<div class="main-content">
<!-- 创建/加入房间卡片 -->
<div class="action-cards">
<!-- 创建房间 -->
<div class="action-card create-card" @click="showCreateDialog = true">
<div class="card-icon">
<el-icon :size="48"><Plus /></el-icon>
</div>
<h3>创建房间</h3>
<p>创建对练房间邀请伙伴加入</p>
</div>
<!-- 加入房间 -->
<div class="action-card join-card" @click="showJoinDialog = true">
<div class="card-icon">
<el-icon :size="48"><Connection /></el-icon>
</div>
<h3>加入房间</h3>
<p>输入房间码加入对练</p>
</div>
</div>
<!-- 我的房间列表 -->
<div class="my-rooms" v-if="myRooms.length > 0">
<h2>我的对练记录</h2>
<div class="room-list">
<div
v-for="room in myRooms"
:key="room.id"
class="room-item"
@click="enterRoom(room)"
>
<div class="room-info">
<span class="room-name">{{ room.room_name || room.scene_name || '双人对练' }}</span>
<span class="room-code">{{ room.room_code }}</span>
</div>
<div class="room-meta">
<el-tag :type="getStatusType(room.status)" size="small">
{{ getStatusText(room.status) }}
</el-tag>
<span class="room-time">{{ formatTime(room.created_at) }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 创建房间对话框 -->
<el-dialog
v-model="showCreateDialog"
title="创建对练房间"
width="500px"
:close-on-click-modal="false"
>
<el-form :model="createForm" label-width="100px">
<el-form-item label="场景名称">
<el-input v-model="createForm.scene_name" placeholder="如:销售场景对练" />
</el-form-item>
<el-form-item label="角色A名称">
<el-input v-model="createForm.role_a_name" placeholder="如:销售顾问" />
</el-form-item>
<el-form-item label="角色B名称">
<el-input v-model="createForm.role_b_name" placeholder="如:顾客" />
</el-form-item>
<el-form-item label="我扮演">
<el-radio-group v-model="createForm.host_role">
<el-radio label="A">{{ createForm.role_a_name || '角色A' }}</el-radio>
<el-radio label="B">{{ createForm.role_b_name || '角色B' }}</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="场景背景">
<el-input
v-model="createForm.scene_background"
type="textarea"
:rows="3"
placeholder="描述对练场景的背景信息(可选)"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showCreateDialog = false">取消</el-button>
<el-button type="primary" @click="handleCreateRoom" :loading="isCreating">
创建房间
</el-button>
</template>
</el-dialog>
<!-- 加入房间对话框 -->
<el-dialog
v-model="showJoinDialog"
title="加入对练房间"
width="400px"
:close-on-click-modal="false"
>
<el-form>
<el-form-item label="房间码">
<el-input
v-model="joinRoomCode"
placeholder="请输入6位房间码"
maxlength="6"
style="font-size: 24px; text-align: center; letter-spacing: 8px;"
@keyup.enter="handleJoinRoom"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showJoinDialog = false">取消</el-button>
<el-button type="primary" @click="handleJoinRoom" :loading="isJoining">
加入房间
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Plus, Connection } from '@element-plus/icons-vue'
import { useDuoPracticeStore } from '@/stores/duoPracticeStore'
import { getMyRooms, type RoomListItem } from '@/api/duoPractice'
const router = useRouter()
const store = useDuoPracticeStore()
// 状态
const showCreateDialog = ref(false)
const showJoinDialog = ref(false)
const isCreating = ref(false)
const isJoining = ref(false)
const joinRoomCode = ref('')
const myRooms = ref<RoomListItem[]>([])
// 创建表单
const createForm = ref({
scene_name: '',
role_a_name: '销售顾问',
role_b_name: '顾客',
host_role: 'A' as 'A' | 'B',
scene_background: ''
})
// 加载我的房间列表
const loadMyRooms = async () => {
try {
const res: any = await getMyRooms()
if (res.code === 200) {
myRooms.value = res.data.rooms
}
} catch (error) {
console.error('加载房间列表失败:', error)
}
}
// 创建房间
const handleCreateRoom = async () => {
if (!createForm.value.scene_name) {
ElMessage.warning('请输入场景名称')
return
}
isCreating.value = true
try {
await store.createRoom(createForm.value)
showCreateDialog.value = false
// 跳转到房间页面
router.push(`/trainee/duo-practice/room/${store.roomCode}`)
} catch (error) {
// 错误已在 store 中处理
} finally {
isCreating.value = false
}
}
// 加入房间
const handleJoinRoom = async () => {
if (!joinRoomCode.value || joinRoomCode.value.length < 6) {
ElMessage.warning('请输入6位房间码')
return
}
isJoining.value = true
try {
await store.joinRoom(joinRoomCode.value)
showJoinDialog.value = false
// 跳转到房间页面
router.push(`/trainee/duo-practice/room/${store.roomCode}`)
} catch (error) {
// 错误已在 store 中处理
} finally {
isJoining.value = false
}
}
// 进入房间
const enterRoom = (room: RoomListItem) => {
router.push(`/trainee/duo-practice/room/${room.room_code}`)
}
// 获取状态标签类型
const getStatusType = (status: string) => {
const map: Record<string, string> = {
'waiting': 'warning',
'ready': 'info',
'practicing': 'success',
'completed': '',
'canceled': 'danger'
}
return map[status] || ''
}
// 获取状态文本
const getStatusText = (status: string) => {
const map: Record<string, string> = {
'waiting': '等待加入',
'ready': '准备就绪',
'practicing': '对练中',
'completed': '已完成',
'canceled': '已取消'
}
return map[status] || status
}
// 格式化时间
const formatTime = (time?: string) => {
if (!time) return ''
const date = new Date(time)
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
onMounted(() => {
loadMyRooms()
})
</script>
<style scoped lang="scss">
.duo-practice-page {
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
.page-header {
text-align: center;
margin-bottom: 40px;
h1 {
font-size: 28px;
font-weight: 600;
margin-bottom: 8px;
}
.subtitle {
color: #666;
font-size: 16px;
}
}
.action-cards {
display: flex;
gap: 24px;
justify-content: center;
margin-bottom: 48px;
.action-card {
width: 280px;
padding: 40px 24px;
background: #fff;
border-radius: 16px;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
border: 2px solid transparent;
&:hover {
transform: translateY(-4px);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.1);
}
&.create-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
.card-icon {
background: rgba(255, 255, 255, 0.2);
}
p {
color: rgba(255, 255, 255, 0.8);
}
}
&.join-card {
border-color: #667eea;
.card-icon {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
}
&:hover {
border-color: #764ba2;
}
}
.card-icon {
width: 80px;
height: 80px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 20px;
}
h3 {
font-size: 20px;
font-weight: 600;
margin-bottom: 8px;
}
p {
font-size: 14px;
color: #666;
}
}
}
.my-rooms {
h2 {
font-size: 20px;
font-weight: 600;
margin-bottom: 16px;
}
.room-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
}
.room-item {
background: #fff;
border-radius: 12px;
padding: 16px;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.room-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
.room-name {
font-weight: 500;
font-size: 16px;
}
.room-code {
font-family: monospace;
font-size: 14px;
color: #667eea;
background: #f0f2ff;
padding: 2px 8px;
border-radius: 4px;
}
}
.room-meta {
display: flex;
justify-content: space-between;
align-items: center;
.room-time {
font-size: 12px;
color: #999;
}
}
}
}
</style>
<template>
<div class="duo-practice-page">
<div class="page-header">
<h1>双人对练</h1>
<p class="subtitle">与伙伴一起进行角色扮演对练提升实战能力</p>
</div>
<div class="main-content">
<!-- 创建/加入房间卡片 -->
<div class="action-cards">
<!-- 创建房间 -->
<div class="action-card create-card" @click="showCreateDialog = true">
<div class="card-icon">
<el-icon :size="48"><Plus /></el-icon>
</div>
<h3>创建房间</h3>
<p>创建对练房间邀请伙伴加入</p>
</div>
<!-- 加入房间 -->
<div class="action-card join-card" @click="showJoinDialog = true">
<div class="card-icon">
<el-icon :size="48"><Connection /></el-icon>
</div>
<h3>加入房间</h3>
<p>输入房间码加入对练</p>
</div>
</div>
<!-- 我的房间列表 -->
<div class="my-rooms" v-if="myRooms.length > 0">
<h2>我的对练记录</h2>
<div class="room-list">
<div
v-for="room in myRooms"
:key="room.id"
class="room-item"
@click="enterRoom(room)"
>
<div class="room-info">
<span class="room-name">{{ room.room_name || room.scene_name || '双人对练' }}</span>
<span class="room-code">{{ room.room_code }}</span>
</div>
<div class="room-meta">
<el-tag :type="getStatusType(room.status)" size="small">
{{ getStatusText(room.status) }}
</el-tag>
<span class="room-time">{{ formatTime(room.created_at) }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 创建房间对话框 -->
<el-dialog
v-model="showCreateDialog"
title="创建对练房间"
width="500px"
:close-on-click-modal="false"
>
<el-form :model="createForm" label-width="100px">
<el-form-item label="场景名称">
<el-input v-model="createForm.scene_name" placeholder="如:销售场景对练" />
</el-form-item>
<el-form-item label="角色A名称">
<el-input v-model="createForm.role_a_name" placeholder="如:销售顾问" />
</el-form-item>
<el-form-item label="角色B名称">
<el-input v-model="createForm.role_b_name" placeholder="如:顾客" />
</el-form-item>
<el-form-item label="我扮演">
<el-radio-group v-model="createForm.host_role">
<el-radio label="A">{{ createForm.role_a_name || '角色A' }}</el-radio>
<el-radio label="B">{{ createForm.role_b_name || '角色B' }}</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="场景背景">
<el-input
v-model="createForm.scene_background"
type="textarea"
:rows="3"
placeholder="描述对练场景的背景信息(可选)"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showCreateDialog = false">取消</el-button>
<el-button type="primary" @click="handleCreateRoom" :loading="isCreating">
创建房间
</el-button>
</template>
</el-dialog>
<!-- 加入房间对话框 -->
<el-dialog
v-model="showJoinDialog"
title="加入对练房间"
width="400px"
:close-on-click-modal="false"
>
<el-form>
<el-form-item label="房间码">
<el-input
v-model="joinRoomCode"
placeholder="请输入6位房间码"
maxlength="6"
style="font-size: 24px; text-align: center; letter-spacing: 8px;"
@keyup.enter="handleJoinRoom"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showJoinDialog = false">取消</el-button>
<el-button type="primary" @click="handleJoinRoom" :loading="isJoining">
加入房间
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Plus, Connection } from '@element-plus/icons-vue'
import { useDuoPracticeStore } from '@/stores/duoPracticeStore'
import { getMyRooms, type RoomListItem } from '@/api/duoPractice'
const router = useRouter()
const store = useDuoPracticeStore()
// 状态
const showCreateDialog = ref(false)
const showJoinDialog = ref(false)
const isCreating = ref(false)
const isJoining = ref(false)
const joinRoomCode = ref('')
const myRooms = ref<RoomListItem[]>([])
// 创建表单
const createForm = ref({
scene_name: '',
role_a_name: '销售顾问',
role_b_name: '顾客',
host_role: 'A' as 'A' | 'B',
scene_background: ''
})
// 加载我的房间列表
const loadMyRooms = async () => {
try {
const res: any = await getMyRooms()
if (res.code === 200) {
myRooms.value = res.data.rooms
}
} catch (error) {
console.error('加载房间列表失败:', error)
}
}
// 创建房间
const handleCreateRoom = async () => {
if (!createForm.value.scene_name) {
ElMessage.warning('请输入场景名称')
return
}
isCreating.value = true
try {
await store.createRoom(createForm.value)
showCreateDialog.value = false
// 跳转到房间页面
router.push(`/trainee/duo-practice/room/${store.roomCode}`)
} catch (error) {
// 错误已在 store 中处理
} finally {
isCreating.value = false
}
}
// 加入房间
const handleJoinRoom = async () => {
if (!joinRoomCode.value || joinRoomCode.value.length < 6) {
ElMessage.warning('请输入6位房间码')
return
}
isJoining.value = true
try {
await store.joinRoom(joinRoomCode.value)
showJoinDialog.value = false
// 跳转到房间页面
router.push(`/trainee/duo-practice/room/${store.roomCode}`)
} catch (error) {
// 错误已在 store 中处理
} finally {
isJoining.value = false
}
}
// 进入房间
const enterRoom = (room: RoomListItem) => {
router.push(`/trainee/duo-practice/room/${room.room_code}`)
}
// 获取状态标签类型
const getStatusType = (status: string) => {
const map: Record<string, string> = {
'waiting': 'warning',
'ready': 'info',
'practicing': 'success',
'completed': '',
'canceled': 'danger'
}
return map[status] || ''
}
// 获取状态文本
const getStatusText = (status: string) => {
const map: Record<string, string> = {
'waiting': '等待加入',
'ready': '准备就绪',
'practicing': '对练中',
'completed': '已完成',
'canceled': '已取消'
}
return map[status] || status
}
// 格式化时间
const formatTime = (time?: string) => {
if (!time) return ''
const date = new Date(time)
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
onMounted(() => {
loadMyRooms()
})
</script>
<style scoped lang="scss">
.duo-practice-page {
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
.page-header {
text-align: center;
margin-bottom: 40px;
h1 {
font-size: 28px;
font-weight: 600;
margin-bottom: 8px;
}
.subtitle {
color: #666;
font-size: 16px;
}
}
.action-cards {
display: flex;
gap: 24px;
justify-content: center;
margin-bottom: 48px;
.action-card {
width: 280px;
padding: 40px 24px;
background: #fff;
border-radius: 16px;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
border: 2px solid transparent;
&:hover {
transform: translateY(-4px);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.1);
}
&.create-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
.card-icon {
background: rgba(255, 255, 255, 0.2);
}
p {
color: rgba(255, 255, 255, 0.8);
}
}
&.join-card {
border-color: #667eea;
.card-icon {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
}
&:hover {
border-color: #764ba2;
}
}
.card-icon {
width: 80px;
height: 80px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 20px;
}
h3 {
font-size: 20px;
font-weight: 600;
margin-bottom: 8px;
}
p {
font-size: 14px;
color: #666;
}
}
}
.my-rooms {
h2 {
font-size: 20px;
font-weight: 600;
margin-bottom: 16px;
}
.room-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
}
.room-item {
background: #fff;
border-radius: 12px;
padding: 16px;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.room-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
.room-name {
font-weight: 500;
font-size: 16px;
}
.room-code {
font-family: monospace;
font-size: 14px;
color: #667eea;
background: #f0f2ff;
padding: 2px 8px;
border-radius: 4px;
}
}
.room-meta {
display: flex;
justify-content: space-between;
align-items: center;
.room-time {
font-size: 12px;
color: #999;
}
}
}
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff