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:
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user