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
+2 -2
View File
@@ -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%;
+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,
@@ -41,6 +41,8 @@ export interface TeamProfile {
export interface SupervisorScenarioOption {
id: string;
name: string;
growthLevel?: string;
competencyCode?: string;
}
export const supervisorScenarioOptions: SupervisorScenarioOption[] = [
+12
View File
@@ -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 {
+35 -1
View File
@@ -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');