feat(M5): P0 契约层 + 审批/应用设置下线

- types/homeworkV2.ts:V2 全量类型契约(五态/枚举/矩阵/版本/通知)
- teacherHomework/studentHomework/notification 三客户端(28 端点)
- §6 审批功能下线:删 ApprovalList/ApprovalSetting/ApprovalFlowEditor,
  types/api/menu/menuRoute/App/mock 同步清理,DROPPED_MOD_IDS={5,6}
- §7 应用设置下线:删 HomeworkSetting/TypeSetBox/UserCheckInput,
  SettingApi 与 dict/contacts 死代码清理,DROPPED_MOD_IDS 扩 {21}
- 新增 V2 组件:TeacherHomeworks(已发布+草稿箱)、ReviewModal(批阅弹窗)、
  AttachmentUpload(附件上传)——尚未挂路由,页面切换随后续阶段
This commit is contained in:
wanghu
2026-09-16 02:29:32 +08:00
parent d75aab8d07
commit e6639f7925
40 changed files with 7734 additions and 1535 deletions
@@ -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,169 @@
<template>
<div class="type_set-box">
<div class="type_name">
<i class="iconfont icon-stop fl mr-10" v-if="!isEdit && !active" style="color: #ff5959"></i>
<Input v-if="isEdit" v-model="type.name" ref="type_input" @on-blur="update" @on-enter="update" />
<span v-else class="fl">{{ type.name }}</span>
</div>
<div class="type_action flex">
<div class="action_item" @click="open">
<span v-if="active">停用</span>
<span v-else>启用</span>
</div>
<div class="action_edit" @click="edit">
<span>编辑</span>
</div>
<div class="action_delete" @click="remove">
<span>删除</span>
</div>
</div>
<div class="divider"></div>
<div class="divider2"></div>
</div>
</template>
<script>
export default {
name: "TypeSetBox",
props: {
type: {
name: { type: String, default: "" },
id: { type: String, default: "" },
},
active: { type: Boolean, default: true },
},
data() {
return {
isEdit: false,
eventType: "",
};
},
watch: {
type: {
deep: true,
handler(newVal) {
if (this.isEdit) {
this.isEdit = false;
}
},
},
"type.name"(newVal) {
if (this.isEdit) {
this.isEdit = false;
}
},
},
methods: {
open() {
this.$emit("active", this.type.id);
},
edit() {
this.isEdit = !this.isEdit;
if (this.isEdit) this.$nextTick(() => this.$refs["type_input"].focus());
},
update(e) {
if (!this.type.name) {
this.$Message.error("值不得为空!");
return;
}
if (this.eventType == "") {
this.eventType = e.type;
this.isEdit = false;
this.$emit("edit", this.type.id, this.type.name);
setTimeout(() => {
this.eventType = "";
}, 500);
}
},
remove() {
this.$emit("remove", this.type.id);
},
},
};
</script>
<style lang="scss" scoped>
.flex {
display: flex;
}
.type_set-box {
width: 222px;
background: #ffffff;
margin-right: 15px;
margin-bottom: 15px;
.type_name {
height: 66px;
border: 1px solid #e9e9e9;
border-bottom: none;
padding-left: 15px;
padding-right: 15px;
line-height: 66px;
display: flex;
justify-content: center;
align-items: center;
span {
height: 24px;
font-size: 15px;
font-family: PingFangSC-Semibold, PingFang SC;
font-weight: 500;
color: #272829;
line-height: 24px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 222px;
display: inline-block;
}
.iconfont {
font-size: 26px;
}
}
.type_action {
flex-wrap: wrap;
justify-content: center;
align-items: center;
width: 222px;
height: 30px;
line-height: 30px;
background: #f7f7f7;
border-radius: 0px 0px 2px 2px;
border-top: none;
border: 1px solid #e9e9e9;
span {
font-size: 12px;
}
div {
cursor: pointer;
flex: 1;
text-align: center;
}
div:hover {
color: #6c91f0;
}
}
.divider {
// position: relative;
height: 14px;
width: 1px;
background: #e8e8e8;
// left: 73px;
// top: -23px;
float: left;
margin-left: 71px;
margin-top: -22px;
}
.divider2 {
// position: relative;
// left: 146px;
// top: -37px;
height: 14px;
width: 1px;
background: #e8e8e8;
float: left;
margin-left: 147px;
margin-top: -22px;
}
}
</style>
@@ -0,0 +1,165 @@
<template>
<!-- 作业对象选择:全部用户 / 指定用户(自旧前端 user_check_input.vue 适配) -->
<div class="check-user-wrap">
<div class="check-user-box">
<RadioGroup v-model="isCheckUserAll" class="radio" @on-change="userAllCheck">
<Radio :disabled="editDisabled" :label="0">{{ isEdit ? "现有" : "全部" }}用户</Radio>
<Radio :label="1">指定用户</Radio>
</RadioGroup>
<Input
type="text"
v-model="userNameStr"
readonly
:disabled="!isCheckUserAll"
placeholder="选择指定的用户"
class="input"
></Input>
<Button icon="md-add" @click="checkUserShow" :disabled="!isCheckUserAll"></Button>
<Button icon="md-trash" @click="resetUser" :disabled="!isCheckUserAll"></Button>
</div>
<UserCheckModal
ref="userCheckModal"
:sources="sources"
:userids="tempUserIds"
@on-submit="submitUser"
/>
</div>
</template>
<script lang="ts">
/*
* 作业对象选择输入(自旧前端 oldfront/src/components/user_check_input.vue 适配 Vue3)。
* v-model 值为 "all" 或用户 id 逗号串;切换到「指定用户」直接弹出选择用户弹窗。
*/
import UserCheckModal from "@/components/UserCheckModal.vue";
import { baseApi, settingApi } from "@/api/module/homework";
export default {
name: "UserCheckInput",
components: { UserCheckModal },
props: {
modelValue: {
type: String,
default: "all",
},
/** 编辑态:全部用户选项禁用为「现有用户」 */
isEdit: {
type: Boolean,
default: false,
},
},
emits: ["update:modelValue"],
data() {
return {
isCheckUserAll: 0 as number,
tempUserIds: [] as any[],
userNameStr: "",
editDisabled: false,
sources: [
{
name: "基础平台",
loader: () => baseApi.getSchoolAll().send() as Promise<any[]>,
},
{
name: "自定义组",
loader: () => settingApi.getGroupTree().send() as Promise<any[]>,
},
],
};
},
created() {
if (this.modelValue === "all") {
this.isCheckUserAll = 0;
this.userNameStr = this.isEdit ? "现有用户" : "全部用户";
} else {
this.isCheckUserAll = 1;
this.tempUserIds = this.modelValue.split(",").map((x) => parseInt(x)).filter((x) => !isNaN(x));
}
},
methods: {
checkUserShow() {
(this.$refs.userCheckModal as any).show();
},
resetUser() {
//清除用户
this.tempUserIds = [];
this.userNameStr = "";
this.$emit("update:modelValue", "");
},
userAllCheck(type: number) {
let tempIds: any = String(this.modelValue || "")
.split(",")
.map((x) => parseInt(x))
.filter((x) => !isNaN(x));
if (type == 0) {
if (this.isEdit) {
this.tempUserIds = tempIds;
this.userNameStr = "现有用户";
this.$emit("update:modelValue", String(tempIds));
} else {
this.tempUserIds = [];
this.userNameStr = "全部用户";
this.$emit("update:modelValue", "all");
}
} else {
if (this.isEdit) {
this.editDisabled = true;
}
this.tempUserIds = tempIds;
this.userNameStr = "";
this.checkUserShow();
}
},
submitUser(arr: any[]) {
//确认用户
this.userNameStr = arr.map((x) => x.name).join("、");
const ids = arr.map((x) => x.id).join(",");
this.$emit("update:modelValue", ids || "all");
if (!arr.length) {
// 清空后回到全部用户
this.isCheckUserAll = 0;
this.userNameStr = "全部用户";
this.$emit("update:modelValue", "all");
}
},
},
watch: {
modelValue(val: string) {
if (val === "all") {
this.isCheckUserAll = 0;
this.userNameStr = this.isEdit ? "现有用户" : "全部用户";
} else if (val === "") {
this.userNameStr = "";
} else {
// 编辑回显:指定用户 id 串
this.isCheckUserAll = 1;
this.tempUserIds = val
.split(",")
.map((x) => parseInt(x))
.filter((x) => !isNaN(x));
}
},
},
};
</script>
<style lang="scss" scoped>
.check-user-wrap {
width: 100%;
.check-user-box {
display: flex;
align-items: center;
gap: 8px;
.radio {
width: 180px;
flex: none;
font-size: 12px;
vertical-align: middle;
line-height: 32px;
}
.input {
flex: 1;
}
}
}
</style>
@@ -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,482 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted, nextTick } from "vue";
import { Message } from "view-ui-plus";
import draggable from "vuedraggable";
import MyConfirm from "@/components/MyConfirm";
import UserCheckModal from "@/components/UserCheckModal.vue";
import TypeSetBox from "@/components/TypeSetBox.vue";
import { settingApi, baseApi } from "@/api/module/homework";
import type { DeptManageItem, ContactGroupPage, ContactUserPage, SchoolTreeNode } from "@/types/homework";
import Utils from "@/utils";
/* ============ Tab 切换(规范 §1:tabs-wrapper + Tabs type=card,切换只 v-show 内容区) ============ */
const currentType = ref(1);
function tabChange(name: string) {
currentType.value = Number(name);
if (currentType.value === 1) {
getDeptList();
} else if (currentType.value === 3) {
getGroupList();
}
}
/* ============ 发文部门(直接复用 UserBase 同源 TypeSetBox 卡片组件) ============ */
interface DeptItem {
SerID: number;
CodeID: string;
Name: string;
Enable: number;
}
const deptData = ref<DeptItem[]>([]);
const deptModal = ref(false);
const deptForm = reactive({ name: "" });
const deptFormRef = ref();
const deptRule = {
name: [{ required: true, message: "请输入发文部门名称", trigger: "blur" }],
};
async function getDeptList() {
try {
const data: DeptManageItem[] = await settingApi.getDictMangeList().send();
deptData.value = (data || []).map((d) => ({
SerID: d.SerID,
CodeID: d.CodeID,
Name: d.Name,
Enable: d.Enable ?? 1,
}));
} catch (e: any) {
Message.error(e.message || "获取发文部门列表失败");
}
}
function addDeptShow() {
deptForm.name = "";
deptModal.value = true;
}
async function deptModalSave() {
const valid = await deptFormRef.value?.validate();
if (!valid) {
Message.info("请填写红色星号内容!");
return;
}
try {
await settingApi.addDeptDic(deptForm.name.trim()).send();
Message.success("操作成功!");
deptModal.value = false;
await getDeptList();
} catch (e: any) {
Message.error(e.message || "操作失败");
}
}
function findDept(id: string): DeptItem | undefined {
return deptData.value.find((x) => String(x.SerID) === id);
}
/** TypeSetBox 启用/停用(保留确认弹窗) */
function onDeptToggle(id: string) {
const item = findDept(id);
if (item) toggleDeptStatus(item);
}
/** TypeSetBox 内联编辑部门名(组件已拦截空值) */
async function onDeptRename(id: string, name: string) {
const item = findDept(id);
if (!item) return;
try {
await settingApi.updateDeptDic(item.SerID, name.trim(), item.Enable).send();
Message.success("操作成功!");
await getDeptList();
} catch (e: any) {
Message.error(e.message || "操作失败");
}
}
function onDeptRemove(id: string) {
const item = findDept(id);
if (item) deleteDept(item);
}
async function deleteDept(item: DeptItem) {
await MyConfirm({
title: "删除",
msg: `确定删除“ ${item.Name} ”这个发文部门吗?`,
info: "删除后不可恢复",
async onOk() {
try {
await settingApi.delDept(item.SerID).send();
Message.success("操作成功!");
await getDeptList();
} catch (e: any) {
Message.error(e.message || "删除失败");
}
},
});
}
async function toggleDeptStatus(item: DeptItem) {
const enable = item.Enable === 1;
await MyConfirm({
msg: `确定${enable ? "停用" : "启用"}发文部门「${item.Name}」吗?`,
title: "修改状态",
async onOk() {
try {
await settingApi.updateDeptDic(item.SerID, item.Name, enable ? 0 : 1).send();
item.Enable = enable ? 0 : 1;
Message.success("操作成功!");
} catch (e: any) {
Message.error(e.message || "操作失败");
}
},
});
}
/* 拖动排序:提交当前顺序的 SerID 数组 */
async function deptSortSave() {
const ids = deptData.value.map((x: DeptItem) => x.SerID);
try {
await settingApi.sortDeptDic(ids.join(",")).send();
Message.success("排序成功");
} catch (e: any) {
Message.error(e.message || "排序失败");
}
}
/* ============ 自定义组(规范 §5 联系组管理页形态) ============ */
interface GroupItem {
serid: number;
GroupName: string;
count: number;
}
const groupData = ref<GroupItem[]>([]);
const groupTotal = ref(0);
const groupPage = ref(1);
const groupCount = ref(parseInt(localStorage.getItem("customPageCount") || "10"));
async function getGroupList() {
try {
const data: any = await settingApi.getGroup(groupPage.value, groupCount.value).send();
groupData.value = (data.list || []).map((d: any) => ({
serid: d.serid,
GroupName: d.GroupName,
count: d.count ?? 0,
}));
groupTotal.value = data.total || 0;
} catch (e: any) {
Message.error(e.message || "获取自定义组列表失败");
}
}
const groupModal = ref(false);
const groupEditingId = ref<number | null>(null);
const groupForm = reactive({ name: "" });
const groupFormRef = ref();
const groupRule = {
name: [{ required: true, message: "请输入联系组名称", trigger: "blur" }],
};
function addGroupShow() {
groupEditingId.value = null;
groupForm.name = "";
groupModal.value = true;
}
function editGroupShow(row: GroupItem) {
groupEditingId.value = row.serid;
groupForm.name = row.GroupName;
groupModal.value = true;
}
async function groupModalSave() {
const valid = await groupFormRef.value?.validate();
if (!valid) {
Message.info("请填写红色星号内容!");
return;
}
try {
if (groupEditingId.value == null) {
await settingApi.addGroup(groupForm.name.trim()).send();
} else {
await settingApi.editGroup(groupEditingId.value, groupForm.name.trim()).send();
}
Message.success("操作成功!");
groupModal.value = false;
await getGroupList();
} catch (e: any) {
Message.error(e.message || "操作失败");
}
}
async function deleteGroup(row: GroupItem) {
await MyConfirm({
title: "删除",
msg: `确定删除“ ${row.GroupName} ”这个组吗?`,
info: "删除后不可恢复",
async onOk() {
try {
await settingApi.delGroup(String(row.serid)).send();
Message.success("操作成功!");
// 删除后智能翻页:当前页仅剩一条则回退一页
if (groupData.value.length <= 1 && groupPage.value > 1) groupPage.value -= 1;
await getGroupList();
} catch (e: any) {
Message.error(e.message || "删除失败");
}
},
});
}
/* ---------- 查看联系组用户 ---------- */
const seeUserModal = ref(false);
const seeUserGid = ref(0);
const seeUserGroupName = ref("");
const seeUserData = ref<{ account: string; name: string }[]>([]);
const seeUserTotal = ref(0);
const seeUserPage = ref(1);
const seeUserColumns = [
{ title: "序号", type: "index", width: 70 },
{ title: "账号", key: "account", align: "center" },
{ title: "真实姓名", key: "name", width: 160, align: "center" },
];
async function seeUserShow(row: GroupItem) {
seeUserGid.value = row.serid;
seeUserGroupName.value = row.GroupName;
seeUserPage.value = 1;
await loadSeeUser();
seeUserModal.value = true;
}
async function loadSeeUser() {
try {
const data: ContactUserPage = await settingApi.getContactsTea(seeUserGid.value, seeUserPage.value, 5).send();
seeUserData.value = (data.list || []).map((d) => ({ account: d.account, name: d.name }));
seeUserTotal.value = data.total || 0;
} catch (e: any) {
Message.error(e.message || "获取用户列表失败");
}
}
/* ---------- 设置联系组用户(规范选人组件,打开前回显已有成员) ---------- */
const setUserCheckRef = ref();
const setUserGid = ref(0);
const setUserIds = ref<number[]>([]);
// 设置自定义组成员只需要基础平台组织树,无需「基础平台/自定义组」两个 tab
const setUserSources = [
{
name: "基础平台",
loader: () => baseApi.getSchoolAll().send(),
},
];
async function settingUserShow(row: GroupItem) {
setUserGid.value = row.serid;
setUserIds.value = [];
try {
const data: ContactUserPage = await settingApi.getContactsTea(row.serid, 0, 0).send();
setUserIds.value = (data.list || [])
.map((d) => Number(d.userId ?? d.id))
.filter((n: number) => Number.isFinite(n) && n > 0);
} catch (e: any) {
Message.error(e.message || "获取已有用户失败");
}
await nextTick();
setUserCheckRef.value.show();
}
async function settingUserSubmit(users: SchoolTreeNode[]) {
if (!users.length) {
Message.info("请选择用户!");
return;
}
// on-submit 返回的是用户对象数组({id,name,group}),取 id 拼接
const ids = users.map((x) => (typeof x === "object" && x != null ? x.id : x)).join(",");
try {
await settingApi.setContactsTea(setUserGid.value, ids).send();
Message.success("设置成功");
// 刷新组列表,组员数即时更新
await getGroupList();
} catch (e: any) {
Message.error(e.message || "设置失败");
}
}
onMounted(() => {
getDeptList();
});
/* ---------- 自定义组表格(规范 §5:序号/组名/操作图标列 width 250) ---------- */
const groupColumns = [
{ title: "序号", type: "index", width: 80, align: "center" },
{ title: "组名", key: "GroupName", align: "left" },
{ title: "组员数", key: "count", width: 100, align: "center" },
{
title: "操作",
width: 250,
align: "center",
render: (h: any, params: any) => {
const row: GroupItem = params.row;
return [
Utils.operatingHandle(h, "查看用户", "icon-browse", () => seeUserShow(row)),
Utils.operatingHandle(h, "编辑", "icon-edit", () => editGroupShow(row)),
Utils.operatingHandle(h, "删除", "icon-ashbin", () => deleteGroup(row)),
Utils.operatingHandle(h, "设置用户", "icon-user-filling", () => settingUserShow(row)),
];
},
},
];
const groupTableHeight = computed(() => Utils.useTableHeight({ isTabs: true, isSearch: true, isPage: true }));
</script>
<template>
<div class="tabs-wrapper">
<Tabs class="tabs-panel" type="card" :value="String(currentType)" @on-click="tabChange">
<TabPane label="发文部门" name="1"></TabPane>
<TabPane label="自定义组" name="3"></TabPane>
</Tabs>
<!-- 发文部门 -->
<div class="main-container" v-show="currentType === 1">
<Alert show-icon>设置说明:拖动卡片调整部门排序;停用的部门不出现在「作业发送」的部门下拉中</Alert>
<div class="search-wrap fix">
<Button class="fl" type="primary" @click="addDeptShow">新增部门</Button>
</div>
<draggable
class="type_set-wrap fix"
tag="ul"
v-model="deptData"
group="homeworkDept"
item-key="SerID"
@change="deptSortSave"
>
<template #item="{ element }">
<li>
<TypeSetBox
:type="{ id: String(element.SerID), name: element.Name }"
:active="element.Enable === 1"
@active="onDeptToggle"
@edit="onDeptRename"
@remove="onDeptRemove"
/>
</li>
</template>
</draggable>
</div>
<!-- 自定义组 -->
<div class="main-container" v-show="currentType === 3">
<div class="search-wrap fix">
<Button class="fl" type="primary" @click="addGroupShow">新建联系组</Button>
</div>
<Table
:columns="groupColumns"
:data="groupData"
class="link-table table-bottom-box"
:height="groupTableHeight"
no-data-text="暂无数据"
></Table>
<div class="page-panel">
<div class="fl ml-15 align-middle"></div>
<Page
class="page-box fr"
:total="groupTotal"
:current="groupPage"
:page-size="groupCount"
:page-size-opts="[10, 20, 30, 40]"
@on-page-size-change="
(size: number) => {
groupPage = 1;
groupCount = size;
localStorage.setItem('customPageCount', String(size));
getGroupList();
}
"
@on-change="
(p: number) => {
groupPage = p;
getGroupList();
}
"
placement="top"
show-total
show-sizer
></Page>
</div>
</div>
<!-- 新增发文部门 Modal(编辑走 TypeSetBox 内联编辑) -->
<Modal v-model="deptModal" :mask-closable="false" width="450px" title="新增部门">
<Form ref="deptFormRef" :model="deptForm" :rules="deptRule" :label-width="110">
<FormItem label="发文部门名称:" prop="name">
<Input placeholder="请输入发文部门名称" v-model="deptForm.name" />
</FormItem>
</Form>
<template #footer>
<Button type="primary" @click="deptModalSave">提交</Button>
<Button @click="deptModal = false">取消</Button>
</template>
</Modal>
<!-- 新建/编辑联系组 Modal(规范 §5:450px + label-width 100 + 提交/取消) -->
<Modal v-model="groupModal" :mask-closable="false" width="450px" :title="groupEditingId == null ? '新建联系组' : '编辑联系组'">
<Form ref="groupFormRef" :model="groupForm" :rules="groupRule" :label-width="100">
<FormItem label="联系组名称:" prop="name">
<Input placeholder="请输入联系组名称" v-model="groupForm.name" />
</FormItem>
</Form>
<template #footer>
<Button type="primary" @click="groupModalSave">提交</Button>
<Button @click="groupModal = false">取消</Button>
</template>
</Modal>
<!-- 查看联系组用户 Modal(规范 §5:Modal 内小表格 + small 分页) -->
<Modal v-model="seeUserModal" :title="`查看联系组用户${seeUserGroupName ? ' - ' + seeUserGroupName : ''}`">
<Table height="260" :columns="seeUserColumns" :data="seeUserData" no-data-text="暂无用户"></Table>
<div class="small-pager">
<Page
:current="seeUserPage"
:page-size="5"
:total="seeUserTotal"
@on-change="
(p: number) => {
seeUserPage = p;
loadSeeUser();
}
"
size="small"
show-total
/>
</div>
<template #footer>
<Button @click="seeUserModal = false">关闭</Button>
</template>
</Modal>
<!-- 选择用户弹窗(设置联系组用户) -->
<UserCheckModal ref="setUserCheckRef" :sources="setUserSources" :userids="setUserIds" @on-submit="settingUserSubmit" />
</div>
</template>
<style lang="scss" scoped>
.tab-body {
min-height: 300px;
}
.type_set-wrap {
margin: 12px 0 16px;
display: flex;
flex-wrap: wrap;
}
/* 拖动排序手型(与 UserBase AppCategory 页面级样式一致,组件内不含) */
.type_set-wrap :deep(.type_name) {
cursor: move;
}
.small-pager {
margin-top: 8px;
display: flex;
justify-content: flex-end;
}
</style>
+132
View File
@@ -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;
}
@@ -0,0 +1,214 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useRequest } from "alova/client";
import { Message } from "view-ui-plus";
import { homeworkApi } from "@/api/module/homework";
import type { HomeworkVo, ApprovalQuery } from "@/types/homework";
import MyConfirm from "@/components/MyConfirm";
import Utils from "@/utils";
/**
* 审批列表:展示「需审批后发布」的作业(approvalEnabled=1)。
* 状态过滤:pending(缺省) / approved / rejected / all;
* 操作:查看作业(详情)→ 通过 / 驳回(填审批意见)。
*/
const route = useRoute();
const router = useRouter();
const keyword = ref("");
const status = ref("pending");
const pageNum = ref(1);
const pageSize = ref(parseInt(localStorage.getItem("customPageCount") || "10"));
const { data: page, loading, send: reload } = useRequest(
(params: ApprovalQuery) => homeworkApi.approvalList(params),
{ immediate: false, initialData: { content: [], total: 0, pageIndex: 1, pageSize: 10, totalPages: 0, hasNext: false } }
);
async function run() {
try {
await reload({ keyword: keyword.value || "", status: status.value, pageIndex: pageNum.value, pageSize: pageSize.value });
} catch (e: any) {
Message.error(e.message || "获取审批列表失败");
}
}
function doSearch() {
pageNum.value = 1;
run();
}
function pageUpdate(p: number) {
pageNum.value = p;
run();
}
function pageSizeUpdate(size: number) {
pageNum.value = 1;
pageSize.value = size;
localStorage.setItem("customPageCount", String(size));
run();
}
onMounted(run);
watch(
() => route.fullPath,
() => run()
);
/* ---------- 审批操作 ---------- */
function view(row: HomeworkVo) {
router.push({ path: "/homeworklist/detail", query: { serid: row.id, type: 0 } });
}
function approve(row: HomeworkVo) {
MyConfirm({
title: "审批通过",
msg: `确定通过「 ${row.title} 」的当前审批步骤吗?`,
info: "会签需全部通过、或签任一人通过即进入下一步;最后一步通过后发布",
async onOk() {
try {
const res: any = await homeworkApi.approve((row as any).id).send();
Message.success(res?.status === "approved" ? "已通过全部步骤,作业已发布" : "本步骤已通过,流转至下一步");
run();
} catch (e: any) {
Message.error(e.message || "操作失败");
}
},
});
}
/* 驳回:弹窗填写审批意见 */
const rejectModal = ref(false);
const rejectRow = ref<HomeworkVo | null>(null);
const rejectRemark = ref("");
function openReject(row: HomeworkVo) {
rejectRow.value = row;
rejectRemark.value = "";
rejectModal.value = true;
}
async function doReject() {
if (!rejectRemark.value.trim()) {
Message.info("请填写驳回意见");
return;
}
try {
await homeworkApi.reject(rejectRow.value!.id, rejectRemark.value.trim()).send();
Message.success("已驳回,退回发布人");
rejectModal.value = false;
run();
} catch (e: any) {
Message.error(e.message || "操作失败");
}
}
/* ---------- 状态标签配色 ---------- */
const STATUS_TAG: Record<string, string> = { pending: "warning", approved: "success", rejected: "error", none: "default" };
const approvalTag = (row: any) => STATUS_TAG[row.approvalStatus] || "default";
const columns = [
{ title: "序号", type: "index", width: 80, align: "center" },
{ title: "标题", key: "title", align: "left", tooltip: true },
{ title: "来源部门", key: "deptName", width: 130, align: "left" },
{ title: "发布人", key: "publisherName", width: 110, align: "left", tooltip: true },
{
title: "提交时间",
width: 170,
align: "left",
render: (h: any, p: any) => h("span", {}, String(p.row.createTime || "").slice(0, 16)),
},
{
title: "审批状态",
width: 240,
align: "left",
render: (h: any, p: any) => h("Tag", { color: approvalTag(p.row) }, () => p.row.approvalStatusDesc || "—"),
},
{
title: "审批意见",
key: "approvalRemark",
align: "left",
width: 180,
tooltip: true,
render: (h: any, p: any) => h("span", {}, p.row.approvalRemark || "—"),
},
{
title: "操作",
width: 240,
align: "center",
fixed: "right",
render: (h: any, p: any) => {
const row: any = p.row;
const ops = [Utils.operatingHandle(h, "查看作业", "icon-browse", () => view(row))];
if (row.canApprove) {
ops.push(Utils.operatingHandle(h, "通过", "icon-ckpy", () => approve(row)));
ops.push(Utils.operatingHandle(h, "驳回", "icon-pingbi", () => openReject(row)));
} else if (row.approvalStatus === "pending") {
// 非当前审批步骤的审批人:仅展示,不出现操作按钮
ops.push(h("span", { style: "color:#999;font-size:12px;margin-left:8px" }, "等待他人审批"));
}
return ops;
},
},
];
const tableHeight = computed(() => Utils.useTableHeight({ isSearch: true, isOperat: true, isPage: true }));
</script>
<template>
<div class="page-wrapper">
<div class="main-container">
<!-- 搜索区 -->
<div class="search-wrap fix">
<RadioGroup v-model="status" type="button" button-style="solid" class="fl" @on-change="doSearch">
<Radio label="pending"><span>待审批</span></Radio>
<Radio label="approved"><span>已通过</span></Radio>
<Radio label="rejected"><span>已驳回</span></Radio>
<Radio label="all"><span>全部</span></Radio>
</RadioGroup>
<Input
class="fr"
style="width: 220px"
search
suffix="ios-search"
placeholder="输入标题/发布人搜索"
v-model="keyword"
@on-search="doSearch"
@on-clear="doSearch"
/>
</div>
<Table :columns="columns" :data="page.content" class="link-table table-bottom-box" :height="tableHeight" :loading="loading" no-data-text="暂无待审批作业"></Table>
<div class="page-panel">
<div class="fl ml-15 align-middle"></div>
<Page
class="page-box fr"
:total="page.total"
:current="pageNum"
:page-size="pageSize"
:page-size-opts="[10, 20, 30, 40]"
@on-page-size-change="pageSizeUpdate"
@on-change="pageUpdate"
placement="top"
show-total
show-sizer
></Page>
</div>
<!-- 驳回弹窗 -->
<Modal v-model="rejectModal" title="驳回作业" :mask-closable="false" width="460px">
<Form :label-width="80">
<FormItem label="作业标题">
<span>{{ rejectRow?.title }}</span>
</FormItem>
<FormItem label="审批意见">
<Input v-model="rejectRemark" type="textarea" :rows="3" placeholder="请填写驳回原因,将退回发布人"></Input>
</FormItem>
</Form>
<template #footer>
<Button type="error" @click="doReject">确认驳回</Button>
<Button @click="rejectModal = false">取消</Button>
</template>
</Modal>
</div>
</div>
</template>
<style scoped></style>
@@ -0,0 +1,160 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from "vue";
import { useRequest } from "alova/client";
import { Message } from "view-ui-plus";
import { approvalSettingApi, homeworkApi } from "@/api/module/homework";
import type { ApproverVo } from "@/types/homework";
import MyConfirm from "@/components/MyConfirm";
import Utils from "@/utils";
/**
* 审核设置:维护「审批人候选池」。发布时从这里选审批人——选到人即走审批流程。
* 逻辑很简单:任一审批人通过即发布,驳回退回发布人;也可在发布界面配多步会签/或签。
*/
const { data: deptOptions } = useRequest(homeworkApi.getSysDic());
const approvers = ref<ApproverVo[]>([]);
const loading = ref(false);
async function loadApprovers() {
loading.value = true;
try {
approvers.value = (await approvalSettingApi.approvers().send()) || [];
} catch (e: any) {
Message.error(e.message || "获取审批人失败");
} finally {
loading.value = false;
}
}
const modal = ref(false);
const editingId = ref<number | null>(null);
const form = reactive({ account: "", name: "", deptId: "" });
const formRef = ref();
const rule = {
name: [{ required: true, message: "请输入审批人姓名", trigger: "blur" }],
deptId: [{ required: true, message: "请选择所属部门", trigger: "change" }],
};
function addApprover() {
editingId.value = null;
form.account = "";
form.name = "";
form.deptId = "";
modal.value = true;
}
function editApprover(row: ApproverVo) {
editingId.value = row.id;
form.account = row.account;
form.name = row.name;
form.deptId = row.deptId;
modal.value = true;
}
async function saveApprover() {
const valid = await formRef.value?.validate();
if (!valid) {
Message.info("请填写红色星号内容!");
return;
}
try {
if (editingId.value == null) {
await approvalSettingApi.addApprover({ account: form.account.trim(), name: form.name.trim(), deptId: form.deptId }).send();
} else {
await approvalSettingApi.updateApprover({ id: editingId.value, name: form.name.trim(), deptId: form.deptId }).send();
}
Message.success("操作成功!");
modal.value = false;
await loadApprovers();
} catch (e: any) {
Message.error(e.message || "操作失败");
}
}
async function toggleApprover(row: ApproverVo) {
const enabled = row.enabled === 1 ? 0 : 1;
try {
await approvalSettingApi.updateApprover({ id: row.id, enabled }).send();
row.enabled = enabled;
Message.success("操作成功!");
} catch (e: any) {
Message.error(e.message || "操作失败");
}
}
function removeApprover(row: ApproverVo) {
MyConfirm({
title: "删除",
msg: `确定删除审批人「 ${row.name} 」吗?`,
info: "删除后不可恢复",
async onOk() {
try {
await approvalSettingApi.delApprover(row.id).send();
Message.success("删除成功");
await loadApprovers();
} catch (e: any) {
Message.error(e.message || "删除失败");
}
},
});
}
const columns = [
{ title: "姓名", key: "name", align: "left", minWidth: 120 },
{ title: "账号", key: "account", align: "left", width: 130 },
{ title: "所属部门", key: "deptName", align: "left", minWidth: 130 },
{
title: "状态",
width: 120,
align: "center",
render: (h: any, p: any) => h("Tag", { color: p.row.enabled === 1 ? "success" : "default" }, () => (p.row.enabled === 1 ? "启用" : "停用")),
},
{
title: "操作",
width: 220,
align: "center",
render: (h: any, p: any) => {
const row: ApproverVo = p.row;
return [
Utils.operatingHandle(h, row.enabled === 1 ? "停用" : "启用", "icon-pingbi", () => toggleApprover(row)),
Utils.operatingHandle(h, "编辑", "icon-edit", () => editApprover(row)),
Utils.operatingHandle(h, "删除", "icon-ashbin", () => removeApprover(row)),
];
},
},
];
const tableHeight = computed(() => Utils.useTableHeight({ isSearch: true, isOperat: true, isPage: false }));
onMounted(loadApprovers);
</script>
<template>
<div class="page-wrapper">
<div class="main-container">
<Alert show-icon type="info">
这里维护可被选为审批人的名单。<b>发布或编辑作业时,只要选中了审批人,作业就会进入审批流程</b>(默认一步·或签,也可配多步会签/或签);任一审批人通过即发布,驳回退回发布人。
</Alert>
<div class="search-wrap fix">
<Button class="fl" type="primary" @click="addApprover">新增审批人</Button>
</div>
<Table :columns="columns" :data="approvers" :loading="loading" class="link-table table-bottom-box" :height="tableHeight" no-data-text="暂无审批人"></Table>
<Modal v-model="modal" :mask-closable="false" width="450px" :title="editingId == null ? '新增审批人' : '编辑审批人'">
<Form ref="formRef" :model="form" :rules="rule" :label-width="90">
<FormItem label="姓名:" prop="name">
<Input v-model="form.name" placeholder="请输入审批人姓名" />
</FormItem>
<FormItem label="账号:" prop="account">
<Input v-model="form.account" placeholder="登录账号(可选)" />
</FormItem>
<FormItem label="所属部门:" prop="deptId">
<Select v-model="form.deptId" placeholder="请选择部门" transfer>
<Option v-for="d in deptOptions" :key="d.k" :value="d.k">{{ d.v }}</Option>
</Select>
</FormItem>
</Form>
<template #footer>
<Button type="primary" @click="saveApprover">提交</Button>
<Button @click="modal = false">取消</Button>
</template>
</Modal>
</div>
</div>
</template>
<style scoped></style>