feat: 提交校园公告前端工程源码(Vue3 + Vite + view-ui-plus)
build / build (push) Successful in 10m17s

- 同步仓库 CI 配置 .gitea/workflows/build.yml
- 纳入 src/public/config/scripts 等工程文件
- .gitignore 增加 .scratch/.backup/.trash,排除本地临时产物
This commit is contained in:
lijing
2026-09-15 11:15:03 +08:00
parent 36f921450c
commit ec1f94eaa8
344 changed files with 40001 additions and 2 deletions
+994
View File
@@ -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>
+237
View File
@@ -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>
+534
View File
@@ -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>
+609
View File
@@ -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>
+607
View File
@@ -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>
+514
View File
@@ -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>
+482
View File
@@ -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>
+31
View File
@@ -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>