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;
|
||||
|
||||
Reference in New Issue
Block a user