feat(aihr): add voice answers to daily drills

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