- 同步仓库 CI 配置 .gitea/workflows/build.yml - 纳入 src/public/config/scripts 等工程文件 - .gitignore 增加 .scratch/.backup/.trash,排除本地临时产物
This commit is contained in:
@@ -0,0 +1,994 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { useRequest } from "alova/client";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { noticeApi } from "@/api/module/notice";
|
||||
import type { ApprovalQuery, NoticeDetailVo, NoticePageResult, NoticeVo } from "@/types/notice";
|
||||
import FilePreviewModal from "@/components/FilePreviewModal.vue";
|
||||
import Utils from "@/utils";
|
||||
|
||||
/**
|
||||
* 审批中心(v15):与「已收到」同款左右分栏。
|
||||
* 左:状态 tab(含条数)+ 关键字搜索 + 卡片列表(标题/徽标/部门·时间/审批进度/当前审批人);
|
||||
* 右:公告详情,按钮行 上一篇 / 下一篇 / 通过 / 驳回 / 打印,下方依次展示审批流程(多步进度)与审批轨迹(时间线)。
|
||||
* 详情走 GetDetail(不标读)——审批人查看不应计入接收人的阅读统计;姓名统一取后端解析后的人名(v15 后端已补真实姓名与同步教师ID)。
|
||||
*/
|
||||
const route = useRoute();
|
||||
|
||||
/* ============ 左侧列表 ============ */
|
||||
const keyword = ref("");
|
||||
/** 审批状态 tab:pending(缺省) / approved / rejected / all */
|
||||
const auditState = ref("pending");
|
||||
const pageNum = ref(1);
|
||||
const pageSize = ref(50);
|
||||
/** 当前选中项下标(-1 无选中),同时驱动左列高亮与上一篇/下一篇 */
|
||||
const selected = ref(-1);
|
||||
|
||||
const { data: page, loading, send: reload } = useRequest((params: ApprovalQuery) => noticeApi.approvalList(params), {
|
||||
immediate: false,
|
||||
initialData: { content: [], total: 0, pageIndex: 1, pageSize: 50, totalPages: 0, hasNext: false },
|
||||
});
|
||||
|
||||
/** v13:四 tab 统计数字(ApprovalTabCount,不随 keyword 变化;为 0 不显示) */
|
||||
const tabCounts = ref({ pending: 0, approved: 0, rejected: 0, all: 0 });
|
||||
const tabCountText = (n: number) => (n > 0 ? ` (${n})` : "");
|
||||
async function loadTabCounts() {
|
||||
try {
|
||||
tabCounts.value = await noticeApi.approvalTabCount().send();
|
||||
} catch {
|
||||
/* 统计接口异常时静默保留上次数字,不打断列表操作 */
|
||||
}
|
||||
}
|
||||
|
||||
const list = computed<NoticeVo[]>(() => page.value?.content || []);
|
||||
const currentRow = computed<NoticeVo | null>(() => list.value[selected.value] || null);
|
||||
|
||||
/**
|
||||
* 拉取列表并联动详情。
|
||||
* @param keepSelected 审批动作后为 true:优先保持原公告选中(该单已流转出当前筛选时回落首条)
|
||||
*/
|
||||
async function run(keepSelected = false) {
|
||||
loadTabCounts();
|
||||
const keepId = keepSelected && currentRow.value ? currentRow.value.id : 0;
|
||||
try {
|
||||
const data: NoticePageResult<NoticeVo> = await reload({
|
||||
keyword: keyword.value || "",
|
||||
auditState: auditState.value,
|
||||
pageIndex: pageNum.value,
|
||||
pageSize: pageSize.value,
|
||||
});
|
||||
const rows = data?.content || [];
|
||||
let idx = keepId ? rows.findIndex((r) => r.id === keepId) : -1;
|
||||
if (idx < 0) idx = rows.length ? 0 : -1;
|
||||
selected.value = idx;
|
||||
if (idx >= 0) await loadDetail(rows[idx].id);
|
||||
else detail.value = null;
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "获取审批列表失败");
|
||||
}
|
||||
}
|
||||
function doSearch() {
|
||||
pageNum.value = 1;
|
||||
run();
|
||||
}
|
||||
function pageUpdate(p: number) {
|
||||
pageNum.value = p;
|
||||
run();
|
||||
}
|
||||
|
||||
onMounted(run);
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => run()
|
||||
);
|
||||
|
||||
/* ============ 右侧详情(GetDetail,不标读) ============ */
|
||||
const detail = ref<NoticeDetailVo | null>(null);
|
||||
const detailLoading = ref(false);
|
||||
|
||||
async function checkNotice(item: NoticeVo, index: number) {
|
||||
selected.value = index;
|
||||
await loadDetail(item.id);
|
||||
}
|
||||
|
||||
async function loadDetail(serid: number) {
|
||||
detailLoading.value = true;
|
||||
try {
|
||||
detail.value = await noticeApi.getNoticeDetails(serid).send();
|
||||
} catch (e: any) {
|
||||
detail.value = null;
|
||||
Message.error(e.message || "加载公告失败");
|
||||
} finally {
|
||||
detailLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 上一篇/下一篇:在当前筛选结果(当前 tab + 关键字 + 当前页)内移动,与左列选中联动 */
|
||||
function goStep(delta: number) {
|
||||
const idx = selected.value + delta;
|
||||
if (idx < 0 || idx >= list.value.length) return;
|
||||
checkNotice(list.value[idx], idx);
|
||||
}
|
||||
const prevDisabled = computed(() => selected.value <= 0);
|
||||
const nextDisabled = computed(() => selected.value < 0 || selected.value >= list.value.length - 1);
|
||||
|
||||
/* ============ 审批动作 ============ */
|
||||
/** 仅「待审批 + 我是本步待签人」可操作(后端 canApprove 由 requireStepApprover/canApproveNow 同口径算出) */
|
||||
const canAct = computed(() => !!detail.value && detail.value.approvalStatus === "pending" && detail.value.canApprove === true);
|
||||
const waitingHint = computed(() => {
|
||||
const d = detail.value;
|
||||
if (!d || d.approvalStatus === "pending") return "等待他人审批";
|
||||
return "该单已处理,无可操作项";
|
||||
});
|
||||
|
||||
/* 通过:弹窗填写审批意见(可空,缺省“同意”) */
|
||||
const approveModal = ref(false);
|
||||
const approveOpinion = ref("");
|
||||
const approving = ref(false);
|
||||
function openApprove() {
|
||||
approveOpinion.value = "同意";
|
||||
approveModal.value = true;
|
||||
}
|
||||
async function doApprove() {
|
||||
if (!detail.value) return;
|
||||
approving.value = true;
|
||||
try {
|
||||
await noticeApi.approve(detail.value.serid, approveOpinion.value.trim()).send();
|
||||
approveModal.value = false;
|
||||
Message.success("本步骤已通过,最后一步通过后公告发布");
|
||||
await run(true);
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "操作失败");
|
||||
} finally {
|
||||
approving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* 驳回:弹窗填写审批意见(必填),退回发布人 */
|
||||
const rejectModal = ref(false);
|
||||
const rejectRemark = ref("");
|
||||
const rejecting = ref(false);
|
||||
function openReject() {
|
||||
rejectRemark.value = "";
|
||||
rejectModal.value = true;
|
||||
}
|
||||
async function doReject() {
|
||||
if (!detail.value) return;
|
||||
if (!rejectRemark.value.trim()) {
|
||||
Message.info("请填写驳回意见");
|
||||
return;
|
||||
}
|
||||
rejecting.value = true;
|
||||
try {
|
||||
await noticeApi.reject(detail.value.serid, rejectRemark.value.trim()).send();
|
||||
rejectModal.value = false;
|
||||
Message.success("已驳回,退回发布人");
|
||||
await run(true);
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "操作失败");
|
||||
} finally {
|
||||
rejecting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============ 审批流程 / 审批轨迹展示口径 ============ */
|
||||
const FLOW_STATUS_TEXT: Record<string, string> = { approved: "已通过", pending: "审批中", rejected: "已驳回", waiting: "待流转" };
|
||||
const FLOW_STATUS_COLOR: Record<string, string> = { approved: "#19be6b", pending: "#ff9900", rejected: "#ed4014", waiting: "#c5c8ce" };
|
||||
const flowStatusText = (s?: string) => FLOW_STATUS_TEXT[s || "waiting"] || "待流转";
|
||||
const flowStatusColor = (s?: string) => FLOW_STATUS_COLOR[s || "waiting"] || "#c5c8ce";
|
||||
const voteText = (v?: string) => (v === "all" ? "会签" : "或签");
|
||||
const ACTION_TEXT: Record<number, string> = { 1: "提交审批", 2: "通过", 3: "驳回" };
|
||||
const actionText = (a?: number) => ACTION_TEXT[Number(a)] || "处理";
|
||||
/** 轨迹/流程姓名统一取后端人名(v15 后端已按 teaId→账号兜底解析),无值时以「—」占位而非账号 */
|
||||
const personName = (name?: string | null, account?: string | null) => String(name || account || "—");
|
||||
|
||||
/* ============ v23/v27:详情折叠 / 展开(开关常驻标题行) ============ */
|
||||
/** v27:详情是否展开(默认展开)。控制「标题以下整块」——元信息 + 提示条 + 审批流程/轨迹 + 正文附件;
|
||||
折叠开关常驻标题行(.detail-toggle),收起后仍可再点开。 */
|
||||
const approvalOpen = ref(true);
|
||||
/** 合并原三态 Alert:状态→提示条类型(无状态则不显示提示条) */
|
||||
const alertType = computed(() => {
|
||||
const s = detail.value?.approvalStatus;
|
||||
if (s === "pending") return "warning";
|
||||
if (s === "rejected") return "error";
|
||||
if (s === "approved") return "success";
|
||||
return "";
|
||||
});
|
||||
const alertText = computed(() => {
|
||||
const d = detail.value;
|
||||
if (!d) return "";
|
||||
if (d.approvalStatus === "pending") return `该公告正在审批中:${d.approvalStatusDesc || "待审批"}`;
|
||||
if (d.approvalStatus === "rejected") return "该公告已被驳回,已退回发布人修改。";
|
||||
if (d.approvalStatus === "approved") return "该公告已审批通过并发布。";
|
||||
return "";
|
||||
});
|
||||
const showAlert = computed(() => !!alertType.value);
|
||||
|
||||
/* ============ 接收范围 / 元信息 ============ */
|
||||
const receiveLabel = computed(() => {
|
||||
const d: any = detail.value || {};
|
||||
const scopes: any[] = d.pushScopes || [];
|
||||
if (scopes.length) return scopes.map((s) => s.scopeName).join("、");
|
||||
const rl = String(d.ReceiveList || "").trim();
|
||||
if (rl === "ALL") return "全体教师";
|
||||
if (rl.startsWith("[")) return "指定教师名单";
|
||||
return "全部班级";
|
||||
});
|
||||
|
||||
/* ============ 附件 ============ */
|
||||
function downFile(id: number) {
|
||||
if (!id) return;
|
||||
window.location.href = noticeApi.fileDownUrl(id);
|
||||
}
|
||||
function downAll() {
|
||||
if (!detail.value) return;
|
||||
window.location.href = noticeApi.allFileDownUrl(detail.value.serid);
|
||||
}
|
||||
/** 展示名:后端 name=原始文件名,filename=存储相对路径 */
|
||||
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";
|
||||
}
|
||||
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);
|
||||
}
|
||||
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 onPrint() {
|
||||
if (!detail.value) return;
|
||||
const win = window.open("", "_blank");
|
||||
if (!win) return;
|
||||
win.document.write(`
|
||||
<html>
|
||||
<head><title>${detail.value.title || ""}</title></head>
|
||||
<body style="font-family:'Microsoft YaHei',sans-serif;padding:24px;">
|
||||
<h2>${detail.value.title || ""}</h2>
|
||||
<p style="color:#666;font-size:13px;">
|
||||
来源:${detail.value.DeptName || ""} 发布人:${detail.value.TeaName || ""} 发布时间:${detail.value.EditDate || ""}
|
||||
</p>
|
||||
<hr/>
|
||||
<div>${detail.value.Contents || ""}</div>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
win.document.close();
|
||||
win.focus();
|
||||
win.print();
|
||||
}
|
||||
|
||||
/* 时间显示(列表 MM/dd HH:mm,同已收到) */
|
||||
function shortDate(date: string) {
|
||||
const d = new Date(String(date || "").replace(/-/g, "/"));
|
||||
if (isNaN(d.getTime())) return date;
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${pad(d.getMonth() + 1)}/${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-wrapper">
|
||||
<div class="main-container">
|
||||
<div class="accept-wrap">
|
||||
<!-- ============ 左侧:审批列表 ============ -->
|
||||
<div class="title-list">
|
||||
<div class="filter-box">
|
||||
<RadioGroup v-model="auditState" type="button" size="small" @on-change="doSearch">
|
||||
<Radio label="pending">待审批{{ tabCountText(tabCounts.pending) }}</Radio>
|
||||
<Radio label="approved">已通过{{ tabCountText(tabCounts.approved) }}</Radio>
|
||||
<Radio label="rejected">已驳回{{ tabCountText(tabCounts.rejected) }}</Radio>
|
||||
<Radio label="all">全部{{ tabCountText(tabCounts.all) }}</Radio>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<div class="search-box">
|
||||
<Input v-model="keyword" clearable search placeholder="搜索标题、发布人、部门......" @on-search="doSearch" @on-clear="doSearch"></Input>
|
||||
</div>
|
||||
<div class="notice-items" v-if="!loading || list.length">
|
||||
<div
|
||||
v-for="(item, index) in list"
|
||||
:key="item.id"
|
||||
:class="{ list_item: true, active: index === selected }"
|
||||
@click="checkNotice(item, index)"
|
||||
>
|
||||
<div class="tt">
|
||||
<span class="tt-text">{{ item.title }}</span>
|
||||
<span v-if="item.topmost == 1" class="badge badge-top">置顶</span>
|
||||
<span v-if="item.type == 2" class="badge badge-import">重要</span>
|
||||
</div>
|
||||
<div class="other">
|
||||
<span>{{ item.deptName }}</span>
|
||||
<span>{{ shortDate(item.updateTime || item.createTime) }}</span>
|
||||
</div>
|
||||
<div class="other">
|
||||
<span class="state" :class="'state-' + (item.approvalStatus || 'none')">{{ item.approvalStatusDesc || "—" }}</span>
|
||||
<span class="pending-names">{{ (item.pendingApproverNames || []).length ? "待 " + item.pendingApproverNames.join("、") : "" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="notice-items" v-else>
|
||||
<div class="empty">暂无审批任务</div>
|
||||
</div>
|
||||
<div class="list-page" v-if="page.total > pageSize">
|
||||
<Page
|
||||
small
|
||||
simple
|
||||
:total="page.total"
|
||||
:current="pageNum"
|
||||
:page-size="pageSize"
|
||||
@on-change="pageUpdate"
|
||||
></Page>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ 右侧:公告详情 + 审批操作 ============ -->
|
||||
<div class="detail-content" v-if="detail">
|
||||
<div class="notice-title">
|
||||
<div class="title-header">
|
||||
<div class="title-left">
|
||||
<div class="tt">{{ detail.title }}</div>
|
||||
<span class="detail-toggle" @click="approvalOpen = !approvalOpen">
|
||||
<span>{{ approvalOpen ? "收起" : "展开" }}</span>
|
||||
<i class="chevron" :class="{ up: approvalOpen }"></i>
|
||||
</span>
|
||||
</div>
|
||||
<div class="ops fr">
|
||||
<span class="op" :class="{ disabled: prevDisabled }" @click="!prevDisabled && goStep(-1)"><i class="arrow">‹</i><span>上一篇</span></span>
|
||||
<span class="op" :class="{ disabled: nextDisabled }" @click="!nextDisabled && goStep(1)"><span>下一篇</span><i class="arrow">›</i></span>
|
||||
<template v-if="canAct">
|
||||
<span class="op op-pass" @click="openApprove"><i class="iconfont icon-ckpy"></i><span>通过</span></span>
|
||||
<span class="op op-reject" @click="openReject"><i class="iconfont icon-pingbi"></i><span>驳回</span></span>
|
||||
</template>
|
||||
<span class="op" v-else-if="detail.approvalStatus === 'pending'" :title="waitingHint">
|
||||
<i class="iconfont icon-liucheng"></i><span>{{ waitingHint }}</span>
|
||||
</span>
|
||||
<span class="op" @click="onPrint"><i class="iconfont icon-print"></i><span>打印</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="title-content" v-if="approvalOpen">
|
||||
<p>来源:{{ detail.DeptName }}</p>
|
||||
<p>发布人:{{ detail.TeaName }}</p>
|
||||
<p>发布时间:{{ detail.EditDate }}</p>
|
||||
<p>接收范围:{{ receiveLabel }}</p>
|
||||
<p>
|
||||
<span>附件:{{ (detail.ChaoSend || []).length }}个</span>
|
||||
<span class="to-file" v-if="detail.ChaoSend && detail.ChaoSend.length > 0" @click="downAll">全部下载</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- v27:提示条并入折叠区(随 approvalOpen 收起),折叠开关已移到标题行 -->
|
||||
<div class="approval-bar" v-if="showAlert && approvalOpen">
|
||||
<Alert :type="alertType" show-icon banner class="approval-alert">
|
||||
<div class="alert-row">
|
||||
<span class="alert-text">{{ alertText }}</span>
|
||||
</div>
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
<div class="detail-scroll" v-if="approvalOpen">
|
||||
<!-- 审批流程:多步进度(步名·表决方式·审批人·每人结论与意见) -->
|
||||
<div class="flow-block" v-if="detail.approvalFlow && detail.approvalFlow.length">
|
||||
<div class="block-title">审批流程</div>
|
||||
<div class="flow-step" v-for="(step, si) in detail.approvalFlow" :key="'flow_' + si">
|
||||
<div class="flow-head">
|
||||
<span class="step-no">{{ si + 1 }}</span>
|
||||
<span class="step-name">{{ step.name }}</span>
|
||||
<span class="step-vote">{{ voteText(step.vote) }}</span>
|
||||
<span class="step-state" :style="{ color: flowStatusColor(step.status) }">{{ flowStatusText(step.status) }}</span>
|
||||
</div>
|
||||
<div class="flow-approvers">
|
||||
审批人:<b v-for="(n, ni) in step.approverNames || []" :key="'ap_' + ni">{{ personName(n, (step.approvers || [])[ni]) }}</b>
|
||||
<span v-if="!(step.approverNames || []).length">—</span>
|
||||
</div>
|
||||
<div class="flow-records" v-if="step.records && step.records.length">
|
||||
<div class="flow-record" v-for="(r, ri) in step.records" :key="'fr_' + ri">
|
||||
<span :class="'res-' + r.result">{{ r.result === "rejected" ? "驳回" : "通过" }}</span>
|
||||
<b>{{ personName(r.name, r.account) }}</b>
|
||||
<span class="time">{{ r.time }}</span>
|
||||
<span class="remark" v-if="r.remark">{{ r.remark }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flow-pending" v-if="step.pending && step.pending.length">
|
||||
待签:{{ step.pending.map((p) => personName(p, "")).join("、") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 审批轨迹:提交与各级处理留痕(按时间顺序) -->
|
||||
<div class="track-block" v-if="detail.approvalRecords && detail.approvalRecords.length">
|
||||
<div class="block-title">审批轨迹</div>
|
||||
<div class="track-item" v-for="(rec, ri) in detail.approvalRecords" :key="'tr_' + ri">
|
||||
<span class="dot" :class="'dot-' + rec.action"></span>
|
||||
<div class="track-main">
|
||||
<div class="track-line1">
|
||||
<b>{{ personName(rec.operatorName, rec.operatorAccount) }}</b>
|
||||
<span :class="'act-' + rec.action">{{ rec.actionDesc || actionText(rec.action) }}</span>
|
||||
<span class="time">{{ rec.actionTime }}</span>
|
||||
</div>
|
||||
<div class="track-line2" v-if="rec.opinion">意见:{{ rec.opinion }}</div>
|
||||
<div class="track-line2" v-if="rec.assigneeTeaIds && rec.assigneeTeaIds.length">
|
||||
选定审批人 {{ rec.assigneeTeaIds.length }} 人
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 正文与附件 -->
|
||||
<div class="notice-content">
|
||||
<div class="ql-editor" v-html="detail.Contents"></div>
|
||||
<div class="file" v-if="detail.ChaoSend && detail.ChaoSend.length > 0">
|
||||
<div class="file-title">
|
||||
<span>附件({{ detail.ChaoSend.length }}个)</span>
|
||||
<span class="all-down" @click="downAll">全部下载</span>
|
||||
</div>
|
||||
<div class="file-list">
|
||||
<div class="file-item" v-for="item in detail.ChaoSend" :key="item.id">
|
||||
<div class="left">
|
||||
<i class="iconfont file-type-icon" :class="getFileIcon(displayName(item))"></i>
|
||||
</div>
|
||||
<div class="center">
|
||||
<div class="name" :title="displayName(item)">{{ displayName(item) }}</div>
|
||||
<div class="size">{{ Utils.getFileSize(item.size) }}</div>
|
||||
</div>
|
||||
<div class="right">
|
||||
<a
|
||||
href="javascript:void(0);"
|
||||
class="mr-8"
|
||||
v-if="getIsPreview(displayName(item))"
|
||||
@click="openPreview(item)"
|
||||
>查看</a
|
||||
>
|
||||
<a href="javascript:void(0);" @click="downFile(item.fileid || item.id)">下载</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-content empty-detail" v-else>
|
||||
<span>{{ detailLoading ? "加载中..." : "暂无公告" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 附件预览弹窗 -->
|
||||
<FilePreviewModal v-model="previewState.show" :file-id="previewState.fileId" :file-name="previewState.fileName" />
|
||||
|
||||
<!-- 通过弹窗(意见可空) -->
|
||||
<Modal v-model="approveModal" title="审批通过" :mask-closable="false" width="460px">
|
||||
<Form :label-width="80">
|
||||
<FormItem label="公告标题">
|
||||
<span>{{ detail?.title }}</span>
|
||||
</FormItem>
|
||||
<FormItem label="审批进度">
|
||||
<span>{{ detail?.approvalStatusDesc }}</span>
|
||||
</FormItem>
|
||||
<FormItem label="审批意见">
|
||||
<Input v-model="approveOpinion" type="textarea" :rows="3" placeholder="审批意见(可空)"></Input>
|
||||
</FormItem>
|
||||
</Form>
|
||||
<p class="approve-hint">会签需全部通过、或签任一人通过即进入下一步;最后一步通过后公告发布。</p>
|
||||
<template #footer>
|
||||
<Button type="success" :loading="approving" @click="doApprove">确认通过</Button>
|
||||
<Button @click="approveModal = false" style="margin-left: 8px">取消</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- 驳回弹窗(意见必填) -->
|
||||
<Modal v-model="rejectModal" title="驳回公告" :mask-closable="false" width="460px">
|
||||
<Form :label-width="80">
|
||||
<FormItem label="公告标题">
|
||||
<span>{{ detail?.title }}</span>
|
||||
</FormItem>
|
||||
<FormItem label="审批意见">
|
||||
<Input v-model="rejectRemark" type="textarea" :rows="3" placeholder="请填写驳回原因,将退回发布人"></Input>
|
||||
</FormItem>
|
||||
</Form>
|
||||
<template #footer>
|
||||
<Button type="error" :loading="rejecting" @click="doReject">确认驳回</Button>
|
||||
<Button @click="rejectModal = false" style="margin-left: 8px">取消</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* 根容器 page-wrapper > main-container:内容区可视高度 = 100vh - 112(头部+面包屑) - 24(容器底padding) */
|
||||
.accept-wrap {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
height: calc(100vh - 136px);
|
||||
}
|
||||
|
||||
/* ---------------- 左侧列表(与已收到同款) ---------------- */
|
||||
.title-list {
|
||||
width: 360px;
|
||||
flex: none;
|
||||
background: #fff;
|
||||
border: 1px solid #ebeef4;
|
||||
border-radius: 3px;
|
||||
padding: 14px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.filter-box {
|
||||
padding: 0 15px 0;
|
||||
/* v19.1:四个状态 tab(含条数)一行等宽显示。
|
||||
库的按钮式 Radio 本是「首尾相接的分段控件」:非首项 border-left:0 靠 ::before 分隔线补位、
|
||||
checked 用 box-shadow:-1px 0 0 0 补左边界、first/last 各只切一半圆角。
|
||||
v13 加的 gap:6px 把分段拆成独立 chip 后这些机制全部错位(游离的蓝/灰短竖线、方角、
|
||||
checked+focus 的 2px 蓝晕与 ::after 蓝条叠成「双边框」),故回到原生无间隙分段形态,
|
||||
只做等宽与字号收紧,并清掉分隔伪元素与选中/聚焦阴影——选中态即蓝边蓝字。 */
|
||||
:deep(.ivu-radio-group) {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
width: 100%;
|
||||
}
|
||||
:deep(.ivu-radio-group-button .ivu-radio-wrapper) {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
padding: 0 4px;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
&:before,
|
||||
&:after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
:deep(.ivu-radio-group-button .ivu-radio-wrapper-checked),
|
||||
:deep(.ivu-radio-group-button .ivu-radio-wrapper-checked.ivu-radio-focus) {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
.search-box {
|
||||
padding: 10px 15px 0;
|
||||
}
|
||||
.notice-items {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
margin-top: 12px;
|
||||
.list_item {
|
||||
padding: 10px 15px;
|
||||
cursor: pointer;
|
||||
border-left: 3px solid transparent;
|
||||
.tt {
|
||||
font-weight: 600;
|
||||
color: #272829;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
.tt-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.badge {
|
||||
flex: none;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
line-height: 16px;
|
||||
padding: 0 6px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.badge-top {
|
||||
background: #fef3c7;
|
||||
color: #d97706;
|
||||
}
|
||||
.badge-import {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
}
|
||||
.other {
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
.state {
|
||||
&.state-pending {
|
||||
color: #ff9900;
|
||||
}
|
||||
&.state-approved {
|
||||
color: #19be6b;
|
||||
}
|
||||
&.state-rejected {
|
||||
color: #ed4014;
|
||||
}
|
||||
}
|
||||
.pending-names {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 190px;
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
background: #f6f9ff;
|
||||
}
|
||||
&.active {
|
||||
background: #ecf3ff;
|
||||
border-left-color: #2d8cf0;
|
||||
}
|
||||
}
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
padding: 40px 0;
|
||||
}
|
||||
}
|
||||
.list-page {
|
||||
padding: 8px 15px 0;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 右侧详情 ---------------- */
|
||||
.detail-content {
|
||||
flex: 1;
|
||||
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;
|
||||
}
|
||||
/* v21:标题+操作行常驻不滚;其下 Alert/流程/轨迹/正文/附件整体一个滚动区。
|
||||
此前仅 .notice-content 滚动,flow/track 条目一多即把正文挤到 0 高并被 overflow:hidden 裁切 */
|
||||
.notice-title {
|
||||
flex: none;
|
||||
}
|
||||
.detail-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.title-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
.title-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
.tt {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #272829;
|
||||
}
|
||||
/* v27:折叠开关常驻标题行——工程无对应 iconfont 字形,chevron 用边框绘制,收起态朝下、展开态旋转朝上 */
|
||||
.detail-toggle {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: #808695;
|
||||
white-space: nowrap;
|
||||
&:hover {
|
||||
color: #2d8cf0;
|
||||
}
|
||||
.chevron {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-right: 1.5px solid currentColor;
|
||||
border-bottom: 1.5px solid currentColor;
|
||||
transform: rotate(45deg);
|
||||
transition: transform 0.2s ease;
|
||||
&.up {
|
||||
transform: rotate(-135deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
.ops {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex: none;
|
||||
.op {
|
||||
cursor: pointer;
|
||||
color: #515a6e;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
i.arrow {
|
||||
font-style: normal;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
&:hover {
|
||||
color: #2d8cf0;
|
||||
}
|
||||
&.disabled {
|
||||
color: #c5c8ce;
|
||||
cursor: not-allowed;
|
||||
&:hover {
|
||||
color: #c5c8ce;
|
||||
}
|
||||
}
|
||||
&.op-pass:hover {
|
||||
color: #19be6b;
|
||||
}
|
||||
&.op-reject:hover {
|
||||
color: #ed4014;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.title-content {
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px dashed #e8e8e8;
|
||||
font-size: 13px;
|
||||
color: #808695;
|
||||
p {
|
||||
margin: 4px 0;
|
||||
}
|
||||
.to-file {
|
||||
margin-left: 10px;
|
||||
color: #2d8cf0;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
/* v23:状态提示条常驻——脱离滚动区,flex:none 固定在标题/元信息之下 */
|
||||
.approval-bar {
|
||||
flex: none;
|
||||
.approval-alert {
|
||||
margin: 10px 0 0;
|
||||
}
|
||||
/* Alert 默认插槽包在行内 span.ivu-alert-message 内,提为块级满宽让 .alert-row 撑满 */
|
||||
:deep(.ivu-alert-message) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
.alert-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
.alert-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
/* v21:滚动职责已上移到 .detail-scroll,正文随包裹层自然流式 */
|
||||
.notice-content {
|
||||
padding-top: 10px;
|
||||
.file {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
margin-top: 16px;
|
||||
.file-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
background: #f7f7f7;
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
.all-down {
|
||||
color: #2d8cf0;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
.file-list {
|
||||
padding: 10px 12px;
|
||||
.file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 6px;
|
||||
border-bottom: 1px dashed #f0f0f0;
|
||||
.file-type-icon {
|
||||
font-size: 26px;
|
||||
color: #515a6e;
|
||||
}
|
||||
.center {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
.name {
|
||||
color: #272829;
|
||||
font-size: 13px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.size {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
.right a {
|
||||
margin-right: 8px;
|
||||
color: #2d8cf0;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 审批流程 / 轨迹 ---------------- */
|
||||
.block-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #272829;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.flow-block,
|
||||
.track-block {
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
background: #fafcff;
|
||||
border: 1px solid #ebeef4;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.flow-step {
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px dashed #eceff5;
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.flow-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
.step-no {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
border-radius: 50%;
|
||||
background: #2d8cf0;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
}
|
||||
.step-name {
|
||||
font-weight: 600;
|
||||
color: #272829;
|
||||
}
|
||||
.step-vote {
|
||||
font-size: 12px;
|
||||
color: #808695;
|
||||
border: 1px solid #e8eaec;
|
||||
border-radius: 2px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.step-state {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
.flow-approvers {
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
color: #515a6e;
|
||||
b {
|
||||
font-weight: 400;
|
||||
color: #272829;
|
||||
margin-right: 6px;
|
||||
}
|
||||
}
|
||||
.flow-records {
|
||||
margin-top: 6px;
|
||||
.flow-record {
|
||||
font-size: 12px;
|
||||
color: #808695;
|
||||
line-height: 22px;
|
||||
.res-approved {
|
||||
color: #19be6b;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.res-rejected {
|
||||
color: #ed4014;
|
||||
margin-right: 4px;
|
||||
}
|
||||
b {
|
||||
color: #272829;
|
||||
font-weight: 400;
|
||||
margin-right: 8px;
|
||||
}
|
||||
.remark {
|
||||
margin-left: 8px;
|
||||
color: #515a6e;
|
||||
}
|
||||
}
|
||||
}
|
||||
.flow-pending {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #ff9900;
|
||||
}
|
||||
}
|
||||
.track-item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 6px 0;
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-top: 6px;
|
||||
flex: none;
|
||||
background: #c5c8ce;
|
||||
&.dot-1 {
|
||||
background: #2d8cf0;
|
||||
}
|
||||
&.dot-2 {
|
||||
background: #19be6b;
|
||||
}
|
||||
&.dot-3 {
|
||||
background: #ed4014;
|
||||
}
|
||||
}
|
||||
.track-main {
|
||||
flex: 1;
|
||||
.track-line1 {
|
||||
font-size: 13px;
|
||||
b {
|
||||
color: #272829;
|
||||
font-weight: 600;
|
||||
margin-right: 8px;
|
||||
}
|
||||
.act-1 {
|
||||
color: #2d8cf0;
|
||||
}
|
||||
.act-2 {
|
||||
color: #19be6b;
|
||||
}
|
||||
.act-3 {
|
||||
color: #ed4014;
|
||||
}
|
||||
.time {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
.track-line2 {
|
||||
font-size: 12px;
|
||||
color: #808695;
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.approve-hint {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 6px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,237 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, nextTick, h } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useRequest } from "alova/client";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { noticeApi } from "@/api/module/notice";
|
||||
import type { NoticeVo, NoticeQuery } from "@/types/notice";
|
||||
import ApprovalFlowEditor, { type FlowModel } from "@/components/ApprovalFlowEditor.vue";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import Utils from "@/utils";
|
||||
|
||||
/**
|
||||
* 草稿箱:未发送的通知草稿(status=0 本人)。
|
||||
* 操作:继续编辑(带参进写通知)、提交审批(选审批人后 Submit,空选复用草稿已存流程定义)、删除。
|
||||
*/
|
||||
const router = useRouter();
|
||||
|
||||
const keyword = ref("");
|
||||
const pageNum = ref(1);
|
||||
const pageSize = ref(parseInt(localStorage.getItem("customPageCount") || "10"));
|
||||
|
||||
const { data: page, loading, send: reload } = useRequest(
|
||||
(params: NoticeQuery) => noticeApi.draftList(params),
|
||||
{ immediate: false, initialData: { content: [], total: 0, pageIndex: 1, pageSize: 10, totalPages: 0, hasNext: false } }
|
||||
);
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
await reload({ keyword: keyword.value || "", pageIndex: pageNum.value, pageSize: pageSize.value, orderByTime: "2" });
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "获取草稿列表失败");
|
||||
}
|
||||
}
|
||||
function doSearch() {
|
||||
pageNum.value = 1;
|
||||
run();
|
||||
}
|
||||
function pageUpdate(p: number) {
|
||||
pageNum.value = p;
|
||||
run();
|
||||
}
|
||||
function pageSizeUpdate(size: number) {
|
||||
pageNum.value = 1;
|
||||
pageSize.value = size;
|
||||
localStorage.setItem("customPageCount", String(size));
|
||||
run();
|
||||
}
|
||||
|
||||
onMounted(run);
|
||||
|
||||
/* ---------- 继续编辑 ---------- */
|
||||
function edit(row: NoticeVo) {
|
||||
router.push({ path: "/list/new", query: { serid: row.id } });
|
||||
}
|
||||
|
||||
/* ---------- 删除 ---------- */
|
||||
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 || "删除失败");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- 提交审批(弹窗选审批人 → Submit) ---------- */
|
||||
const submitModal = ref(false);
|
||||
const submitRow = ref<NoticeVo | null>(null);
|
||||
const flowModel = ref<FlowModel>({ enabled: false, flow: [] });
|
||||
const flowEditorRef = ref();
|
||||
const submitting = ref(false);
|
||||
|
||||
function openSubmit(row: NoticeVo) {
|
||||
submitRow.value = row;
|
||||
flowModel.value = { enabled: false, flow: [] };
|
||||
submitModal.value = true;
|
||||
// 弹窗复用同一编辑器实例,打开时重置内部勾选与步骤
|
||||
nextTick(() => flowEditorRef.value?.setFlow(null));
|
||||
}
|
||||
|
||||
async function doSubmit() {
|
||||
if (!submitRow.value) return;
|
||||
// 勾了审批但一步都没选人 → 拦截;未勾选(不重选人)则沿用草稿已存流程定义
|
||||
if (flowEditorRef.value && !flowEditorRef.value.validate()) {
|
||||
Message.info("请为审批流程选择审批人,或取消勾选");
|
||||
return;
|
||||
}
|
||||
const flow = flowModel.value.flow || [];
|
||||
submitting.value = true;
|
||||
try {
|
||||
const steps = flow.length
|
||||
? flow.map((s, i) => ({ name: `第${i + 1}步`, vote: s.vote, approverTeaIds: [...s.approverTeaIds] }))
|
||||
: undefined;
|
||||
await noticeApi.submit({ serid: submitRow.value.id, steps }).send();
|
||||
Message.success("已提交审批,可在「已发管理」查看进度");
|
||||
submitModal.value = false;
|
||||
run();
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "提交审批失败");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 表格 ---------- */
|
||||
const columns = [
|
||||
{ title: "序号", type: "index", width: 90, align: "center" },
|
||||
{
|
||||
title: "标题",
|
||||
key: "title",
|
||||
align: "left",
|
||||
tooltip: true,
|
||||
render: (h: any, p: any) => {
|
||||
const row: any = p.row;
|
||||
const children = [h("span", {}, row.title)];
|
||||
if (row.timingSendTime) {
|
||||
children.push(h("Tag", { style: "margin-left:6px" }, () => "定时"));
|
||||
}
|
||||
return h("div", {}, children);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "类型",
|
||||
width: 100,
|
||||
align: "center",
|
||||
render: (h: any, p: any) =>
|
||||
h("Tag", { color: p.row.type == 2 ? "error" : "default" }, () => p.row.typeDesc || (p.row.type == 2 ? "重要" : "普通")),
|
||||
},
|
||||
{
|
||||
title: "定时发送",
|
||||
width: 170,
|
||||
align: "left",
|
||||
render: (h: any, p: any) => h("span", {}, p.row.timingSendTime ? String(p.row.timingSendTime).replace("T", " ").slice(0, 16) : "—"),
|
||||
},
|
||||
{
|
||||
title: "最后修改",
|
||||
width: 170,
|
||||
align: "left",
|
||||
render: (h: any, p: any) => h("span", {}, String(p.row.updateTime || p.row.createTime || "").replace("T", " ").slice(0, 16)),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
width: 220,
|
||||
align: "center",
|
||||
fixed: "right",
|
||||
render: (h: any, p: any) => {
|
||||
const row: any = p.row;
|
||||
return [
|
||||
Utils.operatingHandle(h, "继续编辑", "icon-xiugaijl", () => edit(row)),
|
||||
Utils.operatingHandle(h, "提交审批", "icon-shenpi", () => openSubmit(row)),
|
||||
Utils.operatingHandle(h, "删除草稿", "icon-ashbin", () => del(row)),
|
||||
];
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const tableHeight = computed(() => Utils.useTableHeight({ isSearch: true, isOperat: true, isPage: true }));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-wrapper">
|
||||
<div class="main-container">
|
||||
<!-- 搜索区 -->
|
||||
<div class="search-wrap fix">
|
||||
<Button class="fl" type="primary" size="large" @click="router.push('/list/new')">写公告</Button>
|
||||
<Input
|
||||
class="fr"
|
||||
style="width: 220px"
|
||||
search
|
||||
suffix="ios-search"
|
||||
placeholder="输入标题关键字搜索"
|
||||
v-model="keyword"
|
||||
@on-search="doSearch"
|
||||
@on-clear="doSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 表格 + 分页 -->
|
||||
<Table
|
||||
:columns="columns"
|
||||
:data="page.content"
|
||||
class="link-table table-bottom-box"
|
||||
:height="tableHeight"
|
||||
:loading="loading"
|
||||
no-data-text="草稿箱是空的"
|
||||
></Table>
|
||||
<div class="page-panel">
|
||||
<div class="fl ml-15 align-middle"></div>
|
||||
<Page
|
||||
class="page-box fr"
|
||||
:total="page.total"
|
||||
:current="pageNum"
|
||||
:page-size="pageSize"
|
||||
:page-size-opts="[10, 20, 30, 40]"
|
||||
@on-page-size-change="pageSizeUpdate"
|
||||
@on-change="pageUpdate"
|
||||
placement="top"
|
||||
show-total
|
||||
show-sizer
|
||||
></Page>
|
||||
</div>
|
||||
|
||||
<!-- 提交审批弹窗 -->
|
||||
<Modal v-model="submitModal" title="提交审批" :mask-closable="false" width="620px">
|
||||
<p class="submit-tip">公告:{{ submitRow?.title }}</p>
|
||||
<ApprovalFlowEditor ref="flowEditorRef" v-model="flowModel" unchecked-text="(不勾选=沿用草稿已存流程)"></ApprovalFlowEditor>
|
||||
<p class="submit-hint">不重选审批人时,将沿用草稿保存时配置的审批流程。</p>
|
||||
<template #footer>
|
||||
<Button type="warning" :loading="submitting" @click="doSubmit">提交审批</Button>
|
||||
<Button @click="submitModal = false" style="margin-left: 8px">取消</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.submit-tip {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.submit-hint {
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,534 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, shallowRef, computed, onMounted, onBeforeUnmount, nextTick } 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 } from "@/api/module/notice";
|
||||
import type { NoticeDetailVo, NoticeRequest, PushScopeItem, UpFileVo } from "@/types/notice";
|
||||
import ClassScopeInput from "@/components/ClassScopeInput.vue";
|
||||
import ApprovalFlowEditor, { type FlowModel } from "@/components/ApprovalFlowEditor.vue";
|
||||
import NoticeUpload from "@/components/NoticeUpload.vue";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import config from "@/assets/config/host.config";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
/* 页面模式:serid=编辑/草稿续编/驳回重提 / fid=转发 / 默认新建 */
|
||||
const serid = computed(() => Number(route.query.serid) || 0);
|
||||
const fid = computed(() => Number(route.query.fid) || 0);
|
||||
const isEdit = computed(() => serid.value > 0);
|
||||
|
||||
const form = reactive({
|
||||
title: "",
|
||||
dept: "",
|
||||
type: 1, // 重要程度:1 普通 / 2 重要
|
||||
content: "",
|
||||
isTopmost: false,
|
||||
isShortMsg: false,
|
||||
isReceipt: false, // 学员阅读回执 receiptFlag
|
||||
});
|
||||
/* 接收班级(v5):全部班级(scopeType=1 全校) / 指定班级(scopeType=4 班级列表) */
|
||||
const pushScopes = ref<PushScopeItem[]>([]);
|
||||
const scopePanelRef = ref();
|
||||
const fileids = ref<number[]>([]);
|
||||
const editFileList = ref<{ name: string; fileid: number }[]>([]);
|
||||
const isSms = ref(false);
|
||||
/** 编辑源状态:0 草稿 / 1 已发布 / 3 已驳回([修订稿] 前缀、删除按钮与跳转目标依据) */
|
||||
const editStatus = ref(1);
|
||||
|
||||
/** 发布类动作(直接发布 / 定时发送)是否可用:新建与草稿续编可发布(走 Create 落已发布新记录),
|
||||
* 已驳回须改对内容后重新提交审批,已发布修订只走 Edit 保存(v14) */
|
||||
const publishViaCreate = computed(() => !isEdit.value || editStatus.value === 0);
|
||||
/** 草稿按钮文案:已发布修订点它只保存内容、不改状态,仍叫「保存」以免名实不符;其余为「存为草稿」 */
|
||||
const draftBtnText = computed(() => (isEdit.value && editStatus.value === 1 ? "保存" : "存为草稿"));
|
||||
|
||||
/* ============ 审批:选人即送审(默认一步·或签,可展开多步会签/或签) ============ */
|
||||
const flowEditorRef = ref();
|
||||
const flowModel = ref<FlowModel>({ enabled: false, flow: [] });
|
||||
|
||||
/* 旧项目验证规则(oldfront/new_notice.vue ruleValidate) */
|
||||
const ruleValidate = {
|
||||
title: [
|
||||
{ required: true, message: "请输入标题", trigger: "blur" },
|
||||
{ max: 200, message: "公告标题太长,请修改标题长度" },
|
||||
],
|
||||
dept: [{ required: true, message: "请选择发布部门", trigger: "change" }],
|
||||
};
|
||||
|
||||
/* 发文部门下拉 */
|
||||
const { data: deptOptions, loading: deptLoading } = useRequest(noticeApi.getSysDic());
|
||||
|
||||
/* 是否开通短信 */
|
||||
onMounted(async () => {
|
||||
try {
|
||||
isSms.value = !!(await noticeApi.getSchModuleIsSms().send());
|
||||
} catch {
|
||||
isSms.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
/* ============ 富文本编辑器(wangeditor v5 for vue3) ============ */
|
||||
const editorRef = shallowRef();
|
||||
const editorLoading = ref(true);
|
||||
const toolbarConfig = { excludeKeys: ["group-video", "fullScreen"] };
|
||||
const serviceBase = config.isMock ? "" : config.serviceHost;
|
||||
const editorConfig = {
|
||||
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",
|
||||
});
|
||||
// ApiResponseAdvice 包装为 {code,msg,data},data 为 UpFileVo[](id/fileTrueName/fileName)
|
||||
const res: { code: number; msg?: string; data?: UpFileVo[] } = await resp.json();
|
||||
if (res.code === 0 && Array.isArray(res.data) && res.data.length) {
|
||||
const f = res.data[0];
|
||||
// img src 必须是浏览器可直接访问的地址:Preview inline 输出图片(Down 是 attachment 不适合内嵌)
|
||||
const url = `${serviceBase}/api/file/Preview?id=${f.id}`;
|
||||
insertFn(url, f.fileTrueName || "", url);
|
||||
} else {
|
||||
Message.error(res.msg || "图片上传失败");
|
||||
}
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "图片上传失败");
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
function handleCreated(editor: any) {
|
||||
editorRef.value = editor;
|
||||
editorLoading.value = false;
|
||||
}
|
||||
onBeforeUnmount(() => {
|
||||
editorRef.value?.destroy();
|
||||
});
|
||||
|
||||
/* ============ 定时发送 ============ */
|
||||
const isModal = reactive({ timiSend: false });
|
||||
const timeForm = reactive({ date1: "", date2: "" });
|
||||
const dateOpt = {
|
||||
disabledDate(date: Date) {
|
||||
return date && date.valueOf() < Date.now() - 86400000;
|
||||
},
|
||||
};
|
||||
|
||||
function dateFormat(d: Date, fmt: string) {
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
const o: Record<string, number> = {
|
||||
"M+": d.getMonth() + 1,
|
||||
"d+": d.getDate(),
|
||||
"H+": d.getHours(),
|
||||
"m+": d.getMinutes(),
|
||||
"s+": d.getSeconds(),
|
||||
};
|
||||
if (/(y+)/.test(fmt)) fmt = fmt.replace(RegExp.$1, (d.getFullYear() + "").substring(4 - RegExp.$1.length));
|
||||
for (const k in o) {
|
||||
if (new RegExp("(" + k + ")").test(fmt)) {
|
||||
fmt = fmt.replace(RegExp.$1, RegExp.$1.length == 1 ? String(o[k]) : pad(o[k]));
|
||||
}
|
||||
}
|
||||
return fmt;
|
||||
}
|
||||
|
||||
/* ============ 编辑 / 转发回显(对齐 api-test openComposeEdit:转发=全量预填+【转发】前缀) ============ */
|
||||
onMounted(async () => {
|
||||
if (isEdit.value || fid.value > 0) {
|
||||
const sourceId = isEdit.value ? serid.value : fid.value;
|
||||
const isForward = fid.value > 0 && !isEdit.value;
|
||||
try {
|
||||
const detail: NoticeDetailVo = await noticeApi.getNoticeDetails(sourceId).send();
|
||||
if (isEdit.value) {
|
||||
editStatus.value = detail.status ?? 1;
|
||||
// 驳回/发布修订保留旧版「去 [修订稿] 前缀」行为;草稿原样回显
|
||||
form.title =
|
||||
editStatus.value === 0 ? detail.title || "" : (detail.title || "").replace("[修订稿]", "");
|
||||
} else {
|
||||
form.title = "【转发】" + (detail.title || "");
|
||||
}
|
||||
form.dept = detail.DeptID || "";
|
||||
form.type = detail.type === 2 ? 2 : 1;
|
||||
form.isTopmost = detail.topmost === 1;
|
||||
form.isShortMsg = detail.smsFlag === 1;
|
||||
form.isReceipt = detail.receiptFlag === 1;
|
||||
if (detail.DsfsTime) timeForm.date1 = detail.DsfsTime;
|
||||
if (isEdit.value) {
|
||||
// 接收班级回显(v5 二选一):pushScopes 原样;旧链路班级通知(receiveType=class)按班级 id
|
||||
// 映射 scopeType=4(组件按树解析真实班级名);tlist=all → 全部班级
|
||||
let scopes: PushScopeItem[] = [];
|
||||
if (Array.isArray(detail.pushScopes) && detail.pushScopes.length) {
|
||||
scopes = detail.pushScopes.map((s) => ({ ...s }));
|
||||
} else if (detail.receiveType === "class" && Array.isArray(detail.tlist) && detail.tlist.length) {
|
||||
scopes = detail.tlist.map((id: any) => ({
|
||||
scopeType: 4,
|
||||
scopeId: Number(id),
|
||||
scopeName: `班级${id}`,
|
||||
}));
|
||||
} else if (detail.tlist === "all") {
|
||||
scopes = [{ scopeType: 1, scopeId: 0, scopeName: "全部班级" }];
|
||||
}
|
||||
nextTick(() => scopePanelRef.value?.setScopes(scopes));
|
||||
} else {
|
||||
nextTick(() => scopePanelRef.value?.setScopes(detail.pushScopes || []));
|
||||
}
|
||||
form.content = detail.Contents || "";
|
||||
// 审批流回显:仅编辑态还原“选人即送审”的多步配置(转发不继承审批流,api-test 同口径)
|
||||
if (isEdit.value && (detail as any).approvalEnabled && Array.isArray((detail as any).approvalFlow)) {
|
||||
// 仅回显教师树路径步骤(approverTeaIds);旧账号路径单不回显(已知限制,api-test 同口径)
|
||||
const flowSteps = (detail as any).approvalFlow.filter((l: any) => (l.approverTeaIds || []).length);
|
||||
const mv: FlowModel = {
|
||||
enabled: true,
|
||||
flow: flowSteps.map((l: any) => ({
|
||||
vote: l.vote === "all" ? "all" : "any",
|
||||
approverTeaIds: [...(l.approverTeaIds || [])],
|
||||
})),
|
||||
};
|
||||
const names = flowSteps.map((l: any) => l.approverNames || []);
|
||||
nextTick(() => flowEditorRef.value?.setFlow(mv, names));
|
||||
}
|
||||
if (Array.isArray(detail.ChaoSend) && detail.ChaoSend.length) {
|
||||
editFileList.value = detail.ChaoSend.map((f) => ({ name: f.filename || f.name, fileid: f.fileid || f.id }));
|
||||
fileids.value = editFileList.value.map((f) => f.fileid);
|
||||
}
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "加载公告失败");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/* ============ 提交 ============ */
|
||||
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 });
|
||||
const { send: sendDraft } = useRequest((payload: NoticeRequest) => noticeApi.saveDraft(payload), { immediate: false });
|
||||
const { send: sendSubmit } = useRequest((payload: { serid: number; steps?: any[] }) => noticeApi.submit(payload), { immediate: false });
|
||||
|
||||
async function handleValidate(): Promise<boolean> {
|
||||
// view-ui-plus validate() 以 resolve(valid) 返回校验结果,不 reject
|
||||
const valid = await formRef.value?.validate();
|
||||
if (!valid) {
|
||||
Message.info("请填写红色星号内容!");
|
||||
}
|
||||
return !!valid;
|
||||
}
|
||||
|
||||
/** 组装请求体;timing 携带定时时间(仅直接发布/编辑保存)。
|
||||
* withFlow=true(提交审批/存草稿)时携带流程定义;编辑态对齐 api-test:
|
||||
* flow 恒携带(未勾选=空数组清空流程定义),approverTeaIds 传空数组清旧教师快照。 */
|
||||
function buildBody(withFlow: boolean): NoticeRequest {
|
||||
const deptObj = (deptOptions.value || []).find((d) => String(d.k) === String(form.dept));
|
||||
const data: NoticeRequest = {
|
||||
title: form.title,
|
||||
content: form.content,
|
||||
type: form.type,
|
||||
deptId: form.dept,
|
||||
deptName: deptObj ? deptObj.v : "", // 字典缺失时后端回退该名称(api-test buildNoticeBody 同口径)
|
||||
pushScopes: pushScopes.value,
|
||||
topmost: form.isTopmost ? 1 : 0,
|
||||
receiptFlag: form.isReceipt ? 1 : 0,
|
||||
fileIds: fileids.value.join(","), //附件
|
||||
smsFlag: form.isShortMsg ? 1 : 0, //是否发送短信
|
||||
};
|
||||
if (withFlow && flowModel.value.enabled && flowModel.value.flow.length) {
|
||||
data.flow = flowModel.value.flow;
|
||||
}
|
||||
// 编辑态:未勾审批也带 flow:[](后端 status!=已发布 时清空流程定义);旧教师快照一并清空
|
||||
if (isEdit.value) {
|
||||
if (!data.flow) data.flow = [];
|
||||
data.approverTeaIds = [];
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 校验定时时间并写入 body;不合法返回 false */
|
||||
function applyTiming(data: NoticeRequest, fromModal: boolean): boolean {
|
||||
if (fromModal) {
|
||||
if (!timeForm.date1) {
|
||||
Message.info("请选择发送日期!");
|
||||
return false;
|
||||
}
|
||||
if (!timeForm.date2) {
|
||||
Message.info("请选择发送时间!");
|
||||
return false;
|
||||
}
|
||||
const d = new Date(timeForm.date1);
|
||||
const [h, m] = String(timeForm.date2).split(":").map(Number);
|
||||
d.setHours(h || 0, m || 0, 0, 0);
|
||||
if (d.getTime() < Date.now()) {
|
||||
Message.info("请选择大于当前的时间!");
|
||||
return false;
|
||||
}
|
||||
data.timingSendTime = dateFormat(d, "yyyy-MM-dd HH:mm:ss");
|
||||
return true;
|
||||
}
|
||||
// 编辑时已带定时时间:仅当该时间仍在未来才继续沿用排期;
|
||||
// 已过期的排期(如被驳回/暂存的草稿带着旧的定时发送时间,且页面无「定时发送」入口可改)视为失效,
|
||||
// 静默丢弃而非报错阻断——否则「提交审批/存为草稿」会被这条过期时间卡死(v20)。
|
||||
// 后端 edit() 仅在 timingSendTime!=null 时更新该列,故此处不写即保留原值;驳回重提走送审链路,
|
||||
// 且 isTiming 把过期时间判为「立即发布」,不写不会有副作用。
|
||||
if (isEdit.value && timeForm.date1) {
|
||||
const t = new Date(timeForm.date1).getTime();
|
||||
if (t && t > Date.now()) {
|
||||
data.timingSendTime = dateFormat(new Date(timeForm.date1), "yyyy-MM-dd HH:mm:ss");
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 提交入口(v14 四态):direct 直接发布 / draft 存为草稿 / approve 提交审批 / isTime 定时发送(按 direct 排期)。
|
||||
* 新建:draft→SaveDraft,其余→Create;编辑态:direct/isTime 且源为草稿→Create 新记录 + 删原草稿,
|
||||
* draft/approve 及已发布修订→Edit(Edit 不改状态,approve 追加 Submit 送审)。 */
|
||||
async function doAdd(action: "direct" | "draft" | "approve" | "isTime" = "direct") {
|
||||
const isTime = action === "isTime";
|
||||
const act = isTime ? "direct" : action;
|
||||
if (!(await handleValidate())) return;
|
||||
// 草稿不圈人可先存;直接发布/提交审批须至少圈定接收班级(v5:全部班级默认即有值)
|
||||
if (act !== "draft" && !pushScopes.value.length) {
|
||||
Message.info("请选择接收班级!");
|
||||
return;
|
||||
}
|
||||
if (act === "approve" && !(flowModel.value.enabled && flowModel.value.flow.length)) {
|
||||
Message.info("请先在审批流程中选择审批人!");
|
||||
return;
|
||||
}
|
||||
// 勾了审批但一步都没选人 → 拦截
|
||||
if (flowEditorRef.value && !flowEditorRef.value.validate()) {
|
||||
Message.info("请为审批流程选择审批人,或取消勾选“走审批流程”");
|
||||
return;
|
||||
}
|
||||
// 已配置审批流程(勾选且已选人)时不允许走直接发布/定时发送绕过审批(v14)
|
||||
if (act === "direct" && flowModel.value.enabled && flowModel.value.flow.length) {
|
||||
Message.info("已配置审批流程,请点击「提交审批」发布");
|
||||
return;
|
||||
}
|
||||
|
||||
// 存草稿也携带流程定义:后端 SaveDraft 存流程,草稿箱「沿用草稿已存流程」才可用
|
||||
const data = buildBody(act === "approve" || act === "draft");
|
||||
if (isTime && !applyTiming(data, true)) return;
|
||||
if (!isTime && !applyTiming(data, false)) return;
|
||||
|
||||
isLoading.value = true;
|
||||
try {
|
||||
if (!isEdit.value) {
|
||||
if (act === "draft") {
|
||||
await sendDraft(data);
|
||||
Message.success("已存为草稿,可在草稿箱继续编辑");
|
||||
router.push("/drafts");
|
||||
return;
|
||||
}
|
||||
if (act === "approve") data.publishType = "approve";
|
||||
await sendCreate(data);
|
||||
Message.success(act === "approve" ? "已提交审批,通过后发布" : "发送成功");
|
||||
router.push("/list");
|
||||
} else if (act === "direct" && publishViaCreate.value) {
|
||||
// 草稿续编的直接发布/定时发送:后端 Edit 不改状态、也没有发布既有草稿的接口,
|
||||
// 故按 api-test 直接发布口径走 Create 落已发布新记录(不带流程定义、不带 sourceId),再删原草稿(v14)
|
||||
data.publishType = "direct";
|
||||
await sendCreate(data);
|
||||
try {
|
||||
await sendDelete(serid.value);
|
||||
} catch {
|
||||
Message.warning("原草稿删除失败,请到草稿箱手动删除");
|
||||
}
|
||||
Message.success(isTime ? "已设置定时发送,到点自动发布" : "发送成功");
|
||||
router.push("/list");
|
||||
} else {
|
||||
data.sourceId = serid.value;
|
||||
// 驳回/已发布修订保留 [修订稿] 标记;草稿续编不加
|
||||
if (editStatus.value === 3 || editStatus.value === 1) {
|
||||
if (data.title.indexOf("[修订稿]") == -1) data.title = "[修订稿]" + data.title;
|
||||
}
|
||||
await sendEdit(data);
|
||||
if (act === "approve") {
|
||||
// 提交审批:编辑保存(含流程定义)后按步 Submit(0/3→2)
|
||||
const steps = (flowModel.value.flow || []).map((s, i) => ({
|
||||
name: `第${i + 1}步`,
|
||||
vote: s.vote,
|
||||
approverTeaIds: [...s.approverTeaIds],
|
||||
}));
|
||||
await sendSubmit({ serid: serid.value, steps });
|
||||
Message.success("已提交审批,通过后发布");
|
||||
} else if (act === "draft") {
|
||||
// 存为草稿只落内容,状态原样保留(已驳回不回退为草稿)
|
||||
Message.success(editStatus.value === 0 ? "草稿已保存" : "已保存,状态仍为已驳回");
|
||||
} else {
|
||||
Message.success("保存成功");
|
||||
}
|
||||
router.push(editStatus.value === 0 ? "/drafts" : "/list");
|
||||
}
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "保存失败");
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
/* ============ 编辑态删除(草稿/已驳回,对齐 api-test deleteFromCompose) ============ */
|
||||
const { send: sendDelete } = useRequest((id: number) => noticeApi.deleteNotice(String(id)), { immediate: false });
|
||||
|
||||
function delFromCompose() {
|
||||
MyConfirm({
|
||||
title: "删除公告",
|
||||
msg: `确定删除“ ${form.title} ”吗?`,
|
||||
info: "删除后不可恢复",
|
||||
async onOk() {
|
||||
try {
|
||||
await sendDelete(serid.value);
|
||||
Message.success("删除成功");
|
||||
router.push(editStatus.value === 0 ? "/drafts" : "/list");
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "删除失败");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-wrapper">
|
||||
<div class="main-container">
|
||||
<Form ref="formRef" class="myform" :model="form" :rules="ruleValidate" :label-width="100">
|
||||
<FormItem label="公告标题:" prop="title">
|
||||
<Input type="text" v-model="form.title" maxlength="200" show-word-limit placeholder="请输入公告标题"></Input>
|
||||
</FormItem>
|
||||
<FormItem label="重要程度:" prop="type">
|
||||
<RadioGroup v-model="form.type">
|
||||
<Radio :label="1">普通</Radio>
|
||||
<Radio :label="2"><span class="important-text">重要</span></Radio>
|
||||
</RadioGroup>
|
||||
</FormItem>
|
||||
<FormItem label="来源部门:" prop="dept">
|
||||
<Select v-model="form.dept" placeholder="请选择部门" :loading="deptLoading">
|
||||
<Option value="">请选择部门</Option>
|
||||
<Option v-for="item in deptOptions" :key="item.k" :value="item.k">{{ item.v }}</Option>
|
||||
</Select>
|
||||
</FormItem>
|
||||
<FormItem label="接收班级:" class="notice-obj">
|
||||
<ClassScopeInput ref="scopePanelRef" v-model="pushScopes"></ClassScopeInput>
|
||||
</FormItem>
|
||||
<FormItem label="发布选项:">
|
||||
<div class="publish-opts">
|
||||
<Checkbox v-model="form.isTopmost">置顶</Checkbox>
|
||||
<Checkbox v-if="isSms" v-model="form.isShortMsg">发送短信提醒</Checkbox>
|
||||
<Checkbox v-model="form.isReceipt">学员阅读回执</Checkbox>
|
||||
</div>
|
||||
</FormItem>
|
||||
<FormItem label="添加附件:">
|
||||
<NoticeUpload v-model:fileids="fileids" :defaultList="editFileList"></NoticeUpload>
|
||||
</FormItem>
|
||||
<FormItem label="审批:">
|
||||
<ApprovalFlowEditor ref="flowEditorRef" v-model="flowModel"></ApprovalFlowEditor>
|
||||
</FormItem>
|
||||
</Form>
|
||||
|
||||
<!-- 富文本编辑器 -->
|
||||
<div class="editor-wrap">
|
||||
<Toolbar class="editor-toolbar" :editor="editorRef" :defaultConfig="toolbarConfig" mode="default" />
|
||||
<Editor
|
||||
class="editor-content"
|
||||
v-model="form.content"
|
||||
:defaultConfig="editorConfig"
|
||||
mode="default"
|
||||
@onCreated="handleCreated"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 动作区(v14):直接发布 → 存为草稿 → 提交审批 → 定时发送 → 删除;
|
||||
已驳回只给「存为草稿 + 提交审批」(改对内容后重新送审),已发布修订按钮为「保存」 -->
|
||||
<div class="bottom-btn">
|
||||
<Button v-if="publishViaCreate" type="primary" :loading="isLoading" @click="doAdd('direct')">直接发布</Button>
|
||||
<Button :type="publishViaCreate ? 'default' : 'primary'" style="margin-left: 10px" :loading="isLoading" @click="doAdd('draft')">{{ draftBtnText }}</Button>
|
||||
<Button type="warning" style="margin-left: 10px" :loading="isLoading" @click="doAdd('approve')">提交审批</Button>
|
||||
<Button v-if="publishViaCreate || editStatus === 1" style="margin-left: 10px" @click="isModal.timiSend = true">定时发送</Button>
|
||||
<Button
|
||||
v-if="isEdit && (editStatus === 0 || editStatus === 3)"
|
||||
type="error"
|
||||
style="margin-left: 10px"
|
||||
@click="delFromCompose"
|
||||
>删除</Button>
|
||||
</div>
|
||||
|
||||
<!-- 定时发送 -->
|
||||
<Modal v-model="isModal.timiSend" title="定时发送" width="420px">
|
||||
<Form class="myform" :label-width="100" inline>
|
||||
<FormItem label="发送日期">
|
||||
<DatePicker
|
||||
type="date"
|
||||
transfer
|
||||
v-model="timeForm.date1"
|
||||
format="yyyy-MM-dd"
|
||||
:options="dateOpt"
|
||||
placeholder="选择发送日期"
|
||||
style="width: 240px"
|
||||
></DatePicker>
|
||||
</FormItem>
|
||||
<FormItem label="发送时间">
|
||||
<TimePicker
|
||||
type="time"
|
||||
transfer
|
||||
v-model="timeForm.date2"
|
||||
format="HH:mm"
|
||||
:steps="[1, 10]"
|
||||
placeholder="选择发送时间"
|
||||
style="width: 240px"
|
||||
></TimePicker>
|
||||
</FormItem>
|
||||
</Form>
|
||||
<template #footer>
|
||||
<Button type="primary" :loading="isLoading" @click="doAdd('isTime')">提交</Button>
|
||||
<Button @click="isModal.timiSend = false" style="margin-left: 8px">取消</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* 页面超高时的滚动由 App.vue 的 .main-wrapper 统一承接 */
|
||||
.myform {
|
||||
:deep(.ivu-form-item) {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
}
|
||||
.important-text {
|
||||
color: #ed4014;
|
||||
}
|
||||
.field-hint {
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.publish-opts {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
align-items: center;
|
||||
line-height: 32px;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,609 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { useRequest } from "alova/client";
|
||||
import { Message, Tooltip } from "view-ui-plus";
|
||||
import { noticeApi } from "@/api/module/notice";
|
||||
import type { NoticeDetailVo, NoticeQuery, NoticeVo, NoticePageResult, ReadTabCountVo } from "@/types/notice";
|
||||
import FilePreviewModal from "@/components/FilePreviewModal.vue";
|
||||
import { printNotice } from "@/utils/noticePrint";
|
||||
import Utils from "@/utils";
|
||||
|
||||
/* ============ 左侧列表 ============ */
|
||||
const keyword = ref("");
|
||||
const readType = ref(0); // 0 全部 / 1 未读 / 2 已读
|
||||
const noticeType = ref(""); // "" 全部 / 1 普通 / 2 重要
|
||||
const pageNum = ref(1);
|
||||
const pageSize = ref(50);
|
||||
const selected = ref(0);
|
||||
|
||||
const { data: page, loading, send: reload } = useRequest(
|
||||
(params: NoticeQuery) => noticeApi.list(params),
|
||||
{
|
||||
immediate: false,
|
||||
initialData: { content: [], total: 0, pageIndex: 1, pageSize: 50, totalPages: 0, hasNext: false, unreadCount: 0 },
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 列表与统计共用的筛选条件(v17):只带 keyword / type,与 GET notice/List 的过滤同源。
|
||||
* readType 不进来——tab 统计要同时给出三个桶,上下篇序列也刻意不受 readType 影响。
|
||||
*/
|
||||
function currentFilter(): NoticeQuery {
|
||||
const q: NoticeQuery = { keyword: keyword.value || "" };
|
||||
if (noticeType.value !== "") q.type = Number(noticeType.value);
|
||||
return q;
|
||||
}
|
||||
|
||||
/* 三 tab 统计(ReadTabCount):数字随 keyword/type 联动,恒有 all = unread + read */
|
||||
const tabCounts = ref<ReadTabCountVo>({ all: 0, unread: 0, read: 0 });
|
||||
function tabLabel(text: string, n: number) {
|
||||
return n > 0 ? `${text} (${n})` : text;
|
||||
}
|
||||
async function loadTabCounts() {
|
||||
try {
|
||||
tabCounts.value = await noticeApi.readTabCount(currentFilter()).send();
|
||||
} catch {
|
||||
/* 统计接口异常时静默保留上次数字,不打断列表操作(同审批中心 v13) */
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
loadTabCounts();
|
||||
const params: NoticeQuery = {
|
||||
...currentFilter(),
|
||||
readType: readType.value,
|
||||
pageIndex: pageNum.value,
|
||||
pageSize: pageSize.value,
|
||||
orderByTime: "1",
|
||||
};
|
||||
const data: NoticePageResult<NoticeVo> = await reload(params);
|
||||
// 默认选中第一条(同旧版进入页面即展示最新通知)
|
||||
const list = data?.content || [];
|
||||
if (!detail.value && list.length) {
|
||||
selected.value = 0;
|
||||
await loadDetail(list[0].id, true);
|
||||
}
|
||||
}
|
||||
function onSearch() {
|
||||
pageNum.value = 1;
|
||||
run();
|
||||
}
|
||||
function onClear() {
|
||||
keyword.value = "";
|
||||
onSearch();
|
||||
}
|
||||
function onReadTypeChange() {
|
||||
onSearch();
|
||||
}
|
||||
function onTypeChange() {
|
||||
onSearch();
|
||||
}
|
||||
|
||||
/* 一键已读(旧版 icon-saoba 清除按钮) */
|
||||
async function allRead() {
|
||||
try {
|
||||
await noticeApi.initializeRed().send();
|
||||
Message.success("操作成功!");
|
||||
await run();
|
||||
if (detail.value) await loadDetail(detail.value.serid, false);
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "操作失败");
|
||||
}
|
||||
}
|
||||
|
||||
/* ============ 右侧详情 ============ */
|
||||
const detail = ref<NoticeDetailVo | null>(null);
|
||||
const detailLoading = ref(false);
|
||||
/** 已收序列里的相邻公告ID(0=该方向没有),随详情一起刷新 */
|
||||
const prevId = ref(0);
|
||||
const nextId = ref(0);
|
||||
|
||||
/** 左列条目 DOM(函数式 ref),用于跳篇后把当前条滚进可视区 */
|
||||
const itemEls = ref<HTMLElement[]>([]);
|
||||
function setItemEl(el: any, index: number) {
|
||||
if (el) itemEls.value[index] = el as HTMLElement;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
run();
|
||||
});
|
||||
|
||||
async function checkNotice(item: NoticeVo, index: number) {
|
||||
selected.value = index;
|
||||
await loadDetail(item.id, true);
|
||||
}
|
||||
|
||||
async function loadDetail(serid: number, markRead: boolean) {
|
||||
detailLoading.value = true;
|
||||
try {
|
||||
detail.value = await noticeApi.modify(serid, markRead ? 1 : 0).send();
|
||||
await loadNav(serid);
|
||||
if (markRead) {
|
||||
// 标读后刷新左侧未读状态(含三 tab 数字)
|
||||
await run();
|
||||
}
|
||||
await ensureHighlighted();
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "加载公告失败");
|
||||
} finally {
|
||||
detailLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 跳篇筛选:重要程度必须以 noticeType 传(本接口的 `type` 已被序列类型占用) */
|
||||
function currentNavFilter(): { keyword: string; noticeType?: number } {
|
||||
const f: { keyword: string; noticeType?: number } = { keyword: keyword.value || "" };
|
||||
if (noticeType.value !== "") f.noticeType = Number(noticeType.value);
|
||||
return f;
|
||||
}
|
||||
|
||||
/** 上一篇/下一篇ID:走「我可见的已收序列」并沿用当前 keyword/type 筛选(后端刻意忽略 readType) */
|
||||
async function loadNav(serid: number) {
|
||||
try {
|
||||
const nav = await noticeApi.getNextPrevNotice(serid, "1", currentNavFilter()).send();
|
||||
prevId.value = nav.prevId || 0;
|
||||
nextId.value = nav.nextId || 0;
|
||||
} catch {
|
||||
prevId.value = 0;
|
||||
nextId.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function goNav(id: number) {
|
||||
if (!id) return;
|
||||
await loadDetail(id, true);
|
||||
}
|
||||
|
||||
/** 高亮索引跟随当前详情(列表重载后条目可能位移,不能沿用旧下标) */
|
||||
function reposition() {
|
||||
const id = detail.value?.serid;
|
||||
const list = page.value?.content || [];
|
||||
selected.value = id ? list.findIndex((n) => n.id === id) : -1;
|
||||
if (selected.value >= 0) {
|
||||
itemEls.value[selected.value]?.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 目标条可能不在当前筛选结果内(最典型:未读 tab 里刚被标读的那条会从未读集合消失)。
|
||||
* 处理:把 tab 切回「全部」重载一次再定位;仍找不到就不高亮(不擅自动用户的关键字/类型筛选)。
|
||||
*/
|
||||
async function ensureHighlighted() {
|
||||
reposition();
|
||||
if (selected.value < 0 && detail.value && readType.value !== 0) {
|
||||
readType.value = 0;
|
||||
pageNum.value = 1;
|
||||
await run();
|
||||
reposition();
|
||||
}
|
||||
}
|
||||
|
||||
/* ============ 附件 ============ */
|
||||
function downFile(id: number) {
|
||||
window.location.href = noticeApi.fileDownUrl(id);
|
||||
}
|
||||
function downAll() {
|
||||
window.location.href = noticeApi.allFileDownUrl(detail.value.serid);
|
||||
}
|
||||
|
||||
/** 展示名:后端 name=原始文件名,filename=存储相对路径(notice/2026xxxx/uuid.ext) */
|
||||
function displayName(item: { name?: string; filename?: string }) {
|
||||
return item.name || item.filename || "";
|
||||
}
|
||||
|
||||
/* 附件预览弹窗(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),
|
||||
};
|
||||
}
|
||||
|
||||
/* 附件图标(按扩展名,图标名对齐旧版 matchSuffix → icon-word/excel/ppt/pdf1/txt/tupian1/yasuo/weizhi) */
|
||||
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);
|
||||
}
|
||||
|
||||
/* ============ 打印(v17:改用公共工具,打印件含接收范围与附件清单) ============ */
|
||||
function onPrint() {
|
||||
if (!printNotice(detail.value)) {
|
||||
Message.error("打印窗口被浏览器拦截,请允许本站弹出窗口后重试");
|
||||
}
|
||||
}
|
||||
|
||||
/* 时间显示(列表 MM/dd HH:mm,同旧版) */
|
||||
function shortDate(date: string) {
|
||||
const d = new Date(String(date || "").replace(/-/g, "/"));
|
||||
if (isNaN(d.getTime())) return date;
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${pad(d.getMonth() + 1)}/${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/* 时间显示(列表 MM/dd HH:mm,同旧版) */</script>
|
||||
|
||||
<template>
|
||||
<div class="page-wrapper">
|
||||
<div class="main-container">
|
||||
<div class="accept-wrap">
|
||||
<!-- 左侧通知列表 -->
|
||||
<div class="title-list">
|
||||
<div class="total">
|
||||
<span>未读<span class="num">{{ page.unreadCount || 0 }}</span>条</span>
|
||||
<Tooltip content="一键已读" placement="top" transfer>
|
||||
<i class="iconfont icon-saoba clear-icon" @click="allRead"></i>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div class="filter-box">
|
||||
<RadioGroup v-model="readType" type="button" size="small" @on-change="onReadTypeChange">
|
||||
<Radio :label="0">{{ tabLabel("全部", tabCounts.all) }}</Radio>
|
||||
<Radio :label="1">{{ tabLabel("未读", tabCounts.unread) }}</Radio>
|
||||
<Radio :label="2">{{ tabLabel("已读", tabCounts.read) }}</Radio>
|
||||
</RadioGroup>
|
||||
<Select v-model="noticeType" size="small" clearable placeholder="全部类型" style="width: 96px" @on-change="onTypeChange">
|
||||
<Option value="1">普通</Option>
|
||||
<Option value="2">重要</Option>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="search-box">
|
||||
<Input
|
||||
v-model="keyword"
|
||||
clearable
|
||||
search
|
||||
placeholder="搜索发布人,标题,部门......"
|
||||
@on-search="onSearch"
|
||||
@on-clear="onClear"
|
||||
></Input>
|
||||
</div>
|
||||
<div class="notice-items">
|
||||
<div
|
||||
v-for="(item, index) in page.content"
|
||||
:key="item.id"
|
||||
:ref="(el) => setItemEl(el, index)"
|
||||
:class="{ list_item: true, readed: item.read, active: index == selected }"
|
||||
@click="checkNotice(item, index)"
|
||||
>
|
||||
<div class="tt">
|
||||
<span v-if="!item.read" class="dot"></span>
|
||||
<span class="tt-text">{{ item.title }}</span>
|
||||
<span v-if="item.topmost == 1" class="badge badge-top">置顶</span>
|
||||
<span v-if="item.type == 2" class="badge badge-import">重要</span>
|
||||
<span v-if="item.isPulled == 1" class="badge badge-pulled">已撤回</span>
|
||||
</div>
|
||||
<div class="other">
|
||||
<span>{{ item.deptName }}</span>
|
||||
<span>{{ shortDate(item.timingSendTime || item.createTime) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!page.content.length" class="empty">暂无公告</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧详情 -->
|
||||
<div class="detail-content" v-if="detail">
|
||||
<div class="notice-title">
|
||||
<div class="title-header">
|
||||
<div class="tt">{{ detail.title }}</div>
|
||||
<div class="ops fr">
|
||||
<span class="op" :class="{ disabled: !prevId }" @click="goNav(prevId)"><span>上一篇</span></span>
|
||||
<span class="op" :class="{ disabled: !nextId }" @click="goNav(nextId)"><span>下一篇</span></span>
|
||||
<span class="op" @click="onPrint"><i class="iconfont icon-print"></i><span>打印</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="title-content">
|
||||
<p>来源:{{ detail.DeptName }}</p>
|
||||
<p>发布人:{{ detail.TeaName }}</p>
|
||||
<p>发布时间:{{ detail.EditDate }}</p>
|
||||
<p>
|
||||
<span>附件:{{ (detail.ChaoSend || []).length }}个</span>
|
||||
<span class="to-file" v-if="detail.ChaoSend && detail.ChaoSend.length > 0" @click="downAll">全部下载</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 学员阅读回执提示(receiptFlag=1:需家长/学员确认阅读) -->
|
||||
<Alert v-if="detail.receiptFlag == 1" type="info" show-icon banner class="receipt-alert">
|
||||
本公告需学员阅读确认,请确认已通知到学员/家长。
|
||||
</Alert>
|
||||
<div class="notice-content">
|
||||
<div class="ql-editor" v-html="detail.Contents"></div>
|
||||
<div class="file" v-if="detail.ChaoSend && detail.ChaoSend.length > 0">
|
||||
<div class="file-title">
|
||||
<span>附件({{ detail.ChaoSend.length }}个)</span>
|
||||
<span class="all-down" @click="downAll">全部下载</span>
|
||||
</div>
|
||||
<div class="file-list">
|
||||
<div class="file-item" v-for="item in detail.ChaoSend" :key="item.id">
|
||||
<div class="left">
|
||||
<i class="iconfont file-type-icon" :class="getFileIcon(displayName(item))"></i>
|
||||
</div>
|
||||
<div class="center">
|
||||
<div class="name" :title="displayName(item)">{{ displayName(item) }}</div>
|
||||
<div class="size">{{ Utils.getFileSize(item.size) }}</div>
|
||||
</div>
|
||||
<div class="right">
|
||||
<a
|
||||
href="javascript:void(0);"
|
||||
class="mr-8"
|
||||
v-if="getIsPreview(displayName(item))"
|
||||
@click="openPreview(item)"
|
||||
>查看</a
|
||||
>
|
||||
<a href="javascript:void(0);" @click="downFile(item.fileid || item.id)">下载</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-content empty-detail" v-else>
|
||||
<span>{{ detailLoading ? "加载中..." : "暂无公告" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 附件预览弹窗 -->
|
||||
<FilePreviewModal v-model="previewState.show" :file-id="previewState.fileId" :file-name="previewState.fileName" />
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* 根容器 page-wrapper > main-container:内容区可视高度 = 100vh - 112(头部+面包屑) - 24(容器底padding) */
|
||||
.accept-wrap {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
height: calc(100vh - 136px);
|
||||
}
|
||||
.title-list {
|
||||
width: 360px;
|
||||
flex: none;
|
||||
background: #fff;
|
||||
border: 1px solid #ebeef4;
|
||||
border-radius: 3px;
|
||||
padding: 14px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.total {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 15px 10px;
|
||||
.num {
|
||||
padding: 0 5px;
|
||||
color: #5c9edc;
|
||||
font-weight: 600;
|
||||
}
|
||||
.clear-icon {
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
color: #999;
|
||||
&:hover {
|
||||
color: #2d8cf0;
|
||||
}
|
||||
}
|
||||
}
|
||||
.search-box {
|
||||
padding: 0 15px;
|
||||
}
|
||||
.filter-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
/* tab 文字带条数后可能超出 360px 左列宽度,允许换行 */
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 1px 15px 10px;
|
||||
}
|
||||
.receipt-alert {
|
||||
margin: 10px 0 0;
|
||||
}
|
||||
.notice-items {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
margin-top: 12px;
|
||||
.list_item {
|
||||
padding: 10px 15px;
|
||||
cursor: pointer;
|
||||
border-left: 3px solid transparent;
|
||||
.tt {
|
||||
font-weight: 600;
|
||||
color: #272829;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
.dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #ed4014;
|
||||
flex: none;
|
||||
}
|
||||
.tt-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.badge {
|
||||
flex: none;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
line-height: 16px;
|
||||
padding: 0 6px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.badge-top {
|
||||
background: #fef3c7;
|
||||
color: #d97706;
|
||||
}
|
||||
.badge-import {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
.badge-pulled {
|
||||
background: #f1f5f9;
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
.other {
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
&:hover {
|
||||
background: #f6f9ff;
|
||||
}
|
||||
&.active {
|
||||
background: #ecf3ff;
|
||||
border-left-color: #2d8cf0;
|
||||
}
|
||||
/* 已读弱化 */
|
||||
&.readed .tt {
|
||||
color: #8a94a6;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
padding: 40px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
.detail-content {
|
||||
flex: 1;
|
||||
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;
|
||||
.tt {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #272829;
|
||||
}
|
||||
.ops {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
.op {
|
||||
cursor: pointer;
|
||||
color: #515a6e;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
&:hover {
|
||||
color: #2d8cf0;
|
||||
}
|
||||
/* 上一篇/下一篇无可跳目标时置灰(goNav 内部也会短路) */
|
||||
&.disabled {
|
||||
color: #c5c8ce;
|
||||
cursor: not-allowed;
|
||||
&:hover {
|
||||
color: #c5c8ce;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.title-content {
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px dashed #e8e8e8;
|
||||
font-size: 13px;
|
||||
color: #808695;
|
||||
p {
|
||||
margin: 4px 0;
|
||||
}
|
||||
.to-file {
|
||||
margin-left: 10px;
|
||||
color: #2d8cf0;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
.notice-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding-top: 10px;
|
||||
.file {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
margin-top: 16px;
|
||||
.file-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
background: #f7f7f7;
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
.all-down {
|
||||
color: #2d8cf0;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
.file-list {
|
||||
padding: 10px 12px;
|
||||
.file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 6px;
|
||||
border-bottom: 1px dashed #f0f0f0;
|
||||
.file-type-icon {
|
||||
font-size: 26px;
|
||||
color: #515a6e;
|
||||
}
|
||||
.center {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
.name {
|
||||
color: #272829;
|
||||
font-size: 13px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.size {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
.right a {
|
||||
margin-right: 8px;
|
||||
color: #2d8cf0;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,607 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { noticeApi } from "@/api/module/notice";
|
||||
import type { NoticeDetailVo, NextPrevVo, ReadGroup } from "@/types/notice";
|
||||
import FilePreviewModal from "@/components/FilePreviewModal.vue";
|
||||
import { printNotice, receiveScopeLabel } from "@/utils/noticePrint";
|
||||
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();
|
||||
});
|
||||
|
||||
/* 上一篇/下一篇或浏览器前进后退改变 serid 时重新加载。
|
||||
加载统一由本 watch 触发:goNav 里直接 load() 会在 router.replace 尚未提交时读到旧 serid(竞态),
|
||||
导致点了「下一篇」却重新拉取当前公告、序列不前进。 */
|
||||
watch(
|
||||
() => route.query.serid,
|
||||
(id) => {
|
||||
if (Number(id)) 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;
|
||||
// 只负责改地址;真正的重新加载交给 watch(route.query.serid),避免读到未提交的旧 serid
|
||||
router.replace({ path: route.path, query: { ...route.query, serid: id } });
|
||||
}
|
||||
|
||||
/* 阅读进度(同旧版:已查看百分比) */
|
||||
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));
|
||||
|
||||
/* E4:Alert 数据源——后端列表 VO 无 approvalBy/Time/Remark,改为从审批轨迹 approvalRecords 取末条对应动作记录 */
|
||||
function lastRecord(action: number) {
|
||||
const rs = detail.value?.approvalRecords || [];
|
||||
for (let i = rs.length - 1; i >= 0; i--) {
|
||||
if (Number(rs[i].action) === action) return rs[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const lastReject = computed(() => lastRecord(3));
|
||||
const lastApprove = computed(() => lastRecord(2));
|
||||
|
||||
/* E5:接收范围标签——与打印件共用 utils/noticePrint.receiveScopeLabel(v17,两处口径不再各自漂移) */
|
||||
const receiveLabel = computed(() => receiveScopeLabel(detail.value));
|
||||
|
||||
async function openRead() {
|
||||
try {
|
||||
readGroups.value = await noticeApi.orgizeModify(serid.value).send();
|
||||
showRead.value = true;
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "加载阅读情况失败");
|
||||
}
|
||||
}
|
||||
|
||||
/* 四态状态徽标配色:0 草稿 / 1 已发布(定时中) / 2 待审批 / 3 已驳回 */
|
||||
const STATUS_COLOR: Record<number, string> = { 0: "default", 1: "success", 2: "warning", 3: "error" };
|
||||
function statusColor(): string {
|
||||
return STATUS_COLOR[Number(detail.value?.status)] || "default";
|
||||
}
|
||||
function statusText(): string {
|
||||
const d: any = detail.value || {};
|
||||
if (Number(d.status) === 1 && d.isDsfs) return "定时发送";
|
||||
return d.statusDesc || "已发布";
|
||||
}
|
||||
|
||||
/* 编辑(管理视角) */
|
||||
function onEdit() {
|
||||
router.push({ path: "/list/new", query: { serid: serid.value } });
|
||||
}
|
||||
|
||||
/* 打印(v17:改用公共工具,打印件含接收范围与附件清单) */
|
||||
function onPrint() {
|
||||
if (!printNotice(detail.value)) {
|
||||
Message.error("打印窗口被浏览器拦截,请允许本站弹出窗口后重试");
|
||||
}
|
||||
}
|
||||
|
||||
function downFile(id: number) {
|
||||
if (!id) return;
|
||||
window.location.href = noticeApi.fileDownUrl(id);
|
||||
}
|
||||
function downAll() {
|
||||
window.location.href = noticeApi.allFileDownUrl(serid.value);
|
||||
}
|
||||
|
||||
/** 展示名:后端 name=原始文件名,filename=存储相对路径(notice/2026xxxx/uuid.ext) */
|
||||
function displayName(item: { name?: string; filename?: string }) {
|
||||
return item.name || item.filename || "";
|
||||
}
|
||||
/* 附件图标(按扩展名,图标名对齐旧版 matchSuffix → icon-word/excel/ppt/pdf1/txt/tupian1/yasuo/weizhi) */
|
||||
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),
|
||||
};
|
||||
}
|
||||
</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 v-if="viewType === 0" class="ops">
|
||||
<Button size="small" @click="onEdit">编辑</Button>
|
||||
<Button size="small" @click="onPrint">打印</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 管理视角:阅读进度统计条(同旧版已发通知详情) -->
|
||||
<template v-if="viewType === 0">
|
||||
<h2 class="notice-title2">{{ 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 class="stat-tags">
|
||||
<Tag>{{ detail.DeptName }}</Tag>
|
||||
<Tag>{{ detail.TeaName }}</Tag>
|
||||
<Tag>{{ detail.EditDate }}</Tag>
|
||||
<Tag color="blue">接收范围:{{ receiveLabel }}</Tag>
|
||||
<Tag v-if="detail.type == 2" color="error">重要</Tag>
|
||||
<Tag :color="statusColor()">{{ statusText() }}</Tag>
|
||||
<Tag v-if="detail.approvalEnabled"
|
||||
:color="({ pending: 'warning', approved: 'success', rejected: 'error' } as any)[detail.approvalStatus || ''] || 'default'">
|
||||
{{ detail.approvalStatusDesc }}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
</template>
|
||||
|
||||
<!-- 查看视角:普通标题 -->
|
||||
<h2 v-else class="notice-title2">{{ detail.title }}</h2>
|
||||
|
||||
<div v-if="viewType !== 0" class="meta">
|
||||
<span>发文单位:{{ detail.DeptName || "—" }}</span>
|
||||
<span>发布人:{{ detail.TeaName || "—" }}</span>
|
||||
<span>发布时间:{{ detail.EditDate }}</span>
|
||||
<span>接收范围:{{ receiveLabel }}</span>
|
||||
<span v-if="detail.isDsfs">定时发送:{{ detail.DsfsTime }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 审批状态提示(待审批 / 已驳回 时展示) -->
|
||||
<Alert v-if="detail.approvalStatus === 'pending'" type="warning" show-icon banner style="margin: 8px 0">
|
||||
该公告已提交审批,<b>审批通过后才会对学生发布</b>。
|
||||
</Alert>
|
||||
<Alert v-else-if="detail.approvalStatus === 'rejected'" type="error" show-icon banner style="margin: 8px 0">
|
||||
审批意见({{ lastReject?.operatorName || "—" }} {{ lastReject?.actionTime || "" }}):{{ lastReject?.opinion || "未填写" }}
|
||||
</Alert>
|
||||
<Alert v-else-if="detail.approvalEnabled && detail.approvalStatus === 'approved'" type="success" show-icon banner style="margin: 8px 0">
|
||||
已由 {{ lastApprove?.operatorName || "—" }} 于 {{ lastApprove?.actionTime || "—" }} 审批通过并发布。
|
||||
</Alert>
|
||||
|
||||
<!-- 审批轨迹(提交/通过/驳回全量时间线,按轮次由提交行切分) -->
|
||||
<div v-if="detail.approvalRecords && detail.approvalRecords.length" class="approval-records">
|
||||
<div class="ar-title">审批轨迹</div>
|
||||
<div class="ar-list">
|
||||
<div v-for="(r, i) in detail.approvalRecords" :key="i" class="ar-item" :class="'act-' + r.action">
|
||||
<span class="ar-dot"></span>
|
||||
<span class="ar-desc">{{ r.actionDesc }}</span>
|
||||
<span class="ar-operator">{{ r.operatorName }}</span>
|
||||
<span class="ar-time">{{ r.actionTime }}</span>
|
||||
<span v-if="r.opinion" class="ar-opinion">意见:{{ r.opinion }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 审批流程(多步·会签/或签)进度 -->
|
||||
<div v-if="detail.approvalFlow && detail.approvalFlow.length" class="flow-progress">
|
||||
<div class="fp-title">审批流程</div>
|
||||
<div class="fp-steps">
|
||||
<div class="fp-step" v-for="(lv, i) in detail.approvalFlow" :key="i" :class="'is-' + lv.status">
|
||||
<div class="fp-dot">{{ i + 1 }}</div>
|
||||
<div class="fp-body">
|
||||
<div class="fp-name">
|
||||
第 {{ i + 1 }} 步 · {{ lv.name }}
|
||||
<Tag size="small" :color="lv.vote === 'all' ? 'purple' : 'cyan'">{{ lv.vote === "all" ? "会签" : "或签" }}</Tag>
|
||||
<Tag size="small" :color="({ approved: 'success', pending: 'warning', rejected: 'error', waiting: 'default' } as any)[lv.status]">
|
||||
{{ ({ approved: "已通过", pending: "审批中", rejected: "已驳回", waiting: "未开始" } as any)[lv.status] }}
|
||||
</Tag>
|
||||
</div>
|
||||
<div class="fp-people">审批人:{{ (lv.approverNames || []).join("、") }}</div>
|
||||
<div v-if="lv.status === 'pending' && lv.pending.length" class="fp-wait">待其签:{{ lv.pending.join("、") }}</div>
|
||||
<div v-for="(r, ri) in lv.records" :key="ri" class="fp-rec" :class="r.result">
|
||||
{{ r.name }} {{ r.result === "approved" ? "通过" : "驳回" }} · {{ r.time }}<span v-if="r.remark">:{{ r.remark }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content" v-html="detail.Contents"></div>
|
||||
|
||||
<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>
|
||||
|
||||
<!-- 查看阅读情况(同旧版:绿=已读 红=未读) -->
|
||||
<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">
|
||||
<span
|
||||
v-for="t in g.teaslist"
|
||||
:key="t.account || t.teaname"
|
||||
:class="t.states === 'read' ? 'green' : 'red'"
|
||||
class="user-name"
|
||||
>{{ t.teaname }}</span
|
||||
>
|
||||
</div>
|
||||
<div v-if="!g.teaslist || !g.teaslist.length" class="empty">暂无人员</div>
|
||||
</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;
|
||||
}
|
||||
.ops {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.notice-title2 {
|
||||
font-size: 20px;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 18px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px dashed #e8e8e8;
|
||||
font-size: 13px;
|
||||
color: #808695;
|
||||
}
|
||||
/* 管理视角统计条 */
|
||||
.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: 300px;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.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-type-icon {
|
||||
font-size: 16px;
|
||||
color: #515a6e;
|
||||
}
|
||||
.file-name {
|
||||
max-width: 320px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.file-size {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.approval-records {
|
||||
margin: 8px 0 4px;
|
||||
.ar-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #17233d;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.ar-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: #515a6e;
|
||||
line-height: 22px;
|
||||
flex-wrap: wrap;
|
||||
.ar-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #c5c8ce;
|
||||
align-self: center;
|
||||
flex: none;
|
||||
}
|
||||
.ar-desc {
|
||||
font-weight: 600;
|
||||
}
|
||||
.ar-time {
|
||||
color: #999;
|
||||
}
|
||||
.ar-opinion {
|
||||
color: #808695;
|
||||
}
|
||||
&.act-3 {
|
||||
.ar-dot {
|
||||
background: #ed4014;
|
||||
}
|
||||
.ar-desc {
|
||||
color: #ed4014;
|
||||
}
|
||||
}
|
||||
&.act-2 {
|
||||
.ar-dot {
|
||||
background: #19be6b;
|
||||
}
|
||||
.ar-desc {
|
||||
color: #19be6b;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.flow-progress {
|
||||
margin: 12px 0 4px;
|
||||
padding: 12px 14px;
|
||||
background: #f8faff;
|
||||
border: 1px solid #e8f0fe;
|
||||
border-radius: 6px;
|
||||
.fp-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #17233d;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.fp-steps {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.fp-step {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-width: 240px;
|
||||
}
|
||||
.fp-dot {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
line-height: 22px;
|
||||
text-align: center;
|
||||
border-radius: 50%;
|
||||
background: #c5c8ce;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
flex: none;
|
||||
}
|
||||
.is-approved .fp-dot {
|
||||
background: #19be6b;
|
||||
}
|
||||
.is-pending .fp-dot {
|
||||
background: #ff9900;
|
||||
}
|
||||
.is-rejected .fp-dot {
|
||||
background: #ed4014;
|
||||
}
|
||||
.fp-body {
|
||||
flex: 1;
|
||||
}
|
||||
.fp-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #17233d;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.fp-people {
|
||||
font-size: 12px;
|
||||
color: #808695;
|
||||
}
|
||||
.fp-wait {
|
||||
color: #ff9900;
|
||||
}
|
||||
.fp-rec {
|
||||
font-size: 12px;
|
||||
margin-top: 3px;
|
||||
&.approved {
|
||||
color: #19be6b;
|
||||
}
|
||||
&.rejected {
|
||||
color: #ed4014;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,514 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch, h } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useRequest } from "alova/client";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { noticeApi } from "@/api/module/notice";
|
||||
import type { NoticeVo, NoticeQuery, ReadReceiptVo } from "@/types/notice";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import Utils from "@/utils";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
/* 全部通知(noticelist) / 已发管理(list) */
|
||||
const isManage = computed(() => route.path === "/noticelist");
|
||||
|
||||
/* ============ 搜索条件(规范 §1:日期 fl + 下拉 fl + 关键字 fr 宽200 即搜) ============ */
|
||||
const keyword = ref("");
|
||||
const deptType = ref("");
|
||||
const statusType = ref(""); // 状态:"" 全部(排除草稿) / 1 已发布 / 2 待审批 / 3 已驳回
|
||||
const noticeType = ref(""); // 类型:"" 全部 / 1 普通 / 2 重要
|
||||
const startDt = ref("");
|
||||
const endDt = ref("");
|
||||
const { data: deptOptions } = useRequest(noticeApi.getSysDic());
|
||||
|
||||
function fmtDate(d: Date) {
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
|
||||
/* ============ 分页(规范 §3:customPageCount 页长记忆) ============ */
|
||||
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 || "",
|
||||
deptId: deptType.value || "",
|
||||
pageIndex: pageNum.value,
|
||||
pageSize: pageSize.value,
|
||||
orderByTime: "1",
|
||||
};
|
||||
if (statusType.value !== "") params.status = Number(statusType.value);
|
||||
if (noticeType.value !== "") params.type = Number(noticeType.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 pageUpdate(p: number) {
|
||||
pageNum.value = p;
|
||||
run();
|
||||
}
|
||||
function pageSizeUpdate(size: number) {
|
||||
pageNum.value = 1;
|
||||
pageSize.value = size;
|
||||
localStorage.setItem("customPageCount", String(size));
|
||||
run();
|
||||
}
|
||||
|
||||
function resetFilter() {
|
||||
pageNum.value = 1;
|
||||
keyword.value = "";
|
||||
deptType.value = "";
|
||||
statusType.value = "";
|
||||
noticeType.value = "";
|
||||
if (!isManage.value) {
|
||||
const start = new Date();
|
||||
start.setMonth(start.getMonth() - 1);
|
||||
startDt.value = fmtDate(start);
|
||||
endDt.value = fmtDate(new Date());
|
||||
} else {
|
||||
startDt.value = "";
|
||||
endDt.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 旧版:已发通知默认筛选近一个月,通知列表不默认
|
||||
resetFilter();
|
||||
run();
|
||||
});
|
||||
|
||||
/* 同一组件复用于 /list 和 /noticelist,切换路由时重置条件并重新加载 */
|
||||
watch(
|
||||
() => route.path,
|
||||
() => {
|
||||
resetFilter();
|
||||
run();
|
||||
}
|
||||
);
|
||||
|
||||
/* ============ 操作(规范 §2:operating-btn iconfont;§6:删除 myConfirm) ============ */
|
||||
function openDetail(row: NoticeVo) {
|
||||
// 详情路由随入口:已发管理 → /list/detail(高亮已发管理),全部通知 → /noticelist/detail(高亮全部通知)
|
||||
const detailPath = route.path.startsWith("/noticelist") ? "/noticelist/detail" : "/list/detail";
|
||||
router.push({ path: detailPath, query: { serid: row.id, type: 0 } });
|
||||
}
|
||||
|
||||
/* 编辑/驳回重提:status=3 已驳回进入写通知编辑(改后可重新提交审批) */
|
||||
function editRow(row: NoticeVo) {
|
||||
router.push({ path: "/list/new", query: { serid: row.id } });
|
||||
}
|
||||
|
||||
/* 转发:基于源通知创建新通知 */
|
||||
function forwardRow(row: NoticeVo) {
|
||||
router.push({ path: "/list/new", query: { fid: row.id } });
|
||||
}
|
||||
|
||||
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 pullRow(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 || "撤回失败");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/* 置顶/取消置顶 */
|
||||
async function toggleTop(row: NoticeVo) {
|
||||
try {
|
||||
await noticeApi.setTopmost(row.id, row.topmost == 1 ? 0 : 1).send();
|
||||
Message.success(row.topmost == 1 ? "已取消置顶" : "已置顶");
|
||||
run();
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "操作失败");
|
||||
}
|
||||
}
|
||||
|
||||
/* 催读:向未读人员发送提醒(要求学员阅读回执的通知) */
|
||||
async function expediteRow(row: NoticeVo) {
|
||||
try {
|
||||
await noticeApi.expedite(row.id).send();
|
||||
Message.success("已向未读人员发送催读提醒");
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "操作失败");
|
||||
}
|
||||
}
|
||||
|
||||
/* ============ 阅读回执(ReadReceipt:年级→班级→学生 + 教师组) ============ */
|
||||
const receiptModal = ref(false);
|
||||
const receiptLoading = ref(false);
|
||||
const receipt = ref<ReadReceiptVo | null>(null);
|
||||
const receiptRow = ref<NoticeVo | null>(null);
|
||||
|
||||
async function openReceipt(row: NoticeVo) {
|
||||
receiptRow.value = row;
|
||||
receiptModal.value = true;
|
||||
receiptLoading.value = true;
|
||||
receipt.value = null;
|
||||
try {
|
||||
receipt.value = await noticeApi.readReceipt(row.id).send();
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "加载阅读回执失败");
|
||||
} finally {
|
||||
receiptLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function receiptExpedite() {
|
||||
if (!receiptRow.value) return;
|
||||
try {
|
||||
await noticeApi.expedite(receiptRow.value.id).send();
|
||||
Message.success("已向未读人员发送催读提醒");
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "操作失败");
|
||||
}
|
||||
}
|
||||
|
||||
const receiptPercent = computed(() => {
|
||||
const allow = Number(receipt.value?.allowCount) || 0;
|
||||
const read = Number(receipt.value?.readCount) || 0;
|
||||
return allow ? Math.round((read / allow) * 100) : 0;
|
||||
});
|
||||
|
||||
/* 附件下载:逐个触发(间隔 500ms,同旧版) */
|
||||
function downloadFiles(row: NoticeVo & { UpFile?: string }) {
|
||||
if (!row.UpFile) return;
|
||||
row.UpFile.split(",").forEach((id: string, i: number) => {
|
||||
setTimeout(() => {
|
||||
window.location.href = noticeApi.fileDownUrl(Number(id));
|
||||
}, i * 500);
|
||||
});
|
||||
}
|
||||
|
||||
/* 状态列:草稿/已发布(定时中)/待审批(步骤进度)/已驳回 + 已撤回/置顶徽标 */
|
||||
const STATUS_TAG: Record<string, string> = { pending: "warning", approved: "success", rejected: "error", none: "default" };
|
||||
|
||||
function renderStatus(h: any, row: any) {
|
||||
const parts: any[] = [];
|
||||
if (row.status === 1) {
|
||||
// 定时未到点:定时中(已排期)
|
||||
if (row.timingSendTime && row.timingPublished === 0 && row.isPulled !== 1) {
|
||||
parts.push(h("Tag", { color: "purple" }, () => "定时中"));
|
||||
} else {
|
||||
parts.push(h("Tag", { color: "success" }, () => "已发布"));
|
||||
}
|
||||
} else if (row.status === 2) {
|
||||
parts.push(h("Tag", { color: "warning" }, () => "待审批"));
|
||||
// 多步进度文本(如「第1/2步 校级审核·会签 0/2」),与徽标重复时不再显示
|
||||
if (row.approvalStatusDesc && row.approvalStatusDesc !== "待审批") {
|
||||
parts.push(h("div", { style: "font-size:12px;color:#808695;line-height:1.4;margin-top:2px" }, String(row.approvalStatusDesc)));
|
||||
}
|
||||
} else if (row.status === 3) {
|
||||
parts.push(h("Tag", { color: "error" }, () => "已驳回"));
|
||||
} else {
|
||||
parts.push(h("Tag", {}, () => row.statusDesc || "草稿"));
|
||||
}
|
||||
if (row.isPulled === 1) parts.push(h("Tag", { style: "margin-left:4px" }, () => "已撤回"));
|
||||
if (row.topmost == 1) parts.push(h("Tag", { color: "orange", style: "margin-left:4px" }, () => "置顶"));
|
||||
return h("div", {}, parts);
|
||||
}
|
||||
|
||||
/* 表格列(对照旧版 notice_list.vue;操作列图标式 fixed right) */
|
||||
const columns = computed(() => [
|
||||
{ title: "序号", type: "index", width: 70, align: "center" },
|
||||
{
|
||||
title: "标题",
|
||||
key: "title",
|
||||
align: "left",
|
||||
minWidth: 200,
|
||||
// 公告标题超过 8 个字时截断并显示略号,完整标题悬浮可见
|
||||
render: (h: any, p: any) => {
|
||||
const t = String(p.row.title ?? "");
|
||||
return h("span", { attrs: { title: t } }, t.length > 8 ? `${t.slice(0, 8)}…` : t);
|
||||
},
|
||||
},
|
||||
{ title: "类型", width: 80, align: "center",
|
||||
render: (h: any, p: any) => h("Tag", { color: p.row.type == 2 ? "error" : "default" }, () => p.row.typeDesc || (p.row.type == 2 ? "重要" : "普通")) },
|
||||
{ title: "来源部门", key: "deptName", width: 110, align: "left" },
|
||||
{ title: "发布人", key: "publisherName", width: 90, align: "left", tooltip: true },
|
||||
{ title: "状态", width: 190, align: "left", render: (h: any, p: any) => renderStatus(h, p.row) },
|
||||
{
|
||||
title: "发布时间",
|
||||
width: 150,
|
||||
align: "left",
|
||||
render: (h: any, p: any) => h("span", {}, String(p.row.timingSendTime || p.row.createTime || "").replace("T", " ").slice(0, 16)),
|
||||
},
|
||||
{
|
||||
title: "阅读情况",
|
||||
width: 110,
|
||||
align: "left",
|
||||
render: (h: any, p: any) =>
|
||||
p.row.status === 1
|
||||
? h("span", {}, `${p.row.readCount || 0} / ${p.row.allowCount || 0} 已读`)
|
||||
: h("span", { style: "color:#999;" }, "—"),
|
||||
},
|
||||
{
|
||||
title: "附件",
|
||||
width: 70,
|
||||
align: "center",
|
||||
render: (h: any, p: any) =>
|
||||
p.row.UpFile
|
||||
? Utils.operatingHandle(h, "附件下载", "icon-attachment", () => downloadFiles(p.row))
|
||||
: h("span", { style: "color:#999;" }, "无"),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
width: 300,
|
||||
align: "center",
|
||||
fixed: "right",
|
||||
render: (h: any, p: any) => {
|
||||
const row: any = p.row;
|
||||
const ops = [Utils.operatingHandle(h, "查看公告", "icon-browse", () => openDetail(row))];
|
||||
if (row.status === 1) {
|
||||
ops.push(Utils.operatingHandle(h, "阅读回执", "icon-liucheng", () => openReceipt(row)));
|
||||
if (row.isPulled !== 1) {
|
||||
if (!isManage.value) {
|
||||
ops.push(Utils.operatingHandle(h, "撤回", "icon-stop", () => pullRow(row)));
|
||||
ops.push(Utils.operatingHandle(h, row.topmost == 1 ? "取消置顶" : "置顶", "icon-zzdj", () => toggleTop(row)));
|
||||
if (row.receiptFlag == 1) {
|
||||
ops.push(Utils.operatingHandle(h, "催读", "icon-cuwu", () => expediteRow(row)));
|
||||
}
|
||||
ops.push(Utils.operatingHandle(h, "转发", "icon-fasong", () => forwardRow(row)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (row.status === 3) {
|
||||
ops.push(Utils.operatingHandle(h, "编辑重提", "icon-xiugaijl", () => editRow(row)));
|
||||
}
|
||||
ops.push(Utils.operatingHandle(h, "删除公告", "icon-ashbin", () => del(row)));
|
||||
return ops;
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const tableHeight = computed(() => Utils.useTableHeight({ isSearch: true, isOperat: true, isPage: true }));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-wrapper">
|
||||
<div class="main-container">
|
||||
<!-- 1. 操作按钮区 -->
|
||||
<div class="fix mb-12" v-if="!isManage">
|
||||
<Button class="fl" type="primary" size="large" @click="router.push('/list/new')">写公告</Button>
|
||||
</div>
|
||||
|
||||
<!-- 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="statusType" class="search-wrap_sel ml-12" clearable placeholder="全部状态" style="width: 110px" @on-change="doSearch">
|
||||
<Option value="1">已发布</Option>
|
||||
<Option value="2">待审批</Option>
|
||||
<Option value="3">已驳回</Option>
|
||||
</Select>
|
||||
<Select v-model="noticeType" class="search-wrap_sel ml-12" clearable placeholder="全部类型" style="width: 100px" @on-change="doSearch">
|
||||
<Option value="1">普通</Option>
|
||||
<Option value="2">重要</Option>
|
||||
</Select>
|
||||
<Select v-model="deptType" class="search-wrap_sel ml-12" clearable placeholder="来源部门" @on-change="doSearch">
|
||||
<Option v-for="d in deptOptions" :key="d.k" :value="d.k">{{ d.v }}</Option>
|
||||
</Select>
|
||||
<Input
|
||||
class="fr"
|
||||
style="width: 200px"
|
||||
search
|
||||
suffix="ios-search"
|
||||
placeholder="输入关键字搜索"
|
||||
v-model="keyword"
|
||||
@on-search="doSearch"
|
||||
@on-clear="doSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 3. 表格 + 分页 -->
|
||||
<Table
|
||||
:columns="columns"
|
||||
:data="page.content"
|
||||
class="link-table table-bottom-box"
|
||||
:height="tableHeight"
|
||||
:loading="loading"
|
||||
no-data-text="暂无数据"
|
||||
></Table>
|
||||
<div class="page-panel">
|
||||
<div class="fl ml-15 align-middle"></div>
|
||||
<Page
|
||||
class="page-box fr"
|
||||
:total="page.total"
|
||||
:current="pageNum"
|
||||
:page-size="pageSize"
|
||||
:page-size-opts="[10, 20, 30, 40]"
|
||||
@on-page-size-change="pageSizeUpdate"
|
||||
@on-change="pageUpdate"
|
||||
placement="top"
|
||||
show-total
|
||||
show-sizer
|
||||
></Page>
|
||||
</div>
|
||||
|
||||
<!-- 阅读回执弹窗(年级→班级→学生着色 + 教师组 + 催读) -->
|
||||
<Modal v-model="receiptModal" :title="`阅读回执:${receiptRow?.title || ''}`" width="720px">
|
||||
<div v-if="receiptLoading" class="receipt-tip">加载中…</div>
|
||||
<template v-else-if="receipt">
|
||||
<div class="receipt-summary">
|
||||
<span>
|
||||
<b class="green">{{ receipt.readCount || 0 }}</b> / {{ receipt.allowCount || 0 }} 已读({{ receiptPercent }}%)
|
||||
</span>
|
||||
<Progress :percent="receiptPercent" :stroke-width="8" style="flex: 1" />
|
||||
</div>
|
||||
<div class="receipt-list">
|
||||
<template v-if="receipt.grades && receipt.grades.length">
|
||||
<div v-for="g in receipt.grades" :key="g.gradeId">
|
||||
<div class="receipt-group">{{ g.gradeName || "未分组" }}</div>
|
||||
<div v-for="c in g.classes" :key="c.classId" class="receipt-class">
|
||||
<div class="receipt-class-name">
|
||||
{{ c.className }}({{ c.students.filter((s) => s.read).length }}/{{ c.students.length }} 已读)
|
||||
</div>
|
||||
<div class="receipt-tags">
|
||||
<span
|
||||
v-for="s in c.students"
|
||||
:key="s.account"
|
||||
:class="['stu-tag', s.read ? 'read' : 'unread']"
|
||||
:title="s.readTime ? '阅读时间:' + s.readTime : '未读'"
|
||||
>{{ s.name || s.account }}{{ s.read ? " ✓" : "" }}</span
|
||||
>
|
||||
<span v-if="!c.students.length" class="receipt-empty">该班级暂无接收学生</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="receipt-empty">无学员接收记录</div>
|
||||
<template v-if="receipt.teachers && receipt.teachers.length">
|
||||
<div class="receipt-group">
|
||||
教师({{ receipt.teachers.filter((t) => t.read).length }}/{{ receipt.teachers.length }} 已读)
|
||||
</div>
|
||||
<div class="receipt-tags">
|
||||
<span
|
||||
v-for="t in receipt.teachers"
|
||||
:key="t.account"
|
||||
:class="['stu-tag', t.read ? 'read' : 'unread']"
|
||||
:title="t.readTime ? '阅读时间:' + t.readTime : '未读'"
|
||||
>{{ t.name || t.account }}{{ t.read ? " ✓" : "" }}</span
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="receipt-tip">暂无回执数据</div>
|
||||
<template #footer>
|
||||
<Button type="warning" @click="receiptExpedite">催收未读</Button>
|
||||
<Button type="primary" @click="receiptModal = false" style="margin-left: 8px">关闭</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.receipt-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 13px;
|
||||
color: #515a6e;
|
||||
}
|
||||
.receipt-summary .green {
|
||||
font-size: 18px;
|
||||
color: #19be6b;
|
||||
}
|
||||
.receipt-list {
|
||||
max-height: 420px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.receipt-group {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #515a6e;
|
||||
margin: 12px 0 6px;
|
||||
}
|
||||
.receipt-class {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.receipt-class-name {
|
||||
font-size: 12px;
|
||||
color: #808695;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.receipt-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.stu-tag {
|
||||
font-size: 12px;
|
||||
padding: 2px 9px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.stu-tag.read {
|
||||
background: #dcfce7;
|
||||
color: #16a34a;
|
||||
}
|
||||
.stu-tag.unread {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
.receipt-empty {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.receipt-tip {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
padding: 30px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,482 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, nextTick } from "vue";
|
||||
import { Message } from "view-ui-plus";
|
||||
import draggable from "vuedraggable";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import UserCheckModal from "@/components/UserCheckModal.vue";
|
||||
import TypeSetBox from "@/components/TypeSetBox.vue";
|
||||
import { settingApi, baseApi } from "@/api/module/notice";
|
||||
import type { DeptManageItem, ContactGroupPage, ContactUserPage, SchoolTreeNode } from "@/types/notice";
|
||||
import Utils from "@/utils";
|
||||
|
||||
/* ============ Tab 切换(规范 §1:tabs-wrapper + Tabs type=card,切换只 v-show 内容区) ============ */
|
||||
const currentType = ref(1);
|
||||
|
||||
function tabChange(name: string) {
|
||||
currentType.value = Number(name);
|
||||
if (currentType.value === 1) {
|
||||
getDeptList();
|
||||
} else if (currentType.value === 3) {
|
||||
getGroupList();
|
||||
}
|
||||
}
|
||||
|
||||
/* ============ 发文部门(直接复用 UserBase 同源 TypeSetBox 卡片组件) ============ */
|
||||
interface DeptItem {
|
||||
SerID: number;
|
||||
CodeID: string;
|
||||
Name: string;
|
||||
Enable: number;
|
||||
}
|
||||
const deptData = ref<DeptItem[]>([]);
|
||||
const deptModal = ref(false);
|
||||
const deptForm = reactive({ name: "" });
|
||||
const deptFormRef = ref();
|
||||
const deptRule = {
|
||||
name: [{ required: true, message: "请输入发文部门名称", trigger: "blur" }],
|
||||
};
|
||||
|
||||
async function getDeptList() {
|
||||
try {
|
||||
const data: DeptManageItem[] = await settingApi.getDictMangeList().send();
|
||||
deptData.value = (data || []).map((d) => ({
|
||||
SerID: d.SerID,
|
||||
CodeID: d.CodeID,
|
||||
Name: d.Name,
|
||||
Enable: d.Enable ?? 1,
|
||||
}));
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "获取发文部门列表失败");
|
||||
}
|
||||
}
|
||||
|
||||
function addDeptShow() {
|
||||
deptForm.name = "";
|
||||
deptModal.value = true;
|
||||
}
|
||||
|
||||
async function deptModalSave() {
|
||||
const valid = await deptFormRef.value?.validate();
|
||||
if (!valid) {
|
||||
Message.info("请填写红色星号内容!");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await settingApi.addDeptDic(deptForm.name.trim()).send();
|
||||
Message.success("操作成功!");
|
||||
deptModal.value = false;
|
||||
await getDeptList();
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "操作失败");
|
||||
}
|
||||
}
|
||||
|
||||
function findDept(id: string): DeptItem | undefined {
|
||||
return deptData.value.find((x) => String(x.SerID) === id);
|
||||
}
|
||||
|
||||
/** TypeSetBox 启用/停用(保留确认弹窗) */
|
||||
function onDeptToggle(id: string) {
|
||||
const item = findDept(id);
|
||||
if (item) toggleDeptStatus(item);
|
||||
}
|
||||
|
||||
/** TypeSetBox 内联编辑部门名(组件已拦截空值) */
|
||||
async function onDeptRename(id: string, name: string) {
|
||||
const item = findDept(id);
|
||||
if (!item) return;
|
||||
try {
|
||||
await settingApi.updateDeptDic(item.SerID, name.trim(), item.Enable).send();
|
||||
Message.success("操作成功!");
|
||||
await getDeptList();
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "操作失败");
|
||||
}
|
||||
}
|
||||
|
||||
function onDeptRemove(id: string) {
|
||||
const item = findDept(id);
|
||||
if (item) deleteDept(item);
|
||||
}
|
||||
|
||||
async function deleteDept(item: DeptItem) {
|
||||
await MyConfirm({
|
||||
title: "删除",
|
||||
msg: `确定删除“ ${item.Name} ”这个发文部门吗?`,
|
||||
info: "删除后不可恢复",
|
||||
async onOk() {
|
||||
try {
|
||||
await settingApi.delDept(item.SerID).send();
|
||||
Message.success("操作成功!");
|
||||
await getDeptList();
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "删除失败");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function toggleDeptStatus(item: DeptItem) {
|
||||
const enable = item.Enable === 1;
|
||||
await MyConfirm({
|
||||
msg: `确定${enable ? "停用" : "启用"}发文部门「${item.Name}」吗?`,
|
||||
title: "修改状态",
|
||||
async onOk() {
|
||||
try {
|
||||
await settingApi.updateDeptDic(item.SerID, item.Name, enable ? 0 : 1).send();
|
||||
item.Enable = enable ? 0 : 1;
|
||||
Message.success("操作成功!");
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "操作失败");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/* 拖动排序:提交当前顺序的 SerID 数组 */
|
||||
async function deptSortSave() {
|
||||
const ids = deptData.value.map((x: DeptItem) => x.SerID);
|
||||
try {
|
||||
await settingApi.sortDeptDic(ids.join(",")).send();
|
||||
Message.success("排序成功");
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "排序失败");
|
||||
}
|
||||
}
|
||||
|
||||
/* ============ 自定义组(规范 §5 联系组管理页形态) ============ */
|
||||
interface GroupItem {
|
||||
serid: number;
|
||||
GroupName: string;
|
||||
count: number;
|
||||
}
|
||||
const groupData = ref<GroupItem[]>([]);
|
||||
const groupTotal = ref(0);
|
||||
const groupPage = ref(1);
|
||||
const groupCount = ref(parseInt(localStorage.getItem("customPageCount") || "10"));
|
||||
|
||||
async function getGroupList() {
|
||||
try {
|
||||
const data: any = await settingApi.getGroup(groupPage.value, groupCount.value).send();
|
||||
groupData.value = (data.list || []).map((d: any) => ({
|
||||
serid: d.serid,
|
||||
GroupName: d.GroupName,
|
||||
count: d.count ?? 0,
|
||||
}));
|
||||
groupTotal.value = data.total || 0;
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "获取自定义组列表失败");
|
||||
}
|
||||
}
|
||||
|
||||
const groupModal = ref(false);
|
||||
const groupEditingId = ref<number | null>(null);
|
||||
const groupForm = reactive({ name: "" });
|
||||
const groupFormRef = ref();
|
||||
const groupRule = {
|
||||
name: [{ required: true, message: "请输入联系组名称", trigger: "blur" }],
|
||||
};
|
||||
|
||||
function addGroupShow() {
|
||||
groupEditingId.value = null;
|
||||
groupForm.name = "";
|
||||
groupModal.value = true;
|
||||
}
|
||||
|
||||
function editGroupShow(row: GroupItem) {
|
||||
groupEditingId.value = row.serid;
|
||||
groupForm.name = row.GroupName;
|
||||
groupModal.value = true;
|
||||
}
|
||||
|
||||
async function groupModalSave() {
|
||||
const valid = await groupFormRef.value?.validate();
|
||||
if (!valid) {
|
||||
Message.info("请填写红色星号内容!");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (groupEditingId.value == null) {
|
||||
await settingApi.addGroup(groupForm.name.trim()).send();
|
||||
} else {
|
||||
await settingApi.editGroup(groupEditingId.value, groupForm.name.trim()).send();
|
||||
}
|
||||
Message.success("操作成功!");
|
||||
groupModal.value = false;
|
||||
await getGroupList();
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "操作失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteGroup(row: GroupItem) {
|
||||
await MyConfirm({
|
||||
title: "删除",
|
||||
msg: `确定删除“ ${row.GroupName} ”这个组吗?`,
|
||||
info: "删除后不可恢复",
|
||||
async onOk() {
|
||||
try {
|
||||
await settingApi.delGroup(String(row.serid)).send();
|
||||
Message.success("操作成功!");
|
||||
// 删除后智能翻页:当前页仅剩一条则回退一页
|
||||
if (groupData.value.length <= 1 && groupPage.value > 1) groupPage.value -= 1;
|
||||
await getGroupList();
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "删除失败");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- 查看联系组用户 ---------- */
|
||||
const seeUserModal = ref(false);
|
||||
const seeUserGid = ref(0);
|
||||
const seeUserGroupName = ref("");
|
||||
const seeUserData = ref<{ account: string; name: string }[]>([]);
|
||||
const seeUserTotal = ref(0);
|
||||
const seeUserPage = ref(1);
|
||||
const seeUserColumns = [
|
||||
{ title: "序号", type: "index", width: 70 },
|
||||
{ title: "账号", key: "account", align: "center" },
|
||||
{ title: "真实姓名", key: "name", width: 160, align: "center" },
|
||||
];
|
||||
|
||||
async function seeUserShow(row: GroupItem) {
|
||||
seeUserGid.value = row.serid;
|
||||
seeUserGroupName.value = row.GroupName;
|
||||
seeUserPage.value = 1;
|
||||
await loadSeeUser();
|
||||
seeUserModal.value = true;
|
||||
}
|
||||
|
||||
async function loadSeeUser() {
|
||||
try {
|
||||
const data: ContactUserPage = await settingApi.getContactsTea(seeUserGid.value, seeUserPage.value, 5).send();
|
||||
seeUserData.value = (data.list || []).map((d) => ({ account: d.account, name: d.name }));
|
||||
seeUserTotal.value = data.total || 0;
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "获取用户列表失败");
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 设置联系组用户(规范选人组件,打开前回显已有成员) ---------- */
|
||||
const setUserCheckRef = ref();
|
||||
const setUserGid = ref(0);
|
||||
const setUserIds = ref<number[]>([]);
|
||||
// 设置自定义组成员只需要基础平台组织树,无需「基础平台/自定义组」两个 tab
|
||||
const setUserSources = [
|
||||
{
|
||||
name: "基础平台",
|
||||
loader: () => baseApi.getSchoolAll().send(),
|
||||
},
|
||||
];
|
||||
|
||||
async function settingUserShow(row: GroupItem) {
|
||||
setUserGid.value = row.serid;
|
||||
setUserIds.value = [];
|
||||
try {
|
||||
const data: ContactUserPage = await settingApi.getContactsTea(row.serid, 0, 0).send();
|
||||
setUserIds.value = (data.list || [])
|
||||
.map((d) => Number(d.userId ?? d.id))
|
||||
.filter((n: number) => Number.isFinite(n) && n > 0);
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "获取已有用户失败");
|
||||
}
|
||||
await nextTick();
|
||||
setUserCheckRef.value.show();
|
||||
}
|
||||
|
||||
async function settingUserSubmit(users: SchoolTreeNode[]) {
|
||||
if (!users.length) {
|
||||
Message.info("请选择用户!");
|
||||
return;
|
||||
}
|
||||
// on-submit 返回的是用户对象数组({id,name,group}),取 id 拼接
|
||||
const ids = users.map((x) => (typeof x === "object" && x != null ? x.id : x)).join(",");
|
||||
try {
|
||||
await settingApi.setContactsTea(setUserGid.value, ids).send();
|
||||
Message.success("设置成功");
|
||||
// 刷新组列表,组员数即时更新
|
||||
await getGroupList();
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "设置失败");
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getDeptList();
|
||||
});
|
||||
|
||||
/* ---------- 自定义组表格(规范 §5:序号/组名/操作图标列 width 250) ---------- */
|
||||
const groupColumns = [
|
||||
{ title: "序号", type: "index", width: 80, align: "center" },
|
||||
{ title: "组名", key: "GroupName", align: "left" },
|
||||
{ title: "组员数", key: "count", width: 100, align: "center" },
|
||||
{
|
||||
title: "操作",
|
||||
width: 250,
|
||||
align: "center",
|
||||
render: (h: any, params: any) => {
|
||||
const row: GroupItem = params.row;
|
||||
return [
|
||||
Utils.operatingHandle(h, "查看用户", "icon-browse", () => seeUserShow(row)),
|
||||
Utils.operatingHandle(h, "编辑", "icon-edit", () => editGroupShow(row)),
|
||||
Utils.operatingHandle(h, "删除", "icon-ashbin", () => deleteGroup(row)),
|
||||
Utils.operatingHandle(h, "设置用户", "icon-user-filling", () => settingUserShow(row)),
|
||||
];
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const groupTableHeight = computed(() => Utils.useTableHeight({ isTabs: true, isSearch: true, isPage: true }));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tabs-wrapper">
|
||||
<Tabs class="tabs-panel" type="card" :value="String(currentType)" @on-click="tabChange">
|
||||
<TabPane label="发文部门" name="1"></TabPane>
|
||||
<TabPane label="自定义组" name="3"></TabPane>
|
||||
</Tabs>
|
||||
|
||||
<!-- 发文部门 -->
|
||||
<div class="main-container" v-show="currentType === 1">
|
||||
<Alert show-icon>设置说明:拖动卡片调整部门排序;停用的部门不出现在「写公告」的部门下拉中</Alert>
|
||||
<div class="search-wrap fix">
|
||||
<Button class="fl" type="primary" @click="addDeptShow">新增部门</Button>
|
||||
</div>
|
||||
<draggable
|
||||
class="type_set-wrap fix"
|
||||
tag="ul"
|
||||
v-model="deptData"
|
||||
group="noticeDept"
|
||||
item-key="SerID"
|
||||
@change="deptSortSave"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<li>
|
||||
<TypeSetBox
|
||||
:type="{ id: String(element.SerID), name: element.Name }"
|
||||
:active="element.Enable === 1"
|
||||
@active="onDeptToggle"
|
||||
@edit="onDeptRename"
|
||||
@remove="onDeptRemove"
|
||||
/>
|
||||
</li>
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
|
||||
<!-- 自定义组 -->
|
||||
<div class="main-container" v-show="currentType === 3">
|
||||
<div class="search-wrap fix">
|
||||
<Button class="fl" type="primary" @click="addGroupShow">新建联系组</Button>
|
||||
</div>
|
||||
<Table
|
||||
:columns="groupColumns"
|
||||
:data="groupData"
|
||||
class="link-table table-bottom-box"
|
||||
:height="groupTableHeight"
|
||||
no-data-text="暂无数据"
|
||||
></Table>
|
||||
<div class="page-panel">
|
||||
<div class="fl ml-15 align-middle"></div>
|
||||
<Page
|
||||
class="page-box fr"
|
||||
:total="groupTotal"
|
||||
:current="groupPage"
|
||||
:page-size="groupCount"
|
||||
:page-size-opts="[10, 20, 30, 40]"
|
||||
@on-page-size-change="
|
||||
(size: number) => {
|
||||
groupPage = 1;
|
||||
groupCount = size;
|
||||
localStorage.setItem('customPageCount', String(size));
|
||||
getGroupList();
|
||||
}
|
||||
"
|
||||
@on-change="
|
||||
(p: number) => {
|
||||
groupPage = p;
|
||||
getGroupList();
|
||||
}
|
||||
"
|
||||
placement="top"
|
||||
show-total
|
||||
show-sizer
|
||||
></Page>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增发文部门 Modal(编辑走 TypeSetBox 内联编辑) -->
|
||||
<Modal v-model="deptModal" :mask-closable="false" width="450px" title="新增部门">
|
||||
<Form ref="deptFormRef" :model="deptForm" :rules="deptRule" :label-width="110">
|
||||
<FormItem label="发文部门名称:" prop="name">
|
||||
<Input placeholder="请输入发文部门名称" v-model="deptForm.name" />
|
||||
</FormItem>
|
||||
</Form>
|
||||
<template #footer>
|
||||
<Button type="primary" @click="deptModalSave">提交</Button>
|
||||
<Button @click="deptModal = false">取消</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- 新建/编辑联系组 Modal(规范 §5:450px + label-width 100 + 提交/取消) -->
|
||||
<Modal v-model="groupModal" :mask-closable="false" width="450px" :title="groupEditingId == null ? '新建联系组' : '编辑联系组'">
|
||||
<Form ref="groupFormRef" :model="groupForm" :rules="groupRule" :label-width="100">
|
||||
<FormItem label="联系组名称:" prop="name">
|
||||
<Input placeholder="请输入联系组名称" v-model="groupForm.name" />
|
||||
</FormItem>
|
||||
</Form>
|
||||
<template #footer>
|
||||
<Button type="primary" @click="groupModalSave">提交</Button>
|
||||
<Button @click="groupModal = false">取消</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- 查看联系组用户 Modal(规范 §5:Modal 内小表格 + small 分页) -->
|
||||
<Modal v-model="seeUserModal" :title="`查看联系组用户${seeUserGroupName ? ' - ' + seeUserGroupName : ''}`">
|
||||
<Table height="260" :columns="seeUserColumns" :data="seeUserData" no-data-text="暂无用户"></Table>
|
||||
<div class="small-pager">
|
||||
<Page
|
||||
:current="seeUserPage"
|
||||
:page-size="5"
|
||||
:total="seeUserTotal"
|
||||
@on-change="
|
||||
(p: number) => {
|
||||
seeUserPage = p;
|
||||
loadSeeUser();
|
||||
}
|
||||
"
|
||||
size="small"
|
||||
show-total
|
||||
/>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button @click="seeUserModal = false">关闭</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- 选择用户弹窗(设置联系组用户) -->
|
||||
<UserCheckModal ref="setUserCheckRef" :sources="setUserSources" :userids="setUserIds" @on-submit="settingUserSubmit" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tab-body {
|
||||
min-height: 300px;
|
||||
}
|
||||
.type_set-wrap {
|
||||
margin: 12px 0 16px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
/* 拖动排序手型(与 UserBase AppCategory 页面级样式一致,组件内不含) */
|
||||
.type_set-wrap :deep(.type_name) {
|
||||
cursor: move;
|
||||
}
|
||||
.small-pager {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from "view-ui-plus";
|
||||
|
||||
/**
|
||||
* 通知统计:统计功能开发中,暂不提供页面。
|
||||
*/
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-wrapper">
|
||||
<div class="main-container stats-dev">
|
||||
<Icon type="ios-stats" size="56" color="#c5c8ce" />
|
||||
<p class="tip">公告统计功能开发中,敬请期待……</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.stats-dev {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: calc(100vh - 136px);
|
||||
color: #999;
|
||||
}
|
||||
.tip {
|
||||
margin-top: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user