1782 lines
84 KiB
HTML
1782 lines
84 KiB
HTML
<!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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>
|