Files
coursehomework-front/src/views/homework/StudentHomeworks.vue
T
lijing bb7a832290 feat(theme): 接入云海子主题通道(A 类免插件,规范 v1.12 §2/§3)
本次同时包含会话前遗留的本地未提交改动(作业模块 M5 相关页面/接口/样式调整、
host.config 的 SSO 兜底推导、.gitignore 等),按团队确认一并提交。

主题通道部分:
- index.html 插 A 类 loader:唯一接入点,FOUC 门/manifest 预取/主题 link 追加与排序
  全由通道 yh-theme-init.js 承担,主题升级走 /static 通道,本应用无需重新构建
- vite.config 加 /static dev 代理(YH_THEME_PROXY_TARGET 可覆盖,默认线上通道)
- host.config 回写 __APP_CONFIG__.serviceHost(仅同源):init 的 appearance 请求有硬性
  同源限制,跨源会被直接跳过;本地 dev 保持空串走同源 /api 代理
- 顶栏头像接平台键 yh-avatar,监听 yh-appearance-updated,userInfo 兜底
- view-ui-plus 声明锁 1.3.24(对齐通道皮肤生成版本)
- .gitea/workflows/build.yml:CI 平台统一更新(构建容器名唯一化 + npm 走 npmmirror)
2026-10-21 17:38:11 +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>