This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,873 @@
|
||||
<script setup lang="ts">
|
||||
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 { noticeApi, dormApi } from "@/api/module/dorm";
|
||||
import type { NoticeDetailVo, NoticeRequest, DictOption, UpFileVo, DormBuilding, DormRoom, ReceiptFormTemplate } from "@/types/dorm";
|
||||
import AttachmentUpload from "@/components/AttachmentUpload.vue";
|
||||
import config from "@/assets/config/host.config";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const serid = computed(() => Number(route.query.serid) || 0);
|
||||
const fid = computed(() => Number(route.query.fid) || 0);
|
||||
const isEdit = computed(() => serid.value > 0);
|
||||
|
||||
/** 通知类型(默认口径;「宿舍设置-通知类型」里新增的会通过字典接口补充) */
|
||||
const DEFAULT_TYPES = ["作息调整", "宿舍调整", "卫生检查", "违规通报", "停水停电", "安全排查", "留宿申请", "家长探视", "夜间查寝", "物品清退", "疫情防控", "假期安排", "其他"];
|
||||
|
||||
/** 紧急程度 */
|
||||
const LEVELS = [
|
||||
{ v: "normal", label: "普通(家长端常规展示)" },
|
||||
{ v: "important", label: "重要(家长端加粗并优先展示)" },
|
||||
{ v: "urgent", label: "紧急(家长端置顶并标红)" },
|
||||
];
|
||||
|
||||
/**
|
||||
* 回执方式(家校闭环的核心档位):
|
||||
* - none 仅通知:家长阅读即知悉,不产生回执记录
|
||||
* - confirm 需确认:家长打开通知即自动确认,无需点击(适合作息调整、停水停电)
|
||||
* - fill 需填报:家长按下方**统一回执单**提交(公共区固定 + 专属区随单据类型)
|
||||
*/
|
||||
const RECEIPT_MODES = [
|
||||
{ v: "none", label: "仅通知(阅读即知悉,不产生回执)" },
|
||||
{ v: "confirm", label: "需确认(家长打开通知即自动确认)" },
|
||||
{ v: "fill", label: "需填报(家长按下方统一回执单提交)" },
|
||||
];
|
||||
|
||||
/**
|
||||
* 公共区在家长端的固定项(与后端 COMMON_FIELDS 口径一致):
|
||||
* 学生 / 家长 / 联系电话由花名册带出,回执结论由家长选择 —— **所有单据完全相同**。
|
||||
*/
|
||||
const COMMON_PREVIEW = ["学生姓名(带出)", "家长姓名(带出)", "联系电话(带出)", "回执结论(必选)"];
|
||||
|
||||
/**
|
||||
* 表单模型:**所有参与校验的字段都必须直接挂在这个对象上**。
|
||||
*
|
||||
* 踩坑记录:不要把它写成 `computed(() => ({ ...form, dateRange: refA.value }))`。
|
||||
* 那种写法每次依赖变化都会生成一个**新对象**,而 FormItem 校验时读的是 `Form.model`
|
||||
* ——props 要等父组件重新渲染才更新,本轮同步触发的 change 校验读到的仍是旧对象,
|
||||
* 结果就是「宿舍明明选了,必填红字却不消失」。用一个稳定的 reactive 对象即可避免。
|
||||
*/
|
||||
const form = reactive({
|
||||
title: "",
|
||||
noticeType: "",
|
||||
level: "normal",
|
||||
deptId: "",
|
||||
receiptMode: "none",
|
||||
/** 需填报时的单据类型(字段由后端模板带出,切换即刷新下方预览) */
|
||||
formType: "",
|
||||
needSupervisor: 0,
|
||||
content: "",
|
||||
/** 接收房间 id(字符串;勾选「全部宿舍」时不做限制,仅用于校验与回显) */
|
||||
receiveList: [] as string[],
|
||||
});
|
||||
|
||||
/** 生效时间(留空=立即生效) */
|
||||
const effectiveTime = ref<any>("");
|
||||
/** 回执截止时间(留空=不限) */
|
||||
const deadlineTime = ref<any>("");
|
||||
const smsFlag = ref(false);
|
||||
const topmost = ref(false);
|
||||
const allDorms = ref(false);
|
||||
/** 已选房间的展示信息(roomId → DormRoom),用于汇总文案与人数统计 */
|
||||
const pickedRooms = ref<Record<string, DormRoom>>({});
|
||||
const fileids = ref<number[]>([]);
|
||||
const editFileList = ref<{ name: string; fileid: number }[]>([]);
|
||||
/** 单据模板(后端预置;发布人只选类型,字段不可增删改) */
|
||||
const formTemplates = ref<ReceiptFormTemplate[]>([]);
|
||||
const curTemplate = computed(() => formTemplates.value.find(t => t.type === form.formType) || null);
|
||||
|
||||
/* ============ 发布部门 / 通知类型下拉 ============ */
|
||||
const deptOptions = ref<DictOption[]>([]);
|
||||
const typeOptions = ref<string[]>([]);
|
||||
async function loadDicts() {
|
||||
try {
|
||||
formTemplates.value = (await noticeApi.getFormTemplates().send()) || [];
|
||||
} catch {
|
||||
/* 忽略:模板不可用时「需填报」无法选型 */
|
||||
}
|
||||
try {
|
||||
deptOptions.value = (await noticeApi.getDeptDic().send()) || [];
|
||||
} catch {
|
||||
/* 忽略:字典不可用时留空 */
|
||||
}
|
||||
try {
|
||||
const t = (await noticeApi.getNoticeTypeDic().send()) || [];
|
||||
const names = Array.from(new Set(t.map(x => String(x.v ?? x.k)).filter(Boolean)));
|
||||
// 通知类型以字典接口为准;仅在接口无数据/异常时回退默认口径
|
||||
typeOptions.value = names.length ? names : [...DEFAULT_TYPES];
|
||||
} catch {
|
||||
typeOptions.value = [...DEFAULT_TYPES];
|
||||
}
|
||||
}
|
||||
|
||||
/* ============ 接收宿舍选择(楼栋 → 楼层 → 房间) ============ */
|
||||
const roomModal = reactive({ open: false, buildingId: 0, rooms: [] as DormRoom[], loading: false });
|
||||
const buildings = ref<DormBuilding[]>([]);
|
||||
|
||||
async function openRoomPicker() {
|
||||
roomModal.open = true;
|
||||
if (!buildings.value.length) {
|
||||
try {
|
||||
buildings.value = (await dormApi.getBuildingList().send()) || [];
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "加载宿舍楼栋失败");
|
||||
}
|
||||
}
|
||||
if (!roomModal.buildingId && buildings.value.length) roomModal.buildingId = buildings.value[0].buildingId;
|
||||
await loadRooms();
|
||||
}
|
||||
|
||||
async function loadRooms() {
|
||||
const bid = roomModal.buildingId;
|
||||
if (!bid) {
|
||||
roomModal.rooms = [];
|
||||
return;
|
||||
}
|
||||
roomModal.loading = true;
|
||||
try {
|
||||
roomModal.rooms = (await dormApi.getRoomList(bid).send()) || [];
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "加载房间失败");
|
||||
} finally {
|
||||
roomModal.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 切换楼栋并拉取该楼房间 */
|
||||
function selectBuilding(b: DormBuilding) {
|
||||
if (roomModal.buildingId === b.buildingId) return;
|
||||
roomModal.buildingId = b.buildingId;
|
||||
loadRooms();
|
||||
}
|
||||
|
||||
/** 房间按楼层分组(% 操作符已按 floors 建楼,取整即可) */
|
||||
const floorGroups = computed(() => {
|
||||
const map = new Map<number, DormRoom[]>();
|
||||
roomModal.rooms.forEach(r => {
|
||||
const list = map.get(r.floor) || [];
|
||||
list.push(r);
|
||||
map.set(r.floor, list);
|
||||
});
|
||||
return Array.from(map.entries())
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([floor, rooms]) => ({ floor, rooms: rooms.sort((a, b) => a.roomNo.localeCompare(b.roomNo)) }));
|
||||
});
|
||||
|
||||
const curBuilding = computed(() => buildings.value.find(b => b.buildingId === roomModal.buildingId) || null);
|
||||
|
||||
function isPicked(roomId: number) {
|
||||
return form.receiveList.includes(String(roomId));
|
||||
}
|
||||
function toggleRoom(room: DormRoom, checked: boolean) {
|
||||
allDorms.value = false;
|
||||
const key = String(room.roomId);
|
||||
if (checked) {
|
||||
if (!form.receiveList.includes(key)) form.receiveList.push(key);
|
||||
pickedRooms.value[key] = room;
|
||||
} else {
|
||||
form.receiveList = form.receiveList.filter(x => x !== key);
|
||||
delete pickedRooms.value[key];
|
||||
}
|
||||
}
|
||||
/** 整层/整栋勾选:roooms 全部置为 checked */
|
||||
function toggleRooms(rooms: DormRoom[], checked: boolean) {
|
||||
allDorms.value = false;
|
||||
rooms.forEach(r => toggleRoom(r, checked));
|
||||
}
|
||||
function floorAllChecked(rooms: DormRoom[]) {
|
||||
return rooms.length > 0 && rooms.every(r => isPicked(r.roomId));
|
||||
}
|
||||
function floorIndeterminate(rooms: DormRoom[]) {
|
||||
const n = rooms.filter(r => isPicked(r.roomId)).length;
|
||||
return n > 0 && n < rooms.length;
|
||||
}
|
||||
|
||||
/** 已选汇总:N 间 · M 人,按楼栋分组展示「楼栋名(房间号…)」 */
|
||||
const pickedSummary = computed(() => {
|
||||
if (allDorms.value) return "全部宿舍(所有楼栋房间)";
|
||||
const ids = form.receiveList;
|
||||
if (!ids.length) return "";
|
||||
const beds = ids.reduce((s, id) => s + (pickedRooms.value[id]?.usedBeds || 0), 0);
|
||||
const byBld = new Map<string, string[]>();
|
||||
for (const id of ids) {
|
||||
const r = pickedRooms.value[id];
|
||||
if (!r) continue;
|
||||
const key = r.buildingName || "未分区";
|
||||
if (!byBld.has(key)) byBld.set(key, []);
|
||||
byBld.get(key)!.push(r.roomNo || String(id));
|
||||
}
|
||||
const groups = [...byBld.entries()].map(([b, rooms]) => `${b}(${rooms.join("、")})`);
|
||||
const shown = groups.slice(0, 6).join("、");
|
||||
return `${ids.length} 间宿舍 · ${beds} 名住宿生${groups.length > 6 ? "…" : ""}|${shown}`;
|
||||
});
|
||||
|
||||
/** 当前楼栋是否已整栋勾选(模板里不做复杂内联判断,避免箭头函数写在插值里) */
|
||||
function allCurBuildingChecked() {
|
||||
return roomModal.rooms.length > 0 && roomModal.rooms.every(r => isPicked(r.roomId));
|
||||
}
|
||||
function toggleAllCurBuilding() {
|
||||
toggleRooms(roomModal.rooms, !allCurBuildingChecked());
|
||||
}
|
||||
/** 已选人数(发布页与选择弹窗底部共用) */
|
||||
const pickedBeds = computed(() => form.receiveList.reduce((s, id) => s + (pickedRooms.value[id]?.usedBeds || 0), 0));
|
||||
|
||||
function confirmRooms() {
|
||||
roomModal.open = false;
|
||||
// 宿舍是在弹窗里勾出来的(不是表单控件),FormItem 收不到 change 事件,
|
||||
// 必须手动清掉上一次校验留下的红字,否则「宿舍选完了红字还挂着」
|
||||
clearFieldState(["receiveList"]);
|
||||
Message.success(allDorms.value ? "已选择全部宿舍" : `已选择 ${form.receiveList.length} 间宿舍`);
|
||||
}
|
||||
function clearRooms() {
|
||||
form.receiveList = [];
|
||||
pickedRooms.value = {};
|
||||
allDorms.value = false;
|
||||
}
|
||||
function toggleAllDorms(v: boolean) {
|
||||
if (v) clearRooms();
|
||||
allDorms.value = v;
|
||||
clearFieldState(["receiveList"]);
|
||||
}
|
||||
|
||||
/* ============ 需填报:统一回执单(单据类型选型) ============ */
|
||||
/** 切换单据类型时清掉上一次的必填红字 */
|
||||
function onFormTypeChange() {
|
||||
clearFieldState(["formType"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验规则:写成**普通常量**,不要写成 computed。
|
||||
* view-ui-plus 的 Form 里有一个 `watch: { rules () { this.validate() } }`——
|
||||
* 只要 rules 的引用变了就会全量重校验一遍;用 computed 时会因为依赖变化反复触发,
|
||||
* 而且 rules 作为 prop 要等父组件重渲染才更新,和「同步 change 事件里立刻校验」互相打架。
|
||||
*/
|
||||
const ruleValidate = {
|
||||
title: [
|
||||
{ required: true, message: "请输入通知标题", trigger: "blur" },
|
||||
{ max: 50, message: "通知标题太长,请修改标题长度" },
|
||||
],
|
||||
noticeType: [{ required: true, message: "请选择通知类型", trigger: "change" }],
|
||||
deptId: [{ required: true, message: "请选择发布部门", trigger: "change" }],
|
||||
/**
|
||||
* 接收宿舍:勾「全部宿舍」时 receiveList 保持空数组(提交时发 "ALL"),
|
||||
* 因此不能用 `type:"array", min:1` —— 那样勾了全部宿舍照样判不通过、红字不消。
|
||||
* 改用自定义 validator 覆盖两种合法态;`required` 仍保留,用于渲染必填星号
|
||||
* (FormItem.setRules 靠 rules.some(r => r.required) 决定是否显示星号)。
|
||||
*/
|
||||
receiveList: [
|
||||
{
|
||||
required: true,
|
||||
validator: (_rule: any, value: any, cb: any) =>
|
||||
allDorms.value || (Array.isArray(value) && value.length > 0)
|
||||
? cb()
|
||||
: cb(new Error("请选择接收宿舍(或勾选「全部宿舍」)")),
|
||||
trigger: "change",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** 只校验指定字段(view-ui-plus 的 validateField 一次只接一个 prop),返回是否全部通过 */
|
||||
async function validateFields(props: string[]) {
|
||||
const f: any = formRef.value;
|
||||
if (!f?.fields) return true;
|
||||
const results = await Promise.all(
|
||||
props.map(
|
||||
p =>
|
||||
new Promise<boolean>(resolve => {
|
||||
try {
|
||||
f.validateField(p, (message: string) => resolve(!message));
|
||||
} catch {
|
||||
resolve(true);
|
||||
}
|
||||
})
|
||||
)
|
||||
);
|
||||
return results.every(Boolean);
|
||||
}
|
||||
|
||||
/** 清掉指定字段的红字(存草稿时用,避免上次校验的残留标记) */
|
||||
function clearFieldState(props: string[]) {
|
||||
const f: any = formRef.value;
|
||||
if (!f?.fields) return;
|
||||
f.fields
|
||||
.filter((x: any) => props.includes(x.prop))
|
||||
.forEach((x: any) => {
|
||||
x.validateState = "";
|
||||
x.validateMessage = "";
|
||||
});
|
||||
}
|
||||
|
||||
/* ============ 富文本编辑器 ============ */
|
||||
const editorRef = shallowRef();
|
||||
const toolbarConfig = { excludeKeys: ["group-video", "fullScreen"] };
|
||||
const serviceBase = config.isMock ? "" : config.serviceHost;
|
||||
const editorConfig = {
|
||||
placeholder: "请输入通知内容(执行要求、时间安排、需家长配合事项等)……",
|
||||
MENU_CONF: {
|
||||
uploadImage: {
|
||||
async customUpload(file: File, insertFn: (url: string, alt: string, href: string) => void) {
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
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];
|
||||
const url = `${serviceBase}/api/file/Preview?id=${f.id}`;
|
||||
insertFn(url, f.fileTrueName || "", url);
|
||||
} else {
|
||||
Message.error(res.msg || "图片上传失败");
|
||||
}
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "图片上传失败");
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
function handleCreated(editor: any) {
|
||||
editorRef.value = editor;
|
||||
}
|
||||
onBeforeUnmount(() => editorRef.value?.destroy());
|
||||
|
||||
/* ============ 定时发布 ============ */
|
||||
const timeModal = reactive({ open: false, date: "" });
|
||||
/** 定时发布弹窗表单(同样需要 :model + prop 才能正常校验) */
|
||||
const timeFormRef = ref();
|
||||
const timeRules = {
|
||||
date: [{ required: true, message: "请选择定时发布时间", trigger: "change" }],
|
||||
};
|
||||
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, "/"));
|
||||
if (isNaN(dt.getTime())) return "";
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}:00`;
|
||||
}
|
||||
|
||||
/* ============ 编辑 / 复用回显 ============ */
|
||||
onMounted(async () => {
|
||||
await loadDicts();
|
||||
if (!(isEdit.value || fid.value > 0)) return;
|
||||
const sourceId = isEdit.value ? serid.value : fid.value;
|
||||
try {
|
||||
const detail: NoticeDetailVo = await noticeApi.getNoticeDetails(sourceId).send();
|
||||
form.title = isEdit.value ? detail.title || "" : `${detail.title || ""}(复用)`;
|
||||
form.noticeType = detail.noticeType || "";
|
||||
form.level = detail.level || "normal";
|
||||
form.deptId = detail.DeptID || "";
|
||||
form.receiptMode = detail.receiptMode || "none";
|
||||
form.needSupervisor = detail.needSupervisor || 0;
|
||||
form.content = detail.Contents || "";
|
||||
if (detail.effectiveTime) effectiveTime.value = detail.effectiveTime;
|
||||
if (detail.deadlineTime) deadlineTime.value = detail.deadlineTime;
|
||||
form.formType = detail.formType || "";
|
||||
if (isEdit.value) {
|
||||
if (detail.isDsfs && detail.DsfsTime) timeModal.date = detail.DsfsTime;
|
||||
// 接收对象回显:拿到房间 id 后补齐房间信息(用于汇总文案)
|
||||
if (detail.tlist === "all" || !Array.isArray(detail.tlist)) {
|
||||
allDorms.value = true;
|
||||
} else {
|
||||
form.receiveList = (detail.tlist as string[]).map(v => String(v));
|
||||
try {
|
||||
const all = (await dormApi.getRoomList().send()) || [];
|
||||
all.filter(r => form.receiveList.includes(String(r.roomId))).forEach(r => (pickedRooms.value[String(r.roomId)] = r));
|
||||
} catch {
|
||||
/* 忽略:房间信息缺失只影响汇总文案 */
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Array.isArray(detail.ChaoSend) && detail.ChaoSend.length) {
|
||||
editFileList.value = detail.ChaoSend.map(f => ({ name: f.name || f.filename, fileid: f.fileid || f.id }));
|
||||
fileids.value = editFileList.value.map(f => f.fileid);
|
||||
}
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "加载通知失败");
|
||||
}
|
||||
});
|
||||
|
||||
/* ============ 提交 ============ */
|
||||
const formRef = ref();
|
||||
const isLoading = ref(false);
|
||||
const { send: sendCreate } = useRequest((payload: NoticeRequest) => noticeApi.create(payload), { immediate: false });
|
||||
const { send: sendEdit } = useRequest((payload: NoticeRequest) => noticeApi.editNotice(payload), { immediate: false });
|
||||
|
||||
/** 需填报类校验:必须选定单据类型(字段清单由模板带出,无需校验字段本身) */
|
||||
function checkFormFields(): boolean {
|
||||
if (form.receiptMode !== "fill") return true;
|
||||
if (!form.formType) {
|
||||
Message.info("「需填报」类通知需先选择回执单据类型");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function doAdd(mode: "publish" | "draft" | "time") {
|
||||
if (mode === "draft") {
|
||||
// 存草稿只要求标题:接收宿舍 / 类型允许暂缺,顺手清掉它们的残留红字
|
||||
if (!(await validateFields(["title"]))) {
|
||||
Message.info("请先填写通知标题再存草稿");
|
||||
return;
|
||||
}
|
||||
clearFieldState(["noticeType", "deptId", "receiveList"]);
|
||||
} else {
|
||||
const ok = await validateFields(["title", "noticeType", "deptId", "receiveList"]);
|
||||
if (!ok) {
|
||||
Message.info("请填写带 * 的必填项");
|
||||
return;
|
||||
}
|
||||
if (!checkFormFields()) return;
|
||||
}
|
||||
const data: NoticeRequest = {
|
||||
title: form.title,
|
||||
content: form.content,
|
||||
noticeType: form.noticeType,
|
||||
level: form.level as any,
|
||||
deptId: form.deptId,
|
||||
deptName: deptOptions.value.find(d => String(d.k) === form.deptId)?.v || "",
|
||||
effectiveTime: effectiveTime.value ? fmt(effectiveTime.value) : undefined,
|
||||
deadlineTime: deadlineTime.value ? fmt(deadlineTime.value) : undefined,
|
||||
receiptMode: form.receiptMode as any,
|
||||
needSupervisor: form.needSupervisor ? 1 : 0,
|
||||
topmost: topmost.value ? 1 : 0,
|
||||
receiveList: allDorms.value ? "ALL" : form.receiveList.join(","),
|
||||
receiveType: "room",
|
||||
fileIds: fileids.value.join(","),
|
||||
smsFlag: smsFlag.value ? 1 : 0,
|
||||
/** 统一回执单:只传单据类型,字段清单由后端按类型生成 */
|
||||
formType: form.receiptMode === "fill" ? (form.formType as any) : undefined,
|
||||
draft: mode === "draft",
|
||||
};
|
||||
// 回执截止早于生效时间的组合是无意义的,提前拦掉
|
||||
if (data.effectiveTime && data.deadlineTime && data.deadlineTime < data.effectiveTime) {
|
||||
Message.info("回执截止时间需晚于生效时间");
|
||||
return;
|
||||
}
|
||||
if (mode === "time") {
|
||||
// view-ui-plus 的 Form.validate() 是 resolve(valid),不会 reject,
|
||||
// 写成 .then(() => true) 会恒为 true 直接跳过校验
|
||||
if ((await timeFormRef.value?.validate?.().catch(() => false)) !== true) {
|
||||
Message.info("请填写定时发送时间");
|
||||
return;
|
||||
}
|
||||
const t = new Date(String(timeModal.date).replace(/-/g, "/")).getTime();
|
||||
if (t < Date.now()) {
|
||||
Message.info("定时时间需晚于当前时间");
|
||||
return;
|
||||
}
|
||||
data.timingSendTime = fmt(timeModal.date);
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
try {
|
||||
if (!isEdit.value) {
|
||||
await sendCreate(data);
|
||||
Message.success(mode === "draft" ? "已存草稿" : mode === "time" ? "已设置定时发布" : "发布成功");
|
||||
timeModal.open = false;
|
||||
router.push("/list");
|
||||
} else {
|
||||
data.sourceId = serid.value;
|
||||
await sendEdit(data);
|
||||
Message.success("保存成功");
|
||||
timeModal.open = false;
|
||||
router.push("/list");
|
||||
}
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "保存失败");
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const showReceiptOpts = computed(() => form.receiptMode !== "none");
|
||||
const isFill = computed(() => form.receiptMode === "fill");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-wrapper">
|
||||
<div class="main-container">
|
||||
<Form ref="formRef" class="myform" :model="form" :rules="ruleValidate" :label-width="110">
|
||||
<FormItem label="通知标题:" prop="title">
|
||||
<Input v-model="form.title" placeholder="请输入通知标题(50 字以内)"></Input>
|
||||
</FormItem>
|
||||
<FormItem label="通知类型:" prop="noticeType">
|
||||
<Select v-model="form.noticeType" placeholder="请选择通知类型" style="width: 240px">
|
||||
<Option v-for="c in typeOptions" :key="c" :value="c">{{ c }}</Option>
|
||||
</Select>
|
||||
<span class="inline-hint">类型可在「宿舍设置 → 通知类型」中扩展</span>
|
||||
</FormItem>
|
||||
<FormItem label="发布部门:" prop="deptId">
|
||||
<Select v-model="form.deptId" placeholder="请选择发布部门" style="width: 240px">
|
||||
<Option v-for="d in deptOptions" :key="String(d.k)" :value="String(d.k)">{{ d.v }}</Option>
|
||||
</Select>
|
||||
</FormItem>
|
||||
<FormItem label="紧急程度:">
|
||||
<RadioGroup v-model="form.level">
|
||||
<Radio v-for="l in LEVELS" :key="l.v" :label="l.v">{{ l.label }}</Radio>
|
||||
</RadioGroup>
|
||||
</FormItem>
|
||||
<FormItem label="生效时间:">
|
||||
<DatePicker type="datetime" v-model="effectiveTime" format="yyyy-MM-dd HH:mm" placeholder="立即生效" style="width: 260px"></DatePicker>
|
||||
<span class="inline-hint">留空表示立即生效;用于「明早起执行新作息」这类提前下发</span>
|
||||
</FormItem>
|
||||
<FormItem label="回执截止:">
|
||||
<DatePicker type="datetime" v-model="deadlineTime" format="yyyy-MM-dd HH:mm" placeholder="不设截止" style="width: 260px"></DatePicker>
|
||||
<div class="hint">留空表示不设截止;「仅通知」类无需回执,此项可忽略</div>
|
||||
</FormItem>
|
||||
<FormItem label="回执方式:">
|
||||
<RadioGroup v-model="form.receiptMode">
|
||||
<Radio v-for="m in RECEIPT_MODES" :key="m.v" :label="m.v">{{ m.label }}</Radio>
|
||||
</RadioGroup>
|
||||
</FormItem>
|
||||
|
||||
<!-- 需填报:统一回执单(选单据类型 + 只读字段预览) -->
|
||||
<FormItem v-if="isFill" label="回执单据:" prop="formType" class="fill-fields-item">
|
||||
<div class="fill-toolbar">
|
||||
<Select v-model="form.formType" placeholder="请选择单据类型" style="width: 220px" @on-change="onFormTypeChange">
|
||||
<Option v-for="t in formTemplates" :key="t.type" :value="t.type">{{ t.name }}</Option>
|
||||
</Select>
|
||||
<span class="inline-hint">{{ curTemplate ? curTemplate.desc : "单据字段由系统预置,发布人只需选择类型" }}</span>
|
||||
</div>
|
||||
<div class="form-preview" v-if="curTemplate">
|
||||
<div class="fp-head">
|
||||
{{ curTemplate.name }} · 家长端表单预览({{ COMMON_PREVIEW.length + curTemplate.fields.length }} 项)
|
||||
<span class="fp-note">字段不可增删改;如需调整请联系管理员修改模板</span>
|
||||
</div>
|
||||
<div class="fp-group">公共区 · 所有单据一致</div>
|
||||
<div class="fp-fields">
|
||||
<span class="fp-chip fixed" v-for="c in COMMON_PREVIEW" :key="c">{{ c }}</span>
|
||||
</div>
|
||||
<div class="fp-group">专属区 · {{ curTemplate.name }}</div>
|
||||
<div class="fp-fields" v-if="curTemplate.fields.length">
|
||||
<span class="fp-chip" v-for="f in curTemplate.fields" :key="f.key">
|
||||
{{ f.label }}<em v-if="f.required">*</em><i v-if="f.unit">({{ f.unit }})</i>
|
||||
</span>
|
||||
</div>
|
||||
<div class="fp-empty" v-else>该单据无额外登记项,家长只需给出回执结论</div>
|
||||
</div>
|
||||
<div class="hint warn" v-else>请先选择单据类型,家长端将按该单据的统一回执单填报</div>
|
||||
</FormItem>
|
||||
|
||||
<FormItem label="接收宿舍:" prop="receiveList">
|
||||
<div class="cls-row">
|
||||
<Button icon="md-home" @click="openRoomPicker">选择宿舍</Button>
|
||||
<Checkbox v-model="allDorms" @on-change="toggleAllDorms">全部宿舍</Checkbox>
|
||||
<a v-if="!allDorms && form.receiveList.length" class="clear-link" @click="clearRooms">清空</a>
|
||||
</div>
|
||||
<div class="hint" :class="{ warn: !allDorms && !form.receiveList.length }">
|
||||
{{ pickedSummary || "发布前必须选择接收宿舍:宿舍通知按「房间」下发,应收人数取房间实际入住床位数" }}
|
||||
</div>
|
||||
</FormItem>
|
||||
<FormItem v-if="showReceiptOpts" label="宿管配合:">
|
||||
<i-switch v-model="form.needSupervisor" :true-value="1" :false-value="0" />
|
||||
<span class="inline-hint">开启后在通知中标明「需宿管到场配合执行」,并同步提醒对应楼栋宿管</span>
|
||||
</FormItem>
|
||||
<FormItem label="置顶展示:">
|
||||
<i-switch v-model="topmost" />
|
||||
<span class="inline-hint">置顶后家长端列表置顶并标记「顶」</span>
|
||||
</FormItem>
|
||||
<FormItem label="短信提醒:">
|
||||
<i-switch v-model="smsFlag" />
|
||||
<span class="inline-hint">开启后向未读家长发送短信提醒(需学校已开通短信)</span>
|
||||
</FormItem>
|
||||
<FormItem label="添加附件:">
|
||||
<AttachmentUpload v-model:fileids="fileids" :default-list="editFileList"></AttachmentUpload>
|
||||
</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" 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="roomModal.open" title="选择接收宿舍" width="880px" :mask-closable="false">
|
||||
<div class="room-picker">
|
||||
<div class="bld-list">
|
||||
<div
|
||||
v-for="b in buildings"
|
||||
:key="b.buildingId"
|
||||
:class="['bld-item', roomModal.buildingId === b.buildingId ? 'active' : '']"
|
||||
@click="selectBuilding(b)"
|
||||
>
|
||||
<div class="bld-name">{{ b.buildingName }}</div>
|
||||
<div class="bld-meta">{{ b.roomCount }} 间 · {{ b.usedBeds }}/{{ b.bedCount }} 人</div>
|
||||
</div>
|
||||
<div v-if="!buildings.length" class="empty">暂无楼栋</div>
|
||||
</div>
|
||||
<div class="room-area" v-if="curBuilding">
|
||||
<div class="room-bar">
|
||||
<span>{{ curBuilding.buildingName }}</span>
|
||||
<span class="supervisor">宿管:{{ curBuilding.supervisorName }}({{ curBuilding.supervisorPhone }})</span>
|
||||
<a class="fr" @click="toggleAllCurBuilding()">{{ allCurBuildingChecked() ? "取消本楼全选" : "全选本楼" }}</a>
|
||||
</div>
|
||||
<div class="floor-box" v-for="g in floorGroups" :key="g.floor">
|
||||
<div class="floor-h">
|
||||
<Checkbox
|
||||
:model-value="floorAllChecked(g.rooms)"
|
||||
:indeterminate="floorIndeterminate(g.rooms)"
|
||||
@on-change="(v) => toggleRooms(g.rooms, v)"
|
||||
>
|
||||
{{ g.floor }} 层
|
||||
</Checkbox>
|
||||
<span class="floor-meta">{{ g.rooms.length }} 间 · {{ g.rooms.reduce((s, r) => s + r.usedBeds, 0) }} 人</span>
|
||||
</div>
|
||||
<div class="room-grid">
|
||||
<Checkbox v-for="r in g.rooms" :key="r.roomId" :model-value="isPicked(r.roomId)" @on-change="(v) => toggleRoom(r, v)">
|
||||
{{ r.roomNo }}({{ r.usedBeds }}/{{ r.bedCount }})
|
||||
</Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="roomModal.loading" class="empty">加载中…</div>
|
||||
</div>
|
||||
<div class="room-area" v-else><div class="empty">请先选择楼栋</div></div>
|
||||
</div>
|
||||
<div class="picked-bar">
|
||||
已选:{{ allDorms ? "全部宿舍" : `${form.receiveList.length} 间 · ${pickedBeds} 名住宿生` }}
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button @click="clearRooms">清空选择</Button>
|
||||
<Button @click="roomModal.open = false">取消</Button>
|
||||
<Button type="primary" @click="confirmRooms">确定</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<Modal v-model="timeModal.open" title="定时发布" width="420px">
|
||||
<Form ref="timeFormRef" :model="timeModal" :rules="timeRules" :label-width="90">
|
||||
<FormItem label="发布时间:" prop="date">
|
||||
<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>
|
||||
.myform {
|
||||
:deep(.ivu-form-item) {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
:deep(.fill-fields-item .ivu-form-item-content) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
color: #c5c8ce;
|
||||
line-height: 1.6;
|
||||
margin-top: 2px;
|
||||
&.warn {
|
||||
color: #ed4014;
|
||||
}
|
||||
}
|
||||
.inline-hint {
|
||||
font-size: 12px;
|
||||
color: #808695;
|
||||
margin-left: 10px;
|
||||
}
|
||||
.cls-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
.clear-link {
|
||||
color: #2d8cf0;
|
||||
}
|
||||
.editor-wrap {
|
||||
border: 1px solid #dcdee2;
|
||||
border-radius: 4px;
|
||||
z-index: 100;
|
||||
.editor-toolbar {
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
}
|
||||
.editor-content {
|
||||
:deep(.w-e-text-container) {
|
||||
height: 320px !important;
|
||||
}
|
||||
overflow-y: hidden;
|
||||
}
|
||||
}
|
||||
.bottom-btn {
|
||||
margin-top: 6px;
|
||||
}
|
||||
/* 统一回执单:单据选型 + 字段只读预览 */
|
||||
.fill-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.form-preview {
|
||||
border: 1px solid #e8eaec;
|
||||
border-radius: 4px;
|
||||
padding: 10px 12px;
|
||||
background: #fafcff;
|
||||
.fp-head {
|
||||
font-size: 13px;
|
||||
color: #515a6e;
|
||||
font-weight: 600;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px dashed #e8e8e8;
|
||||
.fp-note {
|
||||
margin-left: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: #808695;
|
||||
}
|
||||
}
|
||||
.fp-group {
|
||||
margin: 10px 0 6px;
|
||||
font-size: 12px;
|
||||
color: #808695;
|
||||
}
|
||||
.fp-fields {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 8px;
|
||||
}
|
||||
.fp-chip {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
font-size: 13px;
|
||||
color: #17233d;
|
||||
background: #f0f6ff;
|
||||
border: 1px solid #d3e4ff;
|
||||
border-radius: 3px;
|
||||
em {
|
||||
font-style: normal;
|
||||
color: #ed4014;
|
||||
margin-left: 2px;
|
||||
}
|
||||
i {
|
||||
font-style: normal;
|
||||
color: #808695;
|
||||
}
|
||||
&.fixed {
|
||||
color: #515a6e;
|
||||
background: #f6f7f9;
|
||||
border-color: #e8eaec;
|
||||
}
|
||||
}
|
||||
.fp-empty {
|
||||
font-size: 12px;
|
||||
color: #c5c8ce;
|
||||
padding: 4px 0;
|
||||
}
|
||||
}
|
||||
/* 宿舍选择器 */
|
||||
.room-picker {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
height: 420px;
|
||||
.bld-list {
|
||||
width: 230px;
|
||||
flex: none;
|
||||
border: 1px solid #e8eaec;
|
||||
border-radius: 4px;
|
||||
overflow-y: auto;
|
||||
.bld-item {
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
.bld-name {
|
||||
color: #17233d;
|
||||
font-size: 13px;
|
||||
}
|
||||
.bld-meta {
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
&:hover {
|
||||
background: #f6f9ff;
|
||||
}
|
||||
&.active {
|
||||
background: #ecf3ff;
|
||||
border-left: 3px solid #2d8cf0;
|
||||
}
|
||||
}
|
||||
}
|
||||
.room-area {
|
||||
flex: 1;
|
||||
border: 1px solid #e8eaec;
|
||||
border-radius: 4px;
|
||||
overflow-y: auto;
|
||||
padding: 10px 12px;
|
||||
.room-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
font-size: 13px;
|
||||
color: #17233d;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px dashed #e8e8e8;
|
||||
.supervisor {
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
}
|
||||
a {
|
||||
color: #2d8cf0;
|
||||
}
|
||||
}
|
||||
.floor-box {
|
||||
margin-top: 10px;
|
||||
.floor-h {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 4px 0;
|
||||
.floor-meta {
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
.room-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 18px;
|
||||
padding: 4px 0 4px 22px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.empty {
|
||||
color: #c5c8ce;
|
||||
font-size: 13px;
|
||||
padding: 16px 0;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
.picked-bar {
|
||||
margin-top: 10px;
|
||||
padding: 8px 12px;
|
||||
background: #f7f9fc;
|
||||
border: 1px solid #e8eaec;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
color: #515a6e;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,584 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { noticeApi } from "@/api/module/dorm";
|
||||
import type { NoticeDetailVo, NextPrevVo, ReadGroup } from "@/types/dorm";
|
||||
import FilePreviewModal from "@/components/FilePreviewModal.vue";
|
||||
import Utils from "@/utils";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const serid = computed(() => Number(route.query.serid) || 0);
|
||||
/* type=0 管理视角(不标读) / type=1 查看视角(标读);注意 0 是合法值不能用 || 兜底 */
|
||||
const viewType = computed(() => {
|
||||
const t = Number(route.query.type);
|
||||
return t === 0 ? 0 : 1;
|
||||
});
|
||||
/* 上一/下一篇的序列类型:管理视角走已发(type=0),查看走已收(type=1) */
|
||||
const navType = computed(() => (viewType.value === 0 ? "0" : "1"));
|
||||
|
||||
const detail = ref<NoticeDetailVo | null>(null);
|
||||
const loading = ref(false);
|
||||
const readGroups = ref<ReadGroup[]>([]);
|
||||
const showRead = ref(false);
|
||||
const prevId = ref(0);
|
||||
const nextId = ref(0);
|
||||
|
||||
onMounted(async () => {
|
||||
await load();
|
||||
});
|
||||
|
||||
async function load() {
|
||||
if (!serid.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
detail.value = await noticeApi.modify(serid.value, viewType.value).send();
|
||||
const nav: NextPrevVo = await noticeApi.getNextPrevNotice(serid.value, navType.value).send();
|
||||
prevId.value = nav.prevId || 0;
|
||||
nextId.value = nav.nextId || 0;
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "加载通知失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function goNav(id: number) {
|
||||
if (!id) return;
|
||||
router.replace({ path: route.path, query: { ...route.query, serid: id } });
|
||||
load();
|
||||
}
|
||||
|
||||
/* 已读进度(已查看百分比,按房间计) */
|
||||
const readPercent = computed(() => {
|
||||
const total = Number(detail.value?.numcount) || 0;
|
||||
const read = Number(detail.value?.readnumcount) || 0;
|
||||
if (!total) return 0;
|
||||
return Math.round((read / total) * 100);
|
||||
});
|
||||
const unreadCount = computed(() => Math.max((Number(detail.value?.numcount) || 0) - (Number(detail.value?.readnumcount) || 0), 0));
|
||||
|
||||
/* 回执/填报进度 */
|
||||
const progress = computed(() => detail.value?.progress || null);
|
||||
const actedCount = computed(() => (progress.value ? progress.value.confirmed + progress.value.filled : 0));
|
||||
const receiptDesc = computed(() => detail.value?.receiptModeDesc || "仅通知");
|
||||
const isFill = computed(() => detail.value?.receiptMode === "fill");
|
||||
|
||||
async function openRead() {
|
||||
try {
|
||||
readGroups.value = await noticeApi.orgizeModify(serid.value).send();
|
||||
showRead.value = true;
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "加载阅读情况失败");
|
||||
}
|
||||
}
|
||||
|
||||
/** 催办某未读学生的家长 */
|
||||
async function onRemindStudent(t: any) {
|
||||
try {
|
||||
await noticeApi.remindStudent(serid.value, t.studentId).send();
|
||||
Message.success(`已向「${t.teaname}」家长发送催办提醒`);
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "催办失败");
|
||||
}
|
||||
}
|
||||
|
||||
/** 代标记某学生已读,并刷新阅读名单与统计 */
|
||||
async function onMarkRead(t: any) {
|
||||
try {
|
||||
await noticeApi.markRead(serid.value, t.studentId).send();
|
||||
Message.success("已标记为已读");
|
||||
readGroups.value = await noticeApi.orgizeModify(serid.value).send();
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "操作失败");
|
||||
}
|
||||
}
|
||||
|
||||
/* 管理视角操作 */
|
||||
function onEdit() {
|
||||
router.push({ path: "/list/new", query: { serid: serid.value } });
|
||||
}
|
||||
function onReuse() {
|
||||
router.push({ path: "/list/new", query: { fid: serid.value } });
|
||||
}
|
||||
function goReceipts() {
|
||||
router.push({ path: "/receipts", query: { serid: serid.value } });
|
||||
}
|
||||
|
||||
/* 打印 */
|
||||
function onPrint() {
|
||||
if (!detail.value) return;
|
||||
const d = detail.value;
|
||||
const win = window.open("", "_blank");
|
||||
if (!win) return;
|
||||
win.document.write(`
|
||||
<html>
|
||||
<head><title>${d.title || ""}</title></head>
|
||||
<body style="font-family:'Microsoft YaHei',sans-serif;padding:24px;">
|
||||
<h2>${d.title || ""}</h2>
|
||||
<p style="color:#666;font-size:13px;">
|
||||
发布部门:${d.DeptName || ""} 发布人:${d.TeaName || ""} 发布时间:${d.EditDate || ""}<br/>
|
||||
通知类型:${d.noticeType || "—"} 紧急程度:${d.levelDesc || "普通"} 回执方式:${d.receiptModeDesc || "仅通知"}<br/>
|
||||
生效时间:${d.effectiveTime || "立即生效"} 回执截止:${d.deadlineTime || "不限"}<br/>
|
||||
接收宿舍:${d.receiveText || "全部宿舍"}
|
||||
</p>
|
||||
<hr/>
|
||||
<div>${d.Contents || ""}</div>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
win.document.close();
|
||||
win.focus();
|
||||
win.print();
|
||||
}
|
||||
|
||||
function downFile(id: number) {
|
||||
if (!id) return;
|
||||
window.location.href = noticeApi.fileDownUrl(id);
|
||||
}
|
||||
function downAll() {
|
||||
window.location.href = noticeApi.allFileDownUrl(serid.value);
|
||||
}
|
||||
|
||||
/** 展示名:后端 name=原始文件名,filename=存储相对路径 */
|
||||
function displayName(item: { name?: string; filename?: string }) {
|
||||
return item.name || item.filename || "";
|
||||
}
|
||||
/* 附件图标(按扩展名,图标名对齐旧版 matchSuffix) */
|
||||
function getFileIcon(name: string) {
|
||||
const suffix = String(name || "").split(".").pop()?.toLowerCase() || "";
|
||||
if (["doc", "docx", "wps"].includes(suffix)) return "icon-word";
|
||||
if (["xls", "xlsx"].includes(suffix)) return "icon-excel";
|
||||
if (["ppt", "pptx"].includes(suffix)) return "icon-ppt";
|
||||
if (["pdf"].includes(suffix)) return "icon-pdf1";
|
||||
if (["txt"].includes(suffix)) return "icon-txt";
|
||||
if (["jpg", "jpeg", "png", "gif", "bmp"].includes(suffix)) return "icon-tupian1";
|
||||
if (["zip", "rar"].includes(suffix)) return "icon-yasuo";
|
||||
return "icon-weizhi";
|
||||
}
|
||||
/* 可在线预览(open-file-viewer 支持:图片/pdf/txt/docx/xlsx/pptx;doc/zip 等仅下载) */
|
||||
function getIsPreview(name: string) {
|
||||
const suffix = String(name || "").split(".").pop()?.toLowerCase() || "";
|
||||
return ["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg", "txt", "pdf", "docx", "xlsx", "pptx"].includes(suffix);
|
||||
}
|
||||
/* 附件预览弹窗(open-file-viewer) */
|
||||
const previewState = ref<{ show: boolean; fileId: number | null; fileName: string }>({
|
||||
show: false,
|
||||
fileId: null,
|
||||
fileName: "",
|
||||
});
|
||||
function openPreview(item: { name?: string; filename?: string; fileid?: number; id?: number }) {
|
||||
previewState.value = {
|
||||
show: true,
|
||||
fileId: Number(item.fileid || item.id) || null,
|
||||
fileName: displayName(item),
|
||||
};
|
||||
}
|
||||
|
||||
function shortTime(v?: string | null, fallback = "不限") {
|
||||
if (!v) return fallback;
|
||||
return String(v).slice(0, 16);
|
||||
}
|
||||
|
||||
const LEVEL_HEX: Record<string, string> = { normal: "#808695", important: "#ff9900", urgent: "#ed4014" };
|
||||
|
||||
/* 需填报字段声明的展示列(纯 render,避免 iView 具名 slot 差异) */
|
||||
const FIELD_TYPE_TEXT: Record<string, string> = {
|
||||
text: "单行文本",
|
||||
number: "数字",
|
||||
date: "日期",
|
||||
datetime: "日期时间",
|
||||
select: "下拉选择",
|
||||
textarea: "多行文本",
|
||||
};
|
||||
const fieldColumns = [
|
||||
{
|
||||
title: "区块",
|
||||
width: 90,
|
||||
align: "center",
|
||||
render: (h: any, p: any) =>
|
||||
h("Tag", { color: p.row.group === "common" ? "blue" : "green", size: "small" }, () => (p.row.group === "common" ? "公共区" : "专属区")),
|
||||
},
|
||||
{ title: "字段名称", key: "label", minWidth: 160 },
|
||||
{
|
||||
title: "类型",
|
||||
width: 110,
|
||||
align: "center",
|
||||
render: (h: any, p: any) => h("span", {}, FIELD_TYPE_TEXT[p.row.type] || p.row.type),
|
||||
},
|
||||
{
|
||||
title: "必填",
|
||||
width: 80,
|
||||
align: "center",
|
||||
render: (h: any, p: any) => h("span", { style: p.row.required ? "color:#ed4014;font-weight:600;" : "color:#808695;" }, p.row.required ? "是" : "否"),
|
||||
},
|
||||
{
|
||||
title: "可选项 / 填报提示",
|
||||
minWidth: 240,
|
||||
render: (h: any, p: any) => {
|
||||
const f = p.row;
|
||||
if (f.options && f.options.length) return h("span", { style: "color:#515a6e;" }, f.options.map((o: any) => o.label).join(" / "));
|
||||
return h("span", { style: "color:#808695;" }, (f.placeholder || "—") + (f.unit ? `(单位:${f.unit})` : ""));
|
||||
},
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-wrapper">
|
||||
<div class="main-container" v-if="detail">
|
||||
<div class="ops-top">
|
||||
<div class="nav">
|
||||
<Button size="small" :disabled="!prevId" @click="goNav(prevId)">上一篇</Button>
|
||||
<Button size="small" :disabled="!nextId" @click="goNav(nextId)">下一篇</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 管理视角:阅读/回执进度统计条 -->
|
||||
<template v-if="viewType === 0">
|
||||
<h2 class="notice-title">
|
||||
<Tag v-if="detail.level !== 'normal'" :color="detail.level === 'urgent' ? 'error' : 'warning'" size="small">{{ detail.levelDesc }}</Tag>
|
||||
{{ detail.title }}
|
||||
</h2>
|
||||
<div class="stat-bar">
|
||||
<div class="progress-box">
|
||||
<Progress :percent="readPercent" hide-info :stroke-width="10" />
|
||||
<span class="percent-label">已查看 {{ readPercent }}%</span>
|
||||
</div>
|
||||
<div class="stat-nums" title="点击查看阅读情况" @click="openRead">
|
||||
<span class="item">应读 <b class="blue">{{ detail.numcount || 0 }}</b> 人</span>
|
||||
<span class="item">已读 <b class="orange">{{ detail.readnumcount || 0 }}</b> 人</span>
|
||||
<span class="item">未读 <b class="blue">{{ unreadCount }}</b> 人</span>
|
||||
</div>
|
||||
<div v-if="progress && detail.receiptMode !== 'none'" class="stat-nums" title="点击查看回执名单" @click="goReceipts">
|
||||
<span class="item">应收 <b class="blue">{{ progress.should }}</b> 人</span>
|
||||
<span class="item">已回执 <b class="orange">{{ actedCount }}</b> 人</span>
|
||||
<span class="item">回执率 <b class="orange">{{ progress.receiptRate }}%</b></span>
|
||||
</div>
|
||||
<div class="stat-tags">
|
||||
<Tag color="blue">{{ detail.noticeType }}</Tag>
|
||||
<Tag>{{ detail.DeptName }}</Tag>
|
||||
<Tag>{{ detail.TeaName }}</Tag>
|
||||
<Tag>{{ detail.EditDate }}</Tag>
|
||||
<Tag color="green">接收宿舍:{{ detail.receiveText || "全部宿舍" }}</Tag>
|
||||
<Tag color="purple">{{ detail.noticeStatusDesc || "已发布" }}</Tag>
|
||||
<Tag>{{ receiptDesc }}</Tag>
|
||||
<Tag v-if="detail.needSupervisor" color="orange">需宿管配合</Tag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
</template>
|
||||
|
||||
<!-- 查看视角:普通标题 -->
|
||||
<h2 v-else class="notice-title">
|
||||
<Tag v-if="detail.level !== 'normal'" :color="detail.level === 'urgent' ? 'error' : 'warning'" size="small">{{ detail.levelDesc }}</Tag>
|
||||
{{ detail.title }}
|
||||
</h2>
|
||||
|
||||
<!-- 通知要素(两会话都展示) -->
|
||||
<div class="act-meta">
|
||||
<div class="m-item"><span class="k">通知类型</span><span class="v">{{ detail.noticeType || "—" }}</span></div>
|
||||
<div class="m-item">
|
||||
<span class="k">紧急程度</span>
|
||||
<span class="v" :style="{ color: LEVEL_HEX[detail.level] || '#17233d' }">{{ detail.levelDesc || "普通" }}</span>
|
||||
</div>
|
||||
<div class="m-item"><span class="k">生效时间</span><span class="v">{{ shortTime(detail.effectiveTime, "立即生效") }}</span></div>
|
||||
<div class="m-item"><span class="k">回执截止</span><span class="v">{{ shortTime(detail.deadlineTime, "不设截止") }}</span></div>
|
||||
<div class="m-item"><span class="k">回执方式</span><span class="v">{{ receiptDesc }}</span></div>
|
||||
<div class="m-item"><span class="k">宿管配合</span><span class="v">{{ detail.needSupervisor ? "需到场配合" : "无需配合" }}</span></div>
|
||||
</div>
|
||||
|
||||
<div v-if="viewType !== 0" class="meta">
|
||||
<span>发布部门:{{ detail.DeptName || "—" }}</span>
|
||||
<span>发布人:{{ detail.TeaName || "—" }}</span>
|
||||
<span>发布时间:{{ detail.EditDate }}</span>
|
||||
<span>接收宿舍:{{ detail.receiveText || "全部宿舍" }}</span>
|
||||
<span v-if="detail.isDsfs">定时发送:{{ detail.DsfsTime }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 需填报:统一回执单(公共区 + 专属区) -->
|
||||
<div v-if="isFill && detail.formFields && detail.formFields.length" class="form-fields">
|
||||
<div class="ff-title">
|
||||
家长需填报的表单 · {{ detail.formTypeDesc || "统一回执单" }}({{ detail.formFields.length }} 项)
|
||||
<span class="ff-sub">公共区所有单据一致,专属区随单据类型变化;字段由系统预置,不可增删改</span>
|
||||
</div>
|
||||
<Table :columns="fieldColumns" :data="detail.formFields" size="small" no-data-text="暂无"></Table>
|
||||
</div>
|
||||
|
||||
<!-- 正文 -->
|
||||
<div class="content" v-html="detail.Contents"></div>
|
||||
|
||||
<div v-if="detail.ChaoSend && detail.ChaoSend.length" class="files">
|
||||
<div class="files-title">
|
||||
附件({{ detail.ChaoSend.length }})<a class="down-all" @click="downAll">全部下载</a>
|
||||
</div>
|
||||
<div v-for="f in detail.ChaoSend" :key="f.fileid || f.id" class="file-item">
|
||||
<i class="iconfont file-type-icon" :class="getFileIcon(displayName(f))"></i>
|
||||
<span class="file-name" :title="displayName(f)">{{ displayName(f) }}</span>
|
||||
<span class="file-size">{{ Utils.getFileSize(f.size) }}</span>
|
||||
<a class="file-op" v-if="getIsPreview(displayName(f))" @click="openPreview(f)">查看</a>
|
||||
<a class="file-op" @click="downFile(f.fileid || f.id)">下载</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 管理视角底部操作 -->
|
||||
<div v-if="viewType === 0" class="bottom-btns">
|
||||
<Button v-if="detail.receiptMode !== 'none'" type="primary" icon="icon-chakan" @click="goReceipts">回执情况</Button>
|
||||
<Button style="margin-left: 10px" @click="onEdit">编辑</Button>
|
||||
<Button style="margin-left: 10px" @click="onReuse">复用再发</Button>
|
||||
<Button style="margin-left: 10px" @click="onPrint">打印</Button>
|
||||
</div>
|
||||
|
||||
<!-- 查看阅读情况(绿=已读 红=未读,名单为住宿生) -->
|
||||
<Modal v-model="showRead" title="查看阅读情况" :width="620">
|
||||
<div class="read-tip">提示:<span class="green">绿色</span>表示已读,<span class="red">红色</span>表示未读;名单为住宿生(前缀为其房号)</div>
|
||||
<div class="read-list">
|
||||
<div v-for="g in readGroups" :key="g.GroupName" class="read-group">
|
||||
<div class="read-group-name">{{ g.GroupName }}:</div>
|
||||
<div class="read-users" style="display: flex; flex-direction: column; gap: 4px">
|
||||
<div v-for="t in g.teaslist" :key="t.account" style="display: flex; align-items: center; gap: 10px">
|
||||
<span :class="t.states === 'read' ? 'green' : 'red'" class="user-name" style="min-width: 130px">{{ t.teaname }}</span>
|
||||
<span class="user-meta" style="color: #808695; font-size: 12px">
|
||||
{{ t.className || "—" }}
|
||||
<template v-if="t.guardianPhone"> · {{ t.guardianName || "家长" }} {{ t.guardianPhone }}</template>
|
||||
</span>
|
||||
<span v-if="t.states === 'read'" style="color: #19be6b; font-size: 12px">已读</span>
|
||||
<span v-else style="display: flex; gap: 10px">
|
||||
<a @click="onRemindStudent(t)">催办</a>
|
||||
<a @click="onMarkRead(t)">标记已读</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!g.teaslist || !g.teaslist.length" class="empty">暂无住宿生</div>
|
||||
</div>
|
||||
<div v-if="!readGroups.length" class="empty">暂无数据</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button type="primary" @click="showRead = false">确定</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
<div class="main-container" v-else-if="loading">加载中...</div>
|
||||
<div class="main-container empty" v-else>通知不存在或已被删除</div>
|
||||
</div>
|
||||
<!-- 附件预览弹窗 -->
|
||||
<FilePreviewModal v-model="previewState.show" :file-id="previewState.fileId" :file-name="previewState.fileName" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ops-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.notice-title {
|
||||
font-size: 20px;
|
||||
margin: 0 0 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 18px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px dashed #e8e8e8;
|
||||
font-size: 13px;
|
||||
color: #808695;
|
||||
}
|
||||
/* 通知要素栅格 */
|
||||
.act-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 30px;
|
||||
padding: 10px 0 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.act-meta .m-item {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
min-width: 220px;
|
||||
}
|
||||
.act-meta .k {
|
||||
color: #8a94a6;
|
||||
flex: none;
|
||||
}
|
||||
.act-meta .v {
|
||||
color: #17233d;
|
||||
font-weight: 500;
|
||||
}
|
||||
/* 管理视角统计条 */
|
||||
.stat-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 4px 0;
|
||||
font-size: 13px;
|
||||
color: #17233d;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.progress-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 260px;
|
||||
}
|
||||
.percent-label {
|
||||
color: #f60;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
}
|
||||
.stat-nums {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.stat-nums .item {
|
||||
color: #8a94a6;
|
||||
}
|
||||
.stat-nums b {
|
||||
font-size: 20px;
|
||||
margin: 0 2px;
|
||||
}
|
||||
.stat-nums .blue {
|
||||
color: #5c9edc;
|
||||
}
|
||||
.stat-nums .orange {
|
||||
color: #f60;
|
||||
}
|
||||
.stat-tags {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.divider {
|
||||
border-bottom: 1px dashed #e8e8e8;
|
||||
margin: 8px 0;
|
||||
}
|
||||
/* 填报字段声明 */
|
||||
.form-fields {
|
||||
margin: 6px 0 4px;
|
||||
padding: 10px 12px;
|
||||
background: #fafcff;
|
||||
border: 1px solid #e8eaec;
|
||||
border-radius: 4px;
|
||||
.ff-title {
|
||||
font-size: 13px;
|
||||
color: #515a6e;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
.ff-sub {
|
||||
margin-left: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: #808695;
|
||||
}
|
||||
}
|
||||
}
|
||||
.content {
|
||||
padding: 16px 0;
|
||||
line-height: 1.8;
|
||||
word-break: break-all;
|
||||
}
|
||||
.files {
|
||||
border-top: 1px dashed #e8e8e8;
|
||||
padding-top: 12px;
|
||||
}
|
||||
.files-title {
|
||||
font-size: 13px;
|
||||
color: #808695;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.down-all {
|
||||
margin-left: 12px;
|
||||
color: #2d8cf0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.file-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #515a6e;
|
||||
margin: 0 16px 8px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.file-item .file-type-icon {
|
||||
font-size: 16px;
|
||||
color: #515a6e;
|
||||
}
|
||||
.file-item .file-name {
|
||||
max-width: 320px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.file-item .file-size {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
.file-item .file-op {
|
||||
color: #2d8cf0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.bottom-btns {
|
||||
border-top: 1px dashed #e8e8e8;
|
||||
padding-top: 14px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
/* 阅读情况弹窗 */
|
||||
.read-tip {
|
||||
background: #e8f4ff;
|
||||
border: 1px solid #d3e9ff;
|
||||
color: #515a6e;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.read-list {
|
||||
max-height: 380px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.read-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.read-group-name {
|
||||
flex: none;
|
||||
width: 130px;
|
||||
text-align: right;
|
||||
color: #515a6e;
|
||||
font-size: 13px;
|
||||
line-height: 22px;
|
||||
}
|
||||
.read-users {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 14px;
|
||||
}
|
||||
.user-name {
|
||||
font-size: 13px;
|
||||
line-height: 22px;
|
||||
cursor: default;
|
||||
}
|
||||
.green {
|
||||
color: #19be6b;
|
||||
}
|
||||
.red {
|
||||
color: #ed4014;
|
||||
}
|
||||
.empty {
|
||||
color: #999;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,477 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useRequest } from "alova/client";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { noticeApi, dormApi } from "@/api/module/dorm";
|
||||
import type { NoticeVo, NoticeQuery, DormBuilding } from "@/types/dorm";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import Utils from "@/utils";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
/* 我发布的(/list 只看自己) / 全部通知(/noticelist) */
|
||||
const isManage = computed(() => route.path === "/noticelist");
|
||||
|
||||
/* 通知类型(与 mock / 后端字典同口径,「宿舍设置-通知类型」里可扩展) */
|
||||
const NOTICE_TYPES = ["作息调整", "宿舍调整", "卫生检查", "违规通报", "停水停电", "安全排查", "留宿申请", "家长探视", "夜间查寝", "物品清退", "疫情防控", "假期安排", "其他"];
|
||||
|
||||
/* 紧急程度 */
|
||||
const LEVELS = [
|
||||
{ v: "normal", label: "普通" },
|
||||
{ v: "important", label: "重要" },
|
||||
{ v: "urgent", label: "紧急" },
|
||||
];
|
||||
|
||||
/* ============ 状态标签页(通知流程) ============ */
|
||||
const STATUS_TABS = [
|
||||
{ name: "all", label: "全部" },
|
||||
{ name: "published", label: "已发布" },
|
||||
{ name: "closed", label: "已截止" },
|
||||
{ name: "finished", label: "已结束" },
|
||||
{ name: "draft", label: "草稿" },
|
||||
];
|
||||
const noticeStatus = ref("all");
|
||||
|
||||
/* ============ 搜索条件 ============ */
|
||||
const keyword = ref("");
|
||||
const noticeType = ref("");
|
||||
const level = ref("");
|
||||
const buildingId = ref<number | "">("");
|
||||
const startDt = ref("");
|
||||
const endDt = ref("");
|
||||
|
||||
/* 楼栋下拉(筛选「哪些楼栋被覆盖」) */
|
||||
const buildings = ref<DormBuilding[]>([]);
|
||||
async function loadBuildings() {
|
||||
try {
|
||||
buildings.value = (await dormApi.getBuildingList().send()) || [];
|
||||
} catch {
|
||||
/* 忽略:楼栋不可用时留空 */
|
||||
}
|
||||
}
|
||||
|
||||
/* ============ 分页 ============ */
|
||||
const pageNum = ref(1);
|
||||
const pageSize = ref(parseInt(localStorage.getItem("customPageCount") || "10"));
|
||||
|
||||
const { data: page, loading, send: reload } = useRequest((params: NoticeQuery) => noticeApi.sendList(params), {
|
||||
immediate: false,
|
||||
initialData: { content: [], total: 0, pageIndex: 1, pageSize: 10, totalPages: 0, hasNext: false },
|
||||
});
|
||||
|
||||
async function run() {
|
||||
const params: NoticeQuery = {
|
||||
keyword: keyword.value || "",
|
||||
noticeType: noticeType.value || "",
|
||||
level: level.value || "",
|
||||
noticeStatus: noticeStatus.value || "all",
|
||||
pageIndex: pageNum.value,
|
||||
pageSize: pageSize.value,
|
||||
orderByTime: "1",
|
||||
};
|
||||
if (buildingId.value !== "") params.buildingId = Number(buildingId.value);
|
||||
if (startDt.value) params.startTime = startDt.value;
|
||||
if (endDt.value) params.endTime = endDt.value;
|
||||
if (!isManage.value) params.scope = "user";
|
||||
try {
|
||||
await reload(params);
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "获取通知列表失败");
|
||||
}
|
||||
}
|
||||
function doSearch() {
|
||||
pageNum.value = 1;
|
||||
run();
|
||||
}
|
||||
function onTab(name: string) {
|
||||
noticeStatus.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();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadBuildings();
|
||||
run();
|
||||
nextTick(calcTableHeight);
|
||||
window.addEventListener("resize", calcTableHeight);
|
||||
});
|
||||
onBeforeUnmount(() => window.removeEventListener("resize", calcTableHeight));
|
||||
|
||||
/* 同一组件复用于 /list 与 /noticelist,切换路由时重置并重新加载 */
|
||||
watch(
|
||||
() => route.path,
|
||||
() => {
|
||||
pageNum.value = 1;
|
||||
keyword.value = "";
|
||||
noticeType.value = "";
|
||||
level.value = "";
|
||||
buildingId.value = "";
|
||||
noticeStatus.value = "all";
|
||||
run();
|
||||
}
|
||||
);
|
||||
|
||||
/* ============ 行操作 ============ */
|
||||
/** 查看某条通知的家长回执情况 */
|
||||
function viewReceipts(row: NoticeVo) {
|
||||
router.push({ path: "/receipts", query: { serid: row.id } });
|
||||
}
|
||||
/** 管理视角查看详情(不标读) */
|
||||
function viewDetail(row: NoticeVo) {
|
||||
const base = isManage.value ? "/noticelist/detail" : "/list/detail";
|
||||
router.push({ path: base, query: { serid: row.id, type: 0 } });
|
||||
}
|
||||
function edit(row: NoticeVo) {
|
||||
router.push({ path: "/list/new", query: { serid: row.id } });
|
||||
}
|
||||
/** 复用已有通知再发一条(预填表单) */
|
||||
function reuse(row: NoticeVo) {
|
||||
router.push({ path: "/list/new", query: { fid: row.id } });
|
||||
}
|
||||
function remind(row: NoticeVo) {
|
||||
MyConfirm({
|
||||
title: "催办回执",
|
||||
msg: `向“${row.title}”的未回执家长发送提醒?`,
|
||||
info: "提醒将以站内消息推送给尚未确认的家长(学校开通短信后同步下发短信)",
|
||||
async onOk() {
|
||||
try {
|
||||
const r = await noticeApi.remind(row.id).send();
|
||||
Message.success(`已提醒 ${(r && (r as any).reminded) ?? 0} 位未回执家长`);
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "催办失败");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
function toggleTop(row: NoticeVo) {
|
||||
const next = row.topmost ? 0 : 1;
|
||||
MyConfirm({
|
||||
title: next ? "置顶" : "取消置顶",
|
||||
msg: `确定${next ? "置顶" : "取消置顶"}“${row.title}”吗?`,
|
||||
async onOk() {
|
||||
try {
|
||||
await noticeApi.setTopmost(row.id, next).send();
|
||||
Message.success("操作成功!");
|
||||
run();
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "操作失败");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
function pull(row: NoticeVo) {
|
||||
MyConfirm({
|
||||
title: "撤回",
|
||||
msg: `确定撤回“${row.title}”吗?`,
|
||||
info: "撤回后家长端不再展示该通知,已提交的回执记录保留",
|
||||
async onOk() {
|
||||
try {
|
||||
await noticeApi.userPull(row.id).send();
|
||||
Message.success("已撤回");
|
||||
run();
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "撤回失败");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
function del(row: NoticeVo) {
|
||||
MyConfirm({
|
||||
title: "删除",
|
||||
msg: `确定删除“${row.title}”这条通知吗?`,
|
||||
info: "删除后该通知的家长回执记录一并清除,且不可恢复",
|
||||
async onOk() {
|
||||
try {
|
||||
await noticeApi.deleteNotice(String(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 opBtn(h: any, text: string, icon: string, onClick: () => void) {
|
||||
return Utils.operatingHandle(h, text, icon, onClick);
|
||||
}
|
||||
/**
|
||||
* 回执进度:进度条 + 已回/应回 · 回执率(单行)。
|
||||
* 「已读」「未回执」已还原为各自独立的列,本列只留进度本身。
|
||||
*/
|
||||
function progressCell(h: any, row: NoticeVo) {
|
||||
const g = row.progress;
|
||||
if (!g) {
|
||||
return h("span", { style: "color:#c5c8ce;font-size:13px;" }, "—");
|
||||
}
|
||||
const acted = g.confirmed + g.filled;
|
||||
return h("div", { style: "display:flex;align-items:center;gap:8px;white-space:nowrap;" }, [
|
||||
h("div", { style: "flex:1;min-width:48px;height:6px;background:#f0f2f5;border-radius:4px;overflow:hidden;" }, [
|
||||
h("div", {
|
||||
style: `width:${Math.min(100, g.receiptRate)}%;height:100%;background:linear-gradient(90deg,#2d8cf0,#5cadff);border-radius:4px;`,
|
||||
}),
|
||||
]),
|
||||
h("span", { style: "font-size:12px;color:#515a6e;white-space:nowrap;" }, `${acted}/${g.should} · ${g.receiptRate}%`),
|
||||
]);
|
||||
}
|
||||
/** 状态胶囊 */
|
||||
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,
|
||||
]);
|
||||
}
|
||||
/** 单元格内的小徽标(内联样式:render 函数产出的节点不吃 scoped 样式) */
|
||||
function miniTag(h: any, text: string, color: string, bg: string) {
|
||||
return h(
|
||||
"span",
|
||||
{
|
||||
style: `display:inline-block;padding:0 4px;margin-right:6px;font-size:11px;line-height:16px;color:${color};background:${bg};border-radius:3px;vertical-align:1px;`,
|
||||
},
|
||||
text
|
||||
);
|
||||
}
|
||||
/**
|
||||
* 标题:置顶 / 已撤回 徽标 + 可点击进详情。
|
||||
* 紧急程度已还原为独立列(LEVEL_HEX 着色),本列不再出彩标。
|
||||
*/
|
||||
function titleCell(h: any, row: NoticeVo) {
|
||||
const nodes: any[] = [];
|
||||
if (row.topmost) nodes.push(miniTag(h, "顶", "#fff", "#ed4014"));
|
||||
if (row.isPulled) nodes.push(miniTag(h, "已撤回", "#808695", "#f0f2f5"));
|
||||
nodes.push(
|
||||
h(
|
||||
"a",
|
||||
{
|
||||
style: "color:#2d8cf0;",
|
||||
title: row.title,
|
||||
onClick: () => viewDetail(row),
|
||||
},
|
||||
row.title
|
||||
)
|
||||
);
|
||||
return h("span", {}, nodes);
|
||||
}
|
||||
|
||||
const STATUS_HEX: Record<string, string> = { draft: "#c5c8ce", published: "#2d8cf0", closed: "#ff9900", finished: "#19be6b" };
|
||||
/** 紧急程度色(与 NoticeStats / NoticeDetails 保持一致) */
|
||||
const LEVEL_HEX: Record<string, string> = { normal: "#808695", important: "#ff9900", urgent: "#ed4014" };
|
||||
/** 紧急程度单元格 */
|
||||
function levelCell(h: any, row: NoticeVo) {
|
||||
return h("span", { style: `color:${LEVEL_HEX[row.level] || "#808695"};` }, row.levelDesc || "普通");
|
||||
}
|
||||
|
||||
/* ============ 表格列 ============ */
|
||||
/**
|
||||
* 列宽合计 ~1750px:宽屏(1440/1366)下也**超出可视区,保留横向滚动**,
|
||||
* 表头各列都有充足宽度、不再压缩换行;`fixed: "right"` 的操作列常驻右侧,
|
||||
* 横向滚动时始终可见(横向滚动条的样式见 App.vue 的 .ivu-table-overflowX 覆写)。
|
||||
*
|
||||
* 注意:不要再为了「收进一屏」合并列 —— 列一多列宽必然不够,
|
||||
* 合并只会把表头挤成一团。要横向空间就让表格横向滚动。
|
||||
*/
|
||||
const columns = [
|
||||
{ title: "序号", type: "index", width: 76, align: "center" },
|
||||
{ title: "通知标题", minWidth: 260, align: "left", render: (h: any, p: any) => titleCell(h, p.row) },
|
||||
{ title: "通知类型", key: "noticeType", width: 110, align: "center" },
|
||||
{ title: "紧急程度", width: 100, align: "center", render: (h: any, p: any) => levelCell(h, p.row) },
|
||||
{
|
||||
title: "生效 / 截止",
|
||||
width: 200,
|
||||
align: "center",
|
||||
render: (h: any, p: any) => {
|
||||
const r = p.row as NoticeVo;
|
||||
const s = r.effectiveTime ? String(r.effectiveTime).slice(5, 16) : "立即";
|
||||
const e = r.deadlineTime ? String(r.deadlineTime).slice(5, 16) : "不限";
|
||||
return h("span", { style: "white-space:nowrap;font-size:12px;" }, `${s} ~ ${e}`);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "接收宿舍",
|
||||
minWidth: 220,
|
||||
align: "left",
|
||||
render: (h: any, p: any) => {
|
||||
const r = p.row as NoticeVo;
|
||||
return h(
|
||||
"span",
|
||||
{
|
||||
title: r.receiveText || "",
|
||||
style: "display:inline-block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:middle;",
|
||||
},
|
||||
r.receiveText || (r.roomCount ? `${r.roomCount} 间宿舍` : "-")
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "回执方式",
|
||||
width: 110,
|
||||
align: "center",
|
||||
render: (h: any, p: any) => h("span", {}, (p.row as NoticeVo).receiptModeDesc || "仅通知"),
|
||||
},
|
||||
{
|
||||
title: "回执进度",
|
||||
width: 200,
|
||||
align: "left",
|
||||
render: (h: any, p: any) => progressCell(h, p.row as NoticeVo),
|
||||
},
|
||||
{
|
||||
title: "已读",
|
||||
width: 90,
|
||||
align: "center",
|
||||
render: (h: any, p: any) => {
|
||||
const r = p.row as NoticeVo;
|
||||
return h("span", {}, `${r.readCount ?? 0}/${r.allowCount ?? 0}`);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "未回执",
|
||||
width: 90,
|
||||
align: "center",
|
||||
render: (h: any, p: any) => {
|
||||
const r = p.row as NoticeVo;
|
||||
const n = r.progress?.unsigned ?? 0;
|
||||
return h("span", { style: n > 0 ? "color:#ed4014;font-weight:600;" : "" }, n as any);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
width: 94,
|
||||
align: "left",
|
||||
render: (h: any, p: any) => {
|
||||
const st = (p.row as NoticeVo).noticeStatus || "published";
|
||||
return statusPill(h, STATUS_HEX[st], (p.row as NoticeVo).noticeStatusDesc || "已发布");
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
width: 260,
|
||||
align: "center",
|
||||
fixed: "right",
|
||||
render: (h: any, p: any) => {
|
||||
const row = p.row as NoticeVo;
|
||||
const nodes: any[] = [];
|
||||
if (row.noticeStatus !== "draft") nodes.push(opBtn(h, "回执情况", "icon-chakan", () => viewReceipts(row)));
|
||||
if (row.noticeStatus === "draft") {
|
||||
nodes.push(opBtn(h, "编辑", "icon-edit", () => edit(row)));
|
||||
} else {
|
||||
nodes.push(opBtn(h, "复用再发", "icon-fasong", () => reuse(row)));
|
||||
}
|
||||
if (row.noticeStatus === "published" && row.progress && row.progress.unsigned > 0) {
|
||||
nodes.push(opBtn(h, "催办", "icon-saoba", () => remind(row)));
|
||||
}
|
||||
nodes.push(opBtn(h, row.topmost ? "取消置顶" : "置顶", "icon-tianjia", () => toggleTop(row)));
|
||||
if (row.noticeStatus !== "draft" && !row.isPulled) nodes.push(opBtn(h, "撤回", "icon-stop", () => pull(row)));
|
||||
nodes.push(opBtn(h, "删除", "icon-ashbin", () => del(row)));
|
||||
return nodes;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/* ============ 表格高度:按真实 DOM 量,不再用常量估算 ============ */
|
||||
/** 表格外层容器(量它的顶边位置) */
|
||||
const tableWrap = ref<HTMLElement | null>(null);
|
||||
const tableHeight = ref(400);
|
||||
|
||||
/**
|
||||
* 表格高度 = 分页条顶边 − 表格顶边 − 12px 呼吸位(列宽超出容器时再扣掉横向滚动条约 15px)。
|
||||
*
|
||||
* 原来用 `Utils.useTableHeight({isSearch,isOperat,isPage})` 的常量估算,漏算了 Tabs 栏
|
||||
* 与容器内边距(约 78px):表格比可用空间高,而 `.page-panel` 是 absolute 定位在底部,
|
||||
* 于是表格压到分页上。
|
||||
* 这里直接量 DOM —— 加减筛选条件、改标签页高度都不会再失配;窗口 resize 时重算。
|
||||
*/
|
||||
function calcTableHeight() {
|
||||
const wrap = tableWrap.value;
|
||||
if (!wrap) return;
|
||||
const rect = wrap.getBoundingClientRect();
|
||||
const panel = wrap.parentElement?.querySelector(".page-panel") as HTMLElement | null;
|
||||
const limit = panel ? panel.getBoundingClientRect().top : window.innerHeight;
|
||||
// 列宽合计超过容器宽度 → 会出现横向滚动条,它会吃掉表体 ~15px 高度,一并预留,
|
||||
// 否则最后一行的分数会被滚动条切掉
|
||||
const colsWidth = (columns as any[]).reduce((sum, c) => sum + (c.width || c.minWidth || 100), 0);
|
||||
const hBar = colsWidth > wrap.clientWidth ? 15 : 0;
|
||||
tableHeight.value = Math.max(240, Math.floor(limit - rect.top - 12 - hBar));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-wrapper">
|
||||
<div class="main-container">
|
||||
<!-- 1. 操作 + 状态标签 -->
|
||||
<div class="fix mb-12">
|
||||
<Button class="fl" type="primary" size="large" icon="md-add" @click="router.push('/list/new')">发布通知</Button>
|
||||
</div>
|
||||
<Tabs v-model="noticeStatus" @on-click="onTab" class="mb-12">
|
||||
<TabPane v-for="t in STATUS_TABS" :key="t.name" :name="t.name" :label="t.label"></TabPane>
|
||||
</Tabs>
|
||||
|
||||
<!-- 2. 搜索区 -->
|
||||
<div class="search-wrap fix">
|
||||
<div class="search-wrap_date fl">
|
||||
<DatePicker type="date" placeholder="发布时间起" v-model="startDt" @on-change="doSearch"></DatePicker>
|
||||
<span class="ml-8 mr-8">至</span>
|
||||
<DatePicker type="date" placeholder="发布时间止" v-model="endDt" @on-change="doSearch"></DatePicker>
|
||||
</div>
|
||||
<Select v-model="noticeType" class="search-wrap_sel ml-12" clearable placeholder="通知类型" @on-change="doSearch">
|
||||
<Option v-for="c in NOTICE_TYPES" :key="c" :value="c">{{ c }}</Option>
|
||||
</Select>
|
||||
<Select v-model="level" class="search-wrap_sel ml-12" clearable placeholder="紧急程度" @on-change="doSearch">
|
||||
<Option v-for="l in LEVELS" :key="l.v" :value="l.v">{{ l.label }}</Option>
|
||||
</Select>
|
||||
<Select v-model="buildingId" class="search-wrap_sel ml-12" clearable placeholder="宿舍楼栋" @on-change="doSearch">
|
||||
<Option v-for="b in buildings" :key="b.buildingId" :value="b.buildingId">{{ b.buildingName }}</Option>
|
||||
</Select>
|
||||
<Input
|
||||
class="fr"
|
||||
style="width: 220px"
|
||||
search
|
||||
suffix="ios-search"
|
||||
placeholder="搜索标题 / 类型 / 发布人"
|
||||
v-model="keyword"
|
||||
@on-search="doSearch"
|
||||
@on-clear="doSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 3. 表格 + 分页 -->
|
||||
<div ref="tableWrap">
|
||||
<Table
|
||||
:columns="columns"
|
||||
:data="page.content"
|
||||
class="link-table table-bottom-box"
|
||||
:height="tableHeight"
|
||||
:loading="loading"
|
||||
no-data-text="暂无数据"
|
||||
></Table>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,322 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { noticeApi } from "@/api/module/dorm";
|
||||
import type { NoticeStatisticsVo } from "@/types/dorm";
|
||||
|
||||
const stat = ref<NoticeStatisticsVo | null>(null);
|
||||
const loading = ref(false);
|
||||
|
||||
/* 统计区间(默认不限,可切换近 1 / 3 / 6 个月) */
|
||||
const range = ref("all");
|
||||
const RANGES = [
|
||||
{ v: "all", label: "全部" },
|
||||
{ v: "m1", label: "近 1 个月" },
|
||||
{ v: "m3", label: "近 3 个月" },
|
||||
{ v: "m6", label: "近 6 个月" },
|
||||
];
|
||||
|
||||
function fmt(d: Date) {
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
function rangeParams() {
|
||||
if (range.value === "all") return {};
|
||||
const months = Number(range.value.slice(1));
|
||||
const end = new Date();
|
||||
const start = new Date();
|
||||
start.setMonth(start.getMonth() - months);
|
||||
return { startTime: fmt(start), endTime: fmt(end) };
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
stat.value = await noticeApi.statistics(rangeParams()).send();
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "加载统计失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
onMounted(load);
|
||||
|
||||
/* 类型分布条 */
|
||||
const maxDist = computed(() => Math.max(1, ...(stat.value?.byType || []).map(d => d.count)));
|
||||
const TYPE_HEX = ["#2d8cf0", "#19be6b", "#ff9900", "#ed4014", "#8e44ad", "#00c1d4", "#515a6e", "#ff6b81", "#7f8c8d", "#e67e22", "#16a085", "#c0392b", "#2980b9"];
|
||||
const typeColor = (i: number) => TYPE_HEX[i % TYPE_HEX.length];
|
||||
|
||||
/* 月度趋势条 */
|
||||
const maxMonth = computed(() => Math.max(1, ...(stat.value?.byMonth || []).map(m => m.count)));
|
||||
/* 楼栋覆盖:通知数 / 覆盖床位 */
|
||||
const maxBldNotice = computed(() => Math.max(1, ...(stat.value?.byBuilding || []).map(b => b.noticeCount)));
|
||||
|
||||
const cards = computed(() => [
|
||||
{ label: "通知总数", value: stat.value?.total ?? 0 },
|
||||
{ label: "已发布", value: stat.value?.publishedCount ?? 0 },
|
||||
{ label: "我发布的", value: stat.value?.myNoticeCount ?? 0 },
|
||||
{ label: "重要及紧急", value: stat.value?.urgentCount ?? 0 },
|
||||
{ label: "累计回执人次", value: stat.value?.totalReceipts ?? 0 },
|
||||
{ label: "平均回执率", value: (stat.value?.avgReceiptRate ?? 0) + "%" },
|
||||
{ label: "覆盖住宿生人次", value: stat.value?.totalCovered ?? 0 },
|
||||
{ label: "待处理回执", value: stat.value?.pendingReview ?? 0 },
|
||||
]);
|
||||
|
||||
const NOTICE_STATUS_TEXT: Record<string, string> = { draft: "草稿", published: "已发布", closed: "已截止", finished: "已结束" };
|
||||
const LEVEL_HEX: Record<string, string> = { normal: "#808695", important: "#ff9900", urgent: "#ed4014" };
|
||||
|
||||
const tableColumns = [
|
||||
{ title: "通知", key: "title", minWidth: 260, tooltip: true },
|
||||
{ title: "通知类型", key: "noticeType", width: 110, align: "center" },
|
||||
{
|
||||
title: "紧急程度",
|
||||
width: 100,
|
||||
align: "center",
|
||||
render: (h: any, p: any) => h("span", { style: `color:${LEVEL_HEX[p.row.level] || "#808695"};` }, p.row.levelDesc),
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
width: 90,
|
||||
align: "center",
|
||||
render: (h: any, p: any) => h("span", {}, NOTICE_STATUS_TEXT[p.row.noticeStatus] || p.row.noticeStatus),
|
||||
},
|
||||
{ title: "应收", key: "should", width: 80, align: "center" },
|
||||
{
|
||||
title: "已回执",
|
||||
width: 84,
|
||||
align: "center",
|
||||
render: (h: any, p: any) => h("span", { style: "color:#19be6b;font-weight:600;" }, p.row.confirmed + p.row.filled),
|
||||
},
|
||||
{
|
||||
title: "未回执",
|
||||
width: 84,
|
||||
align: "center",
|
||||
render: (h: any, p: any) => h("span", { style: p.row.unsigned > 0 ? "color:#ed4014;font-weight:600;" : "" }, p.row.unsigned),
|
||||
},
|
||||
{
|
||||
title: "回执率",
|
||||
width: 140,
|
||||
align: "center",
|
||||
render: (h: any, p: any) =>
|
||||
h("div", { style: "display:flex;align-items:center;gap:8px;" }, [
|
||||
h("div", { style: "flex:1;height:6px;background:#f0f2f5;border-radius:4px;overflow:hidden;" }, [
|
||||
h("div", {
|
||||
style: `width:${Math.min(100, p.row.receiptRate)}%;height:100%;background:linear-gradient(90deg,#2d8cf0,#5cadff);border-radius:4px;`,
|
||||
}),
|
||||
]),
|
||||
h("span", { style: "font-size:12px;color:#515a6e;white-space:nowrap;" }, p.row.receiptRate + "%"),
|
||||
]),
|
||||
},
|
||||
{
|
||||
title: "覆盖宿舍 / 床位",
|
||||
width: 140,
|
||||
align: "center",
|
||||
render: (h: any, p: any) => h("span", {}, `${p.row.rooms} 间 / ${p.row.beds} 床`),
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-wrapper">
|
||||
<div class="main-container" v-if="stat">
|
||||
<Spin v-if="loading" fix />
|
||||
|
||||
<!-- 区间切换 -->
|
||||
<div class="fix mb-12">
|
||||
<span class="range-label fl">统计区间:</span>
|
||||
<RadioGroup v-model="range" class="fl" type="button" @on-change="load">
|
||||
<Radio v-for="r in RANGES" :key="r.v" :label="r.v">{{ r.label }}</Radio>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<!-- 总览卡 -->
|
||||
<Row :gutter="16" class="mb-16">
|
||||
<Col v-for="(c, i) in cards" :key="i" :span="6">
|
||||
<div class="ov-card">
|
||||
<div class="ov-v">{{ c.value }}</div>
|
||||
<div class="ov-l">{{ c.label }}</div>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row :gutter="16" class="mb-16">
|
||||
<!-- 通知类型分布 -->
|
||||
<Col :span="12">
|
||||
<div class="block">
|
||||
<div class="block-h">通知类型分布</div>
|
||||
<div class="dist">
|
||||
<div class="dist-row" v-for="(d, i) in stat.byType" :key="d.key">
|
||||
<div class="dist-label">{{ d.label }}</div>
|
||||
<div class="dist-track">
|
||||
<div class="dist-bar" :style="{ width: (d.count / maxDist) * 100 + '%', background: typeColor(i) }"></div>
|
||||
</div>
|
||||
<div class="dist-num">{{ d.count }}</div>
|
||||
</div>
|
||||
<p v-if="!stat.byType.length" class="empty-tip">暂无数据</p>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
<!-- 月度发布趋势 -->
|
||||
<Col :span="12">
|
||||
<div class="block">
|
||||
<div class="block-h">月度发布数量</div>
|
||||
<div class="dist">
|
||||
<div class="dist-row" v-for="m in stat.byMonth" :key="m.year + '-' + m.month">
|
||||
<div class="dist-label">{{ m.year }} 年 {{ m.month }} 月</div>
|
||||
<div class="dist-track">
|
||||
<div class="dist-bar" :style="{ width: (m.count / maxMonth) * 100 + '%', background: '#2d8cf0' }"></div>
|
||||
</div>
|
||||
<div class="dist-num">{{ m.count }}</div>
|
||||
</div>
|
||||
<p v-if="!stat.byMonth.length" class="empty-tip">暂无数据</p>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row :gutter="16" class="mb-16">
|
||||
<!-- 楼栋覆盖 -->
|
||||
<Col :span="12">
|
||||
<div class="block">
|
||||
<div class="block-h">按宿舍楼栋覆盖</div>
|
||||
<div class="dist">
|
||||
<div class="dist-row" v-for="b in stat.byBuilding" :key="b.buildingName">
|
||||
<div class="dist-label">{{ b.buildingName }}</div>
|
||||
<div class="dist-track">
|
||||
<div class="dist-bar" :style="{ width: (b.noticeCount / maxBldNotice) * 100 + '%', background: '#19be6b' }"></div>
|
||||
</div>
|
||||
<div class="dist-num">{{ b.noticeCount }}</div>
|
||||
<div class="dist-extra">覆盖 {{ b.beds }} 床</div>
|
||||
</div>
|
||||
<p v-if="!stat.byBuilding.length" class="empty-tip">暂无数据</p>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
<!-- 发布人分布 -->
|
||||
<Col :span="12">
|
||||
<div class="block">
|
||||
<div class="block-h">按发布人统计</div>
|
||||
<div class="pub-list">
|
||||
<div v-for="p in stat.byPublisher" :key="p.publisherName" class="pub-item">
|
||||
<span class="pub-name">{{ p.publisherName }}</span>
|
||||
<span class="pub-count">{{ p.count }} 条</span>
|
||||
</div>
|
||||
<p v-if="!stat.byPublisher.length" class="empty-tip">暂无数据</p>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<!-- 我发布的通知 · 回执与已读 -->
|
||||
<div class="block">
|
||||
<div class="block-h">我发布的通知 · 家长回执情况</div>
|
||||
<Table :columns="tableColumns" :data="stat.byNotice || []" no-data-text="暂无需要回执统计的通知"></Table>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="main-container"><Spin fix /></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mb-16 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.range-label {
|
||||
color: #515a6e;
|
||||
line-height: 32px;
|
||||
}
|
||||
.ov-card {
|
||||
background: #fafcff;
|
||||
border: 1px solid #e8eaec;
|
||||
border-radius: 6px;
|
||||
padding: 18px 16px;
|
||||
text-align: center;
|
||||
.ov-v {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
color: #17233d;
|
||||
}
|
||||
.ov-l {
|
||||
margin-top: 4px;
|
||||
color: #808695;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
.block {
|
||||
border: 1px solid #e8eaec;
|
||||
border-radius: 6px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 16px;
|
||||
background: #fff;
|
||||
height: 100%;
|
||||
.block-h {
|
||||
font-weight: 600;
|
||||
color: #17233d;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
.dist {
|
||||
.dist-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 10px 0;
|
||||
.dist-label {
|
||||
width: 132px;
|
||||
color: #515a6e;
|
||||
font-size: 13px;
|
||||
text-align: right;
|
||||
flex: none;
|
||||
}
|
||||
.dist-track {
|
||||
flex: 1;
|
||||
height: 16px;
|
||||
background: #f0f2f5;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
.dist-bar {
|
||||
height: 100%;
|
||||
border-radius: 8px;
|
||||
min-width: 2px;
|
||||
}
|
||||
}
|
||||
.dist-num {
|
||||
width: 36px;
|
||||
color: #17233d;
|
||||
font-weight: 600;
|
||||
}
|
||||
.dist-extra {
|
||||
width: 92px;
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.pub-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
.pub-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #f7f9fc;
|
||||
border: 1px solid #e8eaec;
|
||||
border-radius: 4px;
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
.pub-name {
|
||||
color: #17233d;
|
||||
}
|
||||
.pub-count {
|
||||
color: #2d8cf0;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
.empty-tip {
|
||||
color: #c5c8ce;
|
||||
font-size: 13px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,916 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, watch, nextTick } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { noticeApi } from "@/api/module/dorm";
|
||||
import type { ParentNoticeVo, MyNoticeVo, MyNoticesVo, MyReceiptInfo, FillField } from "@/types/dorm";
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
/* 当前标签页:全部 / 待回执 / 已回执 / 已结束(/par/mine 默认落「已回执」) */
|
||||
const routeTab = () => String((route.meta as any)?.initTab || "all");
|
||||
const tab = ref(routeTab());
|
||||
|
||||
const list = ref<ParentNoticeVo[]>([]);
|
||||
const buckets = ref<MyNoticesVo["buckets"]>({ todo: 0, done: 0, finished: 0, all: 0 });
|
||||
const loading = ref(false);
|
||||
const keyword = ref("");
|
||||
const selected = ref<number>(0);
|
||||
|
||||
const TABS = computed(() => [
|
||||
{ name: "all", label: `全部(${buckets.value.all})` },
|
||||
{ name: "todo", label: `待回执(${buckets.value.todo})` },
|
||||
{ name: "done", label: `已回执(${buckets.value.done})` },
|
||||
{ name: "finished", label: `已结束(${buckets.value.finished})` },
|
||||
]);
|
||||
|
||||
/** 只刷新左侧列表与分桶计数,不动右侧当前选中项(供自动确认后回填使用) */
|
||||
async function refreshList() {
|
||||
const r = await noticeApi.myNotices({ pageIndex: 1, pageSize: 100 }).send();
|
||||
list.value = r.list || [];
|
||||
buckets.value = r.buckets || { todo: 0, done: 0, finished: 0, all: 0 };
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
await refreshList();
|
||||
// 默认选中第一条(进入页面即展示最新通知)
|
||||
const first = filtered.value[0];
|
||||
if (first && !selected.value) {
|
||||
await open(first);
|
||||
} else if (!filtered.value.length) {
|
||||
detail.value = null;
|
||||
selected.value = 0;
|
||||
}
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "加载宿舍通知失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* 标签页 + 关键字过滤(数据量小,前端过滤即可,避免多余请求) */
|
||||
const filtered = computed(() => {
|
||||
const kw = keyword.value.trim();
|
||||
return list.value.filter(a => {
|
||||
if (kw && !(a.title.includes(kw) || (a.noticeType || "").includes(kw) || (a.deptName || "").includes(kw) || (a.roomNo || "").includes(kw))) return false;
|
||||
const acted = !!a.my && (a.my.status === "confirmed" || a.my.status === "filled");
|
||||
const pending = !a.viewOnly && !acted && a.canReceipt;
|
||||
if (tab.value === "todo") return pending;
|
||||
if (tab.value === "done") return acted;
|
||||
if (tab.value === "finished") return a.noticeStatus === "finished" || a.noticeStatus === "closed";
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
/** 标签页内容为空时清空右侧详情,避免「列表没有但详情还在」的错位 */
|
||||
watch(tab, () => {
|
||||
const first = filtered.value[0];
|
||||
if (first) {
|
||||
open(first);
|
||||
} else {
|
||||
detail.value = null;
|
||||
selected.value = 0;
|
||||
}
|
||||
});
|
||||
watch(keyword, () => {
|
||||
const first = filtered.value[0];
|
||||
if (first) open(first);
|
||||
else {
|
||||
detail.value = null;
|
||||
selected.value = 0;
|
||||
}
|
||||
});
|
||||
|
||||
/* ============ 右侧详情 ============ */
|
||||
const detail = ref<MyNoticeVo | null>(null);
|
||||
const detailLoading = ref(false);
|
||||
|
||||
/** 是否已有有效回执(已确认 / 已填报);「已取消」不算 */
|
||||
const hasReceipt = computed(() => {
|
||||
const st = detail.value?.my?.status;
|
||||
return st === "confirmed" || st === "filled";
|
||||
});
|
||||
|
||||
function nowStr() {
|
||||
const d = new Date();
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 「需确认」类通知:家长端**不需要点「已知悉」**——打开通知详情即视为已确认,自动提交回执。
|
||||
* 仅在「已发布 + 可回执 + 尚未回执」时触发;提交后只刷新左侧列表与计数,不重新拉详情,避免自我循环。
|
||||
*/
|
||||
const autoConfirming = ref(false);
|
||||
async function autoConfirmIfNeeded() {
|
||||
const d = detail.value;
|
||||
const a = d?.notice;
|
||||
if (!d || !a) return;
|
||||
if (a.receiptMode !== "confirm" || a.noticeStatus !== "published") return;
|
||||
const st = d.my?.status;
|
||||
if (st && st !== "none" && st !== "canceled") return;
|
||||
if (autoConfirming.value) return;
|
||||
autoConfirming.value = true;
|
||||
try {
|
||||
await submitConfirm(a.id, false);
|
||||
} catch {
|
||||
/* 自动确认失败则保留手动兜底按钮,不打扰家长 */
|
||||
} finally {
|
||||
autoConfirming.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 提交确认回执;withToast = true 时给出手动兜底的提示 */
|
||||
async function submitConfirm(serid: number, withToast: boolean) {
|
||||
await noticeApi.submitReceipt({ serid }).send();
|
||||
const d = detail.value;
|
||||
if (d && d.notice.id === serid) {
|
||||
d.my = {
|
||||
receiptId: d.my?.receiptId || 0,
|
||||
status: "confirmed",
|
||||
statusDesc: "已确认",
|
||||
fillData: d.my?.fillData || null,
|
||||
guardianName: d.my?.guardianName || "",
|
||||
guardianPhone: d.my?.guardianPhone || "",
|
||||
relation: d.my?.relation || "",
|
||||
remark: d.my?.remark || "",
|
||||
submittedAt: nowStr(),
|
||||
} as MyReceiptInfo;
|
||||
}
|
||||
if (withToast) Message.success("已确认知悉");
|
||||
await refreshList();
|
||||
}
|
||||
|
||||
async function open(item: ParentNoticeVo) {
|
||||
selected.value = item.id;
|
||||
detailLoading.value = true;
|
||||
try {
|
||||
detail.value = await noticeApi.myNotice(item.id).send();
|
||||
// 需确认类:打开即自动确认
|
||||
await autoConfirmIfNeeded();
|
||||
// 需填报类:回填上一次提交的内容,便于修改
|
||||
await nextTick();
|
||||
prefillFillForm();
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "加载通知失败");
|
||||
detail.value = null;
|
||||
} finally {
|
||||
detailLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============ 需填报:动态单据表单 ============ */
|
||||
/**
|
||||
* 表单模型:**动态字段必须与固定字段挂在同一层**。
|
||||
*
|
||||
* 踩坑记录:早先把动态字段值放在 `fillModal.data[key]`,而 FormItem 写的是 `:prop="f.key"`,
|
||||
* 于是 FormItem 取值走 `getPropByPath(model, 'conclusion')` → `fillModal.conclusion`
|
||||
* ——恒为 undefined,required 校验永远不通过:**家长明明选了值,红字也不消失、表单交不上去**。
|
||||
* 规则:Form 的 `:model` 路径与 FormItem 的 `:prop` 必须落在同一层,本文件不再用 data 中间层。
|
||||
*/
|
||||
interface FillModalState {
|
||||
open: boolean;
|
||||
serid: number;
|
||||
title: string;
|
||||
submitting: boolean;
|
||||
guardianName: string;
|
||||
guardianPhone: string;
|
||||
remark: string;
|
||||
/** 单据动态字段(字段键 → 值),与 FormItem 的 prop 同层 */
|
||||
[key: string]: any;
|
||||
}
|
||||
/** 模型自带的固定键:重填时只清动态键,不动这些 */
|
||||
const FILL_MODAL_BASE_KEYS = ["open", "serid", "title", "submitting", "guardianName", "guardianPhone", "remark"];
|
||||
const fillModal = reactive<FillModalState>({
|
||||
open: false,
|
||||
serid: 0,
|
||||
title: "",
|
||||
submitting: false,
|
||||
guardianName: "",
|
||||
guardianPhone: "",
|
||||
remark: "",
|
||||
});
|
||||
const fillFormRef = ref();
|
||||
/** 统一回执单的完整字段清单(公共区 + 专属区,顺序由后端保证) */
|
||||
const curFields = computed<FillField[]>(() => detail.value?.formFields || []);
|
||||
/** 公共区字段:所有单据完全一致(当前为「回执结论」) */
|
||||
const commonFields = computed<FillField[]>(() => curFields.value.filter(f => f.group === "common"));
|
||||
/** 专属区字段:随单据类型带出,如留宿起止、到访时段、搬迁日期 */
|
||||
const specificFields = computed<FillField[]>(() => curFields.value.filter(f => f.group !== "common"));
|
||||
/**
|
||||
* 弹窗渲染序列:公共区 +(有专属字段时)专属区。
|
||||
* 抽成序列是为了让字段控件在模板里只写一遍,避免两段各复制一套 v-if 分支。
|
||||
*/
|
||||
const fillSections = computed(() => {
|
||||
const sections: { key: string; title: string; note: string; fields: FillField[] }[] = [
|
||||
{ key: "common", title: "公共区 · 所有单据一致", note: "", fields: commonFields.value },
|
||||
];
|
||||
if (specificFields.value.length) {
|
||||
sections.push({ key: "specific", title: "专属区", note: detail.value?.formTypeDesc || "", fields: specificFields.value });
|
||||
}
|
||||
return sections;
|
||||
});
|
||||
|
||||
/** 清掉上一张单据残留的动态字段键(不同单据的专属区字段不同,留着会串数据) */
|
||||
function resetFillKeys() {
|
||||
Object.keys(fillModal).forEach(k => {
|
||||
if (!FILL_MODAL_BASE_KEYS.includes(k)) delete fillModal[k];
|
||||
});
|
||||
}
|
||||
|
||||
/** 把已提交的 fillData 回填到表单(未提交过则留空) */
|
||||
function prefillFillForm() {
|
||||
const my = detail.value?.my;
|
||||
resetFillKeys();
|
||||
syncFillRules();
|
||||
curFields.value.forEach(f => {
|
||||
const v = my?.fillData?.[f.key];
|
||||
fillModal[f.key] = f.type === "number" && v !== undefined && v !== "" ? Number(v) : v ?? "";
|
||||
});
|
||||
fillModal.guardianName = my?.guardianName || "";
|
||||
fillModal.guardianPhone = my?.guardianPhone || "";
|
||||
fillModal.remark = my?.remark || "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验规则:**常量对象 + 原地增删键**,不要写成 computed。
|
||||
*
|
||||
* view-ui-plus 的 form.vue 里有 `watch: { rules () { this.validate() } }`——
|
||||
* rules 的对象**引用**一变就全量重校验一遍。写成 computed 时,detail/字段清单每次变化
|
||||
* 都会生成新对象,于是「刚打开弹窗、还没填,所有必填项一起爆红」,且与 change 事件里的
|
||||
* 单字段校验互相打架(单字段刚校验通过、全量校验又把它标红)。
|
||||
* 原地改键不会改引用,Form 不会触发全量重校验。
|
||||
*
|
||||
* 另两条硬约束:Form 必须绑 :model 且 FormItem 必须有 prop(否则字段名解析成 undefined,
|
||||
* 填了值也照样报错);数字字段要显式 type:"number"(async-validator 默认按 string 校验)。
|
||||
*/
|
||||
const fillRules: Record<string, any[]> = {
|
||||
guardianName: [{ required: true, message: "请输入家长姓名", trigger: "blur" }],
|
||||
guardianPhone: [
|
||||
{ required: true, message: "请输入联系电话", trigger: "blur" },
|
||||
{ pattern: /^1\d{10}$/, message: "请输入 11 位手机号", trigger: "blur" },
|
||||
],
|
||||
};
|
||||
/** 选择类控件(Select / DatePicker / InputNumber)派发的是 change,其余按 blur */
|
||||
const CHANGE_TRIGGER_TYPES = ["select", "date", "datetime", "number"];
|
||||
/** 按当前单据的字段清单原地同步规则(保持对象引用不变) */
|
||||
function syncFillRules() {
|
||||
Object.keys(fillRules).forEach(k => {
|
||||
if (k !== "guardianName" && k !== "guardianPhone") delete fillRules[k];
|
||||
});
|
||||
curFields.value.forEach(f => {
|
||||
const arr: any[] = [];
|
||||
if (f.required) {
|
||||
const trigger = CHANGE_TRIGGER_TYPES.includes(f.type) ? "change" : "blur";
|
||||
arr.push(
|
||||
f.type === "number"
|
||||
? { required: true, type: "number", message: `请填写${f.label}`, trigger }
|
||||
: { required: true, message: `请填写${f.label}`, trigger }
|
||||
);
|
||||
}
|
||||
fillRules[f.key] = arr;
|
||||
});
|
||||
}
|
||||
|
||||
function openFill() {
|
||||
const a = detail.value?.notice;
|
||||
if (!a) return;
|
||||
prefillFillForm();
|
||||
fillModal.serid = a.id;
|
||||
fillModal.title = a.title;
|
||||
fillModal.open = true;
|
||||
// 清掉上一次打开残留的校验状态,避免再打开时红字还在
|
||||
nextTick(() => {
|
||||
const f: any = fillFormRef.value;
|
||||
if (f?.fields) f.fields.forEach((x: any) => { x.validateState = ""; x.validateMessage = ""; });
|
||||
});
|
||||
}
|
||||
|
||||
async function doFill() {
|
||||
// view-ui-plus 的 Form.validate() 是 resolve(valid),校验失败不会 reject,
|
||||
// 所以必须取 resolve 出来的布尔值;用 .then(() => true) 会恒真、直接放行空表单
|
||||
const ok = (await fillFormRef.value?.validate?.().catch(() => false)) === true;
|
||||
if (!ok) {
|
||||
Message.info("请填写带 * 的必填项");
|
||||
return;
|
||||
}
|
||||
fillModal.submitting = true;
|
||||
try {
|
||||
// 数值字段统一转字符串提交(后端 fillData 为 Map<String,String>)
|
||||
const fillData: Record<string, string> = {};
|
||||
curFields.value.forEach(f => {
|
||||
const v = fillModal[f.key];
|
||||
if (v !== undefined && v !== null && String(v) !== "") fillData[f.key] = String(v);
|
||||
});
|
||||
await noticeApi
|
||||
.submitReceipt({
|
||||
serid: fillModal.serid,
|
||||
fillData,
|
||||
guardianName: fillModal.guardianName.trim(),
|
||||
guardianPhone: fillModal.guardianPhone.trim(),
|
||||
remark: fillModal.remark,
|
||||
})
|
||||
.send();
|
||||
Message.success("回执已提交,感谢配合!");
|
||||
fillModal.open = false;
|
||||
const cur = fillModal.serid;
|
||||
await load();
|
||||
const item = list.value.find(a => a.id === cur);
|
||||
if (item) await open(item);
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "提交失败");
|
||||
} finally {
|
||||
fillModal.submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function doCancel() {
|
||||
const a = detail.value?.notice;
|
||||
if (!a) return;
|
||||
try {
|
||||
await noticeApi.cancelReceipt(a.id).send();
|
||||
Message.success("已取消回执");
|
||||
await load();
|
||||
const item = list.value.find(x => x.id === a.id);
|
||||
if (item) await open(item);
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "取消失败");
|
||||
}
|
||||
}
|
||||
|
||||
/* ============ 附件 ============ */
|
||||
function downFile(id: number) {
|
||||
window.location.href = noticeApi.fileDownUrl(id);
|
||||
}
|
||||
function displayName(item: { name?: string; filename?: string }) {
|
||||
return item.name || item.filename || "";
|
||||
}
|
||||
function getFileIcon(name: string) {
|
||||
const suffix = String(name || "").split(".").pop()?.toLowerCase() || "";
|
||||
if (["doc", "docx", "wps"].includes(suffix)) return "icon-word";
|
||||
if (["xls", "xlsx"].includes(suffix)) return "icon-excel";
|
||||
if (["ppt", "pptx"].includes(suffix)) return "icon-ppt";
|
||||
if (["pdf"].includes(suffix)) return "icon-pdf1";
|
||||
if (["txt"].includes(suffix)) return "icon-txt";
|
||||
if (["jpg", "jpeg", "png", "gif", "bmp"].includes(suffix)) return "icon-tupian1";
|
||||
if (["zip", "rar"].includes(suffix)) return "icon-yasuo";
|
||||
return "icon-weizhi";
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
|
||||
function shortDate(v?: string | null, fallback = "不限") {
|
||||
if (!v) return fallback;
|
||||
return String(v).slice(0, 16);
|
||||
}
|
||||
/** 列表时间显示 MM/dd */
|
||||
function listDate(v?: string | null) {
|
||||
const d = new Date(String(v || "").replace(/-/g, "/"));
|
||||
if (isNaN(d.getTime())) return "—";
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${pad(d.getMonth() + 1)}/${pad(d.getDate())}`;
|
||||
}
|
||||
/** 回执状态标签色 */
|
||||
function statusTagType(a: ParentNoticeVo): string {
|
||||
const m = a.my;
|
||||
if (!m || m.status === "none") return a.canReceipt ? "warning" : "default";
|
||||
if (m.status === "filled") return "primary";
|
||||
if (m.status === "confirmed") return "success";
|
||||
if (m.status === "canceled") return "error";
|
||||
return "default";
|
||||
}
|
||||
function statusText(a: ParentNoticeVo): string {
|
||||
const m = a.my;
|
||||
if (m && m.status !== "none") return m.statusDesc;
|
||||
if (a.viewOnly) return "仅通知";
|
||||
return a.canReceipt ? "待回执" : a.noticeStatusDesc;
|
||||
}
|
||||
/** 紧急程度色 */
|
||||
function levelHex(l: string) {
|
||||
return l === "urgent" ? "#ed4014" : l === "important" ? "#ff9900" : "";
|
||||
}
|
||||
/** 填报内容展示:按字段顺序展开「标签 值」 */
|
||||
const myFillRows = computed(() => {
|
||||
const my = detail.value?.my;
|
||||
if (!my || !my.fillData) return [] as { label: string; value: string }[];
|
||||
const fields = curFields.value;
|
||||
const order = fields.length ? fields : Object.keys(my.fillData).map(k => ({ key: k, label: k } as FillField));
|
||||
return order.map(f => {
|
||||
let v: any = my.fillData![f.key];
|
||||
if (f.type === "select" && f.options) v = f.options.find(o => o.value === v)?.label ?? v;
|
||||
return { label: f.label, value: v === undefined || v === null || v === "" ? "—" : String(v) };
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-wrapper">
|
||||
<div class="main-container">
|
||||
<div class="par-wrap">
|
||||
<!-- 左侧通知列表 -->
|
||||
<div class="title-list">
|
||||
<!-- view-ui-plus 4.x 的 Tabs 只有 modelValue,写 :value 不会生效(activeKey 会退回第一个 pane) -->
|
||||
<Tabs v-model="tab" size="small" @on-click="(n: string) => (tab = n)">
|
||||
<TabPane v-for="t in TABS" :key="t.name" :name="t.name" :label="t.label"></TabPane>
|
||||
</Tabs>
|
||||
<div class="search-box">
|
||||
<Input v-model="keyword" clearable search placeholder="搜索标题 / 类型 / 发布部门 / 房间" />
|
||||
</div>
|
||||
<div class="activity-items">
|
||||
<div
|
||||
v-for="item in filtered"
|
||||
:key="item.id"
|
||||
:class="{ list_item: true, active: selected === item.id }"
|
||||
@click="open(item)"
|
||||
>
|
||||
<div class="tt">
|
||||
<span v-if="item.level !== 'normal'" class="lvl" :style="{ color: levelHex(item.level), borderColor: levelHex(item.level) }">
|
||||
{{ item.levelDesc }}
|
||||
</span>
|
||||
<span class="cat">{{ item.noticeType }}</span>
|
||||
{{ item.title }}
|
||||
</div>
|
||||
<div class="other">
|
||||
<span>{{ item.buildingName }} {{ item.roomNo }}</span>
|
||||
<span>{{ listDate(item.createTime) }}</span>
|
||||
</div>
|
||||
<div class="tags">
|
||||
<Tag :color="statusTagType(item)" size="small">{{ statusText(item) }}</Tag>
|
||||
<Tag size="small">{{ item.receiptModeDesc }}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!filtered.length" class="empty">{{ loading ? "加载中…" : "暂无通知" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧详情 -->
|
||||
<div class="detail-content" v-if="detail">
|
||||
<div class="act-title">
|
||||
<div class="title-header">
|
||||
<div class="tt">
|
||||
<span v-if="detail.notice.level !== 'normal'" class="lvl-lg" :style="{ background: levelHex(detail.notice.level) }">
|
||||
{{ detail.notice.levelDesc }}
|
||||
</span>
|
||||
{{ detail.notice.title }}
|
||||
</div>
|
||||
<div class="ops">
|
||||
<Tag color="blue">{{ detail.notice.noticeType }}</Tag>
|
||||
<Tag color="purple">{{ detail.notice.noticeStatusDesc }}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<span>发布:{{ detail.notice.deptName }}</span>
|
||||
<span>发布人:{{ detail.notice.publisherName }}</span>
|
||||
<span>所在宿舍:{{ detail.notice.buildingName }} {{ detail.notice.roomNo }} · {{ detail.notice.bedNo }} 床</span>
|
||||
<span>生效:{{ shortDate(detail.notice.effectiveTime, "立即生效") }}</span>
|
||||
<span>回执截止:{{ shortDate(detail.notice.deadlineTime, "不设截止") }}</span>
|
||||
<span>回执方式:{{ detail.notice.receiptModeDesc }}</span>
|
||||
<span v-if="detail.notice.needSupervisor">宿管到场配合</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 我的回执状态 -->
|
||||
<div class="my-receipt" :class="{ only: detail.notice.receiptMode === 'none' }">
|
||||
<template v-if="detail.notice.receiptMode === 'none'">
|
||||
<i class="iconfont icon-weidu"></i>
|
||||
<span class="hint">本条为通知类信息,阅读即视为知悉,无需提交回执。</span>
|
||||
</template>
|
||||
<template v-else-if="hasReceipt">
|
||||
<i class="iconfont icon-shenpi"></i>
|
||||
<span class="hint">
|
||||
我的回执:<b>{{ detail.my.statusDesc }}</b>
|
||||
<template v-if="detail.my.guardianName"> · 联系人 {{ detail.my.guardianName }}({{ detail.my.guardianPhone }})</template>
|
||||
<template v-if="detail.my.submittedAt"> · 提交于 {{ String(detail.my.submittedAt).slice(0, 16) }}</template>
|
||||
<template v-if="detail.notice.receiptMode === 'confirm'"> · 打开通知即自动确认,无需点击</template>
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<i class="iconfont icon-shijian"></i>
|
||||
<span class="hint">
|
||||
{{
|
||||
detail.notice.receiptMode === "confirm"
|
||||
? "正在为你自动确认知悉…"
|
||||
: detail.my?.status === "canceled"
|
||||
? "你的回执已取消,如仍需办理请重新提交。"
|
||||
: "尚未提交回执,请在回执截止前完成填报。"
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 我的填报内容(已提交的需填报类) -->
|
||||
<div v-if="myFillRows.length" class="my-fill">
|
||||
<div class="mf-h">我的填报内容</div>
|
||||
<div class="mf-body">
|
||||
<div v-for="r in myFillRows" :key="r.label" class="mf-row">
|
||||
<span class="k">{{ r.label }}</span><span class="v">{{ r.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="act-content">
|
||||
<div class="ql-editor" v-html="detail.notice.content"></div>
|
||||
<div v-if="detail.notice.files && detail.notice.files.length" class="files">
|
||||
<div class="files-title">附件({{ detail.notice.files.length }})</div>
|
||||
<div v-for="f in detail.notice.files" :key="f.fileid || f.id" class="file-item">
|
||||
<i class="iconfont file-type-icon" :class="getFileIcon(displayName(f))"></i>
|
||||
<span class="file-name" :title="displayName(f)">{{ displayName(f) }}</span>
|
||||
<a class="file-op" @click="downFile(f.fileid || f.id)">下载</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="btns">
|
||||
<template v-if="detail.notice.noticeStatus === 'published'">
|
||||
<!-- 仅通知:无需任何操作 -->
|
||||
<span v-if="detail.notice.receiptMode === 'none'" class="closed-tip">通知类信息,无需回执。</span>
|
||||
<!-- 需确认:打开即自动确认,仅在自动确认失败时给手动兜底 -->
|
||||
<template v-else-if="detail.notice.receiptMode === 'confirm'">
|
||||
<span v-if="hasReceipt" class="closed-tip">已自动确认知悉,无需其他操作。</span>
|
||||
<Button v-else type="primary" icon="md-checkmark" :loading="autoConfirming" @click="submitConfirm(detail.notice.id, true)">
|
||||
确认知悉
|
||||
</Button>
|
||||
</template>
|
||||
<!-- 需填报:家长按单据字段填报 -->
|
||||
<template v-else>
|
||||
<Button v-if="!hasReceipt" type="primary" icon="md-create" @click="openFill">填写并提交</Button>
|
||||
<template v-else>
|
||||
<Button type="primary" icon="md-create" @click="openFill">修改回执</Button>
|
||||
<Button style="margin-left: 10px" @click="doCancel">取消回执</Button>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
<span v-else-if="detail.notice.receiptMode !== 'none'" class="closed-tip">该通知已截止回执,如有疑问请联系宿管或班主任。</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-content empty-detail" v-else>
|
||||
<span>{{ detailLoading ? "加载中…" : "暂无通知" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 填报 / 回执弹窗 -->
|
||||
<Modal v-model="fillModal.open" :title="fillModal.title" width="580px" :mask-closable="false">
|
||||
<div class="fill-notice">
|
||||
统一回执单 · {{ detail?.formTypeDesc || "通用回执单" }}
|
||||
<span>公共区所有单据一致,专属区随单据类型变化;字段由学校预置,家长无需自行增减</span>
|
||||
</div>
|
||||
<Form ref="fillFormRef" :model="fillModal" :rules="fillRules" :label-width="120">
|
||||
<div class="form-divider">
|
||||
公共区 · 所有单据一致
|
||||
<span class="fd-sub">学生与家长信息由花名册带出,可直接修改</span>
|
||||
</div>
|
||||
<FormItem label="学生宿舍:">
|
||||
<span class="ro-text">{{ detail?.notice.buildingName }} {{ detail?.notice.roomNo }} · {{ detail?.notice.bedNo }} 床</span>
|
||||
</FormItem>
|
||||
<FormItem label="家长姓名:" prop="guardianName">
|
||||
<Input v-model="fillModal.guardianName" placeholder="请输入家长姓名" style="width: 260px" />
|
||||
</FormItem>
|
||||
<FormItem label="联系电话:" prop="guardianPhone">
|
||||
<Input v-model="fillModal.guardianPhone" placeholder="11 位手机号" style="width: 260px" />
|
||||
</FormItem>
|
||||
|
||||
<!-- 字段渲染:公共字段 → 专属字段(控件只写一遍,避免两段各复制一套分支) -->
|
||||
<template v-for="sec in fillSections" :key="sec.key">
|
||||
<FormItem v-for="f in sec.fields" :key="f.key" :label="`${f.label}:`" :prop="f.key">
|
||||
<Input v-if="f.type === 'text'" v-model="fillModal[f.key]" :placeholder="f.placeholder || `请输入${f.label}`" />
|
||||
<Input
|
||||
v-else-if="f.type === 'textarea'"
|
||||
v-model="fillModal[f.key]"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
:placeholder="f.placeholder || `请输入${f.label}`"
|
||||
/>
|
||||
<InputNumber v-else-if="f.type === 'number'" v-model="fillModal[f.key]" :min="0" :step="1" style="width: 180px" />
|
||||
<DatePicker
|
||||
v-else-if="f.type === 'date'"
|
||||
v-model="fillModal[f.key]"
|
||||
type="date"
|
||||
transfer
|
||||
format="yyyy-MM-dd"
|
||||
:placeholder="f.placeholder || `请选择${f.label}`"
|
||||
style="width: 220px"
|
||||
></DatePicker>
|
||||
<DatePicker
|
||||
v-else-if="f.type === 'datetime'"
|
||||
v-model="fillModal[f.key]"
|
||||
type="datetime"
|
||||
transfer
|
||||
format="yyyy-MM-dd HH:mm"
|
||||
:placeholder="f.placeholder || `请选择${f.label}`"
|
||||
style="width: 240px"
|
||||
></DatePicker>
|
||||
<Select v-else-if="f.type === 'select'" v-model="fillModal[f.key]" :placeholder="`请选择${f.label}`" style="width: 260px">
|
||||
<Option v-for="o in f.options || []" :key="o.value" :value="o.value">{{ o.label }}</Option>
|
||||
</Select>
|
||||
<span v-if="f.unit" class="inline-hint">单位:{{ f.unit }}</span>
|
||||
</FormItem>
|
||||
<div v-if="sec.key === 'common' && specificFields.length" class="form-divider">
|
||||
专属区 · {{ detail?.formTypeDesc || "单据登记项" }}
|
||||
<span class="fd-sub">该项随单据类型变化</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="form-divider">补充说明</div>
|
||||
<FormItem label="备注:">
|
||||
<Input v-model="fillModal.remark" type="textarea" :rows="2" placeholder="如有过敏史、接送安排、需特殊照顾等情况请在此说明" />
|
||||
</FormItem>
|
||||
</Form>
|
||||
<template #footer>
|
||||
<Button @click="fillModal.open = false">取消</Button>
|
||||
<Button type="primary" :loading="fillModal.submitting" @click="doFill">提交回执</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* 内容区可视高度 = 100vh - 112(头部+面包屑) - 24(容器底padding) */
|
||||
.par-wrap {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
height: calc(100vh - 136px);
|
||||
}
|
||||
.title-list {
|
||||
width: 380px;
|
||||
flex: none;
|
||||
background: #fff;
|
||||
border: 1px solid #ebeef4;
|
||||
border-radius: 3px;
|
||||
padding: 8px 0 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
:deep(.ivu-tabs-nav-scroll) {
|
||||
padding: 0 8px;
|
||||
}
|
||||
/* 展示全部标签、去掉左右滚动箭头 */
|
||||
:deep(.ivu-tabs-nav-prev),
|
||||
:deep(.ivu-tabs-nav-next) {
|
||||
display: none !important;
|
||||
}
|
||||
:deep(.ivu-tabs-nav-container),
|
||||
:deep(.ivu-tabs-nav-container-scrolling) {
|
||||
padding: 0 !important;
|
||||
}
|
||||
:deep(.ivu-tabs-nav-wrap) {
|
||||
transform: none !important;
|
||||
}
|
||||
:deep(.ivu-tabs-nav) {
|
||||
float: none !important;
|
||||
}
|
||||
:deep(.ivu-tabs-bar) {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.search-box {
|
||||
padding: 0 15px;
|
||||
}
|
||||
.activity-items {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
margin-top: 10px;
|
||||
.list_item {
|
||||
padding: 10px 15px;
|
||||
cursor: pointer;
|
||||
border-left: 3px solid transparent;
|
||||
.tt {
|
||||
font-weight: 600;
|
||||
color: #272829;
|
||||
line-height: 1.4;
|
||||
.cat {
|
||||
display: inline-block;
|
||||
padding: 0 5px;
|
||||
margin-right: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: #2d8cf0;
|
||||
background: #ecf3ff;
|
||||
border-radius: 3px;
|
||||
vertical-align: 1px;
|
||||
}
|
||||
.lvl {
|
||||
display: inline-block;
|
||||
padding: 0 5px;
|
||||
margin-right: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
border: 1px solid;
|
||||
border-radius: 3px;
|
||||
vertical-align: 1px;
|
||||
}
|
||||
}
|
||||
.other {
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
.tags {
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
&:hover {
|
||||
background: #f6f9ff;
|
||||
}
|
||||
&.active {
|
||||
background: #ecf3ff;
|
||||
border-left-color: #2d8cf0;
|
||||
}
|
||||
}
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
padding: 40px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
.detail-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: #fff;
|
||||
border: 1px solid #ebeef4;
|
||||
border-radius: 3px;
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
&.empty-detail {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #999;
|
||||
}
|
||||
.title-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
.tt {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #272829;
|
||||
.lvl-lg {
|
||||
display: inline-block;
|
||||
padding: 0 6px;
|
||||
margin-right: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: #fff;
|
||||
border-radius: 3px;
|
||||
vertical-align: 2px;
|
||||
}
|
||||
}
|
||||
.ops {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex: none;
|
||||
}
|
||||
}
|
||||
.meta {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 18px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px dashed #e8e8e8;
|
||||
font-size: 13px;
|
||||
color: #808695;
|
||||
}
|
||||
.my-receipt {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
background: #e8f4ff;
|
||||
border: 1px solid #d3e9ff;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
color: #515a6e;
|
||||
&.only {
|
||||
background: #f6f7f9;
|
||||
border-color: #e8eaec;
|
||||
}
|
||||
.iconfont {
|
||||
font-size: 16px;
|
||||
color: #2d8cf0;
|
||||
}
|
||||
b {
|
||||
color: #2d8cf0;
|
||||
}
|
||||
}
|
||||
.my-fill {
|
||||
margin-top: 10px;
|
||||
border: 1px solid #e8eaec;
|
||||
border-radius: 4px;
|
||||
background: #fafcff;
|
||||
.mf-h {
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #515a6e;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.mf-body {
|
||||
padding: 6px 12px 10px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 30px;
|
||||
.mf-row {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
min-width: 220px;
|
||||
padding: 4px 0;
|
||||
.k {
|
||||
color: #8a94a6;
|
||||
}
|
||||
.v {
|
||||
color: #17233d;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.act-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px 0;
|
||||
.files {
|
||||
border-top: 1px dashed #e8e8e8;
|
||||
padding-top: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.files-title {
|
||||
font-size: 13px;
|
||||
color: #808695;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.file-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #515a6e;
|
||||
margin: 0 16px 8px 0;
|
||||
font-size: 13px;
|
||||
.file-name {
|
||||
max-width: 320px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.file-op {
|
||||
color: #2d8cf0;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
.btns {
|
||||
border-top: 1px dashed #e8e8e8;
|
||||
padding-top: 12px;
|
||||
.closed-tip {
|
||||
color: #808695;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.inline-hint {
|
||||
font-size: 12px;
|
||||
color: #808695;
|
||||
margin-left: 10px;
|
||||
}
|
||||
.form-divider {
|
||||
margin: 4px 0 12px;
|
||||
padding: 6px 10px;
|
||||
background: #f7f9fc;
|
||||
border-left: 3px solid #2d8cf0;
|
||||
font-size: 13px;
|
||||
color: #515a6e;
|
||||
.fd-sub {
|
||||
margin-left: 10px;
|
||||
font-size: 12px;
|
||||
color: #808695;
|
||||
}
|
||||
}
|
||||
/* 统一回执单说明条 */
|
||||
.fill-notice {
|
||||
margin-bottom: 10px;
|
||||
padding: 8px 12px;
|
||||
background: #e8f4ff;
|
||||
border: 1px solid #d3e9ff;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
color: #515a6e;
|
||||
span {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: #808695;
|
||||
}
|
||||
}
|
||||
.ro-text {
|
||||
font-size: 13px;
|
||||
color: #17233d;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,451 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, watch } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { noticeApi } from "@/api/module/dorm";
|
||||
import type { ReceiptsResultVo, ReceiptBrief, NoticeVo, FillField } from "@/types/dorm";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import Utils from "@/utils";
|
||||
|
||||
const route = useRoute();
|
||||
const serid = ref<number>(Number(route.query.serid) || 0);
|
||||
|
||||
/* 未指定通知时,先在“我发布的”里选一条 */
|
||||
const myNotices = ref<NoticeVo[]>([]);
|
||||
const loadingList = ref(false);
|
||||
async function loadMyNotices() {
|
||||
loadingList.value = true;
|
||||
try {
|
||||
const r = await noticeApi.sendList({ scope: "user", pageSize: 100, pageIndex: 1 }).send();
|
||||
// 仅通知类没有回执;草稿尚未下发
|
||||
myNotices.value = (r.content || []).filter((n: NoticeVo) => n.receiptMode !== "none" && n.noticeStatus !== "draft");
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "加载通知失败");
|
||||
} finally {
|
||||
loadingList.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============ 回执数据 ============ */
|
||||
const data = ref<ReceiptsResultVo | null>(null);
|
||||
const loading = ref(false);
|
||||
const receiptStatus = ref("all");
|
||||
|
||||
async function load() {
|
||||
if (!serid.value) {
|
||||
data.value = null;
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
data.value = await noticeApi.receipts(serid.value, receiptStatus.value).send();
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "加载回执情况失败");
|
||||
data.value = null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
watch(receiptStatus, load);
|
||||
watch(
|
||||
() => route.query.serid,
|
||||
v => {
|
||||
serid.value = Number(v) || 0;
|
||||
receiptStatus.value = "all";
|
||||
load();
|
||||
}
|
||||
);
|
||||
onMounted(async () => {
|
||||
await loadMyNotices();
|
||||
load();
|
||||
});
|
||||
|
||||
const summary = computed(() => data.value?.summary || null);
|
||||
/** 统一回执单的完整字段清单(公共区 + 专属区) */
|
||||
const formFields = computed<FillField[]>(() => data.value?.formFields || []);
|
||||
/** 公共区字段:所有单据一致,可跨通知合并统计 */
|
||||
const commonFields = computed<FillField[]>(() => formFields.value.filter(f => f.group === "common"));
|
||||
/** 专属区字段:随单据类型变化,逐单据展示 */
|
||||
const specificFields = computed<FillField[]>(() => formFields.value.filter(f => f.group !== "common"));
|
||||
/** 单据类型描述(如「留宿申请单」) */
|
||||
const formTypeDesc = computed(() => data.value?.formTypeDesc || "");
|
||||
|
||||
const STATUS_TEXT: Record<string, string> = { none: "未回执", confirmed: "已确认", filled: "已填报", canceled: "已取消" };
|
||||
const STATUS_COLOR: Record<string, string> = { none: "default", confirmed: "success", filled: "primary", canceled: "error" };
|
||||
/** 回执结论标签色 */
|
||||
const CONCL_COLOR: Record<string, string> = { 同意: "success", 不同意: "error", 需沟通: "warning" };
|
||||
|
||||
/** 取某字段的展示值(select 映射为选项 label) */
|
||||
function fieldValue(f: FillField, fd: Record<string, string>) {
|
||||
const v = fd[f.key];
|
||||
if (v === undefined || v === null || v === "") return "—";
|
||||
if (f.type === "select" && f.options) return f.options.find(o => o.value === v)?.label ?? v;
|
||||
return String(v);
|
||||
}
|
||||
|
||||
/**
|
||||
* 回执结论单元格:取公共区字段(key=conclusion)。
|
||||
* 结论是唯一能跨通知横向比较的填报内容,所以单独成列,而不是塞进「单据登记项」里。
|
||||
*/
|
||||
function conclusionCell(h: any, row: ReceiptBrief) {
|
||||
if (!row.hasReceipt) return h("span", { style: "color:#c5c8ce;" }, "—");
|
||||
const fd = row.fillData || {};
|
||||
const hit = commonFields.value.find(f => fd[f.key] !== undefined && String(fd[f.key]) !== "");
|
||||
if (!hit) return h("span", { style: "color:#808695;" }, "已确认知悉");
|
||||
const label = fieldValue(hit, fd);
|
||||
return h("Tag", { color: CONCL_COLOR[label] || "default" }, () => label);
|
||||
}
|
||||
|
||||
/** 单据登记项单元格:只展开专属区字段(公共区已由结论列承载) */
|
||||
function specificCell(h: any, row: ReceiptBrief) {
|
||||
if (!row.hasReceipt) return h("span", { style: "color:#c5c8ce;" }, "—");
|
||||
const fields = specificFields.value;
|
||||
if (!fields.length) return h("span", { style: "color:#808695;" }, "—");
|
||||
const fd = row.fillData || {};
|
||||
const text = fields.map(f => `${f.label}:${fieldValue(f, fd)}`).join(";");
|
||||
return h("span", { style: "color:#515a6e;", attrs: { title: text } }, text);
|
||||
}
|
||||
|
||||
/* 回执名单表格列(纯 render,避免 iView 具名 slot 差异) */
|
||||
const stuColumns = computed(() => {
|
||||
const cols: any[] = [
|
||||
{ title: "学生", key: "studentName", minWidth: 100 },
|
||||
{
|
||||
title: "床位",
|
||||
width: 80,
|
||||
align: "center",
|
||||
render: (h: any, p: any) => h("span", {}, p.row.bedNo || "—"),
|
||||
},
|
||||
{
|
||||
title: "家长",
|
||||
width: 100,
|
||||
render: (h: any, p: any) => h("span", {}, p.row.hasReceipt ? p.row.guardianName || "—" : "—"),
|
||||
},
|
||||
{
|
||||
title: "联系电话",
|
||||
width: 130,
|
||||
render: (h: any, p: any) => h("span", {}, p.row.hasReceipt ? p.row.guardianPhone || "—" : "—"),
|
||||
},
|
||||
{
|
||||
title: "与学生关系",
|
||||
width: 100,
|
||||
align: "center",
|
||||
render: (h: any, p: any) => h("span", {}, p.row.hasReceipt ? p.row.relation || "—" : "—"),
|
||||
},
|
||||
];
|
||||
if (formFields.value.length) {
|
||||
cols.push({ title: "回执结论", width: 110, align: "center", render: (h: any, p: any) => conclusionCell(h, p.row) });
|
||||
cols.push({ title: "单据登记项", minWidth: 220, render: (h: any, p: any) => specificCell(h, p.row) });
|
||||
}
|
||||
cols.push(
|
||||
{
|
||||
title: "回执状态",
|
||||
width: 100,
|
||||
align: "center",
|
||||
render: (h: any, p: any) =>
|
||||
h("Tag", { color: STATUS_COLOR[p.row.status] || "default" }, () => STATUS_TEXT[p.row.status] || p.row.status),
|
||||
},
|
||||
{
|
||||
title: "回执时间",
|
||||
width: 150,
|
||||
render: (h: any, p: any) => h("span", {}, p.row.submittedAt ? String(p.row.submittedAt).slice(5, 16) : "—"),
|
||||
},
|
||||
{
|
||||
title: "备注",
|
||||
minWidth: 170,
|
||||
tooltip: true,
|
||||
render: (h: any, p: any) => h("span", { style: "color:#808695;" }, p.row.remark || "—"),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
width: 210,
|
||||
align: "center",
|
||||
render: (h: any, p: any) => {
|
||||
const row = p.row as ReceiptBrief;
|
||||
const nodes: any[] = [Utils.operatingHandle(h, "回执详情", "icon-chakan", () => openDetail(row))];
|
||||
if (row.hasReceipt) {
|
||||
nodes.push(Utils.operatingHandle(h, "取消/撤回", "icon-stop", () => review(row, "cancel")));
|
||||
nodes.push(Utils.operatingHandle(h, "退回未回执", "icon-saoba", () => review(row, "reset")));
|
||||
} else {
|
||||
nodes.push(Utils.operatingHandle(h, "代为确认", "icon-shenpi", () => review(row, "confirm")));
|
||||
}
|
||||
return nodes;
|
||||
},
|
||||
}
|
||||
);
|
||||
return cols;
|
||||
});
|
||||
|
||||
/* ============ 回执详情弹窗 ============ */
|
||||
const detailModal = reactive({
|
||||
open: false,
|
||||
loading: false,
|
||||
data: null as any,
|
||||
});
|
||||
async function openDetail(row: ReceiptBrief) {
|
||||
detailModal.open = true;
|
||||
detailModal.loading = true;
|
||||
detailModal.data = null;
|
||||
try {
|
||||
detailModal.data = await noticeApi.receiptDetail(row.receiptId).send();
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "加载回执详情失败");
|
||||
} finally {
|
||||
detailModal.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============ 回执处理(代为确认 / 取消 / 退回) ============ */
|
||||
function review(row: ReceiptBrief, action: "confirm" | "cancel" | "reset") {
|
||||
const conf: Record<string, { title: string; msg: string; info?: string }> = {
|
||||
confirm: { title: "代为确认", msg: `代「${row.studentName}」的家长确认知悉该通知?`, info: "适用于家长电话口头确认、或系统外已告知的情形" },
|
||||
cancel: { title: "取消 / 撤回回执", msg: `将「${row.studentName}」的回执置为「已取消」?`, info: "如家长申请不参与(不留宿、不探视),可置为已取消并留痕" },
|
||||
reset: { title: "退回未回执", msg: `将「${row.studentName}」的回执退回「未回执」?`, info: "退回后家长端可重新提交,原填报内容将被清空" },
|
||||
};
|
||||
MyConfirm({
|
||||
...conf[action],
|
||||
async onOk() {
|
||||
try {
|
||||
await noticeApi.review({ receiptId: row.receiptId, action }).send();
|
||||
Message.success("操作成功!");
|
||||
load();
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "操作失败");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/* ============ 催办 / 导出 ============ */
|
||||
async function remind() {
|
||||
if (!serid.value) return;
|
||||
try {
|
||||
const r = await noticeApi.remind(serid.value).send();
|
||||
Message.success(`已向 ${(r && (r as any).reminded) ?? 0} 位未回执家长发送提醒`);
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "催办失败");
|
||||
}
|
||||
}
|
||||
async function exportList() {
|
||||
if (!serid.value) return;
|
||||
try {
|
||||
const csv: any = await noticeApi.exportReceipts(serid.value).send();
|
||||
const name = `宿舍通知回执名单_${data.value?.title || serid.value}.csv`;
|
||||
const blob = new Blob([typeof csv === "string" ? csv : String(csv)], { type: "text/csv;charset=utf-8;" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
Message.success("已导出回执名单");
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "导出失败");
|
||||
}
|
||||
}
|
||||
|
||||
/** 需填报类:详情里的 fillData 按「公共区 / 专属区」分成两段展开 */
|
||||
const detailFillGroups = computed(() => {
|
||||
const d = detailModal.data;
|
||||
if (!d || !d.fillData) return [] as { title: string; rows: { label: string; value: string }[] }[];
|
||||
const fields: FillField[] = d.formFields || [];
|
||||
const fd: Record<string, string> = d.fillData || {};
|
||||
const toRow = (f: FillField) => ({ label: f.label, value: fieldValue(f, fd) });
|
||||
return [
|
||||
{ title: "公共区 · 所有单据一致", rows: fields.filter(f => f.group === "common").map(toRow) },
|
||||
{ title: `专属区 · ${d.formTypeDesc || "单据登记项"}`, rows: fields.filter(f => f.group !== "common").map(toRow) },
|
||||
].filter(g => g.rows.length);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-wrapper">
|
||||
<div class="main-container">
|
||||
<!-- 顶部:通知选择 + 状态过滤 + 操作 -->
|
||||
<div class="fix mb-12">
|
||||
<div class="fl" style="display: flex; align-items: center; gap: 8px">
|
||||
<span style="color: #515a6e">通知:</span>
|
||||
<Select v-model="serid" style="width: 380px" placeholder="选择一条需要回执的通知" filterable :loading="loadingList" @on-change="load">
|
||||
<Option v-for="n in myNotices" :key="n.id" :value="n.id">{{ n.noticeType }} · {{ n.title }}</Option>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="fr" style="display: flex; align-items: center; gap: 8px">
|
||||
<span style="color: #515a6e">状态:</span>
|
||||
<Select v-model="receiptStatus" style="width: 150px">
|
||||
<Option value="all">全部</Option>
|
||||
<Option value="confirmed">已确认</Option>
|
||||
<Option value="filled">已填报</Option>
|
||||
<Option value="canceled">已取消</Option>
|
||||
<Option value="none">未回执</Option>
|
||||
</Select>
|
||||
<Button icon="md-notifications-outline" :disabled="!serid" @click="remind">催办回执</Button>
|
||||
<Button icon="md-download" :disabled="!serid" @click="exportList">导出名单</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="!serid" class="tip">请先在上方选择一条通知查看家长回执情况。</p>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="summary" class="sum-bar mb-12">
|
||||
<div class="sum-title">
|
||||
<b>{{ data?.title }}</b>
|
||||
<Tag v-if="data && data.level !== 'normal'" :color="data.level === 'urgent' ? 'error' : 'warning'" size="small">{{ data?.levelDesc }}</Tag>
|
||||
<span class="sub">
|
||||
{{ data?.noticeType }} · {{ data?.noticeStatusDesc }} · {{ data?.receiptModeDesc }}
|
||||
<template v-if="formTypeDesc"> · 单据:{{ formTypeDesc }}</template>
|
||||
· 生效 {{ data?.effectiveTime ? String(data.effectiveTime).slice(0, 16) : "立即" }} · 回执截止
|
||||
{{ data?.deadlineTime ? String(data.deadlineTime).slice(0, 16) : "不限" }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="sum-cards">
|
||||
<div class="card"><div class="n">{{ summary.should }}</div><div class="l">应收(住宿生)</div></div>
|
||||
<div class="card"><div class="n ok">{{ summary.confirmed + summary.filled }}</div><div class="l">已回执</div></div>
|
||||
<div class="card"><div class="n">{{ summary.confirmed }}</div><div class="l">已确认</div></div>
|
||||
<div class="card"><div class="n">{{ summary.filled }}</div><div class="l">已填报</div></div>
|
||||
<div class="card"><div class="n err">{{ summary.canceled }}</div><div class="l">已取消</div></div>
|
||||
<div class="card"><div class="n warn">{{ summary.unsigned }}</div><div class="l">未回执</div></div>
|
||||
<div class="card"><div class="n">{{ summary.receiptRate }}%</div><div class="l">回执率</div></div>
|
||||
<div class="card"><div class="n">{{ summary.rooms }}</div><div class="l">覆盖宿舍</div></div>
|
||||
<div class="card"><div class="n">{{ summary.beds }}</div><div class="l">覆盖床位</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="groups" v-if="data">
|
||||
<div v-for="(g, gi) in data.groups" :key="gi" class="grp">
|
||||
<div class="grp-h">{{ g.groupName }}({{ g.students.length }} 人)</div>
|
||||
<Table :columns="stuColumns" :data="g.students" size="small" :loading="loading"></Table>
|
||||
</div>
|
||||
<p v-if="!data.groups.length" class="tip">该状态下暂无住宿生记录。</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 回执详情弹窗 -->
|
||||
<Modal v-model="detailModal.open" title="家长回执详情" width="560px">
|
||||
<Spin v-if="detailModal.loading" fix />
|
||||
<div v-if="detailModal.data" class="rc-detail">
|
||||
<div class="rc-row"><span class="k">通知</span><span class="v">{{ detailModal.data.noticeTitle }}</span></div>
|
||||
<div class="rc-row"><span class="k">通知类型</span><span class="v">{{ detailModal.data.noticeType }}</span></div>
|
||||
<div class="rc-row"><span class="k">回执单据</span><span class="v">{{ detailModal.data.formTypeDesc || "—" }}</span></div>
|
||||
<div class="rc-row"><span class="k">宿舍</span><span class="v">{{ detailModal.data.buildingName }} {{ detailModal.data.roomNo }} · {{ detailModal.data.bedNo }} 床</span></div>
|
||||
<div class="rc-row"><span class="k">学生</span><span class="v">{{ detailModal.data.studentName }}</span></div>
|
||||
<div class="rc-row"><span class="k">家长</span><span class="v">{{ detailModal.data.guardianName || "—" }}</span></div>
|
||||
<div class="rc-row"><span class="k">联系电话</span><span class="v">{{ detailModal.data.guardianPhone || "—" }}</span></div>
|
||||
<div class="rc-row"><span class="k">与学生关系</span><span class="v">{{ detailModal.data.relation || "—" }}</span></div>
|
||||
<div v-for="g in detailFillGroups" :key="g.title" class="rc-group">
|
||||
<div class="rc-group-title">{{ g.title }}</div>
|
||||
<div v-for="r in g.rows" :key="r.label" class="rc-row">
|
||||
<span class="k">{{ r.label }}</span><span class="v">{{ r.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rc-row">
|
||||
<span class="k">回执状态</span>
|
||||
<span class="v">
|
||||
<Tag :color="STATUS_COLOR[detailModal.data.status] || 'default'">{{ detailModal.data.statusDesc }}</Tag>
|
||||
</span>
|
||||
</div>
|
||||
<div class="rc-row"><span class="k">回执时间</span><span class="v">{{ detailModal.data.submittedAt || "—" }}</span></div>
|
||||
<div class="rc-row"><span class="k">是否已读</span><span class="v">{{ detailModal.data.read ? `已读${detailModal.data.readAt ? "(" + detailModal.data.readAt + ")" : ""}` : "未读" }}</span></div>
|
||||
<div class="rc-row"><span class="k">备注</span><span class="v">{{ detailModal.data.remark || "—" }}</span></div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button @click="detailModal.open = false">关闭</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tip {
|
||||
color: #808695;
|
||||
padding: 20px 0;
|
||||
}
|
||||
.sum-bar {
|
||||
border: 1px solid #e8eaec;
|
||||
border-radius: 4px;
|
||||
padding: 14px 16px;
|
||||
background: #fafcff;
|
||||
.sum-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.sub {
|
||||
color: #808695;
|
||||
font-size: 13px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.sum-cards {
|
||||
display: flex;
|
||||
gap: 28px;
|
||||
margin-top: 12px;
|
||||
flex-wrap: wrap;
|
||||
.card {
|
||||
text-align: center;
|
||||
.n {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: #17233d;
|
||||
}
|
||||
.l {
|
||||
font-size: 12px;
|
||||
color: #808695;
|
||||
}
|
||||
.warn {
|
||||
color: #ff9900;
|
||||
}
|
||||
.ok {
|
||||
color: #19be6b;
|
||||
}
|
||||
.err {
|
||||
color: #ed4014;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.groups {
|
||||
margin-top: 4px;
|
||||
.grp {
|
||||
margin-bottom: 14px;
|
||||
.grp-h {
|
||||
font-weight: 600;
|
||||
padding: 8px 4px;
|
||||
color: #17233d;
|
||||
}
|
||||
}
|
||||
}
|
||||
.rc-detail {
|
||||
max-height: 460px;
|
||||
overflow-y: auto;
|
||||
.rc-group {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.rc-group-title {
|
||||
margin: 8px 0 2px;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
color: #515a6e;
|
||||
background: #f7f9fc;
|
||||
border-left: 3px solid #2d8cf0;
|
||||
}
|
||||
.rc-row {
|
||||
display: flex;
|
||||
padding: 7px 0;
|
||||
border-bottom: 1px dashed #f0f0f0;
|
||||
font-size: 13px;
|
||||
.k {
|
||||
width: 96px;
|
||||
flex: none;
|
||||
color: #8a94a6;
|
||||
}
|
||||
.v {
|
||||
flex: 1;
|
||||
color: #17233d;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user