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:
wanghu
2026-09-16 02:29:32 +08:00
parent d75aab8d07
commit e6639f7925
40 changed files with 7734 additions and 1535 deletions
-217
View File
@@ -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>
+129
View File
@@ -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>
+315
View File
@@ -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>
-169
View File
@@ -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>
-165
View File
@@ -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>
+3 -3
View File
@@ -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";