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:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 = '请扫描班主任发的「填报二维码」进入,或让班主任重新发一次'
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user