feat(mobile): unify page states with shared StateBlock component

This commit is contained in:
2026-07-21 01:02:39 +08:00
parent ee28465c30
commit 2198a92bae
14 changed files with 407 additions and 186 deletions
+253
View File
@@ -0,0 +1,253 @@
<template>
<view class="state-block" :class="{ card }">
<template v-if="type === 'loading'">
<view class="state-skeleton" aria-hidden="true">
<view v-for="row in 3" :key="row" class="skeleton-row">
<view class="skeleton-avatar"></view>
<view class="skeleton-lines">
<view class="skeleton-line long"></view>
<view class="skeleton-line short"></view>
</view>
</view>
</view>
<view class="state-loading">
<view class="state-spinner" aria-hidden="true"></view>
<text class="state-loading-text">{{ title || '数据加载中…' }}</text>
</view>
</template>
<template v-else>
<view class="state-icon" :class="type" aria-hidden="true">
<svg v-if="type === 'empty'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="7" y="3" width="10" height="4" rx="1.2" />
<path d="M17 5h1.4A1.6 1.6 0 0 1 20 6.6v12.8a1.6 1.6 0 0 1-1.6 1.6H5.6A1.6 1.6 0 0 1 4 19.4V6.6A1.6 1.6 0 0 1 5.6 5H7" />
<circle cx="10.5" cy="12.5" r="2.6" />
<path d="m12.6 14.6 3.2 3.2" />
</svg>
<svg v-else-if="type === 'error'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M2.5 8.8a15 15 0 0 1 19 0" />
<path d="M5.6 12.4a10.5 10.5 0 0 1 12.8 0" />
<path d="M8.7 15.9a6 6 0 0 1 4.4-1.7" />
<circle cx="17.5" cy="17.5" r="3.4" fill="#ffffff" />
<path d="m16 16 3 3m0-3-3 3" stroke="#dc2626" />
</svg>
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2.8 4.6 5.6v5.2c0 4.6 3.1 8.9 7.4 10.4 4.3-1.5 7.4-5.8 7.4-10.4V5.6z" />
<circle cx="12" cy="10" r="2.2" />
<path d="M8.6 16.4a3.9 3.9 0 0 1 6.8 0" />
</svg>
</view>
<text class="state-title">{{ title || defaultTitle }}</text>
<text v-if="desc || defaultDesc" class="state-desc">{{ desc || defaultDesc }}</text>
<view v-if="actionText || secondaryActionText" class="state-actions">
<button v-if="secondaryActionText" class="state-button secondary" @click="$emit('secondary-action')">{{ secondaryActionText }}</button>
<button v-if="actionText" class="state-button" :class="{ filled: type === 'access' }" @click="$emit('action')">{{ actionText }}</button>
</view>
</template>
</view>
</template>
<script setup lang="ts">
import { computed } from 'vue';
const props = withDefaults(defineProps<{
type: 'loading' | 'empty' | 'error' | 'access';
title?: string;
desc?: string;
actionText?: string;
secondaryActionText?: string;
card?: boolean;
}>(), {
title: '',
desc: '',
actionText: '',
secondaryActionText: '',
card: true
});
defineEmits(['action', 'secondary-action']);
const defaultTitle = computed(() => {
if (props.type === 'error') return '数据加载失败';
if (props.type === 'access') return '当前账号无主管权限';
if (props.type === 'empty') return '暂无数据';
return '';
});
const defaultDesc = computed(() => {
if (props.type === 'error') return '请检查网络后重试';
if (props.type === 'access') return '团队数据仅对主管或项目经理开放';
return '';
});
</script>
<style scoped>
.state-block.card {
margin-top: 16px;
padding: 30px 18px;
border: 1px solid #e5edf7;
border-radius: 12px;
background: #ffffff;
}
.state-block:not(.card) {
padding: 22px 8px;
}
.state-skeleton {
display: flex;
flex-direction: column;
gap: 14px;
}
.skeleton-row {
display: flex;
align-items: center;
gap: 10px;
}
.skeleton-avatar {
width: 36px;
height: 36px;
flex: 0 0 auto;
border-radius: 50%;
background: #eef1f5;
animation: state-pulse 1.4s ease-in-out infinite;
}
.skeleton-lines {
display: flex;
flex-direction: column;
gap: 7px;
flex: 1;
}
.skeleton-line {
height: 10px;
border-radius: 5px;
background: #eef1f5;
animation: state-pulse 1.4s ease-in-out infinite;
}
.skeleton-line.long {
width: 68%;
}
.skeleton-line.short {
width: 42%;
}
@keyframes state-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.45; }
}
.state-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 18px;
}
.state-spinner {
width: 16px;
height: 16px;
border: 2px solid #f3c1c5;
border-top-color: var(--brand);
border-radius: 50%;
animation: state-spin 0.9s linear infinite;
}
@keyframes state-spin {
to { transform: rotate(360deg); }
}
.state-loading-text {
color: var(--employee-muted);
font-size: 13px;
}
.state-icon {
display: flex;
align-items: center;
justify-content: center;
width: 64px;
height: 64px;
margin: 0 auto;
border-radius: 50%;
background: #f4f6f9;
color: #9aa3ae;
}
.state-icon svg {
width: 34px;
height: 34px;
}
.state-icon.error {
color: #8a929c;
}
.state-icon.access {
color: #8a929c;
}
.state-title {
display: block;
margin-top: 14px;
color: var(--employee-text);
font-size: 15px;
font-weight: 900;
text-align: center;
}
.state-desc {
display: block;
margin-top: 6px;
color: var(--employee-muted);
font-size: 12px;
line-height: 1.5;
text-align: center;
}
.state-actions {
display: flex;
justify-content: center;
gap: 10px;
margin-top: 16px;
}
.state-button {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 108px;
min-height: 38px;
margin: 0;
padding: 0 18px;
border: 1px solid var(--brand);
border-radius: 19px;
background: #ffffff;
color: var(--brand);
font-size: 13px;
font-weight: 800;
line-height: 1;
}
.state-button::after {
border: 0;
}
.state-button.filled {
border: 0;
background: var(--brand);
color: #ffffff;
}
.state-button.secondary {
border-color: #d7dde5;
background: #ffffff;
color: #3a3d44;
}
</style>
@@ -10,14 +10,14 @@
<view v-if="message && !accessDenied" class="status-message">{{ message }}</view>
<view v-if="accessDenied" class="panel supervisor-access-panel">
<text class="panel-title">无主管权限</text>
<text class="muted supervisor-access-copy">当前账号不是主管或项目经理,无法查看团队训练数据。</text>
<view class="supervisor-access-actions">
<button class="secondary-button" @click="goToEmployee">返回员工端</button>
<button class="primary-button" @click="switchAccount">切换账号</button>
</view>
</view>
<StateBlock
v-if="accessDenied"
type="access"
action-text="切换账号"
secondary-action-text="返回员工端"
@action="switchAccount"
@secondary-action="goToEmployee"
/>
<view v-else-if="!loggedIn" class="panel login-panel">
<view>
@@ -189,6 +189,7 @@ import { clearAuth, getAuth, isLoggedIn, rememberLoginRedirect } from '@/service
import { resetPageScroll } from '@/services/navigation';
import { createPracticeAssignmentsBatch, getPracticeAlerts, getPracticeScenarios, getPracticeTeamSnapshot } from '@/services/practice';
import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
import StateBlock from '@/components/StateBlock.vue';
import {
createAsyncGenerationGuard,
inferSupervisorScenarioId,
@@ -80,10 +80,14 @@
</view>
</template>
<view v-else class="panel empty-panel">
<view class="empty">暂无本次派发结果,请先在指派页完成派发。</view>
<button class="primary-button empty-action" @click="goToAssign">去指派专项</button>
</view>
<StateBlock
v-else
type="empty"
title="暂无本次派发结果"
desc="请先在指派页完成派发"
action-text="去指派专项"
@action="goToAssign"
/>
<SupervisorTabBar active="team" />
</view>
@@ -94,6 +98,7 @@ import { computed, ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import { resetPageScroll } from '@/services/navigation';
import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
import StateBlock from '@/components/StateBlock.vue';
interface AssignResultItem {
extPartyId: string;
@@ -451,14 +456,4 @@ onShow(() => {
background: #ffffff;
color: var(--brand);
}
.empty-panel {
margin-top: 16px;
text-align: center;
}
.empty-action {
width: 100%;
margin-top: 14px;
}
</style>
@@ -12,7 +12,7 @@
<text class="create-button-plus">+</text>
<text>新建考试</text>
</button>
<text v-if="message" class="status-message">{{ message }}</text>
<text v-if="message && exams.length" class="status-message">{{ message }}</text>
<view class="exam-stats">
<view class="exam-stat">
@@ -40,7 +40,8 @@
>{{ tab.label }}</button>
</view>
<view v-if="loading" class="empty compact-empty">考试列表加载中…</view>
<StateBlock v-if="loading" type="loading" title="考试列表加载中…" :card="false" />
<StateBlock v-else-if="loadFailed" type="error" :card="false" action-text="重新加载" @action="load" />
<template v-else-if="filteredExams.length">
<view v-for="item in filteredExams" :key="item.id" class="exam-card">
<view class="exam-head">
@@ -63,7 +64,7 @@
</view>
</view>
</template>
<view v-else class="empty compact-empty">{{ emptyLabel }}</view>
<StateBlock v-else type="empty" :card="false" :title="emptyLabel" />
</view>
<SupervisorTabBar active="team" />
</view>
@@ -75,6 +76,7 @@ import { onShow } from '@dcloudio/uni-app';
import type { ExamSummary } from '@/types/api';
import { listSupervisorExams } from '@/services/exam';
import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
import StateBlock from '@/components/StateBlock.vue';
type ExamStateKey = 'active' | 'draft' | 'ended';
@@ -111,6 +113,7 @@ const filteredExams = computed(() => (statusFilter.value === 'all'
: exams.value.filter((item) => examState(item).key === statusFilter.value)));
const emptyLabel = computed(() => (statusFilter.value === 'all' ? '暂无考试,先创建岗位题目并派发。' : '当前状态下暂无考试'));
const loadFailed = computed(() => !loading.value && !!message.value && !exams.value.length);
const dueDateLabel = (value?: string | null) => {
const due = value?.slice(0, 10) || '';
@@ -9,7 +9,7 @@
<button v-if="results.length" class="export-button" @click="exportResults">导出结果</button>
</view>
<text v-if="message" class="status-message">{{ message }}</text>
<text v-if="message && results.length" class="status-message">{{ message }}</text>
<view v-if="examTitle" class="exam-name">{{ examTitle }}</view>
@@ -47,7 +47,9 @@
>{{ tab.label }}</button>
</view>
<view v-if="filteredResults.length" class="panel result-panel">
<StateBlock v-if="firstLoading" type="loading" title="考试结果加载中…" />
<StateBlock v-else-if="loadFailed" type="error" action-text="重新加载" @action="load" />
<view v-else-if="filteredResults.length" class="panel result-panel">
<view v-for="item in filteredResults" :key="item.attemptId || item.extPartyId" class="result-row">
<view class="result-avatar">
<uni-icons type="person" size="20" color="#9aa0a6" />
@@ -60,9 +62,7 @@
<text class="result-badge" :class="statusClass(item)">{{ statusLabel(item) }}</text>
</view>
</view>
<view v-else-if="!loading" class="panel empty-panel">
<view class="empty">暂无符合筛选的派发结果</view>
</view>
<StateBlock v-else type="empty" title="暂无符合筛选的派发结果" />
<SupervisorTabBar active="team" />
</view>
</template>
@@ -73,6 +73,7 @@ import { onLoad } from '@dcloudio/uni-app';
import type { ExamResultItem } from '@/types/api';
import { getSupervisorExam, getSupervisorExamResults } from '@/services/exam';
import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
import StateBlock from '@/components/StateBlock.vue';
type ResultFilter = 'all' | 'passed' | 'failed' | 'pending';
@@ -103,6 +104,8 @@ const filteredResults = computed(() => results.value.filter((item) => {
if (resultFilter.value === 'pending') return isPending(item);
return true;
}));
const loadFailed = computed(() => !loading.value && !!message.value && !results.value.length);
const firstLoading = computed(() => loading.value && !results.value.length && !message.value);
const statusLabel = (item: ExamResultItem) => (isPending(item) ? '未完成' : isPassed(item) ? '通过' : '未通过');
const statusClass = (item: ExamResultItem) => (isPending(item) ? 'pending' : isPassed(item) ? 'passed' : 'failed');
@@ -386,8 +389,4 @@ onLoad((options) => {
background: #f1f5f9;
color: #64748b;
}
.empty-panel {
margin-top: 12px;
}
</style>
+21 -26
View File
@@ -22,16 +22,16 @@
</view>
</view>
<view v-if="message && !accessDenied" class="status-message">{{ message }}</view>
<view v-if="message && !accessDenied && hasData" class="status-message">{{ message }}</view>
<view v-if="accessDenied" class="panel supervisor-access-panel">
<text class="panel-title">无主管权限</text>
<text class="muted supervisor-access-copy">当前账号不是主管或项目经理,无法查看团队训练数据。</text>
<view class="supervisor-access-actions">
<button class="secondary-button" @click="goToEmployee">返回员工端</button>
<button class="primary-button" @click="switchAccount">切换账号</button>
</view>
</view>
<StateBlock
v-if="accessDenied"
type="access"
action-text="切换账号"
secondary-action-text="返回员工端"
@action="switchAccount"
@secondary-action="goToEmployee"
/>
<view v-else-if="!loggedIn" class="panel login-panel">
<view>
@@ -41,6 +41,10 @@
<button class="primary-button login-button" @click="goLogin">去登录</button>
</view>
<StateBlock v-else-if="loadFailed" type="error" action-text="重新加载" @action="loadPage" />
<StateBlock v-else-if="firstLoading" type="loading" title="工作台数据加载中…" />
<template v-else>
<view class="panel team-panel">
<view class="panel-head">
@@ -148,6 +152,7 @@ import { getPracticeAlerts, getPracticeReviews, getPracticeTeamSnapshot } from '
import { getMobileHome } from '@/services/home';
import { apiRequest } from '@/services/api';
import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
import StateBlock from '@/components/StateBlock.vue';
import {
buildSupervisorMetrics,
buildTeamProfiles,
@@ -195,6 +200,13 @@ const completionRate = computed(() =>
teamTotal.value ? Math.min(100, Math.round((todayDone.value / teamTotal.value) * 100)) : 0);
const attentionCount = computed(() =>
teamProfiles.value.filter((item) => item.risk === 'high' || item.risk === 'medium').length);
const hasData = computed(() =>
members.value.length > 0
|| records.value.length > 0
|| assignments.value.length > 0
|| pendingReviews.value.length > 0);
const loadFailed = computed(() => !loading.value && !!message.value && !hasData.value);
const firstLoading = computed(() => loading.value && !hasData.value && !message.value);
interface TodoRow {
key: string;
@@ -907,21 +919,4 @@ onUnload(invalidateRequests);
font-size: 14px;
line-height: 1.5;
}
.supervisor-access-panel {
padding: 18px;
}
.supervisor-access-copy {
display: block;
margin-top: 8px;
font-size: 14px;
line-height: 1.5;
}
.supervisor-access-actions {
display: flex;
gap: 10px;
margin-top: 14px;
}
</style>
@@ -11,16 +11,16 @@
</button>
</view>
<view v-if="message && !accessDenied" class="status-message">{{ message }}</view>
<view v-if="message && !accessDenied && hasData" class="status-message">{{ message }}</view>
<view v-if="accessDenied" class="panel supervisor-access-panel">
<text class="panel-title">无主管权限</text>
<text class="muted supervisor-access-copy">当前账号不是主管或项目经理,无法查看成员训练数据。</text>
<view class="supervisor-access-actions">
<button class="secondary-button" @click="goToEmployee">返回员工端</button>
<button class="primary-button" @click="switchAccount">切换账号</button>
</view>
</view>
<StateBlock
v-if="accessDenied"
type="access"
action-text="切换账号"
secondary-action-text="返回员工端"
@action="switchAccount"
@secondary-action="goToEmployee"
/>
<view v-else-if="!loggedIn" class="panel login-panel">
<view>
@@ -30,6 +30,10 @@
<button class="primary-button login-button" @click="goLogin">去登录</button>
</view>
<StateBlock v-else-if="loadFailed" type="error" action-text="重新加载" @action="loadPage" />
<StateBlock v-else-if="firstLoading" type="loading" title="成员数据加载中…" />
<template v-else>
<view class="panel member-head">
<view class="member-avatar large">
@@ -128,6 +132,7 @@ import { clearAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { resetPageScroll } from '@/services/navigation';
import { getPracticeTeamSnapshot } from '@/services/practice';
import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
import StateBlock from '@/components/StateBlock.vue';
import {
buildTeamProfiles,
inferSupervisorScenarioId,
@@ -189,6 +194,9 @@ const lowScoreScene = computed(() => {
return '';
});
const memberInitial = computed(() => displayName.value.trim().charAt(0) || '员');
const hasData = computed(() => records.value.length > 0 || assignments.value.length > 0);
const loadFailed = computed(() => !loading.value && !!message.value && !hasData.value);
const firstLoading = computed(() => loading.value && !hasData.value && !message.value);
const riskBadgeText = computed(() => {
const risk = profile.value?.risk;
return risk === 'high' ? '高风险' : risk === 'medium' ? '待复盘' : '正常';
+15 -13
View File
@@ -8,7 +8,7 @@
</view>
</view>
<view v-if="message" class="status-message">{{ message }}</view>
<view v-if="message && detail" class="status-message">{{ message }}</view>
<template v-if="detail">
<view class="panel success-panel">
@@ -75,10 +75,17 @@
</view>
</template>
<view v-else-if="!loading" class="panel empty-panel">
<view class="empty">复盘结果暂不可用</view>
<button class="secondary-button back-list-button" @click="goToReviews">返回待复盘</button>
</view>
<StateBlock v-else-if="loadFailed" type="error" action-text="重新加载" @action="loadPage" />
<StateBlock v-else-if="loading" type="loading" title="复盘结果加载中…" />
<StateBlock
v-else
type="empty"
title="复盘结果暂不可用"
action-text="返回待复盘"
@action="goToReviews"
/>
<SupervisorTabBar active="review" />
</view>
</template>
@@ -91,6 +98,7 @@ import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { resetPageScroll } from '@/services/navigation';
import { getPracticeAssignments, getPracticeReviewDetail, getPracticeTeamSnapshot } from '@/services/practice';
import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
import StateBlock from '@/components/StateBlock.vue';
const basePath = '/pages/supervisor/review/done';
const reviewId = ref(0);
@@ -105,6 +113,8 @@ const weekNames = ['周日', '周一', '周二', '周三', '周四', '周五', '
const now = new Date();
const doneTimeLabel = `今天 ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')} · ${now.getMonth() + 1}月${now.getDate()}日 ${weekNames[now.getDay()]}`;
const loadFailed = computed(() => !loading.value && !!message.value && !detail.value);
const displayName = computed(() => {
const identity = (detail.value?.extPartyId || '').trim();
const mapped = identity ? memberNames.value[identity] : '';
@@ -427,12 +437,4 @@ onShow(() => {
background: #ffffff;
color: #3a3d44;
}
.empty-panel {
padding-bottom: 18px;
}
.back-list-button {
width: 100%;
}
</style>
@@ -11,16 +11,16 @@
</button>
</view>
<view v-if="message && !accessDenied" class="status-message">{{ message }}</view>
<view v-if="message && !accessDenied && detail" class="status-message">{{ message }}</view>
<view v-if="accessDenied" class="panel supervisor-access-panel">
<text class="panel-title">无主管权限</text>
<text class="muted supervisor-access-copy">当前账号不是主管或项目经理,无法查看团队训练数据。</text>
<view class="supervisor-access-actions">
<button class="secondary-button" @click="goToEmployee">返回员工端</button>
<button class="primary-button" @click="switchAccount">切换账号</button>
</view>
</view>
<StateBlock
v-if="accessDenied"
type="access"
action-text="切换账号"
secondary-action-text="返回员工端"
@action="switchAccount"
@secondary-action="goToEmployee"
/>
<view v-else-if="!loggedIn" class="panel login-panel">
<view>
@@ -30,6 +30,10 @@
<button class="primary-button login-button" @click="goLogin">去登录</button>
</view>
<StateBlock v-else-if="loadFailed" type="error" action-text="重新加载" @action="loadDetail" />
<StateBlock v-else-if="firstLoading" type="loading" title="复盘详情加载中…" />
<template v-else-if="detail">
<view class="panel head-panel">
<view class="head-main">
@@ -206,10 +210,13 @@
</view>
</template>
<view v-else-if="loggedIn && !loading" class="panel empty-panel">
<view class="empty">{{ reviewId ? '复盘详情暂不可用' : '缺少有效的复盘ID' }}</view>
<button class="secondary-button back-list-button" @click="goToReviews">返回待复盘</button>
</view>
<StateBlock
v-else-if="loggedIn && !loading"
type="empty"
:title="reviewId ? '复盘详情暂不可用' : '缺少有效的复盘ID'"
action-text="返回待复盘"
@action="goToReviews"
/>
<SupervisorTabBar v-if="loggedIn && !accessDenied" active="review" />
</view>
</template>
@@ -223,6 +230,7 @@ import { clearAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { resetPageScroll } from '@/services/navigation';
import { getPracticeReviewDetail, getPracticeScenarios, getPracticeTeamSnapshot, markPracticeReviewed } from '@/services/practice';
import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
import StateBlock from '@/components/StateBlock.vue';
import {
createAsyncGenerationGuard,
isReviewEditable,
@@ -251,6 +259,8 @@ const audioErrors = ref<Record<string, string>>({});
const audioRequests = createAsyncGenerationGuard();
const memberNames = ref<Record<string, string>>({});
const editable = computed(() => isReviewEditable(detail.value?.status));
const loadFailed = computed(() => !loading.value && !!message.value && !detail.value);
const firstLoading = computed(() => loading.value && !detail.value && !message.value);
const displayName = computed(() => {
const identity = (detail.value?.extPartyId || '').trim();
@@ -588,19 +598,6 @@ onBeforeUnmount(() => {
line-height: 1.5;
}
.supervisor-access-copy {
display: block;
margin-top: 8px;
font-size: 14px;
line-height: 1.5;
}
.supervisor-access-actions {
display: flex;
gap: 10px;
margin-top: 14px;
}
.panel-head {
display: flex;
align-items: center;
@@ -1161,12 +1158,4 @@ onBeforeUnmount(() => {
font-size: 13px;
line-height: 1.5;
}
.empty-panel {
padding-bottom: 18px;
}
.back-list-button {
width: 100%;
}
</style>
@@ -11,16 +11,16 @@
</button>
</view>
<view v-if="message && !accessDenied" class="status-message">{{ message }}</view>
<view v-if="message && !accessDenied && reviews.length" class="status-message">{{ message }}</view>
<view v-if="accessDenied" class="panel supervisor-access-panel">
<text class="panel-title">无主管权限</text>
<text class="muted supervisor-access-copy">当前账号不是主管或项目经理,无法查看团队训练数据。</text>
<view class="supervisor-access-actions">
<button class="secondary-button" @click="goToEmployee">返回员工端</button>
<button class="primary-button" @click="switchAccount">切换账号</button>
</view>
</view>
<StateBlock
v-if="accessDenied"
type="access"
action-text="切换账号"
secondary-action-text="返回员工端"
@action="switchAccount"
@secondary-action="goToEmployee"
/>
<view v-else-if="!loggedIn" class="panel login-panel">
<view>
@@ -30,6 +30,10 @@
<button class="primary-button login-button" @click="goLogin">去登录</button>
</view>
<StateBlock v-else-if="loadFailed" type="error" action-text="重新加载" @action="loadPage" />
<StateBlock v-else-if="firstLoading" type="loading" title="复盘数据加载中…" />
<template v-else>
<view class="filter-bar">
<view class="filter-tabs">
@@ -99,6 +103,7 @@ import { clearAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { resetPageScroll } from '@/services/navigation';
import { getPracticeAlerts, getPracticeReviews, getPracticeTeamSnapshot } from '@/services/practice';
import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
import StateBlock from '@/components/StateBlock.vue';
import { buildSupervisorMetrics, isSupervisorAccessDenied } from '@/services/supervisor-view';
type FilterKey = 'all' | 'below60' | 'repeatLow';
@@ -186,6 +191,8 @@ const displayedReviews = computed<ReviewRow[]>(() => {
? a.record.score - b.record.score
: b.record.time.localeCompare(a.record.time));
});
const loadFailed = computed(() => !loading.value && !!message.value && !reviews.value.length);
const firstLoading = computed(() => loading.value && !reviews.value.length && !message.value);
const clearData = () => {
reviews.value = [];
@@ -358,23 +365,6 @@ onShow(() => {
line-height: 1.5;
}
.supervisor-access-panel {
padding: 18px;
}
.supervisor-access-copy {
display: block;
margin-top: 8px;
font-size: 14px;
line-height: 1.5;
}
.supervisor-access-actions {
display: flex;
gap: 10px;
margin-top: 14px;
}
/* 筛选与排序 */
.filter-bar {
display: flex;
+18 -10
View File
@@ -11,16 +11,16 @@
</button>
</view>
<view v-if="message && !accessDenied" class="status-message">{{ message }}</view>
<view v-if="message && !accessDenied && hasData" class="status-message">{{ message }}</view>
<view v-if="accessDenied" class="panel supervisor-access-panel">
<text class="panel-title">无主管权限</text>
<text class="muted supervisor-access-copy">当前账号不是主管或项目经理,无法查看团队训练数据。</text>
<view class="supervisor-access-actions">
<button class="secondary-button" @click="goToEmployee">返回员工端</button>
<button class="primary-button" @click="switchAccount">切换账号</button>
</view>
</view>
<StateBlock
v-if="accessDenied"
type="access"
action-text="切换账号"
secondary-action-text="返回员工端"
@action="switchAccount"
@secondary-action="goToEmployee"
/>
<view v-else-if="!loggedIn" class="panel login-panel">
<view>
@@ -30,6 +30,10 @@
<button class="primary-button login-button" @click="goLogin">去登录</button>
</view>
<StateBlock v-else-if="loadFailed" type="error" action-text="重新加载" @action="loadPage" />
<StateBlock v-else-if="firstLoading" type="loading" title="团队数据加载中…" />
<template v-else>
<view class="risk-summary">
<view class="risk-item high">
@@ -102,7 +106,7 @@
<text v-else class="team-arrow">›</text>
</view>
</view>
<view v-else-if="!loading" class="empty">暂无团队画像数据</view>
<StateBlock v-else-if="!loading" type="empty" title="暂无团队画像数据" :card="false" />
</view>
</template>
<SupervisorTabBar v-if="loggedIn && !accessDenied" active="team" />
@@ -117,6 +121,7 @@ import { clearAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { resetPageScroll } from '@/services/navigation';
import { assignPracticeDifficulty, getPracticeDifficulties, getPracticeTeamSnapshot } from '@/services/practice';
import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
import StateBlock from '@/components/StateBlock.vue';
import {
buildTeamProfiles,
inferSupervisorScenarioId,
@@ -158,6 +163,9 @@ const sortedDifficulties = computed(() => [...mistakes.value].sort((a, b) => {
if (difficultySort.value === 'score') return a.averageScore - b.averageScore;
return b.count - a.count;
}));
const hasData = computed(() => teamProfiles.value.length > 0 || mistakes.value.length > 0);
const loadFailed = computed(() => !loading.value && !!message.value && !hasData.value);
const firstLoading = computed(() => loading.value && !hasData.value && !message.value);
const clearData = () => {
records.value = [];
+2 -1
View File
@@ -101,7 +101,7 @@
</view>
</view>
</view>
<view v-else class="empty">暂无入库案例</view>
<StateBlock v-else type="empty" :card="false" title="暂无入库案例" />
</view>
<view v-if="caseDetail" class="panel case-detail-panel">
@@ -147,6 +147,7 @@ import { releaseAuthenticatedFileUrl } from '@/services/api';
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { navigateBackToToday, resetPageScroll } from '@/services/navigation';
import { ensureEmployeePosition } from '@/services/position';
import StateBlock from '@/components/StateBlock.vue';
const loggedIn = ref(false);
const selectedFile = ref<CaseSelectedFile | null>(null);
+10 -8
View File
@@ -71,10 +71,13 @@
</view>
</template>
<view v-else-if="loggedIn && !loading" class="panel empty-panel">
<text class="empty">{{ message || '训练记录不存在或不属于当前账号' }}</text>
<button class="secondary-button" @click="goBack">返回我的页面</button>
</view>
<StateBlock
v-else-if="loggedIn && !loading"
type="empty"
:title="message || '训练记录不存在或不属于当前账号'"
action-text="返回我的页面"
@action="goBack"
/>
</view>
</template>
@@ -86,6 +89,7 @@ import { authenticatedFileUrl, releaseAuthenticatedFileUrl } from '@/services/ap
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { getPracticeHistoryDetail } from '@/services/practice';
import { resetPageScroll } from '@/services/navigation';
import StateBlock from '@/components/StateBlock.vue';
const detail = ref<ReviewDetail | null>(null);
const reviewId = ref(0);
@@ -335,13 +339,11 @@ onUnload(releaseAudio);
color: #fff;
}
.login-panel,
.empty-panel {
.login-panel {
text-align: center;
}
.login-button,
.empty-panel button {
.login-button {
margin-top: 14px;
}
</style>
-25
View File
@@ -293,31 +293,6 @@ textarea:focus-visible {
line-height: 1.5;
}
.supervisor-access-panel {
border-left: 4px solid var(--brand);
}
.supervisor-access-copy {
display: block;
margin-top: 8px;
font-size: 14px;
line-height: 1.6;
}
.supervisor-access-actions {
display: grid;
margin-top: 18px;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.supervisor-access-actions button {
width: 100%;
min-width: 0;
padding: 0 10px;
font-size: 14px;
}
.empty {
padding: 28px 12px;
color: var(--muted);