feat(aihr): finalize practice growth and access boundaries
This commit is contained in:
@@ -34,7 +34,7 @@
|
||||
<uni-easyinput
|
||||
v-model="formData.phone"
|
||||
aria-label="手机号"
|
||||
type="number"
|
||||
type="tel"
|
||||
maxlength="11"
|
||||
:clearable="false"
|
||||
primary-color="#e60012"
|
||||
@@ -53,7 +53,7 @@
|
||||
v-model="formData.smsCode"
|
||||
class="code-input"
|
||||
aria-label="验证码"
|
||||
type="number"
|
||||
type="tel"
|
||||
maxlength="6"
|
||||
:clearable="false"
|
||||
primary-color="#e60012"
|
||||
|
||||
@@ -42,6 +42,18 @@
|
||||
<text class="head-title">{{ displayName }} · {{ detail.scene }}</text>
|
||||
</view>
|
||||
<text class="head-meta">{{ detail.time }} · {{ detail.status }}</text>
|
||||
<view class="curriculum-summary">
|
||||
<text class="curriculum-summary-label">训练归属</text>
|
||||
<view class="curriculum-summary-tags">
|
||||
<text class="curriculum-summary-tag">{{ detail.projectType || '住宅' }}</text>
|
||||
<text class="curriculum-summary-tag">{{ detail.position || '岗位待补录' }}</text>
|
||||
<text class="curriculum-summary-tag">{{ detail.growthLevel || '层级待补录' }}</text>
|
||||
<text class="curriculum-summary-tag">{{ detail.competencyCode || '能力项待归类' }}</text>
|
||||
</view>
|
||||
<text v-if="detail.collaborationPositions" class="curriculum-summary-note">协同岗位:{{ detail.collaborationPositions }}</text>
|
||||
<text v-if="detail.curriculumVersion && detail.curriculumVersion !== '历史未记录'" class="curriculum-summary-note">目录版本:{{ detail.curriculumVersion }}(本次训练快照)</text>
|
||||
<text v-else class="curriculum-summary-note">目录版本未记录;该训练早于课程快照机制,不能按当前场景倒推。</text>
|
||||
</view>
|
||||
<view class="ai-summary">
|
||||
<view class="ai-summary-label">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z"/><path d="M19 15l.9 2.1L22 18l-2.1.9L19 21l-.9-2.1L16 18l2.1-.9z"/></svg>
|
||||
@@ -184,7 +196,7 @@
|
||||
:class="{ active: nextScenarioId === item.id }"
|
||||
@click="selectNextScenario(item.id)"
|
||||
>
|
||||
{{ item.name }}
|
||||
{{ item.name }}<text v-if="item.growthLevel || item.competencyCode"> · {{ item.growthLevel || '基础' }}{{ item.competencyCode ? ` · ${item.competencyCode}` : '' }}</text>
|
||||
</button>
|
||||
</view>
|
||||
<textarea
|
||||
@@ -196,6 +208,23 @@
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view v-if="nextConfirmableGrowthLevel" class="next-training-section">
|
||||
<text class="field-label">训练层级确认(可选)</text>
|
||||
<view class="scenario-row">
|
||||
<button class="scenario-chip" :class="{ active: !confirmedGrowthLevel }" @click="confirmedGrowthLevel = ''">
|
||||
暂不确认
|
||||
</button>
|
||||
<button
|
||||
class="scenario-chip"
|
||||
:class="{ active: confirmedGrowthLevel === nextConfirmableGrowthLevel }"
|
||||
@click="confirmedGrowthLevel = nextConfirmableGrowthLevel"
|
||||
>
|
||||
确认可进入{{ nextConfirmableGrowthLevel }}
|
||||
</button>
|
||||
</view>
|
||||
<text class="growth-confirmation-note">仅记录训练层级证据,不调整职级、薪酬或上岗资格。</text>
|
||||
</view>
|
||||
|
||||
<button class="primary-button submit-button tabbar-safe-action" :disabled="marking" @click="markReviewed">
|
||||
{{ marking ? '提交中…' : '标记已复盘' }}
|
||||
</button>
|
||||
@@ -207,6 +236,9 @@
|
||||
<text v-if="detail.incentivePoint != null" class="complete-point">
|
||||
人工贡献分 {{ detail.incentivePoint }}
|
||||
</text>
|
||||
<text v-if="detail.confirmedGrowthLevel" class="complete-point">
|
||||
已确认可进入{{ detail.confirmedGrowthLevel }}{{ detail.growthConfirmedTime ? ` · ${detail.growthConfirmedTime}` : '' }}
|
||||
</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -226,7 +258,7 @@ import { computed, onBeforeUnmount, ref } from 'vue';
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app';
|
||||
import type { ReviewDetail, ReviewDialogue } from '@/types/api';
|
||||
import { authenticatedFileUrl, releaseAuthenticatedFileUrl } from '@/services/api';
|
||||
import { clearAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||
import { clearAuth, isLoggedIn, rememberLoginRedirect, type UserPosition } from '@/services/auth';
|
||||
import { resetPageScroll } from '@/services/navigation';
|
||||
import { getPracticeReviewDetail, getPracticeScenarios, getPracticeTeamSnapshot, markPracticeReviewed } from '@/services/practice';
|
||||
import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
|
||||
@@ -235,7 +267,6 @@ import {
|
||||
createAsyncGenerationGuard,
|
||||
isReviewEditable,
|
||||
isSupervisorAccessDenied,
|
||||
supervisorScenarioOptions,
|
||||
type SupervisorScenarioOption,
|
||||
type SupervisorScenarioId
|
||||
} from '@/services/supervisor-view';
|
||||
@@ -246,8 +277,9 @@ const detail = ref<ReviewDetail | null>(null);
|
||||
const advice = ref('');
|
||||
const incentivePoint = ref('');
|
||||
const nextScenarioId = ref<SupervisorScenarioId | ''>('');
|
||||
const scenarioOptions = ref<SupervisorScenarioOption[]>([...supervisorScenarioOptions]);
|
||||
const scenarioOptions = ref<SupervisorScenarioOption[]>([]);
|
||||
const nextReason = ref('');
|
||||
const confirmedGrowthLevel = ref('');
|
||||
const loggedIn = ref(false);
|
||||
const accessDenied = ref(false);
|
||||
const loading = ref(false);
|
||||
@@ -258,7 +290,16 @@ const audioLoading = ref<Record<string, boolean>>({});
|
||||
const audioErrors = ref<Record<string, string>>({});
|
||||
const audioRequests = createAsyncGenerationGuard();
|
||||
const memberNames = ref<Record<string, string>>({});
|
||||
const scenarioPosition = (value?: string): UserPosition | null => {
|
||||
const known: UserPosition[] = ['生活顾问', '保安', '保洁', '保修', '客服'];
|
||||
return known.includes(value as UserPosition) ? value as UserPosition : null;
|
||||
};
|
||||
const editable = computed(() => isReviewEditable(detail.value?.status));
|
||||
const nextConfirmableGrowthLevel = computed(() => {
|
||||
if (detail.value?.growthLevel === '初级') return '中级';
|
||||
if (detail.value?.growthLevel === '中级') return '高级';
|
||||
return '';
|
||||
});
|
||||
const loadFailed = computed(() => !loading.value && !!message.value && !detail.value);
|
||||
const firstLoading = computed(() => loading.value && !detail.value && !message.value);
|
||||
|
||||
@@ -315,6 +356,7 @@ const clearDetail = () => {
|
||||
incentivePoint.value = '';
|
||||
nextScenarioId.value = '';
|
||||
nextReason.value = '';
|
||||
confirmedGrowthLevel.value = '';
|
||||
};
|
||||
|
||||
const refreshAuthState = () => {
|
||||
@@ -410,6 +452,8 @@ const loadDetail = async () => {
|
||||
incentivePoint.value = detail.value.incentivePoint == null ? '' : String(detail.value.incentivePoint);
|
||||
nextScenarioId.value = '';
|
||||
nextReason.value = '';
|
||||
confirmedGrowthLevel.value = '';
|
||||
void loadScenarioOptions();
|
||||
} catch (error) {
|
||||
clearDetail();
|
||||
handleRequestError(error, '复盘详情加载失败');
|
||||
@@ -419,18 +463,30 @@ const loadDetail = async () => {
|
||||
};
|
||||
|
||||
const loadScenarioOptions = async () => {
|
||||
const position = scenarioPosition(detail.value?.position);
|
||||
if (!position) {
|
||||
scenarioOptions.value = [];
|
||||
nextScenarioId.value = '';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const rows = await getPracticeScenarios('生活顾问');
|
||||
const rows = await getPracticeScenarios(position);
|
||||
const nextOptions = rows
|
||||
.filter((item) => item.enabled !== false && item.id && item.name)
|
||||
.map((item) => ({ id: item.id, name: item.name }));
|
||||
.filter((item) => item.enabled !== false && (!item.reviewStatus || item.reviewStatus === '已发布') && item.id && item.name)
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
growthLevel: item.growthLevel,
|
||||
competencyCode: item.competencyCode
|
||||
}));
|
||||
if (nextOptions.length) {
|
||||
scenarioOptions.value = nextOptions;
|
||||
} else {
|
||||
scenarioOptions.value = [];
|
||||
}
|
||||
} catch {
|
||||
scenarioOptions.value = [...supervisorScenarioOptions];
|
||||
scenarioOptions.value = [];
|
||||
nextScenarioId.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -469,7 +525,8 @@ const markReviewed = async () => {
|
||||
advice: advice.value.trim(),
|
||||
incentivePoint: point,
|
||||
nextScenarioId: nextScenarioId.value || undefined,
|
||||
nextReason: nextScenarioId.value ? nextReason.value.trim() : undefined
|
||||
nextReason: nextScenarioId.value ? nextReason.value.trim() : undefined,
|
||||
confirmedGrowthLevel: confirmedGrowthLevel.value || undefined
|
||||
});
|
||||
const params = new URLSearchParams({ id: String(detail.value.id) });
|
||||
if (assignedScenario) params.set('assigned', assignedScenario);
|
||||
@@ -691,6 +748,51 @@ onBeforeUnmount(() => {
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.curriculum-summary {
|
||||
margin-top: 11px;
|
||||
padding: 10px 11px;
|
||||
border-radius: 12px;
|
||||
background: #f7faff;
|
||||
}
|
||||
|
||||
.curriculum-summary-label,
|
||||
.curriculum-summary-note {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.curriculum-summary-label {
|
||||
color: #315b8f;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.curriculum-summary-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 7px;
|
||||
}
|
||||
|
||||
.curriculum-summary-tag {
|
||||
max-width: 100%;
|
||||
padding: 3px 7px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: #eaf2ff;
|
||||
color: #4b6381;
|
||||
font-size: 10px;
|
||||
line-height: 1.35;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.curriculum-summary-note {
|
||||
margin-top: 5px;
|
||||
color: #778395;
|
||||
font-size: 10px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.head-score {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
@@ -1108,6 +1210,14 @@ onBeforeUnmount(() => {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.growth-confirmation-note {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
color: #7b8798;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -41,6 +41,8 @@ export interface TeamProfile {
|
||||
export interface SupervisorScenarioOption {
|
||||
id: string;
|
||||
name: string;
|
||||
growthLevel?: string;
|
||||
competencyCode?: string;
|
||||
}
|
||||
|
||||
export const supervisorScenarioOptions: SupervisorScenarioOption[] = [
|
||||
|
||||
@@ -673,6 +673,9 @@ export interface ReviewDetail extends PracticeRecord {
|
||||
aiComment: string;
|
||||
reviewAdvice?: string;
|
||||
incentivePoint?: number | null;
|
||||
confirmedGrowthLevel?: string;
|
||||
growthConfirmedBy?: string;
|
||||
growthConfirmedTime?: string;
|
||||
scoreItems: CompetencyDimension[];
|
||||
dialogue: ReviewDialogue[];
|
||||
annotations: ReviewAnnotation[];
|
||||
@@ -684,6 +687,7 @@ export interface ReviewAdviceRequest {
|
||||
incentivePoint?: number;
|
||||
nextScenarioId?: string;
|
||||
nextReason?: string;
|
||||
confirmedGrowthLevel?: string;
|
||||
}
|
||||
|
||||
export interface PracticeAssignment {
|
||||
@@ -1145,6 +1149,13 @@ export interface GrowthStage {
|
||||
evidence: string;
|
||||
}
|
||||
|
||||
export interface GrowthConfirmation {
|
||||
position: string;
|
||||
confirmedGrowthLevel: string;
|
||||
reviewer: string;
|
||||
confirmedTime: string;
|
||||
}
|
||||
|
||||
export interface CompetencyProfile {
|
||||
score: number;
|
||||
completed: number;
|
||||
@@ -1156,6 +1167,7 @@ export interface CompetencyProfile {
|
||||
dimensions: CompetencyDimension[];
|
||||
growthPath?: GrowthStage[];
|
||||
capabilityProgress?: CapabilityProgress[];
|
||||
growthConfirmations?: GrowthConfirmation[];
|
||||
}
|
||||
|
||||
export interface CapabilityProgress {
|
||||
|
||||
@@ -42,6 +42,13 @@ test('训练页提交失败会回滚本次乐观追加的对话', async () => {
|
||||
assert.match(source, /uni\.showToast\(\{ title: message\.value \|\| '提交失败', icon: 'none'/);
|
||||
});
|
||||
|
||||
test('训练主操作使用当前已发布的场景,而不覆盖为内置默认场景', async () => {
|
||||
const source = await pageSource('../src/pages/user/practice/index.vue');
|
||||
|
||||
assert.match(source, /@click="start\(selectedScenarioId\)"/);
|
||||
assert.match(source, /const start = async \(scenarioId = selectedScenarioId\.value, assignmentId\?: number\)/);
|
||||
});
|
||||
|
||||
test('训练求助先留存会话事件,再以 practice_help 来源检索 SOP', async () => {
|
||||
const [practicePage, practiceService, knowledgeService] = await Promise.all([
|
||||
pageSource('../src/pages/user/practice/index.vue'),
|
||||
@@ -347,10 +354,37 @@ test('员工练习页显示当前岗位而不是固定生活顾问', async () =>
|
||||
|
||||
assert.match(source, /getSelectedPosition/);
|
||||
assert.match(source, /const positionLabel = ref\(getSelectedPosition\(\) \|\| '生活顾问'\)/);
|
||||
assert.match(source, /\{\{ positionLabel \}\} · 业主服务场景/);
|
||||
assert.match(source, /const selectedScenarioDescription = computed\(\(\) =>/);
|
||||
assert.match(source, /\{\{ selectedScenarioDescription \}\}/);
|
||||
assert.doesNotMatch(source, /scenario-desc">生活顾问 · 业主服务场景/);
|
||||
});
|
||||
|
||||
test('场景目录加载失败时清空旧场景并禁止开始训练', async () => {
|
||||
const source = await pageSource('../src/pages/user/practice/index.vue');
|
||||
|
||||
assert.match(source, /selectedScenarioId\.value = ''/);
|
||||
assert.match(source, /activeScenarioName\.value = '暂无可用训练场景'/);
|
||||
assert.match(source, /handleError\(scenarioResult\.reason, '训练场景加载失败'\)/);
|
||||
assert.match(source, /:disabled="!canStartPractice \|\| busy"/);
|
||||
assert.match(source, /!curriculumLoading\.value/);
|
||||
});
|
||||
|
||||
test('无已发布场景时不展示默认预习卡或训练入口', async () => {
|
||||
const source = await pageSource('../src/pages/user/practice/index.vue');
|
||||
|
||||
assert.match(source, /v-if="loggedIn && practiceView === 'prep' && availableScenarios\.length" class="panel scenario-card employee-card"/);
|
||||
assert.match(source, /const activeScenario = nextScenarios\.find\([\s\S]*?\|\| nextScenarios\[0\];[\s\S]*?else \{\s*selectedScenarioId\.value = '';\s*activeScenarioName\.value = '暂无可用训练场景';\s*prepCard\.value = null;/);
|
||||
});
|
||||
|
||||
test('待训练专项等待目录加载,并对下线或待发布场景给出明确提示', async () => {
|
||||
const source = await pageSource('../src/pages/user/practice/index.vue');
|
||||
|
||||
assert.match(source, /if \(assignmentLoading\.value \|\| curriculumLoading\.value\) return;/);
|
||||
assert.match(source, /startAssignment\(item\);\s*uni\.removeStorageSync\(pendingAssignmentKey\);/);
|
||||
assert.match(source, /const scenario = availableScenarios\.value\.find\(\(entry\) => entry\.id === scenarioId\);/);
|
||||
assert.match(source, /本次专项关联的场景已下线或待发布,请联系内容运营后再练习/);
|
||||
});
|
||||
|
||||
test('员工问师傅页显示当前岗位而不是固定生活顾问', async () => {
|
||||
const source = await pageSource('../src/pages/user/sop/index.vue');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user