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

@@ -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, /&lt;script&gt;x&lt;\/script&gt;/);
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/);
});