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 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(
@@ -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 '完成时间',
+1 -1
View File
@@ -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 只作为未配置模型或外部接口异常时的降级,不作为真实测试通过证据。
+1 -1
View File
@@ -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. 未完成项与优先级
+1 -1
View File
@@ -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 走现有文本兜底,不影响训练闭环 |
+107 -16
View File
@@ -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;
}
+2 -2
View File
@@ -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
});
+4
View File
@@ -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 {
+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 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"