本次同时包含会话前遗留的本地未提交改动(作业模块 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)
732 lines
29 KiB
Vue
732 lines
29 KiB
Vue
<script setup lang="ts">
|
||
import { ref, reactive, computed, watch, onMounted, onBeforeUnmount, nextTick } from "vue";
|
||
import { useRouter } from "vue-router";
|
||
import { useRequest } from "alova/client";
|
||
import { Message } from "view-ui-plus";
|
||
import { teacherApi } from "@/api/module/teacherHomework";
|
||
import type { HomeworkV2Vo, TeacherHomeworkQuery } from "@/types/homeworkV2";
|
||
import type { BasePageResult, SubjectVo } from "@/types/homeworkV2";
|
||
import MyConfirm from "@/components/MyConfirm";
|
||
import Utils from "@/utils";
|
||
|
||
const router = useRouter();
|
||
|
||
/* 学科下拉绑定 /subjects/assigned(布置过 ∪ 任教,subId=筛选值与列表 subject 精确匹配);
|
||
* 接口失败或为空时降级为内置清单,保证筛选始终可用 */
|
||
const SUBJECTS = ["语文", "数学", "英语", "物理", "化学", "生物", "道德与法治", "历史", "地理", "科学", "综合实践"];
|
||
const subjectOpts = ref<SubjectVo[]>(SUBJECTS.map((s) => ({ subId: s, subName: s })));
|
||
async function loadSubjects() {
|
||
try {
|
||
const list = await teacherApi.assignedSubjects().send();
|
||
if (Array.isArray(list) && list.length) subjectOpts.value = list;
|
||
} catch {
|
||
/* 降级用内置清单 */
|
||
}
|
||
}
|
||
loadSubjects();
|
||
|
||
/* ============ 视图:单行 Tab(已发布/进行中/已截止/已归档 + 草稿箱,草稿箱用分隔线隔开,带数量) ============ */
|
||
type ViewMode = "published" | "drafts";
|
||
const viewMode = ref<ViewMode>("published");
|
||
/* 已发布状态筛选(空 = 已发布全部状态) */
|
||
const pubStatus = ref("");
|
||
const TAB_DEFS = [
|
||
{ name: "", label: "已发布" },
|
||
{ name: "PUBLISHED", label: "进行中" },
|
||
{ name: "CLOSED", label: "已截止" },
|
||
{ name: "ARCHIVED", label: "已归档" },
|
||
{ name: "drafts", label: "草稿箱" },
|
||
];
|
||
/* Tab 数量(接口无汇总端点,用 pageSize=1 的分页 total 汇出;随关键字/学科筛选刷新) */
|
||
const tabCounts = ref<Record<string, number>>({});
|
||
async function loadCounts() {
|
||
const base = { keyword: keyword.value || undefined, subject: subject.value || undefined, pageIndex: 1, pageSize: 1 };
|
||
try {
|
||
const [pub, closed, arch, draft] = await Promise.all([
|
||
teacherApi.published({ ...base, hwStatus: "PUBLISHED" }).send(),
|
||
teacherApi.published({ ...base, hwStatus: "CLOSED" }).send(),
|
||
teacherApi.published({ ...base, hwStatus: "ARCHIVED" }).send(),
|
||
teacherApi.drafts(base).send(),
|
||
]);
|
||
tabCounts.value = {
|
||
"": pub.total + closed.total + arch.total,
|
||
PUBLISHED: pub.total,
|
||
CLOSED: closed.total,
|
||
ARCHIVED: arch.total,
|
||
drafts: draft.total,
|
||
};
|
||
} catch {
|
||
/* 数量加载失败不影响主列表 */
|
||
}
|
||
}
|
||
/* Tab 标签用 render:文字 + 计数徽标(数量未加载时只显示文字) */
|
||
const PUB_TABS = computed(() =>
|
||
TAB_DEFS.map((t) => ({
|
||
name: t.name,
|
||
label: (h: any) =>
|
||
h("span", { class: "hw-tab" }, [
|
||
h("span", { class: "hw-tab-text" }, t.label),
|
||
tabCounts.value[t.name] === undefined
|
||
? null
|
||
: h("span", { class: "hw-tab-count" }, String(tabCounts.value[t.name])),
|
||
]),
|
||
}))
|
||
);
|
||
const tabValue = computed(() => (viewMode.value === "drafts" ? "drafts" : pubStatus.value));
|
||
|
||
/* ============ 搜索条件 ============ */
|
||
const keyword = ref("");
|
||
const subject = ref("");
|
||
|
||
/* ============ 分页 ============ */
|
||
const pageNum = ref(1);
|
||
const pageSize = ref(parseInt(localStorage.getItem("customPageCount") || "10"));
|
||
|
||
const EMPTY_PAGE: BasePageResult<HomeworkV2Vo> = { content: [], total: 0, pageIndex: 1, pageSize: 10, totalPages: 0, hasNext: false };
|
||
const { data: page, loading, send: reload } = useRequest((params: TeacherHomeworkQuery, mode: ViewMode) =>
|
||
mode === "drafts" ? teacherApi.drafts(params) : teacherApi.published(params),
|
||
{ immediate: false, initialData: EMPTY_PAGE });
|
||
|
||
async function run() {
|
||
const params: TeacherHomeworkQuery = {
|
||
keyword: keyword.value || undefined,
|
||
subject: subject.value || undefined,
|
||
pageIndex: pageNum.value,
|
||
pageSize: pageSize.value,
|
||
};
|
||
if (viewMode.value === "published" && pubStatus.value) params.hwStatus = pubStatus.value;
|
||
loadCounts();
|
||
try {
|
||
await reload(params, viewMode.value);
|
||
} catch (e: any) {
|
||
Message.error(e.message || "获取作业列表失败");
|
||
}
|
||
}
|
||
function doSearch() {
|
||
pageNum.value = 1;
|
||
run();
|
||
}
|
||
function onTab(name: string) {
|
||
if (name === "drafts") {
|
||
viewMode.value = "drafts";
|
||
} else {
|
||
viewMode.value = "published";
|
||
pubStatus.value = name;
|
||
}
|
||
doSearch();
|
||
}
|
||
function pageUpdate(p: number) {
|
||
pageNum.value = p;
|
||
run();
|
||
}
|
||
function pageSizeUpdate(size: number) {
|
||
pageNum.value = 1;
|
||
pageSize.value = size;
|
||
localStorage.setItem("customPageCount", String(size));
|
||
run();
|
||
}
|
||
|
||
run();
|
||
watch(
|
||
() => router.currentRoute.value.path,
|
||
() => {
|
||
pageNum.value = 1;
|
||
keyword.value = "";
|
||
subject.value = "";
|
||
pubStatus.value = "";
|
||
viewMode.value = "published";
|
||
run();
|
||
}
|
||
);
|
||
|
||
/* ============ 状态列 & 操作列(与「全部作业」共用 Utils 里那一套口径) ============ */
|
||
/**
|
||
* 状态:统一走 Utils.hwStatusPill(状态图标 + 文字 + 同色浅底)。
|
||
* 之前这里用 iview Tag、全部作业用圆点,同一个模块两套观感;现在两边同一张状态表,
|
||
* 颜色/图标/文案都只在 utils/index.ts 的 HW_STATUS_META 里定义一次。
|
||
*/
|
||
function statusCell(h: any, code: string | undefined, text?: string) {
|
||
return Utils.hwStatusPill(h, code, text);
|
||
}
|
||
function fmtTime(v?: string | null): string {
|
||
return v ? String(v).slice(0, 16) : "—";
|
||
}
|
||
/** 短字段单元格:强制单行(时间、状态这类短文本折行很难看,且会顶破 48px 行高) */
|
||
function nowrapCell(h: any, text: string, style = "") {
|
||
return h("span", { style: `white-space:nowrap;${style}` }, text);
|
||
}
|
||
/** 长字段单元格:单行 + 省略号,悬停由原生 title 看全文(与宿舍通知「接收宿舍」同一手法) */
|
||
function ellipsisCell(h: any, text: string) {
|
||
return h(
|
||
"span",
|
||
{
|
||
title: text,
|
||
style: "display:inline-block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:middle;",
|
||
},
|
||
text
|
||
);
|
||
}
|
||
|
||
/* ============ 行操作 ============ */
|
||
function viewDetail(row: HomeworkV2Vo) {
|
||
router.push({ path: "/list/detail", query: { serid: String(row.id) } });
|
||
}
|
||
function viewSubmissions(row: HomeworkV2Vo) {
|
||
router.push({ path: "/submissions", query: { serid: String(row.id) } });
|
||
}
|
||
function toGrade(row: HomeworkV2Vo) {
|
||
router.push({ path: "/grade", query: { serid: String(row.id) } });
|
||
}
|
||
/** 勾选了任一提交渠道(拍照/图片、上传附件、文字作答)=教师需批改 → 显示批阅入口;
|
||
* 仅查阅(none)=学生只预览,不显示批阅 */
|
||
function needGrade(row: HomeworkV2Vo): boolean {
|
||
return (row.submitType || []).some((t) => t !== "none");
|
||
}
|
||
function edit(row: HomeworkV2Vo) {
|
||
router.push({ path: "/list/new", query: { serid: String(row.id) } });
|
||
}
|
||
function remind(row: HomeworkV2Vo, type: "NOT_READ" | "NOT_SUBMITTED") {
|
||
const label = type === "NOT_READ" ? "未读" : "未交";
|
||
MyConfirm({
|
||
title: "提醒",
|
||
msg: `向“ ${row.title} ”的${label}学生发送提醒?`,
|
||
async onOk() {
|
||
try {
|
||
const r = await teacherApi.remind(row.id, type).send();
|
||
Message.success(`已提醒 ${r?.reminded ?? 0} 位${label}学生`);
|
||
} catch (e: any) {
|
||
Message.error(e.message || "提醒失败");
|
||
}
|
||
},
|
||
});
|
||
}
|
||
|
||
/* 延长截止(仅进行中;新截止须晚于 now 与原截止) */
|
||
const extendModal = reactive({ open: false, row: null as HomeworkV2Vo | null, time: null as Date | null, saving: false });
|
||
function openExtend(row: HomeworkV2Vo) {
|
||
extendModal.row = row;
|
||
extendModal.time = null;
|
||
extendModal.open = true;
|
||
}
|
||
async function doExtend() {
|
||
const row = extendModal.row;
|
||
if (!row) return;
|
||
if (!extendModal.time) {
|
||
Message.info("请选择新的截止时间");
|
||
return;
|
||
}
|
||
const d = extendModal.time;
|
||
const pad = (n: number) => String(n).padStart(2, "0");
|
||
const at = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:00`;
|
||
if (row.deadlineAt && at <= String(row.deadlineAt)) {
|
||
Message.info("新截止时间必须晚于原截止时间");
|
||
return;
|
||
}
|
||
extendModal.saving = true;
|
||
try {
|
||
await teacherApi.extend(row.id, at).send();
|
||
Message.success("截止时间已延长");
|
||
extendModal.open = false;
|
||
run();
|
||
} catch (e: any) {
|
||
Message.error(e.message || "延长截止失败");
|
||
} finally {
|
||
extendModal.saving = false;
|
||
}
|
||
}
|
||
|
||
function copyRow(row: HomeworkV2Vo) {
|
||
MyConfirm({
|
||
title: "复制",
|
||
msg: `复制“ ${row.title} ”为新草稿?`,
|
||
info: "复制作业内容与班级设置,不含提交与阅读数据",
|
||
async onOk() {
|
||
try {
|
||
const newId = await teacherApi.copy(row.id).send();
|
||
Message.success("已复制为新草稿");
|
||
router.push({ path: "/list/new", query: { serid: String(newId) } });
|
||
} catch (e: any) {
|
||
Message.error(e.message || "复制失败");
|
||
}
|
||
},
|
||
});
|
||
}
|
||
function archive(row: HomeworkV2Vo) {
|
||
MyConfirm({
|
||
title: "归档",
|
||
msg: `归档“ ${row.title} ”?`,
|
||
info: "归档后作业只读,不再触发任何提醒",
|
||
async onOk() {
|
||
try {
|
||
await teacherApi.archive(row.id).send();
|
||
Message.success("已归档");
|
||
run();
|
||
} catch (e: any) {
|
||
Message.error(e.message || "归档失败");
|
||
}
|
||
},
|
||
});
|
||
}
|
||
function doExport(row: HomeworkV2Vo) {
|
||
window.location.href = teacherApi.exportUrl(row.id);
|
||
}
|
||
|
||
/* 草稿箱操作 */
|
||
function publishDraft(row: HomeworkV2Vo) {
|
||
MyConfirm({
|
||
title: "发布",
|
||
msg: `立即发布“ ${row.title} ”?`,
|
||
info: "发布后参与班级学生将收到作业通知",
|
||
async onOk() {
|
||
try {
|
||
await teacherApi.publish(row.id).send();
|
||
Message.success("发布成功");
|
||
viewMode.value = "published";
|
||
pubStatus.value = "";
|
||
pageNum.value = 1;
|
||
run();
|
||
} catch (e: any) {
|
||
Message.error(e.message || "发布失败");
|
||
}
|
||
},
|
||
});
|
||
}
|
||
function cancelSchedule(row: HomeworkV2Vo) {
|
||
MyConfirm({
|
||
title: "取消定时",
|
||
msg: `取消“ ${row.title} ”的定时发布?`,
|
||
info: "取消后作业回到草稿状态",
|
||
async onOk() {
|
||
try {
|
||
await teacherApi.schedule(row.id, { cancel: true }).send();
|
||
Message.success("已取消定时");
|
||
run();
|
||
} catch (e: any) {
|
||
Message.error(e.message || "取消定时失败");
|
||
}
|
||
},
|
||
});
|
||
}
|
||
function del(row: HomeworkV2Vo) {
|
||
MyConfirm({
|
||
title: "删除",
|
||
msg: `确定删除“ ${row.title} ”这条作业吗?`,
|
||
info: "删除后不可恢复",
|
||
async onOk() {
|
||
try {
|
||
await teacherApi.remove(row.id).send();
|
||
Message.success("删除成功");
|
||
if ((page.value?.content?.length || 0) <= 1 && pageNum.value > 1) pageNum.value -= 1;
|
||
run();
|
||
} catch (e: any) {
|
||
Message.error(e.message || "删除失败");
|
||
}
|
||
},
|
||
});
|
||
}
|
||
|
||
/* ============ 已发布列表列 ============ */
|
||
/**
|
||
* 列宽口径(对齐宿舍通知 NoticeList):
|
||
* - 序号列 ≥ 76:iview 单元格左右各 18px padding,「序号」两字约 36px,给 60 会被挤成「序/号」两行;
|
||
* - 「未读未交」「待批阅」「提交率」这类 3~4 字表头各自留足一字不折行的宽度;
|
||
* - 时间列 150 + nowrap:130 会把「2026-09-15 10:00」折成两行;
|
||
* - 班级单行省略,悬停由原生 title 看全文;
|
||
* - 列宽合计 ~1700px,宽屏也保留横向滚动(滚动条 8px 圆角的样式见 App.vue 的
|
||
* .ivu-table-overflowX 覆写)。**不要再为了「收进一屏」去压列宽或合并列** ——
|
||
* 把列压窄只会让表头折行,越改越难看。
|
||
*/
|
||
const pubColumns = computed(() => [
|
||
{ title: "序号", type: "index", width: 76, align: "center" },
|
||
{
|
||
title: "标题",
|
||
key: "title",
|
||
minWidth: 300,
|
||
align: "left",
|
||
tooltip: true,
|
||
/* 标题是长自由文本,列宽再大也会超 —— 单行 + 省略号,悬停用原生 title 看全文
|
||
* (宿舍通知 NoticeList 的 titleCell 就是「蓝色链接 + title 属性」,这里补上省略号,
|
||
* 否则长标题会折成两行、和「全部作业」页的单行样式不一致) */
|
||
render: (h: any, p: any) =>
|
||
h(
|
||
"a",
|
||
{
|
||
style: "display:inline-block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:middle;color:#2d8cf0;",
|
||
title: p.row.title,
|
||
onClick: () => viewDetail(p.row),
|
||
},
|
||
() => p.row.title
|
||
),
|
||
},
|
||
{ title: "学科", key: "subjectName", width: 120, align: "center" },
|
||
{
|
||
title: "班级",
|
||
width: 190,
|
||
align: "left",
|
||
render: (h: any, p: any) => ellipsisCell(h, (p.row.classes || []).map((c: any) => c.className).join("、") || "—"),
|
||
},
|
||
{ title: "发布时间", width: 170, align: "left", render: (h: any, p: any) => nowrapCell(h, fmtTime(p.row.publishedAt)) },
|
||
{ title: "截止时间", width: 170, align: "left", render: (h: any, p: any) => nowrapCell(h, fmtTime(p.row.deadlineAt)) },
|
||
{
|
||
title: "应交",
|
||
width: 70,
|
||
align: "center",
|
||
render: (h: any, p: any) => h("span", {}, String(p.row.progress?.should ?? "—")),
|
||
},
|
||
{
|
||
title: "已读",
|
||
width: 70,
|
||
align: "center",
|
||
render: (h: any, p: any) => h("span", {}, String(p.row.progress?.read ?? "—")),
|
||
},
|
||
{
|
||
title: "已交",
|
||
width: 70,
|
||
align: "center",
|
||
render: (h: any, p: any) => h("span", {}, String(p.row.progress?.submitted ?? "—")),
|
||
},
|
||
{
|
||
title: "未读未交",
|
||
width: 100,
|
||
align: "center",
|
||
render: (h: any, p: any) => {
|
||
const g = p.row.progress;
|
||
if (!g) return h("span", { style: "color:#999;" }, "—");
|
||
return h("span", { style: g.unreadUndone > 0 ? "color:#ed4014;font-weight:600;" : "color:#999;" }, String(g.unreadUndone));
|
||
},
|
||
},
|
||
{
|
||
title: "提交率",
|
||
width: 90,
|
||
align: "center",
|
||
render: (h: any, p: any) => {
|
||
const g = p.row.progress;
|
||
if (!g) return h("span", { style: "color:#999;" }, "—");
|
||
return h("span", {}, `${g.submitRate}%`);
|
||
},
|
||
},
|
||
{
|
||
title: "待批阅",
|
||
width: 95,
|
||
align: "center",
|
||
render: (h: any, p: any) => {
|
||
const g = p.row.progress;
|
||
if (!g) return h("span", { style: "color:#999;" }, "—");
|
||
return h("span", { style: g.pendingGrade > 0 ? "color:#f90;font-weight:600;" : "color:#999;" }, String(g.pendingGrade));
|
||
},
|
||
},
|
||
{
|
||
title: "状态",
|
||
width: 120,
|
||
align: "center",
|
||
render: (h: any, p: any) => statusCell(h, p.row.hwStatus, p.row.hwStatusDesc),
|
||
},
|
||
{
|
||
title: "操作",
|
||
slot: "action",
|
||
/* 3 个图标按钮:32px + tooltip 左右各 2px = 36px → 108px,再留单元格左右 padding
|
||
各 18px(36px) 与少量余量 → 170 恰好一行;给 150 时最后一个会被挤到第二行 */
|
||
width: 170,
|
||
align: "center",
|
||
fixed: "right",
|
||
},
|
||
]);
|
||
|
||
/* 更多菜单分发 */
|
||
function onPubMore(name: string, row: HomeworkV2Vo) {
|
||
if (name === "notRead") remind(row, "NOT_READ");
|
||
else if (name === "notSubmitted") remind(row, "NOT_SUBMITTED");
|
||
else if (name === "extend") openExtend(row);
|
||
else if (name === "copy") copyRow(row);
|
||
else if (name === "archive") archive(row);
|
||
else if (name === "export") doExport(row);
|
||
}
|
||
|
||
/* ============ 草稿箱列 ============ */
|
||
const draftColumns = computed(() => [
|
||
{ title: "序号", type: "index", width: 76, align: "center" },
|
||
{ title: "标题", key: "title", minWidth: 300, align: "left", tooltip: true },
|
||
{ title: "学科", key: "subjectName", width: 120, align: "center" },
|
||
{
|
||
title: "班级",
|
||
width: 190,
|
||
align: "left",
|
||
render: (h: any, p: any) => ellipsisCell(h, (p.row.classes || []).map((c: any) => c.className).join("、") || "—"),
|
||
},
|
||
{ title: "更新时间", width: 170, align: "left", render: (h: any, p: any) => nowrapCell(h, fmtTime(p.row.updateTime)) },
|
||
{ title: "截止时间", width: 170, align: "left", render: (h: any, p: any) => nowrapCell(h, fmtTime(p.row.deadlineAt)) },
|
||
{
|
||
title: "状态",
|
||
/* 「定时 2026-09-18 08:00」+ 状态图标 + 胶囊内边距实测 185px,单元格左右 padding 各 18px
|
||
→ 列宽给 230 才有余量(220 实测只剩 0~1px,改字号/字体就会截掉一截) */
|
||
width: 230,
|
||
align: "center",
|
||
render: (h: any, p: any) => {
|
||
const row = p.row as HomeworkV2Vo;
|
||
const scheduled = row.hwStatus === "DRAFT_SCHEDULED";
|
||
return statusCell(h, row.hwStatus, scheduled ? `定时 ${fmtTime(row.scheduledAt)}` : undefined);
|
||
},
|
||
},
|
||
{
|
||
title: "操作",
|
||
slot: "draftAction",
|
||
/* 最多 4 个图标按钮(编辑/发布/取消定时/删除):4 × 36 + padding 36 + 余量 = 200 */
|
||
width: 200,
|
||
align: "center",
|
||
fixed: "right",
|
||
},
|
||
]);
|
||
|
||
const columns = computed(() => (viewMode.value === "drafts" ? draftColumns.value : pubColumns.value));
|
||
|
||
/* ============ 表格高度:量真实 DOM,不用常量估算 ============ */
|
||
/** 表格外层容器(量它的顶边位置) */
|
||
const tableWrap = ref<HTMLElement | null>(null);
|
||
const tableHeight = ref(400);
|
||
|
||
/**
|
||
* 表格高度 = 分页条顶边 − 表格顶边 − 12px 呼吸位(列宽超出容器时再扣掉横向滚动条约 15px)。
|
||
*
|
||
* 原先用 `Utils.useTableHeight({isSearch,isOperat,isPage})` 按 body 高度减固定值估算:
|
||
* 它既不认本页把状态 Tab 嵌在搜索行里的实际高度,也不认容器内边距,算出的值与真实可用
|
||
* 空间对不齐;而 `.page-panel` 是 absolute 吸底定位,表格一旦比可用空间高就压到分页上,
|
||
* 横向滚动条也没给它留位置(最后一行会被滚动条切掉)。
|
||
* 这里直接量 DOM —— 加减筛选条件、切 Tab、改字号都不会再失配;窗口 resize 时重算。
|
||
*/
|
||
function calcTableHeight() {
|
||
const wrap = tableWrap.value;
|
||
if (!wrap) return;
|
||
const rect = wrap.getBoundingClientRect();
|
||
const panel = wrap.parentElement?.querySelector(".page-panel") as HTMLElement | null;
|
||
const limit = panel ? panel.getBoundingClientRect().top : window.innerHeight;
|
||
// 列宽合计超过容器宽度 → 会出现横向滚动条,它会吃掉表体 ~15px 高度,一并预留
|
||
const colsWidth = (columns.value as any[]).reduce((s, c) => s + (c.width || c.minWidth || 100), 0);
|
||
const hBar = colsWidth > wrap.clientWidth ? 15 : 0;
|
||
tableHeight.value = Math.max(240, Math.floor(limit - rect.top - 12 - hBar));
|
||
}
|
||
onMounted(() => {
|
||
nextTick(calcTableHeight);
|
||
window.addEventListener("resize", calcTableHeight);
|
||
});
|
||
onBeforeUnmount(() => window.removeEventListener("resize", calcTableHeight));
|
||
/* 已发布 / 草稿箱两套列宽不同 → 横向滚动条的有无会变,切 Tab 后重算 */
|
||
watch(columns, () => nextTick(calcTableHeight));
|
||
</script>
|
||
|
||
<template>
|
||
<div class="page-wrapper">
|
||
<div class="main-container">
|
||
<!-- 1. 操作 -->
|
||
<div class="fix mb-12">
|
||
<Button class="fl" type="primary" size="large" @click="router.push('/list/new')">布置作业</Button>
|
||
</div>
|
||
|
||
<!-- 2. 状态筛选(单行 Tab) + 搜索区 -->
|
||
<div class="search-wrap fix">
|
||
<div class="fl">
|
||
<Tabs :value="tabValue" @on-click="onTab" size="small" class="inner-tabs">
|
||
<TabPane v-for="t in PUB_TABS" :key="t.name" :name="t.name" :label="t.label"></TabPane>
|
||
</Tabs>
|
||
</div>
|
||
<Select v-model="subject" class="search-wrap_sel ml-12 fl" clearable placeholder="学科" style="width: 120px" @on-change="doSearch">
|
||
<Option v-for="s in subjectOpts" :key="s.subId" :value="s.subId">{{ s.subName }}</Option>
|
||
</Select>
|
||
<Input
|
||
class="fr"
|
||
style="width: 200px"
|
||
search
|
||
suffix="ios-search"
|
||
placeholder="输入关键字搜索"
|
||
v-model="keyword"
|
||
@on-search="doSearch"
|
||
@on-clear="doSearch"
|
||
/>
|
||
</div>
|
||
|
||
<!-- 3. 表格 + 分页(tableWrap 供 calcTableHeight 量真实可用高度) -->
|
||
<div ref="tableWrap">
|
||
<Table
|
||
:columns="columns"
|
||
:data="page.content"
|
||
class="link-table table-bottom-box"
|
||
:height="tableHeight"
|
||
:loading="loading"
|
||
no-data-text="暂无数据"
|
||
>
|
||
<!-- 已发布行操作:图标按钮 + 悬浮说明
|
||
(提交情况=icon-chakan、批阅=icon-shenpi,与「全部作业」「提交情况」页同一套语义;
|
||
其余低频操作仍收在「更多」里,不丢功能) -->
|
||
<template #action="{ row }">
|
||
<Tooltip content="提交情况" placement="bottom" transfer>
|
||
<div class="operating-btn iconfont icon-chakan" @click="viewSubmissions(row)"></div>
|
||
</Tooltip>
|
||
<Tooltip v-if="needGrade(row)" content="批阅" placement="bottom" transfer>
|
||
<div class="operating-btn iconfont icon-shenpi" @click="toGrade(row)"></div>
|
||
</Tooltip>
|
||
<Dropdown transfer @on-click="(name: string) => onPubMore(name, row)">
|
||
<!-- 触发器不再包 Tooltip:Dropdown 默认 hover 展开,悬停时两个气泡会打架,
|
||
这里用原生 title 兜底说明 -->
|
||
<div class="operating-btn operating-btn-more" title="更多"><Icon type="ios-more" size="20" /></div>
|
||
<template #list>
|
||
<DropdownMenu>
|
||
<DropdownItem v-if="row.hwStatus === 'PUBLISHED' && (row.progress?.unread ?? 0) > 0" name="notRead">提醒未读</DropdownItem>
|
||
<DropdownItem v-if="row.hwStatus === 'PUBLISHED' && (row.progress?.unsubmitted ?? 0) > 0" name="notSubmitted">催交</DropdownItem>
|
||
<DropdownItem v-if="row.hwStatus === 'PUBLISHED'" name="extend">延长截止</DropdownItem>
|
||
<DropdownItem name="copy">复制</DropdownItem>
|
||
<DropdownItem v-if="row.hwStatus === 'PUBLISHED' || row.hwStatus === 'CLOSED'" name="archive">归档</DropdownItem>
|
||
<DropdownItem name="export">导出报表</DropdownItem>
|
||
</DropdownMenu>
|
||
</template>
|
||
</Dropdown>
|
||
</template>
|
||
<!-- 草稿箱行操作:同一套图标口径(编辑/发布/取消定时/删除) -->
|
||
<template #draftAction="{ row }">
|
||
<Tooltip content="编辑" placement="bottom" transfer>
|
||
<div class="operating-btn iconfont icon-edit" @click="edit(row)"></div>
|
||
</Tooltip>
|
||
<Tooltip content="发布" placement="bottom" transfer>
|
||
<div class="operating-btn iconfont icon-fasong" @click="publishDraft(row)"></div>
|
||
</Tooltip>
|
||
<Tooltip v-if="row.hwStatus === 'DRAFT_SCHEDULED'" content="取消定时" placement="bottom" transfer>
|
||
<div class="operating-btn iconfont icon-stop" @click="cancelSchedule(row)"></div>
|
||
</Tooltip>
|
||
<Tooltip content="删除" placement="bottom" transfer>
|
||
<div class="operating-btn iconfont icon-ashbin" @click="del(row)"></div>
|
||
</Tooltip>
|
||
</template>
|
||
</Table>
|
||
</div>
|
||
<div class="page-panel">
|
||
<div class="fl ml-15 align-middle"></div>
|
||
<Page
|
||
class="page-box fr"
|
||
:total="page.total"
|
||
:current="pageNum"
|
||
:page-size="pageSize"
|
||
:page-size-opts="[10, 20, 30, 40]"
|
||
@on-page-size-change="pageSizeUpdate"
|
||
@on-change="pageUpdate"
|
||
placement="top"
|
||
show-total
|
||
show-sizer
|
||
></Page>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 延长截止 -->
|
||
<Modal v-model="extendModal.open" title="延长截止时间" width="420px">
|
||
<Form :label-width="100">
|
||
<FormItem label="新截止时间:">
|
||
<DatePicker
|
||
type="datetime"
|
||
transfer
|
||
v-model="extendModal.time"
|
||
format="yyyy-MM-dd HH:mm"
|
||
placeholder="选择新的截止时间"
|
||
style="width: 240px"
|
||
></DatePicker>
|
||
<div class="extend-tip" v-if="extendModal.row?.deadlineAt">
|
||
原截止时间:{{ extendModal.row.deadlineAt }}
|
||
</div>
|
||
</FormItem>
|
||
</Form>
|
||
<template #footer>
|
||
<Button type="primary" :loading="extendModal.saving" @click="doExtend">保存</Button>
|
||
<Button @click="extendModal.open = false" style="margin-left: 8px">取消</Button>
|
||
</template>
|
||
</Modal>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped lang="scss">
|
||
.search-wrap {
|
||
.inner-tabs {
|
||
margin-bottom: -8px;
|
||
:deep(.ivu-tabs-bar) {
|
||
margin-bottom: 0;
|
||
}
|
||
/* ink bar 由组件 JS 按前序 tab 宽度累计定位,自定义 margin 间距会使其偏移,
|
||
* 故隐藏之,改用激活项伪元素画下划线 */
|
||
:deep(.ivu-tabs-ink-bar) {
|
||
display: none;
|
||
}
|
||
:deep(.ivu-tabs-tab) {
|
||
position: relative;
|
||
margin-right: 28px;
|
||
padding: 10px 2px;
|
||
transition: color 0.2s ease;
|
||
}
|
||
/* 激活下划线:加粗 + 圆角,随 tab 盒子对齐 */
|
||
:deep(.ivu-tabs-tab-active)::after,
|
||
:deep(.ivu-tabs-tab-active:hover)::after {
|
||
content: "";
|
||
position: absolute;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
height: 3px;
|
||
border-radius: 3px 3px 0 0;
|
||
background: #2d8cf0;
|
||
}
|
||
/* 标签文字 + 计数徽标 */
|
||
:deep(.hw-tab) {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
.hw-tab-text {
|
||
font-size: 14px;
|
||
}
|
||
.hw-tab-count {
|
||
min-width: 16px;
|
||
margin-left: 6px;
|
||
padding: 0 6px;
|
||
font-size: 12px;
|
||
line-height: 16px;
|
||
text-align: center;
|
||
border-radius: 8px;
|
||
background: #f2f3f5;
|
||
color: #808695;
|
||
transition: all 0.2s ease;
|
||
}
|
||
}
|
||
:deep(.ivu-tabs-tab:hover .hw-tab-count) {
|
||
background: #e8f3ff;
|
||
color: #2d8cf0;
|
||
}
|
||
:deep(.ivu-tabs-tab-active .hw-tab-count),
|
||
:deep(.ivu-tabs-tab-active:hover .hw-tab-count) {
|
||
background: #2d8cf0;
|
||
color: #fff;
|
||
}
|
||
/* 末位(草稿箱)与状态组之间用竖线隔开 */
|
||
:deep(.ivu-tabs-tab:last-child) {
|
||
margin-right: 0;
|
||
margin-left: 26px;
|
||
&::before {
|
||
content: "";
|
||
position: absolute;
|
||
left: -14px;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
width: 1px;
|
||
height: 16px;
|
||
background: #dcdee2;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.extend-tip {
|
||
color: #808695;
|
||
font-size: 12px;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
/* 操作列图标按钮组:Tooltip / Dropdown 两种容器都是 inline-block,
|
||
默认 baseline 对齐会让「更多」比另两个偏低 2~3px,统一按中线对齐 */
|
||
:deep(.ivu-table-cell) {
|
||
.ivu-tooltip,
|
||
.ivu-dropdown {
|
||
vertical-align: middle;
|
||
}
|
||
}
|
||
</style>
|