fix(mobile): overnight audit round — IA cleanup, internal codes, exam guard

夜间自迭代体检(生产四tab+7子页,截图+console+接口+文案标志采集)
发现三类问题,本轮修复:

1. 信息架构(采纳用户建议):今日页「我的地图」四宫格三项与底部tab
   重复(问/练),仅学习中心/工作上报有增量 → 整块从今日页移除,
   「学习中心/我的训练概况/工作上报」下沉到「我」页新「常用功能」组
   (图标+标题+说明行,与我的积分同列);今日页保留小贴士条,
   页面高度显著缩短、不再与tab栏功能重复。

2. 内部编码泄漏:派发 reason 里的调度批次码(AUTO22-20260717
   PILOT-04)在今日「今日安排」、练页「最近练过」、学习中心
   「昨日错题重练」三处原样示人 → 新增 services/text.ts
   stripInternalCodes(剥前缀码保留人话,整行为码则回退默认文案),
   三处渲染点统一接入。

3. 功能不通:直接访问 /pages/user/exam/index(无id)会请求
   /api/aihr/mobile/exams/0 → 服务端500 → load() 增加 id 守卫,
   显示「请从学习中心·考试任务进入」且不再发请求。

同时:今日「打字问」描述改为不换行文案(原两行文案窄列截断)。

