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
+531
View File
@@ -0,0 +1,531 @@
<template>
<view class="tasks">
<view class="tasks__wrap">
<!-- 待办提示:学生打开这一屏最想知道的是"我还有什么没交" -->
<view class="tasks__hero">
<text class="tasks__title">我的填报</text>
<text class="tasks__sub">
共 {{ data.total }} 项,其中 <text class="tasks__strong">{{ data.todoCount }}</text> 项待完成
</text>
</view>
<!-- 消息入口:一期推送靠一次性授权,拿不到授权时消息**只在这里**,
所以入口要常驻,并且带未读角标(否则学生不会点进来) -->
<view class="tasks__msg" @click="goMessages">
<text class="tasks__msg-title">我的消息</text>
<text v-if="unread" class="tasks__msg-badge">{{ unread }}</text>
<text v-else class="tasks__msg-sub">暂时没有未读</text>
</view>
<!--
老师入口:这一屏对老师和学生是**同一个页面**(都叫"我的填报"),
因为老师也常常是家长/学员,两套导航反而让人找不到。
但老师多一个入口 —— 靠 /auth/me 的角色判断,而不是让他自己选身份。
-->
<view v-if="isTeacher" class="tasks__teacher" @click="goDashboard">
<text class="tasks__teacher-title">我是老师 · 进班级看板</text>
<text class="tasks__teacher-sub">看待审、右滑通过、看班上谁还没交</text>
</view>
<view v-if="loading" class="tasks__empty">正在读取…</view>
<view v-else-if="!data.list.length" class="tasks__empty">
<text>老师还没有把你加入任何一次认定的名单。</text>
<text class="tasks__empty-hint">
如果你已经拿到填报二维码,扫一下就能核对身份;核对不上可以在那个页面申请加入名单。
</text>
</view>
<view v-for="t in data.list" :key="t.taskId" class="tasks__card" @click="open(t)">
<view class="tasks__head">
<text class="tasks__name">{{ t.taskName }}</text>
<text class="tasks__status" :class="statusClass(t)">{{ t.statusText }}</text>
</view>
<view class="tasks__row">
<text class="tasks__label">报考</text>
<text class="tasks__value">
{{ [t.occupation, t.worktype, t.jobLevel].filter(Boolean).join(' · ') || '—' }}
</text>
</view>
<view class="tasks__row">
<text class="tasks__label">班级</text>
<text class="tasks__value">{{ t.orgName || '—' }}</text>
</view>
<view class="tasks__row">
<text class="tasks__label">截止</text>
<text class="tasks__value">{{ fmtDate(t.endTime) }}{{ remainText(t) }}</text>
</view>
<!-- 被驳回时把原因摆出来:这是学生唯一知道"哪里不对"的来源 -->
<view v-if="t.submissionStatus === 4 && t.lastRejectReason" class="tasks__reject">
<text class="tasks__strong">被驳回:</text>{{ t.lastRejectReason }}
</view>
<!-- 不能填时给出原因(服务端算好的,前端不自己判) -->
<view v-else-if="!t.canFillNow && t.fillBlockReason" class="tasks__block">
{{ t.fillBlockReason }}
</view>
<view class="tasks__actions">
<button v-if="t.canFillNow" class="tasks__btn" @click.stop="open(t)">
{{ t.submissionStatus === 4 ? '修改后重新提交' : (t.submissionStatus === 0 ? '继续填写' : '开始填写') }}
</button>
<button v-else class="tasks__btn tasks__btn--ghost" @click.stop="open(t)">
查看进度
</button>
<button v-if="t.canWithdraw" class="tasks__btn tasks__btn--warn" @click.stop="withdraw(t)">
撤回修改
</button>
</view>
</view>
<view class="tasks__footer">职业技能等级认定材料收集中台</view>
</view>
</view>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { request } from '@/utils/request'
/**
* 我的任务列表(《03-接口设计》§9.1,学生端)。
*
* ## 为什么需要这一屏
*
* 在此之前学生只能靠"再扫一次二维码"回到某个任务 —— 而:
* · 老师发的是**一次性通知**,消息刷过去就找不到了;
* · 被驳回之后学生往往不知道自己还有活要干(通知渠道还没做,只能靠他自己回来看)。
* 这一屏让他随时能看到「我还有什么没交、为什么被驳回」。
*
* ## 三件事由**服务端**决定,前端不自己判
*
* `canFillNow` / `fillBlockReason` / `canWithdraw` 都是服务端算好的。
* 前端自己按任务状态判"能不能填"必然漏条件(还要看截止时间、是否在审核中、
* 是否已通过),而漏掉的表现是"按钮能点,点进去被拒"。
*/
defineOptions({ name: 'MyTasks' })
interface MyTask {
taskId: number
taskName: string
taskStatus: number
canFillNow: boolean
fillBlockReason?: string
endTime?: string
remainDays?: number
orgName?: string
occupation?: string
worktype?: string
jobLevel?: string
submissionStatus?: number
/** 有填报单时才有:撤回与看进度都要用它 */
submissionId?: number
statusText: string
rejectCount?: number
lastRejectReason?: string
canWithdraw: boolean
}
const loading = ref(false)
const isTeacher = ref(false)
const unread = ref(0)
const data = reactive<{ list: MyTask[], total: number, todoCount: number, doneCount: number }>({
list: [],
total: 0,
todoCount: 0,
doneCount: 0
})
function goMessages() {
uni.navigateTo({ url: '/pages/messages/messages' })
}
/** 未读数单独取一次(列表接口不带它 —— 那是消息中心的字段)。 */
async function loadUnread() {
try {
const d = await request<{ unreadCount: number }>({
url: '/api/v1/mp/messages?pageSize=1&read=false',
token: token()
})
unread.value = d.unreadCount || 0
} catch {
unread.value = 0
}
}
/** 老师角色(与后端 BuiltinRole 的 role_code 一致)。 */
const TEACHER_ROLES = ['HEAD_TEACHER', 'DEPUTY_TEACHER', 'AUDITOR', 'SCHOOL_ADMIN']
/**
* 判断"我是不是老师"。
*
* <p>用 {@code /auth/me} 的 roleCodes,而不是在本地存一个"身份"开关 ——
* 开关会被学生误点,而角色是服务端判定的。
* 取不到就当学生(fail-safe:宁可少给一个入口,也不要给错)。
*/
async function detectTeacher() {
try {
const me = await request<{ roleCodes?: string[] }>({
url: '/api/v1/auth/me',
token: token()
})
isTeacher.value = (me.roleCodes || []).some((r) => TEACHER_ROLES.includes(r))
} catch {
isTeacher.value = false
}
}
function goDashboard() {
uni.navigateTo({ url: '/pages/dashboard/dashboard' })
}
function token(): string {
try {
return uni.getStorageSync('token') || ''
} catch {
return ''
}
}
function fmtDate(v?: string) {
return v ? v.replace('T', ' ').slice(0, 10) : '—'
}
/** 截止提醒:只剩几天要说出来 —— 这是学生最容易忽略的事 */
function remainText(t: MyTask) {
if (t.remainDays === undefined || t.remainDays === null) {
return ''
}
if (t.remainDays < 0) {
return '(已过)'
}
if (t.remainDays === 0) {
return '(今天截止)'
}
return t.remainDays <= 3 ? `(还剩 ${t.remainDays} 天)` : ''
}
function statusClass(t: MyTask) {
const s = t.submissionStatus
if (s === 3) return 'tasks__status--ok'
if (s === 4) return 'tasks__status--bad'
if (s === 1 || s === 2) return 'tasks__status--wait'
return 'tasks__status--todo'
}
async function load() {
loading.value = true
try {
const d = await request<{ list: MyTask[], total: number, todoCount: number, doneCount: number }>({
url: '/api/v1/mp/tasks',
token: token()
})
data.list = d.list || []
data.total = d.total || 0
data.todoCount = d.todoCount || 0
data.doneCount = d.doneCount || 0
} catch (e) {
uni.showToast({ title: e instanceof Error ? e.message : '读取失败', icon: 'none' })
} finally {
loading.value = false
}
}
/**
* 打开一个任务。
*
* 两条路:
* · 能填 → 直接进填报页(带 taskId;H5 的页面跳转不保留参数,所以要显式拼)
* · 不能填 → 进进度页看状态(那里有材料清单、问题与时间轴)
*/
function open(t: MyTask) {
if (t.canFillNow) {
uni.navigateTo({ url: `/pages/fill/fill?taskId=${t.taskId}` })
} else {
// 带 submissionId:进度页要它才能拉数据(列表本来就带了,不用再查一次)
uni.navigateTo({
url: `/pages/progress/progress?taskId=${t.taskId}`
+ `&submissionId=${t.submissionId || 0}`
+ `&taskName=${encodeURIComponent(t.taskName || '')}`
})
}
}
function withdraw(t: MyTask) {
// submissionId 由列表直接给出(服务端已带)—— 不再去"按 taskId 找填报单",
// 那需要额外接口,而列表里本来就有这个字段
if (!t.submissionId) {
uni.showToast({ title: '找不到填报单,请刷新后重试', icon: 'none' })
return
}
uni.showModal({
title: '撤回这次提交?',
content: '撤回后可以继续修改,改完再重新提交。老师那边会看到你还没交。',
success: async (res) => {
if (!res.confirm) {
return
}
try {
await request({
url: `/api/v1/mp/submissions/${t.submissionId}/withdraw`,
method: 'POST',
token: token()
})
uni.showToast({ title: '已撤回,可以继续修改', icon: 'none' })
await load()
} catch (e) {
uni.showToast({ title: e instanceof Error ? e.message : '撤回失败', icon: 'none' })
}
}
})
}
// 用 onShow 而不是 onLoad:从填报页返回时列表要刷新(状态可能已经变了)
onShow(() => {
load()
loadUnread()
if (!isTeacher.value) {
detectTeacher()
}
})
</script>
<style lang="scss">
$primary: #0052d9;
$success: #00a870;
$danger: #e34d59;
$warn: #ed7b2f;
$page-bg: #f3f3f3;
page {
background-color: $page-bg;
}
.tasks {
min-height: 100vh;
padding: 24rpx;
&__wrap {
max-width: 720rpx;
margin: 0 auto;
}
&__hero {
padding: 32rpx 28rpx;
margin-bottom: 24rpx;
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: 26rpx;
color: rgb(255 255 255 / 85%);
}
&__strong {
font-weight: 600;
}
&__empty {
display: flex;
flex-direction: column;
gap: 16rpx;
padding: 40rpx 28rpx;
font-size: 28rpx;
line-height: 1.7;
color: #4a5768;
background: #fff;
border-radius: 16rpx;
}
&__empty-hint {
font-size: 24rpx;
color: #8b95a3;
}
&__card {
padding: 28rpx;
margin-bottom: 20rpx;
background: #fff;
border-radius: 16rpx;
}
&__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 16rpx;
}
&__name {
flex: 1;
padding-right: 16rpx;
font-size: 32rpx;
font-weight: 600;
color: #1b2430;
}
&__status {
flex: none;
padding: 4rpx 14rpx;
font-size: 22rpx;
border-radius: 8rpx;
&--todo {
color: $warn;
background: #fdf2e9;
}
&--wait {
color: $primary;
background: #eaf1fd;
}
&--ok {
color: $success;
background: #e8f6f0;
}
&--bad {
color: $danger;
background: #fdecee;
}
}
&__row {
display: flex;
justify-content: space-between;
padding: 8rpx 0;
}
&__label {
font-size: 26rpx;
color: #8b95a3;
}
&__value {
flex: 1;
font-size: 26rpx;
color: #1b2430;
text-align: right;
}
&__reject {
padding: 18rpx 20rpx;
margin-top: 14rpx;
font-size: 25rpx;
line-height: 1.7;
color: #a03a44;
background: #fdecee;
border-radius: 12rpx;
}
&__block {
padding: 18rpx 20rpx;
margin-top: 14rpx;
font-size: 25rpx;
line-height: 1.7;
color: #4a5768;
background: #f7f8fa;
border-radius: 12rpx;
}
&__actions {
display: flex;
gap: 16rpx;
margin-top: 20rpx;
}
&__btn {
flex: 1;
height: 80rpx;
font-size: 28rpx;
line-height: 80rpx;
color: #fff;
background: $primary;
border-radius: 12rpx;
&--ghost {
color: $primary;
background: #eaf1fd;
}
&--warn {
color: $warn;
background: #fdf2e9;
}
}
&__msg {
display: flex;
align-items: center;
gap: 16rpx;
padding: 22rpx 28rpx;
margin-bottom: 20rpx;
background: #fff;
border-radius: 16rpx;
}
&__msg-title {
flex: 1;
font-size: 28rpx;
color: #1b2430;
}
&__msg-badge {
min-width: 36rpx;
padding: 2rpx 12rpx;
font-size: 22rpx;
color: #fff;
text-align: center;
background: $danger;
border-radius: 18rpx;
}
&__msg-sub {
font-size: 23rpx;
color: #b3b9c1;
}
&__teacher {
padding: 24rpx 28rpx;
margin-bottom: 20rpx;
background: #fff;
border-left: 8rpx solid $primary;
border-radius: 16rpx;
}
&__teacher-title {
display: block;
font-size: 29rpx;
font-weight: 600;
color: $primary;
}
&__teacher-sub {
display: block;
margin-top: 6rpx;
font-size: 23rpx;
color: #8b95a3;
}
&__footer {
margin-top: 40rpx;
font-size: 24rpx;
color: #b3b9c1;
text-align: center;
}
}
</style>