fix(aihr): harden broadcast and mobile assistant flows
This commit is contained in:
@@ -126,7 +126,7 @@
|
||||
/>
|
||||
</view>
|
||||
|
||||
<button class="primary-button submit-button" :disabled="assigning || historyLoading || !scenarioOptions.length || !selectedMemberIds.length" @click="assignPractice">
|
||||
<button class="primary-button submit-button tabbar-safe-action" :disabled="assigning || historyLoading || !scenarioOptions.length || !selectedMemberIds.length" @click="assignPractice">
|
||||
{{ assigning ? '指派中' : `确认派发(${selectedMemberIds.length}人)` }}
|
||||
</button>
|
||||
|
||||
|
||||
@@ -80,6 +80,25 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="trainingTrend.length" class="panel training-trend-panel">
|
||||
<view class="panel-head-row">
|
||||
<view class="panel-head">
|
||||
<view class="panel-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3v18h18"/><path d="m7 15 4-4 3 2 5-6"/></svg>
|
||||
</view>
|
||||
<text class="panel-title">近期训练走势</text>
|
||||
</view>
|
||||
<text class="trend-summary">{{ visibleAlerts.length ? `${visibleAlerts.length} 项风险待跟进` : '团队状态稳定' }}</text>
|
||||
</view>
|
||||
<view class="trend-bars" role="img" aria-label="近期训练分数走势">
|
||||
<view v-for="point in trainingTrend" :key="point.key" class="trend-bar-item">
|
||||
<text class="trend-value">{{ point.value }}</text>
|
||||
<view class="trend-track"><view class="trend-fill" :style="{ height: `${Math.max(12, point.value)}%` }" /></view>
|
||||
<text class="trend-label">{{ point.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="panel todo-panel">
|
||||
<view class="panel-head-row">
|
||||
<view class="panel-head">
|
||||
@@ -144,6 +163,7 @@ import type {
|
||||
PracticeAssignment,
|
||||
PracticeRecord,
|
||||
PracticeTeamMember,
|
||||
PracticeTeamProfileSummary,
|
||||
RoleHome
|
||||
} from '@/types/api';
|
||||
import { clearAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||
@@ -155,7 +175,10 @@ import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
|
||||
import StateBlock from '@/components/StateBlock.vue';
|
||||
import {
|
||||
buildSupervisorMetrics,
|
||||
buildTeamProfileRollup,
|
||||
buildTeamProfiles,
|
||||
buildTrainingTrend,
|
||||
actionablePracticeAlerts,
|
||||
isSupervisorAccessDenied
|
||||
} from '@/services/supervisor-view';
|
||||
import { createPageRequestScope, currentAccountKey } from '@/services/request-scope';
|
||||
@@ -165,6 +188,7 @@ const pendingReviews = ref<PracticeRecord[]>([]);
|
||||
const records = ref<PracticeRecord[]>([]);
|
||||
const assignments = ref<PracticeAssignment[]>([]);
|
||||
const members = ref<PracticeTeamMember[]>([]);
|
||||
const teamProfileSummaries = ref<PracticeTeamProfileSummary[]>([]);
|
||||
const alerts = ref<PracticeAlert[]>([]);
|
||||
const loggedIn = ref(false);
|
||||
const accessDenied = ref(false);
|
||||
@@ -183,13 +207,28 @@ const todayKey = `${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.ge
|
||||
const supervisorMetrics = computed(() =>
|
||||
buildSupervisorMetrics(pendingReviews.value, assignments.value, alerts.value));
|
||||
const teamProfiles = computed(() =>
|
||||
buildTeamProfiles(records.value, assignments.value, members.value));
|
||||
const teamTotal = computed(() => teamProfiles.value.length);
|
||||
buildTeamProfiles(records.value, assignments.value, members.value, teamProfileSummaries.value));
|
||||
const teamProfileRollup = computed(() => buildTeamProfileRollup(teamProfiles.value, todayKey));
|
||||
const hasFullTeamProfileSummaries = computed(() => {
|
||||
if (!members.value.length) return false;
|
||||
const summaryIds = new Set(
|
||||
teamProfileSummaries.value.map((item) => item.extPartyId?.trim()).filter(Boolean)
|
||||
);
|
||||
return members.value.every((member) => summaryIds.has(member.extPartyId.trim()));
|
||||
});
|
||||
const trainingTrend = computed(() => buildTrainingTrend(records.value));
|
||||
const visibleAlerts = computed(() => actionablePracticeAlerts(alerts.value).slice(0, 3));
|
||||
const teamTotal = computed(() => teamProfileRollup.value.teamTotal);
|
||||
const pendingReviewCount = computed(() =>
|
||||
supervisorMetrics.value.find((item) => item.key === 'pendingReviews')?.value || 0);
|
||||
hasFullTeamProfileSummaries.value
|
||||
? teamProfileRollup.value.pendingReview
|
||||
: supervisorMetrics.value.find((item) => item.key === 'pendingReviews')?.value || 0);
|
||||
const pendingAssignmentCount = computed(() =>
|
||||
supervisorMetrics.value.find((item) => item.key === 'pendingAssignments')?.value || 0);
|
||||
hasFullTeamProfileSummaries.value
|
||||
? teamProfileRollup.value.pendingAssignment
|
||||
: supervisorMetrics.value.find((item) => item.key === 'pendingAssignments')?.value || 0);
|
||||
const todayDone = computed(() => {
|
||||
if (hasFullTeamProfileSummaries.value) return teamProfileRollup.value.todayDone;
|
||||
const trained = new Set<string>();
|
||||
records.value.forEach((item) => {
|
||||
if (item.time.startsWith(todayKey)) trained.add(item.extPartyId || item.trainee);
|
||||
@@ -307,6 +346,7 @@ const clearData = () => {
|
||||
records.value = [];
|
||||
assignments.value = [];
|
||||
members.value = [];
|
||||
teamProfileSummaries.value = [];
|
||||
alerts.value = [];
|
||||
};
|
||||
|
||||
@@ -345,7 +385,7 @@ const loadPage = async () => {
|
||||
const [nextReviews, nextAlerts, snapshot, home, identity] = await Promise.all([
|
||||
getPracticeReviews(),
|
||||
getPracticeAlerts(),
|
||||
getPracticeTeamSnapshot(),
|
||||
getPracticeTeamSnapshot(true),
|
||||
getMobileHome('supervisor').catch((): RoleHome | null => null),
|
||||
apiRequest<MobileIdentity>({
|
||||
url: '/api/aihr/mobile/me',
|
||||
@@ -361,6 +401,7 @@ const loadPage = async () => {
|
||||
records.value = snapshot.records;
|
||||
assignments.value = snapshot.assignments;
|
||||
members.value = snapshot.members;
|
||||
teamProfileSummaries.value = snapshot.profiles || [];
|
||||
if (home?.location) communityName.value = home.location.split('·')[0].trim() || '住宅项目';
|
||||
if (identity?.positionName) supervisorName.value = identity.positionName;
|
||||
} catch (error) {
|
||||
@@ -655,6 +696,68 @@ onUnload(invalidateRequests);
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.training-trend-panel {
|
||||
margin-top: 14px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.panel-head-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.trend-summary {
|
||||
color: #8a8d93;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.trend-bars {
|
||||
display: flex;
|
||||
height: 128px;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.trend-bar-item {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
grid-template-rows: 18px 1fr 18px;
|
||||
align-items: end;
|
||||
gap: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.trend-value,
|
||||
.trend-label {
|
||||
color: #71717a;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.trend-track {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
align-items: flex-end;
|
||||
border-radius: 8px 8px 3px 3px;
|
||||
background: #f4f4f5;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.trend-fill {
|
||||
width: 100%;
|
||||
min-height: 12%;
|
||||
border-radius: 8px 8px 3px 3px;
|
||||
background: linear-gradient(180deg, #ff7a83 0%, #e60012 100%);
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
<view class="member-stats">
|
||||
<view class="member-stat">
|
||||
<text class="member-stat-value">{{ totalSessions }}</text>
|
||||
<text class="member-stat-label">训练次数</text>
|
||||
<text class="member-stat-label">当前明细</text>
|
||||
</view>
|
||||
<view class="member-stat">
|
||||
<text class="member-stat-value medium">{{ profile?.pendingReview ?? 0 }}</text>
|
||||
@@ -66,7 +66,7 @@
|
||||
<view class="panel trend-panel">
|
||||
<view class="section-head">
|
||||
<text class="panel-title">训练趋势</text>
|
||||
<text class="muted">最近 {{ trendItems.length }} 次</text>
|
||||
<text class="muted">近期可见 {{ trendItems.length }} 次</text>
|
||||
</view>
|
||||
<view v-if="trendItems.length" class="trend-list">
|
||||
<view v-for="(item, index) in trendItems" :key="`${item.scene}-${index}`" class="trend-row">
|
||||
@@ -86,7 +86,7 @@
|
||||
</view>
|
||||
<view class="evidence-list">
|
||||
<view class="evidence-row">
|
||||
<text class="evidence-text">已完成 <text class="evidence-strong">{{ totalSessions }}</text> 次训练</text>
|
||||
<text class="evidence-text">已加载 <text class="evidence-strong">{{ totalSessions }}</text> 条近期明细</text>
|
||||
</view>
|
||||
<view class="evidence-row">
|
||||
<text class="evidence-text" :class="{ warn: lowScoreScene }">{{ lowScoreScene ? `连续低分场景:${lowScoreScene}` : '暂无连续低分场景' }}</text>
|
||||
@@ -127,7 +127,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { onHide, onLoad, onShow, onUnload } from '@dcloudio/uni-app';
|
||||
import type { PracticeAssignment, PracticeRecord } from '@/types/api';
|
||||
import type { PracticeAssignment, PracticeRecord, PracticeTeamProfileSummary } from '@/types/api';
|
||||
import { clearAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||
import { resetPageScroll } from '@/services/navigation';
|
||||
import { getPracticeTeamSnapshot } from '@/services/practice';
|
||||
@@ -148,6 +148,7 @@ const displayName = ref('成员');
|
||||
const records = ref<PracticeRecord[]>([]);
|
||||
const assignments = ref<PracticeAssignment[]>([]);
|
||||
const memberNames = ref<Record<string, string>>({});
|
||||
const teamProfileSummaries = ref<PracticeTeamProfileSummary[]>([]);
|
||||
const loggedIn = ref(false);
|
||||
const accessDenied = ref(false);
|
||||
const loading = ref(false);
|
||||
@@ -157,7 +158,7 @@ let visibleAccountKey = '';
|
||||
|
||||
const profiles = computed(() => {
|
||||
const members = Object.entries(memberNames.value).map(([id, name]) => ({ extPartyId: id, name }));
|
||||
return buildTeamProfiles(records.value, assignments.value, members);
|
||||
return buildTeamProfiles(records.value, assignments.value, members, teamProfileSummaries.value);
|
||||
});
|
||||
const profile = computed<TeamProfile | null>(() =>
|
||||
profiles.value.find((item) => item.extPartyId === extPartyId.value)
|
||||
@@ -194,7 +195,11 @@ const lowScoreScene = computed(() => {
|
||||
return '';
|
||||
});
|
||||
const memberInitial = computed(() => displayName.value.trim().charAt(0) || '员');
|
||||
const hasData = computed(() => records.value.length > 0 || assignments.value.length > 0);
|
||||
const hasData = computed(() =>
|
||||
records.value.length > 0
|
||||
|| assignments.value.length > 0
|
||||
|| Object.keys(memberNames.value).length > 0
|
||||
|| teamProfileSummaries.value.length > 0);
|
||||
const loadFailed = computed(() => !loading.value && !!message.value && !hasData.value);
|
||||
const firstLoading = computed(() => loading.value && !hasData.value && !message.value);
|
||||
const riskBadgeText = computed(() => {
|
||||
@@ -220,6 +225,7 @@ const clearData = () => {
|
||||
records.value = [];
|
||||
assignments.value = [];
|
||||
memberNames.value = {};
|
||||
teamProfileSummaries.value = [];
|
||||
};
|
||||
|
||||
const refreshAuthState = () => {
|
||||
@@ -254,12 +260,13 @@ const loadPage = async () => {
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const snapshot = await getPracticeTeamSnapshot();
|
||||
const snapshot = await getPracticeTeamSnapshot(true);
|
||||
if (!requests.isCurrent(request)) return;
|
||||
accessDenied.value = false;
|
||||
records.value = snapshot.records;
|
||||
assignments.value = snapshot.assignments;
|
||||
memberNames.value = Object.fromEntries(snapshot.members.map((item) => [item.extPartyId, item.name]));
|
||||
teamProfileSummaries.value = snapshot.profiles || [];
|
||||
if (!displayName.value || displayName.value === '成员') {
|
||||
const mapped = memberNames.value[extPartyId.value];
|
||||
if (mapped) displayName.value = mapped;
|
||||
|
||||
@@ -196,7 +196,7 @@
|
||||
/>
|
||||
</view>
|
||||
|
||||
<button class="submit-button" :disabled="marking" @click="markReviewed">
|
||||
<button class="primary-button submit-button tabbar-safe-action" :disabled="marking" @click="markReviewed">
|
||||
{{ marking ? '提交中…' : '标记已复盘' }}
|
||||
</button>
|
||||
</view>
|
||||
|
||||
@@ -81,11 +81,15 @@
|
||||
<view class="panel team-panel">
|
||||
<view class="section-head">
|
||||
<text class="panel-title">成员列表</text>
|
||||
<text class="muted">按风险排序</text>
|
||||
<text class="muted">{{ filteredTeamProfiles.length }} 人 · 按风险排序</text>
|
||||
</view>
|
||||
<view v-if="teamProfiles.length" class="team-list">
|
||||
<view class="team-search-row">
|
||||
<uni-icons type="search" size="16" color="#94a3b8" />
|
||||
<input v-model="teamSearch" class="team-search-input" placeholder="搜索成员姓名或身份标识" @input="teamPage = 1" />
|
||||
</view>
|
||||
<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"
|
||||
@click="openMember(member)"
|
||||
@@ -106,7 +110,12 @@
|
||||
<text v-else class="team-arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
<StateBlock v-else-if="!loading" type="empty" title="暂无团队画像数据" :card="false" />
|
||||
<StateBlock v-else-if="!loading" type="empty" :title="teamSearch ? '没有匹配的成员' : '暂无团队画像数据'" :card="false" />
|
||||
<button
|
||||
v-if="teamPagination.page < teamPagination.pageCount"
|
||||
class="load-more-members"
|
||||
@click="teamPage += 1"
|
||||
>加载更多(已显示 {{ visibleTeamProfiles.length }}/{{ filteredTeamProfiles.length }} 人)</button>
|
||||
</view>
|
||||
</template>
|
||||
<SupervisorTabBar v-if="loggedIn && !accessDenied" active="team" />
|
||||
@@ -116,7 +125,13 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
|
||||
import type { PracticeAssignment, PracticeDifficulty, PracticeRecord, PracticeTeamMember } from '@/types/api';
|
||||
import type {
|
||||
PracticeAssignment,
|
||||
PracticeDifficulty,
|
||||
PracticeRecord,
|
||||
PracticeTeamMember,
|
||||
PracticeTeamProfileSummary
|
||||
} from '@/types/api';
|
||||
import { clearAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||
import { resetPageScroll } from '@/services/navigation';
|
||||
import { assignPracticeDifficulty, getPracticeDifficulties, getPracticeTeamSnapshot } from '@/services/practice';
|
||||
@@ -124,8 +139,10 @@ import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
|
||||
import StateBlock from '@/components/StateBlock.vue';
|
||||
import {
|
||||
buildTeamProfiles,
|
||||
filterTeamProfiles,
|
||||
inferSupervisorScenarioId,
|
||||
isSupervisorAccessDenied,
|
||||
paginateTeamProfiles,
|
||||
type TeamProfile
|
||||
} from '@/services/supervisor-view';
|
||||
import { createPageRequestScope, currentAccountKey } from '@/services/request-scope';
|
||||
@@ -134,6 +151,7 @@ const currentPath = '/pages/supervisor/team/index';
|
||||
const records = ref<PracticeRecord[]>([]);
|
||||
const assignments = ref<PracticeAssignment[]>([]);
|
||||
const members = ref<PracticeTeamMember[]>([]);
|
||||
const teamProfileSummaries = ref<PracticeTeamProfileSummary[]>([]);
|
||||
const mistakes = ref<PracticeDifficulty[]>([]);
|
||||
const loggedIn = ref(false);
|
||||
const accessDenied = ref(false);
|
||||
@@ -147,10 +165,17 @@ const difficultySorts = [
|
||||
type DifficultySort = typeof difficultySorts[number]['key'];
|
||||
const difficultySort = ref<DifficultySort>('count');
|
||||
const assigningDifficultyKey = ref('');
|
||||
const teamSearch = ref('');
|
||||
const teamPage = ref(1);
|
||||
const teamPageSize = 20;
|
||||
const requests = createPageRequestScope();
|
||||
let visibleAccountKey = '';
|
||||
|
||||
const teamProfiles = computed(() => buildTeamProfiles(records.value, assignments.value, members.value));
|
||||
const teamProfiles = computed(() =>
|
||||
buildTeamProfiles(records.value, assignments.value, members.value, teamProfileSummaries.value));
|
||||
const filteredTeamProfiles = computed(() => filterTeamProfiles(teamProfiles.value, teamSearch.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;
|
||||
@@ -171,7 +196,10 @@ const clearData = () => {
|
||||
records.value = [];
|
||||
assignments.value = [];
|
||||
members.value = [];
|
||||
teamProfileSummaries.value = [];
|
||||
mistakes.value = [];
|
||||
teamSearch.value = '';
|
||||
teamPage.value = 1;
|
||||
};
|
||||
|
||||
const refreshAuthState = () => {
|
||||
@@ -206,13 +234,15 @@ const loadPage = async () => {
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const [snapshot, difficulties] = await Promise.all([getPracticeTeamSnapshot(), getPracticeDifficulties(30)]);
|
||||
const [snapshot, difficulties] = await Promise.all([getPracticeTeamSnapshot(true), getPracticeDifficulties(30)]);
|
||||
if (!requests.isCurrent(request)) return;
|
||||
accessDenied.value = false;
|
||||
records.value = snapshot.records;
|
||||
assignments.value = snapshot.assignments;
|
||||
members.value = snapshot.members;
|
||||
teamProfileSummaries.value = snapshot.profiles || [];
|
||||
mistakes.value = difficulties?.length ? difficulties : (snapshot.mistakes ?? []);
|
||||
teamPage.value = 1;
|
||||
} catch (error) {
|
||||
if (!requests.isCurrent(request)) return;
|
||||
handleRequestError(error, '团队画像加载失败');
|
||||
@@ -343,6 +373,38 @@ onUnload(invalidateRequests);
|
||||
min-width: 104px;
|
||||
}
|
||||
|
||||
.team-search-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 12px 0;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.team-search-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 38px;
|
||||
color: #1e293b;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.load-more-members {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
margin: 12px 0 0;
|
||||
border: 1px solid #fecdd3;
|
||||
border-radius: 12px;
|
||||
background: #fff5f5;
|
||||
color: #bd0010;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.refresh-button {
|
||||
min-width: 78px;
|
||||
padding: 0 10px;
|
||||
|
||||
@@ -894,10 +894,6 @@ const askTopic = (topic: string) => {
|
||||
uni.switchTab({ url: '/pages/user/sop/index' });
|
||||
};
|
||||
|
||||
const goCases = () => {
|
||||
uni.navigateTo({ url: '/pages/user/cases/index' });
|
||||
};
|
||||
|
||||
onShow(() => {
|
||||
const nextAccountKey = currentAccountKey();
|
||||
if (visibleAccountKey && visibleAccountKey !== nextAccountKey) {
|
||||
|
||||
@@ -373,7 +373,7 @@
|
||||
|
||||
<view v-if="result" class="result-actions">
|
||||
<button class="secondary-button" @click="openPracticeHistory">查看训练记录</button>
|
||||
<button class="primary-button" @click="start(selectedScenarioId)">再练一次</button>
|
||||
<button class="primary-button tabbar-safe-action" @click="start(selectedScenarioId)">再练一次</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
@@ -1443,6 +1443,7 @@ const finish = async (force = false) => {
|
||||
const nextResult = await finishPractice(sessionId.value, assignmentId);
|
||||
if (!requests.isCurrent(request)) return;
|
||||
result.value = nextResult;
|
||||
resetPageScroll();
|
||||
status.value = 'finished';
|
||||
message.value = '训练完成,主管端待复盘已更新';
|
||||
completedAssignmentId.value = assignmentId;
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
<view v-if="loggedIn" class="position-badge">
|
||||
<text class="badge-text">{{ positionProfile.value }} · {{ positionProfile.heroTitle }}</text>
|
||||
</view>
|
||||
<text v-if="loggedIn" class="identity-source">岗位由人力系统组织同步</text>
|
||||
<text v-else class="identity-note">登录后查看训练记录和能力画像</text>
|
||||
</view>
|
||||
<button v-if="!loggedIn" class="primary-gradient-btn compact-login-btn" @click="goLogin">去登录</button>
|
||||
|
||||
@@ -160,7 +160,7 @@
|
||||
class="bubble-action"
|
||||
:class="{ negative: msg.feedbackVerdict === 'down' }"
|
||||
:disabled="msg.feedbackSubmitting || Boolean(msg.feedbackVerdict)"
|
||||
@click="feedback('down', msg)"
|
||||
@click="openNegativeFeedback(msg)"
|
||||
>
|
||||
没解决
|
||||
</button>
|
||||
@@ -168,6 +168,34 @@
|
||||
{{ msg.feedbackVerdict === 'up' ? '已记录' : '已进入复核' }}
|
||||
</text>
|
||||
</view>
|
||||
<view v-if="msg.feedbackPanelOpen && !msg.feedbackVerdict" class="negative-feedback-panel">
|
||||
<text class="negative-feedback-title">哪里还没解决?(可多选)</text>
|
||||
<view class="negative-feedback-options">
|
||||
<button
|
||||
v-for="option in feedbackReasonOptions"
|
||||
:key="option.code"
|
||||
class="negative-feedback-option"
|
||||
:class="{ active: msg.feedbackReasonCodes.includes(option.code) }"
|
||||
:disabled="msg.feedbackSubmitting"
|
||||
@click="toggleNegativeFeedbackReason(msg, option.code)"
|
||||
>{{ option.label }}</button>
|
||||
</view>
|
||||
<textarea
|
||||
v-model="msg.feedbackComment"
|
||||
class="textarea negative-feedback-comment"
|
||||
maxlength="200"
|
||||
:disabled="msg.feedbackSubmitting"
|
||||
placeholder="可补充具体问题,方便复核"
|
||||
/>
|
||||
<text class="negative-feedback-hint">请至少选择一项原因后提交</text>
|
||||
<button
|
||||
class="bubble-action primary"
|
||||
:disabled="msg.feedbackSubmitting || !msg.feedbackReasonCodes.length"
|
||||
@click="submitNegativeFeedback(msg)"
|
||||
>
|
||||
{{ msg.feedbackSubmitting ? '提交中' : '提交复核' }}
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view v-if="msg.citationsOpen && msg.result.snippets?.length" class="bubble-citations">
|
||||
<view v-for="(snippet, index) in msg.result.snippets" :key="`${snippet.docTitle}-${index}`" class="citation">
|
||||
@@ -442,6 +470,9 @@ interface MasterMsg {
|
||||
toolCode: ToolCode;
|
||||
feedbackVerdict: AnswerFeedbackVerdict | '';
|
||||
feedbackSubmitting: boolean;
|
||||
feedbackPanelOpen: boolean;
|
||||
feedbackReasonCodes: string[];
|
||||
feedbackComment: string;
|
||||
citationsOpen: boolean;
|
||||
voiceStatus: 'preparing' | 'ready' | 'unavailable';
|
||||
voiceDuration: number | null;
|
||||
@@ -933,6 +964,9 @@ const sendQuestion = async (rawText: string, options: { voice?: boolean } = {})
|
||||
toolCode: '',
|
||||
feedbackVerdict: '',
|
||||
feedbackSubmitting: false,
|
||||
feedbackPanelOpen: false,
|
||||
feedbackReasonCodes: [],
|
||||
feedbackComment: '',
|
||||
citationsOpen: false,
|
||||
voiceStatus: spoken ? 'preparing' : 'unavailable',
|
||||
voiceDuration: null,
|
||||
@@ -1003,6 +1037,9 @@ const runDataTool = async (toolCode: 'MY_PRACTICE_SUMMARY' | 'TEAM_PRACTICE_SUMM
|
||||
toolCode: effectiveToolCode,
|
||||
feedbackVerdict: '',
|
||||
feedbackSubmitting: false,
|
||||
feedbackPanelOpen: false,
|
||||
feedbackReasonCodes: [],
|
||||
feedbackComment: '',
|
||||
citationsOpen: false,
|
||||
voiceStatus: 'unavailable',
|
||||
voiceDuration: null,
|
||||
@@ -1205,6 +1242,33 @@ const serviceMemoryStatusLabel = (status?: string | null) => ({
|
||||
|
||||
const displayCitationDate = (value?: string | null) => value ? value.replace('T', ' ').slice(0, 16) : '未记录';
|
||||
|
||||
const feedbackReasonOptions = [
|
||||
{ code: 'NO_ANSWER', label: '缺少制度或SOP依据' },
|
||||
{ code: 'NOT_SPECIFIC', label: '处理步骤不够具体' },
|
||||
{ code: 'WRONG_REFERENCE', label: '答案或引用不正确' },
|
||||
{ code: 'NOT_APPLICABLE', label: '不适用于当前现场' }
|
||||
];
|
||||
|
||||
const openNegativeFeedback = (msg: MasterMsg) => {
|
||||
if (msg.feedbackSubmitting || msg.feedbackVerdict) return;
|
||||
msg.feedbackPanelOpen = !msg.feedbackPanelOpen;
|
||||
};
|
||||
|
||||
const toggleNegativeFeedbackReason = (msg: MasterMsg, code: string) => {
|
||||
if (msg.feedbackSubmitting) return;
|
||||
msg.feedbackReasonCodes = msg.feedbackReasonCodes.includes(code)
|
||||
? msg.feedbackReasonCodes.filter((item) => item !== code)
|
||||
: [...msg.feedbackReasonCodes, code];
|
||||
};
|
||||
|
||||
const submitNegativeFeedback = async (msg: MasterMsg) => {
|
||||
if (!msg.feedbackReasonCodes.length) {
|
||||
uni.showToast({ title: '请至少选择一项未解决原因', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
await feedback('down', msg);
|
||||
};
|
||||
|
||||
const feedback = async (verdict: AnswerFeedbackVerdict, msg: MasterMsg) => {
|
||||
if (msg.feedbackSubmitting || msg.feedbackVerdict) return;
|
||||
const fragmentIds = Array.from(
|
||||
@@ -1224,9 +1288,12 @@ const feedback = async (verdict: AnswerFeedbackVerdict, msg: MasterMsg) => {
|
||||
queryText: msg.result.queryText || '',
|
||||
fragmentIds,
|
||||
verdict,
|
||||
reviewId: msg.result.reviewId
|
||||
reviewId: msg.result.reviewId,
|
||||
reasonCodes: verdict === 'down' ? msg.feedbackReasonCodes : undefined,
|
||||
comment: verdict === 'down' ? msg.feedbackComment.trim() : undefined
|
||||
});
|
||||
msg.feedbackVerdict = verdict;
|
||||
msg.feedbackPanelOpen = false;
|
||||
uni.showToast({ title: verdict === 'down' ? '已进入复核' : '已记录', icon: 'none' });
|
||||
} catch (error) {
|
||||
uni.showToast({ title: requestErrorText(error, '反馈失败'), icon: 'none' });
|
||||
@@ -2918,6 +2985,58 @@ onUnload(() => {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.negative-feedback-panel {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
border: 1px solid #f1c5ca;
|
||||
border-radius: 12px;
|
||||
background: #fff8f8;
|
||||
}
|
||||
|
||||
.negative-feedback-title {
|
||||
color: #5b3136;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.negative-feedback-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.negative-feedback-option {
|
||||
min-height: 28px;
|
||||
margin: 0;
|
||||
padding: 0 9px;
|
||||
border: 1px solid #f0d6d9;
|
||||
border-radius: 999px;
|
||||
background: #ffffff;
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.negative-feedback-option.active {
|
||||
border-color: #e70014;
|
||||
background: #fff0f1;
|
||||
color: #bd0010;
|
||||
}
|
||||
|
||||
.negative-feedback-comment {
|
||||
min-height: 58px;
|
||||
padding: 8px;
|
||||
border-color: #f0d6d9;
|
||||
background: #ffffff;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.negative-feedback-hint {
|
||||
color: #8a5960;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.bubble-citations {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -310,23 +310,6 @@ const primaryScheduleTitle = computed(() => {
|
||||
return '';
|
||||
});
|
||||
|
||||
const assignmentSourceLabel = (source?: string) => {
|
||||
if (source === 'daily') return '每日三题';
|
||||
if (source === 'retry') return '错题再练';
|
||||
if (source === 'camp') return '训练营';
|
||||
return '专项训练';
|
||||
};
|
||||
|
||||
const primaryAssignmentSourceLabel = computed(() =>
|
||||
primaryAssignment.value ? assignmentSourceLabel(primaryAssignment.value.source) : ''
|
||||
);
|
||||
|
||||
const primaryDisplayTitle = computed(() => {
|
||||
const label = primaryAssignmentSourceLabel.value;
|
||||
const title = primaryScheduleTitle.value;
|
||||
return label && title ? `${label}:${title}` : title;
|
||||
});
|
||||
|
||||
const primaryScheduleDesc = computed(() => {
|
||||
const item = primaryAssignment.value;
|
||||
if (item) {
|
||||
|
||||
@@ -145,9 +145,11 @@ export const getPracticeReviews = () =>
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
export const getPracticeTeamSnapshot = () =>
|
||||
export const getPracticeTeamSnapshot = (includeProfiles = false) =>
|
||||
apiRequest<PracticeTeamSnapshot>({
|
||||
url: '/api/aihr/mobile/practice/team',
|
||||
url: includeProfiles
|
||||
? '/api/aihr/mobile/practice/team?includeProfiles=true'
|
||||
: '/api/aihr/mobile/practice/team',
|
||||
method: 'GET',
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
@@ -2,7 +2,8 @@ import type {
|
||||
PracticeAlert,
|
||||
PracticeAssignment,
|
||||
PracticeRecord,
|
||||
PracticeTeamMember
|
||||
PracticeTeamMember,
|
||||
PracticeTeamProfileSummary
|
||||
} from '@/types/api';
|
||||
|
||||
export type SupervisorMetricKey = 'pendingReviews' | 'pendingAssignments' | 'lowScores' | 'alerts';
|
||||
@@ -30,6 +31,7 @@ export interface TeamProfile {
|
||||
latestScoreText: string;
|
||||
latestScene: string;
|
||||
lastPracticeAt: string;
|
||||
completedToday?: boolean;
|
||||
pendingReview: number;
|
||||
pendingAssignment: number;
|
||||
reason: string;
|
||||
@@ -101,6 +103,9 @@ interface TeamProfileDraft {
|
||||
export const manualPracticeAssignments = (assignments: PracticeAssignment[]) =>
|
||||
assignments.filter((item) => item.source !== 'daily');
|
||||
|
||||
const normalizedTeamCount = (value: number | null | undefined) =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0;
|
||||
|
||||
export const actionablePracticeAlerts = (alerts: PracticeAlert[]) =>
|
||||
alerts.filter((item) => item.type !== 'stable');
|
||||
|
||||
@@ -186,9 +191,20 @@ export const buildSupervisorMetrics = (
|
||||
export const buildTeamProfiles = (
|
||||
reviews: PracticeRecord[],
|
||||
assignments: PracticeAssignment[],
|
||||
members: PracticeTeamMember[] = []
|
||||
members: PracticeTeamMember[] = [],
|
||||
profileSummaries: PracticeTeamProfileSummary[] = []
|
||||
): TeamProfile[] => {
|
||||
const rows = new Map<string, TeamProfileDraft>();
|
||||
const summariesByExtPartyId = new Map(
|
||||
profileSummaries
|
||||
.filter((item) => Boolean(item?.extPartyId?.trim()))
|
||||
.map((item) => [item.extPartyId.trim(), item] as const)
|
||||
);
|
||||
const hasFullTeamSummaries = summariesByExtPartyId.size > 0;
|
||||
const memberExtPartyIds = new Set(
|
||||
members.map((item) => item.extPartyId.trim()).filter(Boolean)
|
||||
);
|
||||
const normalizedExtPartyId = (value?: string | null) => value?.trim() || '';
|
||||
const ensure = (rawKey: string | undefined, rawName: string, rawExtPartyId?: string) => {
|
||||
const extPartyId = rawExtPartyId?.trim() || '';
|
||||
const candidateName = rawName.trim();
|
||||
@@ -224,6 +240,7 @@ export const buildTeamProfiles = (
|
||||
});
|
||||
|
||||
reviews.forEach((item) => {
|
||||
if (hasFullTeamSummaries && !memberExtPartyIds.has(normalizedExtPartyId(item.extPartyId))) return;
|
||||
const row = ensure(item.extPartyId || item.trainee, item.trainee, item.extPartyId);
|
||||
if (isReviewEditable(item.status)) {
|
||||
row.pendingReview += 1;
|
||||
@@ -245,6 +262,7 @@ export const buildTeamProfiles = (
|
||||
});
|
||||
|
||||
manualPracticeAssignments(assignments).forEach((item) => {
|
||||
if (hasFullTeamSummaries && !memberExtPartyIds.has(normalizedExtPartyId(item.extPartyId))) return;
|
||||
const row = ensure(item.extPartyId || item.traineeName, item.traineeName, item.extPartyId);
|
||||
if (item.status !== '已完成') row.pendingAssignment += 1;
|
||||
row.latestScene ||= item.scenarioName.trim();
|
||||
@@ -258,19 +276,31 @@ export const buildTeamProfiles = (
|
||||
|
||||
return Array.from(rows.values())
|
||||
.map<TeamProfile>((row) => {
|
||||
const latestScore = row.latestPractice?.score;
|
||||
const latestScene = row.latestPractice?.scene || row.latestScene;
|
||||
const risk: TeamProfile['risk'] = row.pendingLowScore !== undefined
|
||||
const summary = summariesByExtPartyId.get(row.extPartyId);
|
||||
const latestScore = typeof summary?.latestScore === 'number' && Number.isFinite(summary.latestScore)
|
||||
? summary.latestScore
|
||||
: row.latestPractice?.score;
|
||||
const latestScene = summary?.latestScene?.trim() || row.latestPractice?.scene || row.latestScene;
|
||||
const lastPracticeAt = summary?.lastPracticeAt?.trim() || row.latestPractice?.time || '';
|
||||
const completedToday = typeof summary?.completedToday === 'boolean'
|
||||
? summary.completedToday
|
||||
: undefined;
|
||||
const pendingReview = summary ? normalizedTeamCount(summary.pendingReview) : row.pendingReview;
|
||||
const pendingAssignment = summary ? normalizedTeamCount(summary.pendingAssignment) : row.pendingAssignment;
|
||||
const pendingLowScore = summary && typeof summary.pendingLowScore === 'number' && Number.isFinite(summary.pendingLowScore)
|
||||
? summary.pendingLowScore
|
||||
: row.pendingLowScore;
|
||||
const risk: TeamProfile['risk'] = pendingLowScore !== undefined
|
||||
? 'high'
|
||||
: row.pendingReview > 0
|
||||
: pendingReview > 0
|
||||
? 'medium'
|
||||
: 'low';
|
||||
const reason = risk === 'high'
|
||||
? `${row.name} 有未复盘低分记录(${row.pendingLowScore}分),建议定向再练`
|
||||
? `${row.name} 有未复盘低分记录(${pendingLowScore}分),建议定向再练`
|
||||
: risk === 'medium'
|
||||
? `${row.name} 有 ${row.pendingReview} 条待复盘,建议完成点评后派专项`
|
||||
: row.pendingAssignment > 0
|
||||
? `${row.name} 有 ${row.pendingAssignment} 项待训练,建议跟进完成`
|
||||
? `${row.name} 有 ${pendingReview} 条待复盘,建议完成点评后派专项`
|
||||
: pendingAssignment > 0
|
||||
? `${row.name} 有 ${pendingAssignment} 项待训练,建议跟进完成`
|
||||
: `${row.name} 当前无待处理训练,可按需派发专项`;
|
||||
|
||||
return {
|
||||
@@ -283,9 +313,10 @@ export const buildTeamProfiles = (
|
||||
latestScore,
|
||||
latestScoreText: latestScore === undefined ? '暂无评分' : `${latestScore}分`,
|
||||
latestScene,
|
||||
lastPracticeAt: row.latestPractice?.time || '',
|
||||
pendingReview: row.pendingReview,
|
||||
pendingAssignment: row.pendingAssignment,
|
||||
lastPracticeAt,
|
||||
completedToday,
|
||||
pendingReview,
|
||||
pendingAssignment,
|
||||
reason,
|
||||
risk
|
||||
};
|
||||
@@ -297,6 +328,35 @@ export const buildTeamProfiles = (
|
||||
|| (a.latestScore ?? 100) - (b.latestScore ?? 100));
|
||||
};
|
||||
|
||||
export interface TeamProfileRollup {
|
||||
teamTotal: number;
|
||||
pendingReview: number;
|
||||
pendingAssignment: number;
|
||||
todayDone: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Profiles are the only complete team-level source. Recent activity lists are
|
||||
* intentionally capped, so dashboard counts must not derive from them.
|
||||
*/
|
||||
export const buildTeamProfileRollup = (
|
||||
profiles: TeamProfile[],
|
||||
todayKey: string
|
||||
): TeamProfileRollup => profiles.reduce<TeamProfileRollup>((rollup, profile) => {
|
||||
rollup.teamTotal += 1;
|
||||
rollup.pendingReview += normalizedTeamCount(profile.pendingReview);
|
||||
rollup.pendingAssignment += normalizedTeamCount(profile.pendingAssignment);
|
||||
const completedToday = profile.completedToday === true
|
||||
|| (profile.completedToday === undefined && Boolean(todayKey) && profile.lastPracticeAt.startsWith(todayKey));
|
||||
if (completedToday) rollup.todayDone += 1;
|
||||
return rollup;
|
||||
}, {
|
||||
teamTotal: 0,
|
||||
pendingReview: 0,
|
||||
pendingAssignment: 0,
|
||||
todayDone: 0
|
||||
});
|
||||
|
||||
export const filterTeamProfiles = <T extends Pick<TeamProfile, 'displayName' | 'extPartyId'>>(
|
||||
profiles: T[],
|
||||
query: string
|
||||
@@ -313,7 +373,11 @@ export const paginateTeamProfiles = <T>(profiles: T[], requestedPage: number, pa
|
||||
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),
|
||||
// The team page uses this as a progressive “load more” view, not a
|
||||
// replacement pager. Keep earlier members visible when the next batch is
|
||||
// requested so the control matches its label and does not make the list
|
||||
// jump backward.
|
||||
items: profiles.slice(0, page * safeSize),
|
||||
page,
|
||||
pageCount
|
||||
};
|
||||
|
||||
@@ -540,11 +540,24 @@ export interface PracticeTeamMember {
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** Full-team, lightweight aggregate; detailed records remain intentionally capped. */
|
||||
export interface PracticeTeamProfileSummary {
|
||||
extPartyId: string;
|
||||
latestScore?: number | null;
|
||||
latestScene?: string | null;
|
||||
lastPracticeAt?: string | null;
|
||||
completedToday?: boolean | null;
|
||||
pendingReview?: number | null;
|
||||
pendingLowScore?: number | null;
|
||||
pendingAssignment?: number | null;
|
||||
}
|
||||
|
||||
export interface PracticeTeamSnapshot {
|
||||
members: PracticeTeamMember[];
|
||||
records: PracticeRecord[];
|
||||
assignments: PracticeAssignment[];
|
||||
mistakes: PracticeMistakeAggregate[];
|
||||
profiles?: PracticeTeamProfileSummary[];
|
||||
}
|
||||
|
||||
export interface PracticeScenarioOption {
|
||||
|
||||
Reference in New Issue
Block a user