Files
xiaotutongxue/frontend-mp/src/pages/tasks/tasks.vue
T
lovife ad86e2d521 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 产物含新增各页。
2026-09-30 09:02:52 +08:00

532 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>