Files
CampusnoticeService/src/main/resources/static/api-test.html
T
wanghu 8d2e18df21
build / build-push (push) Successful in 52s
1
2026-09-11 09:03:24 +08:00

1782 lines
84 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>校园通知</title>
<style>
:root {
--primary: #2563eb; --primary-dark: #1d4ed8; --primary-light: #dbeafe;
--bg: #f1f5f9; --card: #fff; --border: #e2e8f0;
--text: #0f172a; --text2: #64748b; --text3: #94a3b8;
--red: #dc2626; --red-bg: #fee2e2; --green: #16a34a; --green-bg: #dcfce7;
--orange: #d97706; --orange-bg: #fef3c7; --purple: #7c3aed; --purple-bg: #ede9fe;
--radius: 10px; --shadow: 0 1px 3px rgba(15,23,42,.06), 0 1px 2px rgba(15,23,42,.04);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif; background: var(--bg); color: var(--text); font-size: 14px; }
button { font-family: inherit; }
/* ============ 顶部导航 ============ */
.topbar { position: sticky; top: 0; z-index: 100; background: #fff; border-bottom: 1px solid var(--border); height: 56px; display: flex; align-items: center; padding: 0 24px; gap: 28px; }
.brand { display: flex; align-items: center; gap: 8px; font-size: 17px; font-weight: 700; color: var(--primary); cursor: pointer; white-space: nowrap; }
.brand svg { width: 24px; height: 24px; }
.nav { display: flex; gap: 4px; flex: 1; overflow-x: auto; }
.nav-item { position: relative; border: none; background: none; padding: 8px 14px; border-radius: 8px; font-size: 14px; color: var(--text2); cursor: pointer; white-space: nowrap; display: flex; align-items: center; gap: 6px; }
.nav-item:hover { background: var(--bg); color: var(--text); }
.nav-item.active { background: var(--primary-light); color: var(--primary); font-weight: 600; }
.nav-badge { min-width: 18px; height: 18px; padding: 0 5px; border-radius: 9px; background: var(--red); color: #fff; font-size: 11px; line-height: 18px; text-align: center; font-weight: 600; }
.nav-badge:empty { display: none; }
.user-chip { display: flex; align-items: center; gap: 8px; white-space: nowrap; }
.avatar { width: 32px; height: 32px; border-radius: 50%; background: var(--primary); color: #fff; display: flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 600; }
.user-name { font-weight: 600; max-width: 120px; overflow: hidden; text-overflow: ellipsis; }
.user-dept { color: var(--text3); font-size: 12px; }
.btn-logout { border: 1px solid var(--border); background: none; border-radius: 8px; padding: 6px 12px; cursor: pointer; color: var(--text2); font-size: 13px; }
.btn-logout:hover { color: var(--red); border-color: var(--red); }
/* ============ 主区域 ============ */
.wrap { max-width: 1100px; margin: 0 auto; padding: 20px 24px 60px; }
.view { display: none; }
.view.active { display: block; }
.page-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; flex-wrap: wrap; gap: 10px; }
.page-title { font-size: 20px; font-weight: 700; }
.page-sub { color: var(--text3); font-size: 13px; margin-top: 3px; }
/* ============ 工具条/筛选 ============ */
.toolbar { display: flex; gap: 10px; margin-bottom: 14px; flex-wrap: wrap; align-items: center; }
.seg { display: inline-flex; background: #e2e8f0; border-radius: 8px; padding: 3px; }
.seg button { border: none; background: none; padding: 5px 14px; border-radius: 6px; cursor: pointer; font-size: 13px; color: var(--text2); }
.seg button.on { background: #fff; color: var(--primary); font-weight: 600; box-shadow: var(--shadow); }
.input { border: 1px solid var(--border); border-radius: 8px; padding: 7px 12px; font-size: 13px; background: #fff; color: var(--text); outline: none; }
.input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(37,99,235,.1); }
select.input { cursor: pointer; }
.search-box { width: 220px; }
/* ============ 按钮 ============ */
.btn { border: none; border-radius: 8px; padding: 8px 16px; cursor: pointer; font-size: 13px; font-weight: 500; display: inline-flex; align-items: center; gap: 6px; transition: .15s; }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.btn-primary { background: var(--primary); color: #fff; }
.btn-primary:hover:not(:disabled) { background: var(--primary-dark); }
.btn-ghost { background: #fff; border: 1px solid var(--border); color: var(--text2); }
.btn-ghost:hover:not(:disabled) { border-color: var(--primary); color: var(--primary); }
.btn-danger { background: none; border: 1px solid var(--border); color: var(--red); }
.btn-danger:hover:not(:disabled) { border-color: var(--red); background: var(--red-bg); }
.btn-success { background: var(--green); color: #fff; }
.btn-success:hover:not(:disabled) { background: #15803d; }
.btn-warn { background: var(--orange); color: #fff; }
.btn-warn:hover:not(:disabled) { background: #b45309; }
.btn-sm { padding: 4px 10px; font-size: 12px; border-radius: 6px; }
.link-btn { border: none; background: none; color: var(--primary); cursor: pointer; font-size: 13px; padding: 4px 6px; border-radius: 4px; }
.link-btn:hover { background: var(--primary-light); }
.link-btn.danger { color: var(--red); }
.link-btn.danger:hover { background: var(--red-bg); }
.link-btn.success { color: var(--green); }
.link-btn.success:hover { background: var(--green-bg); }
/* ============ 通知列表 ============ */
.notice-list { display: flex; flex-direction: column; gap: 10px; }
.notice-item { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 18px; cursor: pointer; transition: .15s; display: flex; gap: 14px; align-items: flex-start; }
.notice-item:hover { border-color: var(--primary); box-shadow: 0 2px 8px rgba(37,99,235,.08); }
.read-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--red); margin-top: 8px; flex-shrink: 0; }
.read-dot.read { background: transparent; }
.notice-main { flex: 1; min-width: 0; }
.notice-title-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.notice-title { font-size: 15px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.notice-item.unread .notice-title { font-weight: 700; }
.notice-meta { color: var(--text3); font-size: 12px; margin-top: 6px; display: flex; gap: 14px; flex-wrap: wrap; }
.notice-side { display: flex; flex-direction: column; align-items: flex-end; gap: 6px; flex-shrink: 0; }
.notice-time { color: var(--text3); font-size: 12px; }
.badge { font-size: 11px; padding: 2px 8px; border-radius: 10px; font-weight: 500; white-space: nowrap; }
.b-important { background: var(--red-bg); color: var(--red); }
.b-normal { background: #f1f5f9; color: var(--text2); }
.b-top { background: var(--orange-bg); color: var(--orange); }
.b-draft { background: #f1f5f9; color: var(--text2); }
.b-published { background: var(--green-bg); color: var(--green); }
.b-pending { background: var(--orange-bg); color: var(--orange); }
.b-rejected { background: var(--red-bg); color: var(--red); }
.b-pulled { background: #f1f5f9; color: var(--text3); }
.b-timing { background: var(--purple-bg); color: var(--purple); }
.b-sms { background: var(--primary-light); color: var(--primary); }
.b-receipt { background: var(--primary-light); color: var(--primary); }
.read-progress { font-size: 12px; color: var(--text3); }
/* ============ 表格(已发/审批/草稿) ============ */
.table-card { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
table.tbl { width: 100%; border-collapse: collapse; }
table.tbl th { text-align: left; font-size: 12px; color: var(--text3); font-weight: 500; padding: 10px 14px; background: #f8fafc; border-bottom: 1px solid var(--border); white-space: nowrap; }
table.tbl td { padding: 12px 14px; border-bottom: 1px solid #f1f5f9; font-size: 13px; vertical-align: middle; }
table.tbl tr:last-child td { border-bottom: none; }
table.tbl tr:hover td { background: #f8fafc; }
.td-title { font-weight: 500; max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; }
.td-title:hover { color: var(--primary); }
.td-actions { white-space: nowrap; }
/* ============ 分页 ============ */
.pager { display: flex; align-items: center; justify-content: space-between; margin-top: 16px; color: var(--text3); font-size: 13px; }
.pager-btns { display: flex; gap: 8px; }
.empty { text-align: center; padding: 60px 0; color: var(--text3); }
.empty .icon { font-size: 44px; margin-bottom: 12px; }
/* ============ 写通知表单 ============ */
.compose { display: grid; grid-template-columns: 1fr 340px; gap: 16px; align-items: start; }
.panel { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; }
.panel + .panel { margin-top: 16px; }
.panel-title { font-size: 15px; font-weight: 600; margin-bottom: 14px; display: flex; align-items: center; gap: 8px; }
.panel-title .req { color: var(--red); }
.form-row { margin-bottom: 16px; }
.form-label { display: block; font-size: 13px; color: var(--text2); margin-bottom: 6px; font-weight: 500; }
.form-label .req { color: var(--red); margin-left: 2px; }
.form-input { width: 100%; border: 1px solid var(--border); border-radius: 8px; padding: 9px 12px; font-size: 14px; outline: none; background: #fff; color: var(--text); font-family: inherit; }
.form-input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(37,99,235,.1); }
textarea.form-input { min-height: 260px; resize: vertical; line-height: 1.7; font-size: 14px; }
.form-hint { font-size: 12px; color: var(--text3); margin-top: 5px; line-height: 1.5; }
.radio-group { display: flex; gap: 16px; }
.radio-group label { display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 14px; }
.check-inline { display: flex; gap: 18px; flex-wrap: wrap; }
.check-inline label { display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 13px; color: var(--text2); }
.compose-side .panel { padding: 16px; }
/* 范围树 */
.scope-tree { max-height: 300px; overflow: auto; border: 1px solid var(--border); border-radius: 8px; padding: 8px; }
.tree-node { user-select: none; }
.tree-row { display: flex; align-items: center; gap: 6px; padding: 4px 6px; border-radius: 6px; cursor: pointer; }
.tree-row:hover { background: #f1f5f9; }
.tree-row input { accent-color: var(--primary); cursor: pointer; }
.tree-toggle { width: 16px; text-align: center; color: var(--text3); font-size: 11px; cursor: pointer; flex-shrink: 0; }
.tree-children { margin-left: 20px; display: none; }
.tree-node.expanded > .tree-children { display: block; }
.scope-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; min-height: 10px; }
.chip { display: inline-flex; align-items: center; gap: 4px; background: var(--primary-light); color: var(--primary); border-radius: 12px; padding: 3px 10px; font-size: 12px; }
.chip b { cursor: pointer; font-weight: 400; opacity: .7; }
.chip b:hover { opacity: 1; }
.preview-box { margin-top: 10px; background: #f8fafc; border-radius: 8px; padding: 10px 12px; font-size: 13px; display: flex; gap: 16px; }
.preview-box b { font-size: 16px; color: var(--primary); }
/* 附件 */
.file-list { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
.file-item { display: flex; align-items: center; gap: 8px; background: #f8fafc; border: 1px solid var(--border); border-radius: 8px; padding: 7px 10px; font-size: 13px; }
.file-item .fname { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-item .fsize { color: var(--text3); font-size: 12px; }
.file-item .fdel { color: var(--red); cursor: pointer; padding: 0 4px; }
.upload-btn { display: inline-flex; align-items: center; gap: 6px; }
/* 操作栏 */
.compose-actions { display: flex; gap: 10px; margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--border); flex-wrap: wrap; }
/* ============ 统计卡片 ============ */
.stat-cards { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 16px; }
.stat-card { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; }
.stat-num { font-size: 26px; font-weight: 700; color: var(--primary); }
.stat-label { font-size: 12px; color: var(--text3); margin-top: 4px; }
/* ============ 弹窗 ============ */
.mask { position: fixed; inset: 0; background: rgba(15,23,42,.45); z-index: 200; display: none; align-items: flex-start; justify-content: center; padding: 40px 16px; overflow: auto; }
.mask.show { display: flex; }
.modal { background: #fff; border-radius: 14px; width: 100%; max-width: 760px; box-shadow: 0 20px 60px rgba(0,0,0,.2); animation: pop .18s ease; }
.modal-sm { max-width: 440px; }
@keyframes pop { from { transform: translateY(12px) scale(.98); opacity: 0; } }
.modal-head { display: flex; align-items: center; justify-content: space-between; padding: 16px 22px; border-bottom: 1px solid var(--border); }
.modal-title { font-size: 16px; font-weight: 700; }
.modal-close { border: none; background: none; font-size: 20px; color: var(--text3); cursor: pointer; padding: 2px 8px; border-radius: 6px; }
.modal-close:hover { background: var(--bg); color: var(--text); }
.modal-body { padding: 20px 22px; }
.modal-foot { padding: 14px 22px; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: 10px; }
/* 通知详情 */
.detail-meta { display: flex; flex-wrap: wrap; gap: 14px; color: var(--text2); font-size: 13px; padding-bottom: 14px; border-bottom: 1px solid var(--border); margin-bottom: 16px; }
.detail-content { line-height: 1.8; font-size: 14px; word-break: break-word; min-height: 60px; }
.detail-content img { max-width: 100%; }
.detail-files { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border); }
.detail-files .form-label { margin-bottom: 8px; }
/* 回执 */
.receipt-summary { display: flex; align-items: center; gap: 14px; margin-bottom: 16px; }
.progress { flex: 1; height: 10px; background: #e2e8f0; border-radius: 5px; overflow: hidden; }
.progress > div { height: 100%; background: var(--green); border-radius: 5px; transition: width .4s; }
.receipt-group-title { font-size: 13px; font-weight: 600; color: var(--text2); margin: 14px 0 8px; }
.class-block { margin-bottom: 10px; }
.class-block-title { font-size: 12px; color: var(--text3); margin-bottom: 6px; }
.stu-tags { display: flex; flex-wrap: wrap; gap: 6px; }
.stu-tag { font-size: 12px; padding: 3px 9px; border-radius: 10px; }
.stu-tag.read { background: var(--green-bg); color: var(--green); }
.stu-tag.unread { background: var(--red-bg); color: var(--red); }
/* ============ Toast ============ */
#toastBox { position: fixed; top: 70px; left: 50%; transform: translateX(-50%); z-index: 500; display: flex; flex-direction: column; gap: 8px; align-items: center; }
.toast { background: #0f172a; color: #fff; border-radius: 10px; padding: 10px 20px; font-size: 13px; box-shadow: 0 8px 24px rgba(0,0,0,.25); animation: pop .2s ease; max-width: 80vw; }
.toast.ok { background: var(--green); }
.toast.err { background: var(--red); }
/* ============ 登录提示 ============ */
.login-tip { text-align: center; padding: 90px 20px; }
.login-tip h2 { margin: 16px 0 8px; }
.login-tip p { color: var(--text3); margin-bottom: 24px; }
@media (max-width: 900px) {
.compose { grid-template-columns: 1fr; }
.stat-cards { grid-template-columns: repeat(2, 1fr); }
.user-dept { display: none; }
.topbar { padding: 0 12px; gap: 12px; }
.wrap { padding: 14px 12px 60px; }
}
</style>
</head>
<body>
<!-- ==================== 顶部导航 ==================== -->
<div class="topbar">
<div class="brand" onclick="switchView('inbox')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 8l9 6 9-6"/><rect x="3" y="4" width="18" height="16" rx="2"/></svg>
校园通知
</div>
<div class="nav">
<button class="nav-item" data-view="inbox" onclick="switchView('inbox')">我的通知 <span class="nav-badge" id="unreadBadge"></span></button>
<button class="nav-item" data-view="compose" onclick="switchView('compose')">写通知</button>
<button class="nav-item" data-view="sent" onclick="switchView('sent')">已发管理</button>
<button class="nav-item" data-view="drafts" onclick="switchView('drafts')">草稿箱</button>
<button class="nav-item" data-view="approvals" onclick="switchView('approvals')">审批中心</button>
<button class="nav-item" data-view="settings" onclick="switchView('settings')">设置管理</button>
</div>
<div class="user-chip">
<div class="avatar" id="userAvatar">?</div>
<div>
<div class="user-name" id="userName">加载中…</div>
<div class="user-dept" id="userDept"></div>
</div>
<button class="btn-logout" onclick="doLogout()">退出</button>
</div>
</div>
<div class="wrap">
<!-- ==================== 登录提示 ==================== -->
<div id="loginTip" class="login-tip" style="display:none">
<div style="font-size:52px">🔒</div>
<h2>登录状态已失效</h2>
<p>请重新登录后再使用校园通知。</p>
<button class="btn btn-primary" onclick="location.reload()">重新加载</button>
</div>
<div id="appRoot">
<!-- ==================== 我的通知(收件箱) ==================== -->
<div class="view" id="view-inbox">
<div class="page-head">
<div>
<div class="page-title">我的通知</div>
<div class="page-sub" id="inboxSub"></div>
</div>
<button class="btn btn-ghost" onclick="markAllRead()">全部标为已读</button>
</div>
<div class="toolbar">
<div class="seg" id="readSeg">
<button class="on" data-v="0" onclick="inboxFilter('readType',0,this)">全部</button>
<button data-v="1" onclick="inboxFilter('readType',1,this)">未读</button>
<button data-v="2" onclick="inboxFilter('readType',2,this)">已读</button>
</div>
<select class="input" id="inboxType" onchange="reloadInbox()">
<option value="">全部类型</option>
<option value="1">普通</option>
<option value="2">重要</option>
</select>
<input class="input search-box" id="inboxKw" placeholder="🔍 搜索标题关键字" onkeydown="if(event.key==='Enter')reloadInbox()">
<button class="btn btn-primary btn-sm" onclick="reloadInbox()">查询</button>
</div>
<div class="notice-list" id="inboxList"></div>
<div class="pager" id="inboxPager"></div>
</div>
<!-- ==================== 写通知 ==================== -->
<div class="view" id="view-compose">
<div class="page-head">
<div>
<div class="page-title" id="composeTitle">写通知</div>
<div class="page-sub" id="composeSub">填写内容并选择推送范围,支持直接发布、存为草稿或提交审批</div>
</div>
<button class="btn btn-ghost" id="composeBack" style="display:none" onclick="resetCompose();switchView('sent')">返回列表</button>
</div>
<div class="compose">
<div><!-- 左列 -->
<div class="panel">
<div class="form-row">
<label class="form-label">通知标题<span class="req">*</span></label>
<input class="form-input" id="cTitle" maxlength="200" placeholder="请输入通知标题">
</div>
<div class="form-row">
<label class="form-label">重要程度<span class="req">*</span></label>
<div class="radio-group">
<label><input type="radio" name="cType" value="1" checked> 普通</label>
<label><input type="radio" name="cType" value="2"> <span style="color:var(--red)">重要</span></label>
</div>
</div>
<div class="form-row">
<label class="form-label">发布部门<span class="req">*</span></label>
<select class="form-input" id="cDept"><option value="">加载中…</option></select>
</div>
<div class="form-row" style="margin-bottom:0">
<label class="form-label">通知内容</label>
<textarea class="form-input" id="cContent" placeholder="请输入通知正文,支持换行"></textarea>
</div>
</div>
<div class="panel">
<div class="panel-title">附件</div>
<label class="btn btn-ghost upload-btn">
📎 上传附件
<input type="file" id="cFileInput" style="display:none" onchange="uploadFile(this)">
</label>
<span class="form-hint" style="display:inline-block;margin-left:10px">可多选;上传后自动关联本通知</span>
<div class="file-list" id="cFileList"></div>
</div>
<div class="panel">
<div class="panel-title">发布选项</div>
<div class="check-inline" style="margin-bottom:14px">
<label><input type="checkbox" id="cTopmost"> 置顶</label>
<label><input type="checkbox" id="cSms"> 发送短信提醒</label>
<label><input type="checkbox" id="cReceipt"> 学员阅读回执</label>
</div>
<label class="form-label">定时发送</label>
<input class="form-input" type="datetime-local" id="cTiming" style="width:240px">
<div class="form-hint">留空表示立即发送;定时通知审核通过后到点自动发出</div>
</div>
<div class="panel" id="approvePanel" style="display:none">
<div class="panel-title">审批人</div>
<label class="form-label">审批教师<span class="req">*</span>(多人或签,任一人处理即可)</label>
<div class="scope-tree" id="approverTree"><div style="padding:20px;text-align:center;color:var(--text3)">加载中…</div></div>
<div class="scope-chips" id="approverChips"></div>
<button class="link-btn" style="margin-top:6px" onclick="loadApproverSuggest(1)">🔄 带入上次所选审批人</button>
<div class="form-hint">展开组织架构勾选教师作为审批人,可多选;按所选教师提交审批,或签任一人处理即可</div>
</div>
<div class="compose-actions" id="composeActions">
<!-- 按模式渲染 -->
</div>
</div>
<div class="compose-side"><!-- 右列 -->
<div class="panel">
<div class="panel-title">推送范围(学员)</div>
<div class="scope-tree" id="scopeTree"><div style="padding:20px;text-align:center;color:var(--text3)">加载中…</div></div>
<div class="scope-chips" id="scopeChips"></div>
<div class="preview-box" id="scopePreview" style="display:none">
<span>学员 <b id="pvStu">0</b></span>
<span>教师 <b id="pvTea">0</b></span>
<span>合计 <b id="pvTotal">0</b></span>
</div>
<div class="form-hint">勾选「全校」或具体学段/年级/班级;不选则仅通知名单中的教师</div>
</div>
<div class="panel">
<div class="panel-title">教师接收名单</div>
<input class="form-input" id="cReceivers" placeholder="如 zhangsan,lisi 或 ALL" oninput="previewScope()">
<div class="form-hint">教师账号,逗号分隔;填 ALL 通知全体教师;留空表示不定向通知教师</div>
</div>
</div>
</div>
</div>
<!-- ==================== 已发管理 ==================== -->
<div class="view" id="view-sent">
<div class="page-head">
<div>
<div class="page-title">已发管理</div>
<div class="page-sub">我发布的通知:查看阅读情况、撤回、置顶、催读与转发</div>
</div>
</div>
<div class="stat-cards" id="sentStats"></div>
<div class="toolbar">
<select class="input" id="sentStatus" onchange="reloadSent()">
<option value="">全部状态</option>
<option value="1">已发布</option>
<option value="2">待审批</option>
<option value="3">已驳回</option>
</select>
<select class="input" id="sentType" onchange="reloadSent()">
<option value="">全部类型</option>
<option value="1">普通</option>
<option value="2">重要</option>
</select>
<input class="input search-box" id="sentKw" placeholder="🔍 搜索标题关键字" onkeydown="if(event.key==='Enter')reloadSent()">
<input class="input" type="date" id="sentStart" onchange="reloadSent()" title="开始日期">
<span style="color:var(--text3)">~</span>
<input class="input" type="date" id="sentEnd" onchange="reloadSent()" title="结束日期">
<button class="btn btn-primary btn-sm" onclick="reloadSent()">查询</button>
</div>
<div class="table-card">
<table class="tbl">
<thead><tr>
<th style="width:34%">标题</th><th>类型</th><th>状态</th><th>阅读情况</th><th>发布时间</th><th style="width:230px">操作</th>
</tr></thead>
<tbody id="sentBody"></tbody>
</table>
</div>
<div class="pager" id="sentPager"></div>
</div>
<!-- ==================== 草稿箱 ==================== -->
<div class="view" id="view-drafts">
<div class="page-head">
<div>
<div class="page-title">草稿箱</div>
<div class="page-sub">未发送的通知草稿,可继续编辑后提交</div>
</div>
</div>
<div class="toolbar">
<input class="input search-box" id="draftKw" placeholder="🔍 搜索标题关键字" onkeydown="if(event.key==='Enter')reloadDrafts()">
<button class="btn btn-primary btn-sm" onclick="reloadDrafts()">查询</button>
</div>
<div class="table-card">
<table class="tbl">
<thead><tr>
<th style="width:40%">标题</th><th>类型</th><th>定时</th><th>最后修改</th><th style="width:170px">操作</th>
</tr></thead>
<tbody id="draftBody"></tbody>
</table>
</div>
<div class="pager" id="draftPager"></div>
</div>
<!-- ==================== 审批中心 ==================== -->
<div class="view" id="view-approvals">
<div class="page-head">
<div>
<div class="page-title">审批中心</div>
<div class="page-sub">我作为审批人收到的审批任务(或签:任一审批人处理即可)</div>
</div>
</div>
<div class="toolbar">
<div class="seg" id="auditSeg">
<button class="on" data-v="pending" onclick="auditFilter('pending',this)">待我审批</button>
<button data-v="reviewed" onclick="auditFilter('reviewed',this)">已处理</button>
</div>
<input class="input search-box" id="auditKw" placeholder="🔍 搜索标题关键字" onkeydown="if(event.key==='Enter')reloadApprovals()">
<button class="btn btn-primary btn-sm" onclick="reloadApprovals()">查询</button>
</div>
<div class="table-card">
<table class="tbl">
<thead id="auditHead"></thead>
<tbody id="auditBody"></tbody>
</table>
</div>
<div class="pager" id="auditPager"></div>
</div>
<!-- ==================== 设置管理(发布部门) ==================== -->
<div class="view" id="view-settings">
<div class="page-head">
<div>
<div class="page-title">设置管理</div>
<div class="page-sub">管理系统字典:通知发布部门的新增、改名、启用/禁用、排序与删除</div>
</div>
</div>
<div class="toolbar">
<button class="btn btn-primary" onclick="addDept()">+ 添加部门</button>
<button class="btn btn-ghost" id="saveDeptOrderBtn" style="display:none" onclick="saveDeptOrder()">💾 保存排序</button>
</div>
<div class="table-card">
<table class="tbl">
<thead><tr>
<th style="width:90px">排序</th><th style="width:40%">部门名称</th><th>编码</th><th>状态</th><th style="width:220px">操作</th>
</tr></thead>
<tbody id="deptBody"></tbody>
</table>
</div>
<div class="form-hint" style="margin-top:10px;line-height:1.8">
使用 ↑↓ 调整显示顺序,调整后点击「保存排序」生效;<br>
禁用后的部门在「写通知」中不再可选,但不影响已发布通知;已被通知引用的部门无法删除。
</div>
</div>
</div><!-- /appRoot -->
</div>
<!-- ==================== 通知详情弹窗 ==================== -->
<div class="mask" id="detailMask" onclick="if(event.target===this)closeModal('detailMask')">
<div class="modal">
<div class="modal-head">
<div class="modal-title">通知详情</div>
<button class="modal-close" onclick="closeModal('detailMask')">✕</button>
</div>
<div class="modal-body">
<div style="font-size:18px;font-weight:700;margin-bottom:10px" id="dTitle"></div>
<div class="detail-meta" id="dMeta"></div>
<div class="detail-content" id="dContent"></div>
<div class="detail-files" id="dFiles" style="display:none">
<label class="form-label">附件</label>
<div class="file-list" id="dFileList"></div>
<button class="btn btn-ghost btn-sm" style="margin-top:10px" id="dZipBtn">📦 打包下载全部附件</button>
</div>
</div>
<div class="modal-foot">
<button class="btn btn-ghost" id="dPrev">← 上一篇</button>
<button class="btn btn-ghost" id="dNext">下一篇 →</button>
<button class="btn btn-primary" onclick="closeModal('detailMask')">关闭</button>
</div>
</div>
</div>
<!-- ==================== 阅读回执弹窗 ==================== -->
<div class="mask" id="receiptMask" onclick="if(event.target===this)closeModal('receiptMask')">
<div class="modal">
<div class="modal-head">
<div class="modal-title">阅读回执</div>
<button class="modal-close" onclick="closeModal('receiptMask')">✕</button>
</div>
<div class="modal-body" id="receiptBody" style="max-height:64vh;overflow:auto"></div>
<div class="modal-foot">
<button class="btn btn-warn" id="receiptExpedite">📣 催收未读</button>
<button class="btn btn-primary" onclick="closeModal('receiptMask')">关闭</button>
</div>
</div>
</div>
<!-- ==================== 通用输入弹窗(审批意见等) ==================== -->
<div class="mask" id="promptMask" onclick="if(event.target===this)cancelPrompt()">
<div class="modal modal-sm">
<div class="modal-head">
<div class="modal-title" id="promptTitle"></div>
<button class="modal-close" onclick="cancelPrompt()">✕</button>
</div>
<div class="modal-body">
<textarea class="form-input" id="promptInput" style="min-height:90px" placeholder="请输入内容"></textarea>
</div>
<div class="modal-foot">
<button class="btn btn-ghost" onclick="cancelPrompt()">取消</button>
<button class="btn btn-primary" id="promptOk">确定</button>
</div>
</div>
</div>
<div id="toastBox"></div>
<script>
/* =========================================================
* 校园通知 · 用户端
* 接口约定:{code:0, msg, data} 为成功;日期 yyyy-MM-dd HH:mm:ss
* ======================================================= */
// 接口与页面同上下文部署(如 /campusnotice/api/...),用相对路径避免写死根路径 /api 导致 404
const API_BASE = 'api';
/* ---------- 基础工具 ---------- */
const $ = id => document.getElementById(id);
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
function escAttr(s) { return esc(s).replace(/\n/g, ' '); }
function toast(msg, type) {
const t = document.createElement('div');
t.className = 'toast ' + (type || '');
t.textContent = msg;
$('toastBox').appendChild(t);
setTimeout(() => { t.style.opacity = '0'; t.style.transition = '.4s'; setTimeout(() => t.remove(), 400); }, 2400);
}
function fmtSize(n) {
if (!n && n !== 0) return '';
if (n < 1024) return n + 'B';
if (n < 1048576) return (n / 1024).toFixed(1) + 'KB';
return (n / 1048576).toFixed(1) + 'MB';
}
function openModal(id) { $(id).classList.add('show'); }
function closeModal(id) { $(id).classList.remove('show'); }
/* 通用输入弹窗:resolve 输入值,取消/关闭 resolve null */
let promptResolve = null;
function askInput(title, placeholder, defaultValue) {
$('promptTitle').textContent = title;
$('promptInput').placeholder = placeholder || '';
$('promptInput').value = defaultValue || '';
openModal('promptMask');
setTimeout(() => $('promptInput').focus(), 50);
return new Promise(resolve => { promptResolve = resolve; });
}
function finishPrompt(value) {
closeModal('promptMask');
if (promptResolve) { const r = promptResolve; promptResolve = null; r(value); }
}
$('promptOk').onclick = () => finishPrompt($('promptInput').value);
function cancelPrompt() { finishPrompt(null); }
function confirmBox(msg) { return Promise.resolve(window.confirm(msg)); }
/* ---------- API 封装 ---------- */
class ApiError extends Error { constructor(msg, code) { super(msg); this.code = code; } }
class AuthError extends Error {}
async function api(path, opt) {
opt = opt || {};
let url = API_BASE + path;
if (opt.params) {
const qs = new URLSearchParams();
for (const [k, v] of Object.entries(opt.params)) {
if (v !== undefined && v !== null && v !== '') qs.append(k, v);
}
const s = qs.toString();
if (s) url += '?' + s;
}
const opts = { method: opt.method || 'GET', headers: {}, credentials: 'same-origin' };
if (opt.form) {
opts.body = opt.form;
} else if (opt.body !== undefined) {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(opt.body);
}
let resp;
try {
resp = await fetch(url, opts);
} catch (e) {
// 302 跳转 CAS 跨域会以网络错误呈现
throw new AuthError();
}
const ct = resp.headers.get('content-type') || '';
if (resp.redirected || (ct.includes('text/html') && !ct.includes('json'))) throw new AuthError();
if (opt.raw) return resp;
let j;
try { j = await resp.json(); } catch (e) { throw new ApiError('响应解析失败(HTTP ' + resp.status + ')'); }
if (j.code !== 0) throw new ApiError(j.msg || '操作失败', j.code);
return j.data;
}
/* =========================================================
* 用户信息 / 登录
* ======================================================= */
let currentUser = null;
async function loadUser() {
try {
const u = await api('/base/GetUserInfo');
currentUser = u || {};
const name = u.name || u.userName || u.loginName || u.account || '用户';
$('userName').textContent = name;
$('userAvatar').textContent = name.charAt(0);
$('userDept').textContent = u.deptName || '';
} catch (e) {
if (e instanceof AuthError) { showLoginTip(); throw e; }
$('userName').textContent = '未获取到用户';
}
}
function showLoginTip() {
$('userName').textContent = '未登录';
$('userDept').textContent = '';
$('appRoot').style.display = 'none';
$('loginTip').style.display = 'block';
}
async function doLogout() {
if (!await confirmBox('确定退出登录吗?')) return;
try { await api('/base/Logout', { method: 'POST' }); } catch (e) { /* 忽略 */ }
location.reload();
}
/* =========================================================
* 视图切换
* ======================================================= */
const viewLoaders = {
inbox: () => { reloadInbox(); refreshUnreadBadge(); },
compose: () => { initComposeOnce(); },
sent: () => { loadSentStats(); reloadSent(); },
drafts: () => reloadDrafts(),
approvals: () => reloadApprovals(),
settings: () => reloadDepts()
};
let curView = 'inbox';
function switchView(v) {
curView = v;
document.querySelectorAll('.view').forEach(el => el.classList.remove('active'));
document.querySelectorAll('.nav-item').forEach(el => el.classList.toggle('active', el.dataset.view === v));
$('view-' + v).classList.add('active');
viewLoaders[v] && viewLoaders[v]();
}
/* =========================================================
* 收件箱(我的通知)
* ======================================================= */
const inboxState = { pageIndex: 1, pageSize: 10, readType: 0, unreadCount: 0 };
function inboxFilter(key, value, btn) {
inboxState[key] = value;
btn.parentNode.querySelectorAll('button').forEach(b => b.classList.remove('on'));
btn.classList.add('on');
reloadInbox();
}
function reloadInbox() {
inboxState.pageIndex = 1;
loadInbox();
}
async function refreshUnreadBadge() {
try {
const r = await api('/notice/List', { params: { pageIndex: 1, pageSize: 1, readType: 1 } });
inboxState.unreadCount = (r && r.unreadCount) || 0;
} catch (e) { inboxState.unreadCount = 0; }
renderUnreadBadge();
}
function renderUnreadBadge() {
const n = inboxState.unreadCount;
$('unreadBadge').textContent = n > 0 ? (n > 99 ? '99+' : n) : '';
}
async function loadInbox() {
const box = $('inboxList');
box.innerHTML = '<div class="empty">加载中…</div>';
let r;
try {
r = await api('/notice/List', {
params: {
pageIndex: inboxState.pageIndex, pageSize: inboxState.pageSize,
readType: inboxState.readType, type: $('inboxType').value,
keyword: $('inboxKw').value.trim()
}
});
} catch (e) { return handleListError(e, box); }
const list = (r && r.content) || [];
inboxState.unreadCount = (r && r.unreadCount) || inboxState.unreadCount;
renderUnreadBadge();
$('inboxSub').textContent = r ? ('共 ' + r.total + ' 条 · 未读 ' + r.unreadCount + ' 条') : '';
if (!list.length) {
box.innerHTML = '<div class="empty"><div class="icon">📭</div>暂无通知</div>';
$('inboxPager').innerHTML = '';
return;
}
box.innerHTML = list.map(n => `
<div class="notice-item ${n.read ? '' : 'unread'}" onclick="openDetail(${n.id}, 'inbox')">
<div class="read-dot ${n.read ? 'read' : ''}"></div>
<div class="notice-main">
<div class="notice-title-row">
<span class="notice-title">${esc(n.title)}</span>
${n.topmost == 1 ? '<span class="badge b-top">置顶</span>' : ''}
${n.type == 2 ? '<span class="badge b-important">重要</span>' : '<span class="badge b-normal">普通</span>'}
${n.isPulled == 1 ? '<span class="badge b-pulled">已撤回</span>' : ''}
</div>
<div class="notice-meta">
<span>👤 ${esc(n.publisherName || n.publisherAccount || '-')}</span>
<span>🏛 ${esc(n.deptName || '-')}</span>
</div>
</div>
<div class="notice-side">
<span class="notice-time">${esc((n.timingSendTime || n.createTime || '').replace('T', ' '))}</span>
</div>
</div>`).join('');
renderPager('inboxPager', r, inboxState.pageIndex, p => { inboxState.pageIndex = p; loadInbox(); });
}
async function markAllRead() {
if (!await confirmBox('确定将全部通知标记为已读吗?')) return;
try {
const r = await api('/notice/InitializeRed', { method: 'POST' });
const n = r && (r.count ?? r.total ?? 0);
toast('已全部标记为已读' + (n ? '(' + n + ' 条)' : ''), 'ok');
loadInbox(); refreshUnreadBadge();
} catch (e) { handleErr(e); }
}
/* =========================================================
* 通知详情(弹窗)
* ======================================================= */
let detailNav = { type: '1', serid: null };
async function openDetail(serid, from) {
let d;
try {
d = normalizeDetail(await api('/notice/Detail', { params: { serid, type: 1 } }));
} catch (e) { return handleErr(e); }
detailNav = { type: '1', serid };
$('dTitle').textContent = d.title || '';
const files = d.chaoSend || [];
const metas = [];
if (d.publisherName || d.teaName) metas.push('👤 ' + esc(d.publisherName || d.teaName));
if (d.deptName) metas.push('🏛 ' + esc(d.deptName));
if (d.editDate) metas.push('🕒 ' + esc(d.editDate));
if (d.numcount != null) metas.push('应读 ' + d.numcount + ' 人');
if (d.readnumcount != null) metas.push('已读 ' + d.readnumcount + ' 人');
$('dMeta').innerHTML = metas.map(m => '<span>' + m + '</span>').join('');
$('dContent').innerHTML = renderRichText(d.contents || '(无正文)');
// 附件
if (files.length) {
$('dFiles').style.display = 'block';
$('dFileList').innerHTML = files.map(f => `
<div class="file-item">
<span>📎</span>
<span class="fname">${esc(f.name || f.filename)}</span>
<span class="fsize">${fmtSize(f.size)}</span>
<button class="link-btn" onclick="downloadFile(${f.fileid || f.id})">下载</button>
</div>`).join('');
$('dZipBtn').onclick = () => { location.href = API_BASE + '/notice/AllFileDown?dataid=' + detailNav.serid; };
} else {
$('dFiles').style.display = 'none';
}
// 上一篇/下一篇
let np = {};
try { np = await api('/notice/GetNextPrev', { params: { serid, type: 1 } }) || {}; } catch (e) { /* 忽略 */ }
setupNavBtn('dPrev', np.prev || np.preId || np.prevId, '上一篇');
setupNavBtn('dNext', np.next || np.nextId, '下一篇');
openModal('detailMask');
// 本地立即置为已读态
if (from === 'inbox') { refreshUnreadBadge(); }
}
function setupNavBtn(btnId, targetId, label) {
const btn = $(btnId);
if (targetId) {
btn.disabled = false;
btn.textContent = label;
btn.onclick = () => openDetail(targetId, 'inbox');
} else {
btn.disabled = true;
btn.textContent = label;
btn.onclick = null;
}
}
function downloadFile(fileid) { location.href = API_BASE + '/file/Down?id=' + fileid; }
/* 简单净化:去 script/iframe/on* 属性,换行转 <br> */
function renderRichText(text) {
let html = String(text);
html = html.replace(/<\s*(script|iframe|object|embed|style)[\s\S]*?<\s*\/\s*\1\s*>/gi, '')
.replace(/<\s*(script|iframe|object|embed|style)[^>]*>/gi, '')
.replace(/\son\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '')
.replace(/javascript\s*:/gi, '');
if (!/<[a-z][\s\S]*>/i.test(html)) {
html = '<p>' + esc(html).replace(/\n/g, '</p><p>') + '</p>';
}
return html;
}
/* =========================================================
* 写通知(发布 / 编辑 / 转发)
* ======================================================= */
const composeState = { mode: 'create', sourceId: null, files: [], scopes: [], deptList: [], inited: false };
let scopeTreeData = [];
function initComposeOnce() {
if (composeState.inited) return;
composeState.inited = true;
loadDeptOptions();
loadScopeTree();
previewScope();
}
let deptOptionsPromise = null;
function loadDeptOptions() {
// 并发调用共享同一次请求;refreshComposeDept 清除缓存后强制刷新
if (!deptOptionsPromise) {
deptOptionsPromise = api('/dict/List', { params: { type: 1 } }).then(list => {
composeState.deptList = list || [];
$('cDept').innerHTML = '<option value="">请选择发布部门</option>' +
composeState.deptList.map(d => `<option value="${escAttr(d.k)}">${esc(d.v || d.k)}</option>`).join('');
}).catch(e => {
deptOptionsPromise = null;
$('cDept').innerHTML = '<option value="">部门加载失败</option>';
});
}
return deptOptionsPromise;
}
async function loadScopeTree() {
try {
scopeTreeData = await api('/notice/ScopeTree') || [];
$('scopeTree').innerHTML = scopeTreeData.map(n => renderTreeNode(n, 0)).join('');
} catch (e) {
$('scopeTree').innerHTML = '<div style="padding:20px;text-align:center;color:var(--text3)">范围树加载失败</div>';
}
}
function renderTreeNode(node, depth) {
const key = node.scopeType + ':' + node.scopeId;
const hasChildren = node.children && node.children.length;
return `<div class="tree-node" id="tn-${escAttr(key)}" data-depth="${depth}">
<div class="tree-row">
${hasChildren ? `<span class="tree-toggle" onclick="toggleTreeNode('${escAttr(key)}')">▶</span>` : '<span class="tree-toggle"></span>'}
<input type="checkbox" id="sc-${escAttr(key)}" onchange="onScopeCheck('${escAttr(key)}')">
<span onclick="toggleTreeNode('${escAttr(key)}')">${esc(node.name)}</span>
</div>
${hasChildren ? `<div class="tree-children">${node.children.map(c => renderTreeNode(c, depth + 1)).join('')}</div>` : ''}
</div>`;
}
function toggleTreeNode(key) {
const el = $('tn-' + key);
if (el) el.classList.toggle('expanded');
}
function findNode(nodes, type, id) {
for (const n of nodes) {
if (n.scopeType === type && String(n.scopeId) === String(id)) return n;
if (n.children) { const f = findNode(n.children, type, id); if (f) return f; }
}
return null;
}
function collectScopeItems() {
// 取「最粗粒度」的勾选:若父节点勾选,则忽略其子孙
const items = [];
const walk = (nodes, ancestorChecked) => {
for (const n of nodes) {
const cb = $('sc-' + n.scopeType + ':' + n.scopeId);
const checked = cb && cb.checked;
if (checked && !ancestorChecked) {
items.push({ scopeType: n.scopeType, scopeId: n.scopeId, scopeName: n.name });
}
if (n.children) walk(n.children, ancestorChecked || checked);
}
};
walk(scopeTreeData, false);
return items;
}
function onScopeCheck() {
renderScopeChips();
previewScope();
}
function renderScopeChips() {
const items = collectScopeItems();
composeState.scopes = items;
$('scopeChips').innerHTML = items.map((s, i) =>
`<span class="chip">${esc(s.scopeName)}<b onclick="uncheckScope('${escAttr(s.scopeType + ':' + s.scopeId)}')">✕</b></span>`).join('');
}
function uncheckScope(key) {
const cb = $('sc-' + key);
if (cb) { cb.checked = false; onScopeCheck(); }
}
let previewTimer = null;
function previewScope() {
clearTimeout(previewTimer);
previewTimer = setTimeout(async () => {
composeState.scopes = collectScopeItems();
try {
const v = await api('/notice/ScopePreview', {
params: {
scopes: JSON.stringify(composeState.scopes),
receiveList: $('cReceivers').value.trim()
}
});
if (v) {
$('pvStu').textContent = v.studentCount || 0;
$('pvTea').textContent = v.teacherCount || 0;
$('pvTotal').textContent = v.totalCount || 0;
$('scopePreview').style.display = 'flex';
}
} catch (e) { $('scopePreview').style.display = 'none'; }
}, 350);
}
/* ---------- 附件上传 ---------- */
async function uploadFile(input) {
const file = input.files[0];
input.value = '';
if (!file) return;
if (file.size > 50 * 1024 * 1024) return toast('文件不能超过 50MB', 'err');
const fd = new FormData();
fd.append('file', file);
toast('正在上传 ' + file.name + ' …');
try {
const r = await api('/base/FileUpload', { method: 'POST', form: fd });
composeState.files.push({ fileid: r.fileid || r.id, name: r.name || file.name, size: r.size || file.size });
renderComposeFiles();
toast('上传成功', 'ok');
} catch (e) { handleErr(e); }
}
function renderComposeFiles() {
$('cFileList').innerHTML = composeState.files.map((f, i) => `
<div class="file-item">
<span>📎</span><span class="fname">${esc(f.name)}</span>
<span class="fsize">${fmtSize(f.size)}</span>
<span class="fdel" onclick="removeComposeFile(${i})">✕</span>
</div>`).join('');
}
function removeComposeFile(i) { composeState.files.splice(i, 1); renderComposeFiles(); }
/* ---------- 组装请求体 ---------- */
function buildNoticeBody() {
const title = $('cTitle').value.trim();
if (!title) { toast('请填写通知标题', 'err'); return null; }
const deptId = $('cDept').value;
if (!deptId) { toast('请选择发布部门', 'err'); return null; }
const deptObj = composeState.deptList.find(d => String(d.k) === String(deptId));
const content = $('cContent').value;
const timing = $('cTiming').value ? $('cTiming').value.replace('T', ' ') + ':00' : null;
const receivers = $('cReceivers').value.trim();
const body = {
title,
content,
type: Number(document.querySelector('input[name="cType"]:checked').value),
deptId,
deptName: deptObj ? deptObj.v : '',
topmost: $('cTopmost').checked ? 1 : 0,
smsFlag: $('cSms').checked ? 1 : 0,
receiptFlag: $('cReceipt').checked ? 1 : 0,
receiveList: receivers || null,
fileIds: composeState.files.map(f => f.fileid).join(','),
timingSendTime: timing,
pushScopes: composeState.scopes.length ? composeState.scopes : null
};
if (composeState.sourceId) body.sourceId = composeState.sourceId;
// 编辑态:保存当前绑定的审批教师(空数组=清空审批人,后端据此持久化)
if (composeState.mode === 'edit') {
body.approverTeaIds = collectApproverTeaIds();
}
return body;
}
/* ---------- 模式管理 ---------- */
function renderComposeActions() {
const box = $('composeActions');
if (composeState.mode === 'edit') {
box.innerHTML = `
<button class="btn btn-primary" onclick="submitCompose('edit')">💾 保存修改</button>
<button class="btn btn-warn" onclick="submitComposeFromForm('approve')">📨 提交审批</button>
<button class="btn btn-danger" onclick="deleteFromCompose()">删除</button>`;
} else {
box.innerHTML = `
<button class="btn btn-primary" onclick="submitCompose('direct')">🚀 直接发布</button>
<button class="btn btn-ghost" onclick="submitCompose('draft')">💾 存为草稿</button>
<button class="btn btn-warn" id="btnApproveSubmit" onclick="onComposeApproveClick()">📨 提交审批</button>`;
}
}
/* 新建模式提交审批:首点展开选人面板,再点确认提交 */
function onComposeApproveClick() {
if ($('approvePanel').style.display === 'none') {
toggleApprovePanel();
$('btnApproveSubmit').textContent = '📨 确认提交审批';
} else {
submitCompose('approve');
}
}
function toggleApprovePanel() {
const p = $('approvePanel');
p.style.display = p.style.display === 'none' ? 'block' : 'none';
if (p.style.display === 'block') {
ensureApproverTree().catch(e => handleErr(e));
loadApproverSuggest();
}
}
/* ---------- 审批人教师树(base/GetSchoolAll:type=1 组 / type=2 教师) ---------- */
const approverState = { treePromise: null, names: {}, selected: {} };
function ensureApproverTree() {
if (!approverState.treePromise) {
approverState.treePromise = api('/base/GetSchoolAll').then(data => {
const tree = data || [];
approverState.names = {};
$('approverTree').innerHTML = tree.length
? tree.map(n => renderApproverNode(n)).join('')
: '<div style="padding:20px;text-align:center;color:var(--text3)">暂无教师数据</div>';
// 渲染后按已选状态回勾(编辑回显早于树加载完成的情况)
Object.keys(approverState.selected).forEach(tid => syncApproverChecks(tid, true));
}).catch(e => {
$('approverTree').innerHTML = '<div style="padding:20px;text-align:center;color:var(--text3)">教师树加载失败,可重试</div>';
approverState.treePromise = null;
throw e;
});
}
return approverState.treePromise;
}
function renderApproverNode(node) {
if (node.type === 1) {
const hasChildren = node.children && node.children.length;
const key = 'atn-' + node.id; // toggleTreeNode 内部拼 tn- 前缀,节点 id 需为 tn-atn-<gid>
return `<div class="tree-node" id="tn-${key}">
<div class="tree-row">
${hasChildren ? `<span class="tree-toggle" onclick="toggleTreeNode('${key}')">▶</span>` : '<span class="tree-toggle"></span>'}
<span onclick="toggleTreeNode('${key}')">📁 ${esc(node.name)}</span>
</div>
${hasChildren ? `<div class="tree-children">${node.children.map(renderApproverNode).join('')}</div>` : ''}
</div>`;
}
// 教师叶子:同教师可能在多个组下重复出现,统一按 data-tid 同步勾选态
approverState.names[node.id] = node.name || ('ID ' + node.id);
return `<div class="tree-node">
<div class="tree-row">
<span class="tree-toggle"></span>
<input type="checkbox" data-tid="${node.id}" onchange="onApproverCheck(this)">
<span onclick="onApproverLabelClick(this)">👤 ${esc(node.name)}</span>
</div>
</div>`;
}
function onApproverLabelClick(el) {
const cb = el.parentNode.querySelector('input[type=checkbox]');
if (!cb) return;
cb.checked = !cb.checked;
onApproverCheck(cb);
}
function onApproverCheck(cb) {
const tid = cb.getAttribute('data-tid');
if (cb.checked) {
approverState.selected[tid] = { id: tid, name: approverState.names[tid] || ('ID ' + tid) };
} else {
delete approverState.selected[tid];
}
syncApproverChecks(tid, cb.checked);
renderApproverChips();
}
function syncApproverChecks(tid, checked) {
document.querySelectorAll('#approverTree input[data-tid="' + tid + '"]').forEach(cb => { cb.checked = checked; });
}
function renderApproverChips() {
const items = Object.values(approverState.selected);
$('approverChips').innerHTML = items.map(t =>
`<span class="chip">${esc(t.name)}<b onclick="removeApprover('${escAttr(t.id)}')">✕</b></span>`).join('');
}
function removeApprover(tid) {
delete approverState.selected[tid];
syncApproverChecks(tid, false);
renderApproverChips();
}
function clearApproverSelection() {
Object.keys(approverState.selected).forEach(tid => syncApproverChecks(tid, false));
approverState.selected = {};
renderApproverChips();
}
/* 已选教师ID列表(教师树叶子节点ID,直接提交后端绑定校验) */
function collectApproverTeaIds() {
return Object.keys(approverState.selected).map(id => Number(id)).filter(id => !isNaN(id));
}
/* 按教师ID勾树(带入上次所选 / 编辑回显);姓名优先 ApproverResolve 绑定,教师已离职等未命中时降级展示 */
async function bindApproversByTeaIds(teaIds) {
const missed = [];
if (!teaIds || !teaIds.length) return missed;
await ensureApproverTree();
let byId = {};
try {
const resolved = await api('/notice/ApproverResolve', {
params: { ids: teaIds.join(',') }
}) || [];
resolved.forEach(r => { byId[r.id] = r; });
} catch (e) { /* 绑定接口失败不阻断勾选,姓名降级树节点/ID */ }
for (const tid of teaIds) {
const r = byId[tid];
const treeName = approverState.names[tid];
// 未命中学校教师名单(教师已离职/转校):保留勾选并提示,提交时后端会再次校验
if (!r || !r.name) missed.push(treeName || ('ID ' + tid));
approverState.selected[tid] = { id: tid, name: (r && r.name) || treeName || ('ID ' + tid) };
syncApproverChecks(tid, true);
}
renderApproverChips();
return missed;
}
async function loadApproverSuggest(byUser) {
try {
const v = await api('/notice/ApproverSuggest');
if (v && v.teaIds && v.teaIds.length) {
clearApproverSelection();
const missed = await bindApproversByTeaIds(v.teaIds);
const n = Object.keys(approverState.selected).length;
if (n) toast('已带入上次所选 ' + n + ' 名审批人' + (missed.length ? '(' + missed.join('、') + ' 未找到)' : ''), 'ok');
else if (byUser) toast('上次所选审批人已不存在,请重新勾选', 'err');
} else if (byUser) {
toast('暂无审批人记忆,请在组织架构中勾选教师', 'err');
}
} catch (e) { handleErr(e); }
}
function resetCompose() {
composeState.mode = 'create';
composeState.sourceId = null;
composeState.files = [];
composeState.scopes = [];
$('cTitle').value = '';
$('cContent').value = '';
$('cDept').value = '';
document.querySelector('input[name="cType"][value="1"]').checked = true;
$('cTopmost').checked = false;
$('cSms').checked = false;
$('cReceipt').checked = false;
$('cTiming').value = '';
$('cReceivers').value = '';
clearApproverSelection();
$('approvePanel').style.display = 'none';
renderComposeFiles(); renderScopeChips(); renderComposeActions();
$('composeTitle').textContent = '写通知';
$('composeSub').textContent = '填写内容并选择推送范围,支持直接发布、存为草稿或提交审批';
$('composeBack').style.display = 'none';
document.querySelectorAll('#scopeTree input[type=checkbox]').forEach(cb => cb.checked = false);
}
/* 进入编辑/转发模式并预填 */
async function openComposeEdit(serid, mode) {
resetCompose();
switchView('compose');
composeState.mode = mode;
composeState.sourceId = mode === 'create' ? null : serid;
renderComposeActions();
$('composeBack').style.display = 'inline-flex';
$('composeTitle').textContent = mode === 'forward' ? '转发通知' : '编辑通知';
$('composeSub').textContent = mode === 'forward'
? '基于原通知内容创建新通知,可修改后发布'
: '修改完成后保存,或提交审批重新走发布流程';
try {
// 部门下拉与详情并行加载,两者就绪后再回填,避免选项未加载导致 select 赋值落空
const [d] = await Promise.all([
api('/notice/GetDetail', { params: { serid } }).then(normalizeDetail),
loadDeptOptions()
]);
$('cTitle').value = mode === 'forward' ? '【转发】' + (d.title || '') : (d.title || '');
if (d.deptId != null && d.deptId !== '') $('cDept').value = d.deptId;
// 重要程度:1 普通 / 2 重要
const typeRadio = document.querySelector('input[name="cType"][value="' + (d.type || 1) + '"]');
if (typeRadio) typeRadio.checked = true;
// HTML 正文还原为纯文本填入 textarea
$('cContent').value = htmlToText(d.contents || '');
// 发布选项
$('cTopmost').checked = d.topmost == 1;
$('cSms').checked = d.smsFlag == 1;
$('cReceipt').checked = d.receiptFlag == 1;
// 定时发送:后端 "yyyy-MM-dd HH:mm:ss" → datetime-local 的 "yyyy-MM-ddTHH:mm"
$('cTiming').value = (d.isDsfs && d.DsfsTime) ? String(d.DsfsTime).replace(' ', 'T').slice(0, 16) : '';
if (d.pushScopes && d.pushScopes.length) {
setTimeout(() => {
for (const s of d.pushScopes) {
const cb = $('sc-' + s.scopeType + ':' + s.scopeId);
if (cb) cb.checked = true;
}
renderScopeChips(); previewScope();
}, 300);
}
// 附件回显
composeState.files = d.chaoSend.map(f => ({ fileid: f.fileid || f.id, name: f.name || f.filename, size: f.size }));
renderComposeFiles();
// 审批人回显:仅编辑态且已有审批教师时才显示面板并绑定勾选;
// 为空(null/[])则保持收起,用户点「提交审批」时再展开选人。
if (mode === 'edit' && d.approverTeaIds && d.approverTeaIds.length) {
$('approvePanel').style.display = 'block';
ensureApproverTree().catch(e => handleErr(e));
const missed = await bindApproversByTeaIds(d.approverTeaIds);
if (missed.length) toast('审批教师 ' + missed.join('、') + ' 未在本校教师名单,请重新选择', 'err');
}
} catch (e) { handleErr(e); }
}
/* GetDetail/Detail 返回沿用旧 .NET 字段名(DeptID/Contents/ChaoSend…),归一化为前端小写风格 */
function normalizeDetail(d) {
d = d || {};
return Object.assign({}, d, {
deptId: d.DeptID != null ? d.DeptID : d.deptId,
deptName: d.DeptName || d.deptName,
editDate: d.EditDate || d.editDate,
contents: d.Contents || d.contents || '',
chaoSend: d.ChaoSend || d.chaoSend || []
});
}
function htmlToText(html) {
const div = document.createElement('div');
div.innerHTML = String(html);
return div.textContent || div.innerText || '';
}
/* ---------- 提交 ---------- */
async function submitCompose(action) {
const body = buildNoticeBody();
if (!body) return;
if (action === 'approve') {
$('approvePanel').style.display = 'block';
await ensureApproverTree().catch(e => handleErr(e));
const teaIds = collectApproverTeaIds();
if (!teaIds.length) { $('approverTree').scrollIntoView({ block: 'center' }); return toast('请在组织架构中勾选审批教师', 'err'); }
body.publishType = 'approve';
body.approverTeaIds = teaIds;
} else {
body.publishType = action;
}
await doSubmitCompose(body, action);
}
/* 从既有通知(草稿/已驳回)提交审批:先保存修改,再提交 */
async function submitComposeFromForm() {
const serid = composeState.sourceId;
if (!serid) return toast('缺少通知ID', 'err');
const body = buildNoticeBody();
if (!body) return;
$('approvePanel').style.display = 'block';
await ensureApproverTree().catch(e => handleErr(e));
const teaIds = collectApproverTeaIds();
if (!teaIds.length) { $('approverTree').scrollIntoView({ block: 'center' }); return toast('请在组织架构中勾选审批教师', 'err'); }
try {
await api('/notice/Edit', { method: 'POST', body });
await api('/notice/Submit', {
method: 'POST',
body: { serid, approverTeaIds: teaIds, opinion: null }
});
toast('已提交审批', 'ok');
resetCompose();
switchView('sent');
} catch (e) { handleErr(e); }
}
async function doSubmitCompose(body, action) {
try {
let newId;
if (composeState.mode === 'edit') {
await api('/notice/Edit', { method: 'POST', body });
} else if (action === 'draft') {
newId = await api('/notice/SaveDraft', { method: 'POST', body });
} else {
newId = await api('/notice/Create', { method: 'POST', body });
}
const msg = { direct: '发布成功', draft: '已存为草稿', approve: '已提交审批' }[action] || '操作成功';
toast(msg, 'ok');
resetCompose();
if (action === 'draft') switchView('drafts');
else if (composeState.mode === 'edit') switchView('sent');
else switchView('inbox');
} catch (e) { handleErr(e); }
}
async function deleteFromCompose() {
const serid = composeState.sourceId;
if (!serid) return;
if (!await confirmBox('确定删除该草稿/通知吗?删除后不可恢复。')) return;
try {
await api('/notice/Delete', { method: 'POST', body: [serid] });
toast('删除成功', 'ok');
resetCompose();
switchView('sent');
} catch (e) { handleErr(e); }
}
/* =========================================================
* 已发管理
* ======================================================= */
const sentState = { pageIndex: 1, pageSize: 10 };
function reloadSent() { sentState.pageIndex = 1; loadSent(); }
async function loadSentStats() {
try {
const s = await api('/notice/Statis');
$('sentStats').innerHTML = `
<div class="stat-card"><div class="stat-num">${s.total ?? 0}</div><div class="stat-label">累计发布</div></div>
<div class="stat-card"><div class="stat-num">${s.publishedCount ?? 0}</div><div class="stat-label">当前已发布</div></div>
<div class="stat-card"><div class="stat-num">${s.totalReadRecords ?? 0}</div><div class="stat-label">累计阅读人次</div></div>
<div class="stat-card"><div class="stat-num">${s.unreadForCurrentUser ?? 0}</div><div class="stat-label">我的未读</div></div>`;
} catch (e) { $('sentStats').innerHTML = ''; }
}
async function loadSent() {
const body = $('sentBody');
body.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--text3);padding:30px">加载中…</td></tr>';
let r;
try {
r = await api('/notice/SendList', {
params: {
pageIndex: sentState.pageIndex, pageSize: sentState.pageSize,
scope: 'user', status: $('sentStatus').value, type: $('sentType').value,
keyword: $('sentKw').value.trim(), startTime: $('sentStart').value, endTime: $('sentEnd').value
}
});
} catch (e) { return handleListError(e, body, 6); }
const list = (r && r.content) || [];
if (!list.length) {
body.innerHTML = '<tr><td colspan="6"><div class="empty"><div class="icon">📮</div>暂无通知</div></td></tr>';
$('sentPager').innerHTML = '';
return;
}
body.innerHTML = list.map(n => {
const status = n.status, badges = [];
let statusBadge = '';
if (status === 0) statusBadge = '<span class="badge b-draft">草稿</span>';
else if (status === 1) statusBadge = (n.timingSendTime && n.timingPublished === 0 && n.isPulled !== 1)
? '<span class="badge b-timing">定时中</span><span class="badge b-published">已排期</span>'
: '<span class="badge b-published">已发布</span>';
else if (status === 2) statusBadge = '<span class="badge b-pending">待审批</span>';
else if (status === 3) statusBadge = '<span class="badge b-rejected">已驳回</span>';
if (n.isPulled === 1) statusBadge += ' <span class="badge b-pulled">已撤回</span>';
if (n.topmost == 1) statusBadge += ' <span class="badge b-top">置顶</span>';
const readInfo = (n.allowCount != null)
? `<span class="read-progress">${n.readCount || 0} / ${n.allowCount} 已读</span>`
: '<span class="read-progress">-</span>';
const acts = [];
if (status === 1) {
acts.push(`<button class="link-btn" onclick="openReceipt(${n.id})">阅读回执</button>`);
if (n.isPulled !== 1) {
acts.push(`<button class="link-btn" onclick="pullNotice(${n.id})">撤回</button>`);
acts.push(`<button class="link-btn" onclick="topmost(${n.id},${n.topmost == 1 ? 0 : 1},'${escAttr(n.title)}')">${n.topmost == 1 ? '取消置顶' : '置顶'}</button>`);
if (n.receiptFlag == 1) acts.push(`<button class="link-btn" onclick="expedite(${n.id})">催读</button>`);
}
}
if (status === 1 || status === 3 || status === 0) {
acts.push(`<button class="link-btn" onclick="deleteNotices([${n.id}],'${escAttr(n.title)}',()=>{loadSent();loadSentStats();})">删除</button>`);
}
if (status === 0 || status === 3) {
acts.push(`<button class="link-btn success" onclick="openComposeEdit(${n.id},'edit')">编辑</button>`);
}
if (status === 1 && n.isPulled !== 1) {
acts.push(`<button class="link-btn" onclick="openComposeEdit(${n.id},'forward')">转发</button>`);
}
return `<tr>
<td class="td-title" onclick="previewSent(${n.id})" title="${escAttr(n.title)}">${esc(n.title)}</td>
<td>${n.type == 2 ? '<span class="badge b-important">重要</span>' : '<span class="badge b-normal">普通</span>'}</td>
<td>${statusBadge}</td>
<td>${readInfo}</td>
<td style="white-space:nowrap;color:var(--text2)">${esc((n.timingSendTime || n.createTime || '').replace('T', ' '))}</td>
<td class="td-actions">${acts.join('')}</td>
</tr>`;
}).join('');
renderPager('sentPager', r, sentState.pageIndex, p => { sentState.pageIndex = p; loadSent(); });
}
/* 已发列表点标题:管理端查看(不标记学员已读,用 GetDetail 呈现) */
async function previewSent(serid) {
let d;
try { d = normalizeDetail(await api('/notice/GetDetail', { params: { serid } })); }
catch (e) { return handleErr(e); }
$('dTitle').textContent = d.title || '';
const metas = [];
if (d.publisherName || d.teaName) metas.push('👤 ' + esc(d.publisherName || d.teaName));
if (d.deptName) metas.push('🏛 ' + esc(d.deptName));
if (d.editDate) metas.push('🕒 ' + esc(d.editDate));
if (d.numcount != null) metas.push('应读 ' + d.numcount + ' 人');
if (d.readnumcount != null) metas.push('已读 ' + d.readnumcount + ' 人');
if (d.statusDesc) metas.push('状态:' + esc(d.statusDesc));
$('dMeta').innerHTML = metas.map(m => '<span>' + m + '</span>').join('');
$('dContent').innerHTML = renderRichText(d.contents || '(无正文)');
const files = d.chaoSend || [];
if (files.length) {
$('dFiles').style.display = 'block';
$('dFileList').innerHTML = files.map(f => `
<div class="file-item"><span>📎</span>
<span class="fname">${esc(f.name || f.filename)}</span>
<span class="fsize">${fmtSize(f.size)}</span>
<button class="link-btn" onclick="downloadFile(${f.fileid || f.id})">下载</button>
</div>`).join('');
$('dZipBtn').onclick = () => { location.href = API_BASE + '/notice/AllFileDown?dataid=' + serid; };
} else $('dFiles').style.display = 'none';
$('dPrev').disabled = true; $('dNext').disabled = true;
detailNav = { type: '0', serid };
openModal('detailMask');
}
async function pullNotice(serid) {
if (!await confirmBox('撤回后所有人将不再看到该通知,确定撤回吗?')) return;
try { await api('/notice/UserPull', { method: 'POST', params: { serid } }); toast('已撤回', 'ok'); loadSent(); }
catch (e) { handleErr(e); }
}
async function topmost(serid, val, title) {
try {
await api('/notice/SetTopmost', { method: 'POST', params: { serid, topmost: val } });
toast(val == 1 ? '已置顶' : '已取消置顶', 'ok');
loadSent();
} catch (e) { handleErr(e); }
}
async function expedite(serid) {
try { await api('/notice/Expedite', { method: 'POST', params: { serid } }); toast('已向未读人员发送催读提醒', 'ok'); }
catch (e) { handleErr(e); }
}
async function deleteNotices(ids, title, after) {
if (!await confirmBox('确定删除「' + title + '」吗?删除后不可恢复。')) return;
try {
await api('/notice/Delete', { method: 'POST', body: ids });
toast('删除成功', 'ok');
after && after();
} catch (e) { handleErr(e); }
}
/* =========================================================
* 阅读回执
* ======================================================= */
let receiptSerid = null;
async function openReceipt(serid) {
receiptSerid = serid;
const box = $('receiptBody');
box.innerHTML = '<div class="empty">加载中…</div>';
openModal('receiptMask');
let v;
try { v = await api('/notice/ReadReceipt', { params: { serid } }); }
catch (e) { box.innerHTML = '<div class="empty">加载失败</div>'; return handleErr(e); }
const allow = v.allowCount || 0, read = v.readCount || 0;
const pct = allow ? Math.round(read * 100 / allow) : 0;
let html = `
<div class="receipt-summary">
<span style="font-size:13px;color:var(--text2)"><b style="font-size:18px;color:var(--green)">${read}</b> / ${allow} 已读(${pct}%)</span>
<div class="progress"><div style="width:${pct}%"></div></div>
</div>`;
if (v.grades && v.grades.length) {
html += v.grades.map(g => `
<div class="receipt-group-title">🎓 ${esc(g.gradeName || '未分组')}</div>
${(g.classes || []).map(c => `
<div class="class-block">
<div class="class-block-title">🏫 ${esc(c.className || '')}(${(c.students || []).filter(s => s.read).length}/${(c.students || []).length} 已读)</div>
<div class="stu-tags">
${(c.students || []).map(s => `<span class="stu-tag ${s.read ? 'read' : 'unread'}" title="${escAttr(s.readTime ? '阅读时间:' + s.readTime : '未读')}">${esc(s.name || s.account)}${s.read ? ' ✓' : ''}</span>`).join('') || '<span style="color:var(--text3);font-size:12px">该班级暂无接收学生</span>'}
</div>
</div>`).join('')}`);
} else {
html += '<div class="empty" style="padding:20px">无学员接收记录</div>';
}
if (v.teachers && v.teachers.length) {
html += `<div class="receipt-group-title">👨‍🏫 教师(${v.teachers.filter(t => t.read).length}/${v.teachers.length} 已读)</div>
<div class="stu-tags">${v.teachers.map(t => `<span class="stu-tag ${t.read ? 'read' : 'unread'}" title="${escAttr(t.readTime ? '阅读时间:' + t.readTime : '未读')}">${esc(t.name || t.account)}${t.read ? ' ✓' : ''}</span>`).join('')}</div>`;
}
box.innerHTML = html;
const btn = $('receiptExpedite');
btn.style.display = 'inline-flex';
btn.onclick = async () => {
try { await api('/notice/Expedite', { method: 'POST', params: { serid: receiptSerid } }); toast('已向未读人员发送催读提醒', 'ok'); }
catch (e) { handleErr(e); }
};
}
/* =========================================================
* 草稿箱
* ======================================================= */
const draftState = { pageIndex: 1, pageSize: 10 };
function reloadDrafts() { draftState.pageIndex = 1; loadDrafts(); }
async function loadDrafts() {
const body = $('draftBody');
body.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--text3);padding:30px">加载中…</td></tr>';
let r;
try {
r = await api('/notice/DraftList', {
params: { pageIndex: draftState.pageIndex, pageSize: draftState.pageSize, keyword: $('draftKw').value.trim() }
});
} catch (e) { return handleListError(e, body, 5); }
const list = (r && r.content) || [];
if (!list.length) {
body.innerHTML = '<tr><td colspan="5"><div class="empty"><div class="icon">📝</div>草稿箱是空的</div></td></tr>';
$('draftPager').innerHTML = '';
return;
}
body.innerHTML = list.map(n => `<tr>
<td class="td-title" onclick="openComposeEdit(${n.id},'edit')" title="${escAttr(n.title)}">${esc(n.title)}
${n.timingSendTime ? '<span class="badge b-timing">定时</span>' : ''}</td>
<td>${n.type == 2 ? '<span class="badge b-important">重要</span>' : '<span class="badge b-normal">普通</span>'}</td>
<td style="color:var(--text2)">${esc((n.timingSendTime || '').replace('T', ' ') || '-')}</td>
<td style="color:var(--text2)">${esc((n.updateTime || n.createTime || '').replace('T', ' '))}</td>
<td class="td-actions">
<button class="link-btn success" onclick="openComposeEdit(${n.id},'edit')">编辑</button>
<button class="link-btn" onclick="deleteNotices([${n.id}],'${escAttr(n.title)}',()=>{loadDrafts();})">删除</button>
</td>
</tr>`).join('');
renderPager('draftPager', r, draftState.pageIndex, p => { draftState.pageIndex = p; loadDrafts(); });
}
/* =========================================================
* 审批中心
* ======================================================= */
const auditState = { tab: 'pending', pageIndex: 1, pageSize: 10 };
function auditFilter(tab, btn) {
auditState.tab = tab;
auditState.pageIndex = 1;
btn.parentNode.querySelectorAll('button').forEach(b => b.classList.remove('on'));
btn.classList.add('on');
renderAuditHead();
loadApprovals();
}
function renderAuditHead() {
$('auditHead').innerHTML = auditState.tab === 'pending'
? '<tr><th style="width:32%">标题</th><th>类型</th><th>发布人</th><th>审批人</th><th>提交时间</th><th style="width:150px">操作</th></tr>'
: '<tr><th style="width:28%">标题</th><th>发布人</th><th>处理结果</th><th>处理人</th><th>处理时间</th><th>意见</th></tr>';
}
function reloadApprovals() { auditState.pageIndex = 1; loadApprovals(); }
async function loadApprovals() {
const body = $('auditBody');
const cols = auditState.tab === 'pending' ? 6 : 6;
body.innerHTML = `<tr><td colspan="${cols}" style="text-align:center;color:var(--text3);padding:30px">加载中…</td></tr>`;
let r;
try {
r = await api('/notice/ApprovalList', {
params: {
auditState: auditState.tab, pageIndex: auditState.pageIndex,
pageSize: auditState.pageSize, keyword: $('auditKw').value.trim()
}
});
} catch (e) { return handleListError(e, body, cols); }
const list = (r && r.content) || [];
if (!list.length) {
body.innerHTML = `<tr><td colspan="${cols}"><div class="empty"><div class="icon">${auditState.tab === 'pending' ? '✅' : '🗂'}</div>${auditState.tab === 'pending' ? '暂无待审批任务' : '暂无处理记录'}</div></td></tr>`;
$('auditPager').innerHTML = '';
return;
}
body.innerHTML = list.map(n => {
if (auditState.tab === 'pending') {
return `<tr>
<td class="td-title" onclick="auditDetail(${n.id})" title="${escAttr(n.title)}">${esc(n.title)}
${n.type == 2 ? '<span class="badge b-important">重要</span>' : ''}
${n.timingSendTime ? '<span class="badge b-timing">定时</span>' : ''}</td>
<td>${n.type == 2 ? '<span class="badge b-important">重要</span>' : '<span class="badge b-normal">普通</span>'}</td>
<td>${esc(n.publisherName || n.publisherAccount || '-')}</td>
<td style="max-width:160px" title="${escAttr((n.approverNames || []).join('、'))}">${esc((n.approverNames || []).join('、') || '-')}</td>
<td style="color:var(--text2);white-space:nowrap">${esc(n.submitTime || '')}</td>
<td class="td-actions">
<button class="link-btn" onclick="auditDetail(${n.id})">查看</button>
<button class="link-btn success" onclick="approveAction(${n.id},true)">通过</button>
<button class="link-btn danger" onclick="approveAction(${n.id},false)">驳回</button>
</td>
</tr>`;
}
const actionBadge = n.handledAction === 2
? '<span class="badge b-published">通过</span>'
: '<span class="badge b-rejected">驳回</span>';
return `<tr>
<td class="td-title" onclick="auditDetail(${n.id})" title="${escAttr(n.title)}">${esc(n.title)}</td>
<td>${esc(n.publisherName || n.publisherAccount || '-')}</td>
<td>${actionBadge}${n.handled ? '' : ''}</td>
<td>${esc(n.handledName || n.handledAccount || '-')}${n.handledByMe ? ' <span class="badge b-normal">我</span>' : ''}</td>
<td style="color:var(--text2);white-space:nowrap">${esc(n.handledTime || '')}</td>
<td style="max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${escAttr(n.handledOpinion || '')}">${esc(n.handledOpinion || '-')}</td>
</tr>`;
}).join('');
renderPager('auditPager', r, auditState.pageIndex, p => { auditState.pageIndex = p; loadApprovals(); });
}
/* 查看/审批前先看内容(GetDetail 不标读) */
async function auditDetail(serid) { await previewSent(serid); }
async function approveAction(serid, pass) {
let opinion;
if (pass) {
opinion = await askInput('审批通过', '审批意见(可空)', '同意');
if (opinion === null) return; // 取消
if (!opinion.trim()) opinion = '同意';
} else {
opinion = await askInput('审批驳回', '请填写驳回意见(必填,发布人可修改后重新提交)', '');
if (opinion === null) return;
if (!opinion.trim()) return toast('驳回必须填写意见', 'err');
}
try {
if (pass) await api('/notice/Approve', { method: 'POST', params: { serid, opinion: opinion.trim() } });
else await api('/notice/Reject', { method: 'POST', params: { serid, opinion: opinion.trim() } });
toast(pass ? '已通过,通知将按计划发布' : '已驳回,发布人可修改后重新提交', 'ok');
loadApprovals();
} catch (e) { handleErr(e); }
}
/* =========================================================
* 设置管理(发布部门)
* ManageList 返回 {SerID, CodeID, Name, Des(排序), Enable(1启用/0禁用)}
* ======================================================= */
let deptList = [];
function reloadDepts() { loadDepts(); }
async function loadDepts() {
const body = $('deptBody');
body.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--text3);padding:30px">加载中…</td></tr>';
let list;
try {
list = await api('/dict/ManageList', { params: { type: 'dept' } }) || [];
} catch (e) { return handleListError(e, body, 5); }
deptList = list;
$('saveDeptOrderBtn').style.display = 'none';
renderDepts();
}
function renderDepts() {
const body = $('deptBody');
if (!deptList.length) {
body.innerHTML = '<tr><td colspan="5"><div class="empty"><div class="icon">🏛</div>暂无发布部门,点击「添加部门」创建</div></td></tr>';
return;
}
body.innerHTML = deptList.map((d, i) => {
const enabled = d.Enable == 1;
return `<tr>
<td style="white-space:nowrap">
<button class="link-btn" title="上移" ${i === 0 ? 'disabled style="opacity:.25;cursor:default"' : ''} onclick="moveDept(${i},-1)">↑</button>
<button class="link-btn" title="下移" ${i === deptList.length - 1 ? 'disabled style="opacity:.25;cursor:default"' : ''} onclick="moveDept(${i},1)">↓</button>
</td>
<td style="font-weight:500">${esc(d.Name)}</td>
<td style="color:var(--text3)">${esc(d.CodeID || '-')}</td>
<td>${enabled ? '<span class="badge b-published">启用</span>' : '<span class="badge b-pulled">禁用</span>'}</td>
<td class="td-actions">
<button class="link-btn" onclick="renameDept(${d.SerID},'${escAttr(d.Name)}')">编辑</button>
<button class="link-btn ${enabled ? '' : 'success'}" onclick="toggleDept(${d.SerID},${enabled ? 0 : 1},'${escAttr(d.Name)}')">${enabled ? '禁用' : '启用'}</button>
<button class="link-btn danger" onclick="deleteDept(${d.SerID},'${escAttr(d.Name)}')">删除</button>
</td>
</tr>`;
}).join('');
}
/* 变更后刷新写通知页的部门下拉(若已加载过) */
function refreshComposeDept() {
if (!composeState.inited) return;
deptOptionsPromise = null;
loadDeptOptions();
}
async function addDept() {
const name = await askInput('添加发布部门', '请输入部门名称', '');
if (name === null) return;
if (!name.trim()) return toast('部门名称不能为空', 'err');
try {
await api('/dict/Add', { method: 'POST', params: { name: name.trim() } });
toast('已添加', 'ok');
loadDepts(); refreshComposeDept();
} catch (e) { handleErr(e); }
}
async function renameDept(serid, oldName) {
const name = await askInput('编辑部门名称', '请输入新的部门名称', oldName);
if (name === null) return;
if (!name.trim()) return toast('部门名称不能为空', 'err');
const item = deptList.find(d => d.SerID === serid);
try {
await api('/dict/Update', {
method: 'POST',
params: { SerID: serid, Name: name.trim(), Enable: item ? item.Enable : 1 }
});
toast('已保存', 'ok');
loadDepts(); refreshComposeDept();
} catch (e) { handleErr(e); }
}
async function toggleDept(serid, enable, name) {
try {
await api('/dict/Update', { method: 'POST', params: { SerID: serid, Name: name, Enable: enable } });
toast(enable == 1 ? '已启用' : '已禁用,写通知时不再显示', 'ok');
loadDepts(); refreshComposeDept();
} catch (e) { handleErr(e); }
}
async function deleteDept(serid, name) {
if (!await confirmBox('确定删除「' + name + '」吗?若已被通知引用将无法删除。')) return;
try {
await api('/dict/Delete', { method: 'POST', params: { serid } });
toast('已删除', 'ok');
loadDepts(); refreshComposeDept();
} catch (e) { handleErr(e); }
}
function moveDept(i, dir) {
const j = i + dir;
if (j < 0 || j >= deptList.length) return;
[deptList[i], deptList[j]] = [deptList[j], deptList[i]];
renderDepts();
$('saveDeptOrderBtn').style.display = 'inline-flex';
}
async function saveDeptOrder() {
// Sort 接口按 ids 顺序赋递减排号,即 ids 顺序 = 页面显示顺序(自上而下)
const ids = deptList.map(d => d.SerID).join(',');
try {
await api('/dict/Sort', { method: 'POST', params: { ids } });
toast('排序已保存', 'ok');
loadDepts(); refreshComposeDept();
} catch (e) { handleErr(e); }
}
/* =========================================================
* 分页 / 错误处理
* ======================================================= */
function renderPager(boxId, r, pageIndex, go) {
const box = $(boxId);
if (!r || (r.totalPages || 1) <= 1) { box.innerHTML = ''; return; }
box.innerHTML = `
<span>第 ${r.pageIndex} / ${r.totalPages} 页 · 共 ${r.total} 条</span>
<div class="pager-btns">
<button class="btn btn-ghost btn-sm" ${pageIndex <= 1 ? 'disabled' : ''}>上一页</button>
<button class="btn btn-ghost btn-sm" ${r.hasNext ? '' : 'disabled'}>下一页</button>
</div>`;
const [prev, next] = box.querySelectorAll('button');
prev.onclick = () => pageIndex > 1 && go(pageIndex - 1);
next.onclick = () => r.hasNext && go(pageIndex + 1);
}
function handleListError(e, box, colspan) {
if (e instanceof AuthError) return showLoginTip();
const msg = e instanceof ApiError ? e.message : '加载失败,请稍后重试';
if (box.tagName === 'TBODY') {
box.innerHTML = `<tr><td colspan="${colspan || 6}"><div class="empty">${esc(msg)}</div></td></tr>`;
} else {
box.innerHTML = `<div class="empty">${esc(msg)}</div>`;
}
}
function handleErr(e) {
if (e instanceof AuthError) return showLoginTip();
toast(e instanceof ApiError ? e.message : '操作失败,请稍后重试', 'err');
}
/* =========================================================
* 启动
* ======================================================= */
(async function init() {
renderAuditHead();
renderComposeActions();
try {
await loadUser();
} catch (e) { return; /* 未登录:已展示登录提示 */ }
$('appRoot').style.display = '';
switchView('inbox');
})();
</script>
</body>
</html>