feat(aihr): add voice answers to daily drills
This commit is contained in:
+14
-3
@@ -261,7 +261,9 @@ public final class AihrPracticeDto {
|
||||
String referenceAnswer,
|
||||
String userAnswer,
|
||||
String feedback,
|
||||
Boolean correct
|
||||
Boolean correct,
|
||||
String audioUrl,
|
||||
Long audioOssId
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -276,10 +278,19 @@ public final class AihrPracticeDto {
|
||||
) {
|
||||
}
|
||||
|
||||
public record DailyDrillAnswerRequest(String answer) {
|
||||
public record DailyDrillAnswerRequest(String answer, String audioUrl, Long audioOssId) {
|
||||
}
|
||||
|
||||
public record DailyDrillAnswerResponse(Long id, String status, Boolean correct, Integer score, String feedback, String referenceAnswer) {
|
||||
public record DailyDrillAnswerResponse(
|
||||
Long id,
|
||||
String status,
|
||||
Boolean correct,
|
||||
Integer score,
|
||||
String feedback,
|
||||
String referenceAnswer,
|
||||
String audioUrl,
|
||||
Long audioOssId
|
||||
) {
|
||||
}
|
||||
|
||||
public record PromptTemplateRequest(
|
||||
|
||||
+20
-8
@@ -913,7 +913,7 @@ public class AihrPracticeSeedService {
|
||||
args.add(limit);
|
||||
String sql = """
|
||||
SELECT id, ext_party_id, trainee_name, scenario_id, scenario_name, source, reason, status, create_time,
|
||||
answer_text, feedback, correct
|
||||
answer_text, answer_audio_url, answer_audio_oss_id, feedback, correct
|
||||
FROM aihr_practice_assignment
|
||||
%s
|
||||
ORDER BY create_time DESC, id DESC
|
||||
@@ -1587,7 +1587,7 @@ public class AihrPracticeSeedService {
|
||||
ensureDailyDrills(party);
|
||||
return jdbcTemplate.query("""
|
||||
SELECT id, ext_party_id, trainee_name, scenario_id, scenario_name, source, reason, status, create_time,
|
||||
answer_text, feedback, correct
|
||||
answer_text, answer_audio_url, answer_audio_oss_id, feedback, correct
|
||||
FROM aihr_practice_assignment
|
||||
WHERE tenant_id = ?
|
||||
AND (? = '' OR ext_party_id = ?)
|
||||
@@ -1625,7 +1625,7 @@ public class AihrPracticeSeedService {
|
||||
args.add(normalizeLimit(limit));
|
||||
return jdbcTemplate.query("""
|
||||
SELECT id, ext_party_id, trainee_name, scenario_id, scenario_name, source, reason, status, create_time,
|
||||
answer_text, feedback, correct
|
||||
answer_text, answer_audio_url, answer_audio_oss_id, feedback, correct
|
||||
FROM aihr_practice_assignment
|
||||
WHERE tenant_id = ?
|
||||
AND ext_party_id IN (%s)
|
||||
@@ -1694,13 +1694,13 @@ public class AihrPracticeSeedService {
|
||||
""", TENANT_ID, extPartyId, traineeName, scenario.id(), firstNonBlank(request == null ? null : request.scenarioName(), scenario.name()), source, reason, createdAt, createdAt);
|
||||
List<PracticeAssignmentResponse> rows = jdbcTemplate.query("""
|
||||
SELECT id, ext_party_id, trainee_name, scenario_id, scenario_name, source, reason, status, create_time,
|
||||
answer_text, feedback, correct
|
||||
answer_text, answer_audio_url, answer_audio_oss_id, feedback, correct
|
||||
FROM aihr_practice_assignment
|
||||
WHERE tenant_id = ? AND ext_party_id = ? AND scenario_id = ? AND source = ? AND reason = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
""", this::mapAssignment, TENANT_ID, extPartyId, scenario.id(), source, reason);
|
||||
return rows.isEmpty() ? new PracticeAssignmentResponse(null, extPartyId, traineeName, scenario.id(), scenario.name(), source, reason, "待训练", now.format(TIME_FORMATTER), null, null, null, null, null) : rows.get(0);
|
||||
return rows.isEmpty() ? new PracticeAssignmentResponse(null, extPartyId, traineeName, scenario.id(), scenario.name(), source, reason, "待训练", now.format(TIME_FORMATTER), null, null, null, null, null, null, null) : rows.get(0);
|
||||
}
|
||||
|
||||
public PracticeAssignmentResponse createAssignment(PracticeAssignmentRequest request, String supervisorExtPartyId) {
|
||||
@@ -1758,12 +1758,16 @@ public class AihrPracticeSeedService {
|
||||
}
|
||||
DailyDrillParts parts = dailyDrillParts((String) rows.get(0).get("reason"), (String) rows.get(0).get("scenario_id"));
|
||||
String answer = maskSensitiveText(firstNonBlank(request == null ? null : request.answer(), ""));
|
||||
String audioUrl = firstNonBlank(request == null ? null : request.audioUrl(), "");
|
||||
Long audioOssId = request == null ? null : request.audioOssId();
|
||||
DailyDrillScore score = scoreDailyDrill(answer, parts.referenceAnswer());
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
jdbcTemplate.update("""
|
||||
UPDATE aihr_practice_assignment
|
||||
SET status = '已完成',
|
||||
answer_text = ?,
|
||||
answer_audio_url = ?,
|
||||
answer_audio_oss_id = ?,
|
||||
feedback = ?,
|
||||
correct = ?,
|
||||
finished_time = ?,
|
||||
@@ -1771,6 +1775,8 @@ public class AihrPracticeSeedService {
|
||||
WHERE tenant_id = ? AND id = ? AND source = 'daily'
|
||||
""",
|
||||
answer,
|
||||
audioUrl,
|
||||
audioOssId,
|
||||
score.feedback(),
|
||||
score.correct() ? 1 : 0,
|
||||
Timestamp.valueOf(now),
|
||||
@@ -1778,7 +1784,7 @@ public class AihrPracticeSeedService {
|
||||
TENANT_ID,
|
||||
id
|
||||
);
|
||||
return new DailyDrillAnswerResponse(id, "已完成", score.correct(), score.score(), score.feedback(), parts.referenceAnswer());
|
||||
return new DailyDrillAnswerResponse(id, "已完成", score.correct(), score.score(), score.feedback(), parts.referenceAnswer(), audioUrl, audioOssId);
|
||||
}
|
||||
|
||||
public List<PromptTemplateResponse> promptTemplates(String category, String code, Boolean enabled) {
|
||||
@@ -2248,7 +2254,9 @@ public class AihrPracticeSeedService {
|
||||
"daily".equals(source) && "已完成".equals(status) ? daily.referenceAnswer() : null,
|
||||
rs.getString("answer_text"),
|
||||
"daily".equals(source) ? stripReferenceAnswer(rs.getString("feedback")) : rs.getString("feedback"),
|
||||
correctValue == null ? null : rs.getBoolean("correct")
|
||||
correctValue == null ? null : rs.getBoolean("correct"),
|
||||
rs.getString("answer_audio_url"),
|
||||
rs.getObject("answer_audio_oss_id") == null ? null : rs.getLong("answer_audio_oss_id")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3057,6 +3065,8 @@ public class AihrPracticeSeedService {
|
||||
`reason` varchar(500) DEFAULT NULL COMMENT '派发原因',
|
||||
`status` varchar(30) NOT NULL DEFAULT '待训练' COMMENT '状态',
|
||||
`answer_text` varchar(1000) DEFAULT NULL COMMENT '每日三题作答',
|
||||
`answer_audio_url` varchar(500) DEFAULT NULL COMMENT '每日三题语音回放地址',
|
||||
`answer_audio_oss_id` bigint DEFAULT NULL COMMENT '每日三题语音OSS编号',
|
||||
`feedback` varchar(1000) DEFAULT NULL COMMENT '每日三题反馈',
|
||||
`correct` tinyint DEFAULT NULL COMMENT '是否答对',
|
||||
`finished_time` datetime DEFAULT NULL COMMENT '完成时间',
|
||||
@@ -3068,7 +3078,9 @@ public class AihrPracticeSeedService {
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 对练派发任务';
|
||||
""");
|
||||
ensureColumn("aihr_practice_assignment", "answer_text", "ALTER TABLE aihr_practice_assignment ADD COLUMN `answer_text` varchar(1000) DEFAULT NULL COMMENT '每日三题作答' AFTER `status`");
|
||||
ensureColumn("aihr_practice_assignment", "feedback", "ALTER TABLE aihr_practice_assignment ADD COLUMN `feedback` varchar(1000) DEFAULT NULL COMMENT '每日三题反馈' AFTER `answer_text`");
|
||||
ensureColumn("aihr_practice_assignment", "answer_audio_url", "ALTER TABLE aihr_practice_assignment ADD COLUMN `answer_audio_url` varchar(500) DEFAULT NULL COMMENT '每日三题语音回放地址' AFTER `answer_text`");
|
||||
ensureColumn("aihr_practice_assignment", "answer_audio_oss_id", "ALTER TABLE aihr_practice_assignment ADD COLUMN `answer_audio_oss_id` bigint DEFAULT NULL COMMENT '每日三题语音OSS编号' AFTER `answer_audio_url`");
|
||||
ensureColumn("aihr_practice_assignment", "feedback", "ALTER TABLE aihr_practice_assignment ADD COLUMN `feedback` varchar(1000) DEFAULT NULL COMMENT '每日三题反馈' AFTER `answer_audio_oss_id`");
|
||||
ensureColumn("aihr_practice_assignment", "correct", "ALTER TABLE aihr_practice_assignment ADD COLUMN `correct` tinyint DEFAULT NULL COMMENT '是否答对' AFTER `feedback`");
|
||||
ensureColumn("aihr_practice_assignment", "finished_time", "ALTER TABLE aihr_practice_assignment ADD COLUMN `finished_time` datetime DEFAULT NULL COMMENT '完成时间' AFTER `correct`");
|
||||
assignmentTableReady = true;
|
||||
|
||||
@@ -78,6 +78,8 @@ CREATE TABLE IF NOT EXISTS `aihr_practice_assignment` (
|
||||
`reason` varchar(500) DEFAULT NULL COMMENT '派发原因',
|
||||
`status` varchar(30) NOT NULL DEFAULT '待训练' COMMENT '状态',
|
||||
`answer_text` varchar(1000) DEFAULT NULL COMMENT '每日三题作答',
|
||||
`answer_audio_url` varchar(500) DEFAULT NULL COMMENT '每日三题语音回放地址',
|
||||
`answer_audio_oss_id` bigint DEFAULT NULL COMMENT '每日三题语音OSS编号',
|
||||
`feedback` varchar(1000) DEFAULT NULL COMMENT '每日三题反馈',
|
||||
`correct` tinyint DEFAULT NULL COMMENT '是否答对',
|
||||
`finished_time` datetime DEFAULT NULL COMMENT '完成时间',
|
||||
|
||||
@@ -138,7 +138,7 @@ curl -fsS "$API_BASE/api/aihr/org/sync" \
|
||||
- 模型配置 API 文件:`frontend/src/api/aihr/model.ts`
|
||||
- 模型配置页面:`frontend/src/views/system/model/index.vue`
|
||||
- 用户侧工程:`mobile-uni/`,路由为 `/h5/#/pages/user/today/index`、`/h5/#/pages/user/practice/index`、`/h5/#/pages/user/sop/index`、`/h5/#/pages/user/profile/index`、`/h5/#/pages/candidate/index/index`、`/h5/#/pages/supervisor/index/index`。未登录先走手机号短信登录,登录后 API 优先请求真实业务接口,不复用后台页面路由。语音输入不要新增移动端专用接口,浏览器录音和文件选择兜底都复用 `/api/ai/asr`。
|
||||
- 员工端训练:底部“练”进入 `/h5/#/pages/user/practice/index`,展示今日任务、每日三题、专项训练、训练前预习卡和训练对话;完成后记录写入 `aihr_practice_session`,员工端可提交 1-5 分训练体验和可选意见,员工端展示训练历史和能力画像,主管端展示待复盘列表,点进单条可看评分、话术、导师改写,并可标记“已复盘”。
|
||||
- 员工端训练:底部“练”进入 `/h5/#/pages/user/practice/index`,展示今日任务、每日三题、专项训练、训练前预习卡和训练对话;每日三题支持文字或录音转写,作答接口 `/api/aihr/mobile/practice/assignments/{id}/answer` 可携带 `answer/audioUrl/audioOssId`,语音地址会随作业记录保存;完成后记录写入 `aihr_practice_session`,员工端可提交 1-5 分训练体验和可选意见,员工端展示训练历史和能力画像,主管端展示待复盘列表,点进单条可看评分、话术、导师改写,并可标记“已复盘”。
|
||||
- 候选人端:`/h5/#/pages/candidate/index/index` 的“开始面试/面试练习”复用 AI 面试 API;“补充资料”走候选资料上传接口,成功后展示资料类型和审核状态。管理端 `/recruit/interview` 底部展示“候选资料审核”,支持通过/驳回。
|
||||
- 演示 fallback 只作为未配置模型或外部接口异常时的降级,不作为真实测试通过证据。
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
| 训练音频对象权限 | `AihrMobileController`、`AihrPracticeSeedService`;`GET /api/aihr/mobile/oss/{ossId}` | 已按员工本人、主管项目范围和系统用户区分音频读取权限;本地真实 HTTP 验证:归属员工 200,其他员工 403;生产正式主管范围仍需组织同步数据支撑 |
|
||||
| 候选人面试与 HR 复核 | `mobile-uni/src/pages/candidate/*`、`frontend/src/views/recruit/interview.vue` | 工程闭环已在位;候选人资料移动端上传/列表现在只接受 APP 候选人本人身份,HR 继续通过独立审核接口处理;需继续保留人工复核证据 |
|
||||
| SOP/RAG/资料处理 | `frontend/src/views/knowledge/*`、`backend/.../AihrSopSeedService.java` | 搜索、引用、反馈、异步处理和评审入口已在位 |
|
||||
| 对练/每日题/主管带教 | `backend/.../AihrPracticeSeedService.java`、`mobile-uni/src/pages/user/practice` | 主闭环已在位;低分再练、错题归因仍需更明确的用户可见闭环 |
|
||||
| 对练/每日题/主管带教 | `backend/.../AihrPracticeSeedService.java`、`mobile-uni/src/pages/user/practice` | 每日三题现支持文字或录音转写,语音 `audioUrl/ossId` 随作业提交并持久化;普通陪练、即时反馈、低分再练、错题归因和主管带教已在位,正式窗口仍需验证真实音频与低分样本 |
|
||||
| 案例上传/整理/筛选 | `backend/.../AihrCaseService.java`、`mobile-uni/src/pages/user/cases`、`frontend/src/views/knowledge/cases.vue` | 上传与整理已在位,案例状态在内存缺失时可从 `aihr_case_record` 回读恢复;APP 用户的读取按项目范围校验,写入接口已增加“主管/项目经理/系统用户”门禁,普通员工不能提交、整理或入库案例;本轮补上管理端上传的项目选择与后端无项目拒绝,避免案例落成员工不可见的无范围记录;当前仓库仍没有可用预渲染视频样片资产,也没有稳定的案例详情/学习点模型,不能用假播放器宣称案例学习闭环完成 |
|
||||
|
||||
## 3. 未完成项与优先级
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
| 顺序 | 页面 | 点击路径 | 必须看到 |
|
||||
|---|---|---|---|
|
||||
| 1 | 管理端 `/train/practice` | 切换生活顾问场景 → 查看 Rubric/预习卡 → 开始对练 | 场景来自数据库 12 条 seed;Rubric、SOP 引用、预习卡可见;模型不可用时仍回退内置剧本 |
|
||||
| 2 | 员工端 `/h5/#/pages/user/practice/index` | 今日任务 → 每日三题作答 → 开始训练 → 训练中点“求助” → 完成评分 | 每日三题即时反馈;求助命中 SOP 或明确报错;完成后画像、历史和主管端待复盘同步 |
|
||||
| 2 | 员工端 `/h5/#/pages/user/practice/index` | 今日任务 → 每日三题文字或录音作答 → 开始训练 → 训练中点“求助” → 完成评分 | 每日三题即时反馈且语音有回放留痕;求助命中 SOP 或明确报错;完成后画像、历史和主管端待复盘同步 |
|
||||
| 3 | 主管端 `/h5/#/pages/supervisor/index/index` | 团队画像/待复盘 → 进入复盘详情 → 看逐句标注 → 写复盘建议 → 派发再练 | 能看到评分、音频、逐句标注、主管建议;从团队画像派专项后员工端出现专项/再练任务 |
|
||||
| 4 | 管理端 `/train/practice` | 选择训练记录 → 人工校准评分 → 选择试点起止日期 → 导出试点 CSV | 导出只统计批次内能唯一匹配在职组织的正式数据,包含训练次数、每人 10 次完训率、平均分、预警数、主管复盘率、AI/人工分档一致率、SOP 可用率及正式身份明细 |
|
||||
| 5 | 模型成本闸门 | 设置 `AIHR_AI_RUNTIME_ENABLED=false` 后重启后端 → 调 `/api/aihr/model/chat` | 返回 `cost-guard-fallback`,chat/asr/tts 走现有文本兜底,不影响训练闭环 |
|
||||
|
||||
@@ -90,6 +90,24 @@
|
||||
:disabled="item.status === '已完成'"
|
||||
placeholder="写一句你的处理话术"
|
||||
/>
|
||||
<view class="daily-voice-actions">
|
||||
<button
|
||||
class="secondary-button"
|
||||
:class="{ 'recording-active': recording && recordingAssignmentId === item.id }"
|
||||
:disabled="Boolean(answeringId) || transcribing || (recording && recordingAssignmentId !== item.id) || item.status === '已完成'"
|
||||
@click="() => toggleRecording(item.id)"
|
||||
>
|
||||
{{ recording && recordingAssignmentId === item.id ? '停止录音' : '录音转写' }}
|
||||
</button>
|
||||
<button
|
||||
class="ghost-button"
|
||||
:disabled="Boolean(answeringId) || recording || transcribing || item.status === '已完成'"
|
||||
@click="() => pickAudio(item.id)"
|
||||
>
|
||||
{{ transcribing ? '转写中' : '选音频' }}
|
||||
</button>
|
||||
</view>
|
||||
<text v-if="dailyAudioUrls[item.id]" class="voice-state">已附带语音回放</text>
|
||||
<button class="primary-button small-button" :disabled="Boolean(answeringId) || item.status === '已完成'" @click="submitDaily(item)">
|
||||
{{ item.status === '已完成' ? '已完成' : '提交' }}
|
||||
</button>
|
||||
@@ -159,10 +177,10 @@
|
||||
placeholder="先承接业主情绪,再说明处理动作和反馈时间。"
|
||||
/>
|
||||
<view class="reply-actions">
|
||||
<button class="secondary-button" :class="{ 'recording-active': recording }" :disabled="busy || transcribing" @click="toggleRecording">
|
||||
<button class="secondary-button" :class="{ 'recording-active': recording }" :disabled="busy || transcribing" @click="() => toggleRecording()">
|
||||
{{ recording ? '停止录音' : '录音转写' }}
|
||||
</button>
|
||||
<button class="ghost-button" :disabled="busy || recording || transcribing" @click="pickAudio">
|
||||
<button class="ghost-button" :disabled="busy || recording || transcribing" @click="() => pickAudio()">
|
||||
{{ transcribing ? '转写中' : '选音频' }}
|
||||
</button>
|
||||
<button class="secondary-button" :disabled="busy || helpLoading" @click="loadHelp">
|
||||
@@ -289,12 +307,15 @@ const redFlagActive = ref(false);
|
||||
const assignments = ref<PracticeAssignment[]>([]);
|
||||
const practiceHistory = ref<PracticeRecord[]>([]);
|
||||
const drillAnswers = ref<Record<number, string>>({});
|
||||
const dailyAudioUrls = ref<Record<number, string>>({});
|
||||
const dailyAudioOssIds = ref<Record<number, number | undefined>>({});
|
||||
const assignmentLoading = ref(false);
|
||||
const answeringId = ref<number | null>(null);
|
||||
const activeAssignmentId = ref<number | undefined>();
|
||||
const highlightedAssignmentId = ref<number | null>(null);
|
||||
const loggedIn = ref(false);
|
||||
const recording = ref(false);
|
||||
const recordingAssignmentId = ref<number | null>(null);
|
||||
const transcribing = ref(false);
|
||||
const draftAudioUrl = ref('');
|
||||
const draftAudioOssId = ref<number | undefined>();
|
||||
@@ -359,6 +380,8 @@ const clearPracticeData = () => {
|
||||
assignments.value = [];
|
||||
practiceHistory.value = [];
|
||||
drillAnswers.value = {};
|
||||
dailyAudioUrls.value = {};
|
||||
dailyAudioOssIds.value = {};
|
||||
prepCard.value = null;
|
||||
prepLoading.value = false;
|
||||
turns.value = [];
|
||||
@@ -383,6 +406,7 @@ const clearPracticeData = () => {
|
||||
trustScore.value = 0;
|
||||
redFlagActive.value = false;
|
||||
answeringId.value = null;
|
||||
recordingAssignmentId.value = null;
|
||||
activeAssignmentId.value = undefined;
|
||||
highlightedAssignmentId.value = null;
|
||||
transcribing.value = false;
|
||||
@@ -422,6 +446,7 @@ const cancelRecording = () => {
|
||||
mediaRecorder.value = null;
|
||||
stopMediaTracks();
|
||||
recording.value = false;
|
||||
recordingAssignmentId.value = null;
|
||||
};
|
||||
|
||||
const applyAsrResult = (result: { text?: string; audioUrl?: string; ossId?: number | string }) => {
|
||||
@@ -435,12 +460,37 @@ const applyAsrResult = (result: { text?: string; audioUrl?: string; ossId?: numb
|
||||
message.value = '语音已转成文字,请确认后提交';
|
||||
};
|
||||
|
||||
const transcribeBlob = async (blob: Blob) => {
|
||||
const applyDailyAsrResult = (assignmentId: number, result: { text?: string; audioUrl?: string; ossId?: number | string }) => {
|
||||
const text = (result.text || '').trim();
|
||||
if (!text) throw new Error('语音没有识别到文字');
|
||||
const current = (drillAnswers.value[assignmentId] || '').trim();
|
||||
drillAnswers.value = {
|
||||
...drillAnswers.value,
|
||||
[assignmentId]: current ? `${current}\n${text}` : text
|
||||
};
|
||||
dailyAudioUrls.value = {
|
||||
...dailyAudioUrls.value,
|
||||
[assignmentId]: result.audioUrl || ''
|
||||
};
|
||||
const ossId = Number(result.ossId);
|
||||
dailyAudioOssIds.value = {
|
||||
...dailyAudioOssIds.value,
|
||||
[assignmentId]: Number.isFinite(ossId) ? ossId : undefined
|
||||
};
|
||||
message.value = '每日一练语音已转成文字,请确认后提交';
|
||||
};
|
||||
|
||||
const transcribeBlob = async (blob: Blob, assignmentId?: number) => {
|
||||
if (transcribing.value) return;
|
||||
transcribing.value = true;
|
||||
message.value = '语音转写中';
|
||||
try {
|
||||
applyAsrResult(await transcribeSpeechBlob(blob));
|
||||
const result = await transcribeSpeechBlob(blob);
|
||||
if (assignmentId === undefined) {
|
||||
applyAsrResult(result);
|
||||
} else {
|
||||
applyDailyAsrResult(assignmentId, result);
|
||||
}
|
||||
} catch (error) {
|
||||
handleError(error, '语音转写失败');
|
||||
} finally {
|
||||
@@ -448,14 +498,19 @@ const transcribeBlob = async (blob: Blob) => {
|
||||
}
|
||||
};
|
||||
|
||||
const pickAudio = async () => {
|
||||
const pickAudio = async (assignmentId?: number) => {
|
||||
if (busy.value || recording.value || transcribing.value) return;
|
||||
if (!requireLogin()) return;
|
||||
transcribing.value = true;
|
||||
message.value = '语音转写中';
|
||||
try {
|
||||
const file = await chooseSpeechAudio();
|
||||
applyAsrResult(await transcribeSpeechFile(file));
|
||||
const result = await transcribeSpeechFile(file);
|
||||
if (assignmentId === undefined) {
|
||||
applyAsrResult(result);
|
||||
} else {
|
||||
applyDailyAsrResult(assignmentId, result);
|
||||
}
|
||||
} catch (error) {
|
||||
handleError(error, '语音转写失败');
|
||||
} finally {
|
||||
@@ -463,11 +518,11 @@ const pickAudio = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const startRecording = async () => {
|
||||
const startRecording = async (assignmentId?: number) => {
|
||||
if (busy.value || transcribing.value) return;
|
||||
if (!requireLogin()) return;
|
||||
if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
|
||||
await pickAudio();
|
||||
await pickAudio(assignmentId);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -476,6 +531,7 @@ const startRecording = async () => {
|
||||
const chunks: Blob[] = [];
|
||||
mediaStream.value = stream;
|
||||
mediaRecorder.value = recorder;
|
||||
recordingAssignmentId.value = assignmentId ?? null;
|
||||
recorder.ondataavailable = (event) => {
|
||||
if (event.data?.size) chunks.push(event.data);
|
||||
};
|
||||
@@ -488,13 +544,16 @@ const startRecording = async () => {
|
||||
message.value = '未录到音频,请改用文字输入';
|
||||
return;
|
||||
}
|
||||
void transcribeBlob(blob);
|
||||
const targetAssignmentId = assignmentId;
|
||||
recordingAssignmentId.value = null;
|
||||
void transcribeBlob(blob, targetAssignmentId);
|
||||
};
|
||||
recorder.start();
|
||||
recording.value = true;
|
||||
message.value = '正在录音';
|
||||
} catch (error) {
|
||||
await pickAudio();
|
||||
recordingAssignmentId.value = null;
|
||||
await pickAudio(assignmentId);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -505,12 +564,12 @@ const stopRecording = () => {
|
||||
recorder.stop();
|
||||
};
|
||||
|
||||
const toggleRecording = () => {
|
||||
const toggleRecording = (assignmentId?: number) => {
|
||||
if (recording.value) {
|
||||
stopRecording();
|
||||
return;
|
||||
}
|
||||
void startRecording();
|
||||
void startRecording(assignmentId);
|
||||
};
|
||||
|
||||
const handleError = (error: unknown, fallback: string) => {
|
||||
@@ -543,6 +602,14 @@ const loadAssignments = async () => {
|
||||
practiceHistory.value = [];
|
||||
}
|
||||
drillAnswers.value = Object.fromEntries(assignments.value.map((item) => [item.id, item.userAnswer || '']));
|
||||
dailyAudioUrls.value = Object.fromEntries(
|
||||
assignments.value.filter((item) => item.audioUrl).map((item) => [item.id, item.audioUrl || ''])
|
||||
);
|
||||
dailyAudioOssIds.value = Object.fromEntries(
|
||||
assignments.value
|
||||
.filter((item) => item.audioOssId !== undefined && item.audioOssId !== null)
|
||||
.map((item) => [item.id, Number(item.audioOssId)])
|
||||
);
|
||||
consumePendingAssignment();
|
||||
} catch (error) {
|
||||
handleError(error, '今日任务加载失败');
|
||||
@@ -581,17 +648,28 @@ const consumePendingAssignment = () => {
|
||||
const submitDaily = async (item: PracticeAssignment) => {
|
||||
if (answeringId.value) return;
|
||||
const answer = (drillAnswers.value[item.id] || '').trim();
|
||||
if (!answer) {
|
||||
message.value = '请先填写处理话术';
|
||||
const audioUrl = dailyAudioUrls.value[item.id] || undefined;
|
||||
const audioOssId = dailyAudioOssIds.value[item.id];
|
||||
if (!answer && !audioUrl) {
|
||||
message.value = '请先输入或录制处理话术';
|
||||
return;
|
||||
}
|
||||
answeringId.value = item.id;
|
||||
message.value = '';
|
||||
try {
|
||||
const result = await answerDailyDrill(item.id, answer);
|
||||
const result = await answerDailyDrill(item.id, answer, audioUrl, audioOssId);
|
||||
assignments.value = assignments.value.map((row) =>
|
||||
row.id === item.id
|
||||
? { ...row, status: result.status, userAnswer: answer, feedback: result.feedback, referenceAnswer: result.referenceAnswer, correct: result.correct }
|
||||
? {
|
||||
...row,
|
||||
status: result.status,
|
||||
userAnswer: answer,
|
||||
feedback: result.feedback,
|
||||
referenceAnswer: result.referenceAnswer,
|
||||
correct: result.correct,
|
||||
audioUrl: result.audioUrl || audioUrl,
|
||||
audioOssId: result.audioOssId || audioOssId
|
||||
}
|
||||
: row
|
||||
);
|
||||
message.value = `每日三题得分 ${result.score}`;
|
||||
@@ -1041,6 +1119,19 @@ onShow(() => {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.daily-voice-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.daily-voice-actions button {
|
||||
min-width: 0;
|
||||
padding: 0 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.daily-textarea {
|
||||
min-height: 82px;
|
||||
}
|
||||
|
||||
@@ -174,11 +174,11 @@ export const createPracticeAssignment = (data: PracticeAssignmentRequest) =>
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
export const answerDailyDrill = (id: number, answer: string) =>
|
||||
export const answerDailyDrill = (id: number, answer: string, audioUrl?: string, audioOssId?: number) =>
|
||||
apiRequest<DailyDrillAnswerResponse>({
|
||||
url: `/api/aihr/mobile/practice/assignments/${id}/answer`,
|
||||
method: 'POST',
|
||||
data: { answer },
|
||||
data: { answer, audioUrl, audioOssId },
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
|
||||
@@ -296,6 +296,8 @@ export interface PracticeAssignment {
|
||||
userAnswer?: string;
|
||||
feedback?: string;
|
||||
correct?: boolean | null;
|
||||
audioUrl?: string;
|
||||
audioOssId?: number | string;
|
||||
}
|
||||
|
||||
export interface PracticeAssignmentRequest {
|
||||
@@ -314,6 +316,8 @@ export interface DailyDrillAnswerResponse {
|
||||
score: number;
|
||||
feedback: string;
|
||||
referenceAnswer: string;
|
||||
audioUrl?: string;
|
||||
audioOssId?: number | string;
|
||||
}
|
||||
|
||||
export interface PracticeAlert {
|
||||
|
||||
@@ -411,6 +411,10 @@ contains frontend/src/views/train/practice.vue "practice_help"
|
||||
contains mobile-uni/src/pages/user/practice/index.vue "loadHelp"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "turnEvidence"
|
||||
contains mobile-uni/src/pages/user/practice/index.vue "每日三题"
|
||||
contains mobile-uni/src/pages/user/practice/index.vue "每日一练语音已转成文字"
|
||||
contains mobile-uni/src/pages/user/practice/index.vue "dailyAudioOssIds"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "answer_audio_oss_id"
|
||||
contains backend/script/sql/aihr_practice_mysql8.sql "answer_audio_url"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "pilot_metric_ready"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java "/practice/satisfaction"
|
||||
contains backend/script/sql/aihr_practice_mysql8.sql "satisfaction_score"
|
||||
|
||||
Reference in New Issue
Block a user