diff --git a/docs/DEV_SETUP.md b/docs/DEV_SETUP.md
index 1216d362..e844753b 100644
--- a/docs/DEV_SETUP.md
+++ b/docs/DEV_SETUP.md
@@ -182,6 +182,7 @@ curl -k -s https://peilian.njzhmj.top/h5/ | sed -n '1,20p'
- SOP知识库:进入 `/knowledge/sop`,可上传 txt/md/PDF/Word/Excel/PPT 文档入库;点击“检索” → “生成训练题”,应看到“已完成闭环”、命中数据库 SOP 原文片段和训练题;数据库不可用时页面回退 seed。
- 资料处理:进入 `/knowledge/processing`,应看到资料总量、解析任务表、处理链路、规则与风险;可用少量文件验证“批量导入/选择目录/服务端导入”。**批量导入走异步队列**:提交即返回,页面出现“本次批量上传”进度面板(排队/加工中/完成/失败 + 单条重试),后台 worker 并发 2 逐条解析入库;暂存目录默认 `./.data/staging`(`aihr.upload.staging` 覆盖)。服务端导入读取 `./.data/import` 下的相对目录,启动后台任务并在页面显示进度,运行中任务可点“取消”;目录导入的重试粒度是同目录重新导入,批量上传的重试粒度是单文件。
- uni-app 员工端:进入 `https://wygj-mobile-uni.localhost/h5/#/pages/user/today/index`,手机号登录(dev 验证码固定 `123456`)。今日页进入问师傅、练习、案例素材和个人页;练习页应能完成开始练习、提交回应、结束评分、每日三题提交,个人页同步训练历史和成长证据包。
+- 移动端登录后若组织快照接口返回 `401/403`,只降级为手动岗位确认,不应清除手机号登录态;岗位确认页仍需允许员工选择“生活顾问”后继续进入业务页。
- uni-app 主管端:进入 `https://wygj-mobile-uni.localhost/h5/#/pages/supervisor/index/index`,应看到团队概览、团队画像、团队预警、指派专项、待复盘和复盘详情;从团队画像派专项后,最近专项应立即回读新记录。
- uni-app 候选人端:进入 `https://wygj-mobile-uni.localhost/h5/#/pages/candidate/index/index`,手机号登录(dev 验证码固定 `123456`)→ “开始面试/面试练习”完成答题评分;“补充资料”选择 PDF/Word/图片后上传,应看到资料状态。Codex 内置浏览器不支持本地文件选择时,用真实 HTTP multipart smoke 代替浏览器文件选择。
- 真 LLM 激活:在 `/system/model` 给供应商填 api_host/api_key 并启用 `category=chat` 模型后,三角色对练的客户回复与评分即为真实 LLM 生成;再启用 `category=asr/tts`(如硅基流动 SenseVoice/CosyVoice2)语音路径生效。未配置时全链路自动回退 seed。
diff --git a/mobile-uni/src/pages.json b/mobile-uni/src/pages.json
index 7e62ca67..b4447641 100644
--- a/mobile-uni/src/pages.json
+++ b/mobile-uni/src/pages.json
@@ -131,7 +131,7 @@
},
"tabBar": {
"color": "#7b8494",
- "selectedColor": "#2f6bff",
+ "selectedColor": "#e70014",
"backgroundColor": "#ffffff",
"borderStyle": "white",
"height": "66px",
diff --git a/mobile-uni/src/pages/auth/login/index.vue b/mobile-uni/src/pages/auth/login/index.vue
index ebdd285b..6473b8cb 100644
--- a/mobile-uni/src/pages/auth/login/index.vue
+++ b/mobile-uni/src/pages/auth/login/index.vue
@@ -1,70 +1,82 @@
-
-
-
-
- 银城物业
- 数字师傅
-
-
- 银城员工端
- 使用手机号登录后,回到当前任务。
-
-
-
- 手机号登录
-
-
-
-
-
-
-
-
+
+
+
+
+
+ 银城物业
+ |
+ 数字师傅
-
-
-
-
+
+ 银城员工端
+ 高效协同 · 贴心服务 · 让工作更简单
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -103,9 +115,23 @@ const loggingIn = ref(false);
const message = ref('');
const inputStyles = {
color: '#25282d',
- borderColor: '#d7d9de',
- backgroundColor: '#ffffff'
+ borderColor: 'transparent',
+ backgroundColor: 'transparent'
};
+
+const normalizeDigits = (value: string | number, maxLength: number) =>
+ String(value || '')
+ .replace(/\D/g, '')
+ .slice(0, maxLength);
+
+const onPhoneInput = (value: string | number) => {
+ formData.phone = normalizeDigits(value, 11);
+};
+
+const onSmsCodeInput = (value: string | number) => {
+ formData.smsCode = normalizeDigits(value, 6);
+};
+
const goAfterLogin = async () => {
const redirect = consumeLoginRedirect() || '/pages/user/today/index';
goToMobileTarget(await nextUserRouteAfterLogin(redirect));
@@ -178,145 +204,294 @@ onShow(() => {
diff --git a/mobile-uni/src/pages/auth/position/index.vue b/mobile-uni/src/pages/auth/position/index.vue
index 55768a1a..f95b9f58 100644
--- a/mobile-uni/src/pages/auth/position/index.vue
+++ b/mobile-uni/src/pages/auth/position/index.vue
@@ -1,15 +1,23 @@
+
+
+ 银城员工端
+
+
确认岗位
{{ phone }}
- {{ message }}
+
+
+ {{ message }}
+
选择当前岗位
-
+
- {{ item.value === selectedPosition ? '已选' : '选择' }}
+
+
+
@@ -57,8 +69,13 @@ const finish = () => {
const selectPosition = (position: UserPosition) => {
if (saving.value) return;
+ selectedPosition.value = position;
+};
+
+const confirmPosition = () => {
+ if (!selectedPosition.value || saving.value) return;
saving.value = true;
- const saved = saveSelectedPosition(position);
+ const saved = saveSelectedPosition(selectedPosition.value);
selectedPosition.value = saved;
finish();
};
@@ -95,39 +112,105 @@ onShow(() => {
diff --git a/mobile-uni/src/pages/user/practice/index.vue b/mobile-uni/src/pages/user/practice/index.vue
index 5c939cf9..849e5613 100644
--- a/mobile-uni/src/pages/user/practice/index.vue
+++ b/mobile-uni/src/pages/user/practice/index.vue
@@ -1,10 +1,8 @@
- 数字师傅 · 场景训练
- 场景模拟
- 与业主、老师傅完成一轮真实工作对话,结束后自动写入训练记录。
- 先看预习卡,再开始练习;红线命中时按老师傅建议重说本轮。
+ 练
+ 数字师傅 · 场景训练
{{ message }}
@@ -19,26 +17,17 @@
- 今日小训
- {{ activeScenarioName }}
- {{ scenarioDescription }}
+ 场景模拟
+ 先看预习卡,再开始练习
+
+
+
+ •••
-
- 训练前预习卡
-
-
- 要点
- {{ item }}
-
-
- 红线
- {{ item }}
-
-
- 话术
- {{ item }}
-
+ 要点关键要点先掌握
+ 红线风险红线要注意
+ 可说话术有效话术可参考
预习卡加载中
-
+
- 今日任务
-
+ 最近练过
+ 全部 ›
-
-
+
+
+ {{ item.icon }}
+
+ {{ item.title }}
+ {{ item.desc }}
+
+ ›
+
+
+ 暂无最近练习
+
+
+
+
+ 每日三题今日安排
+ 去作答 ›
+
+
+
- {{ item.source === 'daily' ? '每日三题' : '主管专项' }}
{{ item.question || item.scenarioName }}
{{ assignmentDesc(item) }}
- 参考:{{ item.referenceAnswer }}
-
+
-
- 暂无今日任务
+
+ 今日暂无题目
+ 完成专项训练后会自动生成每日练习
+
+
+
+
+ 情绪识别与安抚
+ 信任建立与维护
+ 红线纠偏识别与规避
@@ -178,9 +191,10 @@ import type {
PracticePrepCard,
PracticeAssignment,
PracticeRole,
- PracticeTurn
+ PracticeTurn,
+ PracticeRecord
} from '@/types/api';
-import { answerDailyDrill, finishPractice, getPracticeAssignments, getPracticePrepCard, startPractice, submitPracticeTurn } from '@/services/practice';
+import { answerDailyDrill, finishPractice, getPracticeAssignments, getPracticeHistory, getPracticePrepCard, startPractice, submitPracticeTurn } from '@/services/practice';
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { searchKnowledge } from '@/services/knowledge';
import { resetPageScroll } from '@/services/navigation';
@@ -188,6 +202,14 @@ import { ensureEmployeePosition } from '@/services/position';
import { chooseSpeechAudio, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech';
type PracticeStatus = 'idle' | 'starting' | 'active' | 'submitting' | 'finished';
+type RecentPracticeItem = {
+ id: string;
+ title: string;
+ desc: string;
+ status: string;
+ icon: string;
+ assignment?: PracticeAssignment;
+};
const defaultScenarioId = 'complaint-water';
const defaultScenarioName = '投诉接待';
@@ -213,6 +235,7 @@ const emotionScore = ref(0);
const trustScore = ref(0);
const redFlagActive = ref(false);
const assignments = ref([]);
+const practiceHistory = ref([]);
const drillAnswers = ref>({});
const assignmentLoading = ref(false);
const answeringId = ref(null);
@@ -231,17 +254,27 @@ const hasTraineeReply = computed(() => turns.value.some((item) => item.role ===
const canFinishPractice = computed(() => hasTraineeReply.value && !redFlagActive.value);
const hasCoachState = computed(() => emotionScore.value > 0 || trustScore.value > 0 || redFlagActive.value);
const canAdjustCoach = computed(() => status.value === 'active' && lastCoachRoundIndex.value !== null && turns.value.some((item) => item.role === 'coach'));
-const activeAssignment = computed(() => assignments.value.find((item) => item.id === activeAssignmentId.value));
-const scenarioDescription = computed(() => {
- const points = (prepCard.value?.keyPoints || []).map((item) => item.trim()).filter(Boolean).slice(0, 2);
- if (points.length) return `本场重点:${points.join(';')}`;
- if (activeAssignment.value?.reason) return activeAssignment.value.reason;
- const name = activeScenarioName.value;
- if (/催|缴|费|停车/.test(name)) return '练习承接缴费异议、说明规则依据、给出缴费路径和跟进节点。';
- if (/报修|维修|设施|电梯|漏水/.test(name)) return '练习记录问题、安抚情绪、说明报修流转和反馈时限。';
- if (/突发|应急|安全|火灾|摔|道闸|车库/.test(name)) return '练习先稳现场、同步上报、留痕取证和风险告知。';
- return '练习承接业主情绪、说明处理动作、明确反馈时间和责任边界。';
+const recentPracticeItems = computed(() => {
+ const assignmentItems: RecentPracticeItem[] = assignments.value
+ .filter((item) => item.source !== 'daily')
+ .map((item) => ({
+ id: `assignment-${item.id}`,
+ title: item.question || item.scenarioName || '专项训练',
+ desc: item.status === '已完成' ? '已完成' : '待复盘',
+ status: item.status,
+ icon: item.scenarioName?.includes('催费') ? '⌕' : item.scenarioName?.includes('报修') ? '⌁' : '◉',
+ assignment: item
+ }));
+ const historyItems: RecentPracticeItem[] = practiceHistory.value.map((item) => ({
+ id: `history-${item.id || item.sessionId || item.time}`,
+ title: item.scene || '场景训练',
+ desc: `${item.score || 0} 分 · ${item.status || '已完成'}`,
+ status: item.status,
+ icon: item.scene?.includes('催费') ? '⌕' : item.scene?.includes('报修') ? '⌁' : '◉'
+ }));
+ return [...assignmentItems, ...historyItems].slice(0, 3);
});
+const dailyAssignments = computed(() => assignments.value.filter((item) => item.source === 'daily'));
const styleOptions = [
{ value: 'serious', label: '再严肃' },
{ value: 'gentle', label: '再温柔' },
@@ -272,6 +305,7 @@ const refreshAuthState = () => {
const clearPracticeData = () => {
cancelRecording();
assignments.value = [];
+ practiceHistory.value = [];
drillAnswers.value = {};
prepCard.value = null;
prepLoading.value = false;
@@ -447,6 +481,11 @@ const loadAssignments = async () => {
assignmentLoading.value = true;
try {
assignments.value = await getPracticeAssignments(true);
+ try {
+ practiceHistory.value = await getPracticeHistory();
+ } catch {
+ practiceHistory.value = [];
+ }
drillAnswers.value = Object.fromEntries(assignments.value.map((item) => [item.id, item.userAnswer || '']));
consumePendingAssignment();
} catch (error) {
@@ -456,6 +495,14 @@ const loadAssignments = async () => {
}
};
+const openRecentItem = (item: RecentPracticeItem) => {
+ if (item.assignment) {
+ startAssignment(item.assignment);
+ return;
+ }
+ uni.navigateTo({ url: '/pages/user/profile/index' });
+};
+
const consumePendingAssignment = () => {
const raw = uni.getStorageSync(pendingAssignmentKey);
if (!raw) return;
@@ -869,10 +916,10 @@ onShow(() => {
.assignment-row {
margin-top: 12px;
padding: 16px;
- border: 1px solid #deebff;
+ border: 1px solid #f1dfe0;
border-radius: 20px;
background: #ffffff;
- box-shadow: 0 10px 26px rgba(47, 107, 255, 0.08);
+ box-shadow: 0 8px 22px rgba(24, 28, 36, 0.05);
}
.assignment-row.highlighted {
@@ -885,15 +932,15 @@ onShow(() => {
width: fit-content;
padding: 4px 8px;
border-radius: 5px;
- background: #fff0f1;
- color: #bd0010;
+ background: #fff1f2;
+ color: #d51b2b;
font-size: 12px;
font-weight: 800;
}
.assignment-badge.daily {
- background: #fff5df;
- color: #a86100;
+ background: #fff1f2;
+ color: #d51b2b;
}
.assignment-title {
@@ -927,8 +974,8 @@ onShow(() => {
width: fit-content;
padding: 5px 10px;
border-radius: 5px;
- background: #edf7f0;
- color: #168243;
+ background: #fff1f2;
+ color: #d51b2b;
font-size: 13px;
font-weight: 900;
}
@@ -1011,7 +1058,7 @@ onShow(() => {
}
.dialogue-card {
- border-color: #e1e2e5;
+ border-color: #f0dfe0;
box-shadow: var(--employee-card-shadow);
}
@@ -1073,11 +1120,11 @@ onShow(() => {
}
.emotion-fill {
- background: #d98200;
+ background: #e45757;
}
.trust-fill {
- background: #168243;
+ background: #d51b2b;
}
.red-flag {
@@ -1110,8 +1157,8 @@ onShow(() => {
}
.turn-row.customer {
- border-left-color: #315b8f;
- background: #f0f4f8;
+ border-left-color: #b8bdc5;
+ background: #f7f7f8;
}
.turn-row.trainee {
@@ -1120,8 +1167,8 @@ onShow(() => {
}
.turn-row.coach {
- border-left-color: #168243;
- background: #edf7f0;
+ border-left-color: #d98282;
+ background: #fff5f5;
}
.reply-actions button.recording-active {
@@ -1151,9 +1198,9 @@ onShow(() => {
.style-chip {
width: auto;
- border-color: #c9d9ce;
+ border-color: #f0c8ca;
background: #ffffff;
- color: #168243;
+ color: #d51b2b;
}
.reply-card {
@@ -1163,7 +1210,7 @@ onShow(() => {
.practice-textarea {
min-height: 124px;
- border-color: #d6deea;
+ border-color: #f0dfe0;
border-radius: 18px;
background: #f9fbff;
}
@@ -1183,8 +1230,8 @@ onShow(() => {
.help-ref {
padding: 5px 8px;
border-radius: 5px;
- background: #edf7f0;
- color: #168243;
+ background: #fff1f2;
+ color: #d51b2b;
font-size: 12px;
font-weight: 800;
}
@@ -1196,7 +1243,7 @@ onShow(() => {
}
.result-score {
- color: #2f6bff;
+ color: #e52323;
font-size: 44px;
font-weight: 900;
line-height: 1;
@@ -1214,13 +1261,332 @@ onShow(() => {
flex-direction: column;
gap: 6px;
padding: 12px;
- border-left: 3px solid #d98200;
+ border-left: 3px solid #e52323;
border-radius: 6px;
- background: #fff9ed;
+ background: #fff6f6;
}
.mentor-rewrite text:first-child {
- color: #855000;
+ color: #c71927;
font-weight: 900;
}
+
+/* 练页入口态:对应高保真原型,真实对话状态仍沿用下方业务面板 */
+.practice-page {
+ padding: 0 18px 104px;
+ background: #fafafa;
+}
+
+.practice-head {
+ margin: 0 -18px;
+ padding: 36px 18px 26px;
+ background: #ffffff;
+ text-align: center;
+}
+
+.practice-head::after,
+.practice-eyebrow,
+.practice-tip {
+ display: none;
+}
+
+.practice-head .page-title {
+ color: #111111;
+ font-size: 38px;
+ font-weight: 900;
+}
+
+.practice-head .page-subtitle {
+ max-width: none;
+ margin-top: 8px;
+ color: #858991;
+ font-size: 16px;
+}
+
+.scenario-card {
+ position: relative;
+ overflow: hidden;
+ margin-top: 18px;
+ padding: 22px 18px 18px;
+ border: 0;
+ border-radius: 20px;
+ border-left: 0;
+ background: #ffffff;
+ box-shadow: 0 8px 24px rgba(24, 28, 36, 0.06);
+}
+
+.scenario-headline {
+ position: relative;
+ z-index: 1;
+ max-width: 58%;
+ gap: 8px;
+}
+
+.scenario-title {
+ color: #111111;
+ font-size: 30px;
+ font-weight: 900;
+}
+
+.scenario-desc {
+ color: #858991;
+ font-size: 17px;
+}
+
+.scenario-visual {
+ position: absolute;
+ top: 24px;
+ right: 18px;
+ width: 42%;
+ height: 108px;
+ border-radius: 50%;
+ background: #fff1f2;
+ opacity: 0.9;
+}
+
+.visual-sheet {
+ position: absolute;
+ top: 18px;
+ right: 24px;
+ width: 92px;
+ height: 76px;
+ border-radius: 12px;
+ background: #ffffff;
+ box-shadow: 0 8px 16px rgba(231, 0, 20, 0.08);
+ transform: rotate(7deg);
+}
+
+.visual-sheet::before,
+.visual-sheet::after {
+ position: absolute;
+ left: 14px;
+ width: 48px;
+ height: 6px;
+ border-radius: 6px;
+ background: #f5dfe1;
+ content: '';
+}
+
+.visual-sheet::before {
+ top: 20px;
+}
+
+.visual-sheet::after {
+ top: 36px;
+ width: 30px;
+}
+
+.visual-bubble {
+ position: absolute;
+ right: 34px;
+ bottom: 16px;
+ display: flex;
+ width: 72px;
+ height: 42px;
+ align-items: center;
+ justify-content: center;
+ border-radius: 14px;
+ background: #ef5b61;
+ color: #ffffff;
+ font-size: 19px;
+ letter-spacing: 3px;
+ box-shadow: 0 8px 14px rgba(231, 0, 20, 0.16);
+}
+
+.visual-bubble::after {
+ position: absolute;
+ right: 10px;
+ bottom: -7px;
+ width: 14px;
+ height: 14px;
+ background: #ef5b61;
+ content: '';
+ transform: rotate(35deg);
+}
+
+.prep-card {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 8px;
+ margin-top: 16px;
+ padding: 0;
+ border: 0;
+ background: transparent;
+}
+
+.prep-mini {
+ display: flex;
+ min-height: 68px;
+ flex-direction: column;
+ justify-content: center;
+ gap: 4px;
+ padding: 8px;
+ border: 1px solid #f4d9da;
+ border-radius: 12px;
+ background: #fffafa;
+}
+
+.prep-mini .prep-label {
+ color: #e70014;
+ font-size: 15px;
+ font-weight: 800;
+}
+
+.prep-mini text:last-child {
+ color: #8c8f96;
+ font-size: 11px;
+ line-height: 1.3;
+}
+
+.scenario-card .primary-button {
+ margin-top: 18px;
+ background: #e70014;
+ box-shadow: 0 8px 16px rgba(231, 0, 20, 0.18);
+}
+
+.recent-panel,
+.daily-panel {
+ margin-top: 14px;
+ padding: 18px;
+ border: 0;
+ border-radius: 20px;
+ background: #ffffff;
+ box-shadow: 0 8px 24px rgba(24, 28, 36, 0.05);
+}
+
+.recent-link,
+.daily-action-label {
+ color: #858991;
+ font-size: 14px;
+}
+
+.recent-row {
+ display: flex;
+ min-height: 64px;
+ align-items: center;
+ margin-top: 0;
+ padding: 10px 0;
+ border: 0;
+ border-bottom: 1px solid #f0f1f3;
+ border-radius: 0;
+ box-shadow: none;
+}
+
+.recent-icon {
+ display: flex;
+ width: 42px;
+ height: 42px;
+ flex: 0 0 42px;
+ align-items: center;
+ justify-content: center;
+ margin-right: 12px;
+ border-radius: 50%;
+ background: #fff1f2;
+ color: #e52323;
+ font-size: 24px;
+ font-weight: 800;
+}
+
+.recent-row .assignment-copy {
+ min-width: 0;
+ flex: 1;
+}
+
+.recent-row:last-child {
+ border-bottom: 0;
+}
+
+.recent-row .assignment-title {
+ font-size: 17px;
+}
+
+.recent-row .assignment-desc {
+ margin-top: 4px;
+ color: #858991;
+}
+
+.recent-arrow {
+ color: #8c9098;
+ font-size: 25px;
+}
+
+.daily-panel .assignment-head {
+ margin-top: 0;
+}
+
+.daily-subtitle {
+ display: block;
+ margin-top: 4px;
+ color: #858991;
+ font-size: 14px;
+}
+
+.daily-empty {
+ display: flex;
+ min-height: 72px;
+ flex-direction: column;
+ justify-content: center;
+ gap: 5px;
+ margin-top: 14px;
+ padding: 0 14px;
+ border: 1px dashed #f0c8ca;
+ border-radius: 14px;
+ background: #fffafa;
+ color: #858991;
+ font-size: 13px;
+ line-height: 1.45;
+ text-align: center;
+}
+
+.daily-empty text:first-child {
+ color: #d51b2b;
+ font-size: 15px;
+ font-weight: 800;
+}
+
+.daily-row {
+ display: block;
+ margin-top: 12px;
+ border-color: #f0f1f3;
+ border-radius: 14px;
+ box-shadow: none;
+}
+
+.daily-row .daily-answer {
+ margin-top: 8px;
+}
+
+.capability-strip {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 0;
+ margin-top: 14px;
+ padding: 16px 8px;
+ border-radius: 18px;
+ background: #ffffff;
+ box-shadow: 0 8px 24px rgba(24, 28, 36, 0.05);
+}
+
+.capability-strip view {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ align-items: center;
+ border-right: 1px solid #ececee;
+}
+
+.capability-strip view:last-child {
+ border-right: 0;
+}
+
+.capability-strip text:first-child {
+ color: #1a1a1a;
+ font-size: 16px;
+ font-weight: 800;
+}
+
+.capability-strip text:last-child {
+ color: #8a8d93;
+ font-size: 11px;
+}
diff --git a/mobile-uni/src/pages/user/sop/index.vue b/mobile-uni/src/pages/user/sop/index.vue
index f6f04bc7..698453ea 100644
--- a/mobile-uni/src/pages/user/sop/index.vue
+++ b/mobile-uni/src/pages/user/sop/index.vue
@@ -2,12 +2,23 @@
SOP · 话术 · 工作流程
- 问师傅
- 直接问工作中的 SOP、话术和流程。答案附带可追溯的知识库依据。
+ 问
+ 数字师傅 · SOP问答
+ 管家(生活顾问) · 住宅项目
先把问题问清楚,再按需生成总结卡,不伪造分类能力。
+
+
+ 问师傅
+ 直接问工作中的 SOP、话术和流程
+
+
+ •••
+ •••
+
+
+
+ ⌕
+
+ 知识库引用可追溯
+ 答案来自标准 SOP、附件来源,查得清楚、用得放心
+
+ ›
+
+
+
+ •••
+ 暂无相关回答
+ 换个问题试试吧
+
+
需要登录
@@ -888,4 +914,313 @@ onShow(() => {
color: #78350f;
line-height: 1.45;
}
+
+/* 视觉稿对齐:与练页统一的红白卡片体系 */
+.sop-page {
+ padding: 0 18px 96px;
+ background: #fafafa;
+}
+
+.sop-head {
+ margin: 0 -18px;
+ padding: 34px 18px 24px;
+ background: #ffffff;
+ text-align: center;
+}
+
+.sop-head::after,
+.sop-eyebrow,
+.toolbar-tip {
+ display: none;
+}
+
+.sop-head .page-title {
+ color: #111111;
+ font-size: 38px;
+ font-weight: 900;
+}
+
+.sop-head .page-subtitle {
+ max-width: none;
+ margin-top: 10px;
+ color: #858991;
+ font-size: 16px;
+}
+
+.sop-context {
+ display: inline-flex;
+ margin-top: 14px;
+ padding: 7px 14px;
+ border: 1px solid #f8d7d8;
+ border-radius: 999px;
+ background: #fff7f7;
+ color: #d51b2b;
+ font-size: 13px;
+ font-weight: 800;
+}
+
+.query-panel {
+ margin-top: 18px;
+ padding: 22px 18px 18px;
+ border: 0;
+ border-radius: 20px;
+ background: #ffffff;
+ box-shadow: 0 8px 24px rgba(24, 28, 36, 0.06);
+}
+
+.query-panel .textarea,
+.sop-query-textarea {
+ min-height: 126px;
+ height: 126px;
+ border-color: #dedfe2;
+ border-radius: 16px;
+ background: #ffffff;
+}
+
+.query-panel .chip {
+ min-height: 40px;
+ border-color: #e52323;
+ border-radius: 999px;
+ background: #ffffff;
+ color: #e52323;
+}
+
+.query-submit {
+ min-height: 54px;
+ margin-top: 8px;
+ border-radius: 18px;
+ background: #e52323;
+ box-shadow: 0 8px 16px rgba(229, 35, 35, 0.18);
+}
+
+.knowledge-tip-card {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-top: 14px;
+ padding: 16px;
+ border: 0;
+ border-radius: 18px;
+ background: #ffffff;
+ box-shadow: 0 8px 24px rgba(24, 28, 36, 0.05);
+}
+
+.knowledge-tip-icon {
+ display: flex;
+ width: 46px;
+ height: 46px;
+ align-items: center;
+ justify-content: center;
+ border-radius: 14px;
+ background: #fff0f1;
+ color: #e52323;
+ font-size: 28px;
+ font-weight: 900;
+}
+
+.knowledge-tip-copy {
+ display: flex;
+ min-width: 0;
+ flex: 1;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.knowledge-tip-title {
+ color: #25282d;
+ font-size: 16px;
+ font-weight: 900;
+}
+
+.knowledge-tip-desc {
+ color: #858991;
+ font-size: 12px;
+ line-height: 1.45;
+}
+
+.knowledge-tip-arrow {
+ color: #8c9098;
+ font-size: 26px;
+}
+
+.answer-empty {
+ display: flex;
+ align-items: center;
+ margin-top: 14px;
+ padding: 28px 18px;
+ flex-direction: column;
+ border: 0;
+ border-radius: 20px;
+ background: #ffffff;
+ box-shadow: 0 8px 24px rgba(24, 28, 36, 0.04);
+}
+
+.answer-empty-icon {
+ color: #f2b4b6;
+ font-size: 22px;
+ letter-spacing: 5px;
+}
+
+.answer-empty-title {
+ margin-top: 12px;
+ color: #666a72;
+ font-size: 18px;
+ font-weight: 800;
+}
+
+.answer-empty-desc {
+ margin-top: 6px;
+ color: #9a9da3;
+ font-size: 13px;
+}
+
+.answer-panel {
+ border-color: #f0dfe0;
+ border-left-color: #e52323;
+}
+
+.answer-panel .panel-title,
+.result-score {
+ color: #e52323;
+}
+
+.keypoint,
+.speech-bubble {
+ border-left-color: #e52323;
+ background: #fff5f5;
+}
+
+.cautions,
+.reminder-item {
+ border-left-color: #e52323;
+ background: #fff5f5;
+ color: #b71925;
+}
+
+.feedback-actions .active {
+ border-color: #e52323;
+ background: #fff0f1;
+ color: #e52323;
+}
+
+.feedback-actions .negative {
+ border-color: #e52323;
+ background: #fff0f1;
+ color: #b71925;
+}
+
+.summary-panel {
+ border-color: #f0dfe0;
+ background: #ffffff;
+}
+
+.summary-panel .ghost-button {
+ border-color: #e52323;
+ background: #ffffff;
+ color: #e52323;
+}
+
+.modal-card {
+ border-top-color: #e52323;
+}
+
+.badge-num,
+.timeline-node {
+ background: #e52323;
+}
+
+.a-badge {
+ background: #fff0f1;
+ color: #e52323;
+}
+
+.reminder-text {
+ color: #b71925;
+}
+
+/* 原型对齐:问师傅主卡的标题与对话插画 */
+.query-hero {
+ position: relative;
+ min-height: 84px;
+ overflow: hidden;
+}
+
+.query-copy {
+ position: relative;
+ z-index: 1;
+ display: flex;
+ max-width: 66%;
+ flex-direction: column;
+ gap: 7px;
+}
+
+.query-title {
+ color: #111111;
+ font-size: 30px;
+ font-weight: 900;
+ line-height: 1.1;
+}
+
+.query-subtitle {
+ color: #858991;
+ font-size: 15px;
+ line-height: 1.45;
+}
+
+.query-visual {
+ position: absolute;
+ top: -6px;
+ right: -4px;
+ width: 42%;
+ height: 96px;
+ border-radius: 50%;
+ background: #fff3f3;
+}
+
+.query-bubble {
+ position: absolute;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
+ letter-spacing: 3px;
+}
+
+.query-bubble.primary {
+ top: 7px;
+ right: 26px;
+ width: 92px;
+ height: 56px;
+ border-radius: 28px 28px 28px 10px;
+ background: #f58d91;
+ color: #ffffff;
+ font-size: 18px;
+}
+
+.query-bubble.primary::after {
+ position: absolute;
+ bottom: -8px;
+ left: 18px;
+ width: 16px;
+ height: 16px;
+ background: #f58d91;
+ content: '';
+ transform: rotate(28deg);
+}
+
+.query-bubble.secondary {
+ right: 2px;
+ bottom: 5px;
+ width: 70px;
+ height: 42px;
+ border-radius: 22px 22px 8px 22px;
+ background: #ffffff;
+ box-shadow: 0 5px 14px rgba(229, 35, 35, 0.08);
+ color: #f3b7b9;
+ font-size: 13px;
+}
+
+.query-panel .query-caption {
+ display: none;
+}
diff --git a/mobile-uni/src/pages/user/today/index.vue b/mobile-uni/src/pages/user/today/index.vue
index ad102020..5b841966 100644
--- a/mobile-uni/src/pages/user/today/index.vue
+++ b/mobile-uni/src/pages/user/today/index.vue
@@ -1,96 +1,140 @@
+
+
+
+ 银城员工端
+
+
+
+
+
+
+
{{ message }}
-
-
- 数字师傅 · 今日带练
{{ positionProfile.value }}
{{ positionProfile.heroTitle }}
有问题,随时问老师傅
-
-
- {{ mentorBubbleText }}
+
+
+ {{ mentorBubbleText }}
+
+
-
-
-
-
-
-
-
-
@@ -123,7 +169,7 @@ import { onShow } from '@dcloudio/uni-app';
import type { PracticeAssignment, RoleHome } from '@/types/api';
import { getMobileHome } from '@/services/home';
import { getPracticeAssignments } from '@/services/practice';
-import { getAuth, getSelectedPositionProfile, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
+import { getSelectedPositionProfile, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { resetPageScroll } from '@/services/navigation';
import { ensureEmployeePosition } from '@/services/position';
@@ -137,66 +183,100 @@ const primaryTipQuestion = computed(() => quickQuestions.value[0] || '岗位SOP'
const pendingSopQueryKey = 'aihr_sop_pending_query';
const pendingAssignmentKey = 'aihr_pending_assignment_id';
-const toneFor = (title: string) => {
- if (title.includes('SOP') || title.includes('查')) return 'ask';
- if (title.includes('练')) return 'practice';
- if (title.includes('学') || title.includes('案例')) return 'learn';
- return 'report';
-};
+const chipIcons = ['chatbubble', 'help', 'wallet'];
+const chipIcon = (index: number) => chipIcons[index] || 'chatbubble';
-const shortTitleFor = (tone: string) => {
- if (tone === 'ask') return '问';
- if (tone === 'practice') return '练';
- if (tone === 'learn') return '学';
- return '报';
-};
-
-const toolOrder = ['ask', 'practice', 'learn', 'report'];
-
-const mapTiles = computed(() => {
- if (!home.value?.tools?.length) return [];
- return home.value.tools
- .map((item) => {
- const tone = toneFor(item.title);
- return {
- title: item.title,
- shortTitle: shortTitleFor(tone),
- desc: item.desc.replace(/\n/g, ' / '),
- tone
- };
- })
- .sort((left, right) => toolOrder.indexOf(left.tone) - toolOrder.indexOf(right.tone));
-});
+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')
|| assignments.value.find((item) => item.status === '待训练')
|| null
);
+
const hasSchedule = computed(() => Boolean(primaryAssignment.value || home.value?.primary || home.value?.review));
+
+const scheduleCount = computed(() => {
+ let count = 0;
+ if (primaryAssignment.value || home.value?.primary) count += 1;
+ if (home.value?.review) count += 1;
+ return count;
+});
+
+const scheduleCountLabel = computed(() => `${scheduleCount.value || 1} 项任务`);
+
const primaryScheduleTitle = computed(() => {
const item = primaryAssignment.value;
if (item) {
return item.source === 'daily'
- ? `每日三题:${item.question || item.scenarioName}`
- : `专项训练:${item.scenarioName || item.question}`;
+ ? (item.question || item.scenarioName || '每日三题')
+ : (item.scenarioName || item.question || '专项训练');
}
- return `每日小训:${home.value?.primary?.title.replace('三角色对练', '') || '场景练习'}`;
-});
-const primaryScheduleDesc = computed(() => {
- const item = primaryAssignment.value;
- if (item) return item.source === 'daily' ? '提交后显示反馈与参考答案' : (item.reason || '主管指派专项训练');
- return home.value?.primary?.desc || '进入练习页查看今日任务';
-});
-const mentorName = computed(() => {
- if (!loggedIn.value) return '同事';
- const phone = getAuth().phone.trim();
- return phone ? `尾号${phone.slice(-4)}的同事` : '同事';
+ if (home.value?.primary?.title) {
+ return home.value.primary.title.replace('三角色对练', '').replace('重点训练', '专项练习').trim() || '场景练习';
+ }
+ return '';
});
-const mentorBubbleText = computed(() =>
- loggedIn.value ? `${mentorName.value},今天先做两件事` : '同事,登录后看今天任务'
-);
+const primaryScheduleDesc = computed(() => {
+ const item = primaryAssignment.value;
+ if (item) {
+ if (item.source === 'daily') return '提交后显示反馈与参考答案';
+ return item.reason || '学习投诉处理全流程与沟通话术';
+ }
+ return home.value?.primary?.desc || '进入练习页查看今日任务';
+});
+
+const primaryScheduleTime = computed(() => {
+ const item = primaryAssignment.value;
+ if (item?.source === 'daily') return '今日';
+ return '09:30-11:30';
+});
+
+const primarySchedulePlace = computed(() => {
+ const item = primaryAssignment.value;
+ if (item?.source === 'daily') return '线上答题';
+ return '线上学习';
+});
+
+const tipBody = computed(() => {
+ const tip = positionProfile.value.tip || '';
+ return tip.replace(/^今日小贴士[::]\s*/, '').replace(/^小贴士[::]\s*/, '') || tip;
+});
+
+const mentorBubbleText = computed(() => {
+ if (!loggedIn.value) return '登录后看今天任务';
+ if (primaryScheduleTitle.value) {
+ const topic = primaryScheduleTitle.value.replace(/专项|训练|练习/g, '').trim();
+ if (topic) return `今天先把${topic}练熟`;
+ }
+ return '今天先把投诉处理练熟';
+});
const loadHome = async () => {
loggedIn.value = isLoggedIn();
@@ -255,6 +335,10 @@ const openPrimarySchedule = () => {
uni.switchTab({ url: '/pages/user/practice/index' });
};
+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();
@@ -272,7 +356,7 @@ const openTool = (title: string) => {
uni.navigateTo({ url: '/pages/user/cases/index' });
return;
}
- uni.navigateTo({ url: `/pages/common/building/index?title=${encodeURIComponent(title)}` });
+ openBuilding(title);
};
onShow(() => {
@@ -282,261 +366,338 @@ onShow(() => {
diff --git a/mobile-uni/src/services/api.ts b/mobile-uni/src/services/api.ts
index 92410a65..31dfc9c6 100644
--- a/mobile-uni/src/services/api.ts
+++ b/mobile-uni/src/services/api.ts
@@ -12,6 +12,7 @@ export interface RequestOptions {
header?: Record;
timeout?: number;
auth?: boolean;
+ clearAuthOnUnauthorized?: boolean;
}
const normalizeUrl = (url: string) => `${apiBase}${url.startsWith('/') ? url : `/${url}`}`;
@@ -27,11 +28,11 @@ export const authHeaders = (json = true, includeAuth = true) => {
};
};
-export const readPayload = (statusCode: number, data: unknown): T => {
+export const readPayload = (statusCode: number, data: unknown, clearAuthOnUnauthorized = true): T => {
const payload = data as ApiPayload;
const code = typeof payload?.code === 'number' ? payload.code : undefined;
if (statusCode < 200 || statusCode >= 300 || (code !== undefined && code !== 200)) {
- if (statusCode === 401 || statusCode === 403 || code === 401 || code === 403) {
+ if (clearAuthOnUnauthorized && (statusCode === 401 || statusCode === 403 || code === 401 || code === 403)) {
clearAuth();
}
throw new Error(payload?.msg || `HTTP ${statusCode}`);
@@ -40,11 +41,11 @@ export const readPayload = (statusCode: number, data: unknown): T => {
return payload?.data as T;
};
-export const parsePayloadText = (statusCode: number, text: string) => {
+export const parsePayloadText = (statusCode: number, text: string, clearAuthOnUnauthorized = true) => {
try {
return JSON.parse(text || '{}');
} catch (error) {
- if (statusCode === 401 || statusCode === 403) clearAuth();
+ if (clearAuthOnUnauthorized && (statusCode === 401 || statusCode === 403)) clearAuth();
throw error instanceof SyntaxError
? new Error(statusCode >= 400 ? `HTTP ${statusCode}` : '响应解析失败')
: error;
@@ -67,7 +68,10 @@ const requestWithXhr = async (options: RequestOptions): Promise => {
}).forEach(([key, value]) => xhr.setRequestHeader(key, value));
xhr.onload = () => {
try {
- resolve({ status: xhr.status, data: parsePayloadText(xhr.status, xhr.responseText || 'null') });
+ resolve({
+ status: xhr.status,
+ data: parsePayloadText(xhr.status, xhr.responseText || 'null', options.clearAuthOnUnauthorized !== false)
+ });
} catch {
reject(new Error(xhr.status >= 400 ? `HTTP ${xhr.status}` : '响应解析失败'));
}
@@ -77,7 +81,7 @@ const requestWithXhr = async (options: RequestOptions): Promise => {
xhr.send(hasBody ? JSON.stringify(options.data) : undefined);
});
- return readPayload(response.status, response.data);
+ return readPayload(response.status, response.data, options.clearAuthOnUnauthorized !== false);
};
export const apiRequest = async (options: RequestOptions): Promise => {
@@ -100,7 +104,7 @@ export const apiRequest = async (options: RequestOptions): Promise => {
});
});
- return readPayload(response.statusCode, response.data);
+ return readPayload(response.statusCode, response.data, options.clearAuthOnUnauthorized !== false);
};
export const authenticatedFileUrl = async (url: string): Promise => {
diff --git a/mobile-uni/src/services/auth.ts b/mobile-uni/src/services/auth.ts
index 5f153f04..e3562ea3 100644
--- a/mobile-uni/src/services/auth.ts
+++ b/mobile-uni/src/services/auth.ts
@@ -30,7 +30,7 @@ const positionProfiles: Record = {
value: '生活顾问',
heroTitle: '客服管家',
quickQuestions: ['催费话术', '投诉应对', '收费标准'],
- tip: '今日小贴士:催费沟通先共情,再说明,再给方案。'
+ tip: '接待客户三步法:听清诉求 → 共情理解 → 解决闭环'
},
保安: {
value: '保安',
diff --git a/mobile-uni/src/services/position.ts b/mobile-uni/src/services/position.ts
index 023aea7e..9d40bbe2 100644
--- a/mobile-uni/src/services/position.ts
+++ b/mobile-uni/src/services/position.ts
@@ -35,7 +35,8 @@ export const hydratePositionFromOrg = async (phone = getAuth().phone): Promise({
url: `/api/aihr/org/snapshot?keyword=${encodeURIComponent(keyword)}&status=active&pageSize=1`,
method: 'GET',
- timeout: 15000
+ timeout: 15000,
+ clearAuthOnUnauthorized: false
});
const officialPosition = snapshot.rows?.[0]?.positionName || '';
return saveSelectedPosition(officialPosition);
diff --git a/mobile-uni/src/static/mentor-hifi.png b/mobile-uni/src/static/mentor-hifi.png
new file mode 100644
index 00000000..2a98c789
Binary files /dev/null and b/mobile-uni/src/static/mentor-hifi.png differ
diff --git a/mobile-uni/tests/mobile-user-pages.test.mjs b/mobile-uni/tests/mobile-user-pages.test.mjs
index 37d89a0b..88d0a84e 100644
--- a/mobile-uni/tests/mobile-user-pages.test.mjs
+++ b/mobile-uni/tests/mobile-user-pages.test.mjs
@@ -23,8 +23,11 @@ test('SOP 查询框不再覆盖 textarea 的 box-sizing', async () => {
test('员工首页地图主标题保留真实工具名称', async () => {
const source = await pageSource('../src/pages/user/today/index.vue');
- assert.match(source, /\{\{ tile\.shortTitle \}\}<\/text>/);
+ assert.match(source, /class="map-symbol"/);
assert.match(source, /\{\{ tile\.title \}\}<\/text>/);
+ assert.match(source, /title:\s*'问老师傅'/);
+ assert.match(source, /title:\s*'刻意练习'/);
+ assert.match(source, /shortTitle:\s*'问'/);
});
test('训练页提交失败会回滚本次乐观追加的对话', async () => {
@@ -43,13 +46,23 @@ test('manifest 明确声明为 uni-app Vue3 CLI 工程', async () => {
test('员工端 tabBar 继续保留四个固定入口', async () => {
const source = await pageSource('../src/pages.json');
- assert.match(source, /"selectedColor"\s*:\s*"#2f6bff"/);
+ assert.match(source, /"selectedColor"\s*:\s*"#e70014"/);
assert.match(source, /pages\/user\/today\/index/);
assert.match(source, /pages\/user\/practice\/index/);
assert.match(source, /pages\/user\/sop\/index/);
assert.match(source, /pages\/user\/profile\/index/);
});
+test('登录页按高保真原型保留品牌结构和紧凑验证码行', async () => {
+ const source = await pageSource('../src/pages/auth/login/index.vue');
+
+ assert.match(source, /高效协同 · 贴心服务 · 让工作更简单/);
+ assert.match(source, /]+type="phone"/);
+ assert.match(source, /]+type="locked"/);
+ assert.doesNotMatch(source, /class="logo-badge"/);
+ assert.doesNotMatch(source, /grid-template-columns:\s*1fr;[\s\S]*?\.code-button/);
+});
+
test('共享视觉基座包含员工端主色和卡片圆角 token', async () => {
const source = await pageSource('../src/styles/base.css');
@@ -78,6 +91,22 @@ test('员工首页保留师傅区、语音条、今日安排、我的地图四
assert.match(source, /voice-panel/);
assert.match(source, /今日安排/);
assert.match(source, /我的地图/);
+ assert.match(source, /brand-bar/);
+ assert.match(source, /ask-card/);
+ assert.match(source, /小贴士/);
+});
+
+test('员工首页高保真入口继续使用真实图标和业务跳转', async () => {
+ const source = await pageSource('../src/pages/user/today/index.vue');
+
+ assert.match(source, /type="notification"/);
+ assert.match(source, /type="chatboxes"/);
+ assert.match(source, /type="compose"/);
+ assert.match(source, /type="location"/);
+ assert.match(source, /const goSop = \(\) =>/);
+ assert.match(source, /const openPrimarySchedule = \(\) =>/);
+ assert.match(source, /getMobileHome\('user'\)/);
+ assert.match(source, /getPracticeAssignments\(true\)/);
});
test('问师傅页保留搜索框、答案区和总结卡入口', async () => {