ad86e2d521
学生端:待办数、状态、驳回原因、截止提醒、撤回;进度页的材料清单与进度条 分母用「这个人本次真的要交什么」(不适用的必传材料不进分母,否则永远填不满)。 班主任端:已交率与已通过分开呈现(已提交 ≠ 已通过)、右滑通过 / 左滑不绑驳回、 一键通过只带无问题项;代传走一步 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 产物含新增各页。
532 lines
14 KiB
Vue
532 lines
14 KiB
Vue
<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>
|