Initial commit: AI Interview System

This commit is contained in:
111
2026-01-23 13:57:48 +08:00
commit 95770afe21
127 changed files with 24686 additions and 0 deletions

BIN
frontend/dist.tar.gz Normal file

Binary file not shown.

13
frontend/index.html Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

10
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,10 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
</script>
<template>
<RouterView />
</template>
<style scoped>
</style>

View 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
View File

@@ -0,0 +1,11 @@
export { request } from './request'
export { candidateApi } from './candidate'
export type {
SubmitCandidateResponse,
CreateRoomRequest,
CreateRoomResponse,
Candidate,
CandidateDetail,
CandidateListResponse,
CandidateListParams,
} from './candidate'

View 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

View File

@@ -0,0 +1 @@
export { useRTC } from './useRTC'

View 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-115% 过滤环境噪音和回声
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 结束(说完了)
}
}

View 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 BotCoze 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}`)
}
}
}

View 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>

View 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
View 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')

View 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>

View 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>

View 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>

View 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>

File diff suppressed because it is too large Load Diff

View 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>

View 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>

View 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>

File diff suppressed because it is too large Load Diff

View 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">
如有问题请联系 HRhr@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>

View 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>

View 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">
支持 PDFDOCDOCX 格式大小不超过 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>

View 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

View 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
View 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
}

View 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'

View 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
View 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" }]
}

View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

File diff suppressed because one or more lines are too long

View 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
View File

@@ -0,0 +1,2 @@
declare const _default: import("vite").UserConfig;
export default _default;

24
frontend/vite.config.js Normal file
View 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
View 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'],
},
})