fix(mobile): drive learning search from assignments
This commit is contained in:
@@ -153,6 +153,9 @@ mobile-uni/
|
||||
- 员工端训练前预习卡已接入: 新 uni-app 对练页复用 `/api/train/practice/scenarios/{id}/prep-card`,开始前展示要点、红线和可说话术。
|
||||
- 员工端训练前预习卡与页头已收敛展示: 隐藏内部 promptVersion,训练卡标题跟随当前专项场景,页头不再固定写“投诉接待”。
|
||||
- 员工端训练中已接 SOP 求助: 新 uni-app 对练页复用 `/api/knowledge/search`,展示正式检索答案和引用,失败明确报错不兜假答案。
|
||||
- 员工学习页已按正式 assignment 驱动专项检索: 先读取员工今日任务,优先用未完成的非 daily 场景名检索知识并加载对应预习卡;assignment 失败时明确报错,仍用岗位默认主题和默认场景继续加载,不补前端假数据。
|
||||
- 员工学习页和案例素材页已补“返回今日”子页闭环: 正常页面栈返回来源页,直链进入时回到员工今日页。
|
||||
- 当前“学”页仍是今日任务、SOP 检索和训练前预习的辅助入口,不是课程/考试系统;当前范围不包含课程编排、章节进度、题库考试或证书能力。
|
||||
- 员工端对练建议已接调节按钮: 老师傅建议生成后可按“再严肃/再温柔/更细致/更专业”调用 `turn` 的 `regenerate/style`,只替换建议不重复计分。
|
||||
- 员工端训练中已展示情绪/信任/红线纠偏: 新 uni-app 对练页直接渲染 `turn` 返回的 `emotion/trust/redFlag`,让员工看到话术对业主状态的影响。
|
||||
- 员工端红线话术已改为同轮重说: `redFlag=true` 时不推进下一轮、不结束评分,提示员工按老师傅建议重说当前轮;纠正提交会替换本轮旧话术,与后端覆盖语义一致。
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"dev": "uni -p h5",
|
||||
"dev:h5": "uni -p h5",
|
||||
"build:h5": "uni build -p h5",
|
||||
"test:unit": "node --test tests/*.test.mjs",
|
||||
"typecheck": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
<template>
|
||||
<view class="page cases-page">
|
||||
<text class="page-title">案例素材</text>
|
||||
<text class="page-subtitle">把一线语音整理成可复用培训案例。上传后依次完成 ASR、AI 整理和入库。</text>
|
||||
<view class="subpage-header">
|
||||
<button
|
||||
class="subpage-back-button"
|
||||
aria-label="返回今日"
|
||||
title="返回今日"
|
||||
@click="navigateBackToToday"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<view class="subpage-heading">
|
||||
<text class="page-title">案例素材</text>
|
||||
<text class="page-subtitle">把一线语音整理成可复用培训案例。上传后依次完成 ASR、AI 整理和入库。</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="message" class="status-message">{{ message }}</view>
|
||||
|
||||
@@ -85,7 +97,7 @@ import { onShow } from '@dcloudio/uni-app';
|
||||
import type { CaseCurateResponse, CaseOrganizeResponse, CaseRecord, CaseSelectedFile, CaseUploadResponse } from '@/types/api';
|
||||
import { chooseCaseAudio, curateCase, getCaseRecords, organizeCase, uploadCaseAudio } from '@/services/case';
|
||||
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||
import { resetPageScroll } from '@/services/navigation';
|
||||
import { navigateBackToToday, resetPageScroll } from '@/services/navigation';
|
||||
import { ensureEmployeePosition } from '@/services/position';
|
||||
|
||||
const loggedIn = ref(false);
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
<template>
|
||||
<view class="page learning-page">
|
||||
<view class="learning-head">
|
||||
<text class="page-title">学</text>
|
||||
<text class="page-subtitle">按你的岗位读取今日任务、SOP 依据和训练前预习卡。</text>
|
||||
<view class="subpage-header">
|
||||
<button
|
||||
class="subpage-back-button"
|
||||
aria-label="返回今日"
|
||||
title="返回今日"
|
||||
@click="navigateBackToToday"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<view class="subpage-heading">
|
||||
<text class="page-title">学</text>
|
||||
<text class="page-subtitle">按你的岗位读取今日任务、SOP 依据和训练前预习卡。</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="message" class="status-message">{{ message }}</view>
|
||||
@@ -18,11 +28,11 @@
|
||||
<view v-if="loggedIn" class="learning-summary">
|
||||
<view class="summary-item">
|
||||
<text class="summary-value">{{ pendingCount }}</text>
|
||||
<text class="summary-label">待学习</text>
|
||||
<text class="summary-label">待完成</text>
|
||||
</view>
|
||||
<view class="summary-item">
|
||||
<text class="summary-value">{{ completedCount }}</text>
|
||||
<text class="summary-label">已完成</text>
|
||||
<text class="summary-label">今日完成</text>
|
||||
</view>
|
||||
<view class="summary-item">
|
||||
<text class="summary-value">{{ referenceCount }}</text>
|
||||
@@ -32,7 +42,7 @@
|
||||
|
||||
<view v-if="loggedIn" class="panel">
|
||||
<view class="panel-head">
|
||||
<text class="panel-title">今日学习</text>
|
||||
<text class="panel-title">今日任务</text>
|
||||
<button class="ghost-button compact-button" :disabled="assignmentLoading" @click="loadAssignments">
|
||||
{{ assignmentLoading ? '刷新中' : '刷新' }}
|
||||
</button>
|
||||
@@ -55,7 +65,7 @@
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="empty">暂无今日学习任务</view>
|
||||
<view v-else class="empty">暂无今日任务</view>
|
||||
</view>
|
||||
|
||||
<view v-if="loggedIn" class="panel">
|
||||
@@ -140,7 +150,8 @@ import type {
|
||||
import { getPracticeAssignments, getPracticePrepCard } from '@/services/practice';
|
||||
import { searchKnowledge } from '@/services/knowledge';
|
||||
import { getSelectedPositionProfile, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||
import { resetPageScroll } from '@/services/navigation';
|
||||
import { buildLearningTopics, selectLearningTopic } from '@/services/learning';
|
||||
import { navigateBackToToday, resetPageScroll } from '@/services/navigation';
|
||||
import { ensureEmployeePosition } from '@/services/position';
|
||||
|
||||
const defaultScenarioId = 'complaint-water';
|
||||
@@ -158,13 +169,12 @@ const prepLoading = ref(false);
|
||||
const knowledgeLoading = ref(false);
|
||||
const positionProfile = ref(getSelectedPositionProfile());
|
||||
|
||||
const topics = computed(() => {
|
||||
const profile = positionProfile.value;
|
||||
return [
|
||||
`${profile.value} 岗前 SOP`,
|
||||
...profile.quickQuestions
|
||||
];
|
||||
});
|
||||
const defaultTopic = computed(() => `${positionProfile.value.value} 岗前 SOP`);
|
||||
const topics = computed(() => buildLearningTopics(
|
||||
assignments.value,
|
||||
defaultTopic.value,
|
||||
positionProfile.value.quickQuestions
|
||||
));
|
||||
|
||||
const pendingCount = computed(() => assignments.value.filter((item) => item.status !== '已完成').length);
|
||||
const completedCount = computed(() => assignments.value.filter((item) => item.status === '已完成').length);
|
||||
@@ -218,6 +228,7 @@ const loadAssignments = async () => {
|
||||
try {
|
||||
assignments.value = await getPracticeAssignments(true);
|
||||
} catch (error) {
|
||||
assignments.value = [];
|
||||
handleRequestError(error, '学习任务加载失败');
|
||||
} finally {
|
||||
assignmentLoading.value = false;
|
||||
@@ -264,12 +275,12 @@ const loadPage = async () => {
|
||||
return;
|
||||
}
|
||||
refreshAuthState();
|
||||
activeTopic.value = topics.value[0] || activeTopic.value;
|
||||
await loadAssignments();
|
||||
activeTopic.value = selectLearningTopic(assignments.value, defaultTopic.value);
|
||||
await Promise.all([
|
||||
loadAssignments(),
|
||||
loadKnowledge(activeTopic.value)
|
||||
loadKnowledge(activeTopic.value),
|
||||
loadPrepCard()
|
||||
]);
|
||||
await loadPrepCard();
|
||||
};
|
||||
|
||||
const goLogin = () => {
|
||||
@@ -309,10 +320,6 @@ onShow(() => {
|
||||
padding-bottom: 42px;
|
||||
}
|
||||
|
||||
.learning-head {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
export interface LearningTopicAssignment {
|
||||
source?: string | null;
|
||||
status?: string | null;
|
||||
scenarioName?: string | null;
|
||||
}
|
||||
|
||||
const normalized = (value?: string | null) => value?.trim() || '';
|
||||
|
||||
export const selectLearningTopic = (
|
||||
assignments: readonly LearningTopicAssignment[],
|
||||
fallbackTopic: string
|
||||
) => {
|
||||
const scenarioAssignments = assignments.filter((item) =>
|
||||
normalized(item.source) !== 'daily' && Boolean(normalized(item.scenarioName))
|
||||
);
|
||||
const selected = scenarioAssignments.find((item) => normalized(item.status) !== '已完成')
|
||||
|| scenarioAssignments[0];
|
||||
|
||||
return normalized(selected?.scenarioName) || fallbackTopic;
|
||||
};
|
||||
|
||||
export const buildLearningTopics = (
|
||||
assignments: readonly LearningTopicAssignment[],
|
||||
fallbackTopic: string,
|
||||
quickQuestions: readonly string[]
|
||||
) => {
|
||||
const values = [
|
||||
fallbackTopic,
|
||||
...assignments
|
||||
.filter((item) => normalized(item.source) !== 'daily')
|
||||
.map((item) => normalized(item.scenarioName)),
|
||||
...quickQuestions
|
||||
];
|
||||
const topics: string[] = [];
|
||||
|
||||
values.forEach((value) => {
|
||||
const topic = normalized(value);
|
||||
if (topic && !topics.includes(topic)) {
|
||||
topics.push(topic);
|
||||
}
|
||||
});
|
||||
|
||||
return topics;
|
||||
};
|
||||
@@ -13,3 +13,18 @@ export const resetPageScroll = () => {
|
||||
setTimeout(scrollTop, 80);
|
||||
setTimeout(scrollTop, 240);
|
||||
};
|
||||
|
||||
const switchToToday = () => {
|
||||
uni.switchTab({ url: '/pages/user/today/index' });
|
||||
};
|
||||
|
||||
export const navigateBackToToday = () => {
|
||||
if (getCurrentPages().length <= 1) {
|
||||
switchToToday();
|
||||
return;
|
||||
}
|
||||
uni.navigateBack({
|
||||
delta: 1,
|
||||
fail: switchToToday
|
||||
});
|
||||
};
|
||||
|
||||
@@ -49,6 +49,36 @@ button::after {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.subpage-header {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.subpage-heading {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.subpage-back-button {
|
||||
display: flex;
|
||||
flex: 0 0 44px;
|
||||
width: 44px;
|
||||
min-width: 44px;
|
||||
height: 44px;
|
||||
min-height: 44px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
color: #1e293b;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.panel {
|
||||
margin-top: 16px;
|
||||
padding: 18px;
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test, { after } from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createServer } from 'vite';
|
||||
|
||||
const vite = await createServer({
|
||||
root: fileURLToPath(new URL('..', import.meta.url)),
|
||||
configFile: false,
|
||||
optimizeDeps: { noDiscovery: true },
|
||||
server: { middlewareMode: true },
|
||||
appType: 'custom'
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await vite.close();
|
||||
});
|
||||
|
||||
const learning = await vite.ssrLoadModule('/src/services/learning.ts');
|
||||
const { selectLearningTopic } = learning;
|
||||
|
||||
const assignment = (overrides = {}) => ({
|
||||
source: 'manual',
|
||||
status: '待完成',
|
||||
scenarioName: '',
|
||||
...overrides
|
||||
});
|
||||
|
||||
test('优先选择第一条未完成且非 daily 的非空专项场景', () => {
|
||||
const topic = selectLearningTopic([
|
||||
assignment({ source: 'daily', scenarioName: '每日三题' }),
|
||||
assignment({ status: '已完成', scenarioName: '催费沟通' }),
|
||||
assignment({ scenarioName: ' ' }),
|
||||
assignment({ scenarioName: ' 客服应对投诉 ' }),
|
||||
assignment({ scenarioName: '电梯困人处置' })
|
||||
], '生活顾问 岗前 SOP');
|
||||
|
||||
assert.equal(topic, '客服应对投诉');
|
||||
});
|
||||
|
||||
test('无未完成专项时选择第一条非 daily 的非空场景', () => {
|
||||
const topic = selectLearningTopic([
|
||||
assignment({ source: 'daily', status: '已完成', scenarioName: '每日三题' }),
|
||||
assignment({ status: '已完成', scenarioName: '催费沟通' }),
|
||||
assignment({ status: '已完成', scenarioName: '客户投诉处理' })
|
||||
], '生活顾问 岗前 SOP');
|
||||
|
||||
assert.equal(topic, '催费沟通');
|
||||
});
|
||||
|
||||
test('无可用专项场景时回退到调用方的岗位默认主题', () => {
|
||||
const topic = selectLearningTopic([
|
||||
assignment({ source: 'daily', scenarioName: '每日三题' }),
|
||||
assignment({ scenarioName: ' ' })
|
||||
], '生活顾问 岗前 SOP');
|
||||
|
||||
assert.equal(topic, '生活顾问 岗前 SOP');
|
||||
});
|
||||
|
||||
test('专项场景名进入 topics 且去重,同时保留岗位默认主题和快捷问句', () => {
|
||||
const topics = learning.buildLearningTopics?.([
|
||||
assignment({ scenarioName: ' 客服应对投诉 ' }),
|
||||
assignment({ status: '已完成', scenarioName: '客服应对投诉' }),
|
||||
assignment({ source: 'daily', scenarioName: '每日三题' }),
|
||||
assignment({ scenarioName: '' }),
|
||||
assignment({ scenarioName: '电梯困人处置' })
|
||||
], '生活顾问 岗前 SOP', ['催费话术', '投诉应对']) ?? [];
|
||||
|
||||
assert.deepEqual(topics, [
|
||||
'生活顾问 岗前 SOP',
|
||||
'客服应对投诉',
|
||||
'电梯困人处置',
|
||||
'催费话术',
|
||||
'投诉应对'
|
||||
]);
|
||||
});
|
||||
Reference in New Issue
Block a user