Files
coursehomework-front/src/views/homework/StudentHomeworks.vue
T
lijing d075c4da5d
build / build (push) Successful in 6m16s
fix: 作业模块契约缺陷修复(B-01~B-09)+ 列表页状态/操作列图标化统一
契约与健壮性(明细见 bug检测与修复.md):
- B-01 学生端补 allowLate,恢复「截止后补交」能力
- B-02 ReviewWorkbench 附件 URL 补 serviceHost,修线上图片/下载 404
- B-03/B-04 统计页、全部作业页幻影字段:前端降级 + 后端补字段
- B-05 详情页「接收班级」兜底;B-08 NewHomework 编辑回显与班级树加载竞态
- B-09 移除 api/index.ts 未定义的 GlobalFetch 与硬编码 baseURL

列表页界面统一(我布置的 / 全部作业):
- 状态列统一走 Utils.HW_STATUS_META + hwStatusPill(图标 + 文字 + 同色浅底胶囊)
- 操作列改图标按钮:提交情况 icon-chakan、批阅 icon-shenpi,低频操作收进「更多」下拉(功能全保留)
- 两页操作列同宽,切页不跳列;草稿箱状态列加宽以容纳「定时 …」胶囊
- Submissions 批阅图标与列表行操作对齐

构建:npm run build 通过(exit 0)
2026-09-22 12:30:00 +08:00

