fix(aihr): harden broadcast and mobile assistant flows

This commit is contained in:
2026-07-23 03:35:36 +08:00
parent 62c8b41672
commit 4b31848e3d
30 changed files with 1127 additions and 208 deletions
@@ -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>
+108 -5
View File
@@ -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>
+69 -7
View File
@@ -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;