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:
2026-09-30 09:02:52 +08:00
parent 45271a3c08
commit ad86e2d521
23 changed files with 4997 additions and 20 deletions
@@ -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>