feat(mobile): finish employee app page polish

This commit is contained in:
2026-07-11 18:59:30 +08:00
parent ca84e6756c
commit c66fc4e4b0
12 changed files with 1873 additions and 658 deletions
+1 -1
View File
@@ -131,7 +131,7 @@
},
"tabBar": {
"color": "#7b8494",
"selectedColor": "#2f6bff",
"selectedColor": "#e70014",
"backgroundColor": "#ffffff",
"borderStyle": "white",
"height": "66px",
+306 -131
View File
@@ -1,70 +1,82 @@
<template>
<view class="page login-page">
<view class="login-head">
<view class="brand-lockup">
<image class="login-logo" src="/static/brand-logo.png" mode="aspectFit" />
<view class="brand-copy">
<text class="brand-name">银城物业</text>
<text class="login-eyebrow">数字师傅</text>
</view>
</view>
<text class="page-title">银城员工端</text>
<text class="page-subtitle">使用手机号登录后,回到当前任务。</text>
</view>
<view class="panel login-card">
<text class="panel-title">手机号登录</text>
<uni-forms
ref="loginForm"
class="login-form"
:model="formData"
:rules="formRules"
label-position="top"
validate-trigger="submit"
err-show-type="undertext"
:border="false"
>
<uni-forms-item label="手机号" name="phone" required>
<uni-easyinput
v-model="formData.phone"
type="number"
maxlength="11"
:clearable="false"
primary-color="#e70014"
placeholder="请输入手机号"
placeholder-style="color: #9aa5b5"
:styles="inputStyles"
/>
</uni-forms-item>
<uni-forms-item label="验证码" name="smsCode" required class="code-form-item">
<view class="code-row">
<uni-easyinput
v-model="formData.smsCode"
class="code-input"
type="number"
maxlength="6"
:clearable="false"
primary-color="#e70014"
placeholder="请输入验证码"
placeholder-style="color: #9aa5b5"
:styles="inputStyles"
/>
<button class="secondary-button code-button" :disabled="sending" @click="sendCode">
{{ sending ? '发送中' : '获取验证码' }}
</button>
<view class="login-shell">
<view class="login-head">
<view class="brand-lockup">
<image class="login-logo" src="/static/brand-logo.png" mode="aspectFit" />
<view class="brand-copy">
<text class="brand-name">银城物业</text>
<text class="brand-divider">|</text>
<text class="login-eyebrow">数字师傅</text>
</view>
</uni-forms-item>
</uni-forms>
<button class="primary-button" :disabled="loggingIn" @click="login">
{{ loggingIn ? '登录中' : '登录' }}
</button>
<uni-notice-bar
v-if="message"
:text="message"
:background-color="message === '验证码已发送' ? '#edf7f0' : '#fff2f3'"
:color="message === '验证码已发送' ? '#168243' : '#bd0010'"
show-icon
/>
</view>
<text class="page-title">银城员工端</text>
<text class="page-subtitle">高效协同 · 贴心服务 · 让工作更简单</text>
</view>
<view class="panel login-card">
<uni-forms
ref="loginForm"
class="login-form"
:model="formData"
:rules="formRules"
label-position="top"
validate-trigger="submit"
err-show-type="undertext"
:border="false"
>
<uni-forms-item label="手机号" name="phone">
<view class="field-shell">
<uni-icons class="field-icon" type="phone" size="22" color="#a7adb8" />
<uni-easyinput
v-model="formData.phone"
aria-label="手机号"
type="number"
maxlength="11"
:clearable="false"
primary-color="#e70014"
@input="onPhoneInput"
placeholder="请输入手机号"
placeholder-style="color: #b5b8c1"
:styles="inputStyles"
/>
</view>
</uni-forms-item>
<uni-forms-item label="验证码" name="smsCode">
<view class="code-row">
<view class="field-shell code-shell">
<uni-icons class="field-icon" type="locked" size="22" color="#a7adb8" />
<uni-easyinput
v-model="formData.smsCode"
class="code-input"
aria-label="验证码"
type="number"
maxlength="6"
:clearable="false"
primary-color="#e70014"
@input="onSmsCodeInput"
placeholder="请输入验证码"
placeholder-style="color: #b5b8c1"
:styles="inputStyles"
/>
</view>
<button class="secondary-button code-button" :disabled="sending" @click="sendCode">
{{ sending ? '发送中' : '获取验证码' }}
</button>
</view>
</uni-forms-item>
</uni-forms>
<button class="primary-button login-submit" :disabled="loggingIn" @click="login">
{{ loggingIn ? '登录中' : '登录' }}
</button>
<uni-notice-bar
v-if="message"
:text="message"
:background-color="message === '验证码已发送' ? '#edf7f0' : '#fff2f3'"
:color="message === '验证码已发送' ? '#168243' : '#bd0010'"
show-icon
/>
</view>
</view>
</view>
</template>
@@ -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(() => {
<style scoped>
.login-page {
display: flex;
min-height: 100vh;
flex-direction: column;
justify-content: flex-start;
padding-top: 52px;
background: #f7f7f8;
padding: 0;
background:
radial-gradient(circle at 18% 10%, rgba(255, 255, 255, 0.98), rgba(247, 247, 247, 0.86) 38%, rgba(239, 239, 239, 0.72) 100%),
linear-gradient(180deg, #fafafa 0%, #f2f2f2 100%);
}
.login-shell {
min-height: 100vh;
padding: 114px 30px 52px;
overflow: hidden;
}
.login-shell::before,
.login-shell::after {
position: fixed;
inset: auto;
width: 180px;
height: 180px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.45);
filter: blur(10px);
content: '';
pointer-events: none;
}
.login-shell::before {
top: 64px;
right: -46px;
}
.login-shell::after {
bottom: 120px;
left: -72px;
}
.login-head {
position: relative;
margin-bottom: 32px;
padding-top: 0;
}
.login-head::before {
position: absolute;
top: 73px;
left: 0;
width: 42px;
height: 3px;
border-radius: 0;
background: #e70014;
content: '';
max-width: 720px;
margin: 0 auto 43px;
}
.brand-lockup {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 28px;
gap: 12px;
margin-bottom: 36px;
}
.login-logo {
width: 42px;
height: 42px;
width: 48px;
height: 48px;
flex: none;
transform: translateX(6px) scale(1.4);
}
.brand-copy {
display: flex;
flex-direction: column;
gap: 3px;
align-items: center;
gap: 8px;
}
.brand-name,
.brand-divider,
.login-eyebrow {
display: block;
display: inline-flex;
align-items: center;
color: #34373c;
font-size: 13px;
font-weight: 800;
font-size: 18px;
font-weight: 700;
line-height: 1.2;
}
.brand-divider {
color: #8d9097;
font-weight: 400;
}
.login-eyebrow {
color: #168243;
font-size: 12px;
color: #1d9668;
}
.login-head .page-title {
color: #25282d;
font-size: 32px;
display: block;
color: #252931;
font-size: 34px;
font-weight: 800;
line-height: 1.16;
letter-spacing: 1px;
}
.login-head .page-subtitle {
display: block;
margin-top: 12px;
color: #74777d;
font-size: 14px;
color: #6d7078;
font-size: 15px;
line-height: 1.6;
}
.login-card {
position: relative;
display: flex;
flex-direction: column;
gap: 20px;
padding: 26px 20px 22px;
border: 1px solid #e3e4e7;
border-top: 3px solid #e70014;
gap: 18px;
max-width: 720px;
margin: 0 auto;
padding: 36px 20px 36px;
border: 1px solid rgba(225, 227, 232, 0.95);
border-top: 4px solid #f20b1a;
border-radius: 12px;
box-shadow: 0 10px 24px rgba(27, 31, 38, 0.07);
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 12px 28px rgba(29, 31, 35, 0.12), 0 1px 0 rgba(255, 255, 255, 0.8) inset;
}
.login-card::before {
position: absolute;
inset: 0;
border-radius: 12px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.35) 0%, rgba(255, 255, 255, 0) 26%);
content: '';
pointer-events: none;
}
.login-card > * {
position: relative;
z-index: 1;
}
.login-form {
margin-top: 2px;
margin-top: 0;
}
.login-form :deep(.uni-forms-item) {
margin-bottom: 18px;
margin-bottom: 22px;
}
.login-form :deep(.uni-forms-item:last-child) {
margin-bottom: 0;
margin-bottom: 7px;
}
.login-form :deep(.uni-forms-item__label) {
padding-bottom: 8px;
color: #34373c;
font-size: 14px;
font-weight: 800;
}
.login-form :deep(.uni-easyinput__content) {
min-height: 44px;
border-radius: 6px;
padding-bottom: 4px;
color: #20242b;
font-size: 15px;
font-weight: 700;
}
.login-form :deep(.uni-forms-item__error) {
margin-top: 6px;
margin-top: 9px;
font-size: 13px;
}
.code-row {
.field-shell {
display: flex;
gap: 10px;
align-items: center;
min-height: 56px;
padding: 0 14px;
border: 1px solid #d8dce3;
border-radius: 8px;
background: #ffffff;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.92);
transition: border-color 160ms ease, box-shadow 160ms ease;
}
.field-shell:focus-within {
border-color: #e70014;
box-shadow: 0 0 0 3px rgba(231, 0, 20, 0.08);
}
.field-shell :deep(.uni-easyinput) {
flex: 1;
min-width: 0;
}
.field-shell :deep(.uni-easyinput__content) {
min-height: auto;
border: none !important;
border-radius: 0;
background: transparent !important;
}
.field-shell :deep(.uni-easyinput__content-input) {
height: 28px;
color: #25282d;
font-size: 15px;
line-height: 28px;
}
.field-shell :deep(.uni-easyinput__placeholder-class) {
color: #b5b8c1;
font-size: 15px;
}
.field-icon {
margin-right: 12px;
flex: none;
}
.code-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 84px;
align-items: stretch;
gap: 12px;
}
.code-shell,
.code-input {
flex: 1;
min-width: 0;
}
.code-button {
width: 118px;
min-height: 44px;
flex: none;
border-color: #e70014;
background: #ffffff;
color: #e70014;
font-size: 14px;
.code-shell {
padding-right: 10px;
padding-left: 12px;
}
.login-card > .primary-button {
min-height: 52px;
.code-shell .field-icon {
margin-right: 9px;
}
.code-shell :deep(.uni-easyinput__content-input),
.code-shell :deep(.uni-easyinput__placeholder-class) {
font-size: 13px;
}
.code-button {
width: 100%;
min-height: 56px;
padding: 0 4px;
border: 1px solid #f20b1a;
border-radius: 8px;
background: #e70014;
box-shadow: 0 8px 16px rgba(231, 0, 20, 0.16);
background: linear-gradient(180deg, #ffffff 0%, #fffdfd 100%);
color: #f20b1a;
font-size: 12px;
font-weight: 700;
line-height: 1.3;
white-space: nowrap;
box-shadow: 0 6px 14px rgba(242, 11, 26, 0.06);
}
.login-submit {
display: flex;
width: 100%;
min-height: 60px;
margin-top: 4px;
align-items: center;
justify-content: center;
align-self: stretch;
padding: 0 20px;
border-radius: 8px;
background: linear-gradient(90deg, rgba(255, 255, 255, 0.34) 0%, rgba(255, 255, 255, 0) 12%),
linear-gradient(180deg, #ff1124 0%, #f40619 100%);
color: #ffffff;
font-size: 22px;
font-weight: 800;
letter-spacing: 2px;
box-shadow: 0 8px 18px rgba(231, 0, 20, 0.16), inset 0 1px 0 rgba(255, 255, 255, 0.24);
}
.login-card :deep(.uni-noticebar) {
margin: 0;
border-radius: 10px;
}
@media (max-width: 375px) {
.login-shell {
padding-right: 32px;
padding-left: 32px;
}
.brand-name,
.brand-divider,
.login-eyebrow {
font-size: 17px;
}
.login-head .page-title {
font-size: 32px;
}
.code-row {
grid-template-columns: minmax(0, 1fr) 80px;
gap: 10px;
}
.code-button {
width: 100%;
min-height: 56px;
}
}
</style>
+134 -27
View File
@@ -1,15 +1,23 @@
<template>
<view class="page position-page">
<view class="brand-bar">
<image class="brand-logo" src="/static/brand-logo.png" mode="aspectFit" />
<text class="brand-name">银城员工端</text>
</view>
<view class="position-head">
<text class="page-title">确认岗位</text>
<text class="page-subtitle">{{ phone }}</text>
</view>
<view v-if="message" class="status-message">{{ message }}</view>
<view v-if="message" class="status-message">
<uni-icons type="info" size="22" color="#d98200" />
<text>{{ message }}</text>
</view>
<view class="panel">
<text class="panel-title">选择当前岗位</text>
<view class="position-list">
<view class="position-grid">
<button
v-for="item in positionCards"
:key="item.value"
@@ -22,10 +30,14 @@
<text class="position-name">{{ item.value }}</text>
<text class="position-title">{{ item.heroTitle }}</text>
</view>
<text class="position-mark">{{ item.value === selectedPosition ? '已选' : '选择' }}</text>
<view class="position-radio" :class="{ checked: item.value === selectedPosition }" />
</button>
</view>
</view>
<button class="confirm-button" :disabled="!selectedPosition || saving" @click="confirmPosition">
{{ saving ? '保存中…' : '确认岗位' }}
</button>
</view>
</template>
@@ -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(() => {
<style scoped>
.position-page {
display: flex;
min-height: 100vh;
flex-direction: column;
justify-content: center;
padding: 24px 24px 32px;
background: linear-gradient(180deg, #ffffff 0%, #fbfbfb 68%, #f6f6f6 100%);
box-sizing: border-box;
}
.brand-bar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 38px;
}
.brand-logo {
width: 34px;
height: 34px;
}
.brand-name {
color: #1a1a1a;
font-size: 20px;
font-weight: 800;
}
.position-head {
margin-bottom: 10px;
margin-bottom: 24px;
}
.position-list {
.page-title {
display: block;
color: #18202b;
font-size: 34px;
font-weight: 900;
line-height: 1.15;
}
.page-subtitle {
display: block;
margin-top: 10px;
color: #7d8794;
font-size: 18px;
letter-spacing: 0.04em;
}
.status-message {
display: flex;
margin-top: 16px;
flex-direction: column;
gap: 10px;
align-items: flex-start;
gap: 12px;
margin-bottom: 22px;
padding: 16px 14px;
border: 1px solid #f1c889;
border-left: 4px solid #d98200;
border-radius: 12px;
background: #fff8ec;
color: #8a5a12;
font-size: 14px;
line-height: 1.55;
}
.panel {
padding: 22px 18px 24px;
border: 1px solid #eceff2;
border-radius: 18px;
background: #ffffff;
box-shadow: 0 10px 26px rgba(24, 28, 36, 0.06);
}
.panel-title {
display: block;
color: #18202b;
font-size: 22px;
font-weight: 800;
}
.position-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-top: 18px;
}
.position-card {
display: flex;
min-height: 72px;
width: 100%;
box-sizing: border-box;
min-height: 112px;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border: 1px solid #e2e8f0;
padding: 16px 14px;
border: 1px solid #e0e4e9;
border-radius: 16px;
background: #f8fafc;
background: #ffffff;
text-align: left;
}
.position-card.active {
border-color: #2563eb;
background: #eff6ff;
border-color: #e70014;
background: #fffafa;
}
.position-copy {
@@ -137,21 +220,45 @@ onShow(() => {
}
.position-name {
color: #0f172a;
font-size: 18px;
font-weight: 900;
color: #18202b;
font-size: 19px;
font-weight: 800;
}
.position-title {
margin-top: 6px;
color: #64748b;
color: #7d8794;
font-size: 13px;
}
.position-mark {
color: #2563eb;
font-size: 14px;
font-weight: 900;
white-space: nowrap;
.position-radio {
width: 22px;
height: 22px;
flex: none;
border: 2px solid #b8bec7;
border-radius: 50%;
box-sizing: border-box;
}
.position-radio.checked {
border-color: #e70014;
box-shadow: inset 0 0 0 5px #ffffff;
background: #e70014;
}
.confirm-button {
width: 100%;
min-height: 56px;
margin-top: 24px;
border-radius: 14px;
background: #e70014;
color: #ffffff;
font-size: 20px;
font-weight: 800;
box-shadow: 0 10px 20px rgba(231, 0, 20, 0.18);
}
.confirm-button[disabled] {
opacity: 0.45;
}
</style>
+434 -68
View File
@@ -1,10 +1,8 @@
<template>
<view class="page practice-page employee-page-shell">
<view class="practice-head">
<text class="employee-kicker practice-eyebrow">数字师傅 · 场景训练</text>
<text class="page-title">场景模拟</text>
<text class="page-subtitle">与业主、老师傅完成一轮真实工作对话,结束后自动写入训练记录。</text>
<text class="practice-tip">先看预习卡,再开始练习;红线命中时按老师傅建议重说本轮。</text>
<text class="page-title">练</text>
<text class="page-subtitle">数字师傅 · 场景训练</text>
</view>
<view v-if="message" class="status-message">{{ message }}</view>
@@ -19,26 +17,17 @@
<view v-if="loggedIn" class="panel scenario-card employee-card">
<view class="scenario-headline">
<text class="scenario-badge">今日小训</text>
<text class="scenario-title">{{ activeScenarioName }}</text>
<text class="scenario-desc">{{ scenarioDescription }}</text>
<text class="scenario-title">场景模拟</text>
<text class="scenario-desc">先看预习卡,再开始练习</text>
</view>
<view class="scenario-visual" aria-hidden="true">
<view class="visual-sheet" />
<view class="visual-bubble"><text>•••</text></view>
</view>
<view v-if="prepCard" class="prep-card">
<view class="prep-head">
<text>训练前预习卡</text>
</view>
<view class="prep-block">
<text class="prep-label">要点</text>
<text v-for="item in prepCard.keyPoints" :key="`point-${item}`" class="prep-line">{{ item }}</text>
</view>
<view class="prep-block">
<text class="prep-label">红线</text>
<text v-for="item in prepCard.redLines" :key="`red-${item}`" class="prep-line">{{ item }}</text>
</view>
<view class="prep-block">
<text class="prep-label">话术</text>
<text v-for="item in prepCard.scripts" :key="`script-${item}`" class="prep-line">{{ item }}</text>
</view>
<view class="prep-mini"><text class="prep-label">要点</text><text>关键要点先掌握</text></view>
<view class="prep-mini"><text class="prep-label">红线</text><text>风险红线要注意</text></view>
<view class="prep-mini"><text class="prep-label">可说话术</text><text>有效话术可参考</text></view>
</view>
<text v-else-if="prepLoading" class="muted">预习卡加载中</text>
<button
@@ -51,20 +40,36 @@
</button>
</view>
<view v-if="loggedIn" class="panel assignment-panel">
<view v-if="loggedIn" class="panel assignment-panel recent-panel">
<view class="assignment-head">
<text class="panel-title">今日任务</text>
<button class="ghost-button refresh-button" :disabled="assignmentLoading" @click="loadAssignments">刷新</button>
<text class="panel-title">最近练过</text>
<text class="recent-link">全部 ›</text>
</view>
<view v-if="assignments.length" class="assignment-list">
<view v-for="item in assignments" :key="item.id" class="assignment-row" :class="{ highlighted: highlightedAssignmentId === item.id }">
<view v-if="recentPracticeItems.length" class="assignment-list">
<view v-for="item in recentPracticeItems" :key="item.id" class="assignment-row recent-row" :class="{ highlighted: item.assignment && highlightedAssignmentId === item.assignment.id }" @click="openRecentItem(item)">
<view class="recent-icon" aria-hidden="true">{{ item.icon }}</view>
<view class="assignment-copy">
<text class="assignment-title">{{ item.title }}</text>
<text class="assignment-desc">{{ item.desc }}</text>
</view>
<text class="recent-arrow">›</text>
</view>
</view>
<view v-else class="empty">暂无最近练习</view>
</view>
<view v-if="loggedIn" class="panel assignment-panel daily-panel">
<view class="assignment-head">
<view><text class="panel-title">每日三题</text><text class="daily-subtitle">今日安排</text></view>
<text class="daily-action-label">去作答 ›</text>
</view>
<view v-if="dailyAssignments.length" class="assignment-list">
<view v-for="item in dailyAssignments" :key="item.id" class="assignment-row daily-row">
<view class="assignment-copy">
<text class="assignment-badge" :class="{ daily: item.source === 'daily' }">{{ item.source === 'daily' ? '每日三题' : '主管专项' }}</text>
<text class="assignment-title">{{ item.question || item.scenarioName }}</text>
<text v-if="assignmentDesc(item)" class="assignment-desc">{{ assignmentDesc(item) }}</text>
<text v-if="item.status === '已完成' && item.referenceAnswer" class="assignment-reference">参考:{{ item.referenceAnswer }}</text>
</view>
<view v-if="item.source === 'daily'" class="daily-answer">
<view class="daily-answer">
<textarea
v-model="drillAnswers[item.id]"
class="textarea daily-textarea"
@@ -76,10 +81,18 @@
{{ item.status === '已完成' ? '已完成' : '提交' }}
</button>
</view>
<button v-else class="secondary-button small-button" :disabled="!canStartPractice" @click="startAssignment(item)">开始专项</button>
</view>
</view>
<view v-else class="empty">暂无今日任务</view>
<view v-else class="daily-empty">
<text>今日暂无题目</text>
<text>完成专项训练后会自动生成每日练习</text>
</view>
</view>
<view v-if="loggedIn" class="capability-strip">
<view><text>情绪</text><text>识别与安抚</text></view>
<view><text>信任</text><text>建立与维护</text></view>
<view><text>红线纠偏</text><text>识别与规避</text></view>
</view>
<view v-if="turns.length" class="panel dialogue-card employee-card">
@@ -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<PracticeAssignment[]>([]);
const practiceHistory = ref<PracticeRecord[]>([]);
const drillAnswers = ref<Record<number, string>>({});
const assignmentLoading = ref(false);
const answeringId = ref<number | null>(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<RecentPracticeItem[]>(() => {
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;
}
</style>
+337 -2
View File
@@ -2,12 +2,23 @@
<view class="page sop-page employee-page-shell">
<view class="sop-head">
<text class="employee-kicker sop-eyebrow">SOP · 话术 · 工作流程</text>
<text class="page-title">问师傅</text>
<text class="page-subtitle">直接问工作中的 SOP、话术和流程。答案附带可追溯的知识库依据。</text>
<text class="page-title">问</text>
<text class="page-subtitle">数字师傅 · SOP问答</text>
<text class="sop-context">管家(生活顾问) · 住宅项目</text>
<text class="toolbar-tip">先把问题问清楚,再按需生成总结卡,不伪造分类能力。</text>
</view>
<view class="panel query-panel employee-card">
<view class="query-hero">
<view class="query-copy">
<text class="query-title">问师傅</text>
<text class="query-subtitle">直接问工作中的 SOP、话术和流程</text>
</view>
<view class="query-visual" aria-hidden="true">
<view class="query-bubble primary"><text>•••</text></view>
<view class="query-bubble secondary"><text>•••</text></view>
</view>
</view>
<textarea
v-model="query"
class="textarea sop-query-textarea"
@@ -26,6 +37,21 @@
<text v-if="message" class="status-message">{{ message }}</text>
</view>
<view v-if="loggedIn && !result" class="knowledge-tip-card employee-card">
<view class="knowledge-tip-icon">⌕</view>
<view class="knowledge-tip-copy">
<text class="knowledge-tip-title">知识库引用可追溯</text>
<text class="knowledge-tip-desc">答案来自标准 SOP、附件来源,查得清楚、用得放心</text>
</view>
<text class="knowledge-tip-arrow">›</text>
</view>
<view v-if="loggedIn && !result && !loading" class="answer-empty employee-card">
<text class="answer-empty-icon">•••</text>
<text class="answer-empty-title">暂无相关回答</text>
<text class="answer-empty-desc">换个问题试试吧</text>
</view>
<view v-if="!loggedIn" class="panel login-panel employee-card">
<view>
<text class="panel-title">需要登录</text>
@@ -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;
}
</style>
File diff suppressed because it is too large Load Diff
+11 -7
View File
@@ -12,6 +12,7 @@ export interface RequestOptions {
header?: Record<string, string>;
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 = <T>(statusCode: number, data: unknown): T => {
export const readPayload = <T>(statusCode: number, data: unknown, clearAuthOnUnauthorized = true): T => {
const payload = data as ApiPayload<T>;
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 = <T>(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 <T>(options: RequestOptions): Promise<T> => {
}).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 <T>(options: RequestOptions): Promise<T> => {
xhr.send(hasBody ? JSON.stringify(options.data) : undefined);
});
return readPayload<T>(response.status, response.data);
return readPayload<T>(response.status, response.data, options.clearAuthOnUnauthorized !== false);
};
export const apiRequest = async <T>(options: RequestOptions): Promise<T> => {
@@ -100,7 +104,7 @@ export const apiRequest = async <T>(options: RequestOptions): Promise<T> => {
});
});
return readPayload<T>(response.statusCode, response.data);
return readPayload<T>(response.statusCode, response.data, options.clearAuthOnUnauthorized !== false);
};
export const authenticatedFileUrl = async (url: string): Promise<string> => {
+1 -1
View File
@@ -30,7 +30,7 @@ const positionProfiles: Record<UserPosition, UserPositionProfile> = {
value: '生活顾问',
heroTitle: '客服管家',
quickQuestions: ['催费话术', '投诉应对', '收费标准'],
tip: '今日小贴士:催费沟通先共情,再说明,再给方案。'
tip: '接待客户三步法:听清诉求 → 共情理解 → 解决闭环'
},
保安: {
value: '保安',
+2 -1
View File
@@ -35,7 +35,8 @@ export const hydratePositionFromOrg = async (phone = getAuth().phone): Promise<U
const snapshot = await apiRequest<OrgSnapshotResponse>({
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);
Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

+31 -2
View File
@@ -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, /<text class="map-symbol">\{\{ tile\.shortTitle \}\}<\/text>/);
assert.match(source, /class="map-symbol"/);
assert.match(source, /<text class="map-title">\{\{ 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, /<uni-icons[^>]+type="phone"/);
assert.match(source, /<uni-icons[^>]+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 () => {