feat(aihr): finalize practice growth and access boundaries

This commit is contained in:
2026-07-25 00:42:19 +08:00
parent a90f40e4d8
commit c9d2012ec1
87 changed files with 6804 additions and 2538 deletions
+444 -21
View File
@@ -15,10 +15,69 @@
<button class="primary-button login-button" @click="goLogin">去登录</button>
</view>
<view v-if="loggedIn && practiceView === 'prep'" class="panel scenario-card employee-card">
<view v-if="loggedIn && practiceView === 'prep'" class="panel growth-card employee-card">
<view class="growth-card-head">
<view>
<text class="panel-title">我的成长位置</text>
<text class="growth-meta">住宅物业 · {{ positionLabel }} · {{ currentGrowthLevel }}</text>
</view>
<text class="growth-level-badge">{{ currentGrowthLevel }}</text>
</view>
<text v-if="growthConfirmation" class="growth-confirmation">
主管已确认可进入{{ growthConfirmation.confirmedGrowthLevel }}{{ growthConfirmation.confirmedTime ? ` · ${growthConfirmation.confirmedTime}` : '' }}
</text>
<view v-if="growthPath.length" class="growth-path-list">
<view v-for="item in growthPath" :key="item.level" class="growth-path-row" :class="{ reached: item.status === '已达成' }">
<view class="growth-path-copy">
<text class="growth-path-level">{{ item.level }}</text>
<text class="growth-path-evidence">{{ item.evidence }}</text>
</view>
<text class="growth-path-status">{{ item.status }} · {{ item.progress }}%</text>
</view>
</view>
<view v-if="capabilityProgress.length" class="capability-progress-list">
<text class="capability-progress-title">已覆盖能力项</text>
<view v-for="item in capabilityProgress.slice(0, 3)" :key="`${item.growthLevel}-${item.competencyCode}`" class="capability-progress-row">
<text>{{ item.growthLevel }} · {{ item.competencyCode }}</text>
<text>{{ item.completed }} 次 · 均分 {{ item.averageScore }}</text>
</view>
</view>
<text v-else class="growth-empty">完成后会按“岗位 · 层级 · 能力项”记录训练证据;历史未归类记录不会被倒推改写。</text>
<text class="growth-note">这是训练建议与证据展示,不自动改变职级、薪酬或晋升资格。</text>
</view>
<view v-if="loggedIn && practiceView === 'prep'" class="panel scene-picker-card employee-card">
<view class="scene-picker-head">
<view>
<text class="panel-title">场景训练</text>
<text class="scene-picker-subtitle">选择已发布、已启用的 {{ positionLabel }} 场景</text>
</view>
<text v-if="curriculumLoading" class="scene-picker-loading">加载中</text>
</view>
<scroll-view v-if="availableScenarios.length" class="scene-option-scroll" scroll-x>
<view class="scene-option-list">
<button
v-for="item in availableScenarios"
:key="item.id"
class="scene-option"
:class="{ selected: selectedScenarioId === item.id }"
:disabled="busy"
@click="selectPracticeScenario(item)"
>
<text class="scene-option-level">{{ item.growthLevel || '基础' }}</text>
<text class="scene-option-name">{{ item.name }}</text>
<text class="scene-option-capability">{{ item.competencyCode || '待运营归类' }}</text>
</button>
</view>
</scroll-view>
<text v-else-if="!curriculumLoading" class="scene-picker-empty">当前岗位暂无已发布场景,请联系内容运营完成审核发布。</text>
<text class="scene-picker-note">复杂场景的正式前置解锁规则仍待业务确认;系统不会以估算分数硬性拦截已发布训练。</text>
</view>
<view v-if="loggedIn && practiceView === 'prep' && availableScenarios.length" class="panel scenario-card employee-card">
<view class="scenario-headline">
<text class="scenario-title">{{ activeScenarioName }}</text>
<text class="scenario-desc">{{ positionLabel }} · 业主服务场景</text>
<text class="scenario-desc">{{ selectedScenarioDescription }}</text>
</view>
<view class="scenario-persona">
<view class="persona-avatar">业</view>
@@ -46,8 +105,8 @@
<button
v-if="status === 'idle' || status === 'finished'"
class="primary-button"
:disabled="busy"
@click="start()"
:disabled="!canStartPractice || busy"
@click="start(selectedScenarioId)"
>
{{ status === 'finished' ? '再练一次' : '我已准备好,开始练习' }}
</button>
@@ -66,7 +125,7 @@
<button
v-if="status === 'idle' || status === 'finished'"
class="secondary-button realtime-start-button"
:disabled="busy"
:disabled="!canStartPractice || busy"
@click="startRealtime"
>
实时陪练 Beta
@@ -373,7 +432,7 @@
<view v-if="result" class="result-actions">
<button class="secondary-button" @click="openPracticeHistory">查看训练记录</button>
<button class="primary-button tabbar-safe-action" @click="start(selectedScenarioId)">再练一次</button>
<button class="primary-button tabbar-safe-action" :disabled="!canStartPractice || busy" @click="start(selectedScenarioId)">再练一次</button>
</view>
</view>
</template>
@@ -391,9 +450,13 @@ import type {
SpeechVoiceProfile,
SpeechPlaybackStatus,
PracticeTurn,
PracticeRecord
PracticeRecord,
PracticeScenarioOption,
CompetencyProfile,
GrowthStage,
CapabilityProgress
} from '@/types/api';
import { answerDailyDrill, finishPractice, getPracticeAssignments, getPracticeHistory, getPracticePrepCard, recordPracticeHelp, startPractice, submitPracticeQuestionFeedback, submitPracticeSatisfaction, submitPracticeTurn } from '@/services/practice';
import { answerDailyDrill, finishPractice, getCompetencyProfile, getPracticeAssignments, getPracticeHistory, getPracticePrepCard, getPracticeScenarios, recordPracticeHelp, startPractice, submitPracticeQuestionFeedback, submitPracticeSatisfaction, submitPracticeTurn } from '@/services/practice';
import { getSelectedPosition, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { searchKnowledge } from '@/services/knowledge';
import { resetPageScroll } from '@/services/navigation';
@@ -436,6 +499,9 @@ const satisfactionComment = ref('');
const satisfactionBusy = ref(false);
const satisfactionSubmitted = ref(false);
const selectedScenarioId = ref(defaultScenarioId);
const availableScenarios = ref<PracticeScenarioOption[]>([]);
const competencyProfile = ref<CompetencyProfile | null>(null);
const curriculumLoading = ref(false);
const prepCard = ref<PracticePrepCard | null>(null);
const prepLoading = ref(false);
const activeScenarioName = ref(defaultScenarioName);
@@ -512,11 +578,39 @@ const practicePageSubtitle = computed(() => {
if (practiceView.value === 'active') return `${activeScenarioName.value} · 第 ${Math.min(roundIndex.value + 1, activeRoundCount.value)}/${activeRoundCount.value} 轮`;
return '先掌握要点,再开始场景练习';
});
const canStartPractice = computed(() => status.value === 'idle' || status.value === 'finished');
const selectedScenario = computed(() =>
availableScenarios.value.find((item) => item.id === selectedScenarioId.value)
);
const canStartPractice = computed(() =>
!curriculumLoading.value
&& (status.value === 'idle' || status.value === 'finished')
&& Boolean(selectedScenario.value?.id)
);
const hasTraineeReply = computed(() => turns.value.some((item) => item.role === 'trainee'));
const canFinishPractice = computed(() => hasTraineeReply.value && !redFlagActive.value);
const hasCoachState = computed(() => emotionScore.value > 0 || trustScore.value > 0 || redFlagActive.value);
const canAdjustCoach = computed(() => status.value === 'active' && lastCoachRoundIndex.value !== null && turns.value.some((item) => item.role === 'coach'));
const selectedScenarioDescription = computed(() => {
const item = selectedScenario.value;
return [positionLabel.value, item?.growthLevel || '基础训练', item?.competencyCode || '业主服务场景']
.filter(Boolean)
.join(' · ');
});
const growthConfirmation = computed(() =>
(competencyProfile.value?.growthConfirmations || [])
.find((item) => item.position === positionLabel.value)
);
const currentGrowthLevel = computed(() => {
const confirmed = growthConfirmation.value?.confirmedGrowthLevel || '';
if (['初级', '中级', '高级'].includes(confirmed)) return confirmed;
const level = competencyProfile.value?.aiLevel || '';
return ['初级', '中级', '高级'].includes(level) ? level : '初级';
});
const growthPath = computed<GrowthStage[]>(() => competencyProfile.value?.growthPath || []);
const capabilityProgress = computed<CapabilityProgress[]>(() =>
(competencyProfile.value?.capabilityProgress || [])
.filter((item) => !item.position || item.position === positionLabel.value)
);
const recentPracticeItems = computed<RecentPracticeItem[]>(() => {
const assignmentItems: RecentPracticeItem[] = assignments.value
.filter((item) => item.source !== 'daily')
@@ -781,6 +875,9 @@ const clearPracticeData = () => {
cancelRecording();
assignments.value = [];
practiceHistory.value = [];
availableScenarios.value = [];
competencyProfile.value = null;
curriculumLoading.value = false;
drillAnswers.value = {};
dailyAudioUrls.value = {};
dailyAudioOssIds.value = {};
@@ -1133,10 +1230,68 @@ const loadAssignments = async (request: RequestScopeTicket = requests.begin('ass
if (!requests.isCurrent(request)) return;
handleError(error, '今日任务加载失败');
} finally {
if (requests.isCurrent(request)) assignmentLoading.value = false;
if (requests.isCurrent(request)) {
assignmentLoading.value = false;
consumePendingAssignment();
}
}
};
const loadCurriculum = async (request: RequestScopeTicket = requests.begin('curriculum')) => {
refreshAuthState();
if (!loggedIn.value) {
availableScenarios.value = [];
competencyProfile.value = null;
return;
}
curriculumLoading.value = true;
try {
const [profileResult, scenarioResult] = await Promise.allSettled([
getCompetencyProfile(),
getPracticeScenarios(positionLabel.value)
]);
if (!requests.isCurrent(request)) return;
if (profileResult.status === 'fulfilled') {
competencyProfile.value = profileResult.value;
}
if (scenarioResult.status === 'fulfilled') {
const nextScenarios = (scenarioResult.value || []).filter((item) =>
item.enabled !== false && (!item.reviewStatus || item.reviewStatus === '已发布')
);
availableScenarios.value = nextScenarios;
const activeScenario = nextScenarios.find((item) => item.id === selectedScenarioId.value) || nextScenarios[0];
if (activeScenario) {
selectedScenarioId.value = activeScenario.id;
activeScenarioName.value = activeScenario.name || defaultScenarioName;
void loadPrepCard(activeScenario.id, requests.begin('prep-card'));
} else {
selectedScenarioId.value = '';
activeScenarioName.value = '暂无可用训练场景';
prepCard.value = null;
}
} else {
availableScenarios.value = [];
selectedScenarioId.value = '';
activeScenarioName.value = '暂无可用训练场景';
prepCard.value = null;
handleError(scenarioResult.reason, '训练场景加载失败');
}
} finally {
if (requests.isCurrent(request)) {
curriculumLoading.value = false;
consumePendingAssignment();
}
}
};
const selectPracticeScenario = (item: PracticeScenarioOption) => {
if (busy.value || !item?.id) return;
selectedScenarioId.value = item.id;
activeScenarioName.value = item.name || defaultScenarioName;
prepCard.value = null;
void loadPrepCard(item.id, requests.begin('prep-card'));
};
const openRecentItem = (item: RecentPracticeItem) => {
if (item.assignment) {
startAssignment(item.assignment);
@@ -1152,20 +1307,31 @@ const openPracticeHistory = () => {
const consumePendingAssignment = () => {
const raw = uni.getStorageSync(pendingAssignmentKey);
if (!raw) return;
uni.removeStorageSync(pendingAssignmentKey);
const id = Number(raw);
if (!Number.isFinite(id)) return;
if (!Number.isFinite(id)) {
uni.removeStorageSync(pendingAssignmentKey);
return;
}
if (assignmentLoading.value || curriculumLoading.value) return;
const item = assignments.value.find((row) => row.id === id);
if (!item) {
uni.removeStorageSync(pendingAssignmentKey);
message.value = '首页任务已更新,请从列表选择';
return;
}
highlightedAssignmentId.value = item.id;
if (item.source !== 'daily' && item.status === '待训练' && canStartPractice.value) {
startAssignment(item);
if (item.source === 'daily') {
uni.removeStorageSync(pendingAssignmentKey);
message.value = '已定位首页任务,请填写话术后提交';
return;
}
message.value = item.source === 'daily' ? '已定位首页任务,请填写话术后提交' : '该任务已完成,请选择其他任务';
if (item.status !== '待训练') {
uni.removeStorageSync(pendingAssignmentKey);
message.value = item.status === '已完成' ? '该任务已完成,请选择其他任务' : `该任务当前为${item.status || '不可训练'},请从列表查看`;
return;
}
startAssignment(item);
uni.removeStorageSync(pendingAssignmentKey);
};
const submitDaily = async (item: PracticeAssignment) => {
@@ -1227,11 +1393,22 @@ const submitItemFeedback = async (assignmentId: number, verdict: 'useful' | 'not
};
const startAssignment = (item: PracticeAssignment) => {
if (!canStartPractice.value) return;
selectedScenarioId.value = item.scenarioId || defaultScenarioId;
activeScenarioName.value = item.scenarioName || defaultScenarioName;
if (curriculumLoading.value) {
message.value = '训练场景加载中,请稍候';
return false;
}
const scenarioId = (item.scenarioId || '').trim();
const scenario = availableScenarios.value.find((entry) => entry.id === scenarioId);
if (!scenario) {
message.value = '本次专项关联的场景已下线或待发布,请联系内容运营后再练习';
return false;
}
if (!canStartPractice.value) return false;
selectedScenarioId.value = scenario.id;
activeScenarioName.value = scenario.name || item.scenarioName || defaultScenarioName;
void loadPrepCard(selectedScenarioId.value);
void start(item.scenarioId || defaultScenarioId, item.id);
void start(scenario.id, item.id);
return true;
};
const loadPrepCard = async (
@@ -1257,7 +1434,7 @@ const loadPrepCard = async (
}
};
const start = async (scenarioId = defaultScenarioId, assignmentId?: number) => {
const start = async (scenarioId = selectedScenarioId.value, assignmentId?: number) => {
if (!canStartPractice.value) return;
if (!requireLogin()) return;
stopRealtime();
@@ -1461,6 +1638,7 @@ const finish = async (force = false) => {
completedAssignmentFeedback.value = null;
activeAssignmentId.value = undefined;
await loadAssignments();
void loadCurriculum(requests.begin('curriculum'));
} catch (error) {
if (!requests.isCurrent(request)) return;
status.value = 'active';
@@ -1495,13 +1673,14 @@ const handleShow = async () => {
if (!requests.isCurrent(pageRequest)) return;
positionLabel.value = getSelectedPosition() || '生活顾问';
void loadAssignments(requests.begin('assignments'));
void loadPrepCard(selectedScenarioId.value, requests.begin('prep-card'));
void loadCurriculum(requests.begin('curriculum'));
};
const invalidateRequests = () => {
requests.invalidate();
assignmentLoading.value = false;
prepLoading.value = false;
curriculumLoading.value = false;
answeringId.value = null;
feedbackSubmittingId.value = null;
helpLoading.value = false;
@@ -1611,6 +1790,250 @@ onUnload(() => {
padding: 0 12px;
}
.growth-card,
.scene-picker-card {
margin-top: 16px;
border-color: #dbe6f5;
background: #ffffff;
box-shadow: var(--employee-card-shadow);
}
.growth-card {
padding: 18px;
}
.growth-card-head,
.scene-picker-head,
.growth-path-row,
.capability-progress-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.growth-meta,
.growth-note,
.growth-empty,
.scene-picker-subtitle,
.scene-picker-note,
.scene-picker-empty,
.growth-path-evidence,
.capability-progress-title,
.scene-option-level,
.scene-option-capability {
display: block;
}
.growth-meta {
margin-top: 5px;
color: #66758a;
font-size: 12px;
line-height: 1.45;
}
.growth-confirmation {
display: block;
margin-top: 10px;
color: #0f766e;
font-size: 12px;
font-weight: 700;
line-height: 1.45;
}
.growth-level-badge {
flex: 0 0 auto;
padding: 5px 10px;
border-radius: 999px;
background: #eef5ff;
color: #315b8f;
font-size: 12px;
font-weight: 800;
}
.growth-path-list {
display: grid;
gap: 8px;
margin-top: 14px;
}
.growth-path-row {
align-items: flex-start;
padding: 9px 0;
border-top: 1px solid #eef1f5;
}
.growth-path-row.reached .growth-path-level,
.growth-path-row.reached .growth-path-status {
color: #168243;
}
.growth-path-copy {
min-width: 0;
}
.growth-path-level {
display: block;
color: #25282d;
font-size: 13px;
font-weight: 800;
}
.growth-path-evidence {
margin-top: 3px;
color: #7b8491;
font-size: 11px;
line-height: 1.45;
}
.growth-path-status {
flex: 0 0 auto;
padding-top: 1px;
color: #54667d;
font-size: 11px;
white-space: nowrap;
}
.capability-progress-list {
display: grid;
gap: 7px;
margin-top: 12px;
padding: 11px 12px;
border-radius: 12px;
background: #f7faff;
}
.capability-progress-title {
color: #315b8f;
font-size: 12px;
font-weight: 800;
}
.capability-progress-row {
color: #566577;
font-size: 11px;
line-height: 1.45;
}
.growth-empty,
.growth-note {
color: #778395;
font-size: 11px;
line-height: 1.5;
}
.growth-empty {
margin-top: 12px;
}
.growth-note {
margin-top: 10px;
color: #315b8f;
}
.scene-picker-card {
padding: 18px 0 14px;
overflow: hidden;
}
.scene-picker-head {
padding: 0 18px;
}
.scene-picker-subtitle {
margin-top: 5px;
color: #69798d;
font-size: 12px;
line-height: 1.45;
}
.scene-picker-loading {
color: #708198;
font-size: 12px;
}
.scene-option-scroll {
margin-top: 12px;
white-space: nowrap;
}
.scene-option-list {
display: inline-flex;
gap: 10px;
padding: 0 18px;
}
.scene-option {
display: inline-flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
width: 144px;
min-height: 100px;
padding: 13px;
border: 1px solid #dfe7f1;
border-radius: 14px;
background: #f8fafc;
color: #25282d;
text-align: left;
vertical-align: top;
white-space: normal;
}
.scene-option.selected {
border-color: #e70014;
background: #fff7f7;
box-shadow: 0 6px 16px rgba(231, 0, 20, 0.08);
}
.scene-option:disabled {
opacity: 0.62;
}
.scene-option-level {
color: #d51b2b;
font-size: 11px;
font-weight: 800;
}
.scene-option-name {
display: block;
min-height: 38px;
margin-top: 7px;
overflow: hidden;
color: #25282d;
font-size: 14px;
font-weight: 900;
line-height: 1.4;
text-overflow: ellipsis;
}
.scene-option-capability {
margin-top: 4px;
overflow: hidden;
color: #778395;
font-size: 11px;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.scene-picker-note,
.scene-picker-empty {
padding: 0 18px;
color: #778395;
font-size: 11px;
line-height: 1.5;
}
.scene-picker-note {
margin-top: 12px;
}
.scene-picker-empty {
margin-top: 12px;
}
.scenario-card,
.dialogue-card,
.reply-card,