fix(aihr): close Figma UI regression gaps

This commit is contained in:
2026-07-17 04:17:14 +08:00
parent a822b22116
commit e93421ca1e
35 changed files with 1452 additions and 107 deletions
+7
View File
@@ -11,6 +11,7 @@
"@dcloudio/uni-app": "3.0.0-alpha-5020120260706001",
"@dcloudio/uni-h5": "3.0.0-alpha-5020120260706001",
"@dcloudio/uni-ui": "^1.5.12",
"lxgw-wenkai-tc-webfont": "^1.0.0",
"vue": "3.5.30"
},
"devDependencies": {
@@ -5816,6 +5817,12 @@
"yallist": "^3.0.2"
}
},
"node_modules/lxgw-wenkai-tc-webfont": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/lxgw-wenkai-tc-webfont/-/lxgw-wenkai-tc-webfont-1.0.0.tgz",
"integrity": "sha512-LOGglSMNw6Nufn+ULaSjliwNwtkCE1cwXuayN09pg+KMDGD5XxxGrAbca1Ap+YdP4NLotdBDKi5wmUXbgqTxHg==",
"license": "MIT"
},
"node_modules/magic-string": {
"version": "0.30.11",
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.11.tgz",
+1
View File
@@ -15,6 +15,7 @@
"@dcloudio/uni-app": "3.0.0-alpha-5020120260706001",
"@dcloudio/uni-h5": "3.0.0-alpha-5020120260706001",
"@dcloudio/uni-ui": "^1.5.12",
"lxgw-wenkai-tc-webfont": "^1.0.0",
"vue": "3.5.30"
},
"devDependencies": {
@@ -0,0 +1,91 @@
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created using
the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -28,7 +28,28 @@
</view>
<template v-else>
<view class="panel assignment-form">
<view class="assign-tabs" role="tablist" aria-label="指派专项视图">
<button
class="assign-tab"
:class="{ active: assignView === 'create' }"
role="tab"
:aria-selected="assignView === 'create'"
@click="assignView = 'create'"
>
新建指派
</button>
<button
class="assign-tab"
:class="{ active: assignView === 'progress' }"
role="tab"
:aria-selected="assignView === 'progress'"
@click="assignView = 'progress'"
>
派发进度
</button>
</view>
<view v-if="assignView === 'create'" class="panel assignment-form">
<text class="field-label">训练对象</text>
<input
v-model="targetExtPartyId"
@@ -37,6 +58,17 @@
placeholder="输入手机号或外部ID"
@input="targetName = ''"
/>
<view v-if="memberSuggestions.length" class="target-suggestions">
<button
v-for="member in memberSuggestions"
:key="member.extPartyId"
class="target-suggestion"
@click="selectTarget(member)"
>
<text>{{ member.name || '未命名员工' }}</text>
<text>{{ maskPhone(member.extPartyId) }}</text>
</button>
</view>
<input
v-model="targetName"
class="input field-input"
@@ -45,9 +77,15 @@
/>
<text class="field-label section-label">训练场景</text>
<input
v-model="scenarioSearch"
class="input field-input"
maxlength="30"
placeholder="搜索训练场景"
/>
<view v-if="scenarioOptions.length" class="scenario-row">
<button
v-for="item in scenarioOptions"
v-for="item in filteredScenarioOptions"
:key="item.id"
class="chip scenario-chip"
:class="{ active: assignmentScenarioId === item.id }"
@@ -59,6 +97,29 @@
</view>
<text v-else class="muted block">暂无可派发的已启用场景</text>
<text class="field-label section-label">训练目标</text>
<input
v-model="assignmentGoal"
class="input field-input"
maxlength="200"
placeholder="例如:稳定完成投诉首轮承接与时限承诺"
/>
<text class="field-label section-label">任务要求</text>
<textarea
v-model="assignmentRequirements"
class="textarea requirements-input"
maxlength="500"
placeholder="填写练习次数、重点动作或合格标准"
/>
<text class="field-label section-label">截止时间</text>
<picker mode="date" :value="assignmentDueTime" @change="selectDueDate">
<view class="input due-date-field" :class="{ placeholder: !assignmentDueTime }">
{{ assignmentDueTime || '选择截止日期(可选)' }}
</view>
</picker>
<text class="field-label section-label">指派原因</text>
<textarea
v-model="assignmentReason"
@@ -66,12 +127,12 @@
maxlength="180"
placeholder="填写本次专项训练原因"
/>
<button class="primary-button submit-button" :disabled="assigning || historyLoading || !scenarioOptions.length" @click="assignPractice">
<button class="primary-button submit-button tabbar-safe-action" :disabled="assigning || historyLoading || !scenarioOptions.length" @click="assignPractice">
{{ assigning ? '指派中' : '确认指派' }}
</button>
</view>
<view class="panel history-panel">
<view v-if="assignView === 'progress'" class="panel history-panel">
<view class="section-head">
<text class="panel-title">最近专项</text>
<button
@@ -87,14 +148,23 @@
<view class="assignment-copy">
<text class="assignment-title">{{ item.traineeName }} · {{ item.scenarioName }}</text>
<text class="assignment-desc">{{ assignmentSummary(item) }}</text>
<text v-if="item.taskGoal" class="assignment-goal">目标:{{ item.taskGoal }}</text>
<view class="assignment-progress-track" aria-hidden="true">
<view class="assignment-progress-value" :style="{ width: `${assignmentProgress(item)}%` }" />
</view>
</view>
<view class="assignment-status-copy">
<text class="assignment-source">{{ assignmentSourceLabel(item.source) }}</text>
<text v-if="item.dueTime" class="assignment-due" :class="{ overdue: isAssignmentOverdue(item) }">
{{ isAssignmentOverdue(item) ? '已逾期' : `截止 ${item.dueTime.slice(0, 10)}` }}
</text>
</view>
<text class="assignment-source">{{ assignmentSourceLabel(item.source) }}</text>
</view>
</view>
<view v-else-if="!historyLoading" class="empty compact-empty">暂无专项指派记录</view>
</view>
<view class="panel progress-panel">
<view v-if="assignView === 'progress'" class="panel progress-panel">
<view class="section-head">
<text class="panel-title">派发进度与预警</text>
<text class="muted">{{ progressSummary }}</text>
@@ -120,8 +190,8 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue';
import { onLoad, onShow } from '@dcloudio/uni-app';
import type { PracticeAlert, PracticeAssignment } from '@/types/api';
import { clearAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import type { PracticeAlert, PracticeAssignment, PracticeTeamMember } from '@/types/api';
import { clearAuth, isLoggedIn, maskPhone, rememberLoginRedirect } from '@/services/auth';
import { resetPageScroll } from '@/services/navigation';
import { createPracticeAssignment, getPracticeAlerts, getPracticeScenarios, getPracticeTeamSnapshot } from '@/services/practice';
import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
@@ -136,12 +206,18 @@ import {
} from '@/services/supervisor-view';
const basePath = '/pages/supervisor/assign/index';
const assignView = ref<'create' | 'progress'>('create');
const targetExtPartyId = ref('');
const targetName = ref('');
const assignmentScenarioId = ref<SupervisorScenarioId>('fee-parking');
const scenarioSearch = ref('');
const scenarioOptions = ref<SupervisorScenarioOption[]>([...supervisorScenarioOptions]);
const assignmentGoal = ref('');
const assignmentRequirements = ref('');
const assignmentDueTime = ref('');
const assignmentReason = ref('');
const assignments = ref<PracticeAssignment[]>([]);
const members = ref<PracticeTeamMember[]>([]);
const alerts = ref<PracticeAlert[]>([]);
const loggedIn = ref(false);
const accessDenied = ref(false);
@@ -157,6 +233,25 @@ const selectedTargetLabel = computed(() => {
return targetName.value.trim() ? `${targetName.value.trim()} · ${target}` : target;
});
const recentAssignments = computed(() => manualPracticeAssignments(assignments.value).slice(0, 10));
const memberSuggestions = computed(() => {
const keyword = targetExtPartyId.value.trim().toLowerCase();
if (!keyword) return [];
return members.value
.filter((member) => {
const name = member.name?.trim().toLowerCase() || '';
return member.extPartyId.toLowerCase().includes(keyword) || name.includes(keyword);
})
.slice(0, 6);
});
const filteredScenarioOptions = computed(() => {
const keyword = scenarioSearch.value.trim().toLowerCase();
const options = keyword
? scenarioOptions.value.filter((item) => item.name.toLowerCase().includes(keyword))
: scenarioOptions.value;
const selected = scenarioOptions.value.find((item) => item.id === assignmentScenarioId.value);
const visible = selected && !options.some((item) => item.id === selected.id) ? [selected, ...options] : options;
return visible.slice(0, 6);
});
const progressCounts = computed(() => {
const items = recentAssignments.value;
return {
@@ -187,12 +282,18 @@ const resetDraft = () => {
targetExtPartyId.value = '';
targetName.value = '';
assignmentScenarioId.value = 'fee-parking';
scenarioSearch.value = '';
assignmentGoal.value = '';
assignmentRequirements.value = '';
assignmentDueTime.value = '';
assignmentReason.value = '';
};
const clearPrivateState = () => {
resetDraft();
assignView.value = 'create';
assignments.value = [];
members.value = [];
alerts.value = [];
historyLoading.value = false;
historyRequests.invalidate();
@@ -230,6 +331,7 @@ const loadAssignments = async (force = false) => {
if (historyRequests.isCurrent(generation)) {
accessDenied.value = false;
assignments.value = snapshot.assignments;
members.value = snapshot.members;
alerts.value = nextAlerts;
}
} catch (error) {
@@ -243,6 +345,15 @@ const loadAssignments = async (force = false) => {
}
};
const selectTarget = (member: PracticeTeamMember) => {
targetExtPartyId.value = member.extPartyId;
targetName.value = member.name;
};
const selectDueDate = (event: { detail?: { value?: string } }) => {
assignmentDueTime.value = event.detail?.value || '';
};
const loadScenarioOptions = async () => {
try {
const rows = await getPracticeScenarios('生活顾问');
@@ -293,11 +404,16 @@ const assignPractice = async () => {
traineeName: targetName.value.trim() || `手机用户${extPartyId.slice(-4)}`,
scenarioId: assignmentScenarioId.value,
scenarioName: scenario?.name,
reason: assignmentReason.value.trim() || '主管派发专项训练'
reason: assignmentReason.value.trim() || '主管派发专项训练',
taskGoal: assignmentGoal.value.trim() || undefined,
taskRequirements: assignmentRequirements.value.trim() || undefined,
dueTime: assignmentDueTime.value || undefined
});
resetDraft();
uni.showToast({ title: '已指派', icon: 'none' });
await loadAssignments(true);
assignView.value = 'progress';
resetPageScroll();
} catch (error) {
handleRequestError(error, '指派失败');
} finally {
@@ -316,6 +432,14 @@ const assignmentSummary = (item: PracticeAssignment) => {
return `${item.createTime} · ${item.status} · ${reason}`;
};
const assignmentProgress = (item: PracticeAssignment) => item.status === '已完成' ? 100 : 0;
const isAssignmentOverdue = (item: PracticeAssignment) => {
if (!item.dueTime || item.status === '已完成') return false;
const deadline = Date.parse(item.dueTime.replace(' ', 'T'));
return Number.isFinite(deadline) && deadline < Date.now();
};
const goLogin = () => {
rememberLoginRedirect(basePath);
uni.redirectTo({ url: '/pages/auth/login/index' });
@@ -379,6 +503,38 @@ onBeforeUnmount(() => {
min-width: 104px;
}
.assign-tabs {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 4px;
margin-bottom: 14px;
padding: 4px;
border: 1px solid #dbe4ee;
border-radius: 14px;
background: #eef2f6;
}
.assign-tab {
min-height: 42px;
border: 0;
border-radius: 10px;
background: transparent;
color: #64748b;
font-size: 14px;
font-weight: 800;
line-height: 42px;
}
.assign-tab::after {
border: 0;
}
.assign-tab.active {
background: #fff;
color: var(--employee-primary);
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.08);
}
.refresh-button {
min-width: 82px;
padding: 0 10px;
@@ -405,6 +561,7 @@ onBeforeUnmount(() => {
.field-input,
.reason-input,
.requirements-input,
.scenario-row,
.submit-button {
margin-top: 9px;
@@ -414,12 +571,61 @@ onBeforeUnmount(() => {
margin-top: 10px;
}
.target-suggestions {
display: flex;
flex-direction: column;
gap: 1px;
overflow: hidden;
margin-top: 6px;
border: 1px solid #dbe4ee;
border-radius: 12px;
background: #fff;
}
.target-suggestion {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 44px;
padding: 0 12px;
border: 0;
border-radius: 0;
background: #fff;
color: #0f172a;
font-size: 13px;
line-height: 44px;
text-align: left;
}
.target-suggestion + .target-suggestion {
border-top: 1px solid #eef2f6;
}
.target-suggestion::after {
border: 0;
}
.target-suggestion text:last-child {
color: #64748b;
font-size: 12px;
}
.scenario-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.due-date-field {
display: flex;
align-items: center;
margin-top: 9px;
}
.due-date-field.placeholder {
color: #94a3b8;
}
.scenario-chip {
border-radius: 8px;
}
@@ -460,6 +666,36 @@ onBeforeUnmount(() => {
display: block;
}
.assignment-goal {
display: block;
margin-top: 5px;
color: #475569;
font-size: 12px;
line-height: 1.4;
}
.assignment-progress-track {
overflow: hidden;
height: 5px;
margin-top: 9px;
border-radius: 999px;
background: #e2e8f0;
}
.assignment-progress-value {
height: 100%;
border-radius: inherit;
background: var(--employee-primary);
}
.assignment-status-copy {
display: flex;
flex: 0 0 auto;
flex-direction: column;
align-items: flex-end;
gap: 7px;
}
.assignment-title {
color: #0f172a;
font-size: 14px;
@@ -483,6 +719,16 @@ onBeforeUnmount(() => {
font-weight: 800;
}
.assignment-due {
color: #64748b;
font-size: 11px;
font-weight: 700;
}
.assignment-due.overdue {
color: #b91c1c;
}
.compact-empty {
padding: 18px 8px 4px;
}
+110 -3
View File
@@ -44,7 +44,6 @@
>
<text class="metric-value">{{ item.value }}</text>
<text class="metric-label">{{ item.title }}</text>
<text class="metric-desc">{{ item.desc }}</text>
</view>
</view>
</view>
@@ -54,9 +53,9 @@
<text class="panel-title">连续低分预警</text>
<text class="muted">{{ actionableAlerts.length }} 条</text>
</view>
<view v-if="alerts.length" class="alert-list">
<view v-if="visibleAlerts.length" class="alert-list">
<button
v-for="item in alerts"
v-for="item in visibleAlerts"
:key="`${item.type}-${item.title}`"
class="alert-row"
:class="item.severity"
@@ -93,6 +92,23 @@
</view>
</view>
<view class="panel training-trend-panel">
<view class="section-head">
<text class="panel-title">近期训练趋势</text>
<text class="muted">最近 {{ trainingTrend.length }} 次</text>
</view>
<view v-if="trainingTrend.length" class="training-trend-list">
<view v-for="item in trainingTrend" :key="item.key" class="training-trend-row">
<text class="training-trend-label">{{ item.label }}</text>
<view class="training-trend-track">
<view class="training-trend-value" :style="{ width: `${item.value}%` }" />
</view>
<text class="training-trend-score">{{ item.value }}</text>
</view>
</view>
<view v-else class="empty compact-empty">完成训练后展示趋势</view>
</view>
<view class="panel task-panel">
<view class="section-head">
<text class="panel-title">快捷入口</text>
@@ -144,6 +160,7 @@ import {
actionablePracticeAlerts,
buildSupervisorMetrics,
buildTeamProfiles,
buildTrainingTrend,
isSupervisorAccessDenied
} from '@/services/supervisor-view';
@@ -167,6 +184,10 @@ const pendingAssignmentCount = computed(() =>
supervisorMetrics.value.find((item) => item.key === 'pendingAssignments')?.value || 0);
const pendingPreview = computed(() => pendingReviews.value.slice(0, 3));
const actionableAlerts = computed(() => actionablePracticeAlerts(alerts.value));
const visibleAlerts = computed(() => [...actionableAlerts.value]
.sort((a, b) => (a.severity === 'high' ? 0 : 1) - (b.severity === 'high' ? 0 : 1))
.slice(0, 1));
const trainingTrend = computed(() => buildTrainingTrend(records.value));
const clearData = () => {
pendingReviews.value = [];
@@ -601,4 +622,90 @@ onShow(() => {
.text-button {
color: #e60012;
}
.metric-item {
min-height: 76px;
padding: 13px 16px;
}
.metric-label {
margin-top: 6px;
}
.training-trend-panel {
padding: 18px;
}
.training-trend-list {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 14px;
}
.training-trend-row {
display: grid;
grid-template-columns: 42px 1fr 30px;
gap: 9px;
align-items: center;
}
.training-trend-label,
.training-trend-score {
color: #8a8d93;
font-size: 12px;
}
.training-trend-score {
color: #25282d;
font-weight: 800;
text-align: right;
}
.training-trend-track {
overflow: hidden;
height: 7px;
border-radius: 999px;
background: #e8eaed;
}
.training-trend-value {
height: 100%;
border-radius: inherit;
background: #e60012;
}
.task-list {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.task-row {
min-height: 82px;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 3px;
padding: 10px;
border: 1px solid #f0f1f3;
border-radius: 14px;
}
.task-row:last-child {
border-bottom: 1px solid #f0f1f3;
}
.task-title {
font-size: 13px;
}
.task-meta {
margin-top: 3px;
font-size: 11px;
}
.task-arrow {
display: none;
}
</style>
@@ -9,7 +9,7 @@
<view class="panel account-card">
<text class="panel-title">当前账号</text>
<text class="account-phone">{{ phone || '未登录' }}</text>
<text class="account-phone">{{ displayPhone || '未登录' }}</text>
<text class="muted account-note">主管端团队数据仅对当前项目范围可见。</text>
</view>
@@ -31,13 +31,14 @@
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { computed, ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
import { clearAuth, getAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { clearAuth, getAuth, isLoggedIn, maskPhone, rememberLoginRedirect } from '@/services/auth';
import { resetPageScroll } from '@/services/navigation';
const phone = ref('');
const displayPhone = computed(() => maskPhone(phone.value));
const refreshAuth = () => {
phone.value = getAuth().phone;
@@ -32,6 +32,7 @@
<template v-else-if="detail">
<view class="panel summary-panel">
<text class="section-kicker">训练摘要</text>
<view class="summary-head">
<view class="summary-copy">
<text class="detail-title">{{ detail.trainee }} · {{ detail.scene }}</text>
@@ -56,7 +57,7 @@
</view>
<view v-if="detail.dialogue?.length" class="panel">
<text class="panel-title">对话记录</text>
<text class="panel-title">录音与逐句复盘</text>
<view class="dialogue-list">
<view v-for="(turn, index) in detail.dialogue" :key="`${turn.role}-${index}`" class="dialogue-row">
<text class="dialogue-label">{{ turn.label }}</text>
@@ -78,7 +79,7 @@
</view>
<view v-if="detail.annotations?.length || detail.mentorRewrite" class="panel">
<text class="panel-title">标注与建议</text>
<text class="panel-title">SOP 依据与改进建议</text>
<view v-if="detail.annotations?.length" class="annotation-list">
<view v-for="item in detail.annotations" :key="`${item.turnIndex}-${item.issue}`" class="annotation-row">
<text class="annotation-issue">{{ item.issue }}</text>
@@ -93,7 +94,7 @@
</view>
<view v-if="editable" class="panel review-form">
<text class="panel-title">主管复盘</text>
<text class="panel-title">下一步行动</text>
<text class="field-label">复盘建议</text>
<textarea
v-model="advice"
@@ -136,7 +137,7 @@
/>
</view>
<button class="primary-button submit-button" :disabled="marking" @click="markReviewed">
<button class="primary-button submit-button tabbar-safe-action" :disabled="marking" @click="markReviewed">
{{ marking ? '提交中' : '标记已复盘' }}
</button>
</view>
@@ -549,6 +550,29 @@ onBeforeUnmount(() => {
margin-top: 10px;
}
.score-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.score-list .score-row {
min-height: 96px;
align-items: flex-start;
justify-content: center;
flex-direction: column;
gap: 4px;
padding: 12px;
border: 0;
border-radius: 12px;
background: #f7f8fa;
}
.score-list .score-note {
margin-top: 0;
font-size: 11px;
}
.score-row,
.dialogue-row,
.annotation-row {
@@ -659,4 +683,13 @@ onBeforeUnmount(() => {
.empty-panel {
padding-bottom: 18px;
}
.section-kicker {
display: block;
margin-bottom: 10px;
color: #e60012;
font-size: 12px;
font-weight: 900;
letter-spacing: 0.08em;
}
</style>
@@ -35,9 +35,20 @@
<text class="panel-title">复盘队列</text>
<text class="muted">低分优先</text>
</view>
<view v-if="reviews.length" class="review-list">
<view class="review-filters">
<button
v-for="item in reviews"
v-for="item in reviewFilters"
:key="item.value"
class="chip review-filter"
:class="{ active: reviewFilter === item.value }"
@click="reviewFilter = item.value"
>
{{ item.label }}
</button>
</view>
<view v-if="visibleReviews.length" class="review-list">
<button
v-for="item in visibleReviews"
:key="item.id || item.sessionId || item.time"
class="review-row"
@click="openReview(item)"
@@ -53,7 +64,7 @@
</view>
</button>
</view>
<view v-else-if="!loading" class="empty">暂无待复盘记录</view>
<view v-else-if="!loading" class="empty">当前筛选暂无复盘记录</view>
</view>
<SupervisorTabBar v-if="loggedIn && !accessDenied" active="review" />
</view>
@@ -67,7 +78,12 @@ import { clearAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { resetPageScroll } from '@/services/navigation';
import { getPracticeAlerts, getPracticeReviews } from '@/services/practice';
import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
import { buildSupervisorMetrics, isSupervisorAccessDenied } from '@/services/supervisor-view';
import {
buildSupervisorMetrics,
filterReviewRecords,
isSupervisorAccessDenied,
type ReviewFilter
} from '@/services/supervisor-view';
const currentPath = '/pages/supervisor/reviews/index';
const reviews = ref<PracticeRecord[]>([]);
@@ -76,6 +92,13 @@ const loggedIn = ref(false);
const accessDenied = ref(false);
const loading = ref(false);
const message = ref('');
const reviewFilter = ref<ReviewFilter>('all');
const reviewFilters: Array<{ value: ReviewFilter; label: string }> = [
{ value: 'all', label: '全部' },
{ value: 'low', label: '连续低分' },
{ value: 'pending', label: '待复盘' }
];
const visibleReviews = computed(() => filterReviewRecords(reviews.value, reviewFilter.value));
const pendingReviewTotal = computed(() =>
buildSupervisorMetrics(reviews.value, [], alerts.value)
@@ -215,6 +238,23 @@ onShow(() => {
margin-top: 8px;
}
.review-filters {
display: flex;
gap: 8px;
margin-top: 12px;
}
.review-filter {
border-radius: 999px;
background: #ffffff;
}
.review-filter.active {
border-color: var(--employee-primary);
background: var(--employee-primary-soft);
color: var(--employee-primary);
}
.review-row {
display: flex;
width: 100%;
+111 -41
View File
@@ -31,6 +31,47 @@
</view>
<template v-else>
<view v-if="selectedMember" class="panel member-detail-panel">
<view class="section-head">
<view>
<text class="panel-title">{{ selectedMember.displayName }} · 能力详情</text>
<text class="muted member-detail-subtitle">训练画像与成长证据</text>
</view>
<button class="text-button" @click="closeMember">返回列表</button>
</view>
<view class="member-summary">
<view>
<text class="member-score">{{ selectedMember.latestScoreText }}</text>
<text class="member-summary-label">最近训练评分</text>
</view>
<view>
<text class="member-score">{{ memberRecords.length }}</text>
<text class="member-summary-label">训练次数</text>
</view>
<view>
<text class="member-score">{{ selectedMember.pendingReview }}</text>
<text class="member-summary-label">待复盘</text>
</view>
</view>
<text class="subsection-title">训练趋势</text>
<view v-if="memberRecords.length" class="trend-list">
<view v-for="item in memberRecords" :key="item.id || item.time" class="trend-row">
<text class="trend-scene">{{ item.scene }}</text>
<view class="trend-track"><view class="trend-fill" :style="{ width: `${Math.max(0, Math.min(100, item.score))}%` }" /></view>
<text class="trend-score">{{ item.score }}</text>
</view>
</view>
<view v-else class="detail-empty">暂无训练评分,先派发一次专项训练</view>
<text class="subsection-title">成长证据</text>
<view class="evidence-list">
<text>已完成 {{ memberRecords.filter((item) => item.status === '已完成').length }} 次训练</text>
<text>当前 {{ selectedMember.pendingAssignment }} 项专项待训练</text>
<text>{{ selectedMember.reason }}</text>
</view>
<button class="primary-button member-assign-button" @click="assignMember(selectedMember)">派发专项</button>
</view>
<template v-else>
<view class="risk-summary">
<view class="risk-item high">
<text class="risk-value">{{ riskCounts.high }}</text>
@@ -69,12 +110,22 @@
<view class="panel team-panel">
<view class="section-head">
<text class="panel-title">成员列表</text>
<text class="muted">按风险排序</text>
<view>
<text class="panel-title">成员列表</text>
<text class="muted team-result-count">{{ filteredTeamProfiles.length }} 人 · 按风险排序</text>
</view>
<text class="muted">第 {{ teamPagination.page }}/{{ teamPagination.pageCount }} 页</text>
</view>
<view v-if="teamProfiles.length" class="team-list">
<input
v-model="teamQuery"
class="input team-search"
maxlength="40"
placeholder="搜索姓名、手机号或外部ID"
@input="teamPage = 1"
/>
<view v-if="visibleTeamProfiles.length" class="team-list">
<view
v-for="member in teamProfiles"
v-for="member in visibleTeamProfiles"
:key="member.key"
class="team-row"
:class="member.risk"
@@ -95,44 +146,13 @@
<button class="secondary-button team-action" @click.stop="assignMember(member)">派专项</button>
</view>
</view>
<view v-else-if="!loading" class="empty">暂无团队画像数据</view>
</view>
<view v-if="selectedMember" class="panel member-detail-panel">
<view class="section-head">
<text class="panel-title">{{ selectedMember.displayName }} · 能力详情</text>
<button class="text-button" @click="selectedMember = null">收起</button>
</view>
<view class="member-summary">
<view>
<text class="member-score">{{ selectedMember.latestScoreText }}</text>
<text class="member-summary-label">最近训练评分</text>
</view>
<view>
<text class="member-score">{{ memberRecords.length }}</text>
<text class="member-summary-label">训练次数</text>
</view>
<view>
<text class="member-score">{{ selectedMember.pendingReview }}</text>
<text class="member-summary-label">待复盘</text>
</view>
</view>
<text class="subsection-title">训练趋势</text>
<view v-if="memberRecords.length" class="trend-list">
<view v-for="item in memberRecords" :key="item.id || item.time" class="trend-row">
<text class="trend-scene">{{ item.scene }}</text>
<view class="trend-track"><view class="trend-fill" :style="{ width: `${Math.max(0, Math.min(100, item.score))}%` }" /></view>
<text class="trend-score">{{ item.score }}</text>
</view>
</view>
<view v-else class="detail-empty">暂无训练评分,先派发一次专项训练</view>
<text class="subsection-title">成长证据</text>
<view class="evidence-list">
<text>已完成 {{ memberRecords.filter((item) => item.status === '已完成').length }} 次训练</text>
<text>当前 {{ selectedMember.pendingAssignment }} 项专项待训练</text>
<text>{{ selectedMember.reason }}</text>
<view v-else-if="!loading" class="empty">没有匹配的团队成员</view>
<view v-if="teamPagination.pageCount > 1" class="team-pagination">
<button class="secondary-button" :disabled="teamPagination.page <= 1" @click="changeTeamPage(-1)">上一页</button>
<button class="secondary-button" :disabled="teamPagination.page >= teamPagination.pageCount" @click="changeTeamPage(1)">下一页</button>
</view>
</view>
</template>
</template>
<SupervisorTabBar v-if="loggedIn && !accessDenied" active="team" />
</view>
@@ -148,8 +168,10 @@ import { getPracticeTeamSnapshot } from '@/services/practice';
import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
import {
buildTeamProfiles,
filterTeamProfiles,
inferSupervisorScenarioId,
isSupervisorAccessDenied,
paginateTeamProfiles,
type TeamProfile
} from '@/services/supervisor-view';
@@ -163,8 +185,14 @@ const accessDenied = ref(false);
const loading = ref(false);
const message = ref('');
const selectedMember = ref<TeamProfile | null>(null);
const teamQuery = ref('');
const teamPage = ref(1);
const teamPageSize = 20;
const teamProfiles = computed(() => buildTeamProfiles(records.value, assignments.value, members.value));
const filteredTeamProfiles = computed(() => filterTeamProfiles(teamProfiles.value, teamQuery.value));
const teamPagination = computed(() => paginateTeamProfiles(filteredTeamProfiles.value, teamPage.value, teamPageSize));
const visibleTeamProfiles = computed(() => teamPagination.value.items);
const riskCounts = computed(() => teamProfiles.value.reduce(
(counts, item) => {
counts[item.risk] += 1;
@@ -185,6 +213,7 @@ const clearData = () => {
members.value = [];
mistakes.value = [];
selectedMember.value = null;
teamPage.value = 1;
};
const refreshAuthState = () => {
@@ -244,7 +273,21 @@ const assignMember = (member: TeamProfile) => {
};
const selectMember = (member: TeamProfile) => {
selectedMember.value = selectedMember.value?.key === member.key ? null : member;
selectedMember.value = member;
resetPageScroll();
};
const closeMember = () => {
selectedMember.value = null;
resetPageScroll();
};
const changeTeamPage = (delta: number) => {
teamPage.value = Math.min(
teamPagination.value.pageCount,
Math.max(1, teamPagination.value.page + delta)
);
resetPageScroll();
};
const openReviews = () => {
@@ -443,6 +486,17 @@ onShow(() => {
margin-top: 8px;
}
.team-result-count,
.member-detail-subtitle {
display: block;
margin-top: 4px;
font-size: 11px;
}
.team-search {
margin-top: 10px;
}
.team-row {
display: flex;
align-items: center;
@@ -521,6 +575,22 @@ onShow(() => {
font-size: 12px;
}
.team-pagination {
display: grid;
margin-top: 14px;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.team-pagination button,
.member-assign-button {
width: 100%;
}
.member-assign-button {
margin-top: 18px;
}
.member-detail-panel {
margin-top: 14px;
}
+41 -1
View File
@@ -101,7 +101,11 @@
</view>
</view>
</view>
<view v-else class="empty">暂无入库案例</view>
<view v-else class="case-empty-state">
<view class="case-empty-icon">案</view>
<text class="case-empty-title">暂时还没有可学习案例</text>
<text class="case-empty-desc">案例经主管整理并入库后会出现在这里;提交权限由组织角色决定。</text>
</view>
</view>
<view v-if="caseDetail" class="panel case-detail-panel">
@@ -535,4 +539,40 @@ onUnload(clearCaseMediaUrl);
font-weight: 900;
white-space: nowrap;
}
.case-empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 28px 16px 20px;
text-align: center;
}
.case-empty-icon {
display: flex;
width: 52px;
height: 52px;
align-items: center;
justify-content: center;
border-radius: 18px;
background: #fff0f1;
color: #e60012;
font-size: 20px;
font-weight: 900;
}
.case-empty-title {
margin-top: 14px;
color: #1f2937;
font-size: 15px;
font-weight: 900;
}
.case-empty-desc {
max-width: 280px;
margin-top: 7px;
color: #6b7280;
font-size: 13px;
line-height: 1.55;
}
</style>
+31 -8
View File
@@ -21,6 +21,7 @@
<template v-else-if="detail">
<view class="panel summary-panel">
<text class="section-kicker">训练摘要</text>
<view class="summary-head">
<view>
<text class="detail-title">{{ detail.scene }}</text>
@@ -33,17 +34,17 @@
<view v-if="detail.scoreItems?.length" class="panel">
<text class="panel-title">评分明细</text>
<view v-for="item in detail.scoreItems" :key="item.label" class="score-row">
<view>
<view class="score-summary-grid">
<view v-for="item in detail.scoreItems" :key="item.label" class="score-row">
<text class="score-label">{{ item.label }}</text>
<text class="score-value">{{ item.value }}</text>
<text class="score-note">{{ item.note }}</text>
</view>
<text class="score-value">{{ item.value }}</text>
</view>
</view>
<view v-if="detail.dialogue?.length" class="panel">
<text class="panel-title">对话记录</text>
<text class="panel-title">录音与逐句复盘</text>
<view v-for="(turn, index) in detail.dialogue" :key="`${turn.role}-${index}`" class="dialogue-row">
<text class="dialogue-label">{{ turn.label }}</text>
<text class="dialogue-text">{{ turn.text }}</text>
@@ -58,7 +59,7 @@
</view>
<view v-if="detail.annotations?.length || detail.mentorRewrite" class="panel">
<text class="panel-title">复盘建议</text>
<text class="panel-title">SOP 依据与改进建议</text>
<view v-for="item in detail.annotations" :key="`${item.turnIndex}-${item.issue}`" class="annotation-row">
<text class="annotation-issue">{{ item.issue }}</text>
<text class="annotation-rewrite">建议:{{ item.rewrite }}</text>
@@ -258,11 +259,24 @@ onUnload(releaseAudio);
border-top: 1px solid #ececee;
}
.score-summary-grid {
display: grid;
margin-top: 10px;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.score-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 96px;
align-items: flex-start;
justify-content: center;
flex-direction: column;
gap: 4px;
padding: 12px;
border: 0;
border-radius: 12px;
background: #f7f8fa;
}
.score-note,
@@ -342,4 +356,13 @@ onUnload(releaseAudio);
.empty-panel button {
margin-top: 14px;
}
.section-kicker {
display: block;
margin-bottom: 10px;
color: #e60012;
font-size: 12px;
font-weight: 900;
letter-spacing: 0.08em;
}
</style>
+37 -1
View File
@@ -190,7 +190,7 @@
{{ helpLoading ? '求助中' : '求助' }}
</button>
<button class="secondary-button" :disabled="busy || !canFinishPractice" @click="finish()">结束评分</button>
<button class="primary-button" :disabled="busy" @click="submit">
<button class="primary-button tabbar-safe-action" :disabled="busy" @click="submit">
{{ status === 'submitting' ? '提交中' : '提交回应' }}
</button>
</view>
@@ -209,6 +209,12 @@
<text class="result-label">本次评分</text>
<text class="result-score">{{ result.totalScore }}</text>
<text class="result-comment">{{ result.aiComment }}</text>
<view v-if="result.score?.length" class="result-dimensions">
<view v-for="item in result.score" :key="item.label" class="result-dimension">
<text>{{ item.value }}</text>
<text>{{ item.label }}</text>
</view>
</view>
<view v-if="result.mentorRewrite" class="mentor-rewrite">
<text>老师傅建议</text>
<text>{{ result.mentorRewrite }}</text>
@@ -911,6 +917,7 @@ const finish = async (force = false) => {
message.value = '';
try {
result.value = await finishPractice(sessionId.value, activeAssignmentId.value);
resetPageScroll();
status.value = 'finished';
message.value = '训练完成,主管端待复盘已更新';
activeAssignmentId.value = undefined;
@@ -1464,6 +1471,35 @@ onShow(() => {
line-height: 1.55;
}
.result-dimensions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.result-dimension {
display: flex;
min-height: 66px;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 5px;
border-radius: 14px;
background: #f7f8fa;
text-align: center;
}
.result-dimension text:first-child {
color: var(--employee-primary);
font-size: 22px;
font-weight: 900;
}
.result-dimension text:last-child {
color: var(--employee-muted);
font-size: 12px;
}
.mentor-rewrite {
display: flex;
flex-direction: column;
+5 -3
View File
@@ -3,7 +3,7 @@
<view class="profile-head">
<text class="profile-eyebrow">银城物业 · 员工成长</text>
<text class="page-title">我的成长</text>
<text class="page-subtitle">{{ phone || '登录后查看训练记录和能力画像' }}</text>
<text class="page-subtitle">{{ displayPhone || '登录后查看训练记录和能力画像' }}</text>
</view>
<view v-if="message" class="status-message">{{ message }}</view>
@@ -11,7 +11,7 @@
<view class="panel account-card">
<view>
<text class="panel-title">账号</text>
<text class="account-phone">{{ phone || '未登录' }}</text>
<text class="account-phone">{{ displayPhone || '未登录' }}</text>
</view>
<button v-if="!loggedIn" class="primary-button account-action" @click="goLogin">去登录</button>
<button v-else class="secondary-button account-action" @click="logout">退出</button>
@@ -153,7 +153,7 @@
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { computed, ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import type { CompetencyProfile, PracticeRecord, PromotionEvidence } from '@/types/api';
import {
@@ -162,6 +162,7 @@ import {
getPositionProfile,
getSelectedPositionProfile,
isLoggedIn,
maskPhone,
rememberLoginRedirect,
saveSelectedPosition,
userPositionOptions,
@@ -173,6 +174,7 @@ import { ensureEmployeePosition } from '@/services/position';
const loggedIn = ref(false);
const phone = ref('');
const displayPhone = computed(() => maskPhone(phone.value));
const loading = ref(false);
const message = ref('');
const profile = ref<CompetencyProfile | null>(null);
+130 -5
View File
@@ -84,20 +84,51 @@
<button
class="secondary-button"
:class="{ active: feedbackVerdict === 'up' }"
:disabled="feedbackSubmitting || Boolean(feedbackVerdict)"
:disabled="feedbackSubmitting || Boolean(feedbackVerdict) || showNegativeFeedback"
@click="feedback('up')"
>
有帮助
</button>
<button
class="secondary-button"
:class="{ active: feedbackVerdict === 'down', negative: feedbackVerdict === 'down' }"
:class="{ active: feedbackVerdict === 'down' || showNegativeFeedback, negative: feedbackVerdict === 'down' || showNegativeFeedback }"
:disabled="feedbackSubmitting || Boolean(feedbackVerdict)"
@click="feedback('down')"
@click="openNegativeFeedback"
>
没解决
</button>
</view>
<view v-if="showNegativeFeedback && !feedbackVerdict" class="negative-feedback-panel">
<text class="negative-feedback-title">哪里没有解决?(可多选)</text>
<view class="negative-reason-list">
<button
v-for="item in feedbackReasonOptions"
:key="item.code"
class="negative-reason"
:class="{ active: selectedFeedbackReasons.includes(item.code) }"
:disabled="feedbackSubmitting"
@click="toggleFeedbackReason(item.code)"
>
{{ item.label }}
</button>
</view>
<textarea
v-model="feedbackComment"
class="textarea negative-feedback-comment"
maxlength="500"
placeholder="补充说明(可选)"
/>
<view class="negative-feedback-actions">
<button class="secondary-button" :disabled="feedbackSubmitting" @click="closeNegativeFeedback">取消</button>
<button
class="primary-button"
:disabled="feedbackSubmitting || !selectedFeedbackReasons.length"
@click="submitNegativeFeedback"
>
{{ feedbackSubmitting ? '提交中' : '提交反馈' }}
</button>
</view>
</view>
<text v-if="feedbackVerdict" class="feedback-status">
{{ feedbackVerdict === 'up' ? '已记录为有帮助' : '已提交未解决反馈,后续会继续优化这条答案' }}
</text>
@@ -220,15 +251,28 @@ const loading = ref(false);
const summaryLoading = ref(false);
const feedbackSubmitting = ref(false);
const feedbackVerdict = ref<AnswerFeedbackVerdict | ''>('');
const showNegativeFeedback = ref(false);
const selectedFeedbackReasons = ref<string[]>([]);
const feedbackComment = ref('');
const message = ref('');
const loggedIn = ref(false);
const pendingSopQueryKey = 'aihr_sop_pending_query';
const feedbackReasonOptions = [
{ code: 'no_answer', label: '没有回答问题' },
{ code: 'not_specific', label: '步骤不够具体' },
{ code: 'wrong_reference', label: '引用依据不准确' },
{ code: 'outdated', label: '内容已经过期' },
{ code: 'not_applicable', label: '话术不适合现场' }
];
const resetResultState = () => {
result.value = null;
summaryCard.value = null;
feedbackVerdict.value = '';
feedbackSubmitting.value = false;
showNegativeFeedback.value = false;
selectedFeedbackReasons.value = [];
feedbackComment.value = '';
};
const refreshAuthState = () => {
@@ -271,6 +315,7 @@ const search = async () => {
resetResultState();
try {
result.value = await searchKnowledge(value);
resetPageScroll();
if (!result.value.answer && !result.value.snippets?.length) {
message.value = '未检索到可引用内容';
}
@@ -297,7 +342,10 @@ const cleanOrderedText = (text = '') =>
.replace(/^(?:[一二三四五六七八九十]+[、..]|[0-9]+[.)、..])\s*/, '')
.trim();
const feedback = async (verdict: AnswerFeedbackVerdict) => {
const feedback = async (
verdict: AnswerFeedbackVerdict,
details: { reasonCodes?: string[]; comment?: string } = {}
) => {
if (!result.value || feedbackSubmitting.value || feedbackVerdict.value) return;
const fragmentIds = Array.from(
new Set(
@@ -317,9 +365,12 @@ const feedback = async (verdict: AnswerFeedbackVerdict) => {
queryText: result.value.queryText || query.value,
fragmentIds,
verdict,
reviewId: result.value.reviewId
reviewId: result.value.reviewId,
reasonCodes: details.reasonCodes,
comment: details.comment
});
feedbackVerdict.value = verdict;
showNegativeFeedback.value = false;
uni.showToast({ title: verdict === 'down' ? '已进入复核' : '已记录', icon: 'none' });
} catch (error) {
if (!handleRequestError(error, '反馈失败', true)) {
@@ -333,6 +384,32 @@ const feedback = async (verdict: AnswerFeedbackVerdict) => {
}
};
const openNegativeFeedback = () => {
if (feedbackSubmitting.value || feedbackVerdict.value) return;
showNegativeFeedback.value = true;
selectedFeedbackReasons.value = [];
feedbackComment.value = '';
};
const closeNegativeFeedback = () => {
if (feedbackSubmitting.value) return;
showNegativeFeedback.value = false;
};
const toggleFeedbackReason = (code: string) => {
selectedFeedbackReasons.value = selectedFeedbackReasons.value.includes(code)
? selectedFeedbackReasons.value.filter((item) => item !== code)
: [...selectedFeedbackReasons.value, code];
};
const submitNegativeFeedback = () => {
if (!selectedFeedbackReasons.value.length) return;
void feedback('down', {
reasonCodes: selectedFeedbackReasons.value,
comment: feedbackComment.value.trim()
});
};
const buildSummaryCard = async () => {
if (summaryLoading.value) return;
const value = (result.value?.queryText || query.value).trim();
@@ -624,6 +701,54 @@ onShow(() => {
color: #c34d45;
}
.negative-feedback-panel {
display: flex;
flex-direction: column;
gap: 12px;
padding: 14px;
border-radius: 16px;
background: #fff8f8;
}
.negative-feedback-title {
color: var(--employee-text);
font-size: 15px;
font-weight: 900;
}
.negative-reason-list {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.negative-reason {
min-height: 40px;
padding: 0 10px;
border: 1px solid #f1d7d9;
background: #ffffff;
color: #5c626c;
font-size: 13px;
text-align: left;
}
.negative-reason.active {
border-color: var(--employee-primary);
background: var(--employee-primary-soft);
color: var(--employee-primary);
}
.negative-feedback-comment {
min-height: 84px;
border-color: #f1d7d9;
}
.negative-feedback-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.feedback-status {
display: block;
padding: 10px 12px;
+27 -1
View File
@@ -115,7 +115,7 @@
</button>
</view>
<view class="schedule-row" v-if="home?.review">
<view class="schedule-row review-schedule-row" v-if="home?.review">
<view class="schedule-icon-wrap review-icon">
<uni-icons class="schedule-icon" type="calendar-filled" size="26" color="#d98200" />
</view>
@@ -917,9 +917,13 @@ onShow(() => {
}
.schedule-desc {
display: -webkit-box;
overflow: hidden;
color: #8a8d93;
font-size: 14px;
line-height: 1.4;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.schedule-action {
@@ -942,6 +946,28 @@ onShow(() => {
box-shadow: 0 8px 16px rgba(217, 130, 0, 0.16);
}
.review-schedule-row {
min-height: 62px;
padding-top: 8px;
padding-bottom: 8px;
}
.review-schedule-row .schedule-icon-wrap {
width: 42px;
height: 42px;
}
.review-schedule-row .schedule-meta,
.review-schedule-row .schedule-desc {
display: none;
}
.review-schedule-row .schedule-action {
width: 70px;
min-height: 40px;
font-size: 14px;
}
.map-grid {
display: grid;
grid-template-columns: 1fr 1fr;
+3
View File
@@ -134,3 +134,6 @@ export const consumeLoginRedirect = () => {
};
export const validatePhone = (phone: string) => phonePattern.test(phone);
export const maskPhone = (phone: string) =>
/^\d{8,}$/.test(phone) ? `${phone.slice(0, 3)}****${phone.slice(-4)}` : phone;
+5 -1
View File
@@ -49,6 +49,8 @@ export const submitAnswerFeedback = (params: {
fragmentIds: number[];
verdict: AnswerFeedbackVerdict;
reviewId?: number | null;
reasonCodes?: string[];
comment?: string;
}) =>
apiRequest<{ saved: number }>({
url: '/api/knowledge/answer-feedback',
@@ -60,7 +62,9 @@ export const submitAnswerFeedback = (params: {
category: 'sop',
position: getSelectedPosition(),
source: 'mobile_uni_sop',
reviewId: params.reviewId ?? null
reviewId: params.reviewId ?? null,
reasonCodes: params.reasonCodes || [],
comment: params.comment || ''
},
timeout: 15000
});
+57 -2
View File
@@ -15,6 +15,12 @@ export interface SupervisorMetric {
primary?: boolean;
}
export interface TrainingTrendPoint {
key: string;
label: string;
value: number;
}
export interface TeamProfile {
key: string;
extPartyId: string;
@@ -98,8 +104,28 @@ export const manualPracticeAssignments = (assignments: PracticeAssignment[]) =>
export const actionablePracticeAlerts = (alerts: PracticeAlert[]) =>
alerts.filter((item) => item.type !== 'stable');
export const buildTrainingTrend = (records: PracticeRecord[]): TrainingTrendPoint[] =>
records
.filter((item) => Number.isFinite(item.score))
.sort((a, b) => practiceTimeValue(b.time) - practiceTimeValue(a.time))
.slice(0, 5)
.reverse()
.map((item, index) => ({
key: `${item.id || item.sessionId || item.time}-${index}`,
label: trainingTrendLabel(item.time, index),
value: Math.max(0, Math.min(100, item.score))
}));
export const isReviewEditable = (status: string | null | undefined) => status === '待复盘';
export type ReviewFilter = 'all' | 'low' | 'pending';
export const filterReviewRecords = (reviews: PracticeRecord[], filter: ReviewFilter) => {
if (filter === 'low') return reviews.filter((item) => item.score < 80);
if (filter === 'pending') return reviews.filter((item) => isReviewEditable(item.status));
return reviews;
};
export const isSupervisorAccessDenied = (error: unknown) => {
const message = error instanceof Error
? error.message
@@ -131,14 +157,14 @@ export const buildSupervisorMetrics = (
return [
{
key: 'pendingReviews',
title: '当前待复盘',
title: '待复盘',
value: pendingReviews,
desc: pendingReviews ? '优先处理低分和待处理记录' : '暂无待复盘记录',
primary: true
},
{
key: 'pendingAssignments',
title: '当前待训练',
title: '待训练',
value: pendingAssignments,
desc: pendingAssignments ? '仍有专项训练待完成' : '暂无未完成专项'
},
@@ -271,12 +297,41 @@ export const buildTeamProfiles = (
|| (a.latestScore ?? 100) - (b.latestScore ?? 100));
};
export const filterTeamProfiles = <T extends Pick<TeamProfile, 'displayName' | 'extPartyId'>>(
profiles: T[],
query: string
) => {
const keyword = query.trim().toLowerCase();
if (!keyword) return profiles;
return profiles.filter((item) =>
item.displayName.toLowerCase().includes(keyword)
|| item.extPartyId.toLowerCase().includes(keyword));
};
export const paginateTeamProfiles = <T>(profiles: T[], requestedPage: number, pageSize: number) => {
const safeSize = Math.max(1, Math.floor(pageSize));
const pageCount = Math.max(1, Math.ceil(profiles.length / safeSize));
const page = Math.min(pageCount, Math.max(1, Math.floor(requestedPage)));
return {
items: profiles.slice((page - 1) * safeSize, page * safeSize),
page,
pageCount
};
};
function identitySuffix(value: string) {
if (/^1[3-9]\d{9}$/.test(value)) return `尾号${value.slice(-4)}`;
if (value.length > 8) return `末尾${value.slice(-6)}`;
return value;
}
function trainingTrendLabel(value: string, index: number) {
const parts = value.match(/\d+/g) || [];
if (parts.length >= 3 && parts[0].length === 4) return `${parts[1].padStart(2, '0')}-${parts[2].padStart(2, '0')}`;
if (parts.length >= 2) return `${parts[0].padStart(2, '0')}-${parts[1].padStart(2, '0')}`;
return `第${index + 1}次`;
}
function practiceTimeValue(value: string) {
const parts = value.match(/\d+/g)?.map(Number) || [];
if (parts.length < 4 || parts.some((part) => !Number.isFinite(part))) {
+12 -1
View File
@@ -1,8 +1,11 @@
@import 'lxgw-wenkai-tc-webfont/lxgwwenkaitc-regular.css';
@import 'lxgw-wenkai-tc-webfont/lxgwwenkaitc-bold.css';
page {
min-height: 100%;
background: #f3f5f7;
color: #182230;
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Segoe UI", sans-serif;
font-family: "LXGW WenKai TC", -apple-system, BlinkMacSystemFont, "PingFang SC", "Segoe UI", sans-serif;
}
:root {
@@ -32,6 +35,7 @@ page {
--employee-card-radius: 24px;
--employee-chip-radius: 18px;
--employee-card-shadow: 0 16px 40px rgba(230, 0, 18, 0.08);
--app-bottom-nav-space: calc(82px + env(safe-area-inset-bottom));
}
view,
@@ -298,6 +302,13 @@ textarea:focus-visible {
background: var(--brand-pressed);
}
.tabbar-safe-action {
position: sticky;
bottom: var(--app-bottom-nav-space);
z-index: 12;
box-shadow: 0 10px 24px rgba(230, 0, 18, 0.2);
}
uni-tabbar.uni-tabbar-bottom,
uni-tabbar.uni-tabbar-bottom .uni-tabbar {
border-top: 1px solid var(--line) !important;
+6
View File
@@ -302,6 +302,9 @@ export interface PracticeAssignment {
scenarioName: string;
source: string;
reason: string;
taskGoal?: string;
taskRequirements?: string;
dueTime?: string;
status: string;
createTime: string;
question?: string;
@@ -355,6 +358,9 @@ export interface PracticeAssignmentRequest {
scenarioName?: string;
source?: string;
reason?: string;
taskGoal?: string;
taskRequirements?: string;
dueTime?: string;
}
export interface DailyDrillAnswerResponse {
@@ -37,6 +37,33 @@ test('训练页提交失败会回滚本次乐观追加的对话', async () => {
assert.match(source, /turns\.value = previousTurns/);
});
test('训练结果和 SOP 答案生成后重置到内容首屏', async () => {
const [practiceSource, sopSource] = await Promise.all([
pageSource('../src/pages/user/practice/index.vue'),
pageSource('../src/pages/user/sop/index.vue')
]);
assert.match(practiceSource, /result\.value = await finishPractice\([\s\S]*?resetPageScroll\(\)/);
assert.match(sopSource, /result\.value = await searchKnowledge\(value\);\s*resetPageScroll\(\)/);
});
test('训练历史按摘要、逐句复盘、SOP 依据和行动建议分层', async () => {
const source = await pageSource('../src/pages/user/history/index.vue');
assert.match(source, /训练摘要/);
assert.match(source, /录音与逐句复盘/);
assert.match(source, /SOP 依据与改进建议/);
assert.match(source, /推荐话术/);
});
test('案例页为无权限和无案例提供明确空状态', async () => {
const source = await pageSource('../src/pages/user/cases/index.vue');
assert.match(source, /class="case-empty-state"/);
assert.match(source, /暂时还没有可学习案例/);
assert.match(source, /提交权限由组织角色决定/);
});
test('manifest 明确声明为 uni-app Vue3 CLI 工程', async () => {
const source = await pageSource('../src/manifest.json');
@@ -66,6 +93,8 @@ test('登录页按高保真原型保留品牌结构和紧凑验证码行', async
test('共享视觉基座包含员工端主色和卡片圆角 token', async () => {
const source = await pageSource('../src/styles/base.css');
assert.match(source, /lxgw-wenkai-tc-webfont/);
assert.match(source, /font-family:\s*"LXGW WenKai TC"/);
assert.match(source, /--employee-primary:\s*#e60012/);
assert.match(source, /--employee-card-radius:\s*24px/);
assert.match(source, /\.employee-card\s*\{/);
@@ -143,6 +172,21 @@ test('问师傅页保留搜索框、答案区和总结卡入口', async () => {
assert.match(source, /summary-panel/);
});
test('SOP 没解决先收集结构化原因再提交', async () => {
const [source, service] = await Promise.all([
pageSource('../src/pages/user/sop/index.vue'),
pageSource('../src/services/knowledge.ts')
]);
assert.match(source, /feedbackReasonOptions/);
assert.match(source, /negative-feedback-panel/);
assert.match(source, /submitNegativeFeedback/);
assert.match(source, /reasonCodes:\s*selectedFeedbackReasons\.value/);
assert.match(source, /comment:\s*feedbackComment\.value\.trim\(\)/);
assert.match(service, /reasonCodes\?:\s*string\[\]/);
assert.match(service, /comment\?:\s*string/);
});
test('训练页保留任务卡、对话区、回复区和结果区并互斥切换', async () => {
const source = await pageSource('../src/pages/user/practice/index.vue');
@@ -156,6 +200,23 @@ test('训练页保留任务卡、对话区、回复区和结果区并互斥切
assert.match(source, /practiceView === 'prep'/);
assert.match(source, /practiceView === 'active'/);
assert.match(source, /practiceView\.value === 'result'/);
assert.match(source, /result\.score\?\.length/);
assert.match(source, /class="result-dimensions"/);
});
test('底部固定导航页面复用安全操作区', async () => {
const [base, practice, assign, review] = await Promise.all([
pageSource('../src/styles/base.css'),
pageSource('../src/pages/user/practice/index.vue'),
pageSource('../src/pages/supervisor/assign/index.vue'),
pageSource('../src/pages/supervisor/review/index.vue')
]);
assert.match(base, /--app-bottom-nav-space/);
assert.match(base, /\.tabbar-safe-action\s*\{/);
assert.match(practice, /primary-button tabbar-safe-action/);
assert.match(assign, /primary-button submit-button tabbar-safe-action/);
assert.match(review, /primary-button submit-button tabbar-safe-action/);
});
test('员工练习页显示当前岗位而不是固定生活顾问', async () => {
+85 -1
View File
@@ -21,13 +21,18 @@ const {
actionablePracticeAlerts,
buildSupervisorMetrics,
buildTeamProfiles,
buildTrainingTrend,
createAsyncGenerationGuard,
filterReviewRecords,
filterTeamProfiles,
inferSupervisorScenarioId,
isReviewEditable,
isSupervisorAccessDenied,
paginateTeamProfiles,
reviewCompletionAction,
supervisorScenarioOptions
} = supervisorView;
const { maskPhone } = await vite.ssrLoadModule('/src/services/auth.ts');
const assignPageSource = await readFile(
new URL('../src/pages/supervisor/assign/index.vue', import.meta.url),
@@ -152,6 +157,20 @@ test('低于 80 分为高风险,有待复盘为中风险,其余为低风险'
assert.equal(profiles.find((item) => item.extPartyId === 'low')?.risk, 'low');
});
test('待复盘列表支持全部、低分和待处理筛选', () => {
const rows = [
review({ id: 1, score: 68, status: '待复盘' }),
review({ id: 2, score: 88, status: '待复盘' }),
review({ id: 3, score: 92, status: '已复盘' })
];
assert.equal(filterReviewRecords(rows, 'all').length, 3);
assert.deepEqual(filterReviewRecords(rows, 'low').map((item) => item.id), [1]);
assert.deepEqual(filterReviewRecords(rows, 'pending').map((item) => item.id), [1, 2]);
assert.match(reviewsPageSource, /reviewFilters/);
assert.match(reviewsPageSource, /visibleReviews/);
});
test('主管页面共享三个业务场景及文本推荐规则', () => {
assert.deepEqual(supervisorScenarioOptions.map((item) => item.id), [
'fee-parking',
@@ -159,7 +178,8 @@ test('主管页面共享三个业务场景及文本推荐规则', () => {
'repair-elevator-delay'
]);
assert.match(practiceServiceSource, /getPracticeScenarios/);
assert.match(assignPageSource, /v-for="item in scenarioOptions"/);
assert.match(assignPageSource, /v-for="item in filteredScenarioOptions"/);
assert.match(assignPageSource, /scenarioSearch/);
assert.match(assignPageSource, /暂无可派发的已启用场景/);
assert.match(reviewPageSource, /scenarioOptions/);
assert.match(reviewPageSource, /scenarioOptions\.value = \[\]/);
@@ -185,6 +205,20 @@ test('stable 提示不计入团队预警指标', () => {
]).find((item) => item.key === 'alerts')?.value, 0);
});
test('主管工作台展示真实训练趋势并压缩预警首屏密度', () => {
const trend = buildTrainingTrend([
review({ id: 1, time: '2026-07-15 09:00:00', score: 70 }),
review({ id: 2, time: '2026-07-16 09:00:00', score: 82 }),
review({ id: 3, time: '2026-07-17 09:00:00', score: 91 })
]);
assert.deepEqual(trend.map((item) => item.value), [70, 82, 91]);
assert.match(dashboardPageSource, /class="panel training-trend-panel"/);
assert.match(dashboardPageSource, /trainingTrend/);
assert.match(dashboardPageSource, /visibleAlerts/);
assert.doesNotMatch(dashboardPageSource, /<text class="metric-desc"/);
});
test('画像按时间显示最新训练,但任一未复盘低分仍保持高风险', () => {
const profiles = buildTeamProfiles([
review({
@@ -223,6 +257,38 @@ test('组织快照中的稳定成员即使没有训练记录也保留在画像
assert.equal(profiles[0].risk, 'low');
});
test('团队画像搜索和分页不把 2316 名成员一次性渲染', () => {
const profiles = Array.from({ length: 2316 }, (_, index) => ({
key: `member-${index}`,
extPartyId: `employee-${index}`,
displayName: `员工 ${index}`
}));
assert.equal(paginateTeamProfiles(profiles, 1, 20).items.length, 20);
assert.equal(paginateTeamProfiles(profiles, 1, 20).pageCount, 116);
assert.deepEqual(
filterTeamProfiles(profiles, '员工 2315').map((item) => item.key),
['member-2315']
);
assert.match(teamPageSource, /v-for="member in visibleTeamProfiles"/);
assert.match(teamPageSource, /v-if="selectedMember" class="panel member-detail-panel"/);
assert.match(teamPageSource, /<template v-else>[\s\S]*class="panel team-panel"/);
});
test('员工和主管个人页只展示脱敏手机号', async () => {
const [employeeProfile, supervisorProfile] = await Promise.all([
readFile(new URL('../src/pages/user/profile/index.vue', import.meta.url), 'utf8'),
readFile(new URL('../src/pages/supervisor/profile/index.vue', import.meta.url), 'utf8')
]);
assert.equal(maskPhone('181368856126'), '181****6126');
assert.equal(maskPhone('invalid'), 'invalid');
assert.match(employeeProfile, /displayPhone/);
assert.match(supervisorProfile, /displayPhone/);
assert.doesNotMatch(employeeProfile, /\{\{ phone \|\|/);
assert.doesNotMatch(supervisorProfile, /\{\{ phone \|\|/);
});
test('组织快照姓名不可读时用稳定身份标识展示', () => {
const profiles = buildTeamProfiles([], [], [
{ extPartyId: '13900001111', name: '???????' },
@@ -275,6 +341,10 @@ test('复盘详情只允许待复盘提交并保留未保存草稿', () => {
assert.match(reviewPageSource, /v-else class="panel review-complete"/);
assert.match(reviewPageSource, /if \(!editable\.value\)[\s\S]*该记录已完成复盘/);
assert.match(reviewPageSource, /onShow\(\(\) => \{[\s\S]*refreshAuthState\(\);[\s\S]*if \(!loggedIn\.value\) \{[\s\S]*accessDenied\.value = false;[\s\S]*clearDetail\(\);\s*return;\s*\}[\s\S]*if \(!detail\.value && !loading\.value\)[\s\S]*loadDetail\(\)/);
assert.match(reviewPageSource, /训练摘要/);
assert.match(reviewPageSource, /录音与逐句复盘/);
assert.match(reviewPageSource, /SOP 依据与改进建议/);
assert.match(reviewPageSource, /下一步行动/);
});
test('复盘详情登录和完成导航不堆叠页面', () => {
@@ -339,6 +409,20 @@ test('指派页通过团队快照在进入时校验主管权限', () => {
assert.doesNotMatch(assignPageSource, /getPracticeAssignments/);
});
test('指派表单和派发进度使用同页分段视图避免堆成长页', () => {
assert.match(assignPageSource, /assignView/);
assert.match(assignPageSource, /v-if="assignView === 'create'" class="panel assignment-form"/);
assert.match(assignPageSource, /v-if="assignView === 'progress'" class="panel history-panel"/);
assert.match(assignPageSource, /class="assign-tabs"/);
assert.match(assignPageSource, /memberSuggestions/);
assert.match(assignPageSource, /selectTarget/);
assert.match(assignPageSource, /class="target-suggestions"/);
assert.match(assignPageSource, /assignmentDueTime/);
assert.match(assignPageSource, /assignmentGoal/);
assert.match(assignPageSource, /assignmentRequirements/);
assert.match(assignPageSource, /item\.dueTime/);
});
test('指派页保留 H5 查询参数二次解码', () => {
assert.match(assignPageSource, /decodeURIComponent\(value\)\.trim\(\)/);
});