fix(aihr): close Figma UI regression gaps
This commit is contained in:
Generated
+7
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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))) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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\(\)/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user