feat(aihr): add candidate voice interview answers
This commit is contained in:
+8
-2
@@ -17,7 +17,10 @@ public final class AihrInterviewDto {
|
||||
public record QuestionResponse(String questionId, Integer seq, String questionText, String point, String scenario, String sampleAnswer) {
|
||||
}
|
||||
|
||||
public record AnswerRequest(String sessionId, String questionId, String answerText, String answerAudioUrl) {
|
||||
public record AnswerRequest(String sessionId, String questionId, String answerText, String answerAudioUrl, Long answerAudioOssId) {
|
||||
public AnswerRequest(String sessionId, String questionId, String answerText, String answerAudioUrl) {
|
||||
this(sessionId, questionId, answerText, answerAudioUrl, null);
|
||||
}
|
||||
}
|
||||
|
||||
public record AnswerResponse(Integer score, Map<String, Integer> dimensions, String aiComment) {
|
||||
@@ -50,7 +53,10 @@ public final class AihrInterviewDto {
|
||||
public record DimensionResponse(String label, Object value, String note) {
|
||||
}
|
||||
|
||||
public record QuestionRecordResponse(Integer seq, String question, String answer, String rating) {
|
||||
public record QuestionRecordResponse(Integer seq, String question, String answer, String rating, Long audioOssId) {
|
||||
public QuestionRecordResponse(Integer seq, String question, String answer, String rating) {
|
||||
this(seq, question, answer, rating, null);
|
||||
}
|
||||
}
|
||||
|
||||
public record RecordResponse(
|
||||
|
||||
+34
-4
@@ -19,6 +19,7 @@ import org.dromara.aihr.domain.AihrInterviewDto.RecordResponse;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.ReviewRequest;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.StartRequest;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.StartResponse;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -59,7 +60,7 @@ public class AihrInterviewService {
|
||||
);
|
||||
GeneratedQuestions generated = generateQuestions(candidate).orElseGet(() -> new GeneratedQuestions(candidate.questions(), "local-rubric"));
|
||||
String sessionId = "iv-" + UUID.randomUUID();
|
||||
sessions.put(sessionId, new InterviewSession(tenantId(), candidate, generated.questions(), new ConcurrentHashMap<>(), generated.source()));
|
||||
sessions.put(sessionId, new InterviewSession(tenantId(), candidate, generated.questions(), new ConcurrentHashMap<>(), new ConcurrentHashMap<>(), generated.source()));
|
||||
return new StartResponse(sessionId, candidate.id(), candidate.name(), candidate.position(), generated.questions(), generated.source());
|
||||
}
|
||||
|
||||
@@ -74,9 +75,15 @@ public class AihrInterviewService {
|
||||
if (!isBlank(ownerIdentity)) {
|
||||
requireSessionOwner(session, ownerIdentity);
|
||||
}
|
||||
validateAnswerAudioOwnership(request == null ? null : request.answerAudioOssId(), ownerIdentity);
|
||||
if (session != null && request != null && !isBlank(request.questionId())) {
|
||||
requireKnownQuestion(session, request.questionId());
|
||||
session.answers().put(request.questionId(), answerText);
|
||||
if (request.answerAudioOssId() == null) {
|
||||
session.answerAudioOssIds().remove(request.questionId());
|
||||
} else {
|
||||
session.answerAudioOssIds().put(request.questionId(), request.answerAudioOssId());
|
||||
}
|
||||
}
|
||||
int baseScore = answerText.isBlank() ? 0 : Math.min(92, 62 + Math.min(answerText.length() / 4, 30));
|
||||
return new AnswerResponse(
|
||||
@@ -98,7 +105,7 @@ public class AihrInterviewService {
|
||||
sessionKey,
|
||||
key -> {
|
||||
CandidateProfile candidate = resolveCandidate(null, null, null, key);
|
||||
return new InterviewSession(tenantId(), candidate, candidate.questions(), new ConcurrentHashMap<>(), "local-rubric");
|
||||
return new InterviewSession(tenantId(), candidate, candidate.questions(), new ConcurrentHashMap<>(), new ConcurrentHashMap<>(), "local-rubric");
|
||||
}
|
||||
);
|
||||
} else {
|
||||
@@ -463,7 +470,8 @@ public class AihrInterviewService {
|
||||
return session.questions().stream().map(question -> {
|
||||
String answer = clean(session.answers().get(question.questionId()));
|
||||
String rating = answer.length() >= 32 ? "优秀" : answer.length() >= 12 ? "良好" : "待提升";
|
||||
return new QuestionRecordResponse(question.seq(), question.questionText(), answer.isBlank() ? "未回答" : answer, rating);
|
||||
return new QuestionRecordResponse(question.seq(), question.questionText(), answer.isBlank() ? "未回答" : answer, rating,
|
||||
session.answerAudioOssIds().get(question.questionId()));
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@@ -641,6 +649,28 @@ public class AihrInterviewService {
|
||||
}
|
||||
}
|
||||
|
||||
private void validateAnswerAudioOwnership(Long audioOssId, String ownerIdentity) {
|
||||
if (audioOssId == null) {
|
||||
return;
|
||||
}
|
||||
if (isBlank(ownerIdentity)) {
|
||||
throw new ServiceException("录音归属校验失败,请重新录音后提交");
|
||||
}
|
||||
try {
|
||||
Integer count = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM aihr_practice_audio_upload
|
||||
WHERE tenant_id = ? AND oss_id = ? AND owner_identity = ?
|
||||
AND status IN ('staged', 'bound')
|
||||
""", Integer.class, tenantId(), audioOssId, ownerIdentity.trim());
|
||||
if (count == null || count == 0) {
|
||||
throw new ServiceException("录音归属校验失败,请重新录音后提交");
|
||||
}
|
||||
} catch (DataAccessException error) {
|
||||
throw new ServiceException("录音归属校验失败,请重新录音后提交");
|
||||
}
|
||||
}
|
||||
|
||||
private CandidateProfile dynamicCandidate(String candidateId, String candidateName, String positionCode) {
|
||||
CandidateProfile template = candidates.get("zhang-ming");
|
||||
String cleanId = clean(candidateId);
|
||||
@@ -758,7 +788,7 @@ public class AihrInterviewService {
|
||||
}
|
||||
|
||||
private record InterviewSession(String tenantId, CandidateProfile candidate, List<QuestionResponse> questions,
|
||||
Map<String, String> answers, String source) {
|
||||
Map<String, String> answers, Map<String, Long> answerAudioOssIds, String source) {
|
||||
}
|
||||
|
||||
private record GeneratedQuestions(List<QuestionResponse> questions, String source) {
|
||||
|
||||
+20
@@ -8,6 +8,7 @@ import org.dromara.aihr.domain.AihrInterviewDto.FinishRequest;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.RecordResponse;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.ReviewRequest;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.StartRequest;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
@@ -334,6 +335,25 @@ class AihrInterviewServiceTest {
|
||||
service.answer(new AnswerRequest(ownerSession.sessionId(), "q1", "本人回答", null), "candidate-a");
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("dev")
|
||||
void interviewAnswerRejectsAudioOwnedByAnotherIdentity() {
|
||||
JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
|
||||
AihrModelSeedService modelService = mock(AihrModelSeedService.class);
|
||||
when(modelService.tryChat(anyString(), anyString(), anyDouble())).thenReturn(Optional.empty());
|
||||
AihrInterviewService service = new AihrInterviewService(new ObjectMapper(), modelService, jdbcTemplate);
|
||||
var started = service.start(new StartRequest("candidate-audio", "候选人", "customer-service", "mobile"));
|
||||
when(jdbcTemplate.queryForObject(anyString(), eq(Integer.class), eq("000000"), eq(9002L), eq("candidate-audio")))
|
||||
.thenReturn(0);
|
||||
|
||||
ServiceException error = assertThrows(ServiceException.class, () -> service.answer(
|
||||
new AnswerRequest(started.sessionId(), "q1", "语音回答", null, 9002L), "candidate-audio"
|
||||
));
|
||||
|
||||
assertEquals("录音归属校验失败,请重新录音后提交", error.getMessage());
|
||||
verify(jdbcTemplate).queryForObject(anyString(), eq(Integer.class), eq("000000"), eq(9002L), eq("candidate-audio"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("dev")
|
||||
void interviewAnswerRejectsUnknownSessionForSystemFlow() {
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
| 用户侧三端首页 `mobile-uni` hash 路由;旧 `/h5/user`、`/h5/candidate`、`/h5/supervisor` 兼容重定向 | `GET /api/aihr/mobile/home/{role}` | 未登录请求只返回不读取租户业务统计的公开首屏 seed;已登录移动端请求自动携带 `Authorization/clientid`,才返回员工/主管真实统计;移动端本地 fallback 保演示 |
|
||||
| 移动端登录后角色识别 | `GET /api/aihr/mobile/me` | 认证后按手机号匹配组织快照;`position_level` 为“主管/项目经理”时进入主管端,否则进入员工端;接口失败回退员工端 |
|
||||
| 移动端员工训练与主管复盘闭环 | 员工复用 `POST /api/train/practice/start`、`/turn`、`/finish`,查询 `GET /api/aihr/mobile/practice/history`、`/practice/mistakes`、`/profile`;训练完成后提交 `POST /api/aihr/mobile/practice/satisfaction`;主管查询 `GET /api/aihr/mobile/practice/team`、`/practice/alerts`、`/practice/reviews`、`/practice/reviews/{id}`,标记 `POST /api/aihr/mobile/practice/reviews/{id}/reviewed`,指派 `POST /api/aihr/mobile/practice/assignments` | 员工端登录后带 `Authorization` 与 `clientid` 调用;`mode=mobile` 完成后写入 `aihr_practice_session`。满意度接口只接受本人已完成训练的 1-5 分,意见脱敏后落库,未填写不补默认值。错题本按员工本人聚合低分/红线回合,并关联已有 `retry` assignment,不伪造错题结论。主管 `/practice/team` 在同一项目权限范围内额外返回 `mistakes` 聚合,按场景/归因统计次数、影响人数、平均分和最近发生时间;普通员工返回“无主管权限”。主管接口以后端当前登录手机号映射在职组织快照,仅允许岗位为“主管/项目经理”的账号,并按租户和项目范围返回真实成员、全状态训练记录及非 daily 专项。复盘标记只允许首次 `待复盘 -> 已复盘` 创建后续专项,并发重复提交幂等;可带 `incentivePoint` 写入贡献度。每日三题正式只对 `hire_date` 在当前日期前三个月内的在职员工派发;没有入职日期时不使用训练次数推断,开发 Demo 的旧回退只有在 `dev/local` profile 且由 `aihr.practice.allow-legacy-daily-drill-fallback` 显式开启时生效,生产 profile 强制关闭。 |
|
||||
| 移动端候选人闭环 | 页面拆为 `/pages/candidate/index/index`、`/interview/index`、`/materials/index`、`/progress/index`、`/study/index`;面试复用 `POST /api/recruit/interview/start`、`/answer`、`/finish` 和 `GET /records`;资料 `POST/GET /api/aihr/mobile/candidate/materials`;预习 `POST /api/knowledge/search`;HR 审核 `GET /api/aihr/hr/candidate/materials`、`POST /api/aihr/hr/candidate/materials/{id}/review` | 候选人端登录后带 `Authorization` 与 `clientid` 调用;首页只按当前手机号对应的真实面试记录和最新资料状态分流,不读取公开 home seed。APP 候选人身份以后端登录手机号为准,前端 `candidateId/candidateName` 只作非 APP 场景兼容参数;`/answer` 与 `/finish` 还会校验当前 APP 手机号与启动会话的候选人 ID 一致,未知或他人会话直接拒绝;面试拉题/评分走真实模型优先;资料写 `sys_oss`/MinIO 和 `aihr_candidate_material`,HR 审核后进度页同步三态;岗前预习查询正式 SOP,不兜前端示例答案 |
|
||||
| 移动端候选人闭环 | 页面拆为 `/pages/candidate/index/index`、`/interview/index`、`/materials/index`、`/progress/index`、`/study/index`;面试复用 `POST /api/recruit/interview/start`、`/answer`、`/finish` 和 `GET /records`;资料 `POST/GET /api/aihr/mobile/candidate/materials`;预习 `POST /api/knowledge/search`;HR 审核 `GET /api/aihr/hr/candidate/materials`、`POST /api/aihr/hr/candidate/materials/{id}/review` | 候选人端登录后带 `Authorization` 与 `clientid` 调用;首页只按当前手机号对应的真实面试记录和最新资料状态分流,不读取公开 home seed。APP 候选人身份以后端登录手机号为准,前端 `candidateId/candidateName` 只作非 APP 场景兼容参数;面试支持文字作答和复用 `/api/ai/asr` 的录音转文字/选音频,`/answer` 可带可选 `answerAudioOssId`,服务端只接受当前候选人名下处于 `staged/bound` 状态的音频并将 `ossId` 写入问题快照,不向客户端暴露原始 OSS URL;`/answer` 与 `/finish` 还会校验当前 APP 手机号与启动会话的候选人 ID 一致,未知或他人会话直接拒绝;面试拉题/评分走真实模型优先;资料写 `sys_oss`/MinIO 和 `aihr_candidate_material`,HR 审核后进度页同步三态;岗前预习查询正式 SOP,不兜前端示例答案 |
|
||||
|
||||
## 后端落点
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# BRD 功能审查与未完成项记录
|
||||
|
||||
- 2026-07-15 BRD 4.1 候选人语音/文本作答补齐:候选人面试页新增“录音转文字”和“选择音频”,复用现有 `/api/ai/asr`、MediaRecorder 与文件选择兜底;`/answer` 接受可选 `answerAudioOssId`,服务端校验音频处于当前 APP 身份的 `staged/bound` 状态后,才把 `ossId` 写入问题快照,既保留旧文字客户端兼容,也不向客户端暴露原始 OSS URL。补充归属拒绝单测、候选人页面单测和 demo marker,待本轮构建验证后再提交。
|
||||
|
||||
- 2026-07-15 BRD 4.1 候选人进度状态修正:候选人进度页此前用最近一份补充资料的审核状态作为“面试人工复核”状态,可能在面试已复核但资料未提交时误显示“人工复核中”;现改为面试状态读取 `InterviewRecord.reviewStatus`,并将补充资料审核单独展示为未提交/待审核/已通过/已驳回。仅修正移动端展示与类型契约,不改 API、数据库或审核结论;`mobile-uni` 类型检查、单元测试 `56/56` 和 H5 构建通过。
|
||||
|
||||
- 2026-07-15 BRD 4.2/P1 正式任务与资格只读桥接:复核确认 `aihr_onboard_task`、`aihr_qualification_gate` 已有结构但员工端没有读取入口;现新增登录 APP 用户范围内的 `GET /api/aihr/mobile/onboard/tasks` 与 `GET /api/aihr/mobile/qualification`,通过组织快照把手机号解析到正式 `ext_party_id`,不接受前端身份参数。`mobile-uni` 学习页新增正式岗前/入职任务和资格状态展示;空表明确显示“未配置”,不生成任务、不把 AI 训练分数当资格结论。该批次未写入生产数据,正式任务/资格规则仍需 HR 与组织系统提供。
|
||||
|
||||
@@ -48,8 +48,29 @@
|
||||
:disabled="interviewStatus !== 'active'"
|
||||
placeholder="输入你的真实回答"
|
||||
/>
|
||||
<view class="question-audio-actions">
|
||||
<button
|
||||
class="text-button"
|
||||
:disabled="interviewBusy || Boolean(transcribingQuestionId) || (Boolean(recordingQuestionId) && recordingQuestionId !== item.questionId)"
|
||||
@click="toggleQuestionRecording(item.questionId)"
|
||||
>
|
||||
{{ recordingQuestionId === item.questionId ? '停止录音' : '录音转文字' }}
|
||||
</button>
|
||||
<button
|
||||
class="text-button"
|
||||
:disabled="interviewBusy || Boolean(transcribingQuestionId) || Boolean(recordingQuestionId)"
|
||||
@click="pickQuestionAudio(item.questionId)"
|
||||
>
|
||||
{{ transcribingQuestionId === item.questionId ? '转写中' : '选择音频' }}
|
||||
</button>
|
||||
<text v-if="interviewAudioOssIds[item.questionId]" class="audio-state">已附带语音留痕</text>
|
||||
</view>
|
||||
<button class="primary-button finish-button" :disabled="interviewStatus !== 'active'" @click="finishInterview">
|
||||
</view>
|
||||
<button
|
||||
class="primary-button finish-button"
|
||||
:disabled="interviewStatus !== 'active' || Boolean(recordingQuestionId) || Boolean(transcribingQuestionId)"
|
||||
@click="finishInterview"
|
||||
>
|
||||
{{ interviewStatus === 'submitting' ? '评分中' : '提交评分' }}
|
||||
</button>
|
||||
</view>
|
||||
@@ -100,7 +121,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import type { InterviewFinishResponse, InterviewQuestion, InterviewRecord } from '@/types/api';
|
||||
import type { AsrResponse, InterviewFinishResponse, InterviewQuestion, InterviewRecord } from '@/types/api';
|
||||
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||
import {
|
||||
finishCandidateInterview,
|
||||
@@ -110,17 +131,23 @@ import {
|
||||
} from '@/services/candidate';
|
||||
import { candidateRoutes } from '@/services/candidate-view';
|
||||
import { resetPageScroll } from '@/services/navigation';
|
||||
import { chooseSpeechAudio, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech';
|
||||
|
||||
const currentPath = '/pages/candidate/interview/index';
|
||||
const records = ref<InterviewRecord[]>([]);
|
||||
const interviewQuestions = ref<InterviewQuestion[]>([]);
|
||||
const interviewAnswers = ref<Record<string, string>>({});
|
||||
const interviewAudioOssIds = ref<Record<string, number | undefined>>({});
|
||||
const interviewResult = ref<InterviewFinishResponse | null>(null);
|
||||
const interviewSessionId = ref('');
|
||||
const interviewStatus = ref<'idle' | 'starting' | 'active' | 'submitting' | 'finished'>('idle');
|
||||
const recordsLoading = ref(false);
|
||||
const loggedIn = ref(false);
|
||||
const message = ref('');
|
||||
const transcribingQuestionId = ref('');
|
||||
const recordingQuestionId = ref('');
|
||||
const mediaRecorder = ref<MediaRecorder | null>(null);
|
||||
const mediaStream = ref<MediaStream | null>(null);
|
||||
|
||||
const interviewBusy = computed(() => interviewStatus.value === 'starting' || interviewStatus.value === 'submitting');
|
||||
const answeredCount = computed(() => interviewQuestions.value.filter((item) =>
|
||||
@@ -131,8 +158,10 @@ const refreshAuthState = () => {
|
||||
};
|
||||
|
||||
const clearDraft = () => {
|
||||
cancelQuestionRecording();
|
||||
interviewQuestions.value = [];
|
||||
interviewAnswers.value = {};
|
||||
interviewAudioOssIds.value = {};
|
||||
interviewResult.value = null;
|
||||
interviewSessionId.value = '';
|
||||
interviewStatus.value = 'idle';
|
||||
@@ -181,6 +210,7 @@ const startInterview = async () => {
|
||||
interviewStatus.value = 'starting';
|
||||
interviewQuestions.value = [];
|
||||
interviewAnswers.value = {};
|
||||
interviewAudioOssIds.value = {};
|
||||
interviewResult.value = null;
|
||||
interviewSessionId.value = '';
|
||||
message.value = '';
|
||||
@@ -189,6 +219,7 @@ const startInterview = async () => {
|
||||
interviewSessionId.value = data.sessionId;
|
||||
interviewQuestions.value = data.questions || [];
|
||||
interviewAnswers.value = Object.fromEntries(interviewQuestions.value.map((item) => [item.questionId, '']));
|
||||
interviewAudioOssIds.value = {};
|
||||
interviewStatus.value = 'active';
|
||||
message.value = `已生成 ${interviewQuestions.value.length} 道题`;
|
||||
} catch (error) {
|
||||
@@ -197,12 +228,126 @@ const startInterview = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const stopQuestionMediaTracks = () => {
|
||||
mediaStream.value?.getTracks().forEach((track) => track.stop());
|
||||
mediaStream.value = null;
|
||||
};
|
||||
|
||||
const cancelQuestionRecording = () => {
|
||||
const recorder = mediaRecorder.value;
|
||||
if (recorder && recorder.state !== 'inactive') {
|
||||
recorder.ondataavailable = null;
|
||||
recorder.onstop = null;
|
||||
recorder.stop();
|
||||
}
|
||||
mediaRecorder.value = null;
|
||||
stopQuestionMediaTracks();
|
||||
recordingQuestionId.value = '';
|
||||
};
|
||||
|
||||
const applyQuestionAsrResult = (questionId: string, result: AsrResponse) => {
|
||||
const text = (result.text || '').trim();
|
||||
if (!text) throw new Error('语音没有识别到文字');
|
||||
interviewAnswers.value = { ...interviewAnswers.value, [questionId]: text };
|
||||
const ossId = Number(result.ossId);
|
||||
interviewAudioOssIds.value = {
|
||||
...interviewAudioOssIds.value,
|
||||
[questionId]: Number.isFinite(ossId) ? ossId : undefined
|
||||
};
|
||||
message.value = '语音已转成文字,请确认后提交';
|
||||
};
|
||||
|
||||
const transcribeQuestionFile = async (questionId: string) => {
|
||||
if (interviewBusy.value || transcribingQuestionId.value) return;
|
||||
transcribingQuestionId.value = questionId;
|
||||
message.value = '语音转写中';
|
||||
try {
|
||||
applyQuestionAsrResult(questionId, await transcribeSpeechFile(await chooseSpeechAudio()));
|
||||
} catch (error) {
|
||||
handleRequestError(error, '语音转写失败');
|
||||
} finally {
|
||||
transcribingQuestionId.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const transcribeQuestionBlob = async (questionId: string, blob: Blob) => {
|
||||
if (transcribingQuestionId.value) return;
|
||||
transcribingQuestionId.value = questionId;
|
||||
message.value = '语音转写中';
|
||||
try {
|
||||
applyQuestionAsrResult(questionId, await transcribeSpeechBlob(blob));
|
||||
} catch (error) {
|
||||
handleRequestError(error, '语音转写失败');
|
||||
} finally {
|
||||
transcribingQuestionId.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const startQuestionRecording = async (questionId: string) => {
|
||||
if (interviewBusy.value || transcribingQuestionId.value) return;
|
||||
if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
|
||||
await transcribeQuestionFile(questionId);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const recorder = new MediaRecorder(stream);
|
||||
const chunks: Blob[] = [];
|
||||
mediaStream.value = stream;
|
||||
mediaRecorder.value = recorder;
|
||||
recordingQuestionId.value = questionId;
|
||||
recorder.ondataavailable = (event) => {
|
||||
if (event.data?.size) chunks.push(event.data);
|
||||
};
|
||||
recorder.onstop = () => {
|
||||
mediaRecorder.value = null;
|
||||
recordingQuestionId.value = '';
|
||||
stopQuestionMediaTracks();
|
||||
const blob = new Blob(chunks, { type: recorder.mimeType || 'audio/webm' });
|
||||
if (!blob.size) {
|
||||
message.value = '未录到音频,请改用文字输入';
|
||||
return;
|
||||
}
|
||||
void transcribeQuestionBlob(questionId, blob);
|
||||
};
|
||||
recorder.start();
|
||||
message.value = '正在录音';
|
||||
} catch {
|
||||
recordingQuestionId.value = '';
|
||||
cancelQuestionRecording();
|
||||
await transcribeQuestionFile(questionId);
|
||||
}
|
||||
};
|
||||
|
||||
const stopQuestionRecording = () => {
|
||||
const recorder = mediaRecorder.value;
|
||||
if (!recordingQuestionId.value || !recorder || recorder.state === 'inactive') return;
|
||||
message.value = '录音结束,正在转写';
|
||||
recorder.stop();
|
||||
};
|
||||
|
||||
const toggleQuestionRecording = (questionId: string) => {
|
||||
if (recordingQuestionId.value === questionId) {
|
||||
stopQuestionRecording();
|
||||
} else if (!recordingQuestionId.value) {
|
||||
void startQuestionRecording(questionId);
|
||||
}
|
||||
};
|
||||
|
||||
const pickQuestionAudio = (questionId: string) => {
|
||||
void transcribeQuestionFile(questionId);
|
||||
};
|
||||
|
||||
const finishInterview = async () => {
|
||||
if (interviewBusy.value) return;
|
||||
if (interviewStatus.value !== 'active' || !interviewSessionId.value) {
|
||||
message.value = '请先开始面试';
|
||||
return;
|
||||
}
|
||||
if (recordingQuestionId.value || transcribingQuestionId.value) {
|
||||
message.value = '请先完成语音转写';
|
||||
return;
|
||||
}
|
||||
const answers = Object.fromEntries(
|
||||
Object.entries(interviewAnswers.value).map(([key, value]) => [key, value.trim()])
|
||||
);
|
||||
@@ -215,7 +360,7 @@ const finishInterview = async () => {
|
||||
message.value = '';
|
||||
try {
|
||||
await Promise.all(Object.entries(answers).map(([questionId, answer]) =>
|
||||
saveInterviewAnswer(interviewSessionId.value, questionId, answer)
|
||||
saveInterviewAnswer(interviewSessionId.value, questionId, answer, interviewAudioOssIds.value[questionId])
|
||||
));
|
||||
interviewResult.value = await finishCandidateInterview(interviewSessionId.value, answers);
|
||||
records.value = interviewResult.value.records?.length
|
||||
@@ -368,6 +513,26 @@ onShow(() => {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.question-audio-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.question-audio-actions .text-button {
|
||||
min-height: 32px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.audio-state {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.result-title {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
|
||||
@@ -97,11 +97,16 @@ export const startCandidateInterview = () =>
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
export const saveInterviewAnswer = (sessionId: string, questionId: string, answerText: string) =>
|
||||
export const saveInterviewAnswer = (
|
||||
sessionId: string,
|
||||
questionId: string,
|
||||
answerText: string,
|
||||
answerAudioOssId?: number
|
||||
) =>
|
||||
apiRequest<void>({
|
||||
url: '/api/recruit/interview/answer',
|
||||
method: 'POST',
|
||||
data: { sessionId, questionId, answerText },
|
||||
data: { sessionId, questionId, answerText, answerAudioOssId },
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
|
||||
@@ -511,6 +511,7 @@ export interface InterviewQuestionRecord {
|
||||
question: string;
|
||||
answer: string;
|
||||
rating: string;
|
||||
audioOssId?: number | string | null;
|
||||
}
|
||||
|
||||
export interface InterviewFinishResponse {
|
||||
|
||||
@@ -108,6 +108,8 @@ test('面试、资料、进度和预习页各自只承载一类真实任务', as
|
||||
assert.match(interview, /startCandidateInterview/);
|
||||
assert.match(interview, /saveInterviewAnswer/);
|
||||
assert.match(interview, /finishCandidateInterview/);
|
||||
assert.match(interview, /transcribeSpeechFile/);
|
||||
assert.match(interview, /录音转文字/);
|
||||
assert.doesNotMatch(interview, /uploadCandidateMaterial|searchKnowledge/);
|
||||
|
||||
assert.match(materials, /chooseCandidateFile/);
|
||||
|
||||
@@ -556,6 +556,8 @@ contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/control
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPromptTemplateController.java "/api/aihr/prompt-templates"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPromptTemplateController.java "public R<List<PromptTemplateResponse>> list"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "mobileCompletedCount"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrInterviewService.java "validateAnswerAudioOwnership"
|
||||
contains mobile-uni/src/pages/candidate/interview/index.vue "录音转文字"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "任务完成度"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "响应时效"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "new DimensionResponse(\"响应时效\", \"待采集\""
|
||||
|
||||
Reference in New Issue
Block a user