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:
+4
-5
@@ -4,7 +4,7 @@ import layout from "./components/layout.vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import config from "@/assets/config/host.config";
|
||||
import { baseApi } from "@/api/module/homework";
|
||||
import { resolveMenuPage } from "@/utils/menuRoute";
|
||||
import { resolveMenuPage, dropDroppedModules } from "@/utils/menuRoute";
|
||||
import { Message } from "view-ui-plus";
|
||||
|
||||
const router = useRouter();
|
||||
@@ -19,9 +19,8 @@ const layoutData = reactive({
|
||||
isAlone: false,
|
||||
});
|
||||
|
||||
function roleOfPath(p: string): "teacher" | "student" | "parent" {
|
||||
function roleOfPath(p: string): "teacher" | "student" {
|
||||
if (p.startsWith("/stu")) return "student";
|
||||
if (p.startsWith("/par")) return "parent";
|
||||
return "teacher";
|
||||
}
|
||||
|
||||
@@ -38,8 +37,8 @@ async function initShell() {
|
||||
const user: any = await baseApi.getUserInfo().send();
|
||||
const { id, name, profilePicture } = user || {};
|
||||
|
||||
// 2. 获取当前用户已授权的模块列表
|
||||
const modList: any[] = await baseApi.getUserMod().send();
|
||||
// 2. 获取当前用户已授权的模块列表(剔除已下线的审批模块,避免死菜单)
|
||||
const modList: any[] = dropDroppedModules(await baseApi.getUserMod().send());
|
||||
|
||||
layoutData.userInfo = { id, name: name || "管理员", avatar: profilePicture || "" };
|
||||
layoutData.modList = modList || [];
|
||||
|
||||
+3
-124
@@ -14,14 +14,8 @@ import type {
|
||||
HomeworkStatisticsVo,
|
||||
HomeworkRequest,
|
||||
HomeworkQuery,
|
||||
DictOption,
|
||||
DeptManageItem,
|
||||
ContactGroupPage,
|
||||
ContactUserPage,
|
||||
SchoolTreeNode,
|
||||
ClassTreeNode,
|
||||
ApprovalQuery,
|
||||
ApproverVo,
|
||||
SubmissionsResultVo,
|
||||
SubmissionDetailVo,
|
||||
GradeRequest,
|
||||
@@ -40,7 +34,8 @@ import type {
|
||||
* 变更要点(P0):
|
||||
* 1. 作业域去掉 Homework_list 二级段(/api/homework/Homework_list/List → /api/homework/List)
|
||||
* 2. 文件域从 base/ 迁移到 file/(/api/base/FileDown → /api/file/Down)
|
||||
* 3. 字典域从 base/ 迁移到 dict/,GET 写操作改 POST(/api/base/DelDept → /api/dict/Delete)
|
||||
* 3. 字典域与联系组域(发文部门管理、自定义联系组)随「应用设置」功能下线整体移除
|
||||
* (见 classwork-frontend-m5-plan.md §7),本文件不再封装 /api/dict/* 与 /api/contacts/*
|
||||
* 4. postJSON 用于 @RequestBody 接口;postForm 用于 @RequestParam 接口(表单提交)
|
||||
* 5. 参数更名:chaoSend→fileIds、topmost→smsFlag
|
||||
* 6. Delete 入参从 ids 逗号串改为 List<Integer> JSON 数组
|
||||
@@ -52,9 +47,7 @@ import type {
|
||||
|
||||
const HOMEWORK = "/api/homework";
|
||||
const BASE = "/api/base";
|
||||
const DICT = "/api/dict";
|
||||
const FILE = "/api/file";
|
||||
const CONTACTS = "/api/contacts";
|
||||
|
||||
/** GET 请求,T 为返回数据类型 */
|
||||
function get<T = any>(url: string, params?: Record<string, any>): Method<T> {
|
||||
@@ -110,10 +103,6 @@ class BaseApi {
|
||||
getUserMod() {
|
||||
return get<SchoolTreeNode[]>(`${BASE}/GetUserMod`);
|
||||
}
|
||||
/** 当前学校组织树(部门→教师,用于选择用户) */
|
||||
getSchoolAll() {
|
||||
return get<SchoolTreeNode[]>(`${BASE}/GetSchoolAll`);
|
||||
}
|
||||
/** 年级→班级树(学生场景:选择通知对象到班级一级) */
|
||||
getClassTree() {
|
||||
return get<ClassTreeNode[]>(`${BASE}/GetClassTree`);
|
||||
@@ -209,7 +198,7 @@ class HomeworkApi {
|
||||
return postForm<{ reminded: number }>(`${HOMEWORK}/Remind`, { serid });
|
||||
}
|
||||
|
||||
// ========== v2 学生 / 家长 ==========
|
||||
// ========== v2 学生端 ==========
|
||||
/** 今日/我的作业(当前学生视角,含提交与批改态 + 分桶计数) */
|
||||
myHomeworks() {
|
||||
return get<MyHomeworksVo>(`${HOMEWORK}/MyHomeworks`);
|
||||
@@ -222,127 +211,17 @@ class HomeworkApi {
|
||||
submit(data: SubmitRequest) {
|
||||
return postJSON<{ subId: number; submitStatus: string }>(`${HOMEWORK}/Submit`, data);
|
||||
}
|
||||
/** 家长提醒孩子(只读端) */
|
||||
parentNudge(serid: number) {
|
||||
return postForm<void>(`${HOMEWORK}/ParentNudge`, { serid });
|
||||
}
|
||||
|
||||
// ========== 附件 ==========
|
||||
fileDownUrl = fileDownUrl;
|
||||
allFileDownUrl = allFileDownUrl;
|
||||
|
||||
// ========== 部门字典 ==========
|
||||
/** 发文部门下拉 */
|
||||
getSysDic() {
|
||||
return get<DictOption[]>(`${DICT}/List`, { type: 1 });
|
||||
}
|
||||
/** 是否开通短信(迁移到 dict/Sync 查询,保持旧调用名不变) */
|
||||
getSchModuleIsSms() {
|
||||
return get<boolean>(`${DICT}/Sync`);
|
||||
}
|
||||
/** 作业统计 */
|
||||
statistics(params?: { starttime?: string; endtime?: string }) {
|
||||
return get<HomeworkStatisticsVo>(`${HOMEWORK}/Statis`, params);
|
||||
}
|
||||
|
||||
// ========== 审批(可选流程:needApproval=1 的作业进入审批列表) ==========
|
||||
/** 审批列表(status: pending/approved/rejected/all,缺省 pending) */
|
||||
approvalList(params: ApprovalQuery) {
|
||||
return get<BasePageResult<HomeworkVo>>(`${HOMEWORK}/ApprovalList`, params);
|
||||
}
|
||||
/** 待审批数量(菜单角标) */
|
||||
approvalCount() {
|
||||
return get<number>(`${HOMEWORK}/ApprovalCount`);
|
||||
}
|
||||
/** 审批通过(作业置为已发布) */
|
||||
approve(serid: number) {
|
||||
return postForm<void>(`${HOMEWORK}/Approve`, { serid });
|
||||
}
|
||||
/** 审批驳回(附审批意见,作业退回发布人可编辑重提) */
|
||||
reject(serid: number, remark: string) {
|
||||
return postForm<void>(`${HOMEWORK}/Reject`, { serid, remark });
|
||||
}
|
||||
}
|
||||
|
||||
class SettingApi {
|
||||
// ========== 发文部门管理 ==========
|
||||
/** 获取发文部门管理列表 */
|
||||
getDictMangeList() {
|
||||
return get<DeptManageItem[]>(`${DICT}/ManageList`, { type: "dept" });
|
||||
}
|
||||
/** 新增发文部门 */
|
||||
addDeptDic(name: string) {
|
||||
return postForm<void>(`${DICT}/Add`, { name });
|
||||
}
|
||||
/** 更新发文部门(名称、启用状态) */
|
||||
updateDeptDic(SerID: number, Name: string, Enable = 1) {
|
||||
return postForm<void>(`${DICT}/Update`, { SerID, Name, Enable });
|
||||
}
|
||||
/** 删除发文部门(原 GET DelDept,已 POST 化) */
|
||||
delDept(serid: number) {
|
||||
return postForm<void>(`${DICT}/Delete`, { serid });
|
||||
}
|
||||
/** 发文部门排序(原 GET OrdbyDicIds,已 POST 化) */
|
||||
sortDeptDic(ids: string) {
|
||||
return postForm<void>(`${DICT}/Sort`, { ids });
|
||||
}
|
||||
// ========== 自定义组管理 ==========
|
||||
/** 获取联系组列表 */
|
||||
getGroup(page: number, count: number) {
|
||||
return get<ContactGroupPage>(`${CONTACTS}/GetGroup`, { page, count });
|
||||
}
|
||||
/** 新增联系组 */
|
||||
addGroup(name: string) {
|
||||
return postForm<void>(`${CONTACTS}/AddGroup`, { name });
|
||||
}
|
||||
/** 编辑联系组 */
|
||||
editGroup(id: number, name: string) {
|
||||
return postForm<void>(`${CONTACTS}/EditGroup`, { name, id });
|
||||
}
|
||||
/** 删除联系组 */
|
||||
delGroup(ids: string) {
|
||||
return postForm<void>(`${CONTACTS}/DelGroup`, { ids });
|
||||
}
|
||||
/** 获取联系组用户 */
|
||||
getContactsTea(gid: number, page = 0, count = 0) {
|
||||
return get<ContactUserPage>(`${CONTACTS}/GetContactsTea`, { gid, page, count });
|
||||
}
|
||||
/** 设置联系组用户 */
|
||||
setContactsTea(gid: number, ids: string) {
|
||||
return postForm<void>(`${CONTACTS}/SetContactsTea`, { gid, ids });
|
||||
}
|
||||
/** 联系组用户树(选择用户-自定义组 tab) */
|
||||
getGroupTree() {
|
||||
return get<SchoolTreeNode[]>(`${CONTACTS}/GetGroupTree`, { type: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
/** 审批人维护 + 审批人候选池(发布时选人即送审) */
|
||||
class ApprovalSettingApi {
|
||||
/** 审批人候选池(发布界面选审批人用,仅启用者) */
|
||||
approverPool() {
|
||||
return get<{ id: number; name: string; account: string; deptName: string; isParent: boolean }[]>(`${DICT}/ApproverPool`);
|
||||
}
|
||||
/** 审批人列表(管理页) */
|
||||
approvers() {
|
||||
return get<ApproverVo[]>(`${DICT}/Approvers`);
|
||||
}
|
||||
/** 新增审批人 */
|
||||
addApprover(data: { account: string; name: string; deptId: string }) {
|
||||
return postJSON<void>(`${DICT}/AddApprover`, data);
|
||||
}
|
||||
/** 更新审批人(姓名/部门/启用状态) */
|
||||
updateApprover(data: { id: number; name?: string; deptId?: string; enabled?: number }) {
|
||||
return postJSON<void>(`${DICT}/UpdateApprover`, data);
|
||||
}
|
||||
/** 删除审批人 */
|
||||
delApprover(id: number) {
|
||||
return postForm<void>(`${DICT}/DelApprover`, { id });
|
||||
}
|
||||
}
|
||||
|
||||
export const baseApi = new BaseApi();
|
||||
export const homeworkApi = new HomeworkApi();
|
||||
export const settingApi = new SettingApi();
|
||||
export const approvalSettingApi = new ApprovalSettingApi();
|
||||
export default homeworkApi;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Method } from "alova";
|
||||
import { alovaInstance } from "@/utils/alova/alova-iview";
|
||||
import type { BasePageResult, NotificationVo, PageQueryV2 } from "@/types/homeworkV2";
|
||||
|
||||
/**
|
||||
* 课堂作业通知中心接口封装(M5,对齐 NotificationController)。
|
||||
* 教师端同接口,按 receiver_role 区分(后端按登录账号过滤)。
|
||||
*/
|
||||
|
||||
const NOTIFY = "/api/notifications";
|
||||
|
||||
/** GET 请求 */
|
||||
function get<T = any>(url: string, params?: Record<string, any>): Method<T> {
|
||||
return new Method<T>("GET", alovaInstance, url, { params });
|
||||
}
|
||||
|
||||
/** POST(无 body 写操作) */
|
||||
function post<T = any>(url: string): Method<T> {
|
||||
return new Method<T>("POST", alovaInstance, url);
|
||||
}
|
||||
|
||||
class NotificationApi {
|
||||
/** 通知分页(unreadOnly=true 只看未读;返回含 unreadCount 时由页面取用) */
|
||||
list(params?: PageQueryV2 & { unreadOnly?: boolean }) {
|
||||
return get<BasePageResult<NotificationVo> & { unreadCount?: number }>(NOTIFY, params);
|
||||
}
|
||||
/** 单条已读 */
|
||||
markRead(id: number) {
|
||||
return post<void>(`${NOTIFY}/${id}/read`);
|
||||
}
|
||||
/** 全部已读 */
|
||||
markAllRead() {
|
||||
return post<void>(`${NOTIFY}/read-all`);
|
||||
}
|
||||
}
|
||||
|
||||
export const notificationApi = new NotificationApi();
|
||||
export default notificationApi;
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Method } from "alova";
|
||||
import { alovaInstance } from "@/utils/alova/alova-iview";
|
||||
import type {
|
||||
BasePageResult,
|
||||
PageQueryV2,
|
||||
StudentHomeworkDetailV2Vo,
|
||||
StudentHomeworkV2Vo,
|
||||
StudentResultVo,
|
||||
StudentSubmitRequest,
|
||||
StudentTab,
|
||||
SubmissionActionRequest,
|
||||
} from "@/types/homeworkV2";
|
||||
|
||||
/**
|
||||
* 课堂作业 V2 学生端接口封装(M5,对齐 StudentHomeworkController,PRD §18 学生页 1~3)。
|
||||
*
|
||||
* 身份:当前登录用户账号 ↔ class_student 名册;仅可见本人班级作业(后端禁 IDOR)。
|
||||
*/
|
||||
|
||||
const STUDENT = "/api/student/homeworks";
|
||||
|
||||
/** GET 请求 */
|
||||
function get<T = any>(url: string, params?: Record<string, any>): Method<T> {
|
||||
return new Method<T>("GET", alovaInstance, url, { params });
|
||||
}
|
||||
|
||||
/** POST JSON body(@RequestBody) */
|
||||
function postJSON<T = any>(url: string, data?: Record<string, any> | any[]): Method<T> {
|
||||
return new Method<T>(
|
||||
"POST",
|
||||
alovaInstance,
|
||||
url,
|
||||
{ headers: { "Content-Type": "application/json;charset=UTF-8" } },
|
||||
data ? JSON.stringify(data) : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
/** PUT JSON body(@RequestBody) */
|
||||
function putJSON<T = any>(url: string, data?: Record<string, any> | any[]): Method<T> {
|
||||
return new Method<T>(
|
||||
"PUT",
|
||||
alovaInstance,
|
||||
url,
|
||||
{ headers: { "Content-Type": "application/json;charset=UTF-8" } },
|
||||
data ? JSON.stringify(data) : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
class StudentHomeworkApi {
|
||||
/** 我的作业列表(tab=all/todo/done/closed + 分页/keyword) */
|
||||
list(tab: StudentTab | string = "all", params?: PageQueryV2) {
|
||||
return get<BasePageResult<StudentHomeworkV2Vo>>(STUDENT, { tab, ...params });
|
||||
}
|
||||
/** 作业详情(含说明/附件/我的当前提交内容) */
|
||||
detail(id: number) {
|
||||
return get<StudentHomeworkDetailV2Vo>(`${STUDENT}/${id}`);
|
||||
}
|
||||
/** 阅读上报(前端停留 ≥3 秒后调用;幂等) */
|
||||
read(id: number) {
|
||||
return postJSON<void>(`${STUDENT}/${id}/read`);
|
||||
}
|
||||
/** 提交作业,返回 { submissionId } */
|
||||
submit(id: number, data: StudentSubmitRequest) {
|
||||
return postJSON<{ submissionId: number }>(`${STUDENT}/${id}/submissions`, data);
|
||||
}
|
||||
/** 撤回(截止前)/ 打回后重交,返回 { submissionId: 新提交行 id } */
|
||||
action(id: number, submissionId: number, data: SubmissionActionRequest) {
|
||||
return putJSON<{ submissionId: number }>(`${STUDENT}/${id}/submissions/${submissionId}`, data);
|
||||
}
|
||||
/** 批阅结果(当前版本 + 历史版本含各版评语) */
|
||||
result(id: number) {
|
||||
return get<StudentResultVo>(`${STUDENT}/${id}/result`);
|
||||
}
|
||||
}
|
||||
|
||||
export const studentApi = new StudentHomeworkApi();
|
||||
export default studentApi;
|
||||
@@ -0,0 +1,161 @@
|
||||
import { Method } from "alova";
|
||||
import { alovaInstance } from "@/utils/alova/alova-iview";
|
||||
import conf from "@/assets/config/host.config";
|
||||
import type {
|
||||
BasePageResult,
|
||||
ClassTreeNodeV2,
|
||||
HomeworkStatsVo,
|
||||
HomeworkV2Request,
|
||||
HomeworkV2Vo,
|
||||
RemindType,
|
||||
ReviewRequest,
|
||||
ReturnRequest,
|
||||
StudentMatrixQuery,
|
||||
StudentMatrixVo,
|
||||
SubmissionVersionVo,
|
||||
TeacherHomeworkQuery,
|
||||
} from "@/types/homeworkV2";
|
||||
|
||||
/**
|
||||
* 课堂作业 V2 教师端接口封装(M5,对齐 TeacherHomeworkV2Controller,PRD §18 页 1~6)。
|
||||
*
|
||||
* 路径 /api/teacher/homeworks…,RESTful 动词与后端一字对齐;统一响应由
|
||||
* alova-iview 解包为 data(code==0),页面直接使用返回值。
|
||||
*/
|
||||
|
||||
const TEACHER = "/api/teacher/homeworks";
|
||||
|
||||
/** GET 请求 */
|
||||
function get<T = any>(url: string, params?: Record<string, any>): Method<T> {
|
||||
return new Method<T>("GET", alovaInstance, url, { params });
|
||||
}
|
||||
|
||||
/** POST JSON body(@RequestBody) */
|
||||
function postJSON<T = any>(url: string, data?: Record<string, any> | any[]): Method<T> {
|
||||
return new Method<T>(
|
||||
"POST",
|
||||
alovaInstance,
|
||||
url,
|
||||
{ headers: { "Content-Type": "application/json;charset=UTF-8" } },
|
||||
data ? JSON.stringify(data) : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
/** PUT JSON body(@RequestBody) */
|
||||
function putJSON<T = any>(url: string, data?: Record<string, any> | any[]): Method<T> {
|
||||
return new Method<T>(
|
||||
"PUT",
|
||||
alovaInstance,
|
||||
url,
|
||||
{ headers: { "Content-Type": "application/json;charset=UTF-8" } },
|
||||
data ? JSON.stringify(data) : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
/** DELETE(无 body) */
|
||||
function del<T = any>(url: string): Method<T> {
|
||||
return new Method<T>("DELETE", alovaInstance, url);
|
||||
}
|
||||
|
||||
class TeacherHomeworkApi {
|
||||
// ========== 页 1:发布作业 ==========
|
||||
|
||||
/** 创建作业(落草稿),返回新作业 id */
|
||||
create(data: HomeworkV2Request) {
|
||||
return postJSON<number>(TEACHER, data);
|
||||
}
|
||||
/** 编辑作业(仅草稿两态) */
|
||||
edit(id: number, data: HomeworkV2Request) {
|
||||
return putJSON<void>(`${TEACHER}/${id}`, data);
|
||||
}
|
||||
/** 删除作业(仅草稿两态) */
|
||||
remove(id: number) {
|
||||
return del<void>(`${TEACHER}/${id}`);
|
||||
}
|
||||
/** 立即发布(草稿 → 进行中) */
|
||||
publish(id: number) {
|
||||
return postJSON<void>(`${TEACHER}/${id}/publish`);
|
||||
}
|
||||
/** 定时发布 / 取消定时(cancel=true 回退草稿并清空定时时间) */
|
||||
schedule(id: number, payload: { scheduledAt?: string; cancel?: boolean }) {
|
||||
return postJSON<void>(`${TEACHER}/${id}/schedule`, payload);
|
||||
}
|
||||
|
||||
// ========== 页 2:草稿箱 ==========
|
||||
|
||||
drafts(params: TeacherHomeworkQuery) {
|
||||
return get<BasePageResult<HomeworkV2Vo>>(`${TEACHER}/drafts`, params);
|
||||
}
|
||||
|
||||
// ========== 页 3:已发布列表 ==========
|
||||
|
||||
published(params: TeacherHomeworkQuery) {
|
||||
return get<BasePageResult<HomeworkV2Vo>>(`${TEACHER}/published`, params);
|
||||
}
|
||||
/** 提醒未读/未交学生,返回 { reminded: 实际提醒人数 } */
|
||||
remind(id: number, type: RemindType, studentIds?: string[]) {
|
||||
return postJSON<{ reminded: number }>(`${TEACHER}/${id}/remind`, { type, studentIds });
|
||||
}
|
||||
/** 延长截止时间(新截止须晚于当前时间) */
|
||||
extend(id: number, deadlineAt: string) {
|
||||
return postJSON<void>(`${TEACHER}/${id}/extend`, { deadlineAt });
|
||||
}
|
||||
/** 复制为新草稿,返回新作业 id */
|
||||
copy(id: number) {
|
||||
return postJSON<number>(`${TEACHER}/${id}/copy`);
|
||||
}
|
||||
/** 归档(只读,不再触发任何提醒) */
|
||||
archive(id: number) {
|
||||
return postJSON<void>(`${TEACHER}/${id}/archive`);
|
||||
}
|
||||
/** 导出学生矩阵 Excel 直链(浏览器 location.href 跳转) */
|
||||
exportUrl(id: number): string {
|
||||
return `${conf.serviceHost}${TEACHER}/${id}/export`;
|
||||
}
|
||||
|
||||
// ========== 页 4:详情 / 统计 ==========
|
||||
|
||||
detail(id: number) {
|
||||
return get<HomeworkV2Vo>(`${TEACHER}/${id}`);
|
||||
}
|
||||
/** 汇总统计卡片(总进度 + 分班进度) */
|
||||
stats(id: number) {
|
||||
return get<HomeworkStatsVo>(`${TEACHER}/${id}/stats`);
|
||||
}
|
||||
|
||||
// ========== 页 5:学生矩阵 ==========
|
||||
|
||||
/** 学生矩阵分页(阅读/提交/批阅状态,未交学生生成占位行 submissionId=0) */
|
||||
students(id: number, params: StudentMatrixQuery) {
|
||||
return get<BasePageResult<StudentMatrixVo>>(`${TEACHER}/${id}/students`, params);
|
||||
}
|
||||
|
||||
// ========== 页 6:批阅 / 打回 / 版本 ==========
|
||||
|
||||
/** 保存批阅(→ REVIEWED,推送批阅完成通知);score 与 comment 至少一项 */
|
||||
review(id: number, studentId: string, data: ReviewRequest) {
|
||||
return postJSON<void>(`${TEACHER}/${id}/submissions/${studentId}/review`, data);
|
||||
}
|
||||
/** 打回(→ RETURNED,推送打回通知);comment 必填 */
|
||||
returnSubmission(id: number, studentId: string, data: ReturnRequest) {
|
||||
return postJSON<void>(`${TEACHER}/${id}/submissions/${studentId}/return`, data);
|
||||
}
|
||||
/** 历史提交版本(批阅弹窗切换版本) */
|
||||
versions(id: number, studentId: string) {
|
||||
return get<SubmissionVersionVo[]>(`${TEACHER}/${id}/submissions/${studentId}/versions`);
|
||||
}
|
||||
|
||||
// ========== 辅助:班级树 / 名册同步 ==========
|
||||
|
||||
/** 年级→班级树(发布选班;叶子 id=classId,含在班学生数) */
|
||||
classTree() {
|
||||
return get<ClassTreeNodeV2[]>(`${TEACHER}/classes`);
|
||||
}
|
||||
/** 手动触发名册同步(bschclass 接口就绪前为空操作) */
|
||||
syncClasses() {
|
||||
return postJSON<{ synced: number }>(`${TEACHER}/classes/sync`);
|
||||
}
|
||||
}
|
||||
|
||||
export const teacherApi = new TeacherHomeworkApi();
|
||||
export default teacherApi;
|
||||
@@ -3,16 +3,16 @@ import type { IModule } from "@/store";
|
||||
/**
|
||||
* 课程作业左侧菜单数据(对照后端 GetUserMod 现行模块)。
|
||||
* 当前由 layout 直接使用;接入后端 GetUserMod 后,以接口返回为准。
|
||||
* 注:课程作业不做审批、不开放应用设置,故不含「审批列表/审核设置/应用设置」;
|
||||
* 平台模块配置若仍下发 modId 5/6/21,由 utils/menuRoute 的 DROPPED_MOD_IDS
|
||||
* 统一剔除(见 classwork-frontend-m5-plan.md §6、§7)。
|
||||
*/
|
||||
export function getMenuList(): Promise<IModule[]> {
|
||||
const menu: IModule[] = [
|
||||
{ id: 1, modId: 1, name: "已收到", page: "/" },
|
||||
{ id: 2, modId: 2, name: "已发送", page: "/list" },
|
||||
{ id: 3, modId: 3, name: "全部作业", page: "/homeworklist" },
|
||||
{ id: 5, modId: 5, name: "审批列表", page: "/approval" },
|
||||
{ id: 6, modId: 6, name: "审核设置", page: "/approval-setting" },
|
||||
{ id: 4, modId: 4, name: "作业统计", page: "/stats" },
|
||||
{ id: 21, modId: 21, name: "应用设置", page: "/setting" },
|
||||
];
|
||||
return Promise.resolve(menu);
|
||||
}
|
||||
|
||||
+47
-560
@@ -4,22 +4,20 @@
|
||||
* 契约对齐 src/api/module/homework.ts(P0 标准化):
|
||||
* - /api/homework/{List,SendList,Detail,GetDetail,GetNextPrev,ReadDetail,Create,Edit,Delete,
|
||||
* InitializeRed,UserPull,SetTopmost,Expedite,ReceiveUsers,Statis}
|
||||
* - /api/homework/{ApprovalList,ApprovalCount,Approve,Reject} ← 审批(可选流程)
|
||||
* - /api/dict/{List,Sync,ManageList,Add,Update,Delete,Sort}
|
||||
* - /api/base/{GetUserInfo,GetUserMod,GetLayout,GetSchoolAll,GetClassTree,FileUpload}
|
||||
* - /api/contacts/* /api/file/{UploadImage,Preview}
|
||||
* - /api/base/{GetUserInfo,GetUserMod,GetLayout,GetClassTree,FileUpload}
|
||||
* - /api/file/{UploadImage,Preview}
|
||||
* (原 /api/dict/* 与 /api/contacts/* 随「应用设置」功能下线移除,见 classwork-frontend-m5-plan.md §7)
|
||||
* 统一响应 {code,msg,data},由 alova-core 解包为 data(code==0)。
|
||||
*
|
||||
* 课程作业(任课教师布置场景)关键点:
|
||||
* 1. 布置对象到「班级」一级:receiveList = "ALL" 或 班级 id 逗号串;应查看/已查看人数按班级学生数累加。
|
||||
* 2. 审批可选:approvalEnabled=1 的作业先入 pending,经「教研组初审 + 教务处复核」通过(approved)后才对学生可见;
|
||||
* approvalEnabled=0 直接 none 发布。rejected 退回任课教师可编辑重提。
|
||||
* 3. 当前登录演示身份 = 数学教师 朱皓东(zhd);收件箱以「初一(1)班(1301)」视角判定已读/未读。
|
||||
* 2. 当前登录演示身份 = 数学教师 朱皓东(zhd);收件箱以「初一(1)班(1301)」视角判定已读/未读。
|
||||
* 3. 课程作业不做审批(无送审环节,发布即可见),详见 classwork-frontend-m5-plan.md §6。
|
||||
*/
|
||||
|
||||
/* ============================ 基础数据 ============================ */
|
||||
|
||||
/** 发布单位(作业归属教研组 / 教务处)字典 */
|
||||
/** 部门字典(仅作 Create/Edit 解析 deptName 之用,无维护入口——「应用设置」已下线) */
|
||||
const deptDict = [
|
||||
{ SerID: 101, CodeID: "D001", Name: "语文教研组", Enable: 1 },
|
||||
{ SerID: 102, CodeID: "D002", Name: "数学教研组", Enable: 1 },
|
||||
@@ -30,7 +28,6 @@ const deptDict = [
|
||||
{ SerID: 107, CodeID: "D007", Name: "体育教研组", Enable: 0 },
|
||||
{ SerID: 108, CodeID: "D008", Name: "教务处(作业管理)", Enable: 1 },
|
||||
];
|
||||
let deptSeq = 109;
|
||||
let nextFileId = 9001;
|
||||
|
||||
/** 当前登录用户(发布教师) */
|
||||
@@ -38,60 +35,6 @@ const ME = { account: "zhd", name: "朱皓东" };
|
||||
/** 收件箱演示视角:所在班级 id(已读/未读按该班判定) */
|
||||
const ME_CLASS = "1301";
|
||||
|
||||
/** 审批人候选池(发布时从这里选审批人;维护见「审批人」设置) */
|
||||
let approverSeq = 7;
|
||||
const approvers = [
|
||||
{ id: 1, account: "zhd", name: "朱皓东", deptId: "D002", deptName: "数学教研组", enabled: 1 },
|
||||
{ id: 2, account: "lwj", name: "刘文娟", deptId: "D001", deptName: "语文教研组", enabled: 1 },
|
||||
{ id: 3, account: "why", name: "王海燕", deptId: "D005", deptName: "政史地教研组", enabled: 1 },
|
||||
{ id: 4, account: "hy", name: "黄瑶", deptId: "D004", deptName: "理化生教研组", enabled: 1 },
|
||||
{ id: 5, account: "cy", name: "陈垠", deptId: "D003", deptName: "英语教研组", enabled: 1 },
|
||||
{ id: 6, account: "zm", name: "周敏", deptId: "D008", deptName: "教务处(作业管理)", enabled: 1 },
|
||||
];
|
||||
const approverName = (account: string) => approvers.find((a) => a.account === account)?.name || account;
|
||||
const approverPoolAccounts = () => approvers.filter((a) => a.enabled === 1).map((a) => a.account);
|
||||
|
||||
/**
|
||||
* 审批流 = 有序多步;每步 { name, vote, approvers:[account...] }。
|
||||
* - vote=all 会签:该步全部审批人通过才进入下一步;
|
||||
* - vote=any 或签:该步任一人通过即进入下一步;任一步有人驳回 → 整单驳回。
|
||||
* 「选人即送审」:发布时选了审批人就自动生成流程。
|
||||
*/
|
||||
type FlowLevel = { name: string; vote: "all" | "any"; approvers: string[]; records: { account: string; name: string; result: "approved" | "rejected"; time: string; remark: string }[] };
|
||||
/** 由发布入参构建流程:flow=[{name,vote,approvers}] 多步;否则 approvers=[] 单步(默认或签) */
|
||||
function buildFlowFromInput(input: any): FlowLevel[] | null {
|
||||
const mk = (name: string, vote: "all" | "any", accs: string[]): FlowLevel => ({ name, vote, approvers: accs.filter(Boolean), records: [] });
|
||||
const src = Array.isArray(input?.flow) ? input.flow : null;
|
||||
if (src && src.length) {
|
||||
const levels = src
|
||||
.map((l: any, i: number) => mk(String(l.name || `第${i + 1}步`), l.vote === "all" ? "all" : "any", (l.approvers || []).map(String)))
|
||||
.filter((l: FlowLevel) => l.approvers.length);
|
||||
return levels.length ? levels : null;
|
||||
}
|
||||
const accs = (input?.approvers || []).map(String).filter(Boolean);
|
||||
return accs.length ? [mk("审批", input?.vote === "all" ? "all" : "any", accs)] : null;
|
||||
}
|
||||
/** 当前步尚未处理的审批人 */
|
||||
function pendingApprovers(n: any): string[] {
|
||||
const lv = (n.flow || [])[n.approvalLevel || 0];
|
||||
if (!lv) return [];
|
||||
const acted = new Set(lv.records.map((r: any) => r.account));
|
||||
return lv.approvers.filter((a: string) => !acted.has(a));
|
||||
}
|
||||
/** 当前用户是否为该单当前步的待签审批人 */
|
||||
function canAct(n: any, account = ME.account) {
|
||||
return n.approvalStatus === "pending" && pendingApprovers(n).includes(account);
|
||||
}
|
||||
/** 状态展示文本(含多步进度) */
|
||||
function flowProgressText(n: any): string {
|
||||
if (n.approvalStatus !== "pending" || !n.flow) return APPROVAL_DESC[n.approvalStatus] || "已发布";
|
||||
const lv = n.flow[n.approvalLevel || 0];
|
||||
if (!lv) return "待审批";
|
||||
const done = lv.records.filter((r: any) => r.result === "approved").length;
|
||||
const tag = lv.vote === "all" ? `第${(n.approvalLevel || 0) + 1}/${n.flow.length}步 ${lv.name}·会签 ${done}/${lv.approvers.length}` : `第${(n.approvalLevel || 0) + 1}/${n.flow.length}步 ${lv.name}·或签`;
|
||||
return `待审批(${tag})`;
|
||||
}
|
||||
|
||||
/** 年级 → 班级树(叶子=班级,带学生数) */
|
||||
const gradeClasses = [
|
||||
{
|
||||
@@ -132,7 +75,7 @@ const ALL_STUDENTS = ALL_CLASSES.reduce((s, id) => s + classMap[id].studentCount
|
||||
|
||||
/**
|
||||
* 作业存储。classRead = 已读班级 id 列表(班级维度阅读统计)。
|
||||
* approvalEnabled/approvalStatus:见文件头说明;pending/rejected 不进入学生收件箱。
|
||||
* 无审批门控:仅草稿(hwStatus=draft)与定时未到点的作业不进入学生收件箱。
|
||||
*/
|
||||
const homeworks = [
|
||||
{
|
||||
@@ -152,11 +95,6 @@ const homeworks = [
|
||||
smsFlag: 0,
|
||||
timingSendTime: null,
|
||||
createTime: "2026-09-01 10:35:00",
|
||||
approvalEnabled: 0,
|
||||
approvalStatus: "none",
|
||||
approvalRemark: null,
|
||||
approvalBy: null,
|
||||
approvalTime: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
@@ -174,11 +112,6 @@ const homeworks = [
|
||||
smsFlag: 1,
|
||||
timingSendTime: null,
|
||||
createTime: "2026-09-03 14:22:00",
|
||||
approvalEnabled: 1,
|
||||
approvalStatus: "approved",
|
||||
approvalRemark: "同意发布",
|
||||
approvalBy: "周敏",
|
||||
approvalTime: "2026-09-03 16:00:00",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
@@ -196,33 +129,23 @@ const homeworks = [
|
||||
smsFlag: 0,
|
||||
timingSendTime: null,
|
||||
createTime: "2026-09-08 09:00:00",
|
||||
approvalEnabled: 1,
|
||||
approvalStatus: "pending",
|
||||
approvalRemark: null,
|
||||
approvalBy: null,
|
||||
approvalTime: null,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: "国庆假期数学作业(计算巩固 + 生活中的数学)(定时发布)",
|
||||
title: "国庆假期数学作业(计算巩固 + 生活中的数学)",
|
||||
deptId: "D002",
|
||||
deptName: "数学教研组",
|
||||
publisherAccount: "zhd",
|
||||
publisherName: "朱皓东",
|
||||
content: "<p>假期数学作业分两部分:①计算巩固——口算题卡 3 页;②实践探究——记录假期家庭一周开支并制成统计表。</p><p>定于 9 月 30 日 08:00 发布给全部班级,收假后第一节课查验。</p>",
|
||||
content: "<p>假期数学作业分两部分:①计算巩固——口算题卡 3 页;②实践探究——记录假期家庭一周开支并制成统计表。</p><p>已发布给全部班级,请于截止前提交,收假后第一节课查验。</p>",
|
||||
receiveList: "ALL",
|
||||
classRead: [],
|
||||
files: null,
|
||||
topmost: 0,
|
||||
isPulled: 0,
|
||||
smsFlag: 0,
|
||||
timingSendTime: "2026-09-30 08:00:00",
|
||||
timingSendTime: null,
|
||||
createTime: "2026-09-05 16:40:00",
|
||||
approvalEnabled: 0,
|
||||
approvalStatus: "none",
|
||||
approvalRemark: null,
|
||||
approvalBy: null,
|
||||
approvalTime: null,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
@@ -240,11 +163,6 @@ const homeworks = [
|
||||
smsFlag: 0,
|
||||
timingSendTime: null,
|
||||
createTime: "2026-09-06 11:05:00",
|
||||
approvalEnabled: 1,
|
||||
approvalStatus: "approved",
|
||||
approvalRemark: "内容合规,同意",
|
||||
approvalBy: "周敏",
|
||||
approvalTime: "2026-09-06 14:30:00",
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
@@ -262,11 +180,6 @@ const homeworks = [
|
||||
smsFlag: 0,
|
||||
timingSendTime: null,
|
||||
createTime: "2026-09-07 08:30:00",
|
||||
approvalEnabled: 0,
|
||||
approvalStatus: "none",
|
||||
approvalRemark: null,
|
||||
approvalBy: null,
|
||||
approvalTime: null,
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
@@ -284,11 +197,6 @@ const homeworks = [
|
||||
smsFlag: 0,
|
||||
timingSendTime: null,
|
||||
createTime: "2026-09-09 10:10:00",
|
||||
approvalEnabled: 1,
|
||||
approvalStatus: "pending",
|
||||
approvalRemark: null,
|
||||
approvalBy: null,
|
||||
approvalTime: null,
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
@@ -306,42 +214,10 @@ const homeworks = [
|
||||
smsFlag: 0,
|
||||
timingSendTime: null,
|
||||
createTime: "2026-09-08 15:20:00",
|
||||
approvalEnabled: 1,
|
||||
approvalStatus: "rejected",
|
||||
approvalRemark: "请补充作业评价标准与提交方式后再提交审核",
|
||||
approvalBy: "刘文娟",
|
||||
approvalTime: "2026-09-09 09:00:00",
|
||||
},
|
||||
];
|
||||
let homeworkSeq = 9;
|
||||
|
||||
/** 给需要审批的种子作业构建多步流程(演示会签/或签与不同进度) */
|
||||
function seedFlows() {
|
||||
const twoSteps = () => buildFlowFromInput({ flow: [{ name: "教研组初审", vote: "all", approvers: ["zhd", "lwj"] }, { name: "教务处复核", vote: "any", approvers: ["cy", "zm", "why"] }] })!;
|
||||
homeworks.forEach((n: any) => {
|
||||
if (!n.approvalEnabled) {
|
||||
n.flow = null;
|
||||
n.approvalLevel = 0;
|
||||
return;
|
||||
}
|
||||
const flow = twoSteps();
|
||||
n.flow = flow;
|
||||
n.approvalLevel = 0;
|
||||
if (n.approvalStatus === "approved") {
|
||||
flow.forEach((lv) => lv.approvers.forEach((a) => lv.records.push({ account: a, name: approverName(a), result: "approved", time: n.approvalTime || "2026-09-06 14:30:00", remark: "同意" })));
|
||||
n.approvalLevel = flow.length;
|
||||
} else if (n.approvalStatus === "rejected") {
|
||||
n.flow = buildFlowFromInput({ flow: [{ name: "教研组初审", vote: "any", approvers: ["zhd", "lwj"] }] })!;
|
||||
n.approvalLevel = 0;
|
||||
n.flow[0].records.push({ account: "lwj", name: approverName("lwj"), result: "rejected", time: n.approvalTime || "2026-09-09 09:00:00", remark: n.approvalRemark || "已驳回" });
|
||||
}
|
||||
});
|
||||
// 实验报告(id7):教研组初审会签已过 1/2(朱皓东已签),等待刘文娟
|
||||
const n7 = homeworks.find((n: any) => n.id === 7);
|
||||
if (n7?.flow) n7.flow[0].records.push({ account: "zhd", name: approverName("zhd"), result: "approved", time: "2026-09-09 15:00:00", remark: "内容属实" });
|
||||
}
|
||||
seedFlows();
|
||||
|
||||
/* ===================== 课程作业扩展模型(v1 核心闭环:布置→提交→批改→统计) ===================== */
|
||||
|
||||
/** 每份作业的“作业化”元数据(学科 / 截止 / 提交方式 / 流程状态),叠加到 homeworks 上 */
|
||||
@@ -386,11 +262,13 @@ const submissions: any[] = [];
|
||||
let subSeq = 1;
|
||||
homeworks.forEach((n: any) => {
|
||||
if (n.hwStatus === "draft") return;
|
||||
if ((n.submitType || []).includes("none")) return;
|
||||
const viewOnly = (n.submitType || []).includes("none");
|
||||
targetClassIds(n).forEach((cid) => {
|
||||
(roster[cid] || []).forEach((stu) => {
|
||||
const r = seededRand(n.id * 1000 + stu.studentId);
|
||||
const submitted = r < (n.hwStatus === "collecting" ? 0.62 : 0.9);
|
||||
const submitted = !viewOnly && r < (n.hwStatus === "collecting" ? 0.62 : 0.9);
|
||||
// 已读:交了的必然读过;未交的按更高概率“读了没交”。仅查阅只统计已读。
|
||||
const read = viewOnly ? r < 0.85 : submitted || r < 0.9;
|
||||
let submitStatus = "unsubmitted";
|
||||
let submittedAt: string | null = null;
|
||||
let grade: any = null;
|
||||
@@ -418,10 +296,11 @@ homeworks.forEach((n: any) => {
|
||||
studentName: stu.studentName,
|
||||
classId: cid,
|
||||
className: classMap[cid]?.className || cid,
|
||||
read,
|
||||
submittedAt,
|
||||
submitStatus,
|
||||
text: submitted ? "(学生作答内容 · mock)" : "",
|
||||
images: submitted && (n.submitType || []).includes("image") ? [] : [],
|
||||
images: submitted && (n.submitType || []).includes("image") ? [mockImageDataURL(stu.studentId)] : [],
|
||||
attachments: submitted && (n.submitType || []).includes("attach") ? [] : [],
|
||||
grade,
|
||||
});
|
||||
@@ -441,6 +320,7 @@ function progressOf(homeworkId: number) {
|
||||
const graded = list.filter((s) => GRADED_SET.includes(s.submitStatus)).length;
|
||||
const needRedo = list.filter((s) => s.submitStatus === "needRedo").length;
|
||||
const redone = list.filter((s) => s.submitStatus === "redone").length;
|
||||
const read = list.filter((s) => s.read).length;
|
||||
return {
|
||||
should,
|
||||
submitted,
|
||||
@@ -449,22 +329,24 @@ function progressOf(homeworkId: number) {
|
||||
needRedo,
|
||||
redone,
|
||||
unsubmitted: should - submitted,
|
||||
read,
|
||||
unread: should - read,
|
||||
submitRate: should ? Number(((submitted / should) * 100).toFixed(1)) : 0,
|
||||
gradeRate: submitted ? Number(((graded / submitted) * 100).toFixed(1)) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/* ============ 多角色视角(v2 学生 / 家长端;端分离:SetRole 入口切换) ============ */
|
||||
type Role = "teacher" | "student" | "parent";
|
||||
/* ============ 多角色视角(v2 学生端;家长即登录学生账号,端分离:SetRole 入口切换) ============ */
|
||||
type Role = "teacher" | "student";
|
||||
let viewerRole: Role = "teacher";
|
||||
/** 演示学生:取 ME_CLASS(初一(1)班)花名册第一名;家长为其监护人 */
|
||||
/** 演示学生:取 ME_CLASS(初一(1)班)花名册第一名 */
|
||||
const DEMO_STUDENT = (() => {
|
||||
const arr = roster[ME_CLASS] || [];
|
||||
const s = arr[0] || { studentId: 130101, studentName: "演示学生" };
|
||||
return { studentId: s.studentId, studentName: s.studentName, classId: ME_CLASS, className: classMap[ME_CLASS]?.className || "初一(1)班" };
|
||||
})();
|
||||
function setViewerRole(r: any): Role {
|
||||
viewerRole = r === "student" || r === "parent" ? (r as Role) : "teacher";
|
||||
viewerRole = r === "student" ? "student" : "teacher";
|
||||
return viewerRole;
|
||||
}
|
||||
/** 当前学生视角可见作业(已发布、面向本班、非未来定时、非草稿),附本人提交/批改态 */
|
||||
@@ -486,12 +368,16 @@ function viewerHomeworks() {
|
||||
content: n.content,
|
||||
submitType: n.submitType || ["text"],
|
||||
viewOnly: (n.submitType || []).includes("none"),
|
||||
hwStatus: n.hwStatus || "collecting",
|
||||
allowLate: !!n.allowLate,
|
||||
my: mine
|
||||
? {
|
||||
subId: mine.id,
|
||||
submitStatus: mine.submitStatus,
|
||||
submittedAt: mine.submittedAt,
|
||||
text: mine.text,
|
||||
images: mine.images || [],
|
||||
attachments: mine.attachments || [],
|
||||
score: mine.grade ? mine.grade.score : null,
|
||||
level: mine.grade ? mine.grade.level : null,
|
||||
comment: mine.grade ? mine.grade.comment : "",
|
||||
@@ -522,33 +408,6 @@ const filesStore: Record<number, { id: number; filename: string; fileTrueName: s
|
||||
9003: { id: 9003, filename: "初三英语阅读专项-答题卡.pdf", fileTrueName: "初三英语阅读专项-答题卡.pdf", size: 819200 },
|
||||
};
|
||||
|
||||
/** 自定义联系组(教师协作组,成员为 userId) */
|
||||
const groups = [
|
||||
{ serid: 1, GroupName: "语文教研组", members: [1, 2, 3] },
|
||||
{ serid: 2, GroupName: "信息中心", members: [10, 11] },
|
||||
{ serid: 3, GroupName: "初一年级组", members: [4, 6, 8] },
|
||||
];
|
||||
let groupSeq = 4;
|
||||
|
||||
const idNames: Record<number, string> = {
|
||||
1: "朱皓东", 2: "刘文娟", 3: "陈垠", 4: "王海燕", 5: "李强",
|
||||
6: "何明明", 7: "周敏", 8: "孙丽娟", 9: "吴建国", 10: "赵晓琳", 11: "黄瑶",
|
||||
};
|
||||
|
||||
const teacherNames: Record<string, string> = {
|
||||
zhd: "朱皓东", lwj: "刘文娟", lwm: "刘文娟", cy: "陈垠", hy: "黄瑶",
|
||||
hj: "何明明", why: "王海燕", lq: "李强", zm: "周敏", slj: "孙丽娟", wjg: "吴建国", zxl: "赵晓琳",
|
||||
};
|
||||
|
||||
/** 教师组织树(应用设置-自定义组选人在用;发布对象已改为班级树) */
|
||||
const schoolTree = [
|
||||
{ id: 301, name: "校长室", isParent: true, children: [ { id: 1, name: "朱皓东", isParent: false }, { id: 2, name: "刘文娟", isParent: false } ] },
|
||||
{ id: 302, name: "教务处", isParent: true, children: [ { id: 3, name: "陈垠", isParent: false }, { id: 6, name: "何明明", isParent: false }, { id: 7, name: "周敏", isParent: false } ] },
|
||||
{ id: 303, name: "学生处", isParent: true, children: [ { id: 4, name: "王海燕", isParent: false }, { id: 8, name: "孙丽娟", isParent: false }, { id: 9, name: "吴建国", isParent: false } ] },
|
||||
{ id: 304, name: "总务处", isParent: true, children: [ { id: 5, name: "李强", isParent: false } ] },
|
||||
{ id: 305, name: "信息中心", isParent: true, children: [ { id: 10, name: "赵晓琳", isParent: false }, { id: 11, name: "黄瑶", isParent: false } ] },
|
||||
];
|
||||
|
||||
/** 年级班级树(ztree 结构,供选班级弹窗使用;叶子带 studentCount) */
|
||||
const classTree = gradeClasses.map((g) => ({
|
||||
id: g.gradeId,
|
||||
@@ -615,16 +474,15 @@ function readCountOf(n: any): number {
|
||||
function isReadByMe(n: any): boolean {
|
||||
return (n.classRead || []).includes(ME_CLASS);
|
||||
}
|
||||
/** 是否已发布(对学生可见):审批通过或免审 */
|
||||
/** 是否已发布(对学生可见):非草稿即可见(课程作业无送审环节) */
|
||||
function isPublished(n: any): boolean {
|
||||
return n.approvalStatus === "none" || n.approvalStatus === "approved";
|
||||
return n.hwStatus !== "draft";
|
||||
}
|
||||
/** 定时未到的作业,收件箱不展示 */
|
||||
function isScheduledFuture(n: any): boolean {
|
||||
return !!(n.timingSendTime && n.timingSendTime > "2026-09-10");
|
||||
}
|
||||
|
||||
const APPROVAL_DESC: Record<string, string> = { none: "已发布", pending: "待审批", approved: "已发布", rejected: "已驳回" };
|
||||
const HW_STATUS_DESC: Record<string, string> = { draft: "草稿", collecting: "收集中", closed: "已截止", done: "已完成" };
|
||||
|
||||
function toVo(n: any) {
|
||||
@@ -639,7 +497,7 @@ function toVo(n: any) {
|
||||
submitType: n.submitType || ["text"],
|
||||
hwStatus: n.hwStatus || "collecting",
|
||||
hwStatusDesc: HW_STATUS_DESC[n.hwStatus] || "收集中",
|
||||
progress: n.hwStatus === "draft" || (n.submitType || []).includes("none") ? null : progressOf(n.id),
|
||||
progress: n.hwStatus === "draft" ? null : progressOf(n.id),
|
||||
receiveText: receiveText(n),
|
||||
classCount: isAllTarget(n) ? ALL_CLASSES.length : targetClassIds(n).length,
|
||||
publisherAccount: n.publisherAccount,
|
||||
@@ -658,14 +516,6 @@ function toVo(n: any) {
|
||||
smsFlag: n.smsFlag,
|
||||
receiveType: "class",
|
||||
receiveList: n.receiveList,
|
||||
approvalEnabled: n.approvalEnabled,
|
||||
approvalStatus: n.approvalStatus,
|
||||
approvalStatusDesc: flowProgressText(n),
|
||||
approvalRemark: n.approvalRemark,
|
||||
approvalBy: n.approvalBy,
|
||||
approvalTime: n.approvalTime,
|
||||
approvalLevel: n.approvalLevel || 0,
|
||||
canApprove: canAct(n),
|
||||
createBy: n.publisherAccount,
|
||||
createTime: n.createTime,
|
||||
updateBy: n.publisherAccount,
|
||||
@@ -701,37 +551,9 @@ function toDetail(n: any) {
|
||||
submitType: n.submitType || ["text"],
|
||||
hwStatus: n.hwStatus || "collecting",
|
||||
hwStatusDesc: HW_STATUS_DESC[n.hwStatus] || "收集中",
|
||||
progress: n.hwStatus === "draft" || (n.submitType || []).includes("none") ? null : progressOf(n.id),
|
||||
progress: n.hwStatus === "draft" ? null : progressOf(n.id),
|
||||
DsfsTime: n.timingSendTime,
|
||||
isDsfs: !!n.timingSendTime,
|
||||
approvalEnabled: n.approvalEnabled,
|
||||
approvalStatus: n.approvalStatus,
|
||||
approvalStatusDesc: flowProgressText(n),
|
||||
approvalRemark: n.approvalRemark,
|
||||
approvalBy: n.approvalBy,
|
||||
approvalTime: n.approvalTime,
|
||||
approvalLevel: n.approvalLevel || 0,
|
||||
canApprove: canAct(n),
|
||||
// 审批流进度(供详情时间线 & 发布/编辑回显)
|
||||
approvalFlow: (n.flow || []).map((lv: any, idx: number) => {
|
||||
const acted = new Set(lv.records.map((r: any) => r.account));
|
||||
return {
|
||||
name: lv.name,
|
||||
vote: lv.vote,
|
||||
approvers: [...lv.approvers],
|
||||
approverNames: lv.approvers.map(approverName),
|
||||
status:
|
||||
n.approvalStatus === "approved" || idx < (n.approvalLevel || 0)
|
||||
? "approved"
|
||||
: n.approvalStatus === "rejected" && idx === (n.approvalLevel || 0)
|
||||
? "rejected"
|
||||
: idx === (n.approvalLevel || 0)
|
||||
? "pending"
|
||||
: "waiting",
|
||||
records: lv.records.map((r: any) => ({ ...r })),
|
||||
pending: idx === (n.approvalLevel || 0) && n.approvalStatus === "pending" ? lv.approvers.filter((a: string) => !acted.has(a)).map(approverName) : [],
|
||||
};
|
||||
}),
|
||||
ChaoSend: (n.files ? String(n.files).split(",") : []).map((id: string) => {
|
||||
const f = filesStore[Number(id)];
|
||||
return f
|
||||
@@ -780,8 +602,8 @@ export default [
|
||||
: ok({
|
||||
id: DEMO_STUDENT.studentId,
|
||||
account: "stu" + DEMO_STUDENT.studentId,
|
||||
name: viewerRole === "parent" ? DEMO_STUDENT.studentName + "家长" : DEMO_STUDENT.studentName,
|
||||
role: viewerRole,
|
||||
name: DEMO_STUDENT.studentName,
|
||||
role: "student",
|
||||
studentId: DEMO_STUDENT.studentId,
|
||||
studentName: DEMO_STUDENT.studentName,
|
||||
classId: DEMO_STUDENT.classId,
|
||||
@@ -800,14 +622,10 @@ export default [
|
||||
{ id: 31, modId: 31, name: "今日作业", parentId: 0, page: "/stu/today", orderNumber: 1, usable: true, children: [] },
|
||||
{ id: 32, modId: 32, name: "待订正", parentId: 0, page: "/stu/redo", orderNumber: 2, usable: true, children: [] },
|
||||
]
|
||||
: viewerRole === "parent"
|
||||
? [{ id: 41, modId: 41, name: "孩子作业", parentId: 0, page: "/par/kids", orderNumber: 1, usable: true, children: [] }]
|
||||
: [
|
||||
{ id: 2, modId: 2, name: "我布置的", parentId: 0, page: "/list", orderNumber: 1, usable: true, children: [] },
|
||||
{ id: 25, modId: 25, name: "提交情况", parentId: 0, page: "/submissions", orderNumber: 2, usable: true, children: [] },
|
||||
{ id: 26, modId: 26, name: "批改中心", parentId: 0, page: "/grade", orderNumber: 3, usable: true, children: [] },
|
||||
{ id: 4, modId: 4, name: "作业统计", parentId: 0, page: "/stats", orderNumber: 4, usable: true, children: [] },
|
||||
{ id: 21, modId: 21, name: "应用设置", parentId: 0, page: "/setting", orderNumber: 5, usable: true, children: [] },
|
||||
{ id: 26, modId: 26, name: "批改中心", parentId: 0, page: "/grade", orderNumber: 2, usable: true, children: [] },
|
||||
{ id: 4, modId: 4, name: "作业统计", parentId: 0, page: "/stats", orderNumber: 3, usable: true, children: [] },
|
||||
]
|
||||
),
|
||||
},
|
||||
@@ -817,7 +635,7 @@ export default [
|
||||
response: () =>
|
||||
ok({
|
||||
ssoUrl: "",
|
||||
username: viewerRole === "teacher" ? ME.name : viewerRole === "parent" ? DEMO_STUDENT.studentName + "家长" : DEMO_STUDENT.studentName,
|
||||
username: viewerRole === "teacher" ? ME.name : DEMO_STUDENT.studentName,
|
||||
pathList: [],
|
||||
topName: "课程作业",
|
||||
pageUrl: "",
|
||||
@@ -833,7 +651,6 @@ export default [
|
||||
method: "post",
|
||||
response: ({ query, body }: any) => ok({ role: setViewerRole((bodyOf(body, query) || {}).as || query.as) }),
|
||||
},
|
||||
{ url: "/api/base/GetSchoolAll", method: "get", response: () => ok(schoolTree) },
|
||||
{
|
||||
// 年级 → 班级树(发布对象选择:到班级一级)
|
||||
url: "/api/base/GetClassTree",
|
||||
@@ -841,57 +658,6 @@ export default [
|
||||
response: () => ok(classTree),
|
||||
},
|
||||
|
||||
/* ---------- 发文部门字典 ---------- */
|
||||
{
|
||||
url: "/api/dict/List",
|
||||
method: "get",
|
||||
response: ({ query }: any) => ok(deptDict.filter((d) => d.Enable === 1 || query.type !== "1").map((d) => ({ k: d.CodeID, v: d.Name }))),
|
||||
},
|
||||
{ url: "/api/dict/Sync", method: "get", response: () => ok(true) },
|
||||
{ url: "/api/dict/ManageList", method: "get", response: () => ok(deptDict.map((d) => ({ ...d }))) },
|
||||
{
|
||||
url: "/api/dict/Add",
|
||||
method: "post",
|
||||
response: ({ body, query }: any) => {
|
||||
const name = bodyOf(body, query).name || "未命名部门";
|
||||
deptDict.push({ SerID: deptSeq, CodeID: "D" + String(deptSeq).padStart(3, "0"), Name: name, Enable: 1 });
|
||||
deptSeq++;
|
||||
return ok(null);
|
||||
},
|
||||
},
|
||||
{
|
||||
url: "/api/dict/Update",
|
||||
method: "post",
|
||||
response: ({ body, query }: any) => {
|
||||
const b = bodyOf(body, query);
|
||||
const item = deptDict.find((d) => d.SerID === Number(b.SerID));
|
||||
if (item) {
|
||||
if (b.Name) item.Name = b.Name;
|
||||
if (b.Enable !== undefined && b.Enable !== null && b.Enable !== "") item.Enable = Number(b.Enable);
|
||||
}
|
||||
return ok(null);
|
||||
},
|
||||
},
|
||||
{
|
||||
url: "/api/dict/Delete",
|
||||
method: "post",
|
||||
response: ({ body, query }: any) => {
|
||||
const i = deptDict.findIndex((d) => d.SerID === Number(bodyOf(body, query).serid));
|
||||
if (i > -1) deptDict.splice(i, 1);
|
||||
return ok(null);
|
||||
},
|
||||
},
|
||||
{
|
||||
url: "/api/dict/Sort",
|
||||
method: "post",
|
||||
response: ({ body, query }: any) => {
|
||||
const ids = String(bodyOf(body, query).ids || "").split(",").map(Number).filter(Boolean);
|
||||
const sorted = ids.map((id) => deptDict.find((d) => d.SerID === id)).filter(Boolean) as any[];
|
||||
if (sorted.length === deptDict.length) deptDict.splice(0, deptDict.length, ...sorted);
|
||||
return ok(null);
|
||||
},
|
||||
},
|
||||
|
||||
/* ---------- 作业列表 ---------- */
|
||||
{
|
||||
// 已收到(学生/收件视角):仅已发布、非未来定时、面向本班
|
||||
@@ -982,24 +748,6 @@ export default [
|
||||
response: ({ body, query }: any) => {
|
||||
const b = bodyOf(body, query);
|
||||
const dept = deptDict.find((d) => d.CodeID === b.deptId);
|
||||
// 选人即送审:解析发布入参里的审批流(flow 多步 / approvers 单步),有审批人则进入待审批
|
||||
let flowInput: any = b;
|
||||
if (typeof b.flow === "string") {
|
||||
try {
|
||||
flowInput = { ...flowInput, flow: JSON.parse(b.flow) };
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
if (typeof b.approvers === "string") {
|
||||
try {
|
||||
flowInput = { ...flowInput, approvers: JSON.parse(b.approvers) };
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
const flow = buildFlowFromInput(flowInput);
|
||||
const approvalEnabled = flow ? 1 : 0;
|
||||
const receive = b.receiveList || "all";
|
||||
const isAll = /^all$/i.test(String(receive).trim());
|
||||
const item: any = {
|
||||
@@ -1021,15 +769,9 @@ export default [
|
||||
subject: b.subject || "综合",
|
||||
deadline: b.deadline ? String(b.deadline).replace(/\//g, "-") : null,
|
||||
submitType: Array.isArray(b.submitType) ? b.submitType : String(b.submitType || "text").split(/[,,\s]+/).filter(Boolean),
|
||||
hwStatus: "collecting",
|
||||
approvalEnabled,
|
||||
// 未选审批人 → 直接发布(none);选了 → 待审批(pending)
|
||||
approvalStatus: approvalEnabled ? "pending" : "none",
|
||||
approvalRemark: null,
|
||||
approvalBy: null,
|
||||
approvalTime: null,
|
||||
flow,
|
||||
approvalLevel: 0,
|
||||
allowLate: !!b.allowLate,
|
||||
remindBefore: Array.isArray(b.remindBefore) ? b.remindBefore : String(b.remindBefore || "").split(/[,,\s]+/).filter(Boolean),
|
||||
hwStatus: b.draft ? "draft" : "collecting",
|
||||
};
|
||||
homeworks.unshift(item);
|
||||
return ok(item.id);
|
||||
@@ -1056,41 +798,6 @@ export default [
|
||||
if (b.fileIds !== undefined) n.files = b.fileIds || null;
|
||||
if (b.smsFlag !== undefined) n.smsFlag = Number(b.smsFlag) || 0;
|
||||
if (b.timingSendTime !== undefined) n.timingSendTime = b.timingSendTime ? String(b.timingSendTime).replace(/\//g, "-") : null;
|
||||
// 编辑可重新配置审批流:传了 flow/approvers 就按“选人即送审”重建
|
||||
let flowInput: any = b;
|
||||
if (typeof b.flow === "string") {
|
||||
try {
|
||||
flowInput = { ...flowInput, flow: JSON.parse(b.flow) };
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
if (typeof b.approvers === "string") {
|
||||
try {
|
||||
flowInput = { ...flowInput, approvers: JSON.parse(b.approvers) };
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
const flowChanged = b.flow !== undefined || b.approvers !== undefined;
|
||||
if (flowChanged) {
|
||||
const nf = buildFlowFromInput(flowInput);
|
||||
n.flow = nf;
|
||||
n.approvalEnabled = nf ? 1 : 0;
|
||||
n.approvalLevel = 0;
|
||||
n.approvalStatus = nf ? "pending" : "none";
|
||||
n.approvalRemark = null;
|
||||
n.approvalBy = null;
|
||||
n.approvalTime = null;
|
||||
} else if (n.approvalEnabled && n.approvalStatus === "rejected") {
|
||||
// 未改动审批流,仅内容修改后重新送审:驳回→待审批(保留原流程,回到第 0 步)
|
||||
n.approvalStatus = "pending";
|
||||
n.approvalLevel = 0;
|
||||
(n.flow || []).forEach((lv: any) => (lv.records = []));
|
||||
n.approvalRemark = null;
|
||||
n.approvalBy = null;
|
||||
n.approvalTime = null;
|
||||
}
|
||||
return ok(null);
|
||||
},
|
||||
},
|
||||
@@ -1285,7 +992,7 @@ export default [
|
||||
},
|
||||
},
|
||||
|
||||
/* ---------- v2 学生 / 家长:我的作业 / 提交 / 反馈 ---------- */
|
||||
/* ---------- v2 学生端:我的作业 / 提交 / 反馈 ---------- */
|
||||
{
|
||||
url: "/api/homework/MyHomeworks",
|
||||
method: "get",
|
||||
@@ -1327,9 +1034,8 @@ export default [
|
||||
const b = bodyOf(body, query);
|
||||
const hw = homeworks.find((h) => h.id === Number(b.serid));
|
||||
if (!hw) return fail("作业不存在");
|
||||
if (viewerRole === "parent") return fail("家长端为只读,不能提交");
|
||||
if ((hw.submitType || []).includes("none")) return fail("该作业仅要求查阅,无需提交");
|
||||
if (hw.hwStatus === "closed") return fail("该作业已过截止时间,如需补交请联系老师");
|
||||
if (hw.hwStatus === "closed" && !hw.allowLate) return fail("该作业已过截止时间且未开放补交,请联系老师");
|
||||
const parseArr = (v: any) => (Array.isArray(v) ? v : String(v || "").split(",").filter(Boolean));
|
||||
let s = submissions.find((x) => x.homeworkId === hw.id && x.studentId === DEMO_STUDENT.studentId);
|
||||
if (!s) {
|
||||
@@ -1358,147 +1064,8 @@ export default [
|
||||
return ok({ subId: s.id, submitStatus: s.submitStatus });
|
||||
},
|
||||
},
|
||||
{
|
||||
// 家长“提醒孩子”(mock 只记录)
|
||||
url: "/api/homework/ParentNudge",
|
||||
method: "post",
|
||||
response: ({ body, query }: any) => {
|
||||
const b = bodyOf(body, query);
|
||||
const hw = homeworks.find((h) => h.id === Number(b.serid));
|
||||
if (!hw) return fail("作业不存在");
|
||||
return ok({ nudged: true, studentName: DEMO_STUDENT.studentName, title: hw.title });
|
||||
},
|
||||
},
|
||||
|
||||
/* ---------- 审批(多级:会签/或签) ---------- */
|
||||
{
|
||||
url: "/api/homework/ApprovalList",
|
||||
method: "get",
|
||||
response: ({ query }: any) => {
|
||||
const pageIndex = Number(query.pageIndex || 1);
|
||||
const pageSize = Number(query.pageSize || 10);
|
||||
const status = query.status || "pending";
|
||||
let list = homeworks.filter((n) => n.approvalEnabled === 1);
|
||||
if (status && status !== "all") list = list.filter((n) => n.approvalStatus === status);
|
||||
if (query.scope === "user") list = list.filter((n) => n.publisherAccount === ME.account);
|
||||
list = applyFilters(list, query);
|
||||
list = sortByTimeDesc(list);
|
||||
return ok(paginate(list.map(toVo), pageIndex, pageSize));
|
||||
},
|
||||
},
|
||||
{
|
||||
url: "/api/homework/ApprovalCount",
|
||||
method: "get",
|
||||
// 待我审批:当前步待签人包含我的单子数
|
||||
response: () => ok(homeworks.filter((n) => n.approvalEnabled === 1 && canAct(n)).length),
|
||||
},
|
||||
{
|
||||
url: "/api/homework/Approve",
|
||||
method: "post",
|
||||
response: ({ body, query }: any) => {
|
||||
const b = bodyOf(body, query);
|
||||
const n: any = homeworks.find((x) => x.id === Number(b.serid));
|
||||
if (!n) return fail("作业不存在");
|
||||
if (n.approvalStatus !== "pending") return fail("该作业已处理,无需重复审批");
|
||||
if (!n.flow) return fail("该作业未配置审批流程");
|
||||
const actor = b.account || ME.account;
|
||||
const lv = n.flow[n.approvalLevel || 0];
|
||||
if (!lv) {
|
||||
n.approvalStatus = "approved";
|
||||
return ok({ status: "approved" });
|
||||
}
|
||||
if (!lv.approvers.includes(actor)) return fail("您不是当前步骤的审批人,无权处理");
|
||||
if (lv.records.some((r: any) => r.account === actor)) return fail("您已处理过本步骤,请等待其他审批人");
|
||||
lv.records.push({ account: actor, name: approverName(actor), result: "approved", time: nowStr(), remark: b.remark || "同意" });
|
||||
const remaining = pendingApprovers(n);
|
||||
const stepPass = lv.vote === "any" ? true : remaining.length === 0; // 或签=任一通过;会签=全部通过
|
||||
if (stepPass) {
|
||||
n.approvalLevel = (n.approvalLevel || 0) + 1;
|
||||
if (n.approvalLevel >= n.flow.length) {
|
||||
n.approvalStatus = "approved";
|
||||
n.approvalBy = approverName(actor);
|
||||
n.approvalTime = nowStr();
|
||||
n.approvalRemark = b.remark || "同意发布";
|
||||
}
|
||||
}
|
||||
return ok({ status: n.approvalStatus, level: n.approvalLevel, nextPending: n.approvalStatus === "pending" ? pendingApprovers(n) : [] });
|
||||
},
|
||||
},
|
||||
{
|
||||
url: "/api/homework/Reject",
|
||||
method: "post",
|
||||
response: ({ body, query }: any) => {
|
||||
const b = bodyOf(body, query);
|
||||
const n: any = homeworks.find((x) => x.id === Number(b.serid));
|
||||
if (!n) return fail("作业不存在");
|
||||
if (n.approvalStatus !== "pending") return fail("该作业已处理,无需重复审批");
|
||||
const actor = b.account || ME.account;
|
||||
const lv = (n.flow || [])[n.approvalLevel || 0];
|
||||
if (lv && !lv.approvers.includes(actor)) return fail("您不是当前步骤的审批人,无权处理");
|
||||
if (lv) lv.records.push({ account: actor, name: approverName(actor), result: "rejected", time: nowStr(), remark: b.remark || "不同意" });
|
||||
n.approvalStatus = "rejected";
|
||||
n.approvalBy = approverName(actor);
|
||||
n.approvalTime = nowStr();
|
||||
n.approvalRemark = b.remark || "已驳回";
|
||||
return ok(null);
|
||||
},
|
||||
},
|
||||
|
||||
/* ---------- 审批人维护(发布时从这里选审批人) ---------- */
|
||||
{
|
||||
// 审批人候选池(供发布界面选择审批人,仅启用者;id 供选人组件,account 为真实审批人标识)
|
||||
url: "/api/dict/ApproverPool",
|
||||
method: "get",
|
||||
response: () =>
|
||||
ok(
|
||||
approvers
|
||||
.filter((a) => a.enabled === 1)
|
||||
.map((a) => ({ id: a.id, name: a.name, account: a.account, deptName: a.deptName, isParent: false }))
|
||||
),
|
||||
},
|
||||
{ url: "/api/dict/Approvers", method: "get", response: () => ok(approvers.map((a) => ({ ...a }))) },
|
||||
{
|
||||
url: "/api/dict/AddApprover",
|
||||
method: "post",
|
||||
response: ({ body, query }: any) => {
|
||||
const b = bodyOf(body, query);
|
||||
approvers.push({
|
||||
id: approverSeq++,
|
||||
account: b.account || "",
|
||||
name: b.name || "",
|
||||
deptId: b.deptId || "",
|
||||
deptName: b.deptName || deptDict.find((d) => d.CodeID === b.deptId)?.Name || "",
|
||||
enabled: 1,
|
||||
});
|
||||
return ok(null);
|
||||
},
|
||||
},
|
||||
{
|
||||
url: "/api/dict/UpdateApprover",
|
||||
method: "post",
|
||||
response: ({ body, query }: any) => {
|
||||
const b = bodyOf(body, query);
|
||||
const a = approvers.find((x) => x.id === Number(b.id));
|
||||
if (a) {
|
||||
if (b.name) a.name = b.name;
|
||||
if (b.deptId) {
|
||||
a.deptId = b.deptId;
|
||||
a.deptName = deptDict.find((d) => d.CodeID === b.deptId)?.Name || a.deptName;
|
||||
}
|
||||
if (b.enabled !== undefined && b.enabled !== "") a.enabled = Number(b.enabled);
|
||||
}
|
||||
return ok(null);
|
||||
},
|
||||
},
|
||||
{
|
||||
url: "/api/dict/DelApprover",
|
||||
method: "post",
|
||||
response: ({ body, query }: any) => {
|
||||
const i = approvers.findIndex((x) => x.id === Number(bodyOf(body, query).id));
|
||||
if (i > -1) approvers.splice(i, 1);
|
||||
return ok(null);
|
||||
},
|
||||
},
|
||||
/* ---------- 审批功能已移除(课程作业不做审批) ---------- */
|
||||
|
||||
/* ---------- 附件上传 ---------- */
|
||||
{
|
||||
@@ -1516,96 +1083,16 @@ export default [
|
||||
response: () => {
|
||||
const id = nextFileId++;
|
||||
filesStore[id] = { id, filename: `mock图片${id}.svg`, fileTrueName: `mock图片${id}.png`, size: 2048 };
|
||||
return ok([{ id, fileid: id, fileTrueName: filesStore[id].fileTrueName, fileName: filesStore[id].filename, fileSize: 2048, fileType: "png" }]);
|
||||
return ok([{ id, fileid: id, fileTrueName: filesStore[id].fileTrueName, fileName: filesStore[id].filename, fileSize: 2048, fileType: "png", url: mockImageDataURL(id) }]);
|
||||
},
|
||||
},
|
||||
{ url: "/api/file/Preview", method: "get", response: () => ok({ id: 0, fileurl: mockImageDataURL() }) },
|
||||
|
||||
/* ---------- 联系组(应用设置:自定义组) ---------- */
|
||||
{
|
||||
url: "/api/contacts/GetGroup",
|
||||
method: "get",
|
||||
response: ({ query }: any) => {
|
||||
const pageIndex = Number(query.page || 1);
|
||||
const pageSize = Number(query.count || 10);
|
||||
const mapped = groups.map((g) => ({ serid: g.serid, GroupName: g.GroupName, count: g.members.length }));
|
||||
const start = (pageIndex - 1) * pageSize;
|
||||
return ok({ list: mapped.slice(start, start + pageSize), total: mapped.length });
|
||||
},
|
||||
},
|
||||
{
|
||||
url: "/api/contacts/AddGroup",
|
||||
method: "post",
|
||||
response: ({ body, query }: any) => {
|
||||
const name = bodyOf(body, query).name || "未命名组";
|
||||
groups.push({ serid: groupSeq++, GroupName: name, members: [] });
|
||||
return ok(null);
|
||||
},
|
||||
},
|
||||
{
|
||||
url: "/api/contacts/EditGroup",
|
||||
method: "post",
|
||||
response: ({ body, query }: any) => {
|
||||
const b = bodyOf(body, query);
|
||||
const g = groups.find((x) => x.serid === Number(b.id));
|
||||
if (g) g.GroupName = b.name || g.GroupName;
|
||||
return ok(null);
|
||||
},
|
||||
},
|
||||
{
|
||||
url: "/api/contacts/DelGroup",
|
||||
method: "post",
|
||||
response: ({ body, query }: any) => {
|
||||
const parsed = bodyOf(body, query);
|
||||
const ids = String(parsed.ids ?? "").split(",").map(Number).filter(Boolean);
|
||||
for (const id of ids) {
|
||||
const i = groups.findIndex((g) => g.serid === id);
|
||||
if (i > -1) groups.splice(i, 1);
|
||||
}
|
||||
return ok(null);
|
||||
},
|
||||
},
|
||||
{
|
||||
url: "/api/contacts/GetContactsTea",
|
||||
method: "get",
|
||||
response: ({ query }: any) => {
|
||||
const g = groups.find((x) => x.serid === Number(query.gid));
|
||||
const mapped = (g ? g.members : []).map((id) => ({ account: "user" + id, userId: id, id, name: idNames[id] || String(id) }));
|
||||
const pageIndex = Number(query.page || 0);
|
||||
const pageSize = Number(query.count || 0);
|
||||
if (!pageIndex || !pageSize) return ok({ list: mapped, total: mapped.length });
|
||||
const start = (pageIndex - 1) * pageSize;
|
||||
return ok({ list: mapped.slice(start, start + pageSize), total: mapped.length });
|
||||
},
|
||||
},
|
||||
{
|
||||
url: "/api/contacts/SetContactsTea",
|
||||
method: "post",
|
||||
response: ({ body, query }: any) => {
|
||||
const b = bodyOf(body, query);
|
||||
const g = groups.find((x) => x.serid === Number(b.gid));
|
||||
if (g) g.members = String(b.ids || "").split(",").map(Number).filter(Boolean);
|
||||
return ok(null);
|
||||
},
|
||||
},
|
||||
{
|
||||
url: "/api/contacts/GetGroupTree",
|
||||
method: "get",
|
||||
response: () =>
|
||||
ok(
|
||||
groups.map((g) => ({
|
||||
id: 1000 + g.serid,
|
||||
name: g.GroupName,
|
||||
isParent: true,
|
||||
children: g.members.map((id) => ({ id, name: idNames[id] || String(id), isParent: false })),
|
||||
}))
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
/** 内联 SVG data URL,作为 mock 预览图片 */
|
||||
function mockImageDataURL() {
|
||||
/** 内联 SVG data URL,作为 mock 预览图片(可带编号区分) */
|
||||
function mockImageDataURL(id?: number) {
|
||||
const label = id ? `课程作业 图片#${id}` : "课程作业 Mock 图片";
|
||||
const svg =
|
||||
"<svg xmlns='http://www.w3.org/2000/svg' width='240' height='120'><rect width='100%' height='100%' fill='#e8f1ff'/><text x='50%' y='50%' font-size='16' fill='#5c9edc' text-anchor='middle' dominant-baseline='middle'>课程作业 Mock 图片</text></svg>";
|
||||
`<svg xmlns='http://www.w3.org/2000/svg' width='240' height='180'><rect width='100%' height='100%' fill='#e8f1ff'/><rect x='8' y='8' width='224' height='164' fill='none' stroke='#5c9edc' stroke-width='2' stroke-dasharray='6 4'/><text x='50%' y='50%' font-size='16' fill='#5c9edc' text-anchor='middle' dominant-baseline='middle'>${label}</text></svg>`;
|
||||
return "data:image/svg+xml;base64," + btoa(unescape(encodeURIComponent(svg)));
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
+2
-31
@@ -10,7 +10,7 @@ import type { RouteRecordRaw } from "vue-router";
|
||||
* type: 0=编辑视角(不标读) 1=普通查看(标读)
|
||||
*
|
||||
* modId 是菜单 ↔ 路由的稳定契约(对照后端 GetUserMod 现行数据):
|
||||
* 1=已收到 2=已发送 3=全部作业 4=作业统计 21=应用设置
|
||||
* 1=已收到 2=已发送 3=全部作业 4=作业统计
|
||||
* 发作业为「已发送」的深层子页(modId 22 不在菜单树,meta.parentId=2 关联高亮),
|
||||
* 与 UserBase「应用管理>应用类型管理」范式一致。
|
||||
*/
|
||||
@@ -74,20 +74,6 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import("@/views/homework/HomeworkDetails.vue"),
|
||||
meta: { title: "作业详情", modId: 24, parentId: 3 },
|
||||
},
|
||||
// 审批列表(可选流程:需审批后发布的作业在此审批)
|
||||
{
|
||||
path: "/approval",
|
||||
name: "homework_approval",
|
||||
component: () => import("@/views/homework/ApprovalList.vue"),
|
||||
meta: { title: "审批列表", modId: 5 },
|
||||
},
|
||||
// 审核设置(审批策略 / 部门覆盖 / 审批人)
|
||||
{
|
||||
path: "/approval-setting",
|
||||
name: "homework_approval_setting",
|
||||
component: () => import("@/views/homework/ApprovalSetting.vue"),
|
||||
meta: { title: "审核设置", modId: 6 },
|
||||
},
|
||||
// 作业统计(旧版行为:整页跳转外部统计系统)
|
||||
{
|
||||
path: "/stats",
|
||||
@@ -95,15 +81,8 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import("@/views/homework/HomeworkStats.vue"),
|
||||
meta: { title: "作业统计", modId: 4 },
|
||||
},
|
||||
// 应用设置(发文部门 + 自定义组)
|
||||
{
|
||||
path: "/setting",
|
||||
name: "homework_setting",
|
||||
component: () => import("@/views/homework/HomeworkSetting.vue"),
|
||||
meta: { title: "应用设置", modId: 21 },
|
||||
},
|
||||
|
||||
/* ---- v2 端分离:学生 / 家长 ---- */
|
||||
/* ---- v2 学生端 ---- */
|
||||
// 学生·今日作业
|
||||
{
|
||||
path: "/stu/today",
|
||||
@@ -118,13 +97,6 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import("@/views/homework/StudentHomeworks.vue"),
|
||||
meta: { title: "待订正", modId: 32, role: "student", initTab: "redo" },
|
||||
},
|
||||
// 家长·孩子作业(只读)
|
||||
{
|
||||
path: "/par/kids",
|
||||
name: "parent_kids",
|
||||
component: () => import("@/views/homework/StudentHomeworks.vue"),
|
||||
meta: { title: "孩子作业", modId: 41, role: "parent" },
|
||||
},
|
||||
|
||||
/* ---- 旧版路径兼容重定向(书签、残留的平台模块配置) ---- */
|
||||
{ path: "/details", redirect: "/list/detail" },
|
||||
@@ -139,7 +111,6 @@ const routes: RouteRecordRaw[] = [
|
||||
{ path: "/homework/manage", redirect: "/homeworklist" },
|
||||
{ path: "/homework/homework_details", redirect: "/list/detail" },
|
||||
{ path: "/homework/statistics", redirect: "/stats" },
|
||||
{ path: "/homework/setting", redirect: "/setting" },
|
||||
];
|
||||
|
||||
export default routes;
|
||||
|
||||
+14
-130
@@ -78,30 +78,12 @@ export interface HomeworkVo {
|
||||
hwStatusDesc?: string;
|
||||
/** 提交/批改进度(草稿或仅查阅为 null) */
|
||||
progress?: HomeworkProgress | null;
|
||||
/** 是否启用审批:0 免审直接发布 / 1 需审批后发布 */
|
||||
approvalEnabled: number;
|
||||
/** 审批状态:none 免审 / pending 待审批 / approved 已通过 / rejected 已驳回 */
|
||||
approvalStatus: ApprovalStatus;
|
||||
approvalStatusDesc: string;
|
||||
/** 当前待审步骤下标(从 0 起) */
|
||||
approvalLevel?: number;
|
||||
/** 当前用户是否为该单当前步骤的待签审批人 */
|
||||
canApprove?: boolean;
|
||||
/** 审批意见 */
|
||||
approvalRemark?: string | null;
|
||||
/** 审批人 */
|
||||
approvalBy?: string | null;
|
||||
/** 审批时间 */
|
||||
approvalTime?: string | null;
|
||||
createBy: string | null;
|
||||
createTime: string | null;
|
||||
updateBy: string | null;
|
||||
updateTime: string | null;
|
||||
}
|
||||
|
||||
/** 审批状态枚举 */
|
||||
export type ApprovalStatus = "none" | "pending" | "approved" | "rejected";
|
||||
|
||||
// ============================ 作业详情 ============================
|
||||
|
||||
/** 作业详情视图对象(对齐 HomeworkDetailVo.java,@JsonProperty 保留旧字段名) */
|
||||
@@ -143,35 +125,6 @@ export interface HomeworkDetailVo {
|
||||
receiveType?: string;
|
||||
/** 接收对象可读文本(全部班级 / 班级名串) */
|
||||
receiveText?: string;
|
||||
/** 是否需审批 */
|
||||
approvalEnabled?: number;
|
||||
/** 审批状态 */
|
||||
approvalStatus?: ApprovalStatus;
|
||||
/** 审批状态文本 */
|
||||
approvalStatusDesc?: string;
|
||||
/** 审批意见 */
|
||||
approvalRemark?: string | null;
|
||||
/** 审批人 */
|
||||
approvalBy?: string | null;
|
||||
/** 审批时间 */
|
||||
approvalTime?: string | null;
|
||||
/** 当前待审步骤下标(从 0 起) */
|
||||
approvalLevel?: number;
|
||||
/** 多步审批流进度(也用于发布/编辑回显审批人配置) */
|
||||
approvalFlow?: {
|
||||
name: string;
|
||||
vote: "all" | "any";
|
||||
/** 审批人账号(编辑回显用) */
|
||||
approvers?: string[];
|
||||
/** 审批人姓名(展示用) */
|
||||
approverNames?: string[];
|
||||
status: "approved" | "pending" | "rejected" | "waiting";
|
||||
records: { account: string; name: string; result: "approved" | "rejected"; time: string; remark: string }[];
|
||||
/** 当前步待签人姓名 */
|
||||
pending: string[];
|
||||
}[];
|
||||
/** 当前用户是否为该单当前步骤的待签审批人 */
|
||||
canApprove?: boolean;
|
||||
}
|
||||
|
||||
/** 作业详情附件项(对齐 HomeworkDetailVo.FileItem) */
|
||||
@@ -372,7 +325,13 @@ export interface HomeworkRequest {
|
||||
/** 提交方式:text/image/attach/none */
|
||||
submitType?: string[] | string;
|
||||
type?: number;
|
||||
deptId: string;
|
||||
deptId?: string;
|
||||
/** 是否允许逾期补交 */
|
||||
allowLate?: boolean;
|
||||
/** 截止提醒:如 ["1d","1h"] */
|
||||
remindBefore?: string[];
|
||||
/** 存草稿:true 则不发布 */
|
||||
draft?: boolean;
|
||||
deptName?: string;
|
||||
topmost?: number;
|
||||
/** 接收对象:"all" 或 班级 id 逗号串(receiveType=class) */
|
||||
@@ -384,12 +343,6 @@ export interface HomeworkRequest {
|
||||
timingSendTime?: string;
|
||||
/** 短信发送标记 */
|
||||
smsFlag?: number;
|
||||
/** 审批人账号列表(单步:选人即送审;空=不送审直接发布) */
|
||||
approvers?: string[];
|
||||
/** 单步表决:any=或签(默认)/ all=会签 */
|
||||
vote?: "all" | "any";
|
||||
/** 多步审批流(高级):[{name,vote,approvers}],传了则忽略 approvers/vote */
|
||||
flow?: { name: string; vote: "all" | "any"; approvers: string[] }[];
|
||||
/** 转发源作业ID */
|
||||
sourceId?: number;
|
||||
}
|
||||
@@ -419,55 +372,14 @@ export interface HomeworkQuery {
|
||||
orderByTime?: string;
|
||||
}
|
||||
|
||||
// ============================ 字典/联系组 ============================
|
||||
|
||||
/** 系统字典下拉项 */
|
||||
export interface DictOption {
|
||||
k: number | string;
|
||||
v: string | null;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/** 发文部门管理列表项 */
|
||||
export interface DeptManageItem {
|
||||
SerID: number;
|
||||
CodeID: string;
|
||||
Name: string;
|
||||
Enable: number;
|
||||
}
|
||||
|
||||
/** 联系组列表项 */
|
||||
export interface ContactGroupItem {
|
||||
serid: number;
|
||||
GroupName: string;
|
||||
}
|
||||
|
||||
/** 联系组分页结果 */
|
||||
export interface ContactGroupPage {
|
||||
list: ContactGroupItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** 联系组用户项 */
|
||||
export interface ContactUser {
|
||||
account: string;
|
||||
name: string;
|
||||
userId?: number;
|
||||
id?: number;
|
||||
}
|
||||
|
||||
/** 联系组用户分页结果 */
|
||||
export interface ContactUserPage {
|
||||
list: ContactUser[];
|
||||
total: number;
|
||||
}
|
||||
// ============================ 基础平台 ============================
|
||||
|
||||
/** 组织树节点(基础平台返回) */
|
||||
export interface SchoolTreeNode {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// ============================ 班级 / 审批 ============================
|
||||
// ============================ 班级 ============================
|
||||
|
||||
/** 年级→班级树节点(叶子=班级,id 为数字班级 id,附学生数) */
|
||||
export interface ClassTreeNode {
|
||||
@@ -479,40 +391,10 @@ export interface ClassTreeNode {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/** 审批列表查询参数 */
|
||||
export interface ApprovalQuery {
|
||||
pageIndex?: number;
|
||||
pageSize?: number;
|
||||
/** pending / approved / rejected / all(缺省 pending) */
|
||||
status?: string;
|
||||
keyword?: string;
|
||||
/** "user" 只看我提交的审批 */
|
||||
scope?: string;
|
||||
}
|
||||
// ============================ v2 学生端 ============================
|
||||
|
||||
/** 审批操作入参 */
|
||||
export interface ApprovalAction {
|
||||
serid: number;
|
||||
/** 驳回意见 */
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
// ============================ 审批人 ============================
|
||||
|
||||
/** 审批人 */
|
||||
export interface ApproverVo {
|
||||
id: number;
|
||||
account: string;
|
||||
name: string;
|
||||
deptId: string;
|
||||
deptName: string;
|
||||
enabled: number;
|
||||
}
|
||||
|
||||
// ============================ v2 学生 / 家长端 ============================
|
||||
|
||||
/** 当前视角角色 */
|
||||
export type ViewerRole = "teacher" | "student" | "parent";
|
||||
/** 当前视角角色(家长即登录学生账号,不单列) */
|
||||
export type ViewerRole = "teacher" | "student";
|
||||
|
||||
/** 学生对某作业的提交/批改态 */
|
||||
export interface StudentSubmissionInfo {
|
||||
@@ -541,6 +423,8 @@ export interface StudentHomeworkVo {
|
||||
content: string;
|
||||
submitType: string[];
|
||||
viewOnly: boolean;
|
||||
hwStatus?: HomeworkStatus;
|
||||
allowLate?: boolean;
|
||||
my: StudentSubmissionInfo | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* 课堂作业 V2 类型契约(M5 切换,P0)
|
||||
*
|
||||
* 与后端 model/v2/*.java 一一对齐(Jackson 默认驼峰序列化;LocalDateTime 全局
|
||||
* "yyyy-MM-dd HH:mm:ss" 字符串,见后端 JacksonConfig)。V1 契约仍在 homework.ts
|
||||
* 保留服务未切换页面,互不污染:本文件类型一律带 V2 后缀或独立命名。
|
||||
*/
|
||||
|
||||
// ============================ 枚举 ============================
|
||||
|
||||
/** 作业五态(PRD §17.2 上半部) */
|
||||
export type HwStatusV2 = "DRAFT_UNPUBLISHED" | "DRAFT_SCHEDULED" | "PUBLISHED" | "CLOSED" | "ARCHIVED";
|
||||
|
||||
/** 提交五态(PRD §17.2 下半部;NOT_SUBMITTED 为矩阵占位行) */
|
||||
export type SubmitStatusV2 = "NOT_SUBMITTED" | "SUBMITTED" | "LATE_SUBMITTED" | "RETURNED" | "REVIEWED";
|
||||
|
||||
/** 批阅三态 */
|
||||
export type ReviewStatus = "PENDING" | "REVIEWED" | "RETURNED";
|
||||
|
||||
/** 学生列表 tab(GET /student/homeworks?tab=) */
|
||||
export type StudentTab = "all" | "todo" | "done" | "closed";
|
||||
|
||||
/** 教师提醒类型(POST /{id}/remind) */
|
||||
export type RemindType = "NOT_READ" | "NOT_SUBMITTED";
|
||||
|
||||
/** 通知类型(8 类:PRD 6 类 + SCHEDULE_FAILED / DEADLINE_CHANGED 补充) */
|
||||
export type NotificationType =
|
||||
| "HOMEWORK_PUBLISHED"
|
||||
| "DEADLINE_REMINDER"
|
||||
| "NOT_SUBMITTED"
|
||||
| "NOT_READ"
|
||||
| "REVIEW_DONE"
|
||||
| "RETURNED"
|
||||
| "SCHEDULE_FAILED"
|
||||
| "DEADLINE_CHANGED";
|
||||
|
||||
// ============================ 公共结构 ============================
|
||||
|
||||
/** 统一分页响应外壳(对齐 BasePageResult.java) */
|
||||
export interface BasePageResult<T> {
|
||||
total: number;
|
||||
content: T[];
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
hasNext: boolean;
|
||||
}
|
||||
|
||||
/** 分页查询入参(对齐 BasePageQuery.java:pageIndex 从 1 起) */
|
||||
export interface PageQueryV2 {
|
||||
pageIndex?: number;
|
||||
pageSize?: number;
|
||||
keyword?: string;
|
||||
}
|
||||
|
||||
/** 提交方式(PRD 图片/文件/两者 → image/attach 组合;text 文本;none 仅查阅) */
|
||||
export type SubmitTypeItem = "image" | "attach" | "text" | "none";
|
||||
|
||||
/** 附件条目(作业附件与提交附件共用;fileUrl 必传,dataURL 会被后端拒绝) */
|
||||
export interface AttachmentItem {
|
||||
/** up_file.id(file/Upload 产物回传) */
|
||||
fileId?: number;
|
||||
fileUrl: string;
|
||||
/** image / file */
|
||||
fileType?: string;
|
||||
fileName?: string;
|
||||
sort?: number;
|
||||
}
|
||||
|
||||
/** 参与班级(HomeworkV2Vo.classes) */
|
||||
export interface ClassItem {
|
||||
classId: string;
|
||||
className: string;
|
||||
}
|
||||
|
||||
/** 提交/批阅/阅读进度(应交为分母;打回未重交计入已交) */
|
||||
export interface ProgressVo {
|
||||
/** 应交人数 */
|
||||
should: number;
|
||||
read: number;
|
||||
submitted: number;
|
||||
graded: number;
|
||||
pendingGrade: number;
|
||||
unsubmitted: number;
|
||||
unread: number;
|
||||
/** 未读且未交(高亮口径) */
|
||||
unreadUndone: number;
|
||||
readRate: number;
|
||||
submitRate: number;
|
||||
gradeRate: number;
|
||||
}
|
||||
|
||||
/** 年级→班级树节点(叶子 id=classId,学生数) */
|
||||
export interface ClassTreeNodeV2 {
|
||||
/** 年级节点为 gradeName,班级叶子为 classId */
|
||||
id: string;
|
||||
name: string;
|
||||
isParent: boolean;
|
||||
studentCount?: number;
|
||||
children?: ClassTreeNodeV2[];
|
||||
}
|
||||
|
||||
// ============================ 教师端 ============================
|
||||
|
||||
/** 创建/编辑作业请求(POST / PUT /teacher/homeworks) */
|
||||
export interface HomeworkV2Request {
|
||||
/** 必填 ≤50 字 */
|
||||
title: string;
|
||||
subject?: string;
|
||||
/** 富文本 */
|
||||
description?: string;
|
||||
classIds: string[];
|
||||
attachments?: AttachmentItem[];
|
||||
/** 精确到分钟;空 = 仅查阅型 */
|
||||
deadlineAt?: string;
|
||||
submitType: SubmitTypeItem[];
|
||||
allowLate?: number;
|
||||
remind1d?: number;
|
||||
remind1h?: number;
|
||||
}
|
||||
|
||||
/** 草稿箱/已发布列表/详情共用视图 */
|
||||
export interface HomeworkV2Vo {
|
||||
id: number;
|
||||
title: string;
|
||||
subject?: string;
|
||||
description?: string;
|
||||
hwStatus: HwStatusV2;
|
||||
hwStatusDesc: string;
|
||||
scheduledAt?: string | null;
|
||||
publishedAt?: string | null;
|
||||
deadlineAt?: string | null;
|
||||
archivedAt?: string | null;
|
||||
submitType: string[];
|
||||
allowLate: number;
|
||||
remind1d: number;
|
||||
remind1h: number;
|
||||
classes: ClassItem[];
|
||||
attachments: AttachmentItem[];
|
||||
publisherId?: string;
|
||||
publisherName?: string;
|
||||
createTime?: string;
|
||||
updateTime?: string;
|
||||
/** 非草稿返回 */
|
||||
progress?: ProgressVo | null;
|
||||
}
|
||||
|
||||
/** 教师列表查询(草稿箱/已发布共用) */
|
||||
export interface TeacherHomeworkQuery extends PageQueryV2 {
|
||||
subject?: string;
|
||||
/** 已发布列表:PUBLISHED/CLOSED/ARCHIVED */
|
||||
hwStatus?: string;
|
||||
}
|
||||
|
||||
/** 汇总统计卡片(GET /{id}/stats) */
|
||||
export interface HomeworkStatsVo {
|
||||
homeworkId: number;
|
||||
title: string;
|
||||
hwStatus: HwStatusV2;
|
||||
hwStatusDesc: string;
|
||||
total: ProgressVo;
|
||||
byClass: { classId: string; className: string; progress: ProgressVo }[];
|
||||
}
|
||||
|
||||
/** 学生矩阵查询(GET /{id}/students) */
|
||||
export interface StudentMatrixQuery extends PageQueryV2 {
|
||||
classId?: string;
|
||||
submitStatus?: SubmitStatusV2;
|
||||
reviewStatus?: ReviewStatus;
|
||||
/** 只看待批阅快捷过滤 */
|
||||
pendingGrade?: boolean;
|
||||
}
|
||||
|
||||
/** 学生矩阵行(未交学生 submissionId=0 占位) */
|
||||
export interface StudentMatrixVo {
|
||||
submissionId: number;
|
||||
studentId: string;
|
||||
studentName: string;
|
||||
classId: string;
|
||||
className: string;
|
||||
/** 0 未读 / 1 已读 */
|
||||
isRead: number;
|
||||
firstReadAt?: string | null;
|
||||
lastReadAt?: string | null;
|
||||
submitStatus: SubmitStatusV2;
|
||||
submittedAt?: string | null;
|
||||
/** 0/1 */
|
||||
isLate: number;
|
||||
reviewStatus: ReviewStatus;
|
||||
score?: number | null;
|
||||
/** 最新提交附件 URL 列表(缩略图) */
|
||||
thumbnails?: string[];
|
||||
}
|
||||
|
||||
/** 批阅保存请求(score 与 comment 至少一项) */
|
||||
export interface ReviewRequest {
|
||||
score?: number;
|
||||
comment?: string;
|
||||
/** 圈划标注图片(P2,先收参) */
|
||||
markedImages?: string[];
|
||||
}
|
||||
|
||||
/** 打回请求(comment 必填) */
|
||||
export interface ReturnRequest {
|
||||
comment: string;
|
||||
}
|
||||
|
||||
/** 提交版本条目(教师版本列表/学生结果页共用) */
|
||||
export interface SubmissionVersionVo {
|
||||
submissionId: number;
|
||||
homeworkId: number;
|
||||
studentId: string;
|
||||
studentName: string;
|
||||
version: number;
|
||||
/** 0/1 */
|
||||
isLatest: number;
|
||||
submitStatus: SubmitStatusV2;
|
||||
submitStatusDesc: string;
|
||||
submittedAt?: string | null;
|
||||
isLate: number;
|
||||
text?: string | null;
|
||||
attachments: AttachmentItem[];
|
||||
score?: number | null;
|
||||
comment?: string | null;
|
||||
reviewStatus: ReviewStatus;
|
||||
reviewStatusDesc: string;
|
||||
reviewedBy?: string | null;
|
||||
reviewedAt?: string | null;
|
||||
}
|
||||
|
||||
// ============================ 学生端 ============================
|
||||
|
||||
/** 学生列表项(GET /student/homeworks) */
|
||||
export interface StudentHomeworkV2Vo {
|
||||
id: number;
|
||||
title: string;
|
||||
subject?: string;
|
||||
teacherName?: string;
|
||||
deadlineAt?: string | null;
|
||||
submitType: string[];
|
||||
hwStatus: HwStatusV2;
|
||||
hwStatusDesc: string;
|
||||
my: StudentMySubmission | null;
|
||||
}
|
||||
|
||||
/** 我的提交概要(学生列表项内嵌) */
|
||||
export interface StudentMySubmission {
|
||||
submissionId: number;
|
||||
submitStatus: SubmitStatusV2;
|
||||
submitStatusDesc: string;
|
||||
submittedAt?: string | null;
|
||||
isLate: number;
|
||||
version: number;
|
||||
reviewStatus: ReviewStatus;
|
||||
score?: number | null;
|
||||
comment?: string | null;
|
||||
}
|
||||
|
||||
/** 学生作业详情(GET /student/homeworks/{id}) */
|
||||
export interface StudentHomeworkDetailV2Vo extends StudentHomeworkV2Vo {
|
||||
description?: string;
|
||||
attachments: AttachmentItem[];
|
||||
classes: ClassItem[];
|
||||
/** 我当前版本的提交附件(未提交为 null) */
|
||||
myAttachments?: AttachmentItem[] | null;
|
||||
myText?: string | null;
|
||||
}
|
||||
|
||||
/** 学生提交请求(附件只收 file/Upload 产物,拒绝 dataURL) */
|
||||
export interface StudentSubmitRequest {
|
||||
text?: string;
|
||||
attachments?: AttachmentItem[];
|
||||
}
|
||||
|
||||
/** 撤回/重交动作(PUT /student/homeworks/{id}/submissions/{submissionId}) */
|
||||
export interface SubmissionActionRequest {
|
||||
action: "withdraw" | "resubmit";
|
||||
text?: string;
|
||||
attachments?: AttachmentItem[];
|
||||
}
|
||||
|
||||
/** 学生批阅结果(GET /{id}/result) */
|
||||
export interface StudentResultVo {
|
||||
homeworkId: number;
|
||||
homeworkTitle: string;
|
||||
/** 当前版本批阅结果(未批阅为 null) */
|
||||
latest: SubmissionVersionVo | null;
|
||||
/** 全部版本(新→旧) */
|
||||
versions: SubmissionVersionVo[];
|
||||
}
|
||||
|
||||
// ============================ 通知 ============================
|
||||
|
||||
/** 通知列表项(GET /notifications) */
|
||||
export interface NotificationVo {
|
||||
id: number;
|
||||
type: NotificationType;
|
||||
typeDesc: string;
|
||||
title: string;
|
||||
content?: string;
|
||||
homeworkId?: number | null;
|
||||
relatedId?: number | null;
|
||||
/** 0/1 */
|
||||
isRead: number;
|
||||
readAt?: string | null;
|
||||
createTime?: string;
|
||||
}
|
||||
+33
-4
@@ -41,13 +41,42 @@ const NAME_ALIASES: Record<string, string> = {
|
||||
已发作业: "/list",
|
||||
已收作业: "/accept",
|
||||
作业列表: "/homeworklist",
|
||||
审批列表: "/approval",
|
||||
作业审批: "/approval",
|
||||
审核设置: "/approval-setting",
|
||||
审批设置: "/approval-setting",
|
||||
统计分析: "/stats",
|
||||
};
|
||||
|
||||
/**
|
||||
* 已下线模块:课程作业不做审批(§6)、不开放应用设置(§7)。
|
||||
* 页面与路由已删除,但平台模块配置(GetUserMod)仍可能下发这些模块,
|
||||
* 若不在前端剔除,侧栏会出现「点了没反应」的死菜单(resolveMenuPage 返回空 path)。
|
||||
* modId 与名称双重命中:平台侧改 modId 或改名后仍能兜住。
|
||||
*/
|
||||
export const DROPPED_MOD_IDS: ReadonlySet<number> = new Set<number>([5, 6, 21]);
|
||||
const DROPPED_NAMES: ReadonlySet<string> = new Set<string>([
|
||||
"审批",
|
||||
"审批列表",
|
||||
"审批设置",
|
||||
"审批人管理",
|
||||
"审核",
|
||||
"审核列表",
|
||||
"审核设置",
|
||||
"应用设置",
|
||||
"作业设置",
|
||||
]);
|
||||
|
||||
/** 剔除已下线模块(含 children),按 modId ?? id 或名称命中;返回新数组,不修改入参 */
|
||||
export function dropDroppedModules<T extends { id?: number | null; modId?: number | null; name?: string | null; children?: T[] }>(
|
||||
list: T[] | null | undefined
|
||||
): T[] {
|
||||
if (!Array.isArray(list)) return [];
|
||||
return list
|
||||
.filter((m) => {
|
||||
const key = Number((m?.modId ?? m?.id) as number);
|
||||
if (Number.isFinite(key) && DROPPED_MOD_IDS.has(key)) return false;
|
||||
return !DROPPED_NAMES.has(String(m?.name ?? "").trim());
|
||||
})
|
||||
.map((m) => (Array.isArray(m.children) && m.children.length ? { ...m, children: dropDroppedModules(m.children) } : m));
|
||||
}
|
||||
|
||||
const EXTERNAL_RE = /^(https?:)?\/\//i;
|
||||
|
||||
/** 从平台原始配置里提取内部路由候选串(去掉 hash、query、尾斜杠) */
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useRequest } from "alova/client";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { homeworkApi } from "@/api/module/homework";
|
||||
import type { HomeworkVo, ApprovalQuery } from "@/types/homework";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import Utils from "@/utils";
|
||||
|
||||
/**
|
||||
* 审批列表:展示「需审批后发布」的作业(approvalEnabled=1)。
|
||||
* 状态过滤:pending(缺省) / approved / rejected / all;
|
||||
* 操作:查看作业(详情)→ 通过 / 驳回(填审批意见)。
|
||||
*/
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const keyword = ref("");
|
||||
const status = ref("pending");
|
||||
const pageNum = ref(1);
|
||||
const pageSize = ref(parseInt(localStorage.getItem("customPageCount") || "10"));
|
||||
|
||||
const { data: page, loading, send: reload } = useRequest(
|
||||
(params: ApprovalQuery) => homeworkApi.approvalList(params),
|
||||
{ immediate: false, initialData: { content: [], total: 0, pageIndex: 1, pageSize: 10, totalPages: 0, hasNext: false } }
|
||||
);
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
await reload({ keyword: keyword.value || "", status: status.value, pageIndex: pageNum.value, pageSize: pageSize.value });
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "获取审批列表失败");
|
||||
}
|
||||
}
|
||||
function doSearch() {
|
||||
pageNum.value = 1;
|
||||
run();
|
||||
}
|
||||
function pageUpdate(p: number) {
|
||||
pageNum.value = p;
|
||||
run();
|
||||
}
|
||||
function pageSizeUpdate(size: number) {
|
||||
pageNum.value = 1;
|
||||
pageSize.value = size;
|
||||
localStorage.setItem("customPageCount", String(size));
|
||||
run();
|
||||
}
|
||||
|
||||
onMounted(run);
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => run()
|
||||
);
|
||||
|
||||
/* ---------- 审批操作 ---------- */
|
||||
function view(row: HomeworkVo) {
|
||||
router.push({ path: "/homeworklist/detail", query: { serid: row.id, type: 0 } });
|
||||
}
|
||||
|
||||
function approve(row: HomeworkVo) {
|
||||
MyConfirm({
|
||||
title: "审批通过",
|
||||
msg: `确定通过「 ${row.title} 」的当前审批步骤吗?`,
|
||||
info: "会签需全部通过、或签任一人通过即进入下一步;最后一步通过后发布",
|
||||
async onOk() {
|
||||
try {
|
||||
const res: any = await homeworkApi.approve((row as any).id).send();
|
||||
Message.success(res?.status === "approved" ? "已通过全部步骤,作业已发布" : "本步骤已通过,流转至下一步");
|
||||
run();
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "操作失败");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/* 驳回:弹窗填写审批意见 */
|
||||
const rejectModal = ref(false);
|
||||
const rejectRow = ref<HomeworkVo | null>(null);
|
||||
const rejectRemark = ref("");
|
||||
function openReject(row: HomeworkVo) {
|
||||
rejectRow.value = row;
|
||||
rejectRemark.value = "";
|
||||
rejectModal.value = true;
|
||||
}
|
||||
async function doReject() {
|
||||
if (!rejectRemark.value.trim()) {
|
||||
Message.info("请填写驳回意见");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await homeworkApi.reject(rejectRow.value!.id, rejectRemark.value.trim()).send();
|
||||
Message.success("已驳回,退回发布人");
|
||||
rejectModal.value = false;
|
||||
run();
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "操作失败");
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 状态标签配色 ---------- */
|
||||
const STATUS_TAG: Record<string, string> = { pending: "warning", approved: "success", rejected: "error", none: "default" };
|
||||
const approvalTag = (row: any) => STATUS_TAG[row.approvalStatus] || "default";
|
||||
|
||||
const columns = [
|
||||
{ title: "序号", type: "index", width: 80, align: "center" },
|
||||
{ title: "标题", key: "title", align: "left", tooltip: true },
|
||||
{ title: "来源部门", key: "deptName", width: 130, align: "left" },
|
||||
{ title: "发布人", key: "publisherName", width: 110, align: "left", tooltip: true },
|
||||
{
|
||||
title: "提交时间",
|
||||
width: 170,
|
||||
align: "left",
|
||||
render: (h: any, p: any) => h("span", {}, String(p.row.createTime || "").slice(0, 16)),
|
||||
},
|
||||
{
|
||||
title: "审批状态",
|
||||
width: 240,
|
||||
align: "left",
|
||||
render: (h: any, p: any) => h("Tag", { color: approvalTag(p.row) }, () => p.row.approvalStatusDesc || "—"),
|
||||
},
|
||||
{
|
||||
title: "审批意见",
|
||||
key: "approvalRemark",
|
||||
align: "left",
|
||||
width: 180,
|
||||
tooltip: true,
|
||||
render: (h: any, p: any) => h("span", {}, p.row.approvalRemark || "—"),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
width: 240,
|
||||
align: "center",
|
||||
fixed: "right",
|
||||
render: (h: any, p: any) => {
|
||||
const row: any = p.row;
|
||||
const ops = [Utils.operatingHandle(h, "查看作业", "icon-browse", () => view(row))];
|
||||
if (row.canApprove) {
|
||||
ops.push(Utils.operatingHandle(h, "通过", "icon-ckpy", () => approve(row)));
|
||||
ops.push(Utils.operatingHandle(h, "驳回", "icon-pingbi", () => openReject(row)));
|
||||
} else if (row.approvalStatus === "pending") {
|
||||
// 非当前审批步骤的审批人:仅展示,不出现操作按钮
|
||||
ops.push(h("span", { style: "color:#999;font-size:12px;margin-left:8px" }, "等待他人审批"));
|
||||
}
|
||||
return ops;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const tableHeight = computed(() => Utils.useTableHeight({ isSearch: true, isOperat: true, isPage: true }));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-wrapper">
|
||||
<div class="main-container">
|
||||
<!-- 搜索区 -->
|
||||
<div class="search-wrap fix">
|
||||
<RadioGroup v-model="status" type="button" button-style="solid" class="fl" @on-change="doSearch">
|
||||
<Radio label="pending"><span>待审批</span></Radio>
|
||||
<Radio label="approved"><span>已通过</span></Radio>
|
||||
<Radio label="rejected"><span>已驳回</span></Radio>
|
||||
<Radio label="all"><span>全部</span></Radio>
|
||||
</RadioGroup>
|
||||
<Input
|
||||
class="fr"
|
||||
style="width: 220px"
|
||||
search
|
||||
suffix="ios-search"
|
||||
placeholder="输入标题/发布人搜索"
|
||||
v-model="keyword"
|
||||
@on-search="doSearch"
|
||||
@on-clear="doSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Table :columns="columns" :data="page.content" class="link-table table-bottom-box" :height="tableHeight" :loading="loading" no-data-text="暂无待审批作业"></Table>
|
||||
<div class="page-panel">
|
||||
<div class="fl ml-15 align-middle"></div>
|
||||
<Page
|
||||
class="page-box fr"
|
||||
:total="page.total"
|
||||
:current="pageNum"
|
||||
:page-size="pageSize"
|
||||
:page-size-opts="[10, 20, 30, 40]"
|
||||
@on-page-size-change="pageSizeUpdate"
|
||||
@on-change="pageUpdate"
|
||||
placement="top"
|
||||
show-total
|
||||
show-sizer
|
||||
></Page>
|
||||
</div>
|
||||
|
||||
<!-- 驳回弹窗 -->
|
||||
<Modal v-model="rejectModal" title="驳回作业" :mask-closable="false" width="460px">
|
||||
<Form :label-width="80">
|
||||
<FormItem label="作业标题">
|
||||
<span>{{ rejectRow?.title }}</span>
|
||||
</FormItem>
|
||||
<FormItem label="审批意见">
|
||||
<Input v-model="rejectRemark" type="textarea" :rows="3" placeholder="请填写驳回原因,将退回发布人"></Input>
|
||||
</FormItem>
|
||||
</Form>
|
||||
<template #footer>
|
||||
<Button type="error" @click="doReject">确认驳回</Button>
|
||||
<Button @click="rejectModal = false">取消</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -1,160 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from "vue";
|
||||
import { useRequest } from "alova/client";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { approvalSettingApi, homeworkApi } from "@/api/module/homework";
|
||||
import type { ApproverVo } from "@/types/homework";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import Utils from "@/utils";
|
||||
|
||||
/**
|
||||
* 审核设置:维护「审批人候选池」。发布时从这里选审批人——选到人即走审批流程。
|
||||
* 逻辑很简单:任一审批人通过即发布,驳回退回发布人;也可在发布界面配多步会签/或签。
|
||||
*/
|
||||
const { data: deptOptions } = useRequest(homeworkApi.getSysDic());
|
||||
const approvers = ref<ApproverVo[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
async function loadApprovers() {
|
||||
loading.value = true;
|
||||
try {
|
||||
approvers.value = (await approvalSettingApi.approvers().send()) || [];
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "获取审批人失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const modal = ref(false);
|
||||
const editingId = ref<number | null>(null);
|
||||
const form = reactive({ account: "", name: "", deptId: "" });
|
||||
const formRef = ref();
|
||||
const rule = {
|
||||
name: [{ required: true, message: "请输入审批人姓名", trigger: "blur" }],
|
||||
deptId: [{ required: true, message: "请选择所属部门", trigger: "change" }],
|
||||
};
|
||||
function addApprover() {
|
||||
editingId.value = null;
|
||||
form.account = "";
|
||||
form.name = "";
|
||||
form.deptId = "";
|
||||
modal.value = true;
|
||||
}
|
||||
function editApprover(row: ApproverVo) {
|
||||
editingId.value = row.id;
|
||||
form.account = row.account;
|
||||
form.name = row.name;
|
||||
form.deptId = row.deptId;
|
||||
modal.value = true;
|
||||
}
|
||||
async function saveApprover() {
|
||||
const valid = await formRef.value?.validate();
|
||||
if (!valid) {
|
||||
Message.info("请填写红色星号内容!");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (editingId.value == null) {
|
||||
await approvalSettingApi.addApprover({ account: form.account.trim(), name: form.name.trim(), deptId: form.deptId }).send();
|
||||
} else {
|
||||
await approvalSettingApi.updateApprover({ id: editingId.value, name: form.name.trim(), deptId: form.deptId }).send();
|
||||
}
|
||||
Message.success("操作成功!");
|
||||
modal.value = false;
|
||||
await loadApprovers();
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "操作失败");
|
||||
}
|
||||
}
|
||||
async function toggleApprover(row: ApproverVo) {
|
||||
const enabled = row.enabled === 1 ? 0 : 1;
|
||||
try {
|
||||
await approvalSettingApi.updateApprover({ id: row.id, enabled }).send();
|
||||
row.enabled = enabled;
|
||||
Message.success("操作成功!");
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "操作失败");
|
||||
}
|
||||
}
|
||||
function removeApprover(row: ApproverVo) {
|
||||
MyConfirm({
|
||||
title: "删除",
|
||||
msg: `确定删除审批人「 ${row.name} 」吗?`,
|
||||
info: "删除后不可恢复",
|
||||
async onOk() {
|
||||
try {
|
||||
await approvalSettingApi.delApprover(row.id).send();
|
||||
Message.success("删除成功");
|
||||
await loadApprovers();
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "删除失败");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: "姓名", key: "name", align: "left", minWidth: 120 },
|
||||
{ title: "账号", key: "account", align: "left", width: 130 },
|
||||
{ title: "所属部门", key: "deptName", align: "left", minWidth: 130 },
|
||||
{
|
||||
title: "状态",
|
||||
width: 120,
|
||||
align: "center",
|
||||
render: (h: any, p: any) => h("Tag", { color: p.row.enabled === 1 ? "success" : "default" }, () => (p.row.enabled === 1 ? "启用" : "停用")),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
width: 220,
|
||||
align: "center",
|
||||
render: (h: any, p: any) => {
|
||||
const row: ApproverVo = p.row;
|
||||
return [
|
||||
Utils.operatingHandle(h, row.enabled === 1 ? "停用" : "启用", "icon-pingbi", () => toggleApprover(row)),
|
||||
Utils.operatingHandle(h, "编辑", "icon-edit", () => editApprover(row)),
|
||||
Utils.operatingHandle(h, "删除", "icon-ashbin", () => removeApprover(row)),
|
||||
];
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const tableHeight = computed(() => Utils.useTableHeight({ isSearch: true, isOperat: true, isPage: false }));
|
||||
onMounted(loadApprovers);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-wrapper">
|
||||
<div class="main-container">
|
||||
<Alert show-icon type="info">
|
||||
这里维护可被选为审批人的名单。<b>发布或编辑作业时,只要选中了审批人,作业就会进入审批流程</b>(默认一步·或签,也可配多步会签/或签);任一审批人通过即发布,驳回退回发布人。
|
||||
</Alert>
|
||||
<div class="search-wrap fix">
|
||||
<Button class="fl" type="primary" @click="addApprover">新增审批人</Button>
|
||||
</div>
|
||||
<Table :columns="columns" :data="approvers" :loading="loading" class="link-table table-bottom-box" :height="tableHeight" no-data-text="暂无审批人"></Table>
|
||||
|
||||
<Modal v-model="modal" :mask-closable="false" width="450px" :title="editingId == null ? '新增审批人' : '编辑审批人'">
|
||||
<Form ref="formRef" :model="form" :rules="rule" :label-width="90">
|
||||
<FormItem label="姓名:" prop="name">
|
||||
<Input v-model="form.name" placeholder="请输入审批人姓名" />
|
||||
</FormItem>
|
||||
<FormItem label="账号:" prop="account">
|
||||
<Input v-model="form.account" placeholder="登录账号(可选)" />
|
||||
</FormItem>
|
||||
<FormItem label="所属部门:" prop="deptId">
|
||||
<Select v-model="form.deptId" placeholder="请选择部门" transfer>
|
||||
<Option v-for="d in deptOptions" :key="d.k" :value="d.k">{{ d.v }}</Option>
|
||||
</Select>
|
||||
</FormItem>
|
||||
</Form>
|
||||
<template #footer>
|
||||
<Button type="primary" @click="saveApprover">提交</Button>
|
||||
<Button @click="modal = false">取消</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -4,6 +4,20 @@ import { useRoute, useRouter } from "vue-router";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { homeworkApi } from "@/api/module/homework";
|
||||
import type { HomeworkVo, SubmissionBrief, SubmissionDetailVo } from "@/types/homework";
|
||||
import config from "@/assets/config/host.config";
|
||||
|
||||
const serviceBase = config.isMock ? "" : config.serviceHost;
|
||||
/** 学生提交图片:可能是可直接显示的 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 imgPreview = reactive({ open: false, url: "" });
|
||||
function zoom(url: string) {
|
||||
imgPreview.url = url;
|
||||
imgPreview.open = true;
|
||||
}
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -170,13 +184,14 @@ onMounted(async () => {
|
||||
<div class="answer" v-if="detail">
|
||||
<div class="a-meta">提交时间:{{ detail.submittedAt || "—" }}</div>
|
||||
<div class="a-body" v-html="detail.text || '(无文字作答)'"></div>
|
||||
<div v-if="detail.images && detail.images.length" class="a-files">
|
||||
图片:
|
||||
<a v-for="(im, ii) in detail.images" :key="ii" :href="im" target="_blank">图{{ ii + 1 }}</a>
|
||||
<div v-if="detail.images && detail.images.length" class="ans-imgs">
|
||||
<div class="ans-img" v-for="(im, ii) in detail.images" :key="ii" @click="zoom(previewUrl(im))">
|
||||
<img :src="previewUrl(im)" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="detail.attachments && detail.attachments.length" class="a-files">
|
||||
附件:
|
||||
<a v-for="(at, ai) in detail.attachments" :key="ai" :href="at" target="_blank">附件{{ ai + 1 }}</a>
|
||||
<span class="att-chip" v-for="(at, ai) in detail.attachments" :key="ai">附件{{ ai + 1 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Spin v-if="loadingDetail" fix />
|
||||
@@ -201,10 +216,10 @@ onMounted(async () => {
|
||||
<Input v-model="form.comment" type="textarea" :rows="2" placeholder="批改评语"></Input>
|
||||
</div>
|
||||
<div class="gf-actions fix">
|
||||
<Button class="fl" :disabled="idx === 0" @click="select(idx - 1)">上一份</Button>
|
||||
<Button class="fl" icon="ios-arrow-back" :disabled="idx === 0" @click="select(idx - 1)">上一份</Button>
|
||||
<div class="fr">
|
||||
<Button type="warning" :loading="saving" @click="submit('redo')">要求订正</Button>
|
||||
<Button type="primary" :loading="saving" @click="submit('grade')" style="margin-left: 8px">保存并下一份</Button>
|
||||
<Button type="warning" icon="md-refresh" :loading="saving" @click="submit('redo')">要求订正</Button>
|
||||
<Button type="primary" icon="md-checkmark" :loading="saving" @click="submit('grade')" style="margin-left: 8px">保存并下一份</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -213,10 +228,22 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 作答图片放大预览 -->
|
||||
<Modal v-model="imgPreview.open" footer-hide title="作答图片" width="680px">
|
||||
<div class="zoom-wrap"><img :src="imgPreview.url" /></div>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.zoom-wrap {
|
||||
text-align: center;
|
||||
img {
|
||||
max-width: 100%;
|
||||
max-height: 62vh;
|
||||
}
|
||||
}
|
||||
.tip {
|
||||
color: #808695;
|
||||
padding: 20px 0;
|
||||
@@ -294,8 +321,38 @@ onMounted(async () => {
|
||||
}
|
||||
.a-files {
|
||||
margin-top: 8px;
|
||||
a {
|
||||
margin-right: 10px;
|
||||
color: #808695;
|
||||
.att-chip {
|
||||
display: inline-block;
|
||||
margin: 0 6px 0 4px;
|
||||
padding: 1px 8px;
|
||||
background: #f0f2f5;
|
||||
border-radius: 3px;
|
||||
color: #515a6e;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
.ans-imgs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
.ans-img {
|
||||
width: 92px;
|
||||
height: 92px;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,9 +169,7 @@ function openPreview(item: { name?: string; filename?: string; fileid?: number;
|
||||
<Tag>{{ detail.TeaName }}</Tag>
|
||||
<Tag>{{ detail.EditDate }}</Tag>
|
||||
<Tag color="blue">接收班级:{{ detail.receiveText || "全部班级" }}</Tag>
|
||||
<Tag :color="({ pending: 'warning', approved: 'success', rejected: 'error' } as any)[detail.approvalStatus || ''] || 'default'">
|
||||
{{ detail.approvalStatusDesc || "已发布" }}
|
||||
</Tag>
|
||||
<Tag color="green">{{ detail.hwStatusDesc || "进行中" }}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
@@ -188,40 +186,7 @@ function openPreview(item: { name?: string; filename?: string; fileid?: number;
|
||||
<span v-if="detail.isDsfs">定时发送:{{ detail.DsfsTime }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 审批状态提示(待审批 / 已驳回 时展示) -->
|
||||
<Alert v-if="detail.approvalStatus === 'pending'" type="warning" show-icon banner style="margin: 8px 0">
|
||||
该作业已提交审批,<b>审批通过后才会对学生发布</b>。
|
||||
</Alert>
|
||||
<Alert v-else-if="detail.approvalStatus === 'rejected'" type="error" show-icon banner style="margin: 8px 0">
|
||||
审批意见({{ detail.approvalBy }} {{ detail.approvalTime }}):{{ detail.approvalRemark }}
|
||||
</Alert>
|
||||
<Alert v-else-if="detail.approvalEnabled && detail.approvalStatus === 'approved'" type="success" show-icon banner style="margin: 8px 0">
|
||||
已由 {{ detail.approvalBy }} 于 {{ detail.approvalTime }} 审批通过并发布。
|
||||
</Alert>
|
||||
|
||||
<!-- 审批流程(多步·会签/或签)进度 -->
|
||||
<div v-if="detail.approvalFlow && detail.approvalFlow.length" class="flow-progress">
|
||||
<div class="fp-title">审批流程</div>
|
||||
<div class="fp-steps">
|
||||
<div class="fp-step" v-for="(lv, i) in detail.approvalFlow" :key="i" :class="'is-' + lv.status">
|
||||
<div class="fp-dot">{{ i + 1 }}</div>
|
||||
<div class="fp-body">
|
||||
<div class="fp-name">
|
||||
第 {{ i + 1 }} 步 · {{ lv.name }}
|
||||
<Tag size="small" :color="lv.vote === 'all' ? 'purple' : 'cyan'">{{ lv.vote === "all" ? "会签" : "或签" }}</Tag>
|
||||
<Tag size="small" :color="({ approved: 'success', pending: 'warning', rejected: 'error', waiting: 'default' } as any)[lv.status]">
|
||||
{{ ({ approved: "已通过", pending: "审批中", rejected: "已驳回", waiting: "未开始" } as any)[lv.status] }}
|
||||
</Tag>
|
||||
</div>
|
||||
<div class="fp-people">审批人:{{ (lv.approverNames || []).join("、") }}</div>
|
||||
<div v-if="lv.status === 'pending' && lv.pending.length" class="fp-wait">待其签:{{ lv.pending.join("、") }}</div>
|
||||
<div v-for="(r, ri) in lv.records" :key="ri" class="fp-rec" :class="r.result">
|
||||
{{ r.name }} {{ r.result === "approved" ? "通过" : "驳回" }} · {{ r.time }}<span v-if="r.remark">:{{ r.remark }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 查看视角:正文 -->
|
||||
|
||||
<div class="content" v-html="detail.Contents"></div>
|
||||
|
||||
|
||||
@@ -136,11 +136,34 @@ function del(row: HomeworkVo) {
|
||||
});
|
||||
}
|
||||
|
||||
/* 文本按钮(不依赖具体 iconfont 名称,规避缺图标) */
|
||||
function btn(text: string, onClick: () => void) {
|
||||
return h("Button", { type: "text", size: "small", onClick }, () => text);
|
||||
/* 操作列:图标按钮 + 悬浮提示(项目既有风格) */
|
||||
function opBtn(h: any, text: string, icon: string, onClick: () => void) {
|
||||
return Utils.operatingHandle(h, text, icon, onClick);
|
||||
}
|
||||
/** 提交进度条单元格(内联样式,render 单元格不吃 scoped) */
|
||||
function progressCell(h: any, g: any) {
|
||||
if (!g) return h("span", { style: "color:#c5c8ce;" }, "—");
|
||||
return h("div", { style: "display:flex;align-items:center;gap:8px;" }, [
|
||||
h("div", { style: "flex:1;min-width:54px;height:6px;background:#f0f2f5;border-radius:4px;overflow:hidden;" }, [
|
||||
h("div", { style: `width:${Math.min(100, g.submitRate)}%;height:100%;background:linear-gradient(90deg,#2d8cf0,#5cadff);border-radius:4px;` }),
|
||||
]),
|
||||
h("span", { style: "font-size:12px;color:#515a6e;white-space:nowrap;" }, `${g.submitted}/${g.should} · ${g.submitRate}%`),
|
||||
]);
|
||||
}
|
||||
/** 批改进度单元格 */
|
||||
function gradeCell(h: any, g: any) {
|
||||
if (!g) return h("span", { style: "color:#c5c8ce;" }, "—");
|
||||
return g.pendingGrade > 0 ? h("span", { style: "color:#ff9900;font-weight:600;" }, `待批 ${g.pendingGrade}`) : h("span", { style: "color:#19be6b;" }, "已批完");
|
||||
}
|
||||
/** 状态胶囊 */
|
||||
function statusPill(h: any, hex: string, text: string) {
|
||||
return h("span", { style: "display:inline-flex;align-items:center;gap:6px;font-size:13px;color:#515a6e;" }, [
|
||||
h("i", { style: `width:8px;height:8px;border-radius:50%;display:inline-block;background:${hex};` }),
|
||||
text,
|
||||
]);
|
||||
}
|
||||
|
||||
const STATUS_HEX: Record<string, string> = { draft: "#c5c8ce", collecting: "#2d8cf0", closed: "#ff9900", done: "#19be6b" };
|
||||
const STATUS_COLOR: Record<string, string> = { draft: "default", collecting: "primary", closed: "warning", done: "success" };
|
||||
|
||||
/* ============ 表格列 ============ */
|
||||
@@ -165,42 +188,56 @@ const columns = [
|
||||
},
|
||||
{
|
||||
title: "提交进度",
|
||||
width: 160,
|
||||
width: 170,
|
||||
align: "left",
|
||||
render: (h: any, p: any) => progressCell(h, (p.row as any).progress),
|
||||
},
|
||||
{
|
||||
title: "已读",
|
||||
width: 80,
|
||||
align: "center",
|
||||
render: (h: any, p: any) => {
|
||||
const g = (p.row as any).progress;
|
||||
if (!g) return h("span", { style: "color:#999;" }, "—");
|
||||
return h("span", {}, `已交 ${g.submitted}/${g.should}(${g.submitRate}%)`);
|
||||
return g ? h("span", { style: "color:#2d8cf0;" }, g.read) : h("span", {}, "—");
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "未读未交",
|
||||
width: 90,
|
||||
align: "center",
|
||||
render: (h: any, p: any) => {
|
||||
const g = (p.row as any).progress;
|
||||
if (!g) return h("span", {}, "—");
|
||||
return h("span", { style: g.unread > 0 ? "color:#ed4014;font-weight:600;" : "color:#808695;" }, g.unread);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "批改",
|
||||
width: 150,
|
||||
width: 120,
|
||||
align: "left",
|
||||
render: (h: any, p: any) => {
|
||||
const g = (p.row as any).progress;
|
||||
if (!g) return h("span", { style: "color:#999;" }, "—");
|
||||
return h("span", {}, g.pendingGrade > 0 ? `待批 ${g.pendingGrade}` : "已批完");
|
||||
},
|
||||
render: (h: any, p: any) => gradeCell(h, (p.row as any).progress),
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
width: 100,
|
||||
align: "center",
|
||||
render: (h: any, p: any) => h("Tag", { color: STATUS_COLOR[(p.row as any).hwStatus] || "default" }, () => (p.row as any).hwStatusDesc || "收集中"),
|
||||
width: 110,
|
||||
align: "left",
|
||||
render: (h: any, p: any) => {
|
||||
const st = (p.row as any).hwStatus || "collecting";
|
||||
return statusPill(h, STATUS_HEX[st], (p.row as any).hwStatusDesc || "收集中");
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
width: 240,
|
||||
width: 200,
|
||||
align: "center",
|
||||
fixed: "right",
|
||||
render: (h: any, p: any) => {
|
||||
const row = p.row as HomeworkVo & { hwStatus?: string; progress?: any };
|
||||
const nodes: any[] = [];
|
||||
if (row.hwStatus !== "draft" && (row as any).progress) nodes.push(btn("提交情况", () => viewSubmissions(row)));
|
||||
if (row.hwStatus === "draft") nodes.push(btn("编辑", () => edit(row)));
|
||||
if (row.hwStatus === "collecting" && row.progress && row.progress.unsubmitted > 0) nodes.push(btn("催交", () => remind(row)));
|
||||
nodes.push(btn("删除", () => del(row)));
|
||||
if (row.hwStatus !== "draft" && (row as any).progress) nodes.push(opBtn(h, "提交情况", "icon-chakan", () => viewSubmissions(row)));
|
||||
if (row.hwStatus === "draft") nodes.push(opBtn(h, "编辑", "icon-edit", () => edit(row)));
|
||||
if (row.hwStatus === "collecting" && row.progress && row.progress.unsubmitted > 0) nodes.push(opBtn(h, "催交", "icon-fasong", () => remind(row)));
|
||||
nodes.push(opBtn(h, "删除", "icon-ashbin", () => del(row)));
|
||||
return nodes;
|
||||
},
|
||||
},
|
||||
@@ -214,7 +251,7 @@ const tableHeight = computed(() => Utils.useTableHeight({ isSearch: true, isOper
|
||||
<div class="main-container">
|
||||
<!-- 1. 操作 + 状态标签 -->
|
||||
<div class="fix mb-12">
|
||||
<Button class="fl" type="primary" size="large" @click="router.push('/list/new')">布置作业</Button>
|
||||
<Button class="fl" type="primary" size="large" icon="md-add" @click="router.push('/list/new')">布置作业</Button>
|
||||
</div>
|
||||
<Tabs :value="hwStatus" @on-click="onTab" class="mb-12">
|
||||
<TabPane v-for="t in STATUS_TABS" :key="t.name" :name="t.name" :label="t.label"></TabPane>
|
||||
@@ -270,4 +307,41 @@ const tableHeight = computed(() => Utils.useTableHeight({ isSearch: true, isOper
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
<style scoped>
|
||||
.pg {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.pg-bar {
|
||||
flex: 1;
|
||||
min-width: 54px;
|
||||
height: 6px;
|
||||
background: #f0f2f5;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.pg-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #2d8cf0, #5cadff);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.pg-txt {
|
||||
font-size: 12px;
|
||||
color: #515a6e;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.st-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: #515a6e;
|
||||
}
|
||||
.st-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,482 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, nextTick } from "vue";
|
||||
import { Message } from "view-ui-plus";
|
||||
import draggable from "vuedraggable";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import UserCheckModal from "@/components/UserCheckModal.vue";
|
||||
import TypeSetBox from "@/components/TypeSetBox.vue";
|
||||
import { settingApi, baseApi } from "@/api/module/homework";
|
||||
import type { DeptManageItem, ContactGroupPage, ContactUserPage, SchoolTreeNode } from "@/types/homework";
|
||||
import Utils from "@/utils";
|
||||
|
||||
/* ============ Tab 切换(规范 §1:tabs-wrapper + Tabs type=card,切换只 v-show 内容区) ============ */
|
||||
const currentType = ref(1);
|
||||
|
||||
function tabChange(name: string) {
|
||||
currentType.value = Number(name);
|
||||
if (currentType.value === 1) {
|
||||
getDeptList();
|
||||
} else if (currentType.value === 3) {
|
||||
getGroupList();
|
||||
}
|
||||
}
|
||||
|
||||
/* ============ 发文部门(直接复用 UserBase 同源 TypeSetBox 卡片组件) ============ */
|
||||
interface DeptItem {
|
||||
SerID: number;
|
||||
CodeID: string;
|
||||
Name: string;
|
||||
Enable: number;
|
||||
}
|
||||
const deptData = ref<DeptItem[]>([]);
|
||||
const deptModal = ref(false);
|
||||
const deptForm = reactive({ name: "" });
|
||||
const deptFormRef = ref();
|
||||
const deptRule = {
|
||||
name: [{ required: true, message: "请输入发文部门名称", trigger: "blur" }],
|
||||
};
|
||||
|
||||
async function getDeptList() {
|
||||
try {
|
||||
const data: DeptManageItem[] = await settingApi.getDictMangeList().send();
|
||||
deptData.value = (data || []).map((d) => ({
|
||||
SerID: d.SerID,
|
||||
CodeID: d.CodeID,
|
||||
Name: d.Name,
|
||||
Enable: d.Enable ?? 1,
|
||||
}));
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "获取发文部门列表失败");
|
||||
}
|
||||
}
|
||||
|
||||
function addDeptShow() {
|
||||
deptForm.name = "";
|
||||
deptModal.value = true;
|
||||
}
|
||||
|
||||
async function deptModalSave() {
|
||||
const valid = await deptFormRef.value?.validate();
|
||||
if (!valid) {
|
||||
Message.info("请填写红色星号内容!");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await settingApi.addDeptDic(deptForm.name.trim()).send();
|
||||
Message.success("操作成功!");
|
||||
deptModal.value = false;
|
||||
await getDeptList();
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "操作失败");
|
||||
}
|
||||
}
|
||||
|
||||
function findDept(id: string): DeptItem | undefined {
|
||||
return deptData.value.find((x) => String(x.SerID) === id);
|
||||
}
|
||||
|
||||
/** TypeSetBox 启用/停用(保留确认弹窗) */
|
||||
function onDeptToggle(id: string) {
|
||||
const item = findDept(id);
|
||||
if (item) toggleDeptStatus(item);
|
||||
}
|
||||
|
||||
/** TypeSetBox 内联编辑部门名(组件已拦截空值) */
|
||||
async function onDeptRename(id: string, name: string) {
|
||||
const item = findDept(id);
|
||||
if (!item) return;
|
||||
try {
|
||||
await settingApi.updateDeptDic(item.SerID, name.trim(), item.Enable).send();
|
||||
Message.success("操作成功!");
|
||||
await getDeptList();
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "操作失败");
|
||||
}
|
||||
}
|
||||
|
||||
function onDeptRemove(id: string) {
|
||||
const item = findDept(id);
|
||||
if (item) deleteDept(item);
|
||||
}
|
||||
|
||||
async function deleteDept(item: DeptItem) {
|
||||
await MyConfirm({
|
||||
title: "删除",
|
||||
msg: `确定删除“ ${item.Name} ”这个发文部门吗?`,
|
||||
info: "删除后不可恢复",
|
||||
async onOk() {
|
||||
try {
|
||||
await settingApi.delDept(item.SerID).send();
|
||||
Message.success("操作成功!");
|
||||
await getDeptList();
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "删除失败");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function toggleDeptStatus(item: DeptItem) {
|
||||
const enable = item.Enable === 1;
|
||||
await MyConfirm({
|
||||
msg: `确定${enable ? "停用" : "启用"}发文部门「${item.Name}」吗?`,
|
||||
title: "修改状态",
|
||||
async onOk() {
|
||||
try {
|
||||
await settingApi.updateDeptDic(item.SerID, item.Name, enable ? 0 : 1).send();
|
||||
item.Enable = enable ? 0 : 1;
|
||||
Message.success("操作成功!");
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "操作失败");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/* 拖动排序:提交当前顺序的 SerID 数组 */
|
||||
async function deptSortSave() {
|
||||
const ids = deptData.value.map((x: DeptItem) => x.SerID);
|
||||
try {
|
||||
await settingApi.sortDeptDic(ids.join(",")).send();
|
||||
Message.success("排序成功");
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "排序失败");
|
||||
}
|
||||
}
|
||||
|
||||
/* ============ 自定义组(规范 §5 联系组管理页形态) ============ */
|
||||
interface GroupItem {
|
||||
serid: number;
|
||||
GroupName: string;
|
||||
count: number;
|
||||
}
|
||||
const groupData = ref<GroupItem[]>([]);
|
||||
const groupTotal = ref(0);
|
||||
const groupPage = ref(1);
|
||||
const groupCount = ref(parseInt(localStorage.getItem("customPageCount") || "10"));
|
||||
|
||||
async function getGroupList() {
|
||||
try {
|
||||
const data: any = await settingApi.getGroup(groupPage.value, groupCount.value).send();
|
||||
groupData.value = (data.list || []).map((d: any) => ({
|
||||
serid: d.serid,
|
||||
GroupName: d.GroupName,
|
||||
count: d.count ?? 0,
|
||||
}));
|
||||
groupTotal.value = data.total || 0;
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "获取自定义组列表失败");
|
||||
}
|
||||
}
|
||||
|
||||
const groupModal = ref(false);
|
||||
const groupEditingId = ref<number | null>(null);
|
||||
const groupForm = reactive({ name: "" });
|
||||
const groupFormRef = ref();
|
||||
const groupRule = {
|
||||
name: [{ required: true, message: "请输入联系组名称", trigger: "blur" }],
|
||||
};
|
||||
|
||||
function addGroupShow() {
|
||||
groupEditingId.value = null;
|
||||
groupForm.name = "";
|
||||
groupModal.value = true;
|
||||
}
|
||||
|
||||
function editGroupShow(row: GroupItem) {
|
||||
groupEditingId.value = row.serid;
|
||||
groupForm.name = row.GroupName;
|
||||
groupModal.value = true;
|
||||
}
|
||||
|
||||
async function groupModalSave() {
|
||||
const valid = await groupFormRef.value?.validate();
|
||||
if (!valid) {
|
||||
Message.info("请填写红色星号内容!");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (groupEditingId.value == null) {
|
||||
await settingApi.addGroup(groupForm.name.trim()).send();
|
||||
} else {
|
||||
await settingApi.editGroup(groupEditingId.value, groupForm.name.trim()).send();
|
||||
}
|
||||
Message.success("操作成功!");
|
||||
groupModal.value = false;
|
||||
await getGroupList();
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "操作失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteGroup(row: GroupItem) {
|
||||
await MyConfirm({
|
||||
title: "删除",
|
||||
msg: `确定删除“ ${row.GroupName} ”这个组吗?`,
|
||||
info: "删除后不可恢复",
|
||||
async onOk() {
|
||||
try {
|
||||
await settingApi.delGroup(String(row.serid)).send();
|
||||
Message.success("操作成功!");
|
||||
// 删除后智能翻页:当前页仅剩一条则回退一页
|
||||
if (groupData.value.length <= 1 && groupPage.value > 1) groupPage.value -= 1;
|
||||
await getGroupList();
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "删除失败");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- 查看联系组用户 ---------- */
|
||||
const seeUserModal = ref(false);
|
||||
const seeUserGid = ref(0);
|
||||
const seeUserGroupName = ref("");
|
||||
const seeUserData = ref<{ account: string; name: string }[]>([]);
|
||||
const seeUserTotal = ref(0);
|
||||
const seeUserPage = ref(1);
|
||||
const seeUserColumns = [
|
||||
{ title: "序号", type: "index", width: 70 },
|
||||
{ title: "账号", key: "account", align: "center" },
|
||||
{ title: "真实姓名", key: "name", width: 160, align: "center" },
|
||||
];
|
||||
|
||||
async function seeUserShow(row: GroupItem) {
|
||||
seeUserGid.value = row.serid;
|
||||
seeUserGroupName.value = row.GroupName;
|
||||
seeUserPage.value = 1;
|
||||
await loadSeeUser();
|
||||
seeUserModal.value = true;
|
||||
}
|
||||
|
||||
async function loadSeeUser() {
|
||||
try {
|
||||
const data: ContactUserPage = await settingApi.getContactsTea(seeUserGid.value, seeUserPage.value, 5).send();
|
||||
seeUserData.value = (data.list || []).map((d) => ({ account: d.account, name: d.name }));
|
||||
seeUserTotal.value = data.total || 0;
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "获取用户列表失败");
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 设置联系组用户(规范选人组件,打开前回显已有成员) ---------- */
|
||||
const setUserCheckRef = ref();
|
||||
const setUserGid = ref(0);
|
||||
const setUserIds = ref<number[]>([]);
|
||||
// 设置自定义组成员只需要基础平台组织树,无需「基础平台/自定义组」两个 tab
|
||||
const setUserSources = [
|
||||
{
|
||||
name: "基础平台",
|
||||
loader: () => baseApi.getSchoolAll().send(),
|
||||
},
|
||||
];
|
||||
|
||||
async function settingUserShow(row: GroupItem) {
|
||||
setUserGid.value = row.serid;
|
||||
setUserIds.value = [];
|
||||
try {
|
||||
const data: ContactUserPage = await settingApi.getContactsTea(row.serid, 0, 0).send();
|
||||
setUserIds.value = (data.list || [])
|
||||
.map((d) => Number(d.userId ?? d.id))
|
||||
.filter((n: number) => Number.isFinite(n) && n > 0);
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "获取已有用户失败");
|
||||
}
|
||||
await nextTick();
|
||||
setUserCheckRef.value.show();
|
||||
}
|
||||
|
||||
async function settingUserSubmit(users: SchoolTreeNode[]) {
|
||||
if (!users.length) {
|
||||
Message.info("请选择用户!");
|
||||
return;
|
||||
}
|
||||
// on-submit 返回的是用户对象数组({id,name,group}),取 id 拼接
|
||||
const ids = users.map((x) => (typeof x === "object" && x != null ? x.id : x)).join(",");
|
||||
try {
|
||||
await settingApi.setContactsTea(setUserGid.value, ids).send();
|
||||
Message.success("设置成功");
|
||||
// 刷新组列表,组员数即时更新
|
||||
await getGroupList();
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "设置失败");
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getDeptList();
|
||||
});
|
||||
|
||||
/* ---------- 自定义组表格(规范 §5:序号/组名/操作图标列 width 250) ---------- */
|
||||
const groupColumns = [
|
||||
{ title: "序号", type: "index", width: 80, align: "center" },
|
||||
{ title: "组名", key: "GroupName", align: "left" },
|
||||
{ title: "组员数", key: "count", width: 100, align: "center" },
|
||||
{
|
||||
title: "操作",
|
||||
width: 250,
|
||||
align: "center",
|
||||
render: (h: any, params: any) => {
|
||||
const row: GroupItem = params.row;
|
||||
return [
|
||||
Utils.operatingHandle(h, "查看用户", "icon-browse", () => seeUserShow(row)),
|
||||
Utils.operatingHandle(h, "编辑", "icon-edit", () => editGroupShow(row)),
|
||||
Utils.operatingHandle(h, "删除", "icon-ashbin", () => deleteGroup(row)),
|
||||
Utils.operatingHandle(h, "设置用户", "icon-user-filling", () => settingUserShow(row)),
|
||||
];
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const groupTableHeight = computed(() => Utils.useTableHeight({ isTabs: true, isSearch: true, isPage: true }));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tabs-wrapper">
|
||||
<Tabs class="tabs-panel" type="card" :value="String(currentType)" @on-click="tabChange">
|
||||
<TabPane label="发文部门" name="1"></TabPane>
|
||||
<TabPane label="自定义组" name="3"></TabPane>
|
||||
</Tabs>
|
||||
|
||||
<!-- 发文部门 -->
|
||||
<div class="main-container" v-show="currentType === 1">
|
||||
<Alert show-icon>设置说明:拖动卡片调整部门排序;停用的部门不出现在「作业发送」的部门下拉中</Alert>
|
||||
<div class="search-wrap fix">
|
||||
<Button class="fl" type="primary" @click="addDeptShow">新增部门</Button>
|
||||
</div>
|
||||
<draggable
|
||||
class="type_set-wrap fix"
|
||||
tag="ul"
|
||||
v-model="deptData"
|
||||
group="homeworkDept"
|
||||
item-key="SerID"
|
||||
@change="deptSortSave"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<li>
|
||||
<TypeSetBox
|
||||
:type="{ id: String(element.SerID), name: element.Name }"
|
||||
:active="element.Enable === 1"
|
||||
@active="onDeptToggle"
|
||||
@edit="onDeptRename"
|
||||
@remove="onDeptRemove"
|
||||
/>
|
||||
</li>
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
|
||||
<!-- 自定义组 -->
|
||||
<div class="main-container" v-show="currentType === 3">
|
||||
<div class="search-wrap fix">
|
||||
<Button class="fl" type="primary" @click="addGroupShow">新建联系组</Button>
|
||||
</div>
|
||||
<Table
|
||||
:columns="groupColumns"
|
||||
:data="groupData"
|
||||
class="link-table table-bottom-box"
|
||||
:height="groupTableHeight"
|
||||
no-data-text="暂无数据"
|
||||
></Table>
|
||||
<div class="page-panel">
|
||||
<div class="fl ml-15 align-middle"></div>
|
||||
<Page
|
||||
class="page-box fr"
|
||||
:total="groupTotal"
|
||||
:current="groupPage"
|
||||
:page-size="groupCount"
|
||||
:page-size-opts="[10, 20, 30, 40]"
|
||||
@on-page-size-change="
|
||||
(size: number) => {
|
||||
groupPage = 1;
|
||||
groupCount = size;
|
||||
localStorage.setItem('customPageCount', String(size));
|
||||
getGroupList();
|
||||
}
|
||||
"
|
||||
@on-change="
|
||||
(p: number) => {
|
||||
groupPage = p;
|
||||
getGroupList();
|
||||
}
|
||||
"
|
||||
placement="top"
|
||||
show-total
|
||||
show-sizer
|
||||
></Page>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增发文部门 Modal(编辑走 TypeSetBox 内联编辑) -->
|
||||
<Modal v-model="deptModal" :mask-closable="false" width="450px" title="新增部门">
|
||||
<Form ref="deptFormRef" :model="deptForm" :rules="deptRule" :label-width="110">
|
||||
<FormItem label="发文部门名称:" prop="name">
|
||||
<Input placeholder="请输入发文部门名称" v-model="deptForm.name" />
|
||||
</FormItem>
|
||||
</Form>
|
||||
<template #footer>
|
||||
<Button type="primary" @click="deptModalSave">提交</Button>
|
||||
<Button @click="deptModal = false">取消</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- 新建/编辑联系组 Modal(规范 §5:450px + label-width 100 + 提交/取消) -->
|
||||
<Modal v-model="groupModal" :mask-closable="false" width="450px" :title="groupEditingId == null ? '新建联系组' : '编辑联系组'">
|
||||
<Form ref="groupFormRef" :model="groupForm" :rules="groupRule" :label-width="100">
|
||||
<FormItem label="联系组名称:" prop="name">
|
||||
<Input placeholder="请输入联系组名称" v-model="groupForm.name" />
|
||||
</FormItem>
|
||||
</Form>
|
||||
<template #footer>
|
||||
<Button type="primary" @click="groupModalSave">提交</Button>
|
||||
<Button @click="groupModal = false">取消</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- 查看联系组用户 Modal(规范 §5:Modal 内小表格 + small 分页) -->
|
||||
<Modal v-model="seeUserModal" :title="`查看联系组用户${seeUserGroupName ? ' - ' + seeUserGroupName : ''}`">
|
||||
<Table height="260" :columns="seeUserColumns" :data="seeUserData" no-data-text="暂无用户"></Table>
|
||||
<div class="small-pager">
|
||||
<Page
|
||||
:current="seeUserPage"
|
||||
:page-size="5"
|
||||
:total="seeUserTotal"
|
||||
@on-change="
|
||||
(p: number) => {
|
||||
seeUserPage = p;
|
||||
loadSeeUser();
|
||||
}
|
||||
"
|
||||
size="small"
|
||||
show-total
|
||||
/>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button @click="seeUserModal = false">关闭</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- 选择用户弹窗(设置联系组用户) -->
|
||||
<UserCheckModal ref="setUserCheckRef" :sources="setUserSources" :userids="setUserIds" @on-submit="settingUserSubmit" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tab-body {
|
||||
min-height: 300px;
|
||||
}
|
||||
.type_set-wrap {
|
||||
margin: 12px 0 16px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
/* 拖动排序手型(与 UserBase AppCategory 页面级样式一致,组件内不含) */
|
||||
.type_set-wrap :deep(.type_name) {
|
||||
cursor: move;
|
||||
}
|
||||
.small-pager {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
+180
-237
@@ -1,78 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, shallowRef, computed, onMounted, onBeforeUnmount, nextTick } from "vue";
|
||||
import { ref, reactive, shallowRef, computed, onMounted, onBeforeUnmount } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useRequest } from "alova/client";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { Editor, Toolbar } from "@wangeditor/editor-for-vue";
|
||||
import "@wangeditor/editor/dist/css/style.css";
|
||||
import { homeworkApi } from "@/api/module/homework";
|
||||
import { homeworkApi, baseApi } from "@/api/module/homework";
|
||||
import type { HomeworkDetailVo, HomeworkRequest, UpFileVo } from "@/types/homework";
|
||||
import ClassCheckInput from "@/components/ClassCheckInput.vue";
|
||||
import ApprovalFlowEditor, { type FlowModel } from "@/components/ApprovalFlowEditor.vue";
|
||||
import HomeworkUpload from "@/components/HomeworkUpload.vue";
|
||||
import config from "@/assets/config/host.config";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
/* 页面模式:serid=编辑 / fid=转发 / 默认新建 */
|
||||
const serid = computed(() => Number(route.query.serid) || 0);
|
||||
const fid = computed(() => Number(route.query.fid) || 0);
|
||||
const isEdit = computed(() => serid.value > 0);
|
||||
|
||||
const SUBJECTS = ["语文", "数学", "英语", "物理", "化学", "生物", "道德与法治", "历史", "地理", "科学", "综合实践"];
|
||||
const SUBMIT_TYPES = [
|
||||
{ v: "text", label: "文字作答" },
|
||||
{ v: "image", label: "拍照/图片" },
|
||||
{ v: "attach", label: "上传附件" },
|
||||
{ v: "text", label: "文字作答" },
|
||||
{ v: "none", label: "仅查阅(不必提交)" },
|
||||
];
|
||||
const REMIND_OPTS = [
|
||||
{ v: "1d", label: "截止前 1 天" },
|
||||
{ v: "1h", label: "截止前 1 小时" },
|
||||
];
|
||||
|
||||
const form = reactive({
|
||||
title: "",
|
||||
subject: "",
|
||||
deadline: "",
|
||||
dept: "",
|
||||
rdoname: "all", // 接收对象:all 或 班级 id 逗号串
|
||||
content: "",
|
||||
isShortMsg: false,
|
||||
});
|
||||
const submitTypes = ref<string[]>(["text"]);
|
||||
const submitTypes = ref<string[]>(["image", "attach"]);
|
||||
const allClasses = ref(false);
|
||||
const selClasses = ref<string[]>([]);
|
||||
const allowLate = ref(false);
|
||||
const remindBefore = ref<string[]>([]);
|
||||
const fileids = ref<number[]>([]);
|
||||
const editFileList = ref<{ name: string; fileid: number }[]>([]);
|
||||
const isSms = ref(false);
|
||||
|
||||
/* ============ 审批:选人即送审(默认一步·或签,可展开多步会签/或签) ============ */
|
||||
const flowEditorRef = ref();
|
||||
const flowModel = ref<FlowModel>({ enabled: false, flow: [] });
|
||||
/* 班级下拉多选数据(年级分组) */
|
||||
const classGroups = ref<{ grade: string; items: { value: string; label: string }[] }[]>([]);
|
||||
const allClassIds = ref<string[]>([]);
|
||||
async function loadClasses() {
|
||||
try {
|
||||
const tree: any[] = await baseApi.getClassTree().send();
|
||||
const groups: { grade: string; items: { value: string; label: string }[] }[] = [];
|
||||
const ids: string[] = [];
|
||||
(tree || []).forEach((g) => {
|
||||
const items = (g.children || []).map((c: any) => ({ value: String(c.id), label: c.name }));
|
||||
items.forEach((i) => ids.push(i.value));
|
||||
groups.push({ grade: g.name, items });
|
||||
});
|
||||
classGroups.value = groups;
|
||||
allClassIds.value = ids;
|
||||
} catch {
|
||||
/* 忽略 */
|
||||
}
|
||||
}
|
||||
function toggleAll(v: boolean) {
|
||||
if (v) selClasses.value = [...allClassIds.value];
|
||||
}
|
||||
onMounted(loadClasses);
|
||||
|
||||
/* 旧项目验证规则(oldfront/new_homework.vue ruleValidate) */
|
||||
const ruleValidate = {
|
||||
title: [
|
||||
{ required: true, message: "请输入标题", trigger: "blur" },
|
||||
{ max: 50, message: "作业标题太长,请修改标题长度" },
|
||||
],
|
||||
dept: [{ required: true, message: "请选择发布部门", trigger: "change" }],
|
||||
subject: [{ required: true, message: "请选择学科", trigger: "change" }],
|
||||
deadline: [{ required: true, message: "请选择截止时间", trigger: "change" }],
|
||||
rdoname: [{ required: true, message: "请选择作业对象" }],
|
||||
};
|
||||
|
||||
/* 发文部门下拉 */
|
||||
const { data: deptOptions, loading: deptLoading } = useRequest(homeworkApi.getSysDic());
|
||||
|
||||
/* 是否开通短信 */
|
||||
onMounted(async () => {
|
||||
try {
|
||||
isSms.value = !!(await homeworkApi.getSchModuleIsSms().send());
|
||||
} catch {
|
||||
isSms.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
/* ============ 富文本编辑器(wangeditor v5 for vue3) ============ */
|
||||
/* ============ 富文本编辑器 ============ */
|
||||
const editorRef = shallowRef();
|
||||
const editorLoading = ref(true);
|
||||
const toolbarConfig = { excludeKeys: ["group-video", "fullScreen"] };
|
||||
const serviceBase = config.isMock ? "" : config.serviceHost;
|
||||
const editorConfig = {
|
||||
@@ -83,16 +87,10 @@ const editorConfig = {
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
const resp = await fetch(`${serviceBase}/api/file/UploadImage`, {
|
||||
method: "POST",
|
||||
body: fd,
|
||||
credentials: "include",
|
||||
});
|
||||
// ApiResponseAdvice 包装为 {code,msg,data},data 为 UpFileVo[](id/fileTrueName/fileName)
|
||||
const resp = await fetch(`${serviceBase}/api/file/UploadImage`, { method: "POST", body: fd, credentials: "include" });
|
||||
const res: { code: number; msg?: string; data?: UpFileVo[] } = await resp.json();
|
||||
if (res.code === 0 && Array.isArray(res.data) && res.data.length) {
|
||||
const f = res.data[0];
|
||||
// img src 必须是浏览器可直接访问的地址:Preview inline 输出图片(Down 是 attachment 不适合内嵌)
|
||||
const url = `${serviceBase}/api/file/Preview?id=${f.id}`;
|
||||
insertFn(url, f.fileTrueName || "", url);
|
||||
} else {
|
||||
@@ -107,73 +105,50 @@ const editorConfig = {
|
||||
};
|
||||
function handleCreated(editor: any) {
|
||||
editorRef.value = editor;
|
||||
editorLoading.value = false;
|
||||
}
|
||||
onBeforeUnmount(() => {
|
||||
editorRef.value?.destroy();
|
||||
});
|
||||
onBeforeUnmount(() => editorRef.value?.destroy());
|
||||
|
||||
/* ============ 定时发送 ============ */
|
||||
const isModal = reactive({ timiSend: false });
|
||||
const timeForm = reactive({ date1: "", date2: "" });
|
||||
const dateOpt = {
|
||||
disabledDate(date: Date) {
|
||||
return date && date.valueOf() < Date.now() - 86400000;
|
||||
},
|
||||
};
|
||||
|
||||
function dateFormat(d: Date, fmt: string) {
|
||||
/* ============ 定时发布 ============ */
|
||||
const timeModal = reactive({ open: false, date: "" });
|
||||
const dateOpt = { disabledDate: (d: Date) => d && d.valueOf() < Date.now() - 86400000 };
|
||||
function fmt(d: any) {
|
||||
if (!d) return "";
|
||||
const dt = d instanceof Date ? d : new Date(String(d).replace(/-/g, "/"));
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
const o: Record<string, number> = {
|
||||
"M+": d.getMonth() + 1,
|
||||
"d+": d.getDate(),
|
||||
"H+": d.getHours(),
|
||||
"m+": d.getMinutes(),
|
||||
"s+": d.getSeconds(),
|
||||
};
|
||||
if (/(y+)/.test(fmt)) fmt = fmt.replace(RegExp.$1, (d.getFullYear() + "").substring(4 - RegExp.$1.length));
|
||||
for (const k in o) {
|
||||
if (new RegExp("(" + k + ")").test(fmt)) {
|
||||
fmt = fmt.replace(RegExp.$1, RegExp.$1.length == 1 ? String(o[k]) : pad(o[k]));
|
||||
}
|
||||
}
|
||||
return fmt;
|
||||
return `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}:00`;
|
||||
}
|
||||
|
||||
/* ============ 编辑 / 转发回显 ============ */
|
||||
/* ============ 编辑回显 ============ */
|
||||
onMounted(async () => {
|
||||
if (isEdit.value || fid.value > 0) {
|
||||
const sourceId = isEdit.value ? serid.value : fid.value;
|
||||
try {
|
||||
const detail: HomeworkDetailVo = await homeworkApi.getHomeworkDetails(sourceId).send();
|
||||
if (isEdit.value) {
|
||||
// 旧版:编辑时标题去掉 [修订稿] 前缀,保存时再加回
|
||||
form.title = (detail.title || "").replace("[修订稿]", "");
|
||||
form.dept = detail.DeptID || "";
|
||||
form.subject = (detail as any).subject || "";
|
||||
form.deadline = (detail as any).deadline || "";
|
||||
submitTypes.value = Array.isArray((detail as any).submitType) && (detail as any).submitType.length ? (detail as any).submitType : ["text"];
|
||||
form.rdoname = detail.tlist === "all" || !Array.isArray(detail.tlist) ? "all" : detail.tlist.join(",");
|
||||
if (detail.DsfsTime) timeForm.date1 = detail.DsfsTime;
|
||||
if (!(isEdit.value || fid.value > 0)) return;
|
||||
const sourceId = isEdit.value ? serid.value : fid.value;
|
||||
try {
|
||||
const detail: HomeworkDetailVo = await homeworkApi.getHomeworkDetails(sourceId).send();
|
||||
if (isEdit.value) {
|
||||
form.title = (detail.title || "").replace("[修订稿]", "");
|
||||
form.subject = (detail as any).subject || "";
|
||||
form.deadline = (detail as any).deadline || "";
|
||||
submitTypes.value = Array.isArray((detail as any).submitType) && (detail as any).submitType.length ? (detail as any).submitType : ["image"];
|
||||
allowLate.value = !!(detail as any).allowLate;
|
||||
remindBefore.value = Array.isArray((detail as any).remindBefore) ? (detail as any).remindBefore : [];
|
||||
if (detail.tlist === "all" || !Array.isArray(detail.tlist)) {
|
||||
allClasses.value = true;
|
||||
selClasses.value = [...allClassIds.value];
|
||||
} else {
|
||||
form.title = detail.title || "";
|
||||
allClasses.value = false;
|
||||
selClasses.value = detail.tlist.map((v) => String(v));
|
||||
}
|
||||
form.content = detail.Contents || "";
|
||||
// 审批流回显:编辑时还原为“选人即送审”的多步配置
|
||||
if (isEdit.value && (detail as any).approvalEnabled && Array.isArray((detail as any).approvalFlow)) {
|
||||
const mv: FlowModel = {
|
||||
enabled: true,
|
||||
flow: (detail as any).approvalFlow.map((l: any) => ({ vote: l.vote === "all" ? "all" : "any", approvers: [...(l.approvers || [])] })),
|
||||
};
|
||||
nextTick(() => flowEditorRef.value?.setFlow(mv));
|
||||
}
|
||||
if (Array.isArray(detail.ChaoSend) && detail.ChaoSend.length) {
|
||||
editFileList.value = detail.ChaoSend.map((f) => ({ name: f.filename || f.name, fileid: f.fileid || f.id }));
|
||||
fileids.value = editFileList.value.map((f) => f.fileid);
|
||||
}
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "加载作业失败");
|
||||
if (detail.DsfsTime) timeModal.date = detail.DsfsTime;
|
||||
} else {
|
||||
form.title = detail.title || "";
|
||||
}
|
||||
form.content = detail.Contents || "";
|
||||
if (Array.isArray(detail.ChaoSend) && detail.ChaoSend.length) {
|
||||
editFileList.value = detail.ChaoSend.map((f) => ({ name: f.filename || f.name, fileid: f.fileid || f.id }));
|
||||
fileids.value = editFileList.value.map((f) => f.fileid);
|
||||
}
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "加载作业失败");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -183,83 +158,55 @@ const isLoading = ref(false);
|
||||
const { send: sendCreate } = useRequest((payload: HomeworkRequest) => homeworkApi.create(payload), { immediate: false });
|
||||
const { send: sendEdit } = useRequest((payload: HomeworkRequest) => homeworkApi.editHomework(payload), { immediate: false });
|
||||
|
||||
async function handleValidate(): Promise<boolean> {
|
||||
// view-ui-plus validate() 以 resolve(valid) 返回校验结果,不 reject
|
||||
const valid = await formRef.value?.validate();
|
||||
if (!valid) {
|
||||
Message.info("请填写红色星号内容!");
|
||||
}
|
||||
return !!valid;
|
||||
}
|
||||
|
||||
async function doAdd(_type?: string) {
|
||||
const isTime = _type === "isTime";
|
||||
if (!(await handleValidate())) return;
|
||||
if (!form.rdoname) {
|
||||
Message.info("请选择作业对象!");
|
||||
async function doAdd(mode: "publish" | "draft" | "time") {
|
||||
if (!(await formRef.value?.validate?.())) {
|
||||
Message.info("请填写带 * 的必填项");
|
||||
return;
|
||||
}
|
||||
// 勾了审批但一步都没选人 → 拦截
|
||||
if (flowEditorRef.value && !flowEditorRef.value.validate()) {
|
||||
Message.info("请为审批流程选择审批人,或取消勾选“走审批流程”");
|
||||
if (!allClasses.value && selClasses.value.length === 0) {
|
||||
Message.info("请选择接收班级");
|
||||
return;
|
||||
}
|
||||
const data: HomeworkRequest = {
|
||||
title: form.title,
|
||||
content: form.content,
|
||||
subject: form.subject,
|
||||
deadline: form.deadline ? String(form.deadline).replace(/\//g, "-").slice(0, 16) + ":00" : undefined,
|
||||
deadline: form.deadline ? fmt(form.deadline) : undefined,
|
||||
submitType: submitTypes.value.length ? submitTypes.value : ["text"],
|
||||
receiveList: form.rdoname, // all 或 班级 id 逗号串
|
||||
receiveType: "class", // 学生场景:接收对象到班级
|
||||
deptId: form.dept,
|
||||
fileIds: fileids.value.join(","), //附件
|
||||
smsFlag: form.isShortMsg ? 1 : 0, //是否发送短信
|
||||
allowLate: allowLate.value,
|
||||
remindBefore: remindBefore.value,
|
||||
receiveList: allClasses.value ? "ALL" : selClasses.value.join(","),
|
||||
receiveType: "class",
|
||||
deptId: "",
|
||||
fileIds: fileids.value.join(","),
|
||||
draft: mode === "draft",
|
||||
};
|
||||
// 选人即送审:有审批流(多步 flow,含每步会签/或签)则进入审批;否则后端直接发布
|
||||
if (flowModel.value.enabled && flowModel.value.flow.length) {
|
||||
data.flow = flowModel.value.flow;
|
||||
}
|
||||
if (isTime) {
|
||||
//定时发送
|
||||
if (!timeForm.date1) {
|
||||
Message.info("请选择发送日期!");
|
||||
if (mode === "time") {
|
||||
if (!timeModal.date) {
|
||||
Message.info("请选择定时发布时间");
|
||||
return;
|
||||
}
|
||||
if (!timeForm.date2) {
|
||||
Message.info("请选择发送时间!");
|
||||
const t = new Date(String(timeModal.date).replace(/-/g, "/")).getTime();
|
||||
if (t < Date.now()) {
|
||||
Message.info("定时时间需晚于当前时间");
|
||||
return;
|
||||
}
|
||||
const d = new Date(timeForm.date1);
|
||||
const [h, m] = String(timeForm.date2).split(":").map(Number);
|
||||
d.setHours(h || 0, m || 0, 0, 0);
|
||||
if (d.getTime() < Date.now()) {
|
||||
Message.info("请选择大于当前的时间!");
|
||||
return;
|
||||
}
|
||||
data.timingSendTime = dateFormat(d, "yyyy/MM/dd HH:mm:ss");
|
||||
}
|
||||
if (isEdit.value && timeForm.date1) {
|
||||
//编辑时已有定时时间,校验仍有效
|
||||
const t = new Date(timeForm.date1).getTime();
|
||||
if (t && t < Date.now()) {
|
||||
Message.info("请选择大于当前的时间!");
|
||||
return;
|
||||
}
|
||||
data.timingSendTime = dateFormat(new Date(timeForm.date1), "yyyy-MM-dd HH:mm:ss");
|
||||
data.timingSendTime = fmt(timeModal.date);
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
try {
|
||||
if (!isEdit.value) {
|
||||
await sendCreate(data);
|
||||
Message.success(flowModel.value.enabled ? "已提交,进入审批流程,通过后发布" : "发送成功");
|
||||
Message.success(mode === "draft" ? "已存草稿" : mode === "time" ? "已设置定时发布" : "发布成功");
|
||||
timeModal.open = false;
|
||||
router.push("/list");
|
||||
} else {
|
||||
data.sourceId = serid.value;
|
||||
if (data.title.indexOf("[修订稿]") == -1) data.title = "[修订稿]" + data.title;
|
||||
if (data.title && data.title.indexOf("[修订稿]") === -1) data.title = "[修订稿]" + data.title;
|
||||
await sendEdit(data);
|
||||
Message.success("保存成功");
|
||||
timeModal.open = false;
|
||||
router.push("/list");
|
||||
}
|
||||
} catch (e: any) {
|
||||
@@ -274,98 +221,103 @@ async function doAdd(_type?: string) {
|
||||
<div class="page-wrapper">
|
||||
<div class="main-container">
|
||||
<Form ref="formRef" class="myform" :model="form" :rules="ruleValidate" :label-width="100">
|
||||
<FormItem label="作业标题:" prop="title">
|
||||
<Input type="text" v-model="form.title" placeholder="请输入作业标题"></Input>
|
||||
</FormItem>
|
||||
<FormItem label="学科:" prop="subject">
|
||||
<Select v-model="form.subject" placeholder="请选择学科">
|
||||
<Option v-for="s in SUBJECTS" :key="s" :value="s">{{ s }}</Option>
|
||||
</Select>
|
||||
</FormItem>
|
||||
<FormItem label="截止时间:" prop="deadline">
|
||||
<DatePicker type="datetime" placeholder="请选择提交截止时间" v-model="form.deadline" format="yyyy-MM-dd HH:mm" style="width: 240px"></DatePicker>
|
||||
</FormItem>
|
||||
<FormItem label="来源部门:" prop="dept">
|
||||
<Select v-model="form.dept" placeholder="请选择部门" :loading="deptLoading">
|
||||
<Option value="">请选择部门</Option>
|
||||
<Option v-for="item in deptOptions" :key="item.k" :value="item.k">{{ item.v }}</Option>
|
||||
</Select>
|
||||
</FormItem>
|
||||
<FormItem label="接收班级:" prop="rdoname" class="homework-obj">
|
||||
<ClassCheckInput v-model="form.rdoname" :isEdit="isEdit"></ClassCheckInput>
|
||||
</FormItem>
|
||||
<FormItem label="提交方式:">
|
||||
<CheckboxGroup v-model="submitTypes">
|
||||
<Checkbox v-for="t in SUBMIT_TYPES" :key="t.v" :label="t.v">{{ t.label }}</Checkbox>
|
||||
</CheckboxGroup>
|
||||
</FormItem>
|
||||
<FormItem label="添加附件:">
|
||||
<HomeworkUpload v-model:fileids="fileids" :defaultList="editFileList"></HomeworkUpload>
|
||||
</FormItem>
|
||||
<FormItem label="审批:">
|
||||
<ApprovalFlowEditor ref="flowEditorRef" v-model="flowModel"></ApprovalFlowEditor>
|
||||
</FormItem>
|
||||
</Form>
|
||||
|
||||
<!-- 富文本编辑器 -->
|
||||
<div class="editor-wrap">
|
||||
<Toolbar class="editor-toolbar" :editor="editorRef" :defaultConfig="toolbarConfig" mode="default" />
|
||||
<Editor
|
||||
class="editor-content"
|
||||
v-model="form.content"
|
||||
:defaultConfig="editorConfig"
|
||||
mode="default"
|
||||
@onCreated="handleCreated"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="bottom-btn">
|
||||
<Button type="primary" :loading="isLoading" @click="doAdd()">{{ isEdit ? "保存" : "发送" }}</Button>
|
||||
<Button v-if="!isEdit" style="margin-left: 10px" @click="isModal.timiSend = true">定时发送</Button>
|
||||
</div>
|
||||
|
||||
<!-- 定时发送 -->
|
||||
<Modal v-model="isModal.timiSend" title="定时发送" width="420px">
|
||||
<Form class="myform" :label-width="100" inline>
|
||||
<FormItem label="发送日期">
|
||||
<DatePicker
|
||||
type="date"
|
||||
transfer
|
||||
v-model="timeForm.date1"
|
||||
format="yyyy-MM-dd"
|
||||
:options="dateOpt"
|
||||
placeholder="选择发送日期"
|
||||
style="width: 240px"
|
||||
></DatePicker>
|
||||
<FormItem label="作业标题:" prop="title">
|
||||
<Input v-model="form.title" placeholder="请输入作业标题(50 字以内)"></Input>
|
||||
</FormItem>
|
||||
<FormItem label="发送时间">
|
||||
<TimePicker
|
||||
type="time"
|
||||
transfer
|
||||
v-model="timeForm.date2"
|
||||
format="HH:mm"
|
||||
:steps="[1, 10]"
|
||||
placeholder="选择发送时间"
|
||||
style="width: 240px"
|
||||
></TimePicker>
|
||||
<FormItem label="学科:" prop="subject">
|
||||
<Select v-model="form.subject" placeholder="请选择学科" style="width: 240px">
|
||||
<Option v-for="s in SUBJECTS" :key="s" :value="s">{{ s }}</Option>
|
||||
</Select>
|
||||
</FormItem>
|
||||
<FormItem label="接收班级:" required>
|
||||
<div class="cls-row">
|
||||
<Select
|
||||
v-model="selClasses"
|
||||
multiple
|
||||
:disabled="allClasses"
|
||||
placeholder="选择班级(可多选)"
|
||||
style="width: 420px"
|
||||
@on-change="allClasses = false"
|
||||
>
|
||||
<OptionGroup v-for="g in classGroups" :key="g.grade" :label="g.grade">
|
||||
<Option v-for="c in g.items" :key="c.value" :value="c.value">{{ c.label }}</Option>
|
||||
</OptionGroup>
|
||||
</Select>
|
||||
<Checkbox v-model="allClasses" @on-change="toggleAll">全部班级</Checkbox>
|
||||
</div>
|
||||
<div class="hint">发布前必须选择参与班级</div>
|
||||
</FormItem>
|
||||
<FormItem label="截止时间:">
|
||||
<DatePicker type="datetime" placeholder="截止时间(精确到分钟,不填=不截止)" v-model="form.deadline" format="yyyy-MM-dd HH:mm" style="width: 260px"></DatePicker>
|
||||
<div class="hint">留空表示不设截止;选择「仅查阅」的作业学生无需提交</div>
|
||||
</FormItem>
|
||||
<FormItem label="提交方式:">
|
||||
<CheckboxGroup v-model="submitTypes">
|
||||
<Checkbox v-for="t in SUBMIT_TYPES" :key="t.v" :label="t.v">{{ t.label }}</Checkbox>
|
||||
</CheckboxGroup>
|
||||
</FormItem>
|
||||
<FormItem label="逾期提交:">
|
||||
<i-switch v-model="allowLate" />
|
||||
<span class="inline-hint">截止后仍允许学生补交</span>
|
||||
</FormItem>
|
||||
<FormItem v-if="form.deadline" label="截止提醒:">
|
||||
<CheckboxGroup v-model="remindBefore">
|
||||
<Checkbox v-for="r in REMIND_OPTS" :key="r.v" :label="r.v">{{ r.label }}</Checkbox>
|
||||
</CheckboxGroup>
|
||||
</FormItem>
|
||||
<FormItem label="添加附件:">
|
||||
<HomeworkUpload v-model:fileids="fileids" :defaultList="editFileList"></HomeworkUpload>
|
||||
</FormItem>
|
||||
</Form>
|
||||
<template #footer>
|
||||
<Button type="primary" :loading="isLoading" @click="doAdd('isTime')">提交</Button>
|
||||
<Button @click="isModal.timiSend = false" style="margin-left: 8px">取消</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<div class="editor-wrap">
|
||||
<Toolbar class="editor-toolbar" :editor="editorRef" :defaultConfig="toolbarConfig" mode="default" />
|
||||
<Editor class="editor-content" v-model="form.content" :defaultConfig="editorConfig" mode="default" @onCreated="handleCreated" />
|
||||
</div>
|
||||
|
||||
<div class="bottom-btn">
|
||||
<Button type="primary" :loading="isLoading" icon="md-send" @click="doAdd('publish')">立即发布</Button>
|
||||
<Button :loading="isLoading" icon="md-save-outline" style="margin-left: 10px" @click="doAdd('draft')">存草稿</Button>
|
||||
<Button icon="md-time" style="margin-left: 10px" @click="timeModal.open = true">定时发布</Button>
|
||||
</div>
|
||||
|
||||
<Modal v-model="timeModal.open" title="定时发布" width="420px">
|
||||
<Form :label-width="90">
|
||||
<FormItem label="发布时间">
|
||||
<DatePicker type="datetime" transfer v-model="timeModal.date" format="yyyy-MM-dd HH:mm" :options="dateOpt" placeholder="选择发布时间" style="width: 240px"></DatePicker>
|
||||
</FormItem>
|
||||
</Form>
|
||||
<template #footer>
|
||||
<Button @click="timeModal.open = false">取消</Button>
|
||||
<Button type="primary" :loading="isLoading" @click="doAdd('time')">确定</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* 页面超高时的滚动由 App.vue 的 .main-wrapper 统一承接 */
|
||||
.myform {
|
||||
:deep(.ivu-form-item) {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
}
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
color: #c5c8ce;
|
||||
line-height: 1.6;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.inline-hint {
|
||||
font-size: 12px;
|
||||
color: #808695;
|
||||
margin-left: 10px;
|
||||
}
|
||||
.cls-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
.editor-wrap {
|
||||
border: 1px solid #dcdee2;
|
||||
border-radius: 4px;
|
||||
@@ -383,13 +335,4 @@ async function doAdd(_type?: string) {
|
||||
.bottom-btn {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.approval-hint {
|
||||
font-size: 12px;
|
||||
color: #808695;
|
||||
line-height: 1.5;
|
||||
margin-top: 2px;
|
||||
b {
|
||||
color: #f60;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,10 +5,18 @@ 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 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 role = computed(() => (route.meta as any).role || "student");
|
||||
const isParent = computed(() => role.value === "parent");
|
||||
|
||||
const TABS = [
|
||||
{ name: "all", label: "全部" },
|
||||
@@ -48,34 +56,63 @@ const filtered = computed(() => {
|
||||
if (tab.value === "all") return list;
|
||||
return list.filter((x) => bucketOf(x) === tab.value);
|
||||
});
|
||||
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: "default", submitted: "warning", graded: "success", needRedo: "error", redone: "primary" };
|
||||
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"];
|
||||
}
|
||||
|
||||
function dueInfo(deadline: string | null) {
|
||||
if (!deadline) return { text: "无截止", overdue: false };
|
||||
const d = new Date(String(deadline).replace(/-/g, "/")).getTime();
|
||||
const now = Date.now();
|
||||
const diffH = (d - now) / 3600000;
|
||||
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 };
|
||||
}
|
||||
|
||||
/* ============ 提交 / 重交 ============ */
|
||||
const sub = reactive({ open: false, serid: 0, title: "", text: "", fileids: [] as number[], isRedo: false, submitting: false });
|
||||
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) {
|
||||
sub.open = true;
|
||||
sub.serid = 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;
|
||||
}
|
||||
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, attachments: sub.fileids.join(",") }).send();
|
||||
await homeworkApi.submit({ serid: sub.serid, text: sub.text, images: sub.images, attachments: sub.fileids.map(String) }).send();
|
||||
Message.success("提交成功");
|
||||
sub.open = false;
|
||||
load();
|
||||
@@ -93,215 +130,482 @@ function openFeedback(row: StudentHomeworkVo) {
|
||||
fb.row = row;
|
||||
}
|
||||
|
||||
/* ============ 家长提醒 ============ */
|
||||
async function nudge(row: StudentHomeworkVo) {
|
||||
try {
|
||||
await homeworkApi.parentNudge(row.id).send();
|
||||
Message.success("已提醒孩子");
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "提醒失败");
|
||||
}
|
||||
/* ============ 图片放大预览 ============ */
|
||||
const imgPreview = reactive({ open: false, url: "" });
|
||||
function zoom(url: string) {
|
||||
imgPreview.url = url;
|
||||
imgPreview.open = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-wrapper">
|
||||
<div class="main-container">
|
||||
<div class="head fix mb-12">
|
||||
<span class="h-title">{{ isParent ? "孩子作业" : "我的作业" }}</span>
|
||||
<span class="h-sub" v-if="data">{{ isParent ? "(只读查看 + 提醒)" : "待完成 " + data.buckets.todo + " · 待订正 " + data.buckets.redo }}</span>
|
||||
<div class="main-container stu">
|
||||
<!-- 顶部概览 -->
|
||||
<div class="stu-head">
|
||||
<div class="sh-left">
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<Tabs :value="tab" @on-click="(n: string) => (tab = n)" class="mb-12">
|
||||
<TabPane v-for="t in TABS" :key="t.name" :name="t.name" :label="t.label + (t.name !== 'all' && data ? ' ' + (t.name==='todo'?data.buckets.todo:t.name==='submitted'?data.buckets.submitted:t.name==='graded'?data.buckets.graded:data.buckets.redo) : '')"></TabPane>
|
||||
<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>
|
||||
|
||||
<Spin v-if="loading" fix />
|
||||
<p v-if="!loading && !filtered.length" class="tip">该分类下暂无作业。</p>
|
||||
<div v-if="loading" class="state"><Spin fix /><div style="height: 120px"></div></div>
|
||||
<div v-else-if="!filtered.length" class="state empty">
|
||||
<Icon type="ios-albums-outline" size="52" color="#c5c8ce" />
|
||||
<p>该分类下暂无作业</p>
|
||||
</div>
|
||||
|
||||
<div class="cards">
|
||||
<div class="card" v-for="row in filtered" :key="row.id">
|
||||
<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>
|
||||
<span class="c-title">{{ row.title }}</span>
|
||||
<Tag v-if="row.viewOnly" color="default">仅查阅</Tag>
|
||||
<Tag v-else :color="STATUS_COLOR[row.my ? row.my.submitStatus : 'unsubmitted']">{{ STATUS_TEXT[row.my ? row.my.submitStatus : "unsubmitted"] }}</Tag>
|
||||
<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>
|
||||
</div>
|
||||
<div class="c-meta">
|
||||
<span>{{ row.publisherName }} · {{ row.deptName }}</span>
|
||||
<span :class="{ overdue: dueInfo(row.deadline).overdue && bucketOf(row) === 'todo' }">截止 {{ row.deadline ? String(row.deadline).slice(5, 16) : "—" }}({{ dueInfo(row.deadline).text }})</span>
|
||||
<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>
|
||||
</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>
|
||||
<div class="c-grade warn" v-else-if="row.my && row.my.submitStatus === 'needRedo'">
|
||||
老师要求订正:{{ row.my.comment }}
|
||||
<Icon type="md-refresh" color="#ed4014" size="16" /> 老师要求订正:{{ row.my.comment }}
|
||||
</div>
|
||||
|
||||
<div class="c-actions">
|
||||
<template v-if="!isParent">
|
||||
<Button v-if="bucketOf(row) === 'todo'" type="primary" size="small" @click="openSubmit(row)">提交作业</Button>
|
||||
<Button v-else-if="bucketOf(row) === 'redo'" type="error" size="small" @click="openSubmit(row)">重新提交</Button>
|
||||
<Button
|
||||
v-if="row.my && (row.my.submitStatus === 'graded' || row.my.submitStatus === 'redone' || row.my.submitStatus === 'needRedo')"
|
||||
size="small"
|
||||
@click="openFeedback(row)"
|
||||
>查看批改</Button
|
||||
>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Button
|
||||
v-if="row.my && (row.my.submitStatus === 'graded' || row.my.submitStatus === 'redone' || row.my.submitStatus === 'needRedo')"
|
||||
size="small"
|
||||
@click="openFeedback(row)"
|
||||
>查看批改</Button
|
||||
>
|
||||
<Button v-if="bucketOf(row) === 'todo' || bucketOf(row) === 'redo'" type="primary" size="small" ghost @click="nudge(row)">提醒孩子</Button>
|
||||
</template>
|
||||
<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
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 提交弹窗 -->
|
||||
<Modal v-model="sub.open" :title="(sub.isRedo ? '重新提交 · ' : '提交作业 · ') + sub.title" width="560px">
|
||||
<Form :label-width="72">
|
||||
<FormItem label="文字作答">
|
||||
<Input v-model="sub.text" type="textarea" :rows="4" placeholder="输入作答内容"></Input>
|
||||
</FormItem>
|
||||
<FormItem label="图片/附件">
|
||||
<HomeworkUpload v-model:fileids="sub.fileids"></HomeworkUpload>
|
||||
</FormItem>
|
||||
</Form>
|
||||
<Modal v-model="sub.open" :title="(sub.isRedo ? '重新提交 · ' : '提交作业 · ') + sub.title" width="560px" class-name="submit-modal">
|
||||
<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>
|
||||
<template #footer>
|
||||
<Button @click="sub.open = false">取消</Button>
|
||||
<Button type="primary" :loading="sub.submitting" @click="submit">提交</Button>
|
||||
<Button type="primary" :loading="sub.submitting" icon="md-send" @click="submit">提交</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- 批改反馈弹窗 -->
|
||||
<Modal v-model="fb.open" title="批改反馈" width="480px">
|
||||
<Modal v-model="fb.open" title="批改反馈" width="520px">
|
||||
<div v-if="fb.row && fb.row.my" class="fb">
|
||||
<div class="fb-row"><span>学生提交</span><span>{{ fb.row.my.submittedAt ? String(fb.row.my.submittedAt).slice(0, 16) : "—" }}</span></div>
|
||||
<div class="fb-row"><span>作答内容</span><span class="fb-text">{{ fb.row.my.text || "(仅附件)" }}</span></div>
|
||||
<div class="fb-row"><span>得分</span><span class="fb-score">{{ fb.row.my.score ?? "—" }}</span></div>
|
||||
<div class="fb-row"><span>等级</span><span>{{ fb.row.my.level || "—" }}</span></div>
|
||||
<div class="fb-row"><span>评语</span><span>{{ fb.row.my.comment || "—" }}</span></div>
|
||||
<div class="fb-row" v-if="fb.row.my.isExcellent"><span></span><Tag color="success">优秀作业</Tag></div>
|
||||
<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>
|
||||
</Modal>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.head {
|
||||
.h-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #17233d;
|
||||
.stu {
|
||||
max-width: 960px;
|
||||
}
|
||||
.stu-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: linear-gradient(135deg, #2d8cf0, #5cadff);
|
||||
border-radius: 10px;
|
||||
padding: 16px 20px;
|
||||
color: #fff;
|
||||
margin-bottom: 14px;
|
||||
box-shadow: 0 4px 14px rgba(45, 140, 240, 0.25);
|
||||
.sh-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.h-sub {
|
||||
margin-left: 10px;
|
||||
.sh-icon {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.sh-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.sh-sub {
|
||||
font-size: 12px;
|
||||
opacity: 0.85;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.sh-chips {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
.chip {
|
||||
min-width: 66px;
|
||||
text-align: center;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
b {
|
||||
display: block;
|
||||
font-size: 20px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
span {
|
||||
font-size: 12px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.stu-tabs {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.state {
|
||||
padding: 30px 0;
|
||||
text-align: center;
|
||||
&.empty {
|
||||
color: #a6aab0;
|
||||
p {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.cards .card {
|
||||
background: #fff;
|
||||
border: 1px solid #eceef1;
|
||||
border-left: 4px solid #c5c8ce;
|
||||
border-radius: 8px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 12px;
|
||||
transition: box-shadow 0.2s;
|
||||
&:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.c-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
.c-subj {
|
||||
background: #eaf3ff;
|
||||
color: #2d8cf0;
|
||||
border-radius: 4px;
|
||||
padding: 2px 9px;
|
||||
font-size: 12px;
|
||||
flex: none;
|
||||
}
|
||||
.c-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #17233d;
|
||||
}
|
||||
.c-flag {
|
||||
margin-left: auto;
|
||||
}
|
||||
.c-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-left: auto;
|
||||
}
|
||||
.c-status {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
.c-meta {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
margin-top: 8px;
|
||||
color: #808695;
|
||||
font-size: 13px;
|
||||
.m-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
.c-grade {
|
||||
margin-top: 10px;
|
||||
padding: 8px 12px;
|
||||
background: #f6ffed;
|
||||
border-radius: 6px;
|
||||
color: #389e0d;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
&.warn {
|
||||
background: #fff1f0;
|
||||
color: #cf1322;
|
||||
}
|
||||
.g-badge {
|
||||
background: #ff9900;
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
}
|
||||
.c-actions {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
.mb-12 {
|
||||
margin-bottom: 12px;
|
||||
/* 提交弹窗 */
|
||||
.sm-field {
|
||||
margin-bottom: 14px;
|
||||
.sm-label {
|
||||
font-size: 13px;
|
||||
color: #515a6e;
|
||||
margin-bottom: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
.tip {
|
||||
color: #808695;
|
||||
padding: 16px 0;
|
||||
}
|
||||
.cards {
|
||||
.card {
|
||||
.img-wall {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
.img-cell {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border: 1px solid #e8eaec;
|
||||
border-radius: 6px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 12px;
|
||||
background: #fff;
|
||||
.c-top {
|
||||
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;
|
||||
gap: 10px;
|
||||
.c-subj {
|
||||
background: #e8f3ff;
|
||||
color: #2d8cf0;
|
||||
border-radius: 3px;
|
||||
padding: 1px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.c-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #17233d;
|
||||
}
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
border-bottom-left-radius: 6px;
|
||||
}
|
||||
.c-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: #808695;
|
||||
font-size: 13px;
|
||||
margin-top: 6px;
|
||||
.overdue {
|
||||
color: #ed4014;
|
||||
}
|
||||
}
|
||||
.c-body {
|
||||
margin-top: 8px;
|
||||
color: #515a6e;
|
||||
line-height: 1.6;
|
||||
max-height: 76px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.c-grade {
|
||||
margin-top: 8px;
|
||||
padding: 6px 10px;
|
||||
background: #f6ffed;
|
||||
border-radius: 4px;
|
||||
color: #389e0d;
|
||||
font-size: 13px;
|
||||
&.warn {
|
||||
background: #fff2f0;
|
||||
color: #cf1322;
|
||||
}
|
||||
.g-badge {
|
||||
background: #ff9900;
|
||||
color: #fff;
|
||||
border-radius: 3px;
|
||||
padding: 0 6px;
|
||||
margin-right: 6px;
|
||||
}
|
||||
}
|
||||
.c-actions {
|
||||
margin-top: 12px;
|
||||
button {
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
.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;
|
||||
span:first-child {
|
||||
width: 80px;
|
||||
.fb-k {
|
||||
width: 76px;
|
||||
color: #808695;
|
||||
}
|
||||
.fb-score {
|
||||
font-weight: 600;
|
||||
color: #ed4014;
|
||||
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 {
|
||||
max-width: 100%;
|
||||
max-height: 62vh;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,6 +4,20 @@ import { useRoute } from "vue-router";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { homeworkApi } from "@/api/module/homework";
|
||||
import type { SubmissionsResultVo, SubmissionBrief, HomeworkVo } from "@/types/homework";
|
||||
import config from "@/assets/config/host.config";
|
||||
import Utils from "@/utils";
|
||||
|
||||
const serviceBase = config.isMock ? "" : config.serviceHost;
|
||||
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 imgPreview = reactive({ open: false, url: "" });
|
||||
function zoom(url: string) {
|
||||
imgPreview.url = url;
|
||||
imgPreview.open = true;
|
||||
}
|
||||
|
||||
const route = useRoute();
|
||||
const serid = ref<number>(Number(route.query.serid) || 0);
|
||||
@@ -78,13 +92,25 @@ const stuColumns = [
|
||||
title: "操作",
|
||||
width: 110,
|
||||
align: "center",
|
||||
render: (h: any, p: any) => h("Button", { type: "text", size: "small", onClick: () => openGrade(p.row as SubmissionBrief) }, () => (p.row.hasGrade ? "再批" : "批改")),
|
||||
render: (h: any, p: any) => Utils.operatingHandle(h, p.row.hasGrade ? "再批" : "批改", p.row.hasGrade ? "icon-edit" : "icon-chakan", () => openGrade(p.row as SubmissionBrief)),
|
||||
},
|
||||
];
|
||||
|
||||
/* ============ 内联批改(走 Grade 接口,核心闭环即时批改) ============ */
|
||||
const gradeModal = reactive({ open: false, subId: 0, studentName: "", mode: "grade", action: "grade", score: "" as string | number, level: "A", comment: "" });
|
||||
function openGrade(stu: SubmissionBrief) {
|
||||
const gradeModal = reactive({
|
||||
open: false,
|
||||
subId: 0,
|
||||
studentName: "",
|
||||
mode: "grade",
|
||||
action: "grade",
|
||||
score: "" as string | number,
|
||||
level: "A",
|
||||
comment: "",
|
||||
answerText: "",
|
||||
answerImages: [] as string[],
|
||||
loadingAnswer: false,
|
||||
});
|
||||
async function openGrade(stu: SubmissionBrief) {
|
||||
gradeModal.open = true;
|
||||
gradeModal.subId = stu.subId;
|
||||
gradeModal.studentName = stu.studentName;
|
||||
@@ -93,6 +119,18 @@ function openGrade(stu: SubmissionBrief) {
|
||||
gradeModal.score = stu.score ?? "";
|
||||
gradeModal.level = stu.level || "A";
|
||||
gradeModal.comment = "";
|
||||
gradeModal.answerText = "";
|
||||
gradeModal.answerImages = [];
|
||||
gradeModal.loadingAnswer = true;
|
||||
try {
|
||||
const d = await homeworkApi.submissionDetail(stu.subId).send();
|
||||
gradeModal.answerText = d.text || "";
|
||||
gradeModal.answerImages = (d.images || []).map((v: any) => String(v)).filter(Boolean);
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "加载作答失败");
|
||||
} finally {
|
||||
gradeModal.loadingAnswer = false;
|
||||
}
|
||||
}
|
||||
async function doGrade(action: "grade" | "redo" | "excellent") {
|
||||
const payload: any = { subId: gradeModal.subId, action };
|
||||
@@ -166,7 +204,20 @@ function onSave() {
|
||||
</template>
|
||||
|
||||
<!-- 批改弹窗 -->
|
||||
<Modal v-model="gradeModal.open" :title="`批改 · ${gradeModal.studentName}`" width="440px">
|
||||
<Modal v-model="gradeModal.open" :title="`批改 · ${gradeModal.studentName}`" width="520px">
|
||||
<div class="ans-box">
|
||||
<div class="ans-h">学生作答</div>
|
||||
<Spin v-if="gradeModal.loadingAnswer" />
|
||||
<template v-else>
|
||||
<div class="ans-text">{{ gradeModal.answerText || "(无文字作答)" }}</div>
|
||||
<div class="ans-imgs" v-if="gradeModal.answerImages.length">
|
||||
<div class="ans-img" v-for="(u, ui) in gradeModal.answerImages" :key="ui" @click="zoom(previewUrl(u))">
|
||||
<img :src="previewUrl(u)" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<Divider style="margin: 8px 0" />
|
||||
<Form :label-width="72">
|
||||
<FormItem label="方式">
|
||||
<RadioGroup v-model="gradeModal.mode">
|
||||
@@ -192,15 +243,67 @@ function onSave() {
|
||||
</Form>
|
||||
<template #footer>
|
||||
<Button @click="gradeModal.open = false">取消</Button>
|
||||
<Button type="warning" style="margin-left: 8px" @click="doGrade('excellent')">标为优秀</Button>
|
||||
<Button type="primary" :loading="loading" @click="onSave">保存</Button>
|
||||
<Button type="warning" icon="md-star" style="margin-left: 8px" @click="doGrade('excellent')">标为优秀</Button>
|
||||
<Button type="primary" icon="md-checkmark" :loading="loading" @click="onSave">保存</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- 作答图片放大预览 -->
|
||||
<Modal v-model="imgPreview.open" footer-hide title="作答图片" width="680px">
|
||||
<div class="zoom-wrap"><img :src="imgPreview.url" /></div>
|
||||
</Modal>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ans-box {
|
||||
.ans-h {
|
||||
font-weight: 600;
|
||||
color: #17233d;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.ans-text {
|
||||
color: #515a6e;
|
||||
line-height: 1.6;
|
||||
background: #fafcff;
|
||||
border: 1px solid #e8eaec;
|
||||
border-radius: 4px;
|
||||
padding: 8px 10px;
|
||||
max-height: 140px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.ans-imgs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
.ans-img {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.zoom-wrap {
|
||||
text-align: center;
|
||||
img {
|
||||
max-width: 100%;
|
||||
max-height: 62vh;
|
||||
}
|
||||
}
|
||||
.tip {
|
||||
color: #808695;
|
||||
padding: 20px 0;
|
||||
|
||||
@@ -0,0 +1,522 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch, h } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useRequest } from "alova/client";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { teacherApi } from "@/api/module/teacherHomework";
|
||||
import type { HomeworkV2Vo, TeacherHomeworkQuery } from "@/types/homeworkV2";
|
||||
import type { BasePageResult } from "@/types/homeworkV2";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import Utils from "@/utils";
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const SUBJECTS = ["语文", "数学", "英语", "物理", "化学", "生物", "道德与法治", "历史", "地理", "科学", "综合实践"];
|
||||
|
||||
/* ============ 顶级视图:已发布 / 草稿箱 ============ */
|
||||
type ViewMode = "published" | "drafts";
|
||||
const viewMode = ref<ViewMode>("published");
|
||||
|
||||
/* 已发布状态筛选(空 = 全部) */
|
||||
const PUB_TABS = [
|
||||
{ name: "", label: "全部" },
|
||||
{ name: "PUBLISHED", label: "进行中" },
|
||||
{ name: "CLOSED", label: "已截止" },
|
||||
{ name: "ARCHIVED", label: "已归档" },
|
||||
];
|
||||
const pubStatus = ref("");
|
||||
|
||||
/* ============ 搜索条件 ============ */
|
||||
const keyword = ref("");
|
||||
const subject = ref("");
|
||||
|
||||
/* ============ 分页 ============ */
|
||||
const pageNum = ref(1);
|
||||
const pageSize = ref(parseInt(localStorage.getItem("customPageCount") || "10"));
|
||||
|
||||
const EMPTY_PAGE: BasePageResult<HomeworkV2Vo> = { content: [], total: 0, pageIndex: 1, pageSize: 10, totalPages: 0, hasNext: false };
|
||||
const { data: page, loading, send: reload } = useRequest((params: TeacherHomeworkQuery, mode: ViewMode) =>
|
||||
mode === "drafts" ? teacherApi.drafts(params) : teacherApi.published(params),
|
||||
{ immediate: false, initialData: EMPTY_PAGE });
|
||||
|
||||
async function run() {
|
||||
const params: TeacherHomeworkQuery = {
|
||||
keyword: keyword.value || undefined,
|
||||
subject: subject.value || undefined,
|
||||
pageIndex: pageNum.value,
|
||||
pageSize: pageSize.value,
|
||||
};
|
||||
if (viewMode.value === "published" && pubStatus.value) params.hwStatus = pubStatus.value;
|
||||
try {
|
||||
await reload(params, viewMode.value);
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "获取作业列表失败");
|
||||
}
|
||||
}
|
||||
function doSearch() {
|
||||
pageNum.value = 1;
|
||||
run();
|
||||
}
|
||||
function switchView(name: string) {
|
||||
viewMode.value = name as ViewMode;
|
||||
pageNum.value = 1;
|
||||
run();
|
||||
}
|
||||
function onPubTab(name: string) {
|
||||
pubStatus.value = name;
|
||||
doSearch();
|
||||
}
|
||||
function pageUpdate(p: number) {
|
||||
pageNum.value = p;
|
||||
run();
|
||||
}
|
||||
function pageSizeUpdate(size: number) {
|
||||
pageNum.value = 1;
|
||||
pageSize.value = size;
|
||||
localStorage.setItem("customPageCount", String(size));
|
||||
run();
|
||||
}
|
||||
|
||||
run();
|
||||
watch(
|
||||
() => router.currentRoute.value.path,
|
||||
() => {
|
||||
pageNum.value = 1;
|
||||
keyword.value = "";
|
||||
subject.value = "";
|
||||
pubStatus.value = "";
|
||||
viewMode.value = "published";
|
||||
run();
|
||||
}
|
||||
);
|
||||
|
||||
/* ============ 状态色 ============ */
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
DRAFT_UNPUBLISHED: "default",
|
||||
DRAFT_SCHEDULED: "blue",
|
||||
PUBLISHED: "primary",
|
||||
CLOSED: "warning",
|
||||
ARCHIVED: "success",
|
||||
};
|
||||
function fmtTime(v?: string | null): string {
|
||||
return v ? String(v).slice(0, 16) : "—";
|
||||
}
|
||||
|
||||
/* ============ 行操作 ============ */
|
||||
function viewDetail(row: HomeworkV2Vo) {
|
||||
router.push({ path: "/list/detail", query: { serid: String(row.id) } });
|
||||
}
|
||||
function viewSubmissions(row: HomeworkV2Vo) {
|
||||
router.push({ path: "/submissions", query: { serid: String(row.id) } });
|
||||
}
|
||||
function toGrade(row: HomeworkV2Vo) {
|
||||
router.push({ path: "/grade", query: { serid: String(row.id) } });
|
||||
}
|
||||
function edit(row: HomeworkV2Vo) {
|
||||
router.push({ path: "/list/new", query: { serid: String(row.id) } });
|
||||
}
|
||||
function remind(row: HomeworkV2Vo, type: "NOT_READ" | "NOT_SUBMITTED") {
|
||||
const label = type === "NOT_READ" ? "未读" : "未交";
|
||||
MyConfirm({
|
||||
title: "提醒",
|
||||
msg: `向“ ${row.title} ”的${label}学生发送提醒?`,
|
||||
async onOk() {
|
||||
try {
|
||||
const r = await teacherApi.remind(row.id, type).send();
|
||||
Message.success(`已提醒 ${r?.reminded ?? 0} 位${label}学生`);
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "提醒失败");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/* 延长截止(仅进行中;新截止须晚于 now 与原截止) */
|
||||
const extendModal = reactive({ open: false, row: null as HomeworkV2Vo | null, time: null as Date | null, saving: false });
|
||||
function openExtend(row: HomeworkV2Vo) {
|
||||
extendModal.row = row;
|
||||
extendModal.time = null;
|
||||
extendModal.open = true;
|
||||
}
|
||||
async function doExtend() {
|
||||
const row = extendModal.row;
|
||||
if (!row) return;
|
||||
if (!extendModal.time) {
|
||||
Message.info("请选择新的截止时间");
|
||||
return;
|
||||
}
|
||||
const d = extendModal.time;
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
const at = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:00`;
|
||||
if (row.deadlineAt && at <= String(row.deadlineAt)) {
|
||||
Message.info("新截止时间必须晚于原截止时间");
|
||||
return;
|
||||
}
|
||||
extendModal.saving = true;
|
||||
try {
|
||||
await teacherApi.extend(row.id, at).send();
|
||||
Message.success("截止时间已延长");
|
||||
extendModal.open = false;
|
||||
run();
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "延长截止失败");
|
||||
} finally {
|
||||
extendModal.saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function copyRow(row: HomeworkV2Vo) {
|
||||
MyConfirm({
|
||||
title: "复制",
|
||||
msg: `复制“ ${row.title} ”为新草稿?`,
|
||||
info: "复制作业内容与班级设置,不含提交与阅读数据",
|
||||
async onOk() {
|
||||
try {
|
||||
const newId = await teacherApi.copy(row.id).send();
|
||||
Message.success("已复制为新草稿");
|
||||
router.push({ path: "/list/new", query: { serid: String(newId) } });
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "复制失败");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
function archive(row: HomeworkV2Vo) {
|
||||
MyConfirm({
|
||||
title: "归档",
|
||||
msg: `归档“ ${row.title} ”?`,
|
||||
info: "归档后作业只读,不再触发任何提醒",
|
||||
async onOk() {
|
||||
try {
|
||||
await teacherApi.archive(row.id).send();
|
||||
Message.success("已归档");
|
||||
run();
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "归档失败");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
function doExport(row: HomeworkV2Vo) {
|
||||
window.location.href = teacherApi.exportUrl(row.id);
|
||||
}
|
||||
|
||||
/* 草稿箱操作 */
|
||||
function publishDraft(row: HomeworkV2Vo) {
|
||||
MyConfirm({
|
||||
title: "发布",
|
||||
msg: `立即发布“ ${row.title} ”?`,
|
||||
info: "发布后参与班级学生将收到作业通知",
|
||||
async onOk() {
|
||||
try {
|
||||
await teacherApi.publish(row.id).send();
|
||||
Message.success("发布成功");
|
||||
switchView("published");
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "发布失败");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
function cancelSchedule(row: HomeworkV2Vo) {
|
||||
MyConfirm({
|
||||
title: "取消定时",
|
||||
msg: `取消“ ${row.title} ”的定时发布?`,
|
||||
info: "取消后作业回到草稿状态",
|
||||
async onOk() {
|
||||
try {
|
||||
await teacherApi.schedule(row.id, { cancel: true }).send();
|
||||
Message.success("已取消定时");
|
||||
run();
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "取消定时失败");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
function del(row: HomeworkV2Vo) {
|
||||
MyConfirm({
|
||||
title: "删除",
|
||||
msg: `确定删除“ ${row.title} ”这条作业吗?`,
|
||||
info: "删除后不可恢复",
|
||||
async onOk() {
|
||||
try {
|
||||
await teacherApi.remove(row.id).send();
|
||||
Message.success("删除成功");
|
||||
if ((page.value?.content?.length || 0) <= 1 && pageNum.value > 1) pageNum.value -= 1;
|
||||
run();
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "删除失败");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/* 文本按钮 */
|
||||
function btn(text: string, onClick: () => void) {
|
||||
return h("Button", { type: "text", size: "small", onClick }, () => text);
|
||||
}
|
||||
|
||||
/* ============ 已发布列表列 ============ */
|
||||
const pubColumns = computed(() => [
|
||||
{ title: "序号", type: "index", width: 60, align: "center" },
|
||||
{
|
||||
title: "标题",
|
||||
key: "title",
|
||||
minWidth: 200,
|
||||
align: "left",
|
||||
tooltip: true,
|
||||
render: (h: any, p: any) =>
|
||||
h("a", { style: "color:#2d8cf0;", onClick: () => viewDetail(p.row) }, () => p.row.title),
|
||||
},
|
||||
{ title: "学科", key: "subject", width: 80, align: "center" },
|
||||
{
|
||||
title: "班级",
|
||||
width: 130,
|
||||
align: "left",
|
||||
tooltip: true,
|
||||
render: (h: any, p: any) => h("span", {}, (p.row.classes || []).map((c: any) => c.className).join("、") || "—"),
|
||||
},
|
||||
{ title: "发布时间", width: 130, align: "left", render: (h: any, p: any) => h("span", {}, fmtTime(p.row.publishedAt)) },
|
||||
{ title: "截止时间", width: 130, align: "left", render: (h: any, p: any) => h("span", {}, fmtTime(p.row.deadlineAt)) },
|
||||
{
|
||||
title: "应交",
|
||||
width: 65,
|
||||
align: "center",
|
||||
render: (h: any, p: any) => h("span", {}, String(p.row.progress?.should ?? "—")),
|
||||
},
|
||||
{
|
||||
title: "已读",
|
||||
width: 65,
|
||||
align: "center",
|
||||
render: (h: any, p: any) => h("span", {}, String(p.row.progress?.read ?? "—")),
|
||||
},
|
||||
{
|
||||
title: "已交",
|
||||
width: 65,
|
||||
align: "center",
|
||||
render: (h: any, p: any) => h("span", {}, String(p.row.progress?.submitted ?? "—")),
|
||||
},
|
||||
{
|
||||
title: "未读未交",
|
||||
width: 85,
|
||||
align: "center",
|
||||
render: (h: any, p: any) => {
|
||||
const g = p.row.progress;
|
||||
if (!g) return h("span", { style: "color:#999;" }, "—");
|
||||
return h("span", { style: g.unreadUndone > 0 ? "color:#ed4014;font-weight:600;" : "color:#999;" }, String(g.unreadUndone));
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "提交率",
|
||||
width: 80,
|
||||
align: "center",
|
||||
render: (h: any, p: any) => {
|
||||
const g = p.row.progress;
|
||||
if (!g) return h("span", { style: "color:#999;" }, "—");
|
||||
return h("span", {}, `${g.submitRate}%`);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "待批阅",
|
||||
width: 75,
|
||||
align: "center",
|
||||
render: (h: any, p: any) => {
|
||||
const g = p.row.progress;
|
||||
if (!g) return h("span", { style: "color:#999;" }, "—");
|
||||
return h("span", { style: g.pendingGrade > 0 ? "color:#f90;font-weight:600;" : "color:#999;" }, String(g.pendingGrade));
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
width: 90,
|
||||
align: "center",
|
||||
render: (h: any, p: any) => h("Tag", { color: STATUS_COLOR[p.row.hwStatus] || "default" }, () => p.row.hwStatusDesc || p.row.hwStatus),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
width: 250,
|
||||
align: "center",
|
||||
fixed: "right",
|
||||
render: (h: any, p: any) => {
|
||||
const row = p.row as HomeworkV2Vo;
|
||||
const nodes: any[] = [];
|
||||
nodes.push(btn("提交情况", () => viewSubmissions(row)));
|
||||
if ((row.progress?.pendingGrade ?? 0) > 0) nodes.push(btn("批阅", () => toGrade(row)));
|
||||
const more: any[] = [];
|
||||
if (row.hwStatus === "PUBLISHED") {
|
||||
if ((row.progress?.unread ?? 0) > 0) more.push(mItem("提醒未读", () => remind(row, "NOT_READ")));
|
||||
if ((row.progress?.unsubmitted ?? 0) > 0) more.push(mItem("催交", () => remind(row, "NOT_SUBMITTED")));
|
||||
more.push(mItem("延长截止", () => openExtend(row)));
|
||||
}
|
||||
more.push(mItem("复制", () => copyRow(row)));
|
||||
if (row.hwStatus === "PUBLISHED" || row.hwStatus === "CLOSED") more.push(mItem("归档", () => archive(row)));
|
||||
more.push(mItem("导出报表", () => doExport(row)));
|
||||
nodes.push(
|
||||
h("Dropdown", { transfer: true, onOnClick: (name: string) => onMore(name) }, () => [
|
||||
h("Button", { type: "text", size: "small" }, () => "更多∨"),
|
||||
h("DropdownMenu", { slot: "list" }, () => more),
|
||||
])
|
||||
);
|
||||
return h("div", {}, nodes);
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
/* 更多菜单回调(闭包 name→action) */
|
||||
const moreActions = new Map<string, () => void>();
|
||||
let moreSeq = 0;
|
||||
function mItem(text: string, action: () => void) {
|
||||
const key = `m${++moreSeq}`;
|
||||
moreActions.set(key, action);
|
||||
return h("DropdownItem", { name: key }, () => text);
|
||||
}
|
||||
function onMore(name: string) {
|
||||
moreActions.get(name)?.();
|
||||
}
|
||||
|
||||
/* ============ 草稿箱列 ============ */
|
||||
const draftColumns = computed(() => [
|
||||
{ title: "序号", type: "index", width: 60, align: "center" },
|
||||
{ title: "标题", key: "title", minWidth: 220, align: "left", tooltip: true },
|
||||
{ title: "学科", key: "subject", width: 90, align: "center" },
|
||||
{
|
||||
title: "班级",
|
||||
width: 150,
|
||||
align: "left",
|
||||
tooltip: true,
|
||||
render: (h: any, p: any) => h("span", {}, (p.row.classes || []).map((c: any) => c.className).join("、") || "—"),
|
||||
},
|
||||
{ title: "更新时间", width: 140, align: "left", render: (h: any, p: any) => h("span", {}, fmtTime(p.row.updateTime)) },
|
||||
{ title: "截止时间", width: 140, align: "left", render: (h: any, p: any) => h("span", {}, fmtTime(p.row.deadlineAt)) },
|
||||
{
|
||||
title: "状态",
|
||||
width: 110,
|
||||
align: "center",
|
||||
render: (h: any, p: any) => {
|
||||
const row = p.row as HomeworkV2Vo;
|
||||
const st = row.hwStatus === "DRAFT_SCHEDULED" ? `定时 ${fmtTime(row.scheduledAt)}` : "未发布";
|
||||
return h("Tag", { color: STATUS_COLOR[row.hwStatus] || "default" }, () => st);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
width: 250,
|
||||
align: "center",
|
||||
fixed: "right",
|
||||
render: (h: any, p: any) => {
|
||||
const row = p.row as HomeworkV2Vo;
|
||||
const nodes: any[] = [];
|
||||
nodes.push(btn("编辑", () => edit(row)));
|
||||
nodes.push(btn("发布", () => publishDraft(row)));
|
||||
if (row.hwStatus === "DRAFT_SCHEDULED") nodes.push(btn("取消定时", () => cancelSchedule(row)));
|
||||
nodes.push(btn("删除", () => del(row)));
|
||||
return nodes;
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const columns = computed(() => (viewMode.value === "drafts" ? draftColumns.value : pubColumns.value));
|
||||
const tableHeight = computed(() => Utils.useTableHeight({ isSearch: true, isOperat: true, isPage: true }));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-wrapper">
|
||||
<div class="main-container">
|
||||
<!-- 1. 操作 + 视图切换 -->
|
||||
<div class="fix mb-12">
|
||||
<Button class="fl" type="primary" size="large" @click="router.push('/list/new')">布置作业</Button>
|
||||
</div>
|
||||
<Tabs :value="viewMode" @on-click="switchView" class="mb-12">
|
||||
<TabPane name="published" label="已发布"></TabPane>
|
||||
<TabPane name="drafts" label="草稿箱"></TabPane>
|
||||
</Tabs>
|
||||
|
||||
<!-- 2. 已发布状态筛选 + 搜索区 -->
|
||||
<div class="search-wrap fix">
|
||||
<div v-if="viewMode === 'published'" class="fl">
|
||||
<Tabs :value="pubStatus" @on-click="onPubTab" size="small" class="inner-tabs">
|
||||
<TabPane v-for="t in PUB_TABS" :key="t.name" :name="t.name" :label="t.label"></TabPane>
|
||||
</Tabs>
|
||||
</div>
|
||||
<Select v-model="subject" class="search-wrap_sel ml-12 fl" clearable placeholder="学科" style="width: 120px" @on-change="doSearch">
|
||||
<Option v-for="s in SUBJECTS" :key="s" :value="s">{{ s }}</Option>
|
||||
</Select>
|
||||
<Input
|
||||
class="fr"
|
||||
style="width: 200px"
|
||||
search
|
||||
suffix="ios-search"
|
||||
placeholder="输入关键字搜索"
|
||||
v-model="keyword"
|
||||
@on-search="doSearch"
|
||||
@on-clear="doSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 3. 表格 + 分页 -->
|
||||
<Table
|
||||
:columns="columns"
|
||||
:data="page.content"
|
||||
class="link-table table-bottom-box"
|
||||
:height="tableHeight"
|
||||
:loading="loading"
|
||||
no-data-text="暂无数据"
|
||||
></Table>
|
||||
<div class="page-panel">
|
||||
<div class="fl ml-15 align-middle"></div>
|
||||
<Page
|
||||
class="page-box fr"
|
||||
:total="page.total"
|
||||
:current="pageNum"
|
||||
:page-size="pageSize"
|
||||
:page-size-opts="[10, 20, 30, 40]"
|
||||
@on-page-size-change="pageSizeUpdate"
|
||||
@on-change="pageUpdate"
|
||||
placement="top"
|
||||
show-total
|
||||
show-sizer
|
||||
></Page>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 延长截止 -->
|
||||
<Modal v-model="extendModal.open" title="延长截止时间" width="420px">
|
||||
<Form :label-width="100">
|
||||
<FormItem label="新截止时间:">
|
||||
<DatePicker
|
||||
type="datetime"
|
||||
transfer
|
||||
v-model="extendModal.time"
|
||||
format="yyyy-MM-dd HH:mm"
|
||||
placeholder="选择新的截止时间"
|
||||
style="width: 240px"
|
||||
></DatePicker>
|
||||
<div class="extend-tip" v-if="extendModal.row?.deadlineAt">
|
||||
原截止时间:{{ extendModal.row.deadlineAt }}
|
||||
</div>
|
||||
</FormItem>
|
||||
</Form>
|
||||
<template #footer>
|
||||
<Button type="primary" :loading="extendModal.saving" @click="doExtend">保存</Button>
|
||||
<Button @click="extendModal.open = false" style="margin-left: 8px">取消</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.search-wrap {
|
||||
.inner-tabs {
|
||||
margin-bottom: -8px;
|
||||
:deep(.ivu-tabs-bar) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
.extend-tip {
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user