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:
kate522andClaude Fable 5 committed 2026-10-30 17:24:54 +08:00
1 parent 6195508481
commit fe3ef82718
9 files changed
+1242 -422

No files matched your search

+219 -32
View File
@@ -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);
})();