feat(admin): upgrade message management and broadcast interactions
Add message details, per-row actions, selection-aware bulk controls and confirmation dialogs. Merge filters into the list card and link overview statistics to the matching views with site-timezone today filtering. Improve the compose dialog with Unicode character counters, recipient mode segments, role counts and explicit broadcast confirmation verified against the resolved role and recipient count on submission. Split rendering into focused sibling traits and cover rendering, handlers and UI interactions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6195508481
commit
fe3ef82718
9 files changed
+1242
-422
No files matched your search
@@ -1,79 +1,20 @@
|
||||
/* 消息管理(users.php?page=aksrt_messages)——仅本页入队。
|
||||
* 页面外壳 / 概览 / 表单栅格 / 筛选条 / 空状态均走 native-ui.css 的共享类,
|
||||
* 本文件只保留本页专属样式:发送弹窗、收件人模式切换、类型徽章、读取状态、用户单元格、列宽。 */
|
||||
|
||||
/* 消息管理原生页。共享卡片 / 表单 / 筛选沿用 native-ui.css 的 --an-* 令牌。 */
|
||||
.aksrt-messages-admin [hidden] { display: none; }
|
||||
.aksrt-messages-admin__muted { color: var(--an-muted); }
|
||||
.aksrt-messages-admin__sub { margin-top: 3px; color: var(--an-muted); font-size: 12px; }
|
||||
.aksrt-messages-admin__sub code { padding: 0 4px; font-size: 12px; background: var(--an-bg-soft); }
|
||||
|
||||
/* ---- 页头按钮:图标与文字居中对齐(<a> 与 <button> 一致) ---- */
|
||||
.aksrt-messages-admin .aksrt-native-header__actions .button { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.aksrt-messages-admin .aksrt-native-header__actions .button .dashicons { margin: 0; line-height: 1; vertical-align: middle; }
|
||||
.aksrt-messages-admin .aksrt-native-stat:focus-visible { outline: 2px solid var(--an-primary); outline-offset: 2px; }
|
||||
|
||||
/* ---- 发送系统消息弹窗 ---- */
|
||||
.aksrt-msg-dialog {
|
||||
width: min(640px, calc(100vw - 32px));
|
||||
max-height: calc(100vh - 64px);
|
||||
padding: 0;
|
||||
color: var(--an-text-2);
|
||||
background: var(--an-card);
|
||||
border: 0;
|
||||
border-radius: var(--an-radius);
|
||||
box-shadow: 0 12px 48px rgba(15, 23, 42, .18);
|
||||
}
|
||||
.aksrt-msg-dialog::backdrop { background: rgba(15, 23, 42, .45); }
|
||||
.aksrt-msg-dialog__form { display: flex; flex-direction: column; max-height: inherit; margin: 0; }
|
||||
.aksrt-msg-dialog__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--an-border-soft);
|
||||
}
|
||||
.aksrt-msg-dialog .aksrt-msg-dialog__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
color: var(--an-text);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.aksrt-msg-dialog__title .dashicons { color: var(--an-primary); }
|
||||
.aksrt-msg-dialog__close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
color: var(--an-muted);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: var(--an-radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
.aksrt-msg-dialog__close:hover,
|
||||
.aksrt-msg-dialog__close:focus-visible { color: var(--an-text); background: var(--an-bg-soft); }
|
||||
.aksrt-msg-dialog__body { padding: 18px 20px; overflow-y: auto; }
|
||||
.aksrt-msg-dialog__note { margin: 14px 0 0; font-size: 12px; }
|
||||
.aksrt-msg-dialog__foot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 14px 20px;
|
||||
border-top: 1px solid var(--an-border-soft);
|
||||
}
|
||||
|
||||
/* ---- 代发:收件人模式切换(纯 CSS,按所选模式显隐字段) ---- */
|
||||
.aksrt-messages-admin__modes { display: flex; gap: 16px; margin: 0 0 12px; padding: 0; border: 0; }
|
||||
.aksrt-messages-admin__modes legend { float: left; margin-right: 8px; font-weight: 600; }
|
||||
.aksrt-messages-admin__compose:has(input[name="recipient_mode"][value="users"]:checked) .is-role,
|
||||
.aksrt-messages-admin__compose:has(input[name="recipient_mode"][value="role"]:checked) .is-users { display: none; }
|
||||
|
||||
/* ---- 列表 ---- */
|
||||
/* ---- 列表内的筛选与批量工具栏 ---- */
|
||||
.aksrt-messages-admin__toolbar { padding: 14px 20px; border-bottom: 1px solid var(--an-border-soft); }
|
||||
.aksrt-messages-admin__toolbar .subsubsub { margin: 0 0 12px; }
|
||||
.aksrt-messages-admin__toolbar .aksrt-native-filters { margin: 0; }
|
||||
.aksrt-messages-admin__bulk { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 12px 20px; }
|
||||
.aksrt-messages-admin__selection { margin-right: auto; color: var(--an-muted); font-size: 12px; font-variant-numeric: tabular-nums; }
|
||||
.aksrt-messages-admin .button:disabled,
|
||||
.aksrt-messages-admin .button:disabled:hover { color: var(--an-muted) !important; background: var(--an-bg-soft) !important; border-color: var(--an-border-soft) !important; cursor: not-allowed; }
|
||||
.aksrt-messages-admin__danger.button { color: var(--an-danger); border-color: var(--an-danger); }
|
||||
.aksrt-messages-admin__danger.button:hover,
|
||||
.aksrt-messages-admin__danger.button:focus { color: #fff; background: var(--an-danger); border-color: var(--an-danger); }
|
||||
@@ -84,12 +25,16 @@
|
||||
.aksrt-messages-admin__table .column-read { width: 8%; }
|
||||
.aksrt-messages-admin__table .column-time { width: 14%; }
|
||||
.aksrt-messages-admin__table td { vertical-align: middle; }
|
||||
.aksrt-messages-admin__table .row-title { word-break: break-word; }
|
||||
.aksrt-messages-admin__table .row-title { display: inline; padding: 0; font-weight: 600; text-align: left; word-break: break-word; line-height: 1.5; }
|
||||
.aksrt-messages-admin__table tr.is-unread .row-title { font-weight: 700; }
|
||||
.aksrt-messages-admin__table tr.is-unread > th:first-child { box-shadow: inset 3px 0 0 var(--an-primary); }
|
||||
.aksrt-messages-admin__table tr.is-selected > td,
|
||||
.aksrt-messages-admin__table tr.is-selected > th { background: var(--an-primary-soft); }
|
||||
.aksrt-messages-admin__desc { margin-top: 3px; color: var(--an-text-2); word-break: break-word; }
|
||||
.aksrt-msg-row-actions { display: flex; flex-wrap: wrap; gap: 6px 12px; margin-top: 8px; }
|
||||
.aksrt-msg-row-actions button { padding: 0; font-size: 12px; text-decoration: none; }
|
||||
|
||||
/* 类型徽章(可点击筛选,按类型着色) */
|
||||
/* 类型徽章(可点击筛选) */
|
||||
.aksrt-msg-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -109,19 +54,80 @@
|
||||
.aksrt-msg-badge.is-success { --aksrt-msg-tone: #00a32a; }
|
||||
.aksrt-msg-badge.is-danger { --aksrt-msg-tone: var(--an-danger); }
|
||||
.aksrt-msg-badge.is-social { --aksrt-msg-tone: #8c5bd0; }
|
||||
|
||||
/* 读取状态(带阅读时间 title 提示) */
|
||||
.aksrt-msg-state { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; white-space: nowrap; }
|
||||
.aksrt-msg-state::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
|
||||
.aksrt-msg-state.is-unread { color: var(--an-primary); font-weight: 600; }
|
||||
.aksrt-msg-state.is-read { color: var(--an-muted); }
|
||||
|
||||
/* 用户单元格(点击按该用户筛选) */
|
||||
.aksrt-msg-user { display: inline-flex; align-items: center; gap: 6px; max-width: 100%; min-width: 0; text-decoration: none; }
|
||||
.aksrt-msg-user img { flex: none; border-radius: 50%; }
|
||||
.aksrt-msg-user span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* ---- 发送 / 详情 / 二次确认共用弹窗 ---- */
|
||||
html.aksrt-msg-modal-open { overflow: hidden; }
|
||||
.aksrt-msg-dialog {
|
||||
width: min(640px, calc(100vw - 32px));
|
||||
max-height: calc(100vh - 64px);
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
color: var(--an-text-2);
|
||||
background: var(--an-card);
|
||||
border: 0;
|
||||
border-radius: var(--an-radius);
|
||||
box-shadow: 0 12px 48px rgba(15, 23, 42, .18);
|
||||
}
|
||||
.aksrt-msg-dialog::backdrop { background: rgba(15, 23, 42, .45); }
|
||||
.aksrt-msg-dialog--confirm { width: min(440px, calc(100vw - 32px)); }
|
||||
.aksrt-msg-dialog__frame { display: flex; flex-direction: column; max-height: inherit; margin: 0; }
|
||||
.aksrt-msg-dialog__head {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--an-border-soft);
|
||||
}
|
||||
.aksrt-msg-dialog .aksrt-msg-dialog__title { display: flex; align-items: center; gap: 8px; margin: 0; color: var(--an-text); font-size: 16px; font-weight: 600; line-height: 1.4; }
|
||||
.aksrt-msg-dialog__title .dashicons { flex-shrink: 0; color: var(--an-primary); }
|
||||
.aksrt-msg-dialog__close { display: inline-flex; flex-shrink: 0; align-items: center; justify-content: center; width: 32px; height: 32px; padding: 0; color: var(--an-muted); background: transparent; border: 0; border-radius: var(--an-radius-sm); cursor: pointer; }
|
||||
.aksrt-msg-dialog__close:hover,
|
||||
.aksrt-msg-dialog__close:focus-visible { color: var(--an-text); background: var(--an-bg-soft); }
|
||||
.aksrt-msg-dialog__body { min-height: 0; padding: 18px 20px; overflow-y: auto; overscroll-behavior: contain; }
|
||||
.aksrt-msg-dialog__note { margin: 14px 0 0; font-size: 12px; }
|
||||
.aksrt-msg-dialog__foot { display: flex; flex-shrink: 0; justify-content: flex-end; gap: 8px; padding: 14px 20px; border-top: 1px solid var(--an-border-soft); }
|
||||
.aksrt-msg-dialog .aksrt-native-form-grid { grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr)); }
|
||||
.aksrt-msg-dialog--confirm .aksrt-msg-dialog__body p { margin: 0; line-height: 1.8; }
|
||||
|
||||
/* 分段单选:保留键盘语义,隐藏 WP 默认勾选标记。 */
|
||||
.aksrt-msg-modes { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin: 0 0 16px; padding: 0; border: 0; }
|
||||
.aksrt-msg-modes legend { margin-bottom: 8px; padding: 0; }
|
||||
.aksrt-msg-mode { position: relative; cursor: pointer; }
|
||||
.aksrt-msg-mode input[type="radio"] { position: absolute; width: 1px; height: 1px; min-width: 0; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); border: 0; opacity: 0; appearance: none; }
|
||||
.aksrt-msg-mode > span { display: flex; align-items: center; justify-content: center; gap: 6px; padding: 10px; color: var(--an-text-2); border: 1px solid var(--an-border); border-radius: var(--an-radius-sm); }
|
||||
.aksrt-msg-mode input:checked + span { color: var(--an-primary); background: var(--an-primary-soft); border-color: var(--an-primary); font-weight: 600; }
|
||||
.aksrt-msg-mode:hover > span { border-color: var(--an-primary); }
|
||||
.aksrt-msg-mode input:focus-visible + span { outline: 2px solid var(--an-primary); outline-offset: 2px; }
|
||||
.aksrt-msg-counter { align-self: flex-end; color: var(--an-muted); font-size: 12px; font-variant-numeric: tabular-nums; }
|
||||
.aksrt-msg-counter.is-over { color: var(--an-danger); font-weight: 600; }
|
||||
|
||||
/* 详情原样展示纯文本,保留换行,不插入消息中的 HTML。 */
|
||||
.aksrt-msg-detail__title { margin: 0 0 16px; color: var(--an-text); font-size: 17px; line-height: 1.5; overflow-wrap: anywhere; }
|
||||
.aksrt-msg-detail__meta { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin: 0; padding: 14px; background: var(--an-bg-soft); border-radius: var(--an-radius-sm); }
|
||||
.aksrt-msg-detail__meta dt { color: var(--an-muted); font-size: 12px; }
|
||||
.aksrt-msg-detail__meta dd { margin: 4px 0 0; overflow-wrap: anywhere; }
|
||||
.aksrt-msg-detail__content { line-height: 1.8; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.aksrt-msg-detail__link { display: block; margin-top: 16px; overflow-wrap: anywhere; }
|
||||
|
||||
@media (max-width: 782px) {
|
||||
.aksrt-messages-admin__toolbar,
|
||||
.aksrt-messages-admin__bulk { padding: 12px 14px; }
|
||||
.aksrt-messages-admin__table .column-type,
|
||||
.aksrt-messages-admin__table .column-read { display: none; }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.aksrt-msg-dialog { max-height: calc(100dvh - 32px); }
|
||||
.aksrt-msg-dialog__head,
|
||||
.aksrt-msg-dialog__body,
|
||||
.aksrt-msg-dialog__foot { padding-left: 14px; padding-right: 14px; }
|
||||
.aksrt-msg-detail__meta { grid-template-columns: 1fr; }
|
||||
}
|
||||
+219
-32
@@ -1,40 +1,227 @@
|
||||
/**
|
||||
* 消息管理后台页(users.php?page=aksrt_messages)的「发送系统消息」弹窗。
|
||||
* - 页头按钮 [data-aksrt-dialog-open] 打开对应 <dialog>;
|
||||
* - 关闭按钮 / 点击遮罩关闭;发送失败回跳时(data-aksrt-dialog-autoopen)自动打开;
|
||||
* - 提交后禁用发送按钮防重复提交。
|
||||
* 标记由 src/Admin/Native/UsersMenusMessages.php 与 NativeUi::button_dialog() 输出。
|
||||
*/
|
||||
/** 消息管理:详情、选择与批量操作、发送计数及角色群发确认。仅本页入队。 */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
document.querySelectorAll('[data-aksrt-dialog-open]').forEach(function (button) {
|
||||
var dialog = document.getElementById(button.getAttribute('data-aksrt-dialog-open'));
|
||||
if (!dialog || typeof dialog.showModal !== 'function') {
|
||||
button.hidden = true;
|
||||
var root = document.querySelector('.aksrt-messages-admin');
|
||||
if (!root) return;
|
||||
var compose = document.getElementById('aksrt-msg-compose');
|
||||
var detail = document.getElementById('aksrt-msg-detail');
|
||||
var confirmDialog = document.getElementById('aksrt-msg-confirm');
|
||||
if (!compose || !detail || !confirmDialog || typeof compose.showModal !== 'function') return;
|
||||
|
||||
function text(element, key, values) {
|
||||
var value = element.getAttribute('data-i18n-' + key) || '';
|
||||
return value.replace(/\{(\w+)\}/g, function (match, name) {
|
||||
return values && values[name] !== undefined ? String(values[name]) : match;
|
||||
});
|
||||
}
|
||||
|
||||
function syncScroll() {
|
||||
document.documentElement.classList.toggle('aksrt-msg-modal-open', !!root.querySelector('dialog[open]'));
|
||||
}
|
||||
|
||||
function open(dialog) {
|
||||
if (!dialog.open) dialog.showModal();
|
||||
syncScroll();
|
||||
}
|
||||
|
||||
root.querySelectorAll('.aksrt-msg-dialog').forEach(function (dialog) {
|
||||
var outsideStart = false;
|
||||
function outside(event) {
|
||||
var rect = dialog.getBoundingClientRect();
|
||||
return event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom;
|
||||
}
|
||||
dialog.querySelectorAll('[data-aksrt-dialog-close]').forEach(function (button) {
|
||||
button.addEventListener('click', function () { dialog.close(); });
|
||||
});
|
||||
dialog.addEventListener('pointerdown', function (event) {
|
||||
outsideStart = event.target === dialog && outside(event);
|
||||
});
|
||||
dialog.addEventListener('click', function (event) {
|
||||
if (outsideStart && event.target === dialog && outside(event)) dialog.close();
|
||||
outsideStart = false;
|
||||
});
|
||||
dialog.addEventListener('close', syncScroll);
|
||||
});
|
||||
|
||||
var pending = null;
|
||||
var confirmButton = confirmDialog.querySelector('[data-aksrt-msg-confirm]');
|
||||
function ask(title, message, label, danger, callback) {
|
||||
pending = callback;
|
||||
confirmDialog.querySelector('[data-aksrt-dialog-title]').textContent = title;
|
||||
confirmDialog.querySelector('#aksrt-msg-confirm-text').textContent = message;
|
||||
confirmButton.textContent = label;
|
||||
confirmButton.classList.toggle('aksrt-messages-admin__danger', danger);
|
||||
confirmButton.classList.toggle('button-primary', !danger);
|
||||
open(confirmDialog);
|
||||
confirmDialog.querySelector('[data-aksrt-dialog-close][autofocus]').focus();
|
||||
}
|
||||
confirmDialog.addEventListener('cancel', function () { pending = null; });
|
||||
confirmDialog.addEventListener('close', function () { pending = null; });
|
||||
confirmButton.addEventListener('click', function () {
|
||||
var callback = pending;
|
||||
pending = null;
|
||||
confirmDialog.close();
|
||||
if (callback) callback();
|
||||
});
|
||||
|
||||
root.querySelectorAll('[data-aksrt-msg-view]').forEach(function (button) {
|
||||
button.addEventListener('click', function () {
|
||||
var template = document.getElementById('aksrt-msg-detail-' + button.getAttribute('data-aksrt-msg-view'));
|
||||
if (!template) return;
|
||||
detail.querySelector('[data-aksrt-msg-detail-body]').replaceChildren(template.content.cloneNode(true));
|
||||
open(detail);
|
||||
});
|
||||
});
|
||||
|
||||
var list = root.querySelector('[data-aksrt-msg-list]');
|
||||
var listSubmitting = false;
|
||||
var syncSelection;
|
||||
if (list) {
|
||||
var checks = Array.from(list.querySelectorAll('[data-aksrt-msg-check]'));
|
||||
var checkAll = list.querySelector('[data-aksrt-msg-check-all]');
|
||||
var selection = list.querySelector('[data-aksrt-msg-selection]');
|
||||
var bulkButtons = list.querySelectorAll('[data-aksrt-msg-action]:not([data-id])');
|
||||
|
||||
function selected() { return checks.filter(function (check) { return check.checked; }); }
|
||||
syncSelection = function () {
|
||||
var count = selected().length;
|
||||
selection.textContent = selection.getAttribute('data-label').replace('%d', String(count));
|
||||
checkAll.checked = count > 0 && count === checks.length;
|
||||
checkAll.indeterminate = count > 0 && count < checks.length;
|
||||
checkAll.disabled = listSubmitting || checks.length === 0;
|
||||
bulkButtons.forEach(function (button) { button.disabled = listSubmitting || count === 0; });
|
||||
checks.forEach(function (check) { check.closest('tr').classList.toggle('is-selected', check.checked); });
|
||||
};
|
||||
checkAll.addEventListener('change', function () {
|
||||
checks.forEach(function (check) { check.checked = checkAll.checked; });
|
||||
syncSelection();
|
||||
});
|
||||
checks.forEach(function (check) { check.addEventListener('change', syncSelection); });
|
||||
function mutate(op, id) {
|
||||
if (listSubmitting) return;
|
||||
list.elements.bulk_action.value = op;
|
||||
list.elements.message_id.value = id || '';
|
||||
list.requestSubmit();
|
||||
}
|
||||
list.querySelectorAll('[data-aksrt-msg-action]').forEach(function (button) {
|
||||
button.addEventListener('click', function () {
|
||||
if (listSubmitting) return;
|
||||
var op = button.getAttribute('data-aksrt-msg-action');
|
||||
var id = button.getAttribute('data-id');
|
||||
var count = id ? 1 : selected().length;
|
||||
if (!count) return;
|
||||
if (op === 'delete') {
|
||||
ask(text(confirmDialog, 'delete-title'), text(confirmDialog, 'delete-text', { count: count }), text(confirmDialog, 'delete-send'), true, function () { mutate(op, id); });
|
||||
} else {
|
||||
mutate(op, id);
|
||||
}
|
||||
});
|
||||
});
|
||||
list.addEventListener('submit', function (event) {
|
||||
if (listSubmitting || !list.elements.bulk_action.value || (!list.elements.message_id.value && !selected().length)) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
listSubmitting = true;
|
||||
// 只锁操作按钮,不禁用复选框,否则 ids[] 不会随表单提交。
|
||||
list.querySelectorAll('button').forEach(function (button) { button.disabled = true; });
|
||||
syncSelection();
|
||||
});
|
||||
syncSelection();
|
||||
}
|
||||
|
||||
var form = compose.querySelector('[data-aksrt-msg-compose]');
|
||||
var send = form.querySelector('[data-aksrt-msg-send]');
|
||||
var role = form.elements.role;
|
||||
var counters = form.querySelectorAll('[data-count-limit]');
|
||||
var sending = false;
|
||||
var approved = '';
|
||||
|
||||
function clearApproval() {
|
||||
approved = '';
|
||||
form.elements.confirmed_role.value = '';
|
||||
form.elements.confirmed_count.value = '';
|
||||
}
|
||||
function syncCounters() {
|
||||
counters.forEach(function (field) {
|
||||
// 与 PHP mb_strlen 一致按 Unicode 码点计数,emoji 不占两个字。
|
||||
var count = Array.from(field.value).length;
|
||||
var limit = Number(field.getAttribute('data-count-limit'));
|
||||
var counter = form.querySelector('[data-count-for="' + field.name + '"]');
|
||||
counter.textContent = count + ' / ' + limit;
|
||||
counter.classList.toggle('is-over', count > limit);
|
||||
field.setCustomValidity(count > limit ? text(form, 'count-error', { count: limit }) : '');
|
||||
});
|
||||
}
|
||||
function syncRole() {
|
||||
var option = role.options[role.selectedIndex];
|
||||
var count = option ? Number(option.getAttribute('data-count')) : 0;
|
||||
form.querySelector('[data-aksrt-msg-role-hint]').textContent = count > 0 && !option.disabled ? text(form, 'role-hint', { count: count }) : '';
|
||||
}
|
||||
function mode() { return form.querySelector('input[name="recipient_mode"]:checked').value; }
|
||||
function syncMode() {
|
||||
clearApproval();
|
||||
form.querySelectorAll('[data-aksrt-msg-pane]').forEach(function (pane) {
|
||||
var active = pane.getAttribute('data-aksrt-msg-pane') === mode();
|
||||
pane.hidden = !active;
|
||||
pane.querySelectorAll('input, select, textarea').forEach(function (field) { field.disabled = !active; });
|
||||
});
|
||||
syncRole();
|
||||
}
|
||||
form.querySelectorAll('input[name="recipient_mode"]').forEach(function (radio) { radio.addEventListener('change', syncMode); });
|
||||
role.addEventListener('change', function () { clearApproval(); syncRole(); });
|
||||
form.addEventListener('input', function () { clearApproval(); syncCounters(); });
|
||||
|
||||
function snapshot() {
|
||||
return JSON.stringify([role.value, role.options[role.selectedIndex].getAttribute('data-count'), form.elements.title.value, form.elements.content.value, form.elements.target_url.value]);
|
||||
}
|
||||
form.addEventListener('submit', function (event) {
|
||||
syncCounters();
|
||||
if (sending || !form.reportValidity()) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
var form = dialog.querySelector('form');
|
||||
var submit = dialog.querySelector('[data-aksrt-dialog-submit]');
|
||||
|
||||
function open() {
|
||||
if (submit) submit.disabled = false;
|
||||
dialog.showModal();
|
||||
var first = dialog.querySelector('textarea:not([disabled]), input:not([type="hidden"]):not([type="radio"])');
|
||||
if (first) first.focus();
|
||||
if (mode() === 'role' && approved !== snapshot()) {
|
||||
event.preventDefault();
|
||||
var option = role.options[role.selectedIndex];
|
||||
if (!option || option.disabled || !role.value) return;
|
||||
var count = Number(option.getAttribute('data-count'));
|
||||
var current = snapshot();
|
||||
ask(text(form, 'role-title'), text(form, 'role-confirm', { role: option.getAttribute('data-label'), count: count }), text(form, 'role-send'), false, function () {
|
||||
if (mode() !== 'role' || current !== snapshot()) return;
|
||||
approved = current;
|
||||
form.elements.confirmed_role.value = role.value;
|
||||
form.elements.confirmed_count.value = String(count);
|
||||
form.requestSubmit(send);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
button.addEventListener('click', open);
|
||||
dialog.querySelectorAll('[data-aksrt-dialog-close]').forEach(function (close) {
|
||||
close.addEventListener('click', function () { dialog.close(); });
|
||||
});
|
||||
// 点击遮罩(dialog 自身而非内容区)关闭。
|
||||
dialog.addEventListener('click', function (event) {
|
||||
if (event.target === dialog) dialog.close();
|
||||
});
|
||||
if (form && submit) {
|
||||
form.addEventListener('submit', function () { submit.disabled = true; });
|
||||
}
|
||||
if (dialog.hasAttribute('data-aksrt-dialog-autoopen')) open();
|
||||
sending = true;
|
||||
send.disabled = true;
|
||||
});
|
||||
|
||||
root.querySelectorAll('[data-aksrt-dialog-open]').forEach(function (button) {
|
||||
button.addEventListener('click', function () {
|
||||
open(compose);
|
||||
var field = mode() === 'role' ? role : form.elements.users;
|
||||
field.focus();
|
||||
});
|
||||
});
|
||||
window.addEventListener('pageshow', function () {
|
||||
sending = false;
|
||||
send.disabled = false;
|
||||
clearApproval();
|
||||
if (list) {
|
||||
listSubmitting = false;
|
||||
list.elements.bulk_action.value = '';
|
||||
list.elements.message_id.value = '';
|
||||
list.querySelectorAll('button[data-id], button[data-aksrt-msg-view]').forEach(function (button) { button.disabled = false; });
|
||||
syncSelection();
|
||||
}
|
||||
syncCounters();
|
||||
});
|
||||
syncMode();
|
||||
syncCounters();
|
||||
if (compose.hasAttribute('data-aksrt-dialog-autoopen')) open(compose);
|
||||
})();
|
||||
@@ -1,10 +1,7 @@
|
||||
<?php
|
||||
/**
|
||||
* 消息管理后台页(users.php → aksrt_messages)。
|
||||
*
|
||||
* 单页承载:概览卡片 + 代发系统消息(页头按钮打开弹窗,按用户列表或角色投递)+ 已读状态视图
|
||||
* + 类型 / 收件人 / 关键词筛选 + 全站通知列表(批量标记已读 / 删除)+ 共享分页条。
|
||||
* 查询与写操作在 AKSRT\Messages\NotificationAdminQuery / AdminBroadcast,这里只做页面与路由。
|
||||
* 消息管理后台页:页面编排、带权限 / nonce 的写操作、筛选与反馈。
|
||||
* 展示由同目录的 MessagesList / MessagesCompose traits 承载;查询与投递下沉消息服务。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
@@ -12,17 +9,18 @@
|
||||
namespace AKSRT\Admin\Native;
|
||||
|
||||
use AKSRT\Messages\AdminBroadcast;
|
||||
use AKSRT\Messages\MessageTypes;
|
||||
use AKSRT\Messages\NotificationAdminQuery;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
trait UsersMenusMessages {
|
||||
|
||||
use UsersMenusMessagesList;
|
||||
use UsersMenusMessagesCompose;
|
||||
|
||||
private const MESSAGE_PAGE_SLUG = 'aksrt_messages';
|
||||
private const MESSAGE_COMPOSE_ID = 'aksrt-msg-compose';
|
||||
|
||||
/** 页面专属样式与弹窗脚本,只在本页入队。 */
|
||||
public function enqueue_message_assets(string $hook): void {
|
||||
if ($hook !== 'users_page_' . self::MESSAGE_PAGE_SLUG) {
|
||||
return;
|
||||
@@ -71,18 +69,14 @@ trait UsersMenusMessages {
|
||||
|
||||
$query = $this->message_query_filters($filters);
|
||||
$result = NotificationAdminQuery::paginate($query, $paged);
|
||||
|
||||
$this->render_message_overview();
|
||||
$this->render_message_compose();
|
||||
|
||||
NativeUi::card_open('', '', false, 'aksrt-native-toolbar');
|
||||
NativeUi::card_open(__('站内通知', 'aksrt'), '', true, 'aksrt-messages-admin__card');
|
||||
echo '<div class="aksrt-messages-admin__toolbar">';
|
||||
echo $this->message_views_html($filters, NotificationAdminQuery::status_counts($query)); // phpcs:ignore WordPress.Security.EscapeOutput -- builder escapes.
|
||||
$this->render_message_filter_form($filters);
|
||||
NativeUi::card_close();
|
||||
|
||||
NativeUi::table_card_open(__('站内通知', 'aksrt'));
|
||||
echo '</div>';
|
||||
$this->render_message_table($result['items'], $filters, $result['page'], $query['user_id'] < 0);
|
||||
NativeUi::table_wrap_close();
|
||||
AdminListPagination::render(
|
||||
self::MESSAGE_PAGE_SLUG,
|
||||
$result['total'],
|
||||
@@ -92,13 +86,12 @@ trait UsersMenusMessages {
|
||||
'users.php'
|
||||
);
|
||||
NativeUi::card_close();
|
||||
|
||||
$this->render_message_compose();
|
||||
$this->render_message_dialogs();
|
||||
NativeUi::page_close();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* 写操作
|
||||
* ----------------------------------------------------------------*/
|
||||
|
||||
public function handle_send_message(): void {
|
||||
$this->ensure_cap();
|
||||
check_admin_referer('aksrt_users_send_message');
|
||||
@@ -108,17 +101,24 @@ trait UsersMenusMessages {
|
||||
$content = trim(sanitize_textarea_field((string) wp_unslash($_POST['content'] ?? '')));
|
||||
$target_url = esc_url_raw(trim((string) wp_unslash($_POST['target_url'] ?? '')));
|
||||
|
||||
if (!in_array($mode, ['users', 'role'], true)) {
|
||||
$this->message_fail(__('请选择有效的收件人模式。', 'aksrt'));
|
||||
}
|
||||
if ($content === '') {
|
||||
$this->message_fail(__('请填写消息内容。', 'aksrt'));
|
||||
}
|
||||
if (mb_strlen($content) > AdminBroadcast::CONTENT_MAX) {
|
||||
$this->message_fail(sprintf(__('消息内容不能超过 %d 字。', 'aksrt'), AdminBroadcast::CONTENT_MAX));
|
||||
}
|
||||
if (mb_strlen($title) > AdminBroadcast::TITLE_MAX) {
|
||||
$this->message_fail(sprintf(__('消息标题不能超过 %d 字。', 'aksrt'), AdminBroadcast::TITLE_MAX));
|
||||
}
|
||||
$title = mb_substr($title !== '' ? $title : $content, 0, AdminBroadcast::TITLE_MAX);
|
||||
|
||||
$unknown = 0;
|
||||
if ($mode === 'role') {
|
||||
$ids = AdminBroadcast::resolve_role(sanitize_key((string) wp_unslash($_POST['role'] ?? '')));
|
||||
$role = sanitize_key((string) wp_unslash($_POST['role'] ?? ''));
|
||||
$ids = AdminBroadcast::resolve_role($role);
|
||||
} else {
|
||||
$resolved = AdminBroadcast::resolve_users(sanitize_textarea_field((string) wp_unslash($_POST['users'] ?? '')));
|
||||
$ids = is_wp_error($resolved) ? $resolved : $resolved['ids'];
|
||||
@@ -130,6 +130,13 @@ trait UsersMenusMessages {
|
||||
if ($ids === []) {
|
||||
$this->message_fail(__('没有匹配到任何有效收件人。', 'aksrt'));
|
||||
}
|
||||
// 确认绑定角色与人数;页面加载后人数变化时拒绝投递,避免按过期预览群发。
|
||||
if ($mode === 'role' && (
|
||||
sanitize_key((string) wp_unslash($_POST['confirmed_role'] ?? '')) !== $role
|
||||
|| absint($_POST['confirmed_count'] ?? 0) !== count($ids)
|
||||
)) {
|
||||
$this->message_fail(__('收件人范围尚未确认或人数已变化,请重新确认后发送。', 'aksrt'));
|
||||
}
|
||||
|
||||
$sent = AdminBroadcast::send($ids, $title, $content, $target_url);
|
||||
$this->message_redirect([
|
||||
@@ -145,7 +152,8 @@ trait UsersMenusMessages {
|
||||
check_admin_referer('aksrt_users_bulk_messages');
|
||||
|
||||
$op = sanitize_key((string) wp_unslash($_POST['bulk_action'] ?? ''));
|
||||
$ids = isset($_POST['ids']) && is_array($_POST['ids']) ? array_map('absint', wp_unslash($_POST['ids'])) : [];
|
||||
$single_id = absint($_POST['message_id'] ?? 0);
|
||||
$ids = $single_id > 0 ? [$single_id] : (isset($_POST['ids']) && is_array($_POST['ids']) ? array_map('absint', wp_unslash($_POST['ids'])) : []);
|
||||
$back = $this->message_filter_args($this->message_filters($_POST));
|
||||
$paged = absint($_POST['paged'] ?? 0);
|
||||
if ($paged > 1) {
|
||||
@@ -162,15 +170,8 @@ trait UsersMenusMessages {
|
||||
]));
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* 区块渲染
|
||||
* ----------------------------------------------------------------*/
|
||||
|
||||
private function render_message_notices(): void {
|
||||
$code = sanitize_key((string) wp_unslash($_GET['aksrt_msg'] ?? ''));
|
||||
if ($code === '') {
|
||||
return;
|
||||
}
|
||||
$n = absint($_GET['aksrt_msg_n'] ?? 0);
|
||||
$error = false;
|
||||
switch ($code) {
|
||||
@@ -204,297 +205,24 @@ trait UsersMenusMessages {
|
||||
default:
|
||||
return;
|
||||
}
|
||||
printf(
|
||||
'<div class="notice %s is-dismissible"><p>%s</p></div>',
|
||||
$error ? 'notice-error' : 'notice-success',
|
||||
esc_html($text)
|
||||
);
|
||||
printf('<div class="notice %s is-dismissible"><p>%s</p></div>', $error ? 'notice-error' : 'notice-success', esc_html($text));
|
||||
}
|
||||
|
||||
private function render_message_overview(): void {
|
||||
$stats = NotificationAdminQuery::overview();
|
||||
NativeUi::stats([
|
||||
['label' => __('消息总数', 'aksrt'), 'value' => number_format_i18n($stats['total']), 'icon' => 'dashicons-email-alt', 'tone' => 'info'],
|
||||
['label' => __('未读', 'aksrt'), 'value' => number_format_i18n($stats['unread']), 'icon' => 'dashicons-marker', 'tone' => 'danger'],
|
||||
['label' => __('今日新增', 'aksrt'), 'value' => number_format_i18n($stats['today']), 'icon' => 'dashicons-calendar-alt', 'tone' => 'success'],
|
||||
['label' => __('涉及用户', 'aksrt'), 'value' => number_format_i18n($stats['recipients']), 'icon' => 'dashicons-groups', 'tone' => 'primary'],
|
||||
]);
|
||||
}
|
||||
|
||||
private function render_message_compose(): void {
|
||||
// 发送失败回跳时自动重新打开弹窗,便于修正后再发。
|
||||
$open = sanitize_key((string) wp_unslash($_GET['aksrt_msg'] ?? '')) === 'send_failed';
|
||||
|
||||
printf(
|
||||
'<dialog class="aksrt-msg-dialog" id="%s" aria-labelledby="%s-title"%s>',
|
||||
esc_attr(self::MESSAGE_COMPOSE_ID),
|
||||
esc_attr(self::MESSAGE_COMPOSE_ID),
|
||||
$open ? ' data-aksrt-dialog-autoopen' : ''
|
||||
);
|
||||
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" class="aksrt-msg-dialog__form aksrt-messages-admin__compose">';
|
||||
echo '<input type="hidden" name="action" value="aksrt_users_send_message" />';
|
||||
wp_nonce_field('aksrt_users_send_message');
|
||||
|
||||
printf(
|
||||
'<header class="aksrt-msg-dialog__head"><h2 id="%s-title" class="aksrt-msg-dialog__title"><span class="dashicons dashicons-megaphone" aria-hidden="true"></span>%s</h2>'
|
||||
. '<button type="button" class="aksrt-msg-dialog__close" data-aksrt-dialog-close aria-label="%s"><span class="dashicons dashicons-no-alt" aria-hidden="true"></span></button></header>',
|
||||
esc_attr(self::MESSAGE_COMPOSE_ID),
|
||||
esc_html__('发送系统消息', 'aksrt'),
|
||||
esc_attr__('关闭', 'aksrt')
|
||||
);
|
||||
echo '<div class="aksrt-msg-dialog__body">';
|
||||
|
||||
echo '<fieldset class="aksrt-messages-admin__modes"><legend>' . esc_html__('收件人', 'aksrt') . '</legend>';
|
||||
printf(
|
||||
'<label><input type="radio" name="recipient_mode" value="users" checked /> %s</label>',
|
||||
esc_html__('指定用户', 'aksrt')
|
||||
);
|
||||
printf(
|
||||
'<label><input type="radio" name="recipient_mode" value="role" /> %s</label>',
|
||||
esc_html__('按角色', 'aksrt')
|
||||
);
|
||||
echo '</fieldset>';
|
||||
|
||||
echo '<div class="aksrt-native-form-grid">';
|
||||
printf(
|
||||
'<label class="aksrt-native-field is-wide is-users"><span class="aksrt-native-field__label">%s</span><textarea name="users" rows="2" class="large-text" placeholder="%s"></textarea><small class="aksrt-native-field__hint">%s</small></label>',
|
||||
esc_html__('用户', 'aksrt'),
|
||||
esc_attr__('用户 ID / 用户名 / 邮箱,逗号或换行分隔', 'aksrt'),
|
||||
esc_html(sprintf(__('单次最多 %d 位。', 'aksrt'), AdminBroadcast::MAX_USERS))
|
||||
);
|
||||
echo '<label class="aksrt-native-field is-wide is-role"><span class="aksrt-native-field__label">' . esc_html__('角色', 'aksrt') . '</span><select name="role">';
|
||||
foreach (wp_roles()->get_names() as $role => $name) {
|
||||
printf('<option value="%s">%s</option>', esc_attr($role), esc_html(translate_user_role($name)));
|
||||
}
|
||||
echo '</select><small class="aksrt-native-field__hint">' . esc_html(sprintf(__('角色用户超过 %d 人时拒绝发送。', 'aksrt'), AdminBroadcast::MAX_ROLE_USERS)) . '</small></label>';
|
||||
printf(
|
||||
'<label class="aksrt-native-field"><span class="aksrt-native-field__label">%s</span><input type="text" name="title" maxlength="%d" class="regular-text" placeholder="%s" /></label>',
|
||||
esc_html__('标题', 'aksrt'),
|
||||
(int) AdminBroadcast::TITLE_MAX,
|
||||
esc_attr__('留空则取内容开头', 'aksrt')
|
||||
);
|
||||
printf(
|
||||
'<label class="aksrt-native-field"><span class="aksrt-native-field__label">%s</span><input type="url" name="target_url" class="regular-text" placeholder="https://" /></label>',
|
||||
esc_html__('跳转链接(可选)', 'aksrt')
|
||||
);
|
||||
printf(
|
||||
'<label class="aksrt-native-field is-wide"><span class="aksrt-native-field__label">%s</span><textarea name="content" rows="4" maxlength="%d" required class="large-text"></textarea></label>',
|
||||
esc_html__('内容', 'aksrt'),
|
||||
(int) AdminBroadcast::CONTENT_MAX
|
||||
);
|
||||
echo '</div>';
|
||||
echo '<p class="aksrt-native-muted aksrt-msg-dialog__note">' . esc_html__('以「系统通知」类型逐人投递;邮件 / 公众号提醒按通知配置同步触发。', 'aksrt') . '</p>';
|
||||
echo '</div>';
|
||||
printf(
|
||||
'<footer class="aksrt-msg-dialog__foot"><button type="button" class="button" data-aksrt-dialog-close>%s</button><button type="submit" class="button button-primary" data-aksrt-dialog-submit>%s</button></footer>',
|
||||
esc_html__('取消', 'aksrt'),
|
||||
esc_html__('发送', 'aksrt')
|
||||
);
|
||||
echo '</form></dialog>';
|
||||
}
|
||||
|
||||
/** @param array{all:int,unread:int,read:int} $counts */
|
||||
private function message_views_html(array $filters, array $counts): string {
|
||||
$views = [
|
||||
'' => [__('全部', 'aksrt'), $counts['all']],
|
||||
'unread' => [__('未读', 'aksrt'), $counts['unread']],
|
||||
'read' => [__('已读', 'aksrt'), $counts['read']],
|
||||
];
|
||||
$args = $this->message_filter_args($filters);
|
||||
unset($args['status']);
|
||||
|
||||
$html = '<ul class="subsubsub">';
|
||||
$last = array_key_last($views);
|
||||
foreach ($views as $key => [$label, $count]) {
|
||||
$html .= sprintf(
|
||||
'<li><a href="%s"%s>%s <span class="count">(%s)</span></a>%s</li>',
|
||||
esc_url($this->message_page_url($key === '' ? $args : array_merge($args, ['status' => $key]))),
|
||||
$filters['status'] === $key ? ' class="current" aria-current="page"' : '',
|
||||
esc_html($label),
|
||||
esc_html(number_format_i18n($count)),
|
||||
$key === $last ? '' : ' |'
|
||||
);
|
||||
}
|
||||
return $html . '</ul>';
|
||||
}
|
||||
|
||||
private function render_message_filter_form(array $filters): void {
|
||||
echo '<form method="get" action="' . esc_url(admin_url('users.php')) . '" class="aksrt-native-filters">';
|
||||
echo '<input type="hidden" name="page" value="' . esc_attr(self::MESSAGE_PAGE_SLUG) . '" />';
|
||||
if ($filters['status'] !== '') {
|
||||
echo '<input type="hidden" name="status" value="' . esc_attr($filters['status']) . '" />';
|
||||
}
|
||||
echo '<label class="screen-reader-text" for="aksrt-msg-filter-type">' . esc_html__('按类型筛选', 'aksrt') . '</label>';
|
||||
echo '<select name="type" id="aksrt-msg-filter-type"><option value="">' . esc_html__('全部类型', 'aksrt') . '</option>';
|
||||
foreach (NotificationAdminQuery::types() as $type) {
|
||||
printf(
|
||||
'<option value="%s"%s>%s</option>',
|
||||
esc_attr($type),
|
||||
selected($filters['type'], $type, false),
|
||||
esc_html(MessageTypes::label($type))
|
||||
);
|
||||
}
|
||||
echo '</select>';
|
||||
printf(
|
||||
'<label class="screen-reader-text" for="aksrt-msg-filter-user">%s</label><input type="text" id="aksrt-msg-filter-user" name="user" value="%s" placeholder="%s" />',
|
||||
esc_html__('按收件人筛选', 'aksrt'),
|
||||
esc_attr($filters['user']),
|
||||
esc_attr__('收件人 ID / 用户名 / 邮箱', 'aksrt')
|
||||
);
|
||||
printf(
|
||||
'<label class="screen-reader-text" for="aksrt-msg-filter-s">%s</label><input type="search" id="aksrt-msg-filter-s" name="s" value="%s" placeholder="%s" />',
|
||||
esc_html__('搜索消息', 'aksrt'),
|
||||
esc_attr($filters['s']),
|
||||
esc_attr__('标题 / 内容', 'aksrt')
|
||||
);
|
||||
submit_button(__('筛选', 'aksrt'), '', '', false);
|
||||
if ($filters['type'] !== '' || $filters['user'] !== '' || $filters['s'] !== '') {
|
||||
printf(
|
||||
'<a class="button-link" href="%s">%s</a>',
|
||||
esc_url($this->message_page_url($filters['status'] === '' ? [] : ['status' => $filters['status']])),
|
||||
esc_html__('清除筛选', 'aksrt')
|
||||
);
|
||||
}
|
||||
echo '</form>';
|
||||
}
|
||||
|
||||
/** @param list<array<string,mixed>> $items */
|
||||
private function render_message_table(array $items, array $filters, int $page, bool $user_missing): void {
|
||||
$user_ids = array_values(array_unique(array_filter(array_map(static fn($row): int => (int) ($row['user_id'] ?? 0), $items))));
|
||||
if ($user_ids !== []) {
|
||||
cache_users($user_ids);
|
||||
}
|
||||
|
||||
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" class="aksrt-messages-admin__list">';
|
||||
echo '<input type="hidden" name="action" value="aksrt_users_bulk_messages" />';
|
||||
wp_nonce_field('aksrt_users_bulk_messages');
|
||||
foreach ($this->message_filter_args($filters) as $key => $value) {
|
||||
printf('<input type="hidden" name="%s" value="%s" />', esc_attr($key), esc_attr((string) $value));
|
||||
}
|
||||
echo '<input type="hidden" name="paged" value="' . esc_attr((string) $page) . '" />';
|
||||
|
||||
echo '<div class="tablenav top"><div class="alignleft actions bulkactions">';
|
||||
printf(
|
||||
'<button type="submit" name="bulk_action" value="mark_read" class="button">%s</button> ',
|
||||
esc_html__('标记已读', 'aksrt')
|
||||
);
|
||||
printf(
|
||||
'<button type="submit" name="bulk_action" value="delete" class="button aksrt-messages-admin__danger" onclick="return confirm(\'%s\');">%s</button>',
|
||||
esc_js(__('确定删除所选消息?删除后用户收件箱中也将不再显示。', 'aksrt')),
|
||||
esc_html__('删除', 'aksrt')
|
||||
);
|
||||
echo '</div><br class="clear"></div>';
|
||||
|
||||
echo '<table class="wp-list-table widefat fixed striped aksrt-messages-admin__table"><thead><tr>';
|
||||
echo '<td class="manage-column column-cb check-column"><label class="screen-reader-text" for="cb-select-all-1">' . esc_html__('全选', 'aksrt') . '</label><input id="cb-select-all-1" type="checkbox" /></td>';
|
||||
echo '<th class="column-title">' . esc_html__('消息', 'aksrt') . '</th>';
|
||||
echo '<th class="column-type">' . esc_html__('类型', 'aksrt') . '</th>';
|
||||
echo '<th class="column-user">' . esc_html__('收件人', 'aksrt') . '</th>';
|
||||
echo '<th class="column-read">' . esc_html__('状态', 'aksrt') . '</th>';
|
||||
echo '<th class="column-time">' . esc_html__('发送时间', 'aksrt') . '</th>';
|
||||
echo '</tr></thead><tbody>';
|
||||
|
||||
if ($items === []) {
|
||||
$hint = $user_missing
|
||||
? __('没有找到该收件人。', 'aksrt')
|
||||
: ($this->message_filter_args($filters) === [] ? __('站内还没有任何通知。', 'aksrt') : __('当前筛选条件下没有消息。', 'aksrt'));
|
||||
NativeUi::empty_row(6, __('暂无消息', 'aksrt'), $hint);
|
||||
}
|
||||
foreach ($items as $row) {
|
||||
$this->render_message_row($row, $filters);
|
||||
}
|
||||
echo '</tbody></table></form>';
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $row */
|
||||
private function render_message_row(array $row, array $filters): void {
|
||||
$id = (int) ($row['id'] ?? 0);
|
||||
$type = (string) ($row['type'] ?? '');
|
||||
$title = (string) ($row['title'] ?? '');
|
||||
$desc = (string) ($row['description'] ?? '');
|
||||
$url = (string) ($row['target_url'] ?? '');
|
||||
$unread = empty($row['is_read']);
|
||||
|
||||
echo '<tr class="' . ($unread ? 'is-unread' : 'is-read') . '">';
|
||||
printf(
|
||||
'<th scope="row" class="check-column"><label class="screen-reader-text" for="aksrt-msg-%1$d">%2$s</label><input id="aksrt-msg-%1$d" type="checkbox" name="ids[]" value="%1$d" /></th>',
|
||||
$id,
|
||||
esc_html(sprintf(__('选择消息 #%d', 'aksrt'), $id))
|
||||
);
|
||||
|
||||
echo '<td class="column-title"><strong class="row-title">' . esc_html($title) . '</strong>';
|
||||
if ($desc !== '' && $desc !== $title) {
|
||||
echo '<div class="aksrt-messages-admin__desc">' . esc_html(mb_strimwidth($desc, 0, 160, '…')) . '</div>';
|
||||
}
|
||||
echo '<div class="aksrt-messages-admin__sub"><code>#' . esc_html((string) $id) . '</code>';
|
||||
if ($url !== '') {
|
||||
printf(
|
||||
' <a href="%s" target="_blank" rel="noopener noreferrer">%s<span class="screen-reader-text">%s</span></a>',
|
||||
esc_url($url),
|
||||
esc_html__('跳转链接', 'aksrt'),
|
||||
esc_html__('(在新窗口打开)', 'aksrt')
|
||||
);
|
||||
}
|
||||
echo '</div></td>';
|
||||
|
||||
printf(
|
||||
'<td class="column-type"><a class="aksrt-msg-badge is-%s" href="%s">%s</a></td>',
|
||||
esc_attr(MessageTypes::tone($type)),
|
||||
esc_url($this->message_page_url(array_merge($this->message_filter_args($filters), ['type' => $type]))),
|
||||
esc_html(MessageTypes::label($type))
|
||||
);
|
||||
|
||||
echo '<td class="column-user">' . $this->message_user_cell((int) ($row['user_id'] ?? 0), $filters) . '</td>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in builder.
|
||||
|
||||
$read_at = (string) ($row['read_at'] ?? '');
|
||||
printf(
|
||||
'<td class="column-read"><span class="aksrt-msg-state %s"%s>%s</span></td>',
|
||||
$unread ? 'is-unread' : 'is-read',
|
||||
!$unread && $read_at !== '' ? ' title="' . esc_attr(sprintf(__('阅读于 %s', 'aksrt'), $this->message_time($read_at))) . '"' : '',
|
||||
$unread ? esc_html__('未读', 'aksrt') : esc_html__('已读', 'aksrt')
|
||||
);
|
||||
|
||||
$created = (string) ($row['created_at'] ?? '');
|
||||
$ts = $created !== '' ? strtotime($created . ' UTC') : false;
|
||||
printf(
|
||||
'<td class="column-time"><time datetime="%s">%s</time>%s</td>',
|
||||
esc_attr($ts ? gmdate('c', $ts) : ''),
|
||||
esc_html($this->message_time($created)),
|
||||
$ts ? '<div class="aksrt-messages-admin__sub">' . esc_html(sprintf(__('%s前', 'aksrt'), human_time_diff($ts))) . '</div>' : ''
|
||||
);
|
||||
echo '</tr>';
|
||||
}
|
||||
|
||||
private function message_user_cell(int $user_id, array $filters): string {
|
||||
$user = $user_id > 0 ? get_userdata($user_id) : false;
|
||||
if (!$user) {
|
||||
return '<span class="aksrt-messages-admin__muted">' . esc_html(sprintf(__('已删除用户 #%d', 'aksrt'), $user_id)) . '</span>';
|
||||
}
|
||||
$avatar = function_exists('aksrt_get_avatar_url') ? aksrt_get_avatar_url($user_id, 48) : get_avatar_url($user_id, ['size' => 48]);
|
||||
$filter_url = $this->message_page_url(array_merge($this->message_filter_args($filters), ['user' => (string) $user_id]));
|
||||
return '<a class="aksrt-msg-user" href="' . esc_url($filter_url) . '" title="' . esc_attr__('只看该用户', 'aksrt') . '">'
|
||||
. '<img src="' . esc_url($avatar) . '" width="24" height="24" alt="" />'
|
||||
. '<span>' . esc_html($user->display_name) . '</span></a>'
|
||||
. '<div class="aksrt-messages-admin__sub">#' . esc_html((string) $user_id) . ' · <a href="' . esc_url(get_edit_user_link($user_id)) . '">' . esc_html__('编辑', 'aksrt') . '</a></div>';
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* 参数与 URL
|
||||
* ----------------------------------------------------------------*/
|
||||
|
||||
/** @return array{status:string,type:string,user:string,s:string} */
|
||||
/** @return array{status:string,type:string,user:string,s:string,period:string} */
|
||||
private function message_filters(?array $source = null): array {
|
||||
$source = $source ?? $_GET;
|
||||
$status = sanitize_key((string) wp_unslash($source['status'] ?? ''));
|
||||
$period = sanitize_key((string) wp_unslash($source['period'] ?? ''));
|
||||
return [
|
||||
'status' => in_array($status, NotificationAdminQuery::STATUSES, true) ? $status : '',
|
||||
'type' => sanitize_key((string) wp_unslash($source['type'] ?? '')),
|
||||
'user' => trim(sanitize_text_field((string) wp_unslash($source['user'] ?? ''))),
|
||||
's' => trim(sanitize_text_field((string) wp_unslash($source['s'] ?? ''))),
|
||||
'period' => $period === 'today' ? 'today' : '',
|
||||
];
|
||||
}
|
||||
|
||||
/** 收件人筛选解析为 user_id:0=不限,-1=未找到(查询结果为空)。 */
|
||||
/** 收件人筛选解析为 user_id:0=不限,-1=未找到。 */
|
||||
private function message_query_filters(array $filters): array {
|
||||
$user_id = 0;
|
||||
$needle = $filters['user'];
|
||||
@@ -513,10 +241,10 @@ trait UsersMenusMessages {
|
||||
'type' => $filters['type'],
|
||||
'user_id' => $user_id,
|
||||
's' => $filters['s'],
|
||||
'period' => $filters['period'],
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string,string> 非空筛选参数(用于 URL / 隐藏域) */
|
||||
private function message_filter_args(array $filters): array {
|
||||
return array_filter($filters, static fn($value): bool => $value !== '');
|
||||
}
|
||||
@@ -525,7 +253,6 @@ trait UsersMenusMessages {
|
||||
return add_query_arg(array_merge(['page' => self::MESSAGE_PAGE_SLUG], $args), admin_url('users.php'));
|
||||
}
|
||||
|
||||
/** 通知表时间统一存 GMT,按站点时区展示。 */
|
||||
private function message_time(string $gmt): string {
|
||||
return $gmt !== '' ? get_date_from_gmt($gmt, 'Y-m-d H:i') : '';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
/**
|
||||
* 消息管理弹窗:发送表单、只读详情、删除与角色群发共用的二次确认。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Admin\Native;
|
||||
|
||||
use AKSRT\Messages\AdminBroadcast;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
trait UsersMenusMessagesCompose {
|
||||
|
||||
private function render_message_compose(): void {
|
||||
$open = sanitize_key((string) wp_unslash($_GET['aksrt_msg'] ?? '')) === 'send_failed';
|
||||
printf(
|
||||
'<dialog class="aksrt-msg-dialog" id="%s" aria-labelledby="%s-title"%s>',
|
||||
esc_attr(self::MESSAGE_COMPOSE_ID), esc_attr(self::MESSAGE_COMPOSE_ID), $open ? ' data-aksrt-dialog-autoopen' : ''
|
||||
);
|
||||
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" class="aksrt-msg-dialog__frame aksrt-messages-admin__compose" data-aksrt-msg-compose';
|
||||
$strings = [
|
||||
'count-error' => __('最多输入 {count} 字。', 'aksrt'),
|
||||
'role-hint' => __('将发送给 {count} 位用户;提交前需再次确认。', 'aksrt'),
|
||||
'role-title' => __('确认按角色群发', 'aksrt'),
|
||||
'role-confirm' => __('将向「{role}」的 {count} 位用户发送系统消息,并按通知配置触发提醒。请确认收件人范围与消息内容。', 'aksrt'),
|
||||
'role-send' => __('确认发送', 'aksrt'),
|
||||
];
|
||||
foreach ($strings as $key => $text) {
|
||||
echo ' data-i18n-' . esc_attr($key) . '="' . esc_attr($text) . '"';
|
||||
}
|
||||
echo '>';
|
||||
echo '<input type="hidden" name="action" value="aksrt_users_send_message" />';
|
||||
echo '<input type="hidden" name="confirmed_role" value="" /><input type="hidden" name="confirmed_count" value="" />';
|
||||
wp_nonce_field('aksrt_users_send_message');
|
||||
$this->render_message_dialog_head(self::MESSAGE_COMPOSE_ID, __('发送系统消息', 'aksrt'), 'dashicons-megaphone');
|
||||
echo '<div class="aksrt-msg-dialog__body">';
|
||||
if ($open) {
|
||||
$error = sanitize_text_field((string) wp_unslash($_GET['aksrt_msg_error'] ?? __('消息发送失败,请检查后重试。', 'aksrt')));
|
||||
echo '<p class="aksrt-native-callout is-danger">' . esc_html($error) . '</p>';
|
||||
}
|
||||
echo '<fieldset class="aksrt-msg-modes"><legend class="aksrt-native-field__label">' . esc_html__('收件人', 'aksrt') . '</legend>';
|
||||
foreach (['users' => [__('指定用户', 'aksrt'), 'dashicons-admin-users'], 'role' => [__('按角色群发', 'aksrt'), 'dashicons-groups']] as $value => [$label, $icon]) {
|
||||
echo '<label class="aksrt-msg-mode"><input type="radio" name="recipient_mode" value="' . esc_attr($value) . '"' . checked($value, 'users', false) . ' />';
|
||||
echo '<span><span class="dashicons ' . esc_attr($icon) . '" aria-hidden="true"></span>' . esc_html($label) . '</span></label>';
|
||||
}
|
||||
echo '</fieldset><div class="aksrt-native-form-grid">';
|
||||
printf(
|
||||
'<label class="aksrt-native-field is-wide" data-aksrt-msg-pane="users"><span class="aksrt-native-field__label">%s</span><textarea name="users" rows="2" required placeholder="%s"></textarea><small class="aksrt-native-field__hint">%s</small></label>',
|
||||
esc_html__('用户', 'aksrt'), esc_attr__('用户 ID / 用户名 / 邮箱,逗号或换行分隔', 'aksrt'), esc_html(sprintf(__('单次最多 %d 位。', 'aksrt'), AdminBroadcast::MAX_USERS))
|
||||
);
|
||||
echo '<label class="aksrt-native-field is-wide" data-aksrt-msg-pane="role" hidden><span class="aksrt-native-field__label">' . esc_html__('角色', 'aksrt') . '</span>';
|
||||
echo '<select name="role" required disabled><option value="" selected disabled>' . esc_html__('请选择角色', 'aksrt') . '</option>';
|
||||
$counts = count_users('time');
|
||||
foreach (wp_roles()->get_names() as $role => $name) {
|
||||
$count = (int) ($counts['avail_roles'][$role] ?? 0);
|
||||
$label = translate_user_role($name);
|
||||
$disabled = $count === 0 || $count > AdminBroadcast::MAX_ROLE_USERS;
|
||||
$suffix = $count > AdminBroadcast::MAX_ROLE_USERS ? __('(超过发送上限)', 'aksrt') : ($count === 0 ? __('(暂无用户)', 'aksrt') : '');
|
||||
printf(
|
||||
'<option value="%s" data-count="%d" data-label="%s"%s>%s</option>',
|
||||
esc_attr($role), $count, esc_attr($label), $disabled ? ' disabled' : '',
|
||||
esc_html(sprintf(__('%1$s · %2$s 人', 'aksrt'), $label, number_format_i18n($count)) . $suffix)
|
||||
);
|
||||
}
|
||||
echo '</select><small class="aksrt-native-field__hint">' . esc_html(sprintf(__('单个角色最多 %d 人;空角色和超限角色不可选。', 'aksrt'), AdminBroadcast::MAX_ROLE_USERS)) . '</small>';
|
||||
echo '<small class="aksrt-native-field__hint" data-aksrt-msg-role-hint aria-live="polite"></small></label>';
|
||||
printf(
|
||||
'<label class="aksrt-native-field"><span class="aksrt-native-field__label">%s</span><input type="text" name="title" data-count-limit="%d" aria-describedby="aksrt-msg-title-count" placeholder="%s" /><small class="aksrt-msg-counter" id="aksrt-msg-title-count" data-count-for="title"></small></label>',
|
||||
esc_html__('标题', 'aksrt'), AdminBroadcast::TITLE_MAX, esc_attr__('留空则取内容开头', 'aksrt')
|
||||
);
|
||||
echo '<label class="aksrt-native-field"><span class="aksrt-native-field__label">' . esc_html__('跳转链接(可选)', 'aksrt') . '</span><input type="url" name="target_url" placeholder="https://" /></label>';
|
||||
printf(
|
||||
'<label class="aksrt-native-field is-wide"><span class="aksrt-native-field__label">%s</span><textarea name="content" rows="5" data-count-limit="%d" required aria-describedby="aksrt-msg-content-count"></textarea><small class="aksrt-msg-counter" id="aksrt-msg-content-count" data-count-for="content"></small></label>',
|
||||
esc_html__('内容', 'aksrt'), AdminBroadcast::CONTENT_MAX
|
||||
);
|
||||
echo '</div><p class="aksrt-native-muted aksrt-msg-dialog__note">' . esc_html__('以「系统通知」类型逐人投递;邮件 / 公众号提醒按通知配置同步触发。', 'aksrt') . '</p></div>';
|
||||
echo '<footer class="aksrt-msg-dialog__foot"><button type="button" class="button" data-aksrt-dialog-close>' . esc_html__('取消', 'aksrt') . '</button>';
|
||||
echo '<button type="submit" class="button button-primary" data-aksrt-msg-send>' . esc_html__('发送', 'aksrt') . '</button></footer></form></dialog>';
|
||||
}
|
||||
|
||||
private function render_message_dialogs(): void {
|
||||
echo '<dialog class="aksrt-msg-dialog" id="aksrt-msg-detail" aria-labelledby="aksrt-msg-detail-title"><div class="aksrt-msg-dialog__frame">';
|
||||
$this->render_message_dialog_head('aksrt-msg-detail', __('消息详情', 'aksrt'), 'dashicons-email-alt');
|
||||
echo '<div class="aksrt-msg-dialog__body" data-aksrt-msg-detail-body></div>';
|
||||
echo '<footer class="aksrt-msg-dialog__foot"><button type="button" class="button" data-aksrt-dialog-close>' . esc_html__('关闭', 'aksrt') . '</button></footer></div></dialog>';
|
||||
|
||||
echo '<dialog class="aksrt-msg-dialog aksrt-msg-dialog--confirm" id="aksrt-msg-confirm" aria-labelledby="aksrt-msg-confirm-title" aria-describedby="aksrt-msg-confirm-text"';
|
||||
foreach ([
|
||||
'delete-title' => __('删除消息', 'aksrt'),
|
||||
'delete-text' => __('确定删除 {count} 条消息?删除后用户收件箱中也将不再显示,此操作不可撤销。', 'aksrt'),
|
||||
'delete-send' => __('确认删除', 'aksrt'),
|
||||
] as $key => $text) {
|
||||
echo ' data-i18n-' . esc_attr($key) . '="' . esc_attr($text) . '"';
|
||||
}
|
||||
echo '><div class="aksrt-msg-dialog__frame">';
|
||||
$this->render_message_dialog_head('aksrt-msg-confirm', __('确认操作', 'aksrt'), 'dashicons-warning');
|
||||
echo '<div class="aksrt-msg-dialog__body"><p id="aksrt-msg-confirm-text"></p></div>';
|
||||
echo '<footer class="aksrt-msg-dialog__foot"><button type="button" class="button" data-aksrt-dialog-close autofocus>' . esc_html__('取消', 'aksrt') . '</button>';
|
||||
echo '<button type="button" class="button button-primary" data-aksrt-msg-confirm>' . esc_html__('确认', 'aksrt') . '</button></footer></div></dialog>';
|
||||
}
|
||||
|
||||
private function render_message_dialog_head(string $id, string $title, string $icon): void {
|
||||
echo '<header class="aksrt-msg-dialog__head"><h2 id="' . esc_attr($id) . '-title" class="aksrt-msg-dialog__title">';
|
||||
echo '<span class="dashicons ' . esc_attr($icon) . '" aria-hidden="true"></span><span data-aksrt-dialog-title>' . esc_html($title) . '</span></h2>';
|
||||
echo '<button type="button" class="aksrt-msg-dialog__close" data-aksrt-dialog-close aria-label="' . esc_attr__('关闭', 'aksrt') . '"><span class="dashicons dashicons-no-alt" aria-hidden="true"></span></button></header>';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
<?php
|
||||
/**
|
||||
* 消息管理展示:概览、筛选、列表与按行预渲染的只读详情。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Admin\Native;
|
||||
|
||||
use AKSRT\Messages\MessageTypes;
|
||||
use AKSRT\Messages\NotificationAdminQuery;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
trait UsersMenusMessagesList {
|
||||
|
||||
private function render_message_overview(): void {
|
||||
$stats = NotificationAdminQuery::overview();
|
||||
NativeUi::stats([
|
||||
['label' => __('消息总数', 'aksrt'), 'value' => number_format_i18n($stats['total']), 'icon' => 'dashicons-email-alt', 'tone' => 'info', 'url' => $this->message_page_url()],
|
||||
['label' => __('未读', 'aksrt'), 'value' => number_format_i18n($stats['unread']), 'icon' => 'dashicons-marker', 'tone' => 'danger', 'url' => $this->message_page_url(['status' => 'unread'])],
|
||||
['label' => __('今日新增', 'aksrt'), 'value' => number_format_i18n($stats['today']), 'icon' => 'dashicons-calendar-alt', 'tone' => 'success', 'url' => $this->message_page_url(['period' => 'today'])],
|
||||
['label' => __('涉及用户', 'aksrt'), 'value' => number_format_i18n($stats['recipients']), 'icon' => 'dashicons-groups', 'tone' => 'primary', 'url' => admin_url('users.php'), 'hint' => __('前往用户管理', 'aksrt')],
|
||||
]);
|
||||
}
|
||||
|
||||
private function message_views_html(array $filters, array $counts): string {
|
||||
$views = [
|
||||
'' => [__('全部', 'aksrt'), $counts['all']],
|
||||
'unread' => [__('未读', 'aksrt'), $counts['unread']],
|
||||
'read' => [__('已读', 'aksrt'), $counts['read']],
|
||||
];
|
||||
$args = $this->message_filter_args($filters);
|
||||
unset($args['status']);
|
||||
$html = '<ul class="subsubsub" aria-label="' . esc_attr__('消息状态', 'aksrt') . '">';
|
||||
foreach ($views as $key => [$label, $count]) {
|
||||
$html .= sprintf(
|
||||
'<li><a href="%s"%s>%s <span class="count">(%s)</span></a></li>',
|
||||
esc_url($this->message_page_url($key === '' ? $args : array_merge($args, ['status' => $key]))),
|
||||
$filters['status'] === $key ? ' class="current" aria-current="page"' : '',
|
||||
esc_html($label),
|
||||
esc_html(number_format_i18n($count))
|
||||
);
|
||||
}
|
||||
return $html . '</ul>';
|
||||
}
|
||||
|
||||
private function render_message_filter_form(array $filters): void {
|
||||
echo '<form method="get" action="' . esc_url(admin_url('users.php')) . '" class="aksrt-native-filters">';
|
||||
echo '<input type="hidden" name="page" value="' . esc_attr(self::MESSAGE_PAGE_SLUG) . '" />';
|
||||
if ($filters['status'] !== '') {
|
||||
echo '<input type="hidden" name="status" value="' . esc_attr($filters['status']) . '" />';
|
||||
}
|
||||
echo '<label class="screen-reader-text" for="aksrt-msg-filter-type">' . esc_html__('按类型筛选', 'aksrt') . '</label>';
|
||||
echo '<select name="type" id="aksrt-msg-filter-type"><option value="">' . esc_html__('全部类型', 'aksrt') . '</option>';
|
||||
foreach (NotificationAdminQuery::types() as $type) {
|
||||
printf('<option value="%s"%s>%s</option>', esc_attr($type), selected($filters['type'], $type, false), esc_html(MessageTypes::label($type)));
|
||||
}
|
||||
echo '</select>';
|
||||
echo '<label class="screen-reader-text" for="aksrt-msg-filter-period">' . esc_html__('按发送时间筛选', 'aksrt') . '</label>';
|
||||
echo '<select name="period" id="aksrt-msg-filter-period"><option value="">' . esc_html__('全部时间', 'aksrt') . '</option>';
|
||||
echo '<option value="today"' . selected($filters['period'], 'today', false) . '>' . esc_html__('今日新增', 'aksrt') . '</option></select>';
|
||||
printf(
|
||||
'<label class="screen-reader-text" for="aksrt-msg-filter-user">%s</label><input type="text" id="aksrt-msg-filter-user" name="user" value="%s" placeholder="%s" />',
|
||||
esc_html__('按收件人筛选', 'aksrt'), esc_attr($filters['user']), esc_attr__('收件人 ID / 用户名 / 邮箱', 'aksrt')
|
||||
);
|
||||
printf(
|
||||
'<label class="screen-reader-text" for="aksrt-msg-filter-s">%s</label><input type="search" id="aksrt-msg-filter-s" name="s" value="%s" placeholder="%s" />',
|
||||
esc_html__('搜索消息', 'aksrt'), esc_attr($filters['s']), esc_attr__('标题 / 内容', 'aksrt')
|
||||
);
|
||||
submit_button(__('筛选', 'aksrt'), '', '', false);
|
||||
$other = $filters;
|
||||
unset($other['status']);
|
||||
if ($this->message_filter_args($other) !== []) {
|
||||
echo '<a class="button-link" href="' . esc_url($this->message_page_url($filters['status'] === '' ? [] : ['status' => $filters['status']])) . '">' . esc_html__('清除筛选', 'aksrt') . '</a>';
|
||||
}
|
||||
echo '</form>';
|
||||
}
|
||||
|
||||
private function render_message_table(array $items, array $filters, int $page, bool $user_missing): void {
|
||||
$user_ids = array_values(array_unique(array_filter(array_map(static fn($row): int => (int) ($row['user_id'] ?? 0), $items))));
|
||||
if ($user_ids !== []) {
|
||||
cache_users($user_ids);
|
||||
}
|
||||
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" class="aksrt-messages-admin__list" data-aksrt-msg-list>';
|
||||
echo '<input type="hidden" name="action" value="aksrt_users_bulk_messages" />';
|
||||
wp_nonce_field('aksrt_users_bulk_messages');
|
||||
foreach ($this->message_filter_args($filters) as $key => $value) {
|
||||
printf('<input type="hidden" name="%s" value="%s" />', esc_attr($key), esc_attr((string) $value));
|
||||
}
|
||||
echo '<input type="hidden" name="paged" value="' . esc_attr((string) $page) . '" />';
|
||||
echo '<input type="hidden" name="bulk_action" value="" /><input type="hidden" name="message_id" value="" />';
|
||||
echo '<div class="aksrt-messages-admin__bulk">';
|
||||
echo '<span class="aksrt-messages-admin__selection" data-aksrt-msg-selection role="status" aria-live="polite" data-label="' . esc_attr__('已选 %d 条', 'aksrt') . '"></span>';
|
||||
echo '<button type="button" class="button" data-aksrt-msg-action="mark_read" disabled>' . esc_html__('标记已读', 'aksrt') . '</button>';
|
||||
echo '<button type="button" class="button aksrt-messages-admin__danger" data-aksrt-msg-action="delete" disabled>' . esc_html__('删除', 'aksrt') . '</button>';
|
||||
echo '</div><div class="aksrt-native-table-wrap">';
|
||||
echo '<table class="wp-list-table widefat fixed striped aksrt-messages-admin__table"><thead><tr>';
|
||||
echo '<td class="manage-column column-cb check-column"><label class="screen-reader-text" for="aksrt-msg-check-all">' . esc_html__('全选本页消息', 'aksrt') . '</label><input id="aksrt-msg-check-all" type="checkbox" data-aksrt-msg-check-all /></td>';
|
||||
foreach (['title' => __('消息', 'aksrt'), 'type' => __('类型', 'aksrt'), 'user' => __('收件人', 'aksrt'), 'read' => __('状态', 'aksrt'), 'time' => __('发送时间', 'aksrt')] as $column => $label) {
|
||||
echo '<th scope="col" class="column-' . esc_attr($column) . '">' . esc_html($label) . '</th>';
|
||||
}
|
||||
echo '</tr></thead><tbody>';
|
||||
if ($items === []) {
|
||||
$hint = $user_missing ? __('没有找到该收件人。', 'aksrt')
|
||||
: ($this->message_filter_args($filters) === [] ? __('站内还没有任何通知。', 'aksrt') : __('当前筛选条件下没有消息。', 'aksrt'));
|
||||
echo NativeUi::empty_row(6, __('暂无消息', 'aksrt'), $hint); // phpcs:ignore WordPress.Security.EscapeOutput -- builder escapes.
|
||||
}
|
||||
foreach ($items as $row) {
|
||||
$this->render_message_row($row, $filters);
|
||||
}
|
||||
echo '</tbody></table></div></form>';
|
||||
}
|
||||
|
||||
private function render_message_row(array $row, array $filters): void {
|
||||
$id = (int) $row['id'];
|
||||
$type = (string) $row['type'];
|
||||
$title = (string) $row['title'];
|
||||
$desc = (string) $row['description'];
|
||||
$unread = empty($row['is_read']);
|
||||
echo '<tr class="' . ($unread ? 'is-unread' : 'is-read') . '">';
|
||||
printf(
|
||||
'<th scope="row" class="check-column"><label class="screen-reader-text" for="aksrt-msg-%1$d">%2$s</label><input id="aksrt-msg-%1$d" type="checkbox" name="ids[]" value="%1$d" data-aksrt-msg-check /></th>',
|
||||
$id, esc_html(sprintf(__('选择消息 #%d', 'aksrt'), $id))
|
||||
);
|
||||
echo '<td class="column-title"><button type="button" class="button-link row-title" data-aksrt-msg-view="' . esc_attr((string) $id) . '" aria-haspopup="dialog" aria-controls="aksrt-msg-detail">' . esc_html($title) . '</button>';
|
||||
if ($desc !== '' && $desc !== $title) {
|
||||
echo '<div class="aksrt-messages-admin__desc">' . esc_html(mb_strimwidth($desc, 0, 160, '…')) . '</div>';
|
||||
}
|
||||
echo '<div class="aksrt-messages-admin__sub"><code>#' . esc_html((string) $id) . '</code></div>';
|
||||
echo '<div class="aksrt-msg-row-actions">';
|
||||
printf('<button type="button" class="button-link" data-aksrt-msg-view="%d" aria-haspopup="dialog" aria-controls="aksrt-msg-detail">%s</button>', $id, esc_html__('查看', 'aksrt'));
|
||||
if ($unread) {
|
||||
printf('<button type="button" class="button-link" data-aksrt-msg-action="mark_read" data-id="%d">%s</button>', $id, esc_html__('标记已读', 'aksrt'));
|
||||
}
|
||||
printf('<button type="button" class="button-link button-link-delete" data-aksrt-msg-action="delete" data-id="%d">%s</button>', $id, esc_html__('删除', 'aksrt'));
|
||||
echo '</div>';
|
||||
$this->render_message_detail_template($row);
|
||||
echo '</td>';
|
||||
printf(
|
||||
'<td class="column-type"><a class="aksrt-msg-badge is-%s" href="%s">%s</a></td>',
|
||||
esc_attr(MessageTypes::tone($type)), esc_url($this->message_page_url(array_merge($this->message_filter_args($filters), ['type' => $type]))), esc_html(MessageTypes::label($type))
|
||||
);
|
||||
echo '<td class="column-user">' . $this->message_user_cell((int) $row['user_id'], $filters) . '</td>'; // phpcs:ignore WordPress.Security.EscapeOutput -- builder escapes.
|
||||
$read_at = (string) ($row['read_at'] ?? '');
|
||||
printf(
|
||||
'<td class="column-read"><span class="aksrt-msg-state %s"%s>%s</span></td>',
|
||||
$unread ? 'is-unread' : 'is-read',
|
||||
!$unread && $read_at !== '' ? ' title="' . esc_attr(sprintf(__('阅读于 %s', 'aksrt'), $this->message_time($read_at))) . '"' : '',
|
||||
$unread ? esc_html__('未读', 'aksrt') : esc_html__('已读', 'aksrt')
|
||||
);
|
||||
$created = (string) $row['created_at'];
|
||||
$ts = $created !== '' ? strtotime($created . ' UTC') : false;
|
||||
printf(
|
||||
'<td class="column-time"><time datetime="%s">%s</time>%s</td>',
|
||||
esc_attr($ts ? gmdate('c', $ts) : ''), esc_html($this->message_time($created)),
|
||||
$ts ? '<div class="aksrt-messages-admin__sub">' . esc_html(sprintf(__('%s前', 'aksrt'), human_time_diff($ts))) . '</div>' : ''
|
||||
);
|
||||
echo '</tr>';
|
||||
}
|
||||
|
||||
private function render_message_detail_template(array $row): void {
|
||||
$user = get_userdata((int) $row['user_id']);
|
||||
$recipient = $user ? $user->display_name : __('已删除用户', 'aksrt');
|
||||
echo '<template id="aksrt-msg-detail-' . esc_attr((string) $row['id']) . '">';
|
||||
echo '<h3 class="aksrt-msg-detail__title">' . esc_html((string) $row['title']) . '</h3><dl class="aksrt-msg-detail__meta">';
|
||||
$meta = [
|
||||
__('消息编号', 'aksrt') => '#' . $row['id'],
|
||||
__('收件人', 'aksrt') => $recipient . ' · #' . $row['user_id'],
|
||||
__('消息类型', 'aksrt') => MessageTypes::label((string) $row['type']),
|
||||
__('发送时间', 'aksrt') => $this->message_time((string) $row['created_at']),
|
||||
__('阅读状态', 'aksrt') => empty($row['is_read']) ? __('未读', 'aksrt') : __('已读', 'aksrt'),
|
||||
__('阅读时间', 'aksrt') => empty($row['is_read']) || empty($row['read_at']) ? '—' : $this->message_time((string) $row['read_at']),
|
||||
];
|
||||
foreach ($meta as $label => $value) {
|
||||
echo '<div><dt>' . esc_html($label) . '</dt><dd>' . esc_html($value) . '</dd></div>';
|
||||
}
|
||||
echo '</dl><h4>' . esc_html__('完整内容', 'aksrt') . '</h4><div class="aksrt-msg-detail__content">' . esc_html((string) $row['description']) . '</div>';
|
||||
if (!empty($row['target_url'])) {
|
||||
printf('<a class="aksrt-msg-detail__link" href="%s" target="_blank" rel="noopener noreferrer">%s<span class="screen-reader-text">%s</span></a>', esc_url((string) $row['target_url']), esc_html((string) $row['target_url']), esc_html__('(在新窗口打开)', 'aksrt'));
|
||||
}
|
||||
echo '<p class="aksrt-native-field__hint">' . esc_html__('查看详情不会改变收件人的已读状态。', 'aksrt') . '</p></template>';
|
||||
}
|
||||
|
||||
private function message_user_cell(int $user_id, array $filters): string {
|
||||
$user = $user_id > 0 ? get_userdata($user_id) : false;
|
||||
if (!$user) {
|
||||
return '<span class="aksrt-messages-admin__muted">' . esc_html(sprintf(__('已删除用户 #%d', 'aksrt'), $user_id)) . '</span>';
|
||||
}
|
||||
$avatar = function_exists('aksrt_get_avatar_url') ? aksrt_get_avatar_url($user_id, 48) : get_avatar_url($user_id, ['size' => 48]);
|
||||
$filter_url = $this->message_page_url(array_merge($this->message_filter_args($filters), ['user' => (string) $user_id]));
|
||||
return '<a class="aksrt-msg-user" href="' . esc_url($filter_url) . '" title="' . esc_attr__('只看该用户', 'aksrt') . '">'
|
||||
. '<img src="' . esc_url($avatar) . '" width="24" height="24" alt="" />'
|
||||
. '<span>' . esc_html($user->display_name) . '</span></a>'
|
||||
. '<div class="aksrt-messages-admin__sub">#' . esc_html((string) $user_id) . ' · <a href="' . esc_url(get_edit_user_link($user_id)) . '">' . esc_html__('编辑', 'aksrt') . '</a></div>';
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ final class NotificationAdminQuery {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{status:string,type:string,user_id:int,s:string} $filters user_id:0=不限,<0=无匹配用户
|
||||
* @param array{status:string,type:string,user_id:int,s:string,period?:string} $filters user_id:0=不限,<0=无匹配用户
|
||||
* @return array{items:list<array<string,mixed>>,total:int,page:int}
|
||||
*/
|
||||
public static function paginate(array $filters, int $page, int $per_page = self::PER_PAGE): array {
|
||||
@@ -76,14 +76,15 @@ final class NotificationAdminQuery {
|
||||
}
|
||||
global $wpdb;
|
||||
// created_at 存 GMT;「今日」按站点时区零点换算。
|
||||
$today_gmt = get_gmt_from_date(wp_date('Y-m-d 00:00:00'));
|
||||
[$today_start, $today_end] = self::today_range();
|
||||
$row = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT COUNT(*) AS total,
|
||||
COALESCE(SUM(is_read = 0), 0) AS unread,
|
||||
COALESCE(SUM(created_at >= %s), 0) AS today,
|
||||
COALESCE(SUM(created_at >= %s AND created_at < %s), 0) AS today,
|
||||
COUNT(DISTINCT user_id) AS recipients
|
||||
FROM {$table}",
|
||||
$today_gmt
|
||||
$today_start,
|
||||
$today_end
|
||||
), ARRAY_A);
|
||||
return [
|
||||
'total' => (int) ($row['total'] ?? 0),
|
||||
@@ -158,9 +159,22 @@ final class NotificationAdminQuery {
|
||||
$params[] = $like;
|
||||
$params[] = $like;
|
||||
}
|
||||
if (($filters['period'] ?? '') === 'today') {
|
||||
$where[] = 'created_at >= %s AND created_at < %s';
|
||||
array_push($params, ...self::today_range());
|
||||
}
|
||||
return [implode(' AND ', $where), $params];
|
||||
}
|
||||
|
||||
/** 先在站点时区计算自然日,再转换成 GMT;正确处理夏令时的 23 / 25 小时日。 */
|
||||
private static function today_range(): array {
|
||||
$start = current_datetime()->setTime(0, 0, 0);
|
||||
return [
|
||||
get_gmt_from_date($start->format('Y-m-d H:i:s')),
|
||||
get_gmt_from_date($start->modify('+1 day')->format('Y-m-d H:i:s')),
|
||||
];
|
||||
}
|
||||
|
||||
private static function prepare(string $sql, array $params): string {
|
||||
global $wpdb;
|
||||
return $params === [] ? $sql : (string) $wpdb->prepare($sql, $params);
|
||||
|
||||
@@ -33,10 +33,14 @@ test('write handlers check capability and nonce', () => {
|
||||
const page = read('src/Admin/Native/UsersMenusMessages.php');
|
||||
assert.match(page, /function handle_send_message\(\): void \{\s*\$this->ensure_cap\(\);\s*check_admin_referer\('aksrt_users_send_message'\);/);
|
||||
assert.match(page, /function handle_bulk_messages\(\): void \{\s*\$this->ensure_cap\(\);\s*check_admin_referer\('aksrt_users_bulk_messages'\);/);
|
||||
assert.ok(page.includes("wp_nonce_field('aksrt_users_send_message')"));
|
||||
assert.ok(page.includes("wp_nonce_field('aksrt_users_bulk_messages')"));
|
||||
const compose = read('src/Admin/Native/UsersMenusMessagesCompose.php');
|
||||
const list = read('src/Admin/Native/UsersMenusMessagesList.php');
|
||||
assert.ok(page.includes('use UsersMenusMessagesCompose;'));
|
||||
assert.ok(page.includes('use UsersMenusMessagesList;'));
|
||||
assert.ok(compose.includes("wp_nonce_field('aksrt_users_send_message')"));
|
||||
assert.ok(list.includes("wp_nonce_field('aksrt_users_bulk_messages')"));
|
||||
// 页面层不直接碰数据库,分页走共享组件。
|
||||
assert.doesNotMatch(page, /\$wpdb/);
|
||||
assert.doesNotMatch(page + compose + list, /\$wpdb/);
|
||||
assert.ok(page.includes('AdminListPagination::render('));
|
||||
});
|
||||
|
||||
@@ -53,3 +57,95 @@ test('dead notification helpers are removed', () => {
|
||||
assert.doesNotMatch(read('src/Messages/Message.php'), /get_all_notifications/);
|
||||
assert.ok(fs.existsSync(path.join(root, 'assets/css/admin/messages.css')));
|
||||
});
|
||||
|
||||
let rendered;
|
||||
function harness() {
|
||||
if (!rendered) {
|
||||
const result = require('node:child_process').spawnSync('php', [path.join(__dirname, 'message-admin-harness.php')], { encoding: 'utf8' });
|
||||
assert.equal(result.status, 0, result.stderr + result.stdout);
|
||||
rendered = JSON.parse(result.stdout);
|
||||
}
|
||||
return rendered;
|
||||
}
|
||||
|
||||
test('list toolbar is merged into the table card and inline confirmation is removed', () => {
|
||||
const page = read('src/Admin/Native/UsersMenusMessages.php');
|
||||
const list = read('src/Admin/Native/UsersMenusMessagesList.php');
|
||||
assert.ok(page.indexOf("NativeUi::card_open(__('站内通知'") < page.indexOf('$this->render_message_filter_form'));
|
||||
assert.doesNotMatch(page + list, /onclick=|confirm\(|<details|aksrt-native-toolbar/);
|
||||
const { table, empty } = harness();
|
||||
assert.match(table, /data-aksrt-msg-action="delete" disabled/);
|
||||
assert.match(table, /data-aksrt-msg-selection/);
|
||||
assert.match(table, /name="period" value="today"/);
|
||||
assert.match(empty, /当前筛选条件下没有消息/);
|
||||
});
|
||||
|
||||
test('details preserve full content, escape HTML and unsafe links, and never mark read', () => {
|
||||
const { table, cache_calls } = harness();
|
||||
assert.match(table, /<template id="aksrt-msg-detail-8">/);
|
||||
assert.match(table, /完整内容(?:完整内容){99}/);
|
||||
assert.match(table, /<script>x<\/script>/);
|
||||
assert.doesNotMatch(table, /<script>|href="javascript:/);
|
||||
assert.match(table, /2026-10-30 11:30/);
|
||||
assert.match(table, /查看详情不会改变收件人的已读状态/);
|
||||
assert.equal((table.match(/data-aksrt-msg-action="mark_read" data-id=/g) || []).length, 1);
|
||||
assert.deepEqual(cache_calls, [[7]]);
|
||||
});
|
||||
|
||||
test('role choices display counts and disable only empty or oversized roles', () => {
|
||||
const { compose } = harness();
|
||||
assert.match(compose, /value="subscriber" data-count="3"[^>]*>订阅者 · 3 人/);
|
||||
assert.match(compose, /value="editor" data-count="501"[^>]* disabled/);
|
||||
assert.match(compose, /value="author" data-count="0"[^>]* disabled/);
|
||||
assert.match(compose, /value="contributor" data-count="500"[^>]*>贡献者 · 500 人/);
|
||||
assert.doesNotMatch(compose, /value="contributor"[^>]* disabled/);
|
||||
assert.match(compose, /data-count-limit="191"/);
|
||||
assert.match(compose, /data-count-limit="2000"/);
|
||||
assert.match(compose, /aria-describedby="aksrt-msg-content-count"/);
|
||||
assert.match(compose, /data-aksrt-msg-pane="role" hidden/);
|
||||
});
|
||||
|
||||
test('overview links, status tabs, filter forms and mutations preserve period filtering', () => {
|
||||
const data = harness();
|
||||
assert.equal(data.filters.period, 'today');
|
||||
assert.equal(data.bad_period.period, '');
|
||||
assert.equal(data.query_filters.period, 'today');
|
||||
assert.match(data.overview, /period=today/);
|
||||
assert.match(data.overview, /status=unread/);
|
||||
assert.match(data.filter_form, /value="today" selected/);
|
||||
assert.match(data.views, /period=today/);
|
||||
assert.equal(data.single_delete.redirect.period, 'today');
|
||||
assert.equal(data.single_delete.redirect.status, 'unread');
|
||||
assert.equal(data.single_delete.redirect.paged, '3');
|
||||
});
|
||||
|
||||
test('today counts and filtered lists share prepared site-timezone bounds including DST', () => {
|
||||
const { sql, dst_sql } = harness();
|
||||
assert.equal(sql.length, 4);
|
||||
sql.forEach(({ sql, params }) => {
|
||||
assert.match(sql, /created_at >= %s AND created_at < %s/);
|
||||
assert.ok(params.includes('2026-10-29 16:00:00'));
|
||||
assert.ok(params.includes('2026-10-30 16:00:00'));
|
||||
});
|
||||
assert.doesNotMatch(sql[2].sql, /is_read = 0 AND/);
|
||||
assert.ok(sql[0].params.includes('%a\\%\\_%'));
|
||||
assert.deepEqual(dst_sql[0].params, ['2026-11-01 04:00:00', '2026-11-02 05:00:00']);
|
||||
});
|
||||
|
||||
test('role broadcasts require confirmation of the current role and count on the server', () => {
|
||||
const { send, cap_checked, nonce_checked } = harness();
|
||||
['unconfirmed', 'changed_count', 'changed_role', 'too_large', 'long_title'].forEach((key) => {
|
||||
assert.equal(send[key].sent, 0, key);
|
||||
assert.equal(send[key].redirect.aksrt_msg, 'send_failed', key);
|
||||
});
|
||||
assert.equal(send.confirmed.sent, 3);
|
||||
assert.equal(send.users.sent, 1);
|
||||
assert.equal(cap_checked, 8);
|
||||
assert.equal(nonce_checked.length, 8);
|
||||
});
|
||||
|
||||
test('single row actions cannot accidentally operate on the checked bulk selection', () => {
|
||||
const { single_delete } = harness();
|
||||
assert.deepEqual(single_delete.sql[0].params, [8]);
|
||||
assert.match(single_delete.sql[0].sql, /DELETE FROM .* WHERE id IN/);
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
/** WordPress-free message admin render / handler / SQL harness. No actual notifications are sent. */
|
||||
namespace AKSRT\Core {
|
||||
final class DBMigrator {
|
||||
public static function table($key) { return 'wp_aksrt_' . $key; }
|
||||
}
|
||||
}
|
||||
namespace AKSRT\Messages {
|
||||
final class Message {
|
||||
public static $sent = [];
|
||||
public static function get_instance() { return new self(); }
|
||||
public function send($id, $type, $content, $args) {
|
||||
self::$sent[] = compact('id', 'type', 'content', 'args');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
namespace {
|
||||
define('ABSPATH', __DIR__);
|
||||
define('ARRAY_A', 'ARRAY_A');
|
||||
$root = dirname(__DIR__, 2);
|
||||
define('AKSRT_DIR', $root);
|
||||
define('AKSRT_URI', 'https://example.test/theme');
|
||||
class WP_Error {
|
||||
public function __construct(public $code, public $message) {}
|
||||
public function get_error_message() { return $this->message; }
|
||||
}
|
||||
class WP_User {
|
||||
public $ID = 7;
|
||||
public $display_name = 'Test <user>';
|
||||
}
|
||||
class Redirect extends \RuntimeException {}
|
||||
function __($text, $domain = '') { return $text; }
|
||||
function esc_html($text) { return htmlspecialchars((string) $text, ENT_QUOTES, 'UTF-8'); }
|
||||
function esc_attr($text) { return esc_html($text); }
|
||||
function esc_html__($text, $domain) { return esc_html($text); }
|
||||
function esc_attr__($text, $domain) { return esc_attr($text); }
|
||||
function esc_url($url) { return preg_match('/^javascript:/i', $url) ? '' : esc_attr($url); }
|
||||
function esc_url_raw($url) { return preg_match('/^javascript:/i', $url) ? '' : $url; }
|
||||
function sanitize_key($text) { return preg_replace('/[^a-z0-9_\-]/', '', strtolower($text)); }
|
||||
function sanitize_text_field($text) { return trim(strip_tags($text)); }
|
||||
function sanitize_textarea_field($text) { return trim(strip_tags($text)); }
|
||||
function sanitize_user($text) { return $text; }
|
||||
function wp_unslash($text) { return $text; }
|
||||
function absint($value) { return abs((int) $value); }
|
||||
function is_wp_error($value) { return $value instanceof WP_Error; }
|
||||
function is_email($value) { return filter_var($value, FILTER_VALIDATE_EMAIL); }
|
||||
function admin_url($path) { return 'https://example.test/wp-admin/' . $path; }
|
||||
function get_edit_user_link($id) { return admin_url('user-edit.php?user_id=' . $id); }
|
||||
function get_avatar_url($id, $args) { return 'https://example.test/avatar.png'; }
|
||||
function get_userdata($id) { return $id > 0 ? new WP_User() : false; }
|
||||
function get_user_by($key, $value) { return new WP_User(); }
|
||||
function cache_users($ids) { $GLOBALS['cache_calls'][] = $ids; }
|
||||
function number_format_i18n($count) { return number_format($count); }
|
||||
function human_time_diff($time) { return '1 分钟'; }
|
||||
function checked($actual, $expected, $echo = true) { return $actual === $expected ? ' checked' : ''; }
|
||||
function selected($actual, $expected, $echo = true) { return $actual === $expected ? ' selected' : ''; }
|
||||
function wp_nonce_field($action) { echo '<input type="hidden" name="_wpnonce" value="' . esc_attr($action) . '" />'; }
|
||||
function check_admin_referer($action) { $GLOBALS['nonce_checked'][] = $action; }
|
||||
function wp_roles() {
|
||||
return new class {
|
||||
public $roles = ['subscriber' => [], 'editor' => [], 'author' => [], 'contributor' => []];
|
||||
public function get_names() { return ['subscriber' => '订阅者', 'editor' => '编辑', 'author' => '作者', 'contributor' => '贡献者']; }
|
||||
};
|
||||
}
|
||||
function translate_user_role($role) { return $role; }
|
||||
function count_users($strategy) { return ['avail_roles' => ['subscriber' => 3, 'editor' => 501, 'author' => 0, 'contributor' => 500]]; }
|
||||
function get_users($args) { return array_slice(range(1, $GLOBALS['role_count'] ?? 3), 0, $args['number']); }
|
||||
function wp_safe_redirect($url) { throw new Redirect($url); }
|
||||
function add_query_arg($args, $url) { return $url . '?' . http_build_query($args); }
|
||||
function submit_button($text, $type, $name, $wrap) { echo '<button type="submit">' . esc_html($text) . '</button>'; }
|
||||
function current_datetime() { return new \DateTimeImmutable($GLOBALS['now'] ?? '2026-10-30 12:00:00', new \DateTimeZone($GLOBALS['zone'] ?? 'Asia/Shanghai')); }
|
||||
function get_gmt_from_date($local) { return (new \DateTimeImmutable($local, new \DateTimeZone($GLOBALS['zone'] ?? 'Asia/Shanghai')))->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d H:i:s'); }
|
||||
function get_date_from_gmt($gmt, $format) { return (new \DateTimeImmutable($gmt, new \DateTimeZone('UTC')))->setTimezone(new \DateTimeZone('Asia/Shanghai'))->format($format); }
|
||||
function current_time($format, $gmt = false) { return '2026-10-30 04:00:00'; }
|
||||
class Database {
|
||||
public $prepared = [];
|
||||
public $queries = [];
|
||||
public function prepare($sql, ...$params) {
|
||||
$params = count($params) === 1 && is_array($params[0]) ? $params[0] : $params;
|
||||
$this->prepared[] = ['sql' => $sql, 'params' => $params];
|
||||
return $sql;
|
||||
}
|
||||
public function get_var($sql) { return 2; }
|
||||
public function get_results($sql, $format) { return []; }
|
||||
public function get_row($sql, $format) { return ['total' => 2, 'unread' => 1, 'today' => 1, 'recipients' => 2]; }
|
||||
public function get_col($sql) { return ['system']; }
|
||||
public function query($sql) { $this->queries[] = $sql; return 1; }
|
||||
public function esc_like($text) { return addcslashes($text, '_%\\'); }
|
||||
}
|
||||
$wpdb = new Database();
|
||||
require $root . '/src/Messages/MessageTypes.php';
|
||||
require $root . '/src/Messages/AdminBroadcast.php';
|
||||
require $root . '/src/Messages/NotificationAdminQuery.php';
|
||||
require $root . '/src/Admin/Native/NativeUi.php';
|
||||
require $root . '/src/Admin/Native/UsersMenusMessagesList.php';
|
||||
require $root . '/src/Admin/Native/UsersMenusMessagesCompose.php';
|
||||
require $root . '/src/Admin/Native/UsersMenusMessages.php';
|
||||
class Probe {
|
||||
use \AKSRT\Admin\Native\UsersMenusMessages;
|
||||
public $cap_checked = 0;
|
||||
private function ensure_cap(): void { $this->cap_checked++; }
|
||||
public function invoke($method, ...$args) { return $this->$method(...$args); }
|
||||
public function html($method, ...$args) {
|
||||
ob_start(); $this->$method(...$args); return ob_get_clean();
|
||||
}
|
||||
}
|
||||
$probe = new Probe();
|
||||
$out = [];
|
||||
$filters = $probe->invoke('message_filters', ['period' => 'today', 'status' => 'unread', 's' => 'a%_']);
|
||||
$out['filters'] = $filters;
|
||||
$out['bad_period'] = $probe->invoke('message_filters', ['period' => 'evil']);
|
||||
$out['query_filters'] = $probe->invoke('message_query_filters', $filters);
|
||||
$out['compose'] = $probe->html('render_message_compose');
|
||||
$out['dialogs'] = $probe->html('render_message_dialogs');
|
||||
$out['overview'] = $probe->html('render_message_overview');
|
||||
$out['filter_form'] = $probe->html('render_message_filter_form', $filters);
|
||||
$out['views'] = $probe->invoke('message_views_html', $filters, ['all' => 2, 'unread' => 1, 'read' => 1]);
|
||||
$row = ['id' => 8, 'user_id' => 7, 'type' => 'system', 'title' => '<script>alert(1)</script>', 'description' => str_repeat('完整内容', 100) . "\n<script>x</script>", 'target_url' => 'javascript:alert(1)', 'is_read' => 0, 'created_at' => '2026-10-30 03:00:00', 'read_at' => null];
|
||||
$out['table'] = $probe->html('render_message_table', [$row, array_merge($row, ['id' => 9, 'is_read' => 1, 'read_at' => '2026-10-30 03:30:00'])], $filters, 3, false);
|
||||
$out['empty'] = $probe->html('render_message_table', [], $filters, 1, false);
|
||||
$out['cache_calls'] = $GLOBALS['cache_calls'];
|
||||
|
||||
$wpdb->prepared = [];
|
||||
\AKSRT\Messages\NotificationAdminQuery::paginate($out['query_filters'], 1);
|
||||
\AKSRT\Messages\NotificationAdminQuery::status_counts($out['query_filters']);
|
||||
\AKSRT\Messages\NotificationAdminQuery::overview();
|
||||
$out['sql'] = $wpdb->prepared;
|
||||
$GLOBALS['zone'] = 'America/New_York';
|
||||
$GLOBALS['now'] = '2026-11-01 12:00:00';
|
||||
$wpdb->prepared = [];
|
||||
\AKSRT\Messages\NotificationAdminQuery::overview();
|
||||
$out['dst_sql'] = $wpdb->prepared;
|
||||
|
||||
$base = ['recipient_mode' => 'role', 'role' => 'subscriber', 'content' => 'Hello', 'title' => 'Title'];
|
||||
$cases = [
|
||||
'unconfirmed' => $base,
|
||||
'changed_count' => $base + ['confirmed_role' => 'subscriber', 'confirmed_count' => 2],
|
||||
'changed_role' => $base + ['confirmed_role' => 'editor', 'confirmed_count' => 3],
|
||||
'confirmed' => $base + ['confirmed_role' => 'subscriber', 'confirmed_count' => 3],
|
||||
'too_large' => $base + ['confirmed_role' => 'subscriber', 'confirmed_count' => 501],
|
||||
'users' => ['recipient_mode' => 'users', 'users' => '7', 'content' => 'Hello'],
|
||||
'long_title' => array_merge($base, ['title' => str_repeat('字', 192)]),
|
||||
];
|
||||
foreach ($cases as $name => $post) {
|
||||
$_POST = $post;
|
||||
$GLOBALS['role_count'] = $name === 'too_large' ? 501 : 3;
|
||||
\AKSRT\Messages\Message::$sent = [];
|
||||
try { $probe->handle_send_message(); } catch (Redirect $redirect) {
|
||||
parse_str(parse_url($redirect->getMessage(), PHP_URL_QUERY), $query);
|
||||
$out['send'][$name] = ['redirect' => $query, 'sent' => count(\AKSRT\Messages\Message::$sent)];
|
||||
}
|
||||
}
|
||||
$_POST = ['bulk_action' => 'delete', 'message_id' => 8, 'ids' => [9, 10], 'period' => 'today', 'status' => 'unread', 'paged' => 3];
|
||||
$wpdb->prepared = [];
|
||||
try { $probe->handle_bulk_messages(); } catch (Redirect $redirect) {
|
||||
parse_str(parse_url($redirect->getMessage(), PHP_URL_QUERY), $query);
|
||||
$out['single_delete'] = ['redirect' => $query, 'sql' => $wpdb->prepared];
|
||||
}
|
||||
$out['cap_checked'] = $probe->cap_checked;
|
||||
$out['nonce_checked'] = $GLOBALS['nonce_checked'];
|
||||
echo json_encode($out, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
const vm = require('node:vm');
|
||||
|
||||
function runtime({ autoopen = false, empty = false } = {}) {
|
||||
const ids = new Map();
|
||||
const doc = { activeElement: null };
|
||||
class Element {
|
||||
constructor(attrs = {}) {
|
||||
this.attrs = attrs;
|
||||
this.listeners = {};
|
||||
this.queries = new Map();
|
||||
this.disabled = false;
|
||||
this.hidden = false;
|
||||
this.value = attrs.value || '';
|
||||
this.name = attrs.name || '';
|
||||
this.checked = false;
|
||||
this.required = false;
|
||||
this.validity = '';
|
||||
this.textContent = '';
|
||||
const classes = new Set();
|
||||
this.classList = {
|
||||
toggle(key, on) { if (on) classes.add(key); else classes.delete(key); },
|
||||
contains(key) { return classes.has(key); },
|
||||
};
|
||||
}
|
||||
getAttribute(name) { return this.attrs[name] ?? null; }
|
||||
hasAttribute(name) { return Object.hasOwn(this.attrs, name); }
|
||||
addEventListener(type, fn) { (this.listeners[type] ||= []).push(fn); }
|
||||
fire(type, values = {}) {
|
||||
const event = { target: this, defaultPrevented: false, preventDefault() { this.defaultPrevented = true; }, ...values };
|
||||
(this.listeners[type] || []).forEach((fn) => fn(event));
|
||||
return event;
|
||||
}
|
||||
querySelector(selector) {
|
||||
const result = this.queries.get(selector);
|
||||
return Array.isArray(result) ? result[0] || null : result || null;
|
||||
}
|
||||
querySelectorAll(selector) {
|
||||
const result = this.queries.get(selector);
|
||||
return Array.isArray(result) ? result : result ? [result] : [];
|
||||
}
|
||||
map(selector, elements) { this.queries.set(selector, elements); return this; }
|
||||
focus() { doc.activeElement = this; }
|
||||
setCustomValidity(value) { this.validity = value; }
|
||||
closest(selector) { return this.row; }
|
||||
replaceChildren(child) { this.content = child; }
|
||||
getBoundingClientRect() { return { left: 100, top: 100, right: 500, bottom: 500 }; }
|
||||
}
|
||||
const root = new Element();
|
||||
const window = new Element();
|
||||
doc.documentElement = new Element();
|
||||
doc.getElementById = (id) => ids.get(id) || null;
|
||||
doc.querySelector = (selector) => selector === '.aksrt-messages-admin' ? root : null;
|
||||
const dialogs = [];
|
||||
function dialog(id) {
|
||||
const node = new Element();
|
||||
node.open = false;
|
||||
node.showModal = () => { node.previous = doc.activeElement; node.open = true; };
|
||||
node.close = () => { node.open = false; node.previous?.focus(); node.fire('close'); };
|
||||
node.cancel = () => { node.fire('cancel'); node.close(); };
|
||||
node.closeButton = new Element();
|
||||
node.closeButton.addEventListener('click', () => {});
|
||||
node.map('[data-aksrt-dialog-close]', [node.closeButton]);
|
||||
ids.set(id, node);
|
||||
dialogs.push(node);
|
||||
return node;
|
||||
}
|
||||
const compose = dialog('aksrt-msg-compose');
|
||||
const detail = dialog('aksrt-msg-detail');
|
||||
const confirm = dialog('aksrt-msg-confirm');
|
||||
confirm.attrs = { 'data-i18n-delete-title': '删除消息', 'data-i18n-delete-text': '删除 {count} 条消息', 'data-i18n-delete-send': '确认删除' };
|
||||
const accept = new Element();
|
||||
const cancel = confirm.closeButton;
|
||||
const confirmation = new Element();
|
||||
confirm.map('[data-aksrt-msg-confirm]', accept).map('[data-aksrt-dialog-title]', new Element()).map('#aksrt-msg-confirm-text', confirmation).map('[data-aksrt-dialog-close][autofocus]', cancel);
|
||||
const detailBody = new Element();
|
||||
detail.map('[data-aksrt-msg-detail-body]', detailBody);
|
||||
ids.set('aksrt-msg-detail-8', { content: { cloneNode: () => ({ text: 'Full <script> message' }) } });
|
||||
root.map('.aksrt-msg-dialog', dialogs);
|
||||
const originalQuery = root.querySelector.bind(root);
|
||||
root.querySelector = (selector) => selector === 'dialog[open]' ? dialogs.find((node) => node.open) || null : originalQuery(selector);
|
||||
|
||||
function field(name, value = '', required = false) {
|
||||
const node = new Element({ name, value });
|
||||
node.required = required;
|
||||
return node;
|
||||
}
|
||||
function form(elements, checks = []) {
|
||||
const node = new Element();
|
||||
node.elements = elements;
|
||||
node.submissions = [];
|
||||
node.reportValidity = () => Object.values(elements).every((field) => field.disabled || (!field.validity && (!field.required || field.value)));
|
||||
node.requestSubmit = (submitter) => {
|
||||
if (!node.reportValidity()) return;
|
||||
const event = node.fire('submit', { submitter });
|
||||
if (event.defaultPrevented) return;
|
||||
const data = {};
|
||||
Object.entries(elements).forEach(([key, field]) => { if (!field.disabled) data[key] = field.value; });
|
||||
if (checks.length) data.ids = checks.filter((check) => check.checked && !check.disabled).map((check) => check.value);
|
||||
node.submissions.push(data);
|
||||
};
|
||||
return node;
|
||||
}
|
||||
|
||||
const checks = empty ? [] : [field('ids[]', '8'), field('ids[]', '9')];
|
||||
checks.forEach((check) => { check.row = new Element(); });
|
||||
const list = form({ bulk_action: field('bulk_action'), message_id: field('message_id') }, checks);
|
||||
const checkAll = new Element();
|
||||
const selection = new Element({ 'data-label': '已选 %d 条' });
|
||||
const bulkRead = new Element({ 'data-aksrt-msg-action': 'mark_read' });
|
||||
const bulkDelete = new Element({ 'data-aksrt-msg-action': 'delete' });
|
||||
const rowRead = new Element({ 'data-aksrt-msg-action': 'mark_read', 'data-id': '8' });
|
||||
const rowDelete = new Element({ 'data-aksrt-msg-action': 'delete', 'data-id': '8' });
|
||||
const view = new Element({ 'data-aksrt-msg-view': '8' });
|
||||
const actions = [bulkRead, bulkDelete, rowRead, rowDelete];
|
||||
list.map('[data-aksrt-msg-check]', checks).map('[data-aksrt-msg-check-all]', checkAll).map('[data-aksrt-msg-selection]', selection)
|
||||
.map('[data-aksrt-msg-action]:not([data-id])', [bulkRead, bulkDelete]).map('[data-aksrt-msg-action]', actions)
|
||||
.map('button', [...actions, view]).map('button[data-id], button[data-aksrt-msg-view]', [rowRead, rowDelete, view]);
|
||||
root.map('[data-aksrt-msg-list]', list).map('[data-aksrt-msg-view]', [view]);
|
||||
|
||||
const role = field('role', '', true);
|
||||
role.options = [new Element({ 'data-count': '0' }), new Element({ 'data-count': '3', 'data-label': '订阅者' })];
|
||||
role.options[0].disabled = true;
|
||||
role.selectedIndex = 0;
|
||||
const composeForm = form({
|
||||
users: field('users', '', true), role, title: field('title'), content: field('content', '', true), target_url: field('target_url'),
|
||||
confirmed_role: field('confirmed_role'), confirmed_count: field('confirmed_count'),
|
||||
});
|
||||
composeForm.attrs = {
|
||||
'data-i18n-count-error': '最多 {count} 字', 'data-i18n-role-hint': '发送给 {count} 人',
|
||||
'data-i18n-role-title': '确认群发', 'data-i18n-role-confirm': '{role}: {count} 人', 'data-i18n-role-send': '确认发送',
|
||||
};
|
||||
const usersRadio = new Element({ value: 'users' });
|
||||
usersRadio.checked = true;
|
||||
const roleRadio = new Element({ value: 'role' });
|
||||
const usersPane = new Element({ 'data-aksrt-msg-pane': 'users' });
|
||||
const rolePane = new Element({ 'data-aksrt-msg-pane': 'role' });
|
||||
usersPane.map('input, select, textarea', [composeForm.elements.users]);
|
||||
rolePane.map('input, select, textarea', [role]);
|
||||
const titleCount = new Element();
|
||||
const contentCount = new Element();
|
||||
const roleHint = new Element();
|
||||
const send = new Element();
|
||||
composeForm.elements.title.attrs['data-count-limit'] = '191';
|
||||
composeForm.elements.content.attrs['data-count-limit'] = '2000';
|
||||
composeForm.map('[data-aksrt-msg-send]', send).map('[data-count-limit]', [composeForm.elements.title, composeForm.elements.content])
|
||||
.map('[data-count-for="title"]', titleCount).map('[data-count-for="content"]', contentCount).map('[data-aksrt-msg-role-hint]', roleHint)
|
||||
.map('[data-aksrt-msg-pane]', [usersPane, rolePane]).map('input[name="recipient_mode"]', [usersRadio, roleRadio]);
|
||||
const formQuery = composeForm.querySelector.bind(composeForm);
|
||||
composeForm.querySelector = (selector) => selector === 'input[name="recipient_mode"]:checked' ? (usersRadio.checked ? usersRadio : roleRadio) : formQuery(selector);
|
||||
compose.map('[data-aksrt-msg-compose]', composeForm);
|
||||
if (autoopen) compose.attrs['data-aksrt-dialog-autoopen'] = '';
|
||||
const trigger = new Element();
|
||||
root.map('[data-aksrt-dialog-open]', [trigger]);
|
||||
|
||||
vm.runInNewContext(fs.readFileSync(path.resolve(__dirname, '../../assets/js/admin-messages.js'), 'utf8'), { document: doc, window });
|
||||
function chooseRole() {
|
||||
usersRadio.checked = false;
|
||||
roleRadio.checked = true;
|
||||
roleRadio.fire('change');
|
||||
role.value = 'subscriber';
|
||||
role.selectedIndex = 1;
|
||||
role.fire('change');
|
||||
}
|
||||
function select(index, checked = true) { checks[index].checked = checked; checks[index].fire('change'); }
|
||||
function input(name, value) { composeForm.elements[name].value = value; composeForm.fire('input'); }
|
||||
return { doc, window, compose, detail, confirm, trigger, accept, cancel, confirmation, detailBody, list, checks, checkAll, selection,
|
||||
bulkRead, bulkDelete, rowRead, rowDelete, view, composeForm, role, usersPane, rolePane, roleHint, send, titleCount, contentCount, chooseRole, select, input };
|
||||
}
|
||||
|
||||
test('selection count, indeterminate state and bulk availability stay in sync', () => {
|
||||
const r = runtime();
|
||||
assert.equal(r.bulkRead.disabled, true);
|
||||
assert.equal(r.selection.textContent, '已选 0 条');
|
||||
r.select(0);
|
||||
assert.equal(r.checkAll.indeterminate, true);
|
||||
assert.equal(r.bulkDelete.disabled, false);
|
||||
assert.equal(r.checks[0].row.classList.contains('is-selected'), true);
|
||||
r.checkAll.checked = true;
|
||||
r.checkAll.fire('change');
|
||||
assert.equal(r.selection.textContent, '已选 2 条');
|
||||
assert.equal(r.checkAll.indeterminate, false);
|
||||
assert.equal(runtime({ empty: true }).checkAll.disabled, true);
|
||||
});
|
||||
|
||||
test('bulk deletion requires confirmation, cancel and Escape do not submit', () => {
|
||||
const r = runtime();
|
||||
r.select(0); r.select(1);
|
||||
r.bulkDelete.fire('click');
|
||||
assert.equal(r.confirm.open, true);
|
||||
assert.match(r.confirmation.textContent, /2 条/);
|
||||
assert.equal(r.list.submissions.length, 0);
|
||||
r.cancel.fire('click');
|
||||
r.accept.fire('click');
|
||||
assert.equal(r.list.submissions.length, 0);
|
||||
r.bulkDelete.fire('click'); r.confirm.cancel();
|
||||
r.accept.fire('click');
|
||||
assert.equal(r.list.submissions.length, 0);
|
||||
r.bulkDelete.fire('click'); r.accept.fire('click');
|
||||
assert.deepEqual(r.list.submissions[0], { bulk_action: 'delete', message_id: '', ids: ['8', '9'] });
|
||||
assert.equal(r.bulkDelete.disabled, true);
|
||||
assert.equal(r.checks[0].disabled, false);
|
||||
r.bulkRead.fire('click');
|
||||
assert.equal(r.list.submissions.length, 1);
|
||||
});
|
||||
|
||||
test('single actions isolate one row even when other rows are selected', () => {
|
||||
const r = runtime();
|
||||
r.select(1);
|
||||
r.rowDelete.fire('click');
|
||||
assert.match(r.confirmation.textContent, /1 条/);
|
||||
r.accept.fire('click');
|
||||
assert.equal(r.list.submissions[0].message_id, '8');
|
||||
const read = runtime();
|
||||
read.rowRead.fire('click');
|
||||
assert.equal(read.confirm.open, false);
|
||||
assert.equal(read.list.submissions[0].bulk_action, 'mark_read');
|
||||
assert.equal(read.list.submissions[0].message_id, '8');
|
||||
});
|
||||
|
||||
test('details clone safe pre-rendered content without posting or changing read status', () => {
|
||||
const r = runtime();
|
||||
r.view.fire('click');
|
||||
assert.equal(r.detail.open, true);
|
||||
assert.equal(r.detailBody.content.text, 'Full <script> message');
|
||||
assert.equal(r.list.submissions.length, 0);
|
||||
});
|
||||
|
||||
test('recipient panes disable inactive inputs, switch required fields and show role counts', () => {
|
||||
const r = runtime();
|
||||
assert.equal(r.role.disabled, true);
|
||||
assert.equal(r.usersPane.hidden, false);
|
||||
r.chooseRole();
|
||||
assert.equal(r.role.disabled, false);
|
||||
assert.equal(r.composeForm.elements.users.disabled, true);
|
||||
assert.equal(r.usersPane.hidden, true);
|
||||
assert.equal(r.roleHint.textContent, '发送给 3 人');
|
||||
});
|
||||
|
||||
test('Unicode counters count emoji once and block over-limit input', () => {
|
||||
const r = runtime();
|
||||
r.input('title', '😀你好');
|
||||
assert.equal(r.titleCount.textContent, '3 / 191');
|
||||
r.input('content', '😀'.repeat(2001));
|
||||
assert.equal(r.contentCount.textContent, '2001 / 2000');
|
||||
assert.equal(r.contentCount.classList.contains('is-over'), true);
|
||||
assert.match(r.composeForm.elements.content.validity, /2000/);
|
||||
r.input('content', '字'.repeat(2000));
|
||||
assert.equal(r.composeForm.elements.content.validity, '');
|
||||
});
|
||||
|
||||
test('role sending uses a nested confirmation with the confirmed role and count', () => {
|
||||
const r = runtime();
|
||||
r.chooseRole(); r.input('content', 'Hello');
|
||||
r.trigger.fire('click');
|
||||
r.composeForm.requestSubmit(r.send);
|
||||
assert.equal(r.compose.open, true);
|
||||
assert.equal(r.confirm.open, true);
|
||||
assert.equal(r.confirmation.textContent, '订阅者: 3 人');
|
||||
assert.equal(r.composeForm.submissions.length, 0);
|
||||
r.cancel.fire('click');
|
||||
assert.equal(r.compose.open, true);
|
||||
assert.equal(r.composeForm.submissions.length, 0);
|
||||
assert.equal(r.doc.documentElement.classList.contains('aksrt-msg-modal-open'), true);
|
||||
r.composeForm.requestSubmit(r.send); r.accept.fire('click');
|
||||
assert.equal(r.composeForm.submissions.length, 1);
|
||||
assert.equal(r.composeForm.submissions[0].confirmed_role, 'subscriber');
|
||||
assert.equal(r.composeForm.submissions[0].confirmed_count, '3');
|
||||
assert.equal(r.send.disabled, true);
|
||||
r.composeForm.requestSubmit();
|
||||
assert.equal(r.composeForm.submissions.length, 1);
|
||||
});
|
||||
|
||||
test('confirmation snapshots cannot approve changed content', () => {
|
||||
const r = runtime();
|
||||
r.chooseRole(); r.input('content', 'One'); r.composeForm.requestSubmit();
|
||||
r.input('content', 'Two'); r.accept.fire('click');
|
||||
assert.equal(r.composeForm.submissions.length, 0);
|
||||
assert.equal(r.composeForm.elements.confirmed_role.value, '');
|
||||
});
|
||||
|
||||
test('direct user sending does not ask for role confirmation; bfcache restores buttons', () => {
|
||||
const r = runtime();
|
||||
r.input('users', '7'); r.input('content', 'Hello'); r.composeForm.requestSubmit();
|
||||
assert.equal(r.confirm.open, false);
|
||||
assert.equal(r.composeForm.submissions.length, 1);
|
||||
r.window.fire('pageshow');
|
||||
assert.equal(r.send.disabled, false);
|
||||
r.composeForm.requestSubmit();
|
||||
assert.equal(r.composeForm.submissions.length, 2);
|
||||
});
|
||||
|
||||
test('returning to the page clears the previous mutation target and restores selection controls', () => {
|
||||
const r = runtime();
|
||||
r.select(1); r.rowRead.fire('click');
|
||||
assert.equal(r.list.elements.message_id.value, '8');
|
||||
r.window.fire('pageshow');
|
||||
assert.equal(r.list.elements.message_id.value, '');
|
||||
assert.equal(r.list.elements.bulk_action.value, '');
|
||||
assert.equal(r.rowRead.disabled, false);
|
||||
assert.equal(r.bulkRead.disabled, false);
|
||||
r.list.requestSubmit();
|
||||
assert.equal(r.list.submissions.length, 1);
|
||||
});
|
||||
|
||||
test('failed sends reopen compose and backdrop clicks must start outside the dialog', () => {
|
||||
const r = runtime({ autoopen: true });
|
||||
assert.equal(r.compose.open, true);
|
||||
r.compose.fire('pointerdown', { clientX: 120, clientY: 120 });
|
||||
r.compose.fire('click', { clientX: 120, clientY: 120 });
|
||||
assert.equal(r.compose.open, true);
|
||||
r.compose.fire('pointerdown', { clientX: 0, clientY: 0 });
|
||||
r.compose.fire('click', { clientX: 0, clientY: 0 });
|
||||
assert.equal(r.compose.open, false);
|
||||
assert.equal(r.doc.documentElement.classList.contains('aksrt-msg-modal-open'), false);
|
||||
});
|
||||
Reference in new issue
Block a user