学业风险雷达已接入首页,复用现有正式预警规则、检测、通知和确认流程:
学生首页:显示当前生效预警的自然语言摘要与最多 3 条重点事项,可直达处理页。 辅导员首页:显示所管班级的生效预警学生与详情,可直达完整预警列表。 风险接口加载失败会明确提示,不会误报“暂无风险”。 混合“学生 + 辅导员”角色会优先进入辅导员工作台。
This commit is contained in:
@@ -83,24 +83,42 @@ interface DashboardLink {
|
|||||||
icon: Component
|
icon: Component
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface WarningRecord {
|
||||||
|
id: string
|
||||||
|
studentName: string
|
||||||
|
studentNumber: string
|
||||||
|
className: string
|
||||||
|
status: number
|
||||||
|
detail: string
|
||||||
|
}
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const roles = computed(() => auth.user?.roles ?? [])
|
const roles = computed(() => auth.user?.roles ?? [])
|
||||||
const isStudentOverview = computed(() =>
|
const isStudentOverview = computed(() =>
|
||||||
roles.value.includes('Student') &&
|
roles.value.includes('Student') &&
|
||||||
!roles.value.some((role) =>
|
!roles.value.some((role) =>
|
||||||
['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(role),
|
['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor'].includes(role),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const loadError = ref('')
|
const loadError = ref('')
|
||||||
const data = ref<DashboardData | null>(null)
|
const data = ref<DashboardData | null>(null)
|
||||||
|
const counselorWarnings = ref<WarningRecord[]>([])
|
||||||
const now = ref(new Date())
|
const now = ref(new Date())
|
||||||
|
|
||||||
function hasRole(...allowedRoles: string[]) {
|
function hasRole(...allowedRoles: string[]) {
|
||||||
return roles.value.some((role) => allowedRoles.includes(role))
|
return roles.value.some((role) => allowedRoles.includes(role))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isCounselorDashboard = computed(() =>
|
||||||
|
hasRole('Counselor') && !hasRole('SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'),
|
||||||
|
)
|
||||||
|
|
||||||
|
const activeCounselorWarnings = computed(() =>
|
||||||
|
counselorWarnings.value.filter((warning) => warning.status === 1),
|
||||||
|
)
|
||||||
|
|
||||||
function parseDateOnly(value?: string) {
|
function parseDateOnly(value?: string) {
|
||||||
if (!value) return null
|
if (!value) return null
|
||||||
const [year, month, day] = value.slice(0, 10).split('-').map(Number)
|
const [year, month, day] = value.slice(0, 10).split('-').map(Number)
|
||||||
@@ -366,6 +384,11 @@ async function loadDashboard() {
|
|||||||
loadError.value = ''
|
loadError.value = ''
|
||||||
try {
|
try {
|
||||||
data.value = (await http.get<DashboardData>('/dashboard')).data
|
data.value = (await http.get<DashboardData>('/dashboard')).data
|
||||||
|
if (isCounselorDashboard.value) {
|
||||||
|
counselorWarnings.value = (await http.get<WarningRecord[]>('/warnings/records', {
|
||||||
|
params: { academicTermId: data.value.currentTerm?.id },
|
||||||
|
})).data
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
loadError.value = '教务总览暂时无法加载,请检查服务连接后重试。'
|
loadError.value = '教务总览暂时无法加载,请检查服务连接后重试。'
|
||||||
} finally {
|
} finally {
|
||||||
@@ -483,6 +506,29 @@ onMounted(async () => {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="dashboard-work-grid">
|
<section class="dashboard-work-grid">
|
||||||
|
<article v-if="isCounselorDashboard" class="dashboard-panel counselor-radar">
|
||||||
|
<header class="panel-heading">
|
||||||
|
<div>
|
||||||
|
<span class="panel-index">RADAR / COUNSELOR</span>
|
||||||
|
<h2>需重点关注的学生</h2>
|
||||||
|
<p>{{ activeCounselorWarnings.length ? `当前有 ${activeCounselorWarnings.length} 条生效预警,按最新记录展示。` : '当前没有生效中的学业预警。' }}</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="radar-link" @click="router.push('/warnings')">完整预警 →</button>
|
||||||
|
</header>
|
||||||
|
<div v-if="activeCounselorWarnings.length" class="counselor-risk-list">
|
||||||
|
<button v-for="warning in activeCounselorWarnings.slice(0, 4)" :key="warning.id" type="button" @click="router.push('/warnings')">
|
||||||
|
<span>{{ warning.className }}</span>
|
||||||
|
<b>{{ warning.studentName }}</b>
|
||||||
|
<small>{{ warning.detail }}</small>
|
||||||
|
<i>查看 →</i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="todo-empty">
|
||||||
|
<el-icon><Checked /></el-icon>
|
||||||
|
<div><b>当前没有重点关注学生</b><span>新的学业预警会自动出现在这里。</span></div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
<article class="dashboard-panel todo-panel">
|
<article class="dashboard-panel todo-panel">
|
||||||
<header class="panel-heading">
|
<header class="panel-heading">
|
||||||
<div>
|
<div>
|
||||||
@@ -943,6 +989,28 @@ onMounted(async () => {
|
|||||||
background: var(--dashboard-paper);
|
background: var(--dashboard-paper);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.counselor-radar { border-top: 3px solid var(--dashboard-amber); }
|
||||||
|
.radar-link { padding: 5px 0; border: 0; color: #806136; background: transparent; font-size: 12px; white-space: nowrap; }
|
||||||
|
.radar-link:hover { color: var(--dashboard-blue); }
|
||||||
|
.counselor-risk-list { display: grid; }
|
||||||
|
.counselor-risk-list button {
|
||||||
|
padding: 13px 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 76px 68px minmax(0, 1fr) auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
text-align: left;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid #eee8dc;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.counselor-risk-list button:last-child { border-bottom: 0; }
|
||||||
|
.counselor-risk-list button:hover b { color: var(--dashboard-blue); }
|
||||||
|
.counselor-risk-list span { color: #987337; font: 700 9px/1.3 Consolas, monospace; letter-spacing: .04em; }
|
||||||
|
.counselor-risk-list b { color: #3a4758; font-size: 13px; }
|
||||||
|
.counselor-risk-list small { overflow: hidden; color: #6c7788; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.counselor-risk-list i { color: #8d7042; font-size: 11px; font-style: normal; white-space: nowrap; }
|
||||||
|
|
||||||
.todo-panel,
|
.todo-panel,
|
||||||
.quick-panel,
|
.quick-panel,
|
||||||
.readiness-panel {
|
.readiness-panel {
|
||||||
@@ -1250,6 +1318,9 @@ button:focus-visible {
|
|||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.greeting-insights { padding: 15px; }
|
.greeting-insights { padding: 15px; }
|
||||||
.greeting-insights > div { grid-template-columns: 1fr; }
|
.greeting-insights > div { grid-template-columns: 1fr; }
|
||||||
|
.counselor-risk-list button { grid-template-columns: 1fr auto; }
|
||||||
|
.counselor-risk-list span { grid-column: 1 / -1; }
|
||||||
|
.counselor-risk-list small { white-space: normal; }
|
||||||
.admin-dashboard { gap: 10px; }
|
.admin-dashboard { gap: 10px; }
|
||||||
|
|
||||||
.overview-hero {
|
.overview-hero {
|
||||||
|
|||||||
@@ -83,6 +83,14 @@ interface DashboardGreeting {
|
|||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface WarningItem {
|
||||||
|
id: string
|
||||||
|
type: number
|
||||||
|
status: number
|
||||||
|
detail: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
@@ -93,6 +101,7 @@ const unreadCount = ref(0)
|
|||||||
const exams = ref<ExamItem[]>([])
|
const exams = ref<ExamItem[]>([])
|
||||||
const grades = ref<GradeItem[]>([])
|
const grades = ref<GradeItem[]>([])
|
||||||
const dashboardGreeting = ref<DashboardGreeting | null>(null)
|
const dashboardGreeting = ref<DashboardGreeting | null>(null)
|
||||||
|
const warnings = ref<WarningItem[]>([])
|
||||||
const now = ref(new Date())
|
const now = ref(new Date())
|
||||||
|
|
||||||
const categoryLabels: Record<string, string> = {
|
const categoryLabels: Record<string, string> = {
|
||||||
@@ -173,6 +182,11 @@ const recentGrades = computed(() =>
|
|||||||
.slice(0, 4),
|
.slice(0, 4),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const activeWarnings = computed(() => warnings.value.filter((warning) => warning.status === 1))
|
||||||
|
const radarSummary = computed(() => activeWarnings.value.length
|
||||||
|
? `发现 ${activeWarnings.value.length} 项需要你关注的学习风险,建议优先处理下方提示。`
|
||||||
|
: '系统暂未发现需要你处理的学业风险,继续保持当前学习节奏。')
|
||||||
|
|
||||||
function parseDateOnly(value?: string) {
|
function parseDateOnly(value?: string) {
|
||||||
if (!value) return null
|
if (!value) return null
|
||||||
const [year, month, day] = value.slice(0, 10).split('-').map(Number)
|
const [year, month, day] = value.slice(0, 10).split('-').map(Number)
|
||||||
@@ -296,6 +310,7 @@ async function loadOverview() {
|
|||||||
http.get('/exams/my-schedule'),
|
http.get('/exams/my-schedule'),
|
||||||
http.get('/grades/student/transcript'),
|
http.get('/grades/student/transcript'),
|
||||||
http.get<DashboardGreeting>('/dashboard/greeting'),
|
http.get<DashboardGreeting>('/dashboard/greeting'),
|
||||||
|
http.get<WarningItem[]>('/warnings/my-warnings'),
|
||||||
])
|
])
|
||||||
|
|
||||||
if (results[0].status === 'fulfilled') {
|
if (results[0].status === 'fulfilled') {
|
||||||
@@ -322,6 +337,11 @@ async function loadOverview() {
|
|||||||
if (results[4].status === 'fulfilled') {
|
if (results[4].status === 'fulfilled') {
|
||||||
dashboardGreeting.value = results[4].value.data
|
dashboardGreeting.value = results[4].value.data
|
||||||
}
|
}
|
||||||
|
if (results[5].status === 'fulfilled') {
|
||||||
|
warnings.value = results[5].value.data
|
||||||
|
} else {
|
||||||
|
failedSections.value.push('学业风险雷达')
|
||||||
|
}
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,6 +373,24 @@ onMounted(loadOverview)
|
|||||||
</section>
|
</section>
|
||||||
<p v-if="dashboardGreeting?.narrative" class="student-greeting-narrative">{{ dashboardGreeting.narrative }}</p>
|
<p v-if="dashboardGreeting?.narrative" class="student-greeting-narrative">{{ dashboardGreeting.narrative }}</p>
|
||||||
|
|
||||||
|
<section class="student-risk-radar" :class="{ attention: activeWarnings.length }">
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<span class="panel-kicker">ACADEMIC RADAR</span>
|
||||||
|
<h3>学业风险雷达</h3>
|
||||||
|
</div>
|
||||||
|
<button type="button" @click="router.push('/warnings')">查看全部 <el-icon><ArrowRight /></el-icon></button>
|
||||||
|
</header>
|
||||||
|
<p>{{ radarSummary }}</p>
|
||||||
|
<div v-if="activeWarnings.length" class="risk-list">
|
||||||
|
<button v-for="warning in activeWarnings.slice(0, 3)" :key="warning.id" type="button" @click="router.push('/warnings')">
|
||||||
|
<span>{{ categoryLabels.Warning }}</span>
|
||||||
|
<strong>{{ warning.detail }}</strong>
|
||||||
|
<i>去处理 →</i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<el-alert
|
<el-alert
|
||||||
v-if="failedSections.length"
|
v-if="failedSections.length"
|
||||||
type="warning"
|
type="warning"
|
||||||
@@ -605,6 +643,24 @@ onMounted(loadOverview)
|
|||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.student-risk-radar {
|
||||||
|
padding: 20px 22px;
|
||||||
|
border: 1px solid #dce6eb;
|
||||||
|
background: #fbfdfd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-risk-radar.attention { border-left: 3px solid #c4812a; background: #fffcf6; }
|
||||||
|
.student-risk-radar header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
|
||||||
|
.student-risk-radar h3 { margin: 6px 0 0; color: var(--ink); font-size: 17px; }
|
||||||
|
.student-risk-radar header button { padding: 4px 0; display: inline-flex; align-items: center; gap: 4px; border: 0; color: #526078; background: transparent; font-size: 12px; white-space: nowrap; }
|
||||||
|
.student-risk-radar header button:hover { color: var(--indigo); }
|
||||||
|
.student-risk-radar > p { margin: 13px 0 0; color: #59677a; font-size: 12px; line-height: 1.7; }
|
||||||
|
.risk-list { margin-top: 13px; display: grid; }
|
||||||
|
.risk-list button { padding: 12px 0; display: grid; grid-template-columns: 76px minmax(0, 1fr) auto; gap: 10px; text-align: left; border: 0; border-top: 1px solid #eee6d8; background: transparent; }
|
||||||
|
.risk-list span { align-self: center; color: #a16b1c; font: 700 9px/1.4 Consolas, monospace; letter-spacing: .06em; }
|
||||||
|
.risk-list strong { color: #38475a; font-size: 12px; font-weight: 600; line-height: 1.55; }
|
||||||
|
.risk-list i { align-self: center; color: #9a743d; font-size: 11px; font-style: normal; white-space: nowrap; }
|
||||||
|
|
||||||
.today-status {
|
.today-status {
|
||||||
min-width: 180px;
|
min-width: 180px;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
@@ -945,6 +1001,9 @@ onMounted(loadOverview)
|
|||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.student-greeting-insights { padding: 15px 18px; grid-template-columns: 1fr; }
|
.student-greeting-insights { padding: 15px 18px; grid-template-columns: 1fr; }
|
||||||
|
.student-risk-radar { padding: 18px; }
|
||||||
|
.risk-list button { grid-template-columns: 1fr auto; }
|
||||||
|
.risk-list span { grid-column: 1 / -1; }
|
||||||
.student-overview {
|
.student-overview {
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user