feat(M5-P3): 学生端闭环切换 V2
- StudentHomeworks 切 V2 列表(PRD 学生页 1):tab=all/todo/done/closed 服务端 分页 + keyword;五态徽标 + 逾期角标 + 截止倒计时(逾期/临期变色);todo 桶内 RETURNED 红色强化置顶(/stu/redo initTab=todo);快捷提交弹窗改 AttachmentUpload + studentApi(resubmit 走 PUT action) - HomeworkAccept 改造为学生详情页挂 /stu/detail(PRD 学生页 2/3):作业说明/附件、 进入 3 秒阅读上报(onUnmounted 清理)、我的提交 + 批阅结果 + 历史版本各版评语、 按 submitType 动态提交表单、截止/allowLate 分流、修改提交生成新版本、 截止前撤回、打回订正重交 - 菜单:静态兜底菜单对齐 V2(今日作业/待订正/批改中心),已收作业别名导向 /stu/today
This commit is contained in:
@@ -6,13 +6,16 @@ import type { IModule } from "@/store";
|
||||
* 注:课程作业不做审批、不开放应用设置,故不含「审批列表/审核设置/应用设置」;
|
||||
* 平台模块配置若仍下发 modId 5/6/21,由 utils/menuRoute 的 DROPPED_MOD_IDS
|
||||
* 统一剔除(见 classwork-frontend-m5-plan.md §6、§7)。
|
||||
* v1「已收到」(modId 1) 收件箱已随 M5 下线:学生端入口为 /stu/today(今日作业)。
|
||||
*/
|
||||
export function getMenuList(): Promise<IModule[]> {
|
||||
const menu: IModule[] = [
|
||||
{ id: 1, modId: 1, name: "已收到", page: "/" },
|
||||
{ id: 2, modId: 2, name: "已发送", page: "/list" },
|
||||
{ id: 2, modId: 2, name: "我布置的", page: "/list" },
|
||||
{ id: 26, modId: 26, name: "批改中心", page: "/grade" },
|
||||
{ id: 3, modId: 3, name: "全部作业", page: "/homeworklist" },
|
||||
{ id: 4, modId: 4, name: "作业统计", page: "/stats" },
|
||||
{ id: 31, modId: 31, name: "今日作业", page: "/stu/today" },
|
||||
{ id: 32, modId: 32, name: "待订正", page: "/stu/redo" },
|
||||
];
|
||||
return Promise.resolve(menu);
|
||||
}
|
||||
|
||||
@@ -39,7 +39,10 @@ const NAME_ALIASES: Record<string, string> = {
|
||||
作业发送: "/list/new",
|
||||
发作业: "/list/new",
|
||||
已发作业: "/list",
|
||||
已收作业: "/accept",
|
||||
// v1 收件箱已下线(M5 §2.3):旧叫法统一导向学生今日作业
|
||||
已收到: "/stu/today",
|
||||
已收作业: "/stu/today",
|
||||
今日作业: "/stu/today",
|
||||
作业列表: "/homeworklist",
|
||||
统计分析: "/stats",
|
||||
};
|
||||
|
||||
@@ -1,117 +1,192 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { useRequest } from "alova/client";
|
||||
import { Message, Tooltip } from "view-ui-plus";
|
||||
import { homeworkApi } from "@/api/module/homework";
|
||||
import type { HomeworkDetailVo, HomeworkQuery, HomeworkVo, HomeworkPageResult } from "@/types/homework";
|
||||
import { ref, reactive, computed, onMounted, onUnmounted, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { studentApi } from "@/api/module/studentHomework";
|
||||
import type { AttachmentItem, StudentHomeworkDetailV2Vo, StudentResultVo, SubmissionVersionVo } from "@/types/homeworkV2";
|
||||
import AttachmentUpload from "@/components/AttachmentUpload.vue";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import FilePreviewModal from "@/components/FilePreviewModal.vue";
|
||||
import Utils from "@/utils";
|
||||
import config from "@/assets/config/host.config";
|
||||
|
||||
/* ============ 左侧列表 ============ */
|
||||
const keyword = ref("");
|
||||
const pageNum = ref(1);
|
||||
const pageSize = ref(50);
|
||||
const selected = ref(0);
|
||||
/**
|
||||
* 学生作业详情页(M5 P3,PRD 学生页 2/3):
|
||||
* 作业说明 + 附件 → 进入 3 秒阅读上报 → 我的提交/批阅结果/历史版本 → 提交/撤回/打回重交。
|
||||
*/
|
||||
|
||||
const { data: page, loading, send: reload } = useRequest(
|
||||
(params: HomeworkQuery) => homeworkApi.list(params),
|
||||
{
|
||||
immediate: false,
|
||||
initialData: { content: [], total: 0, pageIndex: 1, pageSize: 50, totalPages: 0, hasNext: false, unreadCount: 0 },
|
||||
}
|
||||
);
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const serviceBase = config.isMock ? "" : config.serviceHost;
|
||||
|
||||
async function run() {
|
||||
const data: HomeworkPageResult<HomeworkVo> = await reload({
|
||||
keyword: keyword.value || "",
|
||||
pageIndex: pageNum.value,
|
||||
pageSize: pageSize.value,
|
||||
orderByTime: "1",
|
||||
});
|
||||
// 默认选中第一条(同旧版进入页面即展示最新作业)
|
||||
const list = data?.content || [];
|
||||
if (!detail.value && list.length) {
|
||||
selected.value = 0;
|
||||
await loadDetail(list[0].id, true);
|
||||
}
|
||||
}
|
||||
function onSearch() {
|
||||
pageNum.value = 1;
|
||||
run();
|
||||
}
|
||||
function onClear() {
|
||||
keyword.value = "";
|
||||
onSearch();
|
||||
}
|
||||
const serid = computed(() => Number(route.query.serid) || 0);
|
||||
|
||||
/* 一键已读(旧版 icon-saoba 清除按钮) */
|
||||
async function allRead() {
|
||||
const detail = ref<StudentHomeworkDetailV2Vo | null>(null);
|
||||
const result = ref<StudentResultVo | null>(null);
|
||||
const loading = ref(false);
|
||||
|
||||
async function load() {
|
||||
if (!serid.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
await homeworkApi.initializeRed().send();
|
||||
Message.success("操作成功!");
|
||||
run();
|
||||
if (detail.value) loadDetail(detail.value.serid, false);
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "操作失败");
|
||||
}
|
||||
}
|
||||
|
||||
/* ============ 右侧详情 ============ */
|
||||
const detail = ref<HomeworkDetailVo | null>(null);
|
||||
const detailLoading = ref(false);
|
||||
|
||||
onMounted(async () => {
|
||||
run();
|
||||
});
|
||||
|
||||
async function checkHomework(item: HomeworkVo, index: number) {
|
||||
selected.value = index;
|
||||
await loadDetail(item.id, true);
|
||||
}
|
||||
|
||||
async function loadDetail(serid: number, markRead: boolean) {
|
||||
detailLoading.value = true;
|
||||
try {
|
||||
detail.value = await homeworkApi.modify(serid, markRead ? 1 : 0).send();
|
||||
if (markRead) {
|
||||
// 标读后刷新左侧未读状态
|
||||
run();
|
||||
detail.value = await studentApi.detail(serid.value).send();
|
||||
result.value = await studentApi.result(serid.value).send().catch(() => null);
|
||||
// 待订正:重交表单预填上一版内容;其余状态下表单内容按需在 openForm 时回填
|
||||
const d = detail.value;
|
||||
if (d.my?.submitStatus === "RETURNED") {
|
||||
form.text = d.myText || "";
|
||||
form.attachments = [...(d.myAttachments || [])];
|
||||
}
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "加载作业失败");
|
||||
detail.value = null;
|
||||
} finally {
|
||||
detailLoading.value = false;
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============ 附件 ============ */
|
||||
function downFile(id: number) {
|
||||
window.location.href = homeworkApi.fileDownUrl(id);
|
||||
/* ============ 阅读上报:停留 ≥3 秒,幂等;离开页面即取消 ============ */
|
||||
let readTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
function armRead() {
|
||||
readTimer = setTimeout(async () => {
|
||||
try {
|
||||
await studentApi.read(serid.value).send();
|
||||
} catch {
|
||||
/* 幂等上报,失败静默 */
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
function downAll() {
|
||||
window.location.href = homeworkApi.allFileDownUrl(detail.value.serid);
|
||||
function disarmRead() {
|
||||
if (readTimer) {
|
||||
clearTimeout(readTimer);
|
||||
readTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 展示名:后端 name=原始文件名,filename=存储相对路径(homework/2026xxxx/uuid.ext) */
|
||||
function displayName(item: { name?: string; filename?: string }) {
|
||||
return item.name || item.filename || "";
|
||||
}
|
||||
|
||||
/* 附件预览弹窗(open-file-viewer) */
|
||||
const previewState = ref<{ show: boolean; fileId: number | null; fileName: string }>({
|
||||
show: false,
|
||||
fileId: null,
|
||||
fileName: "",
|
||||
onMounted(() => {
|
||||
load();
|
||||
if (serid.value) armRead();
|
||||
});
|
||||
function openPreview(item: { name?: string; filename?: string; fileid?: number; id?: number }) {
|
||||
previewState.value = {
|
||||
show: true,
|
||||
fileId: Number(item.fileid || item.id) || null,
|
||||
fileName: displayName(item),
|
||||
};
|
||||
watch(
|
||||
() => route.query.serid,
|
||||
(v) => {
|
||||
disarmRead();
|
||||
if (Number(v) > 0) armRead();
|
||||
load();
|
||||
}
|
||||
);
|
||||
onUnmounted(disarmRead);
|
||||
|
||||
/* ============ 状态口径 ============ */
|
||||
const STATUS_TEXT: Record<string, string> = {
|
||||
NOT_SUBMITTED: "未提交",
|
||||
SUBMITTED: "已提交·待批",
|
||||
LATE_SUBMITTED: "逾期补交·待批",
|
||||
RETURNED: "待订正",
|
||||
REVIEWED: "已批阅",
|
||||
};
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
NOT_SUBMITTED: "default",
|
||||
SUBMITTED: "blue",
|
||||
LATE_SUBMITTED: "warning",
|
||||
RETURNED: "error",
|
||||
REVIEWED: "success",
|
||||
};
|
||||
const HW_COLOR: Record<string, string> = { PUBLISHED: "green", CLOSED: "orange", ARCHIVED: "default" };
|
||||
|
||||
const my = computed(() => detail.value?.my || null);
|
||||
const myStatus = computed(() => my.value?.submitStatus || "NOT_SUBMITTED");
|
||||
const isViewOnly = computed(() => (detail.value?.submitType || []).includes("none") || (detail.value?.submitType || []).length === 0);
|
||||
const types = computed(() => detail.value?.submitType || []);
|
||||
/** 截止口径:PUBLISHED 可交;CLOSED 且 allowLate=1 可补交 */
|
||||
const canSubmitByDeadline = computed(() => {
|
||||
const d = detail.value;
|
||||
if (!d || isViewOnly.value) return false;
|
||||
if (d.hwStatus === "PUBLISHED") return true;
|
||||
if (d.hwStatus === "CLOSED") return d.allowLate === 1;
|
||||
return false;
|
||||
});
|
||||
const preDeadline = computed(() => {
|
||||
const d = detail.value;
|
||||
if (!d?.deadlineAt) return true;
|
||||
return new Date(String(d.deadlineAt).replace(/-/g, "/")).getTime() > Date.now();
|
||||
});
|
||||
function dueInfo() {
|
||||
const d = detail.value?.deadlineAt;
|
||||
if (!d) return { text: "无截止", cls: "" };
|
||||
const t = new Date(String(d).replace(/-/g, "/")).getTime();
|
||||
const diffH = (t - Date.now()) / 3600000;
|
||||
if (diffH < 0) return { text: "已截止", cls: "overdue" };
|
||||
if (diffH < 24) return { text: `剩 ${Math.max(1, Math.round(diffH))} 小时`, cls: "urgent" };
|
||||
return { text: `剩 ${Math.round(diffH / 24)} 天`, cls: "" };
|
||||
}
|
||||
|
||||
/* 附件图标(按扩展名,图标名对齐旧版 matchSuffix → icon-word/excel/ppt/pdf1/txt/tupian1/yasuo/weizhi) */
|
||||
/** 提交表单可见:未交(可交期内)/ 待订正 / 已交未批(修改模式) */
|
||||
const showForm = ref(false);
|
||||
const isEditMode = ref(false); // 已交未批的「修改提交」:直接 POST 新版本
|
||||
function openForm(editMode: boolean) {
|
||||
isEditMode.value = editMode;
|
||||
form.text = detail.value?.myText || "";
|
||||
form.attachments = [...(detail.value?.myAttachments || [])];
|
||||
showForm.value = true;
|
||||
}
|
||||
|
||||
/* ============ 历史版本(新→旧,含各版评语) ============ */
|
||||
const versions = computed<SubmissionVersionVo[]>(() => result.value?.versions || []);
|
||||
|
||||
/* ============ 提交 / 撤回 ============ */
|
||||
const form = reactive({ text: "", attachments: [] as AttachmentItem[], submitting: false });
|
||||
async function doSubmit() {
|
||||
if (!detail.value) return;
|
||||
form.submitting = true;
|
||||
try {
|
||||
if (myStatus.value === "RETURNED" && my.value?.submissionId) {
|
||||
await studentApi.action(detail.value.id, my.value.submissionId, {
|
||||
action: "resubmit",
|
||||
text: form.text,
|
||||
attachments: form.attachments,
|
||||
}).send();
|
||||
} else {
|
||||
await studentApi.submit(detail.value.id, { text: form.text, attachments: form.attachments }).send();
|
||||
}
|
||||
Message.success(myStatus.value === "RETURNED" ? "订正已重新提交" : "提交成功");
|
||||
showForm.value = false;
|
||||
load();
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "提交失败");
|
||||
} finally {
|
||||
form.submitting = false;
|
||||
}
|
||||
}
|
||||
function doWithdraw() {
|
||||
if (!my.value) return;
|
||||
MyConfirm({
|
||||
title: "撤回提交",
|
||||
msg: "撤回后可重新编辑提交(截止前有效)",
|
||||
async onOk() {
|
||||
try {
|
||||
await studentApi.action(serid.value, my.value!.submissionId, { action: "withdraw" }).send();
|
||||
Message.success("已撤回");
|
||||
load();
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "撤回失败");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/* ============ 附件展示/预览/下载(作业附件与提交附件共用) ============ */
|
||||
function previewUrl(a: AttachmentItem) {
|
||||
const u = a.fileUrl || "";
|
||||
if (/^(https?:)?\/\//.test(u) || u.startsWith("data:")) return u;
|
||||
return `${serviceBase}${u}`;
|
||||
}
|
||||
function downUrl(a: AttachmentItem) {
|
||||
return a.fileId ? `${serviceBase}/api/file/Down?id=${a.fileId}` : previewUrl(a);
|
||||
}
|
||||
const imgPreview = reactive({ open: false, url: "" });
|
||||
function zoom(u: string) {
|
||||
imgPreview.url = u;
|
||||
imgPreview.open = true;
|
||||
}
|
||||
function getFileIcon(name: string) {
|
||||
const suffix = String(name || "").split(".").pop()?.toLowerCase() || "";
|
||||
if (["doc", "docx", "wps"].includes(suffix)) return "icon-word";
|
||||
@@ -123,331 +198,371 @@ function getFileIcon(name: string) {
|
||||
if (["zip", "rar"].includes(suffix)) return "icon-yasuo";
|
||||
return "icon-weizhi";
|
||||
}
|
||||
/* 可在线预览(open-file-viewer 支持:图片/pdf/txt/docx/xlsx/pptx;doc/zip 等仅下载) */
|
||||
function getIsPreview(name: string) {
|
||||
const suffix = String(name || "").split(".").pop()?.toLowerCase() || "";
|
||||
return ["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg", "txt", "pdf", "docx", "xlsx", "pptx"].includes(suffix);
|
||||
const previewState = ref<{ show: boolean; fileId: number | null; fileName: string }>({ show: false, fileId: null, fileName: "" });
|
||||
function openPreview(a: AttachmentItem) {
|
||||
if (!a.fileId) {
|
||||
zoom(previewUrl(a));
|
||||
return;
|
||||
}
|
||||
previewState.value = { show: true, fileId: a.fileId, fileName: a.fileName || "" };
|
||||
}
|
||||
|
||||
/* ============ 打印 ============ */
|
||||
function onPrint() {
|
||||
if (!detail.value) return;
|
||||
const win = window.open("", "_blank");
|
||||
if (!win) return;
|
||||
win.document.write(`
|
||||
<html>
|
||||
<head><title>${detail.value.title || ""}</title></head>
|
||||
<body style="font-family:'Microsoft YaHei',sans-serif;padding:24px;">
|
||||
<h2>${detail.value.title || ""}</h2>
|
||||
<p style="color:#666;font-size:13px;">
|
||||
来源:${detail.value.DeptName || ""} 发布人:${detail.value.TeaName || ""} 发布时间:${detail.value.EditDate || ""}
|
||||
</p>
|
||||
<hr/>
|
||||
<div>${detail.value.Contents || ""}</div>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
win.document.close();
|
||||
win.focus();
|
||||
win.print();
|
||||
}
|
||||
|
||||
/* 时间显示(列表 MM/dd HH:mm,同旧版) */
|
||||
function shortDate(date: string) {
|
||||
const d = new Date(String(date || "").replace(/-/g, "/"));
|
||||
if (isNaN(d.getTime())) return date;
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${pad(d.getMonth() + 1)}/${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/* 时间显示(列表 MM/dd HH:mm,同旧版) */</script>
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-wrapper">
|
||||
<div class="main-container">
|
||||
<div class="accept-wrap">
|
||||
<!-- 左侧作业列表 -->
|
||||
<div class="title-list">
|
||||
<div class="total">
|
||||
<span>未读<span class="num">{{ page.unreadCount || 0 }}</span>条</span>
|
||||
<Tooltip content="一键已读" placement="top" transfer>
|
||||
<i class="iconfont icon-saoba clear-icon" @click="allRead"></i>
|
||||
</Tooltip>
|
||||
<div class="main-container detail" v-if="detail">
|
||||
<!-- 头部 -->
|
||||
<div class="d-head">
|
||||
<div class="d-title-row">
|
||||
<Button size="small" icon="ios-arrow-back" @click="router.back()">返回</Button>
|
||||
<h2 class="d-title">{{ detail.title }}</h2>
|
||||
</div>
|
||||
<div class="d-meta">
|
||||
<Tag v-if="detail.subject" color="blue">{{ detail.subject }}</Tag>
|
||||
<Tag :color="HW_COLOR[detail.hwStatus] || 'default'">{{ detail.hwStatusDesc }}</Tag>
|
||||
<span class="m-item"><Icon type="md-person" size="14" /> {{ detail.teacherName || "—" }}</span>
|
||||
<span class="m-item" :class="dueInfo().cls">
|
||||
<Icon type="md-time" size="14" /> 截止 {{ detail.deadlineAt ? String(detail.deadlineAt).slice(0, 16) : "无" }}({{ dueInfo().text }})
|
||||
</span>
|
||||
<span class="m-item">提交方式:{{ types.includes("none") ? "仅查阅" : [types.includes("image") && "图片", types.includes("attach") && "附件", types.includes("text") && "文字"].filter(Boolean).join(" / ") || "—" }}</span>
|
||||
<span v-if="my" class="m-item">我的状态:</span>
|
||||
<Tag v-if="my" :color="STATUS_COLOR[myStatus]">{{ STATUS_TEXT[myStatus] }}<template v-if="my.isLate">(逾期)</template></Tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 作业说明 -->
|
||||
<div class="sec">
|
||||
<div class="sec-h">作业说明</div>
|
||||
<div class="ql-editor d-desc" v-html="detail.description || '(无文字说明)'"></div>
|
||||
<div v-if="detail.attachments && detail.attachments.length" class="d-atts">
|
||||
<div class="att" v-for="(a, i) in detail.attachments" :key="i">
|
||||
<i class="iconfont file-type-icon" :class="getFileIcon(a.fileName || '')"></i>
|
||||
<span class="att-name" :title="a.fileName">{{ a.fileName || "附件" }}</span>
|
||||
<a class="att-op" @click="openPreview(a)">查看</a>
|
||||
<a class="att-op" :href="downUrl(a)" target="_blank">下载</a>
|
||||
</div>
|
||||
<div class="search-box">
|
||||
<Input
|
||||
v-model="keyword"
|
||||
clearable
|
||||
search
|
||||
placeholder="搜索发布人,标题,部门......"
|
||||
@on-search="onSearch"
|
||||
@on-clear="onClear"
|
||||
></Input>
|
||||
</div>
|
||||
<div class="homework-items">
|
||||
<div
|
||||
v-for="(item, index) in page.content"
|
||||
:key="item.id"
|
||||
:class="{ list_item: true, readed: item.read, active: index == selected }"
|
||||
@click="checkHomework(item, index)"
|
||||
>
|
||||
<div class="tt">{{ item.title }}</div>
|
||||
<div class="other">
|
||||
<span>{{ item.deptName }}</span>
|
||||
<span>{{ shortDate(item.createTime) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 打回原因 -->
|
||||
<Alert v-if="myStatus === 'RETURNED'" type="error" show-icon class="mb-12">
|
||||
老师要求订正:{{ result?.latest?.comment || my?.comment || "请订正后重新提交" }}
|
||||
</Alert>
|
||||
|
||||
<!-- 我的提交(当前版本) -->
|
||||
<div class="sec" v-if="my && myStatus !== 'NOT_SUBMITTED'">
|
||||
<div class="sec-h">
|
||||
我的提交(v{{ my.version }})
|
||||
<span class="sec-sub">{{ my.submittedAt ? `提交于 ${String(my.submittedAt).slice(0, 16)}` : "" }}<template v-if="my.isLate"> · 逾期</template></span>
|
||||
</div>
|
||||
<div class="my-text" v-if="detail.myText">{{ detail.myText }}</div>
|
||||
<div class="my-text empty" v-else>(无文字作答)</div>
|
||||
<div class="my-atts" v-if="detail.myAttachments && detail.myAttachments.length">
|
||||
<template v-for="(a, i) in detail.myAttachments" :key="i">
|
||||
<div class="th" v-if="(a.fileType || '').toLowerCase() === 'image'" @click="zoom(previewUrl(a))">
|
||||
<img :src="previewUrl(a)" />
|
||||
</div>
|
||||
<div v-if="!page.content.length" class="empty">暂无作业</div>
|
||||
</div>
|
||||
<div class="file-chip" v-else>
|
||||
<Icon type="md-document" />
|
||||
{{ a.fileName || "附件" }}
|
||||
<a :href="downUrl(a)" target="_blank">下载</a>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 右侧详情 -->
|
||||
<div class="detail-content" v-if="detail">
|
||||
<div class="homework-title">
|
||||
<div class="title-header">
|
||||
<div class="tt">{{ detail.title }}</div>
|
||||
<div class="ops fr">
|
||||
<span class="op" @click="onPrint"><i class="iconfont icon-print"></i><span>打印</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="title-content">
|
||||
<p>来源:{{ detail.DeptName }}</p>
|
||||
<p>发布人:{{ detail.TeaName }}</p>
|
||||
<p>发布时间:{{ detail.EditDate }}</p>
|
||||
<p>
|
||||
<span>附件:{{ (detail.ChaoSend || []).length }}个</span>
|
||||
<span class="to-file" v-if="detail.ChaoSend && detail.ChaoSend.length > 0" @click="downAll">全部下载</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="homework-content">
|
||||
<div class="ql-editor" v-html="detail.Contents"></div>
|
||||
<div class="file" v-if="detail.ChaoSend && detail.ChaoSend.length > 0">
|
||||
<div class="file-title">
|
||||
<span>附件({{ detail.ChaoSend.length }}个)</span>
|
||||
<span class="all-down" @click="downAll">全部下载</span>
|
||||
</div>
|
||||
<div class="file-list">
|
||||
<div class="file-item" v-for="item in detail.ChaoSend" :key="item.id">
|
||||
<div class="left">
|
||||
<i class="iconfont file-type-icon" :class="getFileIcon(displayName(item))"></i>
|
||||
</div>
|
||||
<div class="center">
|
||||
<div class="name" :title="displayName(item)">{{ displayName(item) }}</div>
|
||||
<div class="size">{{ Utils.getFileSize(item.size) }}</div>
|
||||
</div>
|
||||
<div class="right">
|
||||
<a
|
||||
href="javascript:void(0);"
|
||||
class="mr-8"
|
||||
v-if="getIsPreview(displayName(item))"
|
||||
@click="openPreview(item)"
|
||||
>查看</a
|
||||
>
|
||||
<a href="javascript:void(0);" @click="downFile(item.fileid || item.id)">下载</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 批阅结果(当前版本已批) -->
|
||||
<div class="review-box ok" v-if="myStatus === 'REVIEWED' && (my?.score != null || my?.comment)">
|
||||
<div class="sc" v-if="my?.score != null"><b>{{ my.score }}</b><i>得分</i></div>
|
||||
<div class="cm">{{ my?.comment || "无评语" }}</div>
|
||||
</div>
|
||||
<div class="detail-content empty-detail" v-else>
|
||||
<span>{{ detailLoading ? "加载中..." : "暂无作业" }}</span>
|
||||
|
||||
<!-- 操作:修改 / 撤回(已交未批 + 截止前) -->
|
||||
<div class="my-ops" v-if="['SUBMITTED', 'LATE_SUBMITTED'].includes(myStatus) && canSubmitByDeadline && !showForm">
|
||||
<Button icon="md-create" size="small" @click="openForm(true)">修改提交</Button>
|
||||
<Button icon="md-undo" size="small" v-if="detail.hwStatus === 'PUBLISHED' && preDeadline" @click="doWithdraw">撤回</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 提交/重交表单 -->
|
||||
<div class="sec" v-if="!isViewOnly && ((myStatus === 'NOT_SUBMITTED' && canSubmitByDeadline) || (myStatus === 'RETURNED' && canSubmitByDeadline) || (showForm && isEditMode))">
|
||||
<div class="sec-h">
|
||||
{{ myStatus === "RETURNED" ? "订正重交" : isEditMode ? "修改提交(生成新版本)" : detail.hwStatus === "CLOSED" ? "补交作业" : "提交作业" }}
|
||||
</div>
|
||||
<template v-if="types.includes('text') || !types.length">
|
||||
<div class="f-label">文字作答</div>
|
||||
<Input v-model="form.text" type="textarea" :rows="4" placeholder="输入作答内容"></Input>
|
||||
</template>
|
||||
<template v-if="types.includes('image') || types.includes('attach')">
|
||||
<div class="f-label">{{ types.includes("image") && !types.includes("attach") ? "拍照/上传图片" : "上传附件(图片/文件)" }}</div>
|
||||
<AttachmentUpload v-model="form.attachments" :imageOnly="types.includes('image') && !types.includes('attach')"></AttachmentUpload>
|
||||
</template>
|
||||
<div class="f-tip" v-if="!canSubmitByDeadline && myStatus === 'NOT_SUBMITTED'">作业已截止且不允许补交</div>
|
||||
<div class="f-ops">
|
||||
<Button type="primary" :loading="form.submitting" icon="md-send" @click="doSubmit">
|
||||
{{ myStatus === "RETURNED" ? "重新提交" : isEditMode ? "保存新版本" : "提交" }}</Button>
|
||||
<Button v-if="isEditMode" style="margin-left: 8px" @click="showForm = false">取消</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Alert v-else-if="!isViewOnly && myStatus === 'NOT_SUBMITTED' && !canSubmitByDeadline" type="warning" show-icon class="mb-12">
|
||||
作业已截止<template v-if="detail.allowLate !== 1">,如需补交请联系老师</template>
|
||||
</Alert>
|
||||
|
||||
<!-- 历史版本 -->
|
||||
<div class="sec" v-if="versions.length > 1">
|
||||
<div class="sec-h">历史版本({{ versions.length }})</div>
|
||||
<div class="v-item" v-for="v in versions" :key="v.submissionId">
|
||||
<div class="v-head">
|
||||
<Tag size="small" v-if="v.isLatest">当前</Tag>
|
||||
<b>v{{ v.version }}</b>
|
||||
<span class="v-time">{{ v.submittedAt ? String(v.submittedAt).slice(0, 16) : "—" }}<template v-if="v.isLate"> · 逾期</template></span>
|
||||
<Tag size="small" :color="STATUS_COLOR[v.submitStatus]">{{ v.submitStatusDesc || STATUS_TEXT[v.submitStatus] }}</Tag>
|
||||
</div>
|
||||
<div class="v-text" v-if="v.text">{{ v.text }}</div>
|
||||
<div class="v-atts" v-if="v.attachments && v.attachments.length">
|
||||
<template v-for="(a, i) in v.attachments" :key="i">
|
||||
<div class="th" v-if="(a.fileType || '').toLowerCase() === 'image'" @click="zoom(previewUrl(a))"><img :src="previewUrl(a)" /></div>
|
||||
<div class="file-chip" v-else>
|
||||
<Icon type="md-document" />
|
||||
{{ a.fileName || "附件" }}
|
||||
<a :href="downUrl(a)" target="_blank">下载</a>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="v-review" v-if="v.reviewStatus !== 'PENDING'">
|
||||
<Icon :type="v.reviewStatus === 'REVIEWED' ? 'md-checkmark-circle' : 'md-refresh'" :color="v.reviewStatus === 'REVIEWED' ? '#19be6b' : '#ed4014'" size="14" />
|
||||
{{ v.reviewStatusDesc }}:{{ v.score != null ? `${v.score} 分 · ` : "" }}{{ v.comment || "无评语" }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="main-container" v-else-if="loading" style="text-align: center; color: #808695; padding: 60px 0">加载中...</div>
|
||||
<div class="main-container" v-else style="text-align: center; color: #808695; padding: 60px 0">作业不存在或不可见</div>
|
||||
|
||||
<!-- 图片放大 -->
|
||||
<Modal v-model="imgPreview.open" footer-hide title="图片预览" width="680px">
|
||||
<div class="zoom-wrap"><img :src="imgPreview.url" /></div>
|
||||
</Modal>
|
||||
<!-- 附件预览 -->
|
||||
<FilePreviewModal v-model="previewState.show" :file-id="previewState.fileId" :file-name="previewState.fileName" />
|
||||
</div>
|
||||
<!-- 附件预览弹窗 -->
|
||||
<FilePreviewModal v-model="previewState.show" :file-id="previewState.fileId" :file-name="previewState.fileName" />
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* 根容器 page-wrapper > main-container:内容区可视高度 = 100vh - 112(头部+面包屑) - 24(容器底padding) */
|
||||
.accept-wrap {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
height: calc(100vh - 136px);
|
||||
.detail {
|
||||
max-width: 960px;
|
||||
}
|
||||
.title-list {
|
||||
width: 360px;
|
||||
flex: none;
|
||||
background: #fff;
|
||||
border: 1px solid #ebeef4;
|
||||
border-radius: 3px;
|
||||
padding: 14px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.total {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 15px 10px;
|
||||
.num {
|
||||
padding: 0 5px;
|
||||
color: #5c9edc;
|
||||
font-weight: 600;
|
||||
}
|
||||
.clear-icon {
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
color: #999;
|
||||
&:hover {
|
||||
color: #2d8cf0;
|
||||
}
|
||||
}
|
||||
}
|
||||
.search-box {
|
||||
padding: 0 15px;
|
||||
}
|
||||
.homework-items {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
margin-top: 12px;
|
||||
.list_item {
|
||||
padding: 10px 15px;
|
||||
cursor: pointer;
|
||||
border-left: 3px solid transparent;
|
||||
.tt {
|
||||
font-weight: 600;
|
||||
color: #272829;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.other {
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
&:hover {
|
||||
background: #f6f9ff;
|
||||
}
|
||||
&.active {
|
||||
background: #ecf3ff;
|
||||
border-left-color: #2d8cf0;
|
||||
}
|
||||
/* 已读弱化 */
|
||||
&.readed .tt {
|
||||
color: #8a94a6;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
padding: 40px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
.detail-content {
|
||||
flex: 1;
|
||||
background: #fff;
|
||||
border: 1px solid #ebeef4;
|
||||
border-radius: 3px;
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
&.empty-detail {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #999;
|
||||
}
|
||||
.title-header {
|
||||
.d-head {
|
||||
.d-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
.tt {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #272829;
|
||||
}
|
||||
.ops {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
.op {
|
||||
cursor: pointer;
|
||||
color: #515a6e;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
&:hover {
|
||||
color: #2d8cf0;
|
||||
}
|
||||
}
|
||||
gap: 12px;
|
||||
.d-title {
|
||||
font-size: 20px;
|
||||
margin: 0;
|
||||
color: #17233d;
|
||||
}
|
||||
}
|
||||
.title-content {
|
||||
margin-top: 10px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px dashed #e8e8e8;
|
||||
.d-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
margin: 10px 0 4px;
|
||||
font-size: 13px;
|
||||
color: #808695;
|
||||
p {
|
||||
margin: 4px 0;
|
||||
.m-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
&.overdue {
|
||||
color: #ed4014;
|
||||
}
|
||||
&.urgent {
|
||||
color: #ff9900;
|
||||
}
|
||||
}
|
||||
.to-file {
|
||||
}
|
||||
}
|
||||
.sec {
|
||||
border-top: 1px dashed #e8eaec;
|
||||
margin-top: 14px;
|
||||
padding-top: 12px;
|
||||
.sec-h {
|
||||
font-weight: 600;
|
||||
color: #17233d;
|
||||
margin-bottom: 10px;
|
||||
.sec-sub {
|
||||
font-weight: 400;
|
||||
color: #808695;
|
||||
font-size: 13px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.d-desc {
|
||||
color: #515a6e;
|
||||
line-height: 1.8;
|
||||
min-height: 24px;
|
||||
}
|
||||
.d-atts {
|
||||
margin-top: 10px;
|
||||
.att {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0 16px 8px 0;
|
||||
padding: 4px 10px;
|
||||
background: #f7faff;
|
||||
border: 1px solid #e8eaec;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
color: #515a6e;
|
||||
.file-type-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
.att-name {
|
||||
max-width: 260px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.att-op {
|
||||
color: #2d8cf0;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
.homework-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding-top: 10px;
|
||||
.file {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
margin-top: 16px;
|
||||
.file-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
background: #f7f7f7;
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
.all-down {
|
||||
color: #2d8cf0;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
.file-list {
|
||||
padding: 10px 12px;
|
||||
.file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 6px;
|
||||
border-bottom: 1px dashed #f0f0f0;
|
||||
.file-type-icon {
|
||||
font-size: 26px;
|
||||
color: #515a6e;
|
||||
}
|
||||
.center {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
.name {
|
||||
color: #272829;
|
||||
font-size: 13px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.size {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
.right a {
|
||||
margin-right: 8px;
|
||||
color: #2d8cf0;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.my-text {
|
||||
color: #515a6e;
|
||||
line-height: 1.7;
|
||||
background: #fafcff;
|
||||
border: 1px solid #e8eaec;
|
||||
border-radius: 4px;
|
||||
padding: 10px 12px;
|
||||
white-space: pre-wrap;
|
||||
&.empty {
|
||||
color: #c5c8ce;
|
||||
}
|
||||
}
|
||||
.my-atts,
|
||||
.v-atts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
align-items: center;
|
||||
.th {
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
border: 1px solid #e8eaec;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
cursor: zoom-in;
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
&:hover {
|
||||
border-color: #2d8cf0;
|
||||
}
|
||||
}
|
||||
.file-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
background: #f0f2f5;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: #515a6e;
|
||||
a {
|
||||
color: #2d8cf0;
|
||||
}
|
||||
}
|
||||
}
|
||||
.review-box {
|
||||
margin-top: 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
&.ok {
|
||||
background: #f6ffed;
|
||||
.sc {
|
||||
text-align: center;
|
||||
b {
|
||||
font-size: 24px;
|
||||
color: #ed4014;
|
||||
display: block;
|
||||
line-height: 1.1;
|
||||
}
|
||||
i {
|
||||
font-style: normal;
|
||||
font-size: 12px;
|
||||
color: #808695;
|
||||
}
|
||||
}
|
||||
.cm {
|
||||
flex: 1;
|
||||
color: #389e0d;
|
||||
}
|
||||
}
|
||||
}
|
||||
.my-ops {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.f-label {
|
||||
font-size: 13px;
|
||||
color: #515a6e;
|
||||
margin: 12px 0 6px;
|
||||
}
|
||||
.f-tip {
|
||||
color: #ff9900;
|
||||
font-size: 12px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.f-ops {
|
||||
margin-top: 14px;
|
||||
}
|
||||
.v-item {
|
||||
border: 1px solid #eef1f5;
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 10px;
|
||||
background: #fafbfc;
|
||||
.v-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
.v-time {
|
||||
color: #808695;
|
||||
}
|
||||
}
|
||||
.v-text {
|
||||
margin-top: 6px;
|
||||
color: #515a6e;
|
||||
font-size: 13px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.v-review {
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
color: #515a6e;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
.zoom-wrap {
|
||||
text-align: center;
|
||||
img {
|
||||
max-width: 100%;
|
||||
max-height: 62vh;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,118 +1,168 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, watch } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { homeworkApi } from "@/api/module/homework";
|
||||
import type { StudentHomeworkVo, MyHomeworksVo } from "@/types/homework";
|
||||
import HomeworkUpload from "@/components/HomeworkUpload.vue";
|
||||
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 imgUploadUrl = serviceBase + "/api/file/UploadImage";
|
||||
/** 提交图片:可直接显示的 URL/dataURL,或后端 fileid(回退拼预览地址) */
|
||||
function previewUrl(v: number | string) {
|
||||
const s = String(v);
|
||||
if (s.startsWith("data:") || s.startsWith("http")) return s;
|
||||
return `${serviceBase}/api/file/Preview?id=${s}`;
|
||||
}
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
/* V2 tab:all/todo/done/closed(redo 桶已并入 todo,RETURNED 红色强化) */
|
||||
const TABS = [
|
||||
{ name: "all", label: "全部" },
|
||||
{ name: "todo", label: "待完成" },
|
||||
{ name: "submitted", label: "已交待批" },
|
||||
{ name: "graded", label: "已批改" },
|
||||
{ name: "redo", 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 data = ref<MyHomeworksVo | null>(null);
|
||||
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 {
|
||||
data.value = await homeworkApi.myHomeworks().send();
|
||||
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, load);
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => {
|
||||
tab.value = (route.meta as any).initTab || "all";
|
||||
keyword.value = "";
|
||||
pageNum.value = 1;
|
||||
load();
|
||||
}
|
||||
);
|
||||
|
||||
function bucketOf(x: StudentHomeworkVo): string {
|
||||
if (x.viewOnly) return "view";
|
||||
const st = x.my ? x.my.submitStatus : "unsubmitted";
|
||||
if (st === "unsubmitted") return "todo";
|
||||
if (st === "submitted") return "submitted";
|
||||
if (st === "needRedo") return "redo";
|
||||
return "graded";
|
||||
}
|
||||
const filtered = computed(() => {
|
||||
const list = data.value?.list || [];
|
||||
if (tab.value === "all") return list;
|
||||
return list.filter((x) => bucketOf(x) === tab.value);
|
||||
/* 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;
|
||||
});
|
||||
function tabCount(name: string) {
|
||||
const b = data.value?.buckets;
|
||||
if (!b) return 0;
|
||||
return name === "all" ? b.all : name === "todo" ? b.todo : name === "submitted" ? b.submitted : name === "graded" ? b.graded : b.redo;
|
||||
}
|
||||
|
||||
const STATUS_TEXT: Record<string, string> = { unsubmitted: "未提交", submitted: "已交·待批", graded: "已批改", needRedo: "待订正", redone: "已订正·待批" };
|
||||
const STATUS_COLOR: Record<string, string> = { unsubmitted: "#c5c8ce", submitted: "#ff9900", graded: "#19be6b", needRedo: "#ed4014", redone: "#2d8cf0" };
|
||||
function statusColor(x: StudentHomeworkVo) {
|
||||
if (x.viewOnly) return "#808695";
|
||||
return STATUS_COLOR[x.my ? x.my.submitStatus : "unsubmitted"];
|
||||
/* ============ 状态展示 ============ */
|
||||
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 dueInfo(deadline: string | null) {
|
||||
if (!deadline) return { text: "无截止", overdue: false };
|
||||
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 };
|
||||
if (diffH < 24) return { text: `剩 ${Math.max(1, Math.round(diffH))} 小时`, overdue: false };
|
||||
return { text: `剩 ${Math.round(diffH / 24)} 天`, overdue: false };
|
||||
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) } });
|
||||
}
|
||||
|
||||
/* ============ 提交 / 重交 ============ */
|
||||
const sub = reactive({ open: false, serid: 0, title: "", text: "", fileids: [] as number[], images: [] as string[], isRedo: false, submitting: false, uploading: false });
|
||||
function openSubmit(row: StudentHomeworkVo) {
|
||||
/* ============ 提交 / 重交 / 撤回(快捷弹窗,主提交面在 /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.serid = row.id;
|
||||
sub.id = row.id;
|
||||
sub.title = row.title;
|
||||
sub.text = row.my?.text || "";
|
||||
sub.fileids = [];
|
||||
sub.images = (row.my?.images || []).map((v: any) => String(v)).filter(Boolean);
|
||||
sub.isRedo = !!row.my && row.my.submitStatus === "needRedo";
|
||||
}
|
||||
function onImgSuccess(file: any) {
|
||||
sub.uploading = false;
|
||||
const resp = file?.response;
|
||||
if (!resp || resp.code !== 0) {
|
||||
Message.error(resp?.msg || "图片上传失败");
|
||||
return;
|
||||
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;
|
||||
}
|
||||
const item = Array.isArray(resp.data) ? resp.data[0] : resp.data;
|
||||
const url = item?.url || (item?.id != null ? previewUrl(item.id) : "");
|
||||
if (url) {
|
||||
if (sub.images.length >= 9) {
|
||||
Message.warning("最多上传 9 张图片");
|
||||
return;
|
||||
}
|
||||
sub.images.push(url);
|
||||
}
|
||||
}
|
||||
function removeImg(url: string) {
|
||||
sub.images = sub.images.filter((x) => x !== url);
|
||||
}
|
||||
async function submit() {
|
||||
sub.submitting = true;
|
||||
try {
|
||||
await homeworkApi.submit({ serid: sub.serid, text: sub.text, images: sub.images, attachments: sub.fileids.map(String) }).send();
|
||||
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();
|
||||
@@ -122,16 +172,30 @@ async function submit() {
|
||||
sub.submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============ 批改反馈 ============ */
|
||||
const fb = reactive({ open: false, row: null as StudentHomeworkVo | null });
|
||||
function openFeedback(row: StudentHomeworkVo) {
|
||||
fb.open = true;
|
||||
fb.row = row;
|
||||
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;
|
||||
@@ -147,113 +211,91 @@ function zoom(url: string) {
|
||||
<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 class="sh-sub">按时提交,查看老师批阅与订正</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sh-chips">
|
||||
<div class="chip todo"><b>{{ tabCount("todo") }}</b><span>待完成</span></div>
|
||||
<div class="chip redo"><b>{{ tabCount("redo") }}</b><span>待订正</span></div>
|
||||
<div class="chip graded"><b>{{ tabCount("graded") }}</b><span>已批改</span></div>
|
||||
<div class="chip"><b>{{ page.total }}</b><span>本页作业</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs :value="tab" @on-click="(n: string) => (tab = n)" class="stu-tabs">
|
||||
<TabPane v-for="t in TABS" :key="t.name" :name="t.name" :label="t.name === 'all' ? t.label : t.label + ' ' + tabCount(t.name)"></TabPane>
|
||||
</Tabs>
|
||||
<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="!filtered.length" class="state empty">
|
||||
<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 filtered" :key="row.id" :style="{ borderLeftColor: statusColor(row) }">
|
||||
<div class="c-top">
|
||||
<span class="c-subj">{{ row.subject }}</span>
|
||||
<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="row.viewOnly" color="default" class="c-flag">仅查阅</Tag>
|
||||
<span v-else class="c-dot" :style="{ background: statusColor(row) }"></span>
|
||||
<span v-if="!row.viewOnly" class="c-status" :style="{ color: statusColor(row) }">{{ STATUS_TEXT[row.my ? row.my.submitStatus : "unsubmitted"] }}</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.publisherName }} · {{ row.deptName }}</span>
|
||||
<span class="m-item" :class="{ overdue: dueInfo(row.deadline).overdue && bucketOf(row) === 'todo' }">
|
||||
<Icon type="md-time" size="14" /> 截止 {{ row.deadline ? String(row.deadline).slice(5, 16) : "—" }}({{ dueInfo(row.deadline).text }})
|
||||
<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-body" v-html="row.content"></div>
|
||||
<div class="c-imgs" v-if="row.my && row.my.images && row.my.images.length">
|
||||
<div class="th" v-for="(im, ii) in row.my.images" :key="ii" @click="zoom(previewUrl(im))"><img :src="previewUrl(im)" /></div>
|
||||
</div>
|
||||
|
||||
<div class="c-grade" v-if="row.my && (row.my.submitStatus === 'graded' || row.my.submitStatus === 'redone')">
|
||||
<span class="g-badge" v-if="row.my.isExcellent">优秀</span>
|
||||
<Icon type="md-checkmark-circle" color="#19be6b" size="16" />
|
||||
得分 {{ row.my.score ?? "—" }} · 等级 {{ row.my.level ?? "—" }} · {{ row.my.comment }}
|
||||
<!-- 待订正:老师评语强化展示 -->
|
||||
<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 warn" v-else-if="row.my && row.my.submitStatus === 'needRedo'">
|
||||
<Icon type="md-refresh" color="#ed4014" size="16" /> 老师要求订正:{{ row.my.comment }}
|
||||
<!-- 已批阅:得分摘要 -->
|
||||
<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="bucketOf(row) === 'todo' && (row.hwStatus !== 'closed' || row.allowLate)"
|
||||
type="primary"
|
||||
icon="md-cloud-upload"
|
||||
size="small"
|
||||
@click="openSubmit(row)"
|
||||
>{{ row.hwStatus === "closed" && row.allowLate ? "补交作业" : "提交作业" }}</Button
|
||||
>
|
||||
<Button v-else-if="bucketOf(row) === 'todo'" disabled size="small">已截止</Button>
|
||||
<Button v-else-if="bucketOf(row) === 'redo'" type="error" icon="md-refresh" size="small" @click="openSubmit(row)">重新提交</Button>
|
||||
<Button v-if="bucketOf(row) === 'submitted'" ghost icon="md-create" size="small" @click="openSubmit(row)">修改提交</Button>
|
||||
<Button
|
||||
v-if="row.my && ['graded', 'redone', 'needRedo', 'submitted'].includes(row.my.submitStatus)"
|
||||
icon="md-eye"
|
||||
size="small"
|
||||
@click="openFeedback(row)"
|
||||
>{{ row.my.submitStatus === "graded" || row.my.submitStatus === "redone" ? "查看批改" : "查看提交" }}</Button
|
||||
>
|
||||
<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'" type="error" icon="md-refresh" size="small" @click="openSubmit(row)">重新提交</Button>
|
||||
<template v-else-if="['SUBMITTED', 'LATE_SUBMITTED'].includes(myStatus(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>
|
||||
|
||||
<!-- 提交弹窗 -->
|
||||
<Modal v-model="sub.open" :title="(sub.isRedo ? '重新提交 · ' : '提交作业 · ') + sub.title" width="560px" class-name="submit-modal">
|
||||
<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" /> 图片(拍照 / 相册,最多 9 张)</div>
|
||||
<div class="img-wall">
|
||||
<div class="img-cell" v-for="(u, ui) in sub.images" :key="ui">
|
||||
<img :src="previewUrl(u)" />
|
||||
<span class="img-del" @click="removeImg(u)"><Icon type="md-close" size="14" /></span>
|
||||
</div>
|
||||
<Upload
|
||||
class="img-add"
|
||||
:action="imgUploadUrl"
|
||||
:show-upload-list="false"
|
||||
:format="['jpg', 'jpeg', 'png', 'gif']"
|
||||
accept="image/*"
|
||||
multiple
|
||||
name="file"
|
||||
:before-upload="() => (sub.uploading = true)"
|
||||
:on-success="onImgSuccess"
|
||||
:on-error="() => { sub.uploading = false; Message.error('图片上传失败'); }"
|
||||
>
|
||||
<div class="img-add-btn" :class="{ spin: sub.uploading }">
|
||||
<Icon :type="sub.uploading ? 'ios-loading' : 'ios-camera'" :size="24" :class="{ 'iv-load': sub.uploading }" />
|
||||
<span>{{ sub.uploading ? "上传中" : "拍照/上传" }}</span>
|
||||
</div>
|
||||
</Upload>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sm-field">
|
||||
<div class="sm-label"><Icon type="md-attach" size="16" /> 附件</div>
|
||||
<HomeworkUpload v-model:fileids="sub.fileids"></HomeworkUpload>
|
||||
<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>
|
||||
@@ -261,30 +303,6 @@ function zoom(url: string) {
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- 批改反馈弹窗 -->
|
||||
<Modal v-model="fb.open" title="批改反馈" width="520px">
|
||||
<div v-if="fb.row && fb.row.my" class="fb">
|
||||
<div class="fb-row"><span class="fb-k">提交时间</span><span>{{ fb.row.my.submittedAt ? String(fb.row.my.submittedAt).slice(0, 16) : "—" }}</span></div>
|
||||
<div class="fb-row"><span class="fb-k">我的作答</span><span class="fb-text">{{ fb.row.my.text || "(仅附件)" }}</span></div>
|
||||
<div class="fb-row">
|
||||
<span class="fb-k">提交图片</span>
|
||||
<span class="fb-imgs">
|
||||
<template v-if="fb.row.my.images && fb.row.my.images.length">
|
||||
<img v-for="(im, ii) in fb.row.my.images" :key="ii" :src="previewUrl(im)" @click="zoom(previewUrl(im))" />
|
||||
</template>
|
||||
<template v-else>无</template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="fb-score-box" :class="fb.row.my.isExcellent ? 'ex' : ''">
|
||||
<div class="sc"><b>{{ fb.row.my.score ?? "—" }}</b><i>得分</i></div>
|
||||
<div class="sc"><b>{{ fb.row.my.level || "—" }}</b><i>等级</i></div>
|
||||
<div class="cm">{{ fb.row.my.comment || "—" }}</div>
|
||||
<Tag v-if="fb.row.my.isExcellent" color="success">优秀作业</Tag>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer><Button type="primary" @click="fb.open = false">关闭</Button></template>
|
||||
</Modal>
|
||||
|
||||
<!-- 图片放大 -->
|
||||
<Modal v-model="imgPreview.open" footer-hide title="图片预览" width="680px">
|
||||
<div class="zoom-wrap"><img :src="imgPreview.url" /></div>
|
||||
@@ -352,7 +370,10 @@ function zoom(url: string) {
|
||||
}
|
||||
}
|
||||
.stu-tabs {
|
||||
margin-bottom: 6px;
|
||||
margin-bottom: 0;
|
||||
:deep(.ivu-tabs-bar) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
.state {
|
||||
padding: 30px 0;
|
||||
@@ -375,10 +396,16 @@ function zoom(url: string) {
|
||||
&: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;
|
||||
@@ -395,15 +422,19 @@ function zoom(url: string) {
|
||||
.c-flag {
|
||||
margin-left: auto;
|
||||
}
|
||||
.c-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
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 {
|
||||
@@ -420,30 +451,8 @@ function zoom(url: string) {
|
||||
.overdue {
|
||||
color: #ed4014;
|
||||
}
|
||||
}
|
||||
.c-body {
|
||||
margin-top: 8px;
|
||||
color: #515a6e;
|
||||
line-height: 1.6;
|
||||
max-height: 72px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.c-imgs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
.th {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
cursor: zoom-in;
|
||||
border: 1px solid #eee;
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.urgent {
|
||||
color: #ff9900;
|
||||
}
|
||||
}
|
||||
.c-grade {
|
||||
@@ -460,12 +469,6 @@ function zoom(url: string) {
|
||||
background: #fff1f0;
|
||||
color: #cf1322;
|
||||
}
|
||||
.g-badge {
|
||||
background: #ff9900;
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
}
|
||||
.c-actions {
|
||||
margin-top: 12px;
|
||||
@@ -485,122 +488,6 @@ function zoom(url: string) {
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
.img-wall {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
.img-cell {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border: 1px solid #e8eaec;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.img-del {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
border-bottom-left-radius: 6px;
|
||||
}
|
||||
}
|
||||
.img-add-btn {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border: 1px dashed #c5c8ce;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
&:hover {
|
||||
border-color: #2d8cf0;
|
||||
color: #2d8cf0;
|
||||
}
|
||||
}
|
||||
}
|
||||
.iv-load {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
/* 反馈 */
|
||||
.fb {
|
||||
.fb-row {
|
||||
display: flex;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px dashed #eee;
|
||||
.fb-k {
|
||||
width: 76px;
|
||||
color: #808695;
|
||||
flex: none;
|
||||
}
|
||||
.fb-text {
|
||||
color: #515a6e;
|
||||
}
|
||||
.fb-imgs {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
img {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #eee;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
}
|
||||
}
|
||||
.fb-score-box {
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
background: #f7faff;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
&.ex {
|
||||
background: #fff8ec;
|
||||
}
|
||||
.sc {
|
||||
text-align: center;
|
||||
b {
|
||||
font-size: 22px;
|
||||
color: #ed4014;
|
||||
}
|
||||
i {
|
||||
display: block;
|
||||
font-style: normal;
|
||||
font-size: 12px;
|
||||
color: #808695;
|
||||
}
|
||||
}
|
||||
.cm {
|
||||
flex: 1;
|
||||
color: #515a6e;
|
||||
}
|
||||
}
|
||||
}
|
||||
.zoom-wrap {
|
||||
text-align: center;
|
||||
img {
|
||||
|
||||
Reference in New Issue
Block a user