feat(M5): P0 契约层 + 审批/应用设置下线
- types/homeworkV2.ts:V2 全量类型契约(五态/枚举/矩阵/版本/通知)
- teacherHomework/studentHomework/notification 三客户端(28 端点)
- §6 审批功能下线:删 ApprovalList/ApprovalSetting/ApprovalFlowEditor,
types/api/menu/menuRoute/App/mock 同步清理,DROPPED_MOD_IDS={5,6}
- §7 应用设置下线:删 HomeworkSetting/TypeSetBox/UserCheckInput,
SettingApi 与 dict/contacts 死代码清理,DROPPED_MOD_IDS 扩 {21}
- 新增 V2 组件:TeacherHomeworks(已发布+草稿箱)、ReviewModal(批阅弹窗)、
AttachmentUpload(附件上传)——尚未挂路由,页面切换随后续阶段
This commit is contained in:
@@ -1,217 +0,0 @@
|
||||
<template>
|
||||
<div class="flow-editor">
|
||||
<Checkbox v-model="enabled" @on-change="emitChange">选择审批人后走审批流程(不勾选=直接发布)</Checkbox>
|
||||
|
||||
<div v-if="enabled" class="steps">
|
||||
<div class="step" v-for="(st, i) in steps" :key="i">
|
||||
<div class="step-head">
|
||||
<span class="step-no">{{ i + 1 }}</span>
|
||||
<span class="step-tag">第 {{ i + 1 }} 步</span>
|
||||
<Select v-model="st.vote" size="small" class="vote-sel" @on-change="emitChange">
|
||||
<Option value="any">或签(任一通过即可)</Option>
|
||||
<Option value="all">会签(全部通过)</Option>
|
||||
</Select>
|
||||
<span class="step-ops">
|
||||
<a v-if="steps.length > 1 && i > 0" @click="move(i, -1)">上移</a>
|
||||
<a v-if="steps.length > 1 && i < steps.length - 1" @click="move(i, 1)">下移</a>
|
||||
<a v-if="steps.length > 1" class="danger" @click="removeStep(i)">删除</a>
|
||||
</span>
|
||||
</div>
|
||||
<div class="step-body">
|
||||
<span v-if="st.approvers.length" class="chips">
|
||||
<span class="chip" v-for="acc in st.approvers" :key="acc">
|
||||
{{ nameOf(acc) }}<i @click="dropApprover(i, acc)">×</i>
|
||||
</span>
|
||||
</span>
|
||||
<span v-else class="ph">未选择审批人</span>
|
||||
<Button size="small" icon="md-add" @click="openPicker(i)">选择审批人</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="add-step"><a @click="addStep">+ 再加一步(多步依次审批)</a></div>
|
||||
<div class="tip">按顺序流转:上一步通过后进入下一步;任一步驳回则退回发布人。</div>
|
||||
</div>
|
||||
|
||||
<!-- 审批人选择弹窗(复用通用树选弹窗,数据源=审批人候选池) -->
|
||||
<UserCheckModal ref="picker" :sources="pickerSources" :userids="pickerIds" @on-submit="onPick" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { Message } from "view-ui-plus";
|
||||
import UserCheckModal from "@/components/UserCheckModal.vue";
|
||||
import { approvalSettingApi } from "@/api/module/homework";
|
||||
|
||||
type Step = { vote: "all" | "any"; approvers: string[] };
|
||||
export type FlowModel = { enabled: boolean; flow: { vote: "all" | "any"; approvers: string[] }[] };
|
||||
|
||||
const props = defineProps<{ modelValue: FlowModel }>();
|
||||
const emits = defineEmits<{ (e: "update:modelValue", v: FlowModel): void }>();
|
||||
|
||||
const enabled = ref(false);
|
||||
const steps = ref<Step[]>([{ vote: "any", approvers: [] }]);
|
||||
|
||||
/** account ↔ 数字 id(UserCheckModal 用数字 id) */
|
||||
type Pool = { id: number; name: string; account: string };
|
||||
const pool = ref<Pool[]>([]);
|
||||
const nameOf = (acc: string) => pool.value.find((p) => p.account === acc)?.name || acc;
|
||||
const idOf = (acc: string) => pool.value.find((p) => p.account === acc)?.id ?? 0;
|
||||
const accOf = (id: number) => pool.value.find((p) => p.id === id)?.account || "";
|
||||
|
||||
const picker = ref();
|
||||
const pickerIds = ref<number[]>([]);
|
||||
let editingStep = 0;
|
||||
const pickerSources = [{ name: "审批人", loader: () => approvalSettingApi.approverPool().send() as Promise<any[]> }];
|
||||
|
||||
function openPicker(i: number) {
|
||||
editingStep = i;
|
||||
pickerIds.value = steps.value[i].approvers.map(idOf);
|
||||
picker.value.show();
|
||||
}
|
||||
function onPick(list: any[]) {
|
||||
steps.value[editingStep].approvers = (list || []).map((x) => accOf(Number(x.id))).filter(Boolean);
|
||||
emitChange();
|
||||
}
|
||||
|
||||
function addStep() {
|
||||
steps.value.push({ vote: "any", approvers: [] });
|
||||
emitChange();
|
||||
}
|
||||
function removeStep(i: number) {
|
||||
steps.value.splice(i, 1);
|
||||
emitChange();
|
||||
}
|
||||
function move(i: number, dir: number) {
|
||||
const j = i + dir;
|
||||
if (j < 0 || j >= steps.value.length) return;
|
||||
const a = steps.value;
|
||||
[a[i], a[j]] = [a[j], a[i]];
|
||||
emitChange();
|
||||
}
|
||||
function dropApprover(i: number, acc: string) {
|
||||
steps.value[i].approvers = steps.value[i].approvers.filter((x) => x !== acc);
|
||||
emitChange();
|
||||
}
|
||||
|
||||
function emitChange() {
|
||||
const flow = enabled.value ? steps.value.filter((s) => s.approvers.length).map((s) => ({ vote: s.vote, approvers: [...s.approvers] })) : [];
|
||||
emits("update:modelValue", { enabled: enabled.value && flow.length > 0, flow });
|
||||
}
|
||||
|
||||
/** 有效步数(选了人的步) */
|
||||
function validFlow() {
|
||||
return enabled.value ? steps.value.filter((s) => s.approvers.length).map((s) => ({ vote: s.vote, approvers: [...s.approvers] })) : [];
|
||||
}
|
||||
/** 编辑态回显:由父组件在详情加载后调用 */
|
||||
function setFlow(mv: FlowModel | null) {
|
||||
if (mv && mv.enabled && mv.flow?.length) {
|
||||
enabled.value = true;
|
||||
steps.value = mv.flow.map((f) => ({ vote: f.vote === "all" ? "all" : "any", approvers: [...(f.approvers || [])] }));
|
||||
} else {
|
||||
enabled.value = false;
|
||||
steps.value = [{ vote: "any", approvers: [] }];
|
||||
}
|
||||
emitChange();
|
||||
}
|
||||
defineExpose({ validFlow, setFlow, validate: () => !enabled.value || validFlow().length > 0 });
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
pool.value = (await approvalSettingApi.approverPool().send()) || [];
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "加载审批人失败");
|
||||
}
|
||||
// 初始值(若父组件已同步传入)
|
||||
if (props.modelValue && props.modelValue.enabled && props.modelValue.flow?.length) {
|
||||
setFlow(props.modelValue);
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.flow-editor {
|
||||
.steps {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.step {
|
||||
border: 1px solid #e8eaec;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 8px;
|
||||
background: #fbfcfd;
|
||||
}
|
||||
.step-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.step-no {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
text-align: center;
|
||||
border-radius: 50%;
|
||||
background: #2d8cf0;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
}
|
||||
.step-tag {
|
||||
font-size: 13px;
|
||||
color: #17233d;
|
||||
}
|
||||
.vote-sel {
|
||||
width: 190px;
|
||||
}
|
||||
.step-ops {
|
||||
margin-left: auto;
|
||||
a {
|
||||
color: #2d8cf0;
|
||||
font-size: 13px;
|
||||
margin-left: 12px;
|
||||
cursor: pointer;
|
||||
&.danger {
|
||||
color: #ed4014;
|
||||
}
|
||||
}
|
||||
}
|
||||
.step-body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.chip {
|
||||
background: #eaf3ff;
|
||||
color: #2d8cf0;
|
||||
border-radius: 3px;
|
||||
padding: 2px 8px;
|
||||
font-size: 13px;
|
||||
i {
|
||||
font-style: normal;
|
||||
margin-left: 4px;
|
||||
cursor: pointer;
|
||||
color: #9bbfe8;
|
||||
}
|
||||
}
|
||||
.ph {
|
||||
color: #bbb;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
.add-step a {
|
||||
color: #2d8cf0;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tip {
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<!-- V2 附件上传:emit AttachmentItem[](fileId/fileUrl/fileType/fileName/sort)。
|
||||
fileUrl 统一存 /api/file/Preview?id=(浏览器可直接渲染;下载走 Down 直链)。 -->
|
||||
<div class="attachment-upload">
|
||||
<Upload
|
||||
:on-success="onSuccess"
|
||||
:on-remove="onRemove"
|
||||
:default-file-list="fileList"
|
||||
:action="uploadUrl"
|
||||
:accept="accept"
|
||||
:multiple="multiple"
|
||||
:format="formatArr"
|
||||
:max-size="204800"
|
||||
:on-format-error="onFormatError"
|
||||
:on-exceeded-size="onExceeded"
|
||||
>
|
||||
<Button><Icon type="ios-cloud-upload-outline"></Icon>{{ buttonText || (imageOnly ? "拍照/上传图片" : "选择文件") }}</Button>
|
||||
</Upload>
|
||||
<span class="upload-tip">大小不超过 200MB</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Message } from "view-ui-plus";
|
||||
import config from "@/assets/config/host.config";
|
||||
import type { AttachmentItem } from "@/types/homeworkV2";
|
||||
|
||||
/** base/FileUpload(form-data: file)返回体:{code,msg,data:{fileid,name,fileurl,host,type,size}} */
|
||||
interface FileUploadResp {
|
||||
code: number;
|
||||
msg?: string;
|
||||
data: { fileid: number; name: string; fileurl: string; host: string; type: string; size: number };
|
||||
}
|
||||
|
||||
export default {
|
||||
name: "AttachmentUpload",
|
||||
props: {
|
||||
/** 已选附件(v-model) */
|
||||
modelValue: { type: Array as () => AttachmentItem[], default: () => [] },
|
||||
/** 仅图片(拍照提交场景) */
|
||||
imageOnly: { type: Boolean, default: false },
|
||||
multiple: { type: Boolean, default: true },
|
||||
/** 留空时按 imageOnly 自动取「拍照/上传图片」或「选择文件」 */
|
||||
buttonText: { type: String, default: "" },
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
data() {
|
||||
return {
|
||||
uploadUrl: (config.isMock ? "" : config.serviceHost) + "/api/file/Upload",
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
accept(): string {
|
||||
return this.imageOnly ? "image/*" : "";
|
||||
},
|
||||
formatArr(): string[] {
|
||||
return this.imageOnly
|
||||
? ["jpg", "jpeg", "gif", "png", "bmp", "webp"]
|
||||
: ["jpg", "jpeg", "gif", "png", "bmp", "webp", "xls", "doc", "txt", "docx", "xlsx", "zip", "rar", "pdf", "ppt", "pptx", "wps", "mp3", "mp4"];
|
||||
},
|
||||
/** iview Upload 回显列表(只读 name;移除按项过滤) */
|
||||
fileList(): any[] {
|
||||
return (this.modelValue || []).filter((a) => a.fileName).map((a) => ({ name: a.fileName, url: this.previewUrl(a), _item: a }));
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
previewUrl(a: AttachmentItem): string {
|
||||
return a.fileId ? `${config.isMock ? "" : config.serviceHost}/api/file/Preview?id=${a.fileId}` : a.fileUrl;
|
||||
},
|
||||
onSuccess(resp: FileUploadResp, file: any) {
|
||||
if (!resp || resp.code !== 0 || !resp.data) {
|
||||
Message.error(resp?.msg || "上传失败");
|
||||
return;
|
||||
}
|
||||
const d = resp.data;
|
||||
const item: AttachmentItem = {
|
||||
fileId: d.fileid,
|
||||
fileUrl: `/api/file/Preview?id=${d.fileid}`,
|
||||
fileType: this.imageType(d) ? "image" : "file",
|
||||
fileName: d.name || file.name,
|
||||
sort: this.modelValue.length,
|
||||
};
|
||||
this.$emit("update:modelValue", [...this.modelValue, item]);
|
||||
},
|
||||
imageType(d: FileUploadResp["data"]): boolean {
|
||||
const t = String(d.type || "").toLowerCase();
|
||||
if (t.startsWith("image")) return true;
|
||||
return /\.(jpe?g|gif|png|bmp|webp)$/i.test(d.name || d.fileurl || "");
|
||||
},
|
||||
onRemove(file: any) {
|
||||
// 回显项挂 _item;刚上传项从 response.data.fileid 匹配
|
||||
const target: AttachmentItem | undefined = file._item || (file.response?.data ? { fileId: file.response.data.fileid } as AttachmentItem : undefined);
|
||||
if (!target) return;
|
||||
const arr = this.modelValue.filter((a) => a !== target && (target.fileId ? a.fileId !== target.fileId : a.fileUrl !== target.fileUrl));
|
||||
this.$emit("update:modelValue", arr.map((a, i) => ({ ...a, sort: i })));
|
||||
},
|
||||
onFormatError() {
|
||||
Message.error(this.imageOnly ? "仅支持图片文件" : "不支持的上传类型!");
|
||||
},
|
||||
onExceeded() {
|
||||
Message.warning("文件大小超过 200MB 限制");
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.attachment-upload {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
:deep(.ivu-upload) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
:deep(.ivu-upload-list) {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
}
|
||||
:deep(.ivu-upload-list-file) {
|
||||
padding: 4px 0;
|
||||
}
|
||||
.upload-tip {
|
||||
margin-left: 10px;
|
||||
color: #f90;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,315 @@
|
||||
<template>
|
||||
<!-- V2 批阅弹窗:历史版本切换 + 保存批阅(review)/ 打回(return)。
|
||||
Submissions(学生矩阵)与 Grade(批改中心)共用。 -->
|
||||
<Modal :value="open" @input="$emit('update:open', $event)" :title="`批阅 · ${student?.studentName || ''}`" width="640px" :mask-closable="false">
|
||||
<div class="rv-body">
|
||||
<!-- 历史版本切换 -->
|
||||
<div class="rv-versions" v-if="versions.length">
|
||||
<span class="rv-label">提交版本:</span>
|
||||
<RadioGroup :value="curIdx" type="button" size="small" @on-change="(i: number) => (curIdx = i)">
|
||||
<Radio v-for="(v, i) in versions" :key="v.submissionId" :label="i">
|
||||
v{{ v.version }}{{ v.isLatest ? "(当前)" : "" }}
|
||||
</Radio>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<Spin v-if="loading" fix style="position: relative; padding: 40px 0"></Spin>
|
||||
<template v-else-if="cur">
|
||||
<div class="rv-meta">
|
||||
<Tag size="small" :color="SUBMIT_COLOR[cur.submitStatus]">{{ cur.submitStatusDesc || cur.submitStatus }}</Tag>
|
||||
<span v-if="cur.isLate" class="late">逾期</span>
|
||||
<span class="time">提交时间:{{ cur.submittedAt ? String(cur.submittedAt).slice(0, 16) : "—" }}</span>
|
||||
<template v-if="cur.reviewStatus !== 'PENDING'">
|
||||
<span class="time">批阅:{{ cur.reviewedBy || "—" }} {{ cur.reviewedAt ? String(cur.reviewedAt).slice(0, 16) : "" }}</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="rv-text" v-if="cur.text">{{ cur.text }}</div>
|
||||
<div class="rv-text empty" v-else>(无文字作答)</div>
|
||||
|
||||
<!-- 附件:图片缩略图 + 文件条目 -->
|
||||
<div class="rv-atts" v-if="cur.attachments && cur.attachments.length">
|
||||
<div class="rv-imgs" v-if="imageAtts.length">
|
||||
<div class="rv-img" v-for="(a, i) in imageAtts" :key="i" @click="zoom(attUrl(a))">
|
||||
<img :src="attUrl(a)" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="rv-files" v-if="fileAtts.length">
|
||||
<span class="rv-file" v-for="(a, i) in fileAtts" :key="i">
|
||||
{{ a.fileName || "附件" + (i + 1) }}
|
||||
<a :href="attDownUrl(a)" target="_blank">下载</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 上一版批阅结果(历史版本展示) -->
|
||||
<Alert v-if="cur.reviewStatus !== 'PENDING' && (cur.score != null || cur.comment)" type="success" show-icon style="margin-top: 8px">
|
||||
已批阅:{{ cur.score != null ? `${cur.score} 分 · ` : "" }}{{ cur.comment || "无评语" }}
|
||||
</Alert>
|
||||
</template>
|
||||
<div v-else class="rv-empty">该学生尚未提交</div>
|
||||
|
||||
<Divider style="margin: 10px 0" />
|
||||
|
||||
<!-- 批阅输入(仅当前版本待批时) -->
|
||||
<Form :label-width="72" v-if="canReview">
|
||||
<FormItem label="分数">
|
||||
<Input v-model="score" placeholder="可留空只用评语" style="width: 160px"></Input>
|
||||
</FormItem>
|
||||
<FormItem label="评语">
|
||||
<Input v-model="comment" type="textarea" :rows="3" placeholder="输入批阅评语(分数与评语至少填一项)"></Input>
|
||||
</FormItem>
|
||||
</Form>
|
||||
<Alert v-else-if="cur" type="warning" show-icon>该版本已批阅;如需重新批阅或退回订正,请使用打回让学生重交。</Alert>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button @click="$emit('update:open', false)">取消</Button>
|
||||
<Button type="error" style="margin-left: 8px" :loading="returning" @click="openReturn">打回订正</Button>
|
||||
<Button type="primary" :loading="saving" :disabled="!canReview" @click="doReview">保存批阅</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- 打回原因 -->
|
||||
<Modal v-model="returnModal.open" title="打回订正" width="460px">
|
||||
<Form :label-width="80">
|
||||
<FormItem label="订正原因" required>
|
||||
<Input v-model="returnModal.comment" type="textarea" :rows="3" placeholder="打回须填写订正原因(学生可见)"></Input>
|
||||
</FormItem>
|
||||
</Form>
|
||||
<template #footer>
|
||||
<Button @click="returnModal.open = false">取消</Button>
|
||||
<Button type="error" :loading="returning" style="margin-left: 8px" @click="doReturn">确认打回</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- 图片放大 -->
|
||||
<Modal v-model="imgZoom.open" footer-hide title="作答图片" width="680px">
|
||||
<div class="zoom-wrap"><img :src="imgZoom.url" /></div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch } from "vue";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { teacherApi } from "@/api/module/teacherHomework";
|
||||
import type { AttachmentItem, SubmissionVersionVo, StudentMatrixVo } from "@/types/homeworkV2";
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
homeworkId: number;
|
||||
/** 学生(studentId/studentName 必有;score 可选回填) */
|
||||
student: StudentMatrixVo | null;
|
||||
}>();
|
||||
const emit = defineEmits<{ (e: "update:open", v: boolean): void; (e: "saved"): void }>();
|
||||
|
||||
const versions = ref<SubmissionVersionVo[]>([]);
|
||||
const curIdx = ref(0);
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const returning = ref(false);
|
||||
const score = ref<string | number>("");
|
||||
const comment = ref("");
|
||||
|
||||
const SUBMIT_COLOR: Record<string, string> = {
|
||||
NOT_SUBMITTED: "default",
|
||||
SUBMITTED: "blue",
|
||||
LATE_SUBMITTED: "warning",
|
||||
RETURNED: "error",
|
||||
REVIEWED: "success",
|
||||
};
|
||||
|
||||
const cur = computed(() => versions.value[curIdx.value] || null);
|
||||
/** 当前展示版本可批:最新版本且批阅状态 PENDING(未交/已批不可批) */
|
||||
const canReview = computed(() => {
|
||||
const v = cur.value;
|
||||
return !!v && v.isLatest === 1 && v.reviewStatus === "PENDING" && v.submitStatus !== "NOT_SUBMITTED";
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(v) => {
|
||||
if (v) load();
|
||||
}
|
||||
);
|
||||
|
||||
async function load() {
|
||||
if (!props.student) return;
|
||||
loading.value = true;
|
||||
score.value = "";
|
||||
comment.value = "";
|
||||
curIdx.value = 0;
|
||||
try {
|
||||
versions.value = await teacherApi.versions(props.homeworkId, props.student.studentId).send();
|
||||
// 已批版本回填上一次评分(教师微调场景)
|
||||
const reviewed = versions.value.find((v) => v.reviewStatus === "REVIEWED" && v.isLatest === 1);
|
||||
if (reviewed?.score != null) score.value = reviewed.score;
|
||||
if (reviewed?.comment) comment.value = reviewed.comment;
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "加载提交版本失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const imageAtts = computed(() => (cur.value?.attachments || []).filter((a) => (a.fileType || "").toLowerCase() === "image"));
|
||||
const fileAtts = computed(() => (cur.value?.attachments || []).filter((a) => (a.fileType || "").toLowerCase() !== "image"));
|
||||
|
||||
/** 附件展示地址:fileUrl 已是 /api/file/Preview?id= 相对路径;绝对地址原样 */
|
||||
function attUrl(a: AttachmentItem): string {
|
||||
if (/^(https?:)?\/\//.test(a.fileUrl) || a.fileUrl.startsWith("data:")) return a.fileUrl;
|
||||
return a.fileUrl;
|
||||
}
|
||||
function attDownUrl(a: AttachmentItem): string {
|
||||
return a.fileId ? `/api/file/Down?id=${a.fileId}` : a.fileUrl;
|
||||
}
|
||||
|
||||
const imgZoom = reactive({ open: false, url: "" });
|
||||
function zoom(url: string) {
|
||||
imgZoom.url = url;
|
||||
imgZoom.open = true;
|
||||
}
|
||||
|
||||
async function doReview() {
|
||||
if (!props.student) return;
|
||||
const s = String(score.value).trim();
|
||||
const c = comment.value.trim();
|
||||
if (!s && !c) {
|
||||
Message.info("分数与评语至少填写一项");
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
await teacherApi
|
||||
.review(props.homeworkId, props.student.studentId, {
|
||||
score: s ? Number(s) : undefined,
|
||||
comment: c || undefined,
|
||||
})
|
||||
.send();
|
||||
Message.success("批阅已保存");
|
||||
emit("update:open", false);
|
||||
emit("saved");
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "批阅失败");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const returnModal = reactive({ open: false, comment: "" });
|
||||
function openReturn() {
|
||||
returnModal.comment = "";
|
||||
returnModal.open = true;
|
||||
}
|
||||
async function doReturn() {
|
||||
if (!props.student) return;
|
||||
if (!returnModal.comment.trim()) {
|
||||
Message.info("请填写订正原因");
|
||||
return;
|
||||
}
|
||||
returning.value = true;
|
||||
try {
|
||||
await teacherApi.returnSubmission(props.homeworkId, props.student.studentId, { comment: returnModal.comment.trim() }).send();
|
||||
Message.success("已打回,等待学生订正重交");
|
||||
returnModal.open = false;
|
||||
emit("update:open", false);
|
||||
emit("saved");
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "打回失败");
|
||||
} finally {
|
||||
returning.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.rv-body {
|
||||
position: relative;
|
||||
.rv-versions {
|
||||
margin-bottom: 10px;
|
||||
.rv-label {
|
||||
color: #808695;
|
||||
font-size: 13px;
|
||||
margin-right: 6px;
|
||||
}
|
||||
}
|
||||
.rv-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
color: #808695;
|
||||
.late {
|
||||
color: #ed4014;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
.rv-text {
|
||||
color: #515a6e;
|
||||
line-height: 1.6;
|
||||
background: #fafcff;
|
||||
border: 1px solid #e8eaec;
|
||||
border-radius: 4px;
|
||||
padding: 8px 10px;
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
&.empty {
|
||||
color: #c5c8ce;
|
||||
}
|
||||
}
|
||||
.rv-atts {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.rv-imgs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
.rv-img {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
border: 1px solid #e8eaec;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
cursor: zoom-in;
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
&:hover {
|
||||
border-color: #2d8cf0;
|
||||
}
|
||||
}
|
||||
}
|
||||
.rv-files {
|
||||
margin-top: 6px;
|
||||
.rv-file {
|
||||
display: inline-block;
|
||||
margin: 0 8px 4px 0;
|
||||
padding: 2px 8px;
|
||||
background: #f0f2f5;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
color: #515a6e;
|
||||
a {
|
||||
margin-left: 6px;
|
||||
color: #2d8cf0;
|
||||
}
|
||||
}
|
||||
}
|
||||
.rv-empty {
|
||||
color: #c5c8ce;
|
||||
text-align: center;
|
||||
padding: 30px 0;
|
||||
}
|
||||
}
|
||||
.zoom-wrap {
|
||||
text-align: center;
|
||||
img {
|
||||
max-width: 100%;
|
||||
max-height: 62vh;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,169 +0,0 @@
|
||||
<template>
|
||||
<div class="type_set-box">
|
||||
<div class="type_name">
|
||||
<i class="iconfont icon-stop fl mr-10" v-if="!isEdit && !active" style="color: #ff5959"></i>
|
||||
<Input v-if="isEdit" v-model="type.name" ref="type_input" @on-blur="update" @on-enter="update" />
|
||||
<span v-else class="fl">{{ type.name }}</span>
|
||||
</div>
|
||||
<div class="type_action flex">
|
||||
<div class="action_item" @click="open">
|
||||
<span v-if="active">停用</span>
|
||||
<span v-else>启用</span>
|
||||
</div>
|
||||
<div class="action_edit" @click="edit">
|
||||
<span>编辑</span>
|
||||
</div>
|
||||
<div class="action_delete" @click="remove">
|
||||
<span>删除</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
<div class="divider2"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "TypeSetBox",
|
||||
props: {
|
||||
type: {
|
||||
name: { type: String, default: "" },
|
||||
id: { type: String, default: "" },
|
||||
},
|
||||
active: { type: Boolean, default: true },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isEdit: false,
|
||||
eventType: "",
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
type: {
|
||||
deep: true,
|
||||
handler(newVal) {
|
||||
if (this.isEdit) {
|
||||
this.isEdit = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
"type.name"(newVal) {
|
||||
if (this.isEdit) {
|
||||
this.isEdit = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
open() {
|
||||
this.$emit("active", this.type.id);
|
||||
},
|
||||
edit() {
|
||||
this.isEdit = !this.isEdit;
|
||||
if (this.isEdit) this.$nextTick(() => this.$refs["type_input"].focus());
|
||||
},
|
||||
update(e) {
|
||||
if (!this.type.name) {
|
||||
this.$Message.error("值不得为空!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.eventType == "") {
|
||||
this.eventType = e.type;
|
||||
this.isEdit = false;
|
||||
this.$emit("edit", this.type.id, this.type.name);
|
||||
setTimeout(() => {
|
||||
this.eventType = "";
|
||||
}, 500);
|
||||
}
|
||||
},
|
||||
remove() {
|
||||
this.$emit("remove", this.type.id);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
.type_set-box {
|
||||
width: 222px;
|
||||
background: #ffffff;
|
||||
margin-right: 15px;
|
||||
margin-bottom: 15px;
|
||||
.type_name {
|
||||
height: 66px;
|
||||
border: 1px solid #e9e9e9;
|
||||
border-bottom: none;
|
||||
padding-left: 15px;
|
||||
padding-right: 15px;
|
||||
line-height: 66px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
span {
|
||||
height: 24px;
|
||||
font-size: 15px;
|
||||
font-family: PingFangSC-Semibold, PingFang SC;
|
||||
font-weight: 500;
|
||||
color: #272829;
|
||||
line-height: 24px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
width: 222px;
|
||||
display: inline-block;
|
||||
}
|
||||
.iconfont {
|
||||
font-size: 26px;
|
||||
}
|
||||
}
|
||||
.type_action {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 222px;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
background: #f7f7f7;
|
||||
border-radius: 0px 0px 2px 2px;
|
||||
border-top: none;
|
||||
border: 1px solid #e9e9e9;
|
||||
span {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
div {
|
||||
cursor: pointer;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
div:hover {
|
||||
color: #6c91f0;
|
||||
}
|
||||
}
|
||||
.divider {
|
||||
// position: relative;
|
||||
height: 14px;
|
||||
width: 1px;
|
||||
background: #e8e8e8;
|
||||
// left: 73px;
|
||||
// top: -23px;
|
||||
float: left;
|
||||
margin-left: 71px;
|
||||
margin-top: -22px;
|
||||
}
|
||||
.divider2 {
|
||||
// position: relative;
|
||||
// left: 146px;
|
||||
// top: -37px;
|
||||
height: 14px;
|
||||
width: 1px;
|
||||
background: #e8e8e8;
|
||||
float: left;
|
||||
margin-left: 147px;
|
||||
margin-top: -22px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,165 +0,0 @@
|
||||
<template>
|
||||
<!-- 作业对象选择:全部用户 / 指定用户(自旧前端 user_check_input.vue 适配) -->
|
||||
<div class="check-user-wrap">
|
||||
<div class="check-user-box">
|
||||
<RadioGroup v-model="isCheckUserAll" class="radio" @on-change="userAllCheck">
|
||||
<Radio :disabled="editDisabled" :label="0">{{ isEdit ? "现有" : "全部" }}用户</Radio>
|
||||
<Radio :label="1">指定用户</Radio>
|
||||
</RadioGroup>
|
||||
<Input
|
||||
type="text"
|
||||
v-model="userNameStr"
|
||||
readonly
|
||||
:disabled="!isCheckUserAll"
|
||||
placeholder="选择指定的用户"
|
||||
class="input"
|
||||
></Input>
|
||||
<Button icon="md-add" @click="checkUserShow" :disabled="!isCheckUserAll"></Button>
|
||||
<Button icon="md-trash" @click="resetUser" :disabled="!isCheckUserAll"></Button>
|
||||
</div>
|
||||
<UserCheckModal
|
||||
ref="userCheckModal"
|
||||
:sources="sources"
|
||||
:userids="tempUserIds"
|
||||
@on-submit="submitUser"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
/*
|
||||
* 作业对象选择输入(自旧前端 oldfront/src/components/user_check_input.vue 适配 Vue3)。
|
||||
* v-model 值为 "all" 或用户 id 逗号串;切换到「指定用户」直接弹出选择用户弹窗。
|
||||
*/
|
||||
import UserCheckModal from "@/components/UserCheckModal.vue";
|
||||
import { baseApi, settingApi } from "@/api/module/homework";
|
||||
|
||||
export default {
|
||||
name: "UserCheckInput",
|
||||
components: { UserCheckModal },
|
||||
props: {
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: "all",
|
||||
},
|
||||
/** 编辑态:全部用户选项禁用为「现有用户」 */
|
||||
isEdit: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
data() {
|
||||
return {
|
||||
isCheckUserAll: 0 as number,
|
||||
tempUserIds: [] as any[],
|
||||
userNameStr: "",
|
||||
editDisabled: false,
|
||||
sources: [
|
||||
{
|
||||
name: "基础平台",
|
||||
loader: () => baseApi.getSchoolAll().send() as Promise<any[]>,
|
||||
},
|
||||
{
|
||||
name: "自定义组",
|
||||
loader: () => settingApi.getGroupTree().send() as Promise<any[]>,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
created() {
|
||||
if (this.modelValue === "all") {
|
||||
this.isCheckUserAll = 0;
|
||||
this.userNameStr = this.isEdit ? "现有用户" : "全部用户";
|
||||
} else {
|
||||
this.isCheckUserAll = 1;
|
||||
this.tempUserIds = this.modelValue.split(",").map((x) => parseInt(x)).filter((x) => !isNaN(x));
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
checkUserShow() {
|
||||
(this.$refs.userCheckModal as any).show();
|
||||
},
|
||||
resetUser() {
|
||||
//清除用户
|
||||
this.tempUserIds = [];
|
||||
this.userNameStr = "";
|
||||
this.$emit("update:modelValue", "");
|
||||
},
|
||||
userAllCheck(type: number) {
|
||||
let tempIds: any = String(this.modelValue || "")
|
||||
.split(",")
|
||||
.map((x) => parseInt(x))
|
||||
.filter((x) => !isNaN(x));
|
||||
if (type == 0) {
|
||||
if (this.isEdit) {
|
||||
this.tempUserIds = tempIds;
|
||||
this.userNameStr = "现有用户";
|
||||
this.$emit("update:modelValue", String(tempIds));
|
||||
} else {
|
||||
this.tempUserIds = [];
|
||||
this.userNameStr = "全部用户";
|
||||
this.$emit("update:modelValue", "all");
|
||||
}
|
||||
} else {
|
||||
if (this.isEdit) {
|
||||
this.editDisabled = true;
|
||||
}
|
||||
this.tempUserIds = tempIds;
|
||||
this.userNameStr = "";
|
||||
this.checkUserShow();
|
||||
}
|
||||
},
|
||||
submitUser(arr: any[]) {
|
||||
//确认用户
|
||||
this.userNameStr = arr.map((x) => x.name).join("、");
|
||||
const ids = arr.map((x) => x.id).join(",");
|
||||
this.$emit("update:modelValue", ids || "all");
|
||||
if (!arr.length) {
|
||||
// 清空后回到全部用户
|
||||
this.isCheckUserAll = 0;
|
||||
this.userNameStr = "全部用户";
|
||||
this.$emit("update:modelValue", "all");
|
||||
}
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
modelValue(val: string) {
|
||||
if (val === "all") {
|
||||
this.isCheckUserAll = 0;
|
||||
this.userNameStr = this.isEdit ? "现有用户" : "全部用户";
|
||||
} else if (val === "") {
|
||||
this.userNameStr = "";
|
||||
} else {
|
||||
// 编辑回显:指定用户 id 串
|
||||
this.isCheckUserAll = 1;
|
||||
this.tempUserIds = val
|
||||
.split(",")
|
||||
.map((x) => parseInt(x))
|
||||
.filter((x) => !isNaN(x));
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.check-user-wrap {
|
||||
width: 100%;
|
||||
.check-user-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
.radio {
|
||||
width: 180px;
|
||||
flex: none;
|
||||
font-size: 12px;
|
||||
vertical-align: middle;
|
||||
line-height: 32px;
|
||||
}
|
||||
.input {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -134,13 +134,13 @@ const getMenuIcon = (item?: any): string => {
|
||||
const page: string = item?.page || "";
|
||||
if (page === "/list/new") return "icon-xiugaijl";
|
||||
if (page === "/list") return "icon-fasong";
|
||||
if (page === "/submissions") return "icon-liebiao";
|
||||
if (page === "/grade") return "icon-chengji";
|
||||
if (page === "/" || page === "/accept") return "icon-sjx";
|
||||
if (page === "/homeworklist") return "icon-liebiao";
|
||||
if (page === "/approval") return "icon-shenpi";
|
||||
if (page === "/approval-setting") return "icon-quanxian";
|
||||
if (page === "/stats") return "icon-tongji";
|
||||
if (page === "/setting") return "icon-shezhi";
|
||||
if (page === "/stu/today" || page === "/par/kids") return "icon-sjx";
|
||||
if (page === "/stu/today") return "icon-rili";
|
||||
if (page === "/stu/redo") return "icon-xiugaijl";
|
||||
switch (item?.modId ?? item?.id) {
|
||||
case 1: return "icon-xiugaijl";
|
||||
|
||||
Reference in New Issue
Block a user