@@ -5,7 +5,7 @@
< text class = "page-subtitle" > { { practicePageSubtitle } } < / text >
< / view >
< view v-if = "message" class="status-message" > {{ message }} < / view >
< view v-if = "message && (!loggedIn || practiceView !== 'prep') " class="status-message" > {{ message }} < / view >
< view v-if = "!loggedIn" class="panel login-panel employee-card" >
< view >
@@ -15,38 +15,46 @@
< button class = "primary-button login-button" @click ="goLogin" > 去登录 < / button >
< / view >
< 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 v-if = "loggedIn && practiceView === 'prep'" class="panel scenario-card realtime-entry -card employee-card" >
< view class = "realtime-entry -head" >
< view class = "realtime-title-row" >
< text class = "scenario -title" > 实时陪练 < / text >
< text class = "realtime-beta-badge" > Beta < / text >
< / view >
< text class = "growth-level-badge" > { { currentGrowthLevel } } < / text >
< text class = "scenario-desc" > 选一个角色 , 直接开口练 ; 无需等待场景发布 。 < / 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 v-if = "realtimePersonaOptions.length" class="realtime-persona-picker " >
< text class = "realtime-persona-title" > 选择陪练角色 < / text >
< scroll-view class = "scene-option-scroll realtime-persona-scroll" scroll -x >
< view class = "scene-option -list" >
< button
v-for = "item in realtimePersonaOptions"
:key = "item.id"
class = "scene-option realtime-persona-option"
: class = "{ selected: selectedRealtimePersonaId === item.id }"
:disabled = "busy"
@click ="selectRealtimePersona(item)"
>
< text class = "scene-option-name" > { { item . name } } < / text >
< text class = "scene-option-capability" > { { item . hasTools ? '可查询知识库' : '纯对练' } } < / text >
< / button >
< / view >
< text class = "growth-path-status" > { { item . status } } · { { item . progress } } % < / text >
< / scroll-view >
< / 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 >
< button
v-if = "status === 'idle' || status === 'finished'"
class = "primary-button realtime-start-button"
: disabled = "!canStartRealtime || busy"
@click ="startRealtime"
>
开始实时陪练
< / button >
< text class = "realtime-beta-note " > 不计入成绩 , 可随时结束 < / text >
< / view >
< view v-if = "loggedIn && practiceView === 'prep'" class="panel scene-picker-card employee-card" >
< view v-if = "message && loggedIn && practiceView === 'prep'" class="status-message status-message-compact" > {{ message }} < / view >
< view v-if = "loggedIn && practiceView === 'prep' && (curriculumLoading || availableScenarios.length)" class="panel scene-picker-card employee-card" >
< view class = "scene-picker-head" >
< view >
< text class = "panel-title" > 场景训练 < / text >
@@ -71,7 +79,14 @@
< / view >
< / scroll-view >
< text v-else-if = "!curriculumLoading" class="scene-picker-empty" > 当前岗位暂无已发布场景 , 请联系内容运营完成审核发布 。 < / text >
< text class = "scene-picker-note" > 复杂场景的正式前置解锁规则仍待业务确认 ; 系统不会以估算分数硬性拦截已发布训练 。 < / text >
< / view >
< view v-else-if = "loggedIn && practiceView === 'prep'" class="panel scene-empty-card employee-card" >
< view >
< text class = "panel-title" > 常规场景 < / text >
< text class = "scene-picker-subtitle" > 发布后可录音对练并保存成绩 < / text >
< / view >
< text class = "scene-empty-state" > 暂未开放 < / text >
< / view >
< view v-if = "loggedIn && practiceView === 'prep' && availableScenarios.length" class="panel scenario-card practice-scenario-card employee-card" >
@@ -112,41 +127,50 @@
< / button >
< / view >
< view v-if = "loggedIn && practiceView === 'prep'" class="panel scenario-card realtime-entry -card employee-card" >
< view class = "scenario -headline " >
< text class = "scenario-title" > 实时陪练 Beta < / text >
< text class = "scenario-desc" > 直接与数字师傅或模拟业主对话 , 不依赖场景审核发布 。 < / text >
< 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 } } < / text >
< / view >
< view v-if = "realtimePersonaOptions.length" class="realtime-persona-picker" >
< view class = "realtime-persona-heading" >
< text class = "realtime-persona-title" > 实时陪练人设 < / text >
< text class = "growth-level-badge" > { { currentGrowthLevel } } < / text >
< / view >
< scroll-view class = "scene-option-scroll" scroll -x >
< view class = "scene-option-list " >
< button
v-for = "item in realtimePersonaOptions"
:key = "item.id"
class = "scene-option"
: class = "{ selected: selectedRealtimePersonaId === item.id }"
:disabled = "busy"
@click ="selectRealtimePersona(item)"
>
< text class = "scene-option-level" > { { item . hasTools ? '可查询知识库' : '纯对练' } } < / text >
< text class = "scene-option-name" > { { item . name } } < / text >
< text class = "scene-option-capability" > { { item . description } } < / text >
< view class = "growth-summary" >
< view class = "growth-summary-copy " >
< text > 当前进度 < / text >
< text > { { currentGrowthProgress } } % < / text >
< / view >
< view class = "growth-summary-track" aria -hidden = " true " >
< view class = "growth-summary-fill" : style = "{ width: `${currentGrowthProgress}%` }" / >
< / view >
< / view >
< button class = "growth-toggle" :aria-expanded = "growthDetailsExpanded" @click ="growthDetailsExpanded = !growthDetailsExpanded" >
{{ growthDetailsExpanded ? ' 收起成长规则 ' : ' 查看成长规则 ' }}
< text aria -hidden = " true " > { { growthDetailsExpanded ? '⌃' : '⌄' } } < / text >
< / button >
< view v-if = "growthDetailsExpanded" class="growth-details" >
< 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 >
< / scroll-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 >
< button
v-if = "status === 'idle' || status === 'finished'"
class = "secondary-button realtime-start-button"
: disabled = "!canStartRealtime || busy"
@click ="startRealtime"
>
开始实时陪练
< / button >
< text class = "realtime-beta-note" > 自然打断 、 边听边说 ; 本次体验不计入训练成绩 。 < / text >
< / view >
< view v-if = "loggedIn && practiceView === 'prep'" class="panel assignment-panel recent-panel" >
@@ -272,6 +296,11 @@
< scroll -view scroll -y class = "chat-thread" :scroll-top = "threadScrollTop" :scroll-with-animation = "true" >
< view class = "thread-inner" >
< view v-if = "realtimeActive && !turns.length" class="realtime-listening-empty" >
< text class = "realtime-listening-label" > { { realtimeStatusLabel } } < / text >
< text class = "realtime-listening-title" > 直接开口说话 < / text >
< text class = "realtime-listening-copy" > 实时对话会显示在这里 < / text >
< / view >
< template v-for = "(turn, index) in turns" :key="index" >
< view v-if = "turn.role === 'customer'" class="bubble-row left" >
< view class = "avatar customer-avatar" > 业 < / view >
@@ -536,6 +565,7 @@ const realtimeAudioBlocked = ref(false);
const realtimeSessionId = ref ( '' ) ;
const realtimePersonaOptions = ref < RealtimePersonaOption [ ] > ( [ ] ) ;
const selectedRealtimePersonaId = ref ( typeof storedRealtimePersona === 'string' ? storedRealtimePersona : '' ) ;
const growthDetailsExpanded = ref ( false ) ;
const assignments = ref < PracticeAssignment [ ] > ( [ ] ) ;
const practiceHistory = ref < PracticeRecord [ ] > ( [ ] ) ;
const drillAnswers = ref < Record < number , string > > ( { } ) ;
@@ -585,12 +615,12 @@ const practiceView = computed<'prep' | 'active' | 'result'>(() => {
} ) ;
const practicePageTitle = computed ( ( ) => {
if ( practiceView . value === 'result' ) return '训练完成' ;
return practiceView . value === 'active' ? '场景对练' : '训练前预习 ' ;
return practiceView . value === 'active' ? '场景对练' : 'AI 陪练 ' ;
} ) ;
const practicePageSubtitle = computed ( ( ) => {
if ( practiceView . value === 'result' ) return ` ${ activeScenarioName . value } · ${ Math . max ( roundIndex . value , 1 ) } 轮对话 ` ;
if ( practiceView . value === 'active' ) return ` ${ activeScenarioName . value } · 第 ${ Math . min ( roundIndex . value + 1 , activeRoundCount . value ) } / ${ activeRoundCount . value } 轮 ` ;
return '先掌握要点,再开始场景练习 ' ;
return '选一种方式,马上开始 ' ;
} ) ;
const selectedScenario = computed ( ( ) =>
availableScenarios . value . find ( ( item ) => item . id === selectedScenarioId . value )
@@ -624,6 +654,9 @@ const currentGrowthLevel = computed(() => {
return [ '初级' , '中级' , '高级' ] . includes ( level ) ? level : '初级' ;
} ) ;
const growthPath = computed < GrowthStage [ ] > ( ( ) => competencyProfile . value ? . growthPath || [ ] ) ;
const currentGrowthProgress = computed ( ( ) =>
growthPath . value . find ( ( item ) => item . level === currentGrowthLevel . value ) ? . progress || 0
) ;
const capabilityProgress = computed < CapabilityProgress [ ] > ( ( ) =>
( competencyProfile . value ? . capabilityProgress || [ ] )
. filter ( ( item ) => ! item . position || item . position === positionLabel . value )
@@ -1033,6 +1066,7 @@ const startRealtime = async () => {
const session = await createRealtimeSession ( selectedRealtimePersonaId . value || undefined , selectedScenarioId . value || undefined ) ;
if ( ! realtimeActive . value ) return ;
realtimeSessionId . value = session . sessionId ;
activeScenarioName . value = session . personaName || '实时陪练' ;
await realtimePractice . start ( session . sessionUpdate , session . sessionId ) ;
if ( ! realtimeActive . value ) return ;
status . value = 'active' ;
@@ -3552,4 +3586,244 @@ onUnload(() => {
. practice - chat . chat - composer - wrap {
border - top : none ;
}
/* 首屏只承载开始训练;规则和运营说明按需展开。 */
. practice - head {
padding : 18 px 20 px 12 px ;
text - align : left ;
}
. practice - head . page - title {
font - size : 24 px ;
letter - spacing : 0 ;
line - height : 1.25 ;
}
. practice - head . page - subtitle {
margin - top : 3 px ;
font - size : 13 px ;
line - height : 1.45 ;
}
. realtime - entry - card {
gap : 12 px ;
margin - top : 12 px ;
padding : 18 px ;
border : 1 px solid # f2d9dc ;
border - radius : 20 px ;
background : linear - gradient ( 150 deg , # ffffff 0 % , # fff7f7 100 % ) ;
box - shadow : 0 8 px 22 px rgba ( 41 , 29 , 31 , 0.06 ) ;
}
. realtime - entry - head {
display : flex ;
flex - direction : column ;
gap : 5 px ;
}
. realtime - title - row {
display : flex ;
align - items : center ;
gap : 8 px ;
}
. realtime - entry - card . scenario - title {
color : # 25282 d ;
font - size : 20 px ;
line - height : 1.35 ;
}
. realtime - entry - card . scenario - desc {
color : # 6 f7784 ;
font - size : 13 px ;
line - height : 1.45 ;
}
. realtime - beta - badge {
padding : 3 px 7 px ;
border - radius : 999 px ;
background : # fff0f1 ;
color : var ( -- employee - primary ) ;
font - size : 11 px ;
font - weight : 800 ;
}
. realtime - persona - picker {
margin - top : 0 ;
padding : 10 px 0 0 ;
border : 0 ;
border - top : 1 px solid # f1e3e4 ;
border - radius : 0 ;
background : transparent ;
}
. realtime - persona - title {
color : # 5 e6672 ;
font - size : 12 px ;
}
. realtime - persona - scroll {
margin : 8 px - 18 px 0 ;
}
. realtime - persona - option {
width : 124 px ;
min - height : 66 px ;
padding : 10 px 12 px ;
border - radius : 13 px ;
background : # ffffff ;
}
. realtime - persona - option . scene - option - name {
min - height : 0 ;
margin - top : 0 ;
font - size : 14 px ;
}
. realtime - persona - option . scene - option - capability {
width : 100 % ;
margin - top : 5 px ;
}
. scenario - card . realtime - start - button {
min - height : 48 px ;
margin - top : 0 ;
border : 0 ;
background : linear - gradient ( 135 deg , # ed1f28 , # ff4b4d ) ;
color : # ffffff ;
box - shadow : 0 8 px 16 px rgba ( 231 , 0 , 20 , 0.18 ) ;
}
. realtime - beta - note {
font - size : 11 px ;
line - height : 1.35 ;
}
. status - message {
display : block ;
margin - top : 12 px ;
padding : 10 px 12 px ;
border - radius : 12 px ;
background : # fff8e8 ;
color : # 875300 ;
font - size : 12 px ;
line - height : 1.5 ;
}
. status - message - compact {
margin - bottom : 0 ;
}
. scene - picker - card ,
. scene - empty - card ,
. growth - card {
margin - top : 12 px ;
}
. scene - empty - card {
display : flex ;
align - items : center ;
justify - content : space - between ;
gap : 12 px ;
padding : 14 px 16 px ;
}
. scene - empty - state {
flex : 0 0 auto ;
color : # 8 a919d ;
font - size : 12 px ;
}
. growth - card {
padding : 16 px ;
}
. growth - summary {
margin - top : 14 px ;
}
. growth - summary - copy {
display : flex ;
align - items : center ;
justify - content : space - between ;
color : # 697488 ;
font - size : 12 px ;
}
. growth - summary - track {
height : 6 px ;
margin - top : 7 px ;
overflow : hidden ;
border - radius : 999 px ;
background : # edf1f5 ;
}
. growth - summary - fill {
height : 100 % ;
border - radius : inherit ;
background : var ( -- employee - primary ) ;
}
. growth - toggle {
display : flex ;
width : 100 % ;
min - height : 44 px ;
align - items : center ;
justify - content : space - between ;
margin : 10 px 0 0 ;
padding : 0 ;
border : 0 ;
border - top : 1 px solid # eef1f4 ;
border - radius : 0 ;
background : transparent ;
color : # 4 e5d70 ;
font - size : 13 px ;
text - align : left ;
}
. growth - toggle : : after {
border : 0 ;
}
. growth - details {
border - top : 1 px solid # eef1f4 ;
}
. growth - details . growth - path - evidence ,
. growth - details . growth - path - status ,
. growth - details . growth - empty ,
. growth - details . growth - note {
font - size : 12 px ;
}
. realtime - listening - empty {
display : flex ;
min - height : 280 px ;
align - items : center ;
justify - content : center ;
flex - direction : column ;
color : # 687487 ;
text - align : center ;
}
. realtime - listening - label {
padding : 5 px 10 px ;
border - radius : 999 px ;
background : # edf8f2 ;
color : # 168243 ;
font - size : 12 px ;
font - weight : 800 ;
}
. realtime - listening - title {
margin - top : 14 px ;
color : # 25282 d ;
font - size : 20 px ;
font - weight : 900 ;
}
. realtime - listening - copy {
margin - top : 6 px ;
font - size : 13 px ;
}
< / style >