560 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Message } from "view-ui-plus";
import { studentApi } from "@/api/module/studentHomework";
import type { BasePageResult, StudentHomeworkV2Vo, StudentHomeworkDetailV2Vo, AttachmentItem } from "@/types/homeworkV2";
import AttachmentUpload from "@/components/AttachmentUpload.vue";
import MyConfirm from "@/components/MyConfirm";
const route = useRoute();
const router = useRouter();
/* V2 tab:all/todo/done/closed(redo 桶已并入 todo,RETURNED 红色强化) */
const TABS = [
{ name: "all", label: "全部" },
{ name: "todo", label: "待完成" },
{ name: "done", label: "已完成" },
{ name: "closed", label: "已截止" },
];
const tab = ref<string>((route.meta as any).initTab || "all");
const keyword = ref("");
const pageNum = ref(1);
const pageSize = ref(10);
const EMPTY_PAGE: BasePageResult<StudentHomeworkV2Vo> = { content: [], total: 0, pageIndex: 1, pageSize: 10, totalPages: 0, hasNext: false };
const page = ref<BasePageResult<StudentHomeworkV2Vo>>(EMPTY_PAGE);
const loading = ref(false);
async function load() {
loading.value = true;
try {
page.value = await studentApi
.list(tab.value, {
keyword: keyword.value || undefined,
pageIndex: pageNum.value,
pageSize: pageSize.value,
})
.send();
} catch (e: any) {
Message.error(e.message || "加载作业失败");
page.value = EMPTY_PAGE;
} finally {
loading.value = false;
}
}
function onTab(name: string) {
tab.value = name;
pageNum.value = 1;
load();
}
function doSearch() {
pageNum.value = 1;
load();
}
function pageUpdate(p: number) {
pageNum.value = p;
load();
}
onMounted(load);
watch(
() => route.fullPath,
() => {
tab.value = (route.meta as any).initTab || "all";
keyword.value = "";
pageNum.value = 1;
load();
}
);
/* todo 桶内待订正(RETURNED)排前,红色强化 */
const rows = computed(() => {
const list = page.value.content || [];
if (tab.value === "todo") {
return [...list].sort((a, b) => Number(b.my?.submitStatus === "RETURNED") - Number(a.my?.submitStatus === "RETURNED"));
}
return list;
});
/* ============ 状态展示 ============ */
const STATUS_TEXT: Record<string, string> = {
NOT_SUBMITTED: "未提交",
SUBMITTED: "已提交·待批",
LATE_SUBMITTED: "逾期补交·待批",
RETURNED: "待订正",
REVIEWED: "已批阅",
};
/* 左侧色条用色。蓝色对齐主题 #5979EA(原 #2d8cf0 是另一套蓝,与 tab 下划线/主按钮不同色) */
const STATUS_COLOR: Record<string, string> = {
NOT_SUBMITTED: "#c5c8ce",
SUBMITTED: "#5979EA",
LATE_SUBMITTED: "#ff9900",
RETURNED: "#ed4014",
REVIEWED: "#19be6b",
};
/**
* 状态标签(Tag)配色。
* 「未提交」用 default(白底浅灰描边)——属"未产生动作"的静默态,与宿舍通知里
* 「仅通知 / 已截止」同款处理;其余为实心语义色。
*/
const STATUS_TAG: Record<string, string> = {
NOT_SUBMITTED: "default",
SUBMITTED: "#5979EA",
LATE_SUBMITTED: "#ff9900",
RETURNED: "#ed4014",
REVIEWED: "#19be6b",
};
function myStatus(x: StudentHomeworkV2Vo) {
return x.my?.submitStatus || "NOT_SUBMITTED";
}
function statusColor(x: StudentHomeworkV2Vo) {
return STATUS_COLOR[myStatus(x)];
}
function statusTag(x: StudentHomeworkV2Vo) {
return STATUS_TAG[myStatus(x)] || "default";
}
/** 已批阅摘要:分数、评语都空时返回空串(不渲染,避免出现「得分 --」) */
function gradeText(x: StudentHomeworkV2Vo) {
const parts: string[] = [];
const s = x.my?.score;
if (s !== null && s !== undefined && String(s) !== "") parts.push(`得分 ${s}`);
if (x.my?.comment) parts.push(x.my.comment);
return parts.join(" · ");
}
/** 仅查阅型(submitType 含 none) */
function isViewOnly(x: StudentHomeworkV2Vo) {
return (x.submitType || []).includes("none") || (x.submitType || []).length === 0;
}
/** 是否可提交:PUBLISHED 期内,或 CLOSED 且允许补交 */
function canSubmit(x: StudentHomeworkV2Vo) {
if (isViewOnly(x)) return false;
if (x.hwStatus === "PUBLISHED") return true;
if (x.hwStatus === "CLOSED") return x.allowLate === 1;
return false;
}
function dueInfo(deadline: string | null | undefined) {
if (!deadline) return { text: "无截止", overdue: false, urgent: false };
const d = new Date(String(deadline).replace(/-/g, "/")).getTime();
if (isNaN(d)) return { text: "—", overdue: false, urgent: false };
const diffH = (d - Date.now()) / 3600000;
if (diffH < 0) return { text: "已截止", overdue: true, urgent: false };
if (diffH < 24) return { text: `剩 ${Math.max(1, Math.round(diffH))} 小时`, overdue: false, urgent: true };
return { text: `剩 ${Math.round(diffH / 24)} 天`, overdue: false, urgent: false };
}
function goDetail(row: StudentHomeworkV2Vo) {
router.push({ path: "/stu/detail", query: { serid: String(row.id) } });
}
/* ============ 提交 / 重交 / 撤回(快捷弹窗,主提交面在 /stu/detail) ============ */
const sub = reactive({
open: false,
id: 0,
title: "",
mode: "submit" as "submit" | "resubmit",
submissionId: 0,
text: "",
attachments: [] as AttachmentItem[],
loadingEcho: false,
submitting: false,
});
async function openSubmit(row: StudentHomeworkV2Vo) {
sub.open = true;
sub.id = row.id;
sub.title = row.title;
sub.mode = row.my?.submitStatus === "RETURNED" ? "resubmit" : "submit";
sub.submissionId = row.my?.submissionId || 0;
sub.text = "";
sub.attachments = [];
// 回显当前版本内容(修改提交 / 订正重交场景)
sub.loadingEcho = true;
try {
const d: StudentHomeworkDetailV2Vo = await studentApi.detail(row.id).send();
sub.text = d.myText || "";
sub.attachments = [...(d.myAttachments || [])];
} catch {
/* 回显失败不阻塞提交 */
} finally {
sub.loadingEcho = false;
}
}
async function submit() {
sub.submitting = true;
try {
if (sub.mode === "resubmit" && sub.submissionId) {
await studentApi.action(sub.id, sub.submissionId, { action: "resubmit", text: sub.text, attachments: sub.attachments }).send();
} else {
await studentApi.submit(sub.id, { text: sub.text, attachments: sub.attachments }).send();
}
Message.success("提交成功");
sub.open = false;
load();
} catch (e: any) {
Message.error(e.message || "提交失败");
} finally {
sub.submitting = false;
}
}
function withdraw(row: StudentHomeworkV2Vo) {
MyConfirm({
title: "撤回提交",
msg: `撤回“ ${row.title} ”的提交?`,
info: "撤回后可重新编辑提交(截止前有效)",
async onOk() {
try {
await studentApi.action(row.id, row.my!.submissionId, { action: "withdraw" }).send();
Message.success("已撤回,可重新提交");
load();
} catch (e: any) {
Message.error(e.message || "撤回失败");
}
},
});
}
/* ============ 图片放大预览 ============ */
/* 列表卡片不直接渲染附件缩略图(提交/查看走 /stu/detail),此处不再保留放大弹窗 */
</script>
<template>
<div class="page-wrapper">
<div class="main-container stu">
<!-- 顶部概览:轻量信息条(对齐宿舍通知学生端的浅蓝提示条语言,
替换原 91px 渐变大色块:省纵向空间,且不再与主题色 #5979EA 撞色) -->
<div class="stu-head">
<div class="sh-left">
<Icon type="md-book" size="16" />
<span class="sh-title">我的作业</span>
<span class="sh-sub">按时提交,查看老师批阅与订正</span>
</div>
<div class="sh-count">共 <b>{{ page.total }}</b> 份作业</div>
</div>
<div class="stu-filter">
<!-- view-ui-plus 4.x 的 Tabs 只有 modelValue,写 :value 不生效(高亮会与控制逻辑脱钩) -->
<Tabs v-model="tab" @on-click="onTab" class="stu-tabs">
<TabPane v-for="t in TABS" :key="t.name" :name="t.name" :label="t.label"></TabPane>
</Tabs>
<!-- ★ 不要给这个输入框加 clearable:view-ui-plus 的 Input 模板里
清除图标(clearable) 是 v-if、搜索图标(search) 是 v-else-if,
一旦有输入内容清除图标就把放大镜顶掉 → 「点放大镜搜索」直接失效。
本行只做宽度与右对齐,交互保持原样。 -->
<Input
class="stu-search"
search
suffix="ios-search"
placeholder="搜索作业标题"
v-model="keyword"
@on-search="doSearch"
@on-clear="doSearch"
/>
</div>
<div v-if="loading" class="state"><Spin fix /><div style="height: 120px"></div></div>
<div v-else-if="!rows.length" class="state empty">
<Icon type="ios-albums-outline" size="52" color="#c5c8ce" />
<p>该分类下暂无作业</p>
</div>
<div class="cards" :class="{ 'has-page': page.total > pageSize }" v-else>
<div class="card" v-for="row in rows" :key="row.id" :class="{ redo: myStatus(row) === 'RETURNED' }" :style="{ borderLeftColor: statusColor(row) }">
<!-- 左:作业主体信息 -->
<div class="c-main">
<div class="c-top" @click="goDetail(row)">
<span class="c-subj">{{ row.subject || "作业" }}</span>
<span class="c-title" :title="row.title">{{ row.title }}</span>
<span v-if="!isViewOnly(row) && row.my?.isLate" class="c-late">逾期</span>
</div>
<div class="c-meta">
<span class="m-item"><Icon type="md-person" size="14" /> {{ row.teacherName || "—" }}</span>
<!-- 「已截止」只在还需要学生动手时标红(未提交/待订正,且非仅查阅);
已提交、已批阅、仅查阅的条目保持灰字,避免整屏红字像出错 -->
<span
class="m-item"
:class="{
overdue: dueInfo(row.deadlineAt).overdue && !isViewOnly(row) && ['NOT_SUBMITTED', 'RETURNED'].includes(myStatus(row)),
urgent: dueInfo(row.deadlineAt).urgent && myStatus(row) === 'NOT_SUBMITTED',
}"
>
<Icon type="md-time" size="14" />
{{ row.deadlineAt ? `截止 ${String(row.deadlineAt).slice(5, 16)} · ${dueInfo(row.deadlineAt).text}` : "无截止" }}
</span>
</div>
<!-- 待订正:老师评语强化展示 -->
<div class="c-grade warn" v-if="myStatus(row) === 'RETURNED'">
<Icon type="md-refresh" color="#ed4014" size="15" /> 老师要求订正:{{ row.my?.comment || "请订正后重新提交" }}
</div>
<!-- 已批阅:得分摘要(分数/评语都没有时不渲染) -->
<div class="c-grade" v-else-if="myStatus(row) === 'REVIEWED' && gradeText(row)">
<Icon type="md-checkmark-circle" color="#19be6b" size="15" /> {{ gradeText(row) }}
</div>
</div>
<!-- 右:状态 + 操作(对齐宿舍通知用 Tag 表达状态,替代原来的裸色文字)
★ Tag 不要写 size="small":view-ui-plus 4.x 的 Tag.size 只收 default/medium/large,
small 会被 validator 拦掉并刷 Vue warn(Button 才有 small,两者不一致) -->
<div class="c-side">
<Tag v-if="isViewOnly(row)" color="default" class="c-flag">仅查阅</Tag>
<Tag v-else :color="statusTag(row)" class="c-status">{{ STATUS_TEXT[myStatus(row)] }}</Tag>
<div class="c-actions">
<Button v-if="['NOT_SUBMITTED'].includes(myStatus(row)) && canSubmit(row)" type="primary" icon="md-cloud-upload" size="small" @click="openSubmit(row)">
{{ row.hwStatus === "CLOSED" ? "补交作业" : "提交作业" }}</Button>
<Button v-else-if="myStatus(row) === 'RETURNED' && canSubmit(row)" type="error" icon="md-refresh" size="small" @click="openSubmit(row)">重新提交</Button>
<template v-else-if="['SUBMITTED', 'LATE_SUBMITTED'].includes(myStatus(row)) && canSubmit(row) && row.hwStatus === 'PUBLISHED'">
<!-- 必须带 type:裸 ghost 是白字白底,在白色卡片上等于隐形 -->
<Button type="primary" ghost icon="md-create" size="small" @click="openSubmit(row)">修改提交</Button>
<Button icon="md-undo" size="small" @click="withdraw(row)">撤回</Button>
</template>
<Button icon="md-eye" size="small" @click="goDetail(row)">{{ myStatus(row) === "REVIEWED" || myStatus(row) === "RETURNED" ? "查看批阅" : "查看详情" }}</Button>
</div>
</div>
</div>
</div>
<div class="page-panel" v-if="page.total > pageSize">
<Page class="page-box fr" :total="page.total" :current="pageNum" :page-size="pageSize" @on-change="pageUpdate" show-total></Page>
</div>
<!-- 快捷提交弹窗 -->
<Modal v-model="sub.open" :title="(sub.mode === 'resubmit' ? '重新提交 · ' : '提交作业 · ') + sub.title" width="560px" class-name="submit-modal">
<Spin v-if="sub.loadingEcho" fix />
<div class="sm-field">
<div class="sm-label"><Icon type="md-create" size="16" /> 文字作答</div>
<Input v-model="sub.text" type="textarea" :rows="4" placeholder="输入作答内容"></Input>
</div>
<div class="sm-field">
<div class="sm-label"><Icon type="md-image" size="16" /> 图片 / 附件(先上传后提交)</div>
<AttachmentUpload v-model="sub.attachments"></AttachmentUpload>
</div>
<template #footer>
<Button @click="sub.open = false">取消</Button>
<Button type="primary" :loading="sub.submitting" icon="md-send" @click="submit">提交</Button>
</template>
</Modal>
</div>
</div>
</template>
<style lang="scss" scoped>
/*
* 学生端列表版式对齐宿舍通知学生端(ParentNotices.vue / NoticeList.vue):
* 1) 铺满内容区(原来是 max-width:960px,1440 视口下右侧空 280px)
* 2) 顶部不做装饰性大色块,用浅蓝信息条(#e8f4ff + #d3e9ff 描边,与宿舍通知「我的回执」条同款)
* 3) 列表项改为「左信息 / 右状态+操作」两栏,把横向宽度用起来,行高从 ~118px 收到 ~80px
* 4) 状态用 Tag 表达(原来是裸色文字,「未提交」的 #c5c8ce 在白卡上几乎看不见)
* 功能与交互(tab 过滤、搜索、分页、提交/撤回/跳详情)一行未动。
*/
.stu {
width: 100%;
}
/* ---------- 顶部信息条 ---------- */
.stu-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
background: #e8f4ff;
border: 1px solid #d3e9ff;
border-radius: 4px;
padding: 9px 14px;
margin-bottom: 12px;
.sh-left {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
color: #5979ea;
}
.sh-title {
flex: none;
font-size: 15px;
font-weight: 600;
color: #17233d;
}
.sh-sub {
font-size: 12px;
color: #808695;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sh-count {
flex: none;
font-size: 13px;
color: #515a6e;
b {
font-size: 16px;
color: #5979ea;
margin: 0 2px;
}
}
}
/* ---------- 筛选行(tab 左 / 搜索右) ---------- */
.stu-filter {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 12px;
.stu-tabs {
flex: 1;
min-width: 0;
:deep(.ivu-tabs-bar) {
margin-bottom: 0;
}
}
.stu-search {
flex: none;
width: 240px;
}
}
.state {
padding: 30px 0;
text-align: center;
&.empty {
color: #a6aab0;
p {
margin-top: 8px;
}
}
}
/* ---------- 列表项 ---------- */
.cards {
/* 分页条是 position:absolute 钉在视口底部(72px 高、白底),列表在其下方滚动会被盖住;
有分页时垫出这段高度,让最后一条能滚到分页条上方(同 Table 页预留横向滚动条的道理) */
&.has-page {
padding-bottom: 52px;
}
}
.cards .card {
display: flex;
align-items: center;
gap: 16px;
background: #fff;
border: 1px solid #ebeef4;
border-left: 3px solid #c5c8ce;
border-radius: 4px;
padding: 12px 16px;
margin-bottom: 10px;
transition:
box-shadow 0.2s,
border-color 0.2s;
&:hover {
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
}
&.redo {
border-color: #f5b0a6;
border-left-color: #ed4014;
background: #fffaf9;
}
/* 左:主体信息 */
.c-main {
flex: 1;
min-width: 0;
}
.c-top {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
cursor: pointer;
.c-subj {
background: #eaf3ff;
color: #5979ea;
border-radius: 3px;
padding: 1px 8px;
font-size: 12px;
line-height: 18px;
flex: none;
}
.c-title {
font-size: 15px;
font-weight: 600;
color: #17233d;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.c-late {
background: #ff9900;
color: #fff;
border-radius: 3px;
font-size: 11px;
line-height: 16px;
padding: 0 5px;
flex: none;
}
}
.c-meta {
display: flex;
flex-wrap: wrap;
gap: 4px 18px;
margin-top: 6px;
color: #808695;
font-size: 13px;
.m-item {
display: inline-flex;
align-items: center;
gap: 4px;
}
.overdue {
color: #ed4014;
}
.urgent {
color: #ff9900;
}
}
/* 评语条:inline-flex 让底色只包住内容,不拉满整行 */
.c-grade {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 100%;
margin-top: 8px;
padding: 5px 10px;
background: #f6ffed;
border-radius: 4px;
color: #389e0d;
font-size: 12px;
line-height: 18px;
&.warn {
background: #fff1f0;
color: #cf1322;
}
}
/* 右:状态 + 操作 */
.c-side {
flex: none;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
.c-status,
.c-flag {
font-size: 12px;
}
}
.c-actions {
display: flex;
gap: 8px;
}
}
/* 提交弹窗 */
.sm-field {
margin-bottom: 14px;
.sm-label {
font-size: 13px;
color: #515a6e;
margin-bottom: 6px;
display: flex;
align-items: center;
gap: 4px;
}
}
</style>