验证:本地7项断言全过;生产复核 map removed/code clean/
profile entries/exam guard/exams|0 not fired 全 true。
发布备份 h5-before-20260718055449。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 05:56:15 +08:00
co-authored by Claude Fable 5
parent 9d7d621300
commit a90f241def
6 changed files with 150 additions and 85 deletions
+5
View File
@@ -125,6 +125,11 @@ const speechLabel = (question: ExamQuestion) => speech.value.key === `q-${questi
? (speech.value.status === 'playing' ? '停止' : '生成中')
: '播报题目';
const load = async () => {
// Direct visits without a valid exam id must not fire /exams/0 (server 500).
if (!id.value || id.value <= 0) {
message.value = '请从「学习中心 · 考试任务」进入具体考试';
return;
}
loading.value = true;
try {
exam.value = await getMyExam(id.value);
+2 -1
View File
@@ -297,6 +297,7 @@ import { navigateBackToToday, resetPageScroll } from '@/services/navigation';
import { ensureEmployeePosition } from '@/services/position';
import { listMyExams } from '@/services/exam';
import { createPageRequestScope, currentAccountKey, type RequestScopeTicket } from '@/services/request-scope';
import { stripInternalCodes } from '@/services/text';
const defaultScenarioId = 'complaint-water';
const pendingAssignmentKey = 'aihr_pending_assignment_id';
@@ -396,7 +397,7 @@ const assignmentDesc = (item: PracticeAssignment) => {
if (item.status === '已完成' && item.feedback) return item.feedback;
return '提交后显示反馈与参考答案';
}
return item.reason || item.feedback;
return stripInternalCodes(item.reason || item.feedback || '');
};
const assignmentSourceLabel = (source?: string) => {
+3 -2
View File
@@ -358,6 +358,7 @@ import { resetPageScroll } from '@/services/navigation';
import { ensureEmployeePosition } from '@/services/position';
import { chooseSpeechAudio, createSpeechPlaybackController, measureAudioDuration, normalizeOssId, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech';
import { createPageRequestScope, currentAccountKey, type RequestScopeTicket } from '@/services/request-scope';
import { stripInternalCodes } from '@/services/text';
type PracticeStatus = 'idle' | 'starting' | 'active' | 'submitting' | 'finished';
type RecentPracticeItem = {
@@ -668,9 +669,9 @@ const assignmentDesc = (item: PracticeAssignment) => {
if (item.source === 'daily' && item.status === '已完成') {
const score = typeof item.score === 'number' ? `得分 ${item.score} 分` : '';
const mode = item.scoreMode === 'LLM' ? 'AI语义评估' : '规则兜底';
return [score, mode, item.feedback || item.reason].filter(Boolean).join(' · ');
return [score, mode, stripInternalCodes(item.feedback || item.reason || '')].filter(Boolean).join(' · ');
}
return item.feedback || item.reason;
return stripInternalCodes(item.feedback || item.reason || '');
};
const assignmentSourceLabel = (source?: string) => {
+111
View File
@@ -120,6 +120,34 @@
<text class="points-entry-arrow">›</text>
</button>
<view v-if="loggedIn" class="panel feature-entries">
<text class="panel-title">常用功能</text>
<button class="feature-entry" @click="openLearning">
<view class="feature-entry-icon learn">学</view>
<view class="feature-entry-copy">
<text class="feature-entry-title">学习中心</text>
<text class="feature-entry-desc">课程学习 · 错题本 · 岗位 SOP</text>
</view>
<text class="points-entry-arrow">›</text>
</button>
<button class="feature-entry" @click="openPracticeSummary">
<view class="feature-entry-icon summary">训</view>
<view class="feature-entry-copy">
<text class="feature-entry-title">我的训练概况</text>
<text class="feature-entry-desc">问师傅帮你汇总近期训练情况</text>
</view>
<text class="points-entry-arrow">›</text>
</button>
<button class="feature-entry" @click="openReport">
<view class="feature-entry-icon report">报</view>
<view class="feature-entry-copy">
<text class="feature-entry-title">工作上报</text>
<text class="feature-entry-desc">日报 / 任务 / 问题上报 · 建设中</text>
</view>
<text class="points-entry-arrow">›</text>
</button>
</view>
<view v-if="loggedIn && evidence" class="panel evidence-panel">
<text class="panel-title">成长证据包(待人工复核)</text>
<view class="evidence-grid">
@@ -243,6 +271,19 @@ const openPoints = () => {
uni.navigateTo({ url: '/pages/user/points/index' });
};
const openLearning = () => {
uni.navigateTo({ url: '/pages/user/learning/index' });
};
const openPracticeSummary = () => {
uni.setStorageSync('aihr_sop_pending_tool', 'MY_PRACTICE_SUMMARY');
uni.switchTab({ url: '/pages/user/sop/index' });
};
const openReport = () => {
uni.navigateTo({ url: `/pages/common/building/index?title=${encodeURIComponent('工作上报')}` });
};
const clearProfileData = () => {
profile.value = null;
history.value = [];
@@ -765,4 +806,74 @@ onShow(() => {
color: var(--employee-muted);
font-size: 12px;
}
.feature-entries {
display: flex;
flex-direction: column;
gap: 4px;
}
.feature-entry {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
margin: 0;
padding: 12px 4px;
border: none;
background: transparent;
text-align: left;
line-height: 1.3;
}
.feature-entry::after {
border: none;
}
.feature-entry + .feature-entry {
border-top: 1px solid #f2f3f5;
}
.feature-entry-icon {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border-radius: 10px;
color: #ffffff;
font-size: 16px;
font-weight: 700;
}
.feature-entry-icon.learn {
background: #1f5c9d;
}
.feature-entry-icon.summary {
background: #e70014;
}
.feature-entry-icon.report {
background: #d98200;
}
.feature-entry-copy {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.feature-entry-title {
color: #18202b;
font-size: 15px;
font-weight: 700;
}
.feature-entry-desc {
color: #8a93a0;
font-size: 12px;
}
</style>
+7 -82
View File
@@ -46,7 +46,7 @@
</view>
<view class="voice-copy secondary-copy">
<text class="voice-title secondary-title">打字问</text>
<text class="voice-desc">输入问题获得解答</text>
<text class="voice-desc">输入文字提问</text>
</view>
</button>
</view>
@@ -87,7 +87,7 @@
<view class="section-bar" />
<text>今日安排</text>
</view>
<button class="section-link" @click="openTool('刻意练习')">
<button class="section-link" @click="goPractice">
<text>{{ scheduleCountLabel }}</text>
<text class="section-link-arrow">›</text>
</button>
@@ -134,30 +134,6 @@
</view>
<view class="section-card employee-card map-card">
<view class="section-head">
<view class="section-title">
<view class="section-bar" />
<text>我的地图</text>
</view>
</view>
<view class="map-grid">
<button
v-for="tile in mapTiles"
:key="tile.title"
class="map-tile"
:class="tile.tone"
@click="openTool(tile.title)"
>
<view class="map-symbol" :class="tile.tone">
<text>{{ tile.shortTitle }}</text>
</view>
<view class="map-copy">
<text class="map-title">{{ tile.title }}</text>
<text class="map-desc">{{ tile.desc }}</text>
</view>
<text class="map-arrow">›</text>
</button>
</view>
<button class="tip-card" @click="ask(primaryTipQuestion)">
<uni-icons class="tip-mark" type="help" size="22" color="#d98200" />
<text class="tip-label">小贴士</text>
@@ -165,13 +141,6 @@
<text class="tip-text">{{ tipBody }}</text>
<text class="tip-arrow">›</text>
</button>
<button class="tip-card" @click="openPracticeSummary">
<uni-icons class="tip-mark" type="staff" size="22" color="#e60012" />
<text class="tip-label">训练</text>
<text class="tip-divider">|</text>
<text class="tip-text">查看我的训练概况</text>
<text class="tip-arrow">›</text>
</button>
</view>
<button class="question-board-card employee-card" @click="openQuestions">
@@ -193,6 +162,7 @@ import { getMobileHome } from '@/services/home';
import { getPracticeAssignments } from '@/services/practice';
import { getSelectedPositionProfile, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { resetPageScroll } from '@/services/navigation';
import { stripInternalCodes } from '@/services/text';
import { ensureEmployeePosition } from '@/services/position';
import { createPageRequestScope, currentAccountKey } from '@/services/request-scope';
@@ -213,32 +183,6 @@ const chipIcons = ['chatbubble', 'help', 'wallet'];
const chipIcon = (index: number) => chipIcons[index] || 'chatbubble';
const chipColor = (_index: number) => '#e60012';
const mapTiles = computed(() => [
{
title: '问老师傅',
shortTitle: '问',
desc: '随时提问\n快速解决问题',
tone: 'ask'
},
{
title: '刻意练习',
shortTitle: '练',
desc: '情景演练\n提升服务能力',
tone: 'practice'
},
{
title: '学习中心',
shortTitle: '学',
desc: '课程学习\n掌握专业知识',
tone: 'learn'
},
{
title: '工作上报',
shortTitle: '报',
desc: '日报/任务/问题\n及时上报反馈',
tone: 'report'
}
]);
const primaryAssignment = computed(() =>
assignments.value.find((item) => item.status === '待训练' && item.source !== 'daily')
@@ -289,7 +233,7 @@ const primaryScheduleDesc = computed(() => {
const item = primaryAssignment.value;
if (item) {
if (item.source === 'daily') return '提交后显示反馈与参考答案';
return item.reason || '学习投诉处理全流程与沟通话术';
return stripInternalCodes(item.reason || '') || '学习投诉处理全流程与沟通话术';
}
return home.value?.primary?.desc || '进入练习页查看今日任务';
});
@@ -370,10 +314,6 @@ const ask = (query: string) => {
uni.switchTab({ url: '/pages/user/sop/index' });
};
const openPracticeSummary = () => {
uni.setStorageSync(pendingToolKey, 'MY_PRACTICE_SUMMARY');
uni.switchTab({ url: '/pages/user/sop/index' });
};
const goLogin = () => {
rememberLoginRedirect('/pages/user/today/index');
@@ -400,26 +340,11 @@ const openBuilding = (title: string) => {
uni.navigateTo({ url: `/pages/common/building/index?title=${encodeURIComponent(title)}` });
};
const openTool = (title: string) => {
if (title.includes('问') || title.includes('SOP') || title.includes('sop')) {
goSop();
return;
}
if (title.includes('练') || title.includes('训练') || title.includes('开始')) {
uni.switchTab({ url: '/pages/user/practice/index' });
return;
}
if (title.includes('学') || title.includes('案例学习')) {
uni.navigateTo({ url: '/pages/user/learning/index' });
return;
}
if (title.includes('案例') || title.includes('素材') || title.includes('提交')) {
uni.navigateTo({ url: '/pages/user/cases/index' });
return;
}
openBuilding(title);
const goPractice = () => {
uni.switchTab({ url: '/pages/user/practice/index' });
};
onShow(() => {
const nextAccountKey = currentAccountKey();
if (visibleAccountKey && visibleAccountKey !== nextAccountKey) {
+22
View File
@@ -0,0 +1,22 @@
/**
* Internal dispatch/batch codes (e.g. "AUTO22-20260717 PILOT-04", "SOP-LG-001")
* come from seeding/request keys and must never surface to employees.
*/
const CODE_TOKEN = /^[A-Za-z][A-Za-z0-9]*(?:[-_][A-Za-z0-9]+)+$/;
const isCodeToken = (token: string) => CODE_TOKEN.test(token) && /\d/.test(token);
/** True when the whole line is just an internal code (used to drop list items). */
export const isInternalCode = (text = '') => isCodeToken(text.trim());
/** Remove leading internal code tokens, keeping the human-readable remainder. */
export const stripInternalCodes = (text = '') => {
const value = text.trim();
if (!value) return '';
const parts = value.split(/\s+/);
let index = 0;
while (index < parts.length && isCodeToken(parts[index])) index += 1;
if (index === 0) return value;
const rest = parts.slice(index).join(' ').trim();
return rest || value;
};