diff --git a/AGENTS.md b/AGENTS.md
index d9415b91..6b9fed91 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -23,6 +23,7 @@
- 后端默认通过 portless 启动,入口 `https://wygj-api.localhost/`;使用 `portless run --name wygj-api ./scripts/dev-backend.sh` 或 `./scripts/dev.sh`,`PORTLESS=0` 回退 `8080`。管理端代理目标在 `frontend/.env.development`。
- 管理端前端:`./scripts/dev-frontend.sh`,默认走 portless,入口 `https://wygj-admin.localhost/`;显式 `PORTLESS=0` 时回退端口 `5173`。
- 当前用户侧前端:`portless` 启动 `mobile-uni`,入口 `https://wygj-mobile-uni.localhost/h5/`。
+- 移动端主任务页发布前必须用 `390×844` 复核首屏:核心操作和主按钮无需滚动即可看到,规则/免责声明默认折叠,并实际点击主路径;源码存在或页面可加载不能替代该验收。
- 旧移动端兜底:`./scripts/dev-mobile.sh`,端口 `5174`,只用于回看 MVP 演示壳。
- 重置数据:`./scripts/reset-dev-db.sh`,会删除并重建本地 `ry-vue`。
diff --git a/mobile-uni/src/pages/user/practice/index.vue b/mobile-uni/src/pages/user/practice/index.vue
index 1b1cf7c2..57fc9be3 100644
--- a/mobile-uni/src/pages/user/practice/index.vue
+++ b/mobile-uni/src/pages/user/practice/index.vue
@@ -5,7 +5,7 @@
{{ practicePageSubtitle }}
- {{ message }}
+ {{ message }}
@@ -15,38 +15,46 @@
-
-
-
- 我的成长位置
- 住宅物业 · {{ positionLabel }} · {{ currentGrowthLevel }}
+
+
+
+ 实时陪练
+ Beta
- {{ currentGrowthLevel }}
+ 选一个角色,直接开口练;无需等待场景发布。
-
- 主管已确认可进入{{ growthConfirmation.confirmedGrowthLevel }}{{ growthConfirmation.confirmedTime ? ` · ${growthConfirmation.confirmedTime}` : '' }}
-
-
-
-
- {{ item.level }}
- {{ item.evidence }}
+
+ 选择陪练角色
+
+
+
- {{ item.status }} · {{ item.progress }}%
-
+
-
- 已覆盖能力项
-
- {{ item.growthLevel }} · {{ item.competencyCode }}
- {{ item.completed }} 次 · 均分 {{ item.averageScore }}
-
-
- 完成后会按“岗位 · 层级 · 能力项”记录训练证据;历史未归类记录不会被倒推改写。
- 这是训练建议与证据展示,不自动改变职级、薪酬或晋升资格。
+
+ 不计入成绩,可随时结束
-
+ {{ message }}
+
+
场景训练
@@ -71,7 +79,14 @@
当前岗位暂无已发布场景,请联系内容运营完成审核发布。
- 复杂场景的正式前置解锁规则仍待业务确认;系统不会以估算分数硬性拦截已发布训练。
+
+
+
+
+ 常规场景
+ 发布后可录音对练并保存成绩
+
+ 暂未开放
@@ -112,41 +127,50 @@
-
-
- 实时陪练 Beta
- 直接与数字师傅或模拟业主对话,不依赖场景审核发布。
-
-
-
- 实时陪练人设
+
+
+
+ 我的成长
+ 住宅物业 · {{ positionLabel }}
-
-
-
-
-
+ {{ currentGrowthLevel }}
-
@@ -272,6 +296,11 @@
+
+ {{ realtimeStatusLabel }}
+ 直接开口说话
+ 实时对话会显示在这里
+
业
@@ -536,6 +565,7 @@ const realtimeAudioBlocked = ref(false);
const realtimeSessionId = ref('');
const realtimePersonaOptions = ref([]);
const selectedRealtimePersonaId = ref(typeof storedRealtimePersona === 'string' ? storedRealtimePersona : '');
+const growthDetailsExpanded = ref(false);
const assignments = ref([]);
const practiceHistory = ref([]);
const drillAnswers = ref>({});
@@ -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(() => competencyProfile.value?.growthPath || []);
+const currentGrowthProgress = computed(() =>
+ growthPath.value.find((item) => item.level === currentGrowthLevel.value)?.progress || 0
+);
const capabilityProgress = computed(() =>
(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: 18px 20px 12px;
+ text-align: left;
+}
+
+.practice-head .page-title {
+ font-size: 24px;
+ letter-spacing: 0;
+ line-height: 1.25;
+}
+
+.practice-head .page-subtitle {
+ margin-top: 3px;
+ font-size: 13px;
+ line-height: 1.45;
+}
+
+.realtime-entry-card {
+ gap: 12px;
+ margin-top: 12px;
+ padding: 18px;
+ border: 1px solid #f2d9dc;
+ border-radius: 20px;
+ background: linear-gradient(150deg, #ffffff 0%, #fff7f7 100%);
+ box-shadow: 0 8px 22px rgba(41, 29, 31, 0.06);
+}
+
+.realtime-entry-head {
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+}
+
+.realtime-title-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.realtime-entry-card .scenario-title {
+ color: #25282d;
+ font-size: 20px;
+ line-height: 1.35;
+}
+
+.realtime-entry-card .scenario-desc {
+ color: #6f7784;
+ font-size: 13px;
+ line-height: 1.45;
+}
+
+.realtime-beta-badge {
+ padding: 3px 7px;
+ border-radius: 999px;
+ background: #fff0f1;
+ color: var(--employee-primary);
+ font-size: 11px;
+ font-weight: 800;
+}
+
+.realtime-persona-picker {
+ margin-top: 0;
+ padding: 10px 0 0;
+ border: 0;
+ border-top: 1px solid #f1e3e4;
+ border-radius: 0;
+ background: transparent;
+}
+
+.realtime-persona-title {
+ color: #5e6672;
+ font-size: 12px;
+}
+
+.realtime-persona-scroll {
+ margin: 8px -18px 0;
+}
+
+.realtime-persona-option {
+ width: 124px;
+ min-height: 66px;
+ padding: 10px 12px;
+ border-radius: 13px;
+ background: #ffffff;
+}
+
+.realtime-persona-option .scene-option-name {
+ min-height: 0;
+ margin-top: 0;
+ font-size: 14px;
+}
+
+.realtime-persona-option .scene-option-capability {
+ width: 100%;
+ margin-top: 5px;
+}
+
+.scenario-card .realtime-start-button {
+ min-height: 48px;
+ margin-top: 0;
+ border: 0;
+ background: linear-gradient(135deg, #ed1f28, #ff4b4d);
+ color: #ffffff;
+ box-shadow: 0 8px 16px rgba(231, 0, 20, 0.18);
+}
+
+.realtime-beta-note {
+ font-size: 11px;
+ line-height: 1.35;
+}
+
+.status-message {
+ display: block;
+ margin-top: 12px;
+ padding: 10px 12px;
+ border-radius: 12px;
+ background: #fff8e8;
+ color: #875300;
+ font-size: 12px;
+ line-height: 1.5;
+}
+
+.status-message-compact {
+ margin-bottom: 0;
+}
+
+.scene-picker-card,
+.scene-empty-card,
+.growth-card {
+ margin-top: 12px;
+}
+
+.scene-empty-card {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 14px 16px;
+}
+
+.scene-empty-state {
+ flex: 0 0 auto;
+ color: #8a919d;
+ font-size: 12px;
+}
+
+.growth-card {
+ padding: 16px;
+}
+
+.growth-summary {
+ margin-top: 14px;
+}
+
+.growth-summary-copy {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ color: #697488;
+ font-size: 12px;
+}
+
+.growth-summary-track {
+ height: 6px;
+ margin-top: 7px;
+ overflow: hidden;
+ border-radius: 999px;
+ background: #edf1f5;
+}
+
+.growth-summary-fill {
+ height: 100%;
+ border-radius: inherit;
+ background: var(--employee-primary);
+}
+
+.growth-toggle {
+ display: flex;
+ width: 100%;
+ min-height: 44px;
+ align-items: center;
+ justify-content: space-between;
+ margin: 10px 0 0;
+ padding: 0;
+ border: 0;
+ border-top: 1px solid #eef1f4;
+ border-radius: 0;
+ background: transparent;
+ color: #4e5d70;
+ font-size: 13px;
+ text-align: left;
+}
+
+.growth-toggle::after {
+ border: 0;
+}
+
+.growth-details {
+ border-top: 1px solid #eef1f4;
+}
+
+.growth-details .growth-path-evidence,
+.growth-details .growth-path-status,
+.growth-details .growth-empty,
+.growth-details .growth-note {
+ font-size: 12px;
+}
+
+.realtime-listening-empty {
+ display: flex;
+ min-height: 280px;
+ align-items: center;
+ justify-content: center;
+ flex-direction: column;
+ color: #687487;
+ text-align: center;
+}
+
+.realtime-listening-label {
+ padding: 5px 10px;
+ border-radius: 999px;
+ background: #edf8f2;
+ color: #168243;
+ font-size: 12px;
+ font-weight: 800;
+}
+
+.realtime-listening-title {
+ margin-top: 14px;
+ color: #25282d;
+ font-size: 20px;
+ font-weight: 900;
+}
+
+.realtime-listening-copy {
+ margin-top: 6px;
+ font-size: 13px;
+}
diff --git a/mobile-uni/tests/mobile-user-pages.test.mjs b/mobile-uni/tests/mobile-user-pages.test.mjs
index 7177d0b9..2d9fdfff 100644
--- a/mobile-uni/tests/mobile-user-pages.test.mjs
+++ b/mobile-uni/tests/mobile-user-pages.test.mjs
@@ -378,9 +378,15 @@ test('场景目录加载失败时清空旧场景并禁止开始训练', async ()
test('无已发布场景时隐藏常规训练,但仍展示独立实时陪练入口', async () => {
const source = await pageSource('../src/pages/user/practice/index.vue');
+ const realtimeIndex = source.indexOf('class="panel scenario-card realtime-entry-card employee-card"');
+ const sceneIndex = source.indexOf('class="panel scene-picker-card employee-card"');
+ const growthIndex = source.indexOf('class="panel growth-card employee-card"');
assert.match(source, /v-if="loggedIn && practiceView === 'prep' && availableScenarios\.length" class="panel scenario-card practice-scenario-card employee-card"/);
assert.match(source, /v-if="loggedIn && practiceView === 'prep'" class="panel scenario-card realtime-entry-card employee-card"/);
+ assert.ok(realtimeIndex > -1 && realtimeIndex < sceneIndex && sceneIndex < growthIndex, '核心陪练入口必须排在场景和成长说明之前');
+ assert.match(source, /v-if="growthDetailsExpanded" class="growth-details"/);
+ assert.match(source, /return '选一种方式,马上开始';/);
assert.match(source, /:disabled="!canStartRealtime \|\| busy"/);
assert.match(source, /if \(!canStartRealtime\.value \|\| !requireLogin\(\)\) return;/);
assert.doesNotMatch(source, /if \(!canStartPractice\.value \|\| !requireLogin\(\)\) return;\s*speechPlayback\.stop\(\);\s*cancelRecording\(\);\s*turnVoiceGeneration \+= 1;/);
diff --git a/mobile-uni/tests/realtime-practice.test.mjs b/mobile-uni/tests/realtime-practice.test.mjs
index 080ecfd2..5a3090d3 100644
--- a/mobile-uni/tests/realtime-practice.test.mjs
+++ b/mobile-uni/tests/realtime-practice.test.mjs
@@ -50,12 +50,14 @@ test('实时陪练通过受认证代理接入,前端不携带供应商密钥
// 页面:人设卡片选择 + 本地持久化;音色 picker 已删除
assert.match(page, /实时陪练 Beta/);
- assert.match(page, /实时陪练人设/);
+ assert.match(page, /选择陪练角色/);
assert.match(page, /aihr_realtime_practice_persona/);
assert.match(page, /selectRealtimePersona/);
assert.match(page, /fetchRealtimePersonas/);
assert.match(page, /createRealtimeSession/);
assert.match(page, /正在查知识库/);
+ assert.match(page, /activeScenarioName\.value = session\.personaName \|\| '实时陪练'/);
+ assert.match(page, /realtimeActive && !turns\.length/);
assert.match(page, /class="panel scenario-card realtime-entry-card employee-card"/);
assert.match(page, /const canStartRealtime = computed/);
assert.match(page, /if \(!canStartRealtime\.value \|\| !requireLogin\(\)\) return;/);