feat: 提交宿舍通知前端工程源码(Vue3 + Vite + view-ui-plus)
build / build (push) Successful in 5m32s

This commit is contained in:
lijing
2026-09-20 15:47:34 +08:00
parent b6e8229fa1
commit c956a9ec58
328 changed files with 31642 additions and 2 deletions
File diff suppressed because it is too large Load Diff
+873
View File
@@ -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>
+584
View File
@@ -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>
+477
View File
@@ -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>
+322
View File
@@ -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>
+916
View File
@@ -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>
+451
View File
@@ -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>