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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -175,4 +175,4 @@ export function getTeamDashboard() {
|
||||
*/
|
||||
export function getFullDashboardData() {
|
||||
return request.get<FullDashboardData>('/dashboard/all')
|
||||
}
|
||||
}
|
||||
@@ -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`)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
158
frontend/src/api/progress.ts
Normal file
158
frontend/src/api/progress.ts
Normal 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,
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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(() => {
|
||||
// 忽略导入错误
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
211
frontend/src/utils/permissionChecker.ts
Normal file
211
frontend/src/utils/permissionChecker.ts
Normal 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,
|
||||
}
|
||||
294
frontend/src/utils/speechRecognition.ts
Normal file
294
frontend/src/utils/speechRecognition.ts
Normal 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,
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user