feat(aihr): harden M0-M2 mobile training flows
This commit is contained in:
@@ -233,8 +233,7 @@ const login = async () => {
|
||||
method: 'POST',
|
||||
data: {
|
||||
phonenumber: value,
|
||||
smsCode: formData.smsCode.trim(),
|
||||
tenantId: '000000'
|
||||
smsCode: formData.smsCode.trim()
|
||||
},
|
||||
auth: false
|
||||
});
|
||||
|
||||
@@ -126,6 +126,6 @@ onLoad((options) => {
|
||||
.message-time { color: var(--muted); font-size: 12px; }
|
||||
.message-content { overflow-wrap: anywhere; color: var(--ink); font-size: 15px; line-height: 1.8; white-space: pre-wrap; }
|
||||
.detail-actions { display: grid; gap: 8px; margin-top: 8px; padding-top: 16px; border-top: 1px solid var(--employee-line, #ebedf0); }
|
||||
.ask-message-button { width: 100%; min-height: 46px; border-radius: 14px; background: var(--employee-primary, #e60012); color: #fff; font-size: 15px; font-weight: 800; }
|
||||
.ask-message-button { display: flex; width: 100%; min-height: 46px; align-items: center; justify-content: center; padding: 0; border-radius: 14px; background: var(--employee-primary, #e60012); color: #fff; font-size: 15px; font-weight: 800; line-height: 1; }
|
||||
.ask-message-note { color: var(--muted); font-size: 12px; line-height: 1.5; text-align: center; }
|
||||
</style>
|
||||
|
||||
@@ -194,7 +194,7 @@ onUnload(() => {
|
||||
.empty-title { font-size: 17px; font-weight: 900; }
|
||||
.message-list { display: grid; gap: 12px; margin-top: 18px; }
|
||||
.scope-tabs { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin-top: 16px; padding: 4px; border-radius: 14px; background: #f3f4f6; }
|
||||
.scope-tab { min-height: 36px; border-radius: 10px; background: transparent; color: var(--muted); font-size: 13px; font-weight: 800; }
|
||||
.scope-tab { display: flex; min-height: 36px; align-items: center; justify-content: center; padding: 0; border-radius: 10px; background: transparent; color: var(--muted); font-size: 13px; font-weight: 800; line-height: 1; }
|
||||
.scope-tab.active { background: #fff; color: var(--brand); box-shadow: 0 1px 4px rgba(20, 28, 42, .12); }
|
||||
.message-card { display: grid; width: 100%; gap: 10px; padding: 16px; color: var(--ink); text-align: left; }
|
||||
.message-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
@@ -206,7 +206,7 @@ onUnload(() => {
|
||||
.message-tag.related { background: #fff7e8; color: #a76500; }
|
||||
.message-time { color: var(--muted); font-size: 12px; }
|
||||
.message-title { overflow-wrap: anywhere; font-size: 17px; font-weight: 900; line-height: 1.5; }
|
||||
.load-more { width: 100%; min-height: 42px; border: 1px solid #f0a6ac; border-radius: 999px; background: #fff; color: var(--brand); font-size: 14px; font-weight: 800; }
|
||||
.load-more { display: flex; width: 100%; min-height: 42px; align-items: center; justify-content: center; padding: 0; border: 1px solid #f0a6ac; border-radius: 999px; background: #fff; color: var(--brand); font-size: 14px; font-weight: 800; line-height: 1; }
|
||||
.load-more::after { border: 0; }
|
||||
.list-end { display: block; padding: 8px; color: var(--muted); font-size: 12px; text-align: center; }
|
||||
</style>
|
||||
|
||||
@@ -392,7 +392,7 @@ import type {
|
||||
PracticeTurn,
|
||||
PracticeRecord
|
||||
} from '@/types/api';
|
||||
import { answerDailyDrill, finishPractice, getPracticeAssignments, getPracticeHistory, getPracticePrepCard, startPractice, submitPracticeQuestionFeedback, submitPracticeSatisfaction, submitPracticeTurn } from '@/services/practice';
|
||||
import { answerDailyDrill, finishPractice, getPracticeAssignments, getPracticeHistory, getPracticePrepCard, 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';
|
||||
@@ -1289,11 +1289,17 @@ const start = async (scenarioId = defaultScenarioId, assignmentId?: number) => {
|
||||
const loadHelp = async () => {
|
||||
if (helpLoading.value || busy.value) return;
|
||||
if (!requireLogin()) return;
|
||||
if (!sessionId.value) {
|
||||
message.value = '请先开始训练后再求助';
|
||||
return;
|
||||
}
|
||||
const request = requests.begin('help');
|
||||
helpLoading.value = true;
|
||||
message.value = '';
|
||||
try {
|
||||
const data = await searchKnowledge(`${activeScenarioName.value} SOP 处理要点`);
|
||||
await recordPracticeHelp(sessionId.value, roundIndex.value);
|
||||
if (!requests.isCurrent(request)) return;
|
||||
const data = await searchKnowledge(`${activeScenarioName.value} SOP 处理要点`, positionLabel.value, undefined, undefined, 'practice_help');
|
||||
if (!requests.isCurrent(request)) return;
|
||||
if (!data.answer) {
|
||||
throw new Error('未检索到可用 SOP 提示');
|
||||
|
||||
@@ -628,6 +628,17 @@ const clearBroadcastConsultation = () => {
|
||||
clearBroadcastQuestionContext();
|
||||
};
|
||||
|
||||
// A message consultation is re-authorized on every show. When the page is
|
||||
// hidden while that request (or its confirmation dialog) is still pending,
|
||||
// invalidate only the in-flight work. Keep the account-bound pending message
|
||||
// ID so the next onShow can restart the authorization instead of leaving the
|
||||
// composer locked in a stale loading state.
|
||||
const cancelPendingBroadcastConsultation = () => {
|
||||
broadcastContextGeneration += 1;
|
||||
broadcastContextLoading.value = false;
|
||||
broadcastContextPrompting.value = false;
|
||||
};
|
||||
|
||||
const preserveCurrentBroadcastConsultation = () => {
|
||||
if (!broadcastContext.value) return;
|
||||
saveBroadcastQuestionContext(broadcastContext.value.messageId, currentAccountKey());
|
||||
@@ -693,12 +704,15 @@ const restoreBroadcastConsultation = async (pageGeneration: number, accountKey:
|
||||
return;
|
||||
}
|
||||
|
||||
const start = () => void activateBroadcastConsultation(pending, accountKey, pageGeneration);
|
||||
if (!conversationStarted.value) {
|
||||
start();
|
||||
// Do not start project selection until the message context has been
|
||||
// resolved. Otherwise the asynchronous handoff can briefly look like an
|
||||
// ordinary empty conversation and incorrectly open the project picker.
|
||||
await activateBroadcastConsultation(pending, accountKey, pageGeneration);
|
||||
return;
|
||||
}
|
||||
|
||||
const start = () => void activateBroadcastConsultation(pending, accountKey, pageGeneration);
|
||||
broadcastContextPrompting.value = true;
|
||||
uni.showModal({
|
||||
title: '开始新的消息咨询?',
|
||||
@@ -847,7 +861,8 @@ const loadProjectContext = async () => {
|
||||
const identity = await fetchMobileIdentity();
|
||||
availableProjects.value = identity.projects || [];
|
||||
currentProject.value = reconcileCurrentProject(availableProjects.value);
|
||||
if (!currentProject.value && availableProjects.value.length > 1 && !broadcastContext.value && !broadcastContextPrompting.value) {
|
||||
if (!currentProject.value && availableProjects.value.length > 1
|
||||
&& !broadcastContext.value && !broadcastContextLoading.value && !broadcastContextPrompting.value) {
|
||||
projectPickerOpen.value = true;
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -1477,6 +1492,7 @@ onShow(() => {
|
||||
onHide(() => {
|
||||
showGeneration += 1;
|
||||
candidateRefreshGeneration += 1;
|
||||
cancelPendingBroadcastConsultation();
|
||||
speechPlayback.stop();
|
||||
composerRef.value?.teardown();
|
||||
cancelVoiceTranscription();
|
||||
@@ -1485,6 +1501,7 @@ onHide(() => {
|
||||
|
||||
onUnload(() => {
|
||||
showGeneration += 1;
|
||||
cancelPendingBroadcastConsultation();
|
||||
releaseMessageResources();
|
||||
speechPlayback.destroy();
|
||||
composerRef.value?.teardown();
|
||||
|
||||
@@ -83,7 +83,7 @@ export const searchKnowledge = async (
|
||||
position = getSelectedPosition(),
|
||||
conversation?: KnowledgeConversationState,
|
||||
projectCode?: string,
|
||||
source: 'mobile_uni_sop' | 'mobile_uni_voice' = 'mobile_uni_sop',
|
||||
source: 'mobile_uni_sop' | 'mobile_uni_voice' | 'practice_help' = 'mobile_uni_sop',
|
||||
broadcastMessageId?: number
|
||||
) => {
|
||||
const response = await apiRequest<UnifiedKnowledgeResponse>({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
PracticeFinishResponse,
|
||||
PracticeHelpEvent,
|
||||
PracticePrepCard,
|
||||
PracticeRecord,
|
||||
PracticeMistake,
|
||||
@@ -42,6 +43,14 @@ export const getPracticePrepCard = (scenarioId = defaultScenarioId) =>
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
export const recordPracticeHelp = (sessionId: string, roundIndex: number) =>
|
||||
apiRequest<PracticeHelpEvent>({
|
||||
url: `/api/train/practice/sessions/${encodeURIComponent(sessionId)}/help`,
|
||||
method: 'POST',
|
||||
data: { roundIndex },
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
export const getPracticeScenarios = (position = getSelectedPosition()) =>
|
||||
apiRequest<PracticeScenarioOption[]>({
|
||||
url: `/api/train/practice/scenarios?position=${encodeURIComponent(position || '生活顾问')}`,
|
||||
|
||||
@@ -4,12 +4,15 @@ import type { SpeechCapture } from './speech-capture';
|
||||
|
||||
const audioExtensions = ['.mp3', '.wav', '.m4a', '.webm', '.aac', '.ogg'];
|
||||
|
||||
export const MENTOR_VOICE_PROFILE: SpeechVoiceProfile = { role: 'mentor', emotion: 'warm', speed: 0.88 };
|
||||
export const INTERVIEWER_VOICE_PROFILE: SpeechVoiceProfile = { role: 'interviewer', emotion: 'professional', speed: 0.96 };
|
||||
export const customerVoiceProfile = (emotion = 0): SpeechVoiceProfile => {
|
||||
if (emotion >= 80) return { role: 'customer', emotion: 'intense', speed: 1.12 };
|
||||
if (emotion >= 70) return { role: 'customer', emotion: 'serious', speed: 1.04 };
|
||||
return { role: 'customer', emotion: 'calm', speed: 0.98 };
|
||||
export const MENTOR_VOICE_PROFILE: SpeechVoiceProfile = { role: 'mentor', emotion: 'warm', speed: 0.88, dialect: 'mandarin' };
|
||||
export const INTERVIEWER_VOICE_PROFILE: SpeechVoiceProfile = { role: 'interviewer', emotion: 'professional', speed: 0.96, dialect: 'mandarin' };
|
||||
export const customerVoiceProfile = (
|
||||
emotion = 0,
|
||||
dialect: SpeechVoiceProfile['dialect'] = 'mandarin'
|
||||
): SpeechVoiceProfile => {
|
||||
if (emotion >= 80) return { role: 'customer', emotion: 'intense', speed: 1.12, dialect };
|
||||
if (emotion >= 70) return { role: 'customer', emotion: 'serious', speed: 1.04, dialect };
|
||||
return { role: 'customer', emotion: 'calm', speed: 0.98, dialect };
|
||||
};
|
||||
|
||||
// OSS 编号是雪花 ID,超出 JS 安全整数范围,必须按字符串透传,禁止 Number() 强转。
|
||||
@@ -113,6 +116,12 @@ export interface SpeechPlaybackSnapshot {
|
||||
status: SpeechPlaybackStatus;
|
||||
}
|
||||
|
||||
const browserDialectLanguage = (dialect: SpeechVoiceProfile['dialect']) => {
|
||||
if (dialect === 'cantonese') return 'zh-HK';
|
||||
if (dialect === 'sichuanese') return '';
|
||||
return 'zh-CN';
|
||||
};
|
||||
|
||||
export const createSpeechPlaybackController = (
|
||||
onState: (snapshot: SpeechPlaybackSnapshot) => void,
|
||||
onError: (message: string) => void,
|
||||
@@ -150,15 +159,20 @@ export const createSpeechPlaybackController = (
|
||||
value: string,
|
||||
currentGeneration: number,
|
||||
profile?: SpeechVoiceProfile
|
||||
) => {
|
||||
): { played: boolean; message?: string } => {
|
||||
if (
|
||||
typeof window === 'undefined'
|
||||
|| !window.speechSynthesis
|
||||
|| typeof window.SpeechSynthesisUtterance !== 'function'
|
||||
) return false;
|
||||
) return { played: false };
|
||||
|
||||
const lang = browserDialectLanguage(profile?.dialect);
|
||||
if (!lang) {
|
||||
return { played: false, message: '当前设备语音不支持四川话,请使用服务端语音或文字查看' };
|
||||
}
|
||||
|
||||
const utterance = new window.SpeechSynthesisUtterance(value.slice(0, 600));
|
||||
utterance.lang = 'zh-CN';
|
||||
utterance.lang = lang;
|
||||
utterance.rate = profile?.speed || 0.95;
|
||||
utterance.pitch = profile?.role === 'mentor' ? 1.06 : profile?.role === 'customer' ? 1.05 : 1;
|
||||
utterance.onend = () => {
|
||||
@@ -172,7 +186,7 @@ export const createSpeechPlaybackController = (
|
||||
browserUtterance = utterance;
|
||||
publish(key, 'playing');
|
||||
window.speechSynthesis.speak(utterance);
|
||||
return true;
|
||||
return { played: true };
|
||||
};
|
||||
|
||||
const playAudioSource = (key: string, source: string, cacheKey: string, currentGeneration: number) => {
|
||||
@@ -231,9 +245,10 @@ export const createSpeechPlaybackController = (
|
||||
playAudioSource(key, source, sourceKey, currentGeneration);
|
||||
} catch (error) {
|
||||
if (currentGeneration !== generation) return;
|
||||
if (playWithBrowserSpeech(key, value, currentGeneration, profile)) return;
|
||||
const browserFallback = playWithBrowserSpeech(key, value, currentGeneration, profile);
|
||||
if (browserFallback.played) return;
|
||||
stop();
|
||||
onError(error instanceof Error ? error.message : '语音生成失败');
|
||||
onError(browserFallback.message || (error instanceof Error ? error.message : '语音生成失败'));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ export interface SpeechVoiceProfile {
|
||||
voice?: string;
|
||||
speed?: number;
|
||||
emotion?: 'warm' | 'professional' | 'serious' | 'intense' | 'calm';
|
||||
dialect?: 'mandarin' | 'cantonese' | 'sichuanese';
|
||||
}
|
||||
|
||||
export interface ToolItem {
|
||||
@@ -467,6 +468,13 @@ export interface PracticePrepCard {
|
||||
promptVersion: string;
|
||||
}
|
||||
|
||||
export interface PracticeHelpEvent {
|
||||
sessionId: string;
|
||||
scenarioId: string;
|
||||
roundIndex: number;
|
||||
recordedAt: string;
|
||||
}
|
||||
|
||||
export interface PracticeTurnResponse {
|
||||
customerText: string;
|
||||
customerAudioUrl?: string;
|
||||
|
||||
@@ -92,6 +92,11 @@ test('消息咨询只以消息 ID 作为受控上下文,并让每轮文字或
|
||||
test('消息咨询会新开会话,隐藏媒体和数据工具,并在撤回时清空对话', async () => {
|
||||
const page = await read('../src/pages/user/sop/index.vue');
|
||||
|
||||
assert.match(page, /if \(!conversationStarted\.value\) \{\s*\/\/ Do not start project selection[\s\S]*await activateBroadcastConsultation\(pending, accountKey, pageGeneration\);/);
|
||||
assert.match(page, /!broadcastContext\.value && !broadcastContextLoading\.value && !broadcastContextPrompting\.value/);
|
||||
assert.match(page, /const cancelPendingBroadcastConsultation = \(\) => \{\s*broadcastContextGeneration \+= 1;\s*broadcastContextLoading\.value = false;\s*broadcastContextPrompting\.value = false;/);
|
||||
assert.match(page, /onHide\(\(\) => \{\s*showGeneration \+= 1;\s*candidateRefreshGeneration \+= 1;\s*cancelPendingBroadcastConsultation\(\);/);
|
||||
assert.match(page, /onUnload\(\(\) => \{\s*showGeneration \+= 1;\s*cancelPendingBroadcastConsultation\(\);/);
|
||||
assert.match(page, /error\.statusCode === 404 \|\| error\.bizCode === 404/);
|
||||
assert.match(page, /const pending = readBroadcastQuestionContext\(accountKey\);\s*if \(!pending\) \{\s*if \(broadcastContext\.value\) \{\s*clearBroadcastConsultation\(\);\s*resetConversation\(false\);/);
|
||||
assert.match(page, /const startNewConversation = \(\) => \{\s*clearBroadcastConsultation\(\);\s*resetConversation\(true\);/);
|
||||
|
||||
@@ -4,6 +4,17 @@ import test from 'node:test';
|
||||
|
||||
const read = (path) => readFile(new URL(path, import.meta.url), 'utf8');
|
||||
|
||||
const centeredButtonRule = (source, selector) => {
|
||||
const rule = source.match(new RegExp(`\\.${selector}\\s*\\{([^}]*)\\}`))?.[1] || '';
|
||||
|
||||
assert.ok(rule, `missing .${selector} style rule`);
|
||||
assert.match(rule, /display:\s*flex/);
|
||||
assert.match(rule, /align-items:\s*center/);
|
||||
assert.match(rule, /justify-content:\s*center/);
|
||||
assert.match(rule, /padding:\s*0(?:;|\s)/);
|
||||
assert.match(rule, /line-height:\s*1(?:;|\s)/);
|
||||
};
|
||||
|
||||
test('银城大喇叭仅使用认证后的真实广播接口', async () => {
|
||||
const service = await read('../src/services/broadcast.ts');
|
||||
|
||||
@@ -74,3 +85,18 @@ test('消息详情只交接短期且账号绑定的消息 ID,再进入问模
|
||||
assert.match(service, /context\.accountKey !== accountKey \|\| context\.expiresAt <= now/);
|
||||
assert.match(service, /clearBroadcastQuestionContext\(\)/);
|
||||
});
|
||||
|
||||
test('M0/M1 的短文本操作控件始终在可点击区域内居中', async () => {
|
||||
const [base, list, detail, ask] = await Promise.all([
|
||||
read('../src/styles/base.css'),
|
||||
read('../src/pages/user/broadcast/index.vue'),
|
||||
read('../src/pages/user/broadcast/detail.vue'),
|
||||
read('../src/pages/user/sop/index.vue')
|
||||
]);
|
||||
|
||||
assert.match(base, /\.primary-button,\s*\.secondary-button,\s*\.ghost-button\s*\{[^}]*display:\s*flex[^}]*align-items:\s*center[^}]*justify-content:\s*center/);
|
||||
centeredButtonRule(list, 'scope-tab');
|
||||
centeredButtonRule(list, 'load-more');
|
||||
centeredButtonRule(detail, 'ask-message-button');
|
||||
centeredButtonRule(ask, 'broadcast-context-action');
|
||||
});
|
||||
|
||||
@@ -42,6 +42,32 @@ test('训练页提交失败会回滚本次乐观追加的对话', async () => {
|
||||
assert.match(source, /uni\.showToast\(\{ title: message\.value \|\| '提交失败', icon: 'none'/);
|
||||
});
|
||||
|
||||
test('训练求助先留存会话事件,再以 practice_help 来源检索 SOP', async () => {
|
||||
const [practicePage, practiceService, knowledgeService] = await Promise.all([
|
||||
pageSource('../src/pages/user/practice/index.vue'),
|
||||
pageSource('../src/services/practice.ts'),
|
||||
pageSource('../src/services/knowledge.ts')
|
||||
]);
|
||||
|
||||
assert.match(practiceService, /sessions\/\$\{encodeURIComponent\(sessionId\)\}\/help/);
|
||||
assert.match(practicePage, /await recordPracticeHelp\(sessionId\.value, roundIndex\.value\)/);
|
||||
assert.match(practicePage, /'practice_help'/);
|
||||
assert.match(knowledgeService, /'practice_help'/);
|
||||
});
|
||||
|
||||
test('语音 profile 透传方言枚举,并保持普通话默认值', async () => {
|
||||
const [apiTypes, speechService] = await Promise.all([
|
||||
pageSource('../src/types/api.ts'),
|
||||
pageSource('../src/services/speech.ts')
|
||||
]);
|
||||
|
||||
assert.match(apiTypes, /dialect\?: 'mandarin' \| 'cantonese' \| 'sichuanese'/);
|
||||
assert.match(speechService, /dialect: 'mandarin'/);
|
||||
assert.match(speechService, /customerVoiceProfile = \([\s\S]*?dialect:/);
|
||||
assert.match(speechService, /dialect === 'cantonese'.*'zh-HK'/);
|
||||
assert.match(speechService, /dialect === 'sichuanese'[\s\S]*?当前设备语音不支持四川话/);
|
||||
});
|
||||
|
||||
test('训练结果回到顶部,SOP 答案生成后定位到回答区', async () => {
|
||||
const [practiceSource, sopSource, navigationSource] = await Promise.all([
|
||||
pageSource('../src/pages/user/practice/index.vue'),
|
||||
@@ -99,6 +125,15 @@ test('登录页按高保真原型保留品牌结构和紧凑验证码行', async
|
||||
assert.doesNotMatch(source, /grid-template-columns:\s*1fr;[\s\S]*?\.code-button/);
|
||||
});
|
||||
|
||||
test('移动端短信登录不再让客户端选择租户', async () => {
|
||||
const source = await pageSource('../src/pages/auth/login/index.vue');
|
||||
const loginRequest = source.match(/url: '\/auth\/mobile\/sms-login',[\s\S]*?auth: false/)?.[0] || '';
|
||||
|
||||
assert.match(loginRequest, /phonenumber: value/);
|
||||
assert.match(loginRequest, /smsCode: formData\.smsCode\.trim\(\)/);
|
||||
assert.doesNotMatch(loginRequest, /tenantId:/);
|
||||
});
|
||||
|
||||
test('共享视觉基座包含员工端主色和卡片圆角 token', async () => {
|
||||
const source = await pageSource('../src/styles/base.css');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user