Files
coursehomework-front/src/views/homework/StudentHomeworks.vue
T

499 lines
17 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";
import config from "@/assets/config/host.config";
const serviceBase = config.isMock ? "" : config.serviceHost;
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: "已批阅",
};
const STATUS_COLOR: Record<string, string> = {
NOT_SUBMITTED: "#c5c8ce",
SUBMITTED: "#2d8cf0",
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)];
}
/** 仅查阅型(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 || "撤回失败");
}
},
});
}
/* ============ 图片放大预览 ============ */
const imgPreview = reactive({ open: false, url: "" });
function previewUrl(a: AttachmentItem) {
const u = a.fileUrl || "";
if (/^(https?:)?\/\//.test(u) || u.startsWith("data:")) return u;
return `${serviceBase}${u}`;
}
function zoom(url: string) {
imgPreview.url = url;
imgPreview.open = true;
}
</script>
<template>
<div class="page-wrapper">
<div class="main-container stu">
<!-- 顶部概览 -->
<div class="stu-head">
<div class="sh-left">
<div class="sh-icon"><Icon type="md-book" size="26" color="#fff" /></div>
<div>
<div class="sh-title">我的作业</div>
<div class="sh-sub">按时提交,查看老师批阅与订正</div>
</div>
</div>
<div class="sh-chips">
<div class="chip"><b>{{ page.total }}</b><span>本页作业</span></div>
</div>
</div>
<div class="fix mb-12">
<Tabs :value="tab" @on-click="onTab" class="fl stu-tabs">
<TabPane v-for="t in TABS" :key="t.name" :name="t.name" :label="t.label"></TabPane>
</Tabs>
<Input
class="fr"
style="width: 220px"
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" 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-top" @click="goDetail(row)">
<span class="c-subj">{{ row.subject || "作业" }}</span>
<span class="c-title">{{ row.title }}</span>
<Tag v-if="isViewOnly(row)" color="default" class="c-flag">仅查阅</Tag>
<template v-else>
<span v-if="row.my?.isLate" class="c-late">逾期</span>
<span class="c-status" :style="{ color: statusColor(row) }">{{ STATUS_TEXT[myStatus(row)] }}</span>
</template>
</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 && myStatus(row) !== 'REVIEWED', 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="16" /> 老师要求订正:{{ row.my?.comment || "请订正后重新提交" }}
</div>
<!-- 已批阅:得分摘要 -->
<div class="c-grade" v-else-if="myStatus(row) === 'REVIEWED'">
<Icon type="md-checkmark-circle" color="#19be6b" size="16" />
得分 {{ row.my?.score ?? "—" }}<template v-if="row.my?.comment"> · {{ row.my.comment }}</template>
</div>
<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'">
<Button 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 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>
<!-- 图片放大 -->
<Modal v-model="imgPreview.open" footer-hide title="图片预览" width="680px">
<div class="zoom-wrap"><img :src="imgPreview.url" /></div>
</Modal>
</div>
</div>
</template>
<style lang="scss" scoped>
.stu {
max-width: 960px;
}
.stu-head {
display: flex;
align-items: center;
justify-content: space-between;
background: linear-gradient(135deg, #2d8cf0, #5cadff);
border-radius: 10px;
padding: 16px 20px;
color: #fff;
margin-bottom: 14px;
box-shadow: 0 4px 14px rgba(45, 140, 240, 0.25);
.sh-left {
display: flex;
align-items: center;
gap: 12px;
}
.sh-icon {
width: 46px;
height: 46px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.18);
display: flex;
align-items: center;
justify-content: center;
}
.sh-title {
font-size: 18px;
font-weight: 600;
}
.sh-sub {
font-size: 12px;
opacity: 0.85;
margin-top: 2px;
}
.sh-chips {
display: flex;
gap: 10px;
.chip {
min-width: 66px;
text-align: center;
background: rgba(255, 255, 255, 0.16);
border-radius: 8px;
padding: 8px 10px;
b {
display: block;
font-size: 20px;
line-height: 1.1;
}
span {
font-size: 12px;
opacity: 0.9;
}
}
}
}
.stu-tabs {
margin-bottom: 0;
:deep(.ivu-tabs-bar) {
margin-bottom: 0;
}
}
.state {
padding: 30px 0;
text-align: center;
&.empty {
color: #a6aab0;
p {
margin-top: 8px;
}
}
}
.cards .card {
background: #fff;
border: 1px solid #eceef1;
border-left: 4px solid #c5c8ce;
border-radius: 8px;
padding: 14px 16px;
margin-bottom: 12px;
transition: box-shadow 0.2s;
&:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
}
&.redo {
border-color: #f5b0a6;
border-left-color: #ed4014;
background: #fffaf9;
}
.c-top {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
.c-subj {
background: #eaf3ff;
color: #2d8cf0;
border-radius: 4px;
padding: 2px 9px;
font-size: 12px;
flex: none;
}
.c-title {
font-size: 16px;
font-weight: 600;
color: #17233d;
}
.c-flag {
margin-left: auto;
}
.c-late {
background: #ff9900;
color: #fff;
border-radius: 3px;
font-size: 11px;
padding: 0 4px;
flex: none;
}
.c-status {
margin-left: auto;
font-size: 13px;
font-weight: 600;
flex: none;
}
}
.c-meta {
display: flex;
gap: 18px;
margin-top: 8px;
color: #808695;
font-size: 13px;
.m-item {
display: inline-flex;
align-items: center;
gap: 4px;
}
.overdue {
color: #ed4014;
}
.urgent {
color: #ff9900;
}
}
.c-grade {
margin-top: 10px;
padding: 8px 12px;
background: #f6ffed;
border-radius: 6px;
color: #389e0d;
font-size: 13px;
display: flex;
align-items: center;
gap: 6px;
&.warn {
background: #fff1f0;
color: #cf1322;
}
}
.c-actions {
margin-top: 12px;
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;
}
}
.zoom-wrap {
text-align: center;
img {
max-width: 100%;
max-height: 62vh;
}
}
</style>