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
+59
View File
@@ -24,6 +24,65 @@
"navigationBarBackgroundColor": "#0052D9",
"navigationBarTextStyle": "white"
}
},
{
"path": "pages/join/join",
"style": {
"navigationBarTitleText": "申请加入名单",
"navigationBarBackgroundColor": "#0052D9",
"navigationBarTextStyle": "white"
}
},
{
"path": "pages/tasks/tasks",
"style": {
"navigationBarTitleText": "我的填报",
"navigationBarBackgroundColor": "#0052D9",
"navigationBarTextStyle": "white",
"enablePullDownRefresh": true
}
},
{
"path": "pages/progress/progress",
"style": {
"navigationBarTitleText": "填报进度",
"navigationBarBackgroundColor": "#0052D9",
"navigationBarTextStyle": "white"
}
},
{
"path": "pages/dashboard/dashboard",
"style": {
"navigationBarTitleText": "班级看板",
"navigationBarBackgroundColor": "#0052D9",
"navigationBarTextStyle": "white",
"enablePullDownRefresh": true
}
},
{
"path": "pages/audit/audit",
"style": {
"navigationBarTitleText": "待我审核",
"navigationBarBackgroundColor": "#0052D9",
"navigationBarTextStyle": "white"
}
},
{
"path": "pages/audit-detail/audit-detail",
"style": {
"navigationBarTitleText": "审核详情",
"navigationBarBackgroundColor": "#0052D9",
"navigationBarTextStyle": "white"
}
},
{
"path": "pages/messages/messages",
"style": {
"navigationBarTitleText": "消息",
"navigationBarBackgroundColor": "#0052D9",
"navigationBarTextStyle": "white",
"enablePullDownRefresh": true
}
}
],
"globalStyle": {
@@ -0,0 +1,728 @@
<template>
<view class="det">
<view class="det__wrap">
<view v-if="loading" class="det__empty">正在读取…</view>
<template v-else-if="d">
<view class="det__hero">
<text class="det__name">{{ d.realName }}</text>
<text class="det__sub">
{{ d.orgName || '未分班' }} · {{ d.taskName || '' }}
</text>
<view class="det__badges">
<text class="det__status">{{ d.statusText }}</text>
<text v-if="d.redCount" class="det__badge det__badge--bad">红 {{ d.redCount }}</text>
<text v-if="d.yellowCount" class="det__badge det__badge--warn">黄 {{ d.yellowCount }}</text>
<text v-if="d.rejectCount" class="det__badge">已驳 {{ d.rejectCount }} 次</text>
</view>
</view>
<!-- 有红问题时把"为什么不能通过"写在最显眼处 -->
<view v-if="redTitles.length" class="det__red">
有 {{ redTitles.length }} 项阻断级问题,<text class="det__strong">不能通过</text>:
{{ redTitles.join(';') }}
</view>
<view v-if="d.lastRejectText || d.lastRejectCode" class="det__warn">
上次驳回:{{ d.lastRejectText || d.lastRejectCode }}
</view>
<!-- 材料 -->
<view class="det__card">
<view class="det__title">材料</view>
<view v-for="g in d.materials || []" :key="g.materialCode" class="det__mat">
<view class="det__mat-head">
<text class="det__mat-name">{{ g.materialName }}</text>
<text v-if="g.required" class="det__tag det__tag--req">必传</text>
<text class="det__tag" :class="g.done ? 'det__tag--ok' : 'det__tag--todo'">
{{ g.done ? '已交' : '未交' }}
</text>
</view>
<view v-if="g.files && g.files.length" class="det__files">
<view v-for="f in g.files" :key="f.id" class="det__file">
<image v-if="f.previewUrl || f.thumbUrl" :src="f.thumbUrl || f.previewUrl || ''"
class="det__img" mode="aspectFit"
@click="preview(g.files || [], f)" />
<view class="det__file-meta">
<text class="det__file-name">{{ f.standardName || f.originalName || '(无文件名)' }}</text>
<text class="det__file-sub">
{{ f.finalSizeKb ? `${f.finalSizeKb}KB` : '' }}
<template v-if="f.width && f.height"> · {{ f.width }}×{{ f.height }}</template>
<template v-if="f.source && f.source !== 1"> · {{ sourceText(f.source) }}</template>
</text>
<text v-for="(i, idx) in f.issues || []" :key="idx" class="det__file-issue"
:class="i.severity === 3 ? 'det__file-issue--bad' : 'det__file-issue--warn'">
{{ i.title }}
</text>
</view>
</view>
</view>
<text v-else class="det__muted">没有文件</text>
</view>
</view>
<!-- 问题清单 -->
<view class="det__card">
<view class="det__title">
校验问题({{ (d.issues || []).length }})
<text class="det__muted">现场重算</text>
</view>
<view v-for="(i, idx) in d.issues || []" :key="idx" class="det__issue">
<text class="det__issue-title" :class="i.severity === 3 ? 'det__issue--bad' : 'det__issue--warn'">
[{{ i.ruleCode }}] {{ i.title }}
</text>
<text class="det__muted">{{ i.detail }}</text>
</view>
<text v-if="!(d.issues || []).length" class="det__muted">没有校验问题</text>
</view>
<!-- 学生改动 -->
<view v-if="(d.changedDetail || []).length" class="det__card">
<view class="det__title">学生改动({{ d.changedDetail.length }} 项)</view>
<view v-for="c in d.changedDetail" :key="c.field" class="det__change">
<text class="det__change-label">{{ c.label || c.field }}</text>
<text class="det__muted">名册:{{ c.rosterValue || '—' }} → 学生:{{ c.studentValue || '—' }}</text>
</view>
</view>
<!-- 底部动作:驳回必须点按钮 + 二次确认 -->
<view v-if="showActions" class="det__actions">
<button class="det__btn det__btn--reject" @click="openReject">驳回</button>
<button class="det__btn" :disabled="!canApprove" @click="approve">
{{ canApprove ? '通过' : '不能通过(有红问题)' }}
</button>
</view>
<!--
代传入口(§10.6):只在这个学生**还没提交**时才有意义 ——
已提交的单子不能塞材料(那会让审到一半的材料清单变了)。
放在最下面而不是和"通过/驳回"并排:它是补救动作,不是主流程。
-->
<view v-if="canProxy" class="det__proxy">
<view class="det__proxy-title">帮他传材料</view>
<view class="det__proxy-hint">
学生不会弄(照片在家长手机里 / 不会压缩)时用这里。
<text class="det__strong">这份材料会标注「由你代传」</text>,学生也看得到。
</view>
<view class="det__proxy-mats">
<view v-for="g in (d.materials || []).filter((m) => m.required)" :key="g.materialCode"
class="det__proxy-mat" :class="{ 'det__proxy-mat--on': proxyMaterial === g.materialCode }"
@click="proxyMaterial = g.materialCode">
{{ g.materialName }}{{ g.done ? '(已交)' : '' }}
</view>
</view>
<input v-model="proxyReason" class="det__proxy-reason"
placeholder="代传原因(选填,但学生看得到)" :maxlength="100" />
<button class="det__btn det__btn--ghost" :disabled="proxyBusy" @click="doProxy">
{{ proxyBusy ? '正在上传…' : '选择照片并代传' }}
</button>
</view>
</template>
<!-- 驳回弹层:原因快选 + 必须二次确认 -->
<view v-if="rejectVisible" class="det__mask" @click="rejectVisible = false">
<view class="det__sheet" @click.stop>
<view class="det__sheet-title">驳回这一单</view>
<view class="det__sheet-hint">原因会原样发给学生看,所以要说清"哪里不对、该怎么办"。</view>
<view class="det__reasons">
<view v-for="r in reasonCodes" :key="r.code" class="det__reason"
:class="{ 'det__reason--on': rejectForm.reasonCode === r.code }"
@click="pickReason(r)">
{{ r.text }}
</view>
</view>
<textarea v-model="rejectForm.reasonText" class="det__textarea"
:placeholder="needText ? '请写清问题(必填)' : '补充说明(可选)'" :maxlength="200" />
<view class="det__sheet-actions">
<button class="det__btn det__btn--ghost" @click="rejectVisible = false">取消</button>
<button class="det__btn det__btn--reject" :disabled="submitting" @click="doReject">
确认驳回
</button>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { request } from '@/utils/request'
/**
* 学生详情 + 驳回(《03-接口设计》§10.3/§10.4,原型 ③⑤)。
*
* ## 两条铁律在界面上的落点
*
* 1. **驳回必须点按钮 + 二次确认**(左滑不绑驳回)。所以这个页面里
* 驳回是一条独立的路径:选原因 → 写说明 → 确认。误触的代价不可恢复。
* 2. **有红问题不能通过**:按钮直接禁用并把原因显示出来,而不是等他点了再弹 40032。
* 判定以服务端的 `approvePolicy.canApprove` 为准,前端不自己算。
*/
defineOptions({ name: 'TeacherAuditDetail' })
interface Issue {
ruleCode: string
severity: number
title: string
detail: string
}
interface FileItem {
id: number
originalName?: string
standardName?: string
previewUrl?: string
thumbUrl?: string
finalSizeKb?: number
width?: number
height?: number
source?: number
issues?: Issue[]
}
interface MaterialGroup {
materialCode: string
materialName: string
required: boolean
done: boolean
files?: FileItem[]
}
interface Detail {
id: number
status: number
statusText: string
taskName?: string
orgName?: string
realName: string
idCardMask?: string
materials?: MaterialGroup[]
issues?: Issue[]
redCount: number
yellowCount: number
rejectCount: number
lastRejectCode?: string
lastRejectText?: string
changedDetail?: Array<{ field: string, label?: string, rosterValue?: string, studentValue?: string }>
approvePolicy?: { canApprove: boolean, reason?: string, redTitles?: string[] }
}
const loading = ref(false)
const submitting = ref(false)
const d = ref<Detail | null>(null)
const rejectVisible = ref(false)
const reasonCodes = ref<Array<{ code: string, text: string, requiresText: boolean }>>([])
const rejectForm = reactive({ reasonCode: '', reasonText: '' })
let submissionId = 0
let openRejectOnLoad = false
function token(): string {
try {
return uni.getStorageSync('token') || ''
} catch {
return ''
}
}
const redTitles = computed(() => {
const fromPolicy = d.value?.approvePolicy?.redTitles
if (fromPolicy?.length) {
return fromPolicy
}
return (d.value?.issues || []).filter((i) => i.severity === 3).map((i) => i.title)
})
const canApprove = computed(() => d.value?.approvePolicy?.canApprove === true)
const showActions = computed(() => d.value?.status === 2 || d.value?.status === 3)
const needText = computed(() => {
const r = reasonCodes.value.find((x) => x.code === rejectForm.reasonCode)
return r?.requiresText === true
})
function sourceText(src: number) {
return src === 2 ? '班主任代传' : (src === 3 ? '管理员代传' : (src === 4 ? '整理器导入' : ''))
}
function preview(files: FileItem[], cur: FileItem) {
const urls = files.map((f) => f.previewUrl || f.thumbUrl || '').filter(Boolean)
const curUrl = cur.previewUrl || cur.thumbUrl || ''
if (urls.length) {
uni.previewImage({ urls, current: curUrl })
}
}
async function load() {
loading.value = true
try {
d.value = await request<Detail>({
url: `/api/v1/mp/teacher/audit/${submissionId}`,
token: token()
})
if (openRejectOnLoad) {
openRejectOnLoad = false
openReject()
}
} catch (e) {
uni.showToast({ title: e instanceof Error ? e.message : '读取失败', icon: 'none' })
} finally {
loading.value = false
}
}
async function approve() {
try {
const r = await request<{ replayed?: boolean }>({
url: `/api/v1/mp/teacher/audit/${submissionId}/approve`,
method: 'POST',
token: token(),
data: { actionSource: 'BUTTON' }
})
uni.showToast({ title: r?.replayed ? '这一单已经审过了' : '已通过', icon: 'none' })
setTimeout(() => uni.navigateBack(), 600)
} catch (e) {
uni.showModal({
title: '不能通过',
content: e instanceof Error ? e.message : '操作失败',
showCancel: false
})
}
}
async function openReject() {
rejectForm.reasonCode = ''
rejectForm.reasonText = ''
rejectVisible.value = true
if (!reasonCodes.value.length) {
try {
reasonCodes.value = await request<Array<{ code: string, text: string, requiresText: boolean }>>({
url: '/api/v1/mp/teacher/audit/reason-codes',
token: token()
})
} catch {
uni.showToast({ title: '驳回原因字典没取到', icon: 'none' })
}
}
}
function pickReason(r: { code: string, text: string }) {
rejectForm.reasonCode = r.code
}
/** 二次确认:驳回不可恢复(学生要改一份材料),所以再问一次 */
function doReject() {
if (!rejectForm.reasonCode) {
uni.showToast({ title: '请选择驳回原因', icon: 'none' })
return
}
if (needText.value && !rejectForm.reasonText.trim()) {
uni.showToast({ title: '选「其他原因」时必须写清问题', icon: 'none' })
return
}
const r = reasonCodes.value.find((x) => x.code === rejectForm.reasonCode)
uni.showModal({
title: '确认驳回?',
content: `原因:${rejectForm.reasonText.trim() || r?.text || ''}\n`
+ '学生会看到这句话并需要重新提交。一期没有通知渠道,请另外口头告知他。',
confirmText: '确认驳回',
success: async (res) => {
if (!res.confirm) {
return
}
submitting.value = true
try {
const out = await request<{ needManualNotice?: boolean }>({
url: `/api/v1/mp/teacher/audit/${submissionId}/reject`,
method: 'POST',
token: token(),
data: {
reasonCode: rejectForm.reasonCode,
reasonText: rejectForm.reasonText.trim() || undefined
}
})
rejectVisible.value = false
uni.showModal({
title: '已驳回',
content: out?.needManualNotice
? '通知没有发出去(一期没有通知渠道),请口头告知学生。'
: '已通知学生。',
showCancel: false,
success: () => uni.navigateBack()
})
} catch (e) {
uni.showToast({ title: e instanceof Error ? e.message : '驳回失败', icon: 'none' })
} finally {
submitting.value = false
}
}
})
}
onLoad((options) => {
submissionId = Number(options?.submissionId || 0)
openRejectOnLoad = String(options?.reject || '') === '1'
load()
})
</script>
<style lang="scss">
$primary: #0052d9;
$success: #00a870;
$danger: #e34d59;
$warn: #ed7b2f;
$page-bg: #f3f3f3;
page {
background-color: $page-bg;
}
.det {
min-height: 100vh;
padding: 24rpx;
&__wrap {
max-width: 720rpx;
margin: 0 auto;
}
&__empty {
padding: 60rpx 28rpx;
font-size: 28rpx;
color: #8b95a3;
text-align: center;
background: #fff;
border-radius: 16rpx;
}
&__hero {
padding: 28rpx;
margin-bottom: 16rpx;
background: #fff;
border-radius: 16rpx;
}
&__name {
display: block;
font-size: 38rpx;
font-weight: 600;
color: #1b2430;
}
&__sub {
display: block;
margin-top: 8rpx;
font-size: 25rpx;
color: #8b95a3;
}
&__badges {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 16rpx;
}
&__status {
padding: 4rpx 14rpx;
font-size: 22rpx;
color: $primary;
background: #eaf1fd;
border-radius: 8rpx;
}
&__badge {
padding: 4rpx 14rpx;
font-size: 22rpx;
color: #8b95a3;
background: #f2f3f5;
border-radius: 8rpx;
&--bad {
color: $danger;
background: #fdecee;
}
&--warn {
color: $warn;
background: #fdf2e9;
}
}
&__red {
padding: 20rpx 24rpx;
margin-bottom: 16rpx;
font-size: 26rpx;
line-height: 1.7;
color: #a03a44;
background: #fdecee;
border-radius: 12rpx;
}
&__warn {
padding: 20rpx 24rpx;
margin-bottom: 16rpx;
font-size: 26rpx;
line-height: 1.7;
color: #8a5a12;
background: #fdf2e9;
border-radius: 12rpx;
}
&__strong {
font-weight: 600;
}
&__card {
padding: 28rpx;
margin-bottom: 16rpx;
background: #fff;
border-radius: 16rpx;
}
&__title {
margin-bottom: 18rpx;
font-size: 30rpx;
font-weight: 600;
color: #1b2430;
}
&__mat {
padding: 18rpx 0;
border-bottom: 1rpx solid #f2f3f5;
&:last-child {
border-bottom: none;
}
}
&__mat-head {
display: flex;
align-items: center;
gap: 12rpx;
}
&__mat-name {
flex: 1;
font-size: 28rpx;
color: #1b2430;
}
&__tag {
padding: 4rpx 12rpx;
font-size: 21rpx;
border-radius: 6rpx;
&--req {
color: $danger;
background: #fdecee;
}
&--ok {
color: $success;
background: #e8f6f0;
}
&--todo {
color: $warn;
background: #fdf2e9;
}
}
&__files {
display: flex;
flex-direction: column;
gap: 16rpx;
margin-top: 14rpx;
}
&__file {
display: flex;
gap: 16rpx;
}
&__img {
flex: none;
width: 160rpx;
height: 160rpx;
background: #f2f3f5;
border-radius: 10rpx;
}
&__file-meta {
flex: 1;
min-width: 0;
}
&__file-name {
display: block;
font-size: 24rpx;
line-height: 1.5;
color: #1b2430;
word-break: break-all;
}
&__file-sub {
display: block;
margin-top: 6rpx;
font-size: 22rpx;
color: #8b95a3;
}
&__file-issue {
display: block;
margin-top: 6rpx;
font-size: 22rpx;
line-height: 1.5;
&--bad {
color: $danger;
}
&--warn {
color: $warn;
}
}
&__issue {
padding: 12rpx 0;
border-bottom: 1rpx solid #f2f3f5;
&:last-child {
border-bottom: none;
}
}
&__issue-title {
display: block;
font-size: 26rpx;
&--bad {
color: $danger;
}
&--warn {
color: $warn;
}
}
&__change {
padding: 12rpx 0;
border-bottom: 1rpx solid #f2f3f5;
&:last-child {
border-bottom: none;
}
}
&__change-label {
display: block;
font-size: 26rpx;
color: #1b2430;
}
&__muted {
font-size: 23rpx;
color: #8b95a3;
}
&__actions {
display: flex;
gap: 20rpx;
margin-top: 24rpx;
}
&__btn {
flex: 1;
height: 88rpx;
font-size: 30rpx;
line-height: 88rpx;
color: #fff;
background: $primary;
border-radius: 14rpx;
&[disabled] {
color: #fff;
background: #c9cdd4;
}
&--reject {
color: #fff;
background: $danger;
}
&--ghost {
color: #4a5768;
background: #f2f3f5;
}
}
&__mask {
position: fixed;
inset: 0;
z-index: 99;
display: flex;
align-items: flex-end;
background: rgb(0 0 0 / 45%);
}
&__sheet {
width: 100%;
max-height: 80vh;
padding: 32rpx 28rpx 40rpx;
overflow-y: auto;
background: #fff;
border-radius: 24rpx 24rpx 0 0;
}
&__sheet-title {
font-size: 32rpx;
font-weight: 600;
color: #1b2430;
}
&__sheet-hint {
margin-top: 10rpx;
font-size: 24rpx;
line-height: 1.6;
color: #8b95a3;
}
&__reasons {
margin-top: 20rpx;
}
&__reason {
padding: 20rpx 22rpx;
margin-bottom: 12rpx;
font-size: 26rpx;
line-height: 1.5;
color: #4a5768;
background: #f7f8fa;
border: 2rpx solid transparent;
border-radius: 12rpx;
&--on {
color: $primary;
background: #eaf1fd;
border-color: $primary;
}
}
&__textarea {
width: 100%;
height: 160rpx;
padding: 20rpx;
margin-top: 16rpx;
font-size: 26rpx;
background: #f7f8fa;
border-radius: 12rpx;
}
&__sheet-actions {
display: flex;
gap: 20rpx;
margin-top: 24rpx;
}
}
</style>
+517
View File
@@ -0,0 +1,517 @@
<template>
<view class="audit">
<view class="audit__wrap">
<!-- 顶部:还剩几张 + 主动作 -->
<view class="audit__bar">
<text class="audit__count">待审 {{ list.length }} 张</text>
<button class="audit__batch" :disabled="!greenCount" @click="batchApprove">
一键通过无问题({{ greenCount }})
</button>
</view>
<view v-if="loading" class="audit__empty">正在读取…</view>
<view v-else-if="!list.length" class="audit__empty">
<text class="audit__empty-title">没有待你初审的单子了</text>
<text class="audit__empty-hint">学生提交后会自动出现在这里。</text>
</view>
<template v-else>
<!--
⭐ 原型铁律:**通过可以右滑,驳回必须点按钮**。
误触通过的代价是"再撤一次"(可恢复);误触驳回的代价是
学生白改一份材料(不可恢复)。两者不对称,所以手势只绑一个方向。
-->
<movable-area class="audit__area">
<movable-view class="audit__view" direction="horizontal" :x="x" :damping="40"
:out-of-bounds="false" @change="onMove" @touchend="onTouchEnd">
<view class="audit__card" :class="swipeHintClass">
<view class="audit__head">
<text class="audit__name">{{ cur.realName }}</text>
<text class="audit__level" :class="levelClass">{{ levelText }}</text>
</view>
<view class="audit__row">
<text class="audit__label">班级</text>
<text class="audit__value">{{ cur.orgName || '—' }}</text>
</view>
<view class="audit__row">
<text class="audit__label">材料</text>
<text class="audit__value">
{{ cur.materialCount }} 项<template v-if="(cur.materialThumbs || []).length">(有预览)</template>
</text>
</view>
<view class="audit__row">
<text class="audit__label">提交</text>
<text class="audit__value">{{ fmt(cur.submitTime) }}</text>
</view>
<view v-if="cur.redSummary" class="audit__red">阻断:{{ cur.redSummary }}</view>
<view v-else-if="cur.redCount || cur.yellowCount" class="audit__warn">
红 {{ cur.redCount }} · 黄 {{ cur.yellowCount }}
</view>
<view v-if="(cur.changedFields || []).length" class="audit__changed">
学生改了 {{ cur.changedFields.length }} 项预填内容
</view>
<view class="audit__thumbs">
<image v-for="(u, i) in (cur.materialThumbs || []).slice(0, 3)" :key="i" :src="u"
class="audit__thumb" mode="aspectFill" @click="preview(u)" />
</view>
</view>
</movable-view>
</movable-area>
<view class="audit__hint">
<text class="audit__hint-main">右滑 → 通过</text>
<text class="audit__hint-sub">驳回请点下面的按钮(要选原因,不能滑)</text>
</view>
<view class="audit__actions">
<button class="audit__btn audit__btn--reject" @click="reject">驳回</button>
<button class="audit__btn" @click="approve('BUTTON')">通过</button>
</view>
<view class="audit__detail" @click="goDetail">看完整材料与问题清单</view>
</template>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import { request } from '@/utils/request'
/**
* 班主任审核卡片流(《03-接口设计》§10.2~§10.5,原型 ②)。
*
* ## 手势只绑一个方向(这不是防手滑,是防不可恢复的错误)
*
* 通过可以右滑;**驳回必须点按钮**。理由是不对称:
* · 误触通过 → 再撤一次就好(可恢复);
* · 误触驳回 → 学生白改一份材料、多跑一趟(不可恢复)。
* 所以左滑刻意**什么都不做** —— 不给"左滑驳回"留任何入口。
*
* ## 只显示服务端给的待办
*
* "我能不能审这一单"由服务端按范围判定(权限 ≠ 范围)。
* 前端不自己按班级过滤:过滤不了越权,只会在口径不一致时制造假象。
*/
defineOptions({ name: 'TeacherAudit' })
interface Todo {
submissionId: number
realName: string
orgName?: string
taskName?: string
submitTime?: string
level: number
redCount: number
yellowCount: number
redSummary?: string
materialCount: number
materialThumbs?: string[]
changedFields?: string[]
/** 服务端算好的:能不能批量通过(红黄都为零) */
batchApprovable: boolean
statusText: string
}
const loading = ref(false)
const list = ref<Todo[]>([])
const x = ref(0)
const dragging = ref(false)
let orgId = 0
/** 右滑多远算"通过"(px)。太小会误触,太大滑不动 */
const THRESHOLD = 120
const cur = computed<Todo>(() => list.value[0] || ({} as Todo))
const greenCount = computed(() => list.value.filter((t) => t.batchApprovable).length)
const levelText = computed(() => {
const l = cur.value.level
if (l === 3) return '有红问题'
if (l === 2) return '有警告'
return '无问题'
})
const levelClass = computed(() => {
const l = cur.value.level
if (l === 3) return 'audit__level--bad'
if (l === 2) return 'audit__level--warn'
return 'audit__level--ok'
})
const swipeHintClass = computed(() => {
if (!dragging.value) {
return ''
}
return x.value >= THRESHOLD ? 'audit__card--pass' : ''
})
function token(): string {
try {
return uni.getStorageSync('token') || ''
} catch {
return ''
}
}
function fmt(v?: string) {
return v ? v.replace('T', ' ').slice(0, 16) : '—'
}
function onMove(e: { detail?: { x?: number } }) {
const v = Number(e?.detail?.x ?? 0)
x.value = v
dragging.value = true
}
function preview(url: string) {
uni.previewImage({ urls: [url] })
}
function goDetail() {
if (!cur.value.submissionId) {
return
}
uni.navigateTo({
url: `/pages/audit-detail/audit-detail?submissionId=${cur.value.submissionId}`
})
}
async function load() {
loading.value = true
try {
const d = await request<{ list: Todo[] }>({
url: `/api/v1/mp/teacher/audit/todos?node=1${orgId ? `&orgId=${orgId}` : ''}&pageSize=20`,
token: token()
})
list.value = d.list || []
} catch (e) {
uni.showToast({ title: e instanceof Error ? e.message : '读取失败', icon: 'none' })
} finally {
loading.value = false
x.value = 0
dragging.value = false
}
}
/** 右滑到位才通过;左滑**什么都不做**(不给驳回留手势入口) */
function onTouchEnd() {
const passed = x.value >= THRESHOLD
dragging.value = false
if (passed) {
approve('SWIPE')
} else {
// 弹回原位:movable-view 的 x 需要显式设回 0
x.value = 0
}
}
async function approve(source: 'SWIPE' | 'BUTTON') {
const id = cur.value.submissionId
if (!id) {
return
}
try {
const r = await request<{ statusText?: string, replayed?: boolean }>({
url: `/api/v1/mp/teacher/audit/${id}/approve`,
method: 'POST',
token: token(),
data: { actionSource: source }
})
uni.showToast({
title: r?.replayed ? '这一单刚才已经审过了' : '已通过',
icon: 'none'
})
list.value = list.value.slice(1)
x.value = 0
if (!list.value.length) {
await load()
}
} catch (e) {
// 红问题(40032)等错误原样显示 —— 后端的话写得很具体("存在 1 个红色问题")
uni.showModal({
title: '不能通过',
content: e instanceof Error ? e.message : '操作失败',
showCancel: false
})
x.value = 0
}
}
function reject() {
if (!cur.value.submissionId) {
return
}
// 驳回要选原因(且原因会原样给学生看),所以走独立的驳回页,不在卡片上做
uni.navigateTo({
url: `/pages/audit-detail/audit-detail?submissionId=${cur.value.submissionId}&reject=1`
})
}
async function batchApprove() {
const ids = list.value.filter((t) => t.batchApprovable).map((t) => t.submissionId)
if (!ids.length) {
return
}
uni.showModal({
title: `一键通过 ${ids.length} 份?`,
// 说清"只带无问题的":服务端会强制跳过红黄,用户不该以为全过了
content: '只包含**没有任何问题**的单子;有红黄问题的一律跳过,需要你单独看。',
success: async (res) => {
if (!res.confirm) {
return
}
try {
const r = await request<{ approved: number, skipped: number }>({
url: '/api/v1/mp/teacher/audit/batch-approve',
method: 'POST',
token: token(),
data: { submissionIds: ids, actionSource: 'BUTTON' }
})
uni.showModal({
title: '批量通过完成',
content: `通过 ${r?.approved ?? 0} 份,跳过 ${r?.skipped ?? 0} 份。`
+ (r?.skipped ? '被跳过的都是有问题需要单独看的。' : ''),
showCancel: false
})
await load()
} catch (e) {
uni.showToast({ title: e instanceof Error ? e.message : '批量通过失败', icon: 'none' })
}
}
})
}
onLoad((options) => {
orgId = Number(options?.orgId || 0)
})
onShow(load)
</script>
<style lang="scss">
$primary: #0052d9;
$success: #00a870;
$danger: #e34d59;
$warn: #ed7b2f;
$page-bg: #f3f3f3;
page {
background-color: $page-bg;
}
.audit {
min-height: 100vh;
padding: 24rpx;
&__wrap {
max-width: 720rpx;
margin: 0 auto;
}
&__bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20rpx;
}
&__count {
font-size: 28rpx;
font-weight: 600;
color: #1b2430;
}
&__batch {
height: 64rpx;
padding: 0 24rpx;
font-size: 24rpx;
line-height: 64rpx;
color: #fff;
background: $success;
border-radius: 32rpx;
&[disabled] {
color: #fff;
background: #c9cdd4;
}
}
&__empty {
padding: 60rpx 28rpx;
text-align: center;
background: #fff;
border-radius: 16rpx;
}
&__empty-title {
display: block;
font-size: 30rpx;
color: #1b2430;
}
&__empty-hint {
display: block;
margin-top: 12rpx;
font-size: 25rpx;
color: #8b95a3;
}
/* movable-area 必须给高度,否则卡片不可见 */
&__area {
width: 100%;
height: 620rpx;
}
&__view {
width: 100%;
height: 600rpx;
}
&__card {
padding: 32rpx 28rpx;
background: #fff;
border: 2rpx solid transparent;
border-radius: 20rpx;
box-shadow: 0 6rpx 24rpx rgb(27 36 48 / 8%);
transition: border-color 0.15s;
&--pass {
border-color: $success;
}
}
&__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20rpx;
}
&__name {
font-size: 38rpx;
font-weight: 600;
color: #1b2430;
}
&__level {
padding: 4rpx 14rpx;
font-size: 22rpx;
border-radius: 8rpx;
&--ok {
color: $success;
background: #e8f6f0;
}
&--warn {
color: $warn;
background: #fdf2e9;
}
&--bad {
color: $danger;
background: #fdecee;
}
}
&__row {
display: flex;
justify-content: space-between;
padding: 10rpx 0;
}
&__label {
font-size: 26rpx;
color: #8b95a3;
}
&__value {
font-size: 26rpx;
color: #1b2430;
}
&__red {
padding: 16rpx 20rpx;
margin-top: 14rpx;
font-size: 25rpx;
line-height: 1.6;
color: #a03a44;
background: #fdecee;
border-radius: 12rpx;
}
&__warn {
padding: 16rpx 20rpx;
margin-top: 14rpx;
font-size: 25rpx;
color: #8a5a12;
background: #fdf2e9;
border-radius: 12rpx;
}
&__changed {
margin-top: 12rpx;
font-size: 24rpx;
color: $warn;
}
&__thumbs {
display: flex;
gap: 12rpx;
margin-top: 18rpx;
}
&__thumb {
width: 140rpx;
height: 140rpx;
border: 1rpx solid #e5e6eb;
border-radius: 10rpx;
}
&__hint {
margin: 12rpx 0 20rpx;
text-align: center;
}
&__hint-main {
display: block;
font-size: 26rpx;
color: $success;
}
&__hint-sub {
display: block;
margin-top: 6rpx;
font-size: 23rpx;
color: #8b95a3;
}
&__actions {
display: flex;
gap: 20rpx;
}
&__btn {
flex: 1;
height: 88rpx;
font-size: 31rpx;
line-height: 88rpx;
color: #fff;
background: $primary;
border-radius: 14rpx;
&--reject {
color: $danger;
background: #fdecee;
}
}
&__detail {
margin-top: 24rpx;
font-size: 26rpx;
color: $primary;
text-align: center;
}
}
</style>
@@ -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>
+16
View File
@@ -89,6 +89,15 @@ const isError = ref(false)
const loading = ref(false)
const title = ref('材料填报')
/** 取登录态(授权回调把 token 放在 URL fragment 里,落地页存下来的)。 */
function token(): string {
try {
return uni.getStorageSync('token') || ''
} catch {
return ''
}
}
/**
* 当前 URL 里的 scene(授权回调回来时在 hash 路由的 query 里)。
*
@@ -146,6 +155,13 @@ function pickScene(options?: Record<string, string | undefined>): string {
async function load() {
if (!scene.value) {
// ★ 没有 scene 但有登录态 → 说明他是**回来看进度的**,不是扫码进来的。
// 以前这种情况一律报错"请扫描二维码进入",于是学生一旦找不到老师发的那条消息
// 就再也进不来了(通知渠道还没做,他只能靠历史消息)。现在直接送去「我的填报」。
if (token()) {
uni.redirectTo({ url: '/pages/tasks/tasks' })
return
}
loading.value = false
isError.value = true
message.value = '请扫描班主任发的「填报二维码」进入,或让班主任重新发一次'
+392
View File
@@ -0,0 +1,392 @@
<template>
<view class="prog">
<view class="prog__wrap">
<view class="prog__hero">
<text class="prog__task">{{ taskName || '本次填报' }}</text>
<text class="prog__percent">{{ p.progressPercent || '—' }}</text>
<text class="prog__status">{{ p.statusText || '' }}</text>
</view>
<view v-if="loading" class="prog__card">正在读取…</view>
<template v-else>
<!-- 材料清单:每一项说清"交了没有 / 有什么问题" -->
<view class="prog__card">
<view class="prog__title">材料</view>
<view v-for="m in p.materials || []" :key="m.materialCode" class="prog__mat">
<view class="prog__mat-head">
<text class="prog__mat-name">{{ m.materialName }}</text>
<text v-if="m.required" class="prog__tag prog__tag--req">必传</text>
<text class="prog__tag" :class="m.done ? 'prog__tag--ok' : 'prog__tag--todo'">
{{ m.done ? `已交 ${m.fileCount} 份` : '未交' }}
</text>
</view>
<view v-if="m.issueText" class="prog__mat-issue" :class="issueClass(m.issueLevel)">
{{ m.issueText }}
</view>
</view>
<view v-if="!(p.materials || []).length" class="prog__muted">本次没有需要上传的材料</view>
</view>
<!-- 时间轴:只列确实发生过的节点 -->
<view class="prog__card">
<view class="prog__title">进展</view>
<view v-for="(e, i) in p.timeline || []" :key="i" class="prog__tl">
<text class="prog__tl-title">{{ e.title }}</text>
<text class="prog__tl-time">{{ fmt(e.time) }}</text>
<text v-if="e.desc" class="prog__tl-desc">{{ e.desc }}</text>
</view>
<view v-if="!(p.timeline || []).length" class="prog__muted">还没有进展</view>
</view>
<view class="prog__actions">
<button v-if="p.canEdit" class="prog__btn" @click="goFill">
{{ p.status === 4 ? '修改后重新提交' : '继续填写' }}
</button>
<button v-if="p.canWithdraw" class="prog__btn prog__btn--warn" @click="withdraw">
撤回修改
</button>
<button v-if="!p.canEdit && !p.canWithdraw" class="prog__btn prog__btn--ghost" @click="reload">
刷新
</button>
</view>
<view class="prog__foot">
有疑问请联系班主任。撤回之后可以继续修改,改完再重新提交。
</view>
</template>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { request } from '@/utils/request'
/**
* 进度与问题清单(《03-接口设计》§9.12,学生端)。
*
* ## 这一屏回答"我还差什么"
*
* 材料清单是核心:每一项说清"交了没有、有什么问题"。
* **不适用本次的材料不会出现**(服务端按条件可见性过滤)——
* 否则在校生会看到一项"工作证明 未交",然后到处问老师要交什么。
*
* ## 时间轴只列确实发生过的节点
*
* 服务端只写真实节点(没提交就没有"已提交")。所以这里也不做"占位"式展示 ——
* 编一条假时间线会让学生以为系统记错了。
*/
defineOptions({ name: 'MyProgress' })
interface MaterialProgress {
materialCode: string
materialName: string
required: boolean
done: boolean
fileCount: number
issueLevel: number
issueText?: string
}
interface ProgressEvent {
time: string
title: string
desc?: string
}
interface Progress {
submissionId: number
status: number
statusText: string
progressPercent: string
materials: MaterialProgress[]
timeline: ProgressEvent[]
canEdit: boolean
canWithdraw: boolean
canSubmit: boolean
}
const loading = ref(false)
const p = ref<Partial<Progress>>({})
const taskName = ref('')
let taskId = 0
let submissionId = 0
function token(): string {
try {
return uni.getStorageSync('token') || ''
} catch {
return ''
}
}
function fmt(v?: string) {
return v ? v.replace('T', ' ').slice(0, 16) : '—'
}
function issueClass(level: number) {
if (level >= 3) return 'prog__mat-issue--bad'
if (level === 2) return 'prog__mat-issue--warn'
return 'prog__mat-issue--info'
}
/**
* 拿 submissionId 的两条路:
* · 从列表带过来(href 里有 submissionId)—— 最常见
* · 只有 taskId(比如从扫码落地页跳进来)—— 先用进度接口按 taskId 找
*
* ⚠️ 第二种情况需要服务端支持"按 taskId 找我的填报单"。为避免额外接口,
* 这里改成:调用方一律带 submissionId(我的任务列表本来就带)。
* 如果没带,就提示他回列表 —— 比编一个不存在的能力好。
*/
async function load() {
if (!submissionId) {
uni.showModal({
title: '打不开进度',
content: '请从「我的填报」列表里点进来看。',
showCancel: false
})
return
}
loading.value = true
try {
p.value = await request<Progress>({
url: `/api/v1/mp/submissions/${submissionId}/progress`,
token: token()
})
} catch (e) {
uni.showToast({ title: e instanceof Error ? e.message : '读取失败', icon: 'none' })
} finally {
loading.value = false
}
}
function reload() {
load()
}
function goFill() {
uni.navigateTo({ url: `/pages/fill/fill?taskId=${taskId}` })
}
function withdraw() {
uni.showModal({
title: '撤回这次提交?',
content: '撤回后可以继续修改,改完再重新提交。老师那边会看到你还没交。',
success: async (res) => {
if (!res.confirm) {
return
}
try {
await request({
url: `/api/v1/mp/submissions/${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' })
}
}
})
}
onLoad((options) => {
taskId = Number(options?.taskId || 0)
submissionId = Number(options?.submissionId || 0)
taskName.value = options?.taskName ? decodeURIComponent(String(options.taskName)) : ''
load()
})
</script>
<style lang="scss">
$primary: #0052d9;
$success: #00a870;
$danger: #e34d59;
$warn: #ed7b2f;
$page-bg: #f3f3f3;
page {
background-color: $page-bg;
}
.prog {
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;
}
&__task {
display: block;
font-size: 28rpx;
color: rgb(255 255 255 / 85%);
}
&__percent {
display: block;
margin: 8rpx 0;
font-size: 56rpx;
font-weight: 600;
color: #fff;
}
&__status {
display: block;
font-size: 26rpx;
color: rgb(255 255 255 / 90%);
}
&__card {
padding: 28rpx;
margin-bottom: 20rpx;
background: #fff;
border-radius: 16rpx;
}
&__title {
margin-bottom: 18rpx;
font-size: 30rpx;
font-weight: 600;
color: #1b2430;
}
&__mat {
padding: 18rpx 0;
border-bottom: 1rpx solid #f2f3f5;
&:last-child {
border-bottom: none;
}
}
&__mat-head {
display: flex;
align-items: center;
gap: 12rpx;
}
&__mat-name {
flex: 1;
font-size: 28rpx;
color: #1b2430;
}
&__mat-issue {
margin-top: 8rpx;
font-size: 24rpx;
line-height: 1.6;
&--bad {
color: $danger;
}
&--warn {
color: $warn;
}
&--info {
color: #8b95a3;
}
}
&__tag {
padding: 4rpx 12rpx;
font-size: 21rpx;
border-radius: 6rpx;
&--req {
color: $danger;
background: #fdecee;
}
&--ok {
color: $success;
background: #e8f6f0;
}
&--todo {
color: $warn;
background: #fdf2e9;
}
}
&__tl {
padding: 14rpx 0;
border-bottom: 1rpx solid #f2f3f5;
&:last-child {
border-bottom: none;
}
}
&__tl-title {
display: block;
font-size: 27rpx;
color: #1b2430;
}
&__tl-time {
display: block;
margin-top: 4rpx;
font-size: 23rpx;
color: #8b95a3;
}
&__tl-desc {
display: block;
margin-top: 4rpx;
font-size: 24rpx;
color: #4a5768;
}
&__muted {
font-size: 25rpx;
color: #8b95a3;
}
&__actions {
display: flex;
gap: 16rpx;
}
&__btn {
flex: 1;
height: 84rpx;
font-size: 29rpx;
line-height: 84rpx;
color: #fff;
background: $primary;
border-radius: 12rpx;
&--ghost {
color: $primary;
background: #eaf1fd;
}
&--warn {
color: $warn;
background: #fdf2e9;
}
}
&__foot {
margin-top: 28rpx;
font-size: 24rpx;
line-height: 1.7;
color: #8b95a3;
}
}
</style>
+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>