feat(mp): 学生端「我的填报 / 撤回 / 进度」与班主任端看板、审核卡片流、代传材料
学生端:待办数、状态、驳回原因、截止提醒、撤回;进度页的材料清单与进度条 分母用「这个人本次真的要交什么」(不适用的必传材料不进分母,否则永远填不满)。 班主任端:已交率与已通过分开呈现(已提交 ≠ 已通过)、右滑通过 / 左滑不绑驳回、 一键通过只带无问题项;代传走一步 multipart,三道闸门(只给自负责的班 / 只给草稿单 / 必须留痕且学生可见),V12 给 collect_file 补 proxy_by / proxy_reason。 验证:e2e-m3-mytask 42/42、e2e-m5-teacher-dashboard 24/24、e2e-m5-proxy 33/33; H5 build:h5 产物含新增各页。
This commit is contained in:
@@ -0,0 +1,404 @@
|
||||
<template>
|
||||
<view class="dash">
|
||||
<view class="dash__wrap">
|
||||
<view class="dash__hero">
|
||||
<text class="dash__title">我带的班</text>
|
||||
<text class="dash__sub">
|
||||
应填 {{ s.rosterTotal }} 人 · 已交 {{ s.submittedCount }} 人 · 已通过 {{ s.approvedCount }} 人
|
||||
</text>
|
||||
<!--
|
||||
⚠️ 这个百分比是**已提交率**,不是通过率(契约 §10.1 的口径铁律)。
|
||||
所以标签就写"已交率",旁边单独列"已通过" —— 合并成一个"完成率"之后,
|
||||
老师看到 60% 不知道该去催人还是该去看材料。
|
||||
-->
|
||||
<view class="dash__rate">
|
||||
<text class="dash__rate-num">{{ s.submitRate }}</text>
|
||||
<text class="dash__rate-label">已交率</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 待办:这一屏最该被点的是"待我初审" -->
|
||||
<view class="dash__todos">
|
||||
<view class="dash__todo" @click="goAudit">
|
||||
<text class="dash__todo-num">{{ t.auditCount }}</text>
|
||||
<text class="dash__todo-label">待我初审</text>
|
||||
</view>
|
||||
<view class="dash__todo">
|
||||
<text class="dash__todo-num">{{ s.unsubmittedCount }}</text>
|
||||
<text class="dash__todo-label">还没交</text>
|
||||
</view>
|
||||
<view class="dash__todo">
|
||||
<text class="dash__todo-num">{{ t.rejectedAwaitingCount }}</text>
|
||||
<text class="dash__todo-label">驳回等改</text>
|
||||
</view>
|
||||
<view class="dash__todo">
|
||||
<text class="dash__todo-num">{{ s.abnormalCount }}</text>
|
||||
<text class="dash__todo-label">有异常</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 截止提醒:只剩几天是最容易被忽略、后果又最重的事 -->
|
||||
<view v-if="d && d.taskName" class="dash__deadline" :class="deadlineClass">
|
||||
<text class="dash__deadline-title">最近截止:{{ d.taskName }}</text>
|
||||
<text class="dash__deadline-sub">{{ fmtDate(d.endTime) }} {{ remainText }}</text>
|
||||
</view>
|
||||
|
||||
<view class="dash__section">我的班级</view>
|
||||
<view v-if="loading" class="dash__empty">正在读取…</view>
|
||||
<view v-else-if="!orgs.length" class="dash__empty">
|
||||
你还没有被指定为任何班级的初审人。请让管理员在「收集任务 → 配置班级」里指定。
|
||||
</view>
|
||||
|
||||
<view v-for="o in orgs" :key="o.orgId" class="dash__card" @click="goAudit(o)">
|
||||
<view class="dash__card-head">
|
||||
<text class="dash__org">{{ o.orgName }}</text>
|
||||
<text v-if="o.isHead" class="dash__tag dash__tag--head">主班主任</text>
|
||||
<text v-else class="dash__tag">副班主任</text>
|
||||
</view>
|
||||
<view class="dash__card-row">
|
||||
<text class="dash__muted">应填 {{ o.rosterTotal }} · 已交 {{ o.submittedCount }}</text>
|
||||
<text class="dash__muted">已通过 {{ o.approvedCount }}</text>
|
||||
</view>
|
||||
<view class="dash__card-foot">
|
||||
<text class="dash__rate-small">已交率 {{ o.submitRate }}</text>
|
||||
<text v-if="o.auditCount" class="dash__badge">待审 {{ o.auditCount }}</text>
|
||||
<text v-else class="dash__muted">没有待审</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="dash__foot">
|
||||
只统计<text class="dash__strong">我负责的班</text>。已交率与已通过分开算 ——
|
||||
还有人没交要先催,有人没过要看材料。
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { request } from '@/utils/request'
|
||||
|
||||
/**
|
||||
* 班主任看板(《03-接口设计》§10.1,原型「班级看板」)。
|
||||
*
|
||||
* ## 两件事决定了这一屏对不对
|
||||
*
|
||||
* 1. **已交率 ≠ 通过率**。契约 §10.1 的口径铁律:`submitRate` 是
|
||||
* `已提交 / 应填`,与 `approvedCount` **分开呈现**。合并之后
|
||||
* 「还有人没交」(去催报)和「有人没过」(去看材料)会变成同一个数字。
|
||||
* 所以界面上就写"已交率",旁边单独列"已通过"。
|
||||
* 2. **只统计我负责的班**。范围由服务端按"指定初审人 → 副班主任 → 班级负责人"
|
||||
* 三级回退算好;前端**不要**再按 orgId 过滤(前端过滤不了越权,
|
||||
* 只会在口径不一致时制造假象)。
|
||||
*/
|
||||
defineOptions({ name: 'TeacherDashboard' })
|
||||
|
||||
interface Summary {
|
||||
rosterTotal: number
|
||||
submittedCount: number
|
||||
approvedCount: number
|
||||
pendingCount: number
|
||||
unsubmittedCount: number
|
||||
abnormalCount: number
|
||||
submitRate: string
|
||||
}
|
||||
|
||||
interface OrgStat {
|
||||
orgId: number
|
||||
orgName: string
|
||||
rosterTotal: number
|
||||
submittedCount: number
|
||||
approvedCount: number
|
||||
submitRate: string
|
||||
isHead: boolean
|
||||
auditCount: number
|
||||
}
|
||||
|
||||
interface Dashboard {
|
||||
summary: Summary
|
||||
myOrgs: OrgStat[]
|
||||
todos: { auditCount: number, abnormalCount: number, rejectedAwaitingCount: number }
|
||||
deadline?: { taskId: number, taskName: string, endTime: string, remainDays: number }
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const s = reactive<Summary>({
|
||||
rosterTotal: 0, submittedCount: 0, approvedCount: 0,
|
||||
pendingCount: 0, unsubmittedCount: 0, abnormalCount: 0, submitRate: '—'
|
||||
})
|
||||
const t = reactive({ auditCount: 0, abnormalCount: 0, rejectedAwaitingCount: 0 })
|
||||
const orgs = ref<OrgStat[]>([])
|
||||
const d = ref<Dashboard['deadline']>()
|
||||
|
||||
function token(): string {
|
||||
try {
|
||||
return uni.getStorageSync('token') || ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function fmtDate(v?: string) {
|
||||
return v ? v.replace('T', ' ').slice(0, 10) : '—'
|
||||
}
|
||||
|
||||
const remainText = computed(() => {
|
||||
const r = d.value?.remainDays
|
||||
if (r === undefined || r === null) {
|
||||
return ''
|
||||
}
|
||||
if (r < 0) {
|
||||
return '(已过截止)'
|
||||
}
|
||||
return r === 0 ? '(今天截止)' : `(还剩 ${r} 天)`
|
||||
})
|
||||
|
||||
const deadlineClass = computed(() => {
|
||||
const r = d.value?.remainDays
|
||||
if (r === undefined || r === null) {
|
||||
return ''
|
||||
}
|
||||
if (r < 0) {
|
||||
return 'dash__deadline--over'
|
||||
}
|
||||
return r <= 3 ? 'dash__deadline--soon' : ''
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await request<Dashboard>({
|
||||
url: '/api/v1/mp/teacher/dashboard',
|
||||
token: token()
|
||||
})
|
||||
Object.assign(s, data.summary || {})
|
||||
Object.assign(t, data.todos || {})
|
||||
orgs.value = data.myOrgs || []
|
||||
d.value = data.deadline
|
||||
} catch (e) {
|
||||
uni.showToast({ title: e instanceof Error ? e.message : '读取失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goAudit(o?: OrgStat) {
|
||||
// 角标与卡片都跳到审核卡片流;带 orgId 时只看那个班
|
||||
uni.navigateTo({ url: `/pages/audit/audit${o ? `?orgId=${o.orgId}` : ''}` })
|
||||
}
|
||||
|
||||
// onShow:从审核页返回时数字要跟着变(审完一个,"待我初审"就该少 1)
|
||||
onShow(load)
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
$primary: #0052d9;
|
||||
$success: #00a870;
|
||||
$danger: #e34d59;
|
||||
$warn: #ed7b2f;
|
||||
$page-bg: #f3f3f3;
|
||||
|
||||
page {
|
||||
background-color: $page-bg;
|
||||
}
|
||||
|
||||
.dash {
|
||||
min-height: 100vh;
|
||||
padding: 24rpx;
|
||||
|
||||
&__wrap {
|
||||
max-width: 720rpx;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
&__hero {
|
||||
padding: 32rpx 28rpx;
|
||||
margin-bottom: 20rpx;
|
||||
background: linear-gradient(135deg, $primary 0%, #2b6fe0 100%);
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
&__title {
|
||||
display: block;
|
||||
font-size: 36rpx;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&__sub {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
font-size: 25rpx;
|
||||
line-height: 1.6;
|
||||
color: rgb(255 255 255 / 88%);
|
||||
}
|
||||
|
||||
&__rate {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12rpx;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
&__rate-num {
|
||||
font-size: 52rpx;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&__rate-label {
|
||||
font-size: 24rpx;
|
||||
color: rgb(255 255 255 / 85%);
|
||||
}
|
||||
|
||||
&__todos {
|
||||
display: flex;
|
||||
padding: 20rpx 0;
|
||||
margin-bottom: 20rpx;
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
&__todo {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__todo-num {
|
||||
display: block;
|
||||
font-size: 40rpx;
|
||||
font-weight: 600;
|
||||
color: #1b2430;
|
||||
}
|
||||
|
||||
&__todo-label {
|
||||
display: block;
|
||||
margin-top: 6rpx;
|
||||
font-size: 23rpx;
|
||||
color: #8b95a3;
|
||||
}
|
||||
|
||||
&__deadline {
|
||||
padding: 20rpx 24rpx;
|
||||
margin-bottom: 20rpx;
|
||||
font-size: 25rpx;
|
||||
line-height: 1.7;
|
||||
color: #4a5768;
|
||||
background: #f7f8fa;
|
||||
border-radius: 12rpx;
|
||||
|
||||
&--soon {
|
||||
color: #8a5a12;
|
||||
background: #fdf2e9;
|
||||
}
|
||||
|
||||
&--over {
|
||||
color: #a03a44;
|
||||
background: #fdecee;
|
||||
}
|
||||
}
|
||||
|
||||
&__deadline-title {
|
||||
display: block;
|
||||
}
|
||||
|
||||
&__deadline-sub {
|
||||
display: block;
|
||||
margin-top: 4rpx;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
&__section {
|
||||
margin: 24rpx 0 12rpx;
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #1b2430;
|
||||
}
|
||||
|
||||
&__empty {
|
||||
padding: 32rpx 28rpx;
|
||||
font-size: 26rpx;
|
||||
line-height: 1.7;
|
||||
color: #8b95a3;
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
&__card {
|
||||
padding: 24rpx 28rpx;
|
||||
margin-bottom: 16rpx;
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
&__card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
&__org {
|
||||
flex: 1;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #1b2430;
|
||||
}
|
||||
|
||||
&__tag {
|
||||
padding: 4rpx 12rpx;
|
||||
font-size: 21rpx;
|
||||
color: #8b95a3;
|
||||
background: #f2f3f5;
|
||||
border-radius: 6rpx;
|
||||
|
||||
&--head {
|
||||
color: $primary;
|
||||
background: #eaf1fd;
|
||||
}
|
||||
}
|
||||
|
||||
&__card-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
|
||||
&__card-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
|
||||
&__rate-small {
|
||||
font-size: 24rpx;
|
||||
color: #4a5768;
|
||||
}
|
||||
|
||||
&__badge {
|
||||
padding: 4rpx 14rpx;
|
||||
font-size: 22rpx;
|
||||
color: #fff;
|
||||
background: $warn;
|
||||
border-radius: 20rpx;
|
||||
}
|
||||
|
||||
&__muted {
|
||||
font-size: 24rpx;
|
||||
color: #8b95a3;
|
||||
}
|
||||
|
||||
&__strong {
|
||||
font-weight: 600;
|
||||
color: #4a5768;
|
||||
}
|
||||
|
||||
&__foot {
|
||||
margin-top: 32rpx;
|
||||
font-size: 23rpx;
|
||||
line-height: 1.8;
|
||||
color: #b3b9c1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user