@@ -343,12 +343,25 @@
|
||||
</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 class="panel-title">审批流程</div>
|
||||
<label class="check-inline" style="margin-bottom:10px">
|
||||
<input type="checkbox" id="cApproval" onchange="onApprovalToggle(this.checked)">
|
||||
选择审批人后走审批流程(不勾选=直接发布)
|
||||
</label>
|
||||
<div id="flowSteps" style="display:none"></div>
|
||||
<div id="flowOps" style="display:none;margin-top:6px">
|
||||
<button class="link-btn" onclick="addFlowStep()">+ 再加一步(多步依次审批)</button>
|
||||
<button class="link-btn" style="margin-left:14px" onclick="loadApproverSuggest(1)">🔄 带入上次所选审批人(第 1 步)</button>
|
||||
</div>
|
||||
<div id="flowPicker" style="display:none;margin-top:10px">
|
||||
<label class="form-label" id="flowPickerTitle">选择审批人</label>
|
||||
<input class="form-input" id="approverSearch" placeholder="输入名字搜索" style="margin-bottom:8px" oninput="filterApproverTree()">
|
||||
<div class="scope-tree" id="approverTree"><div style="padding:20px;text-align:center;color:var(--text3)">加载中…</div></div>
|
||||
<div style="margin-top:8px">
|
||||
<button class="btn btn-primary btn-sm" onclick="closeFlowPicker()">✓ 完成选择</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-hint" id="flowHint" style="display:none">按顺序流转:上一步通过后进入下一步;任一步驳回则退回发布人。每步可多选审批人:或签任一人通过即过,会签需全部通过。</div>
|
||||
</div>
|
||||
|
||||
<div class="compose-actions" id="composeActions">
|
||||
@@ -359,6 +372,7 @@
|
||||
<div class="compose-side"><!-- 右列 -->
|
||||
<div class="panel">
|
||||
<div class="panel-title">推送范围(学员)</div>
|
||||
<input class="form-input" id="scopeSearch" placeholder="输入名字搜索" style="margin-bottom:8px" oninput="filterScopeTree()">
|
||||
<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">
|
||||
@@ -444,13 +458,15 @@
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<div class="page-title">审批中心</div>
|
||||
<div class="page-sub">我作为审批人收到的审批任务(或签:任一审批人处理即可)</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>
|
||||
<button data-v="approved" onclick="auditFilter('approved',this)">已通过</button>
|
||||
<button data-v="rejected" onclick="auditFilter('rejected',this)">已驳回</button>
|
||||
<button data-v="all" onclick="auditFilter('all',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>
|
||||
@@ -869,12 +885,52 @@ function loadDeptOptions() {
|
||||
}
|
||||
async function loadScopeTree() {
|
||||
try {
|
||||
scopeTreeData = await api('/notice/ScopeTree') || [];
|
||||
$('scopeTree').innerHTML = scopeTreeData.map(n => renderTreeNode(n, 0)).join('');
|
||||
// 发送范围改走学生班级树 base/getStuTree(toUser=false 仅到班级,不含学生叶子)
|
||||
const raw = await api('/base/getStuTree', { params: { toUser: false } }) || [];
|
||||
scopeTreeData = raw.map(n => toScopeNode(n, 1)).filter(Boolean);
|
||||
$('scopeTree').innerHTML = scopeTreeData.length
|
||||
? scopeTreeData.map(n => renderTreeNode(n, 0)).join('')
|
||||
: '<div style="padding:20px;text-align:center;color:var(--text3)">暂无班级数据</div>';
|
||||
} catch (e) {
|
||||
$('scopeTree').innerHTML = '<div style="padding:20px;text-align:center;color:var(--text3)">范围树加载失败</div>';
|
||||
}
|
||||
}
|
||||
/* base/getStuTree 节点 {id,name,type,children} → 推送范围节点 {scopeType,scopeId,name,children}。
|
||||
getStuTree 组节点不区分学校/学段/年级/班级,按子树形态自底向上归类(对齐 PushScopeTypeEnum):
|
||||
无组子节点=班级(4),其父=年级(3),再上=学部(2),顶层根强制=全校(1);
|
||||
toUser=false 时无 type=2 叶子,出现则防御性跳过。 */
|
||||
function toScopeNode(node, forcedType) {
|
||||
if (!node || node.type !== 1) return null;
|
||||
const children = (node.children || []).map(c => toScopeNode(c)).filter(Boolean);
|
||||
let scopeType = forcedType;
|
||||
if (!scopeType) {
|
||||
if (!children.length) scopeType = 4;
|
||||
else if (children.some(c => c.scopeType === 4)) scopeType = 3;
|
||||
else if (children.some(c => c.scopeType === 3)) scopeType = 2;
|
||||
else scopeType = 1;
|
||||
}
|
||||
return { scopeType, scopeId: node.id, name: node.name, children };
|
||||
}
|
||||
/* 按名称过滤范围树:命中节点及其祖先保留,其余分支隐藏;搜索时自动展开命中分支 */
|
||||
function filterScopeTree() {
|
||||
const kw = ($('scopeSearch').value || '').trim().toLowerCase();
|
||||
const visit = nodes => {
|
||||
let any = false;
|
||||
for (const n of nodes) {
|
||||
const el = $('tn-' + n.scopeType + ':' + n.scopeId);
|
||||
const selfMatch = !kw || String(n.name || '').toLowerCase().includes(kw);
|
||||
const childMatch = n.children && n.children.length ? visit(n.children) : false;
|
||||
const hit = selfMatch || childMatch;
|
||||
if (el) {
|
||||
el.style.display = hit ? '' : 'none';
|
||||
if (kw && childMatch) el.classList.add('expanded');
|
||||
}
|
||||
any = any || hit;
|
||||
}
|
||||
return any;
|
||||
};
|
||||
visit(scopeTreeData);
|
||||
}
|
||||
function renderTreeNode(node, depth) {
|
||||
const key = node.scopeType + ':' + node.scopeId;
|
||||
const hasChildren = node.children && node.children.length;
|
||||
@@ -1001,9 +1057,14 @@ function buildNoticeBody() {
|
||||
pushScopes: composeState.scopes.length ? composeState.scopes : null
|
||||
};
|
||||
if (composeState.sourceId) body.sourceId = composeState.sourceId;
|
||||
// 编辑态:保存当前绑定的审批教师(空数组=清空审批人,后端据此持久化)
|
||||
if (composeState.mode === 'edit') {
|
||||
body.approverTeaIds = collectApproverTeaIds();
|
||||
// 多步审批流:勾选审批时按步携带 flow([{name, vote, approverTeaIds}]);
|
||||
// 编辑态未勾选 = flow:[] 清空流程定义,approverTeaIds:[] 清空旧教师快照
|
||||
if (approvalEnabled()) {
|
||||
body.flow = collectFlow();
|
||||
if (composeState.mode === 'edit') body.approverTeaIds = [];
|
||||
} else if (composeState.mode === 'edit') {
|
||||
body.approverTeaIds = [];
|
||||
body.flow = [];
|
||||
}
|
||||
return body;
|
||||
}
|
||||
@@ -1023,7 +1084,7 @@ function renderComposeActions() {
|
||||
<button class="btn btn-warn" id="btnApproveSubmit" onclick="onComposeApproveClick()">📨 提交审批</button>`;
|
||||
}
|
||||
}
|
||||
/* 新建模式提交审批:首点展开选人面板,再点确认提交 */
|
||||
/* 新建模式提交审批:首点展开流程面板,再点确认提交 */
|
||||
function onComposeApproveClick() {
|
||||
if ($('approvePanel').style.display === 'none') {
|
||||
toggleApprovePanel();
|
||||
@@ -1036,24 +1097,131 @@ function toggleApprovePanel() {
|
||||
const p = $('approvePanel');
|
||||
p.style.display = p.style.display === 'none' ? 'block' : 'none';
|
||||
if (p.style.display === 'block') {
|
||||
ensureApproverTree().catch(e => handleErr(e));
|
||||
$('cApproval').checked = true;
|
||||
onApprovalToggle(true);
|
||||
loadApproverSuggest();
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 审批人教师树(base/GetSchoolAll:type=1 组 / type=2 教师) ---------- */
|
||||
const approverState = { treePromise: null, names: {}, selected: {} };
|
||||
/* ---------- 多步审批流编辑器(后端 notice_approval_step 步骤表契约) ---------- */
|
||||
/* 每步 {name, vote: 'any'或签|'all'会签, teaIds:[教师ID], names:{id:姓名}};
|
||||
选人复用教师树(base/getTeaTree,toUser=true 叶子带教师),提交按步传 approverTeaIds,
|
||||
后端按学校教师名单绑定姓名后落步骤表;账号路径(审批人池)由新版前端使用。 */
|
||||
const approverState = { treePromise: null, names: {}, rawTree: [] };
|
||||
const flowState = { steps: [newFlowStep()], picking: -1 };
|
||||
|
||||
function newFlowStep() { return { name: '', vote: 'any', teaIds: [], names: {} }; }
|
||||
function approvalEnabled() { return !!($('cApproval') && $('cApproval').checked); }
|
||||
|
||||
function onApprovalToggle(checked) {
|
||||
$('flowSteps').style.display = checked ? 'block' : 'none';
|
||||
$('flowOps').style.display = checked ? 'block' : 'none';
|
||||
$('flowHint').style.display = checked ? 'block' : 'none';
|
||||
if (checked) {
|
||||
ensureApproverTree().catch(e => handleErr(e));
|
||||
if (!flowState.steps.length) flowState.steps = [newFlowStep()];
|
||||
renderFlowSteps();
|
||||
}
|
||||
}
|
||||
|
||||
function renderFlowSteps() {
|
||||
$('flowSteps').innerHTML = flowState.steps.map((s, i) => {
|
||||
const chips = s.teaIds.map(tid =>
|
||||
`<span class="chip">${esc(s.names[tid] || approverState.names[tid] || ('ID ' + tid))}<b onclick="removeFlowApprover(${i},'${escAttr(tid)}')">✕</b></span>`).join('');
|
||||
return `<div style="margin:10px 0;padding:12px;border:1px solid #e4e7f0;border-radius:8px;background:#fafbfd">
|
||||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
||||
<span class="badge b-normal">${i + 1}</span>
|
||||
<span style="font-weight:500">第 ${i + 1} 步</span>
|
||||
<select class="input" style="width:auto;padding:4px 8px" onchange="setFlowVote(${i},this.value)">
|
||||
<option value="any"${s.vote !== 'all' ? ' selected' : ''}>或签(任一通过即可)</option>
|
||||
<option value="all"${s.vote === 'all' ? ' selected' : ''}>会签(全部通过)</option>
|
||||
</select>
|
||||
<span style="flex:1"></span>
|
||||
${i > 0 ? `<button class="link-btn" onclick="moveFlowStep(${i},-1)">上移</button>` : ''}
|
||||
${i < flowState.steps.length - 1 ? `<button class="link-btn" onclick="moveFlowStep(${i},1)">下移</button>` : ''}
|
||||
${flowState.steps.length > 1 ? `<button class="link-btn danger" onclick="removeFlowStep(${i})">删除</button>` : ''}
|
||||
</div>
|
||||
<div class="scope-chips"${chips ? '' : ' style="display:none"'}>${chips}</div>
|
||||
${chips ? '' : '<div style="color:var(--text3);font-size:12px;margin:6px 0">未选择审批人</div>'}
|
||||
<button class="btn btn-sm" style="margin-top:6px" onclick="openFlowPicker(${i})">+ 选择审批人</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function setFlowVote(i, vote) { flowState.steps[i].vote = vote === 'all' ? 'all' : 'any'; }
|
||||
function addFlowStep() { flowState.steps.push(newFlowStep()); renderFlowSteps(); }
|
||||
function removeFlowStep(i) { flowState.steps.splice(i, 1); renderFlowSteps(); }
|
||||
function moveFlowStep(i, dir) {
|
||||
const j = i + dir;
|
||||
if (j < 0 || j >= flowState.steps.length) return;
|
||||
const a = flowState.steps;
|
||||
[a[i], a[j]] = [a[j], a[i]];
|
||||
renderFlowSteps();
|
||||
}
|
||||
function removeFlowApprover(i, tid) {
|
||||
const s = flowState.steps[i];
|
||||
s.teaIds = s.teaIds.filter(id => String(id) !== String(tid));
|
||||
delete s.names[tid];
|
||||
renderFlowSteps();
|
||||
}
|
||||
|
||||
/* 选人树:共享一棵,按 flowState.picking 定位当前编辑步骤 */
|
||||
function openFlowPicker(i) {
|
||||
flowState.picking = i;
|
||||
$('flowPickerTitle').textContent = '第 ' + (i + 1) + ' 步 · 选择审批人(可多选)';
|
||||
$('flowPicker').style.display = 'block';
|
||||
ensureApproverTree().then(() => {
|
||||
const sel = {};
|
||||
flowState.steps[i].teaIds.forEach(tid => sel[String(tid)] = true);
|
||||
document.querySelectorAll('#approverTree input[data-tid]').forEach(cb => {
|
||||
cb.checked = !!sel[cb.getAttribute('data-tid')];
|
||||
});
|
||||
}).catch(e => handleErr(e));
|
||||
}
|
||||
function closeFlowPicker() {
|
||||
flowState.picking = -1;
|
||||
$('flowPicker').style.display = 'none';
|
||||
renderFlowSteps();
|
||||
}
|
||||
|
||||
/* 收集流程:[{name, vote, approverTeaIds}],仅保留已选人的步 */
|
||||
function collectFlow() {
|
||||
return flowState.steps
|
||||
.map((s, i) => ({ name: s.name || ('第' + (i + 1) + '步'), vote: s.vote || 'any', approverTeaIds: s.teaIds.slice() }))
|
||||
.filter(s => s.approverTeaIds.length);
|
||||
}
|
||||
function flowStepCount() { return flowState.steps.filter(s => s.teaIds.length).length; }
|
||||
|
||||
/* 编辑回显:详情 approvalFlow[].approverTeaIds/vote → 编辑器 */
|
||||
function applyFlowFromDetail(flow) {
|
||||
flowState.steps = (flow || []).map((l, i) => ({
|
||||
name: l.name || ('第' + (i + 1) + '步'),
|
||||
vote: l.vote === 'all' ? 'all' : 'any',
|
||||
teaIds: (l.approverTeaIds || []).map(Number),
|
||||
names: {}
|
||||
}));
|
||||
(flow || []).forEach((l, i) => {
|
||||
const s = flowState.steps[i];
|
||||
const names = l.approverNames || [];
|
||||
if (s.teaIds.length && names.length === s.teaIds.length) {
|
||||
s.teaIds.forEach((tid, j) => s.names[tid] = names[j]);
|
||||
}
|
||||
});
|
||||
if (!flowState.steps.length) flowState.steps = [newFlowStep()];
|
||||
renderFlowSteps();
|
||||
}
|
||||
|
||||
function ensureApproverTree() {
|
||||
if (!approverState.treePromise) {
|
||||
approverState.treePromise = api('/base/GetSchoolAll').then(data => {
|
||||
// 教师树 bschclass /oapi/SchGroup/getTeaTree:toUser=true 组下挂教师叶子,
|
||||
// 叶子 id=teaId 与后端绑定姓名的 UserTeaSerID 同一 ID 空间
|
||||
approverState.treePromise = api('/base/getTeaTree', { params: { toUser: true } }).then(data => {
|
||||
const tree = data || [];
|
||||
approverState.rawTree = tree;
|
||||
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;
|
||||
@@ -1063,6 +1231,35 @@ function ensureApproverTree() {
|
||||
return approverState.treePromise;
|
||||
}
|
||||
|
||||
/* 按名称过滤审批人树:命中教师及其所在组保留,其余分支隐藏;搜索时自动展开命中分支。
|
||||
同一教师在多组重复出现时,所有 data-tid 副本同步显隐。 */
|
||||
function filterApproverTree() {
|
||||
const kw = ($('approverSearch').value || '').trim().toLowerCase();
|
||||
const visit = nodes => {
|
||||
let any = false;
|
||||
for (const n of nodes) {
|
||||
const selfMatch = !kw || String(n.name || '').toLowerCase().includes(kw);
|
||||
const childMatch = n.children && n.children.length ? visit(n.children) : false;
|
||||
const hit = selfMatch || childMatch;
|
||||
if (n.type === 1) {
|
||||
const el = $('tn-atn-' + n.id);
|
||||
if (el) {
|
||||
el.style.display = hit ? '' : 'none';
|
||||
if (kw && childMatch) el.classList.add('expanded');
|
||||
}
|
||||
} else {
|
||||
document.querySelectorAll('#approverTree input[data-tid="' + n.id + '"]').forEach(cb => {
|
||||
const leaf = cb.closest('.tree-node');
|
||||
if (leaf) leaf.style.display = hit ? '' : 'none';
|
||||
});
|
||||
}
|
||||
any = any || hit;
|
||||
}
|
||||
return any;
|
||||
};
|
||||
visit(approverState.rawTree || []);
|
||||
}
|
||||
|
||||
function renderApproverNode(node) {
|
||||
if (node.type === 1) {
|
||||
const hasChildren = node.children && node.children.length;
|
||||
@@ -1093,82 +1290,61 @@ function onApproverLabelClick(el) {
|
||||
onApproverCheck(cb);
|
||||
}
|
||||
|
||||
/* 勾选写入当前编辑步骤(flowState.picking),同教师多组重复出现时全树同步勾选态 */
|
||||
function onApproverCheck(cb) {
|
||||
if (flowState.picking < 0) { cb.checked = false; return; }
|
||||
const tid = cb.getAttribute('data-tid');
|
||||
if (cb.checked) {
|
||||
approverState.selected[tid] = { id: tid, name: approverState.names[tid] || ('ID ' + tid) };
|
||||
const checked = cb.checked;
|
||||
const step = flowState.steps[flowState.picking];
|
||||
if (checked) {
|
||||
if (!step.teaIds.some(id => String(id) === tid)) {
|
||||
step.teaIds.push(Number(tid));
|
||||
step.names[tid] = approverState.names[tid] || ('ID ' + tid);
|
||||
}
|
||||
} else {
|
||||
delete approverState.selected[tid];
|
||||
step.teaIds = step.teaIds.filter(id => String(id) !== tid);
|
||||
delete step.names[tid];
|
||||
}
|
||||
syncApproverChecks(tid, cb.checked);
|
||||
renderApproverChips();
|
||||
document.querySelectorAll('#approverTree input[data-tid="' + tid + '"]').forEach(x => { x.checked = checked; });
|
||||
renderFlowSteps();
|
||||
}
|
||||
|
||||
function syncApproverChecks(tid, checked) {
|
||||
document.querySelectorAll('#approverTree input[data-tid="' + tid + '"]').forEach(cb => { cb.checked = checked; });
|
||||
/* 带入上次所选审批人(ApproverSuggest 记忆 → 第 1 步) */
|
||||
async function loadApproverSuggest(byUser) {
|
||||
try {
|
||||
const v = await api('/notice/ApproverSuggest');
|
||||
if (v && v.teaIds && v.teaIds.length) {
|
||||
const missed = await bindFlowStepByTeaIds(0, v.teaIds);
|
||||
renderFlowSteps();
|
||||
toast('已带入上次所选 ' + flowState.steps[0].teaIds.length + ' 名审批人(第 1 步)' + (missed.length ? '(' + missed.join('、') + ' 未找到)' : ''), 'ok');
|
||||
} else if (byUser) {
|
||||
toast('暂无审批人记忆,请在第 1 步中勾选教师', 'err');
|
||||
}
|
||||
} catch (e) { handleErr(e); }
|
||||
}
|
||||
|
||||
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) {
|
||||
/* 按教师ID绑定某步(记忆带入/回显),姓名经 ApproverResolve 解析,未命中降级 ID */
|
||||
async function bindFlowStepByTeaIds(stepIdx, teaIds) {
|
||||
const missed = [];
|
||||
if (!teaIds || !teaIds.length) return missed;
|
||||
await ensureApproverTree();
|
||||
const step = flowState.steps[stepIdx];
|
||||
step.teaIds = [];
|
||||
step.names = {};
|
||||
let byId = {};
|
||||
try {
|
||||
const resolved = await api('/notice/ApproverResolve', {
|
||||
params: { ids: teaIds.join(',') }
|
||||
}) || [];
|
||||
resolved.forEach(r => { byId[r.id] = r; });
|
||||
} catch (e) { /* 绑定接口失败不阻断勾选,姓名降级树节点/ID */ }
|
||||
} 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);
|
||||
if (!r || !r.name) missed.push('ID ' + tid);
|
||||
step.teaIds.push(Number(tid));
|
||||
step.names[tid] = (r && r.name) || ('ID ' + tid);
|
||||
}
|
||||
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;
|
||||
@@ -1183,13 +1359,18 @@ function resetCompose() {
|
||||
$('cReceipt').checked = false;
|
||||
$('cTiming').value = '';
|
||||
$('cReceivers').value = '';
|
||||
clearApproverSelection();
|
||||
flowState.steps = [newFlowStep()];
|
||||
flowState.picking = -1;
|
||||
$('cApproval').checked = false;
|
||||
onApprovalToggle(false);
|
||||
closeFlowPicker();
|
||||
$('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);
|
||||
if ($('scopeSearch')) { $('scopeSearch').value = ''; filterScopeTree(); }
|
||||
}
|
||||
/* 进入编辑/转发模式并预填 */
|
||||
async function openComposeEdit(serid, mode) {
|
||||
@@ -1234,16 +1415,37 @@ async function openComposeEdit(serid, mode) {
|
||||
// 附件回显
|
||||
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) {
|
||||
// 审批流程面板显隐规则:有审核人才显示,没有则收起(用户点「提交审批」时再展开选人)。
|
||||
if (mode === 'edit' && approvalHasApprovers(d)) {
|
||||
$('approvePanel').style.display = 'block';
|
||||
ensureApproverTree().catch(e => handleErr(e));
|
||||
const missed = await bindApproversByTeaIds(d.approverTeaIds);
|
||||
if (missed.length) toast('审批教师 ' + missed.join('、') + ' 未在本校教师名单,请重新选择', 'err');
|
||||
$('cApproval').checked = true;
|
||||
onApprovalToggle(true);
|
||||
if (d.approvalFlow && d.approvalFlow.length) {
|
||||
// 新流程回显:approvalFlow(多步,含每步 approverTeaIds/vote)→ 编辑器
|
||||
applyFlowFromDetail(d.approvalFlow);
|
||||
const empty = flowState.steps.filter(s => !s.teaIds.length).length;
|
||||
if (empty) toast('有 ' + empty + ' 个步骤未选择审批人,提交前请补选', 'err');
|
||||
} else {
|
||||
// 旧版单步审批人快照(有 approverTeaIds 无步骤表):回显为第 1 步。
|
||||
// 否则保存会按「清空流程」把审批人一并丢失。
|
||||
flowState.steps = [newFlowStep()];
|
||||
const missed = await bindFlowStepByTeaIds(0, d.approverTeaIds);
|
||||
renderFlowSteps();
|
||||
if (missed.length) toast('审批教师 ' + missed.join('、') + ' 未在本校教师名单,请重新选择', 'err');
|
||||
}
|
||||
}
|
||||
} catch (e) { handleErr(e); }
|
||||
}
|
||||
/* 是否存在审核人:新流程步骤(approverTeaIds/approvers)或旧版单步快照 approverTeaIds */
|
||||
function approvalHasApprovers(d) {
|
||||
if (!d) return false;
|
||||
if (Array.isArray(d.approvalFlow) && d.approvalFlow.some(l =>
|
||||
(Array.isArray(l.approverTeaIds) && l.approverTeaIds.length) ||
|
||||
(Array.isArray(l.approvers) && l.approvers.length))) {
|
||||
return true;
|
||||
}
|
||||
return !!(d.approverTeaIds && d.approverTeaIds.length);
|
||||
}
|
||||
/* GetDetail/Detail 返回沿用旧 .NET 字段名(DeptID/Contents/ChaoSend…),归一化为前端小写风格 */
|
||||
function normalizeDetail(d) {
|
||||
d = d || {};
|
||||
@@ -1262,36 +1464,57 @@ function htmlToText(html) {
|
||||
}
|
||||
|
||||
/* ---------- 提交 ---------- */
|
||||
/* 展开审批流程面板并打开指定步骤的选人区(新建/编辑提交审批共用)。
|
||||
编辑态初始面板是收起的,若只调 openFlowPicker 会让选人树落在隐藏面板内不可见。 */
|
||||
function revealApprovalEditor(stepIndex) {
|
||||
const idx = stepIndex || 0;
|
||||
const p = $('approvePanel');
|
||||
p.style.display = 'block';
|
||||
$('cApproval').checked = true;
|
||||
if (!flowState.steps.length) flowState.steps = [newFlowStep()];
|
||||
onApprovalToggle(true);
|
||||
openFlowPicker(idx);
|
||||
p.scrollIntoView({ block: 'center' });
|
||||
}
|
||||
|
||||
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'); }
|
||||
if (!flowStepCount()) {
|
||||
revealApprovalEditor(0);
|
||||
return toast('请先为第 1 步勾选审批教师', 'err');
|
||||
}
|
||||
if (!approvalEnabled()) {
|
||||
$('approvePanel').style.display = 'block';
|
||||
$('cApproval').checked = true;
|
||||
onApprovalToggle(true);
|
||||
}
|
||||
body.publishType = 'approve';
|
||||
body.approverTeaIds = teaIds;
|
||||
body.flow = collectFlow();
|
||||
} else {
|
||||
body.publishType = action;
|
||||
// 直接发布 = 跳过审批流(勾选审批仅对 提交审批/存草稿 生效)
|
||||
if (action === 'direct') delete body.flow;
|
||||
}
|
||||
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'); }
|
||||
if (!flowStepCount()) {
|
||||
// 编辑态首点提交审批:展开流程面板与选人树(否则选人区在隐藏面板内不可见),选人后再次点击即提交
|
||||
revealApprovalEditor(0);
|
||||
return toast('请先为第 1 步勾选审批教师', 'err');
|
||||
}
|
||||
try {
|
||||
await api('/notice/Edit', { method: 'POST', body });
|
||||
await api('/notice/Submit', {
|
||||
method: 'POST',
|
||||
body: { serid, approverTeaIds: teaIds, opinion: null }
|
||||
body: { serid, steps: collectFlow(), opinion: null }
|
||||
});
|
||||
toast('已提交审批', 'ok');
|
||||
resetCompose();
|
||||
@@ -1369,7 +1592,16 @@ async function loadSent() {
|
||||
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 === 2) {
|
||||
// 进度文本与徽标重复(旧版单步无步骤表时 desc 即「待审批」)则不再显示
|
||||
const desc = n.approvalStatusDesc && n.approvalStatusDesc !== '待审批' ? n.approvalStatusDesc : '';
|
||||
const approverIds = parseTeaIdList(n.approverTeaIds);
|
||||
statusBadge = '<span class="badge b-pending">待审批</span>'
|
||||
+ (desc ? ` <span style="color:var(--text3)">${esc(desc)}</span>` : '')
|
||||
+ (approverIds.length
|
||||
? `<br><span class="pending-approvers" data-ids="${approverIds.join(',')}" style="font-size:12px;color:var(--text3)">审核人:加载中…</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>';
|
||||
@@ -1405,8 +1637,45 @@ async function loadSent() {
|
||||
<td class="td-actions">${acts.join('')}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
fillSentApprovers();
|
||||
renderPager('sentPager', r, sentState.pageIndex, p => { sentState.pageIndex = p; loadSent(); });
|
||||
}
|
||||
/* 审批教师ID快照("[10007,10008]" 或数组)→ 数字数组 */
|
||||
function parseTeaIdList(raw) {
|
||||
if (!raw) return [];
|
||||
if (Array.isArray(raw)) return raw.map(Number).filter(Boolean);
|
||||
try {
|
||||
const a = JSON.parse(raw);
|
||||
return Array.isArray(a) ? a.map(Number).filter(Boolean) : [];
|
||||
} catch (e) {
|
||||
return String(raw).replace(/[\[\]\s]/g, '').split(',').filter(Boolean).map(Number).filter(Boolean);
|
||||
}
|
||||
}
|
||||
/* 审批教师ID→姓名缓存(已发列表「待审批」行展示审核人,避免逐行重复请求) */
|
||||
const approverNameCache = {};
|
||||
async function resolveApproverNames(teaIds) {
|
||||
const missing = teaIds.map(Number).filter(id => id && !(String(id) in approverNameCache));
|
||||
if (missing.length) {
|
||||
try {
|
||||
const resolved = await api('/notice/ApproverResolve', { params: { ids: missing.join(',') } }) || [];
|
||||
resolved.forEach(r => { approverNameCache[String(r.id)] = r.name || ('ID ' + r.id); });
|
||||
} catch (e) { /* 解析失败降级 ID 展示,不阻断列表 */ }
|
||||
missing.forEach(id => { if (!(String(id) in approverNameCache)) approverNameCache[String(id)] = 'ID ' + id; });
|
||||
}
|
||||
return teaIds.map(id => approverNameCache[String(id)] || ('ID ' + id));
|
||||
}
|
||||
/* 待审批行的审核人占位 span → 批量解析为「审核人:姓名1、姓名2」 */
|
||||
async function fillSentApprovers() {
|
||||
const spans = Array.from(document.querySelectorAll('#sentBody .pending-approvers'));
|
||||
if (!spans.length) return;
|
||||
const groups = spans.map(el => ({ el, ids: (el.dataset.ids || '').split(',').filter(Boolean) }));
|
||||
await resolveApproverNames(groups.flatMap(g => g.ids));
|
||||
groups.forEach(({ el, ids }) => {
|
||||
el.textContent = ids.length
|
||||
? '审核人:' + ids.map(id => approverNameCache[String(id)] || ('ID ' + id)).join('、')
|
||||
: '';
|
||||
});
|
||||
}
|
||||
/* 已发列表点标题:管理端查看(不标记学员已读,用 GetDetail 呈现) */
|
||||
async function previewSent(serid) {
|
||||
let d;
|
||||
@@ -1421,7 +1690,27 @@ async function previewSent(serid) {
|
||||
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 || '(无正文)');
|
||||
// 多步审批流进度(notice_approval_step):步骤名 · 或签/会签 · 状态 · 已处理/待签
|
||||
if (d.approvalFlow && d.approvalFlow.length) {
|
||||
const stBadge = {
|
||||
approved: '<span class="badge b-published">已通过</span>',
|
||||
rejected: '<span class="badge b-rejected">已驳回</span>',
|
||||
pending: '<span class="badge b-pending">审批中</span>',
|
||||
waiting: '<span class="badge b-normal">待流转</span>'
|
||||
};
|
||||
const flowHtml = '<div style="margin-bottom:12px;padding:10px 12px;border:1px solid #e4e7f0;border-radius:8px;background:#fafbfd">'
|
||||
+ `<div style="font-weight:600;margin-bottom:6px">审批流程(当前第 ${Math.min((d.approvalLevel || 0) + 1, d.approvalFlow.length)}/${d.approvalFlow.length} 步)</div>`
|
||||
+ d.approvalFlow.map((s, i) => {
|
||||
const namesText = (s.approverNames || []).join('、') || '-';
|
||||
const acted = (s.records || []).map(r => `${esc(r.name || r.account)}${r.result === 'rejected' ? '(驳回:' + esc(r.remark || '') + ')' : ''}`).join('、');
|
||||
const pend = (s.pending || []).join('、');
|
||||
return `<div style="font-size:12px;color:var(--text2);margin:3px 0">${i + 1}. ${esc(s.name || ('第' + (i + 1) + '步'))} · ${s.vote === 'all' ? '会签' : '或签'} ${stBadge[s.status] || ''}<br/><span style="color:var(--text3)">审批人:${esc(namesText)}${acted ? '|已处理:' + acted : ''}${pend ? '|待签:' + esc(pend) : ''}</span></div>`;
|
||||
}).join('')
|
||||
+ '</div>';
|
||||
$('dContent').innerHTML = flowHtml + renderRichText(d.contents || '(无正文)');
|
||||
} else {
|
||||
$('dContent').innerHTML = renderRichText(d.contents || '(无正文)');
|
||||
}
|
||||
const files = d.chaoSend || [];
|
||||
if (files.length) {
|
||||
$('dFiles').style.display = 'block';
|
||||
@@ -1555,56 +1844,59 @@ function auditFilter(tab, btn) {
|
||||
}
|
||||
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>';
|
||||
? '<tr><th style="width:28%">标题</th><th>类型</th><th>发布人</th><th style="width:24%">审批进度</th><th>发布时间</th><th style="width:180px">操作</th></tr>'
|
||||
: '<tr><th style="width:28%">标题</th><th>类型</th><th>发布人</th><th style="width:24%">审批进度</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>`;
|
||||
body.innerHTML = '<tr><td colspan="6" 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,
|
||||
status: auditState.tab, pageIndex: auditState.pageIndex,
|
||||
pageSize: auditState.pageSize, keyword: $('auditKw').value.trim()
|
||||
}
|
||||
});
|
||||
} catch (e) { return handleListError(e, body, cols); }
|
||||
} catch (e) { return handleListError(e, body, 6); }
|
||||
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>`;
|
||||
body.innerHTML = `<tr><td colspan="6"><div class="empty"><div class="icon">${auditState.tab === 'pending' ? '✅' : '🗂'}</div>${auditState.tab === 'pending' ? '暂无待审批任务' : '暂无记录'}</div></td></tr>`;
|
||||
$('auditPager').innerHTML = '';
|
||||
return;
|
||||
}
|
||||
body.innerHTML = list.map(n => {
|
||||
const typeBadge = n.type == 2 ? '<span class="badge b-important">重要</span>' : '<span class="badge b-normal">普通</span>';
|
||||
const progress = esc(n.approvalStatusDesc || n.statusDesc || '-');
|
||||
const time = esc((n.updateTime || n.createTime || '').replace('T', ' '));
|
||||
if (auditState.tab === 'pending') {
|
||||
const acts = [`<button class="link-btn" onclick="auditDetail(${n.id})">查看</button>`];
|
||||
if (n.canApprove) {
|
||||
acts.push('<button class="link-btn success" onclick="approveAction(' + n.id + ',true)">通过</button>');
|
||||
acts.push('<button class="link-btn danger" onclick="approveAction(' + n.id + ',false)">驳回</button>');
|
||||
} else {
|
||||
acts.push('<span style="color:var(--text3)">等待他人审批</span>');
|
||||
}
|
||||
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 class="td-title" onclick="auditDetail(${n.id})" title="${escAttr(n.title)}">${esc(n.title)}${n.timingSendTime ? ' <span class="badge b-timing">定时</span>' : ''}</td>
|
||||
<td>${typeBadge}</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>
|
||||
<td style="color:var(--text2)">${progress}</td>
|
||||
<td style="color:var(--text2);white-space:nowrap">${time}</td>
|
||||
<td class="td-actions">${acts.join('')}</td>
|
||||
</tr>`;
|
||||
}
|
||||
const actionBadge = n.handledAction === 2
|
||||
? '<span class="badge b-published">通过</span>'
|
||||
: '<span class="badge b-rejected">驳回</span>';
|
||||
const statusBadge = n.status === 1
|
||||
? '<span class="badge b-published">已发布</span>'
|
||||
: (n.status === 3 ? '<span class="badge b-rejected">已驳回</span>' : esc(n.statusDesc || '-'));
|
||||
return `<tr>
|
||||
<td class="td-title" onclick="auditDetail(${n.id})" title="${escAttr(n.title)}">${esc(n.title)}</td>
|
||||
<td>${typeBadge}</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>
|
||||
<td style="color:var(--text2)">${progress}</td>
|
||||
<td>${statusBadge}</td>
|
||||
<td style="color:var(--text2);white-space:nowrap">${time}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
renderPager('auditPager', r, auditState.pageIndex, p => { auditState.pageIndex = p; loadApprovals(); });
|
||||
|
||||
Reference in New Issue
Block a user