feat: 提交课程作业前端工程源码(Vue3 + Vite + view-ui-plus)
This commit is contained in:
@@ -0,0 +1,498 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user