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:
@@ -0,0 +1,299 @@
|
||||
import { Method } from "alova";
|
||||
import { alovaInstance } from "@/utils/alova/alova-iview";
|
||||
import conf from "@/assets/config/host.config";
|
||||
import type {
|
||||
HomeworkVo,
|
||||
HomeworkDetailVo,
|
||||
HomeworkPageResult,
|
||||
BasePageResult,
|
||||
UpFileResultVo,
|
||||
UpFileVo,
|
||||
InitializeRedVo,
|
||||
NextPrevVo,
|
||||
ReadGroup,
|
||||
HomeworkStatisticsVo,
|
||||
HomeworkRequest,
|
||||
HomeworkQuery,
|
||||
DictOption,
|
||||
DeptManageItem,
|
||||
ContactGroupPage,
|
||||
ContactUserPage,
|
||||
SchoolTreeNode,
|
||||
ClassTreeNode,
|
||||
SubmissionsResultVo,
|
||||
SubmissionDetailVo,
|
||||
GradeRequest,
|
||||
MyHomeworksVo,
|
||||
MySubmissionVo,
|
||||
SubmitRequest,
|
||||
} from "@/types/homework";
|
||||
|
||||
/**
|
||||
* 课程作业接口封装(P0 标准化后新契约,P1-7 TS 契约化)。
|
||||
*
|
||||
* 前缀说明:conf.serviceHost 本地为空(走 vite 代理),线上为基础平台域名;
|
||||
* platform 固定使用 "api",与后端 Config.API_PATH 中的 api 匹配。
|
||||
* 统一响应由 alova-iview 解包为 data(code==0),页面直接使用返回值。
|
||||
*
|
||||
* 变更要点(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)
|
||||
* 4. postJSON 用于 @RequestBody 接口;postForm 用于 @RequestParam 接口(表单提交)
|
||||
* 5. 参数更名:chaoSend→fileIds、topmost→smsFlag
|
||||
* 6. Delete 入参从 ids 逗号串改为 List<Integer> JSON 数组
|
||||
*
|
||||
* 变更要点(P1-7):
|
||||
* 7. 全部方法加返回值泛型标注,替换 Record<string, any>
|
||||
* 8. 列表分页 content 字段对齐后端 BasePageResult(非旧 list)
|
||||
*/
|
||||
|
||||
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> {
|
||||
return new Method<T>("GET", alovaInstance, url, { params });
|
||||
}
|
||||
|
||||
/** POST JSON body(与后端 @RequestBody 对齐),T 为返回数据类型 */
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
/** POST 表单(与后端 @RequestParam 对齐,application/x-www-form-urlencoded),T 为返回数据类型 */
|
||||
function postForm<T = any>(url: string, data?: Record<string, any>): Method<T> {
|
||||
const pairs = data
|
||||
? Object.entries(data).filter(([, v]) => v !== undefined && v !== null)
|
||||
: [];
|
||||
const form = pairs.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`).join("&");
|
||||
// 同时把参数拼到 URL query:后端 @RequestParam 表单/查询参数都能接收,
|
||||
// 且本地 vite-plugin-mock 不解析 urlencoded body,靠 query 才能拿到参数(不影响线上)。
|
||||
const fullUrl = form ? `${url}${url.includes("?") ? "&" : "?"}${form}` : url;
|
||||
return new Method<T>(
|
||||
"POST",
|
||||
alovaInstance,
|
||||
fullUrl,
|
||||
{ headers: { "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8" } },
|
||||
form || undefined
|
||||
);
|
||||
}
|
||||
|
||||
/** 附件下载直链(浏览器 location.href 跳转) */
|
||||
function fileDownUrl(fileId: number): string {
|
||||
return `${conf.serviceHost}${FILE}/Down?id=${fileId}`;
|
||||
}
|
||||
|
||||
/** 作业全部附件打包下载直链 */
|
||||
function allFileDownUrl(dataid: number): string {
|
||||
return `${conf.serviceHost}${HOMEWORK}/AllFileDown?dataid=${dataid}`;
|
||||
}
|
||||
|
||||
class BaseApi {
|
||||
// ========== 用户信息 & 模块权限 ==========
|
||||
/** 获取当前登录用户信息 */
|
||||
getUserInfo() {
|
||||
return get<SchoolTreeNode>(`${BASE}/GetUserInfo`);
|
||||
}
|
||||
/** 获取当前用户已授权的模块列表 */
|
||||
getUserMod() {
|
||||
return get<SchoolTreeNode[]>(`${BASE}/GetUserMod`);
|
||||
}
|
||||
/** 当前学校组织树(部门→教师,用于选择用户) */
|
||||
getSchoolAll() {
|
||||
return get<SchoolTreeNode[]>(`${BASE}/GetSchoolAll`);
|
||||
}
|
||||
/** 年级→班级树(学生场景:选择通知对象到班级一级) */
|
||||
getClassTree() {
|
||||
return get<ClassTreeNode[]>(`${BASE}/GetClassTree`);
|
||||
}
|
||||
/** 端分离入口:切换当前视角(teacher / student / parent),需在拉取用户信息前调用 */
|
||||
setRole(as: "teacher" | "student" | "parent") {
|
||||
return postJSON<{ role: string }>(`${BASE}/SetRole`, { as });
|
||||
}
|
||||
}
|
||||
|
||||
class HomeworkApi {
|
||||
// ========== 收件箱 ==========
|
||||
/** 已收作业列表 */
|
||||
list(params: HomeworkQuery) {
|
||||
return get<HomeworkPageResult<HomeworkVo>>(`${HOMEWORK}/List`, params);
|
||||
}
|
||||
/** 清单:一键全部已读 */
|
||||
initializeRed() {
|
||||
return postForm<InitializeRedVo>(`${HOMEWORK}/InitializeRed`);
|
||||
}
|
||||
|
||||
// ========== 已发/全部作业 ==========
|
||||
/** 已发/全部作业列表(scope="user" 只看自己,不传=全部) */
|
||||
sendList(params: HomeworkQuery) {
|
||||
return get<BasePageResult<HomeworkVo>>(`${HOMEWORK}/SendList`, params);
|
||||
}
|
||||
|
||||
// ========== 详情 ==========
|
||||
/** 查看详情(type=1 标读;type=0 编辑不标读) */
|
||||
modify(serid: number, type = 1) {
|
||||
return get<HomeworkDetailVo>(`${HOMEWORK}/Detail`, { serid, type });
|
||||
}
|
||||
/** 编辑回显(不标读) */
|
||||
getHomeworkDetails(serid: number) {
|
||||
return get<HomeworkDetailVo>(`${HOMEWORK}/GetDetail`, { serid });
|
||||
}
|
||||
/** 上一篇/下一篇 */
|
||||
getNextPrevHomework(serid: number, type: string) {
|
||||
return get<NextPrevVo>(`${HOMEWORK}/GetNextPrev`, { serid, type });
|
||||
}
|
||||
/** 阅读情况(已读/未读分组) */
|
||||
orgizeModify(serid: number) {
|
||||
return get<ReadGroup[]>(`${HOMEWORK}/ReadDetail`, { serid });
|
||||
}
|
||||
/** 追加接收人 */
|
||||
receiveUsers(serid: number, users: string) {
|
||||
return postForm<void>(`${HOMEWORK}/ReceiveUsers`, { serid, users });
|
||||
}
|
||||
|
||||
// ========== 发布/编辑/删除 ==========
|
||||
/** 新建作业(JSON body,fileIds 为附件ID逗号串,smsFlag 为短信标记) */
|
||||
create(data: HomeworkRequest) {
|
||||
return postJSON<number>(`${HOMEWORK}/Create`, data);
|
||||
}
|
||||
/** 编辑作业(JSON body,含 id) */
|
||||
editHomework(data: HomeworkRequest) {
|
||||
return postJSON<void>(`${HOMEWORK}/Edit`, data);
|
||||
}
|
||||
/** 删除作业(ids 数组,支持批量) */
|
||||
deleteHomework(ids: number | string) {
|
||||
// 兼容页面传单个 id 或逗号串,统一转成数组
|
||||
const idArr = String(ids).split(",").map(Number).filter(Boolean);
|
||||
return postJSON<void>(`${HOMEWORK}/Delete`, idArr);
|
||||
}
|
||||
/** 撤回 */
|
||||
userPull(serid: number) {
|
||||
return postForm<void>(`${HOMEWORK}/UserPull`, { serid });
|
||||
}
|
||||
/** 置顶/取消置顶 */
|
||||
setTopmost(serid: number, topmost = 1) {
|
||||
return postForm<void>(`${HOMEWORK}/SetTopmost`, { serid, topmost });
|
||||
}
|
||||
/** 催收未读 */
|
||||
expedite(serid: number) {
|
||||
return postForm<void>(`${HOMEWORK}/Expedite`, { serid });
|
||||
}
|
||||
|
||||
// ========== 课程作业 v1:提交情况 / 批改 / 催交 ==========
|
||||
/** 某作业的提交名单(按班级分组)+ 汇总;submitStatus: all/unsubmitted/submitted/pending/graded/needRedo */
|
||||
submissions(serid: number, submitStatus = "all") {
|
||||
return get<SubmissionsResultVo>(`${HOMEWORK}/Submissions`, { serid, submitStatus });
|
||||
}
|
||||
/** 单份提交详情(批改工作台右侧) */
|
||||
submissionDetail(subId: number) {
|
||||
return get<SubmissionDetailVo>(`${HOMEWORK}/SubmissionDetail`, { subId });
|
||||
}
|
||||
/** 批改(JSON body):action=grade/redo/excellent */
|
||||
grade(data: GradeRequest) {
|
||||
return postJSON<void>(`${HOMEWORK}/Grade`, data);
|
||||
}
|
||||
/** 催交未交学生 */
|
||||
remind(serid: number) {
|
||||
return postForm<{ reminded: number }>(`${HOMEWORK}/Remind`, { serid });
|
||||
}
|
||||
|
||||
// ========== v2 学生端 ==========
|
||||
/** 今日/我的作业(当前学生视角,含提交与批改态 + 分桶计数) */
|
||||
myHomeworks() {
|
||||
return get<MyHomeworksVo>(`${HOMEWORK}/MyHomeworks`);
|
||||
}
|
||||
/** 我对某作业的详情(作业信息 + 我的提交/批改) */
|
||||
mySubmission(serid: number) {
|
||||
return get<MySubmissionVo>(`${HOMEWORK}/MySubmission`, { serid });
|
||||
}
|
||||
/** 提交 / 订正重交(JSON body:serid, text, images, attachments) */
|
||||
submit(data: SubmitRequest) {
|
||||
return postJSON<{ subId: number; submitStatus: string }>(`${HOMEWORK}/Submit`, data);
|
||||
}
|
||||
|
||||
// ========== 附件 ==========
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
export const baseApi = new BaseApi();
|
||||
export const homeworkApi = new HomeworkApi();
|
||||
export const settingApi = new SettingApi();
|
||||
export default homeworkApi;
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { IModule } from "@/store";
|
||||
|
||||
/**
|
||||
* 课程作业左侧菜单数据(对照后端 GetUserMod 现行模块)。
|
||||
* 当前由 layout 直接使用;接入后端 GetUserMod 后,以接口返回为准。
|
||||
* 注:课程作业不做审批,故不含「审批列表/审核设置」;平台模块配置若仍下发 modId 5/6,
|
||||
* 由 utils/menuRoute 的 DROPPED_MOD_IDS 统一剔除(见 classwork-frontend-m5-plan.md §6)。
|
||||
*/
|
||||
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: 4, modId: 4, name: "作业统计", page: "/stats" },
|
||||
{ id: 21, modId: 21, name: "应用设置", page: "/setting" },
|
||||
];
|
||||
return Promise.resolve(menu);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,124 @@
|
||||
import type { RouteRecordRaw } from "vue-router";
|
||||
|
||||
/**
|
||||
* 课程作业模块路由(扁平短路由,与基础平台模块 path 一致)。
|
||||
* 页面间参数通过 query 传递:
|
||||
* - /list/new?serid= (编辑)
|
||||
* - /list/new?fid= (转发)
|
||||
* - /list/new?tempid=(模板引用)
|
||||
* - /list/detail?serid=&type=、/homeworklist/detail?serid=&type=
|
||||
* type: 0=编辑视角(不标读) 1=普通查看(标读)
|
||||
*
|
||||
* modId 是菜单 ↔ 路由的稳定契约(对照后端 GetUserMod 现行数据):
|
||||
* 1=已收到 2=已发送 3=全部作业 4=作业统计 21=应用设置
|
||||
* 发作业为「已发送」的深层子页(modId 22 不在菜单树,meta.parentId=2 关联高亮),
|
||||
* 与 UserBase「应用管理>应用类型管理」范式一致。
|
||||
*/
|
||||
const routes: RouteRecordRaw[] = [
|
||||
// v1 教师默认落地:我布置的
|
||||
{ path: "/", redirect: "/list" },
|
||||
// 学生收件箱(v2 保留,不在菜单)
|
||||
{
|
||||
path: "/accept",
|
||||
name: "homework_accept",
|
||||
component: () => import("@/views/homework/HomeworkAccept.vue"),
|
||||
meta: { title: "已收到", modId: 1 },
|
||||
},
|
||||
// 我布置的(教师端作业列表)
|
||||
{
|
||||
path: "/list",
|
||||
name: "homework_list",
|
||||
component: () => import("@/views/homework/HomeworkList.vue"),
|
||||
meta: { title: "我布置的", modId: 2 },
|
||||
},
|
||||
// 提交情况(某作业的已交/未交名单;从列表行或菜单进入,无 serid 时页内选择作业)
|
||||
{
|
||||
path: "/submissions",
|
||||
name: "homework_submissions",
|
||||
component: () => import("@/views/homework/Submissions.vue"),
|
||||
meta: { title: "提交情况", modId: 25 },
|
||||
},
|
||||
// 批改中心(待批作业→逐份批改工作台;无 serid 时先看待批作业列表)
|
||||
{
|
||||
path: "/grade",
|
||||
name: "homework_grade",
|
||||
component: () => import("@/views/homework/Grade.vue"),
|
||||
meta: { title: "批改中心", modId: 26 },
|
||||
},
|
||||
// 发作业(新建/编辑/转发/模板):已发送的深层子页,不在菜单里,
|
||||
// layout.vue 按.meta.parentId 推导面包屑「已发送>发作业」并高亮父菜单
|
||||
{
|
||||
path: "/list/new",
|
||||
name: "new_homework",
|
||||
component: () => import("@/views/homework/NewHomework.vue"),
|
||||
meta: { title: "发作业", modId: 22, parentId: 2 },
|
||||
},
|
||||
// 全部作业(管理端)
|
||||
{
|
||||
path: "/homeworklist",
|
||||
name: "homework_manage",
|
||||
component: () => import("@/views/homework/HomeworkList.vue"),
|
||||
meta: { title: "全部作业", modId: 3 },
|
||||
},
|
||||
// 作业详情(同一组件挂两个路由,面包屑/菜单高亮由 meta.parentId 确定父节点:
|
||||
// 已发送下 /list/detail,全部作业下 /homeworklist/detail;type 区分是否标读)
|
||||
{
|
||||
path: "/list/detail",
|
||||
name: "homework_list_detail",
|
||||
component: () => import("@/views/homework/HomeworkDetails.vue"),
|
||||
meta: { title: "作业详情", modId: 23, parentId: 2 },
|
||||
},
|
||||
{
|
||||
path: "/homeworklist/detail",
|
||||
name: "homework_manage_detail",
|
||||
component: () => import("@/views/homework/HomeworkDetails.vue"),
|
||||
meta: { title: "作业详情", modId: 24, parentId: 3 },
|
||||
},
|
||||
// 作业统计(旧版行为:整页跳转外部统计系统)
|
||||
{
|
||||
path: "/stats",
|
||||
name: "homework_stats",
|
||||
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 学生端 ---- */
|
||||
// 学生·今日作业
|
||||
{
|
||||
path: "/stu/today",
|
||||
name: "student_today",
|
||||
component: () => import("@/views/homework/StudentHomeworks.vue"),
|
||||
meta: { title: "今日作业", modId: 31, role: "student" },
|
||||
},
|
||||
// 学生·待订正(复用同一页,默认过滤待订正)
|
||||
{
|
||||
path: "/stu/redo",
|
||||
name: "student_redo",
|
||||
component: () => import("@/views/homework/StudentHomeworks.vue"),
|
||||
meta: { title: "待订正", modId: 32, role: "student", initTab: "redo" },
|
||||
},
|
||||
|
||||
/* ---- 旧版路径兼容重定向(书签、残留的平台模块配置) ---- */
|
||||
{ path: "/details", redirect: "/list/detail" },
|
||||
{ path: "/newhomework", redirect: "/list/new" },
|
||||
{ path: "/edit", redirect: "/list/new" },
|
||||
{ path: "/manage", redirect: "/homeworklist" },
|
||||
{ path: "/homework", redirect: "/accept" },
|
||||
{ path: "/homework/newhomework", redirect: "/list/new" },
|
||||
{ path: "/homework/edit", redirect: "/list/new" },
|
||||
{ path: "/homework/homework_accept", redirect: "/accept" },
|
||||
{ path: "/homework/homework_list", redirect: "/list" },
|
||||
{ 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;
|
||||
@@ -0,0 +1,492 @@
|
||||
/**
|
||||
* 课程作业前端类型契约(P1-7)
|
||||
*
|
||||
* 与后端 VO/DTO 一一对齐:
|
||||
* - HomeworkVo → 后端 model/homework/HomeworkVo.java(Jackson 默认驼峰序列化)
|
||||
* - HomeworkDetailVo → 后端 model/homework/HomeworkDetailVo.java(@JsonProperty 保留旧字段名)
|
||||
* - BasePageResult → 后端 model/BasePageResult.java
|
||||
* - HomeworkPageResult extends BasePageResult → 后端 model/homework/HomeworkPageResult.java
|
||||
* - UpFileResultVo → 后端 model/homework/UpFileResultVo.java(@JsonProperty)
|
||||
* - UpFileVo → 后端 model/homework/UpFileVo.java
|
||||
* - InitializeRedVo → 后端 model/homework/InitializeRedVo.java(@JsonProperty)
|
||||
* - HomeworkStatisticsVo → 后端 model/homework/HomeworkStatisticsVo.java
|
||||
* - HomeworkRequest → 后端 model/homework/HomeworkRequest.java
|
||||
* - HomeworkQuery → 后端 model/homework/HomeworkQuery.java + BasePageQuery.java
|
||||
*/
|
||||
|
||||
// ============================ 分页外壳 ============================
|
||||
|
||||
/** 统一分页响应外壳(对齐 BasePageResult.java) */
|
||||
export interface BasePageResult<T> {
|
||||
total: number;
|
||||
content: T[];
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
hasNext: boolean;
|
||||
}
|
||||
|
||||
/** 作业分页结果(对齐 HomeworkPageResult.java,扩展 unreadCount) */
|
||||
export interface HomeworkPageResult<T> extends BasePageResult<T> {
|
||||
/** 当前用户未读作业总数 */
|
||||
unreadCount: number;
|
||||
}
|
||||
|
||||
// ============================ 作业列表项 ============================
|
||||
|
||||
/** 作业视图对象(对齐 HomeworkVo.java,Jackson 默认驼峰序列化) */
|
||||
export interface HomeworkVo {
|
||||
id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
type: number;
|
||||
typeDesc: string;
|
||||
publisherAccount: string;
|
||||
publisherName: string;
|
||||
deptId: string;
|
||||
deptName: string;
|
||||
status: number;
|
||||
statusDesc: string;
|
||||
topmost: number;
|
||||
/** 已读人数 */
|
||||
readCount: number;
|
||||
/** 应读人数 */
|
||||
allowCount: number;
|
||||
/** 当前用户是否已读 */
|
||||
read: boolean;
|
||||
/** 是否撤回:0 否 / 1 是 */
|
||||
isPulled: number;
|
||||
/** 定时发送时间 */
|
||||
timingSendTime: string | null;
|
||||
/** 父作业ID(转发时关联原作业) */
|
||||
parentId: number | null;
|
||||
/** 短信发送标记 */
|
||||
smsFlag: number;
|
||||
/** 接收对象类型:class=班级(学生场景) */
|
||||
receiveType?: string;
|
||||
/** 接收列表:"all" 或 班级 id 逗号串 */
|
||||
receiveList: string | null;
|
||||
/** ===== 课程作业 v1(教师核心闭环)===== */
|
||||
/** 学科 */
|
||||
subject?: string;
|
||||
/** 截止时间 */
|
||||
deadline?: string | null;
|
||||
/** 提交方式:text/image/attach/none */
|
||||
submitType?: string[];
|
||||
/** 作业流程状态:draft 草稿 / collecting 收集中 / closed 已截止 / done 已完成 */
|
||||
hwStatus?: HomeworkStatus;
|
||||
hwStatusDesc?: string;
|
||||
/** 提交/批改进度(草稿或仅查阅为 null) */
|
||||
progress?: HomeworkProgress | null;
|
||||
createBy: string | null;
|
||||
createTime: string | null;
|
||||
updateBy: string | null;
|
||||
updateTime: string | null;
|
||||
}
|
||||
|
||||
// ============================ 作业详情 ============================
|
||||
|
||||
/** 作业详情视图对象(对齐 HomeworkDetailVo.java,@JsonProperty 保留旧字段名) */
|
||||
export interface HomeworkDetailVo {
|
||||
serid: number;
|
||||
title: string;
|
||||
/** 作业内容(旧字段名 Contents) */
|
||||
Contents: string;
|
||||
/** 发布部门ID(旧字段名 DeptID) */
|
||||
DeptID: string;
|
||||
/** 发布部门名称(旧字段名 DeptName) */
|
||||
DeptName: string;
|
||||
/** 发布人姓名(旧字段名 TeaName) */
|
||||
TeaName: string;
|
||||
/** 发布时间(旧字段名 EditDate,已格式化为字符串) */
|
||||
EditDate: string;
|
||||
/** 应读人数 */
|
||||
numcount: number;
|
||||
/** 已读人数 */
|
||||
readnumcount: number;
|
||||
/** 接收人列表 */
|
||||
ReceiveList: string | null;
|
||||
/** 定时发送时间(旧字段名 DsfsTime,已格式化) */
|
||||
DsfsTime: string | null;
|
||||
/** 是否定时发送 */
|
||||
isDsfs: boolean;
|
||||
/** ===== 课程作业 v1 ===== */
|
||||
subject?: string;
|
||||
deadline?: string | null;
|
||||
submitType?: string[];
|
||||
hwStatus?: HomeworkStatus;
|
||||
hwStatusDesc?: string;
|
||||
progress?: HomeworkProgress | null;
|
||||
/** 附件列表(旧字段名 ChaoSend) */
|
||||
ChaoSend: HomeworkDetailFileItem[];
|
||||
/** 接收对象列表:"all" 或 班级 id 数组 */
|
||||
tlist: string | string[];
|
||||
/** 接收对象类型:class=班级 */
|
||||
receiveType?: string;
|
||||
/** 接收对象可读文本(全部班级 / 班级名串) */
|
||||
receiveText?: string;
|
||||
}
|
||||
|
||||
/** 作业详情附件项(对齐 HomeworkDetailVo.FileItem) */
|
||||
export interface HomeworkDetailFileItem {
|
||||
id: number;
|
||||
name: string;
|
||||
size: number;
|
||||
fileid: number;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
// ============================ 文件上传 ============================
|
||||
|
||||
/** 文件上传结果(对齐 UpFileResultVo.java,@JsonProperty) */
|
||||
export interface UpFileResultVo {
|
||||
fileid: number;
|
||||
name: string;
|
||||
fileurl: string;
|
||||
host: string;
|
||||
type: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
/** 附件视图对象(对齐 UpFileVo.java) */
|
||||
export interface UpFileVo {
|
||||
id: number;
|
||||
fileTrueName: string;
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
fileType: string;
|
||||
topicId: number;
|
||||
topicType: string;
|
||||
}
|
||||
|
||||
// ============================ 一键已读 ============================
|
||||
|
||||
/** 一键已读结果(对齐 InitializeRedVo.java,@JsonProperty) */
|
||||
export interface InitializeRedVo {
|
||||
count: number;
|
||||
}
|
||||
|
||||
// ============================ 上一篇/下一篇 ============================
|
||||
|
||||
/** 上一篇/下一篇作业ID */
|
||||
export interface NextPrevVo {
|
||||
prevId: number;
|
||||
nextId: number;
|
||||
}
|
||||
|
||||
// ============================ 阅读情况 ============================
|
||||
|
||||
/** 阅读情况分组项(后端 Map 返回,字段名固定) */
|
||||
export interface ReadGroup {
|
||||
GroupName: string;
|
||||
teaslist: ReadGroupUser[];
|
||||
}
|
||||
|
||||
/** 阅读情况用户项(后端 Map 返回) */
|
||||
export interface ReadGroupUser {
|
||||
account: string;
|
||||
teaname: string;
|
||||
states: 'read' | 'unread';
|
||||
}
|
||||
|
||||
// ============================ 统计 ============================
|
||||
|
||||
/** 作业统计视图(对齐 HomeworkStatisticsVo.java) */
|
||||
export interface HomeworkStatisticsVo {
|
||||
total: number;
|
||||
publishedCount: number;
|
||||
totalReadRecords: number;
|
||||
unreadForCurrentUser: number;
|
||||
byPublisher: { publisherName: string; count: number }[];
|
||||
byMonth: { year: number; month: number; count: number }[];
|
||||
// ===== 课程作业口径(v1,可选) =====
|
||||
/** 我布置的作业数 */
|
||||
myAssignCount?: number;
|
||||
/** 待批改总量 */
|
||||
pendingGrade?: number;
|
||||
/** 平均提交率(%) */
|
||||
avgSubmitRate?: number;
|
||||
/** 按作业进度 */
|
||||
byHomework?: { id: number; title: string; subject: string; hwStatus: HomeworkStatus } & HomeworkProgress[];
|
||||
/** 分数分布 */
|
||||
scoreDist?: { label: string; key: string; count: number }[];
|
||||
}
|
||||
|
||||
// ============================ 课程作业 v1:提交 / 批改 ============================
|
||||
|
||||
/** 作业流程状态 */
|
||||
export type HomeworkStatus = "draft" | "collecting" | "closed" | "done";
|
||||
|
||||
/** 单个提交状态 */
|
||||
export type SubmitStatus = "unsubmitted" | "submitted" | "graded" | "needRedo" | "redone";
|
||||
|
||||
/** 提交/批改进度 */
|
||||
export interface HomeworkProgress {
|
||||
/** 应交人数 */
|
||||
should: number;
|
||||
/** 已交人数 */
|
||||
submitted: number;
|
||||
/** 已批改人数 */
|
||||
graded: number;
|
||||
/** 待批改人数 */
|
||||
pendingGrade: number;
|
||||
/** 待订正人数 */
|
||||
needRedo: number;
|
||||
/** 已订正人数 */
|
||||
redone: number;
|
||||
/** 未交人数 */
|
||||
unsubmitted: number;
|
||||
/** 提交率(%) */
|
||||
submitRate: number;
|
||||
/** 批改率(%) */
|
||||
gradeRate: number;
|
||||
}
|
||||
|
||||
/** 提交名单项(Submissions 接口返回,精简) */
|
||||
export interface SubmissionBrief {
|
||||
subId: number;
|
||||
studentId: number;
|
||||
studentName: string;
|
||||
submitStatus: SubmitStatus;
|
||||
submittedAt: string | null;
|
||||
hasGrade: boolean;
|
||||
score: number | null;
|
||||
level: string | null;
|
||||
isExcellent: boolean;
|
||||
}
|
||||
|
||||
/** 提交名单按班级分组 */
|
||||
export interface SubmissionGroup {
|
||||
className: string;
|
||||
students: SubmissionBrief[];
|
||||
}
|
||||
|
||||
/** 某作业提交情况结果 */
|
||||
export interface SubmissionsResultVo {
|
||||
homeworkId: number;
|
||||
title: string;
|
||||
subject: string;
|
||||
deadline: string | null;
|
||||
hwStatus: HomeworkStatus;
|
||||
hwStatusDesc: string;
|
||||
summary: HomeworkProgress;
|
||||
groups: SubmissionGroup[];
|
||||
}
|
||||
|
||||
/** 批改结果 */
|
||||
export interface GradeResult {
|
||||
score: number | null;
|
||||
level: string | null;
|
||||
comment: string;
|
||||
isExcellent: boolean;
|
||||
markedImages: string[];
|
||||
}
|
||||
|
||||
/** 单份提交详情(批改工作台用) */
|
||||
export interface SubmissionDetailVo {
|
||||
subId: number;
|
||||
homeworkId: number;
|
||||
homeworkTitle: string;
|
||||
subject: string;
|
||||
studentId: number;
|
||||
studentName: string;
|
||||
classId: string;
|
||||
className: string;
|
||||
submitStatus: SubmitStatus;
|
||||
submittedAt: string | null;
|
||||
text: string;
|
||||
images: string[];
|
||||
attachments: string[];
|
||||
grade: GradeResult | null;
|
||||
}
|
||||
|
||||
/** 批改入参 */
|
||||
export interface GradeRequest {
|
||||
subId: number;
|
||||
/** grade 保存批改 / redo 要求订正 / excellent 标为优秀 */
|
||||
action?: "grade" | "redo" | "excellent";
|
||||
score?: number | string;
|
||||
level?: string;
|
||||
comment?: string;
|
||||
isExcellent?: boolean;
|
||||
markedImages?: string[];
|
||||
}
|
||||
|
||||
// ============================ 请求入参 ============================
|
||||
|
||||
/** 作业发布/编辑入参(对齐 HomeworkRequest.java) */
|
||||
export interface HomeworkRequest {
|
||||
title: string;
|
||||
content?: string;
|
||||
/** 学科 */
|
||||
subject?: string;
|
||||
/** 截止时间 yyyy-MM-dd HH:mm:ss */
|
||||
deadline?: string;
|
||||
/** 提交方式:text/image/attach/none */
|
||||
submitType?: string[] | string;
|
||||
type?: number;
|
||||
deptId?: string;
|
||||
/** 是否允许逾期补交 */
|
||||
allowLate?: boolean;
|
||||
/** 截止提醒:如 ["1d","1h"] */
|
||||
remindBefore?: string[];
|
||||
/** 存草稿:true 则不发布 */
|
||||
draft?: boolean;
|
||||
deptName?: string;
|
||||
topmost?: number;
|
||||
/** 接收对象:"all" 或 班级 id 逗号串(receiveType=class) */
|
||||
receiveList?: string;
|
||||
/** 接收对象类型:class=班级 */
|
||||
receiveType?: string;
|
||||
/** 附件文件ID列表(逗号分隔串) */
|
||||
fileIds?: string;
|
||||
timingSendTime?: string;
|
||||
/** 短信发送标记 */
|
||||
smsFlag?: number;
|
||||
/** 转发源作业ID */
|
||||
sourceId?: number;
|
||||
}
|
||||
|
||||
/** 作业列表查询参数(对齐 HomeworkQuery.java + BasePageQuery.java) */
|
||||
export interface HomeworkQuery {
|
||||
pageIndex?: number;
|
||||
pageSize?: number;
|
||||
keyword?: string;
|
||||
status?: number;
|
||||
type?: number;
|
||||
/** 列表范围(管理端):"user" 只看自己发布的,不传=全部 */
|
||||
scope?: string;
|
||||
deptId?: string;
|
||||
/** 学科筛选 */
|
||||
subject?: string;
|
||||
/** 作业流程状态筛选:draft/collecting/closed/done,缺省或 all=全部 */
|
||||
hwStatus?: string;
|
||||
publisherAccount?: string;
|
||||
/** 开始时间(yyyy-MM-dd 短日期) */
|
||||
startTime?: string;
|
||||
/** 结束时间(yyyy-MM-dd 短日期) */
|
||||
endTime?: string;
|
||||
/** 阅读状态过滤(仅用户端):0 全部 / 1 未读 / 2 已读 */
|
||||
readType?: number;
|
||||
/** 排序:1 时间倒序 / 2 时间正序 */
|
||||
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 {
|
||||
id: number;
|
||||
name: string;
|
||||
isParent: boolean;
|
||||
studentCount?: number;
|
||||
children?: ClassTreeNode[];
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// ============================ v2 学生端 ============================
|
||||
|
||||
/** 当前视角角色(家长即登录学生账号,不单列) */
|
||||
export type ViewerRole = "teacher" | "student";
|
||||
|
||||
/** 学生对某作业的提交/批改态 */
|
||||
export interface StudentSubmissionInfo {
|
||||
subId: number;
|
||||
submitStatus: SubmitStatus;
|
||||
submittedAt: string | null;
|
||||
text: string;
|
||||
score: number | null;
|
||||
level: string | null;
|
||||
comment: string;
|
||||
isExcellent: boolean;
|
||||
images?: string[];
|
||||
attachments?: string[];
|
||||
grade?: GradeResult | null;
|
||||
}
|
||||
|
||||
/** 学生视角作业项 */
|
||||
export interface StudentHomeworkVo {
|
||||
id: number;
|
||||
title: string;
|
||||
subject: string;
|
||||
publisherName: string;
|
||||
deptName: string;
|
||||
deadline: string | null;
|
||||
createTime: string | null;
|
||||
content: string;
|
||||
submitType: string[];
|
||||
viewOnly: boolean;
|
||||
hwStatus?: HomeworkStatus;
|
||||
allowLate?: boolean;
|
||||
my: StudentSubmissionInfo | null;
|
||||
}
|
||||
|
||||
/** 今日/我的作业列表 */
|
||||
export interface MyHomeworksVo {
|
||||
buckets: { todo: number; submitted: number; graded: number; redo: number; all: number };
|
||||
list: StudentHomeworkVo[];
|
||||
}
|
||||
|
||||
/** 我的某作业详情(作业信息 + 我的提交/批改) */
|
||||
export interface MySubmissionVo {
|
||||
homework: { id: number; title: string; subject: string; content: string; deadline: string | null; submitType: string[]; publisherName: string; deptName: string };
|
||||
my: StudentSubmissionInfo | null;
|
||||
}
|
||||
|
||||
/** 学生提交作业入参 */
|
||||
export interface SubmitRequest {
|
||||
serid: number;
|
||||
text?: string;
|
||||
/** 图片 URL / dataURL 列表(逗号串或数组) */
|
||||
images?: string[] | string;
|
||||
/** 附件 id 列表(逗号串或数组) */
|
||||
attachments?: string[] | string;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, watch } from "vue";
|
||||
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 { Message } from "view-ui-plus";
|
||||
|
||||
const router = useRouter();
|
||||
const isLogin = ref(false);
|
||||
const layoutData = reactive({
|
||||
appName: "课程作业",
|
||||
baseHost: config.baseHost,
|
||||
ssoHost: config.ssoHost,
|
||||
appList: [] as any[],
|
||||
userInfo: null as any,
|
||||
modList: [] as any[],
|
||||
isAlone: false,
|
||||
});
|
||||
|
||||
function roleOfPath(p: string): "teacher" | "student" {
|
||||
if (p.startsWith("/stu")) return "student";
|
||||
return "teacher";
|
||||
}
|
||||
|
||||
async function initShell() {
|
||||
const role = roleOfPath(router.currentRoute.value.path);
|
||||
try {
|
||||
// 端分离:先切换 mock 当前视角,再拉身份与菜单
|
||||
await baseApi.setRole(role).send();
|
||||
} catch {
|
||||
/* 忽略:非 mock 环境可能无此接口 */
|
||||
}
|
||||
try {
|
||||
// 1. 获取当前用户信息
|
||||
const user: any = await baseApi.getUserInfo().send();
|
||||
const { id, name, profilePicture } = user || {};
|
||||
|
||||
// 2. 获取当前用户已授权的模块列表
|
||||
const modList: any[] = await baseApi.getUserMod().send();
|
||||
|
||||
layoutData.userInfo = { id, name: name || "管理员", avatar: profilePicture || "" };
|
||||
layoutData.modList = modList || [];
|
||||
|
||||
// 开发环境打印平台原始模块配置,便于排查菜单路径解析问题
|
||||
if (import.meta.env.DEV) {
|
||||
console.table(
|
||||
(modList || []).map((m: any) => ({
|
||||
name: m.name,
|
||||
id: m.id,
|
||||
modId: m.modId,
|
||||
path: m.page,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
// 有子菜单时不算「单模块独占」模式
|
||||
const hasChildren = (modList || []).some((m: any) => m.children && m.children.length > 0);
|
||||
layoutData.isAlone = (modList || []).length <= 1 && !hasChildren;
|
||||
|
||||
isLogin.value = true;
|
||||
|
||||
// 登录后跳转第一个可用模块("/" 本身即已收到页,无需跳转)
|
||||
if (router.currentRoute.value.path === "/") {
|
||||
const firstModule = (modList || [])[0];
|
||||
const firstRaw = firstModule?.children?.length
|
||||
? firstModule.children[0]
|
||||
: firstModule;
|
||||
const first = resolveMenuPage(firstRaw?.page, firstRaw?.modId ?? firstRaw?.id, firstRaw?.name);
|
||||
if (!first.external && first.path && first.path !== "/") {
|
||||
router.replace(first.path);
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("加载用户信息失败", error);
|
||||
if (error?.message !== "redirect") {
|
||||
Message.error("载入用户信息失败,请检查后端服务是否正常!");
|
||||
}
|
||||
// 降级:使用默认值("/" 即已收到页,无需跳转)
|
||||
layoutData.userInfo = { name: "管理员", avatar: "" };
|
||||
layoutData.modList = [];
|
||||
layoutData.isAlone = true;
|
||||
isLogin.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(initShell);
|
||||
|
||||
// 端分离:路由跨角色前缀(/stu、/par、其它)切换时,重新设定视角并初始化壳层
|
||||
watch(
|
||||
() => router.currentRoute.value.path,
|
||||
(p, prev) => {
|
||||
if (roleOfPath(p) !== roleOfPath(prev || "")) initShell();
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<layout v-if="isLogin" :layoutData="layoutData" />
|
||||
<div v-if="isLogin" class="base-wrap-main" :class="{ 'alone-page': layoutData.isAlone }">
|
||||
<div class="main-wrapper">
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
#app {
|
||||
height: 100%;
|
||||
}
|
||||
/* 页面级滚动容器:main-wrapper 只有 min-height 时内容超一屏会被 body 的
|
||||
overflow:hidden 直接裁掉(如发作业页),无任何滚动条。定高 + 纵向滚动,
|
||||
放得下的列表页(表格高度由 Utils.useTableHeight 算好)不受影响 */
|
||||
.base-wrap-main .main-wrapper {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
/* 表格操作列(operatingHandle 渲染的 Tooltip 包裹层)横排且随单元格居中 */
|
||||
.ivu-table-cell > .ivu-tooltip {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin: 0 2px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,301 @@
|
||||
import { Method } from "alova";
|
||||
import { alovaInstance } from "@/utils/alova/alova-iview";
|
||||
import conf from "@/assets/config/host.config";
|
||||
import type {
|
||||
HomeworkVo,
|
||||
HomeworkDetailVo,
|
||||
HomeworkPageResult,
|
||||
BasePageResult,
|
||||
UpFileResultVo,
|
||||
UpFileVo,
|
||||
InitializeRedVo,
|
||||
NextPrevVo,
|
||||
ReadGroup,
|
||||
HomeworkStatisticsVo,
|
||||
HomeworkRequest,
|
||||
HomeworkQuery,
|
||||
DictOption,
|
||||
DeptManageItem,
|
||||
ContactGroupPage,
|
||||
ContactUserPage,
|
||||
SchoolTreeNode,
|
||||
ClassTreeNode,
|
||||
ApprovalQuery,
|
||||
ApproverVo,
|
||||
SubmissionsResultVo,
|
||||
SubmissionDetailVo,
|
||||
GradeRequest,
|
||||
MyHomeworksVo,
|
||||
MySubmissionVo,
|
||||
SubmitRequest,
|
||||
} from "@/types/homework";
|
||||
|
||||
/**
|
||||
* 课程作业接口封装(P0 标准化后新契约,P1-7 TS 契约化)。
|
||||
*
|
||||
* 前缀说明:conf.serviceHost 本地为空(走 vite 代理),线上为基础平台域名;
|
||||
* platform 固定使用 "api",与后端 Config.API_PATH 中的 api 匹配。
|
||||
* 统一响应由 alova-iview 解包为 data(code==0),页面直接使用返回值。
|
||||
*
|
||||
* 变更要点(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)
|
||||
* 4. postJSON 用于 @RequestBody 接口;postForm 用于 @RequestParam 接口(表单提交)
|
||||
* 5. 参数更名:chaoSend→fileIds、topmost→smsFlag
|
||||
* 6. Delete 入参从 ids 逗号串改为 List<Integer> JSON 数组
|
||||
*
|
||||
* 变更要点(P1-7):
|
||||
* 7. 全部方法加返回值泛型标注,替换 Record<string, any>
|
||||
* 8. 列表分页 content 字段对齐后端 BasePageResult(非旧 list)
|
||||
*/
|
||||
|
||||
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> {
|
||||
return new Method<T>("GET", alovaInstance, url, { params });
|
||||
}
|
||||
|
||||
/** POST JSON body(与后端 @RequestBody 对齐),T 为返回数据类型 */
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
/** POST 表单(与后端 @RequestParam 对齐,application/x-www-form-urlencoded),T 为返回数据类型 */
|
||||
function postForm<T = any>(url: string, data?: Record<string, any>): Method<T> {
|
||||
const pairs = data
|
||||
? Object.entries(data).filter(([, v]) => v !== undefined && v !== null)
|
||||
: [];
|
||||
const form = pairs.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`).join("&");
|
||||
// 同时把参数拼到 URL query:后端 @RequestParam 表单/查询参数都能接收,
|
||||
// 且本地 vite-plugin-mock 不解析 urlencoded body,靠 query 才能拿到参数(不影响线上)。
|
||||
const fullUrl = form ? `${url}${url.includes("?") ? "&" : "?"}${form}` : url;
|
||||
return new Method<T>(
|
||||
"POST",
|
||||
alovaInstance,
|
||||
fullUrl,
|
||||
{ headers: { "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8" } },
|
||||
form || undefined
|
||||
);
|
||||
}
|
||||
|
||||
/** 附件下载直链(浏览器 location.href 跳转) */
|
||||
function fileDownUrl(fileId: number): string {
|
||||
return `${conf.serviceHost}${FILE}/Down?id=${fileId}`;
|
||||
}
|
||||
|
||||
/** 作业全部附件打包下载直链 */
|
||||
function allFileDownUrl(dataid: number): string {
|
||||
return `${conf.serviceHost}${HOMEWORK}/AllFileDown?dataid=${dataid}`;
|
||||
}
|
||||
|
||||
class BaseApi {
|
||||
// ========== 用户信息 & 模块权限 ==========
|
||||
/** 获取当前登录用户信息 */
|
||||
getUserInfo() {
|
||||
return get<SchoolTreeNode>(`${BASE}/GetUserInfo`);
|
||||
}
|
||||
/** 获取当前用户已授权的模块列表 */
|
||||
getUserMod() {
|
||||
return get<SchoolTreeNode[]>(`${BASE}/GetUserMod`);
|
||||
}
|
||||
/** 当前学校组织树(部门→教师,用于选择用户) */
|
||||
getSchoolAll() {
|
||||
return get<SchoolTreeNode[]>(`${BASE}/GetSchoolAll`);
|
||||
}
|
||||
/** 年级→班级树(学生场景:选择通知对象到班级一级) */
|
||||
getClassTree() {
|
||||
return get<ClassTreeNode[]>(`${BASE}/GetClassTree`);
|
||||
}
|
||||
/** 端分离入口:切换当前视角(teacher / student / parent),需在拉取用户信息前调用 */
|
||||
setRole(as: "teacher" | "student" | "parent") {
|
||||
return postJSON<{ role: string }>(`${BASE}/SetRole`, { as });
|
||||
}
|
||||
}
|
||||
|
||||
class HomeworkApi {
|
||||
// ========== 收件箱 ==========
|
||||
/** 已收作业列表 */
|
||||
list(params: HomeworkQuery) {
|
||||
return get<HomeworkPageResult<HomeworkVo>>(`${HOMEWORK}/List`, params);
|
||||
}
|
||||
/** 清单:一键全部已读 */
|
||||
initializeRed() {
|
||||
return postForm<InitializeRedVo>(`${HOMEWORK}/InitializeRed`);
|
||||
}
|
||||
|
||||
// ========== 已发/全部作业 ==========
|
||||
/** 已发/全部作业列表(scope="user" 只看自己,不传=全部) */
|
||||
sendList(params: HomeworkQuery) {
|
||||
return get<BasePageResult<HomeworkVo>>(`${HOMEWORK}/SendList`, params);
|
||||
}
|
||||
|
||||
// ========== 详情 ==========
|
||||
/** 查看详情(type=1 标读;type=0 编辑不标读) */
|
||||
modify(serid: number, type = 1) {
|
||||
return get<HomeworkDetailVo>(`${HOMEWORK}/Detail`, { serid, type });
|
||||
}
|
||||
/** 编辑回显(不标读) */
|
||||
getHomeworkDetails(serid: number) {
|
||||
return get<HomeworkDetailVo>(`${HOMEWORK}/GetDetail`, { serid });
|
||||
}
|
||||
/** 上一篇/下一篇 */
|
||||
getNextPrevHomework(serid: number, type: string) {
|
||||
return get<NextPrevVo>(`${HOMEWORK}/GetNextPrev`, { serid, type });
|
||||
}
|
||||
/** 阅读情况(已读/未读分组) */
|
||||
orgizeModify(serid: number) {
|
||||
return get<ReadGroup[]>(`${HOMEWORK}/ReadDetail`, { serid });
|
||||
}
|
||||
/** 追加接收人 */
|
||||
receiveUsers(serid: number, users: string) {
|
||||
return postForm<void>(`${HOMEWORK}/ReceiveUsers`, { serid, users });
|
||||
}
|
||||
|
||||
// ========== 发布/编辑/删除 ==========
|
||||
/** 新建作业(JSON body,fileIds 为附件ID逗号串,smsFlag 为短信标记) */
|
||||
create(data: HomeworkRequest) {
|
||||
return postJSON<number>(`${HOMEWORK}/Create`, data);
|
||||
}
|
||||
/** 编辑作业(JSON body,含 id) */
|
||||
editHomework(data: HomeworkRequest) {
|
||||
return postJSON<void>(`${HOMEWORK}/Edit`, data);
|
||||
}
|
||||
/** 删除作业(ids 数组,支持批量) */
|
||||
deleteHomework(ids: number | string) {
|
||||
// 兼容页面传单个 id 或逗号串,统一转成数组
|
||||
const idArr = String(ids).split(",").map(Number).filter(Boolean);
|
||||
return postJSON<void>(`${HOMEWORK}/Delete`, idArr);
|
||||
}
|
||||
/** 撤回 */
|
||||
userPull(serid: number) {
|
||||
return postForm<void>(`${HOMEWORK}/UserPull`, { serid });
|
||||
}
|
||||
/** 置顶/取消置顶 */
|
||||
setTopmost(serid: number, topmost = 1) {
|
||||
return postForm<void>(`${HOMEWORK}/SetTopmost`, { serid, topmost });
|
||||
}
|
||||
/** 催收未读 */
|
||||
expedite(serid: number) {
|
||||
return postForm<void>(`${HOMEWORK}/Expedite`, { serid });
|
||||
}
|
||||
|
||||
// ========== 课程作业 v1:提交情况 / 批改 / 催交 ==========
|
||||
/** 某作业的提交名单(按班级分组)+ 汇总;submitStatus: all/unsubmitted/submitted/pending/graded/needRedo */
|
||||
submissions(serid: number, submitStatus = "all") {
|
||||
return get<SubmissionsResultVo>(`${HOMEWORK}/Submissions`, { serid, submitStatus });
|
||||
}
|
||||
/** 单份提交详情(批改工作台右侧) */
|
||||
submissionDetail(subId: number) {
|
||||
return get<SubmissionDetailVo>(`${HOMEWORK}/SubmissionDetail`, { subId });
|
||||
}
|
||||
/** 批改(JSON body):action=grade/redo/excellent */
|
||||
grade(data: GradeRequest) {
|
||||
return postJSON<void>(`${HOMEWORK}/Grade`, data);
|
||||
}
|
||||
/** 催交未交学生 */
|
||||
remind(serid: number) {
|
||||
return postForm<{ reminded: number }>(`${HOMEWORK}/Remind`, { serid });
|
||||
}
|
||||
|
||||
// ========== v2 学生端 ==========
|
||||
/** 今日/我的作业(当前学生视角,含提交与批改态 + 分桶计数) */
|
||||
myHomeworks() {
|
||||
return get<MyHomeworksVo>(`${HOMEWORK}/MyHomeworks`);
|
||||
}
|
||||
/** 我对某作业的详情(作业信息 + 我的提交/批改) */
|
||||
mySubmission(serid: number) {
|
||||
return get<MySubmissionVo>(`${HOMEWORK}/MySubmission`, { serid });
|
||||
}
|
||||
/** 提交 / 订正重交(JSON body:serid, text, images, attachments) */
|
||||
submit(data: SubmitRequest) {
|
||||
return postJSON<{ subId: number; submitStatus: string }>(`${HOMEWORK}/Submit`, data);
|
||||
}
|
||||
|
||||
// ========== 附件 ==========
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
export const baseApi = new BaseApi();
|
||||
export const homeworkApi = new HomeworkApi();
|
||||
export const settingApi = new SettingApi();
|
||||
export default homeworkApi;
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { IModule } from "@/store";
|
||||
|
||||
/**
|
||||
* 课程作业左侧菜单数据(对照后端 GetUserMod 现行模块)。
|
||||
* 当前由 layout 直接使用;接入后端 GetUserMod 后,以接口返回为准。
|
||||
*/
|
||||
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);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,575 @@
|
||||
/**
|
||||
* 课程作业前端类型契约(P1-7)
|
||||
*
|
||||
* 与后端 VO/DTO 一一对齐:
|
||||
* - HomeworkVo → 后端 model/homework/HomeworkVo.java(Jackson 默认驼峰序列化)
|
||||
* - HomeworkDetailVo → 后端 model/homework/HomeworkDetailVo.java(@JsonProperty 保留旧字段名)
|
||||
* - BasePageResult → 后端 model/BasePageResult.java
|
||||
* - HomeworkPageResult extends BasePageResult → 后端 model/homework/HomeworkPageResult.java
|
||||
* - UpFileResultVo → 后端 model/homework/UpFileResultVo.java(@JsonProperty)
|
||||
* - UpFileVo → 后端 model/homework/UpFileVo.java
|
||||
* - InitializeRedVo → 后端 model/homework/InitializeRedVo.java(@JsonProperty)
|
||||
* - HomeworkStatisticsVo → 后端 model/homework/HomeworkStatisticsVo.java
|
||||
* - HomeworkRequest → 后端 model/homework/HomeworkRequest.java
|
||||
* - HomeworkQuery → 后端 model/homework/HomeworkQuery.java + BasePageQuery.java
|
||||
*/
|
||||
|
||||
// ============================ 分页外壳 ============================
|
||||
|
||||
/** 统一分页响应外壳(对齐 BasePageResult.java) */
|
||||
export interface BasePageResult<T> {
|
||||
total: number;
|
||||
content: T[];
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
hasNext: boolean;
|
||||
}
|
||||
|
||||
/** 作业分页结果(对齐 HomeworkPageResult.java,扩展 unreadCount) */
|
||||
export interface HomeworkPageResult<T> extends BasePageResult<T> {
|
||||
/** 当前用户未读作业总数 */
|
||||
unreadCount: number;
|
||||
}
|
||||
|
||||
// ============================ 作业列表项 ============================
|
||||
|
||||
/** 作业视图对象(对齐 HomeworkVo.java,Jackson 默认驼峰序列化) */
|
||||
export interface HomeworkVo {
|
||||
id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
type: number;
|
||||
typeDesc: string;
|
||||
publisherAccount: string;
|
||||
publisherName: string;
|
||||
deptId: string;
|
||||
deptName: string;
|
||||
status: number;
|
||||
statusDesc: string;
|
||||
topmost: number;
|
||||
/** 已读人数 */
|
||||
readCount: number;
|
||||
/** 应读人数 */
|
||||
allowCount: number;
|
||||
/** 当前用户是否已读 */
|
||||
read: boolean;
|
||||
/** 是否撤回:0 否 / 1 是 */
|
||||
isPulled: number;
|
||||
/** 定时发送时间 */
|
||||
timingSendTime: string | null;
|
||||
/** 父作业ID(转发时关联原作业) */
|
||||
parentId: number | null;
|
||||
/** 短信发送标记 */
|
||||
smsFlag: number;
|
||||
/** 接收对象类型:class=班级(学生场景) */
|
||||
receiveType?: string;
|
||||
/** 接收列表:"all" 或 班级 id 逗号串 */
|
||||
receiveList: string | null;
|
||||
/** ===== 课程作业 v1(教师核心闭环)===== */
|
||||
/** 学科 */
|
||||
subject?: string;
|
||||
/** 截止时间 */
|
||||
deadline?: string | null;
|
||||
/** 提交方式:text/image/attach/none */
|
||||
submitType?: string[];
|
||||
/** 作业流程状态:draft 草稿 / collecting 收集中 / closed 已截止 / done 已完成 */
|
||||
hwStatus?: HomeworkStatus;
|
||||
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 保留旧字段名) */
|
||||
export interface HomeworkDetailVo {
|
||||
serid: number;
|
||||
title: string;
|
||||
/** 作业内容(旧字段名 Contents) */
|
||||
Contents: string;
|
||||
/** 发布部门ID(旧字段名 DeptID) */
|
||||
DeptID: string;
|
||||
/** 发布部门名称(旧字段名 DeptName) */
|
||||
DeptName: string;
|
||||
/** 发布人姓名(旧字段名 TeaName) */
|
||||
TeaName: string;
|
||||
/** 发布时间(旧字段名 EditDate,已格式化为字符串) */
|
||||
EditDate: string;
|
||||
/** 应读人数 */
|
||||
numcount: number;
|
||||
/** 已读人数 */
|
||||
readnumcount: number;
|
||||
/** 接收人列表 */
|
||||
ReceiveList: string | null;
|
||||
/** 定时发送时间(旧字段名 DsfsTime,已格式化) */
|
||||
DsfsTime: string | null;
|
||||
/** 是否定时发送 */
|
||||
isDsfs: boolean;
|
||||
/** ===== 课程作业 v1 ===== */
|
||||
subject?: string;
|
||||
deadline?: string | null;
|
||||
submitType?: string[];
|
||||
hwStatus?: HomeworkStatus;
|
||||
hwStatusDesc?: string;
|
||||
progress?: HomeworkProgress | null;
|
||||
/** 附件列表(旧字段名 ChaoSend) */
|
||||
ChaoSend: HomeworkDetailFileItem[];
|
||||
/** 接收对象列表:"all" 或 班级 id 数组 */
|
||||
tlist: string | string[];
|
||||
/** 接收对象类型:class=班级 */
|
||||
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) */
|
||||
export interface HomeworkDetailFileItem {
|
||||
id: number;
|
||||
name: string;
|
||||
size: number;
|
||||
fileid: number;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
// ============================ 文件上传 ============================
|
||||
|
||||
/** 文件上传结果(对齐 UpFileResultVo.java,@JsonProperty) */
|
||||
export interface UpFileResultVo {
|
||||
fileid: number;
|
||||
name: string;
|
||||
fileurl: string;
|
||||
host: string;
|
||||
type: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
/** 附件视图对象(对齐 UpFileVo.java) */
|
||||
export interface UpFileVo {
|
||||
id: number;
|
||||
fileTrueName: string;
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
fileType: string;
|
||||
topicId: number;
|
||||
topicType: string;
|
||||
}
|
||||
|
||||
// ============================ 一键已读 ============================
|
||||
|
||||
/** 一键已读结果(对齐 InitializeRedVo.java,@JsonProperty) */
|
||||
export interface InitializeRedVo {
|
||||
count: number;
|
||||
}
|
||||
|
||||
// ============================ 上一篇/下一篇 ============================
|
||||
|
||||
/** 上一篇/下一篇作业ID */
|
||||
export interface NextPrevVo {
|
||||
prevId: number;
|
||||
nextId: number;
|
||||
}
|
||||
|
||||
// ============================ 阅读情况 ============================
|
||||
|
||||
/** 阅读情况分组项(后端 Map 返回,字段名固定) */
|
||||
export interface ReadGroup {
|
||||
GroupName: string;
|
||||
teaslist: ReadGroupUser[];
|
||||
}
|
||||
|
||||
/** 阅读情况用户项(后端 Map 返回) */
|
||||
export interface ReadGroupUser {
|
||||
account: string;
|
||||
teaname: string;
|
||||
states: 'read' | 'unread';
|
||||
}
|
||||
|
||||
// ============================ 统计 ============================
|
||||
|
||||
/** 作业统计视图(对齐 HomeworkStatisticsVo.java) */
|
||||
export interface HomeworkStatisticsVo {
|
||||
total: number;
|
||||
publishedCount: number;
|
||||
totalReadRecords: number;
|
||||
unreadForCurrentUser: number;
|
||||
byPublisher: { publisherName: string; count: number }[];
|
||||
byMonth: { year: number; month: number; count: number }[];
|
||||
// ===== 课程作业口径(v1,可选) =====
|
||||
/** 我布置的作业数 */
|
||||
myAssignCount?: number;
|
||||
/** 待批改总量 */
|
||||
pendingGrade?: number;
|
||||
/** 平均提交率(%) */
|
||||
avgSubmitRate?: number;
|
||||
/** 按作业进度 */
|
||||
byHomework?: { id: number; title: string; subject: string; hwStatus: HomeworkStatus } & HomeworkProgress[];
|
||||
/** 分数分布 */
|
||||
scoreDist?: { label: string; key: string; count: number }[];
|
||||
}
|
||||
|
||||
// ============================ 课程作业 v1:提交 / 批改 ============================
|
||||
|
||||
/** 作业流程状态 */
|
||||
export type HomeworkStatus = "draft" | "collecting" | "closed" | "done";
|
||||
|
||||
/** 单个提交状态 */
|
||||
export type SubmitStatus = "unsubmitted" | "submitted" | "graded" | "needRedo" | "redone";
|
||||
|
||||
/** 提交/批改进度 */
|
||||
export interface HomeworkProgress {
|
||||
/** 应交人数 */
|
||||
should: number;
|
||||
/** 已交人数 */
|
||||
submitted: number;
|
||||
/** 已批改人数 */
|
||||
graded: number;
|
||||
/** 待批改人数 */
|
||||
pendingGrade: number;
|
||||
/** 待订正人数 */
|
||||
needRedo: number;
|
||||
/** 已订正人数 */
|
||||
redone: number;
|
||||
/** 未交人数 */
|
||||
unsubmitted: number;
|
||||
/** 提交率(%) */
|
||||
submitRate: number;
|
||||
/** 批改率(%) */
|
||||
gradeRate: number;
|
||||
}
|
||||
|
||||
/** 提交名单项(Submissions 接口返回,精简) */
|
||||
export interface SubmissionBrief {
|
||||
subId: number;
|
||||
studentId: number;
|
||||
studentName: string;
|
||||
submitStatus: SubmitStatus;
|
||||
submittedAt: string | null;
|
||||
hasGrade: boolean;
|
||||
score: number | null;
|
||||
level: string | null;
|
||||
isExcellent: boolean;
|
||||
}
|
||||
|
||||
/** 提交名单按班级分组 */
|
||||
export interface SubmissionGroup {
|
||||
className: string;
|
||||
students: SubmissionBrief[];
|
||||
}
|
||||
|
||||
/** 某作业提交情况结果 */
|
||||
export interface SubmissionsResultVo {
|
||||
homeworkId: number;
|
||||
title: string;
|
||||
subject: string;
|
||||
deadline: string | null;
|
||||
hwStatus: HomeworkStatus;
|
||||
hwStatusDesc: string;
|
||||
summary: HomeworkProgress;
|
||||
groups: SubmissionGroup[];
|
||||
}
|
||||
|
||||
/** 批改结果 */
|
||||
export interface GradeResult {
|
||||
score: number | null;
|
||||
level: string | null;
|
||||
comment: string;
|
||||
isExcellent: boolean;
|
||||
markedImages: string[];
|
||||
}
|
||||
|
||||
/** 单份提交详情(批改工作台用) */
|
||||
export interface SubmissionDetailVo {
|
||||
subId: number;
|
||||
homeworkId: number;
|
||||
homeworkTitle: string;
|
||||
subject: string;
|
||||
studentId: number;
|
||||
studentName: string;
|
||||
classId: string;
|
||||
className: string;
|
||||
submitStatus: SubmitStatus;
|
||||
submittedAt: string | null;
|
||||
text: string;
|
||||
images: string[];
|
||||
attachments: string[];
|
||||
grade: GradeResult | null;
|
||||
}
|
||||
|
||||
/** 批改入参 */
|
||||
export interface GradeRequest {
|
||||
subId: number;
|
||||
/** grade 保存批改 / redo 要求订正 / excellent 标为优秀 */
|
||||
action?: "grade" | "redo" | "excellent";
|
||||
score?: number | string;
|
||||
level?: string;
|
||||
comment?: string;
|
||||
isExcellent?: boolean;
|
||||
markedImages?: string[];
|
||||
}
|
||||
|
||||
// ============================ 请求入参 ============================
|
||||
|
||||
/** 作业发布/编辑入参(对齐 HomeworkRequest.java) */
|
||||
export interface HomeworkRequest {
|
||||
title: string;
|
||||
content?: string;
|
||||
/** 学科 */
|
||||
subject?: string;
|
||||
/** 截止时间 yyyy-MM-dd HH:mm:ss */
|
||||
deadline?: string;
|
||||
/** 提交方式:text/image/attach/none */
|
||||
submitType?: string[] | string;
|
||||
type?: number;
|
||||
deptId?: string;
|
||||
/** 是否允许逾期补交 */
|
||||
allowLate?: boolean;
|
||||
/** 截止提醒:如 ["1d","1h"] */
|
||||
remindBefore?: string[];
|
||||
/** 存草稿:true 则不发布 */
|
||||
draft?: boolean;
|
||||
deptName?: string;
|
||||
topmost?: number;
|
||||
/** 接收对象:"all" 或 班级 id 逗号串(receiveType=class) */
|
||||
receiveList?: string;
|
||||
/** 接收对象类型:class=班级 */
|
||||
receiveType?: string;
|
||||
/** 附件文件ID列表(逗号分隔串) */
|
||||
fileIds?: string;
|
||||
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;
|
||||
}
|
||||
|
||||
/** 作业列表查询参数(对齐 HomeworkQuery.java + BasePageQuery.java) */
|
||||
export interface HomeworkQuery {
|
||||
pageIndex?: number;
|
||||
pageSize?: number;
|
||||
keyword?: string;
|
||||
status?: number;
|
||||
type?: number;
|
||||
/** 列表范围(管理端):"user" 只看自己发布的,不传=全部 */
|
||||
scope?: string;
|
||||
deptId?: string;
|
||||
/** 学科筛选 */
|
||||
subject?: string;
|
||||
/** 作业流程状态筛选:draft/collecting/closed/done,缺省或 all=全部 */
|
||||
hwStatus?: string;
|
||||
publisherAccount?: string;
|
||||
/** 开始时间(yyyy-MM-dd 短日期) */
|
||||
startTime?: string;
|
||||
/** 结束时间(yyyy-MM-dd 短日期) */
|
||||
endTime?: string;
|
||||
/** 阅读状态过滤(仅用户端):0 全部 / 1 未读 / 2 已读 */
|
||||
readType?: number;
|
||||
/** 排序:1 时间倒序 / 2 时间正序 */
|
||||
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 {
|
||||
id: number;
|
||||
name: string;
|
||||
isParent: boolean;
|
||||
studentCount?: number;
|
||||
children?: ClassTreeNode[];
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/** 审批列表查询参数 */
|
||||
export interface ApprovalQuery {
|
||||
pageIndex?: number;
|
||||
pageSize?: number;
|
||||
/** pending / approved / rejected / all(缺省 pending) */
|
||||
status?: string;
|
||||
keyword?: string;
|
||||
/** "user" 只看我提交的审批 */
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
/** 审批操作入参 */
|
||||
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";
|
||||
|
||||
/** 学生对某作业的提交/批改态 */
|
||||
export interface StudentSubmissionInfo {
|
||||
subId: number;
|
||||
submitStatus: SubmitStatus;
|
||||
submittedAt: string | null;
|
||||
text: string;
|
||||
score: number | null;
|
||||
level: string | null;
|
||||
comment: string;
|
||||
isExcellent: boolean;
|
||||
images?: string[];
|
||||
attachments?: string[];
|
||||
grade?: GradeResult | null;
|
||||
}
|
||||
|
||||
/** 学生视角作业项 */
|
||||
export interface StudentHomeworkVo {
|
||||
id: number;
|
||||
title: string;
|
||||
subject: string;
|
||||
publisherName: string;
|
||||
deptName: string;
|
||||
deadline: string | null;
|
||||
createTime: string | null;
|
||||
content: string;
|
||||
submitType: string[];
|
||||
viewOnly: boolean;
|
||||
hwStatus?: HomeworkStatus;
|
||||
allowLate?: boolean;
|
||||
my: StudentSubmissionInfo | null;
|
||||
}
|
||||
|
||||
/** 今日/我的作业列表 */
|
||||
export interface MyHomeworksVo {
|
||||
buckets: { todo: number; submitted: number; graded: number; redo: number; all: number };
|
||||
list: StudentHomeworkVo[];
|
||||
}
|
||||
|
||||
/** 我的某作业详情(作业信息 + 我的提交/批改) */
|
||||
export interface MySubmissionVo {
|
||||
homework: { id: number; title: string; subject: string; content: string; deadline: string | null; submitType: string[]; publisherName: string; deptName: string };
|
||||
my: StudentSubmissionInfo | null;
|
||||
}
|
||||
|
||||
/** 学生提交作业入参 */
|
||||
export interface SubmitRequest {
|
||||
serid: number;
|
||||
text?: string;
|
||||
/** 图片 URL / dataURL 列表(逗号串或数组) */
|
||||
images?: string[] | string;
|
||||
/** 附件 id 列表(逗号串或数组) */
|
||||
attachments?: string[] | string;
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import routes from "@/router/routes";
|
||||
|
||||
/**
|
||||
* 平台模块配置 → 前端内部路由的统一解析器。
|
||||
*
|
||||
* 基础平台里配置的模块 path 格式不可控(可能带 /homework 旧前缀、带
|
||||
* index.html#/hash、是应用入口地址甚至完整 URL、或干脆配错),直接
|
||||
* router.push(item.page) 匹配不到就会白屏或被重定向兜到默认页。所有消费
|
||||
* 模块 page 的地方(侧边栏跳转/高亮、登录后默认跳转)必须先经过这里。
|
||||
*
|
||||
* 解析顺序:路径精确匹配 → 模块名匹配 → 外链判定 → modId 契约匹配。
|
||||
* 全部落空时返回空结果并 console.warn,绝不返回未注册的原始路径——
|
||||
* 把它 push 给路由器会被 / 重定向吞掉,表象就是"点了菜单跳到默认页"。
|
||||
*/
|
||||
|
||||
/** 路由 path 集合:重定向路由穿透到目标 path,保证解析结果可直接用于高亮对照 */
|
||||
const ROUTE_PATHS = routes
|
||||
.map((r) => (typeof r.redirect === "string" ? r.redirect : r.path))
|
||||
.filter((p): p is string => !!p && !p.includes(":"));
|
||||
|
||||
/** modId → 路由 path(取该 modId 的第一个非重定向路由) */
|
||||
const ROUTE_BY_MODID = new Map<number, string>();
|
||||
for (const r of routes) {
|
||||
const modId = (r.meta as any)?.modId as number | undefined;
|
||||
if (!modId || !r.path || r.path.includes(":") || r.redirect) continue;
|
||||
if (!ROUTE_BY_MODID.has(modId)) ROUTE_BY_MODID.set(modId, r.path);
|
||||
}
|
||||
|
||||
/** 标题 → 路由 path(重复标题取先注册的,如 发作业 → /list/new) */
|
||||
const ROUTE_BY_TITLE = new Map<string, string>();
|
||||
for (const r of routes) {
|
||||
if (r.redirect || !r.path || r.path.includes(":")) continue;
|
||||
const title = (r.meta as any)?.title as string | undefined;
|
||||
if (title && !ROUTE_BY_TITLE.has(title)) ROUTE_BY_TITLE.set(title, r.path);
|
||||
}
|
||||
|
||||
/** 平台历史模块名 → 路由(旧菜单叫法与现页面标题不一致的对照) */
|
||||
const NAME_ALIASES: Record<string, string> = {
|
||||
作业发送: "/list/new",
|
||||
发作业: "/list/new",
|
||||
已发作业: "/list",
|
||||
已收作业: "/accept",
|
||||
作业列表: "/homeworklist",
|
||||
统计分析: "/stats",
|
||||
};
|
||||
|
||||
const EXTERNAL_RE = /^(https?:)?\/\//i;
|
||||
|
||||
/** 从平台原始配置里提取内部路由候选串(去掉 hash、query、尾斜杠) */
|
||||
function extractCandidate(raw: string): string {
|
||||
let p = raw.trim();
|
||||
try {
|
||||
p = decodeURIComponent(p);
|
||||
} catch {
|
||||
/* 含非法编码时保持原样 */
|
||||
}
|
||||
// 带 hash 的地址(.../index.html#/setting):取 # 后的 SPA 路由
|
||||
const hashIdx = p.indexOf("#");
|
||||
if (hashIdx >= 0) {
|
||||
p = p.slice(hashIdx + 1);
|
||||
} else if (EXTERNAL_RE.test(p)) {
|
||||
// 无 hash 的完整 URL:取 pathname(如 https://host/setting)
|
||||
try {
|
||||
p = new URL(p, "http://placeholder.local").pathname;
|
||||
} catch {
|
||||
/* 保持原样 */
|
||||
}
|
||||
}
|
||||
const qIdx = p.indexOf("?");
|
||||
if (qIdx >= 0) p = p.slice(0, qIdx);
|
||||
// 入口文件名去掉后即可命中根重定向路由
|
||||
p = p.replace(/index\.html?$/i, "");
|
||||
p = p.replace(/\/+$/, "");
|
||||
if (p && !p.startsWith("/")) p = "/" + p;
|
||||
return p;
|
||||
}
|
||||
|
||||
/** 候选串 → 已注册路由 path;未命中返回 null */
|
||||
function matchRoute(candidate: string): string | null {
|
||||
if (!candidate) return null;
|
||||
const lower = candidate.toLowerCase();
|
||||
// 1. 精确匹配(含重定向穿透后的目标路径)
|
||||
const exact = ROUTE_PATHS.find((p) => p.toLowerCase() === lower);
|
||||
if (exact) return exact;
|
||||
// 2. 兼容旧 /homework 前缀(/homework/setting → /setting)。
|
||||
// 不做通用后缀匹配,避免 /stats/OA/Homework 这类外部路径被误匹配
|
||||
if (lower.startsWith("/homework/")) {
|
||||
const stripped = lower.slice("/homework".length);
|
||||
return ROUTE_PATHS.find((p) => p.toLowerCase() === stripped) ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 按模块名解析路由:精确标题 → 历史别名 → 双向包含(命中多个则放弃) */
|
||||
function matchByName(name?: string | null): string | null {
|
||||
const n = String(name ?? "").trim();
|
||||
if (!n) return null;
|
||||
const byTitle = ROUTE_BY_TITLE.get(n);
|
||||
if (byTitle) return byTitle;
|
||||
const alias = NAME_ALIASES[n];
|
||||
if (alias) return alias;
|
||||
const hits = [...ROUTE_BY_TITLE.entries()].filter(
|
||||
([title]) => title.length >= 2 && (n.includes(title) || title.includes(n))
|
||||
);
|
||||
return hits.length === 1 ? hits[0][1] : null;
|
||||
}
|
||||
|
||||
/** 是否为本应用入口类地址(/、/homework、…/index.html):只表示"打开应用",
|
||||
* 不携带页面信息,不能当作有效跳转目标 */
|
||||
function isAppEntryCandidate(candidate: string): boolean {
|
||||
return !candidate || candidate === "/" || candidate === "/homework";
|
||||
}
|
||||
|
||||
export interface ResolvedMenuPage {
|
||||
/** 内部路由 path;解析不出时为空串 */
|
||||
path: string;
|
||||
/** 外部链接(完整 URL 且不属于本应用路由),需整页跳转而非 router.push */
|
||||
external: boolean;
|
||||
}
|
||||
|
||||
/** 空配置的统一返回值 */
|
||||
export const EMPTY_MENU_PAGE: ResolvedMenuPage = { path: "", external: false };
|
||||
|
||||
export function resolveMenuPage(
|
||||
page?: string | null,
|
||||
modId?: number | null,
|
||||
name?: string | null
|
||||
): ResolvedMenuPage {
|
||||
const raw = String(page ?? "").trim();
|
||||
const byName = (): string | null => matchByName(name);
|
||||
const byModId = (): string | null => (modId != null ? ROUTE_BY_MODID.get(modId) ?? null : null);
|
||||
|
||||
if (raw) {
|
||||
const candidate = extractCandidate(raw);
|
||||
const byPath = matchRoute(candidate);
|
||||
if (byPath) return { path: byPath, external: false };
|
||||
|
||||
// 完整 URL:指向本应用入口的不算有效配置(继续用名字/modId 识别),
|
||||
// 其余按外部链接整页打开
|
||||
if (EXTERNAL_RE.test(raw) && !raw.includes("#")) {
|
||||
if (!isAppEntryCandidate(candidate)) {
|
||||
return { path: raw, external: true };
|
||||
}
|
||||
}
|
||||
|
||||
const fallback = byName() ?? byModId();
|
||||
if (fallback) return { path: fallback, external: false };
|
||||
|
||||
console.warn(
|
||||
"[menuRoute] 模块路径/名称/modId 均无法匹配前端路由,请检查基础平台模块配置:",
|
||||
raw,
|
||||
"modId =", modId,
|
||||
"name =", name
|
||||
);
|
||||
return EMPTY_MENU_PAGE;
|
||||
}
|
||||
|
||||
// 未配置 path:按模块名 → modId 契约解析
|
||||
const fallback = byName() ?? byModId();
|
||||
if (fallback) return { path: fallback, external: false };
|
||||
console.warn("[menuRoute] 模块未配置路径且名称/modId 无法识别:", name, "modId =", modId);
|
||||
return EMPTY_MENU_PAGE;
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
# 规避后端 CAS 过滤器不回 CORS 头导致的跨域拦截;SSO/基础平台用真实环境
|
||||
VITE_BASE_HOST=http://base.yunhaixingtong.com
|
||||
VITE_SSO_HOST=http://sso.yunhaixingtong.com
|
||||
VITE_SERVICE_HOST=http://base.yunhaixingtong.com/campusnotice/
|
||||
VITE_SERVICE_HOST=http://base.yunhaixingtong.com/campushomework/
|
||||
VITE_ZIYUANKU_HOST=https://base.bestsch.com/BschZyk
|
||||
VITE_ZHUANXIANG_HOST=https://base.bestsch.com/BschZncpd
|
||||
VITE_IS_MOCK=false
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
# 课堂作业前端 M5 切换计划 —— 对齐后端 V2 接口完成页面
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| 项目 | 前端 `E:\YHXT\view\CourseHomework`(Vue3 + Vite + TS + view-ui-plus + alova) |
|
||||
| 依据 | 后端 `E:\YHXT\java\ClassHomeWork` 已实现接口(M1~M4 完成,V3.2 运行验证通过)+ `classwork-homework-plan-v3.md` §11 前端适配计划 / §15.5 遗留待办第 1 条 |
|
||||
| 目标 | 前端 9 个核心页面全部切换到 V2 接口面,打通「发布→统计→批阅→学生提交→结果」全链路联调 |
|
||||
| 后端地址 | 本地 `http://localhost:8102`(vite proxy 已配置,无需改动) |
|
||||
| 文档版本 | V1.0(2026-09-15) |
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与现状差距
|
||||
|
||||
### 1.1 后端权威接口面(已实现,本计划的对齐目标)
|
||||
|
||||
后端按 PRD §18 完成了 V2 接口(28 个新端点),同时保留 V1 冻结接口(17 个)。统一响应 `{code,msg,data}`,分页 `BasePageResult`,时间格式全局 `yyyy-MM-dd HH:mm:ss`(无 "T" 分隔符)。
|
||||
|
||||
**教师端 `/api/teacher/homeworks`(19 端点,PRD 页 1~6):**
|
||||
|
||||
| 方法 | 路径 | 用途 |
|
||||
|---|---|---|
|
||||
| POST | `` | 创建作业(一律落草稿 DRAFT_UNPUBLISHED) |
|
||||
| PUT | `/{id}` | 编辑作业(仅草稿两态) |
|
||||
| DELETE | `/{id}` | 删除作业(仅草稿两态) |
|
||||
| POST | `/{id}/publish` | 立即发布 |
|
||||
| POST | `/{id}/schedule` | 定时发布 / 取消定时(`{scheduledAt}` 或 `{cancel:true}`) |
|
||||
| GET | `/drafts` | 草稿箱分页(`subject`/`hwStatus`/`keyword` 筛选) |
|
||||
| GET | `/published` | 已发布列表分页(含应交/已读/已交/未读未交聚合) |
|
||||
| POST | `/{id}/remind` | 提醒未读/未交(`{type: NOT_READ\|NOT_SUBMITTED, studentIds?}`,返回 `{reminded}`) |
|
||||
| POST | `/{id}/extend` | 延长截止(`{deadlineAt}` 须晚于当前值) |
|
||||
| POST | `/{id}/copy` | 复制为新草稿,返回新 id |
|
||||
| POST | `/{id}/archive` | 归档(只读,屏蔽提醒) |
|
||||
| GET | `/{id}/export` | 导出学生矩阵 Excel(文件流) |
|
||||
| GET | `/{id}` | 作业详情 |
|
||||
| GET | `/{id}/stats` | 汇总统计卡片(总进度 + 分班进度) |
|
||||
| GET | `/{id}/students` | 学生矩阵分页(`classId`/`submitStatus`/`reviewStatus`/`pendingGrade` 筛选) |
|
||||
| POST | `/{id}/submissions/{studentId}/review` | 保存批阅(`{score?, comment?, markedImages?}`,score/comment 至少一项) |
|
||||
| POST | `/{id}/submissions/{studentId}/return` | 打回(`{comment}` 必填) |
|
||||
| GET | `/{id}/submissions/{studentId}/versions` | 历史提交版本列表 |
|
||||
| GET | `/classes` | 年级→班级树(叶子 id=classId,含 studentCount) |
|
||||
| POST | `/classes/sync` | 手动触发名册同步 |
|
||||
|
||||
**学生端 `/api/student/homeworks`(6 端点,PRD 学生页 1~3):**
|
||||
|
||||
| 方法 | 路径 | 用途 |
|
||||
|---|---|---|
|
||||
| GET | `` | 我的作业列表(`tab=all/todo/done/closed` + 分页/keyword) |
|
||||
| GET | `/{id}` | 作业详情(含 description/attachments/myAttachments/myText) |
|
||||
| POST | `/{id}/read` | 阅读上报(前端停留 ≥3 秒后调用,幂等) |
|
||||
| POST | `/{id}/submissions` | 提交作业(`{text?, attachments[]}`),返回 `{submissionId}` |
|
||||
| PUT | `/{id}/submissions/{submissionId}` | 撤回/打回重交(`{action: withdraw\|resubmit, text?, attachments?}`) |
|
||||
| GET | `/{id}/result` | 批阅结果(当前版本 + 历史版本含各版评语) |
|
||||
|
||||
**通知 `/api/notifications`(3 端点)**:GET 分页(含未读数)、`POST /{id}/read`、`POST /read-all`。
|
||||
|
||||
**辅助域(已存在,继续沿用)**:文件 `/api/file/Upload|UploadImage|Down|Preview|Delete|List|Bind`;字典 `/api/dict/List|ManageList|Add|Update|Delete|Sort|Sync`;联系组 `/api/contacts/*`;`/api/base/GetUserInfo|GetUserMod|GetSchoolAll|GetGroupUser|FileUpload|Logout`。
|
||||
|
||||
**V1 冻结接口 `/api/homework/*`(17 个)**:继续服务未切换页面(如「已收到」收件箱、「全部作业」管理端、「作业统计」),M5 完成后评估退役。
|
||||
|
||||
### 1.2 前端现状盘点与技术债
|
||||
|
||||
页面与数据源核对结果(`homeworkApi.*` 实际调用 vs 后端真伪):
|
||||
|
||||
| 页面 | 路由 | 现状数据源 | 判定 |
|
||||
|---|---|---|---|
|
||||
| NewHomework.vue | /list/new | 旧 Create/Edit/GetDetail | 真实接口,需切 V2 |
|
||||
| HomeworkList.vue | /list、/homeworklist | 旧 SendList + **remind(伪)** | 部分伪接口 |
|
||||
| HomeworkDetails.vue | /list/detail | 旧 Detail/GetNextPrev/ReadDetail | 真实接口,需切 V2 |
|
||||
| Submissions.vue | /submissions | **Submissions/SubmissionDetail/grade(伪)** | 全部伪接口 |
|
||||
| Grade.vue | /grade | **sendList + Submissions/SubmissionDetail/grade(伪)** | 部分伪接口 |
|
||||
| StudentHomeworks.vue | /stu/today、/stu/redo、/par/kids | **MyHomeworks/parentNudge(伪)** | 全部伪接口 |
|
||||
| HomeworkAccept.vue | /accept | 旧 List/Detail(v1 收件箱) | 真实接口,需改造为学生详情页 |
|
||||
| HomeworkStats.vue | /stats | 旧 Statis | 真实接口,保留 |
|
||||
| HomeworkSetting.vue | /setting | dict/contacts | 真实接口,保留 |
|
||||
| ApprovalList.vue | /approval | **approvalList/approve/reject(伪)** | 后端无审批域 |
|
||||
| ApprovalSetting.vue | /approval-setting | **approvers 系(伪)** | 后端无审批域 |
|
||||
|
||||
**「伪接口」清单**(后端不存在,真实环境必 404,仅 mock 模式可跑):`/api/homework/` 下的 `Submissions、SubmissionDetail、Grade、Remind、MyHomeworks、MySubmission、Submit、ParentNudge、ApprovalList、ApprovalCount、Approve、Reject`;`/api/dict/` 下的 `ApproverPool、Approvers、AddApprover、UpdateApprover、DelApprover`;`/api/base/SetRole`。
|
||||
|
||||
**类型契约差距**:`types/homework.ts` 为 V1 契约(hwStatus 四态 `draft/collecting/closed/done`、submitStatus 小写五值);V2 要求 hwStatus 五态 `DRAFT_UNPUBLISHED/DRAFT_SCHEDULED/PUBLISHED/CLOSED/ARCHIVED`、submitStatus 五态 `NOT_SUBMITTED/SUBMITTED/LATE_SUBMITTED/RETURNED/REVIEWED`、reviewStatus 三态 `PENDING/REVIEWED/RETURNED`。
|
||||
|
||||
**工作区未提交改动**:Grade/StudentHomeworks/Submissions 三页的图片预览/上传优化 + mock/env 配置微调(与本次切换方向一致,保留并在其上继续)。
|
||||
|
||||
## 2. 总体方案
|
||||
|
||||
三层推进:**契约层(类型+API 客户端)→ 教师端页面 → 学生端页面**,最后清理菜单路由与 mock。旧 V1 页面(已收到/全部作业/统计/设置)不动,继续走冻结接口。
|
||||
|
||||
### 2.1 契约层(P0)
|
||||
|
||||
**新增 `src/types/homeworkV2.ts`**(V2 契约独立成文件,不污染 V1 类型;与后端 `model/v2/*.java` 一一对齐):
|
||||
|
||||
```ts
|
||||
/** 作业五态(PRD §17) */
|
||||
export type HwStatusV2 = "DRAFT_UNPUBLISHED" | "DRAFT_SCHEDULED" | "PUBLISHED" | "CLOSED" | "ARCHIVED";
|
||||
/** 提交五态 */
|
||||
export type SubmitStatusV2 = "NOT_SUBMITTED" | "SUBMITTED" | "LATE_SUBMITTED" | "RETURNED" | "REVIEWED";
|
||||
/** 批阅三态 */
|
||||
export type ReviewStatus = "PENDING" | "REVIEWED" | "RETURNED";
|
||||
|
||||
export interface AttachmentItem { fileId?: number; fileUrl: string; fileType?: "image" | "file"; fileName?: string; sort?: number; }
|
||||
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; }
|
||||
export interface HomeworkV2Vo { id; title; subject; description; hwStatus: HwStatusV2; hwStatusDesc; scheduledAt; publishedAt; deadlineAt; archivedAt; submitType: string[]; allowLate; remind1d; remind1h; classes: ClassItem[]; attachments: AttachmentItem[]; publisherId; publisherName; createTime; updateTime; progress?: ProgressVo | null; }
|
||||
export interface HomeworkV2Request { title: string; subject?; description?; classIds: string[]; attachments?: AttachmentItem[]; deadlineAt?; submitType: string[]; allowLate?; remind1d?; remind1h?; }
|
||||
export interface TeacherHomeworkQuery { pageIndex?; pageSize?; keyword?; subject?; hwStatus?; }
|
||||
export interface StudentMatrixVo / StudentMatrixQuery / HomeworkStatsVo / ClassTreeNodeV2(id: string)/ SubmissionVersionVo / StudentHomeworkV2Vo(含 MySubmission)/ StudentHomeworkDetailV2Vo / StudentSubmitRequest / StudentResultVo / NotificationVo …
|
||||
```
|
||||
|
||||
**新增 3 个 API 客户端**(风格沿 `api/module/homework.ts` 的 `get/postJSON` 封装):
|
||||
|
||||
- `src/api/module/teacherHomework.ts` → `teacherApi`:19 端点全覆盖 + `exportUrl(id)`(Excel 直链,浏览器跳转下载)
|
||||
- `src/api/module/studentHomework.ts` → `studentApi`:6 端点
|
||||
- `src/api/module/notification.ts` → `notificationApi`:3 端点
|
||||
|
||||
**修正 `api/module/homework.ts`**:`baseApi.setRole` 标注仅 mock 可用(后端无此端点,真实环境学生身份由 CAS 登录账号 ↔ class_student 映射,联调用种子账号 stu01~stu06);`baseApi.getClassTree` 的调用方改走 `teacherApi.classTree()`。
|
||||
|
||||
### 2.2 页面改造映射(P1~P3)
|
||||
|
||||
| PRD 页面 | 前端页面 | 目标接口 | 关键改造点 |
|
||||
|---|---|---|---|
|
||||
| 页 1 发布作业 | NewHomework.vue | POST/PUT + /classes + publish/schedule + file/Upload | ① 表单重构:classIds(年级→班级树多选,数据源换 `/teacher/homeworks/classes`)② 新增:截止时间(精确到分钟,空=仅查阅型)、submitType(图片/文件/两者/文本)、allowLate、remind1d/remind1h、定时发布时间 ③ 保存语义拆分:「存草稿」(POST/PUT)与「立即发布」(POST 后调 publish)/「定时发布」(POST 后调 schedule)④ 附件走 file/Upload,组装 AttachmentItem[] ⑤ 编辑回显改 GET /teacher/homeworks/{id}(旧 GetDetail 保留给转发场景) |
|
||||
| 页 2 草稿箱 | HomeworkList.vue(/list 增加「草稿箱」段) | GET /drafts | 草稿两态(未发布/定时待发)标签展示;操作:编辑、发布、取消定时(schedule cancel)、删除 |
|
||||
| 页 3 已发布列表 | HomeworkList.vue(/list 主段) | GET /published + remind/extend/copy/archive/export | ① 统计列:应交/已读/已交/**未读未交高亮** + 阅读率/提交率(progress)② 状态筛选 PUBLISHED/CLOSED/ARCHIVED ③ 操作组:提醒(NOT_READ/NOT_SUBMITTED,返回 reminded 提示)、延长截止(日期选择,须晚于当前)、复制为新草稿、归档、导出 Excel(直链)④ 行点击进详情 |
|
||||
| 页 4 详情/统计 | HomeworkDetails.vue | GET /{id} + GET /{id}/stats | 作业信息卡(五态标签、学科、截止、提交方式、班级、附件下载)+ 统计卡片(总进度 + 分班进度:阅读率/提交率/批阅率、待批阅数) |
|
||||
| 页 5 学生矩阵 | Submissions.vue | GET /{id}/students | ① 列:学生/班级/阅读状态(含首末阅读时间)/提交状态(含逾期角标)/提交时间/评分/批阅状态/附件缩略图 ② 筛选:classId、submitStatus、reviewStatus、只看待批阅(pendingGrade)③ 未交学生占位行(submissionId=0)④ 行操作:批阅、打回、提醒单个学生(remind 定向) |
|
||||
| 页 6 批阅/打回/版本 | Grade.vue(批改中心) | /published(pendingGrade 作业列表) + versions + review/return | ① 待批作业列表改 GET /published(按 progress.pendingGrade 排序/展示)② 选中学生(矩阵 pendingGrade=true)→ 右侧工作台 ③ 版本切换(GET versions,默认最新)④ 批阅保存(score+comment 至少一项)⑤ 打回(comment 必填,二次确认)⑥ 提交内容展示:text + 附件/图片(复用已有缩略图放大预览) |
|
||||
| 学生页 1 列表 | StudentHomeworks.vue | GET /student/homeworks?tab= | ① tab 改 all/todo/done/closed(对齐 meta.initTab=redo→todo 语义调整)② 倒计时(deadlineAt)③ my 提交状态徽标(五态 + 逾期角标)④ 分页 + keyword |
|
||||
| 学生页 2 详情/结果 | HomeworkAccept.vue(改造为学生详情页,挂 /stu/detail?serid=) | GET /{id} + read + result | ① 作业说明富文本 + 附件预览/下载 ② **进入页 3 秒定时器调 read(onUnmounted 清理)** ③ 我的提交区(当前版本 text/附件/评分/评语)④ 历史版本 + 各版评语(GET result)⑤ 提交/撤回/重交入口 |
|
||||
| 学生页 3 提交 | HomeworkAccept.vue 内提交区(+列表页快捷弹窗保留) | POST submissions + PUT withdraw/resubmit | ① 按 submitType 动态渲染:文本框 / 图片上传(file/UploadImage 产物→AttachmentItem)/文件上传 ② 截止校验:截止后 allowLate=0 禁交 ③ 撤回(截止前)→ 重新填写重交 ④ 打回(RETURNED)→ 显示订正原因 + 重交 |
|
||||
|
||||
**不动页面**:HomeworkStats(旧 Statis)、HomeworkSetting(dict/contacts)、/accept 的 v1 收件箱语义由 HomeworkAccept 改造承接(路由 `/accept` 重定向到 `/stu/today`)。
|
||||
|
||||
### 2.3 路由与菜单调整(P3)
|
||||
|
||||
| 项 | 处置 | 理由 |
|
||||
|---|---|---|
|
||||
| 新增 `/stu/detail`(HomeworkAccept 改造后) | meta `{ role: "student", title: "作业详情", modId: 33 }` | PRD 学生页 2/3 载体 |
|
||||
| `/accept` | 重定向 `/stu/today` | v1 收件箱语义被学生列表替代 |
|
||||
| `/approval`、`/approval-setting` + 菜单 modId 5/6 | **路由与菜单摘除**(页面文件保留不引) | 后端无审批域(plan-v3 §4.6 顺延),伪接口联调必挂 |
|
||||
| `/par/kids` 家长端 | 保留路由但隐藏入口 | 后端无家长接口(plan-v3 §4.6 顺延),parentNudge 为伪接口;页面内调用加守卫 |
|
||||
| `/homeworklist` 全部作业、`/stats` 统计、`/setting` 设置 | 保留走 V1 冻结接口 | 冻结接口可用,M5 后评估退役 |
|
||||
| 通知中心(可选) | 视排期加 `/notify` 轻量页(notificationApi 3 端点,未读角标) | PRD §18 未列页面,plan-v3 §7.3 补充接口;列为可选项 |
|
||||
|
||||
### 2.4 mock 同步(P4)
|
||||
|
||||
`src/assets/mock/homeworkapi.ts` 增补 V2 端点 mock(teacher 19 + student 6 + notifications 3),数据模型对齐 `types/homeworkV2.ts`,保证 `npm run dev:mock` 全页面可演示;同时清理伪接口 mock(Submissions/Grade/MyHomeworks/审批系),避免继续误导联调。
|
||||
|
||||
## 3. 实施顺序与验收
|
||||
|
||||
| 阶段 | 内容 | 验收标准 |
|
||||
|---|---|---|
|
||||
| P0 契约层 | types/homeworkV2.ts + teacherHomework/studentHomework/notification 三客户端 + homework.ts 修正 | `vue-tsc` 通过;接口路径/参数与本文 §1.1 表逐项一致 |
|
||||
| P1 教师端发布链路 | NewHomework + HomeworkList(草稿箱+已发布) | 真实后端(8102):建作业→存草稿→草稿箱可见→立即发布/定时发布→已发布列表出统计聚合 |
|
||||
| P2 教师端批阅链路 | HomeworkDetails(stats)+ Submissions(矩阵)+ Grade(批阅/打回/版本) | 矩阵筛选/缩略图/占位行正确;批阅→学生端可见结果;打回→重交→版本递增 |
|
||||
| P3 学生端闭环 | StudentHomeworks + HomeworkAccept + 路由菜单调整 | 用种子账号(stu01~stu06):列表 tab→详情 3 秒已读→提交→倒计时/逾期分流→撤回留版本→打回重交→查结果 |
|
||||
| P4 清理与全量走查 | mock 更新、审批/家长端摘除、全页面回归 | dev(dev) + dev:mock 双模式走查;PRD §18 九页面映射逐格核对 |
|
||||
|
||||
每阶段一组语义化 commit;P1 起需要后端 8102 运行 + `sql/v3_seed.sql` 名册种子(库名 `class_homework`,utf8mb4)。
|
||||
|
||||
## 4. 风险与开放问题
|
||||
|
||||
| # | 事项 | 影响 | 处置建议 |
|
||||
|---|---|---|---|
|
||||
| 1 | 学生身份联调:后端无 SetRole,学生接口按 CAS 登录账号 ↔ class_student 映射 | 高(P3 阻塞) | 与后端确认本地登录方案(种子账号 stu01~stu06 走 CAS 或本地放行);mock 模式保留 SetRole 演示用 |
|
||||
| 2 | 审批域下线需产品确认 | 中 | 本计划先摘菜单/路由不删代码;产品若要保留需后端补接口 |
|
||||
| 3 | 提交附件契约:v2 只收 file/Upload 产物(fileId/fileUrl),拒绝 dataURL | 中 | HomeworkUpload 组件改造为真实上传后再组装 AttachmentItem;改造前 dataURL 直交必被后端拒 |
|
||||
| 4 | 学生端入口与菜单结构(/stu/* 不在现有静态菜单) | 低 | 沿用现有约定,学生视角由平台角色决定入口;具体待 GetUserMod 对接方案 |
|
||||
| 5 | Excel 导出/export 直链需带登录态 cookie | 低 | 同源代理下 `location.href` 直跳即可(与 AllFileDown 同模式) |
|
||||
| 6 | V1 页面(已收到/全部作业/统计)与新数据并存的两套状态口径 | 低 | 双轨期 hwStatus 派生映射(后端已做),前端不做转换;M5 后评估 V1 退役 |
|
||||
| 7 | tab=redo(待订正)语义:V2 无独立 redo 桶,RETURNED 归入 todo | 低 | /stu/redo 路由 initTab 改 todo 并在页内强化 RETURNED 过滤,或改路由为 /stu/todo |
|
||||
|
||||
## 5. 交付物清单
|
||||
|
||||
1. `src/types/homeworkV2.ts` —— V2 全量类型契约
|
||||
2. `src/api/module/teacherHomework.ts` / `studentHomework.ts` / `notification.ts` —— 28 端点客户端
|
||||
3. 改造后 7 个页面:NewHomework / HomeworkList / HomeworkDetails / Submissions / Grade / StudentHomeworks / HomeworkAccept
|
||||
4. `src/router/routes.ts` + `src/assets/config/menu.ts` 调整
|
||||
5. `src/assets/mock/homeworkapi.ts` V2 增补与伪接口清理
|
||||
|
||||
---
|
||||
|
||||
## 6. 审核(审批)功能下线(2026-09-15)
|
||||
|
||||
### 6.1 裁决变化
|
||||
|
||||
§2.3 与 §4 风险 2 原口径为「摘菜单/路由、页面文件保留不引,等产品确认」。本次确认**彻底删除审批(审核)功能**:产品定位为任课教师直接布置,不存在送审环节;后端 `ClassHomeWork` 从未实现审批域(无 `approval_*` 列、无 `ApprovalList/Approve/Reject`、无审批人池接口),而前端两页调用的 `homeworkApi.approvalList/approve/reject` 与整个 `approvalSettingApi` **在 API 层根本不存在**,属编译期即悬空的死代码。
|
||||
|
||||
注意边界:本节只删「审批/审核」(approval),**不动「批改/批阅」(grade/review)**——`Grade.vue`、`Submissions.vue`、`ReviewModal.vue` 是作业核心链路。
|
||||
|
||||
### 6.2 牵连面盘点(grep `approval|审核|审批|approver|canApprove|vote|flow` 全仓)
|
||||
|
||||
| # | 文件 | 现状 | 处置 |
|
||||
|---|---|---|---|
|
||||
| 1 | `src/views/homework/ApprovalList.vue` | 审批列表页,未挂路由,调用不存在的 `homeworkApi.approvalList/approve/reject` | 删除(git 跟踪,可 `git checkout` 恢复) |
|
||||
| 2 | `src/views/homework/ApprovalSetting.vue` | 审批人池设置页,未挂路由,调用不存在的 `approvalSettingApi` | 删除(同上) |
|
||||
| 3 | `src/components/ApprovalFlowEditor.vue` | 工作区已处于删除态(`git status` 显示 ` D`) | 保持删除,不恢复 |
|
||||
| 4 | `src/types/homework.ts` | `HomeworkVo` 81-95、`ApprovalStatus` 103、`HomeworkDetailVo` 146-174(含 `approvalFlow`/`canApprove`)、`HomeworkRequest` 393-398(`approvers`/`vote`/`flow`)、`ApprovalQuery` 489、`ApprovalAction` 500、`ApproverVo` 509、分节标题 476 | 全部删除;分节标题改「班级」 |
|
||||
| 5 | `src/api/module/homework.ts` | 23-24 `import { ApprovalQuery, ApproverVo }`(导入后零使用) | 删两行导入 |
|
||||
| 6 | `src/assets/config/menu.ts` | 12-13 静态菜单 `id/modId 5 审批列表 /approval`、`6 审核设置 /approval-setting`(指向不存在的路由) | 删两行 |
|
||||
| 7 | `src/utils/menuRoute.ts` | 无审批别名;但 `resolveMenuPage` 对无法识别的模块只 `console.warn` 并返回 `path:""`,而 `layout.vue:192` 直接渲染 `modList`(不过滤空 path)→ 平台 `GetUserMod` 若仍下发 modId 5/6,侧栏会出现点了没反应的死菜单 | 新增 `DROPPED_MOD_IDS={5,6}` + `dropDroppedModules(list)`(顶层 + children,按 `modId ?? id`,不改入参),`App.vue` 赋值 `layoutData.modList` 前调用 |
|
||||
| 8 | `src/assets/mock/homeworkapi.ts` | 审批引擎与数据:头注释 7/15-16、审批人池 41-52、`buildFlowFromInput` 62-72、`pendingApprovers` 75-79、`canAct` 81-84、`flowProgressText` 86-92、种子 `approval*` 字段 155-313(7 处)、`seedFlows` 318-343、复位循环 345-353、`isPublished` 639-641、`APPROVAL_DESC` 648、列表映射 682-689、详情映射 728-753、Create 1026-1032、Edit 1059-1092 | 全删;`isPublished` 简化为「非草稿即可见」;种子行不再带 `approval*` 键 |
|
||||
| 9 | 其余 `src/views`、`src/components`、`src/router`、`App.vue` | grep **零命中** | 无需改动 |
|
||||
|
||||
后端 `E:\YHXT\java\ClassHomeWork`:审批域从未落地,无可删代码;仅需把 `classwork-homework-plan-v4.md` 的 §0 排除说明与 Q-08 由「待确认」改为「已裁决下线」。
|
||||
|
||||
### 6.3 执行步骤与验收
|
||||
|
||||
1. 备份:`types/homework.ts`、`api/module/homework.ts`、`assets/mock/homeworkapi.ts`、`assets/config/menu.ts`、`utils/menuRoute.ts`、`App.vue` 快照到 `.backup/20260915-drop-approval/`(这些文件工作区相对 HEAD 已有 M5 未提交改动,`git restore` 会连带丢改动,故单独快照)。
|
||||
2. 删 2 个页面文件(`git rm` 语义,历史可查)。
|
||||
3. 按 6.2 顺序改 types → api → menu → menuRoute+App → mock(自底向上,先断引用再清数据)。
|
||||
4. 验收(实测见 §6.4):① `grep -rniE "approval|approver|canApprove" src` 命中数为 0;② `npm run build` 成功;③ `dev:mock` 下逐端点冒烟正常、审批端点 404、侧栏无审批入口。
|
||||
> 原定的 `npx vue-tsc --noEmit` 门禁在本工程**跑不起来**:`vue-tsc@1.8.27` 与 `typescript@5.9.3` 不兼容会直接崩溃,且工程 tsc 基线本就有 65 条存量报错。故类型侧只作「不得新增引用已删符号的报错」的对照检查,见 §6.4 第 4 条。
|
||||
5. 结果回填 §6.4。
|
||||
|
||||
### 6.4 执行结果回填(2026-09-15 完成)
|
||||
|
||||
**改动清单**
|
||||
|
||||
| 文件 | 动作 |
|
||||
|---|---|
|
||||
| `src/views/homework/ApprovalList.vue`、`ApprovalSetting.vue` | `git rm`(索引内 `D`,历史可查) |
|
||||
| `src/components/ApprovalFlowEditor.vue` | 保持工作区已删除状态,未恢复 |
|
||||
| `src/types/homework.ts` | 删 `HomeworkVo` 8 个 approval 字段、`ApprovalStatus`、`HomeworkDetailVo` 9 个 approval 字段(含 `approvalFlow`/`canApprove`)、`HomeworkRequest` 的 `approvers/vote/flow`、`ApprovalQuery`/`ApprovalAction`/`ApproverVo`;分节标题「班级 / 审批」→「班级」 |
|
||||
| `src/api/module/homework.ts` | 删 `ApprovalQuery`、`ApproverVo` 两个死导入 |
|
||||
| `src/assets/config/menu.ts` | 删 modId 5「审批列表」/6「审核设置」两项,头注释记录「平台若仍下发则由 DROPPED_MOD_IDS 剔除」 |
|
||||
| `src/utils/menuRoute.ts` | 新增 `DROPPED_MOD_IDS={5,6}`、`DROPPED_NAMES`(审批/审核 7 个叫法)、`dropDroppedModules()`(顶层+children,按 `modId ?? id` 或名称命中,返回新数组不改入参) |
|
||||
| `src/App.vue` | `GetUserMod` 结果入壳层前经 `dropDroppedModules` 过滤(侧栏渲染、默认跳转、console.table 共用同一份) |
|
||||
| `src/assets/mock/homeworkapi.ts` | 删审批引擎(审批人池 `approvers/approverSeq/approverName/approverPoolAccounts`、`FlowLevel`、`buildFlowFromInput`、`pendingApprovers`、`canAct`、`flowProgressText`、`APPROVAL_DESC`)、`seedFlows()` 与其后的复位 forEach、种子 8 处 approval 属性、`toVo`/详情两处 8 行映射与 `approvalFlow` 块、Create 新对象的 7 行 approval 初始化、Edit 的 flow 重建整段(35 行);`isPublished()` 由「审批通过或免审」改为 `hwStatus !== "draft"`;头注释与存储注释同步改写 |
|
||||
|
||||
**验收实测**
|
||||
|
||||
1. **残留归零**:`grep -rniE "approval|approver|canapprove|flowprogress|buildflow" src` → **0 命中**(中文「审批/审核」仅存于注释与 `DROPPED_NAMES` 名单,属有意保留)。
|
||||
2. **构建通过**:`npm run build`(`vite build --mode base`)**EXIT=0**,18.82s。
|
||||
3. **mock 冒烟**(`npm run dev:mock` @8077,node fetch 逐端点,全部 `code==0`):
|
||||
- `List` 5/7 条、`SendList?scope=user` 3/3 条、`Detail?serid=1&type=0` 出参键仍为 `serid/Contents/DeptID/...ChaoSend/tlist` 且**不再含任何 approval 键**、`ReadDetail` 按班级分组(`account=1301`/`teaname=初一(1)班(45人)`)、`MyHomeworks` buckets `{todo:3,submitted:1,graded:2,redo:0,all:7}`、`Statis` 11 字段齐全、`GetClassTree` 年级→班级带 `studentCount`。
|
||||
- 审批端点确认消失:`/api/homework/ApprovalList`、`/api/dict/ApproverPool` 均 **HTTP 404**。
|
||||
- 临时服务已按 PID 校验(命令行确为本工程 `vite.js --mode mock`)后 taskkill,8077 已释放。
|
||||
4. **类型检查工具链本身不可用(既有问题,非本次引入)**:`vue-tsc@1.8.27` 在 `typescript@5.9.3` 下直接崩溃(`Search string not found: "/supportedTSExtensions = .*(?=;)/"`,vue-tsc 1.x 的 monkey-patch 与 TS 5.5+ 不兼容)。退化用 `npx tsc --noEmit` 得 **65 个报错,全部为存量**:alova 泛型 `TS2344 Type 'T' does not satisfy the constraint 'AlovaGenerics'`(连未改动的 `notification.ts`/`teacherHomework.ts`/`studentHomework.ts` 同样报)、mock 种子数组属性推断 `TS2339`、`menu.ts` 的 `IModule` 未从 `@/store` 导出、`teacherNames` 未使用等;**其中无一条引用被删除的审批符号**(已 grep 验证)。→ 建议后续单列一项:升级 `vue-tsc` 到 2.x 或锁 `typescript<=5.4`,否则类型门禁形同虚设。
|
||||
|
||||
**行为变化(预期内)**:原先 `pending`/`rejected` 的演示种子不再被门控,收件箱从 6 条变 7 条、`Statis.publishedCount` 7/8;这符合「无送审环节,发布即可见」的产品口径。
|
||||
|
||||
**回退方式**:`git checkout HEAD -- src/views/homework/ApprovalList.vue src/views/homework/ApprovalSetting.vue` 可复原页面;types/api/menu/menuRoute/App/mock 六个文件的改动前快照在 `.backup/20260915-drop-approval/`(因工作区相对 HEAD 另有未提交的 M5 改动,直接 `git restore` 会连带丢 M5,故用快照)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 应用设置功能下线(2026-09-15)
|
||||
|
||||
### 7.1 裁决
|
||||
|
||||
「应用设置」(路由 `/setting`,modId 21)承载两块能力:**发文部门字典**(`dict/ManageList·Add·Update·Delete·Sort`)与**自定义联系组**(`contacts/GetGroup·AddGroup·EditGroup·DelGroup·GetContactsTea·SetContactsTea·GetGroupTree`),任课教师布置作业不依赖它们。裁决:
|
||||
|
||||
- **只删前端**:后端 `DictController`/`ContactsController` 及其 `sys_dict`/`contacts_group`/`contacts_group_user` 表**保留不动**(`HomeworkService.resolveDeptName` 仍要查 `sys_dict`;端点无人调用不影响运行)。
|
||||
- **仅该页使用的组件与死 API 一并清掉**(与 §6 同一口径)。
|
||||
|
||||
### 7.2 牵连面盘点
|
||||
|
||||
| 文件 | 现状 | 处置 |
|
||||
|---|---|---|
|
||||
| `src/views/homework/HomeworkSetting.vue` | 应用设置页(部门拖拽排序 + 联系组及组员维护) | 删除(git 跟踪) |
|
||||
| `src/components/TypeSetBox.vue` | 仅被 HomeworkSetting 引用(全仓 grep 零其他消费者) | 删除 |
|
||||
| `src/components/UserCheckInput.vue` | 零消费者(其 `loader` 用 `baseApi.getSchoolAll` + `settingApi.getGroupTree`) | 删除 |
|
||||
| `src/router/routes.ts` | `/setting` 路由(86-91)+ 旧路径重定向 `/homework/setting`(121)+ 头注释 `21=应用设置` | 删两处路由与注释项 |
|
||||
| `src/assets/config/menu.ts` | `{ id: 21, modId: 21, name: "应用设置", page: "/setting" }` | 删除 |
|
||||
| `src/api/module/homework.ts` | `class SettingApi`(12 个方法)+ `export const settingApi`;`BaseApi.getSchoolAll`;`HomeworkApi.getSysDic`(dict/List)、`getSchModuleIsSms`(dict/Sync) 自 §6 后零调用 | 全部删除 |
|
||||
| `src/types/homework.ts` | `DeptManageItem`、`DictOption`、`ContactGroupPage`、`ContactUserPage`、`ContactGroupItem`、`ContactUser` | 删除 |
|
||||
| `src/assets/mock/homeworkapi.ts` | `/api/base/GetSchoolAll`(682)、`/api/dict/*`(692-741)、`/api/contacts/*`(1173-1250) 三组桩;种子 `groups/groupSeq`(412-418)、`idNames`(420)、`teacherNames`(425,本就是死码)、`schoolTree`(431)、`deptSeq`(31) | 全部删除;头注释端点清单同步改写 |
|
||||
|
||||
### 7.3 明确保留项(勿顺手删)
|
||||
|
||||
| 保留 | 理由 |
|
||||
|---|---|
|
||||
| mock 的 `deptDict`(21-30) | Create(830)/Edit(867) 仍用 `deptDict.find(d => d.CodeID === b.deptId)` 解析部门名,是作业域数据来源,不是设置页专属 |
|
||||
| `src/components/UserCheckModal.vue`、`ClassCheckInput.vue` | 发作业选班级仍在用 |
|
||||
| `src/components/MyConfirm` | HomeworkList / TeacherHomeworks 仍在用 |
|
||||
| 类型 `SchoolTreeNode` | `BaseApi.getUserInfo` 的返回类型仍引用它 |
|
||||
| `vuedraggable` 依赖 | 删除后成未用依赖,但改 `package.json`/lock 属额外风险,本次不动(后续清依赖时一并处理) |
|
||||
| 后端 `dict/*`、`contacts/*` 端点与三张表 | 按 7.1 裁决保留 |
|
||||
|
||||
### 7.4 对后端 V4 方案的连带影响(需同步改 `classwork-homework-plan-v4.md`)
|
||||
|
||||
1. **E-01 修法调整**:原计划「`deptId` 为空时回填字典默认发文部门」失去维护入口,改为**从当前教师主组/`BoaUser.getCurrentRole()` 推导** `deptId/deptName`,`sys_dict` 仅作为已有数据的名称解析(`resolveDeptName` 保持)。
|
||||
2. **E-05 面收窄但过滤器仍必需**:`postForm` 的 14 处调用点里,dict 4 处 + contacts 6 处随本功能下线归零,剩余 `Remind`(N-04 新门面)、`ReceiveUsers`、`UserPull`、`SetTopmost`、`Expedite`、`InitializeRed`(无参) —— `SingleValueParameterFilter` 照旧要做,只是回归面变小。
|
||||
|
||||
### 7.5 执行步骤与验收
|
||||
|
||||
1. 快照受影响文件到 `.backup/20260915-drop-app-setting/`。
|
||||
2. `git rm` 三个文件 → 删路由/菜单 → 删 SettingApi 与死方法 → 删 types → 清 mock(端点 + 专属种子)。
|
||||
3. 验收:① `grep -rniE "settingApi|DeptManageItem|ContactGroup|ContactUser|DictOption|getSchoolAll|getGroupTree|TypeSetBox|UserCheckInput|homework_setting" src` 命中 0;② `npm run build` EXIT=0;③ `dev:mock` 冒烟:`List/SendList/Detail/ReadDetail/MyHomeworks/Statis/GetClassTree` 仍全 `code=0`,`/api/dict/*`、`/api/contacts/*`、`/api/base/GetSchoolAll` 均 404,`Create` 仍能解析 `deptName`(回归 §7.3 第一条);④ 侧栏无「应用设置」。
|
||||
4. 结果回填 §7.6。
|
||||
|
||||
### 7.6 执行结果回填(2026-09-15 完成)
|
||||
|
||||
**改动清单**
|
||||
|
||||
| 文件 | 动作 |
|
||||
|---|---|
|
||||
| `src/views/homework/HomeworkSetting.vue`、`src/components/TypeSetBox.vue`、`src/components/UserCheckInput.vue` | `git rm` |
|
||||
| `src/router/routes.ts` | 删 `/setting` 路由 + `/homework/setting` 重定向(目标已不存在,留着会把用户导向未知路由);头注释 modId 对照去掉 `21=应用设置` |
|
||||
| `src/assets/config/menu.ts` | 删 `{ id: 21, modId: 21, 应用设置 }`,头注释改为「不做审批、不开放应用设置」 |
|
||||
| `src/utils/menuRoute.ts` | `DROPPED_MOD_IDS` 扩为 `{5, 6, 21}`,`DROPPED_NAMES` 增「应用设置 / 作业设置」——平台 `GetUserMod` 若仍下发 modId 21,侧栏不会出现死菜单 |
|
||||
| `src/api/module/homework.ts` | 删 `class SettingApi`(12 方法)与 `settingApi` 导出、`BaseApi.getSchoolAll`、`HomeworkApi.getSysDic`/`getSchModuleIsSms`、`DICT`/`CONTACTS` 前缀常量、4 个类型导入;头注释第 3 条改写 |
|
||||
| `src/types/homework.ts` | 删 `DictOption`、`DeptManageItem`、`ContactGroupItem`、`ContactGroupPage`、`ContactUser`、`ContactUserPage`;分节改名为「基础平台」(保留 `SchoolTreeNode`) |
|
||||
| `src/assets/mock/homeworkapi.ts` | 删 `dict` 6 桩 + `contacts` 7 桩 + `GetSchoolAll` 桩;删种子 `groups/groupSeq`、`idNames`、`teacherNames`(本就是死码)、`schoolTree`、`deptSeq`;头注释与 `deptDict` 注释同步改写。**`deptDict` 按 §7.3 保留** |
|
||||
|
||||
**验收实测**
|
||||
|
||||
1. **残留归零**:手写代码中 `settingApi|DeptManageItem|ContactGroup|ContactUser|DictOption|getSchoolAll|getGroupTree|TypeSetBox|UserCheckInput|homework_setting|deptSeq` **0 命中**。另有 12 处 grep 命中经逐条核对均非残留:`src/api/apiDefinitions.ts` 与 `globals.d.ts` 是 `npm run api:gen` 生成的 swagger 快照(镜像后端现状,后端端点仍在,属正确内容);其余为 `SchoolTreeNode` 被大小写不敏感的 `schoolTree` 模式误命中。
|
||||
2. **构建通过**:`npm run build` **EXIT=0**,16.79s。
|
||||
3. **mock 冒烟**(`dev:mock` @8077):保留面 9 个端点全 `code=0`(`List` 5/7、`SendList` 3/3、`Detail`、`ReadDetail` 3 组、`MyHomeworks` buckets `{todo:3,submitted:1,graded:2,redo:0,all:7}`、`Statis`、`GetClassTree`、`GetUserInfo`、`GetUserMod`);**§7.3 保留项回归**:`Create(deptId:"D002")` → `GetDetail` 仍解析出 `DeptName="数学教研组"`、`DeptID="D002"`,说明 `deptDict` 未误删;已删端点 6/6 全部 **404**(`dict/List`、`dict/ManageList`、`dict/Sync`、`contacts/GetGroup`、`contacts/GetGroupTree`、`base/GetSchoolAll`)。临时服务按 PID 命令行确认归属后 taskkill,8077 已释放。
|
||||
4. **类型侧**:沿用 §6.4 第 4 条结论(`vue-tsc@1.8.27` 与 `typescript@5.9.3` 不兼容、工程 tsc 基线 65 条存量错),本次只核对「无引用已删符号的新报错」。
|
||||
|
||||
**遗留(有意不动)**:`vuedraggable` 依赖失去唯一使用者,留待统一清依赖时处理;`apiDefinitions.ts`/`globals.d.ts` 待下次 `npm run api:gen` 自然刷新;后端 `dict/*`、`contacts/*` 与三张表按裁决保留。
|
||||
|
||||
**回退方式**:`git checkout HEAD -- src/views/homework/HomeworkSetting.vue src/components/TypeSetBox.vue src/components/UserCheckInput.vue` 复原三文件;routes/menu/menuRoute/api/types/mock 六个文件的改动前快照在 `.backup/20260915-drop-app-setting/`。
|
||||
|
||||
|
||||
|
||||
|
||||
+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>
|
||||
@@ -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、尾斜杠) */
|
||||
|
||||
@@ -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>
|
||||
|
||||
+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