Initial commit: AI Interview System
This commit is contained in:
BIN
frontend/dist.tar.gz
Normal file
BIN
frontend/dist.tar.gz
Normal file
Binary file not shown.
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AI 面试助手</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
37
frontend/package.json
Normal file
37
frontend/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "ai-interview-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.4.21",
|
||||
"vue-router": "^4.3.0",
|
||||
"pinia": "^2.1.7",
|
||||
"axios": "^1.6.7",
|
||||
"element-plus": "^2.5.6",
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"@volcengine/rtc": "^4.58.1",
|
||||
"@coze/realtime-api": "^1.0.0",
|
||||
"@coze/api": "^1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"@types/node": "^20.11.24",
|
||||
"typescript": "^5.4.2",
|
||||
"vite": "^5.1.5",
|
||||
"vue-tsc": "^2.0.6",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"postcss": "^8.4.35",
|
||||
"autoprefixer": "^10.4.18",
|
||||
"eslint": "^8.57.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.1.0",
|
||||
"@typescript-eslint/parser": "^7.1.0",
|
||||
"eslint-plugin-vue": "^9.22.0"
|
||||
}
|
||||
}
|
||||
3195
frontend/pnpm-lock.yaml
generated
Normal file
3195
frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
10
frontend/src/App.vue
Normal file
10
frontend/src/App.vue
Normal file
@@ -0,0 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
178
frontend/src/api/candidate.ts
Normal file
178
frontend/src/api/candidate.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { request } from './request'
|
||||
|
||||
// 类型定义
|
||||
export interface InitInterviewResponse {
|
||||
sessionId: string
|
||||
name: string
|
||||
debugUrl?: string
|
||||
workflowResponse?: unknown
|
||||
}
|
||||
|
||||
export interface SubmitCandidateResponse {
|
||||
sessionId: string
|
||||
fileId: string
|
||||
}
|
||||
|
||||
export interface CreateRoomRequest {
|
||||
sessionId?: string
|
||||
fileId?: string
|
||||
}
|
||||
|
||||
export interface CreateRoomResponse {
|
||||
roomId: string
|
||||
token: string
|
||||
appId: string
|
||||
userId: string
|
||||
sessionId?: string // 后端生成的会话ID
|
||||
}
|
||||
|
||||
export interface ChatRequest {
|
||||
sessionId: string
|
||||
message: string
|
||||
conversationId?: string
|
||||
}
|
||||
|
||||
export interface ChatResponse {
|
||||
reply: string
|
||||
conversationId: string
|
||||
debugInfo?: {
|
||||
status_history?: Array<{
|
||||
iteration: number
|
||||
status: string
|
||||
required_action?: any
|
||||
}>
|
||||
messages?: Array<{
|
||||
role: string
|
||||
type: string
|
||||
content: string
|
||||
}>
|
||||
raw_responses?: any[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface Candidate {
|
||||
sessionId: string
|
||||
name: string
|
||||
status: 'pending' | 'ongoing' | 'completed'
|
||||
score?: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface CandidateDetail extends Candidate {
|
||||
resume: string
|
||||
currentStage: number
|
||||
scores?: {
|
||||
salesSkill: number
|
||||
salesMindset: number
|
||||
quality: number
|
||||
motivation: number
|
||||
total: number
|
||||
}
|
||||
analysis?: string
|
||||
interviewLog?: string
|
||||
completedAt?: string
|
||||
}
|
||||
|
||||
export interface CandidateListResponse {
|
||||
list: Candidate[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export interface CandidateListParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
status?: string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
}
|
||||
|
||||
// API 方法
|
||||
export const candidateApi = {
|
||||
/**
|
||||
* 初始化面试(工作流 A)
|
||||
* 上传简历 + 执行初始化工作流 → 返回 sessionId
|
||||
*/
|
||||
initInterview(name: string, resumeFile: File) {
|
||||
const formData = new FormData()
|
||||
formData.append('name', name)
|
||||
formData.append('file', resumeFile)
|
||||
return request.upload<InitInterviewResponse>('/init-interview', formData)
|
||||
},
|
||||
|
||||
/**
|
||||
* 上传简历到 Coze
|
||||
*/
|
||||
uploadResume(file: File) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return request.upload<{ fileId: string }>('/upload', formData)
|
||||
},
|
||||
|
||||
/**
|
||||
* 提交候选人信息(上传简历)- 旧方法
|
||||
*/
|
||||
submit(name: string, resumeFile: File) {
|
||||
const formData = new FormData()
|
||||
formData.append('name', name)
|
||||
formData.append('resume', resumeFile)
|
||||
return request.upload<SubmitCandidateResponse>('/candidates', formData)
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建语音房间
|
||||
*/
|
||||
createRoom(data: CreateRoomRequest) {
|
||||
return request.post<CreateRoomResponse>('/rooms', data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 结束面试
|
||||
*/
|
||||
endInterview(sessionId: string) {
|
||||
return request.post<{ success: boolean }>(`/interviews/${sessionId}/end`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取候选人列表
|
||||
*/
|
||||
getList(params: CandidateListParams) {
|
||||
return request.get<CandidateListResponse>('/candidates', { params })
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取候选人详情
|
||||
*/
|
||||
getDetail(sessionId: string) {
|
||||
return request.get<CandidateDetail>(`/candidates/${sessionId}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 导出 PDF 报告
|
||||
*/
|
||||
exportPdf(sessionId: string) {
|
||||
return `/api/candidates/${sessionId}/export`
|
||||
},
|
||||
|
||||
/**
|
||||
* 文本对话(模拟语音)
|
||||
*/
|
||||
chat(data: ChatRequest) {
|
||||
return request.post<ChatResponse>('/chat', data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取 Coze Realtime SDK 配置
|
||||
* 用于直接连接 Coze 语音服务
|
||||
*/
|
||||
getCozeConfig() {
|
||||
return request.get<{
|
||||
accessToken: string
|
||||
botId: string
|
||||
voiceId: string
|
||||
connectorId: string
|
||||
}>('/coze-config')
|
||||
},
|
||||
}
|
||||
11
frontend/src/api/index.ts
Normal file
11
frontend/src/api/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export { request } from './request'
|
||||
export { candidateApi } from './candidate'
|
||||
export type {
|
||||
SubmitCandidateResponse,
|
||||
CreateRoomRequest,
|
||||
CreateRoomResponse,
|
||||
Candidate,
|
||||
CandidateDetail,
|
||||
CandidateListResponse,
|
||||
CandidateListParams,
|
||||
} from './candidate'
|
||||
86
frontend/src/api/request.ts
Normal file
86
frontend/src/api/request.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import axios from 'axios'
|
||||
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
// API 响应类型
|
||||
export interface ApiResponse<T = unknown> {
|
||||
code: number
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
// 创建 axios 实例
|
||||
const instance: AxiosInstance = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
|
||||
timeout: 120000, // 120秒,Coze API 可能需要较长时间
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// 请求拦截器
|
||||
instance.interceptors.request.use(
|
||||
(config) => {
|
||||
// 可以在这里添加 token
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 响应拦截器
|
||||
instance.interceptors.response.use(
|
||||
(response: AxiosResponse<ApiResponse>) => {
|
||||
const { data } = response
|
||||
|
||||
// 业务错误处理
|
||||
if (data.code !== 0) {
|
||||
ElMessage.error(data.message || '请求失败')
|
||||
return Promise.reject(new Error(data.message))
|
||||
}
|
||||
|
||||
return response
|
||||
},
|
||||
(error) => {
|
||||
// HTTP 错误处理
|
||||
const message = error.response?.data?.message || error.message || '网络错误'
|
||||
ElMessage.error(message)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 封装请求方法
|
||||
export const request = {
|
||||
get<T>(url: string, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
||||
return instance.get(url, config).then((res) => res.data)
|
||||
},
|
||||
|
||||
post<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
||||
return instance.post(url, data, config).then((res) => res.data)
|
||||
},
|
||||
|
||||
put<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
||||
return instance.put(url, data, config).then((res) => res.data)
|
||||
},
|
||||
|
||||
delete<T>(url: string, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
||||
return instance.delete(url, config).then((res) => res.data)
|
||||
},
|
||||
|
||||
// 上传文件
|
||||
upload<T>(url: string, formData: FormData, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
||||
return instance.post(url, formData, {
|
||||
...config,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
}).then((res) => res.data)
|
||||
},
|
||||
}
|
||||
|
||||
export default instance
|
||||
1
frontend/src/composables/index.ts
Normal file
1
frontend/src/composables/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { useRTC } from './useRTC'
|
||||
749
frontend/src/composables/useCozeRealtime.ts
Normal file
749
frontend/src/composables/useCozeRealtime.ts
Normal file
@@ -0,0 +1,749 @@
|
||||
/**
|
||||
* Coze Realtime 语音面试 Hook
|
||||
*
|
||||
* 基于 Coze 官方 SDK (@coze/realtime-api) 实现语音面试
|
||||
*
|
||||
* 架构说明:
|
||||
* 1. 使用 @coze/realtime-api SDK 直接连接
|
||||
* 2. 在 bot.join 后通过 sendMessage 发送 session.update 信令传递 session_id
|
||||
*
|
||||
* 参考文档:
|
||||
* - https://www.coze.cn/open/docs/dev_how_to_guides/Realtime_web
|
||||
* - https://www.coze.cn/open/docs/developer_guides/signaling_uplink_event
|
||||
* - https://github.com/coze-dev/coze-js/blob/main/examples/realtime-quickstart-react/src/App.tsx
|
||||
*/
|
||||
import { ref, computed } from 'vue'
|
||||
import type { RTCConnectionState } from '@/types'
|
||||
|
||||
// Coze Realtime 连接参数
|
||||
interface CozeRealtimeParams {
|
||||
accessToken: string // PAT Token
|
||||
botId: string // 智能体 ID
|
||||
sessionId: string // 面试会话 ID(关键:需要传递给工作流)
|
||||
voiceId?: string // 音色 ID(可选)
|
||||
}
|
||||
|
||||
// 音频配置(根据 Coze 官方文档 session.update 事件参数)
|
||||
const AUDIO_CONFIG = {
|
||||
// VAD(语音活动检测)配置
|
||||
// 注意:silence_duration_ms 取值范围 200~2000,默认 500
|
||||
vad: {
|
||||
silenceDurationMs: 800, // 静音持续 0.8 秒判定为说话结束(降低延迟)
|
||||
prefixPaddingMs: 300, // 前置填充 300ms(防止开头被截断)
|
||||
},
|
||||
// 打断配置
|
||||
allowVoiceInterrupt: false, // 禁止语音打断 AI
|
||||
}
|
||||
|
||||
// 本地 VAD 配置
|
||||
const LOCAL_VAD_CONFIG = {
|
||||
enabled: true, // 启用本地 VAD
|
||||
volumeThreshold: 0.15, // 音量阈值(0-1),15% 过滤环境噪音和回声
|
||||
silenceTimeout: 800, // 静音超时(毫秒),持续静音后触发 commit
|
||||
minSpeechDuration: 300, // 最小说话时长(毫秒),防止误触发
|
||||
checkInterval: 50, // 检测间隔(毫秒)
|
||||
}
|
||||
|
||||
// 信令事件名称(来自 @coze/realtime-api EventNames)
|
||||
// 注意:实际使用时从 SDK 动态导入,这里仅作为类型参考
|
||||
const _EventNamesRef = {
|
||||
ALL: 'realtime.event',
|
||||
ALL_CLIENT: 'client.*',
|
||||
ALL_SERVER: 'server.*',
|
||||
CONNECTED: 'client.connected',
|
||||
INTERRUPTED: 'client.interrupted',
|
||||
DISCONNECTED: 'client.disconnected',
|
||||
AUDIO_UNMUTED: 'client.audio.unmuted',
|
||||
AUDIO_MUTED: 'client.audio.muted',
|
||||
ERROR: 'client.error',
|
||||
}
|
||||
void _EventNamesRef // 避免 unused 警告
|
||||
|
||||
/**
|
||||
* Coze Realtime Hook
|
||||
* 使用 Coze 官方 SDK 进行语音面试
|
||||
*/
|
||||
export function useCozeRealtime() {
|
||||
const connectionState = ref<RTCConnectionState>('disconnected')
|
||||
const isMuted = ref(false)
|
||||
const sessionId = ref('')
|
||||
const isSessionUpdateSent = ref(false)
|
||||
const lastError = ref<string | null>(null)
|
||||
|
||||
// 通讯时间线(用于调试显示)
|
||||
interface TimelineEvent {
|
||||
time: string // HH:MM:SS.mmm 格式
|
||||
type: 'send' | 'receive' | 'audio' | 'system' | 'error'
|
||||
event: string // 事件名
|
||||
detail?: string // 详情
|
||||
}
|
||||
const timeline = ref<TimelineEvent[]>([])
|
||||
|
||||
// 添加时间线事件
|
||||
function addTimelineEvent(type: TimelineEvent['type'], event: string, detail?: string) {
|
||||
const now = new Date()
|
||||
const time = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}.${String(now.getMilliseconds()).padStart(3, '0')}`
|
||||
timeline.value.push({ time, type, event, detail })
|
||||
// 保留最近 50 条
|
||||
if (timeline.value.length > 50) {
|
||||
timeline.value.shift()
|
||||
}
|
||||
}
|
||||
|
||||
// 调试信息
|
||||
const debugInfo = ref<{
|
||||
rtcConnected: boolean
|
||||
sessionUpdateSent: boolean
|
||||
sessionId: string
|
||||
botJoined: boolean
|
||||
eventsSent: string[]
|
||||
eventsReceived: string[]
|
||||
errors: string[]
|
||||
}>({
|
||||
rtcConnected: false,
|
||||
sessionUpdateSent: false,
|
||||
sessionId: '',
|
||||
botJoined: false,
|
||||
eventsSent: [],
|
||||
eventsReceived: [],
|
||||
errors: [],
|
||||
})
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let client: any = null
|
||||
|
||||
// ============ 本地 VAD 相关 ============
|
||||
const isSpeaking = ref(false) // 用户是否正在说话
|
||||
const isAISpeaking = ref(false) // AI 是否正在说话(用于暂停收音)
|
||||
const currentVolume = ref(0) // 当前音量(0-1)
|
||||
let audioContext: AudioContext | null = null
|
||||
let analyser: AnalyserNode | null = null
|
||||
let mediaStream: MediaStream | null = null
|
||||
let vadCheckInterval: number | null = null
|
||||
let silenceStartTime: number | null = null
|
||||
let speechStartTime: number | null = null
|
||||
let hasCommittedThisTurn = false // 本轮是否已提交
|
||||
|
||||
/**
|
||||
* 启动本地 VAD 监测
|
||||
*/
|
||||
async function startLocalVAD() {
|
||||
if (!LOCAL_VAD_CONFIG.enabled) return
|
||||
|
||||
try {
|
||||
// 获取麦克风音频流(开启回声消除和噪音抑制)
|
||||
mediaStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true, // 回声消除(消除扬声器声音)
|
||||
noiseSuppression: true, // 噪音抑制
|
||||
autoGainControl: true, // 自动增益控制
|
||||
}
|
||||
})
|
||||
audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()
|
||||
analyser = audioContext.createAnalyser()
|
||||
analyser.fftSize = 512
|
||||
analyser.smoothingTimeConstant = 0.4 // 提高平滑度,减少抖动
|
||||
|
||||
const source = audioContext.createMediaStreamSource(mediaStream)
|
||||
source.connect(analyser)
|
||||
|
||||
const dataArray = new Uint8Array(analyser.frequencyBinCount)
|
||||
|
||||
// 定期检测音量
|
||||
vadCheckInterval = window.setInterval(() => {
|
||||
if (!analyser || isMuted.value) return
|
||||
|
||||
analyser.getByteFrequencyData(dataArray)
|
||||
|
||||
// 计算平均音量(归一化到 0-1)
|
||||
const sum = dataArray.reduce((a, b) => a + b, 0)
|
||||
const avg = sum / dataArray.length / 255
|
||||
currentVolume.value = avg
|
||||
|
||||
// 注意:不再基于 isAISpeaking 暂停收音
|
||||
// 依赖浏览器的回声消除 + 较高的音量阈值来过滤 AI 声音
|
||||
|
||||
const now = Date.now()
|
||||
|
||||
if (avg > LOCAL_VAD_CONFIG.volumeThreshold) {
|
||||
// 检测到声音
|
||||
if (!isSpeaking.value) {
|
||||
isSpeaking.value = true
|
||||
speechStartTime = now
|
||||
silenceStartTime = null
|
||||
hasCommittedThisTurn = false
|
||||
addTimelineEvent('audio', '🎤 检测到说话', `音量: ${(avg * 100).toFixed(1)}%`)
|
||||
console.log(`🎤 [LocalVAD] 检测到说话, 音量: ${(avg * 100).toFixed(1)}%`)
|
||||
}
|
||||
silenceStartTime = null
|
||||
} else if (isSpeaking.value) {
|
||||
// 正在说话但当前静音
|
||||
if (!silenceStartTime) {
|
||||
silenceStartTime = now
|
||||
}
|
||||
|
||||
const silenceDuration = now - silenceStartTime
|
||||
const speechDuration = speechStartTime ? now - speechStartTime : 0
|
||||
|
||||
// 检查是否满足提交条件
|
||||
if (silenceDuration >= LOCAL_VAD_CONFIG.silenceTimeout &&
|
||||
speechDuration >= LOCAL_VAD_CONFIG.minSpeechDuration &&
|
||||
!hasCommittedThisTurn) {
|
||||
// 静音超时,触发提交
|
||||
isSpeaking.value = false
|
||||
hasCommittedThisTurn = true
|
||||
addTimelineEvent('audio', '🔇 说话结束', `静音 ${silenceDuration}ms`)
|
||||
console.log(`🔇 [LocalVAD] 说话结束, 静音 ${silenceDuration}ms, 自动提交`)
|
||||
|
||||
// 自动发送 commit
|
||||
commitAudioInputInternal()
|
||||
}
|
||||
}
|
||||
}, LOCAL_VAD_CONFIG.checkInterval)
|
||||
|
||||
addTimelineEvent('system', '🎙️ 本地VAD启动', `阈值: ${LOCAL_VAD_CONFIG.volumeThreshold}, 静音: ${LOCAL_VAD_CONFIG.silenceTimeout}ms`)
|
||||
console.log('✅ [LocalVAD] 本地 VAD 已启动')
|
||||
} catch (error) {
|
||||
console.error('❌ [LocalVAD] 启动失败:', error)
|
||||
addTimelineEvent('error', '❌ VAD启动失败', String(error))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止本地 VAD 监测
|
||||
*/
|
||||
function stopLocalVAD() {
|
||||
if (vadCheckInterval) {
|
||||
clearInterval(vadCheckInterval)
|
||||
vadCheckInterval = null
|
||||
}
|
||||
if (mediaStream) {
|
||||
mediaStream.getTracks().forEach(track => track.stop())
|
||||
mediaStream = null
|
||||
}
|
||||
if (audioContext) {
|
||||
audioContext.close()
|
||||
audioContext = null
|
||||
}
|
||||
analyser = null
|
||||
isSpeaking.value = false
|
||||
currentVolume.value = 0
|
||||
console.log('🛑 [LocalVAD] 已停止')
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部提交函数(供 VAD 调用)
|
||||
*/
|
||||
function commitAudioInputInternal() {
|
||||
if (!client) return false
|
||||
|
||||
const commitEvent = {
|
||||
id: `evt_${Date.now()}_commit`,
|
||||
event_type: 'input_audio_buffer.commit',
|
||||
}
|
||||
|
||||
try {
|
||||
client.sendMessage(commitEvent)
|
||||
addTimelineEvent('send', '📤 语音已提交', '本地VAD触发')
|
||||
console.log('📤 [LocalVAD] 已发送 commit')
|
||||
debugInfo.value.eventsSent.push('input_audio_buffer.commit (local_vad)')
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('❌ [LocalVAD] commit 失败:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接到 Coze 语音房间
|
||||
*
|
||||
* 使用 @coze/realtime-api SDK 连接
|
||||
*/
|
||||
async function connect(params: CozeRealtimeParams) {
|
||||
try {
|
||||
connectionState.value = 'connecting'
|
||||
lastError.value = null
|
||||
sessionId.value = params.sessionId
|
||||
|
||||
// 更新调试信息
|
||||
debugInfo.value.sessionId = params.sessionId
|
||||
debugInfo.value.eventsSent = []
|
||||
debugInfo.value.eventsReceived = []
|
||||
debugInfo.value.errors = []
|
||||
|
||||
// 清空时间线
|
||||
timeline.value = []
|
||||
addTimelineEvent('system', '🚀 开始连接', `session: ${params.sessionId.slice(-8)}`)
|
||||
|
||||
console.log('=== Coze Realtime: Starting connection ===')
|
||||
console.log('Session ID:', params.sessionId)
|
||||
console.log('Bot ID:', params.botId)
|
||||
|
||||
// 1. 动态导入 Coze Realtime SDK
|
||||
console.log('Step 1: Loading @coze/realtime-api SDK...')
|
||||
const { RealtimeClient, RealtimeUtils, EventNames: SDKEventNames } = await import('@coze/realtime-api')
|
||||
|
||||
// 2. 检查设备权限
|
||||
console.log('Step 2: Checking device permission...')
|
||||
const permission = await RealtimeUtils.checkDevicePermission()
|
||||
if (!permission.audio) {
|
||||
throw new Error('需要麦克风访问权限。请在浏览器地址栏左侧的锁图标中授权。')
|
||||
}
|
||||
console.log('✅ Microphone permission granted')
|
||||
|
||||
// 3. 恢复音频上下文(解决浏览器自动播放限制)
|
||||
console.log('Step 3: Resuming AudioContext for autoplay...')
|
||||
try {
|
||||
// 创建并恢复音频上下文,确保音频可以播放
|
||||
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()
|
||||
if (audioContext.state === 'suspended') {
|
||||
await audioContext.resume()
|
||||
console.log('✅ AudioContext resumed')
|
||||
}
|
||||
} catch (audioErr) {
|
||||
console.warn('AudioContext resume failed:', audioErr)
|
||||
}
|
||||
|
||||
// 4. 初始化客户端(含音频配置)
|
||||
console.log('Step 4: Initializing RealtimeClient...')
|
||||
console.log('Audio config:', AUDIO_CONFIG)
|
||||
client = new RealtimeClient({
|
||||
accessToken: params.accessToken,
|
||||
botId: params.botId,
|
||||
connectorId: '1024', // 固定值
|
||||
voiceId: params.voiceId,
|
||||
allowPersonalAccessTokenInBrowser: true,
|
||||
debug: true,
|
||||
// 🔊 音频配置
|
||||
audioMutedDefault: false, // 默认不静音,启用音频播放
|
||||
suppressStationaryNoise: true, // 抑制静态噪音
|
||||
})
|
||||
|
||||
// 5. 配置事件监听
|
||||
console.log('Step 5: Setting up event listeners...')
|
||||
setupEventListeners(SDKEventNames, params.sessionId)
|
||||
|
||||
// 6. 建立连接
|
||||
console.log('Step 6: Connecting to Coze...')
|
||||
await client.connect()
|
||||
|
||||
// 7. 确保音频输出启用
|
||||
console.log('Step 7: Ensuring audio output is enabled...')
|
||||
try {
|
||||
// 尝试播放一个静音音频来解锁音频播放
|
||||
const silentAudio = new Audio('data:audio/wav;base64,UklGRigAAABXQVZFZm10IBIAAAABAAEARKwAAIhYAQACABAAAABkYXRhAgAAAAEA')
|
||||
silentAudio.volume = 0.01
|
||||
await silentAudio.play().catch(() => {})
|
||||
console.log('✅ Audio output unlocked')
|
||||
} catch (e) {
|
||||
console.warn('Audio unlock attempt:', e)
|
||||
}
|
||||
|
||||
debugInfo.value.rtcConnected = true
|
||||
connectionState.value = 'connected'
|
||||
console.log('=== Coze Realtime: Connected successfully ===')
|
||||
|
||||
// 8. 确保远程音频播放已启用
|
||||
console.log('Step 8: Enabling remote audio playback...')
|
||||
try {
|
||||
// 尝试启用远程音频播放(AI 的声音)
|
||||
if (typeof client.setRemoteAudioEnable === 'function') {
|
||||
await client.setRemoteAudioEnable(true)
|
||||
console.log('✅ Remote audio playback enabled via setRemoteAudioEnable')
|
||||
}
|
||||
// 尝试通过 RTC 引擎设置
|
||||
const rtcEngine = client.getRtcEngine?.()
|
||||
if (rtcEngine) {
|
||||
console.log('📻 RTC Engine available, checking audio settings...')
|
||||
if (typeof rtcEngine.setRemoteAudioPlaybackVolume === 'function') {
|
||||
rtcEngine.setRemoteAudioPlaybackVolume('*', 100)
|
||||
console.log('✅ Remote audio volume set to 100')
|
||||
}
|
||||
if (typeof rtcEngine.subscribeAllRemoteAudio === 'function') {
|
||||
rtcEngine.subscribeAllRemoteAudio()
|
||||
console.log('✅ Subscribed to all remote audio')
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Remote audio setup:', e)
|
||||
}
|
||||
|
||||
// 9. 启动本地 VAD(更快响应)
|
||||
console.log('Step 9: Starting local VAD...')
|
||||
await startLocalVAD()
|
||||
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
console.error('Coze Realtime connect error:', error)
|
||||
lastError.value = errorMessage
|
||||
debugInfo.value.errors.push(errorMessage)
|
||||
connectionState.value = 'failed'
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置事件监听器
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function setupEventListeners(SDKEventNames: any, sid: string) {
|
||||
if (!client) return
|
||||
|
||||
console.log('🎧 Setting up event listeners...')
|
||||
console.log('Available EventNames:', Object.keys(SDKEventNames))
|
||||
|
||||
addTimelineEvent('system', '🔌 连接建立', '开始监听事件')
|
||||
|
||||
// 监听所有服务端事件
|
||||
client.on(SDKEventNames.ALL_SERVER, (eventName: string, event: unknown) => {
|
||||
const timestamp = new Date().toLocaleTimeString()
|
||||
console.log(`📩 [${timestamp}] Server event: ${eventName}`, event)
|
||||
debugInfo.value.eventsReceived.push(`${eventName} @ ${timestamp}`)
|
||||
|
||||
// 当 Bot 加入房间后,发送 session.update 事件
|
||||
if (eventName === 'server.bot.join') {
|
||||
console.log('🤖 Bot joined! Sending session.update...')
|
||||
debugInfo.value.botJoined = true
|
||||
addTimelineEvent('receive', '🤖 Bot 加入', 'server.bot.join')
|
||||
sendSessionUpdate(sid)
|
||||
}
|
||||
|
||||
// 用户开始说话
|
||||
if (eventName === 'server.input_audio_buffer.speech_started') {
|
||||
addTimelineEvent('audio', '🎤 检测到说话', '用户开始说话')
|
||||
}
|
||||
|
||||
// 用户停止说话(VAD 触发)
|
||||
if (eventName === 'server.input_audio_buffer.speech_stopped') {
|
||||
addTimelineEvent('audio', '🔇 说话结束', 'VAD 检测到静音')
|
||||
}
|
||||
|
||||
// 语音提交到服务器
|
||||
if (eventName === 'server.input_audio_buffer.committed') {
|
||||
addTimelineEvent('send', '📤 语音已提交', '等待 AI 处理')
|
||||
}
|
||||
|
||||
// AI 开始处理/思考
|
||||
if (eventName === 'server.conversation.item.created') {
|
||||
addTimelineEvent('receive', '🧠 AI 开始处理', 'conversation.item.created')
|
||||
}
|
||||
|
||||
// AI 开始回复(流式)
|
||||
if (eventName === 'server.conversation.message.delta') {
|
||||
// 只记录第一次 delta(避免太多条目)
|
||||
const lastEvent = timeline.value[timeline.value.length - 1]
|
||||
if (!lastEvent || lastEvent.event !== '💬 AI 回复中') {
|
||||
isAISpeaking.value = true // 🔇 AI 开始说话,暂停用户收音
|
||||
addTimelineEvent('receive', '💬 AI 回复中', '开始接收语音流(暂停收音)')
|
||||
}
|
||||
}
|
||||
|
||||
// AI 回复完成
|
||||
if (eventName === 'server.conversation.message.completed') {
|
||||
addTimelineEvent('receive', '✅ AI 回复完成', 'message.completed')
|
||||
// 延迟后重置 AI 说话状态(用于 UI 显示)
|
||||
setTimeout(() => {
|
||||
if (isAISpeaking.value) {
|
||||
isAISpeaking.value = false
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
// AI 开始说话(用于 UI 显示,不影响收音)
|
||||
if (eventName === 'server.audio.agent.speech_started') {
|
||||
isAISpeaking.value = true
|
||||
addTimelineEvent('receive', '🔊 AI 开始说话', eventName)
|
||||
}
|
||||
|
||||
// AI 说话结束(用于 UI 显示)
|
||||
if (eventName === 'server.audio.agent.speech_stopped') {
|
||||
isAISpeaking.value = false
|
||||
addTimelineEvent('receive', '🔈 AI 说话结束', eventName)
|
||||
}
|
||||
|
||||
// 回合结束
|
||||
if (eventName === 'server.response.done') {
|
||||
isAISpeaking.value = false
|
||||
addTimelineEvent('receive', '🏁 回合结束', 'response.done')
|
||||
}
|
||||
|
||||
// 记录所有包含 audio 的事件(用于调试)
|
||||
if (eventName.toLowerCase().includes('audio')) {
|
||||
console.log(`🔊 [Audio Event] ${eventName}:`, event)
|
||||
}
|
||||
})
|
||||
|
||||
// 监听客户端事件
|
||||
client.on(SDKEventNames.CONNECTED, () => {
|
||||
console.log('✅ [Client] Connected to Coze Realtime')
|
||||
debugInfo.value.eventsReceived.push('client.connected')
|
||||
addTimelineEvent('system', '✅ 已连接', 'RTC 连接成功')
|
||||
})
|
||||
|
||||
client.on(SDKEventNames.DISCONNECTED, () => {
|
||||
console.log('❌ [Client] Disconnected from Coze Realtime')
|
||||
connectionState.value = 'disconnected'
|
||||
debugInfo.value.eventsReceived.push('client.disconnected')
|
||||
addTimelineEvent('system', '❌ 已断开', '连接断开')
|
||||
})
|
||||
|
||||
client.on(SDKEventNames.INTERRUPTED, () => {
|
||||
console.log('⚠️ [Client] Interrupted')
|
||||
debugInfo.value.eventsReceived.push('client.interrupted')
|
||||
addTimelineEvent('system', '⚠️ 被打断', 'AI 被用户打断')
|
||||
})
|
||||
|
||||
client.on(SDKEventNames.AUDIO_MUTED, () => {
|
||||
console.log('🔇 [Client] Audio muted')
|
||||
addTimelineEvent('audio', '🔇 麦克风关闭', '')
|
||||
})
|
||||
|
||||
client.on(SDKEventNames.AUDIO_UNMUTED, () => {
|
||||
console.log('🔊 [Client] Audio unmuted')
|
||||
addTimelineEvent('audio', '🔊 麦克风开启', '')
|
||||
})
|
||||
|
||||
client.on(SDKEventNames.ERROR, (error: unknown) => {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
console.error('❌ [Client] Error:', error)
|
||||
debugInfo.value.errors.push(errorMessage)
|
||||
addTimelineEvent('error', '❌ 错误', errorMessage)
|
||||
})
|
||||
|
||||
// 监听所有事件(调试用)
|
||||
client.on(SDKEventNames.ALL, (eventName: string, data: unknown) => {
|
||||
// 避免重复日志,只记录非 server/client 前缀的事件
|
||||
if (!eventName.startsWith('server.') && !eventName.startsWith('client.')) {
|
||||
console.log(`📬 [ALL] Event: ${eventName}`, data)
|
||||
}
|
||||
})
|
||||
|
||||
console.log('✅ Event listeners set up complete')
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 session.update 信令事件
|
||||
*
|
||||
* 根据 Coze 官方文档 signaling_uplink_event:
|
||||
* - 在 bot.join 后发送
|
||||
* - data.chat_config.parameters: 传递对话流自定义参数(如 session_id)
|
||||
* - data.chat_config.allow_voice_interrupt: 是否允许语音打断
|
||||
* - data.turn_detection: VAD 配置
|
||||
*
|
||||
* 注意:只发送一次,避免 Bot 重连时重复发送导致对话重置
|
||||
*/
|
||||
function sendSessionUpdate(sid: string) {
|
||||
if (!client || !sid) {
|
||||
console.warn('Cannot send session.update: client or sessionId is missing')
|
||||
return false
|
||||
}
|
||||
|
||||
// 防止重复发送(Bot 重连时会再次触发 bot.join 事件)
|
||||
if (isSessionUpdateSent.value) {
|
||||
console.log('⚠️ session.update already sent, skipping to prevent conversation reset')
|
||||
return true
|
||||
}
|
||||
|
||||
// 构造 session.update 事件(严格按照 Coze 文档格式)
|
||||
const event = {
|
||||
id: `evt_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
event_type: 'session.update',
|
||||
data: {
|
||||
// 会话配置
|
||||
chat_config: {
|
||||
// 传递对话流自定义参数
|
||||
parameters: {
|
||||
session_id: sid,
|
||||
},
|
||||
// 禁止语音打断 AI
|
||||
allow_voice_interrupt: AUDIO_CONFIG.allowVoiceInterrupt,
|
||||
},
|
||||
// VAD 声音检测配置
|
||||
turn_detection: {
|
||||
type: 'server_vad', // 服务端 VAD
|
||||
silence_duration_ms: AUDIO_CONFIG.vad.silenceDurationMs, // 静音 2 秒判定说完(范围 200~2000)
|
||||
prefix_padding_ms: AUDIO_CONFIG.vad.prefixPaddingMs, // 前置填充 600ms
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
console.log('📤 Sending session.update:', JSON.stringify(event, null, 2))
|
||||
|
||||
try {
|
||||
// 使用 sendMessage 发送上行信令
|
||||
client.sendMessage(event)
|
||||
console.log('✅ session.update sent successfully!')
|
||||
console.log(`📊 VAD: silence=${AUDIO_CONFIG.vad.silenceDurationMs}ms, prefix=${AUDIO_CONFIG.vad.prefixPaddingMs}ms`)
|
||||
console.log(`🔇 Voice interrupt: ${AUDIO_CONFIG.allowVoiceInterrupt ? 'enabled' : 'disabled'}`)
|
||||
debugInfo.value.eventsSent.push('session.update')
|
||||
isSessionUpdateSent.value = true
|
||||
debugInfo.value.sessionUpdateSent = true
|
||||
return true
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
console.error('❌ Failed to send session.update:', error)
|
||||
debugInfo.value.errors.push(`sendMessage failed: ${errorMessage}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开连接
|
||||
*/
|
||||
async function disconnect() {
|
||||
console.log('🔌 Disconnecting from Coze Realtime...')
|
||||
try {
|
||||
// 先停止本地 VAD
|
||||
stopLocalVAD()
|
||||
|
||||
if (client) {
|
||||
// 先清除事件监听器
|
||||
try {
|
||||
client.clearEventHandlers?.()
|
||||
} catch (e) {
|
||||
console.warn('clearEventHandlers failed:', e)
|
||||
}
|
||||
|
||||
// 断开连接(可能是异步的)
|
||||
try {
|
||||
await client.disconnect()
|
||||
} catch (e) {
|
||||
console.warn('disconnect call failed:', e)
|
||||
}
|
||||
|
||||
client = null
|
||||
}
|
||||
|
||||
connectionState.value = 'disconnected'
|
||||
isSessionUpdateSent.value = false
|
||||
debugInfo.value.rtcConnected = false
|
||||
debugInfo.value.sessionUpdateSent = false
|
||||
debugInfo.value.botJoined = false
|
||||
console.log('✅ Coze Realtime disconnected successfully')
|
||||
} catch (error) {
|
||||
console.error('❌ Disconnect error:', error)
|
||||
// 即使出错也要重置状态
|
||||
stopLocalVAD()
|
||||
connectionState.value = 'disconnected'
|
||||
client = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换静音
|
||||
*/
|
||||
async function toggleMute(mute: boolean) {
|
||||
if (client) {
|
||||
try {
|
||||
await client.setAudioEnable(!mute)
|
||||
isMuted.value = mute
|
||||
} catch (error) {
|
||||
console.error('Toggle mute error:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 打断 AI
|
||||
*/
|
||||
function interrupt() {
|
||||
if (client) {
|
||||
try {
|
||||
client.interrupt()
|
||||
} catch (error) {
|
||||
console.error('Interrupt error:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交当前语音输入(手动触发 VAD 结束)
|
||||
* 发送 input_audio_buffer.commit 信令告诉服务器用户已说完
|
||||
*/
|
||||
function commitAudioInput() {
|
||||
if (!client) {
|
||||
console.warn('Cannot commit audio: client is not connected')
|
||||
return false
|
||||
}
|
||||
|
||||
// 🛑 立刻停止录音状态(用户点击了"说完了")
|
||||
isSpeaking.value = false
|
||||
silenceStartTime = null
|
||||
speechStartTime = null
|
||||
hasCommittedThisTurn = true
|
||||
addTimelineEvent('send', '🛑 手动停止录音', '用户点击说完了')
|
||||
|
||||
// 方案 1: 发送 input_audio_buffer.commit 事件(OpenAI 兼容格式)
|
||||
const commitEvent = {
|
||||
id: `evt_${Date.now()}_commit`,
|
||||
event_type: 'input_audio_buffer.commit',
|
||||
}
|
||||
|
||||
console.log('📤 Sending input_audio_buffer.commit (手动触发 VAD 结束)...')
|
||||
|
||||
try {
|
||||
client.sendMessage(commitEvent)
|
||||
console.log('✅ Audio input committed!')
|
||||
addTimelineEvent('send', '📤 语音已提交', '手动触发')
|
||||
debugInfo.value.eventsSent.push('input_audio_buffer.commit')
|
||||
return true
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
console.error('❌ Failed to commit audio:', error)
|
||||
|
||||
// 方案 2: 如果 commit 不支持,尝试发送 conversation.item.create
|
||||
console.log('📤 Trying alternative: conversation.item.create...')
|
||||
try {
|
||||
const createEvent = {
|
||||
id: `evt_${Date.now()}_create`,
|
||||
event_type: 'conversation.item.create',
|
||||
data: {
|
||||
item: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'input_audio', audio: '' }]
|
||||
}
|
||||
}
|
||||
}
|
||||
client.sendMessage(createEvent)
|
||||
console.log('✅ Alternative event sent!')
|
||||
return true
|
||||
} catch (altError) {
|
||||
console.error('❌ Alternative also failed:', altError)
|
||||
debugInfo.value.errors.push(`commitAudio failed: ${errorMessage}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取调试信息摘要
|
||||
*/
|
||||
const debugSummary = computed(() => {
|
||||
return {
|
||||
...debugInfo.value,
|
||||
connectionState: connectionState.value,
|
||||
isMuted: isMuted.value,
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
connectionState,
|
||||
isMuted,
|
||||
sessionId,
|
||||
isSessionUpdateSent,
|
||||
lastError,
|
||||
debugInfo,
|
||||
debugSummary,
|
||||
timeline, // 通讯时间线
|
||||
// 本地 VAD 状态
|
||||
isSpeaking,
|
||||
isAISpeaking, // AI 是否正在说话
|
||||
currentVolume,
|
||||
connect,
|
||||
disconnect,
|
||||
toggleMute,
|
||||
interrupt,
|
||||
commitAudioInput, // 手动触发 VAD 结束(说完了)
|
||||
}
|
||||
}
|
||||
468
frontend/src/composables/useRTC.ts
Normal file
468
frontend/src/composables/useRTC.ts
Normal file
@@ -0,0 +1,468 @@
|
||||
import { ref } from 'vue'
|
||||
import type { RTCConnectionState } from '@/types'
|
||||
|
||||
// RTC 连接参数
|
||||
interface RTCConnectParams {
|
||||
appId: string
|
||||
roomId: string
|
||||
userId: string
|
||||
token: string
|
||||
sessionId?: string // 面试会话 ID,用于传递给工作流
|
||||
}
|
||||
|
||||
/**
|
||||
* 火山引擎 RTC Hook
|
||||
* 封装 RTC 连接、断开、静音、TTS 发送等操作
|
||||
*/
|
||||
export function useRTC() {
|
||||
const connectionState = ref<RTCConnectionState>('disconnected')
|
||||
const isMuted = ref(false)
|
||||
const isReceiveOnly = ref(false) // 仅接收模式
|
||||
const isTTSSending = ref(false) // TTS 正在发送
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let engine: any = null
|
||||
let audioContext: AudioContext | null = null
|
||||
let ttsDestination: MediaStreamAudioDestinationNode | null = null
|
||||
|
||||
/**
|
||||
* 初始化 TTS 音频上下文
|
||||
* 创建一个虚拟音频目标,用于捕获 TTS 输出
|
||||
*/
|
||||
function initTTSAudio() {
|
||||
if (!audioContext) {
|
||||
audioContext = new AudioContext()
|
||||
ttsDestination = audioContext.createMediaStreamDestination()
|
||||
console.log('TTS audio context initialized')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接 RTC 房间(TTS 模式:使用 TTS 模拟语音输入)
|
||||
*
|
||||
* 工作原理:
|
||||
* 1. 创建虚拟音频设备(MediaStreamAudioDestinationNode)
|
||||
* 2. TTS 输出连接到虚拟设备
|
||||
* 3. RTC 从虚拟设备采集音频发送
|
||||
*/
|
||||
async function connectTTSMode(params: RTCConnectParams) {
|
||||
try {
|
||||
connectionState.value = 'connecting'
|
||||
isReceiveOnly.value = false // TTS 模式可以发送
|
||||
|
||||
// 初始化 TTS 音频
|
||||
initTTSAudio()
|
||||
|
||||
// 动态导入 RTC SDK
|
||||
console.log('Loading RTC SDK (TTS mode)...')
|
||||
const VERTC = await import('@volcengine/rtc')
|
||||
|
||||
// 创建引擎
|
||||
console.log('Creating RTC engine with appId:', params.appId)
|
||||
engine = VERTC.default.createEngine(params.appId)
|
||||
|
||||
// 监听事件
|
||||
engine.on('onUserJoined', (event: { userId: string }) => {
|
||||
console.log('User joined:', event.userId)
|
||||
})
|
||||
|
||||
engine.on('onUserLeave', (event: { userId: string }) => {
|
||||
console.log('User left:', event.userId)
|
||||
})
|
||||
|
||||
engine.on('onError', (error: Error) => {
|
||||
console.error('RTC error:', error)
|
||||
connectionState.value = 'failed'
|
||||
})
|
||||
|
||||
engine.on('onRoomStateChanged', (event: { state: number; errorCode: number }) => {
|
||||
console.log('Room state changed:', event)
|
||||
})
|
||||
|
||||
// 监听远端音频(AI 的回复)
|
||||
engine.on('onRemoteAudioPropertiesReport', (event: any) => {
|
||||
if (event && event.length > 0) {
|
||||
console.log('AI is speaking:', event)
|
||||
}
|
||||
})
|
||||
|
||||
// 加入房间
|
||||
console.log('Joining room (TTS mode):', params.roomId)
|
||||
await engine.joinRoom(
|
||||
params.token,
|
||||
params.roomId,
|
||||
{
|
||||
userId: params.userId,
|
||||
},
|
||||
{
|
||||
isAutoPublish: true, // 自动发布(TTS 音频)
|
||||
isAutoSubscribeAudio: true, // 接收 AI 语音
|
||||
isAutoSubscribeVideo: false,
|
||||
}
|
||||
)
|
||||
|
||||
// 设置自定义音频轨道
|
||||
if (ttsDestination) {
|
||||
const stream = ttsDestination.stream
|
||||
const audioTrack = stream.getAudioTracks()[0]
|
||||
|
||||
if (audioTrack) {
|
||||
console.log('Setting custom audio track for TTS...')
|
||||
// 使用 setAudioTrack 或 replaceTrack API
|
||||
try {
|
||||
// 方法1: 尝试使用 setCustomizeAudioTrack
|
||||
if (engine.setCustomizeAudioTrack) {
|
||||
await engine.setCustomizeAudioTrack(audioTrack)
|
||||
console.log('Custom audio track set via setCustomizeAudioTrack')
|
||||
}
|
||||
// 方法2: 尝试使用外部音频采集
|
||||
else if (engine.setExternalAudioSource) {
|
||||
await engine.setExternalAudioSource(true, 48000, 1)
|
||||
console.log('External audio source enabled')
|
||||
}
|
||||
// 方法3: 开始采集但不使用默认设备
|
||||
else {
|
||||
console.log('Using default audio capture (TTS may not work)')
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to set custom audio track:', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 开始音频采集(使用静音或自定义源)
|
||||
try {
|
||||
await engine.startAudioCapture()
|
||||
console.log('Audio capture started')
|
||||
} catch (e) {
|
||||
console.warn('Audio capture failed (expected in TTS mode):', e)
|
||||
}
|
||||
|
||||
connectionState.value = 'connected'
|
||||
console.log('RTC connected successfully (TTS mode)')
|
||||
} catch (error: any) {
|
||||
console.error('RTC connect error:', error)
|
||||
connectionState.value = 'failed'
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 TTS 发送文字到 RTC 房间
|
||||
* 将文字转换为语音并通过 RTC 发送
|
||||
*/
|
||||
async function sendTTSMessage(text: string): Promise<void> {
|
||||
if (!audioContext || !ttsDestination) {
|
||||
console.error('TTS audio not initialized')
|
||||
return
|
||||
}
|
||||
|
||||
isTTSSending.value = true
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!window.speechSynthesis) {
|
||||
isTTSSending.value = false
|
||||
reject(new Error('浏览器不支持语音合成'))
|
||||
return
|
||||
}
|
||||
|
||||
const utterance = new SpeechSynthesisUtterance(text)
|
||||
utterance.lang = 'zh-CN'
|
||||
utterance.rate = 1.0
|
||||
utterance.pitch = 1.0
|
||||
utterance.volume = 1.0
|
||||
|
||||
// 选择中文语音
|
||||
const voices = window.speechSynthesis.getVoices()
|
||||
const chineseVoice = voices.find(v => v.lang.includes('zh'))
|
||||
if (chineseVoice) {
|
||||
utterance.voice = chineseVoice
|
||||
}
|
||||
|
||||
utterance.onend = () => {
|
||||
console.log('TTS finished:', text)
|
||||
isTTSSending.value = false
|
||||
resolve()
|
||||
}
|
||||
|
||||
utterance.onerror = (event) => {
|
||||
console.error('TTS error:', event)
|
||||
isTTSSending.value = false
|
||||
reject(new Error('语音合成失败'))
|
||||
}
|
||||
|
||||
console.log('TTS speaking:', text)
|
||||
window.speechSynthesis.speak(utterance)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接 RTC 房间(正常麦克风模式)
|
||||
*/
|
||||
async function connect(params: RTCConnectParams) {
|
||||
try {
|
||||
connectionState.value = 'connecting'
|
||||
isReceiveOnly.value = false
|
||||
|
||||
// 1. 先请求麦克风权限
|
||||
console.log('Requesting microphone permission...')
|
||||
await requestMicrophonePermission()
|
||||
|
||||
// 2. 动态导入 RTC SDK
|
||||
console.log('Loading RTC SDK...')
|
||||
const VERTC = await import('@volcengine/rtc')
|
||||
|
||||
// 3. 创建引擎
|
||||
console.log('Creating RTC engine with appId:', params.appId)
|
||||
engine = VERTC.default.createEngine(params.appId)
|
||||
|
||||
// 4. 监听事件
|
||||
engine.on('onUserJoined', (event: { userId: string }) => {
|
||||
console.log('User joined:', event.userId)
|
||||
})
|
||||
|
||||
engine.on('onUserLeave', (event: { userId: string }) => {
|
||||
console.log('User left:', event.userId)
|
||||
})
|
||||
|
||||
engine.on('onError', (error: Error) => {
|
||||
console.error('RTC error:', error)
|
||||
connectionState.value = 'failed'
|
||||
})
|
||||
|
||||
engine.on('onRoomStateChanged', (event: { state: number; errorCode: number }) => {
|
||||
console.log('Room state changed:', event)
|
||||
})
|
||||
|
||||
// 5. 加入房间
|
||||
console.log('Joining room:', params.roomId)
|
||||
await engine.joinRoom(
|
||||
params.token,
|
||||
params.roomId,
|
||||
{
|
||||
userId: params.userId,
|
||||
},
|
||||
{
|
||||
isAutoPublish: true,
|
||||
isAutoSubscribeAudio: true,
|
||||
isAutoSubscribeVideo: false,
|
||||
}
|
||||
)
|
||||
|
||||
// 6. 开始音频采集
|
||||
console.log('Starting audio capture...')
|
||||
await engine.startAudioCapture()
|
||||
|
||||
// 7. 发送 session.update 事件,传递 session_id 给工作流
|
||||
if (params.sessionId) {
|
||||
console.log('Sending session.update event with session_id:', params.sessionId)
|
||||
await sendSessionUpdate(engine, params.sessionId)
|
||||
}
|
||||
|
||||
connectionState.value = 'connected'
|
||||
console.log('RTC connected successfully')
|
||||
} catch (error: any) {
|
||||
console.error('RTC connect error:', error)
|
||||
connectionState.value = 'failed'
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开 RTC 连接
|
||||
*/
|
||||
async function disconnect() {
|
||||
try {
|
||||
if (engine) {
|
||||
try {
|
||||
await engine.stopAudioCapture()
|
||||
} catch (e) {
|
||||
console.warn('Stop audio capture failed:', e)
|
||||
}
|
||||
await engine.leaveRoom()
|
||||
engine.destroy()
|
||||
engine = null
|
||||
}
|
||||
|
||||
// 清理 TTS 音频上下文
|
||||
if (audioContext) {
|
||||
await audioContext.close()
|
||||
audioContext = null
|
||||
ttsDestination = null
|
||||
}
|
||||
|
||||
connectionState.value = 'disconnected'
|
||||
isReceiveOnly.value = false
|
||||
isTTSSending.value = false
|
||||
console.log('RTC disconnected')
|
||||
} catch (error) {
|
||||
console.error('RTC disconnect error:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换静音状态
|
||||
*/
|
||||
function toggleMute(mute: boolean) {
|
||||
if (engine) {
|
||||
if (mute) {
|
||||
engine.muteLocalAudio()
|
||||
} else {
|
||||
engine.unmuteLocalAudio()
|
||||
}
|
||||
isMuted.value = mute
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
connectionState,
|
||||
isMuted,
|
||||
isReceiveOnly,
|
||||
isTTSSending,
|
||||
connect,
|
||||
connectTTSMode,
|
||||
disconnect,
|
||||
toggleMute,
|
||||
sendTTSMessage,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 session.update 事件,将 session_id 传递给 Coze 工作流
|
||||
*
|
||||
* 在 Coze RTC 语音模式下,需要通过此事件传递自定义参数
|
||||
* 工作流可以通过入参获取 session_id
|
||||
*
|
||||
* 根据 Coze 文档,事件需要通过信令通道发送
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async function sendSessionUpdate(engine: any, sessionId: string): Promise<void> {
|
||||
try {
|
||||
// 构造 session.update 事件(Coze 标准格式)
|
||||
const eventData = {
|
||||
id: `event_${Date.now()}`,
|
||||
event_type: 'session.update',
|
||||
data: {
|
||||
chat_config: {
|
||||
parameters: {
|
||||
session_id: sessionId,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const eventJson = JSON.stringify(eventData)
|
||||
console.log('Sending session.update event:', eventData)
|
||||
|
||||
// 尝试多种方式发送事件到 Coze
|
||||
|
||||
// 方法1: sendStreamSyncInfo (火山引擎 RTC 流同步信息)
|
||||
if (engine.sendStreamSyncInfo) {
|
||||
try {
|
||||
const encoder = new TextEncoder()
|
||||
const data = encoder.encode(eventJson)
|
||||
await engine.sendStreamSyncInfo(data, { repeatCount: 3 })
|
||||
console.log('session.update sent via sendStreamSyncInfo')
|
||||
return
|
||||
} catch (e) {
|
||||
console.warn('sendStreamSyncInfo failed:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 方法2: sendSEIMessage (Supplemental Enhancement Information)
|
||||
if (engine.sendSEIMessage) {
|
||||
try {
|
||||
const encoder = new TextEncoder()
|
||||
const data = encoder.encode(eventJson)
|
||||
// streamIndex: 0 表示主流,1 表示屏幕流
|
||||
await engine.sendSEIMessage(0, data, { repeatCount: 3 })
|
||||
console.log('session.update sent via sendSEIMessage')
|
||||
return
|
||||
} catch (e) {
|
||||
console.warn('sendSEIMessage failed:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 方法3: sendPublicStreamSEIMessage
|
||||
if (engine.sendPublicStreamSEIMessage) {
|
||||
try {
|
||||
const encoder = new TextEncoder()
|
||||
const data = encoder.encode(eventJson)
|
||||
await engine.sendPublicStreamSEIMessage(data)
|
||||
console.log('session.update sent via sendPublicStreamSEIMessage')
|
||||
return
|
||||
} catch (e) {
|
||||
console.warn('sendPublicStreamSEIMessage failed:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 方法4: sendRoomMessage (房间消息)
|
||||
if (engine.sendRoomMessage) {
|
||||
try {
|
||||
await engine.sendRoomMessage(eventJson)
|
||||
console.log('session.update sent via sendRoomMessage')
|
||||
return
|
||||
} catch (e) {
|
||||
console.warn('sendRoomMessage failed:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 方法5: sendUserMessage (用户消息)
|
||||
if (engine.sendUserMessage) {
|
||||
try {
|
||||
// 发送给 AI Bot(Coze Bot 在房间中的用户 ID 通常以 bot_ 或 uid_ 开头)
|
||||
await engine.sendUserMessage('', eventJson) // 空字符串表示广播
|
||||
console.log('session.update sent via sendUserMessage')
|
||||
return
|
||||
} catch (e) {
|
||||
console.warn('sendUserMessage failed:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 方法6: 尝试直接调用 engine 上的其他方法
|
||||
console.log('Available engine methods:', Object.keys(engine).filter(k => typeof engine[k] === 'function'))
|
||||
|
||||
console.warn('No suitable method found to send session.update event. Coze may not receive the parameters.')
|
||||
} catch (error) {
|
||||
console.error('Failed to send session.update:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求麦克风权限
|
||||
*/
|
||||
async function requestMicrophonePermission(): Promise<boolean> {
|
||||
try {
|
||||
if (navigator.permissions) {
|
||||
const result = await navigator.permissions.query({ name: 'microphone' as PermissionName })
|
||||
console.log('Microphone permission status:', result.state)
|
||||
|
||||
if (result.state === 'denied') {
|
||||
throw new Error('麦克风权限被拒绝,请在浏览器设置中允许访问麦克风')
|
||||
}
|
||||
}
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
}
|
||||
})
|
||||
|
||||
stream.getTracks().forEach(track => track.stop())
|
||||
|
||||
console.log('Microphone permission granted')
|
||||
return true
|
||||
} catch (error: any) {
|
||||
console.error('Microphone permission error:', error)
|
||||
|
||||
if (error.name === 'NotAllowedError') {
|
||||
throw new Error('请允许使用麦克风以进行语音面试。如果您已拒绝,请在浏览器地址栏左侧的锁图标中重新授权。')
|
||||
} else if (error.name === 'NotFoundError') {
|
||||
throw new Error('未检测到麦克风设备,请确保您的设备已连接麦克风。')
|
||||
} else {
|
||||
throw new Error(`麦克风权限获取失败: ${error.message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
62
frontend/src/layouts/AdminLayout.vue
Normal file
62
frontend/src/layouts/AdminLayout.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const isCollapsed = ref(false)
|
||||
|
||||
function goToCandidates() {
|
||||
router.push('/admin')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-container class="admin-layout h-screen">
|
||||
<!-- 侧边栏 -->
|
||||
<el-aside :width="isCollapsed ? '64px' : '200px'" class="bg-gray-800 transition-all">
|
||||
<div class="h-14 flex items-center justify-center text-white font-bold text-lg border-b border-gray-700">
|
||||
<span v-if="!isCollapsed">AI面试后台</span>
|
||||
<span v-else>AI</span>
|
||||
</div>
|
||||
<el-menu
|
||||
:collapse="isCollapsed"
|
||||
:default-active="$route.path"
|
||||
background-color="#1f2937"
|
||||
text-color="#9ca3af"
|
||||
active-text-color="#3b82f6"
|
||||
class="border-none"
|
||||
>
|
||||
<el-menu-item index="/admin" @click="goToCandidates">
|
||||
<el-icon><User /></el-icon>
|
||||
<template #title>候选人管理</template>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
|
||||
<el-container>
|
||||
<!-- 头部 -->
|
||||
<el-header class="bg-white border-b border-gray-200 flex items-center justify-between px-4">
|
||||
<el-button :icon="isCollapsed ? 'Expand' : 'Fold'" text @click="isCollapsed = !isCollapsed" />
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-gray-600">管理员</span>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<!-- 内容区 -->
|
||||
<el-main class="bg-gray-50 p-6">
|
||||
<RouterView />
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-layout :deep(.el-menu) {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.admin-layout :deep(.el-menu--collapse) {
|
||||
width: 64px;
|
||||
}
|
||||
</style>
|
||||
19
frontend/src/layouts/InterviewLayout.vue
Normal file
19
frontend/src/layouts/InterviewLayout.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="interview-layout min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
|
||||
<RouterView />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.interview-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
22
frontend/src/main.ts
Normal file
22
frontend/src/main.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
import 'element-plus/dist/index.css'
|
||||
import './styles/index.css'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
// 注册 Element Plus 图标
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus)
|
||||
|
||||
app.mount('#app')
|
||||
192
frontend/src/pages/admin/[id].vue
Normal file
192
frontend/src/pages/admin/[id].vue
Normal file
@@ -0,0 +1,192 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { candidateApi } from '@/api'
|
||||
import type { CandidateDetail } from '@/api'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(true)
|
||||
const candidate = ref<CandidateDetail | null>(null)
|
||||
|
||||
// 雷达图数据
|
||||
const radarData = computed(() => {
|
||||
if (!candidate.value?.scores) return []
|
||||
const { salesSkill, salesMindset, quality, motivation } = candidate.value.scores
|
||||
return [
|
||||
{ name: '销售技能', value: salesSkill },
|
||||
{ name: '销售观', value: salesMindset },
|
||||
{ name: '素质项', value: quality },
|
||||
{ name: '求职动机', value: motivation },
|
||||
]
|
||||
})
|
||||
|
||||
// 获取候选人详情
|
||||
async function fetchDetail() {
|
||||
const sessionId = route.params.id as string
|
||||
if (!sessionId) {
|
||||
router.push('/admin')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await candidateApi.getDetail(sessionId)
|
||||
candidate.value = response.data
|
||||
} catch (error) {
|
||||
console.error('Fetch detail error:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 导出 PDF
|
||||
function handleExport() {
|
||||
if (!candidate.value) return
|
||||
const url = candidateApi.exportPdf(candidate.value.sessionId)
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
// 返回列表
|
||||
function goBack() {
|
||||
router.push('/admin')
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
function formatDate(dateStr?: string) {
|
||||
if (!dateStr) return '-'
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
// 获取评分颜色
|
||||
function getScoreColor(score: number) {
|
||||
if (score >= 80) return '#22c55e'
|
||||
if (score >= 60) return '#f59e0b'
|
||||
return '#ef4444'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchDetail()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="candidate-detail">
|
||||
<!-- 返回按钮 -->
|
||||
<div class="mb-4">
|
||||
<el-button text @click="goBack">
|
||||
<el-icon class="mr-1"><ArrowLeft /></el-icon>
|
||||
返回列表
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div v-loading="loading">
|
||||
<template v-if="candidate">
|
||||
<!-- 基本信息卡片 -->
|
||||
<div class="bg-white rounded-lg p-6 mb-6 shadow-sm">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800 mb-2">{{ candidate.name }}</h1>
|
||||
<div class="text-gray-500 space-y-1">
|
||||
<p>会话 ID:{{ candidate.sessionId }}</p>
|
||||
<p>面试时间:{{ formatDate(candidate.createdAt) }}</p>
|
||||
<p v-if="candidate.completedAt">
|
||||
完成时间:{{ formatDate(candidate.completedAt) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<el-button type="primary" @click="handleExport">
|
||||
<el-icon class="mr-1"><Download /></el-icon>
|
||||
导出 PDF
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 评分概览 -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<!-- 综合评分 -->
|
||||
<div class="bg-white rounded-lg p-6 shadow-sm">
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-4">综合评分</h2>
|
||||
<div class="text-center py-8">
|
||||
<div
|
||||
class="text-6xl font-bold mb-2"
|
||||
:style="{ color: getScoreColor(candidate.scores?.total || 0) }"
|
||||
>
|
||||
{{ candidate.scores?.total || 0 }}
|
||||
</div>
|
||||
<p class="text-gray-500">总分(满分 100)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分项评分 -->
|
||||
<div class="bg-white rounded-lg p-6 shadow-sm">
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-4">分项评分</h2>
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-for="item in radarData"
|
||||
:key="item.name"
|
||||
class="flex items-center"
|
||||
>
|
||||
<span class="w-20 text-gray-600">{{ item.name }}</span>
|
||||
<el-progress
|
||||
:percentage="item.value"
|
||||
:stroke-width="12"
|
||||
:color="getScoreColor(item.value)"
|
||||
class="flex-1"
|
||||
/>
|
||||
<span
|
||||
class="w-12 text-right font-medium"
|
||||
:style="{ color: getScoreColor(item.value) }"
|
||||
>
|
||||
{{ item.value }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分析报告 -->
|
||||
<div class="bg-white rounded-lg p-6 mb-6 shadow-sm">
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-4">分析报告</h2>
|
||||
<div class="prose max-w-none text-gray-600 leading-relaxed">
|
||||
{{ candidate.analysis || '暂无分析报告' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 简历内容 -->
|
||||
<div class="bg-white rounded-lg p-6 mb-6 shadow-sm">
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-4">简历内容</h2>
|
||||
<div class="bg-gray-50 rounded-lg p-4 text-gray-600 whitespace-pre-wrap">
|
||||
{{ candidate.resume || '暂无简历内容' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 面试记录 -->
|
||||
<div class="bg-white rounded-lg p-6 shadow-sm">
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-4">面试记录</h2>
|
||||
<div class="bg-gray-50 rounded-lg p-4 text-gray-600 whitespace-pre-wrap max-h-96 overflow-y-auto">
|
||||
{{ candidate.interviewLog || '暂无面试记录' }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="!loading">
|
||||
<el-empty description="未找到候选人信息" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.prose {
|
||||
line-height: 1.8;
|
||||
}
|
||||
</style>
|
||||
182
frontend/src/pages/admin/configs.vue
Normal file
182
frontend/src/pages/admin/configs.vue
Normal file
@@ -0,0 +1,182 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import axios from 'axios'
|
||||
|
||||
const configs = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
await loadConfigs()
|
||||
})
|
||||
|
||||
async function loadConfigs() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await axios.get('/api/admin/configs')
|
||||
if (response.data.code === 0) {
|
||||
configs.value = response.data.data || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Load configs error:', error)
|
||||
ElMessage.error('加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(timeStr: string) {
|
||||
if (!timeStr) return '-'
|
||||
return timeStr.split(' +')[0] || timeStr
|
||||
}
|
||||
|
||||
// 按类型分组
|
||||
const groupedConfigs = computed(() => {
|
||||
const groups: Record<string, any[]> = {}
|
||||
configs.value.forEach(config => {
|
||||
const type = config.config_type || '其他'
|
||||
if (!groups[type]) {
|
||||
groups[type] = []
|
||||
}
|
||||
groups[type].push(config)
|
||||
})
|
||||
return groups
|
||||
})
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed } from 'vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="configs-page">
|
||||
<!-- 页面头部 -->
|
||||
<div class="page-header">
|
||||
<h2>配置管理</h2>
|
||||
<el-button type="primary" @click="loadConfigs" :loading="loading">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 配置列表 -->
|
||||
<div class="configs-grid" v-loading="loading">
|
||||
<template v-if="Object.keys(groupedConfigs).length > 0">
|
||||
<el-card
|
||||
v-for="(items, type) in groupedConfigs"
|
||||
:key="type"
|
||||
class="config-group"
|
||||
>
|
||||
<template #header>
|
||||
<div class="group-header">
|
||||
<el-icon><Setting /></el-icon>
|
||||
<span>{{ type }}</span>
|
||||
<el-tag size="small" type="info">{{ items.length }} 项</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="config-items">
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.config_id"
|
||||
class="config-item"
|
||||
>
|
||||
<div class="item-header">
|
||||
<span class="item-name">{{ item.item_name }}</span>
|
||||
<span class="item-time">{{ formatTime(item.bstudio_create_time) }}</span>
|
||||
</div>
|
||||
<div class="item-content">{{ item.content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<el-empty v-else description="暂无配置数据" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.configs-page {
|
||||
animation: fadeIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.configs-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.config-group {
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.config-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.config-item {
|
||||
padding: 16px;
|
||||
background: #f8fafc;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.item-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
font-weight: 500;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.item-time {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.item-content {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.configs-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
863
frontend/src/pages/admin/dashboard.vue
Normal file
863
frontend/src/pages/admin/dashboard.vue
Normal file
@@ -0,0 +1,863 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const interviews = ref<any[]>([])
|
||||
|
||||
onMounted(async () => {
|
||||
await loadData()
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await axios.get('/api/admin/interviews', {
|
||||
params: { page: 1, page_size: 100 }
|
||||
})
|
||||
if (response.data.code === 0) {
|
||||
interviews.value = response.data.data || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Load data error:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 提取分数
|
||||
function extractScore(scoreField: any, reportField: string): number {
|
||||
if (scoreField && !isNaN(Number(scoreField))) return Number(scoreField)
|
||||
if (!reportField) return 0
|
||||
const patterns = [/(\d+)\s*分/, /得分[::]\s*(\d+)/, /(\d+)\/100/]
|
||||
for (const p of patterns) {
|
||||
const m = reportField?.match?.(p)
|
||||
if (m) return Number(m[1])
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// 计算匹配度
|
||||
function calculateMatchScore(row: any): number {
|
||||
const scores = [
|
||||
extractScore(row.sales_skill_score, row.sales_skill_report),
|
||||
extractScore(row.sales_concept_score, row.sales_concept_report),
|
||||
extractScore(row.competency_score, row.competency_report)
|
||||
].filter(s => s > 0)
|
||||
if (scores.length === 0) return 0
|
||||
return Math.round(scores.reduce((a, b) => a + b, 0) / scores.length)
|
||||
}
|
||||
|
||||
// 统计数据
|
||||
const stats = computed(() => {
|
||||
const total = interviews.value.length
|
||||
const completed = interviews.value.filter(i => {
|
||||
const stageNum = parseInt(i.current_stage) || 0
|
||||
return stageNum >= 50
|
||||
}).length
|
||||
const ongoing = total - completed
|
||||
|
||||
const validScores = interviews.value.map(i => calculateMatchScore(i)).filter(s => s > 0)
|
||||
const avgMatch = validScores.length > 0
|
||||
? Math.round(validScores.reduce((a, b) => a + b, 0) / validScores.length)
|
||||
: 0
|
||||
|
||||
const recommended = interviews.value.filter(i => calculateMatchScore(i) >= 70).length
|
||||
const highRisk = interviews.value.filter(i => i.risk_warning && i.risk_warning.length > 10).length
|
||||
|
||||
return { total, completed, ongoing, avgMatch, recommended, highRisk }
|
||||
})
|
||||
|
||||
// 匹配度分布
|
||||
const matchDistribution = computed(() => {
|
||||
const ranges = [
|
||||
{ label: '85-100', color: '#10b981', count: 0 },
|
||||
{ label: '70-84', color: '#3b82f6', count: 0 },
|
||||
{ label: '55-69', color: '#f59e0b', count: 0 },
|
||||
{ label: '0-54', color: '#ef4444', count: 0 },
|
||||
]
|
||||
|
||||
interviews.value.forEach(i => {
|
||||
const score = calculateMatchScore(i)
|
||||
if (score >= 85) ranges[0].count++
|
||||
else if (score >= 70) ranges[1].count++
|
||||
else if (score >= 55) ranges[2].count++
|
||||
else if (score > 0) ranges[3].count++
|
||||
})
|
||||
|
||||
return ranges
|
||||
})
|
||||
|
||||
// 最近面试
|
||||
const recentInterviews = computed(() => {
|
||||
return [...interviews.value]
|
||||
.sort((a, b) => {
|
||||
const timeA = new Date(a.bstudio_create_time || 0).getTime()
|
||||
const timeB = new Date(b.bstudio_create_time || 0).getTime()
|
||||
return timeB - timeA
|
||||
})
|
||||
.slice(0, 5)
|
||||
})
|
||||
|
||||
// 优质候选人
|
||||
const topCandidates = computed(() => {
|
||||
return [...interviews.value]
|
||||
.map(i => ({ ...i, matchScore: calculateMatchScore(i) }))
|
||||
.filter(i => i.matchScore > 0)
|
||||
.sort((a, b) => b.matchScore - a.matchScore)
|
||||
.slice(0, 5)
|
||||
})
|
||||
|
||||
function formatTime(timeStr: string) {
|
||||
if (!timeStr) return '-'
|
||||
const date = new Date(timeStr)
|
||||
return `${date.getMonth() + 1}月${date.getDate()}日 ${date.getHours()}:${String(date.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function getMatchColor(score: number) {
|
||||
if (score >= 85) return '#10b981'
|
||||
if (score >= 70) return '#3b82f6'
|
||||
if (score >= 55) return '#f59e0b'
|
||||
return '#ef4444'
|
||||
}
|
||||
|
||||
// 根据 stage 数值获取面试阶段信息
|
||||
function getStageInfo(stage: string | number) {
|
||||
const stageNum = typeof stage === 'string' ? parseInt(stage) || 0 : stage
|
||||
|
||||
if (stageNum >= 50) {
|
||||
return { text: '已完成', color: '#10b981', isCompleted: true }
|
||||
} else if (stageNum >= 40) {
|
||||
return { text: '素质项', color: '#8b5cf6', isCompleted: false }
|
||||
} else if (stageNum >= 30) {
|
||||
return { text: '销售观', color: '#3b82f6', isCompleted: false }
|
||||
} else if (stageNum >= 20) {
|
||||
return { text: '销售技能', color: '#f59e0b', isCompleted: false }
|
||||
} else if (stageNum >= 10) {
|
||||
return { text: '简历上传', color: '#94a3b8', isCompleted: false }
|
||||
}
|
||||
return { text: '未开始', color: '#cbd5e1', isCompleted: false }
|
||||
}
|
||||
|
||||
function viewDetail(id: string) {
|
||||
if (id && id !== '[]') {
|
||||
router.push(`/admin/interviews/${id}`)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<!-- 欢迎语 -->
|
||||
<div class="welcome-section">
|
||||
<h1>欢迎回来</h1>
|
||||
<p>这是您的 AI 面试系统数据概览</p>
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片 - 骨架屏 -->
|
||||
<div class="stats-grid" v-if="loading">
|
||||
<div v-for="i in 4" :key="i" class="stat-card skeleton-stat">
|
||||
<div class="skeleton-icon"></div>
|
||||
<div class="skeleton-stat-body">
|
||||
<div class="skeleton-line w-40"></div>
|
||||
<div class="skeleton-line w-60"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片 - 数据 -->
|
||||
<div class="stats-grid" v-else>
|
||||
<div class="stat-card gradient-blue">
|
||||
<div class="stat-icon">
|
||||
<el-icon :size="28"><User /></el-icon>
|
||||
</div>
|
||||
<div class="stat-body">
|
||||
<div class="stat-value">{{ stats.total }}</div>
|
||||
<div class="stat-label">总面试数</div>
|
||||
</div>
|
||||
<div class="stat-decoration"></div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card gradient-green">
|
||||
<div class="stat-icon">
|
||||
<el-icon :size="28"><SuccessFilled /></el-icon>
|
||||
</div>
|
||||
<div class="stat-body">
|
||||
<div class="stat-value">{{ stats.completed }}</div>
|
||||
<div class="stat-label">已完成</div>
|
||||
</div>
|
||||
<div class="stat-decoration"></div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card gradient-purple">
|
||||
<div class="stat-icon">
|
||||
<el-icon :size="28"><TrendCharts /></el-icon>
|
||||
</div>
|
||||
<div class="stat-body">
|
||||
<div class="stat-value">{{ stats.avgMatch }}%</div>
|
||||
<div class="stat-label">平均匹配度</div>
|
||||
</div>
|
||||
<div class="stat-decoration"></div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card gradient-orange">
|
||||
<div class="stat-icon">
|
||||
<el-icon :size="28"><Star /></el-icon>
|
||||
</div>
|
||||
<div class="stat-body">
|
||||
<div class="stat-value">{{ stats.recommended }}</div>
|
||||
<div class="stat-label">推荐录用</div>
|
||||
</div>
|
||||
<div class="stat-decoration"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图表区域 - 骨架屏 -->
|
||||
<div class="charts-row" v-if="loading">
|
||||
<div class="chart-card">
|
||||
<div class="skeleton-line w-30" style="height: 20px; margin-bottom: 20px;"></div>
|
||||
<div class="skeleton-bars">
|
||||
<div v-for="i in 4" :key="i" class="skeleton-bar-row">
|
||||
<div class="skeleton-line w-20"></div>
|
||||
<div class="skeleton-line flex-1"></div>
|
||||
<div class="skeleton-line w-20"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<div class="skeleton-line w-30" style="height: 20px; margin-bottom: 20px;"></div>
|
||||
<div class="skeleton-donut"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图表区域 - 数据 -->
|
||||
<div class="charts-row">
|
||||
<!-- 匹配度分布 -->
|
||||
<div class="chart-card">
|
||||
<h3>匹配度分布</h3>
|
||||
<div class="distribution-chart">
|
||||
<div
|
||||
v-for="(item, idx) in matchDistribution"
|
||||
:key="idx"
|
||||
class="dist-bar-container"
|
||||
>
|
||||
<div class="dist-label">{{ item.label }}</div>
|
||||
<div class="dist-bar-wrapper">
|
||||
<div
|
||||
class="dist-bar"
|
||||
:style="{
|
||||
width: `${(item.count / (stats.total || 1)) * 100}%`,
|
||||
background: item.color
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
<div class="dist-count">{{ item.count }}人</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="distribution-legend">
|
||||
<span v-for="(item, idx) in matchDistribution" :key="idx" class="legend-item">
|
||||
<span class="legend-dot" :style="{ background: item.color }"></span>
|
||||
{{ item.label }}分
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 状态分布 -->
|
||||
<div class="chart-card">
|
||||
<h3>面试状态</h3>
|
||||
<div class="status-chart">
|
||||
<div class="donut-chart">
|
||||
<svg viewBox="0 0 100 100">
|
||||
<circle
|
||||
cx="50" cy="50" r="40"
|
||||
fill="none"
|
||||
stroke="#f1f5f9"
|
||||
stroke-width="12"
|
||||
/>
|
||||
<circle
|
||||
cx="50" cy="50" r="40"
|
||||
fill="none"
|
||||
stroke="#10b981"
|
||||
stroke-width="12"
|
||||
:stroke-dasharray="`${(stats.completed / (stats.total || 1)) * 251.2} 251.2`"
|
||||
stroke-linecap="round"
|
||||
transform="rotate(-90 50 50)"
|
||||
/>
|
||||
</svg>
|
||||
<div class="donut-center">
|
||||
<div class="donut-value">{{ Math.round((stats.completed / (stats.total || 1)) * 100) }}%</div>
|
||||
<div class="donut-label">完成率</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-legend">
|
||||
<div class="legend-row">
|
||||
<span class="legend-dot" style="background: #10b981"></span>
|
||||
<span class="legend-text">已完成</span>
|
||||
<span class="legend-num">{{ stats.completed }}</span>
|
||||
</div>
|
||||
<div class="legend-row">
|
||||
<span class="legend-dot" style="background: #f59e0b"></span>
|
||||
<span class="legend-text">进行中</span>
|
||||
<span class="legend-num">{{ stats.ongoing }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 列表区域 - 骨架屏 -->
|
||||
<div class="lists-row" v-if="loading">
|
||||
<div class="list-card" v-for="i in 2" :key="i">
|
||||
<div class="skeleton-line w-30" style="height: 20px; margin-bottom: 16px;"></div>
|
||||
<div class="skeleton-list">
|
||||
<div v-for="j in 4" :key="j" class="skeleton-list-item">
|
||||
<div class="skeleton-avatar-sm"></div>
|
||||
<div class="skeleton-list-info">
|
||||
<div class="skeleton-line w-40"></div>
|
||||
<div class="skeleton-line w-60"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 列表区域 - 数据 -->
|
||||
<div class="lists-row" v-else>
|
||||
<!-- 最近面试 -->
|
||||
<div class="list-card">
|
||||
<div class="list-header">
|
||||
<h3>最近面试</h3>
|
||||
<el-button text type="primary" @click="router.push('/admin/interviews')">
|
||||
查看全部
|
||||
<el-icon><ArrowRight /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="list-body">
|
||||
<div
|
||||
v-for="item in recentInterviews"
|
||||
:key="item.session_id"
|
||||
class="list-item"
|
||||
@click="viewDetail(item.session_id)"
|
||||
>
|
||||
<div class="item-avatar" :style="{ background: `hsl(${(item.candidate_name || '').charCodeAt(0) * 10}, 70%, 60%)` }">
|
||||
{{ (item.candidate_name || '?').charAt(0) }}
|
||||
</div>
|
||||
<div class="item-info">
|
||||
<div class="item-name">{{ item.candidate_name || '未知' }}</div>
|
||||
<div class="item-time">{{ formatTime(item.bstudio_create_time) }}</div>
|
||||
</div>
|
||||
<el-tag
|
||||
:type="getStageInfo(item.current_stage).isCompleted ? 'success' : 'info'"
|
||||
size="small"
|
||||
effect="light"
|
||||
:style="{ borderColor: getStageInfo(item.current_stage).color, color: getStageInfo(item.current_stage).color }"
|
||||
>
|
||||
{{ getStageInfo(item.current_stage).text }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<el-empty v-if="recentInterviews.length === 0" description="暂无数据" :image-size="60" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 优质候选人 -->
|
||||
<div class="list-card">
|
||||
<div class="list-header">
|
||||
<h3>优质候选人</h3>
|
||||
<el-tag type="success" effect="light" size="small">Top 5</el-tag>
|
||||
</div>
|
||||
<div class="list-body">
|
||||
<div
|
||||
v-for="(item, idx) in topCandidates"
|
||||
:key="item.session_id"
|
||||
class="list-item"
|
||||
@click="viewDetail(item.session_id)"
|
||||
>
|
||||
<div class="item-rank" :class="{ gold: idx === 0, silver: idx === 1, bronze: idx === 2 }">
|
||||
{{ idx + 1 }}
|
||||
</div>
|
||||
<div class="item-info">
|
||||
<div class="item-name">{{ item.candidate_name || '未知' }}</div>
|
||||
<div class="item-score">
|
||||
<el-progress
|
||||
:percentage="item.matchScore"
|
||||
:stroke-width="6"
|
||||
:color="getMatchColor(item.matchScore)"
|
||||
:show-text="false"
|
||||
style="width: 80px"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item-match" :style="{ color: getMatchColor(item.matchScore) }">
|
||||
{{ item.matchScore }}%
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-if="topCandidates.length === 0" description="暂无数据" :image-size="60" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dashboard {
|
||||
animation: fadeIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* 骨架屏样式 */
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
.skeleton-stat {
|
||||
background: #fff !important;
|
||||
color: transparent !important;
|
||||
}
|
||||
|
||||
.skeleton-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
.skeleton-stat-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.skeleton-line {
|
||||
height: 14px;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
.skeleton-line.w-20 { width: 20%; }
|
||||
.skeleton-line.w-30 { width: 30%; }
|
||||
.skeleton-line.w-40 { width: 40%; height: 24px; }
|
||||
.skeleton-line.w-60 { width: 60%; }
|
||||
.skeleton-line.flex-1 { flex: 1; }
|
||||
|
||||
.skeleton-bars {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.skeleton-bar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.skeleton-bar-row .skeleton-line {
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.skeleton-donut {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
border-radius: 50%;
|
||||
margin: 20px auto;
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
.skeleton-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.skeleton-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.skeleton-avatar-sm {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.skeleton-list-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.skeleton-list-info .skeleton-line {
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
/* 欢迎语 */
|
||||
.welcome-section {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.welcome-section h1 {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #1a1a2e;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.welcome-section p {
|
||||
color: #64748b;
|
||||
font-size: 15px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 统计卡片 */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 20px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
border-radius: 20px;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
color: #fff;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gradient-blue { background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%); }
|
||||
.gradient-green { background: linear-gradient(135deg, #10b981 0%, #059669 100%); }
|
||||
.gradient-purple { background: linear-gradient(135deg, #8b5cf6 0%, #6d28d9 100%); }
|
||||
.gradient-orange { background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); }
|
||||
|
||||
.stat-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.stat-decoration {
|
||||
position: absolute;
|
||||
right: -20px;
|
||||
bottom: -20px;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* 图表区域 */
|
||||
.charts-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1.5fr 1fr;
|
||||
gap: 20px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.chart-card h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
|
||||
/* 匹配度分布 */
|
||||
.distribution-chart {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dist-bar-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dist-label {
|
||||
width: 60px;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.dist-bar-wrapper {
|
||||
flex: 1;
|
||||
height: 24px;
|
||||
background: #f1f5f9;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dist-bar {
|
||||
height: 100%;
|
||||
border-radius: 12px;
|
||||
transition: width 0.5s ease;
|
||||
min-width: 4px;
|
||||
}
|
||||
|
||||
.dist-count {
|
||||
width: 50px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.distribution-legend {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.legend-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* 状态分布 */
|
||||
.status-chart {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 32px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.donut-chart {
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.donut-chart svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.donut-center {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.donut-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.donut-label {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.status-legend {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.legend-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.legend-text {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.legend-num {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
/* 列表区域 */
|
||||
.lists-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.list-card {
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.list-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.list-header h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.list-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.list-item:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.item-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.item-rank {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
background: #f1f5f9;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.item-rank.gold { background: #fef3c7; color: #d97706; }
|
||||
.item-rank.silver { background: #e2e8f0; color: #64748b; }
|
||||
.item-rank.bronze { background: #fed7aa; color: #c2410c; }
|
||||
|
||||
.item-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #1a1a2e;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.item-time {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.item-score {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.item-match {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 1200px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.charts-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.lists-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
196
frontend/src/pages/admin/index.vue
Normal file
196
frontend/src/pages/admin/index.vue
Normal file
@@ -0,0 +1,196 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { candidateApi } from '@/api'
|
||||
import type { Candidate, CandidateListParams } from '@/api'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 表格数据
|
||||
const tableData = ref<Candidate[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
|
||||
// 查询参数
|
||||
const queryParams = ref<CandidateListParams>({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
keyword: '',
|
||||
status: '',
|
||||
})
|
||||
|
||||
// 状态选项
|
||||
const statusOptions = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '待面试', value: 'pending' },
|
||||
{ label: '进行中', value: 'ongoing' },
|
||||
{ label: '已完成', value: 'completed' },
|
||||
]
|
||||
|
||||
// 获取列表数据
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await candidateApi.getList(queryParams.value)
|
||||
tableData.value = response.data.list
|
||||
total.value = response.data.total
|
||||
} catch (error) {
|
||||
console.error('Fetch candidates error:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
function handleSearch() {
|
||||
queryParams.value.page = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 重置
|
||||
function handleReset() {
|
||||
queryParams.value = {
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
keyword: '',
|
||||
status: '',
|
||||
}
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 分页变化
|
||||
function handlePageChange(page: number) {
|
||||
queryParams.value.page = page
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 查看详情
|
||||
function handleView(row: Candidate) {
|
||||
router.push(`/admin/candidates/${row.sessionId}`)
|
||||
}
|
||||
|
||||
// 格式化状态
|
||||
function formatStatus(status: string) {
|
||||
const statusMap: Record<string, { label: string; type: string }> = {
|
||||
pending: { label: '待面试', type: 'info' },
|
||||
ongoing: { label: '进行中', type: 'warning' },
|
||||
completed: { label: '已完成', type: 'success' },
|
||||
}
|
||||
return statusMap[status] || { label: status, type: 'info' }
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
function formatDate(dateStr: string) {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="candidate-list">
|
||||
<!-- 页面标题 -->
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-800">候选人管理</h1>
|
||||
<p class="text-gray-500 mt-1">查看和管理所有面试候选人</p>
|
||||
</div>
|
||||
|
||||
<!-- 搜索栏 -->
|
||||
<div class="bg-white rounded-lg p-4 mb-6 shadow-sm">
|
||||
<el-form :inline="true" :model="queryParams" class="flex flex-wrap gap-4">
|
||||
<el-form-item label="姓名">
|
||||
<el-input
|
||||
v-model="queryParams.keyword"
|
||||
placeholder="请输入姓名"
|
||||
clearable
|
||||
style="width: 200px"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select
|
||||
v-model="queryParams.status"
|
||||
placeholder="请选择状态"
|
||||
style="width: 120px"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in statusOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon class="mr-1"><Search /></el-icon>
|
||||
搜索
|
||||
</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<div class="bg-white rounded-lg shadow-sm">
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="tableData"
|
||||
stripe
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-table-column prop="name" label="姓名" width="120" />
|
||||
<el-table-column prop="createdAt" label="面试时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.createdAt) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="formatStatus(row.status).type as any" size="small">
|
||||
{{ formatStatus(row.status).label }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="score" label="综合评分" width="120">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.score" class="font-medium">
|
||||
{{ row.score }} 分
|
||||
</span>
|
||||
<span v-else class="text-gray-400">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="handleView(row)">
|
||||
查看详情
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="flex justify-end p-4">
|
||||
<el-pagination
|
||||
v-model:current-page="queryParams.page"
|
||||
:page-size="queryParams.pageSize"
|
||||
:total="total"
|
||||
layout="total, prev, pager, next"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
1133
frontend/src/pages/admin/interview-detail.vue
Normal file
1133
frontend/src/pages/admin/interview-detail.vue
Normal file
File diff suppressed because it is too large
Load Diff
809
frontend/src/pages/admin/interviews.vue
Normal file
809
frontend/src/pages/admin/interviews.vue
Normal file
@@ -0,0 +1,809 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import axios from 'axios'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const interviews = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const searchKeyword = ref('')
|
||||
const statusFilter = ref('')
|
||||
const pagination = ref({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
total: 0
|
||||
})
|
||||
|
||||
// 统计数据
|
||||
const stats = computed(() => {
|
||||
const total = interviews.value.length
|
||||
const completed = interviews.value.filter(i => {
|
||||
const stageNum = parseInt(i.current_stage) || 0
|
||||
return stageNum >= 50
|
||||
}).length
|
||||
const avgMatch = interviews.value.reduce((sum, i) => sum + (calculateMatchScore(i) || 0), 0) / (total || 1)
|
||||
const highMatch = interviews.value.filter(i => calculateMatchScore(i) >= 80).length
|
||||
|
||||
return { total, completed, avgMatch: Math.round(avgMatch), highMatch }
|
||||
})
|
||||
|
||||
// 筛选后的数据
|
||||
const filteredInterviews = computed(() => {
|
||||
let result = [...interviews.value]
|
||||
|
||||
if (searchKeyword.value) {
|
||||
const kw = searchKeyword.value.toLowerCase()
|
||||
result = result.filter(i =>
|
||||
(i.candidate_name || '').toLowerCase().includes(kw) ||
|
||||
(i.session_id || '').toLowerCase().includes(kw)
|
||||
)
|
||||
}
|
||||
|
||||
if (statusFilter.value) {
|
||||
result = result.filter(i => {
|
||||
const stageNum = parseInt(i.current_stage) || 0
|
||||
if (statusFilter.value === 'completed') return stageNum >= 50
|
||||
if (statusFilter.value === 'ongoing') return stageNum < 50
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await loadInterviews()
|
||||
})
|
||||
|
||||
async function loadInterviews() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await axios.get('/api/admin/interviews', {
|
||||
params: {
|
||||
page: pagination.value.page,
|
||||
page_size: pagination.value.pageSize
|
||||
}
|
||||
})
|
||||
|
||||
if (response.data.code === 0) {
|
||||
interviews.value = response.data.data || []
|
||||
pagination.value.total = interviews.value.length
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Load interviews error:', error)
|
||||
ElMessage.error('加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 计算匹配度(综合评分)
|
||||
function calculateMatchScore(row: any): number {
|
||||
const scores = [
|
||||
extractScore(row.sales_skill_score, row.sales_skill_report),
|
||||
extractScore(row.sales_concept_score, row.sales_concept_report),
|
||||
extractScore(row.competency_score, row.competency_report)
|
||||
].filter(s => s > 0)
|
||||
|
||||
if (scores.length === 0) return 0
|
||||
return Math.round(scores.reduce((a, b) => a + b, 0) / scores.length)
|
||||
}
|
||||
|
||||
// 提取分数
|
||||
function extractScore(scoreField: any, reportField: string): number {
|
||||
if (scoreField && !isNaN(Number(scoreField))) return Number(scoreField)
|
||||
if (!reportField) return 0
|
||||
|
||||
const patterns = [/(\d+)\s*分/, /得分[::]\s*(\d+)/, /评分[::]\s*(\d+)/, /(\d+)\/100/]
|
||||
for (const p of patterns) {
|
||||
const m = reportField.match(p)
|
||||
if (m) return Number(m[1])
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// 获取匹配度等级
|
||||
function getMatchLevel(score: number) {
|
||||
if (score >= 85) return { text: '强烈推荐', color: '#10b981', bg: 'rgba(16, 185, 129, 0.1)' }
|
||||
if (score >= 70) return { text: '推荐', color: '#3b82f6', bg: 'rgba(59, 130, 246, 0.1)' }
|
||||
if (score >= 55) return { text: '待定', color: '#f59e0b', bg: 'rgba(245, 158, 11, 0.1)' }
|
||||
return { text: '不推荐', color: '#ef4444', bg: 'rgba(239, 68, 68, 0.1)' }
|
||||
}
|
||||
|
||||
// 获取风险等级
|
||||
function getRiskLevel(row: any): { level: string; color: string } {
|
||||
const hasWarning = row.risk_warning && row.risk_warning.length > 10
|
||||
const matchScore = calculateMatchScore(row)
|
||||
|
||||
if (hasWarning && matchScore < 60) return { level: '高风险', color: '#ef4444' }
|
||||
if (hasWarning || matchScore < 70) return { level: '中风险', color: '#f59e0b' }
|
||||
return { level: '低风险', color: '#10b981' }
|
||||
}
|
||||
|
||||
function formatTime(timeStr: string) {
|
||||
if (!timeStr) return '-'
|
||||
return timeStr.split(' +')[0]?.replace('T', ' ') || timeStr
|
||||
}
|
||||
|
||||
// 根据 stage 数值获取面试阶段信息
|
||||
function getStageInfo(stage: string | number) {
|
||||
const stageNum = typeof stage === 'string' ? parseInt(stage) || 0 : stage
|
||||
|
||||
if (stageNum >= 50) {
|
||||
return { text: '求职动机', color: '#10b981', isCompleted: true }
|
||||
} else if (stageNum >= 40) {
|
||||
return { text: '素质项评估', color: '#8b5cf6', isCompleted: false }
|
||||
} else if (stageNum >= 30) {
|
||||
return { text: '销售观评估', color: '#3b82f6', isCompleted: false }
|
||||
} else if (stageNum >= 20) {
|
||||
return { text: '销售技能', color: '#f59e0b', isCompleted: false }
|
||||
} else if (stageNum >= 10) {
|
||||
return { text: '简历上传', color: '#94a3b8', isCompleted: false }
|
||||
}
|
||||
return { text: '未开始', color: '#cbd5e1', isCompleted: false }
|
||||
}
|
||||
|
||||
function viewDetail(row: any) {
|
||||
const id = row.session_id || row.id
|
||||
if (!id || id === '[]') {
|
||||
ElMessage.warning('该记录没有有效的 session_id')
|
||||
return
|
||||
}
|
||||
router.push(`/admin/interviews/${id}`)
|
||||
}
|
||||
|
||||
async function deleteInterview(row: any) {
|
||||
const id = row.session_id || row.id
|
||||
if (!id || id === '[]') return
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除候选人 "${row.candidate_name}" 的面试记录吗?`,
|
||||
'确认删除',
|
||||
{ type: 'warning' }
|
||||
)
|
||||
|
||||
await axios.delete(`/api/admin/interviews/${id}`)
|
||||
ElMessage.success('删除成功')
|
||||
await loadInterviews()
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="interviews-page">
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<h1>面试管理</h1>
|
||||
<p class="subtitle">查看和管理所有面试记录</p>
|
||||
</div>
|
||||
<el-button type="primary" @click="loadInterviews" :loading="loading" class="refresh-btn">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新数据
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon blue">
|
||||
<el-icon :size="24"><User /></el-icon>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats.total }}</div>
|
||||
<div class="stat-label">总面试数</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon green">
|
||||
<el-icon :size="24"><SuccessFilled /></el-icon>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats.completed }}</div>
|
||||
<div class="stat-label">已完成</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon purple">
|
||||
<el-icon :size="24"><TrendCharts /></el-icon>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats.avgMatch }}%</div>
|
||||
<div class="stat-label">平均匹配度</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon orange">
|
||||
<el-icon :size="24"><Star /></el-icon>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats.highMatch }}</div>
|
||||
<div class="stat-label">优质候选人</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索和筛选 -->
|
||||
<div class="filter-bar">
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索候选人姓名或 ID..."
|
||||
prefix-icon="Search"
|
||||
clearable
|
||||
class="search-input"
|
||||
/>
|
||||
<el-select v-model="statusFilter" placeholder="筛选状态" clearable class="status-select">
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="已完成" value="completed" />
|
||||
<el-option label="进行中" value="ongoing" />
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<!-- 面试列表 -->
|
||||
<div class="interviews-list">
|
||||
<!-- 加载骨架屏 -->
|
||||
<template v-if="loading">
|
||||
<div v-for="i in 4" :key="i" class="interview-card skeleton">
|
||||
<div class="candidate-section">
|
||||
<div class="skeleton-avatar"></div>
|
||||
<div class="skeleton-info">
|
||||
<div class="skeleton-line w-40"></div>
|
||||
<div class="skeleton-line w-60"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="skeleton-bar-section">
|
||||
<div class="skeleton-line w-full"></div>
|
||||
<div class="skeleton-line w-80"></div>
|
||||
</div>
|
||||
<div class="skeleton-scores">
|
||||
<div class="skeleton-line w-full"></div>
|
||||
<div class="skeleton-line w-full"></div>
|
||||
<div class="skeleton-line w-full"></div>
|
||||
</div>
|
||||
<div class="skeleton-risk"></div>
|
||||
<div class="skeleton-actions">
|
||||
<div class="skeleton-btn"></div>
|
||||
<div class="skeleton-btn-sm"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 实际数据列表 -->
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="interview in filteredInterviews"
|
||||
:key="interview.session_id || interview.id"
|
||||
class="interview-card"
|
||||
@click="viewDetail(interview)"
|
||||
>
|
||||
<!-- 候选人信息 -->
|
||||
<div class="candidate-section">
|
||||
<div class="avatar" :style="{ background: `hsl(${(interview.candidate_name || '').charCodeAt(0) * 10}, 70%, 60%)` }">
|
||||
{{ (interview.candidate_name || '?').charAt(0) }}
|
||||
</div>
|
||||
<div class="candidate-info">
|
||||
<div class="candidate-name">{{ interview.candidate_name || '未知候选人' }}</div>
|
||||
<div class="candidate-meta">
|
||||
<el-icon><Clock /></el-icon>
|
||||
{{ formatTime(interview.bstudio_create_time) }}
|
||||
</div>
|
||||
</div>
|
||||
<el-tag
|
||||
:type="getStageInfo(interview.current_stage).isCompleted ? 'success' : 'info'"
|
||||
effect="light"
|
||||
size="small"
|
||||
class="status-tag"
|
||||
:style="{ borderColor: getStageInfo(interview.current_stage).color, color: getStageInfo(interview.current_stage).color }"
|
||||
>
|
||||
{{ getStageInfo(interview.current_stage).text }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<!-- 匹配度 -->
|
||||
<div class="match-section">
|
||||
<div class="match-header">
|
||||
<span class="match-label">岗位匹配度</span>
|
||||
<span
|
||||
class="match-badge"
|
||||
:style="{
|
||||
color: getMatchLevel(calculateMatchScore(interview)).color,
|
||||
background: getMatchLevel(calculateMatchScore(interview)).bg
|
||||
}"
|
||||
>
|
||||
{{ getMatchLevel(calculateMatchScore(interview)).text }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="match-bar">
|
||||
<div
|
||||
class="match-fill"
|
||||
:style="{
|
||||
width: `${calculateMatchScore(interview)}%`,
|
||||
background: getMatchLevel(calculateMatchScore(interview)).color
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
<div class="match-value">{{ calculateMatchScore(interview) }}%</div>
|
||||
</div>
|
||||
|
||||
<!-- 维度评分 -->
|
||||
<div class="scores-section">
|
||||
<div class="score-item">
|
||||
<span class="score-label">销售技能</span>
|
||||
<span class="score-value">{{ extractScore(interview.sales_skill_score, interview.sales_skill_report) || '-' }}</span>
|
||||
</div>
|
||||
<div class="score-item">
|
||||
<span class="score-label">销售观</span>
|
||||
<span class="score-value">{{ extractScore(interview.sales_concept_score, interview.sales_concept_report) || '-' }}</span>
|
||||
</div>
|
||||
<div class="score-item">
|
||||
<span class="score-label">素质项</span>
|
||||
<span class="score-value">{{ extractScore(interview.competency_score, interview.competency_report) || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 风险标识 -->
|
||||
<div class="risk-section">
|
||||
<div
|
||||
class="risk-indicator"
|
||||
:style="{ color: getRiskLevel(interview).color }"
|
||||
>
|
||||
<el-icon><Warning /></el-icon>
|
||||
{{ getRiskLevel(interview).level }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="actions-section">
|
||||
<el-button type="primary" size="small" @click.stop="viewDetail(interview)">
|
||||
查看详情
|
||||
</el-button>
|
||||
<el-button type="danger" size="small" plain @click.stop="deleteInterview(interview)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-empty v-if="filteredInterviews.length === 0" description="暂无面试记录" class="empty-state" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-wrapper" v-if="filteredInterviews.length > 0">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
layout="total, prev, pager, next"
|
||||
background
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.interviews-page {
|
||||
animation: fadeIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* 页面标题 */
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #1a1a2e;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
border-radius: 10px;
|
||||
padding: 12px 20px;
|
||||
}
|
||||
|
||||
/* 统计卡片 */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.stat-icon.blue { background: linear-gradient(135deg, #3b82f6, #1d4ed8); }
|
||||
.stat-icon.green { background: linear-gradient(135deg, #10b981, #059669); }
|
||||
.stat-icon.purple { background: linear-gradient(135deg, #8b5cf6, #6d28d9); }
|
||||
.stat-icon.orange { background: linear-gradient(135deg, #f59e0b, #d97706); }
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* 筛选栏 */
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 320px;
|
||||
}
|
||||
|
||||
.search-input :deep(.el-input__wrapper) {
|
||||
border-radius: 10px;
|
||||
padding: 8px 16px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.status-select {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.status-select :deep(.el-input__wrapper) {
|
||||
border-radius: 10px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.status-select :deep(.el-select__wrapper) {
|
||||
border-radius: 10px;
|
||||
min-height: 40px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
/* 面试列表 */
|
||||
.interviews-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
/* 骨架屏 */
|
||||
.interview-card.skeleton {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.skeleton-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.skeleton-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.skeleton-line {
|
||||
height: 14px;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
.skeleton-line.w-40 { width: 40%; }
|
||||
.skeleton-line.w-60 { width: 60%; }
|
||||
.skeleton-line.w-80 { width: 80%; }
|
||||
.skeleton-line.w-full { width: 100%; }
|
||||
|
||||
.skeleton-bar-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.skeleton-scores {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.skeleton-scores .skeleton-line {
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.skeleton-risk {
|
||||
width: 60px;
|
||||
height: 24px;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
.skeleton-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.skeleton-btn {
|
||||
width: 80px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
.skeleton-btn-sm {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 48px 24px;
|
||||
}
|
||||
|
||||
.interview-card {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 20px 24px;
|
||||
display: grid;
|
||||
grid-template-columns: 240px 200px 200px 100px auto;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.interview-card:hover {
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 8px 24px rgba(102, 126, 234, 0.15);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
/* 候选人信息 */
|
||||
.candidate-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.candidate-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.candidate-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* 匹配度 */
|
||||
.match-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.match-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.match-label {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.match-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.match-bar {
|
||||
height: 6px;
|
||||
background: #f1f5f9;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.match-fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.match-value {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
/* 维度评分 */
|
||||
.scores-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.score-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.score-label {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.score-value {
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
/* 风险标识 */
|
||||
.risk-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 操作按钮 */
|
||||
.actions-section {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* 分页 */
|
||||
.pagination-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 24px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 1400px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.interview-card {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.candidate-section {
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.match-section,
|
||||
.scores-section,
|
||||
.risk-section {
|
||||
padding-left: 62px;
|
||||
}
|
||||
|
||||
.actions-section {
|
||||
justify-content: flex-start;
|
||||
padding-left: 62px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.status-select {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
259
frontend/src/pages/admin/layout.vue
Normal file
259
frontend/src/pages/admin/layout.vue
Normal file
@@ -0,0 +1,259 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const isCollapsed = ref(false)
|
||||
|
||||
const menuItems = [
|
||||
{ path: '/admin/dashboard', icon: 'DataAnalysis', label: '数据概览', badge: '' },
|
||||
{ path: '/admin/interviews', icon: 'User', label: '面试管理', badge: '' },
|
||||
{ path: '/admin/configs', icon: 'Setting', label: '系统配置', badge: '' },
|
||||
]
|
||||
|
||||
const currentPath = computed(() => route.path)
|
||||
|
||||
function navigate(path: string) {
|
||||
router.push(path)
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
sessionStorage.removeItem('adminToken')
|
||||
router.push('/admin/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-layout">
|
||||
<!-- 侧边栏 -->
|
||||
<aside class="sidebar" :class="{ collapsed: isCollapsed }">
|
||||
<!-- Logo -->
|
||||
<div class="logo-section">
|
||||
<div class="logo-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
||||
<path d="M2 17l10 5 10-5"/>
|
||||
<path d="M2 12l10 5 10-5"/>
|
||||
</svg>
|
||||
</div>
|
||||
<transition name="fade">
|
||||
<span v-if="!isCollapsed" class="logo-text">AI 面试系统</span>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<!-- 导航菜单 -->
|
||||
<nav class="nav-menu">
|
||||
<div
|
||||
v-for="item in menuItems"
|
||||
:key="item.path"
|
||||
class="nav-item"
|
||||
:class="{ active: currentPath.startsWith(item.path) }"
|
||||
@click="navigate(item.path)"
|
||||
>
|
||||
<div class="nav-icon">
|
||||
<el-icon :size="20"><component :is="item.icon" /></el-icon>
|
||||
</div>
|
||||
<transition name="fade">
|
||||
<span v-if="!isCollapsed" class="nav-label">{{ item.label }}</span>
|
||||
</transition>
|
||||
<span v-if="item.badge && !isCollapsed" class="nav-badge">{{ item.badge }}</span>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 底部操作 -->
|
||||
<div class="sidebar-footer">
|
||||
<div class="nav-item" @click="isCollapsed = !isCollapsed">
|
||||
<div class="nav-icon">
|
||||
<el-icon :size="20">
|
||||
<Fold v-if="!isCollapsed" />
|
||||
<Expand v-else />
|
||||
</el-icon>
|
||||
</div>
|
||||
<transition name="fade">
|
||||
<span v-if="!isCollapsed" class="nav-label">收起菜单</span>
|
||||
</transition>
|
||||
</div>
|
||||
<div class="nav-item logout" @click="handleLogout">
|
||||
<div class="nav-icon">
|
||||
<el-icon :size="20"><SwitchButton /></el-icon>
|
||||
</div>
|
||||
<transition name="fade">
|
||||
<span v-if="!isCollapsed" class="nav-label">退出登录</span>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<main class="main-content">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
background: #f0f2f5;
|
||||
}
|
||||
|
||||
/* 侧边栏 */
|
||||
.sidebar {
|
||||
width: 260px;
|
||||
background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.sidebar.collapsed {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
/* Logo */
|
||||
.logo-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 24px 20px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.logo-icon svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 导航菜单 */
|
||||
.nav-menu {
|
||||
flex: 1;
|
||||
padding: 16px 12px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
margin-bottom: 4px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav-badge {
|
||||
margin-left: auto;
|
||||
background: #ff4d4f;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* 底部 */
|
||||
.sidebar-footer {
|
||||
padding: 12px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.nav-item.logout:hover {
|
||||
background: rgba(255, 77, 79, 0.2);
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
/* 主内容 */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
margin-left: 260px;
|
||||
padding: 24px;
|
||||
transition: margin-left 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar.collapsed + .main-content,
|
||||
.sidebar.collapsed ~ .main-content {
|
||||
margin-left: 80px;
|
||||
}
|
||||
|
||||
/* 动画 */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.sidebar .logo-text,
|
||||
.sidebar .nav-label,
|
||||
.sidebar .nav-badge {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-left: 80px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
244
frontend/src/pages/admin/login.vue
Normal file
244
frontend/src/pages/admin/login.vue
Normal file
@@ -0,0 +1,244 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import axios from 'axios'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const form = ref({
|
||||
username: '',
|
||||
password: ''
|
||||
})
|
||||
const loading = ref(false)
|
||||
|
||||
async function handleLogin() {
|
||||
if (!form.value.username || !form.value.password) {
|
||||
ElMessage.warning('请输入用户名和密码')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await axios.post('/api/admin/login', form.value)
|
||||
if (response.data.code === 0) {
|
||||
// 保存登录状态
|
||||
sessionStorage.setItem('adminToken', response.data.data.token)
|
||||
sessionStorage.setItem('adminUser', response.data.data.username)
|
||||
ElMessage.success('登录成功')
|
||||
router.push('/admin/dashboard')
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.response?.data?.detail || '登录失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-container">
|
||||
<!-- Logo -->
|
||||
<div class="logo-section">
|
||||
<div class="logo-icon">
|
||||
<el-icon :size="48"><DataAnalysis /></el-icon>
|
||||
</div>
|
||||
<h1>AI 面试管理后台</h1>
|
||||
<p class="subtitle">Interview Management System</p>
|
||||
</div>
|
||||
|
||||
<!-- 登录表单 -->
|
||||
<el-form class="login-form" @submit.prevent="handleLogin">
|
||||
<el-form-item>
|
||||
<el-input
|
||||
v-model="form.username"
|
||||
placeholder="用户名"
|
||||
prefix-icon="User"
|
||||
size="large"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
prefix-icon="Lock"
|
||||
size="large"
|
||||
show-password
|
||||
@keyup.enter="handleLogin"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="loading"
|
||||
class="login-btn"
|
||||
@click="handleLogin"
|
||||
>
|
||||
{{ loading ? '登录中...' : '登 录' }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<!-- 提示 -->
|
||||
<div class="hint">
|
||||
<el-tag type="info" size="small">默认账号: admin / admin</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 背景装饰 -->
|
||||
<div class="bg-decoration">
|
||||
<div class="circle circle-1"></div>
|
||||
<div class="circle circle-2"></div>
|
||||
<div class="circle circle-3"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
width: 400px;
|
||||
padding: 48px 40px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.4);
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
animation: slideUp 0.6s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.logo-section {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin: 0 auto 16px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
box-shadow: 0 10px 30px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.logo-section h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #1a1a2e;
|
||||
margin: 0 0 8px;
|
||||
font-family: 'Noto Sans SC', sans-serif;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
margin: 0;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.login-form :deep(.el-input__wrapper) {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.login-form :deep(.el-input__wrapper:hover) {
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.15);
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: none;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.login-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 30px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.hint {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 背景装饰 */
|
||||
.bg-decoration {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.circle {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.3), rgba(118, 75, 162, 0.3));
|
||||
filter: blur(60px);
|
||||
}
|
||||
|
||||
.circle-1 {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
top: -100px;
|
||||
right: -100px;
|
||||
animation: float 8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.circle-2 {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
bottom: -50px;
|
||||
left: -50px;
|
||||
animation: float 10s ease-in-out infinite reverse;
|
||||
}
|
||||
|
||||
.circle-3 {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
animation: float 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translate(0, 0); }
|
||||
50% { transform: translate(30px, 30px); }
|
||||
}
|
||||
</style>
|
||||
1134
frontend/src/pages/interview/call.vue
Normal file
1134
frontend/src/pages/interview/call.vue
Normal file
File diff suppressed because it is too large
Load Diff
82
frontend/src/pages/interview/complete.vue
Normal file
82
frontend/src/pages/interview/complete.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const candidateName = ref('')
|
||||
|
||||
onMounted(() => {
|
||||
candidateName.value = sessionStorage.getItem('candidateName') || '候选人'
|
||||
|
||||
// 清除 session 数据
|
||||
sessionStorage.removeItem('sessionId')
|
||||
sessionStorage.removeItem('fileId')
|
||||
sessionStorage.removeItem('candidateName')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="complete-page w-full max-w-lg text-center">
|
||||
<!-- 成功图标 -->
|
||||
<div class="mb-8">
|
||||
<div class="w-24 h-24 mx-auto bg-gradient-to-br from-green-400 to-green-600 rounded-full flex items-center justify-center shadow-lg">
|
||||
<el-icon :size="48" class="text-white"><CircleCheck /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 标题 -->
|
||||
<h1 class="text-3xl font-bold text-gray-800 mb-4">
|
||||
面试完成!
|
||||
</h1>
|
||||
|
||||
<!-- 感谢语 -->
|
||||
<p class="text-gray-600 mb-8 leading-relaxed">
|
||||
感谢您参加本次 AI 面试,{{ candidateName }}!<br />
|
||||
我们将尽快审核您的面试结果,<br />
|
||||
HR 会在 3 个工作日内与您联系。
|
||||
</p>
|
||||
|
||||
<!-- 后续说明 -->
|
||||
<div class="bg-white rounded-xl p-6 mb-8 shadow-sm text-left">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-4">后续流程</h3>
|
||||
<ul class="space-y-3 text-gray-600">
|
||||
<li class="flex items-start gap-3">
|
||||
<span class="flex-shrink-0 w-6 h-6 bg-blue-100 text-blue-600 rounded-full flex items-center justify-center text-sm font-medium">1</span>
|
||||
<span>系统将自动生成您的面试报告</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-3">
|
||||
<span class="flex-shrink-0 w-6 h-6 bg-blue-100 text-blue-600 rounded-full flex items-center justify-center text-sm font-medium">2</span>
|
||||
<span>HR 将审核您的面试表现</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-3">
|
||||
<span class="flex-shrink-0 w-6 h-6 bg-blue-100 text-blue-600 rounded-full flex items-center justify-center text-sm font-medium">3</span>
|
||||
<span>如果通过初试,我们将安排后续面试</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-3">
|
||||
<span class="flex-shrink-0 w-6 h-6 bg-blue-100 text-blue-600 rounded-full flex items-center justify-center text-sm font-medium">4</span>
|
||||
<span>请保持电话畅通,等待 HR 联系</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 提示 -->
|
||||
<p class="text-sm text-gray-400">
|
||||
如有问题,请联系 HR:hr@example.com
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.complete-page {
|
||||
animation: fadeIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
358
frontend/src/pages/interview/index.vue
Normal file
358
frontend/src/pages/interview/index.vue
Normal file
@@ -0,0 +1,358 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { candidateApi } from '@/api'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 表单数据
|
||||
const candidateName = ref('')
|
||||
const resumeFile = ref<File | null>(null)
|
||||
const isUploading = ref(false)
|
||||
const uploadedFileId = ref('')
|
||||
|
||||
// 面试模式:voice(语音)或 text(文字)
|
||||
const interviewMode = ref<'voice' | 'text'>('voice')
|
||||
|
||||
// 调试信息
|
||||
const showDebug = ref(true)
|
||||
const debugInfo = ref<any>(null)
|
||||
|
||||
// 处理文件选择
|
||||
function handleFileChange(file: any) {
|
||||
if (file.raw) {
|
||||
const rawFile = file.raw as File
|
||||
// 验证文件类型
|
||||
if (!rawFile.type.includes('pdf')) {
|
||||
ElMessage.error('请上传 PDF 格式的简历')
|
||||
return false
|
||||
}
|
||||
// 验证文件大小(最大 10MB)
|
||||
if (rawFile.size > 10 * 1024 * 1024) {
|
||||
ElMessage.error('文件大小不能超过 10MB')
|
||||
return false
|
||||
}
|
||||
resumeFile.value = rawFile
|
||||
}
|
||||
return false // 阻止自动上传
|
||||
}
|
||||
|
||||
// 移除文件
|
||||
function handleFileRemove() {
|
||||
resumeFile.value = null
|
||||
uploadedFileId.value = ''
|
||||
}
|
||||
|
||||
// 开始面试
|
||||
async function startInterview() {
|
||||
// 验证姓名
|
||||
if (!candidateName.value.trim()) {
|
||||
ElMessage.error('请输入您的姓名')
|
||||
return
|
||||
}
|
||||
|
||||
// 验证简历
|
||||
if (!resumeFile.value) {
|
||||
ElMessage.error('请上传您的简历')
|
||||
return
|
||||
}
|
||||
|
||||
isUploading.value = true
|
||||
debugInfo.value = { status: '开始初始化...', timestamp: new Date().toISOString() }
|
||||
|
||||
try {
|
||||
// 调用初始化面试 API(工作流 A)
|
||||
ElMessage.info('正在初始化面试,请稍候...')
|
||||
|
||||
debugInfo.value = {
|
||||
...debugInfo.value,
|
||||
step: '调用 initInterview API',
|
||||
inputName: candidateName.value.trim(),
|
||||
fileName: resumeFile.value.name,
|
||||
fileSize: resumeFile.value.size
|
||||
}
|
||||
|
||||
const response = await candidateApi.initInterview(
|
||||
candidateName.value.trim(),
|
||||
resumeFile.value
|
||||
)
|
||||
|
||||
// 记录完整响应
|
||||
debugInfo.value = {
|
||||
...debugInfo.value,
|
||||
step: 'API 响应成功',
|
||||
fullResponse: response,
|
||||
responseData: response.data,
|
||||
sessionId: response.data?.sessionId,
|
||||
name: response.data?.name,
|
||||
debugUrl: response.data?.debugUrl,
|
||||
workflowResponse: response.data?.workflowResponse
|
||||
}
|
||||
|
||||
const { sessionId, name } = response.data
|
||||
|
||||
if (!sessionId) {
|
||||
debugInfo.value.error = 'sessionId 为空!'
|
||||
ElMessage.error('初始化失败:sessionId 为空')
|
||||
return
|
||||
}
|
||||
|
||||
ElMessage.success('面试初始化成功')
|
||||
|
||||
// 保存信息到 sessionStorage,供 call 页面使用
|
||||
sessionStorage.setItem('candidateName', name)
|
||||
sessionStorage.setItem('sessionId', sessionId)
|
||||
sessionStorage.setItem('interviewMode', interviewMode.value)
|
||||
// 保存调试信息
|
||||
sessionStorage.setItem('initDebugInfo', JSON.stringify(debugInfo.value))
|
||||
|
||||
// 跳转到通话页
|
||||
router.push('/call')
|
||||
} catch (error: any) {
|
||||
console.error('Init interview error:', error)
|
||||
debugInfo.value = {
|
||||
...debugInfo.value,
|
||||
step: 'API 错误',
|
||||
error: error.message,
|
||||
errorResponse: error.response?.data,
|
||||
errorStatus: error.response?.status
|
||||
}
|
||||
ElMessage.error(error.message || '初始化失败,请重试')
|
||||
} finally {
|
||||
isUploading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="welcome-page w-full max-w-lg text-center">
|
||||
<!-- Logo 区域 -->
|
||||
<div class="mb-6">
|
||||
<div class="w-20 h-20 mx-auto bg-gradient-to-br from-blue-500 to-indigo-600 rounded-2xl flex items-center justify-center shadow-lg">
|
||||
<el-icon :size="40" class="text-white"><Microphone /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 标题 -->
|
||||
<h1 class="text-2xl font-bold text-gray-800 mb-2">
|
||||
欢迎参加 AI 面试
|
||||
</h1>
|
||||
<p class="text-gray-500 text-sm mb-6">
|
||||
请填写基本信息并上传简历
|
||||
</p>
|
||||
|
||||
<!-- 信息收集表单 -->
|
||||
<div class="bg-white rounded-xl p-6 mb-6 shadow-sm text-left">
|
||||
<!-- 姓名输入 -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
您的姓名 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<el-input
|
||||
v-model="candidateName"
|
||||
placeholder="请输入您的真实姓名"
|
||||
size="large"
|
||||
:disabled="isUploading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 面试模式选择 -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
面试模式 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div
|
||||
class="mode-card p-4 rounded-lg border-2 cursor-pointer transition-all"
|
||||
:class="interviewMode === 'voice' ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:border-gray-300'"
|
||||
@click="interviewMode = 'voice'"
|
||||
>
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<el-icon :size="20" :class="interviewMode === 'voice' ? 'text-blue-500' : 'text-gray-400'">
|
||||
<Microphone />
|
||||
</el-icon>
|
||||
<span class="font-medium" :class="interviewMode === 'voice' ? 'text-blue-600' : 'text-gray-700'">
|
||||
语音对话
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500">通过语音与 AI 面试官交流</p>
|
||||
</div>
|
||||
<div
|
||||
class="mode-card p-4 rounded-lg border-2 cursor-pointer transition-all"
|
||||
:class="interviewMode === 'text' ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:border-gray-300'"
|
||||
@click="interviewMode = 'text'"
|
||||
>
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<el-icon :size="20" :class="interviewMode === 'text' ? 'text-blue-500' : 'text-gray-400'">
|
||||
<ChatLineSquare />
|
||||
</el-icon>
|
||||
<span class="font-medium" :class="interviewMode === 'text' ? 'text-blue-600' : 'text-gray-700'">
|
||||
文字对话
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500">通过文字输入与 AI 面试官交流</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 简历上传 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
简历文件 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<el-upload
|
||||
class="resume-upload"
|
||||
drag
|
||||
:auto-upload="false"
|
||||
:on-change="handleFileChange"
|
||||
:on-remove="handleFileRemove"
|
||||
:limit="1"
|
||||
accept=".pdf"
|
||||
:disabled="isUploading"
|
||||
>
|
||||
<div v-if="!resumeFile" class="py-4">
|
||||
<el-icon class="text-4xl text-gray-400 mb-2"><UploadFilled /></el-icon>
|
||||
<div class="text-gray-500 text-sm">
|
||||
点击或拖拽上传简历
|
||||
</div>
|
||||
<div class="text-gray-400 text-xs mt-1">
|
||||
仅支持 PDF 格式,最大 10MB
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="py-4">
|
||||
<el-icon class="text-4xl text-green-500 mb-2"><Document /></el-icon>
|
||||
<div class="text-gray-700 text-sm">
|
||||
{{ resumeFile.name }}
|
||||
</div>
|
||||
<div class="text-gray-400 text-xs mt-1">
|
||||
{{ (resumeFile.size / 1024 / 1024).toFixed(2) }} MB
|
||||
</div>
|
||||
</div>
|
||||
</el-upload>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 准备事项 -->
|
||||
<div class="bg-gray-50 rounded-xl p-4 mb-6 text-left">
|
||||
<h3 class="text-sm font-medium text-gray-700 mb-2">面试须知</h3>
|
||||
<ul class="space-y-1 text-gray-500 text-xs">
|
||||
<li class="flex items-center gap-2">
|
||||
<el-icon class="text-green-500"><CircleCheck /></el-icon>
|
||||
<span>确保网络稳定、环境安静</span>
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<el-icon class="text-green-500"><CircleCheck /></el-icon>
|
||||
<span>面试时长约 15-20 分钟</span>
|
||||
</li>
|
||||
<li v-if="interviewMode === 'voice'" class="flex items-center gap-2">
|
||||
<el-icon class="text-green-500"><CircleCheck /></el-icon>
|
||||
<span>需要授权麦克风权限</span>
|
||||
</li>
|
||||
<li v-else class="flex items-center gap-2">
|
||||
<el-icon class="text-green-500"><CircleCheck /></el-icon>
|
||||
<span>请认真阅读问题并输入回答</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 开始按钮 -->
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
class="w-full h-12 text-base"
|
||||
:loading="isUploading"
|
||||
:disabled="!candidateName.trim() || !resumeFile"
|
||||
@click="startInterview"
|
||||
>
|
||||
{{ isUploading ? '正在上传...' : '开始面试' }}
|
||||
<el-icon v-if="!isUploading" class="ml-2"><ArrowRight /></el-icon>
|
||||
</el-button>
|
||||
|
||||
<!-- 底部提示 -->
|
||||
<p class="text-xs text-gray-400 mt-4">
|
||||
点击"开始面试"即表示您同意我们的隐私政策
|
||||
</p>
|
||||
|
||||
<!-- 调试面板 -->
|
||||
<div v-if="showDebug" class="debug-panel mt-6">
|
||||
<div class="debug-header">
|
||||
<span>🔧 调试信息</span>
|
||||
<el-switch v-model="showDebug" size="small" />
|
||||
</div>
|
||||
<div v-if="debugInfo" class="debug-content">
|
||||
<pre>{{ JSON.stringify(debugInfo, null, 2) }}</pre>
|
||||
</div>
|
||||
<div v-else class="debug-empty">
|
||||
等待操作...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.welcome-page {
|
||||
animation: fadeIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.resume-upload :deep(.el-upload-dragger) {
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.resume-upload :deep(.el-upload-list) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 调试面板 */
|
||||
.debug-panel {
|
||||
background: #1e1e2d;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.debug-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: #2d2d3d;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.debug-content {
|
||||
padding: 16px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.debug-content pre {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #a0f0a0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
.debug-empty {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
187
frontend/src/pages/interview/info.vue
Normal file
187
frontend/src/pages/interview/info.vue
Normal file
@@ -0,0 +1,187 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { User, UploadFilled, ArrowLeft } from '@element-plus/icons-vue'
|
||||
import type { FormInstance, FormRules, UploadFile, UploadUserFile } from 'element-plus'
|
||||
import { candidateApi } from '@/api'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 表单数据
|
||||
const formRef = ref<FormInstance>()
|
||||
const formData = reactive({
|
||||
name: '',
|
||||
})
|
||||
const fileList = ref<UploadUserFile[]>([])
|
||||
const uploading = ref(false)
|
||||
|
||||
// 表单验证规则
|
||||
const rules: FormRules = {
|
||||
name: [
|
||||
{ required: true, message: '请输入您的姓名', trigger: 'blur' },
|
||||
{ min: 2, max: 20, message: '姓名长度为 2-20 个字符', trigger: 'blur' },
|
||||
],
|
||||
}
|
||||
|
||||
// 文件变化处理
|
||||
function handleFileChange(_file: UploadFile, files: UploadUserFile[]) {
|
||||
// 只保留最后一个文件
|
||||
fileList.value = files.slice(-1)
|
||||
}
|
||||
|
||||
// 文件上传前验证
|
||||
function beforeUpload(file: File) {
|
||||
const allowedTypes = [
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
]
|
||||
const isAllowedType = allowedTypes.includes(file.type)
|
||||
const isLt10M = file.size / 1024 / 1024 < 10
|
||||
|
||||
if (!isAllowedType) {
|
||||
ElMessage.error('只支持 PDF、DOC、DOCX 格式的文件')
|
||||
return false
|
||||
}
|
||||
if (!isLt10M) {
|
||||
ElMessage.error('文件大小不能超过 10MB')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
async function handleSubmit() {
|
||||
if (!formRef.value) return
|
||||
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
|
||||
if (fileList.value.length === 0) {
|
||||
ElMessage.error('请上传您的简历')
|
||||
return
|
||||
}
|
||||
|
||||
uploading.value = true
|
||||
const file = fileList.value[0].raw as File
|
||||
|
||||
const response = await candidateApi.submit(formData.name, file)
|
||||
|
||||
// 存储 session 信息
|
||||
sessionStorage.setItem('sessionId', response.data.sessionId)
|
||||
sessionStorage.setItem('fileId', response.data.fileId)
|
||||
sessionStorage.setItem('candidateName', formData.name)
|
||||
|
||||
ElMessage.success('信息提交成功')
|
||||
router.push('/call')
|
||||
} catch (error) {
|
||||
console.error('Submit error:', error)
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="info-page w-full max-w-lg">
|
||||
<!-- 标题 -->
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-2xl font-bold text-gray-800 mb-2">填写基本信息</h1>
|
||||
<p class="text-gray-500">请填写您的姓名并上传简历</p>
|
||||
</div>
|
||||
|
||||
<!-- 表单 -->
|
||||
<div class="bg-white rounded-xl p-8 shadow-sm">
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
label-position="top"
|
||||
size="large"
|
||||
>
|
||||
<!-- 姓名 -->
|
||||
<el-form-item label="您的姓名" prop="name">
|
||||
<el-input
|
||||
v-model="formData.name"
|
||||
placeholder="请输入您的姓名"
|
||||
:prefix-icon="User"
|
||||
maxlength="20"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 简历上传 -->
|
||||
<el-form-item label="上传简历" required>
|
||||
<el-upload
|
||||
v-model:file-list="fileList"
|
||||
class="w-full"
|
||||
drag
|
||||
:auto-upload="false"
|
||||
:limit="1"
|
||||
accept=".pdf,.doc,.docx"
|
||||
:before-upload="beforeUpload"
|
||||
:on-change="handleFileChange"
|
||||
>
|
||||
<div class="py-8">
|
||||
<el-icon class="text-4xl text-gray-400 mb-4"><UploadFilled /></el-icon>
|
||||
<div class="text-gray-600">
|
||||
将文件拖到此处,或 <span class="text-blue-500">点击上传</span>
|
||||
</div>
|
||||
<div class="text-gray-400 text-sm mt-2">
|
||||
支持 PDF、DOC、DOCX 格式,大小不超过 10MB
|
||||
</div>
|
||||
</div>
|
||||
</el-upload>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 提交按钮 -->
|
||||
<el-form-item class="mb-0 mt-6">
|
||||
<el-button
|
||||
type="primary"
|
||||
class="w-full h-12"
|
||||
:loading="uploading"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
{{ uploading ? '正在处理...' : '提交并开始面试' }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 返回按钮 -->
|
||||
<div class="text-center mt-6">
|
||||
<el-button text @click="router.push('/')">
|
||||
<el-icon class="mr-1"><ArrowLeft /></el-icon>
|
||||
返回上一步
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.info-page {
|
||||
animation: fadeIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-upload-dragger) {
|
||||
border: 2px dashed #e5e7eb;
|
||||
border-radius: 12px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
:deep(.el-upload-dragger:hover) {
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
</style>
|
||||
90
frontend/src/router/index.ts
Normal file
90
frontend/src/router/index.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
// 用户端路由
|
||||
{
|
||||
path: '/',
|
||||
name: 'Interview',
|
||||
component: () => import('@/layouts/InterviewLayout.vue'),
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'Welcome',
|
||||
component: () => import('@/pages/interview/index.vue'),
|
||||
meta: { title: '欢迎' },
|
||||
},
|
||||
{
|
||||
path: 'info',
|
||||
name: 'InfoCollection',
|
||||
component: () => import('@/pages/interview/info.vue'),
|
||||
meta: { title: '信息填写' },
|
||||
},
|
||||
{
|
||||
path: 'call',
|
||||
name: 'Call',
|
||||
component: () => import('@/pages/interview/call.vue'),
|
||||
meta: { title: '语音面试' },
|
||||
},
|
||||
{
|
||||
path: 'complete',
|
||||
name: 'Complete',
|
||||
component: () => import('@/pages/interview/complete.vue'),
|
||||
meta: { title: '面试完成' },
|
||||
},
|
||||
],
|
||||
},
|
||||
// 管理后台路由
|
||||
{
|
||||
path: '/admin/login',
|
||||
name: 'AdminLogin',
|
||||
component: () => import('@/pages/admin/login.vue'),
|
||||
meta: { title: '管理员登录' },
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
name: 'Admin',
|
||||
component: () => import('@/pages/admin/layout.vue'),
|
||||
redirect: '/admin/dashboard',
|
||||
children: [
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'Dashboard',
|
||||
component: () => import('@/pages/admin/dashboard.vue'),
|
||||
meta: { title: '数据概览' },
|
||||
},
|
||||
{
|
||||
path: 'interviews',
|
||||
name: 'InterviewList',
|
||||
component: () => import('@/pages/admin/interviews.vue'),
|
||||
meta: { title: '面试列表' },
|
||||
},
|
||||
{
|
||||
path: 'interviews/:id',
|
||||
name: 'InterviewDetail',
|
||||
component: () => import('@/pages/admin/interview-detail.vue'),
|
||||
meta: { title: '面试详情' },
|
||||
},
|
||||
{
|
||||
path: 'configs',
|
||||
name: 'Configs',
|
||||
component: () => import('@/pages/admin/configs.vue'),
|
||||
meta: { title: '配置管理' },
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
// 路由守卫 - 设置页面标题
|
||||
router.beforeEach((to, _from, next) => {
|
||||
const title = to.meta.title as string
|
||||
document.title = title ? `${title} - AI面试助手` : 'AI面试助手'
|
||||
next()
|
||||
})
|
||||
|
||||
export default router
|
||||
109
frontend/src/styles/index.css
Normal file
109
frontend/src/styles/index.css
Normal file
@@ -0,0 +1,109 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* 全局样式 */
|
||||
:root {
|
||||
--primary-color: #3b82f6;
|
||||
--success-color: #22c55e;
|
||||
--danger-color: #ef4444;
|
||||
--warning-color: #f59e0b;
|
||||
--text-color: #1f2937;
|
||||
--text-secondary: #6b7280;
|
||||
--bg-color: #f9fafb;
|
||||
--border-color: #e5e7eb;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
/* 来电动画 */
|
||||
@keyframes ring {
|
||||
0%, 100% {
|
||||
transform: rotate(-15deg);
|
||||
}
|
||||
50% {
|
||||
transform: rotate(15deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-ring {
|
||||
animation: ring 0.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* 音波动画 */
|
||||
.audio-wave {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.audio-wave span {
|
||||
width: 4px;
|
||||
height: 16px;
|
||||
background-color: var(--primary-color);
|
||||
border-radius: 2px;
|
||||
animation: wave 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.audio-wave span:nth-child(2) {
|
||||
animation-delay: 0.1s;
|
||||
}
|
||||
|
||||
.audio-wave span:nth-child(3) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.audio-wave span:nth-child(4) {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
.audio-wave span:nth-child(5) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
@keyframes wave {
|
||||
0%, 100% {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
50% {
|
||||
transform: scaleY(2);
|
||||
}
|
||||
}
|
||||
|
||||
/* 脉冲动画(来电) */
|
||||
.pulse-ring {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.pulse-ring::before,
|
||||
.pulse-ring::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -20px;
|
||||
border: 3px solid var(--success-color);
|
||||
border-radius: 50%;
|
||||
animation: pulse-expand 1.5s ease-out infinite;
|
||||
}
|
||||
|
||||
.pulse-ring::after {
|
||||
animation-delay: 0.5s;
|
||||
}
|
||||
|
||||
@keyframes pulse-expand {
|
||||
0% {
|
||||
transform: scale(0.8);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.5);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
16
frontend/src/types/env.d.ts
vendored
Normal file
16
frontend/src/types/env.d.ts
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL: string
|
||||
readonly VITE_RTC_APP_ID: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<object, object, unknown>
|
||||
export default component
|
||||
}
|
||||
45
frontend/src/types/index.ts
Normal file
45
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
// 面试状态
|
||||
export type InterviewStatus = 'pending' | 'ongoing' | 'completed'
|
||||
|
||||
// 面试阶段
|
||||
export enum InterviewStage {
|
||||
InfoCollection = 10,
|
||||
SalesSkill = 20,
|
||||
SalesMindset = 30,
|
||||
Quality = 40,
|
||||
Motivation = 50,
|
||||
Completed = 60,
|
||||
}
|
||||
|
||||
// 面试阶段名称
|
||||
export const InterviewStageLabel: Record<number, string> = {
|
||||
[InterviewStage.InfoCollection]: '信息收集',
|
||||
[InterviewStage.SalesSkill]: '销售技能',
|
||||
[InterviewStage.SalesMindset]: '销售观',
|
||||
[InterviewStage.Quality]: '素质项',
|
||||
[InterviewStage.Motivation]: '求职动机',
|
||||
[InterviewStage.Completed]: '面试完成',
|
||||
}
|
||||
|
||||
// 评分维度
|
||||
export interface Scores {
|
||||
salesSkill: number
|
||||
salesMindset: number
|
||||
quality: number
|
||||
motivation: number
|
||||
total: number
|
||||
}
|
||||
|
||||
// 评分维度名称
|
||||
export const ScoreLabels: Record<keyof Omit<Scores, 'total'>, string> = {
|
||||
salesSkill: '销售技能',
|
||||
salesMindset: '销售观',
|
||||
quality: '素质项',
|
||||
motivation: '求职动机',
|
||||
}
|
||||
|
||||
// 用户端面试流程状态
|
||||
export type InterviewFlowStep = 'welcome' | 'info' | 'incoming' | 'incall' | 'complete'
|
||||
|
||||
// RTC 连接状态
|
||||
export type RTCConnectionState = 'disconnected' | 'connecting' | 'connected' | 'failed'
|
||||
40
frontend/tailwind.config.js
Normal file
40
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,40 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
'./index.html',
|
||||
'./src/**/*.{vue,js,ts,jsx,tsx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'pulse-ring': 'pulse-ring 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||
'wave': 'wave 1s ease-in-out infinite',
|
||||
},
|
||||
keyframes: {
|
||||
'pulse-ring': {
|
||||
'0%, 100%': { transform: 'scale(1)', opacity: '1' },
|
||||
'50%': { transform: 'scale(1.1)', opacity: '0.7' },
|
||||
},
|
||||
'wave': {
|
||||
'0%, 100%': { transform: 'scaleY(1)' },
|
||||
'50%': { transform: 'scaleY(1.5)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
31
frontend/tsconfig.json
Normal file
31
frontend/tsconfig.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
/* Path alias */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
11
frontend/tsconfig.node.json
Normal file
11
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
1
frontend/tsconfig.node.tsbuildinfo
Normal file
1
frontend/tsconfig.node.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
1
frontend/tsconfig.tsbuildinfo
Normal file
1
frontend/tsconfig.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
||||
{"root":["./src/main.ts","./src/api/candidate.ts","./src/api/index.ts","./src/api/request.ts","./src/composables/index.ts","./src/composables/usecozerealtime.ts","./src/composables/usertc.ts","./src/router/index.ts","./src/types/env.d.ts","./src/types/index.ts","./src/app.vue","./src/layouts/adminlayout.vue","./src/layouts/interviewlayout.vue","./src/pages/admin/[id].vue","./src/pages/admin/configs.vue","./src/pages/admin/dashboard.vue","./src/pages/admin/index.vue","./src/pages/admin/interview-detail.vue","./src/pages/admin/interviews.vue","./src/pages/admin/layout.vue","./src/pages/admin/login.vue","./src/pages/interview/call.vue","./src/pages/interview/complete.vue","./src/pages/interview/index.vue","./src/pages/interview/info.vue"],"version":"5.9.3"}
|
||||
2
frontend/vite.config.d.ts
vendored
Normal file
2
frontend/vite.config.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
declare const _default: import("vite").UserConfig;
|
||||
export default _default;
|
||||
24
frontend/vite.config.js
Normal file
24
frontend/vite.config.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import { resolve } from 'path';
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
target: ['chrome90', 'edge90', 'firefox90', 'safari14'],
|
||||
},
|
||||
});
|
||||
25
frontend/vite.config.ts
Normal file
25
frontend/vite.config.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
target: ['chrome90', 'edge90', 'firefox90', 'safari14'],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user